From 222d885107aedbf061b54a1673945313e754c25b Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 6 Jul 2026 12:30:36 +0200 Subject: [PATCH] Add implementation plan for onboarding redesign, soft paywall and free tier Co-Authored-By: Claude Fable 5 --- ...07-06-onboarding-soft-paywall-free-tier.md | 1505 +++++++++++++++++ 1 file changed, 1505 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-06-onboarding-soft-paywall-free-tier.md diff --git a/docs/superpowers/plans/2026-07-06-onboarding-soft-paywall-free-tier.md b/docs/superpowers/plans/2026-07-06-onboarding-soft-paywall-free-tier.md new file mode 100644 index 0000000..6b9e84f --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-onboarding-soft-paywall-free-tier.md @@ -0,0 +1,1505 @@ +# 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.