Files
Greenlens/docs/superpowers/plans/2026-07-06-onboarding-soft-paywall-free-tier.md
2026-07-06 12:30:36 +02:00

1506 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<name>/{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 `<Stack>` blocks (session-less stack and main stack), next to the existing onboarding screens, add:
```tsx
<Stack.Screen name="onboarding/slides" options={{ animation: 'slide_from_right' }} />
<Stack.Screen name="onboarding/personalizing" options={{ animation: 'slide_from_right' }} />
```
(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<typeof useColors>;
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 (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onDismiss}>
<View style={styles.backdrop}>
<TouchableOpacity style={styles.backdropTouchable} activeOpacity={1} onPress={onDismiss} />
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
<View style={[styles.handle, { backgroundColor: colors.border }]} />
<View style={styles.iconWrap}>
<View style={[styles.iconCircle, { backgroundColor: colors.primarySoft }]}>
<Ionicons name="leaf-outline" size={34} color={colors.primary} />
</View>
<View style={styles.zeroBadge}>
<Text style={styles.zeroBadgeText}>0</Text>
</View>
</View>
<Text style={[styles.title, { color: colors.text }]}>{copy.title}</Text>
<Text style={[styles.body, { color: colors.textSecondary }]}>
{renewalDate ? copy.body(renewalDate) : copy.bodyNoDate}
</Text>
<TouchableOpacity style={[styles.cta, { backgroundColor: colors.primary }]} onPress={onSeePlans} activeOpacity={0.86}>
<Ionicons name="ribbon-outline" size={19} color={colors.onPrimary} />
<Text style={[styles.ctaText, { color: colors.onPrimary }]}>{copy.cta}</Text>
</TouchableOpacity>
<Text style={[styles.topupsLabel, { color: colors.textMuted }]}>{copy.topupsLabel.toUpperCase()}</Text>
<View style={styles.topupRow}>
{TOPUPS.map((topup) => (
<TouchableOpacity
key={topup.id}
style={[styles.topupChip, { borderColor: topup.best ? colors.primary : colors.border, backgroundColor: colors.surfaceMuted }]}
onPress={() => onTopup(topup.id)}
activeOpacity={0.85}
>
{topup.best && (
<View style={[styles.bestBadge, { backgroundColor: colors.primary }]}>
<Text style={[styles.bestBadgeText, { color: colors.onPrimary }]}>{copy.best}</Text>
</View>
)}
<Text style={[styles.topupAmount, { color: colors.primary }]}>+{topup.amount}</Text>
<Text style={[styles.topupUnit, { color: colors.textSecondary }]}>{copy.credits}</Text>
</TouchableOpacity>
))}
</View>
<TouchableOpacity onPress={onDismiss} style={styles.laterBtn}>
<Text style={[styles.laterText, { color: colors.primary }]}>{copy.later}</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
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
<OutOfCreditsSheet
visible={outOfCreditsVisible}
language={language}
colors={colors}
renewsAtIso={billingSummary?.credits.cycleEndsAt}
onSeePlans={() => {
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
? <Text style={...}>{billingCopy.demoCreditsRemaining(demoScansRemaining)}</Text>
: !hasActiveEntitlement
? <Text style={...}>{billingCopy.demoCreditsRemaining(availableCredits).replace('Demo-', '').replace('demo ', '')}</Text>
: 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
<View style={styles.paywallBody}>
<Text style={[styles.paywallEyebrow, { color: colors.primary }]}>{copy.paywallEyebrow.toUpperCase()}</Text>
<Text style={[styles.paywallHeadline, { color: colors.text }]}>{copy.paywallHeadline}</Text>
<Text style={[styles.paywallSub, { color: colors.textSecondary }]}>{copy.paywallSub}</Text>
<View style={[styles.planCard, { backgroundColor: colors.surfaceMuted }]}>
<Text style={[styles.planCardTitle, { color: colors.text }]}>{copy.planCardTitle}</Text>
<Text style={[styles.planCardBody, { color: colors.textSecondary }]}>{copy.planCardBody}</Text>
<View style={[styles.planCardDivider, { backgroundColor: colors.border }]} />
<Text style={[styles.planCardPrice, { color: colors.text }]}>
{trialEnabled ? copy.planCardPriceTrial(yearlyPrice) : copy.planCardPriceMonthly(monthlyPrice)}
</Text>
</View>
<View style={[styles.trialToggleRow, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<Text style={[styles.trialToggleLabel, { color: colors.text }]}>{copy.trialToggleLabel}</Text>
<Switch
value={trialEnabled}
onValueChange={(next) => setSelectedPaywallPlan(next ? 'yearly' : 'weekly')}
trackColor={{ true: colors.primary, false: colors.border }}
thumbColor="#FFFFFF"
/>
</View>
{trialEnabled ? (
<View style={styles.dueTimeline}>
<View style={styles.dueRow}>
<View style={[styles.dueDot, { backgroundColor: colors.primary }]} />
<Text style={[styles.dueLabel, { color: colors.primary }]}>{copy.dueTodayTrial}</Text>
<Text style={[styles.dueAmount, { color: colors.text }]}>{copy.dueTodayAmount}</Text>
</View>
<View style={[styles.dueLine, { backgroundColor: colors.border }]} />
<View style={styles.dueRow}>
<View style={[styles.dueDot, { backgroundColor: colors.border }]} />
<Text style={[styles.dueLabel, { color: colors.textSecondary }]}>{copy.dueLater(trialEndDate)}</Text>
<Text style={[styles.dueAmount, { color: colors.text }]}>{yearlyPrice}</Text>
</View>
</View>
) : null}
<TouchableOpacity
style={[styles.paywallCta, { backgroundColor: colors.primary }]}
onPress={() => handlePurchase(trialEnabled ? 'yearly_pro' : 'monthly_pro')}
disabled={isUpdating}
activeOpacity={0.86}
>
{isUpdating ? <ActivityIndicator color={colors.onPrimary} /> : (
<Text style={[styles.paywallCtaText, { color: colors.onPrimary }]}>
{trialEnabled ? copy.ctaTrial : copy.ctaMonthly}
</Text>
)}
</TouchableOpacity>
<View style={styles.paywallFooter}>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>Privacy | Terms</Text>
<Text style={[styles.paywallFooterText, { color: colors.textMuted }]}>{copy.cancelAnytime}</Text>
</View>
</View>
```
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<K extends keyof PreAuthAnswers>(key: K, value: PreAuthAnswers[K]): Promise<void> {
const answers = await this.getAnswers();
answers[key] = value;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(answers));
},
async getAnswers(): Promise<PreAuthAnswers> {
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<void> {
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 (
<View style={styles.container}>
<ImageBackground source={require('../assets/welcome_botanical_hero.png')} style={styles.hero} resizeMode="cover">
<SafeAreaView style={styles.heroSafe}>
<View style={styles.heroTopRow}>
<View style={styles.brandRow}>
<Image source={require('../assets/icon.png')} style={styles.logo} resizeMode="cover" />
<Text style={styles.brandName}>Green<Text style={styles.brandAccent}>Lens</Text></Text>
</View>
<View style={styles.ratingPill}>
<Ionicons name="star" size={13} color="#f5c04e" />
<Text style={styles.ratingText}>{copy.rating}</Text>
</View>
</View>
<View style={styles.testimonialCard}>
<Text style={styles.testimonialText}>{copy.testimonial}</Text>
<View style={styles.testimonialMeta}>
<Text style={styles.testimonialAuthor}>{copy.testimonialAuthor}</Text>
<View style={styles.starsRow}>
{[0, 1, 2, 3, 4].map((i) => <Ionicons key={i} name="star" size={14} color="#f5c04e" />)}
</View>
</View>
</View>
</SafeAreaView>
</ImageBackground>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<Text style={styles.headline}>{copy.headline}</Text>
<Text style={styles.subline}>{copy.subline}</Text>
<TouchableOpacity
style={styles.cta}
onPress={() => { posthog.capture('onboarding_started'); router.push('/onboarding/slides'); }}
activeOpacity={0.86}
>
<Text style={styles.ctaText}>{copy.cta}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')} style={styles.loginLink}>
<Text style={styles.loginText}>{copy.login}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/scanner')} style={styles.demoLink}>
<Ionicons name="scan-outline" size={16} color="#4b7c31" />
<Text style={styles.demoText}>{copy.demoScan}</Text>
</TouchableOpacity>
<Text style={styles.legal}>{copy.legal}</Text>
</View>
</View>
);
}
```
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<typeof useColors>;
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 (
<SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]} edges={['top', 'left', 'right', 'bottom']}>
<View style={styles.topBar}>
{onBack ? (
<TouchableOpacity onPress={onBack} style={[styles.backBtn, { backgroundColor: colors.surface }]}>
<Ionicons name="arrow-back" size={20} color={colors.primary} />
</TouchableOpacity>
) : <View style={styles.backBtn} />}
<View style={[styles.progressTrack, { backgroundColor: colors.primarySoft }]}>
<View style={[styles.progressFill, { backgroundColor: colors.primary, width: `${Math.round((step / totalSteps) * 100)}%` }]} />
</View>
<View style={styles.backBtn} />
</View>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>{subtitle}</Text>
<View style={styles.options}>
{options.map((option) => {
const active = selectedId === option.id;
return (
<TouchableOpacity
key={option.id}
onPress={() => onSelect(option.id)}
activeOpacity={0.85}
style={[styles.card, {
backgroundColor: active ? colors.primarySoft : colors.surface,
borderColor: active ? colors.primary : 'transparent',
}]}
>
<Text style={styles.emoji}>{option.emoji}</Text>
<View style={styles.cardCopy}>
<Text style={[styles.cardLabel, { color: active ? colors.primary : colors.text }]}>{option.label}</Text>
{option.subtitle ? <Text style={[styles.cardSubtitle, { color: colors.textMuted }]}>{option.subtitle}</Text> : null}
</View>
</TouchableOpacity>
);
})}
</View>
<View style={styles.footer}>
{skipLabel && onSkip ? (
<TouchableOpacity onPress={onSkip} style={styles.skipBtn}>
<Text style={[styles.skipText, { color: colors.textMuted }]}>{skipLabel}</Text>
</TouchableOpacity>
) : null}
<TouchableOpacity
onPress={onContinue}
disabled={!selectedId}
activeOpacity={0.86}
style={[styles.cta, { backgroundColor: selectedId ? colors.primary : colors.surfaceMuted }]}
>
<Text style={[styles.ctaText, { color: selectedId ? colors.onPrimary : colors.textMuted }]}>{continueLabel}</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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 `<OnboardingQuestion …>`; 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 (
<SafeAreaView style={[styles.safe, { backgroundColor: isDarkMode ? '#0a110b' : '#f8fbef' }]}>
<Text style={[styles.percent, { color: colors.primary }]}>{percent}%</Text>
<View style={styles.ringWrap}>
<View style={[styles.ring, { borderColor: colors.primarySoft }]} />
<View style={[styles.ringProgress, { borderColor: colors.primary, transform: [{ rotate: `${(percent / 100) * 360}deg` }] }]} />
<Image source={require('../../assets/paywall_scan_background.png')} style={styles.ringImage} />
</View>
<View style={[styles.statusPill, { backgroundColor: colors.surfaceMuted }]}>
<Ionicons name="sync-outline" size={15} color={colors.textSecondary} />
<Text style={[styles.statusText, { color: colors.textSecondary }]}>{copy.status}</Text>
</View>
<View style={styles.checklist}>
{copy.steps.map((label, index) => {
const done = percent >= STEP_THRESHOLDS[index];
return (
<View key={label} style={styles.checkRow}>
<Ionicons
name={done ? 'checkmark-circle' : 'ellipse-outline'}
size={24}
color={done ? colors.primary : colors.border}
/>
<Text style={[styles.checkLabel, { color: done ? colors.text : colors.textMuted }]}>{label}</Text>
</View>
);
})}
</View>
<View style={[styles.testimonialCard, { backgroundColor: colors.surface }]}>
<View style={styles.testimonialHeader}>
<Text style={[styles.testimonialAuthor, { color: colors.text }]}>{copy.author}</Text>
<View style={styles.starsRow}>
{[0, 1, 2, 3, 4].map((i) => <Ionicons key={i} name="star" size={13} color="#f5c04e" />)}
</View>
</View>
<Text style={[styles.testimonialText, { color: colors.textSecondary }]}>{copy.testimonial}</Text>
</View>
<View style={[styles.ratingBadge, { borderColor: colors.primary }]}>
<Ionicons name="ribbon-outline" size={16} color={colors.primary} />
<Text style={[styles.ratingText, { color: colors.primary }]}>{copy.rating}</Text>
</View>
</SafeAreaView>
);
}
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.