Onboarding.tsx

7.4 KB · raw

1import { For, Show, createSignal } from "solid-js";2import { storageSet } from "../../shared/storage";3import { PeachWordmark } from "../components/PeachMark";4import { RuneBackground } from "../components/RuneBackground";56interface OnboardingCard {7  headline: string;8  body: string[];9  warning: string | null;10}1112const BASE_CARDS: OnboardingCard[] = [13  {14    headline: "Welcome to\nPeach Passwords!",15    body: [16      "Your passwords live here. Nowhere else.",17      "No account. No server ever accesses your vault. No one but you can see it.",18      "Everything is encrypted on your device with a password only you know.",19    ],20    warning: null,21  },22  {23    headline: "Two keys.",24    body: [25      "Your master password is yours alone. Peach never sees it.",26      "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",27    ],28    warning: "Lose both and your vault cannot be opened.",29  },30  {31    headline: "Everything\nyou need.",32    body: [33      "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",34      "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",35    ],36    warning: null,37  },38];3940const CARDS: OnboardingCard[] = BASE_CARDS;4142/** Index of the card that carries the create/adopt/restore actions. Converges43 *  every path out of onboarding, so a "back" from those flows should resume44 *  here — not rewind to card 0. */45export const ONBOARDING_LAST_CARD = CARDS.length - 1;4647/** Where the current card index is persisted so closing the popup48 *  mid-onboarding resumes at the same card instead of restarting. */49export const ONBOARDING_CARD_STORAGE_KEY = "peach_onboarding_card";5051const CARD_EXIT_MS = 120;52const CARD_ENTER_GAP_MS = 60;5354interface OnboardingProps {55  onComplete: () => void | Promise<void>;56  onRestore?: () => void | Promise<void>;57  onAdopt?: () => void | Promise<void>;58  /** Card index to start on. App passes ONBOARDING_LAST_CARD when returning59   *  from the create-vault/adopt/restore flows so the user lands back where60   *  they left instead of restarting at the first card. */61  initialCard?: number;62}6364export function Onboarding(props: OnboardingProps) {65  const [card, setCard] = createSignal(props.initialCard ?? 0);66  const [exiting, setExiting] = createSignal(false);67  const [completing, setCompleting] = createSignal(false);6869  // Remember where the user got to so a popup close mid-onboarding resumes70  // here instead of rewinding to the opening card.71  function persistCard(index: number) {72    void storageSet(ONBOARDING_CARD_STORAGE_KEY, String(index)).catch(() => {});73  }7475  async function advance() {76    if (exiting() || completing()) return;77    if (card() < CARDS.length - 1) {78      setExiting(true);79      window.setTimeout(() => {80        setCard((c) => c + 1);81        persistCard(card());82        setExiting(false);83      }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);84    } else {85      setCompleting(true);86      // Don't persist peach_onboarded here. App.tsx persists it only when a87      // vault actually exists — so a user who quits mid-vault-creation88      // returns to the start of onboarding instead of getting stranded on89      // the create-vault screen with no back path.90      window.setTimeout(() => {91        void props.onComplete();92      }, 200);93    }94  }9596  function goBack() {97    if (exiting() || completing()) return;98    if (card() === 0) return;99    setExiting(true);100    window.setTimeout(() => {101      setCard((c) => c - 1);102      persistCard(card());103      setExiting(false);104    }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);105  }106107  return (108    <section109      class="peach-screen peach-theme peach-onboarding"110      classList={{ "is-completing": completing() }}111    >112      <RuneBackground opacity={0.07} />113114      <header class="peach-screen-header peach-onboarding-header">115        <PeachWordmark class="peach-wordmark-wrap" />116      </header>117118      <div119        class="peach-onboarding-card"120        classList={{121          "is-exiting": exiting(),122          "peach-onboarding-card--final": card() === CARDS.length - 1,123        }}124        onClick={() => {125          // Final card: creation is an explicit choice; a stray card-body tap126          // must not silently start vault creation.127          if (card() === CARDS.length - 1) return;128          advance();129        }}130        role="button"131        tabIndex={0}132        onKeyDown={(e) => {133          if (e.key === "Enter" || e.key === " ") {134            if (card() === CARDS.length - 1) return;135            e.preventDefault();136            advance();137          }138        }}139      >140        <h2 class="peach-onboarding-card-headline">141          {CARDS[card()]!.headline}142        </h2>143        <hr class="peach-onboarding-divider" />144        <For each={CARDS[card()]!.body}>145          {(para) => <p class="peach-onboarding-body">{para}</p>}146        </For>147        <Show when={CARDS[card()]!.warning}>148          <p class="peach-onboarding-warning">149            <span class="peach-onboarding-warning-icon" aria-hidden="true">⚠</span>150            <span>{CARDS[card()]!.warning}</span>151          </p>152        </Show>153        <Show when={card() === CARDS.length - 1}>154          <p class="peach-onboarding-choice-heading">Already using Peach?</p>155          <div class="peach-onboarding-choice-row">156            <button157              type="button"158              class="peach-onboarding-choice peach-onboarding-choice--adopt"159              onClick={(e) => {160                e.stopPropagation();161                void props.onAdopt?.();162              }}163            >164              <span class="peach-onboarding-choice-label">165                Pair with another device166                <span class="peach-pro-badge">Pro</span>167              </span>168              <span class="peach-onboarding-choice-detail">169                Scan a QR and confirm one 8-digit code.170              </span>171            </button>172            <button173              type="button"174              class="peach-onboarding-choice"175              onClick={(e) => {176                e.stopPropagation();177                void props.onRestore?.();178              }}179            >180              <span class="peach-onboarding-choice-label">Restore a backup</span>181              <span class="peach-onboarding-choice-detail">182                Use PeachScript, a .peach file, CSV, or Codex.183              </span>184            </button>185          </div>186        </Show>187      </div>188189      <div class="peach-onboarding-nav">190        <Show when={card() > 0} fallback={<div class="peach-onboarding-back-spacer" />}>191          <button192            class="peach-onboarding-back"193            onClick={(e) => {194              e.stopPropagation();195              goBack();196            }}197            disabled={completing()}198          >199            ← back200          </button>201        </Show>202        <div class="peach-onboarding-dots" aria-hidden="true">203          <For each={CARDS}>204            {(_, i) => (205              <div206                class="peach-onboarding-dot"207                classList={{ "is-active": i() === card() }}208              />209            )}210          </For>211        </div>212        <button213          class="peach-onboarding-next"214          onClick={(e) => {215            e.stopPropagation();216            advance();217          }}218          disabled={completing()}219        >220          {card() === CARDS.length - 1 ? "Create vault" : "next →"}221        </button>222      </div>223    </section>224  );225}