Files
Greenlens/docs/superpowers/plans/2026-07-06-onboarding-soft-paywall-free-tier.md
2026-07-06 22:25:52 +02:00

68 KiB
Raw Blame History

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/signuponboarding/sourcegoalexperiencehealth-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) → sourcegoalexperiencehealth-checkonboarding/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 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: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: demo mode is guests only (!session), limited to 5/device via guestScanCount. Demo scans must call the normal identification service, not getMockPlantByImage; 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:

"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:

  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:

<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 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):

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 + 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):

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 from SOURCE_OPTIONS (emojis: 🏬 app_store, 📸 instagram, 🎵 tiktok, 👥 friend, 🔎 search, other), onBackrouter.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).

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%: 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).

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 AI demo scans), direct guest health-check and semantic-search API calls still 402.
  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:

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 for non-demo endpoints (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.