From 5037261a4d2566c63a13b5cf4cb50ca39e48b94d Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Mon, 13 Jul 2026 17:20:06 +1000 Subject: [PATCH 1/5] Onboarding flow (guest value-first) + graceful sign-in errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding (wires the dead stub): - 4 steps: value intro → base currency → tap popular brands (real logos) → confirm/edit prices with a live spend total → one "Add N" button bulk-adds via the create workflow → into the app. - Gate: signed-in users without the local has_onboarded flag are redirected to /onboarding; "Skip" also completes it. Route registered in the root Stack. lib/onboarding flag helpers + curated constants/onboardingBrands with indicative default prices. - Dev-only "Reset onboarding" in Settings to re-trigger the flow. - PostHog: onboarding_started/completed/skipped (counts only). Fixes: - Notification deep-link no longer navigates on cold start before the router is mounted (was throwing "path.split is not a function"); keeps the live tap listener, deferred a tick. - Sign-in shows friendly inline errors (no account / wrong password) instead of a console.error dev overlay, with the sign-up nudge. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(auth)/sign-in.tsx | 39 +++-- app/(tabs)/_layout.tsx | 4 + app/(tabs)/settings.tsx | 19 ++- app/_layout.tsx | 28 ++-- app/onboarding.tsx | 294 +++++++++++++++++++++++++++++++++- constants/onboardingBrands.ts | 30 ++++ lib/onboarding.ts | 11 ++ 7 files changed, 394 insertions(+), 31 deletions(-) create mode 100644 constants/onboardingBrands.ts create mode 100644 lib/onboarding.ts diff --git a/app/(auth)/sign-in.tsx b/app/(auth)/sign-in.tsx index b89aba2..16b241f 100644 --- a/app/(auth)/sign-in.tsx +++ b/app/(auth)/sign-in.tsx @@ -22,14 +22,35 @@ export default function SignIn() { const [emailAddress, setEmailAddress] = React.useState(""); const [password, setPassword] = React.useState(""); const [code, setCode] = React.useState(""); + const [formError, setFormError] = React.useState(null); const handleSubmit = async () => { + setFormError(null); const { error } = await signIn.password({ emailAddress, password, }); if (error) { - console.error(JSON.stringify(error, null, 2)); + // Expected states (wrong password, no such account) — show a friendly + // message instead of throwing a dev error overlay. + const first = ( + error as { + errors?: { code?: string; message?: string; longMessage?: string }[]; + } | null + )?.errors?.[0]; + if (first?.code === "form_identifier_not_found") { + setFormError( + "We couldn't find an account with that email. Sign up below to get started.", + ); + } else if (first?.code === "form_password_incorrect") { + setFormError("That password doesn't look right. Please try again."); + } else { + setFormError( + first?.longMessage || + first?.message || + "Something went wrong signing in. Please try again.", + ); + } return; } @@ -151,11 +172,6 @@ export default function SignIn() { } keyboardType="email-address" /> - {errors?.fields?.identifier && ( - - {errors.fields.identifier.message} - - )} @@ -168,13 +184,12 @@ export default function SignIn() { secureTextEntry={true} onChangeText={(password) => setPassword(password)} /> - {errors?.fields?.password && ( - - {errors.fields.password.message} - - )} + {formError && ( + {formError} + )} + - Don't have an account? + Don't have an account? Sign up diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6e3f586..bd3302f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,5 +1,6 @@ import { tabs } from "@/constants/data"; import { colors, components } from "@/constants/theme"; +import { hasOnboarded } from "@/lib/onboarding"; import { useAuth } from "@clerk/expo"; import { clsx } from "clsx"; import { Redirect, Tabs } from "expo-router"; @@ -24,6 +25,9 @@ const TabLayout = () => { if (!isSignedIn) { return ; } + if (!hasOnboarded()) { + return ; + } return ( { const { baseCurrency, setBaseCurrency } = useCurrency(); const { subscriptions } = useSubscriptions(); const posthog = usePostHog(); + const router = useRouter(); const [showCurrencyPicker, setShowCurrencyPicker] = useState(false); const [remindersOn, setRemindersOn] = useState(() => notifications.remindersEnabled(), @@ -143,13 +146,27 @@ const Settings = () => { - signOut()} className="auth-button" > Sign out + + {__DEV__ && ( + { + resetOnboarding(); + router.replace("/onboarding"); + }} + > + + Reset onboarding (dev) + + + )} { - const openFromResponse = ( - response: Notifications.NotificationResponse | null, - ) => { - const id = response?.notification.request.content.data?.subscriptionId; - if (typeof id === "string") { - router.push(`/subscriptions/${id}`); - } + let active = true; + const sub = Notifications.addNotificationResponseReceivedListener( + (response) => { + const id = response?.notification.request.content.data?.subscriptionId; + if (active && typeof id === "string") { + setTimeout(() => router.push(`/subscriptions/${id}`), 0); + } + }, + ); + return () => { + active = false; + sub.remove(); }; - Notifications.getLastNotificationResponseAsync().then(openFromResponse); - const sub = - Notifications.addNotificationResponseReceivedListener(openFromResponse); - return () => sub.remove(); }, [router]); const [fontsLoaded, fontError] = useFonts({ @@ -112,6 +115,7 @@ function RootLayoutContent() { }} > + ); diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 7237372..567811b 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,11 +1,293 @@ -import { Text, View } from "react-native"; +import PickerSheet, { type PickerItem } from "@/components/PickerSheet"; +import SubscriptionIcon from "@/components/SubscriptionIcon"; +import { CURRENCY_CODES, currencyName } from "@/constants/currencies"; +import { ONBOARDING_BRANDS } from "@/constants/onboardingBrands"; +import { useCurrency } from "@/context/CurrencyContext"; +import { useSubscriptions } from "@/context/SubscriptionsContext"; +import "@/global.css"; +import { getMonthlyEquivalent, resolveNextRenewal } from "@/lib/billing"; +import { markOnboarded } from "@/lib/onboarding"; +import { formatCurrency } from "@/lib/utils"; +import clsx from "clsx"; +import dayjs from "dayjs"; +import { useRouter } from "expo-router"; +import { styled } from "nativewind"; +import { usePostHog } from "posthog-react-native"; +import { useEffect, useRef, useState } from "react"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; + +const SafeAreaView = styled(RNSafeAreaView) as any; + +type Step = "intro" | "currency" | "pick" | "confirm"; + +const CURRENCY_ITEMS: PickerItem[] = CURRENCY_CODES.map((code) => ({ + value: code, + label: code, + sublabel: currencyName(code), +})); + +const VALUE_POINTS = [ + "See every subscription and what it really costs you each month", + "Get reminded before renewals and free trials end — so nothing surprises you", + "Your data stays on your device. No bank login, ever.", +]; + +const Onboarding = () => { + const router = useRouter(); + const posthog = usePostHog(); + const { baseCurrency, setBaseCurrency } = useCurrency(); + const { addSubscription } = useSubscriptions(); + + const [step, setStep] = useState("intro"); + const [selected, setSelected] = useState>({}); + const [prices, setPrices] = useState>({}); + const [showCurrencyPicker, setShowCurrencyPicker] = useState(false); + + const started = useRef(false); + useEffect(() => { + if (!started.current) { + started.current = true; + posthog.capture("onboarding_started"); + } + }, [posthog]); + + const selectedBrands = ONBOARDING_BRANDS.filter((b) => selected[b.title]); + const priceFor = (title: string, fallback: number) => + parseFloat(prices[title] ?? String(fallback)) || 0; + const monthlyTotal = selectedBrands.reduce( + (sum, b) => sum + getMonthlyEquivalent(priceFor(b.title, b.price), "monthly"), + 0, + ); + + const finish = (subsAdded: number) => { + markOnboarded(); + posthog.capture("onboarding_completed", { subs_added: subsAdded }); + router.replace("/"); + }; + + const skip = () => { + markOnboarded(); + posthog.capture("onboarding_skipped"); + router.replace("/"); + }; + + const addSelected = () => { + const now = dayjs().toISOString(); + let count = 0; + for (const brand of selectedBrands) { + const price = priceFor(brand.title, brand.price); + if (price <= 0) continue; + addSubscription({ + name: brand.title, + price, + currency: baseCurrency, + billingCycle: "monthly", + category: brand.category, + status: "active", + startDate: now, + renewalDate: + resolveNextRenewal(now, "monthly")?.toISOString() ?? undefined, + }); + count += 1; + } + finish(count); + }; + + const goToPick = () => setStep("pick"); + const afterPick = () => { + if (selectedBrands.length > 0) setStep("confirm"); + else finish(0); + }; -const onboarding = () => { return ( - - onboarding - + + {step === "intro" && ( + + + + + R + + + Recurrly + SMART BILLING + + + Take back control of your subscriptions + + {VALUE_POINTS.map((point) => ( + + + + {point} + + + ))} + + + + setStep("currency")}> + Get started + + + + )} + + {step === "currency" && ( + + + What currency do you pay in? + + Every amount you enter will be shown in this currency. You can + change it later in Settings. + + setShowCurrencyPicker(true)} + > + + {baseCurrency} · {currencyName(baseCurrency)} + + + ▾ + + + + + Continue + + + )} + + {step === "pick" && ( + // Single scroll with the CTA at the end so it's always reachable + // (a flex-1 ScrollView + fixed footer can push the button off-screen). + + Add your subscriptions + + Tap the ones you pay for. You can add more (or custom ones) anytime. + + + {ONBOARDING_BRANDS.map((brand) => { + const active = !!selected[brand.title]; + return ( + + setSelected((s) => ({ ...s, [brand.title]: !s[brand.title] })) + } + style={{ width: "31%" }} + className={clsx( + "items-center gap-2 rounded-2xl border p-3", + active + ? "border-accent bg-accent/10" + : "border-border bg-card", + )} + > + + + {brand.title} + + + ); + })} + + + + {selectedBrands.length > 0 + ? `Continue with ${selectedBrands.length}` + : "Continue"} + + + + + Skip for now + + + + )} + + {step === "confirm" && ( + + Confirm what you pay + + Adjust the defaults to what you actually pay in {baseCurrency}. You + can fine-tune any of these later. + + + + + Your monthly spend + + + {formatCurrency(monthlyTotal, baseCurrency)} + + + ≈ {formatCurrency(monthlyTotal * 12, baseCurrency)} a year + + + + {selectedBrands.map((brand) => ( + + + + {brand.title} + + + setPrices((p) => ({ ...p, [brand.title]: v })) + } + placeholder="0.00" + placeholderTextColor="#666666" + /> + + ))} + + + + Add {selectedBrands.length} subscription + {selectedBrands.length === 1 ? "" : "s"} + + + setStep("pick")}> + + Back + + + + )} + + setShowCurrencyPicker(false)} + /> + ); }; -export default onboarding; +export default Onboarding; diff --git a/constants/onboardingBrands.ts b/constants/onboardingBrands.ts new file mode 100644 index 0000000..8972c7d --- /dev/null +++ b/constants/onboardingBrands.ts @@ -0,0 +1,30 @@ +// Popular subscriptions offered as quick-add tiles during onboarding. Titles +// match brand-icon keywords so SubscriptionIcon renders the right logo. Prices +// are editable starting defaults (indicative monthly, in the user's base +// currency) — the user confirms/adjusts before adding. +export interface OnboardingBrand { + title: string; + price: number; + category: string; +} + +export const ONBOARDING_BRANDS: OnboardingBrand[] = [ + { title: "Netflix", price: 15.49, category: "Entertainment" }, + { title: "Spotify", price: 11.99, category: "Music" }, + { title: "YouTube Premium", price: 13.99, category: "Entertainment" }, + { title: "Apple Music", price: 10.99, category: "Music" }, + { title: "iCloud+", price: 2.99, category: "Cloud" }, + { title: "ChatGPT", price: 20, category: "AI Tools" }, + { title: "Claude", price: 20, category: "AI Tools" }, + { title: "Perplexity", price: 20, category: "AI Tools" }, + { title: "Cursor", price: 20, category: "AI Tools" }, + { title: "Gemini", price: 19.99, category: "AI Tools" }, + { title: "Midjourney", price: 10, category: "AI Tools" }, + { title: "Notion", price: 10, category: "Productivity" }, + { title: "GitHub", price: 4, category: "Developer Tools" }, + { title: "Figma", price: 12, category: "Design" }, + { title: "Dropbox", price: 11.99, category: "Cloud" }, + { title: "Grammarly", price: 12, category: "Productivity" }, + { title: "Audible", price: 14.95, category: "Music" }, + { title: "Discord Nitro", price: 9.99, category: "Entertainment" }, +]; diff --git a/lib/onboarding.ts b/lib/onboarding.ts new file mode 100644 index 0000000..31dc910 --- /dev/null +++ b/lib/onboarding.ts @@ -0,0 +1,11 @@ +import { getKv, setKv } from "@/db/subscriptionsRepo"; + +/** kv flag marking the one-time onboarding as complete. */ +export const ONBOARDED_KEY = "has_onboarded"; + +export const hasOnboarded = (): boolean => getKv(ONBOARDED_KEY) === "1"; + +export const markOnboarded = (): void => setKv(ONBOARDED_KEY, "1"); + +/** Clears the flag so the onboarding flow shows again (used by the dev reset). */ +export const resetOnboarding = (): void => setKv(ONBOARDED_KEY, "0"); From b2ddc6323ad3b4fa7e5a70639f0d6edf6d1b284a Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Tue, 14 Jul 2026 17:56:56 +1000 Subject: [PATCH 2/5] Onboarding: guest-first, all brands, full cycle picker + quick-add framing - Guest-first: onboarding runs before any auth wall; Home/Settings show guest state (Sign in vs Sign out) with a backup/sync nudge. - Onboarding brand grid now derives from ALL brand icons (not a hardcoded subset), with default prices/categories. - Confirm step: replace the Monthly/Yearly toggle with a single tappable cycle chip -> PickerSheet over all real billing cycles, so the spend reveal and reminders are correct for weekly/quarterly/etc. Framed as "Quick add (optional)" with hand-holding copy pointing to the detail screen for the rest. - Fix react-native-css path.split crash: price TextInput uses auth-input class + inline style. - Remove dev seed (db/seed.ts) so no phantom subscriptions; add clearAllSubscriptions + clearAllData and dev "Clear all data" / "Reset onboarding" helpers in Settings. - Update PRODUCTION_PLAN.md to current decisions. Co-Authored-By: Claude Opus 4.8 (1M context) --- PRODUCTION_PLAN.md | 77 +++++++++++++++++++++----- app/(tabs)/_layout.tsx | 8 +-- app/(tabs)/index.tsx | 17 +++--- app/(tabs)/settings.tsx | 77 ++++++++++++++++++-------- app/onboarding.tsx | 81 ++++++++++++++++++++++----- constants/onboardingBrands.ts | 95 ++++++++++++++++++++++++-------- context/SubscriptionsContext.tsx | 22 ++++++-- db/seed.ts | 66 ---------------------- db/subscriptionsRepo.ts | 5 ++ 9 files changed, 288 insertions(+), 160 deletions(-) delete mode 100644 db/seed.ts diff --git a/PRODUCTION_PLAN.md b/PRODUCTION_PLAN.md index b4333cd..03a9a26 100644 --- a/PRODUCTION_PLAN.md +++ b/PRODUCTION_PLAN.md @@ -1,10 +1,41 @@ # Production Plan: Course Prototype → Global Subscription-Tracker Business ($30–50k MRR target) +## ⚠️ CURRENT DECISIONS & STATUS (read first — supersedes anything below that conflicts) + +_Last updated: Jul 2026. This section is the source of truth; older prose below is retained for rationale but the calls here win._ + +### Shipped so far (on `main` + open `onboarding` branch) + +- **Local-first SQLite** persistence: `db/database.ts` + `db/migrations.ts` + `db/subscriptionsRepo.ts`; `SubscriptionsContext` caches over it. **No dev seed** (removed — real data comes from onboarding/user). +- **Full CRUD** incl. wired `subscriptions/[id]` detail (edit / pause / resume / cancel / reactivate / delete) and dual-mode `SubscriptionFormModal`. +- **Billing engine** `lib/billing.ts`: all cycles (weekly/biweekly/monthly/quarterly/semiannual/annual/custom), start-date-aware `resolveNextRenewal` + `getMonthlyEquivalent`. Unit-tested. +- **Brand icons at runtime**: `simple-icons` + Lobe static-SVG data (build-time), rendered as consistent tiles via `components/SubscriptionIcon` (`react-native-svg` `SvgXml`) with a colored **monogram fallback**. Generated by `scripts/generate-brand-icons.mjs` → `constants/brandIcons.ts`. +- **Real Insights** (per-subscription spend chart + monthly/yearly/saved), Home hero with real monthly-equivalent total. +- **Notifications** (`lib/notifications.ts` + `lib/reminders.ts`): local renewal (T-3/T-1) + free-trial (T-2/T-0) reminders, permission-on-first-add, foreground reconciler, tap-to-deep-link. +- **Analytics privacy**: PostHog gets NO subscription names/amounts/PII — only `price_bucket` + counts; opt-out toggle in Settings. +- **Guest-first onboarding**: value → base currency → brand grid (ALL available brands) → confirm/edit prices → bulk-add. Auth is optional. +- **EAS** `eas.json` (dev/preview/prod) + bundle IDs; jest-expo tests; searchable currency + brand pickers. + +### Decisions that CHANGED — do NOT reintroduce the old version + +- **Trial length: 3 days** (not 7). Must be shorter than the weekly plan period. +- **No free-tier count cap** (not "5 subs"). Avg user tracks 2–3 subs so a cap won't bind. Monetize on the AI money-saver + trial, not quantity. (Optional high anti-abuse ceiling only.) +- **Single app-wide base currency, no FX** (not per-sub multi-currency / conversion / dual-display). Users enter amounts in their own currency; changeable in Settings. No `RatesProvider`/FX. +- **Guest-first** (not auth-first): onboarding leads for everyone; sign-in optional, surfaced in Settings, only needed later for Pro/backup. +- **Positioning: an AI money-saver** (sell savings, not "a list"). Free = tracking + basic reminders + export; Pro = AI audit/forecasts + full history + backup/sync + custom reminders + widgets. +- **Single-user-per-device** assumed (no multi-account isolation); multi-**device** portability is the Pro backup/sync story. + +### Newly adopted from competitor scan (Bobby) — see Future roadmap + +List **sort options + multi-category filtering**, **same-day (T-0) reminder** option, manual **re-order**, **custom color/icon** for user-created brands, and a **"suggest a brand"** tie-in to the feature-request board. Explicitly **not** adopting Bobby's multi-currency (we chose single-currency). + +--- + ## Context -The app (currently "Recurrly") is a working course-built prototype: Clerk auth, PostHog analytics, subscription CRUD — but **in-memory mock data** (lost on restart), **no backend, no persistence, no EAS config, no bundle IDs, no tests/CI**, unreachable onboarding/detail stubs, and a **course-template design system that hundreds of other students have shipped near-identically** (cream/navy/orange, Plus Jakarta Sans). Goal: a distinctive, production-grade app on both stores, monetized via RevenueCat, targeting **$30–50k MRR** by capturing the segment Rocket Money bleeds and the global market it ignores. +The app started as a course-built prototype (Clerk auth, PostHog, in-memory mock data, dead stubs, course-template cream/navy/orange design). Phases 0–1 have since made it real (see Status above). Goal: a distinctive, production-grade app on both stores, monetized via RevenueCat, targeting **$30–50k MRR** by capturing the segment Rocket Money bleeds and the global market it ignores. -**Locked decisions:** manual-first detection v1 (email-forward Phase 2, Gmail OAuth/CASA gated on revenue, SMS ruled out by policy) · local-first SQLite + paid encrypted sync · freemium + 7-day trial hybrid · **new name** · **new "Midnight Ledger" design system** (replaces course template entirely) · cycles: weekly→annual+custom · global (multi-currency, i18n) · not competing head-on with Rocket Money — capturing ~10% of their equivalent is the win. +**Direction:** manual-first entry v1 (email-forward Phase 2, Gmail OAuth/CASA gated on revenue, SMS ruled out by policy) · local-first SQLite + user's-own-cloud backup (Pro) · **guest-first, freemium + 3-day trial**, AI-money-saver positioning · **new name** + **"Midnight Ledger" redesign** (not yet done) · cycles: weekly→annual+custom · single base currency + i18n · not competing head-on with Rocket Money — ~10% of their equivalent is the win. --- @@ -101,14 +132,16 @@ The app (currently "Recurrly") is a working course-built prototype: Clerk auth, ## 6. Pricing Strategy -**Philosophy (revised per owner):** the free tier is a _tracker_; Pro is an _AI-personal-finance companion_. Free deliberately excludes every differentiator (AI insights, forecasts, personalized cards, sync, custom reminders) — its job is habit formation and funnel volume. The upsell motion is **free → 7-day trial → paid**: the trial exists so users _feel_ the AI insights on their own real data, which is what converts. CRO lever: **lead with weekly pricing** (low perceived commitment at the decision moment — the Fluently/Blinkist pattern) **anchored against a heavily-discounted annual**; monthly exists but is deliberately unattractive (the decoy). +**Philosophy:** the free tier is a _tracker_; Pro is an _AI money-saver_. Free deliberately excludes every differentiator (AI insights, forecasts, personalized cards, backup, custom reminders) — its job is habit formation and funnel volume. The upsell motion is **free → 3-day trial → paid**: the trial (all features unlocked) exists so users _feel_ the AI insights on their own real data, which is what converts. CRO lever: **lead with weekly pricing** (low perceived commitment at the decision moment — the Cal AI / Fluently pattern) **anchored against a heavily-discounted annual**; monthly exists but is deliberately unattractive (the decoy). **No count cap** — average users track 2–3 subs so a quantity wall wouldn't bind; conversion comes from the AI/savings value + trial, not restriction. | Tier | Price | Includes | | ---------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Free** | $0 | Up to 5 subs, standard T-1 reminders, current-month totals only, full export, all currencies. **No AI, no forecasts, no history, no sync.** | -| **Pro weekly** | **$1.99/wk** | Everything: AI insights & spend audit, renewal forecasts, personalized savings cards, unlimited subs, custom multi-reminders, full history, encrypted backup/sync (P2), widgets (P2), receipt forwarding (P2), app lock | +| **Free** | $0 | **Unlimited** subscriptions, basic renewal + trial reminders, current-month totals, full export. **No AI, no forecasts, no history, no backup, no custom reminders.** | +| **Pro weekly** | **$1.99/wk** | Everything: AI insights & spend audit, renewal forecasts, personalized savings cards, custom multi-reminders, full history, encrypted backup/sync (P2), widgets (P2), receipt forwarding (P2), app lock | | **Pro monthly** (decoy) | $6.99/mo | Same — priced to make weekly feel light and annual feel obvious | -| **Pro annual** ⭐ default-selected | **$39.99/yr** (~$0.77/wk, "save 61%") + **7-day free trial** | Same | +| **Pro annual** ⭐ default-selected | **$39.99/yr** (~$0.77/wk, "save 61%") | Same | + +**Trial: 3 days, all Pro features unlocked, same length on every plan** (must be < the weekly plan's 7-day period, else the weekly plan is cannibalized). Surfaced right after the onboarding spend-reveal (peak intent), dismissible to the free tier. _(No Lifetime tier — one-time revenue against forever AI inference costs is an unbounded liability; ruled out.)_ @@ -135,7 +168,7 @@ _(No Lifetime tier — one-time revenue against forever AI inference costs is an - **Cancellation copilot**: AI-drafted cancellation/negotiation emails per service, paired with the concierge how-to-cancel guides. - Economics: Haiku-class models keep inference at cents/user/mo — margin-safe inside $8.99. Ship AI Advisor _before_ bank linking if Plaid integration lags (it's pure software, no compliance) — it alone can justify the tier at ~$5.99 interim pricing. -**Model rationale (RevenueCat 2025/26 benchmarks):** hard-paywall trials convert ~5× freemium (10.7–12.1% vs ~2.1%) and 80–90% of trials start Day 0 → **hybrid**: permanent free tier for top-of-funnel/virality + dismissible trial paywall at end of onboarding + high-intent triggers (6th sub, locked insights). Low price beats high on trial conversion (47.8% vs 28.4%). Undercuts every competitor (~70% under Rocket Money, ~80% under TrackMySubs). Raise later with grandfathering if warranted — cutting is harder. +**Model rationale (RevenueCat 2025/26 benchmarks):** hard-paywall trials convert ~5× freemium (10.7–12.1% vs ~2.1%) and 80–90% of trials start Day 0 → **hybrid**: permanent free tier for top-of-funnel/virality + dismissible trial paywall after the onboarding reveal + high-intent triggers (tapping locked AI/insights, the backup prompt). Low price beats high on trial conversion (47.8% vs 28.4%). Undercuts every competitor (~70% under Rocket Money, ~80% under TrackMySubs). Raise later with grandfathering if warranted — cutting is harder. **The math to $30–50k MRR (revised pricing):** blended net ARPU ~$3.50–5/mo (weekly high-convert/high-churn + annual LTV backbone + AI justifying the price) → **$30–50k needs ~7–13k payers ≈ 250–450k MAU at 3% conversion ≈ 15–30k downloads/mo** at Rocket Money's ~$2/download economics — achievable without top-1% status, and no longer dependent on bank-linking to get there (Autopilot/Pro+ becomes upside, not prerequisite). Gates: $1k MRR validates → $5k triggers Autopilot+Gmail builds → $30–50k at months 18–30. @@ -153,13 +186,15 @@ Pin NativeWind 5-preview exact (NW4 fallback documented) · verify release build - **Context keeps its API**, becomes cache over repo; adds update/delete(soft+undo toast)/pause/cancel. Seeds → `__DEV__` only. - **CRUD**: wire `[id].tsx` detail screen + card navigation; modal → dual-mode `SubscriptionFormModal`; wire existing `onCancelPress` props (cancelled rows kept — they power money-saved). - **`lib/billing.ts`**: `BILLING_CYCLES` map (all 7), `getNextRenewal` (generalizes existing roll-forward; dayjs clamps month-ends), `getMonthlyEquivalent` (real Home total). -- **Multi-currency**: per-sub currency (locale default), `Intl.NumberFormat` replaces symbol map, bundled FX snapshot + free daily-refresh API cached in kv, "≈" totals in base currency. -- PostHog: `subscription_updated/_cancelled/_deleted/_paused`. +- **Currency (SHIPPED, single-currency):** one app-wide base currency (`CurrencyContext`, locale default, changeable in Settings via searchable picker), `Intl.NumberFormat` formatting. **No per-sub currency, no FX conversion** — deliberately reversed; users enter amounts in their own currency. +- PostHog: `subscription_updated/_cancelled/_deleted/_paused` (SHIPPED). + +> **Phase 0 status: SHIPPED.** SQLite + repo + migrations, CRUD + detail, `lib/billing`, single-currency, brand icons, real Insights, notifications, analytics-privacy, guest-first onboarding, EAS profiles, tests. ### Phase 1 — Launch (~4–6 wks) - **Rebrand PR**: new name (30–40 candidates → USPTO/EUIPO class 9/42 knockout + domain + store + handles; criteria ≤2 syllables, evokes recurrence/money; seeds: Subwise, Duesday, Outflow, Kept) + **Midnight Ledger token/component swap** + icon/splash. -- **Onboarding** (guest-mode first; Clerk account asked only where it earns its place — at trial/purchase for entitlement+backup portability): value panes → brand grid → total-shock reveal → trial paywall → optional email opt-in ("monthly spending summary") for guests who skip the trial. Same template data powers form autocomplete. +- **Onboarding (SHIPPED, guest-first):** value panes → base-currency pick → brand grid (all available brands) → confirm/edit prices → **bulk-add** → into the app as a guest. Clerk account only asked later (Settings, or at trial/purchase). _Still to add:_ the trial paywall at the reveal (with RevenueCat) + optional "email me my monthly summary" opt-in. - **Notifications & reminders** (`expo-notifications` — all **local/scheduled on-device**, no push server needed, consistent with zero-backend): - _Renewal reminders (the core promise):_ per subscription, next-occurrence-only, **T-3 + T-1 at 9:00 local** (free tier: T-1 fixed; Pro: configurable lead times globally + per-subscription). Notification IDs stored on the row; cancel+reschedule on every edit/pause/cancel/delete; foreground reconciler (AppState) rolls past renewals forward and reschedules — this is how we stay under iOS's 64-pending cap and never miss. - _Free-trial expiry tracking (category killer-feature):_ mark any subscription as "trial ends " → aggressive T-2 + T-0 morning alerts — "Your Disney+ trial converts to $13.99/mo TOMORROW — cancel now?" with a deep link to the cancellation guide. Saves users real money fast → drives the money-saved counter, reviews, and word-of-mouth. @@ -167,7 +202,7 @@ Pin NativeWind 5-preview exact (NW4 fallback documented) · verify release build - _Monthly wrap (Pro):_ "Your March: $424.63 across 12 subscriptions, +12% vs Feb" — deep-links into the report; doubles as a re-engagement loop. - _Zombie nudge (Phase 2):_ "Still using Canva? You've paid 8 months" — capped at 1/month. - _Hygiene:_ Android 13+ contextual permission ask (after first sub added, not at launch) + "Renewal reminders" channel; every notification type individually toggleable in Settings; PostHog events on permission grant/deny + notification-open rates. -- **RevenueCat**: `react-native-purchases` + Paywalls v2; `Purchases.logIn(clerkUserId)`; `useEntitlements()` context; gates at 6th sub/insights/settings; 2-tap cancel path in Settings. +- **RevenueCat**: `react-native-purchases` + Paywalls v2; `Purchases.logIn(clerkUserId)`; `useEntitlements()` context; 3-day trial; gate the AI/insights/backup/custom-reminders features (NOT a sub count); 2-tap cancel path in Settings. - **EAS**: eas.json (dev/preview/prod, autoIncrement), bundle IDs, managed credentials, expo-doctor clean. - **Website (submission blocker)**: single-page site on new domain (see Marketing §8) hosting the URLs both stores require — privacy policy, terms, support, Play account-deletion page — plus waitlist. Build early in Phase 1, before store listings are drafted. - **Compliance**: privacy policy+terms on new domain; in-app account deletion (Clerk delete + DB wipe + RC logout + PostHog reset) + Play web-deletion URL; privacy labels/Data Safety; `ITSAppUsesNonExemptEncryption:false`; paywall restore+disclosures; iPad layout pass + screenshots. @@ -217,6 +252,15 @@ All of these ride on the existing SQLite + design system + report/share infrastr - **Icon/theme packs** as a cosmetic Pro perk (tokens already centralized — a theme is a token file). - **Snooze/skip a renewal reminder** (one-tap from the notification) — notification actions, no new infrastructure. +**Adopted from the Bobby competitor scan (all local, low-effort):** + +- **List sort + multi-category filter** on the Subscriptions tab (by price / renewal date / name / cost; filter by one or more categories). Biggest daily-use win we currently lack (we only have search). +- **Same-day (T-0) renewal reminder** as an optional lead time (alongside T-3/T-1). +- **Manual re-order** of subscriptions (drag) — cosmetic control. +- **Custom color + icon** for user-created brands (override the auto-resolved tile) — cosmetic Pro-ish perk. +- **"Suggest a brand"** entry (routes into the feature-request board) for logos we don't have — monogram covers them meanwhile. +- _Explicitly NOT adopting:_ Bobby's per-sub multiple currencies / currency breakdown — we chose single base currency. + --- ## 8. Marketing & Launch Plan @@ -253,20 +297,23 @@ All of these ride on the existing SQLite + design system + report/share infrastr | Solo scope creep | Every Phase-2+ item metric-gated (sync @500 MAU, Autopilot @$3–5k, Gmail @$5k) | | Low willingness-to-pay anchored by Bobby's $2.99 one-time | Paywall sells the AI companion + ongoing-cost features (sync, parsing, reliability), never manual entry — different category, different anchor. No lifetime SKU (AI inference makes one-time pricing an unbounded liability) | | AI token overspend / abuse | Server-side proxy w/ per-user budgets, generate-on-change caching, Haiku-class models (<6% COGS), trial caps, provider spend limits + template-mode kill switch (see §6) | -| Clerk-required auth = onboarding friction + compliance surface | Guest mode in Phase 1; account only for Pro sync | -| FX API disappearance | Bundled fallback rates + `RatesProvider` abstraction; totals degrade to "rates as of " | +| Clerk-required auth = onboarding friction + compliance surface | Solved — guest-first shipped; account only for Pro/backup | +| NativeWind 5-preview `react-native-css` bug (`path.split`) on some `TextInput` classNames | Real, already bit us in onboarding — use `auth-input` class + inline `style` for TextInputs; reinforces the pin-exact / NW4-fallback plan before launch | +| Data leak across accounts on one device | Out of scope — single-user-per-device assumption; portability is the Pro backup/sync story, not local multi-account | | Play new-account 12-tester/14-day rule | Recruit testers during Phase 0 | ## 10. Verification & Go-Live Checklist - **Phase 0:** jest — billing math all cycles × month-end × roll-forward, currency, repo CRUD on in-memory SQLite, migration fixtures; manual — full CRUD matrix both dev clients, kill-relaunch persistence, `tsc`+lint green. -- **Phase 1:** notification protocol (short-interval debug triggers; reschedule-on-edit + cleanup verified via inspector); RevenueCat sandbox purchase/restore both stores; gating at exactly 5→6; fresh-install onboarding (guest + signed paths); iPad layout pass; ≥3 external testers; Sentry symbolicated test crash; PostHog funnel live; store checklist 100% before submission. +- **Phase 1:** notification protocol (short-interval debug triggers; reschedule-on-edit + cleanup) ✅ shipped; RevenueCat sandbox purchase/restore both stores + 3-day trial start/convert; feature-gating verified (locked AI/insights/backup prompt the paywall); fresh-install guest onboarding + "Clear all data" clean-slate; iPad layout pass; ≥3 external testers; Sentry symbolicated test crash; PostHog funnel live; store checklist 100% before submission. - **Phase 2:** backup/restore round-trip via iCloud + Google Drive incl. mid-restore kill and fresh-device restore; encrypted-blob verification (backup file unreadable without key); reports/PDF export rendering checks across data sizes + long names; AI proxy rate-limit + budget-cap behavior (verify graceful template fallback); widget staleness after mutations. - **Go-live:** production builds smoke-tested from the store (not sideloaded), paywall live-purchase verified with a real card + refund, privacy policy URLs resolve, support email answered. ## Critical Files -`context/SubscriptionsContext.tsx` (cache over repo) · new `db/*` (database, migrations, repo) · `lib/billing.ts` (from `lib/utils.ts`) · `components/CreateSubscriptionModal.tsx` → `SubscriptionFormModal` · dead stubs `app/subscriptions/[id].tsx`, `app/onboarding.tsx` · `global.css` + `constants/theme.ts` (Midnight Ledger tokens) · `app.json` + new `eas.json` (rebrand/bundle IDs/plugins) · new `constants/brandTemplates.ts`, `lib/entitlements.ts`. +**Shipped:** `context/SubscriptionsContext.tsx` + `context/CurrencyContext.tsx` · `db/{database,migrations,subscriptionsRepo}.ts` · `lib/{billing,notifications,reminders,analytics,onboarding,brand}.ts` · `components/{SubscriptionFormModal,SubscriptionIcon,PickerSheet}.tsx` · `app/onboarding.tsx` + `app/subscriptions/[id].tsx` (wired) · `constants/{brandIcons,onboardingBrands,currencies}.ts` · `scripts/generate-brand-icons.mjs` · `eas.json` + bundle IDs in `app.json`. + +**Still to build:** `lib/entitlements.ts` (RevenueCat gating) · paywall screen · Cloudflare AI proxy · `global.css` + `constants/theme.ts` "Midnight Ledger" token swap (rebrand) · marketing site. ## Sources diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index bd3302f..bf20dae 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,7 +1,6 @@ import { tabs } from "@/constants/data"; import { colors, components } from "@/constants/theme"; import { hasOnboarded } from "@/lib/onboarding"; -import { useAuth } from "@clerk/expo"; import { clsx } from "clsx"; import { Redirect, Tabs } from "expo-router"; import { Image, View } from "react-native"; @@ -18,13 +17,10 @@ const TabIcon = ({ focused, icon }: TabIconProps) => ( ); const TabLayout = () => { - const { isSignedIn, isLoaded } = useAuth(); const insets = useSafeAreaInsets(); - if (!isLoaded) return null; - if (!isSignedIn) { - return ; - } + // Guest-first: no auth wall. Onboarding runs first for everyone; signing in + // is optional (from Settings) and only needed later for Pro/backup. if (!hasOnboarded()) { return ; } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index cd5ab31..5452f0d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,7 +2,6 @@ import ListHeading from "@/components/ListHeading"; import SubscriptionCard from "@/components/SubscriptionCard"; import SubscriptionFormModal from "@/components/SubscriptionFormModal"; import UpcomingSubscriptionCard from "@/components/UpcomingSubscriptionCard"; -import { HOME_USER } from "@/constants/data"; import { icons } from "@/constants/icons"; import images from "@/constants/images"; import { useCurrency } from "@/context/CurrencyContext"; @@ -22,7 +21,7 @@ import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView) as any; export default function App() { - const { user } = useUser(); + const { user, isSignedIn } = useUser(); const { signOut } = useClerk(); const { subscriptions, addSubscription } = useSubscriptions(); const { baseCurrency } = useCurrency(); @@ -104,7 +103,7 @@ export default function App() { const displayName = user?.firstName || user?.emailAddresses[0]?.emailAddress?.split("@")[0] || - HOME_USER.name; + "Guest"; return ( @@ -124,11 +123,13 @@ export default function App() { /> {displayName} - signOut()} className="ml-4 mt-1"> - - Sign out - - + {isSignedIn && ( + signOut()} className="ml-4 mt-1"> + + Sign out + + + )} setCreateModalVisible(true)}> diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index cae2b01..6fa813f 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -25,9 +25,9 @@ const CURRENCY_ITEMS: PickerItem[] = CURRENCY_CODES.map((code) => ({ const SafeAreaView = styled(RNSafeAreaView) as any; const Settings = () => { const { signOut } = useClerk(); - const { user } = useUser(); + const { user, isSignedIn } = useUser(); const { baseCurrency, setBaseCurrency } = useCurrency(); - const { subscriptions } = useSubscriptions(); + const { subscriptions, clearAllData } = useSubscriptions(); const posthog = usePostHog(); const router = useRouter(); const [showCurrencyPicker, setShowCurrencyPicker] = useState(false); @@ -61,9 +61,17 @@ const Settings = () => { } }; - const displayName = user?.firstName || user?.fullName || user?.emailAddresses[0]?.emailAddress?.split("@")[0] || "User"; - const email = user?.emailAddresses[0]?.emailAddress || "No email"; - const memberSince = user?.createdAt ? dayjs(user.createdAt).format("MMMM D, YYYY") : "Recently"; + const displayName = + user?.firstName || + user?.fullName || + user?.emailAddresses[0]?.emailAddress?.split("@")[0] || + "Guest"; + const email = isSignedIn + ? (user?.emailAddresses[0]?.emailAddress ?? "No email") + : "Not signed in"; + const memberSince = user?.createdAt + ? dayjs(user.createdAt).format("MMMM D, YYYY") + : "Recently"; return ( @@ -146,26 +154,51 @@ const Settings = () => { - signOut()} - className="auth-button" - > - Sign out - + {isSignedIn ? ( + signOut()} className="auth-button"> + Sign out + + ) : ( + router.push("/(auth)/sign-in")} + className="auth-button" + > + Sign in + + )} + {!isSignedIn && ( + + Sign in to back up your subscriptions and sync across devices. + + )} {__DEV__ && ( - { - resetOnboarding(); - router.replace("/onboarding"); - }} - > - - Reset onboarding (dev) - - + + { + resetOnboarding(); + router.replace("/onboarding"); + }} + > + + Reset onboarding (dev) + + + { + clearAllData(); + resetOnboarding(); + router.replace("/onboarding"); + }} + > + + Clear all data (dev) + + + )} diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 567811b..457210f 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -5,7 +5,13 @@ import { ONBOARDING_BRANDS } from "@/constants/onboardingBrands"; import { useCurrency } from "@/context/CurrencyContext"; import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; -import { getMonthlyEquivalent, resolveNextRenewal } from "@/lib/billing"; +import { + BILLING_CYCLE_KEYS, + getCycleLabel, + getMonthlyEquivalent, + resolveNextRenewal, + type BillingCycle, +} from "@/lib/billing"; import { markOnboarded } from "@/lib/onboarding"; import { formatCurrency } from "@/lib/utils"; import clsx from "clsx"; @@ -27,6 +33,12 @@ const CURRENCY_ITEMS: PickerItem[] = CURRENCY_CODES.map((code) => ({ sublabel: currencyName(code), })); +// All cycles except "custom" — a custom interval needs a day-count input, so +// that rare case is set later in the subscription detail screen. +const CYCLE_ITEMS: PickerItem[] = BILLING_CYCLE_KEYS.filter( + (key) => key !== "custom", +).map((key) => ({ value: key, label: getCycleLabel(key) })); + const VALUE_POINTS = [ "See every subscription and what it really costs you each month", "Get reminded before renewals and free trials end — so nothing surprises you", @@ -42,7 +54,11 @@ const Onboarding = () => { const [step, setStep] = useState("intro"); const [selected, setSelected] = useState>({}); const [prices, setPrices] = useState>({}); + const [cycles, setCycles] = useState>({}); const [showCurrencyPicker, setShowCurrencyPicker] = useState(false); + const [cyclePickerFor, setCyclePickerFor] = useState(null); + + const cycleFor = (title: string): BillingCycle => cycles[title] ?? "monthly"; const started = useRef(false); useEffect(() => { @@ -56,7 +72,8 @@ const Onboarding = () => { const priceFor = (title: string, fallback: number) => parseFloat(prices[title] ?? String(fallback)) || 0; const monthlyTotal = selectedBrands.reduce( - (sum, b) => sum + getMonthlyEquivalent(priceFor(b.title, b.price), "monthly"), + (sum, b) => + sum + getMonthlyEquivalent(priceFor(b.title, b.price), cycleFor(b.title)), 0, ); @@ -78,16 +95,16 @@ const Onboarding = () => { for (const brand of selectedBrands) { const price = priceFor(brand.title, brand.price); if (price <= 0) continue; + const cycle = cycleFor(brand.title); addSubscription({ name: brand.title, price, currency: baseCurrency, - billingCycle: "monthly", + billingCycle: cycle, category: brand.category, status: "active", startDate: now, - renewalDate: - resolveNextRenewal(now, "monthly")?.toISOString() ?? undefined, + renewalDate: resolveNextRenewal(now, cycle)?.toISOString() ?? undefined, }); count += 1; } @@ -220,13 +237,20 @@ const Onboarding = () => { - Confirm what you pay + + Quick add · optional + + Confirm what you pay - Adjust the defaults to what you actually pay in {baseCurrency}. You - can fine-tune any of these later. + Set what you pay in {baseCurrency} and how often. Category, renewal + date and payment method are filled in for you — open any + subscription on your dashboard to fine-tune the details and see your + full picture. @@ -247,11 +271,26 @@ const Onboarding = () => { className="flex-row items-center gap-3 rounded-2xl border border-border bg-card p-3" > - - {brand.title} - + + + {brand.title} + + setCyclePickerFor(brand.title)} + className="mt-1 flex-row items-center gap-1 self-start rounded-full border border-accent bg-accent/10 px-3 py-1" + > + + {getCycleLabel(cycleFor(brand.title))} + + + + @@ -269,6 +308,9 @@ const Onboarding = () => { {selectedBrands.length === 1 ? "" : "s"} + + You can edit or remove anything later — just tap a subscription. + setStep("pick")}> Back @@ -286,6 +328,19 @@ const Onboarding = () => { onSelect={setBaseCurrency} onClose={() => setShowCurrencyPicker(false)} /> + + + cyclePickerFor && + setCycles((s) => ({ ...s, [cyclePickerFor]: value as BillingCycle })) + } + onClose={() => setCyclePickerFor(null)} + /> ); }; diff --git a/constants/onboardingBrands.ts b/constants/onboardingBrands.ts index 8972c7d..e544ddb 100644 --- a/constants/onboardingBrands.ts +++ b/constants/onboardingBrands.ts @@ -1,30 +1,77 @@ -// Popular subscriptions offered as quick-add tiles during onboarding. Titles -// match brand-icon keywords so SubscriptionIcon renders the right logo. Prices -// are editable starting defaults (indicative monthly, in the user's base -// currency) — the user confirms/adjusts before adding. +import { BRAND_ICONS } from "@/constants/brandIcons"; + +// Onboarding quick-add tiles: every brand we have an icon for. Titles match +// SubscriptionIcon so the real logo renders. Prices are editable starting +// defaults (indicative monthly, in the user's base currency); the user +// confirms/adjusts before adding. export interface OnboardingBrand { title: string; price: number; category: string; } -export const ONBOARDING_BRANDS: OnboardingBrand[] = [ - { title: "Netflix", price: 15.49, category: "Entertainment" }, - { title: "Spotify", price: 11.99, category: "Music" }, - { title: "YouTube Premium", price: 13.99, category: "Entertainment" }, - { title: "Apple Music", price: 10.99, category: "Music" }, - { title: "iCloud+", price: 2.99, category: "Cloud" }, - { title: "ChatGPT", price: 20, category: "AI Tools" }, - { title: "Claude", price: 20, category: "AI Tools" }, - { title: "Perplexity", price: 20, category: "AI Tools" }, - { title: "Cursor", price: 20, category: "AI Tools" }, - { title: "Gemini", price: 19.99, category: "AI Tools" }, - { title: "Midjourney", price: 10, category: "AI Tools" }, - { title: "Notion", price: 10, category: "Productivity" }, - { title: "GitHub", price: 4, category: "Developer Tools" }, - { title: "Figma", price: 12, category: "Design" }, - { title: "Dropbox", price: 11.99, category: "Cloud" }, - { title: "Grammarly", price: 12, category: "Productivity" }, - { title: "Audible", price: 14.95, category: "Music" }, - { title: "Discord Nitro", price: 9.99, category: "Entertainment" }, -]; +// Known indicative monthly prices for popular services, keyed by lowercased +// brand title. Anything not listed falls back to a generic default. +const DEFAULTS: Record = { + netflix: { price: 15.49, category: "Entertainment" }, + youtube: { price: 13.99, category: "Entertainment" }, + "youtube music": { price: 10.99, category: "Music" }, + hbo: { price: 15.99, category: "Entertainment" }, + crunchyroll: { price: 7.99, category: "Entertainment" }, + twitch: { price: 8.99, category: "Entertainment" }, + "paramount+": { price: 11.99, category: "Entertainment" }, + spotify: { price: 11.99, category: "Music" }, + "apple music": { price: 10.99, category: "Music" }, + tidal: { price: 10.99, category: "Music" }, + deezer: { price: 11.99, category: "Music" }, + soundcloud: { price: 12.5, category: "Music" }, + audible: { price: 14.95, category: "Music" }, + anthropic: { price: 20, category: "AI Tools" }, + claude: { price: 20, category: "AI Tools" }, + openai: { price: 20, category: "AI Tools" }, + chatgpt: { price: 20, category: "AI Tools" }, + perplexity: { price: 20, category: "AI Tools" }, + "github copilot": { price: 10, category: "AI Tools" }, + "google gemini": { price: 19.99, category: "AI Tools" }, + gemini: { price: 19.99, category: "AI Tools" }, + cursor: { price: 20, category: "AI Tools" }, + midjourney: { price: 10, category: "AI Tools" }, + runway: { price: 15, category: "AI Tools" }, + suno: { price: 10, category: "AI Tools" }, + elevenlabs: { price: 5, category: "AI Tools" }, + notion: { price: 10, category: "Productivity" }, + github: { price: 4, category: "Developer Tools" }, + gitlab: { price: 29, category: "Developer Tools" }, + figma: { price: 12, category: "Design" }, + zoom: { price: 13.99, category: "Productivity" }, + dropbox: { price: 11.99, category: "Cloud" }, + "google drive": { price: 1.99, category: "Cloud" }, + grammarly: { price: 12, category: "Productivity" }, + vercel: { price: 20, category: "Developer Tools" }, + jetbrains: { price: 16.9, category: "Developer Tools" }, + medium: { price: 5, category: "Productivity" }, + substack: { price: 5, category: "Productivity" }, + patreon: { price: 5, category: "Entertainment" }, + x: { price: 8, category: "Entertainment" }, + "1password": { price: 2.99, category: "Cloud" }, + dashlane: { price: 4.99, category: "Cloud" }, + nordvpn: { price: 12.99, category: "Cloud" }, + expressvpn: { price: 12.95, category: "Cloud" }, + protonvpn: { price: 9.99, category: "Cloud" }, + steam: { price: 9.99, category: "Entertainment" }, + discord: { price: 9.99, category: "Entertainment" }, + roblox: { price: 4.99, category: "Entertainment" }, + coursera: { price: 59, category: "Productivity" }, + udemy: { price: 20, category: "Productivity" }, + duolingo: { price: 12.99, category: "Productivity" }, + skillshare: { price: 14, category: "Productivity" }, + icloud: { price: 2.99, category: "Cloud" }, +}; + +const FALLBACK = { price: 9.99, category: "Other" }; + +// One tile per available brand icon, in the curated icon order (popular first). +export const ONBOARDING_BRANDS: OnboardingBrand[] = BRAND_ICONS.map((icon) => { + const preset = DEFAULTS[icon.title.toLowerCase()] ?? FALLBACK; + return { title: icon.title, price: preset.price, category: preset.category }; +}); diff --git a/context/SubscriptionsContext.tsx b/context/SubscriptionsContext.tsx index 4a3b8e3..2c859e9 100644 --- a/context/SubscriptionsContext.tsx +++ b/context/SubscriptionsContext.tsx @@ -1,5 +1,5 @@ -import { seedIfEmpty } from "@/db/seed"; import { + clearAllSubscriptions, getAllSubscriptions, insertSubscription, restoreSubscription as repoRestore, @@ -33,6 +33,8 @@ interface SubscriptionsContextValue { resumeSubscription: (id: string) => Subscription | null; cancelSubscription: (id: string) => Subscription | null; getSubscription: (id: string) => Subscription | undefined; + /** Wipes all subscriptions from the device (delete-all / dev reset). */ + clearAllData: () => void; } const SubscriptionsContext = createContext( @@ -45,11 +47,11 @@ export const SubscriptionsProvider = ({ children: ReactNode; }) => { // Load synchronously on first render (SQLite reads are sync) so the UI - // never flashes an empty list before data arrives. - const [subscriptions, setSubscriptions] = useState(() => { - seedIfEmpty(); - return getAllSubscriptions(); - }); + // never flashes an empty list before data arrives. No dev seeding — real + // data comes from onboarding / the user. + const [subscriptions, setSubscriptions] = useState(() => + getAllSubscriptions(), + ); // Always-current snapshot for the foreground reconciler's listener. const subscriptionsRef = useRef(subscriptions); @@ -145,6 +147,12 @@ export const SubscriptionsProvider = ({ [subscriptions], ); + const clearAllData = useCallback(() => { + clearAllSubscriptions(); + void notifications.cancelAllReminders(); + setSubscriptions([]); + }, []); + const value = useMemo( () => ({ subscriptions, @@ -156,6 +164,7 @@ export const SubscriptionsProvider = ({ resumeSubscription, cancelSubscription, getSubscription, + clearAllData, }), [ subscriptions, @@ -167,6 +176,7 @@ export const SubscriptionsProvider = ({ resumeSubscription, cancelSubscription, getSubscription, + clearAllData, ], ); diff --git a/db/seed.ts b/db/seed.ts deleted file mode 100644 index 189bba5..0000000 --- a/db/seed.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { getAllSubscriptions, insertSubscription } from "./subscriptionsRepo"; - -/** Dev-only seed data (replaces the old constants/data.ts mock wiring). */ -const DEV_SEEDS: SubscriptionDraft[] = [ - { - name: "Adobe Creative Cloud", - plan: "Teams Plan", - category: "Design", - paymentMethod: "Visa ending in 8530", - status: "active", - startDate: "2025-03-20T10:00:00.000Z", - price: 77.49, - currency: "USD", - billingCycle: "monthly", - renewalDate: "2026-07-20T10:00:00.000Z", - color: "#f5c542", - }, - { - name: "GitHub Pro", - plan: "Developer", - category: "Developer Tools", - paymentMethod: "Mastercard ending in 2408", - status: "active", - startDate: "2024-11-24T10:00:00.000Z", - price: 9.99, - currency: "USD", - billingCycle: "monthly", - renewalDate: "2026-07-24T10:00:00.000Z", - color: "#e8def8", - }, - { - name: "Claude Pro", - plan: "Pro Plan", - category: "AI Tools", - paymentMethod: "Amex ending in 1010", - status: "paused", - startDate: "2025-06-27T10:00:00.000Z", - price: 20.0, - currency: "USD", - billingCycle: "monthly", - renewalDate: "2026-07-27T10:00:00.000Z", - color: "#b8d4e3", - }, - { - name: "Canva Pro", - plan: "Yearly Access", - category: "Design", - paymentMethod: "Visa ending in 7784", - status: "cancelled", - startDate: "2024-04-02T10:00:00.000Z", - price: 119.99, - currency: "USD", - billingCycle: "annual", - renewalDate: "2027-04-02T10:00:00.000Z", - color: "#b8e8d0", - }, -]; - -/** Seeds the database with demo data in development builds only. */ -export const seedIfEmpty = (): void => { - if (!__DEV__) return; - if (getAllSubscriptions().length > 0) return; - for (const draft of DEV_SEEDS) { - insertSubscription(draft); - } -}; diff --git a/db/subscriptionsRepo.ts b/db/subscriptionsRepo.ts index 2f82b6d..cbe144d 100644 --- a/db/subscriptionsRepo.ts +++ b/db/subscriptionsRepo.ts @@ -166,6 +166,11 @@ export const updateSubscription = ( return getSubscriptionById(id); }; +/** Hard-deletes every subscription (for "delete all my data" / dev reset). */ +export const clearAllSubscriptions = (): void => { + getDatabase().runSync("DELETE FROM subscriptions"); +}; + /** Soft delete (tombstone kept for future sync + undo). */ export const softDeleteSubscription = (id: string): void => { const timestamp = nowIso(); From 2d0a826b37490401679e740bef0a171493f1d266 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Tue, 14 Jul 2026 18:58:27 +1000 Subject: [PATCH 3/5] Guided, animated first-time experience (Cal-AI-style funnel) Onboarding is now a guided funnel with progress, motion, and a payoff, built with core Animated + react-native-svg (no new dependencies): - Steps intro -> goal -> currency -> pick -> confirm -> analyzing -> done, each spring-transitioning in, with a top progress bar (n/N). - A branded guide bubble delivers one short line per step (replaces the paragraph subtitles); a "What brings you here?" goal step lightly tailors later copy and fires onboarding_goal_selected. - Confirm reveal counts up (eased) to the real monthly spend; an "analyzing your spend" anticipation beat precedes an SVG checkmark + confetti celebration with a success haptic. - Intro shows a single brand mark (fixes the double-"R"); copy rewritten to short human lines; type scale enlarged. - Home: one-time "+" pulse until the first add (persisted via lib/nudges), success haptic + count-up hero on add, chevron affordance on cards. New: components/AnimatedCounter, components/onboarding/{GuideBubble, ProgressBar,CelebrationOverlay}, lib/haptics, lib/nudges. Character + one asset renderer (Rive) deferred to the rebrand. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(tabs)/index.tsx | 55 +- app/onboarding.tsx | 547 ++++++++++++------- components/AnimatedCounter.tsx | 44 ++ components/SubscriptionCard.tsx | 10 +- components/onboarding/CelebrationOverlay.tsx | 125 +++++ components/onboarding/GuideBubble.tsx | 49 ++ components/onboarding/ProgressBar.tsx | 42 ++ global.css | 56 ++ lib/haptics.ts | 18 + lib/nudges.ts | 14 + 10 files changed, 766 insertions(+), 194 deletions(-) create mode 100644 components/AnimatedCounter.tsx create mode 100644 components/onboarding/CelebrationOverlay.tsx create mode 100644 components/onboarding/GuideBubble.tsx create mode 100644 components/onboarding/ProgressBar.tsx create mode 100644 lib/haptics.ts create mode 100644 lib/nudges.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 5452f0d..1ad13dc 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,3 +1,4 @@ +import AnimatedCounter from "@/components/AnimatedCounter"; import ListHeading from "@/components/ListHeading"; import SubscriptionCard from "@/components/SubscriptionCard"; import SubscriptionFormModal from "@/components/SubscriptionFormModal"; @@ -9,12 +10,14 @@ import { useSubscriptions } from "@/context/SubscriptionsContext"; import "@/global.css"; import { priceBucket } from "@/lib/analytics"; import { getDaysUntilRenewal, getMonthlyEquivalent } from "@/lib/billing"; +import { success } from "@/lib/haptics"; +import { hasSeenNudge, markNudgeSeen } from "@/lib/nudges"; import { formatCurrency } from "@/lib/utils"; import { useClerk, useUser } from "@clerk/expo"; import { styled } from "nativewind"; import { usePostHog } from "posthog-react-native"; -import { useMemo, useState } from "react"; -import { FlatList, Image, Pressable, Text, View } from "react-native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Animated, FlatList, Image, Pressable, Text, View } from "react-native"; import { useRouter } from "expo-router"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; @@ -28,12 +31,41 @@ export default function App() { const posthog = usePostHog(); const router = useRouter(); const [isCreateModalVisible, setCreateModalVisible] = useState(false); + const [showAddNudge, setShowAddNudge] = useState( + () => !hasSeenNudge("add_first"), + ); const activeSubscriptions = useMemo( () => subscriptions.filter((sub) => sub.status === "active"), [subscriptions], ); + // One-time first-run nudge: gently pulse the "+" until the first sub is added. + const addPulse = useRef(new Animated.Value(1)).current; + const nudgeActive = showAddNudge && activeSubscriptions.length === 0; + useEffect(() => { + if (!nudgeActive) { + addPulse.setValue(1); + return; + } + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(addPulse, { + toValue: 1.15, + duration: 600, + useNativeDriver: true, + }), + Animated.timing(addPulse, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [nudgeActive, addPulse]); + // Real monthly outflow across mixed billing cycles (annual/quarterly/etc. // are normalized to a per-month average, so the total reflects everything). const monthlyTotal = useMemo( @@ -84,6 +116,11 @@ export default function App() { const handleCreate = (draft: SubscriptionDraft) => { const created = addSubscription(draft); + success(); + if (showAddNudge) { + markNudgeSeen("add_first"); + setShowAddNudge(false); + } // Non-identifying signal only: no name, no exact price, no currency. posthog.capture("subscription_created", { subscription_id: created.id, @@ -133,7 +170,9 @@ export default function App() { setCreateModalVisible(true)}> - + + + @@ -147,13 +186,13 @@ export default function App() { - - {formatCurrency(monthlyTotal, baseCurrency)} - + /> ≈ {formatCurrency(yearlyTotal, baseCurrency)} / year @@ -211,7 +250,7 @@ export default function App() { showsVerticalScrollIndicator={false} ListEmptyComponent={ - No subscriptions yet — tap + to add your first one. + No subscriptions yet — tap + and I'll do the math. } contentContainerClassName="pb-30" diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 457210f..599cb1d 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,5 +1,10 @@ +import AnimatedCounter from "@/components/AnimatedCounter"; +import CelebrationOverlay from "@/components/onboarding/CelebrationOverlay"; +import GuideBubble from "@/components/onboarding/GuideBubble"; +import ProgressBar from "@/components/onboarding/ProgressBar"; import PickerSheet, { type PickerItem } from "@/components/PickerSheet"; import SubscriptionIcon from "@/components/SubscriptionIcon"; +import logoGlow from "@/assets/images/logo-glow.png"; import { CURRENCY_CODES, currencyName } from "@/constants/currencies"; import { ONBOARDING_BRANDS } from "@/constants/onboardingBrands"; import { useCurrency } from "@/context/CurrencyContext"; @@ -12,6 +17,7 @@ import { resolveNextRenewal, type BillingCycle, } from "@/lib/billing"; +import { tapLight } from "@/lib/haptics"; import { markOnboarded } from "@/lib/onboarding"; import { formatCurrency } from "@/lib/utils"; import clsx from "clsx"; @@ -20,12 +26,31 @@ import { useRouter } from "expo-router"; import { styled } from "nativewind"; import { usePostHog } from "posthog-react-native"; import { useEffect, useRef, useState } from "react"; -import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { + Animated, + Easing, + Image, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from "react-native"; import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context"; const SafeAreaView = styled(RNSafeAreaView) as any; -type Step = "intro" | "currency" | "pick" | "confirm"; +type Step = + | "intro" + | "goal" + | "currency" + | "pick" + | "confirm" + | "analyzing" + | "done"; + +// Steps that show the progress bar, in order. +const DOT_STEPS: Step[] = ["goal", "currency", "pick", "confirm"]; const CURRENCY_ITEMS: PickerItem[] = CURRENCY_CODES.map((code) => ({ value: code, @@ -39,12 +64,63 @@ const CYCLE_ITEMS: PickerItem[] = BILLING_CYCLE_KEYS.filter( (key) => key !== "custom", ).map((key) => ({ value: key, label: getCycleLabel(key) })); -const VALUE_POINTS = [ - "See every subscription and what it really costs you each month", - "Get reminded before renewals and free trials end — so nothing surprises you", - "Your data stays on your device. No bank login, ever.", +const GOALS = [ + { key: "forgotten", label: "Stop paying for forgotten subs" }, + { key: "renewals", label: "Never miss a renewal" }, + { key: "total", label: "See my total spend" }, + { key: "trials", label: "Catch free-trial endings" }, +] as const; + +// Light, goal-tailored guide line on the confirm step. +const CONFIRM_GUIDE: Record = { + forgotten: "Let's see what's quietly adding up.", + renewals: "I'll remind you before each of these renews.", + total: "Here's what you're really spending.", + trials: "Add these and I'll warn you before any trial charges.", +}; + +const ANALYZING_LINES = [ + "Adding your subscriptions…", + "Working out your monthly total…", ]; +/** Simple ring spinner (no animation library). */ +const Spinner = () => { + const spin = useRef(new Animated.Value(0)).current; + useEffect(() => { + const loop = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + loop.start(); + return () => loop.stop(); + }, [spin]); + return ( + + ); +}; + const Onboarding = () => { const router = useRouter(); const posthog = usePostHog(); @@ -52,14 +128,50 @@ const Onboarding = () => { const { addSubscription } = useSubscriptions(); const [step, setStep] = useState("intro"); + const [goal, setGoal] = useState(null); const [selected, setSelected] = useState>({}); const [prices, setPrices] = useState>({}); const [cycles, setCycles] = useState>({}); const [showCurrencyPicker, setShowCurrencyPicker] = useState(false); const [cyclePickerFor, setCyclePickerFor] = useState(null); + const [addedCount, setAddedCount] = useState(0); + const [celebrateTotal, setCelebrateTotal] = useState(0); + const [analyzeLine, setAnalyzeLine] = useState(0); const cycleFor = (title: string): BillingCycle => cycles[title] ?? "monthly"; + // Fade + spring the active step in whenever `step` changes. + const fade = useRef(new Animated.Value(1)).current; + const slide = useRef(new Animated.Value(0)).current; + useEffect(() => { + fade.setValue(0); + slide.setValue(24); + Animated.parallel([ + Animated.timing(fade, { toValue: 1, duration: 240, useNativeDriver: true }), + Animated.spring(slide, { + toValue: 0, + friction: 7, + tension: 60, + useNativeDriver: true, + }), + ]).start(); + }, [step, fade, slide]); + + // The "analyzing" anticipation beat: cycle lines, then reveal the celebration. + useEffect(() => { + if (step !== "analyzing") return; + setAnalyzeLine(0); + const cycle = setInterval( + () => setAnalyzeLine((i) => (i + 1) % ANALYZING_LINES.length), + 650, + ); + const done = setTimeout(() => setStep("done"), 1500); + return () => { + clearInterval(cycle); + clearTimeout(done); + }; + }, [step]); + const started = useRef(false); useEffect(() => { if (!started.current) { @@ -89,6 +201,18 @@ const Onboarding = () => { router.replace("/"); }; + const selectGoal = (key: string) => { + tapLight(); + setGoal(key); + posthog.capture("onboarding_goal_selected", { goal: key }); + setStep("currency"); + }; + + const toggleBrand = (title: string) => { + tapLight(); + setSelected((s) => ({ ...s, [title]: !s[title] })); + }; + const addSelected = () => { const now = dayjs().toISOString(); let count = 0; @@ -108,215 +232,268 @@ const Onboarding = () => { }); count += 1; } - finish(count); + if (count === 0) { + finish(0); + return; + } + setAddedCount(count); + setCelebrateTotal(monthlyTotal); + setStep("analyzing"); }; - const goToPick = () => setStep("pick"); const afterPick = () => { if (selectedBrands.length > 0) setStep("confirm"); else finish(0); }; + const dotIndex = DOT_STEPS.indexOf(step); + const confirmGuide = + (goal && CONFIRM_GUIDE[goal]) || "Set what you pay and how often."; + return ( - {step === "intro" && ( - - - - - R - - - Recurrly - SMART BILLING - - - Take back control of your subscriptions - - {VALUE_POINTS.map((point) => ( - - - - {point} - + {dotIndex >= 0 && ( + + + + )} + + + {step === "intro" && ( + + + + + + Recurrly + SMART BILLING - ))} + + + See what you're really paying for. + + + No bank login. Your data stays on your phone. + - - - setStep("currency")}> - Get started + setStep("goal")}> + Let's go - - )} + )} - {step === "currency" && ( - - - What currency do you pay in? - - Every amount you enter will be shown in this currency. You can - change it later in Settings. - + {step === "goal" && ( + + + + + {GOALS.map((g) => ( + selectGoal(g.key)} + className={clsx( + "rounded-2xl border p-4", + goal === g.key + ? "border-accent bg-accent/10" + : "border-border bg-card", + )} + > + + {g.label} + + + ))} + + setShowCurrencyPicker(true)} + className="items-center py-2" + onPress={() => setStep("currency")} > - - {baseCurrency} · {currencyName(baseCurrency)} - - - ▾ + + Skip - - Continue - - - )} + )} - {step === "pick" && ( - // Single scroll with the CTA at the end so it's always reachable - // (a flex-1 ScrollView + fixed footer can push the button off-screen). - - Add your subscriptions - - Tap the ones you pay for. You can add more (or custom ones) anytime. - - - {ONBOARDING_BRANDS.map((brand) => { - const active = !!selected[brand.title]; - return ( - - setSelected((s) => ({ ...s, [brand.title]: !s[brand.title] })) - } - style={{ width: "31%" }} - className={clsx( - "items-center gap-2 rounded-2xl border p-3", - active - ? "border-accent bg-accent/10" - : "border-border bg-card", - )} - > - + {step === "currency" && ( + + + + setShowCurrencyPicker(true)} + > + + {baseCurrency} · {currencyName(baseCurrency)} + + + ▾ + + + + setStep("pick")}> + Continue + + + )} + + {step === "pick" && ( + // Single scroll with the CTA at the end so it's always reachable + // (a flex-1 ScrollView + fixed footer can push the button off-screen). + + + + {ONBOARDING_BRANDS.map((brand) => { + const active = !!selected[brand.title]; + return ( + toggleBrand(brand.title)} + style={{ width: "31%" }} + className={clsx( + "items-center gap-2 rounded-2xl border p-3", + active + ? "border-accent bg-accent/10" + : "border-border bg-card", + )} + > + + + {brand.title} + + + ); + })} + + + + {selectedBrands.length > 0 + ? `Continue with ${selectedBrands.length}` + : "Continue"} + + + + + Skip for now + + + + )} + + {step === "confirm" && ( + + + + + + Your monthly spend + + + + ≈ {formatCurrency(monthlyTotal * 12, baseCurrency)} a year + + + + {selectedBrands.map((brand) => ( + + + {brand.title} - - ); - })} - - - - {selectedBrands.length > 0 - ? `Continue with ${selectedBrands.length}` - : "Continue"} - - - - - Skip for now - - - - )} + setCyclePickerFor(brand.title)} + className="mt-1 flex-row items-center gap-1 self-start rounded-full border border-accent bg-accent/10 px-3 py-1" + > + + {getCycleLabel(cycleFor(brand.title))} + + + + + + setPrices((p) => ({ ...p, [brand.title]: v })) + } + placeholder="0.00" + placeholderTextColor="#666666" + /> + + ))} - {step === "confirm" && ( - - - Quick add · optional - - Confirm what you pay - - Set what you pay in {baseCurrency} and how often. Category, renewal - date and payment method are filled in for you — open any - subscription on your dashboard to fine-tune the details and see your - full picture. - - - - - Your monthly spend - - - {formatCurrency(monthlyTotal, baseCurrency)} - - - ≈ {formatCurrency(monthlyTotal * 12, baseCurrency)} a year + + + Add {selectedBrands.length} subscription + {selectedBrands.length === 1 ? "" : "s"} + + + + Edit or remove anything later. Just tap a subscription. - - - {selectedBrands.map((brand) => ( - setStep("pick")} > - - - - {brand.title} - - setCyclePickerFor(brand.title)} - className="mt-1 flex-row items-center gap-1 self-start rounded-full border border-accent bg-accent/10 px-3 py-1" - > - - {getCycleLabel(cycleFor(brand.title))} - - - - - - setPrices((p) => ({ ...p, [brand.title]: v })) - } - placeholder="0.00" - placeholderTextColor="#666666" - /> - - ))} + + Back + + + + )} - - - Add {selectedBrands.length} subscription - {selectedBrands.length === 1 ? "" : "s"} - - - - You can edit or remove anything later — just tap a subscription. - - setStep("pick")}> - - Back + {step === "analyzing" && ( + + + + {ANALYZING_LINES[analyzeLine]} - - + + )} + + + {step === "done" && ( + finish(addedCount)} + /> )} { + const anim = useRef(new Animated.Value(0)).current; + const [display, setDisplay] = useState(0); + + useEffect(() => { + const id = anim.addListener(({ value: v }) => setDisplay(v)); + const animation = Animated.timing(anim, { + toValue: value, + duration, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }); + animation.start(); + return () => { + animation.stop(); + anim.removeListener(id); + }; + }, [value, duration, anim]); + + return {formatCurrency(display, currency)}; +}; + +export default AnimatedCounter; diff --git a/components/SubscriptionCard.tsx b/components/SubscriptionCard.tsx index 802583b..1ec309c 100644 --- a/components/SubscriptionCard.tsx +++ b/components/SubscriptionCard.tsx @@ -62,7 +62,10 @@ const SubscriptionCard = ({ expanded ? "sub-card-expanded" : "bg-card", !isActive && "opacity-60", )} - style={!expanded && color ? { backgroundColor: color } : undefined} + style={({ pressed }) => [ + !expanded && color ? { backgroundColor: color } : null, + pressed && !expanded ? { opacity: 0.85 } : null, + ]} > @@ -87,6 +90,11 @@ const SubscriptionCard = ({ {formatCurrency(price, baseCurrency)} {billing} + {!expanded && ( + + › + + )} {expanded && ( diff --git a/components/onboarding/CelebrationOverlay.tsx b/components/onboarding/CelebrationOverlay.tsx new file mode 100644 index 0000000..ed4315a --- /dev/null +++ b/components/onboarding/CelebrationOverlay.tsx @@ -0,0 +1,125 @@ +import "@/global.css"; +import { success } from "@/lib/haptics"; +import { useEffect, useRef } from "react"; +import { Animated, Text, View } from "react-native"; +import Svg, { Circle, Path } from "react-native-svg"; + +interface CelebrationOverlayProps { + title: string; + subtitle?: string; + /** Called once the celebration finishes (advance to Home). */ + onDone: () => void; +} + +// A handful of dots that burst outward from the checkmark — DIY confetti, no +// asset files. Offsets are the final translation; colors are theme tokens. +const CONFETTI = [ + { x: -84, y: -48, color: "#ea7a53" }, + { x: 88, y: -56, color: "#16a34a" }, + { x: -64, y: 64, color: "#081126" }, + { x: 74, y: 56, color: "#ea7a53" }, + { x: 4, y: -96, color: "#16a34a" }, + { x: 14, y: 92, color: "#ea7a53" }, +]; + +/** + * Full-screen success moment: an SVG checkmark springs in, confetti bursts, a + * short line fades up, and a success haptic fires. Auto-advances via onDone. + * Pure react-native-svg + Animated — no animation library / asset files. + */ +const CelebrationOverlay = ({ + title, + subtitle, + onDone, +}: CelebrationOverlayProps) => { + const scale = useRef(new Animated.Value(0)).current; + const copyOpacity = useRef(new Animated.Value(0)).current; + const burst = useRef(new Animated.Value(0)).current; + + useEffect(() => { + success(); + Animated.sequence([ + Animated.parallel([ + Animated.spring(scale, { + toValue: 1, + friction: 5, + tension: 120, + useNativeDriver: true, + }), + Animated.timing(burst, { + toValue: 1, + duration: 650, + useNativeDriver: true, + }), + Animated.timing(copyOpacity, { + toValue: 1, + duration: 320, + delay: 140, + useNativeDriver: true, + }), + ]), + Animated.delay(1000), + ]).start(() => onDone()); + }, [scale, copyOpacity, burst, onDone]); + + return ( + + + {CONFETTI.map((dot, i) => ( + + ))} + + + + + + + + + + {title} + {subtitle ? ( + {subtitle} + ) : null} + + + + ); +}; + +export default CelebrationOverlay; diff --git a/components/onboarding/GuideBubble.tsx b/components/onboarding/GuideBubble.tsx new file mode 100644 index 0000000..9961d1a --- /dev/null +++ b/components/onboarding/GuideBubble.tsx @@ -0,0 +1,49 @@ +import "@/global.css"; +import { useEffect, useRef } from "react"; +import { Animated, Text, View } from "react-native"; + +/** + * The branded "guide" presence: a circular brand mark + a speech bubble with a + * single short line. Fades/slides in whenever the line changes, so each step of + * onboarding feels like the guide speaking. No mascot art — restyled later with + * the Midnight Ledger rebrand. + * + * Styling lives on inner Views; the Animated.View only carries the transform + * (NativeWind v5-preview doesn't reliably apply className to Animated.View). + */ +const GuideBubble = ({ text }: { text: string }) => { + const opacity = useRef(new Animated.Value(0)).current; + const translateY = useRef(new Animated.Value(8)).current; + + useEffect(() => { + opacity.setValue(0); + translateY.setValue(8); + Animated.parallel([ + Animated.timing(opacity, { + toValue: 1, + duration: 260, + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: 0, + duration: 260, + useNativeDriver: true, + }), + ]).start(); + }, [text, opacity, translateY]); + + return ( + + + + R + + + {text} + + + + ); +}; + +export default GuideBubble; diff --git a/components/onboarding/ProgressBar.tsx b/components/onboarding/ProgressBar.tsx new file mode 100644 index 0000000..3b3f112 --- /dev/null +++ b/components/onboarding/ProgressBar.tsx @@ -0,0 +1,42 @@ +import "@/global.css"; +import { useEffect, useRef } from "react"; +import { Animated, Text, View } from "react-native"; + +/** + * Mockup-style onboarding progress: a filled track that animates forward as the + * user advances, with an "n / N" count. Width animates on the JS thread but it's + * a single short tween per step, so cost is trivial. + */ +const ProgressBar = ({ count, index }: { count: number; index: number }) => { + const fraction = Math.min(1, Math.max(0, (index + 1) / count)); + const anim = useRef(new Animated.Value(fraction)).current; + + useEffect(() => { + Animated.timing(anim, { + toValue: fraction, + duration: 320, + useNativeDriver: false, + }).start(); + }, [fraction, anim]); + + return ( + + + + + + {index + 1}/{count} + + + ); +}; + +export default ProgressBar; diff --git a/global.css b/global.css index 9758488..6f13200 100644 --- a/global.css +++ b/global.css @@ -442,6 +442,62 @@ @apply text-accent; } + .guide-avatar { + @apply size-11 items-center justify-center rounded-2xl bg-accent; + } + + .guide-avatar-text { + @apply text-xl font-sans-extrabold text-background; + } + + .guide-bubble { + @apply flex-1 rounded-2xl rounded-tl-sm border border-border bg-card px-4 py-3; + } + + .guide-bubble-text { + @apply text-lg font-sans-semibold text-primary; + } + + .onboarding-headline { + @apply text-4xl font-sans-extrabold leading-tight text-primary; + } + + .progress-row { + @apply flex-row items-center gap-3; + } + + .progress-track { + @apply h-2 flex-1 overflow-hidden rounded-full bg-border; + } + + .progress-fill { + @apply h-full rounded-full bg-accent; + } + + .progress-count { + @apply text-xs font-sans-bold text-muted-foreground; + } + + .celebrate-overlay { + @apply absolute inset-0 items-center justify-center gap-6 bg-background p-8; + } + + .celebrate-check { + @apply size-24 items-center justify-center rounded-full bg-success; + } + + .celebrate-check-text { + @apply text-5xl font-sans-extrabold text-background; + } + + .celebrate-title { + @apply text-center text-4xl font-sans-extrabold text-primary; + } + + .celebrate-subtitle { + @apply text-center text-base font-sans-medium text-muted-foreground; + } + .insights-header { @apply flex-row items-center justify-between border-b border-border px-5 py-4; } diff --git a/lib/haptics.ts b/lib/haptics.ts new file mode 100644 index 0000000..6844781 --- /dev/null +++ b/lib/haptics.ts @@ -0,0 +1,18 @@ +import * as Haptics from "expo-haptics"; + +/** + * Thin wrappers around expo-haptics. Fire-and-forget and self-silencing so + * callers never need to guard for unsupported hardware / rejected promises. + */ + +/** Light selection tap — e.g. toggling a choice. */ +export const tapLight = (): void => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {}); +}; + +/** Success notification — e.g. committing an add or a celebration. */ +export const success = (): void => { + void Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success, + ).catch(() => {}); +}; diff --git a/lib/nudges.ts b/lib/nudges.ts new file mode 100644 index 0000000..d34603a --- /dev/null +++ b/lib/nudges.ts @@ -0,0 +1,14 @@ +import { getKv, setKv } from "@/db/subscriptionsRepo"; + +/** + * One-time UI nudge flags (e.g. the first-run "+" pulse on Home). Stored in the + * same kv table as onboarding state — see [[lib/onboarding.ts]] for the pattern. + */ +export type NudgeKey = "add_first"; + +const kvKey = (key: NudgeKey): string => `nudge_${key}`; + +export const hasSeenNudge = (key: NudgeKey): boolean => + getKv(kvKey(key)) === "1"; + +export const markNudgeSeen = (key: NudgeKey): void => setKv(kvKey(key), "1"); From 7a2455c90b90cbe4bc36b13b13299d49d5bfe095 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Wed, 15 Jul 2026 12:19:26 +1000 Subject: [PATCH 4/5] Add-another loop, trial/cancelled stats; capture reference-scan backlog Two quick wins from the reference-flow scan: - SubscriptionFormModal: after a create, keep the modal open on a "Subscription added" prompt with Add another / Done, so users can add several in a row (edits still close immediately). - Insights: show "On trial" and "Cancelled" counts alongside the existing spend/active/saved stats. Also record the net-new reference-scan ideas (social login, spend-over-time chart, next-month projection, payment history, catalog filters, date grouping, add-another) into PRODUCTION_PLAN.md, and explicitly reject the wallet/pay/top-up/transfer payment features as conflicting with our no-bank-login, local-first positioning. Co-Authored-By: Claude Opus 4.8 (1M context) --- PRODUCTION_PLAN.md | 10 ++++ components/Insights.tsx | 7 +++ components/SubscriptionFormModal.tsx | 85 +++++++++++++++++++++------- 3 files changed, 83 insertions(+), 19 deletions(-) diff --git a/PRODUCTION_PLAN.md b/PRODUCTION_PLAN.md index 03a9a26..1e82a39 100644 --- a/PRODUCTION_PLAN.md +++ b/PRODUCTION_PLAN.md @@ -261,6 +261,16 @@ All of these ride on the existing SQLite + design system + report/share infrastr - **"Suggest a brand"** entry (routes into the feature-request board) for logos we don't have — monogram covers them meanwhile. - _Explicitly NOT adopting:_ Bobby's per-sub multiple currencies / currency breakdown — we chose single base currency. +**Adopted from a Russian subscription-tracker reference scan (July 2026, flow diagrams + screens):** + +- **"Add another subscription?" loop** after a save — keep the add flow open with an Add-another / Done choice to sustain momentum (bulk-add). _(shipped early as a quick win.)_ +- **Social login (Google/Apple)** at sign-up/paywall via Clerk OAuth — we're email/password only; cuts signup friction where it matters most (the trial gate). Near-term. +- **Analytics upgrades:** an **"on trial" count** _(shipped early as a quick win)_; a **spend-over-time 12-month chart with an average line** (distinct from our current top-subs-by-cost chart); a **"next month" projection** tab; a **payment-history / charge ledger**. → fold into the Phase-2 Reports surface. +- **Catalog category filters + explicit "Nothing found" state** on brand search (pairs with "suggest a brand"). +- **Group the Home list by date** (section headers) and optional **voice search** on the catalog — minor niceties for the UX-redesign phase. +- **"Loading with a reward"** framing — present onboarding completion as a "first win" reward (our celebration already does the mechanics; a copy/reward layer with the rebrand). +- _Explicitly NOT adopting:_ the reference's **Wallet / Pay / Top-up / Transfer** payment features — moving money in-app requires bank/payment rails, which contradicts our "no bank login, local-first, data-stays-on-device" wedge and balloons the compliance surface. Center-FAB tab redesign → deferred to the UX-redesign phase. + --- ## 8. Marketing & Launch Plan diff --git a/components/Insights.tsx b/components/Insights.tsx index 0aa2834..7d94df8 100644 --- a/components/Insights.tsx +++ b/components/Insights.tsx @@ -57,6 +57,7 @@ const Insights = () => { const monthlyTotal = active.reduce((sum, s) => sum + monthlyOf(s), 0); const savedMonthly = cancelled.reduce((sum, s) => sum + monthlyOf(s), 0); + const trialCount = active.filter((s) => s.isTrial).length; // Top subscriptions by monthly-equivalent cost → each bar is a real // subscription from the list, so the chart reconciles with what the user @@ -72,6 +73,8 @@ const Insights = () => { yearlyTotal: monthlyTotal * 12, savedMonthly, activeCount: active.length, + trialCount, + cancelledCount: cancelled.length, chart, maxAmount, }; @@ -121,6 +124,10 @@ const Insights = () => { accent /> + + + + {/* Spend by category */} diff --git a/components/SubscriptionFormModal.tsx b/components/SubscriptionFormModal.tsx index 8a56927..3f1b6de 100644 --- a/components/SubscriptionFormModal.tsx +++ b/components/SubscriptionFormModal.tsx @@ -13,7 +13,7 @@ import { formatSubscriptionDateTime } from "@/lib/utils"; import DateTimePicker from "@react-native-community/datetimepicker"; import clsx from "clsx"; import dayjs from "dayjs"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { KeyboardAvoidingView, Modal, @@ -76,11 +76,27 @@ const SubscriptionFormModal = ({ const [startDate, setStartDate] = useState(new Date()); const [showDatePicker, setShowDatePicker] = useState(false); const [showBrandPicker, setShowBrandPicker] = useState(false); + // After a create, we keep the modal open on a success prompt so the user can + // add another subscription in a row (bulk-add momentum). + const [showAddedPrompt, setShowAddedPrompt] = useState(false); + + const resetForm = useCallback(() => { + setName(""); + setPrice(""); + setPaymentMethod(""); + setBillingCycle("monthly"); + setCustomDays("30"); + setCategory(null); + setIsTrial(false); + setTrialDays("7"); + setStartDate(new Date()); + }, []); // Prefill when opening in edit mode (or reset when opening in create mode). useEffect(() => { if (!visible) return; setShowDatePicker(false); + setShowAddedPrompt(false); if (subscription) { setName(subscription.name); setPrice(String(subscription.price)); @@ -103,17 +119,9 @@ const SubscriptionFormModal = ({ subscription.startDate ? new Date(subscription.startDate) : new Date(), ); } else { - setName(""); - setPrice(""); - setPaymentMethod(""); - setBillingCycle("monthly"); - setCustomDays("30"); - setCategory(null); - setIsTrial(false); - setTrialDays("7"); - setStartDate(new Date()); + resetForm(); } - }, [visible, subscription]); + }, [visible, subscription, resetForm]); const trimmedName = name.trim(); const parsedPrice = parseFloat(price); @@ -166,7 +174,12 @@ const SubscriptionFormModal = ({ }; onSubmit(draft); - onClose(); + // Edits close immediately; a new add offers "add another" to keep momentum. + if (isEdit) { + onClose(); + } else { + setShowAddedPrompt(true); + } }; const onDateChange = (event: { type: string }, selected?: Date) => { @@ -197,18 +210,51 @@ const SubscriptionFormModal = ({ - {isEdit ? "Edit Subscription" : "New Subscription"} + {showAddedPrompt + ? "Subscription added" + : isEdit + ? "Edit Subscription" + : "New Subscription"} × - + {showAddedPrompt ? ( + + + + ✓ + + + + Added to your list + + + Track another, or tap Done. + + { + resetForm(); + setShowAddedPrompt(false); + }} + > + Add another + + + + Done + + + + ) : ( + Name @@ -407,7 +453,8 @@ const SubscriptionFormModal = ({ {isEdit ? "Save Changes" : "Add Subscription"} - + + )} From b4e52bed72a697be17578c932c1bac4b01c68df1 Mon Sep 17 00:00:00 2001 From: Sai Kumar Date: Wed, 15 Jul 2026 14:35:11 +1000 Subject: [PATCH 5/5] Address code-review findings (docs + FTUE correctness) Docs (PRODUCTION_PLAN.md): - Define base-currency change policy (lock once data exists / reset to change; no relabel, no FX), naming the enforcement point + formatters. - Correct the annual-plan revenue assumption ($8.66 -> $3.33/mo from $39.99/yr) and reconcile the downstream COGS figures. - Define sign-out / account-boundary behavior (no account switching; sign-out keeps local data; only Clear-all-data wipes; namespace by user id when sync ships). Code: - CelebrationOverlay: read onDone via a ref, run the sequence once on mount, fire onDone only when finished, stop on cleanup; memoize the callback in onboarding so re-renders don't restart it / double-navigate. - onboarding: normalize comma decimal separators in price parsing ("12,99"); derive the "Add N" CTA count from the same price > 0 rule addSelected uses. - Lazy useState init for Animated.Value (AnimatedCounter anim, Home addPulse) instead of per-render construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- PRODUCTION_PLAN.md | 9 ++--- app/(tabs)/index.tsx | 4 +-- app/onboarding.tsx | 35 ++++++++++++++------ components/AnimatedCounter.tsx | 4 +-- components/onboarding/CelebrationOverlay.tsx | 14 ++++++-- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/PRODUCTION_PLAN.md b/PRODUCTION_PLAN.md index 1e82a39..a6243c9 100644 --- a/PRODUCTION_PLAN.md +++ b/PRODUCTION_PLAN.md @@ -20,7 +20,8 @@ _Last updated: Jul 2026. This section is the source of truth; older prose below - **Trial length: 3 days** (not 7). Must be shorter than the weekly plan period. - **No free-tier count cap** (not "5 subs"). Avg user tracks 2–3 subs so a cap won't bind. Monetize on the AI money-saver + trial, not quantity. (Optional high anti-abuse ceiling only.) -- **Single app-wide base currency, no FX** (not per-sub multi-currency / conversion / dual-display). Users enter amounts in their own currency; changeable in Settings. No `RatesProvider`/FX. +- **Single app-wide base currency, no FX** (not per-sub multi-currency / conversion / dual-display). Users enter amounts in their own currency. No `RatesProvider`/FX. + - **Base-currency change policy (MUST enforce before shipping the Settings picker):** stored amounts are raw numbers with no currency conversion, and they are formatted by the current base currency everywhere — UI (`formatCurrency`), reminders ([lib/reminders.ts](lib/reminders.ts) `formatCurrency(sub.price, baseCurrency)`), and notification scheduling ([lib/notifications.ts](lib/notifications.ts) via `baseCurrency()`). Because there is no FX, a bare currency change would **silently relabel** every existing amount (e.g. `15.49 USD` → `15.49 EUR`) — wrong totals, wrong reminder text. Policy: the base currency is **freely selectable only while no subscription data exists** (onboarding currency step / empty state). **Once any subscription exists it is locked**, and the only way to change it is a guarded **"clear all data and re-enter amounts"** reset (reuses the existing `clearAllData`). We do **not** offer relabel-in-place and we do **not** introduce conversion (that would reverse the no-FX decision). Enforcement point: the Settings currency picker + `CurrencyContext.setBaseCurrency` must gate on `subscriptions.length === 0`. - **Guest-first** (not auth-first): onboarding leads for everyone; sign-in optional, surfaced in Settings, only needed later for Pro/backup. - **Positioning: an AI money-saver** (sell savings, not "a list"). Free = tracking + basic reminders + export; Pro = AI audit/forecasts + full history + backup/sync + custom reminders + widgets. - **Single-user-per-device** assumed (no multi-account isolation); multi-**device** portability is the Pro backup/sync story. @@ -150,7 +151,7 @@ _(No Lifetime tier — one-time revenue against forever AI inference costs is an 1. **Stateless AI proxy — NOT a cloud DB.** A single Cloudflare Worker (free tier: 100k req/day, zero maintenance, no database) holds the API key and enforces per-user rate limits (Clerk JWT verification + Workers KV counter). This is non-negotiable for security: an API key shipped inside the app binary gets extracted and abused within days. Model: **Claude Haiku or GPT-4o-mini class — whichever is cheapest per token at build time** (the proxy makes the provider swappable behind one interface). 2. **Generate on data-change, not on view** — insights/forecast cards are computed when subscriptions actually change (or max 1×/week on a schedule), cached in the DB, and re-served free on every app open. An insight read costs $0; only regeneration costs tokens. 3. **Per-user monthly token budget** — e.g. ~50 AI generations/mo per Pro user enforced at the proxy. When exhausted: degrade gracefully to deterministic (non-LLM) insight templates — the math (totals, deltas, forecasts) is computable without AI; the LLM only adds narrative. Users rarely notice a cap this generous. -4. **Cheap model + tight prompts** — Haiku-class for insight cards and NL quick-add (~$0.001–0.01 per generation). At 50 generations/mo that's **$0.05–0.50/user/mo against $8.66/mo revenue (annual plan)** — sub-6% COGS worst-case. Reserve mid-tier models only for the deep "spend audit" (1–2/user/mo). +4. **Cheap model + tight prompts** — Haiku-class for insight cards and NL quick-add (~$0.001–0.01 per generation). At 50 generations/mo that's **$0.05–0.50/user/mo**; against the annual plan's **$3.33/mo** ($39.99/yr — our lowest per-month revenue) that's ~1.5% typical and ~15% only in the worst case (all 50 generations at the top per-gen cost), held down by the per-user budget cap. Blended across the weekly/monthly plans and typical usage, COGS stays low-single-digit. Reserve mid-tier models only for the deep "spend audit" (1–2/user/mo). 5. **Trial abuse guard** — trial users get a smaller budget (~15 generations) so free-trial farming can't drain spend. 6. **Spend observability + kill switch** — provider budget alerts + a remote-config flag that flips all AI features to template mode instantly if spend anomalies hit. 7. **"Do we buy credits?"** — No pre-purchase needed: Anthropic/OpenAI APIs are pay-as-you-go with configurable monthly spend caps at the account level (set it; the proxy degrades gracefully if hit). If a power-user segment emerges later, _sell_ consumable "AI boost" credit packs via RevenueCat as a revenue line — credits flow toward us, not from us. @@ -306,10 +307,10 @@ All of these ride on the existing SQLite + design system + report/share infrastr | Trademark collision post-launch | Knockout search before buying assets; brand strings centralized (forced rename = 1 day) | | Solo scope creep | Every Phase-2+ item metric-gated (sync @500 MAU, Autopilot @$3–5k, Gmail @$5k) | | Low willingness-to-pay anchored by Bobby's $2.99 one-time | Paywall sells the AI companion + ongoing-cost features (sync, parsing, reliability), never manual entry — different category, different anchor. No lifetime SKU (AI inference makes one-time pricing an unbounded liability) | -| AI token overspend / abuse | Server-side proxy w/ per-user budgets, generate-on-change caching, Haiku-class models (<6% COGS), trial caps, provider spend limits + template-mode kill switch (see §6) | +| AI token overspend / abuse | Server-side proxy w/ per-user budgets, generate-on-change caching, Haiku-class models (low-single-digit % COGS blended; ~15% worst-case on the $3.33/mo annual plan, capped per-user — see §6), trial caps, provider spend limits + template-mode kill switch | | Clerk-required auth = onboarding friction + compliance surface | Solved — guest-first shipped; account only for Pro/backup | | NativeWind 5-preview `react-native-css` bug (`path.split`) on some `TextInput` classNames | Real, already bit us in onboarding — use `auth-input` class + inline `style` for TextInputs; reinforces the pin-exact / NW4-fallback plan before launch | -| Data leak across accounts on one device | Out of scope — single-user-per-device assumption; portability is the Pro backup/sync story, not local multi-account | +| Data leak across accounts on one device | Single-user-per-device, so **no in-app account switching** is offered. Guest-first: local SQLite belongs to the device owner, so **sign-out does NOT wipe local data** (it only disconnects Pro/backup) — meaning a different person signing in would inherit it, which is exactly why account-switching is forbidden. The only local wipe is the explicit **Clear all data** flow (`clearAllData` → clears the subscriptions table + cancels reminders; pair with `resetOnboarding` for a full reset). When multi-account sync ships (Pro, P2+), namespace SQLite rows by Clerk user id. | | Play new-account 12-tester/14-day rule | Recruit testers during Phase 0 | ## 10. Verification & Go-Live Checklist diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 1ad13dc..ec501e3 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -16,7 +16,7 @@ import { formatCurrency } from "@/lib/utils"; import { useClerk, useUser } from "@clerk/expo"; import { styled } from "nativewind"; import { usePostHog } from "posthog-react-native"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Animated, FlatList, Image, Pressable, Text, View } from "react-native"; import { useRouter } from "expo-router"; @@ -41,7 +41,7 @@ export default function App() { ); // One-time first-run nudge: gently pulse the "+" until the first sub is added. - const addPulse = useRef(new Animated.Value(1)).current; + const [addPulse] = useState(() => new Animated.Value(1)); const nudgeActive = showAddNudge && activeSubscriptions.length === 0; useEffect(() => { if (!nudgeActive) { diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 599cb1d..af4a677 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -25,7 +25,7 @@ import dayjs from "dayjs"; import { useRouter } from "expo-router"; import { styled } from "nativewind"; import { usePostHog } from "posthog-react-native"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Animated, Easing, @@ -181,19 +181,35 @@ const Onboarding = () => { }, [posthog]); const selectedBrands = ONBOARDING_BRANDS.filter((b) => selected[b.title]); + // Normalize comma decimal separators (e.g. "12,99") before parsing. const priceFor = (title: string, fallback: number) => - parseFloat(prices[title] ?? String(fallback)) || 0; + parseFloat((prices[title] ?? String(fallback)).replace(",", ".")) || 0; const monthlyTotal = selectedBrands.reduce( (sum, b) => sum + getMonthlyEquivalent(priceFor(b.title, b.price), cycleFor(b.title)), 0, ); + // Same validity rule addSelected uses (price > 0), so the CTA count matches + // what actually gets added. + const addableCount = selectedBrands.filter( + (b) => priceFor(b.title, b.price) > 0, + ).length; + + const finish = useCallback( + (subsAdded: number) => { + markOnboarded(); + posthog.capture("onboarding_completed", { subs_added: subsAdded }); + router.replace("/"); + }, + [posthog, router], + ); - const finish = (subsAdded: number) => { - markOnboarded(); - posthog.capture("onboarding_completed", { subs_added: subsAdded }); - router.replace("/"); - }; + // Stable callback for the celebration so incidental re-renders don't restart + // its animation sequence. + const handleCelebrationDone = useCallback( + () => finish(addedCount), + [finish, addedCount], + ); const skip = () => { markOnboarded(); @@ -460,8 +476,7 @@ const Onboarding = () => { - Add {selectedBrands.length} subscription - {selectedBrands.length === 1 ? "" : "s"} + Add {addableCount} subscription{addableCount === 1 ? "" : "s"} @@ -492,7 +507,7 @@ const Onboarding = () => { finish(addedCount)} + onDone={handleCelebrationDone} /> )} diff --git a/components/AnimatedCounter.tsx b/components/AnimatedCounter.tsx index d57cb36..2a616f4 100644 --- a/components/AnimatedCounter.tsx +++ b/components/AnimatedCounter.tsx @@ -1,5 +1,5 @@ import { formatCurrency } from "@/lib/utils"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { Animated, Easing, Text, type TextProps } from "react-native"; interface AnimatedCounterProps extends TextProps { @@ -20,7 +20,7 @@ const AnimatedCounter = ({ duration = 700, ...textProps }: AnimatedCounterProps) => { - const anim = useRef(new Animated.Value(0)).current; + const [anim] = useState(() => new Animated.Value(0)); const [display, setDisplay] = useState(0); useEffect(() => { diff --git a/components/onboarding/CelebrationOverlay.tsx b/components/onboarding/CelebrationOverlay.tsx index ed4315a..71614b9 100644 --- a/components/onboarding/CelebrationOverlay.tsx +++ b/components/onboarding/CelebrationOverlay.tsx @@ -35,10 +35,14 @@ const CelebrationOverlay = ({ const scale = useRef(new Animated.Value(0)).current; const copyOpacity = useRef(new Animated.Value(0)).current; const burst = useRef(new Animated.Value(0)).current; + // Read onDone through a ref so a changed callback identity never restarts the + // sequence (which would fire onDone — i.e. navigation — more than once). + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; useEffect(() => { success(); - Animated.sequence([ + const sequence = Animated.sequence([ Animated.parallel([ Animated.spring(scale, { toValue: 1, @@ -59,8 +63,12 @@ const CelebrationOverlay = ({ }), ]), Animated.delay(1000), - ]).start(() => onDone()); - }, [scale, copyOpacity, burst, onDone]); + ]); + sequence.start(({ finished }) => { + if (finished) onDoneRef.current(); + }); + return () => sequence.stop(); + }, [scale, copyOpacity, burst]); return (