diff --git a/app/profile/billing.tsx b/app/profile/billing.tsx index 7c513c4..5bc986e 100644 --- a/app/profile/billing.tsx +++ b/app/profile/billing.tsx @@ -161,8 +161,6 @@ const getBillingCopy = (language: Language) => { topupMedium: '100 Credits – 6,99 €', topupLarge: '250 Credits – 12,99 €', topupBestValue: 'BESTES ANGEBOT', - topupRequiresProTitle: 'Pro erforderlich', - topupRequiresProMessage: 'Top-ups sind für aktive Pro-Nutzer gedacht. Starte Pro, um zusätzliche Credits zu kaufen.', cancelTitle: 'Schade, dass du gehst', cancelQuestion: 'Dürfen wir fragen, warum du kündigst?', reasonTooExpensive: 'Es ist mir zu teuer', @@ -237,8 +235,6 @@ const getBillingCopy = (language: Language) => { topupMedium: '100 Créditos – 6,99 €', topupLarge: '250 Créditos – 12,99 €', topupBestValue: 'MEJOR OFERTA', - topupRequiresProTitle: 'Pro requerido', - topupRequiresProMessage: 'Los top-ups son para usuarios Pro activos. Inicia Pro para comprar créditos adicionales.', cancelTitle: 'Lamentamos verte ir', cancelQuestion: '¿Podemos saber por qué cancelas?', reasonTooExpensive: 'Es muy caro', @@ -313,8 +309,6 @@ const getBillingCopy = (language: Language) => { topupMedium: '100 Credits – €6.99', topupLarge: '250 Credits – €12.99', topupBestValue: 'BEST VALUE', - topupRequiresProTitle: 'Pro required', - topupRequiresProMessage: 'Top-ups are for active Pro users. Start Pro to buy extra credits.', cancelTitle: 'Sorry to see you go', cancelQuestion: 'May we ask why you are cancelling?', reasonTooExpensive: 'It is too expensive', @@ -487,10 +481,9 @@ export default function BillingScreen() { }; const handlePurchase = async (productId: PurchaseProductId) => { - if (isTopupProductId(productId) && planId !== 'pro') { - Alert.alert(copy.topupRequiresProTitle, copy.topupRequiresProMessage, [ - { text: copy.manageSubscription, onPress: () => setSubModalVisible(true) }, - ]); + // Guests can't sync purchases to an account; signed-in free users may + // buy top-ups (the server counts topupBalance for free plans too). + if (isTopupProductId(productId) && !session) { return; } diff --git a/app/scanner.tsx b/app/scanner.tsx index a35e979..4fd87ee 100644 --- a/app/scanner.tsx +++ b/app/scanner.tsx @@ -26,16 +26,12 @@ import { getMockPlantByImage } from '../services/backend/mockCatalog'; import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff'; import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet'; -const HEALTH_CHECK_CREDIT_COST = 2; const DEMO_SCAN_LIMIT = 5; const getBillingCopy = (language: 'de' | 'en' | 'es') => { if (language === 'de') { return { creditsLabel: 'Credits', - noCreditsTitle: 'Keine Credits mehr', - noCreditsMessage: 'Du hast keine Credits mehr fuer KI-Scans. Upgrade oder Top-up im Profil.', - healthNoCreditsMessage: `Du brauchst ${HEALTH_CHECK_CREDIT_COST} Credits fuer den Health-Check.`, managePlan: 'Plan verwalten', dismiss: 'Schliessen', genericErrorTitle: 'Fehler', @@ -68,9 +64,6 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => { if (language === 'es') { return { creditsLabel: 'Creditos', - noCreditsTitle: 'Sin creditos', - noCreditsMessage: 'No tienes creditos para escaneos AI. Actualiza o compra top-up en Perfil.', - healthNoCreditsMessage: `Necesitas ${HEALTH_CHECK_CREDIT_COST} creditos para el health-check.`, managePlan: 'Gestionar plan', dismiss: 'Cerrar', genericErrorTitle: 'Error', @@ -102,9 +95,6 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => { return { creditsLabel: 'Credits', - noCreditsTitle: 'No credits left', - noCreditsMessage: 'You have no AI scan credits left. Upgrade or buy a top-up in Profile.', - healthNoCreditsMessage: `You need ${HEALTH_CHECK_CREDIT_COST} credits for the health check.`, managePlan: 'Manage plan', dismiss: 'Close', genericErrorTitle: 'Error', @@ -286,8 +276,11 @@ export default function ScannerScreen() { return; } + // Only pre-block when the billing summary is actually known; if it's null + // (fetch failed / still loading) let the request proceed — the server 402 + // path opens the sheet as the safety net. const requiredCredits = isHealthMode ? 2 : 1; - if (!isDemoMode && availableCredits < requiredCredits) { + if (!isDemoMode && billingSummary && availableCredits < requiredCredits) { posthog.capture('out_of_credits_shown', { trigger: 'pre_check', scan_type: isHealthMode ? 'health_check' : 'identification' }); setOutOfCreditsVisible(true); return; @@ -862,6 +855,7 @@ export default function ScannerScreen() { visible={outOfCreditsVisible} language={language} colors={colors} + isPro={hasActiveEntitlement} renewsAtIso={billingSummary?.credits.cycleEndsAt} onSeePlans={() => { setOutOfCreditsVisible(false); diff --git a/components/OutOfCreditsSheet.tsx b/components/OutOfCreditsSheet.tsx index 55eca45..367ee48 100644 --- a/components/OutOfCreditsSheet.tsx +++ b/components/OutOfCreditsSheet.tsx @@ -6,12 +6,16 @@ import { useColors } from '../constants/Colors'; type ColorsType = ReturnType; -const getCopy = (language: Language) => { +const getCopy = (language: Language, isPro: boolean) => { if (language === 'de') { return { - title: 'Deine Gratis-Scans sind aufgebraucht', - body: (date: string) => `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`, - bodyNoDate: 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.', + title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht', + body: (date: string) => (isPro + ? `Deine Credits erneuern sich am ${date}. Kauf Credits nach, um weiterzuscannen.` + : `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`), + bodyNoDate: isPro + ? 'Kauf Credits nach, um weiterzuscannen.' + : 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.', cta: 'Pro-Pläne ansehen', topupsLabel: 'Oder einzelne Credits kaufen', later: 'Vielleicht später', @@ -21,9 +25,13 @@ const getCopy = (language: Language) => { } if (language === 'es') { return { - title: 'Se acabaron tus escaneos gratis', - body: (date: string) => `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`, - bodyNoDate: 'Pásate a Pro para escanear sin límites.', + title: isPro ? 'Se acabaron tus créditos' : 'Se acabaron tus escaneos gratis', + body: (date: string) => (isPro + ? `Tus créditos se renuevan el ${date}. Compra créditos para seguir escaneando.` + : `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`), + bodyNoDate: isPro + ? 'Compra créditos para seguir escaneando.' + : 'Pásate a Pro para escanear sin límites.', cta: 'Ver planes Pro', topupsLabel: 'O compra créditos sueltos', later: 'Quizás más tarde', @@ -32,9 +40,13 @@ const getCopy = (language: Language) => { }; } return { - title: "You're out of free scans", - body: (date: string) => `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`, - bodyNoDate: 'Upgrade to Pro for unlimited scanning.', + title: isPro ? "You're out of credits" : "You're out of free scans", + body: (date: string) => (isPro + ? `Your credits renew on ${date}. Top up to keep scanning.` + : `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`), + bodyNoDate: isPro + ? 'Top up to keep scanning.' + : 'Upgrade to Pro for unlimited scanning.', cta: 'See Pro Plans', topupsLabel: 'Or buy single credits', later: 'Maybe later', @@ -55,6 +67,7 @@ type Props = { visible: boolean; language: Language; colors: ColorsType; + isPro?: boolean; renewsAtIso?: string | null; onSeePlans: () => void; onTopup: (productId: 'topup_small' | 'topup_medium' | 'topup_large') => void; @@ -67,21 +80,27 @@ const TOPUPS = [ { id: 'topup_large' as const, amount: 250, best: true }, ]; -export function OutOfCreditsSheet({ visible, language, colors, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) { - const copy = getCopy(language); +export function OutOfCreditsSheet({ visible, language, colors, isPro = false, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) { + const copy = getCopy(language, isPro); const renewalDate = formatRenewalDate(renewsAtIso, language); return ( - - + + - + 0 @@ -89,10 +108,12 @@ export function OutOfCreditsSheet({ visible, language, colors, renewsAtIso, onSe {renewalDate ? copy.body(renewalDate) : copy.bodyNoDate} - - - {copy.cta} - + {!isPro && ( + + + {copy.cta} + + )} {copy.topupsLabel.toUpperCase()} {TOPUPS.map((topup) => ( @@ -128,7 +149,7 @@ const styles = StyleSheet.create({ handle: { width: 44, height: 5, borderRadius: 3, marginBottom: 18 }, iconWrap: { marginBottom: 14 }, iconCircle: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center' }, - zeroBadge: { position: 'absolute', top: -2, right: -4, backgroundColor: '#C62828', width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, + zeroBadge: { position: 'absolute', top: -2, right: -4, width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, zeroBadgeText: { color: '#fff', fontSize: 13, fontWeight: '900' }, title: { fontSize: 24, fontWeight: '900', textAlign: 'center', marginBottom: 8 }, body: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18, maxWidth: 320 },