Onboarding.tsx

6.8 KB · raw

1import { For, Show, createSignal } from "solid-js";2import { PeachWordmark } from "../components/PeachMark";3import { RuneBackground } from "../components/RuneBackground";45interface OnboardingCard {6  headline: string;7  body: string[];8  warning: string | null;9}1011const BASE_CARDS: OnboardingCard[] = [12  {13    headline: "Your passwords\nlive here.\nNowhere else.",14    body: [15      "No account. No server ever accesses your vault. No one but you can see it.",16      "Everything is encrypted on your device with a password only you know.",17    ],18    warning: null,19  },20  {21    headline: "Two keys.",22    body: [23      "Your master password is yours alone. Peach never sees it.",24      "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",25    ],26    warning: "Lose both and your vault cannot be opened.",27  },28  {29    headline: "Everything\nyou need.",30    body: [31      "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",32      "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",33    ],34    warning: null,35  },36];3738const CARDS: OnboardingCard[] = BASE_CARDS;3940/** Index of the card that carries the create/adopt/restore actions. Converges41 *  every path out of onboarding, so a "back" from those flows should resume42 *  here — not rewind to card 0. */43export const ONBOARDING_LAST_CARD = CARDS.length - 1;4445const CARD_EXIT_MS = 120;46const CARD_ENTER_GAP_MS = 60;4748interface OnboardingProps {49  onComplete: () => void | Promise<void>;50  onRestore?: () => void | Promise<void>;51  onAdopt?: () => void | Promise<void>;52  /** Card index to start on. App passes ONBOARDING_LAST_CARD when returning53   *  from the create-vault/adopt/restore flows so the user lands back where54   *  they left instead of restarting at the first card. */55  initialCard?: number;56}5758export function Onboarding(props: OnboardingProps) {59  const [card, setCard] = createSignal(props.initialCard ?? 0);60  const [exiting, setExiting] = createSignal(false);61  const [completing, setCompleting] = createSignal(false);6263  async function advance() {64    if (exiting() || completing()) return;65    if (card() < CARDS.length - 1) {66      setExiting(true);67      window.setTimeout(() => {68        setCard((c) => c + 1);69        setExiting(false);70      }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);71    } else {72      setCompleting(true);73      // Don't persist peach_onboarded here. App.tsx persists it only when a74      // vault actually exists — so a user who quits mid-vault-creation75      // returns to the start of onboarding instead of getting stranded on76      // the create-vault screen with no back path.77      window.setTimeout(() => {78        void props.onComplete();79      }, 200);80    }81  }8283  function goBack() {84    if (exiting() || completing()) return;85    if (card() === 0) return;86    setExiting(true);87    window.setTimeout(() => {88      setCard((c) => c - 1);89      setExiting(false);90    }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);91  }9293  return (94    <section95      class="peach-screen peach-theme peach-onboarding"96      classList={{ "is-completing": completing() }}97    >98      <RuneBackground opacity={0.07} />99100      <header class="peach-screen-header peach-onboarding-header">101        <PeachWordmark class="peach-wordmark-wrap" />102      </header>103104      <div105        class="peach-onboarding-card"106        classList={{107          "is-exiting": exiting(),108          "peach-onboarding-card--final": card() === CARDS.length - 1,109        }}110        onClick={() => {111          // Final card: creation is an explicit choice; a stray card-body tap112          // must not silently start vault creation.113          if (card() === CARDS.length - 1) return;114          advance();115        }}116        role="button"117        tabIndex={0}118        onKeyDown={(e) => {119          if (e.key === "Enter" || e.key === " ") {120            if (card() === CARDS.length - 1) return;121            e.preventDefault();122            advance();123          }124        }}125      >126        <h2 class="peach-onboarding-card-headline">127          {CARDS[card()]!.headline}128        </h2>129        <hr class="peach-onboarding-divider" />130        <For each={CARDS[card()]!.body}>131          {(para) => <p class="peach-onboarding-body">{para}</p>}132        </For>133        <Show when={CARDS[card()]!.warning}>134          <p class="peach-onboarding-warning">135            <span class="peach-onboarding-warning-icon" aria-hidden="true">⚠</span>136            <span>{CARDS[card()]!.warning}</span>137          </p>138        </Show>139        <Show when={card() === CARDS.length - 1}>140          <p class="peach-onboarding-choice-heading">Already using Peach?</p>141          <div class="peach-onboarding-choice-row">142            <button143              type="button"144              class="peach-onboarding-choice peach-onboarding-choice--adopt"145              onClick={(e) => {146                e.stopPropagation();147                void props.onAdopt?.();148              }}149            >150              <span class="peach-onboarding-choice-label">151                Pair with another device152                <span class="peach-pro-badge">Pro</span>153              </span>154              <span class="peach-onboarding-choice-detail">155                Scan a QR and confirm one 8-digit code.156              </span>157            </button>158            <button159              type="button"160              class="peach-onboarding-choice"161              onClick={(e) => {162                e.stopPropagation();163                void props.onRestore?.();164              }}165            >166              <span class="peach-onboarding-choice-label">Restore a backup</span>167              <span class="peach-onboarding-choice-detail">168                Use PeachScript, a .peach file, CSV, or Codex.169              </span>170            </button>171          </div>172        </Show>173      </div>174175      <div class="peach-onboarding-nav">176        <Show when={card() > 0} fallback={<div class="peach-onboarding-back-spacer" />}>177          <button178            class="peach-onboarding-back"179            onClick={(e) => {180              e.stopPropagation();181              goBack();182            }}183            disabled={completing()}184          >185            ← back186          </button>187        </Show>188        <div class="peach-onboarding-dots" aria-hidden="true">189          <For each={CARDS}>190            {(_, i) => (191              <div192                class="peach-onboarding-dot"193                classList={{ "is-active": i() === card() }}194              />195            )}196          </For>197        </div>198        <button199          class="peach-onboarding-next"200          onClick={(e) => {201            e.stopPropagation();202            advance();203          }}204          disabled={completing()}205        >206          {card() === CARDS.length - 1 ? "Create vault" : "next →"}207        </button>208      </div>209    </section>210  );211}