import React, { useEffect, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { usePalette } from "../utils/palette"; /** * Optional brand color overrides so onboarding matches the host app's sign-in * styling instead of the generic theme palette. Any omitted key falls back to * the active palette. */ export interface OnboardingTheme { background?: string; card?: string; border?: string; foreground?: string; mutedForeground?: string; primary?: string; primaryForeground?: string; /** Icon-tile background on OnboardingOptionCard. */ iconBackground?: string; } // Generic, app-agnostic first-run onboarding wizard. The host app supplies the // step content (e.g. "secure your account" with MFA/passkey, "connect your // accounts" with Plaid); this shell owns the chrome: progress dots, title, // scroll body, and the continue/skip/finish footer. Reusable across finance // apps (Simple Finance, Paperhands) and beyond — it knows nothing about MFA, // Plaid, or any domain. export interface OnboardingStepApi { /** Advance to the next step (or finish on the last step). */ next: () => void; /** Go back to the previous step (no-op on the first). */ back: () => void; index: number; total: number; isLast: boolean; } export interface OnboardingStepDef { key: string; title: string; subtitle?: string; /** Step body. Receives the wizard api so its own actions can advance. */ content: (api: OnboardingStepApi) => React.ReactNode; /** Show a "skip" affordance that advances without completing the step. */ canSkip?: boolean; /** Override the primary button label for this step. */ primaryLabel?: string; /** Hide the footer primary button (the step drives its own advance). */ hidePrimary?: boolean; /** * Gate advancing: when false the primary button is disabled (e.g. a required * step the user hasn't satisfied yet, like mandatory MFA enrollment). * Defaults to true. */ canContinue?: boolean; /** * Already-satisfied step. Counts toward the total + shows a filled progress * dot, but its body is never rendered — the wizard starts at the first * not-done step and skips done ones. Lets a resumed onboarding show absolute * position ("Step 3 of 3") instead of remaining-only ("1 of 1"). */ done?: boolean; } export interface OnboardingWizardProps { steps: OnboardingStepDef[]; /** Called after the final step's continue/skip. Mark onboarding complete here. */ onComplete: () => void | Promise; /** Branding slot rendered above the progress + card (e.g. logo + app name). */ header?: React.ReactNode; /** Card max width on large screens / web so it reads as an overlay, not full-bleed. */ maxWidth?: number; /** Show the "Step X of N" label under the progress dots. Default true. */ showStepLabel?: boolean; /** Brand color overrides so the chrome matches the app's sign-in styling. */ theme?: OnboardingTheme; skipLabel?: string; finishLabel?: string; continueLabel?: string; } export function OnboardingWizard({ steps, onComplete, header, maxWidth = 440, showStepLabel = true, theme, skipLabel = "Skip for now", finishLabel = "Finish", continueLabel = "Continue", }: OnboardingWizardProps) { const { palette } = usePalette(); const c = { background: theme?.background ?? palette.background, card: theme?.card ?? palette.card, border: theme?.border ?? palette.border, foreground: theme?.foreground ?? palette.foreground, mutedForeground: theme?.mutedForeground ?? palette.mutedForeground, primary: theme?.primary ?? palette.primary, primaryForeground: theme?.primaryForeground ?? palette.primaryForeground, }; // Start at the first not-done step; done steps before it are completed // milestones (filled dots, never rendered). const [index, setIndex] = useState(() => { const i = steps.findIndex((s) => !s.done); return i === -1 ? 0 : i; }); const [finishing, setFinishing] = useState(false); const finish = async () => { setFinishing(true); try { await onComplete(); } finally { setFinishing(false); } }; // Every step satisfied — nothing to render; complete once. const allDone = steps.every((s) => s.done); useEffect(() => { if (allDone) { void finish(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [allDone]); const step = steps[index]; // Next/prev among the not-done steps only (done ones are skipped over). const nextActive = steps.findIndex((s, i) => i > index && !s.done); const isLast = nextActive === -1; const next = async () => { if (isLast) { await finish(); return; } setIndex(nextActive); }; const back = () => { let prev = -1; for (let i = 0; i < index; i += 1) { if (!steps[i]?.done) { prev = i; } } if (prev >= 0) { setIndex(prev); } }; const api: OnboardingStepApi = { next, back, index, total: steps.length, isLast }; if (!step) { return null; } return ( {/* constrained, centered column — an overlay card on web, full-width on phones */} {header ? {header} : null} {/* progress: dots + step label */} {steps.map((s, i) => ( ))} {showStepLabel ? ( Step {index + 1} of {steps.length} ) : null} {/* card panel */} {step.title} {step.subtitle ? ( {step.subtitle} ) : null} {step.content(api)} {!step.hidePrimary ? ( {finishing ? ( ) : ( {step.primaryLabel ?? (isLast ? finishLabel : continueLabel)} )} ) : null} {step.canSkip ? ( {skipLabel} ) : null} ); } export interface OnboardingOptionCardProps { icon?: React.ReactNode; title: string; subtitle?: string; /** Trailing element (e.g. a spinner or chevron). */ right?: React.ReactNode; onPress?: () => void; /** Highlight the card (e.g. completed/selected) with the primary color. */ selected?: boolean; /** Brand color overrides to match the app's sign-in styling. */ theme?: OnboardingTheme; } /** * Tappable card for onboarding step bodies — an icon tile, title + subtitle, * and optional trailing slot. Domain-agnostic; apps drop these into a wizard * step to offer choices (add passkey, connect a bank, …). */ export function OnboardingOptionCard({ icon, title, subtitle, right, onPress, selected, theme, }: OnboardingOptionCardProps) { const { palette } = usePalette(); const c = { card: theme?.card ?? palette.card, border: theme?.border ?? palette.border, foreground: theme?.foreground ?? palette.foreground, mutedForeground: theme?.mutedForeground ?? palette.mutedForeground, primary: theme?.primary ?? palette.primary, iconBackground: theme?.iconBackground ?? palette.muted, }; return ( {icon ? ( {icon} ) : null} {title} {subtitle ? ( {subtitle} ) : null} {right} ); } export default OnboardingWizard;