Onboarding.tsx

6.3 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;3940const CARD_EXIT_MS = 120;41const CARD_ENTER_GAP_MS = 60;4243interface OnboardingProps {44  onComplete: () => void | Promise<void>;45  onRestore?: () => void | Promise<void>;46  onAdopt?: () => void | Promise<void>;47}4849export function Onboarding(props: OnboardingProps) {50  const [card, setCard] = createSignal(0);51  const [exiting, setExiting] = createSignal(false);52  const [completing, setCompleting] = createSignal(false);5354  async function advance() {55    if (exiting() || completing()) return;56    if (card() < CARDS.length - 1) {57      setExiting(true);58      window.setTimeout(() => {59        setCard((c) => c + 1);60        setExiting(false);61      }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);62    } else {63      setCompleting(true);64      // Don't persist peach_onboarded here. App.tsx persists it only when a65      // vault actually exists — so a user who quits mid-vault-creation66      // returns to the start of onboarding instead of getting stranded on67      // the create-vault screen with no back path.68      window.setTimeout(() => {69        void props.onComplete();70      }, 200);71    }72  }7374  function goBack() {75    if (exiting() || completing()) return;76    if (card() === 0) return;77    setExiting(true);78    window.setTimeout(() => {79      setCard((c) => c - 1);80      setExiting(false);81    }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);82  }8384  return (85    <section86      class="peach-screen peach-theme peach-onboarding"87      classList={{ "is-completing": completing() }}88    >89      <RuneBackground opacity={0.07} />9091      <header class="peach-screen-header peach-onboarding-header">92        <PeachWordmark class="peach-wordmark-wrap" />93      </header>9495      <div96        class="peach-onboarding-card"97        classList={{98          "is-exiting": exiting(),99          "peach-onboarding-card--final": card() === CARDS.length - 1,100        }}101        onClick={() => {102          // Final card: creation is an explicit choice; a stray card-body tap103          // must not silently start vault creation.104          if (card() === CARDS.length - 1) return;105          advance();106        }}107        role="button"108        tabIndex={0}109        onKeyDown={(e) => {110          if (e.key === "Enter" || e.key === " ") {111            if (card() === CARDS.length - 1) return;112            e.preventDefault();113            advance();114          }115        }}116      >117        <h2 class="peach-onboarding-card-headline">118          {CARDS[card()]!.headline}119        </h2>120        <hr class="peach-onboarding-divider" />121        <For each={CARDS[card()]!.body}>122          {(para) => <p class="peach-onboarding-body">{para}</p>}123        </For>124        <Show when={CARDS[card()]!.warning}>125          <p class="peach-onboarding-warning">126            <span class="peach-onboarding-warning-icon" aria-hidden="true">⚠</span>127            <span>{CARDS[card()]!.warning}</span>128          </p>129        </Show>130        <Show when={card() === CARDS.length - 1}>131          <p class="peach-onboarding-choice-heading">Already using Peach?</p>132          <div class="peach-onboarding-choice-row">133            <button134              type="button"135              class="peach-onboarding-choice peach-onboarding-choice--adopt"136              onClick={(e) => {137                e.stopPropagation();138                void props.onAdopt?.();139              }}140            >141              <span class="peach-onboarding-choice-label">142                Pair with another device143                <span class="peach-pro-badge">Pro</span>144              </span>145              <span class="peach-onboarding-choice-detail">146                Scan a QR and confirm one 8-digit code.147              </span>148            </button>149            <button150              type="button"151              class="peach-onboarding-choice"152              onClick={(e) => {153                e.stopPropagation();154                void props.onRestore?.();155              }}156            >157              <span class="peach-onboarding-choice-label">Restore a backup</span>158              <span class="peach-onboarding-choice-detail">159                Use PeachScript, a .peach file, CSV, or Codex.160              </span>161            </button>162          </div>163        </Show>164      </div>165166      <div class="peach-onboarding-nav">167        <Show when={card() > 0} fallback={<div class="peach-onboarding-back-spacer" />}>168          <button169            class="peach-onboarding-back"170            onClick={(e) => {171              e.stopPropagation();172              goBack();173            }}174            disabled={completing()}175          >176            ← back177          </button>178        </Show>179        <div class="peach-onboarding-dots" aria-hidden="true">180          <For each={CARDS}>181            {(_, i) => (182              <div183                class="peach-onboarding-dot"184                classList={{ "is-active": i() === card() }}185              />186            )}187          </For>188        </div>189        <button190          class="peach-onboarding-next"191          onClick={(e) => {192            e.stopPropagation();193            advance();194          }}195          disabled={completing()}196        >197          {card() === CARDS.length - 1 ? "Create vault" : "next →"}198        </button>199      </div>200    </section>201  );202}