# Onboarding Redesign, Soft Paywall & Free Tier — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Learna-style onboarding flow (welcome → benefit slides → questions → personalizing → dismissible paywall → sign-up), remove the hard paywall on client and server, and give free accounts 3 scan credits per month. **Architecture:** Backend first (free-tier credit math + remove server pro-gate), then app gating (remove redirect, scanner credit handling, out-of-credits sheet), then paywall restyle (existing `billing.tsx` paywall branch becomes param-driven + trial toggle), then the new onboarding screens. Every screen follows the approved Stitch mockups in `design/stitch-onboarding//{screen.png,code.html}` with tokens from `design/stitch-onboarding/botanical_vitality/DESIGN.md` (light) and `nocturnal_botanical/DESIGN.md` (dark), mapped onto the existing `useColors` theme system. **Tech Stack:** Expo / React Native / expo-router, react-native-purchases (RevenueCat), Express + PostgreSQL backend, `node --test` for server tests, Jest for app tests. **Spec:** `docs/superpowers/specs/2026-07-06-onboarding-soft-paywall-free-tier-design.md` --- ## Context primer (read before starting) **Current flow:** `app/onboarding.tsx` (welcome, auth buttons) → `auth/signup` → `onboarding/source` → `goal` → `experience` → `health-check` → hard paywall (`profile/billing`) or tabs. `app/_layout.tsx:169` force-redirects signed-in non-pro users to `/profile/billing`. Server hard-gates scans via `ensureActiveProEntitlement` (`server/index.js:214`, called at :734 scan, :906 semantic search, :946 health check). **New flow:** welcome → `onboarding/slides` (3 benefit slides) → `source` → `goal` → `experience` → `health-check` → `onboarding/personalizing` → `/profile/billing?view=paywall&context=onboarding` (dismissible) → `auth/signup` → tabs (free plan, 3 credits/month). Note: `onboarding/customize.tsx` stays out of the chain (as today). The spec listed it as a question step; the existing chain uses `health-check` as step 4 — we keep that. **Key backend facts:** - `server/lib/billing.js`: `FREE_MONTHLY_CREDITS = 0` (line 3), `getAvailableCredits` returns 0 for non-pro (line 257), `consumeCredits` throws for non-pro (line 573), `alignAccountToCurrentCycle` self-heals allowance via `isAllowedMonthlyAllowance` (line 76) — legacy free accounts with stored allowance 0 auto-migrate once `FREE_MONTHLY_CREDITS` changes. - Costs (`server/index.js:84-87`): scan primary 1, scan review 0, semantic search 2, health check 2. - Guests: `isGuest(userId)` = `userId === 'guest'`. Guests currently never reach credit consumption because `ensureActiveProEntitlement` throws first. **When removing it, guests must still be blocked server-side** — otherwise all guests share one global `'guest'` billing account and health checks become free for guests (`server/index.js:1005` skips charging guests). - Scan model per plan: `server/lib/openai.js:33` `getScanModelChain(plan)` — free uses the cheap chain. Decision: free gets the pro chain (same quality). - Billing summary shape (`buildBillingSummary`): `credits.cycleEndsAt` is the free-credit renewal date. **Key app facts:** - `app/scanner.tsx`: `isDemoMode = !hasActiveEntitlement` (line 170) — ALL non-pro users currently get client-side mock scans (`getMockPlantByImage`), limited to 5/device via `guestScanCount`. New rule: demo mode = guests only (`!session`); signed-in free users do real scans with credits. - `app/profile/billing.tsx` (1779 lines): already contains the full paywall branch `showPaywallPlans` (line 360: `!session || (!isLoadingBilling && planId !== 'pro')`), purchase/restore/sync/Expo-Go-simulation logic, per-language copy via `getBillingCopy(language)`. We reuse ALL purchase logic — only the paywall trigger and the paywall JSX change. - Theme: `useColors(isDarkMode, colorPalette)` from `constants/Colors.ts` (tokens like `colors.primary`, `colors.surface`, `colors.text`, `colors.primarySoft`, `colors.border`, `colors.onPrimary`, `colors.textSecondary`, `colors.textMuted`, `colors.surfaceMuted`). New screens must support dark mode via these tokens (Stitch dark variants exist as reference). - New-screen copy: follow the `getBillingCopy(language)`-style local copy object pattern (de/es/en) — do NOT add keys to `utils/translations.ts` unless a screen already uses `t.` keys you're keeping. - Onboarding answers are stored per `session.userId` in local SQLite via `OnboardingProgressService` — in the new flow questions run **before** auth, so answers must be buffered (Task 9) and flushed after sign-up/login. - Analytics: `useSafeAnalytics()` → `posthog.capture(...)`. Keep every existing event; new events are specified inline per task. **Testing baseline (memory):** 5 Jest suites fail on a clean tree. Before starting, record the baseline (`npm test 2>&1 | tail -20`) and only compare against it. Server has no test runner yet — Task 1 adds `node --test`. --- ### Task 1: Server test harness + failing free-tier tests **Files:** - Modify: `server/package.json` (test script) - Modify: `server/lib/billing.js:813-831` (exports only) - Create: `server/test/billing.test.js` - [ ] **Step 1: Add test script** In `server/package.json`, replace the test script line: ```json "test": "node --test test/" ``` - [ ] **Step 2: Export the pure helpers under test** In `server/lib/billing.js`, extend `module.exports` (line 813) with the pure functions (they already exist, just aren't exported): ```js module.exports = { AVAILABLE_PRODUCTS, chargeKey, claimNotificationOnce, consumeCreditsWithIdempotency, endpointKey, ensureBillingSchema, getAccountSnapshot, getBillingSummary, getEndpointResponse, getMonthlyAllowanceForPlan, isInsufficientCreditsError, runInTransaction, simulatePurchase, simulateWebhook, syncRevenueCatCustomerInfo, syncRevenueCatWebhookEvent, storeEndpointResponse, // exported for tests buildDefaultAccount, alignAccountToCurrentCycle, getAvailableCredits, consumeCredits, buildBillingSummary, }; ``` - [ ] **Step 3: Write the failing tests** Create `server/test/billing.test.js`: ```js const test = require('node:test'); const assert = require('node:assert/strict'); const { buildDefaultAccount, alignAccountToCurrentCycle, getAvailableCredits, consumeCredits, getMonthlyAllowanceForPlan, } = require('../lib/billing'); const NOW = new Date('2026-07-06T12:00:00Z'); const freeAccount = (overrides = {}) => ({ ...buildDefaultAccount('user-1', NOW), ...overrides, }); test('free plan gets 3 monthly credits', () => { assert.equal(getMonthlyAllowanceForPlan('free'), 3); assert.equal(buildDefaultAccount('u', NOW).monthlyAllowance, 3); }); test('free account has available credits', () => { const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 1 }); assert.equal(getAvailableCredits(account), 2); }); test('free account topup balance counts as available', () => { const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 10 }); assert.equal(getAvailableCredits(account), 10); }); test('consumeCredits charges a free account from the monthly allowance', () => { const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 0 }); const charged = consumeCredits(account, 1); assert.equal(charged, 1); assert.equal(account.usedThisCycle, 1); }); test('consumeCredits throws 402 for an exhausted free account', () => { const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, topupBalance: 0 }); assert.throws(() => consumeCredits(account, 1), (error) => { assert.equal(error.code, 'INSUFFICIENT_CREDITS'); assert.equal(error.status, 402); assert.deepEqual(error.metadata, { required: 1, available: 0 }); return true; }); }); test('legacy free account with allowance 0 is migrated to 3', () => { const account = freeAccount({ monthlyAllowance: 0 }); const aligned = alignAccountToCurrentCycle(account, NOW); assert.equal(aligned.monthlyAllowance, 3); }); test('pro and trial allowances are unchanged', () => { assert.equal(getMonthlyAllowanceForPlan('pro'), 100); const trial = freeAccount({ plan: 'pro', monthlyAllowance: 30, usedThisCycle: 5 }); const aligned = alignAccountToCurrentCycle(trial, NOW); assert.equal(aligned.monthlyAllowance, 30); // trial allowance stays allowed assert.equal(getAvailableCredits(aligned), 25); }); test('monthly cycle rollover resets free usage', () => { const account = freeAccount({ monthlyAllowance: 3, usedThisCycle: 3, cycleEndsAt: '2026-07-01T00:00:00.000Z', }); const aligned = alignAccountToCurrentCycle(account, NOW); assert.equal(aligned.usedThisCycle, 0); assert.equal(aligned.monthlyAllowance, 3); assert.equal(getAvailableCredits(aligned), 3); }); ``` - [ ] **Step 4: Run tests to verify they fail** Run: `cd server && npm test` Expected: FAIL — `free plan gets 3 monthly credits` (0 !== 3), `free account has available credits` (0 !== 2), `consumeCredits charges a free account` (throws), `legacy free account…` (0 !== 3). The pro/trial test may pass already. - [ ] **Step 5: Commit** ```bash git add server/package.json server/lib/billing.js server/test/billing.test.js git commit -m "test(server): add node --test harness with failing free-tier billing tests" ``` --- ### Task 2: Implement the free tier in billing.js **Files:** - Modify: `server/lib/billing.js:3` (constant), `:257-261` (getAvailableCredits), `:571-575` (consumeCredits) - [ ] **Step 1: Set the free allowance** `server/lib/billing.js:3`: ```js const FREE_MONTHLY_CREDITS = 3; ``` - [ ] **Step 2: Make available-credit math plan-independent** Replace `getAvailableCredits` (lines 257-261): ```js const getAvailableCredits = (account) => { const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); return monthlyRemaining + Math.max(0, account.topupBalance); }; ``` - [ ] **Step 3: Let free accounts consume credits** In `consumeCredits` (line 571), delete the plan gate: ```js const consumeCredits = (account, cost) => { if (cost <= 0) return 0; const available = getAvailableCredits(account); if (available < cost) { throw createInsufficientCreditsError(cost, available); } // ... rest unchanged ``` (`isAllowedMonthlyAllowance` needs no change: for free plans it compares against `FREE_MONTHLY_CREDITS`, which is now 3, so legacy stored `0` fails the check and `alignAccountToCurrentCycle` heals it to 3 on next account load.) - [ ] **Step 4: Run tests to verify they pass** Run: `cd server && npm test` Expected: all tests PASS. - [ ] **Step 5: Commit** ```bash git add server/lib/billing.js git commit -m "feat(server): free tier with 3 monthly credits" ``` --- ### Task 3: Remove the server hard paywall (guests stay blocked) **Files:** - Modify: `server/index.js:206-218` (helpers), `:734` (scan), `:742` (scan model), `:906` (semantic search), `:946` (health check) - [ ] **Step 1: Replace the pro-gate helper with a guest gate** Replace lines 206-218 (`createHardPaywallError` + `ensureActiveProEntitlement`) with: ```js const ensureNotGuest = (userId, requiredCredits) => { // Guests use the client-side demo scan; the shared 'guest' billing account // must never consume real credits or run free AI analyses. if (isGuest(userId)) { const error = new Error('Sign in to use scan credits.'); error.code = 'INSUFFICIENT_CREDITS'; error.status = 402; error.metadata = { required: requiredCredits, available: 0 }; throw error; } }; ``` Note: `isGuest` is defined at line 353, *after* this helper — that's fine (function hoisting via `const` arrow does NOT hoist; `ensureNotGuest` is only *called* inside request handlers at runtime, long after module init, so the reference resolves). - [ ] **Step 2: Swap the three call sites** - `server/index.js:734`: `ensureActiveProEntitlement(accountSnapshot, SCAN_PRIMARY_COST);` → `ensureNotGuest(userId, SCAN_PRIMARY_COST);` - `server/index.js:906`: `ensureActiveProEntitlement(accountSnapshot, SEMANTIC_SEARCH_COST);` → `ensureNotGuest(userId, SEMANTIC_SEARCH_COST);` - `server/index.js:946`: `ensureActiveProEntitlement(accountSnapshot, HEALTH_CHECK_COST);` → `ensureNotGuest(userId, HEALTH_CHECK_COST);` Search for any remaining `ensureActiveProEntitlement` references: `grep -n ensureActiveProEntitlement server/index.js` → must return nothing. - [ ] **Step 3: Same scan model for free users** `server/index.js:742`, replace: ```js const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; ``` with: ```js // Free tier gets the same model quality; quantity (3 credits/month) is the differentiator. const scanPlan = 'pro'; ``` The low-confidence AI review pass at line 789 (`shouldReview && accountSnapshot.plan === 'pro'`) stays unchanged — review remains pro-only. - [ ] **Step 4: Verify** Run: `cd server && npm test` → PASS. Run: `node -e "require('./server/index.js')"` is NOT possible (starts the server); instead do a syntax check: `node --check server/index.js` → no output. - [ ] **Step 5: Commit** ```bash git add server/index.js git commit -m "feat(server): remove hard paywall — credits gate scans, guests stay blocked, free tier gets pro model" ``` --- ### Task 4: Remove the client hard-paywall redirect **Files:** - Modify: `app/_layout.tsx:120-175` (gating), also register two new routes used by later tasks - [ ] **Step 1: Delete the entitlement gate** In `app/_layout.tsx`: 1. Remove the unused pieces from the `useApp()` destructuring if they become unused (`billingSummary`, `isActivatingEntitlement`, `isLoadingBilling` are only used by the gate — check with grep before removing). 2. Delete the `hasActiveEntitlement` computation (lines 121-123) and the `isAllowedWithoutEntitlement` block (lines 129-133). 3. Delete the entire `else if (!hasActiveEntitlement && ...)` branch (lines 169-175) so a session always renders the full tab stack. - [ ] **Step 2: Register the new onboarding routes** In BOTH `` blocks (session-less stack and main stack), next to the existing onboarding screens, add: ```tsx ``` (The route files are created in Tasks 11/13; expo-router tolerates registered-but-missing screens at typecheck level since names are strings, but the app won't navigate there until the files exist.) - [ ] **Step 3: Verify** Run: `npx tsc --noEmit` (or `npx expo export --platform android` for a full check) Expected: no NEW errors versus the pre-change state. - [ ] **Step 4: Commit** ```bash git add app/_layout.tsx git commit -m "feat(app): remove hard-paywall redirect — signed-in free users reach the app" ``` --- ### Task 5: Out-of-credits bottom sheet component **Design:** `design/stitch-onboarding/out_of_credits/screen.png` (+ `_dark_mode`, `code.html`). **Files:** - Create: `components/OutOfCreditsSheet.tsx` - [ ] **Step 1: Create the component** ```tsx import React from 'react'; import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Language } from '../types'; import { useColors } from '../constants/Colors'; type ColorsType = ReturnType; const getCopy = (language: Language) => { if (language === 'de') { return { title: 'Deine Gratis-Scans sind aufgebraucht', body: (date: string) => `Deine 3 Gratis-Scans erneuern sich am ${date}. Hol dir Pro für unbegrenztes Scannen.`, bodyNoDate: 'Hol dir Pro für unbegrenztes Scannen und deinen 7-Tage-Rettungsplan.', cta: 'Pro-Pläne ansehen', topupsLabel: 'Oder einzelne Credits kaufen', later: 'Vielleicht später', best: 'BESTE WAHL', credits: 'Credits', }; } if (language === 'es') { return { title: 'Se acabaron tus escaneos gratis', body: (date: string) => `Tus 3 escaneos gratis se renuevan el ${date}. Pásate a Pro para escanear sin límites.`, bodyNoDate: 'Pásate a Pro para escanear sin límites.', cta: 'Ver planes Pro', topupsLabel: 'O compra créditos sueltos', later: 'Quizás más tarde', best: 'MEJOR OPCIÓN', credits: 'créditos', }; } return { title: "You're out of free scans", body: (date: string) => `Your 3 free scans renew on ${date}. Upgrade to Pro for unlimited scanning.`, bodyNoDate: 'Upgrade to Pro for unlimited scanning.', cta: 'See Pro Plans', topupsLabel: 'Or buy single credits', later: 'Maybe later', best: 'BEST', credits: 'credits', }; }; const formatRenewalDate = (iso: string | null | undefined, language: Language): string | null => { if (!iso) return null; const date = new Date(iso); if (Number.isNaN(date.getTime())) return null; const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); }; type Props = { visible: boolean; language: Language; colors: ColorsType; renewsAtIso?: string | null; onSeePlans: () => void; onTopup: (productId: 'topup_small' | 'topup_medium' | 'topup_large') => void; onDismiss: () => void; }; const TOPUPS = [ { id: 'topup_small' as const, amount: 30, best: false }, { id: 'topup_medium' as const, amount: 100, best: false }, { id: 'topup_large' as const, amount: 250, best: true }, ]; export function OutOfCreditsSheet({ visible, language, colors, renewsAtIso, onSeePlans, onTopup, onDismiss }: Props) { const copy = getCopy(language); const renewalDate = formatRenewalDate(renewsAtIso, language); return ( 0 {copy.title} {renewalDate ? copy.body(renewalDate) : copy.bodyNoDate} {copy.cta} {copy.topupsLabel.toUpperCase()} {TOPUPS.map((topup) => ( onTopup(topup.id)} activeOpacity={0.85} > {topup.best && ( {copy.best} )} +{topup.amount} {copy.credits} ))} {copy.later} ); } const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'rgba(10,17,11,0.45)', justifyContent: 'flex-end' }, backdropTouchable: { flex: 1 }, sheet: { borderTopLeftRadius: 26, borderTopRightRadius: 26, paddingHorizontal: 24, paddingTop: 10, paddingBottom: 34, alignItems: 'center' }, handle: { width: 44, height: 5, borderRadius: 3, marginBottom: 18 }, iconWrap: { marginBottom: 14 }, iconCircle: { width: 76, height: 76, borderRadius: 38, alignItems: 'center', justifyContent: 'center' }, zeroBadge: { position: 'absolute', top: -2, right: -4, backgroundColor: '#C62828', width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' }, zeroBadgeText: { color: '#fff', fontSize: 13, fontWeight: '900' }, title: { fontSize: 24, fontWeight: '900', textAlign: 'center', marginBottom: 8 }, body: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18, maxWidth: 320 }, cta: { alignSelf: 'stretch', height: 56, borderRadius: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, marginBottom: 16 }, ctaText: { fontSize: 17, fontWeight: '800' }, topupsLabel: { fontSize: 11, fontWeight: '800', letterSpacing: 0.8, marginBottom: 10 }, topupRow: { flexDirection: 'row', gap: 10, alignSelf: 'stretch', marginBottom: 14 }, 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' }, bestBadgeText: { fontSize: 9, fontWeight: '900', letterSpacing: 0.6 }, topupAmount: { fontSize: 22, fontWeight: '900', marginTop: 6 }, topupUnit: { fontSize: 12, fontWeight: '600' }, laterBtn: { paddingVertical: 8 }, laterText: { fontSize: 15, fontWeight: '800' }, }); ``` - [ ] **Step 2: Verify it compiles** Run: `npx tsc --noEmit` → no new errors. - [ ] **Step 3: Commit** ```bash git add components/OutOfCreditsSheet.tsx git commit -m "feat(app): out-of-credits bottom sheet (Stitch design)" ``` --- ### Task 6: Scanner — demo mode for guests only, credits + sheet for free users **Files:** - Modify: `app/scanner.tsx:168-172` (mode flags), `:269-297` (pre-checks), `:414-425` (402 handler) - [ ] **Step 1: Change the mode flags** Replace lines 168-172: ```tsx const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro' && billingSummary?.entitlement?.status === 'active'; const isDemoMode = !session; // guests get the local demo scan; signed-in users burn real credits const availableCredits = billingSummary?.credits.available ?? 0; const demoScansRemaining = Math.max(0, DEMO_SCAN_LIMIT - guestScanCount); ``` Keep `hasActiveEntitlement` only if still referenced elsewhere in the file (grep first; it is used for UI hints — keep it). - [ ] **Step 2: Add sheet state and handlers** Near the other `useState` calls (~line 181): ```tsx const [outOfCreditsVisible, setOutOfCreditsVisible] = useState(false); ``` Import the sheet at the top of the file: ```tsx import { OutOfCreditsSheet } from '../components/OutOfCreditsSheet'; ``` - [ ] **Step 3: Replace the out-of-credits Alert pre-check** Replace the `if (!isDemoMode && availableCredits <= 0)` block (lines 284-297) — health checks cost 2, scans cost 1, so check against the actual cost: ```tsx const requiredCredits = isHealthMode ? 2 : 1; if (!isDemoMode && availableCredits < requiredCredits) { posthog.capture('out_of_credits_shown', { trigger: 'pre_check', scan_type: isHealthMode ? 'health_check' : 'identification' }); setOutOfCreditsVisible(true); return; } ``` - [ ] **Step 4: Replace the 402 error Alert** In the catch block, replace the `if (isInsufficientCreditsError(error))` Alert (lines 414-425): ```tsx if (isInsufficientCreditsError(error)) { posthog.capture('out_of_credits_shown', { trigger: 'server_402', scan_type: isHealthMode ? 'health_check' : 'identification' }); setOutOfCreditsVisible(true); } ``` - [ ] **Step 5: Render the sheet** At the end of the component's JSX (inside the root view, after the existing modals), add: ```tsx { setOutOfCreditsVisible(false); posthog.capture('paywall_opened', { source: 'out_of_credits' }); router.push('/profile/billing?view=paywall'); }} onTopup={() => { setOutOfCreditsVisible(false); router.push('/profile/billing'); // topups live on the billing management screen }} onDismiss={() => { posthog.capture('paywall_dismissed', { source: 'out_of_credits_sheet' }); setOutOfCreditsVisible(false); }} /> ``` (`language` is already available from `useApp()`; verify `posthog` is the `useSafeAnalytics()` instance already present in this file.) - [ ] **Step 6: Credits badge for free users** The scanner top bar already renders `demoCreditsRemaining(count)` for demo mode. Find that render (search `demoCreditsRemaining`) and extend the condition: demo mode shows demo scans; signed-in **free** users show real credits using the same pill UI: ```tsx {isDemoMode ? {billingCopy.demoCreditsRemaining(demoScansRemaining)} : !hasActiveEntitlement ? {billingCopy.demoCreditsRemaining(availableCredits).replace('Demo-', '').replace('demo ', '')} : null} ``` Cleaner: add a `creditsRemaining: (count: number) => string` entry to the scanner's `getBillingCopy` copy objects (de: `` `${count} Scans übrig` ``, es: `` `${count} escaneos restantes` ``, en: `` `${count} scans left` ``) and use that instead of string surgery. - [ ] **Step 7: Verify + commit** Run: `npx tsc --noEmit` → no new errors. Manually: in Expo Go with a signed-in free account, scanning calls the real backend and shows the sheet at 0 credits. ```bash git add app/scanner.tsx git commit -m "feat(app): scanner uses real credits for free users, demo mode only for guests" ``` --- ### Task 7: Param-driven paywall trigger + dismiss behavior in billing.tsx **Files:** - Modify: `app/profile/billing.tsx:353-470` (trigger + back handling) - [ ] **Step 1: Read route params** Add to the imports from `expo-router`: `useLocalSearchParams`. Inside the component (near line 353): ```tsx const params = useLocalSearchParams<{ view?: string; context?: string }>(); const paywallRequested = params.view === 'paywall'; const onboardingContext = params.context === 'onboarding'; ``` - [ ] **Step 2: Change the paywall trigger** Replace line 360: ```tsx const showPaywallPlans = (!session || paywallRequested) && (!isLoadingBilling || !session) && planId !== 'pro'; ``` Semantics: guests always get the paywall view (unchanged); signed-in non-pro users get it **only** when routed with `view=paywall`; pro users never. - [ ] **Step 3: Dismiss behavior** Replace `handleBack` (lines 446-456): ```tsx const handleBack = useCallback(() => { if (showPaywallPlans) { posthog.capture('paywall_dismissed', { context: onboardingContext ? 'onboarding' : 'in_app' }); if (onboardingContext) { router.replace('/auth/signup'); return; } if (session) { if (router.canGoBack()) router.back(); else router.replace('/(tabs)'); return; } router.replace('/onboarding'); return; } if (router.canGoBack()) { router.back(); return; } router.replace('/(tabs)'); }, [router, showPaywallPlans, onboardingContext, session, posthog]); ``` Update the hardware-back handler (lines 458-470) to call `handleBack()` instead of its own `router.replace('/onboarding')`: ```tsx useFocusEffect( useCallback(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { if (!showPaywallPlans) return false; handleBack(); return true; }); return () => subscription.remove(); }, [showPaywallPlans, handleBack]), ); ``` - [ ] **Step 4: Post-purchase routing in onboarding context** In `handlePurchase` and `completeExpoGoSimulation`, the success paths call `router.replace('/(tabs)')` (lines 480, 544). When `onboardingContext` is true the user has no account yet — route to sign-up instead: ```tsx const postPurchaseRoute = onboardingContext ? '/auth/signup' : '/(tabs)'; ``` Define once near `handleBack` and use it at both call sites (`router.replace(postPurchaseRoute)` / `setTimeout(() => router.replace(postPurchaseRoute), 0)`). Note: purchasing before sign-up creates an anonymous RevenueCat user; the existing restore/sync flow reconciles after account creation. This mirrors the Learna flow (paywall before signup). - [ ] **Step 5: Verify + commit** `npx tsc --noEmit` → clean. Manual: `/profile/billing` as free signed-in user shows the management view; `/profile/billing?view=paywall` shows the paywall; ✕ returns to tabs. ```bash git add app/profile/billing.tsx git commit -m "feat(app): paywall is param-driven and dismissible; onboarding context routes X to sign-up" ``` --- ### Task 8: Paywall UI — single plan card + free-trial toggle (Stitch design) **Design:** `design/stitch-onboarding/greenlens_pro_paywall/{screen.png,code.html}`. **Files:** - Modify: `app/profile/billing.tsx` — paywall JSX branch (starts at line 624 `if (showPaywallPlans)`) and `getBillingCopy` - [ ] **Step 1: Add copy keys** In `getBillingCopy`, add to each language object (de/es/en shown; keep existing keys untouched): ``` de: paywallEyebrow: 'GreenLens Pro', paywallHeadline: 'Unbegrenzter Zugriff', paywallSub: 'Unbegrenzte Scans, Health-Checks und dein persönlicher Pflegeplan.', planCardTitle: 'GreenLens Pro', planCardBody: 'Unbegrenzte KI-Scans, Gesundheitsdiagnose, 7-Tage-Rettungspläne, 100 Credits/Monat', planCardPriceTrial: (price: string) => `7 Tage gratis, dann ${price}/Jahr`, planCardPriceMonthly: (price: string) => `${price}/Monat`, trialToggleLabel: 'Gratis-Test aktiviert', dueTodayTrial: 'Fällig heute — 7 Tage gratis', dueTodayAmount: '0,00 €', dueLater: (date: string) => `Fällig am ${date}`, ctaTrial: 'Gratis testen', ctaMonthly: 'Jetzt starten', cancelAnytime: 'Jederzeit kündbar', es: paywallEyebrow: 'GreenLens Pro', paywallHeadline: 'Acceso ilimitado', paywallSub: 'Escaneos ilimitados, chequeos de salud y tu plan de cuidados personal.', planCardTitle: 'GreenLens Pro', planCardBody: 'Escaneos IA ilimitados, diagnóstico de salud, planes de rescate de 7 días, 100 créditos/mes', planCardPriceTrial: (price: string) => `7 días gratis, luego ${price}/año`, planCardPriceMonthly: (price: string) => `${price}/mes`, trialToggleLabel: 'Prueba gratis activada', dueTodayTrial: 'Hoy — 7 días gratis', dueTodayAmount: '0,00 €', dueLater: (date: string) => `El ${date}`, ctaTrial: 'Probar gratis', ctaMonthly: 'Empezar ahora', cancelAnytime: 'Cancela cuando quieras', en: paywallEyebrow: 'GreenLens Pro', paywallHeadline: 'Get Unlimited Access', paywallSub: 'Unlimited scans, health checks and your personal care plan.', planCardTitle: 'GreenLens Pro', planCardBody: 'Unlimited AI scans, health diagnosis, 7-day rescue plans, 100 credits/month', planCardPriceTrial: (price: string) => `Free for 7 days, then ${price}/year`, planCardPriceMonthly: (price: string) => `${price}/month`, trialToggleLabel: 'Free Trial Enabled', dueTodayTrial: 'Due today — 7 days free', dueTodayAmount: '€0.00', dueLater: (date: string) => `Due ${date}`, ctaTrial: 'Try Free', ctaMonthly: 'Start Now', cancelAnytime: 'Cancel Anytime', ``` (Use real object syntax; the block above lists the key/value pairs to add per language. Type additions must be reflected wherever the copy object type is inferred — it's inferred from the return values, so just keep all three languages structurally identical.) - [ ] **Step 2: Trial toggle state mapping** `selectedPaywallPlan` already exists (`'yearly' | 'weekly'`, default `'yearly'` at line 353). Map the toggle onto it — toggle ON ⇔ `'yearly'` (has the 7-day trial), OFF ⇔ monthly. Add near the derived values (line 425ff): ```tsx const trialEnabled = selectedPaywallPlan === 'yearly'; const trialEndDate = useMemo(() => { const date = new Date(); date.setDate(date.getDate() + 7); const locale = language === 'de' ? 'de-DE' : language === 'es' ? 'es-ES' : 'en-US'; return date.toLocaleDateString(locale, { day: 'numeric', month: 'long' }); }, [language]); ``` - [ ] **Step 3: Replace the paywall JSX** Inside the `if (showPaywallPlans)` branch, keep the existing `ImageBackground` header with `PAYWALL_BACKGROUND`, the ✕ (`handleBack`) and Restore (`handleRestore`) buttons — restyle the body below the hero to: ```tsx {copy.paywallEyebrow.toUpperCase()} {copy.paywallHeadline} {copy.paywallSub} {copy.planCardTitle} {copy.planCardBody} {trialEnabled ? copy.planCardPriceTrial(yearlyPrice) : copy.planCardPriceMonthly(monthlyPrice)} {copy.trialToggleLabel} setSelectedPaywallPlan(next ? 'yearly' : 'weekly')} trackColor={{ true: colors.primary, false: colors.border }} thumbColor="#FFFFFF" /> {trialEnabled ? ( {copy.dueTodayTrial} {copy.dueTodayAmount} {copy.dueLater(trialEndDate)} {yearlyPrice} ) : null} handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')} disabled={isUpdating} activeOpacity={0.86} > {isUpdating ? : ( {trialEnabled ? copy.ctaTrial : copy.ctaMonthly} )} Privacy | Terms {copy.cancelAnytime} ``` Add `Switch` to the `react-native` import. Delete the old two-card weekly/yearly selector JSX and any styles that become unused (verify with `npx tsc --noEmit` + eslint if configured). Add the new styles to the StyleSheet: ```tsx paywallBody: { paddingHorizontal: 22, paddingTop: 10, paddingBottom: 24 }, paywallEyebrow: { fontSize: 12, fontWeight: '900', letterSpacing: 1.4, textAlign: 'center', marginBottom: 6 }, paywallHeadline: { fontSize: 32, fontWeight: '900', textAlign: 'center', marginBottom: 6 }, paywallSub: { fontSize: 15, lineHeight: 21, textAlign: 'center', marginBottom: 18 }, planCard: { borderRadius: 16, padding: 18, marginBottom: 14 }, planCardTitle: { fontSize: 19, fontWeight: '800', marginBottom: 6 }, planCardBody: { fontSize: 14, lineHeight: 20 }, planCardDivider: { height: StyleSheet.hairlineWidth, marginVertical: 12 }, planCardPrice: { fontSize: 15, fontWeight: '800' }, trialToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderRadius: 14, borderWidth: 1, paddingHorizontal: 16, paddingVertical: 12, marginBottom: 16 }, trialToggleLabel: { fontSize: 15, fontWeight: '800' }, dueTimeline: { marginBottom: 18, paddingHorizontal: 4 }, dueRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, dueDot: { width: 10, height: 10, borderRadius: 5 }, dueLine: { width: 2, height: 18, marginLeft: 4, marginVertical: 2 }, dueLabel: { flex: 1, fontSize: 14, fontWeight: '700' }, dueAmount: { fontSize: 14, fontWeight: '800' }, paywallCta: { height: 58, borderRadius: 14, alignItems: 'center', justifyContent: 'center', marginBottom: 12 }, paywallCtaText: { fontSize: 18, fontWeight: '800' }, paywallFooter: { flexDirection: 'row', justifyContent: 'space-between' }, paywallFooterText: { fontSize: 12, fontWeight: '600' }, ``` Also capture the paywall view with context (extend the existing `paywall_viewed` capture at line 414): ```tsx posthog.capture('paywall_viewed', { plan_id: planId, context: onboardingContext ? 'onboarding' : 'in_app', trial_enabled: trialEnabled }); ``` - [ ] **Step 4: Verify + commit** `npx tsc --noEmit` clean; visual check against `design/stitch-onboarding/greenlens_pro_paywall/screen.png` in Expo Go (light + dark). ```bash git add app/profile/billing.tsx git commit -m "feat(app): Stitch paywall — single plan card with free-trial toggle" ``` --- ### Task 9: Pre-auth onboarding answer buffer **Files:** - Create: `services/preAuthOnboardingService.ts` - Modify: `app/auth/signup.tsx`, `app/auth/login.tsx` (flush after auth) - [ ] **Step 1: Create the service** ```ts import AsyncStorage from '@react-native-async-storage/async-storage'; import { OnboardingProgressService } from './onboardingProgressService'; const STORAGE_KEY = 'greenlens_preauth_onboarding_v1'; export type PreAuthAnswers = { acquisitionSource?: string; primaryGoal?: string; experienceLevel?: string; }; export const PreAuthOnboardingService = { async setAnswer(key: K, value: PreAuthAnswers[K]): Promise { const answers = await this.getAnswers(); answers[key] = value; await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(answers)); }, async getAnswers(): Promise { try { const raw = await AsyncStorage.getItem(STORAGE_KEY); return raw ? (JSON.parse(raw) as PreAuthAnswers) : {}; } catch { return {}; } }, // Persist buffered answers into the per-user profile after sign-up/login. async flushToProfile(userId: number): Promise { const answers = await this.getAnswers(); if (answers.acquisitionSource) OnboardingProgressService.setAcquisitionSource(userId, answers.acquisitionSource); if (answers.primaryGoal) OnboardingProgressService.setPrimaryGoal(userId, answers.primaryGoal); if (answers.experienceLevel) OnboardingProgressService.setExperienceLevel(userId, answers.experienceLevel); await AsyncStorage.removeItem(STORAGE_KEY); }, }; ``` - [ ] **Step 2: Flush after successful auth** In `app/auth/signup.tsx` and `app/auth/login.tsx`, locate every success path that establishes a session (email flow and Apple flow — in signup.tsx around lines 88 and 128-143; find the login.tsx equivalents by searching `router.replace`). Immediately after the session is available and **before** navigation, add: ```ts if (session?.userId) { await PreAuthOnboardingService.flushToProfile(session.userId).catch(() => {}); } ``` (Import `PreAuthOnboardingService` in both files. `session.userId` is the local numeric id used by `OnboardingProgressService` — confirm the variable name in each success handler; in signup.tsx the created session object is in scope.) - [ ] **Step 3: Verify + commit** `npx tsc --noEmit` clean. ```bash git add services/preAuthOnboardingService.ts app/auth/signup.tsx app/auth/login.tsx git commit -m "feat(app): buffer onboarding answers before auth, flush to profile after sign-up/login" ``` --- ### Task 10: Welcome screen redesign **Design:** `design/stitch-onboarding/welcome_to_greenlens/{screen.png,code.html}`. **Files:** - Modify: `app/onboarding.tsx` (full rewrite of the JSX; keep route + `useApp` usage) - [ ] **Step 1: Rewrite the screen** Layout per mockup: full-bleed hero (keep `assets/welcome_botanical_hero.png`; a brighter plant-room photo can be swapped in later without code changes), brand row top-left + rating pill top-right, floating testimonial card over the hero, bottom sheet with rounded top, headline, subline, primary "Let's Go" → `router.push('/onboarding/slides')`, "Log in" text link → `/auth/login`, small demo-scan link → `/scanner` (keeps the guest hook), legal text. Local copy object (pattern like the other screens): ```tsx const getWelcomeCopy = (language: Language) => { if (language === 'de') { return { headline: 'Willkommen bei GreenLens!', subline: 'Pflanzen erkennen, verstehen und pflegen — ganz einfach.', testimonial: '„Endlich überleben meine Pflanzen! Absolute Empfehlung."', testimonialAuthor: 'Anna M.', cta: "Los geht's", login: 'Anmelden', demoScan: 'Oder direkt eine Pflanze scannen', legal: 'Mit dem Fortfahren akzeptierst du unsere Datenschutzerklärung und AGB.', rating: '4,8', }; } if (language === 'es') { return { headline: '¡Bienvenido a GreenLens!', subline: 'Identifica, entiende y cuida tus plantas — sin esfuerzo.', testimonial: '"¡Por fin mis plantas sobreviven! Muy recomendable."', testimonialAuthor: 'Anna M.', cta: 'Empezar', login: 'Iniciar sesión', demoScan: 'O escanea una planta ahora', legal: 'Al continuar aceptas nuestra Política de privacidad y Términos.', rating: '4.8', }; } return { headline: 'Welcome to GreenLens!', subline: 'Identify, understand and care for your plants — effortlessly.', testimonial: '"Finally my plants stay alive! Highly recommend."', testimonialAuthor: 'Anna M.', cta: "Let's Go", login: 'Log in', demoScan: 'Or scan a plant right now', legal: 'By continuing you agree to our Privacy Policy and Terms.', rating: '4.8', }; }; ``` Structure (replace the current features list + auth row + subscription link): ```tsx export default function OnboardingScreen() { const { t, language } = useApp(); const posthog = useSafeAnalytics(); const copy = getWelcomeCopy(language); useEffect(() => { posthog.capture('onboarding_welcome_viewed'); }, [posthog]); return ( GreenLens {copy.rating} {copy.testimonial} {copy.testimonialAuthor} {[0, 1, 2, 3, 4].map((i) => )} {copy.headline} {copy.subline} { posthog.capture('onboarding_started'); router.push('/onboarding/slides'); }} activeOpacity={0.86} > {copy.cta} router.push('/auth/login')} style={styles.loginLink}> {copy.login} router.push('/scanner')} style={styles.demoLink}> {copy.demoScan} {copy.legal} ); } ``` Styles: keep the existing color values (`#fbfaf3` sheet, `#437824` primary, `#101c12` text). Sheet fixed to bottom with `borderTopLeftRadius/RightRadius: 28`. Sizes per `code.html`: headline 34/900, subline 16, CTA height 60 radius 16. Delete the old wave-SVG code and `react-native-svg` import if now unused. The welcome screen stays intentionally light-mode (photo + cream sheet), matching today's behavior. - [ ] **Step 2: Verify + commit** `npx tsc --noEmit` clean; visual check vs mockup. ```bash git add app/onboarding.tsx git commit -m "feat(app): Stitch welcome screen with social proof" ``` --- ### Task 11: Benefit slides **Design:** `design/stitch-onboarding/scan_any_plant`, `health_check_care_plan`, `never_forget_watering` (+ dark variants). **Files:** - Create: `app/onboarding/slides.tsx` - [ ] **Step 1: Create the screen** One route, internal page index (0-2). Per slide: top ~58% image area with **native** overlays (no baked-in text), bottom sheet with headline/body/progress-dots/Continue. Slide images: - Slide 1 (Scan): `assets/paywall_scan_background.png` + native green scan-frame (4 corner borders) + result chip "Monstera · 98%". - Slide 2 (Health): `assets/onboarding_health_scan_mockup.png` (clean render) — no overlay needed, plus a native white card "Health Check / Overwatering detected / 7-day rescue plan ready". - Slide 3 (Reminders): `assets/welcome_botanical_header.png` + two native reminder chips ("💧 Water Monstera — today", "🌿 Fertilize Basil — in 3 days"). Copy (de/es/en) per slide via local `getSlidesCopy(language)` (same pattern as Task 10; German: "Scanne jede Pflanze" / "Health Check & Pflegeplan" / "Nie mehr Gießen vergessen" with the sublines from the spec table; Spanish equivalents; English exactly per mockups). Skeleton: ```tsx export default function OnboardingSlidesScreen() { const { language, isDarkMode, colorPalette } = useApp(); const colors = useColors(isDarkMode, colorPalette); const posthog = useSafeAnalytics(); const [page, setPage] = useState(0); const copy = getSlidesCopy(language); const slide = copy.slides[page]; useEffect(() => { posthog.capture('onboarding_slide_viewed', { index: page }); }, [page, posthog]); const next = () => { if (page < copy.slides.length - 1) setPage(page + 1); else router.replace('/onboarding/source'); }; // render: image area with overlay per `page`, then sheet with slide.title, slide.body, // three dots (active = wide pill, colors.primary), Continue button. } ``` Overlay components live in the same file as small local components (`ScanFrameOverlay`, `HealthCardOverlay`, `ReminderChipsOverlay`) — each ~20-40 lines of absolutely-positioned Views, colors from theme tokens, following the mockups. Dots: inactive 8×8 circle `colors.border`, active 26×8 pill `colors.primary`. - [ ] **Step 2: Verify + commit** `npx tsc --noEmit`; swipe through all three slides in Expo Go, last Continue lands on `/onboarding/source`. ```bash git add app/onboarding/slides.tsx git commit -m "feat(app): benefit slides with native overlays" ``` --- ### Task 12: Question screens — Stitch restyle + rerouted chain + pre-auth buffering **Design:** `design/stitch-onboarding/personalization_question/{screen.png,code.html}` (+ dark). **Files:** - Create: `components/OnboardingQuestion.tsx` (shared layout) - Modify: `app/onboarding/source.tsx`, `app/onboarding/goal.tsx`, `app/onboarding/experience.tsx`, `app/onboarding/health-check.tsx` - [ ] **Step 1: Shared question layout component** ```tsx import React from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useColors } from '../constants/Colors'; type ColorsType = ReturnType; export type QuestionOption = { id: string; emoji: string; label: string; subtitle?: string }; type Props = { colors: ColorsType; isDarkMode: boolean; step: number; // 1-based totalSteps: number; title: string; subtitle: string; options: QuestionOption[]; selectedId: string | null; onSelect: (id: string) => void; onContinue: () => void; onBack?: () => void; continueLabel: string; skipLabel?: string; onSkip?: () => void; }; export function OnboardingQuestion({ colors, isDarkMode, step, totalSteps, title, subtitle, options, selectedId, onSelect, onContinue, onBack, continueLabel, skipLabel, onSkip, }: Props) { return ( {onBack ? ( ) : } {title} {subtitle} {options.map((option) => { const active = selectedId === option.id; return ( onSelect(option.id)} activeOpacity={0.85} style={[styles.card, { backgroundColor: active ? colors.primarySoft : colors.surface, borderColor: active ? colors.primary : 'transparent', }]} > {option.emoji} {option.label} {option.subtitle ? {option.subtitle} : null} ); })} {skipLabel && onSkip ? ( {skipLabel} ) : null} {continueLabel} ); } const styles = StyleSheet.create({ safe: { flex: 1, paddingHorizontal: 22 }, topBar: { flexDirection: 'row', alignItems: 'center', gap: 14, paddingVertical: 10 }, backBtn: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' }, progressTrack: { flex: 1, height: 6, borderRadius: 3, overflow: 'hidden' }, progressFill: { height: 6, borderRadius: 3 }, title: { fontSize: 30, lineHeight: 35, fontWeight: '900', textAlign: 'center', marginTop: 18, marginBottom: 8 }, subtitle: { fontSize: 15, lineHeight: 20, textAlign: 'center', marginBottom: 22 }, options: { gap: 12, flex: 1 }, card: { flexDirection: 'row', alignItems: 'center', gap: 14, borderRadius: 16, borderWidth: 2, paddingHorizontal: 16, paddingVertical: 18 }, emoji: { fontSize: 26 }, cardCopy: { flex: 1, gap: 2 }, cardLabel: { fontSize: 17, fontWeight: '800' }, cardSubtitle: { fontSize: 12.5, lineHeight: 16 }, footer: { gap: 8, paddingBottom: 6 }, skipBtn: { alignItems: 'center', paddingVertical: 6 }, skipText: { fontSize: 14, fontWeight: '700' }, cta: { height: 56, borderRadius: 14, alignItems: 'center', justifyContent: 'center' }, ctaText: { fontSize: 17, fontWeight: '800' }, }); ``` - [ ] **Step 2: Migrate the four screens** For each screen, keep: option ids, analytics `posthog.capture(...)` events with identical names/props, `OnboardingProgressService` writes (guarded by `session?.userId` as today), and the language copy content (move labels into `QuestionOption[]`, pick an emoji per option). Replace the JSX with ``; delete the per-screen hero `ImageBackground`, step pill and old styles. Per-screen specifics: - `source.tsx`: step 1/4, options from `SOURCE_OPTIONS` (emojis: 🏬 app_store, 📸 instagram, 🎵 tiktok, 👥 friend, 🔎 search, ✨ other), `onBack` → `router.back()`. In `finish(source)`, ADD buffering before navigation: `if (source) void PreAuthOnboardingService.setAnswer('acquisitionSource', source);` → route stays `/onboarding/goal`. - `goal.tsx`: step 2/4, buffer `primaryGoal`, route stays `/onboarding/experience`. - `experience.tsx`: step 3/4, buffer `experienceLevel`, route stays `/onboarding/health-check` (line 85, unchanged). - `health-check.tsx`: step 4/4. Change line 91 from `router.replace(hasActiveEntitlement ? '/(tabs)' : '/profile/billing')` to `router.replace('/onboarding/personalizing')`. If this screen isn't option-based (it promotes the first health scan), only restyle its header to the shared progress-bar pattern (reuse the `topBar` styles inline) and change the route — do not force it into `OnboardingQuestion`. - [ ] **Step 3: Verify + commit** `npx tsc --noEmit`; run the chain end-to-end in Expo Go (slides → source → goal → experience → health-check → personalizing route error is OK until Task 13). ```bash git add components/OnboardingQuestion.tsx app/onboarding/source.tsx app/onboarding/goal.tsx app/onboarding/experience.tsx app/onboarding/health-check.tsx git commit -m "feat(app): Stitch question screens with shared layout, pre-auth answer buffering" ``` --- ### Task 13: "Personalizing your plan…" progress screen **Design:** `design/stitch-onboarding/personalizing_your_plan/{screen.png,code.html}` (+ dark). **Files:** - Create: `app/onboarding/personalizing.tsx` - [ ] **Step 1: Create the screen** Behavior: percentage counts 0→100 over ~6s (Animated.Value + listener), four checklist rows tick sequentially at 25/50/75/95%, testimonial card + rating badge at the bottom, auto-advance on completion to `router.replace('/profile/billing?view=paywall&context=onboarding')`. ```tsx import React, { useEffect, useRef, useState } from 'react'; import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; import { useApp } from '../../context/AppContext'; import { useColors } from '../../constants/Colors'; import { useSafeAnalytics } from '../../services/analytics'; import { Language } from '../../types'; const getCopy = (language: Language) => { if (language === 'de') { return { status: 'Dein Pflegeplan wird personalisiert…', 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."', author: 'Elena R.', rating: '4,8 APP-STORE-BEWERTUNG', }; } if (language === 'es') { return { status: 'Personalizando tu plan de cuidados…', 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."', author: 'Elena R.', rating: '4.8 VALORACIÓN EN APP STORE', }; } return { status: 'Personalizing your care 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."', author: 'Elena R.', rating: '4.8 APP STORE RATING', }; }; const STEP_THRESHOLDS = [25, 50, 75, 95]; export default function OnboardingPersonalizingScreen() { const { language, isDarkMode, colorPalette } = useApp(); const colors = useColors(isDarkMode, colorPalette); const posthog = useSafeAnalytics(); const copy = getCopy(language); const progress = useRef(new Animated.Value(0)).current; const [percent, setPercent] = useState(0); const navigated = useRef(false); useEffect(() => { posthog.capture('onboarding_personalizing_viewed'); const listener = progress.addListener(({ value }) => setPercent(Math.round(value))); Animated.timing(progress, { toValue: 100, duration: 6000, easing: Easing.inOut(Easing.cubic), useNativeDriver: false, }).start(({ finished }) => { if (finished && !navigated.current) { navigated.current = true; setTimeout(() => { posthog.capture('paywall_opened', { source: 'onboarding' }); router.replace('/profile/billing?view=paywall&context=onboarding'); }, 450); } }); return () => progress.removeListener(listener); }, [progress, posthog]); return ( {percent}% {copy.status} {copy.steps.map((label, index) => { const done = percent >= STEP_THRESHOLDS[index]; return ( {label} ); })} {copy.author} {[0, 1, 2, 3, 4].map((i) => )} {copy.testimonial} {copy.rating} ); } const styles = StyleSheet.create({ safe: { flex: 1, alignItems: 'center', paddingHorizontal: 24, paddingTop: 30 }, percent: { fontSize: 56, fontWeight: '900', marginBottom: 16 }, ringWrap: { width: 150, height: 150, alignItems: 'center', justifyContent: 'center', marginBottom: 22 }, ring: { position: 'absolute', width: 150, height: 150, borderRadius: 75, borderWidth: 7 }, ringProgress: { position: 'absolute', width: 150, height: 150, borderRadius: 75, borderWidth: 7, borderTopColor: 'transparent', borderRightColor: 'transparent' }, ringImage: { width: 112, height: 112, borderRadius: 56 }, statusPill: { flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 999, paddingHorizontal: 18, paddingVertical: 11, marginBottom: 26 }, statusText: { fontSize: 14.5, fontWeight: '800' }, checklist: { alignSelf: 'stretch', gap: 15, marginBottom: 26, paddingHorizontal: 8 }, checkRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, checkLabel: { fontSize: 16.5, fontWeight: '700' }, testimonialCard: { alignSelf: 'stretch', borderRadius: 18, padding: 16, marginBottom: 14 }, testimonialHeader: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }, testimonialAuthor: { fontSize: 14.5, fontWeight: '800' }, starsRow: { flexDirection: 'row', gap: 2 }, testimonialText: { fontSize: 14, lineHeight: 20, fontStyle: 'italic' }, 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 }, }); ``` (The rotate-based ring is an approximation that reads well enough; if it looks wrong on device, fall back to a plain thick progress ring using `react-native-svg` `Circle` with `strokeDashoffset` — svg is already a dependency.) - [ ] **Step 2: Verify + commit** Run the flow: health-check → personalizing → lands on the onboarding-context paywall; ✕ there → sign-up. ```bash git add app/onboarding/personalizing.tsx git commit -m "feat(app): personalizing progress screen, auto-advances to paywall" ``` --- ### Task 14: Sign-up & login restyle + post-auth routing **Design:** `design/stitch-onboarding/sign_up_for_greenlens`, `login_to_greenlens` (+ dark). Background photos: use `assets/welcome_botanical_hero.png` (NOT the Stitch photos — they contain baked-in fake forms). **Files:** - Modify: `app/auth/signup.tsx`, `app/auth/login.tsx` - [ ] **Step 1: Sign-up restyle** Keep ALL existing logic (Apple flow, email flow, error handling, `flushToProfile` from Task 9). Restructure the JSX to the mockup: - Top ~40%: `ImageBackground` `welcome_botanical_hero.png` with dark gradient overlay, white text block: headline "Let's finish your setup!" + subline "Create an account to save your plants and 3 free scans per month." (de: "Erstelle einen Account und sichere dir 3 Gratis-Scans pro Monat." / es equivalent). No personal name greeting — the flow doesn't collect a name (deviation from mockup, agreed direction "Kleinigkeiten"). - Bottom sheet (cream, rounded top): full-width `AppleAuthentication.AppleAuthenticationButton` (`buttonType CONTINUE`, height 56, radius 14) when `appleAvailable`; "OR" divider; **two-step email**: a "Continue with Email" outlined button that toggles `emailExpanded` state revealing the existing email/password/(confirm) inputs + submit button; "Already have an account? Log in" link; legal text. - [ ] **Step 2: Post-auth routing** In `signup.tsx`: the success routes at lines 88 and 139 currently go to `/onboarding/source` (old post-auth questions) — change BOTH to `router.replace('/(tabs)')` (questions now happen pre-auth). Line 143's `router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing')` → `router.replace('/(tabs)')`. In `login.tsx`: find the success navigation(s) (search `router.replace`) and make sure they land on `/(tabs)` with no billing redirect. Add analytics: `posthog.capture('signup_screen_viewed', { context: 'onboarding' })` on mount if not already present. - [ ] **Step 3: Login restyle** Same visual pattern: hero ~35% with "Welcome back!" headline, sheet with Apple button, OR divider, email + password inputs (white, radius 14), "Forgot password?" link (keep existing handler if present), primary "Log in" button, "New here? Create account" link. - [ ] **Step 4: Verify + commit** `npx tsc --noEmit`; full flow test: personalizing → paywall → ✕ → sign-up (email) → lands in tabs with 3 credits; buffered answers flushed (check via profile/analytics or SQLite). ```bash git add app/auth/signup.tsx app/auth/login.tsx git commit -m "feat(app): Stitch sign-up/login screens, sign-up last in onboarding" ``` --- ### Task 15: Final verification - [ ] **Step 1: Server tests** Run: `cd server && npm test` → all pass. `node --check server/index.js` → clean. - [ ] **Step 2: App test suite vs baseline** Run: `npm test 2>&1 | tail -20`. Compare failures against the pre-change baseline (memory: 5 suites fail on clean tree). No NEW failures allowed; fix any regression you introduced. - [ ] **Step 3: Build check** Run: `npx expo export --platform android` → completes without errors. - [ ] **Step 4: Manual QA checklist (Expo Go / dev build)** 1. Fresh install → welcome (social proof) → Let's Go → 3 slides → 4 question steps with progress bar → personalizing (auto) → paywall with trial toggle → ✕ → sign-up → tabs. 2. New account has 3 credits; scan works and decrements; after 3 scans the out-of-credits sheet appears; "See Pro Plans" opens the paywall; ✕ returns to the scanner (app still usable). 3. Guest demo scan from welcome still works (5 local demo scans), server still 402s direct guest API calls. 4. Existing pro account: no paywall anywhere, unchanged manage view under Profile → billing. 5. Login as existing free user: lands in tabs (no hard-paywall redirect), sees credit badge in scanner. 6. Dark mode: slides, questions, personalizing, paywall, sheet all render with dark tokens (compare `_dark_mode` mockups). 7. Trial toggle: ON shows yearly price + due timeline, CTA "Try Free"; OFF shows monthly price, CTA "Start Now". Expo Go simulation path still works. - [ ] **Step 5: Update the spec status + commit** Set the spec's `Status:` line to `Implemented` and commit any doc changes: ```bash git add docs/ git commit -m "docs: mark onboarding/soft-paywall/free-tier spec as implemented" ``` --- ## Self-review notes (already applied) - Spec coverage: welcome ✓(T10) slides ✓(T11) questions ✓(T12) personalizing ✓(T13) paywall ✓(T7/8) sign-up/login ✓(T14) out-of-credits ✓(T5/6) free tier ✓(T1-3) soft gating ✓(T4/6) credits badge ✓(T6 step 6) analytics ✓(inline). Deviations from spec, both agreed-level "Kleinigkeiten": `customize.tsx` stays out of the chain (health-check is step 4), and sign-up shows no personal name (no name is collected). - Guest safety: T3 keeps guests blocked server-side (`ensureNotGuest`) — required because `getOrCreateAccount(db, 'guest')` would otherwise mint a shared free account. - Type consistency: `PreAuthOnboardingService` keys (`acquisitionSource`/`primaryGoal`/`experienceLevel`) match `OnboardingProgressService` setters; `OutOfCreditsSheet` props match the T6 call site; paywall param names (`view`, `context`) consistent across T6/T7/T13.