TTikotok
This commit is contained in:
@@ -54,6 +54,10 @@ const getProfileCopy = (language: Language) => {
|
||||
logoutConfirmTitle: 'Abmelden?',
|
||||
logoutConfirmMessage: 'Möchtest du dich wirklich abmelden?',
|
||||
logoutConfirmBtn: 'Abmelden',
|
||||
guestTitle: 'Du bist noch nicht angemeldet',
|
||||
guestBody: 'Melde dich an, um Pflanzen zu scannen und deine Sammlung zu sichern.',
|
||||
guestSignIn: 'Anmelden',
|
||||
guestSignUp: 'Kostenlos registrieren',
|
||||
};
|
||||
}
|
||||
if (language === 'es') {
|
||||
@@ -79,6 +83,10 @@ const getProfileCopy = (language: Language) => {
|
||||
logoutConfirmTitle: '¿Cerrar sesión?',
|
||||
logoutConfirmMessage: '¿Realmente quieres cerrar sesión?',
|
||||
logoutConfirmBtn: 'Cerrar sesión',
|
||||
guestTitle: 'Aún no has iniciado sesión',
|
||||
guestBody: 'Inicia sesión para escanear plantas y guardar tu colección.',
|
||||
guestSignIn: 'Iniciar sesión',
|
||||
guestSignUp: 'Registrarse gratis',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -103,6 +111,10 @@ const getProfileCopy = (language: Language) => {
|
||||
logoutConfirmTitle: 'Sign out?',
|
||||
logoutConfirmMessage: 'Do you really want to sign out?',
|
||||
logoutConfirmBtn: 'Sign Out',
|
||||
guestTitle: "You're not signed in yet",
|
||||
guestBody: 'Sign in to scan plants and keep your collection safe.',
|
||||
guestSignIn: 'Log in',
|
||||
guestSignUp: 'Sign up for free',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,6 +130,7 @@ export default function ProfileScreen() {
|
||||
profileName,
|
||||
setProfileName,
|
||||
signOut,
|
||||
session,
|
||||
} = useApp();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -199,7 +212,7 @@ export default function ProfileScreen() {
|
||||
const menuItems = [
|
||||
{ label: copy.menuSettings, icon: 'settings-outline', route: '/profile/preferences' as any },
|
||||
{ label: copy.menuBilling, icon: 'card-outline', route: '/profile/billing' as any },
|
||||
{ label: copy.menuData, icon: 'shield-checkmark-outline', route: '/profile/data' as any },
|
||||
...(session ? [{ label: copy.menuData, icon: 'shield-checkmark-outline', route: '/profile/data' as any }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -228,6 +241,26 @@ export default function ProfileScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!session ? (
|
||||
<View style={[styles.card, styles.accountCard, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.guestTitle}</Text>
|
||||
<Text style={[styles.guestBody, { color: colors.textSecondary }]}>{copy.guestBody}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.guestPrimaryBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={() => router.push('/auth/signup')}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.guestPrimaryText, { color: colors.onPrimary }]}>{copy.guestSignUp}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.guestSecondaryBtn}
|
||||
onPress={() => router.push('/auth/login')}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.guestSecondaryText, { color: colors.primary }]}>{copy.guestSignIn}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.card, styles.accountCard, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>{copy.account}</Text>
|
||||
|
||||
@@ -315,6 +348,7 @@ export default function ProfileScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBg, borderColor: colors.cardBorder, padding: 0, overflow: 'hidden' }]}>
|
||||
{menuItems.map((item, idx) => (
|
||||
@@ -336,6 +370,7 @@ export default function ProfileScreen() {
|
||||
))}
|
||||
</View>
|
||||
{/* Logout */}
|
||||
{session && (
|
||||
<TouchableOpacity
|
||||
style={[styles.logoutBtn, { borderColor: colors.dangerSoft, backgroundColor: colors.dangerSoft }]}
|
||||
activeOpacity={0.78}
|
||||
@@ -356,6 +391,7 @@ export default function ProfileScreen() {
|
||||
<Ionicons name="log-out-outline" size={18} color={colors.danger} />
|
||||
<Text style={[styles.logoutText, { color: colors.danger }]}>{copy.logout}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
@@ -387,6 +423,30 @@ const styles = StyleSheet.create({
|
||||
accountCard: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
guestBody: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
guestPrimaryBtn: {
|
||||
height: 50,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
guestPrimaryText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
guestSecondaryBtn: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
guestSecondaryText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
logoutBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -96,6 +96,7 @@ function RootLayoutInner() {
|
||||
colorPalette,
|
||||
signOut,
|
||||
session,
|
||||
hasCompletedOnboarding,
|
||||
isInitializing,
|
||||
isLoadingPlants,
|
||||
} = useApp();
|
||||
@@ -124,7 +125,9 @@ function RootLayoutInner() {
|
||||
let content = null;
|
||||
|
||||
if (isAppReady) {
|
||||
if (!session) {
|
||||
// Guests who finished onboarding (e.g. dismissed the soft paywall) browse the
|
||||
// full app; paid/credit actions are gated at the point of use instead.
|
||||
if (!session && !hasCompletedOnboarding) {
|
||||
// Only redirect if we are not already on an auth-related page or the scanner
|
||||
if (!isAllowedWithoutSession) {
|
||||
content = <Redirect href="/onboarding" />;
|
||||
|
||||
@@ -24,7 +24,7 @@ type SubscriptionProductId = 'monthly_pro' | 'yearly_pro';
|
||||
type TopupProductId = Extract<PurchaseProductId, 'topup_small' | 'topup_medium' | 'topup_large'>;
|
||||
type SubscriptionPackages = Partial<Record<SubscriptionProductId, PurchasesPackage>>;
|
||||
type TopupProducts = Partial<Record<TopupProductId, PurchasesStoreProduct>>;
|
||||
type PaywallPlanId = 'weekly' | 'yearly';
|
||||
type PaywallPlanId = 'monthly' | 'yearly';
|
||||
|
||||
const PAYWALL_BACKGROUND = require('../../assets/paywall_scan_background.png');
|
||||
|
||||
@@ -159,7 +159,7 @@ const getBillingCopy = (language: Language) => {
|
||||
planCardBody: 'Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat',
|
||||
planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`,
|
||||
planCardPriceMonthly: (price: string) => `${price}/Monat`,
|
||||
trialToggleLabel: 'Gratis-Test aktiviert',
|
||||
trialToggleLabel: '7 Tage gratis testen',
|
||||
dueTodayTrial: 'Fällig heute — 7 Tage gratis',
|
||||
dueTodayAmount: '0,00 €',
|
||||
dueLater: (date: string) => `Fällig am ${date}`,
|
||||
@@ -224,7 +224,7 @@ const getBillingCopy = (language: Language) => {
|
||||
planCardBody: 'Escaneos IA ilimitados, diagnóstico de salud, planes de rescate de 7 días, 100 créditos/mes',
|
||||
planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`,
|
||||
planCardPriceMonthly: (price: string) => `${price}/mes`,
|
||||
trialToggleLabel: 'Prueba gratis activada',
|
||||
trialToggleLabel: 'Probar 7 días gratis',
|
||||
dueTodayTrial: 'Hoy — 7 días gratis',
|
||||
dueTodayAmount: '0,00 €',
|
||||
dueLater: (date: string) => `El ${date}`,
|
||||
@@ -289,7 +289,7 @@ const getBillingCopy = (language: Language) => {
|
||||
planCardBody: 'Unlimited AI scans, health diagnosis, 7-day rescue plans, 100 credits/month',
|
||||
planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`,
|
||||
planCardPriceMonthly: (price: string) => `${price}/month`,
|
||||
trialToggleLabel: 'Free Trial Enabled',
|
||||
trialToggleLabel: 'Try 7 days free',
|
||||
dueTodayTrial: 'Due today — 7 days free',
|
||||
dueTodayAmount: '€0.00',
|
||||
dueLater: (date: string) => `Due ${date}`,
|
||||
@@ -308,7 +308,7 @@ export default function BillingScreen() {
|
||||
const params = useLocalSearchParams<{ view?: string; context?: string }>();
|
||||
const paywallRequested = params.view === 'paywall';
|
||||
const onboardingContext = params.context === 'onboarding';
|
||||
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
|
||||
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session, hasCompletedOnboarding, markOnboardingCompleted } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const posthog = useSafeAnalytics();
|
||||
const copy = getBillingCopy(language);
|
||||
@@ -320,7 +320,8 @@ export default function BillingScreen() {
|
||||
const [storeError, setStoreError] = useState<string | null>(null);
|
||||
const [subscriptionPackages, setSubscriptionPackages] = useState<SubscriptionPackages>({});
|
||||
const [topupProducts, setTopupProducts] = useState<TopupProducts>({});
|
||||
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('yearly');
|
||||
// Monthly is the default; the 7-day trial (yearly plan) must be opted into via the toggle.
|
||||
const [selectedPaywallPlan, setSelectedPaywallPlan] = useState<PaywallPlanId>('monthly');
|
||||
|
||||
// Cancel Flow State
|
||||
const [cancelStep, setCancelStep] = useState<'none' | 'survey' | 'offer'>('none');
|
||||
@@ -426,7 +427,9 @@ export default function BillingScreen() {
|
||||
if (showPaywallPlans) {
|
||||
posthog.capture('paywall_dismissed', { context: onboardingContext ? 'onboarding' : 'in_app' });
|
||||
if (onboardingContext) {
|
||||
router.replace('/auth/signup');
|
||||
// Guests continue into the app; paid actions are gated at the point of use.
|
||||
markOnboardingCompleted();
|
||||
router.replace('/(tabs)');
|
||||
return;
|
||||
}
|
||||
if (session) {
|
||||
@@ -434,7 +437,7 @@ export default function BillingScreen() {
|
||||
else router.replace('/(tabs)');
|
||||
return;
|
||||
}
|
||||
router.replace('/onboarding');
|
||||
router.replace(hasCompletedOnboarding ? '/(tabs)' : '/onboarding');
|
||||
return;
|
||||
}
|
||||
if (router.canGoBack()) {
|
||||
@@ -442,7 +445,7 @@ export default function BillingScreen() {
|
||||
return;
|
||||
}
|
||||
router.replace('/(tabs)');
|
||||
}, [router, showPaywallPlans, onboardingContext, session, posthog]);
|
||||
}, [router, showPaywallPlans, onboardingContext, session, posthog, hasCompletedOnboarding, markOnboardingCompleted]);
|
||||
|
||||
const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)';
|
||||
|
||||
@@ -653,7 +656,7 @@ export default function BillingScreen() {
|
||||
<Text style={[styles.trialToggleLabel, { color: colors.text }]}>{copy.trialToggleLabel}</Text>
|
||||
<Switch
|
||||
value={trialEnabled}
|
||||
onValueChange={(next) => setSelectedPaywallPlan(next ? 'yearly' : 'weekly')}
|
||||
onValueChange={(next) => setSelectedPaywallPlan(next ? 'yearly' : 'monthly')}
|
||||
trackColor={{ true: colors.primary, false: colors.border }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
|
||||
@@ -19,13 +19,13 @@ import { PlantRecognitionService } from '../services/plantRecognitionService';
|
||||
import { IdentificationResult } from '../types';
|
||||
import { ResultCard } from '../components/ResultCard';
|
||||
import { backendApiClient, isInsufficientCreditsError, isNetworkError, isTimeoutError } from '../services/backend/backendApiClient';
|
||||
import { isBackendApiError } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { AuthService } from '../services/authService';
|
||||
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
|
||||
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
|
||||
import { isBackendApiError } from '../services/backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
import { AuthService } from '../services/authService';
|
||||
import { consumeSharedImageUri, SHARE_INTENT_KEY } from '../utils/shareHandoff';
|
||||
import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet';
|
||||
|
||||
const DEMO_SCAN_LIMIT = 3;
|
||||
const DEMO_SCAN_LIMIT = 1;
|
||||
|
||||
const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
if (language === 'de') {
|
||||
@@ -51,8 +51,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
demoTitle: 'Rettungsplan bereit',
|
||||
demoMessage: 'Wir haben mögliche Ursachen erkannt. Schalte die vollständige KI-Diagnose und deinen 7-Tage-Rettungsplan frei.',
|
||||
demoNoCreditsTitle: 'Demo-Scans aufgebraucht',
|
||||
demoNoCreditsMessage: 'Du hast deine 3 kostenlosen Demo-Scans auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
|
||||
demoCreditsRemaining: (count: number) => `${count} Demo-Scans übrig`,
|
||||
demoNoCreditsMessage: 'Du hast deinen kostenlosen Demo-Scan auf diesem Gerät genutzt. Starte Pro, um weiter Pflanzen zu scannen.',
|
||||
demoCreditsRemaining: (count: number) => (count === 1 ? '1 Demo-Scan übrig' : `${count} Demo-Scans übrig`),
|
||||
creditsRemaining: (count: number) => `${count} Scans übrig`,
|
||||
appleCta: 'Mit Apple fortfahren',
|
||||
emailCta: 'Mit E-Mail fortfahren',
|
||||
@@ -83,8 +83,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
demoTitle: 'Plan de rescate listo',
|
||||
demoMessage: 'Detectamos posibles causas. Desbloquea el diagnóstico completo con IA y tu plan de rescate de 7 días.',
|
||||
demoNoCreditsTitle: 'Escaneos demo agotados',
|
||||
demoNoCreditsMessage: 'Ya usaste tus 3 escaneos demo gratuitos en este dispositivo. Inicia Pro para seguir escaneando plantas.',
|
||||
demoCreditsRemaining: (count: number) => `${count} escaneos demo restantes`,
|
||||
demoNoCreditsMessage: 'Ya usaste tu escaneo demo gratuito en este dispositivo. Inicia Pro para seguir escaneando plantas.',
|
||||
demoCreditsRemaining: (count: number) => (count === 1 ? '1 escaneo demo restante' : `${count} escaneos demo restantes`),
|
||||
creditsRemaining: (count: number) => `${count} escaneos restantes`,
|
||||
appleCta: 'Continuar con Apple',
|
||||
emailCta: 'Continuar con email',
|
||||
@@ -114,8 +114,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
|
||||
demoTitle: 'Rescue plan ready',
|
||||
demoMessage: 'We found possible causes. Unlock the full AI diagnosis and your 7-day rescue plan.',
|
||||
demoNoCreditsTitle: 'Demo scans used',
|
||||
demoNoCreditsMessage: 'You used your 3 free demo scans on this device. Start Pro to keep scanning plants.',
|
||||
demoCreditsRemaining: (count: number) => `${count} demo scans left`,
|
||||
demoNoCreditsMessage: 'You used your free demo scan on this device. Start Pro to keep scanning plants.',
|
||||
demoCreditsRemaining: (count: number) => (count === 1 ? '1 demo scan left' : `${count} demo scans left`),
|
||||
creditsRemaining: (count: number) => `${count} scans left`,
|
||||
appleCta: 'Continue with Apple',
|
||||
emailCta: 'Continue with email',
|
||||
@@ -142,6 +142,7 @@ export default function ScannerScreen() {
|
||||
setPendingPlant,
|
||||
guestScanCount,
|
||||
incrementGuestScanCount,
|
||||
hasCompletedOnboarding,
|
||||
} = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const router = useRouter();
|
||||
@@ -160,7 +161,7 @@ export default function ScannerScreen() {
|
||||
: params.sharedImageKey;
|
||||
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
|
||||
&& billingSummary?.entitlement?.status === 'active';
|
||||
const isDemoMode = !session; // guests get limited AI demo scans; signed-in users burn real credits
|
||||
const isDemoMode = !session; // guests get limited AI demo scans; signed-in users burn real credits
|
||||
const availableCredits = billingSummary?.credits.available ?? 0;
|
||||
const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount);
|
||||
|
||||
@@ -293,22 +294,22 @@ export default function ScannerScreen() {
|
||||
}, 150);
|
||||
|
||||
try {
|
||||
if (isDemoMode) {
|
||||
posthog.capture('demo_scan_started', {
|
||||
authenticated: Boolean(session),
|
||||
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||
demo_scans_used: guestScanCount,
|
||||
demo_scans_remaining: demoScansRemaining,
|
||||
});
|
||||
const demoResult = await PlantRecognitionService.identify(imageUri, language, {
|
||||
idempotencyKey: createIdempotencyKey('demo-scan-plant'),
|
||||
});
|
||||
setAnalysisProgress(100);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
incrementGuestScanCount();
|
||||
setAnalysisResult(demoResult);
|
||||
posthog.capture('demo_scan_completed', {
|
||||
if (isDemoMode) {
|
||||
posthog.capture('demo_scan_started', {
|
||||
authenticated: Boolean(session),
|
||||
scan_type: isHealthMode ? 'health_check' : 'identification',
|
||||
demo_scans_used: guestScanCount,
|
||||
demo_scans_remaining: demoScansRemaining,
|
||||
});
|
||||
const demoResult = await PlantRecognitionService.identify(imageUri, language, {
|
||||
idempotencyKey: createIdempotencyKey('demo-scan-plant'),
|
||||
});
|
||||
setAnalysisProgress(100);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
incrementGuestScanCount();
|
||||
setAnalysisResult(demoResult);
|
||||
posthog.capture('demo_scan_completed', {
|
||||
authenticated: Boolean(session),
|
||||
latency_ms: Date.now() - startTime,
|
||||
demo_scans_used_after: guestScanCount + 1,
|
||||
@@ -600,7 +601,7 @@ export default function ScannerScreen() {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace('/onboarding');
|
||||
router.replace(hasCompletedOnboarding ? '/(tabs)' : '/onboarding');
|
||||
};
|
||||
|
||||
const controlsPaddingBottom = Math.max(20, insets.bottom + 10);
|
||||
|
||||
Reference in New Issue
Block a user