Testflight

This commit is contained in:
2026-07-06 15:33:53 +02:00
parent 673ead2e3a
commit 99cd885833
19 changed files with 5288 additions and 5288 deletions

View File

@@ -1,40 +1,40 @@
jest.mock('../../server/lib/postgres', () => ({ jest.mock('../../server/lib/postgres', () => ({
get: jest.fn(), get: jest.fn(),
run: jest.fn(), run: jest.fn(),
})); }));
const { get, run } = require('../../server/lib/postgres'); const { get, run } = require('../../server/lib/postgres');
const { deleteAccount, signUp } = require('../../server/lib/auth'); const { deleteAccount, signUp } = require('../../server/lib/auth');
describe('server auth account deletion', () => { describe('server auth account deletion', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
get.mockResolvedValue(null); get.mockResolvedValue(null);
run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); run.mockResolvedValue({ lastId: null, changes: 1, rows: [] });
}); });
it('removes auth and billing rows so the same email can sign up again', async () => { it('removes auth and billing rows so the same email can sign up again', async () => {
const email = 'same@example.com'; const email = 'same@example.com';
await signUp({}, email, 'First User', 'password-1'); await signUp({}, email, 'First User', 'password-1');
await deleteAccount({}, 'usr_deleted'); await deleteAccount({}, 'usr_deleted');
await signUp({}, email, 'Second User', 'password-2'); await signUp({}, email, 'Second User', 'password-2');
const authDeletes = run.mock.calls.filter(([, sql]) => ( const authDeletes = run.mock.calls.filter(([, sql]) => (
typeof sql === 'string' && sql.includes('DELETE FROM auth_users') typeof sql === 'string' && sql.includes('DELETE FROM auth_users')
)); ));
expect(authDeletes).toHaveLength(1); expect(authDeletes).toHaveLength(1);
const billingAccountDeletes = run.mock.calls.filter(([, sql]) => ( const billingAccountDeletes = run.mock.calls.filter(([, sql]) => (
typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts') typeof sql === 'string' && sql.includes('DELETE FROM billing_accounts')
)); ));
expect(billingAccountDeletes).toHaveLength(1); expect(billingAccountDeletes).toHaveLength(1);
const signupChecks = get.mock.calls.filter(([, sql, params]) => ( const signupChecks = get.mock.calls.filter(([, sql, params]) => (
typeof sql === 'string' typeof sql === 'string'
&& sql.includes('SELECT id FROM auth_users WHERE LOWER(email)') && sql.includes('SELECT id FROM auth_users WHERE LOWER(email)')
&& params?.[0] === email && params?.[0] === email
)); ));
expect(signupChecks).toHaveLength(2); expect(signupChecks).toHaveLength(2);
}); });
}); });

View File

@@ -1,45 +1,45 @@
jest.mock('../../server/lib/postgres', () => ({ jest.mock('../../server/lib/postgres', () => ({
get: jest.fn(), get: jest.fn(),
run: jest.fn(), run: jest.fn(),
})); }));
const { get, run } = require('../../server/lib/postgres'); const { get, run } = require('../../server/lib/postgres');
const { syncRevenueCatCustomerInfo } = require('../../server/lib/billing'); const { syncRevenueCatCustomerInfo } = require('../../server/lib/billing');
describe('server billing timestamp normalization', () => { describe('server billing timestamp normalization', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
run.mockResolvedValue({ lastId: null, changes: 1, rows: [] }); run.mockResolvedValue({ lastId: null, changes: 1, rows: [] });
}); });
it('upserts ISO timestamps when postgres returns Date objects', async () => { it('upserts ISO timestamps when postgres returns Date objects', async () => {
get.mockResolvedValueOnce({ get.mockResolvedValueOnce({
userId: 'usr_mnjcdwpo_ax9lf68b', userId: 'usr_mnjcdwpo_ax9lf68b',
plan: 'free', plan: 'free',
provider: 'revenuecat', provider: 'revenuecat',
cycleStartedAt: new Date('2027-04-01T00:00:00.000Z'), cycleStartedAt: new Date('2027-04-01T00:00:00.000Z'),
cycleEndsAt: new Date('2027-05-01T00:00:00.000Z'), cycleEndsAt: new Date('2027-05-01T00:00:00.000Z'),
monthlyAllowance: 15, monthlyAllowance: 15,
usedThisCycle: 0, usedThisCycle: 0,
topupBalance: 0, topupBalance: 0,
renewsAt: null, renewsAt: null,
updatedAt: new Date('2026-04-02T12:00:00.000Z'), updatedAt: new Date('2026-04-02T12:00:00.000Z'),
}); });
await syncRevenueCatCustomerInfo( await syncRevenueCatCustomerInfo(
{}, {},
'usr_mnjcdwpo_ax9lf68b', 'usr_mnjcdwpo_ax9lf68b',
{ entitlements: { active: {} }, nonSubscriptions: {} }, { entitlements: { active: {} }, nonSubscriptions: {} },
{ source: 'topup_purchase' }, { source: 'topup_purchase' },
); );
const upsertCall = run.mock.calls.find(([, sql]) => typeof sql === 'string' && sql.includes('INSERT INTO billing_accounts')); const upsertCall = run.mock.calls.find(([, sql]) => typeof sql === 'string' && sql.includes('INSERT INTO billing_accounts'));
expect(upsertCall).toBeTruthy(); expect(upsertCall).toBeTruthy();
const params = upsertCall[2]; const params = upsertCall[2];
expect(params[3]).toBe('2027-04-01T00:00:00.000Z'); expect(params[3]).toBe('2027-04-01T00:00:00.000Z');
expect(params[4]).toBe('2027-05-01T00:00:00.000Z'); expect(params[4]).toBe('2027-05-01T00:00:00.000Z');
expect(params[3]).not.toContain('Coordinated Universal Time'); expect(params[3]).not.toContain('Coordinated Universal Time');
expect(params[4]).not.toContain('Coordinated Universal Time'); expect(params[4]).not.toContain('Coordinated Universal Time');
}); });
}); });

View File

@@ -1,385 +1,385 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
ImageBackground, ImageBackground,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { Language } from '../../types'; import { Language } from '../../types';
const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png'); const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png');
const getCopy = (language: Language) => { const getCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
return { return {
headline: 'Welcome back!', headline: 'Welcome back!',
subline: 'Melde dich an und mache mit deiner Pflanzenpflege weiter.', subline: 'Melde dich an und mache mit deiner Pflanzenpflege weiter.',
loginCta: 'Anmelden', loginCta: 'Anmelden',
forgot: 'Passwort vergessen?', forgot: 'Passwort vergessen?',
forgotTitle: 'Passwort zuruecksetzen', forgotTitle: 'Passwort zuruecksetzen',
forgotBody: 'Ein Reset-Link ist noch nicht in der App verfuegbar. Bitte nutze aktuell deine gespeicherten Login-Daten.', forgotBody: 'Ein Reset-Link ist noch nicht in der App verfuegbar. Bitte nutze aktuell deine gespeicherten Login-Daten.',
newHere: 'Neu hier?', newHere: 'Neu hier?',
create: 'Account erstellen', create: 'Account erstellen',
emailLabel: 'E-Mail', emailLabel: 'E-Mail',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
headline: 'Welcome back!', headline: 'Welcome back!',
subline: 'Inicia sesion para continuar con el cuidado de tus plantas.', subline: 'Inicia sesion para continuar con el cuidado de tus plantas.',
loginCta: 'Iniciar sesion', loginCta: 'Iniciar sesion',
forgot: 'Olvidaste tu contrasena?', forgot: 'Olvidaste tu contrasena?',
forgotTitle: 'Restablecer contrasena', forgotTitle: 'Restablecer contrasena',
forgotBody: 'El enlace de restablecimiento aun no esta disponible en la app. Usa tus datos guardados por ahora.', forgotBody: 'El enlace de restablecimiento aun no esta disponible en la app. Usa tus datos guardados por ahora.',
newHere: 'Nuevo aqui?', newHere: 'Nuevo aqui?',
create: 'Crear cuenta', create: 'Crear cuenta',
emailLabel: 'Email', emailLabel: 'Email',
}; };
} }
return { return {
headline: 'Welcome back!', headline: 'Welcome back!',
subline: 'Log in to keep scanning, saving and caring for your plants.', subline: 'Log in to keep scanning, saving and caring for your plants.',
loginCta: 'Log in', loginCta: 'Log in',
forgot: 'Forgot password?', forgot: 'Forgot password?',
forgotTitle: 'Reset password', forgotTitle: 'Reset password',
forgotBody: 'Password reset is not available in the app yet. Please use your saved login details for now.', forgotBody: 'Password reset is not available in the app yet. Please use your saved login details for now.',
newHere: 'New here?', newHere: 'New here?',
create: 'Create account', create: 'Create account',
emailLabel: 'Email', emailLabel: 'Email',
}; };
}; };
export default function LoginScreen() { export default function LoginScreen() {
const { isDarkMode, colorPalette, hydrateSession, language, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const copy = getCopy(language); const copy = getCopy(language);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const isExpoGo = Constants.appOwnership === 'expo'; const isExpoGo = Constants.appOwnership === 'expo';
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (isExpoGo) { if (isExpoGo) {
setAppleAvailable(false); setAppleAvailable(false);
return; return;
} }
let mounted = true; let mounted = true;
AppleAuthentication.isAvailableAsync() AppleAuthentication.isAvailableAsync()
.then((available) => { .then((available) => {
if (mounted) setAppleAvailable(available); if (mounted) setAppleAvailable(available);
}) })
.catch(() => { .catch(() => {
if (mounted) setAppleAvailable(false); if (mounted) setAppleAvailable(false);
}); });
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [isExpoGo]); }, [isExpoGo]);
const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.login>>) => { const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.login>>) => {
await hydrateSession(session); await hydrateSession(session);
if (session?.userId) { if (session?.userId) {
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
} }
if (session.isNewUser) { if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
} }
router.replace('/(tabs)'); router.replace('/(tabs)');
}; };
const handleLogin = async () => { const handleLogin = async () => {
if (!email.trim() || !password) { if (!email.trim() || !password) {
setError(t.errFillAllFields); setError(t.errFillAllFields);
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const session = await AuthService.login(email, password); const session = await AuthService.login(email, password);
await finishAuth(session); await finishAuth(session);
} catch (e: any) { } catch (e: any) {
if (e.message === 'USER_NOT_FOUND') { if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound); setError(t.errUserNotFound);
} else if (e.message === 'WRONG_PASSWORD') { } else if (e.message === 'WRONG_PASSWORD') {
setError(t.errWrongPassword); setError(t.errWrongPassword);
} else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError); setError(t.errNetworkError);
} else { } else {
setError(t.errLoginFailed); setError(t.errLoginFailed);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAppleSignIn = async () => { const handleAppleSignIn = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
posthog.capture('apple_login_started', { surface: 'login' }); posthog.capture('apple_login_started', { surface: 'login' });
try { try {
const credential = await AppleAuthentication.signInAsync({ const credential = await AppleAuthentication.signInAsync({
requestedScopes: [ requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL, AppleAuthentication.AppleAuthenticationScope.EMAIL,
], ],
}); });
if (!credential.identityToken) { if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID'); throw new Error('APPLE_AUTH_INVALID');
} }
const fullName = [ const fullName = [
credential.fullName?.givenName, credential.fullName?.givenName,
credential.fullName?.familyName, credential.fullName?.familyName,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({ const session = await AuthService.signInWithApple({
identityToken: credential.identityToken, identityToken: credential.identityToken,
appleUser: credential.user, appleUser: credential.user,
email: credential.email, email: credential.email,
name: fullName || undefined, name: fullName || undefined,
}); });
posthog.capture('apple_login_succeeded', { surface: 'login' }); posthog.capture('apple_login_succeeded', { surface: 'login' });
await finishAuth(session); await finishAuth(session);
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') return; if (e?.code === 'ERR_REQUEST_CANCELED') return;
posthog.capture('apple_login_failed', { posthog.capture('apple_login_failed', {
surface: 'login', surface: 'login',
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
}); });
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE' setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.' ? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError); : t.errAuthError);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]} style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}> <ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
<View style={styles.heroOverlay} /> <View style={styles.heroOverlay} />
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}> <TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
<Ionicons name="arrow-back" size={20} color="#ffffff" /> <Ionicons name="arrow-back" size={20} color="#ffffff" />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.heroCopy}> <View style={styles.heroCopy}>
<Text style={styles.heroTitle}>{copy.headline}</Text> <Text style={styles.heroTitle}>{copy.headline}</Text>
<Text style={styles.heroSubline}>{copy.subline}</Text> <Text style={styles.heroSubline}>{copy.subline}</Text>
</View> </View>
</ImageBackground> </ImageBackground>
<View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}> <View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
{appleAvailable ? ( {appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE} buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK} : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={14} cornerRadius={14}
style={styles.appleButton} style={styles.appleButton}
onPress={handleAppleSignIn} onPress={handleAppleSignIn}
/> />
) : null} ) : null}
{appleAvailable ? ( {appleAvailable ? (
<View style={styles.dividerRow}> <View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> <Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View> </View>
) : null} ) : null}
<View style={styles.form}> <View style={styles.form}>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder} placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
autoComplete="email" autoComplete="email"
returnKeyType="next" returnKeyType="next"
/> />
</View> </View>
</View> </View>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder="Password" placeholder="Password"
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
secureTextEntry={!showPassword} secureTextEntry={!showPassword}
autoComplete="password" autoComplete="password"
returnKeyType="done" returnKeyType="done"
onSubmitEditing={handleLogin} onSubmitEditing={handleLogin}
/> />
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}> <TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} /> <Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={styles.forgotBtn} style={styles.forgotBtn}
onPress={() => Alert.alert(copy.forgotTitle, copy.forgotBody)} onPress={() => Alert.alert(copy.forgotTitle, copy.forgotBody)}
activeOpacity={0.78} activeOpacity={0.78}
> >
<Text style={[styles.forgotText, { color: colors.primary }]}>{copy.forgot}</Text> <Text style={[styles.forgotText, { color: colors.primary }]}>{copy.forgot}</Text>
</TouchableOpacity> </TouchableOpacity>
{error ? ( {error ? (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}> <View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} /> <Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text> <Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text>
</View> </View>
) : null} ) : null}
<TouchableOpacity <TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]} style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
onPress={handleLogin} onPress={handleLogin}
activeOpacity={0.84} activeOpacity={0.84}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" /> <ActivityIndicator color={colors.onPrimary} size="small" />
) : ( ) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.loginCta}</Text> <Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.loginCta}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.signupLink} onPress={() => router.replace('/auth/signup')} activeOpacity={0.78}> <TouchableOpacity style={styles.signupLink} onPress={() => router.replace('/auth/signup')} activeOpacity={0.78}>
<Text style={[styles.signupLinkText, { color: colors.textSecondary }]}> <Text style={[styles.signupLinkText, { color: colors.textSecondary }]}>
{copy.newHere}{' '} {copy.newHere}{' '}
<Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.create}</Text> <Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.create}</Text>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flex: { flex: 1 }, flex: { flex: 1 },
scroll: { flexGrow: 1 }, scroll: { flexGrow: 1 },
hero: { hero: {
minHeight: 290, minHeight: 290,
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: 56, paddingTop: 56,
paddingBottom: 32, paddingBottom: 32,
}, },
heroImage: { resizeMode: 'cover' }, heroImage: { resizeMode: 'cover' },
heroOverlay: { heroOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 12, 7, 0.48)', backgroundColor: 'rgba(5, 12, 7, 0.48)',
}, },
backBtn: { backBtn: {
position: 'absolute', position: 'absolute',
top: 54, top: 54,
left: 22, left: 22,
width: 42, width: 42,
height: 42, height: 42,
borderRadius: 21, borderRadius: 21,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.28)', backgroundColor: 'rgba(0, 0, 0, 0.28)',
}, },
heroCopy: { gap: 10 }, heroCopy: { gap: 10 },
heroTitle: { heroTitle: {
color: '#ffffff', color: '#ffffff',
fontSize: 36, fontSize: 36,
lineHeight: 41, lineHeight: 41,
fontWeight: '900', fontWeight: '900',
}, },
heroSubline: { heroSubline: {
color: 'rgba(255, 255, 255, 0.88)', color: 'rgba(255, 255, 255, 0.88)',
fontSize: 16, fontSize: 16,
lineHeight: 22, lineHeight: 22,
fontWeight: '700', fontWeight: '700',
}, },
sheet: { sheet: {
flex: 1, flex: 1,
marginTop: -24, marginTop: -24,
borderTopLeftRadius: 28, borderTopLeftRadius: 28,
borderTopRightRadius: 28, borderTopRightRadius: 28,
paddingHorizontal: 22, paddingHorizontal: 22,
paddingTop: 24, paddingTop: 24,
paddingBottom: 34, paddingBottom: 34,
gap: 14, gap: 14,
}, },
appleButton: { width: '100%', height: 56 }, appleButton: { width: '100%', height: 56 },
dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
dividerLine: { flex: 1, height: 1 }, dividerLine: { flex: 1, height: 1 },
dividerText: { fontSize: 12, fontWeight: '800' }, dividerText: { fontSize: 12, fontWeight: '800' },
form: { gap: 12 }, form: { gap: 12 },
fieldGroup: { gap: 6 }, fieldGroup: { gap: 6 },
label: { fontSize: 13, fontWeight: '800', marginLeft: 2 }, label: { fontSize: 13, fontWeight: '800', marginLeft: 2 },
inputRow: { inputRow: {
height: 54, height: 54,
borderWidth: 1, borderWidth: 1,
borderRadius: 14, borderRadius: 14,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 14,
}, },
inputIcon: { marginRight: 10 }, inputIcon: { marginRight: 10 },
input: { flex: 1, height: 54, fontSize: 15 }, input: { flex: 1, height: 54, fontSize: 15 },
eyeBtn: { padding: 5, marginLeft: 6 }, eyeBtn: { padding: 5, marginLeft: 6 },
forgotBtn: { alignItems: 'flex-end', marginTop: -4 }, forgotBtn: { alignItems: 'flex-end', marginTop: -4 },
forgotText: { fontSize: 14, fontWeight: '800' }, forgotText: { fontSize: 14, fontWeight: '800' },
errorBox: { errorBox: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 7, gap: 7,
borderRadius: 12, borderRadius: 12,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
}, },
errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
primaryBtn: { primaryBtn: {
height: 56, height: 56,
borderRadius: 14, borderRadius: 14,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
primaryBtnText: { fontSize: 17, fontWeight: '900' }, primaryBtnText: { fontSize: 17, fontWeight: '900' },
signupLink: { alignItems: 'center', paddingTop: 8 }, signupLink: { alignItems: 'center', paddingTop: 8 },
signupLinkText: { fontSize: 15, fontWeight: '700' }, signupLinkText: { fontSize: 15, fontWeight: '700' },
}); });

View File

@@ -1,482 +1,482 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
ImageBackground, ImageBackground,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { AuthService } from '../../services/authService'; import { AuthService } from '../../services/authService';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { Language } from '../../types'; import { Language } from '../../types';
const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png'); const HERO_IMAGE = require('../../assets/welcome_botanical_hero.png');
const getCopy = (language: Language) => { const getCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
return { return {
headline: "Let's finish your setup!", headline: "Let's finish your setup!",
subline: 'Erstelle einen Account, speichere deine Pflanzen und sichere dir 3 Gratis-Scans pro Monat.', subline: 'Erstelle einen Account, speichere deine Pflanzen und sichere dir 3 Gratis-Scans pro Monat.',
emailCta: 'Mit E-Mail fortfahren', emailCta: 'Mit E-Mail fortfahren',
createCta: 'Account erstellen', createCta: 'Account erstellen',
already: 'Schon einen Account?', already: 'Schon einen Account?',
login: 'Anmelden', login: 'Anmelden',
legal: 'Mit dem Fortfahren akzeptierst du Datenschutz und Nutzungsbedingungen.', legal: 'Mit dem Fortfahren akzeptierst du Datenschutz und Nutzungsbedingungen.',
nameLabel: 'Name', nameLabel: 'Name',
emailLabel: 'E-Mail', emailLabel: 'E-Mail',
savePlantPrefix: 'Dein Scan wird nach der Registrierung gespeichert:', savePlantPrefix: 'Dein Scan wird nach der Registrierung gespeichert:',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
headline: "Let's finish your setup!", headline: "Let's finish your setup!",
subline: 'Crea una cuenta para guardar tus plantas y recibir 3 escaneos gratis al mes.', subline: 'Crea una cuenta para guardar tus plantas y recibir 3 escaneos gratis al mes.',
emailCta: 'Continuar con email', emailCta: 'Continuar con email',
createCta: 'Crear cuenta', createCta: 'Crear cuenta',
already: 'Ya tienes cuenta?', already: 'Ya tienes cuenta?',
login: 'Iniciar sesion', login: 'Iniciar sesion',
legal: 'Al continuar aceptas la Politica de privacidad y los Terminos.', legal: 'Al continuar aceptas la Politica de privacidad y los Terminos.',
nameLabel: 'Nombre', nameLabel: 'Nombre',
emailLabel: 'Email', emailLabel: 'Email',
savePlantPrefix: 'Guardaremos tu escaneo despues del registro:', savePlantPrefix: 'Guardaremos tu escaneo despues del registro:',
}; };
} }
return { return {
headline: "Let's finish your setup!", headline: "Let's finish your setup!",
subline: 'Create an account to save your plants and 3 free scans per month.', subline: 'Create an account to save your plants and 3 free scans per month.',
emailCta: 'Continue with Email', emailCta: 'Continue with Email',
createCta: 'Create Account', createCta: 'Create Account',
already: 'Already have an account?', already: 'Already have an account?',
login: 'Log in', login: 'Log in',
legal: 'By continuing you agree to our Privacy Policy and Terms.', legal: 'By continuing you agree to our Privacy Policy and Terms.',
nameLabel: 'Name', nameLabel: 'Name',
emailLabel: 'Email', emailLabel: 'Email',
savePlantPrefix: 'Your scan will be saved after signup:', savePlantPrefix: 'Your scan will be saved after signup:',
}; };
}; };
export default function SignupScreen() { export default function SignupScreen() {
const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, language, t } = useApp(); const { isDarkMode, colorPalette, hydrateSession, getPendingPlant, language, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const copy = getCopy(language); const copy = getCopy(language);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const pendingPlant = getPendingPlant(); const pendingPlant = getPendingPlant();
const isExpoGo = Constants.appOwnership === 'expo'; const isExpoGo = Constants.appOwnership === 'expo';
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
const [emailExpanded, setEmailExpanded] = useState(false); const [emailExpanded, setEmailExpanded] = useState(false);
const [appleAvailable, setAppleAvailable] = useState(false); const [appleAvailable, setAppleAvailable] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
posthog.capture('signup_screen_viewed', { context: 'onboarding' }); posthog.capture('signup_screen_viewed', { context: 'onboarding' });
}, [posthog]); }, [posthog]);
useEffect(() => { useEffect(() => {
if (isExpoGo) { if (isExpoGo) {
setAppleAvailable(false); setAppleAvailable(false);
return; return;
} }
let mounted = true; let mounted = true;
AppleAuthentication.isAvailableAsync() AppleAuthentication.isAvailableAsync()
.then((available) => { .then((available) => {
if (mounted) setAppleAvailable(available); if (mounted) setAppleAvailable(available);
}) })
.catch(() => { .catch(() => {
if (mounted) setAppleAvailable(false); if (mounted) setAppleAvailable(false);
}); });
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [isExpoGo]); }, [isExpoGo]);
const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.signUp>>) => { const finishAuth = async (session: Awaited<ReturnType<typeof AuthService.signUp>>) => {
await hydrateSession(session); await hydrateSession(session);
if (session?.userId) { if (session?.userId) {
await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {});
} }
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
router.replace('/(tabs)'); router.replace('/(tabs)');
}; };
const validate = (): string | null => { const validate = (): string | null => {
if (!name.trim()) return t.errNameRequired; if (!name.trim()) return t.errNameRequired;
if (!email.trim() || !email.includes('@')) return t.errEmailInvalid; if (!email.trim() || !email.includes('@')) return t.errEmailInvalid;
if (password.length < 6) return t.errPasswordShort; if (password.length < 6) return t.errPasswordShort;
if (password !== passwordConfirm) return t.errPasswordMismatch; if (password !== passwordConfirm) return t.errPasswordMismatch;
return null; return null;
}; };
const handleSignup = async () => { const handleSignup = async () => {
const validationError = validate(); const validationError = validate();
if (validationError) { if (validationError) {
setError(validationError); setError(validationError);
setEmailExpanded(true); setEmailExpanded(true);
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const session = await AuthService.signUp(email, name, password); const session = await AuthService.signUp(email, name, password);
await finishAuth(session); await finishAuth(session);
} catch (e: any) { } catch (e: any) {
if (e.message === 'EMAIL_TAKEN') { if (e.message === 'EMAIL_TAKEN') {
setError(t.errEmailTaken); setError(t.errEmailTaken);
} else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') { } else if (e.message === 'BACKEND_URL_MISSING' || e.message === 'NETWORK_ERROR') {
setError(t.errNetworkError); setError(t.errNetworkError);
} else if (e.message === 'SERVER_ERROR') { } else if (e.message === 'SERVER_ERROR') {
setError(t.errServerError); setError(t.errServerError);
} else { } else {
setError(t.errAuthError); setError(t.errAuthError);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleAppleSignIn = async () => { const handleAppleSignIn = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
posthog.capture('apple_login_started', { surface: 'signup' }); posthog.capture('apple_login_started', { surface: 'signup' });
try { try {
const credential = await AppleAuthentication.signInAsync({ const credential = await AppleAuthentication.signInAsync({
requestedScopes: [ requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL, AppleAuthentication.AppleAuthenticationScope.EMAIL,
], ],
}); });
if (!credential.identityToken) { if (!credential.identityToken) {
throw new Error('APPLE_AUTH_INVALID'); throw new Error('APPLE_AUTH_INVALID');
} }
const fullName = [ const fullName = [
credential.fullName?.givenName, credential.fullName?.givenName,
credential.fullName?.familyName, credential.fullName?.familyName,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
const session = await AuthService.signInWithApple({ const session = await AuthService.signInWithApple({
identityToken: credential.identityToken, identityToken: credential.identityToken,
appleUser: credential.user, appleUser: credential.user,
email: credential.email, email: credential.email,
name: fullName || undefined, name: fullName || undefined,
}); });
posthog.capture('apple_login_succeeded', { surface: 'signup' }); posthog.capture('apple_login_succeeded', { surface: 'signup' });
await finishAuth(session); await finishAuth(session);
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') return; if (e?.code === 'ERR_REQUEST_CANCELED') return;
posthog.capture('apple_login_failed', { posthog.capture('apple_login_failed', {
surface: 'signup', surface: 'signup',
error: e instanceof Error ? e.message : String(e), error: e instanceof Error ? e.message : String(e),
}); });
setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE' setError(e?.message === 'APPLE_BACKEND_UNAVAILABLE'
? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.' ? 'Apple Login ist auf dem Backend noch nicht aktiviert. Bitte Backend neu starten oder deployen.'
: t.errAuthError); : t.errAuthError);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]} style={[styles.flex, { backgroundColor: isDarkMode ? '#0a110b' : '#fbfaf3' }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
> >
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
<ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}> <ImageBackground source={HERO_IMAGE} style={styles.hero} imageStyle={styles.heroImage}>
<View style={styles.heroOverlay} /> <View style={styles.heroOverlay} />
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}> <TouchableOpacity style={styles.backBtn} onPress={() => router.back()} activeOpacity={0.8}>
<Ionicons name="arrow-back" size={20} color="#ffffff" /> <Ionicons name="arrow-back" size={20} color="#ffffff" />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.heroCopy}> <View style={styles.heroCopy}>
<Text style={styles.heroTitle}>{copy.headline}</Text> <Text style={styles.heroTitle}>{copy.headline}</Text>
<Text style={styles.heroSubline}>{copy.subline}</Text> <Text style={styles.heroSubline}>{copy.subline}</Text>
</View> </View>
</ImageBackground> </ImageBackground>
<View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}> <View style={[styles.sheet, { backgroundColor: isDarkMode ? '#101a12' : '#fbfaf3' }]}>
{pendingPlant ? ( {pendingPlant ? (
<View style={[styles.pendingHint, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}> <View style={[styles.pendingHint, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="sparkles" size={18} color={colors.primary} /> <Ionicons name="sparkles" size={18} color={colors.primary} />
<Text style={[styles.pendingHintText, { color: colors.text }]}> <Text style={[styles.pendingHintText, { color: colors.text }]}>
{copy.savePlantPrefix} {pendingPlant.result.name} {copy.savePlantPrefix} {pendingPlant.result.name}
</Text> </Text>
</View> </View>
) : null} ) : null}
{appleAvailable ? ( {appleAvailable ? (
<AppleAuthentication.AppleAuthenticationButton <AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE} buttonType={AppleAuthentication.AppleAuthenticationButtonType.CONTINUE}
buttonStyle={isDarkMode buttonStyle={isDarkMode
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK} : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={14} cornerRadius={14}
style={styles.appleButton} style={styles.appleButton}
onPress={handleAppleSignIn} onPress={handleAppleSignIn}
/> />
) : null} ) : null}
{appleAvailable ? ( {appleAvailable ? (
<View style={styles.dividerRow}> <View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text> <Text style={[styles.dividerText, { color: colors.textMuted }]}>{t.orDivider}</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} /> <View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View> </View>
) : null} ) : null}
{!emailExpanded ? ( {!emailExpanded ? (
<TouchableOpacity <TouchableOpacity
style={[styles.emailChoiceBtn, { backgroundColor: colors.surface, borderColor: colors.borderStrong }]} style={[styles.emailChoiceBtn, { backgroundColor: colors.surface, borderColor: colors.borderStrong }]}
onPress={() => setEmailExpanded(true)} onPress={() => setEmailExpanded(true)}
activeOpacity={0.84} activeOpacity={0.84}
> >
<Ionicons name="mail-outline" size={19} color={colors.primary} /> <Ionicons name="mail-outline" size={19} color={colors.primary} />
<Text style={[styles.emailChoiceText, { color: colors.text }]}>{copy.emailCta}</Text> <Text style={[styles.emailChoiceText, { color: colors.text }]}>{copy.emailCta}</Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<View style={styles.form}> <View style={styles.form}>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.nameLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder={t.namePlaceholder} placeholder={t.namePlaceholder}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={name} value={name}
onChangeText={setName} onChangeText={setName}
autoCapitalize="words" autoCapitalize="words"
autoComplete="name" autoComplete="name"
returnKeyType="next" returnKeyType="next"
/> />
</View> </View>
</View> </View>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{copy.emailLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder={t.emailPlaceholder} placeholder={t.emailPlaceholder}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
autoComplete="email" autoComplete="email"
returnKeyType="next" returnKeyType="next"
/> />
</View> </View>
</View> </View>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{t.passwordLabel}</Text>
<View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.inputRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder={t.passwordPlaceholder} placeholder={t.passwordPlaceholder}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
secureTextEntry={!showPassword} secureTextEntry={!showPassword}
autoComplete="new-password" autoComplete="new-password"
returnKeyType="next" returnKeyType="next"
/> />
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}> <TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} /> <Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
<View style={styles.fieldGroup}> <View style={styles.fieldGroup}>
<Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text> <Text style={[styles.label, { color: colors.textSecondary }]}>{t.confirmPasswordLabel}</Text>
<View style={[ <View style={[
styles.inputRow, styles.inputRow,
{ {
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border, borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.border,
}, },
]}> ]}>
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} /> <Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder={t.confirmPasswordPlaceholder} placeholder={t.confirmPasswordPlaceholder}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={passwordConfirm} value={passwordConfirm}
onChangeText={setPasswordConfirm} onChangeText={setPasswordConfirm}
secureTextEntry={!showPasswordConfirm} secureTextEntry={!showPasswordConfirm}
autoComplete="new-password" autoComplete="new-password"
returnKeyType="done" returnKeyType="done"
onSubmitEditing={handleSignup} onSubmitEditing={handleSignup}
/> />
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> <TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
<Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} /> <Ionicons name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'} size={18} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View> </View>
)} )}
{error ? ( {error ? (
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}> <View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} /> <Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
<Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text> <Text style={[styles.errorText, { color: colors.danger }]} selectable>{error}</Text>
</View> </View>
) : null} ) : null}
{emailExpanded ? ( {emailExpanded ? (
<TouchableOpacity <TouchableOpacity
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]} style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.72 : 1 }]}
onPress={handleSignup} onPress={handleSignup}
activeOpacity={0.84} activeOpacity={0.84}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<ActivityIndicator color={colors.onPrimary} size="small" /> <ActivityIndicator color={colors.onPrimary} size="small" />
) : ( ) : (
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.createCta}</Text> <Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.createCta}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
<TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')} activeOpacity={0.78}> <TouchableOpacity style={styles.loginLink} onPress={() => router.replace('/auth/login')} activeOpacity={0.78}>
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}> <Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
{copy.already}{' '} {copy.already}{' '}
<Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.login}</Text> <Text style={{ color: colors.primary, fontWeight: '800' }}>{copy.login}</Text>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text> <Text style={[styles.legal, { color: colors.textMuted }]}>{copy.legal}</Text>
</View> </View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flex: { flex: 1 }, flex: { flex: 1 },
scroll: { flexGrow: 1 }, scroll: { flexGrow: 1 },
hero: { hero: {
minHeight: 330, minHeight: 330,
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: 56, paddingTop: 56,
paddingBottom: 34, paddingBottom: 34,
}, },
heroImage: { resizeMode: 'cover' }, heroImage: { resizeMode: 'cover' },
heroOverlay: { heroOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 12, 7, 0.46)', backgroundColor: 'rgba(5, 12, 7, 0.46)',
}, },
backBtn: { backBtn: {
position: 'absolute', position: 'absolute',
top: 54, top: 54,
left: 22, left: 22,
width: 42, width: 42,
height: 42, height: 42,
borderRadius: 21, borderRadius: 21,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.28)', backgroundColor: 'rgba(0, 0, 0, 0.28)',
}, },
heroCopy: { gap: 10 }, heroCopy: { gap: 10 },
heroTitle: { heroTitle: {
color: '#ffffff', color: '#ffffff',
fontSize: 34, fontSize: 34,
lineHeight: 39, lineHeight: 39,
fontWeight: '900', fontWeight: '900',
}, },
heroSubline: { heroSubline: {
color: 'rgba(255, 255, 255, 0.88)', color: 'rgba(255, 255, 255, 0.88)',
fontSize: 16, fontSize: 16,
lineHeight: 22, lineHeight: 22,
fontWeight: '700', fontWeight: '700',
}, },
sheet: { sheet: {
flex: 1, flex: 1,
marginTop: -24, marginTop: -24,
borderTopLeftRadius: 28, borderTopLeftRadius: 28,
borderTopRightRadius: 28, borderTopRightRadius: 28,
paddingHorizontal: 22, paddingHorizontal: 22,
paddingTop: 24, paddingTop: 24,
paddingBottom: 32, paddingBottom: 32,
gap: 14, gap: 14,
}, },
pendingHint: { pendingHint: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
paddingHorizontal: 14, paddingHorizontal: 14,
paddingVertical: 12, paddingVertical: 12,
}, },
pendingHintText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, pendingHintText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
appleButton: { width: '100%', height: 56 }, appleButton: { width: '100%', height: 56 },
dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
dividerLine: { flex: 1, height: 1 }, dividerLine: { flex: 1, height: 1 },
dividerText: { fontSize: 12, fontWeight: '800' }, dividerText: { fontSize: 12, fontWeight: '800' },
emailChoiceBtn: { emailChoiceBtn: {
height: 56, height: 56,
borderRadius: 14, borderRadius: 14,
borderWidth: 1.5, borderWidth: 1.5,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 10, gap: 10,
}, },
emailChoiceText: { fontSize: 16, fontWeight: '800' }, emailChoiceText: { fontSize: 16, fontWeight: '800' },
form: { gap: 12 }, form: { gap: 12 },
fieldGroup: { gap: 6 }, fieldGroup: { gap: 6 },
label: { fontSize: 13, fontWeight: '800', marginLeft: 2 }, label: { fontSize: 13, fontWeight: '800', marginLeft: 2 },
inputRow: { inputRow: {
height: 52, height: 52,
borderWidth: 1, borderWidth: 1,
borderRadius: 14, borderRadius: 14,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 14,
}, },
inputIcon: { marginRight: 10 }, inputIcon: { marginRight: 10 },
input: { flex: 1, height: 52, fontSize: 15 }, input: { flex: 1, height: 52, fontSize: 15 },
eyeBtn: { padding: 5, marginLeft: 6 }, eyeBtn: { padding: 5, marginLeft: 6 },
errorBox: { errorBox: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 7, gap: 7,
borderRadius: 12, borderRadius: 12,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
}, },
errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' }, errorText: { flex: 1, fontSize: 13, lineHeight: 18, fontWeight: '700' },
primaryBtn: { primaryBtn: {
height: 56, height: 56,
borderRadius: 14, borderRadius: 14,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
primaryBtnText: { fontSize: 17, fontWeight: '900' }, primaryBtnText: { fontSize: 17, fontWeight: '900' },
loginLink: { alignItems: 'center', paddingTop: 4, paddingBottom: 2 }, loginLink: { alignItems: 'center', paddingTop: 4, paddingBottom: 2 },
loginLinkText: { fontSize: 15, fontWeight: '700' }, loginLinkText: { fontSize: 15, fontWeight: '700' },
legal: { textAlign: 'center', fontSize: 11.5, lineHeight: 16, paddingHorizontal: 12 }, legal: { textAlign: 'center', fontSize: 11.5, lineHeight: 16, paddingHorizontal: 12 },
}); });

View File

@@ -1,332 +1,332 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { import {
Image, Image,
ImageBackground, ImageBackground,
SafeAreaView, SafeAreaView,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
useWindowDimensions, useWindowDimensions,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { useSafeAnalytics } from '../services/analytics'; import { useSafeAnalytics } from '../services/analytics';
import { Language } from '../types'; import { Language } from '../types';
const getWelcomeCopy = (language: Language) => { const getWelcomeCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
return { return {
headline: 'Willkommen bei GreenLens!', headline: 'Willkommen bei GreenLens!',
subline: 'Pflanzen erkennen, verstehen und pflegen — ganz einfach.', subline: 'Pflanzen erkennen, verstehen und pflegen — ganz einfach.',
testimonial: '„Endlich überleben meine Pflanzen! Absolute Empfehlung."', testimonial: '„Endlich überleben meine Pflanzen! Absolute Empfehlung."',
testimonialAuthor: 'Anna M.', testimonialAuthor: 'Anna M.',
cta: "Los geht's", cta: "Los geht's",
login: 'Anmelden', login: 'Anmelden',
demoScan: 'Oder direkt eine Pflanze scannen', demoScan: 'Oder direkt eine Pflanze scannen',
legal: 'Mit dem Fortfahren akzeptierst du unsere Datenschutzerklärung und AGB.', legal: 'Mit dem Fortfahren akzeptierst du unsere Datenschutzerklärung und AGB.',
rating: '4,8', rating: '4,8',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
headline: '¡Bienvenido a GreenLens!', headline: '¡Bienvenido a GreenLens!',
subline: 'Identifica, entiende y cuida tus plantas — sin esfuerzo.', subline: 'Identifica, entiende y cuida tus plantas — sin esfuerzo.',
testimonial: '"¡Por fin mis plantas sobreviven! Muy recomendable."', testimonial: '"¡Por fin mis plantas sobreviven! Muy recomendable."',
testimonialAuthor: 'Anna M.', testimonialAuthor: 'Anna M.',
cta: 'Empezar', cta: 'Empezar',
login: 'Iniciar sesión', login: 'Iniciar sesión',
demoScan: 'O escanea una planta ahora', demoScan: 'O escanea una planta ahora',
legal: 'Al continuar aceptas nuestra Política de privacidad y Términos.', legal: 'Al continuar aceptas nuestra Política de privacidad y Términos.',
rating: '4.8', rating: '4.8',
}; };
} }
return { return {
headline: 'Welcome to GreenLens!', headline: 'Welcome to GreenLens!',
subline: 'Identify, understand and care for your plants — effortlessly.', subline: 'Identify, understand and care for your plants — effortlessly.',
testimonial: '"Finally my plants stay alive! Highly recommend."', testimonial: '"Finally my plants stay alive! Highly recommend."',
testimonialAuthor: 'Anna M.', testimonialAuthor: 'Anna M.',
cta: "Let's Go", cta: "Let's Go",
login: 'Log in', login: 'Log in',
demoScan: 'Or scan a plant right now', demoScan: 'Or scan a plant right now',
legal: 'By continuing you agree to our Privacy Policy and Terms.', legal: 'By continuing you agree to our Privacy Policy and Terms.',
rating: '4.8', rating: '4.8',
}; };
}; };
export default function OnboardingScreen() { export default function OnboardingScreen() {
const { language } = useApp(); const { language } = useApp();
const { height } = useWindowDimensions(); const { height } = useWindowDimensions();
const compact = height < 700; const compact = height < 700;
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const copy = getWelcomeCopy(language); const copy = getWelcomeCopy(language);
useEffect(() => { useEffect(() => {
posthog.capture('onboarding_welcome_viewed'); posthog.capture('onboarding_welcome_viewed');
}, [posthog]); }, [posthog]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ImageBackground <ImageBackground
source={require('../assets/welcome_botanical_hero.png')} source={require('../assets/welcome_botanical_hero.png')}
style={[styles.hero, { height: compact ? '48%' : '55%' }]} style={[styles.hero, { height: compact ? '48%' : '55%' }]}
imageStyle={styles.heroImageContent} imageStyle={styles.heroImageContent}
resizeMode="cover" resizeMode="cover"
> >
<View style={styles.heroShadeTop} /> <View style={styles.heroShadeTop} />
<SafeAreaView style={styles.heroSafe}> <SafeAreaView style={styles.heroSafe}>
<View style={styles.heroTopRow}> <View style={styles.heroTopRow}>
<View style={styles.brandRow}> <View style={styles.brandRow}>
<Image <Image
source={require('../assets/icon.png')} source={require('../assets/icon.png')}
style={styles.logo} style={styles.logo}
resizeMode="cover" resizeMode="cover"
/> />
<Text style={styles.brandName}> <Text style={styles.brandName}>
Green<Text style={styles.brandAccent}>Lens</Text> Green<Text style={styles.brandAccent}>Lens</Text>
</Text> </Text>
</View> </View>
<View style={styles.ratingPill}> <View style={styles.ratingPill}>
<Ionicons name="star" size={13} color="#f5c04e" /> <Ionicons name="star" size={13} color="#f5c04e" />
<Text style={styles.ratingText}>{copy.rating}</Text> <Text style={styles.ratingText}>{copy.rating}</Text>
</View> </View>
</View> </View>
<View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}> <View style={[styles.testimonialCard, compact && styles.testimonialCardCompact]}>
<Text style={styles.testimonialText}>{copy.testimonial}</Text> <Text style={styles.testimonialText}>{copy.testimonial}</Text>
<View style={styles.testimonialMeta}> <View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor}>{copy.testimonialAuthor}</Text> <Text style={styles.testimonialAuthor}>{copy.testimonialAuthor}</Text>
<View style={styles.starsRow}> <View style={styles.starsRow}>
{[0, 1, 2, 3, 4].map((i) => ( {[0, 1, 2, 3, 4].map((i) => (
<Ionicons key={i} name="star" size={14} color="#f5c04e" /> <Ionicons key={i} name="star" size={14} color="#f5c04e" />
))} ))}
</View> </View>
</View> </View>
</View> </View>
</SafeAreaView> </SafeAreaView>
</ImageBackground> </ImageBackground>
<View style={styles.sheet}> <View style={styles.sheet}>
<View style={styles.sheetHandle} /> <View style={styles.sheetHandle} />
<View style={styles.sheetContent}> <View style={styles.sheetContent}>
<Text style={[styles.headline, compact && styles.headlineCompact]}>{copy.headline}</Text> <Text style={[styles.headline, compact && styles.headlineCompact]}>{copy.headline}</Text>
<Text style={styles.subline}>{copy.subline}</Text> <Text style={styles.subline}>{copy.subline}</Text>
<View style={styles.spacer} /> <View style={styles.spacer} />
<TouchableOpacity <TouchableOpacity
style={styles.cta} style={styles.cta}
onPress={() => { onPress={() => {
posthog.capture('onboarding_started'); posthog.capture('onboarding_started');
router.push('/onboarding/slides'); router.push('/onboarding/slides');
}} }}
activeOpacity={0.86} activeOpacity={0.86}
> >
<Text style={styles.ctaText}>{copy.cta}</Text> <Text style={styles.ctaText}>{copy.cta}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}> <TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}>
<Text style={styles.loginText}>{copy.login}</Text> <Text style={styles.loginText}>{copy.login}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/scanner')} style={styles.demoLink}> <TouchableOpacity onPress={() => router.push('/scanner')} style={styles.demoLink}>
<Ionicons name="scan-outline" size={16} color="#4b7c31" /> <Ionicons name="scan-outline" size={16} color="#4b7c31" />
<Text style={styles.demoText}>{copy.demoScan}</Text> <Text style={styles.demoText}>{copy.demoScan}</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.legal}>{copy.legal}</Text> <Text style={styles.legal}>{copy.legal}</Text>
</View> </View>
</View> </View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#0a110b', backgroundColor: '#0a110b',
}, },
hero: { hero: {
width: '100%', width: '100%',
}, },
heroImageContent: { heroImageContent: {
backgroundColor: '#0a110b', backgroundColor: '#0a110b',
}, },
heroShadeTop: { heroShadeTop: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
top: 0, top: 0,
height: 120, height: 120,
backgroundColor: 'rgba(10,17,11,0.4)', backgroundColor: 'rgba(10,17,11,0.4)',
}, },
heroSafe: { heroSafe: {
flex: 1, flex: 1,
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 20, paddingHorizontal: 20,
paddingTop: 8, paddingTop: 8,
paddingBottom: 24, paddingBottom: 24,
}, },
heroTopRow: { heroTopRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
brandRow: { brandRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
}, },
logo: { logo: {
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 10, borderRadius: 10,
backgroundColor: '#fff', backgroundColor: '#fff',
}, },
brandName: { brandName: {
color: '#ffffff', color: '#ffffff',
fontSize: 24, fontSize: 24,
fontWeight: '900', fontWeight: '900',
}, },
brandAccent: { brandAccent: {
color: '#a6d66f', color: '#a6d66f',
}, },
ratingPill: { ratingPill: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 5, gap: 5,
backgroundColor: 'rgba(255,255,255,0.18)', backgroundColor: 'rgba(255,255,255,0.18)',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)', borderColor: 'rgba(255,255,255,0.3)',
borderRadius: 999, borderRadius: 999,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 6,
}, },
ratingText: { ratingText: {
color: '#ffffff', color: '#ffffff',
fontSize: 12, fontSize: 12,
fontWeight: '700', fontWeight: '700',
}, },
testimonialCard: { testimonialCard: {
backgroundColor: 'rgba(255,255,255,0.97)', backgroundColor: 'rgba(255,255,255,0.97)',
borderRadius: 16, borderRadius: 16,
padding: 16, padding: 16,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.18, shadowOpacity: 0.18,
shadowRadius: 14, shadowRadius: 14,
elevation: 4, elevation: 4,
}, },
testimonialCardCompact: { testimonialCardCompact: {
padding: 12, padding: 12,
}, },
testimonialText: { testimonialText: {
color: '#191d16', color: '#191d16',
fontSize: 14.5, fontSize: 14.5,
lineHeight: 20, lineHeight: 20,
fontStyle: 'italic', fontStyle: 'italic',
marginBottom: 10, marginBottom: 10,
}, },
testimonialMeta: { testimonialMeta: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
testimonialAuthor: { testimonialAuthor: {
color: '#42493c', color: '#42493c',
fontSize: 13, fontSize: 13,
fontWeight: '700', fontWeight: '700',
}, },
starsRow: { starsRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 1, gap: 1,
}, },
sheet: { sheet: {
flex: 1, flex: 1,
backgroundColor: '#fbfaf3', backgroundColor: '#fbfaf3',
borderTopLeftRadius: 28, borderTopLeftRadius: 28,
borderTopRightRadius: 28, borderTopRightRadius: 28,
marginTop: -20, marginTop: -20,
}, },
sheetHandle: { sheetHandle: {
alignSelf: 'center', alignSelf: 'center',
width: 44, width: 44,
height: 5, height: 5,
borderRadius: 3, borderRadius: 3,
backgroundColor: 'rgba(16,28,18,0.15)', backgroundColor: 'rgba(16,28,18,0.15)',
marginTop: 12, marginTop: 12,
marginBottom: 8, marginBottom: 8,
}, },
sheetContent: { sheetContent: {
flex: 1, flex: 1,
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: 8, paddingTop: 8,
paddingBottom: 12, paddingBottom: 12,
}, },
headline: { headline: {
color: '#101c12', color: '#101c12',
fontSize: 28, fontSize: 28,
lineHeight: 34, lineHeight: 34,
fontWeight: '900', fontWeight: '900',
textAlign: 'center', textAlign: 'center',
marginBottom: 10, marginBottom: 10,
}, },
headlineCompact: { headlineCompact: {
fontSize: 24, fontSize: 24,
lineHeight: 29, lineHeight: 29,
}, },
subline: { subline: {
color: '#5f625d', color: '#5f625d',
fontSize: 16, fontSize: 16,
lineHeight: 22, lineHeight: 22,
fontWeight: '500', fontWeight: '500',
textAlign: 'center', textAlign: 'center',
}, },
spacer: { spacer: {
flex: 1, flex: 1,
minHeight: 12, minHeight: 12,
}, },
cta: { cta: {
height: 60, height: 60,
borderRadius: 16, borderRadius: 16,
backgroundColor: '#437824', backgroundColor: '#437824',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 6, marginBottom: 6,
}, },
ctaText: { ctaText: {
color: '#f8f7ef', color: '#f8f7ef',
fontSize: 18, fontSize: 18,
fontWeight: '800', fontWeight: '800',
}, },
loginLink: { loginLink: {
alignItems: 'center', alignItems: 'center',
paddingVertical: 10, paddingVertical: 10,
}, },
loginText: { loginText: {
color: '#437824', color: '#437824',
fontSize: 15, fontSize: 15,
fontWeight: '800', fontWeight: '800',
}, },
demoLink: { demoLink: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 7, gap: 7,
paddingVertical: 6, paddingVertical: 6,
marginBottom: 8, marginBottom: 8,
}, },
demoText: { demoText: {
color: '#4b7c31', color: '#4b7c31',
fontSize: 13.5, fontSize: 13.5,
fontWeight: '700', fontWeight: '700',
}, },
legal: { legal: {
color: '#6b6d68', color: '#6b6d68',
fontSize: 11, fontSize: 11,
lineHeight: 14, lineHeight: 14,
fontWeight: '500', fontWeight: '500',
textAlign: 'center', textAlign: 'center',
}, },
}); });

View File

@@ -1,67 +1,67 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService'; import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion';
const EXPERIENCE_OPTIONS = [ const EXPERIENCE_OPTIONS = [
{ id: 'beginner', emoji: '🌱' }, { id: 'beginner', emoji: '🌱' },
{ id: 'intermediate', emoji: '☀️' }, { id: 'intermediate', emoji: '☀️' },
{ id: 'advanced', emoji: '🧪' }, { id: 'advanced', emoji: '🧪' },
]; ];
export default function OnboardingExperienceScreen() { export default function OnboardingExperienceScreen() {
const router = useRouter(); const router = useRouter();
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, t } = useApp(); const { session, isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const [selectedLevel, setSelectedLevel] = useState<string | null>(null); const [selectedLevel, setSelectedLevel] = useState<string | null>(null);
const levelLabels: Record<string, string> = { const levelLabels: Record<string, string> = {
beginner: t.experienceOptionBeginner, beginner: t.experienceOptionBeginner,
intermediate: t.experienceOptionIntermediate, intermediate: t.experienceOptionIntermediate,
advanced: t.experienceOptionAdvanced, advanced: t.experienceOptionAdvanced,
}; };
const options: QuestionOption[] = EXPERIENCE_OPTIONS.map((option) => ({ const options: QuestionOption[] = EXPERIENCE_OPTIONS.map((option) => ({
id: option.id, id: option.id,
emoji: option.emoji, emoji: option.emoji,
label: levelLabels[option.id], label: levelLabels[option.id],
})); }));
const finish = (level: string | null) => { const finish = (level: string | null) => {
if (session?.userId && level) { if (session?.userId && level) {
OnboardingProgressService.setExperienceLevel(session.userId, level); OnboardingProgressService.setExperienceLevel(session.userId, level);
} }
if (level) { if (level) {
void PreAuthOnboardingService.setAnswer('experienceLevel', level); void PreAuthOnboardingService.setAnswer('experienceLevel', level);
} }
posthog.capture('onboarding_experience_completed', { posthog.capture('onboarding_experience_completed', {
experience_level: level ?? 'skipped', experience_level: level ?? 'skipped',
}); });
router.replace('/onboarding/health-check'); router.replace('/onboarding/health-check');
}; };
return ( return (
<OnboardingQuestion <OnboardingQuestion
colors={colors} colors={colors}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
step={3} step={3}
totalSteps={4} totalSteps={4}
title={t.experienceOnboardingTitle} title={t.experienceOnboardingTitle}
subtitle={t.experienceOnboardingSubtitle} subtitle={t.experienceOnboardingSubtitle}
options={options} options={options}
selectedId={selectedLevel} selectedId={selectedLevel}
onSelect={setSelectedLevel} onSelect={setSelectedLevel}
onContinue={() => finish(selectedLevel)} onContinue={() => finish(selectedLevel)}
onBack={() => router.back()} onBack={() => router.back()}
continueLabel={t.experienceOnboardingContinue} continueLabel={t.experienceOnboardingContinue}
skipLabel={t.experienceOnboardingSkip} skipLabel={t.experienceOnboardingSkip}
onSkip={() => finish(null)} onSkip={() => finish(null)}
/> />
); );
} }

View File

@@ -1,69 +1,69 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService'; import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion';
const GOAL_OPTIONS = [ const GOAL_OPTIONS = [
{ id: 'identify', emoji: '🔍' }, { id: 'identify', emoji: '🔍' },
{ id: 'care', emoji: '💧' }, { id: 'care', emoji: '💧' },
{ id: 'collection', emoji: '🗂️' }, { id: 'collection', emoji: '🗂️' },
{ id: 'learn', emoji: '📚' }, { id: 'learn', emoji: '📚' },
]; ];
export default function OnboardingGoalScreen() { export default function OnboardingGoalScreen() {
const router = useRouter(); const router = useRouter();
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, t } = useApp(); const { session, isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const [selectedGoal, setSelectedGoal] = useState<string | null>(null); const [selectedGoal, setSelectedGoal] = useState<string | null>(null);
const goalLabels: Record<string, string> = { const goalLabels: Record<string, string> = {
identify: t.goalOptionIdentify, identify: t.goalOptionIdentify,
care: t.goalOptionCare, care: t.goalOptionCare,
collection: t.goalOptionCollection, collection: t.goalOptionCollection,
learn: t.goalOptionLearn, learn: t.goalOptionLearn,
}; };
const options: QuestionOption[] = GOAL_OPTIONS.map((option) => ({ const options: QuestionOption[] = GOAL_OPTIONS.map((option) => ({
id: option.id, id: option.id,
emoji: option.emoji, emoji: option.emoji,
label: goalLabels[option.id], label: goalLabels[option.id],
})); }));
const finish = (goal: string | null) => { const finish = (goal: string | null) => {
if (session?.userId && goal) { if (session?.userId && goal) {
OnboardingProgressService.setPrimaryGoal(session.userId, goal); OnboardingProgressService.setPrimaryGoal(session.userId, goal);
} }
if (goal) { if (goal) {
void PreAuthOnboardingService.setAnswer('primaryGoal', goal); void PreAuthOnboardingService.setAnswer('primaryGoal', goal);
} }
posthog.capture('onboarding_goal_completed', { posthog.capture('onboarding_goal_completed', {
goal: goal ?? 'skipped', goal: goal ?? 'skipped',
}); });
router.replace('/onboarding/experience'); router.replace('/onboarding/experience');
}; };
return ( return (
<OnboardingQuestion <OnboardingQuestion
colors={colors} colors={colors}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
step={2} step={2}
totalSteps={4} totalSteps={4}
title={t.goalOnboardingTitle} title={t.goalOnboardingTitle}
subtitle={t.goalOnboardingSubtitle} subtitle={t.goalOnboardingSubtitle}
options={options} options={options}
selectedId={selectedGoal} selectedId={selectedGoal}
onSelect={setSelectedGoal} onSelect={setSelectedGoal}
onContinue={() => finish(selectedGoal)} onContinue={() => finish(selectedGoal)}
onBack={() => router.back()} onBack={() => router.back()}
continueLabel={t.goalOnboardingContinue} continueLabel={t.goalOnboardingContinue}
skipLabel={t.goalOnboardingSkip} skipLabel={t.goalOnboardingSkip}
onSkip={() => finish(null)} onSkip={() => finish(null)}
/> />
); );
} }

View File

@@ -1,205 +1,205 @@
import React from 'react'; import React from 'react';
import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ImageBackground, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { ThemeBackdrop } from '../../components/ThemeBackdrop'; import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
const ONBOARDING_BACKGROUND = { const ONBOARDING_BACKGROUND = {
light: '#fbfaf3', light: '#fbfaf3',
dark: '#0a110b', dark: '#0a110b',
}; };
const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => { const getHealthOnboardingCopy = (language: 'de' | 'en' | 'es') => {
if (language === 'de') { if (language === 'de') {
return { return {
title: 'Wo ist der Health-Scan?', title: 'Wo ist der Health-Scan?',
subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.', subtitle: 'Du findest ihn auf jeder gespeicherten Pflanze, direkt unter der Beschreibung.',
buttonPreview: 'Health-Scan starten', buttonPreview: 'Health-Scan starten',
cta: 'Weiter', cta: 'Weiter',
skip: 'Spaeter', skip: 'Spaeter',
flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'], flow: ['Pflanze scannen', 'Speichern', 'Detailseite oeffnen', 'Health-Scan starten'],
outputTitle: 'Was du danach bekommst', outputTitle: 'Was du danach bekommst',
outputs: [ outputs: [
'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.', 'Gesundheits-Score mit Status: stabil, beobachten oder kritisch.',
'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.', 'Ausfuehrliche Analyse mit sichtbaren Hinweisen und Unsicherheit.',
'Wahrscheinlichste Ursachen mit Confidence-Werten.', 'Wahrscheinlichste Ursachen mit Confidence-Werten.',
'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.', 'Sofortmassnahmen plus konkreter 7-Tage-Pflegeplan.',
], ],
guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.', guidanceNote: 'Tipp: Fotografiere die ganze Pflanze, die Blattunterseiten und die Erde. Je klarer das Foto, desto genauer wird der Plan.',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
title: 'Donde esta el health-scan?', title: 'Donde esta el health-scan?',
subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.', subtitle: 'Lo encuentras en cada planta guardada, justo debajo de la descripcion.',
buttonPreview: 'Iniciar health-scan', buttonPreview: 'Iniciar health-scan',
cta: 'Continuar', cta: 'Continuar',
skip: 'Mas tarde', skip: 'Mas tarde',
flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'], flow: ['Escanear planta', 'Guardar', 'Abrir detalle', 'Iniciar health-scan'],
outputTitle: 'Que recibes despues', outputTitle: 'Que recibes despues',
outputs: [ outputs: [
'Puntaje de salud con estado: estable, observar o critico.', 'Puntaje de salud con estado: estable, observar o critico.',
'Analisis detallado con senales visibles e incertidumbre.', 'Analisis detallado con senales visibles e incertidumbre.',
'Causas probables con valores de confianza.', 'Causas probables con valores de confianza.',
'Acciones inmediatas y plan concreto de 7 dias.', 'Acciones inmediatas y plan concreto de 7 dias.',
], ],
guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.', guidanceNote: 'Consejo: fotografia la planta completa, el reverso de las hojas y el sustrato. Cuanto mas clara sea la foto, mas preciso sera el plan.',
}; };
} }
return { return {
title: 'Where is the health scan?', title: 'Where is the health scan?',
subtitle: 'It lives on every saved plant, directly below the plant description.', subtitle: 'It lives on every saved plant, directly below the plant description.',
buttonPreview: 'Start health scan', buttonPreview: 'Start health scan',
cta: 'Continue', cta: 'Continue',
skip: 'Later', skip: 'Later',
flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'], flow: ['Scan plant', 'Save', 'Open detail', 'Start health scan'],
outputTitle: 'What you get after', outputTitle: 'What you get after',
outputs: [ outputs: [
'Health score with stable, watch, or critical status.', 'Health score with stable, watch, or critical status.',
'Detailed analysis with visible signals and uncertainty.', 'Detailed analysis with visible signals and uncertainty.',
'Most likely causes with confidence values.', 'Most likely causes with confidence values.',
'Immediate actions plus a concrete 7-day care plan.', 'Immediate actions plus a concrete 7-day care plan.',
], ],
guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.', guidanceNote: 'Tip: photograph the full plant, leaf undersides, and the soil. The clearer the photo, the more precise the plan.',
}; };
}; };
export default function HealthCheckOnboardingScreen() { export default function HealthCheckOnboardingScreen() {
const router = useRouter(); const router = useRouter();
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const { isDarkMode, colorPalette, language, billingSummary } = useApp(); const { isDarkMode, colorPalette, language, billingSummary } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light; const screenBackground = isDarkMode ? ONBOARDING_BACKGROUND.dark : ONBOARDING_BACKGROUND.light;
const copy = getHealthOnboardingCopy(language); const copy = getHealthOnboardingCopy(language);
const finish = (skipped = false) => { const finish = (skipped = false) => {
posthog.capture('onboarding_health_check_explained', { posthog.capture('onboarding_health_check_explained', {
skipped, skipped,
plan: billingSummary?.entitlement?.plan ?? 'free', plan: billingSummary?.entitlement?.plan ?? 'free',
}); });
router.replace('/onboarding/personalizing'); router.replace('/onboarding/personalizing');
}; };
return ( return (
<View style={[styles.container, { backgroundColor: screenBackground }]}> <View style={[styles.container, { backgroundColor: screenBackground }]}>
{isDarkMode ? <ThemeBackdrop colors={colors} /> : null} {isDarkMode ? <ThemeBackdrop colors={colors} /> : null}
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.topBar}> <View style={styles.topBar}>
<TouchableOpacity onPress={() => router.back()} style={[styles.backBtn, { backgroundColor: colors.surface }]}> <TouchableOpacity onPress={() => router.back()} style={[styles.backBtn, { backgroundColor: colors.surface }]}>
<Ionicons name="arrow-back" size={20} color={colors.primary} /> <Ionicons name="arrow-back" size={20} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
<View style={[styles.progressTrack, { backgroundColor: colors.primarySoft }]}> <View style={[styles.progressTrack, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.progressFill, { backgroundColor: colors.primary, width: '100%' }]} /> <View style={[styles.progressFill, { backgroundColor: colors.primary, width: '100%' }]} />
</View> </View>
<View style={styles.backBtn} /> <View style={styles.backBtn} />
</View> </View>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text> <Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text> <Text style={[styles.subtitle, { color: colors.textSecondary }]}>{copy.subtitle}</Text>
</View> </View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<ImageBackground <ImageBackground
source={require('../../assets/onboarding_health_scan_mockup.png')} source={require('../../assets/onboarding_health_scan_mockup.png')}
style={[styles.illustration, { borderColor: colors.border }]} style={[styles.illustration, { borderColor: colors.border }]}
imageStyle={styles.illustrationImage} imageStyle={styles.illustrationImage}
resizeMode="cover" resizeMode="cover"
> >
<View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} /> <View style={[styles.illustrationOverlay, { backgroundColor: isDarkMode ? 'rgba(8, 14, 9, 0.08)' : 'rgba(251, 250, 243, 0.04)' }]} />
</ImageBackground> </ImageBackground>
<View style={[styles.flowCard, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.flowCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
{copy.flow.map((item, index) => ( {copy.flow.map((item, index) => (
<View key={item} style={styles.flowRow}> <View key={item} style={styles.flowRow}>
<View style={[styles.flowIndex, { backgroundColor: index === 3 ? colors.primary : colors.surfaceMuted }]}> <View style={[styles.flowIndex, { backgroundColor: index === 3 ? colors.primary : colors.surfaceMuted }]}>
<Text style={[styles.flowIndexText, { color: index === 3 ? colors.onPrimary : colors.textMuted }]}> <Text style={[styles.flowIndexText, { color: index === 3 ? colors.onPrimary : colors.textMuted }]}>
{index + 1} {index + 1}
</Text> </Text>
</View> </View>
<Text style={[styles.flowText, { color: colors.text }]}>{item}</Text> <Text style={[styles.flowText, { color: colors.text }]}>{item}</Text>
</View> </View>
))} ))}
</View> </View>
<View style={[styles.outputCard, { backgroundColor: colors.surface, borderColor: colors.border }]}> <View style={[styles.outputCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.outputTitle, { color: colors.text }]}>{copy.outputTitle}</Text> <Text style={[styles.outputTitle, { color: colors.text }]}>{copy.outputTitle}</Text>
{copy.outputs.map((item) => ( {copy.outputs.map((item) => (
<View key={item} style={styles.outputRow}> <View key={item} style={styles.outputRow}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} /> <Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={[styles.outputText, { color: colors.textSecondary }]}>{item}</Text> <Text style={[styles.outputText, { color: colors.textSecondary }]}>{item}</Text>
</View> </View>
))} ))}
</View> </View>
<View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}> <View style={[styles.guidanceCard, { backgroundColor: colors.primarySoft, borderColor: colors.border }]}>
<Ionicons name="camera-outline" size={18} color={colors.primaryDark} /> <Ionicons name="camera-outline" size={18} color={colors.primaryDark} />
<Text style={[styles.guidanceText, { color: colors.primaryDark }]}>{copy.guidanceNote}</Text> <Text style={[styles.guidanceText, { color: colors.primaryDark }]}>{copy.guidanceNote}</Text>
</View> </View>
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]} style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
onPress={() => finish(true)} onPress={() => finish(true)}
> >
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text> <Text style={[styles.secondaryBtnText, { color: colors.text }]}>{copy.skip}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}> <TouchableOpacity style={[styles.primaryBtn, { backgroundColor: colors.primary }]} onPress={() => finish(false)}>
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text> <Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>{copy.cta}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</SafeAreaView> </SafeAreaView>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 }, safeArea: { flex: 1, paddingHorizontal: 20, paddingTop: 24, paddingBottom: 20 },
topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 },
backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' },
progressFill: { height: 6, borderRadius: 3 }, progressFill: { height: 6, borderRadius: 3 },
header: { gap: 9, marginTop: 8, marginBottom: 18 }, header: { gap: 9, marginTop: 8, marginBottom: 18 },
title: { fontSize: 30, lineHeight: 34, fontWeight: '900' }, title: { fontSize: 30, lineHeight: 34, fontWeight: '900' },
subtitle: { fontSize: 14, lineHeight: 20 }, subtitle: { fontSize: 14, lineHeight: 20 },
content: { gap: 14, paddingBottom: 12 }, content: { gap: 14, paddingBottom: 12 },
illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' }, illustration: { height: 230, borderRadius: 28, borderWidth: 1, justifyContent: 'center', overflow: 'hidden' },
illustrationImage: { borderRadius: 28 }, illustrationImage: { borderRadius: 28 },
illustrationOverlay: { ...StyleSheet.absoluteFillObject }, illustrationOverlay: { ...StyleSheet.absoluteFillObject },
phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 }, phone: { width: 178, minHeight: 156, borderRadius: 26, borderWidth: 1, padding: 12, gap: 10, marginLeft: 16 },
phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 }, phoneHeader: { height: 58, borderRadius: 18, justifyContent: 'flex-end', padding: 10 },
phoneTitle: { fontSize: 13, fontWeight: '800' }, phoneTitle: { fontSize: 13, fontWeight: '800' },
phoneRows: { gap: 8 }, phoneRows: { gap: 8 },
phoneRowLong: { height: 8, borderRadius: 999 }, phoneRowLong: { height: 8, borderRadius: 999 },
phoneRowShort: { width: '66%', height: 8, borderRadius: 999 }, phoneRowShort: { width: '66%', height: 8, borderRadius: 999 },
healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 }, healthButtonPreview: { height: 34, borderRadius: 14, borderWidth: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 5 },
healthButtonText: { fontSize: 10, fontWeight: '800' }, healthButtonText: { fontSize: 10, fontWeight: '800' },
scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 }, scanCard: { position: 'absolute', right: 16, bottom: 20, width: 136, borderRadius: 20, borderWidth: 1, padding: 14, gap: 7 },
scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' }, scanScore: { fontSize: 25, lineHeight: 29, fontWeight: '900' },
scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' }, scanLabel: { fontSize: 11, fontWeight: '800', textTransform: 'uppercase' },
scanLine: { height: 8, borderRadius: 999 }, scanLine: { height: 8, borderRadius: 999 },
scanLineShort: { width: '68%', height: 8, borderRadius: 999 }, scanLineShort: { width: '68%', height: 8, borderRadius: 999 },
flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 }, flowCard: { borderRadius: 18, borderWidth: 1, padding: 14, gap: 10 },
flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, flowRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, flowIndex: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
flowIndexText: { fontSize: 12, fontWeight: '900' }, flowIndexText: { fontSize: 12, fontWeight: '900' },
flowText: { flex: 1, fontSize: 14, fontWeight: '700' }, flowText: { flex: 1, fontSize: 14, fontWeight: '700' },
outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 }, outputCard: { borderRadius: 18, borderWidth: 1, padding: 16, gap: 11 },
outputTitle: { fontSize: 15, fontWeight: '800' }, outputTitle: { fontSize: 15, fontWeight: '800' },
outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 }, outputRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 9 },
outputText: { flex: 1, fontSize: 13, lineHeight: 18 }, outputText: { flex: 1, fontSize: 13, lineHeight: 18 },
guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, guidanceCard: { borderRadius: 18, borderWidth: 1, padding: 14, flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' }, guidanceText: { flex: 1, fontSize: 12, lineHeight: 18, fontWeight: '600' },
footer: { flexDirection: 'row', gap: 12, marginTop: 12 }, footer: { flexDirection: 'row', gap: 12, marginTop: 12 },
secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' }, secondaryBtn: { flex: 1, height: 52, borderRadius: 16, borderWidth: 1.5, alignItems: 'center', justifyContent: 'center' },
secondaryBtnText: { fontSize: 15, fontWeight: '600' }, secondaryBtnText: { fontSize: 15, fontWeight: '600' },
primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' }, primaryBtn: { flex: 1.3, height: 52, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
primaryBtnText: { fontSize: 15, fontWeight: '700' }, primaryBtnText: { fontSize: 15, fontWeight: '700' },
}); });

View File

@@ -1,165 +1,165 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native'; import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router'; import { router } from 'expo-router';
import Svg, { Circle } from 'react-native-svg'; import Svg, { Circle } from 'react-native-svg';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { Language } from '../../types'; import { Language } from '../../types';
const getCopy = (language: Language) => { const getCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
return { return {
status: 'Dein Pflegeplan wird personalisiert…', status: 'Dein Pflegeplan wird personalisiert…',
steps: ['Antworten werden analysiert', 'Pflegeplan wird erstellt', 'Scan-Credits werden vorbereitet', 'Plan wird finalisiert'], steps: ['Antworten werden analysiert', 'Pflegeplan wird erstellt', 'Scan-Credits werden vorbereitet', 'Plan wird finalisiert'],
testimonial: '„GreenLens hat meine Geigenfeige gerettet. Die täglichen Routinen sind unglaublich präzise."', testimonial: '„GreenLens hat meine Geigenfeige gerettet. Die täglichen Routinen sind unglaublich präzise."',
author: 'Elena R.', author: 'Elena R.',
rating: '4,8 APP-STORE-BEWERTUNG', rating: '4,8 APP-STORE-BEWERTUNG',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
status: 'Personalizando tu plan de cuidados…', status: 'Personalizando tu plan de cuidados…',
steps: ['Analizando tus respuestas', 'Creando tu plan de cuidados', 'Preparando tus créditos de escaneo', 'Finalizando tu plan'], steps: ['Analizando tus respuestas', 'Creando tu plan de cuidados', 'Preparando tus créditos de escaneo', 'Finalizando tu plan'],
testimonial: '"GreenLens salvó mi ficus lyrata. Las rutinas diarias son increíblemente precisas."', testimonial: '"GreenLens salvó mi ficus lyrata. Las rutinas diarias son increíblemente precisas."',
author: 'Elena R.', author: 'Elena R.',
rating: '4.8 VALORACIÓN EN APP STORE', rating: '4.8 VALORACIÓN EN APP STORE',
}; };
} }
return { return {
status: 'Personalizing your care plan…', status: 'Personalizing your care plan…',
steps: ['Analyzing your answers', 'Building your care plan', 'Preparing your scan credits', 'Finalizing your plan'], steps: ['Analyzing your answers', 'Building your care plan', 'Preparing your scan credits', 'Finalizing your plan'],
testimonial: '"GreenLens completely saved my Fiddle Leaf Fig. The daily routines feel incredibly precise."', testimonial: '"GreenLens completely saved my Fiddle Leaf Fig. The daily routines feel incredibly precise."',
author: 'Elena R.', author: 'Elena R.',
rating: '4.8 APP STORE RATING', rating: '4.8 APP STORE RATING',
}; };
}; };
const STEP_THRESHOLDS = [25, 50, 75, 95]; const STEP_THRESHOLDS = [25, 50, 75, 95];
const RING_SIZE = 150; const RING_SIZE = 150;
const RING_STROKE_WIDTH = 7; const RING_STROKE_WIDTH = 7;
const RING_RADIUS = (RING_SIZE - RING_STROKE_WIDTH) / 2; const RING_RADIUS = (RING_SIZE - RING_STROKE_WIDTH) / 2;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
const AnimatedCircle = Animated.createAnimatedComponent(Circle); const AnimatedCircle = Animated.createAnimatedComponent(Circle);
export default function OnboardingPersonalizingScreen() { export default function OnboardingPersonalizingScreen() {
const { language, isDarkMode, colorPalette } = useApp(); const { language, isDarkMode, colorPalette } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const copy = getCopy(language); const copy = getCopy(language);
const progress = useRef(new Animated.Value(0)).current; const progress = useRef(new Animated.Value(0)).current;
const [percent, setPercent] = useState(0); const [percent, setPercent] = useState(0);
const navigated = useRef(false); const navigated = useRef(false);
const strokeDashoffset = progress.interpolate({ const strokeDashoffset = progress.interpolate({
inputRange: [0, 100], inputRange: [0, 100],
outputRange: [RING_CIRCUMFERENCE, 0], outputRange: [RING_CIRCUMFERENCE, 0],
}); });
useEffect(() => { useEffect(() => {
posthog.capture('onboarding_personalizing_viewed'); posthog.capture('onboarding_personalizing_viewed');
const listener = progress.addListener(({ value }) => setPercent(Math.round(value))); const listener = progress.addListener(({ value }) => setPercent(Math.round(value)));
Animated.timing(progress, { Animated.timing(progress, {
toValue: 100, toValue: 100,
duration: 6000, duration: 6000,
easing: Easing.inOut(Easing.cubic), easing: Easing.inOut(Easing.cubic),
useNativeDriver: false, useNativeDriver: false,
}).start(({ finished }) => { }).start(({ finished }) => {
if (finished && !navigated.current) { if (finished && !navigated.current) {
navigated.current = true; navigated.current = true;
setTimeout(() => { setTimeout(() => {
posthog.capture('paywall_opened', { source: 'onboarding' }); posthog.capture('paywall_opened', { source: 'onboarding' });
router.replace('/profile/billing?view=paywall&context=onboarding'); router.replace('/profile/billing?view=paywall&context=onboarding');
}, 450); }, 450);
} }
}); });
return () => progress.removeListener(listener); return () => progress.removeListener(listener);
}, [progress, posthog]); }, [progress, posthog]);
return ( return (
<SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}> <SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}>
<Text style={[styles.percent, { color: colors.primary }]}>{percent}%</Text> <Text style={[styles.percent, { color: colors.primary }]}>{percent}%</Text>
<View style={styles.ringWrap}> <View style={styles.ringWrap}>
<Svg width={RING_SIZE} height={RING_SIZE} style={StyleSheet.absoluteFill}> <Svg width={RING_SIZE} height={RING_SIZE} style={StyleSheet.absoluteFill}>
<Circle <Circle
cx={RING_SIZE / 2} cx={RING_SIZE / 2}
cy={RING_SIZE / 2} cy={RING_SIZE / 2}
r={RING_RADIUS} r={RING_RADIUS}
stroke={colors.primarySoft} stroke={colors.primarySoft}
strokeWidth={RING_STROKE_WIDTH} strokeWidth={RING_STROKE_WIDTH}
fill="none" fill="none"
/> />
<AnimatedCircle <AnimatedCircle
cx={RING_SIZE / 2} cx={RING_SIZE / 2}
cy={RING_SIZE / 2} cy={RING_SIZE / 2}
r={RING_RADIUS} r={RING_RADIUS}
stroke={colors.primary} stroke={colors.primary}
strokeWidth={RING_STROKE_WIDTH} strokeWidth={RING_STROKE_WIDTH}
fill="none" fill="none"
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={`${RING_CIRCUMFERENCE}, ${RING_CIRCUMFERENCE}`} strokeDasharray={`${RING_CIRCUMFERENCE}, ${RING_CIRCUMFERENCE}`}
strokeDashoffset={strokeDashoffset} strokeDashoffset={strokeDashoffset}
rotation="-90" rotation="-90"
originX={RING_SIZE / 2} originX={RING_SIZE / 2}
originY={RING_SIZE / 2} originY={RING_SIZE / 2}
/> />
</Svg> </Svg>
<Image source={require('../../assets/paywall_scan_background.png')} style={styles.ringImage} /> <Image source={require('../../assets/paywall_scan_background.png')} style={styles.ringImage} />
</View> </View>
<View style={[styles.statusPill, { backgroundColor: colors.surfaceMuted }]}> <View style={[styles.statusPill, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="sync-outline" size={15} color={colors.textSecondary} /> <Ionicons name="sync-outline" size={15} color={colors.textSecondary} />
<Text style={[styles.statusText, { color: colors.textSecondary }]}>{copy.status}</Text> <Text style={[styles.statusText, { color: colors.textSecondary }]}>{copy.status}</Text>
</View> </View>
<View style={styles.checklist}> <View style={styles.checklist}>
{copy.steps.map((label, index) => { {copy.steps.map((label, index) => {
const done = percent >= STEP_THRESHOLDS[index]; const done = percent >= STEP_THRESHOLDS[index];
return ( return (
<View key={label} style={styles.checkRow}> <View key={label} style={styles.checkRow}>
<Ionicons <Ionicons
name={done ? 'checkmark-circle' : 'ellipse-outline'} name={done ? 'checkmark-circle' : 'ellipse-outline'}
size={24} size={24}
color={done ? colors.primary : colors.border} color={done ? colors.primary : colors.border}
/> />
<Text style={[styles.checkLabel, { color: done ? colors.text : colors.textMuted }]}>{label}</Text> <Text style={[styles.checkLabel, { color: done ? colors.text : colors.textMuted }]}>{label}</Text>
</View> </View>
); );
})} })}
</View> </View>
<View style={[styles.testimonialCard, { backgroundColor: colors.surface }]}> <View style={[styles.testimonialCard, { backgroundColor: colors.surface }]}>
<View style={styles.testimonialHeader}> <View style={styles.testimonialHeader}>
<Text style={[styles.testimonialAuthor, { color: colors.text }]}>{copy.author}</Text> <Text style={[styles.testimonialAuthor, { color: colors.text }]}>{copy.author}</Text>
<View style={styles.starsRow}> <View style={styles.starsRow}>
{[0, 1, 2, 3, 4].map((i) => <Ionicons key={i} name="star" size={13} color="#f5c04e" />)} {[0, 1, 2, 3, 4].map((i) => <Ionicons key={i} name="star" size={13} color="#f5c04e" />)}
</View> </View>
</View> </View>
<Text style={[styles.testimonialText, { color: colors.textSecondary }]}>{copy.testimonial}</Text> <Text style={[styles.testimonialText, { color: colors.textSecondary }]}>{copy.testimonial}</Text>
</View> </View>
<View style={[styles.ratingBadge, { borderColor: colors.primary }]}> <View style={[styles.ratingBadge, { borderColor: colors.primary }]}>
<Ionicons name="ribbon-outline" size={16} color={colors.primary} /> <Ionicons name="ribbon-outline" size={16} color={colors.primary} />
<Text style={[styles.ratingText, { color: colors.primary }]}>{copy.rating}</Text> <Text style={[styles.ratingText, { color: colors.primary }]}>{copy.rating}</Text>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30 }, safe: { flex: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30 },
percent: { fontSize: 56, fontWeight: '900', marginBottom: 16 }, percent: { fontSize: 56, fontWeight: '900', marginBottom: 16 },
ringWrap: { width: 150, height: 150, alignItems: 'center', justifyContent: 'center', marginBottom: 22 }, ringWrap: { width: 150, height: 150, alignItems: 'center', justifyContent: 'center', marginBottom: 22 },
ringImage: { width: 112, height: 112, borderRadius: 56 }, ringImage: { width: 112, height: 112, borderRadius: 56 },
statusPill: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, paddingHorizontal: 18, paddingVertical: 11, marginBottom: 26 }, statusPill: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, paddingHorizontal: 18, paddingVertical: 11, marginBottom: 26 },
statusText: { fontSize: 14.5, fontWeight: '800' }, statusText: { fontSize: 14.5, fontWeight: '800' },
checklist: { alignSelf: 'stretch', gap: 15, marginBottom: 26, paddingHorizontal: 8 }, checklist: { alignSelf: 'stretch', gap: 15, marginBottom: 26, paddingHorizontal: 8 },
checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
checkLabel: { fontSize: 16.5, fontWeight: '700' }, checkLabel: { fontSize: 16.5, fontWeight: '700' },
testimonialCard: { alignSelf: 'stretch', borderRadius: 18, padding: 16, marginBottom: 14 }, testimonialCard: { alignSelf: 'stretch', borderRadius: 18, padding: 16, marginBottom: 14 },
testimonialHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }, testimonialHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
testimonialAuthor: { fontSize: 14.5, fontWeight: '800' }, testimonialAuthor: { fontSize: 14.5, fontWeight: '800' },
starsRow: { flexDirection: 'row', gap: 2 }, starsRow: { flexDirection: 'row', gap: 2 },
testimonialText: { fontSize: 14, lineHeight: 20, fontStyle: 'italic' }, testimonialText: { fontSize: 14, lineHeight: 20, fontStyle: 'italic' },
ratingBadge: { flexDirection: 'row', alignItems: 'center', gap: 7, borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 9 }, ratingBadge: { flexDirection: 'row', alignItems: 'center', gap: 7, borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 9 },
ratingText: { fontSize: 12.5, fontWeight: '900', letterSpacing: 0.6 }, ratingText: { fontSize: 12.5, fontWeight: '900', letterSpacing: 0.6 },
}); });

View File

@@ -1,495 +1,495 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { Language } from '../../types'; import { Language } from '../../types';
type ColorsType = ReturnType<typeof useColors>; type ColorsType = ReturnType<typeof useColors>;
const getSlidesCopy = (language: Language) => { const getSlidesCopy = (language: Language) => {
if (language === 'de') { if (language === 'de') {
return { return {
slides: [ slides: [
{ {
title: 'Scanne jede Pflanze', title: 'Scanne jede Pflanze',
body: 'Richte die Kamera auf eine Pflanze und GreenLens erkennt sie in Sekunden.', body: 'Richte die Kamera auf eine Pflanze und GreenLens erkennt sie in Sekunden.',
}, },
{ {
title: 'Health Check & Pflegeplan', title: 'Health Check & Pflegeplan',
body: 'GreenLens erkennt Probleme früh und erstellt deinen Rettungsplan.', body: 'GreenLens erkennt Probleme früh und erstellt deinen Rettungsplan.',
}, },
{ {
title: 'Nie mehr Gießen vergessen', title: 'Nie mehr Gießen vergessen',
body: 'Smarte Erinnerungen und deine Pflanzen-Bibliothek halten alles im Blick.', body: 'Smarte Erinnerungen und deine Pflanzen-Bibliothek halten alles im Blick.',
}, },
], ],
resultChip: 'Monstera · 98%', resultChip: 'Monstera · 98%',
healthCheckLabel: 'Health Check', healthCheckLabel: 'Health Check',
overwateringDetected: 'Überwässerung erkannt', overwateringDetected: 'Überwässerung erkannt',
rescuePlanReady: '7-Tage-Rettungsplan bereit', rescuePlanReady: '7-Tage-Rettungsplan bereit',
waterReminder: 'Monstera gießen — heute', waterReminder: 'Monstera gießen — heute',
fertilizeReminder: 'Basilikum düngen — in 3 Tagen', fertilizeReminder: 'Basilikum düngen — in 3 Tagen',
continueLabel: 'Weiter', continueLabel: 'Weiter',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
slides: [ slides: [
{ {
title: 'Escanea cualquier planta', title: 'Escanea cualquier planta',
body: 'Apunta la cámara a una planta y GreenLens la identifica en segundos.', body: 'Apunta la cámara a una planta y GreenLens la identifica en segundos.',
}, },
{ {
title: 'Chequeo de salud y plan de cuidados', title: 'Chequeo de salud y plan de cuidados',
body: 'GreenLens detecta problemas a tiempo y crea tu plan de rescate.', body: 'GreenLens detecta problemas a tiempo y crea tu plan de rescate.',
}, },
{ {
title: 'No olvides regar nunca más', title: 'No olvides regar nunca más',
body: 'Recordatorios inteligentes y tu biblioteca de plantas lo mantienen todo al día.', body: 'Recordatorios inteligentes y tu biblioteca de plantas lo mantienen todo al día.',
}, },
], ],
resultChip: 'Monstera · 98%', resultChip: 'Monstera · 98%',
healthCheckLabel: 'Chequeo de salud', healthCheckLabel: 'Chequeo de salud',
overwateringDetected: 'Exceso de riego detectado', overwateringDetected: 'Exceso de riego detectado',
rescuePlanReady: 'Plan de rescate de 7 días listo', rescuePlanReady: 'Plan de rescate de 7 días listo',
waterReminder: 'Regar Monstera — hoy', waterReminder: 'Regar Monstera — hoy',
fertilizeReminder: 'Abonar albahaca — en 3 días', fertilizeReminder: 'Abonar albahaca — en 3 días',
continueLabel: 'Continuar', continueLabel: 'Continuar',
}; };
} }
return { return {
slides: [ slides: [
{ {
title: 'Scan Any Plant', title: 'Scan Any Plant',
body: 'Point your camera at a plant and GreenLens identifies it in seconds.', body: 'Point your camera at a plant and GreenLens identifies it in seconds.',
}, },
{ {
title: 'Health Check & Care Plan', title: 'Health Check & Care Plan',
body: 'GreenLens spots problems early and builds a rescue plan for you.', body: 'GreenLens spots problems early and builds a rescue plan for you.',
}, },
{ {
title: 'Never Forget Watering', title: 'Never Forget Watering',
body: 'Smart reminders and your personal plant library keep everything on track.', body: 'Smart reminders and your personal plant library keep everything on track.',
}, },
], ],
resultChip: 'Monstera · 98%', resultChip: 'Monstera · 98%',
healthCheckLabel: 'Health Check', healthCheckLabel: 'Health Check',
overwateringDetected: 'Overwatering detected', overwateringDetected: 'Overwatering detected',
rescuePlanReady: '7-day rescue plan ready', rescuePlanReady: '7-day rescue plan ready',
waterReminder: 'Water Monstera — today', waterReminder: 'Water Monstera — today',
fertilizeReminder: 'Fertilize Basil — in 3 days', fertilizeReminder: 'Fertilize Basil — in 3 days',
continueLabel: 'Continue', continueLabel: 'Continue',
}; };
}; };
function ScanFrameOverlay({ resultChip, colors }: { resultChip: string; colors: ColorsType }) { function ScanFrameOverlay({ resultChip, colors }: { resultChip: string; colors: ColorsType }) {
return ( return (
<> <>
<View style={styles.scanFrameWrap} pointerEvents="none"> <View style={styles.scanFrameWrap} pointerEvents="none">
<View style={[styles.cornerTL, { borderColor: colors.primary }]} /> <View style={[styles.cornerTL, { borderColor: colors.primary }]} />
<View style={[styles.cornerTR, { borderColor: colors.primary }]} /> <View style={[styles.cornerTR, { borderColor: colors.primary }]} />
<View style={[styles.cornerBL, { borderColor: colors.primary }]} /> <View style={[styles.cornerBL, { borderColor: colors.primary }]} />
<View style={[styles.cornerBR, { borderColor: colors.primary }]} /> <View style={[styles.cornerBR, { borderColor: colors.primary }]} />
</View> </View>
<View style={styles.resultChip}> <View style={styles.resultChip}>
<Ionicons name="leaf" size={15} color={colors.primary} /> <Ionicons name="leaf" size={15} color={colors.primary} />
<Text style={styles.resultChipText}>{resultChip}</Text> <Text style={styles.resultChipText}>{resultChip}</Text>
</View> </View>
</> </>
); );
} }
function HealthCardOverlay({ function HealthCardOverlay({
label, label,
overwateringDetected, overwateringDetected,
rescuePlanReady, rescuePlanReady,
}: { }: {
label: string; label: string;
overwateringDetected: string; overwateringDetected: string;
rescuePlanReady: string; rescuePlanReady: string;
}) { }) {
return ( return (
<View style={styles.healthCard}> <View style={styles.healthCard}>
<View style={styles.healthCardHeader}> <View style={styles.healthCardHeader}>
<View style={styles.healthCardIcon}> <View style={styles.healthCardIcon}>
<Ionicons name="medkit" size={16} color="#C62828" /> <Ionicons name="medkit" size={16} color="#C62828" />
</View> </View>
<Text style={styles.healthCardTitle}>{label}</Text> <Text style={styles.healthCardTitle}>{label}</Text>
</View> </View>
<View style={[styles.healthRow, styles.healthRowWarning]}> <View style={[styles.healthRow, styles.healthRowWarning]}>
<Ionicons name="warning" size={15} color="#C62828" /> <Ionicons name="warning" size={15} color="#C62828" />
<Text style={styles.healthRowWarningText}>{overwateringDetected}</Text> <Text style={styles.healthRowWarningText}>{overwateringDetected}</Text>
</View> </View>
<View style={[styles.healthRow, styles.healthRowSuccess]}> <View style={[styles.healthRow, styles.healthRowSuccess]}>
<Ionicons name="checkmark-circle" size={15} color="#2e7d32" /> <Ionicons name="checkmark-circle" size={15} color="#2e7d32" />
<Text style={styles.healthRowSuccessText}>{rescuePlanReady}</Text> <Text style={styles.healthRowSuccessText}>{rescuePlanReady}</Text>
</View> </View>
</View> </View>
); );
} }
function ReminderChipsOverlay({ waterReminder, fertilizeReminder }: { waterReminder: string; fertilizeReminder: string }) { function ReminderChipsOverlay({ waterReminder, fertilizeReminder }: { waterReminder: string; fertilizeReminder: string }) {
const [waterLabel, waterMeta] = splitReminder(waterReminder); const [waterLabel, waterMeta] = splitReminder(waterReminder);
const [fertilizeLabel, fertilizeMeta] = splitReminder(fertilizeReminder); const [fertilizeLabel, fertilizeMeta] = splitReminder(fertilizeReminder);
return ( return (
<> <>
<View style={[styles.reminderChip, styles.reminderChipTop]}> <View style={[styles.reminderChip, styles.reminderChipTop]}>
<View style={[styles.reminderIcon, { backgroundColor: '#dff2e6' }]}> <View style={[styles.reminderIcon, { backgroundColor: '#dff2e6' }]}>
<Ionicons name="water" size={16} color="#2e7d32" /> <Ionicons name="water" size={16} color="#2e7d32" />
</View> </View>
<View> <View>
<Text style={styles.reminderLabel}>{waterLabel}</Text> <Text style={styles.reminderLabel}>{waterLabel}</Text>
<Text style={styles.reminderMeta}>{waterMeta}</Text> <Text style={styles.reminderMeta}>{waterMeta}</Text>
</View> </View>
</View> </View>
<View style={[styles.reminderChip, styles.reminderChipBottom]}> <View style={[styles.reminderChip, styles.reminderChipBottom]}>
<View style={[styles.reminderIcon, { backgroundColor: '#e3f3c8' }]}> <View style={[styles.reminderIcon, { backgroundColor: '#e3f3c8' }]}>
<Ionicons name="leaf" size={16} color="#558b2f" /> <Ionicons name="leaf" size={16} color="#558b2f" />
</View> </View>
<View> <View>
<Text style={styles.reminderLabel}>{fertilizeLabel}</Text> <Text style={styles.reminderLabel}>{fertilizeLabel}</Text>
<Text style={styles.reminderMeta}>{fertilizeMeta}</Text> <Text style={styles.reminderMeta}>{fertilizeMeta}</Text>
</View> </View>
</View> </View>
</> </>
); );
} }
// Splits "Water Monstera — today" into ["Water Monstera", "today"] for a two-line chip. // Splits "Water Monstera — today" into ["Water Monstera", "today"] for a two-line chip.
function splitReminder(text: string): [string, string] { function splitReminder(text: string): [string, string] {
const parts = text.split('—').map((part) => part.trim()); const parts = text.split('—').map((part) => part.trim());
if (parts.length === 2) return [parts[0], parts[1]]; if (parts.length === 2) return [parts[0], parts[1]];
return [text, '']; return [text, ''];
} }
export default function OnboardingSlidesScreen() { export default function OnboardingSlidesScreen() {
const router = useRouter(); const router = useRouter();
const { language, isDarkMode, colorPalette } = useApp(); const { language, isDarkMode, colorPalette } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const copy = getSlidesCopy(language); const copy = getSlidesCopy(language);
const slide = copy.slides[page]; const slide = copy.slides[page];
useEffect(() => { useEffect(() => {
posthog.capture('onboarding_slide_viewed', { index: page }); posthog.capture('onboarding_slide_viewed', { index: page });
}, [page, posthog]); }, [page, posthog]);
const next = () => { const next = () => {
if (page < copy.slides.length - 1) { if (page < copy.slides.length - 1) {
setPage(page + 1); setPage(page + 1);
} else { } else {
router.replace('/onboarding/source'); router.replace('/onboarding/source');
} }
}; };
const back = () => { const back = () => {
if (page > 0) { if (page > 0) {
setPage(page - 1); setPage(page - 1);
} else { } else {
router.back(); router.back();
} }
}; };
return ( return (
<View style={[styles.container, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}> <View style={[styles.container, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}>
<View style={styles.imageArea}> <View style={styles.imageArea}>
<Image <Image
source={ source={
page === 0 page === 0
? require('../../assets/paywall_scan_background.png') ? require('../../assets/paywall_scan_background.png')
: page === 1 : page === 1
? require('../../assets/onboarding_health_scan_mockup.png') ? require('../../assets/onboarding_health_scan_mockup.png')
: require('../../assets/welcome_botanical_header.png') : require('../../assets/welcome_botanical_header.png')
} }
style={styles.image} style={styles.image}
resizeMode="cover" resizeMode="cover"
/> />
<SafeAreaView style={styles.imageSafeArea} edges={['top']}> <SafeAreaView style={styles.imageSafeArea} edges={['top']}>
<TouchableOpacity onPress={back} style={styles.backBtn} activeOpacity={0.85}> <TouchableOpacity onPress={back} style={styles.backBtn} activeOpacity={0.85}>
<Ionicons name="arrow-back" size={20} color="#1f2520" /> <Ionicons name="arrow-back" size={20} color="#1f2520" />
</TouchableOpacity> </TouchableOpacity>
</SafeAreaView> </SafeAreaView>
{page === 0 && <ScanFrameOverlay resultChip={copy.resultChip} colors={colors} />} {page === 0 && <ScanFrameOverlay resultChip={copy.resultChip} colors={colors} />}
{page === 1 && ( {page === 1 && (
<HealthCardOverlay <HealthCardOverlay
label={copy.healthCheckLabel} label={copy.healthCheckLabel}
overwateringDetected={copy.overwateringDetected} overwateringDetected={copy.overwateringDetected}
rescuePlanReady={copy.rescuePlanReady} rescuePlanReady={copy.rescuePlanReady}
/> />
)} )}
{page === 2 && ( {page === 2 && (
<ReminderChipsOverlay waterReminder={copy.waterReminder} fertilizeReminder={copy.fertilizeReminder} /> <ReminderChipsOverlay waterReminder={copy.waterReminder} fertilizeReminder={copy.fertilizeReminder} />
)} )}
</View> </View>
<View style={[styles.sheet, { backgroundColor: colors.surface }]}> <View style={[styles.sheet, { backgroundColor: colors.surface }]}>
<Text style={[styles.title, { color: colors.text }]}>{slide.title}</Text> <Text style={[styles.title, { color: colors.text }]}>{slide.title}</Text>
<Text style={[styles.body, { color: colors.textSecondary }]}>{slide.body}</Text> <Text style={[styles.body, { color: colors.textSecondary }]}>{slide.body}</Text>
<View style={styles.dots}> <View style={styles.dots}>
{copy.slides.map((_, index) => ( {copy.slides.map((_, index) => (
<View <View
key={index} key={index}
style={[ style={[
styles.dot, styles.dot,
index === page index === page
? [styles.dotActive, { backgroundColor: colors.primary }] ? [styles.dotActive, { backgroundColor: colors.primary }]
: { backgroundColor: colors.border }, : { backgroundColor: colors.border },
]} ]}
/> />
))} ))}
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.cta, { backgroundColor: colors.primary }]} style={[styles.cta, { backgroundColor: colors.primary }]}
onPress={next} onPress={next}
activeOpacity={0.86} activeOpacity={0.86}
> >
<Text style={[styles.ctaText, { color: colors.onPrimary }]}>{copy.continueLabel}</Text> <Text style={[styles.ctaText, { color: colors.onPrimary }]}>{copy.continueLabel}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
imageArea: { imageArea: {
height: '58%', height: '58%',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
}, },
image: { image: {
width: '100%', width: '100%',
height: '100%', height: '100%',
position: 'absolute', position: 'absolute',
}, },
imageSafeArea: { imageSafeArea: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
}, },
backBtn: { backBtn: {
marginLeft: 16, marginLeft: 16,
marginTop: 8, marginTop: 8,
width: 38, width: 38,
height: 38, height: 38,
borderRadius: 19, borderRadius: 19,
backgroundColor: 'rgba(255,255,255,0.85)', backgroundColor: 'rgba(255,255,255,0.85)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
// Scan frame overlay (slide 1) // Scan frame overlay (slide 1)
scanFrameWrap: { scanFrameWrap: {
position: 'absolute', position: 'absolute',
top: '22%', top: '22%',
left: '20%', left: '20%',
right: '20%', right: '20%',
bottom: '26%', bottom: '26%',
}, },
cornerTL: { cornerTL: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: 30, width: 30,
height: 30, height: 30,
borderTopWidth: 4, borderTopWidth: 4,
borderLeftWidth: 4, borderLeftWidth: 4,
borderTopLeftRadius: 8, borderTopLeftRadius: 8,
}, },
cornerTR: { cornerTR: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
right: 0, right: 0,
width: 30, width: 30,
height: 30, height: 30,
borderTopWidth: 4, borderTopWidth: 4,
borderRightWidth: 4, borderRightWidth: 4,
borderTopRightRadius: 8, borderTopRightRadius: 8,
}, },
cornerBL: { cornerBL: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
left: 0, left: 0,
width: 30, width: 30,
height: 30, height: 30,
borderBottomWidth: 4, borderBottomWidth: 4,
borderLeftWidth: 4, borderLeftWidth: 4,
borderBottomLeftRadius: 8, borderBottomLeftRadius: 8,
}, },
cornerBR: { cornerBR: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,
right: 0, right: 0,
width: 30, width: 30,
height: 30, height: 30,
borderBottomWidth: 4, borderBottomWidth: 4,
borderRightWidth: 4, borderRightWidth: 4,
borderBottomRightRadius: 8, borderBottomRightRadius: 8,
}, },
resultChip: { resultChip: {
position: 'absolute', position: 'absolute',
bottom: '10%', bottom: '10%',
left: 20, left: 20,
right: 20, right: 20,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
backgroundColor: 'rgba(255,255,255,0.94)', backgroundColor: 'rgba(255,255,255,0.94)',
borderRadius: 999, borderRadius: 999,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
}, },
resultChipText: { resultChipText: {
fontSize: 15, fontSize: 15,
fontWeight: '700', fontWeight: '700',
color: '#1f2520', color: '#1f2520',
}, },
// Health card overlay (slide 2) // Health card overlay (slide 2)
healthCard: { healthCard: {
position: 'absolute', position: 'absolute',
bottom: 16, bottom: 16,
left: 16, left: 16,
right: 16, right: 16,
backgroundColor: 'rgba(255,255,255,0.96)', backgroundColor: 'rgba(255,255,255,0.96)',
borderRadius: 18, borderRadius: 18,
padding: 14, padding: 14,
gap: 8, gap: 8,
}, },
healthCardHeader: { healthCardHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
marginBottom: 2, marginBottom: 2,
}, },
healthCardIcon: { healthCardIcon: {
width: 28, width: 28,
height: 28, height: 28,
borderRadius: 14, borderRadius: 14,
backgroundColor: '#fdeaea', backgroundColor: '#fdeaea',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
healthCardTitle: { healthCardTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '800', fontWeight: '800',
color: '#1f2520', color: '#1f2520',
}, },
healthRow: { healthRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
borderRadius: 10, borderRadius: 10,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 8, paddingVertical: 8,
}, },
healthRowWarning: { healthRowWarning: {
backgroundColor: '#fdeaea', backgroundColor: '#fdeaea',
}, },
healthRowWarningText: { healthRowWarningText: {
fontSize: 13.5, fontSize: 13.5,
fontWeight: '700', fontWeight: '700',
color: '#C62828', color: '#C62828',
}, },
healthRowSuccess: { healthRowSuccess: {
backgroundColor: '#e8f3e3', backgroundColor: '#e8f3e3',
}, },
healthRowSuccessText: { healthRowSuccessText: {
fontSize: 13.5, fontSize: 13.5,
fontWeight: '700', fontWeight: '700',
color: '#2e7d32', color: '#2e7d32',
}, },
// Reminder chips overlay (slide 3) // Reminder chips overlay (slide 3)
reminderChip: { reminderChip: {
position: 'absolute', position: 'absolute',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
backgroundColor: 'rgba(255,255,255,0.96)', backgroundColor: 'rgba(255,255,255,0.96)',
borderRadius: 999, borderRadius: 999,
paddingVertical: 8, paddingVertical: 8,
paddingRight: 18, paddingRight: 18,
paddingLeft: 8, paddingLeft: 8,
}, },
reminderChipTop: { reminderChipTop: {
top: '24%', top: '24%',
right: 20, right: 20,
}, },
reminderChipBottom: { reminderChipBottom: {
top: '42%', top: '42%',
left: 20, left: 20,
}, },
reminderIcon: { reminderIcon: {
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 16, borderRadius: 16,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
reminderLabel: { reminderLabel: {
fontSize: 14, fontSize: 14,
fontWeight: '800', fontWeight: '800',
color: '#1f2520', color: '#1f2520',
}, },
reminderMeta: { reminderMeta: {
fontSize: 12, fontSize: 12,
fontWeight: '600', fontWeight: '600',
color: '#5a8a3d', color: '#5a8a3d',
}, },
// Bottom sheet // Bottom sheet
sheet: { sheet: {
flex: 1, flex: 1,
borderTopLeftRadius: 28, borderTopLeftRadius: 28,
borderTopRightRadius: 28, borderTopRightRadius: 28,
marginTop: -24, marginTop: -24,
paddingHorizontal: 24, paddingHorizontal: 24,
paddingTop: 32, paddingTop: 32,
alignItems: 'center', alignItems: 'center',
}, },
title: { title: {
fontSize: 30, fontSize: 30,
fontWeight: '900', fontWeight: '900',
textAlign: 'center', textAlign: 'center',
marginBottom: 10, marginBottom: 10,
}, },
body: { body: {
fontSize: 15.5, fontSize: 15.5,
lineHeight: 22, lineHeight: 22,
textAlign: 'center', textAlign: 'center',
maxWidth: 320, maxWidth: 320,
marginBottom: 20, marginBottom: 20,
}, },
dots: { dots: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
marginBottom: 'auto', marginBottom: 'auto',
}, },
dot: { dot: {
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
}, },
dotActive: { dotActive: {
width: 26, width: 26,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
}, },
cta: { cta: {
alignSelf: 'stretch', alignSelf: 'stretch',
height: 58, height: 58,
borderRadius: 14, borderRadius: 14,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 24, marginBottom: 24,
}, },
ctaText: { ctaText: {
fontSize: 17, fontSize: 17,
fontWeight: '800', fontWeight: '800',
}, },
}); });

View File

@@ -1,74 +1,74 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAnalytics } from '../../services/analytics'; import { useSafeAnalytics } from '../../services/analytics';
import { useColors } from '../../constants/Colors'; import { useColors } from '../../constants/Colors';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { OnboardingProgressService } from '../../services/onboardingProgressService'; import { OnboardingProgressService } from '../../services/onboardingProgressService';
import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService'; import { PreAuthOnboardingService } from '../../services/preAuthOnboardingService';
import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion'; import { OnboardingQuestion, QuestionOption } from '../../components/OnboardingQuestion';
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = [
{ id: 'app_store', emoji: '🏬', signal: 'organic_store' }, { id: 'app_store', emoji: '🏬', signal: 'organic_store' },
{ id: 'instagram', emoji: '📸', signal: 'social_visual' }, { id: 'instagram', emoji: '📸', signal: 'social_visual' },
{ id: 'tiktok', emoji: '🎵', signal: 'social_video' }, { id: 'tiktok', emoji: '🎵', signal: 'social_video' },
{ id: 'friend', emoji: '👥', signal: 'referral' }, { id: 'friend', emoji: '👥', signal: 'referral' },
{ id: 'search', emoji: '🔎', signal: 'high_intent_search' }, { id: 'search', emoji: '🔎', signal: 'high_intent_search' },
{ id: 'other', emoji: '✨', signal: 'unclassified' }, { id: 'other', emoji: '✨', signal: 'unclassified' },
]; ];
export default function OnboardingSourceScreen() { export default function OnboardingSourceScreen() {
const router = useRouter(); const router = useRouter();
const posthog = useSafeAnalytics(); const posthog = useSafeAnalytics();
const { session, isDarkMode, colorPalette, t } = useApp(); const { session, isDarkMode, colorPalette, t } = useApp();
const colors = useColors(isDarkMode, colorPalette); const colors = useColors(isDarkMode, colorPalette);
const [selectedSource, setSelectedSource] = useState<string | null>(null); const [selectedSource, setSelectedSource] = useState<string | null>(null);
const sourceLabels: Record<string, string> = { const sourceLabels: Record<string, string> = {
app_store: t.sourceOptionAppStore, app_store: t.sourceOptionAppStore,
instagram: t.sourceOptionInstagram, instagram: t.sourceOptionInstagram,
tiktok: t.sourceOptionTikTok, tiktok: t.sourceOptionTikTok,
friend: t.sourceOptionFriend, friend: t.sourceOptionFriend,
search: t.sourceOptionSearch, search: t.sourceOptionSearch,
other: t.sourceOptionOther, other: t.sourceOptionOther,
}; };
const options: QuestionOption[] = SOURCE_OPTIONS.map((option) => ({ const options: QuestionOption[] = SOURCE_OPTIONS.map((option) => ({
id: option.id, id: option.id,
emoji: option.emoji, emoji: option.emoji,
label: sourceLabels[option.id], label: sourceLabels[option.id],
})); }));
const finish = (source: string | null) => { const finish = (source: string | null) => {
if (session?.userId && source) { if (session?.userId && source) {
OnboardingProgressService.setAcquisitionSource(session.userId, source); OnboardingProgressService.setAcquisitionSource(session.userId, source);
} }
if (source) { if (source) {
void PreAuthOnboardingService.setAnswer('acquisitionSource', source); void PreAuthOnboardingService.setAnswer('acquisitionSource', source);
} }
posthog.capture('onboarding_source_completed', { posthog.capture('onboarding_source_completed', {
source: source ?? 'skipped', source: source ?? 'skipped',
revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped', revops_signal: SOURCE_OPTIONS.find((option) => option.id === source)?.signal ?? 'skipped',
}); });
router.replace('/onboarding/goal'); router.replace('/onboarding/goal');
}; };
return ( return (
<OnboardingQuestion <OnboardingQuestion
colors={colors} colors={colors}
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
step={1} step={1}
totalSteps={4} totalSteps={4}
title={t.sourceOnboardingTitle} title={t.sourceOnboardingTitle}
subtitle={t.sourceOnboardingSubtitle} subtitle={t.sourceOnboardingSubtitle}
options={options} options={options}
selectedId={selectedSource} selectedId={selectedSource}
onSelect={setSelectedSource} onSelect={setSelectedSource}
onContinue={() => finish(selectedSource)} onContinue={() => finish(selectedSource)}
onBack={() => router.back()} onBack={() => router.back()}
continueLabel={t.sourceOnboardingContinue} continueLabel={t.sourceOnboardingContinue}
skipLabel={t.sourceOnboardingSkip} skipLabel={t.sourceOnboardingSkip}
onSkip={() => finish(null)} onSkip={() => finish(null)}
/> />
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,107 +1,107 @@
import React from 'react'; import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../constants/Colors'; import { useColors } from '../constants/Colors';
type ColorsType = ReturnType<typeof useColors>; type ColorsType = ReturnType<typeof useColors>;
export type QuestionOption = { id: string; emoji: string; label: string; subtitle?: string }; export type QuestionOption = { id: string; emoji: string; label: string; subtitle?: string };
type Props = { type Props = {
colors: ColorsType; colors: ColorsType;
isDarkMode: boolean; isDarkMode: boolean;
step: number; // 1-based step: number; // 1-based
totalSteps: number; totalSteps: number;
title: string; title: string;
subtitle: string; subtitle: string;
options: QuestionOption[]; options: QuestionOption[];
selectedId: string | null; selectedId: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onContinue: () => void; onContinue: () => void;
onBack?: () => void; onBack?: () => void;
continueLabel: string; continueLabel: string;
skipLabel?: string; skipLabel?: string;
onSkip?: () => void; onSkip?: () => void;
}; };
export function OnboardingQuestion({ export function OnboardingQuestion({
colors, isDarkMode, step, totalSteps, title, subtitle, options, colors, isDarkMode, step, totalSteps, title, subtitle, options,
selectedId, onSelect, onContinue, onBack, continueLabel, skipLabel, onSkip, selectedId, onSelect, onContinue, onBack, continueLabel, skipLabel, onSkip,
}: Props) { }: Props) {
return ( return (
<SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]} edges={['top', 'left', 'right', 'bottom']}> <SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.topBar}> <View style={styles.topBar}>
{onBack ? ( {onBack ? (
<TouchableOpacity onPress={onBack} style={[styles.backBtn, { backgroundColor: colors.surface }]}> <TouchableOpacity onPress={onBack} style={[styles.backBtn, { backgroundColor: colors.surface }]}>
<Ionicons name="arrow-back" size={20} color={colors.primary} /> <Ionicons name="arrow-back" size={20} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
) : <View style={styles.backBtn} />} ) : <View style={styles.backBtn} />}
<View style={[styles.progressTrack, { backgroundColor: colors.primarySoft }]}> <View style={[styles.progressTrack, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.progressFill, { backgroundColor: colors.primary, width: `${Math.round((step / totalSteps) * 100)}%` }]} /> <View style={[styles.progressFill, { backgroundColor: colors.primary, width: `${Math.round((step / totalSteps) * 100)}%` }]} />
</View> </View>
<View style={styles.backBtn} /> <View style={styles.backBtn} />
</View> </View>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text> <Text style={[styles.title, { color: colors.text }]}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{subtitle}</Text> <Text style={[styles.subtitle, { color: colors.textSecondary }]}>{subtitle}</Text>
<View style={styles.options}> <View style={styles.options}>
{options.map((option) => { {options.map((option) => {
const active = selectedId === option.id; const active = selectedId === option.id;
return ( return (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
onPress={() => onSelect(option.id)} onPress={() => onSelect(option.id)}
activeOpacity={0.85} activeOpacity={0.85}
style={[styles.card, { style={[styles.card, {
backgroundColor: active ? colors.primarySoft : colors.surface, backgroundColor: active ? colors.primarySoft : colors.surface,
borderColor: active ? colors.primary : 'transparent', borderColor: active ? colors.primary : 'transparent',
}]} }]}
> >
<Text style={styles.emoji}>{option.emoji}</Text> <Text style={styles.emoji}>{option.emoji}</Text>
<View style={styles.cardCopy}> <View style={styles.cardCopy}>
<Text style={[styles.cardLabel, { color: active ? colors.primary : colors.text }]}>{option.label}</Text> <Text style={[styles.cardLabel, { color: active ? colors.primary : colors.text }]}>{option.label}</Text>
{option.subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textMuted }]}>{option.subtitle}</Text> : null} {option.subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textMuted }]}>{option.subtitle}</Text> : null}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
})} })}
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
{skipLabel && onSkip ? ( {skipLabel && onSkip ? (
<TouchableOpacity onPress={onSkip} style={styles.skipBtn}> <TouchableOpacity onPress={onSkip} style={styles.skipBtn}>
<Text style={[styles.skipText, { color: colors.textMuted }]}>{skipLabel}</Text> <Text style={[styles.skipText, { color: colors.textMuted }]}>{skipLabel}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
<TouchableOpacity <TouchableOpacity
onPress={onContinue} onPress={onContinue}
disabled={!selectedId} disabled={!selectedId}
activeOpacity={0.86} activeOpacity={0.86}
style={[styles.cta, { backgroundColor: selectedId ? colors.primary : colors.surfaceMuted }]} style={[styles.cta, { backgroundColor: selectedId ? colors.primary : colors.surfaceMuted }]}
> >
<Text style={[styles.ctaText, { color: selectedId ? colors.onPrimary : colors.textMuted }]}>{continueLabel}</Text> <Text style={[styles.ctaText, { color: selectedId ? colors.onPrimary : colors.textMuted }]}>{continueLabel}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
safe: { flex: 1, paddingHorizontal: 22 }, safe: { flex: 1, paddingHorizontal: 22 },
topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 },
backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' },
progressFill: { height: 6, borderRadius: 3 }, progressFill: { height: 6, borderRadius: 3 },
title: { fontSize: 30, lineHeight: 35, fontWeight: '900', textAlign: 'center', marginTop: 18, marginBottom: 8 }, title: { fontSize: 30, lineHeight: 35, fontWeight: '900', textAlign: 'center', marginTop: 18, marginBottom: 8 },
subtitle: { fontSize: 15, lineHeight: 20, textAlign: 'center', marginBottom: 22 }, subtitle: { fontSize: 15, lineHeight: 20, textAlign: 'center', marginBottom: 22 },
options: { gap: 12, flex: 1 }, options: { gap: 12, flex: 1 },
card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 16, borderWidth: 2, paddingHorizontal: 16, paddingVertical: 18 }, card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 16, borderWidth: 2, paddingHorizontal: 16, paddingVertical: 18 },
emoji: { fontSize: 26 }, emoji: { fontSize: 26 },
cardCopy: { flex: 1, gap: 2 }, cardCopy: { flex: 1, gap: 2 },
cardLabel: { fontSize: 17, fontWeight: '800' }, cardLabel: { fontSize: 17, fontWeight: '800' },
cardSubtitle: { fontSize: 12.5, lineHeight: 16 }, cardSubtitle: { fontSize: 12.5, lineHeight: 16 },
footer: { gap: 8, paddingBottom: 6 }, footer: { gap: 8, paddingBottom: 6 },
skipBtn: { alignItems: 'center', paddingVertical: 6 }, skipBtn: { alignItems: 'center', paddingVertical: 6 },
skipText: { fontSize: 14, fontWeight: '700' }, skipText: { fontSize: 14, fontWeight: '700' },
cta: { height: 56, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, cta: { height: 56, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
ctaText: { fontSize: 17, fontWeight: '800' }, ctaText: { fontSize: 17, fontWeight: '800' },
}); });

View File

@@ -1,167 +1,167 @@
import React from 'react'; import React from 'react';
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Language } from '../types'; import { Language } from '../types';
import { useColors } from '../constants/Colors'; import { useColors } from '../constants/Colors';
type ColorsType = ReturnType<typeof useColors>; type ColorsType = ReturnType<typeof useColors>;
const getCopy = (language: Language, isPro: boolean) => { const getCopy = (language: Language, isPro: boolean) => {
if (language === 'de') { if (language === 'de') {
return { return {
title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht', title: isPro ? 'Deine Credits sind aufgebraucht' : 'Deine Gratis-Scans sind aufgebraucht',
body: (date: string) => (isPro body: (date: string) => (isPro
? `Deine Credits erneuern sich am ${date}. Kauf Credits nach, um weiterzuscannen.` ? `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.`), : `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`),
bodyNoDate: isPro bodyNoDate: isPro
? 'Kauf Credits nach, um weiterzuscannen.' ? 'Kauf Credits nach, um weiterzuscannen.'
: 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.', : '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',
best: 'BESTE WAHL', best: 'BESTE WAHL',
credits: 'Credits', credits: 'Credits',
}; };
} }
if (language === 'es') { if (language === 'es') {
return { return {
title: isPro ? 'Se acabaron tus créditos' : 'Se acabaron tus escaneos gratis', title: isPro ? 'Se acabaron tus créditos' : 'Se acabaron tus escaneos gratis',
body: (date: string) => (isPro body: (date: string) => (isPro
? `Tus créditos se renuevan el ${date}. Compra créditos para seguir escaneando.` ? `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.`), : `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`),
bodyNoDate: isPro bodyNoDate: isPro
? 'Compra créditos para seguir escaneando.' ? 'Compra créditos para seguir escaneando.'
: 'Pásate a Pro para escanear sin límites.', : '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',
best: 'MEJOR OPCIÓN', best: 'MEJOR OPCIÓN',
credits: 'créditos', credits: 'créditos',
}; };
} }
return { return {
title: isPro ? "You're out of credits" : "You're out of free scans", title: isPro ? "You're out of credits" : "You're out of free scans",
body: (date: string) => (isPro body: (date: string) => (isPro
? `Your credits renew on ${date}. Top up to keep scanning.` ? `Your credits renew on ${date}. Top up to keep scanning.`
: `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`), : `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`),
bodyNoDate: isPro bodyNoDate: isPro
? 'Top up to keep scanning.' ? 'Top up to keep scanning.'
: 'Upgrade to Pro for unlimited 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',
best: 'BEST', best: 'BEST',
credits: 'credits', credits: 'credits',
}; };
}; };
const formatRenewalDate = (iso: string | null | undefined, language: Language): string | null => { const formatRenewalDate = (iso: string | null | undefined, language: Language): string | null => {
if (!iso) return null; if (!iso) return null;
const date = new Date(iso); const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null; if (Number.isNaN(date.getTime())) return null;
const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US';
return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
}; };
type Props = { type Props = {
visible: boolean; visible: boolean;
language: Language; language: Language;
colors: ColorsType; colors: ColorsType;
isPro?: boolean; 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;
onDismiss: () => void; onDismiss: () => void;
}; };
const TOPUPS = [ const TOPUPS = [
{ id: 'topup_small' as const, amount: 30, best: false }, { id: 'topup_small' as const, amount: 30, best: false },
{ id: 'topup_medium' as const, amount: 100, best: false }, { id: 'topup_medium' as const, amount: 100, best: false },
{ id: 'topup_large' as const, amount: 250, best: true }, { id: 'topup_large' as const, amount: 250, best: true },
]; ];
export function OutOfCreditsSheet({ visible, language, colors, isPro = false, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) { export function OutOfCreditsSheet({ visible, language, colors, isPro = false, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) {
const copy = getCopy(language, isPro); 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 <TouchableOpacity
style={styles.backdropTouchable} style={styles.backdropTouchable}
activeOpacity={1} activeOpacity={1}
onPress={onDismiss} onPress={onDismiss}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={copy.later} accessibilityLabel={copy.later}
/> />
<View style={[styles.sheet, { backgroundColor: colors.surface }]} accessibilityViewIsModal> <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, { backgroundColor: colors.danger }]}> <View style={[styles.zeroBadge, { backgroundColor: colors.danger }]}>
<Text style={styles.zeroBadgeText}>0</Text> <Text style={styles.zeroBadgeText}>0</Text>
</View> </View>
</View> </View>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text> <Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<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 && ( {!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) => (
<TouchableOpacity <TouchableOpacity
key={topup.id} key={topup.id}
style={[styles.topupChip, { borderColor: topup.best ? colors.primary : colors.border, backgroundColor: colors.surfaceMuted }]} style={[styles.topupChip, { borderColor: topup.best ? colors.primary : colors.border, backgroundColor: colors.surfaceMuted }]}
onPress={() => onTopup(topup.id)} onPress={() => onTopup(topup.id)}
activeOpacity={0.85} activeOpacity={0.85}
> >
{topup.best && ( {topup.best && (
<View style={[styles.bestBadge, { backgroundColor: colors.primary }]}> <View style={[styles.bestBadge, { backgroundColor: colors.primary }]}>
<Text style={[styles.bestBadgeText, { color: colors.onPrimary }]}>{copy.best}</Text> <Text style={[styles.bestBadgeText, { color: colors.onPrimary }]}>{copy.best}</Text>
</View> </View>
)} )}
<Text style={[styles.topupAmount, { color: colors.primary }]}>+{topup.amount}</Text> <Text style={[styles.topupAmount, { color: colors.primary }]}>+{topup.amount}</Text>
<Text style={[styles.topupUnit, { color: colors.textSecondary }]}>{copy.credits}</Text> <Text style={[styles.topupUnit, { color: colors.textSecondary }]}>{copy.credits}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
<TouchableOpacity onPress={onDismiss} style={styles.laterBtn}> <TouchableOpacity onPress={onDismiss} style={styles.laterBtn}>
<Text style={[styles.laterText, { color: colors.primary }]}>{copy.later}</Text> <Text style={[styles.laterText, { color: colors.primary }]}>{copy.later}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</Modal> </Modal>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(10,17,11,0.45)', justifyContent: 'flex-end' }, backdrop: { flex: 1, backgroundColor: 'rgba(10,17,11,0.45)', justifyContent: 'flex-end' },
backdropTouchable: { flex: 1 }, backdropTouchable: { flex: 1 },
sheet: { borderTopLeftRadius: 26, borderTopRightRadius: 26, paddingHorizontal: 24, paddingTop: 10, paddingBottom: 34, alignItems: 'center' }, sheet: { borderTopLeftRadius: 26, borderTopRightRadius: 26, paddingHorizontal: 24, paddingTop: 10, paddingBottom: 34, alignItems: 'center' },
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, 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 },
cta: { alignSelf: 'stretch', height: 56, borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 16 }, cta: { alignSelf: 'stretch', height: 56, borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 16 },
ctaText: { fontSize: 17, fontWeight: '800' }, ctaText: { fontSize: 17, fontWeight: '800' },
topupsLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.8, marginBottom: 10 }, topupsLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.8, marginBottom: 10 },
topupRow: { flexDirection: 'row', gap: 10, alignSelf: 'stretch', marginBottom: 14 }, topupRow: { flexDirection: 'row', gap: 10, alignSelf: 'stretch', marginBottom: 14 },
topupChip: { flex: 1, borderWidth: 1.5, borderRadius: 14, paddingVertical: 14, alignItems: 'center', overflow: 'hidden' }, topupChip: { flex: 1, borderWidth: 1.5, borderRadius: 14, paddingVertical: 14, alignItems: 'center', overflow: 'hidden' },
bestBadge: { position: 'absolute', top: 0, left: 0, right: 0, paddingVertical: 3, alignItems: 'center' }, bestBadge: { position: 'absolute', top: 0, left: 0, right: 0, paddingVertical: 3, alignItems: 'center' },
bestBadgeText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.6 }, bestBadgeText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.6 },
topupAmount: { fontSize: 22, fontWeight: '900', marginTop: 6 }, topupAmount: { fontSize: 22, fontWeight: '900', marginTop: 6 },
topupUnit: { fontSize: 12, fontWeight: '600' }, topupUnit: { fontSize: 12, fontWeight: '600' },
laterBtn: { paddingVertical: 8 }, laterBtn: { paddingVertical: 8 },
laterText: { fontSize: 15, fontWeight: '800' }, laterText: { fontSize: 15, fontWeight: '800' },
}); });

View File

@@ -1,71 +1,71 @@
# Onboarding Redesign, Soft Paywall & Free Tier — Design Spec # Onboarding Redesign, Soft Paywall & Free Tier — Design Spec
**Date:** 2026-07-06 **Date:** 2026-07-06
**Status:** Implemented **Status:** Implemented
**Design reference:** `design/stitch-onboarding/` (Stitch export: 10 screens, light + dark variants, `code.html` per screen, design tokens in `botanical_vitality/DESIGN.md` (light) and `nocturnal_botanical/DESIGN.md` (dark)) **Design reference:** `design/stitch-onboarding/` (Stitch export: 10 screens, light + dark variants, `code.html` per screen, design tokens in `botanical_vitality/DESIGN.md` (light) and `nocturnal_botanical/DESIGN.md` (dark))
## Goal ## Goal
Replicate the Learna-style onboarding → soft paywall → sign-up flow, remove the hard paywall (client and server), and introduce a free tier with 3 monthly scan credits. Sign-up moves to the *end* of onboarding, after the paywall (pattern credited with +52% sales in the reference video). Replicate the Learna-style onboarding → soft paywall → sign-up flow, remove the hard paywall (client and server), and introduce a free tier with 3 monthly scan credits. Sign-up moves to the *end* of onboarding, after the paywall (pattern credited with +52% sales in the reference video).
## 1. New user flow ## 1. New user flow
``` ```
Welcome (social proof) Welcome (social proof)
→ Benefit slides (3) → Benefit slides (3)
→ Personalization questions (existing: source, goal, experience, customize) → Personalization questions (existing: source, goal, experience, customize)
→ "Personalizing your care plan…" progress screen → "Personalizing your care plan…" progress screen
→ Paywall (dismissible ✕) → Paywall (dismissible ✕)
→ Sign-up (Continue with Apple / Email) → Sign-up (Continue with Apple / Email)
→ App (tabs), free plan with 3 credits/month → App (tabs), free plan with 3 credits/month
``` ```
- The guest **demo scan stays** available from the welcome screen (existing `isGuest` server handling unchanged). - The guest **demo scan stays** available from the welcome screen (existing `isGuest` server handling unchanged).
- Existing users: "Log in" link on the welcome screen → login. - Existing users: "Log in" link on the welcome screen → login.
- Paywall ✕ during onboarding → continues to sign-up. Paywall ✕ in-app → simply closes. - Paywall ✕ during onboarding → continues to sign-up. Paywall ✕ in-app → simply closes.
## 2. Screens ## 2. Screens
Each screen follows the Stitch mockup in `design/stitch-onboarding/<name>/screen.png` (+ `_dark_mode` variant). `code.html` is the source of truth for spacing/sizes. Design tokens (colors, Plus Jakarta Sans-style extra-bold typography) come from the two `DESIGN.md` files, mapped into the existing `constants/Colors.ts` light/dark system. Each screen follows the Stitch mockup in `design/stitch-onboarding/<name>/screen.png` (+ `_dark_mode` variant). `code.html` is the source of truth for spacing/sizes. Design tokens (colors, Plus Jakarta Sans-style extra-bold typography) come from the two `DESIGN.md` files, mapped into the existing `constants/Colors.ts` light/dark system.
| Screen | Stitch reference | Implementation | Imagery | | Screen | Stitch reference | Implementation | Imagery |
|---|---|---|---| |---|---|---|---|
| Welcome | `welcome_to_greenlens` | Redesign `app/onboarding.tsx`: hero photo, rating badge "4.8", testimonial card, "Let's Go" CTA, "Log in" link, legal footer. Keep demo-scan entry. | Bright plant-room photo (new or existing asset) | | Welcome | `welcome_to_greenlens` | Redesign `app/onboarding.tsx`: hero photo, rating badge "4.8", testimonial card, "Let's Go" CTA, "Log in" link, legal footer. Keep demo-scan entry. | Bright plant-room photo (new or existing asset) |
| Benefit slide 1 "Scan Any Plant" | `scan_any_plant` | New screen(s), progress dots, Continue | Reuse existing render style (`assets/onboarding_*_mockup.png` language); scan-frame + "Monstera identified · 98%" chip built natively in RN, NOT baked into the photo | | Benefit slide 1 "Scan Any Plant" | `scan_any_plant` | New screen(s), progress dots, Continue | Reuse existing render style (`assets/onboarding_*_mockup.png` language); scan-frame + "Monstera identified · 98%" chip built natively in RN, NOT baked into the photo |
| Benefit slide 2 "Health Check & Care Plan" | `health_check_care_plan` | New screen | Photo + native overlay card (Overwatering detected / 7-day rescue plan ready) | | Benefit slide 2 "Health Check & Care Plan" | `health_check_care_plan` | New screen | Photo + native overlay card (Overwatering detected / 7-day rescue plan ready) |
| Benefit slide 3 "Never Forget Watering" | `never_forget_watering` | New screen | Photo + native reminder chips | | Benefit slide 3 "Never Forget Watering" | `never_forget_watering` | New screen | Photo + native reminder chips |
| Personalization questions | `personalization_question` | Restyle existing `app/onboarding/{source,goal,experience,customize}.tsx`: top progress bar + back arrow, white option cards radius 16, selected = green border + tint, Continue button | Emoji/icon per option | | Personalization questions | `personalization_question` | Restyle existing `app/onboarding/{source,goal,experience,customize}.tsx`: top progress bar + back arrow, white option cards radius 16, selected = green border + tint, Continue button | Emoji/icon per option |
| Personalizing progress | `personalizing_your_plan` | New screen: animated % + progress ring, sequential checkmarks ("Analyzing your answers", "Building your care plan", "Preparing your scan credits", "Finalizing your plan"), testimonial card (filled stars), rating badge. Auto-advances to paywall. | Small round plant photo in ring | | Personalizing progress | `personalizing_your_plan` | New screen: animated % + progress ring, sequential checkmarks ("Analyzing your answers", "Building your care plan", "Preparing your scan credits", "Finalizing your plan"), testimonial card (filled stars), rating badge. Auto-advances to paywall. | Small round plant photo in ring |
| Paywall | `greenlens_pro_paywall` | Redesign paywall mode of `app/profile/billing.tsx`: ✕ top-left, Restore top-right, "GreenLens Pro / Get Unlimited Access", plan card, **Free Trial toggle** (ON = yearly with 7-day trial → "Due today €0 / Due <date> €39.99"; OFF = monthly, no trial), CTA "Try Free"/"Start Now", Cancel Anytime, Privacy/Terms. Dates dynamic. Credit top-ups remain reachable. | `assets/paywall_scan_background.png` as header | | Paywall | `greenlens_pro_paywall` | Redesign paywall mode of `app/profile/billing.tsx`: ✕ top-left, Restore top-right, "GreenLens Pro / Get Unlimited Access", plan card, **Free Trial toggle** (ON = yearly with 7-day trial → "Due today €0 / Due <date> €39.99"; OFF = monthly, no trial), CTA "Try Free"/"Start Now", Cancel Anytime, Privacy/Terms. Dates dynamic. Credit top-ups remain reachable. | `assets/paywall_scan_background.png` as header |
| Sign-up | `sign_up_for_greenlens` | Restyle `app/auth/signup.tsx`: dark botanical hero with "<Name>, Let's finish your setup!" (name from onboarding answers if given, else generic), "Continue with Apple" (exists), OR divider, "Continue with Email" expanding to email/password fields (two-step), "Already have an account? Log in", legal. | `assets/welcome_botanical_hero.png` (replaces Stitch photo with baked-in fake form) | | Sign-up | `sign_up_for_greenlens` | Restyle `app/auth/signup.tsx`: dark botanical hero with "<Name>, Let's finish your setup!" (name from onboarding answers if given, else generic), "Continue with Apple" (exists), OR divider, "Continue with Email" expanding to email/password fields (two-step), "Already have an account? Log in", legal. | `assets/welcome_botanical_hero.png` (replaces Stitch photo with baked-in fake form) |
| Login | `login_to_greenlens` | Restyle `app/auth/login.tsx`: "Welcome back!", Apple button, email/password, Forgot password?, "New here? Create account". | Clean botanical photo (existing assets) | | Login | `login_to_greenlens` | Restyle `app/auth/login.tsx`: "Welcome back!", Apple button, email/password, Forgot password?, "New here? Create account". | Clean botanical photo (existing assets) |
| Out of credits | `out_of_credits` | New bottom sheet over scanner: leaf icon with "0" badge, "You're out of free scans", renewal date, "See Pro Plans" → paywall, top-up chips (+30/+100/+250, BEST badge on 250), "Maybe later". | — | | Out of credits | `out_of_credits` | New bottom sheet over scanner: leaf icon with "0" badge, "You're out of free scans", renewal date, "See Pro Plans" → paywall, top-up chips (+30/+100/+250, BEST badge on 250), "Maybe later". | — |
Known mockup fixes (agreed): replace AI-artifact photos (garbled phone UI on scan slide, fake forms baked into sign-up/login backgrounds), filled stars on testimonials, dynamic dates. Known mockup fixes (agreed): replace AI-artifact photos (garbled phone UI on scan slide, fake forms baked into sign-up/login backgrounds), filled stars on testimonials, dynamic dates.
## 3. Free tier (backend, `server/lib/billing.js` + `server/index.js`) ## 3. Free tier (backend, `server/lib/billing.js` + `server/index.js`)
- `FREE_MONTHLY_CREDITS: 0 → 3`. Existing monthly-allowance reset logic already covers free accounts; verify reset works for plan `free`. - `FREE_MONTHLY_CREDITS: 0 → 3`. Existing monthly-allowance reset logic already covers free accounts; verify reset works for plan `free`.
- **Remove server hard paywall:** drop `ensureActiveProEntitlement` from scan and health-check endpoints (`server/index.js`). Credit consumption becomes the only gate; 0 credits → existing 402 `INSUFFICIENT_CREDITS` error. - **Remove server hard paywall:** drop `ensureActiveProEntitlement` from scan and health-check endpoints (`server/index.js`). Credit consumption becomes the only gate; 0 credits → existing 402 `INSUFFICIENT_CREDITS` error.
- **Same scan model for free and pro** (`getScanModel` returns the pro model for both plans). Cost accepted (~cents/user/month at 3 scans). - **Same scan model for free and pro** (`getScanModel` returns the pro model for both plans). Cost accepted (~cents/user/month at 3 scans).
- **Low-confidence AI review pass stays pro-only** (unchanged) so a free scan never burns 2 credits, and Pro keeps a quality edge. - **Low-confidence AI review pass stays pro-only** (unchanged) so a free scan never burns 2 credits, and Pro keeps a quality edge.
- Trial handling unchanged (yearly plan carries 7-day trial, 30 credits during trial). - Trial handling unchanged (yearly plan carries 7-day trial, 30 credits during trial).
## 4. Soft paywall (app) ## 4. Soft paywall (app)
- `app/_layout.tsx`: remove the `!hasActiveEntitlement → Redirect /profile/billing` block (line ~169). Signed-in users always reach the tabs regardless of plan. - `app/_layout.tsx`: remove the `!hasActiveEntitlement → Redirect /profile/billing` block (line ~169). Signed-in users always reach the tabs regardless of plan.
- Scanner: on 402 `INSUFFICIENT_CREDITS` → open the out-of-credits bottom sheet (dismissible) instead of blocking. - Scanner: on 402 `INSUFFICIENT_CREDITS` → open the out-of-credits bottom sheet (dismissible) instead of blocking.
- Free users see a credits badge (e.g. "3 scans left") on Home/Scanner so the limit is visible before it bites. - Free users see a credits badge (e.g. "3 scans left") on Home/Scanner so the limit is visible before it bites.
- Existing PostHog events (`trial_started`, purchase events) stay; add events for paywall_shown / paywall_dismissed / onboarding step views. - Existing PostHog events (`trial_started`, purchase events) stay; add events for paywall_shown / paywall_dismissed / onboarding step views.
## 5. Out of scope ## 5. Out of scope
- Instagram-story auto-advance on benefit slides (manual Continue first) - Instagram-story auto-advance on benefit slides (manual Continue first)
- Win-back / cancellation flows - Win-back / cancellation flows
- Android Google Sign-In (RevenueCat Android key still placeholder) - Android Google Sign-In (RevenueCat Android key still placeholder)
- Chat-style onboarding rebuild - Chat-style onboarding rebuild
## 6. Open questions ## 6. Open questions
- Exact free-credit renewal display date source (billing summary already exposes renewal info for pro; confirm shape for free accounts). - Exact free-credit renewal display date source (billing summary already exposes renewal info for pro; confirm shape for free accounts).
- Whether existing signed-in free users (previously hard-walled) need a one-time "you now have 3 free scans" toast. Nice-to-have. - Whether existing signed-in free users (previously hard-walled) need a one-time "you now have 3 free scans" toast. Nice-to-have.

View File

@@ -1,86 +1,86 @@
{ {
"name": "greenlens", "name": "greenlens",
"version": "2.2.9", "version": "2.2.9",
"main": "expo-router/entry", "main": "expo-router/entry",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start --offline", "start": "expo start --offline",
"android": "expo start --android --offline", "android": "expo start --android --offline",
"ios": "expo start --ios --offline", "ios": "expo start --ios --offline",
"web": "expo start --web --offline", "web": "expo start --web --offline",
"build:dev": "eas build --profile development --platform android", "build:dev": "eas build --profile development --platform android",
"build:preview": "eas build --profile preview --platform android", "build:preview": "eas build --profile preview --platform android",
"build:prod": "eas build --profile production --platform android", "build:prod": "eas build --profile production --platform android",
"postinstall": "patch-package", "postinstall": "patch-package",
"test": "jest", "test": "jest",
"audit:semantic": "node scripts/generate_semantic_audit.js" "audit:semantic": "node scripts/generate_semantic_audit.js"
}, },
"jest": { "jest": {
"preset": "jest-expo", "preset": "jest-expo",
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
], ],
"setupFiles": [ "setupFiles": [
"./jest.setup.js" "./jest.setup.js"
], ],
"testPathIgnorePatterns": [ "testPathIgnorePatterns": [
"<rootDir>/server/test/" "<rootDir>/server/test/"
] ]
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@google/genai": "^1.38.0", "@google/genai": "^1.38.0",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"expo": "^54.0.33", "expo": "^54.0.33",
"expo-apple-authentication": "~8.0.8", "expo-apple-authentication": "~8.0.8",
"expo-application": "~7.0.8", "expo-application": "~7.0.8",
"expo-asset": "~12.0.12", "expo-asset": "~12.0.12",
"expo-av": "^16.0.8", "expo-av": "^16.0.8",
"expo-blur": "~15.0.8", "expo-blur": "~15.0.8",
"expo-build-properties": "^55.0.9", "expo-build-properties": "^55.0.9",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20", "expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10", "expo-device": "~8.0.10",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.21",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-image-manipulator": "~14.0.8", "expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.10", "expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
"expo-localization": "~17.0.8", "expo-localization": "~17.0.8",
"expo-notifications": "~0.32.16", "expo-notifications": "~0.32.16",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-share-intent": "^5.1.1", "expo-share-intent": "^5.1.1",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-sqlite": "~16.0.10", "expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.16", "expo-updates": "~29.0.16",
"expo-video": "~3.0.16", "expo-video": "~3.0.16",
"posthog-react-native": "^4.37.1", "posthog-react-native": "^4.37.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-purchases": "^9.10.5", "react-native-purchases": "^9.10.5",
"react-native-purchases-ui": "^9.10.5", "react-native-purchases-ui": "^9.10.5",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
"react-native-web": "^0.21.2", "react-native-web": "^0.21.2",
"react-native-worklets": "0.5.1" "react-native-worklets": "0.5.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.0", "@babel/core": "^7.25.0",
"@testing-library/jest-native": "^5.4.3", "@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3", "@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expo": "^54.0.17", "jest-expo": "^54.0.17",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"typescript": "^5.3.0" "typescript": "^5.3.0"
} }
} }

View File

@@ -1,90 +1,90 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
buildDefaultAccount, buildDefaultAccount,
alignAccountToCurrentCycle, alignAccountToCurrentCycle,
getAvailableCredits, getAvailableCredits,
consumeCredits, consumeCredits,
ensureSufficientCredits, ensureSufficientCredits,
getMonthlyAllowanceForPlan, getMonthlyAllowanceForPlan,
} = require('../lib/billing'); } = require('../lib/billing');
const NOW = new Date('2026-07-06T12:00:00Z'); const NOW = new Date('2026-07-06T12:00:00Z');
const freeAccount = (overrides = {}) => ({ const freeAccount = (overrides = {}) => ({
...buildDefaultAccount('user-1', NOW), ...buildDefaultAccount('user-1', NOW),
...overrides, ...overrides,
}); });
test('free plan gets 3 monthly credits', () => { test('free plan gets 3 monthly credits', () => {
assert.equal(getMonthlyAllowanceForPlan('free'), 3); assert.equal(getMonthlyAllowanceForPlan('free'), 3);
assert.equal(buildDefaultAccount('u', NOW).monthlyAllowance, 3); assert.equal(buildDefaultAccount('u', NOW).monthlyAllowance, 3);
}); });
test('free account has available credits', () => { test('free account has available credits', () => {
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1 });
assert.equal(getAvailableCredits(account), 2); assert.equal(getAvailableCredits(account), 2);
}); });
test('free account topup balance counts as available', () => { test('free account topup balance counts as available', () => {
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 10 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 10 });
assert.equal(getAvailableCredits(account), 10); assert.equal(getAvailableCredits(account), 10);
}); });
test('consumeCredits charges a free account from the monthly allowance', () => { test('consumeCredits charges a free account from the monthly allowance', () => {
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 0 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 0 });
const charged = consumeCredits(account, 1); const charged = consumeCredits(account, 1);
assert.equal(charged, 1); assert.equal(charged, 1);
assert.equal(account.usedThisCycle, 1); assert.equal(account.usedThisCycle, 1);
}); });
test('consumeCredits throws 402 for an exhausted free account', () => { test('consumeCredits throws 402 for an exhausted free account', () => {
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 0 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 0 });
assert.throws(() => consumeCredits(account, 1), (error) => { assert.throws(() => consumeCredits(account, 1), (error) => {
assert.equal(error.code, 'INSUFFICIENT_CREDITS'); assert.equal(error.code, 'INSUFFICIENT_CREDITS');
assert.equal(error.status, 402); assert.equal(error.status, 402);
assert.deepEqual(error.metadata, { required: 1, available: 0 }); assert.deepEqual(error.metadata, { required: 1, available: 0 });
return true; return true;
}); });
}); });
test('legacy free account with allowance 0 is migrated to 3', () => { test('legacy free account with allowance 0 is migrated to 3', () => {
const account = freeAccount({ monthlyAllowance: 0 }); const account = freeAccount({ monthlyAllowance: 0 });
const aligned = alignAccountToCurrentCycle(account, NOW); const aligned = alignAccountToCurrentCycle(account, NOW);
assert.equal(aligned.monthlyAllowance, 3); assert.equal(aligned.monthlyAllowance, 3);
}); });
test('pro and trial allowances are unchanged', () => { test('pro and trial allowances are unchanged', () => {
assert.equal(getMonthlyAllowanceForPlan('pro'), 100); assert.equal(getMonthlyAllowanceForPlan('pro'), 100);
const trial = freeAccount({ plan: 'pro', monthlyAllowance: 30, usedThisCycle: 5 }); const trial = freeAccount({ plan: 'pro', monthlyAllowance: 30, usedThisCycle: 5 });
const aligned = alignAccountToCurrentCycle(trial, NOW); const aligned = alignAccountToCurrentCycle(trial, NOW);
assert.equal(aligned.monthlyAllowance, 30); // trial allowance stays allowed assert.equal(aligned.monthlyAllowance, 30); // trial allowance stays allowed
assert.equal(getAvailableCredits(aligned), 25); assert.equal(getAvailableCredits(aligned), 25);
}); });
test('monthly cycle rollover resets free usage', () => { test('monthly cycle rollover resets free usage', () => {
const account = freeAccount({ const account = freeAccount({
monthlyAllowance: 3, monthlyAllowance: 3,
usedThisCycle: 3, usedThisCycle: 3,
cycleEndsAt: '2026-07-01T00:00:00.000Z', cycleEndsAt: '2026-07-01T00:00:00.000Z',
}); });
const aligned = alignAccountToCurrentCycle(account, NOW); const aligned = alignAccountToCurrentCycle(account, NOW);
assert.equal(aligned.usedThisCycle, 0); assert.equal(aligned.usedThisCycle, 0);
assert.equal(aligned.monthlyAllowance, 3); assert.equal(aligned.monthlyAllowance, 3);
assert.equal(getAvailableCredits(aligned), 3); assert.equal(getAvailableCredits(aligned), 3);
}); });
test('ensureSufficientCredits throws 402 when balance is below cost', () => { test('ensureSufficientCredits throws 402 when balance is below cost', () => {
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 2, topupBalance: 0 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 2, topupBalance: 0 });
assert.throws(() => ensureSufficientCredits(account, 2), (error) => { assert.throws(() => ensureSufficientCredits(account, 2), (error) => {
assert.equal(error.code, 'INSUFFICIENT_CREDITS'); assert.equal(error.code, 'INSUFFICIENT_CREDITS');
assert.equal(error.status, 402); assert.equal(error.status, 402);
assert.deepEqual(error.metadata, { required: 2, available: 1 }); assert.deepEqual(error.metadata, { required: 2, available: 1 });
return true; return true;
}); });
}); });
test('ensureSufficientCredits passes when balance covers cost', () => { test('ensureSufficientCredits passes when balance covers cost', () => {
const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 }); const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1, topupBalance: 0 });
assert.doesNotThrow(() => ensureSufficientCredits(account, 2)); assert.doesNotThrow(() => ensureSufficientCredits(account, 2));
}); });

View File

@@ -1,36 +1,36 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { OnboardingProgressService } from './onboardingProgressService'; import { OnboardingProgressService } from './onboardingProgressService';
const STORAGE_KEY = 'greenlens_preauth_onboarding_v1'; const STORAGE_KEY = 'greenlens_preauth_onboarding_v1';
export type PreAuthAnswers = { export type PreAuthAnswers = {
acquisitionSource?: string; acquisitionSource?: string;
primaryGoal?: string; primaryGoal?: string;
experienceLevel?: string; experienceLevel?: string;
}; };
export const PreAuthOnboardingService = { export const PreAuthOnboardingService = {
async setAnswer<K extends keyof PreAuthAnswers>(key: K, value: PreAuthAnswers[K]): Promise<void> { async setAnswer<K extends keyof PreAuthAnswers>(key: K, value: PreAuthAnswers[K]): Promise<void> {
const answers = await this.getAnswers(); const answers = await this.getAnswers();
answers[key] = value; answers[key] = value;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(answers)); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(answers));
}, },
async getAnswers(): Promise<PreAuthAnswers> { async getAnswers(): Promise<PreAuthAnswers> {
try { try {
const raw = await AsyncStorage.getItem(STORAGE_KEY); const raw = await AsyncStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as PreAuthAnswers) : {}; return raw ? (JSON.parse(raw) as PreAuthAnswers) : {};
} catch { } catch {
return {}; return {};
} }
}, },
// Persist buffered answers into the per-user profile after sign-up/login. // Persist buffered answers into the per-user profile after sign-up/login.
async flushToProfile(userId: number): Promise<void> { async flushToProfile(userId: number): Promise<void> {
const answers = await this.getAnswers(); const answers = await this.getAnswers();
if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(userId, answers.acquisitionSource); if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(userId, answers.acquisitionSource);
if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(userId, answers.primaryGoal); if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(userId, answers.primaryGoal);
if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(userId, answers.experienceLevel); if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(userId, answers.experienceLevel);
await AsyncStorage.removeItem(STORAGE_KEY); await AsyncStorage.removeItem(STORAGE_KEY);
}, },
}; };