1506 lines
68 KiB
Markdown
1506 lines
68 KiB
Markdown
# 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.
|