fix(app): review fixes — billing-unknown pre-check, free-user top-ups, pro out-of-credits copy
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -161,8 +161,6 @@ const getBillingCopy = (language: Language) => {
|
|||||||
topupMedium: '100 Credits – 6,99 €',
|
topupMedium: '100 Credits – 6,99 €',
|
||||||
topupLarge: '250 Credits – 12,99 €',
|
topupLarge: '250 Credits – 12,99 €',
|
||||||
topupBestValue: 'BESTES ANGEBOT',
|
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',
|
cancelTitle: 'Schade, dass du gehst',
|
||||||
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
|
cancelQuestion: 'Dürfen wir fragen, warum du kündigst?',
|
||||||
reasonTooExpensive: 'Es ist mir zu teuer',
|
reasonTooExpensive: 'Es ist mir zu teuer',
|
||||||
@@ -237,8 +235,6 @@ const getBillingCopy = (language: Language) => {
|
|||||||
topupMedium: '100 Créditos – 6,99 €',
|
topupMedium: '100 Créditos – 6,99 €',
|
||||||
topupLarge: '250 Créditos – 12,99 €',
|
topupLarge: '250 Créditos – 12,99 €',
|
||||||
topupBestValue: 'MEJOR OFERTA',
|
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',
|
cancelTitle: 'Lamentamos verte ir',
|
||||||
cancelQuestion: '¿Podemos saber por qué cancelas?',
|
cancelQuestion: '¿Podemos saber por qué cancelas?',
|
||||||
reasonTooExpensive: 'Es muy caro',
|
reasonTooExpensive: 'Es muy caro',
|
||||||
@@ -313,8 +309,6 @@ const getBillingCopy = (language: Language) => {
|
|||||||
topupMedium: '100 Credits – €6.99',
|
topupMedium: '100 Credits – €6.99',
|
||||||
topupLarge: '250 Credits – €12.99',
|
topupLarge: '250 Credits – €12.99',
|
||||||
topupBestValue: 'BEST VALUE',
|
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',
|
cancelTitle: 'Sorry to see you go',
|
||||||
cancelQuestion: 'May we ask why you are cancelling?',
|
cancelQuestion: 'May we ask why you are cancelling?',
|
||||||
reasonTooExpensive: 'It is too expensive',
|
reasonTooExpensive: 'It is too expensive',
|
||||||
@@ -487,10 +481,9 @@ export default function BillingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePurchase = async (productId: PurchaseProductId) => {
|
const handlePurchase = async (productId: PurchaseProductId) => {
|
||||||
if (isTopupProductId(productId) && planId !== 'pro') {
|
// Guests can't sync purchases to an account; signed-in free users may
|
||||||
Alert.alert(copy.topupRequiresProTitle, copy.topupRequiresProMessage, [
|
// buy top-ups (the server counts topupBalance for free plans too).
|
||||||
{ text: copy.manageSubscription, onPress: () => setSubModalVisible(true) },
|
if (isTopupProductId(productId) && !session) {
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,16 +26,12 @@ import { getMockPlantByImage } from '../services/backend/mockCatalog';
|
|||||||
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
|
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
|
||||||
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
|
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
|
||||||
|
|
||||||
const HEALTH_CHECK_CREDIT_COST = 2;
|
|
||||||
const DEMO_SCAN_LIMIT = 5;
|
const DEMO_SCAN_LIMIT = 5;
|
||||||
|
|
||||||
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||||
if (language === 'de') {
|
if (language === 'de') {
|
||||||
return {
|
return {
|
||||||
creditsLabel: 'Credits',
|
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',
|
managePlan: 'Plan verwalten',
|
||||||
dismiss: 'Schliessen',
|
dismiss: 'Schliessen',
|
||||||
genericErrorTitle: 'Fehler',
|
genericErrorTitle: 'Fehler',
|
||||||
@@ -68,9 +64,6 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
if (language === 'es') {
|
if (language === 'es') {
|
||||||
return {
|
return {
|
||||||
creditsLabel: 'Creditos',
|
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',
|
managePlan: 'Gestionar plan',
|
||||||
dismiss: 'Cerrar',
|
dismiss: 'Cerrar',
|
||||||
genericErrorTitle: 'Error',
|
genericErrorTitle: 'Error',
|
||||||
@@ -102,9 +95,6 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
creditsLabel: 'Credits',
|
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',
|
managePlan: 'Manage plan',
|
||||||
dismiss: 'Close',
|
dismiss: 'Close',
|
||||||
genericErrorTitle: 'Error',
|
genericErrorTitle: 'Error',
|
||||||
@@ -286,8 +276,11 @@ export default function ScannerScreen() {
|
|||||||
return;
|
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;
|
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' });
|
posthog.capture('out_of_credits_shown', { trigger: 'pre_check', scan_type: isHealthMode ? 'health_check' : 'identification' });
|
||||||
setOutOfCreditsVisible(true);
|
setOutOfCreditsVisible(true);
|
||||||
return;
|
return;
|
||||||
@@ -862,6 +855,7 @@ export default function ScannerScreen() {
|
|||||||
visible={outOfCreditsVisible}
|
visible={outOfCreditsVisible}
|
||||||
language={language}
|
language={language}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
|
isPro={hasActiveEntitlement}
|
||||||
renewsAtIso={billingSummary?.credits.cycleEndsAt}
|
renewsAtIso={billingSummary?.credits.cycleEndsAt}
|
||||||
onSeePlans={() => {
|
onSeePlans={() => {
|
||||||
setOutOfCreditsVisible(false);
|
setOutOfCreditsVisible(false);
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import { useColors } from '../constants/Colors';
|
|||||||
|
|
||||||
type ColorsType = ReturnType<typeof useColors>;
|
type ColorsType = ReturnType<typeof useColors>;
|
||||||
|
|
||||||
const getCopy = (language: Language) => {
|
const getCopy = (language: Language, isPro: boolean) => {
|
||||||
if (language === 'de') {
|
if (language === 'de') {
|
||||||
return {
|
return {
|
||||||
title: 'Deine Gratis-Scans sind aufgebraucht',
|
title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht',
|
||||||
body: (date: string) => `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`,
|
body: (date: string) => (isPro
|
||||||
bodyNoDate: 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.',
|
? `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',
|
cta: 'Pro-Pläne ansehen',
|
||||||
topupsLabel: 'Oder einzelne Credits kaufen',
|
topupsLabel: 'Oder einzelne Credits kaufen',
|
||||||
later: 'Vielleicht später',
|
later: 'Vielleicht später',
|
||||||
@@ -21,9 +25,13 @@ const getCopy = (language: Language) => {
|
|||||||
}
|
}
|
||||||
if (language === 'es') {
|
if (language === 'es') {
|
||||||
return {
|
return {
|
||||||
title: 'Se acabaron tus escaneos gratis',
|
title: isPro ? 'Se acabaron tus créditos' : '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.`,
|
body: (date: string) => (isPro
|
||||||
bodyNoDate: 'Pásate a Pro para escanear sin límites.',
|
? `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',
|
cta: 'Ver planes Pro',
|
||||||
topupsLabel: 'O compra créditos sueltos',
|
topupsLabel: 'O compra créditos sueltos',
|
||||||
later: 'Quizás más tarde',
|
later: 'Quizás más tarde',
|
||||||
@@ -32,9 +40,13 @@ const getCopy = (language: Language) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title: "You're out of free scans",
|
title: isPro ? "You're out of credits" : "You're out of free scans",
|
||||||
body: (date: string) => `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`,
|
body: (date: string) => (isPro
|
||||||
bodyNoDate: 'Upgrade to Pro for unlimited scanning.',
|
? `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',
|
cta: 'See Pro Plans',
|
||||||
topupsLabel: 'Or buy single credits',
|
topupsLabel: 'Or buy single credits',
|
||||||
later: 'Maybe later',
|
later: 'Maybe later',
|
||||||
@@ -55,6 +67,7 @@ type Props = {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
language: Language;
|
language: Language;
|
||||||
colors: ColorsType;
|
colors: ColorsType;
|
||||||
|
isPro?: boolean;
|
||||||
renewsAtIso?: string | null;
|
renewsAtIso?: string | null;
|
||||||
onSeePlans: () => void;
|
onSeePlans: () => void;
|
||||||
onTopup: (productId: 'topup_small' | 'topup_medium' | 'topup_large') => 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 },
|
{ id: 'topup_large' as const, amount: 250, best: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function OutOfCreditsSheet({ visible, language, colors, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) {
|
export function OutOfCreditsSheet({ visible, language, colors, isPro = false, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) {
|
||||||
const copy = getCopy(language);
|
const copy = getCopy(language, isPro);
|
||||||
const renewalDate = formatRenewalDate(renewsAtIso, language);
|
const renewalDate = formatRenewalDate(renewsAtIso, language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onDismiss}>
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onDismiss}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
<TouchableOpacity style={styles.backdropTouchable} activeOpacity={1} onPress={onDismiss} />
|
<TouchableOpacity
|
||||||
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
|
style={styles.backdropTouchable}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={onDismiss}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={copy.later}
|
||||||
|
/>
|
||||||
|
<View style={[styles.sheet, { backgroundColor: colors.surface }]} accessibilityViewIsModal>
|
||||||
<View style={[styles.handle, { backgroundColor: colors.border }]} />
|
<View style={[styles.handle, { backgroundColor: colors.border }]} />
|
||||||
<View style={styles.iconWrap}>
|
<View style={styles.iconWrap}>
|
||||||
<View style={[styles.iconCircle, { backgroundColor: colors.primarySoft }]}>
|
<View style={[styles.iconCircle, { backgroundColor: colors.primarySoft }]}>
|
||||||
<Ionicons name="leaf-outline" size={34} color={colors.primary} />
|
<Ionicons name="leaf-outline" size={34} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.zeroBadge}>
|
<View style={[styles.zeroBadge, { backgroundColor: colors.danger }]}>
|
||||||
<Text style={styles.zeroBadgeText}>0</Text>
|
<Text style={styles.zeroBadgeText}>0</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -89,10 +108,12 @@ export function OutOfCreditsSheet({ visible, language, colors, renewsAtIso, onSe
|
|||||||
<Text style={[styles.body, { color: colors.textSecondary }]}>
|
<Text style={[styles.body, { color: colors.textSecondary }]}>
|
||||||
{renewalDate ? copy.body(renewalDate) : copy.bodyNoDate}
|
{renewalDate ? copy.body(renewalDate) : copy.bodyNoDate}
|
||||||
</Text>
|
</Text>
|
||||||
|
{!isPro && (
|
||||||
<TouchableOpacity style={[styles.cta, { backgroundColor: colors.primary }]} onPress={onSeePlans} activeOpacity={0.86}>
|
<TouchableOpacity style={[styles.cta, { backgroundColor: colors.primary }]} onPress={onSeePlans} activeOpacity={0.86}>
|
||||||
<Ionicons name="ribbon-outline" size={19} color={colors.onPrimary} />
|
<Ionicons name="ribbon-outline" size={19} color={colors.onPrimary} />
|
||||||
<Text style={[styles.ctaText, { color: colors.onPrimary }]}>{copy.cta}</Text>
|
<Text style={[styles.ctaText, { color: colors.onPrimary }]}>{copy.cta}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<Text style={[styles.topupsLabel, { color: colors.textMuted }]}>{copy.topupsLabel.toUpperCase()}</Text>
|
<Text style={[styles.topupsLabel, { color: colors.textMuted }]}>{copy.topupsLabel.toUpperCase()}</Text>
|
||||||
<View style={styles.topupRow}>
|
<View style={styles.topupRow}>
|
||||||
{TOPUPS.map((topup) => (
|
{TOPUPS.map((topup) => (
|
||||||
@@ -128,7 +149,7 @@ const styles = StyleSheet.create({
|
|||||||
handle: { width: 44, height: 5, borderRadius: 3, marginBottom: 18 },
|
handle: { width: 44, height: 5, borderRadius: 3, marginBottom: 18 },
|
||||||
iconWrap: { marginBottom: 14 },
|
iconWrap: { marginBottom: 14 },
|
||||||
iconCircle: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center' },
|
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' },
|
zeroBadgeText: { color: '#fff', fontSize: 13, fontWeight: '900' },
|
||||||
title: { fontSize: 24, fontWeight: '900', textAlign: 'center', marginBottom: 8 },
|
title: { fontSize: 24, fontWeight: '900', textAlign: 'center', marginBottom: 8 },
|
||||||
body: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18, maxWidth: 320 },
|
body: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18, maxWidth: 320 },
|
||||||
|
|||||||
Reference in New Issue
Block a user