68 KiB
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),getAvailableCreditsreturns 0 for non-pro (line 257),consumeCreditsthrows for non-pro (line 573),alignAccountToCurrentCycleself-heals allowance viaisAllowedMonthlyAllowance(line 76) — legacy free accounts with stored allowance 0 auto-migrate onceFREE_MONTHLY_CREDITSchanges.- Costs (
server/index.js:84-87): scan primary 1, scan review 0, semantic search 2, health check 2. - Guests:
isGuest(userId)=userId === 'guest'. Guests may run the limited pre-auth demo identification scan, but the demo scan must use the same AI identification path as a normal scan. Guests must still be blocked from health checks and semantic search because those would otherwise use one shared'guest'billing account. - Scan model per plan:
server/lib/openai.js:33getScanModelChain(plan)— free uses the cheap chain. Decision: free gets the pro chain (same quality). - Billing summary shape (
buildBillingSummary):credits.cycleEndsAtis the free-credit renewal date.
Key app facts:
app/scanner.tsx: demo mode is guests only (!session), limited to 5/device viaguestScanCount. Demo scans must call the normal identification service, notgetMockPlantByImage; signed-in free users do real scans with credits.app/profile/billing.tsx(1779 lines): already contains the full paywall branchshowPaywallPlans(line 360:!session || (!isLoadingBilling && planId !== 'pro')), purchase/restore/sync/Expo-Go-simulation logic, per-language copy viagetBillingCopy(language). We reuse ALL purchase logic — only the paywall trigger and the paywall JSX change.- Theme:
useColors(isDarkMode, colorPalette)fromconstants/Colors.ts(tokens likecolors.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 toutils/translations.tsunless a screen already usest.keys you're keeping. - Onboarding answers are stored per
session.userIdin local SQLite viaOnboardingProgressService— 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:
"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):
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:
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
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:
const FREE_MONTHLY_CREDITS = 3;
- Step 2: Make available-credit math plan-independent
Replace getAvailableCredits (lines 257-261):
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:
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
git add server/lib/billing.js
git commit -m "feat(server): free tier with 3 monthly credits"
Task 3: Remove the server hard paywall (guest demo scan allowed)
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 for non-demo endpoints
Replace lines 206-218 (createHardPaywallError + ensureActiveProEntitlement) with:
const ensureNotGuest = (userId, requiredCredits) => {
// Guests may use the limited pre-auth demo scan, but the shared 'guest'
// billing account must never consume credits for non-demo endpoints.
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: remove the guest gate from/v1/scan; guest demo scans may run primary AI identification without consuming account credits. -
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:
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
with:
// 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
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:
- Remove the unused pieces from the
useApp()destructuring if they become unused (billingSummary,isActivatingEntitlement,isLoadingBillingare only used by the gate — check with grep before removing). - Delete the
hasActiveEntitlementcomputation (lines 121-123) and theisAllowedWithoutEntitlementblock (lines 129-133). - 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:
<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
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
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
git add components/OutOfCreditsSheet.tsx
git commit -m "feat(app): out-of-credits bottom sheet (Stitch design)"
Task 6: Scanner — AI 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:
const hasActiveEntitlement = billingSummary?.entitlement?.plan === 'pro'
&& billingSummary?.entitlement?.status === 'active';
const isDemoMode = !session; // guests get limited AI demo scans; 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):
const [outOfCreditsVisible, setOutOfCreditsVisible] = useState(false);
Import the sheet at the top of the file:
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:
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):
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:
<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:
{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.
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):
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:
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):
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'):
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:
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.
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 624if (showPaywallPlans)) andgetBillingCopy -
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):
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:
<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:
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):
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).
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
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:
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.
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 +useAppusage) -
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):
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):
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.
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:
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.
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
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 fromSOURCE_OPTIONS(emojis: 🏬 app_store, 📸 instagram, 🎵 tiktok, 👥 friend, 🔎 search, ✨ other),onBack→router.back(). Infinish(source), ADD buffering before navigation:if (source) void PreAuthOnboardingService.setAnswer('acquisitionSource', source);→ route stays/onboarding/goal. -
goal.tsx: step 2/4, bufferprimaryGoal, route stays/onboarding/experience. -
experience.tsx: step 3/4, bufferexperienceLevel, route stays/onboarding/health-check(line 85, unchanged). -
health-check.tsx: step 4/4. Change line 91 fromrouter.replace(hasActiveEntitlement ? '/(tabs)' : '/profile/billing')torouter.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 thetopBarstyles inline) and change the route — do not force it intoOnboardingQuestion. -
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).
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').
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.
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%:
ImageBackgroundwelcome_botanical_hero.pngwith 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) whenappleAvailable; "OR" divider; two-step email: a "Continue with Email" outlined button that togglesemailExpandedstate 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).
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)
- 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.
- 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).
- Guest demo scan from welcome still works (5 AI demo scans), direct guest health-check and semantic-search API calls still 402.
- Existing pro account: no paywall anywhere, unchanged manage view under Profile → billing.
- Login as existing free user: lands in tabs (no hard-paywall redirect), sees credit badge in scanner.
- Dark mode: slides, questions, personalizing, paywall, sheet all render with dark tokens (compare
_dark_modemockups). - 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:
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.tsxstays 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 for non-demo endpoints (
ensureNotGuest) — required becausegetOrCreateAccount(db, 'guest')would otherwise mint a shared free account. - Type consistency:
PreAuthOnboardingServicekeys (acquisitionSource/primaryGoal/experienceLevel) matchOnboardingProgressServicesetters;OutOfCreditsSheetprops match the T6 call site; paywall param names (view,context) consistent across T6/T7/T13.