Files
Kordant/web/src/routes/(auth)/onboarding.tsx
Michael Freno 25da0cd687 feat: add auth pages (login, signup, password reset, onboarding)
- Create stub auth API (lib/auth.ts) with simulated delay
- Add PasswordInput component with visibility toggle
- Add SocialAuthButtons component (Google/Apple placeholders)
- Add AuthLayout with split-panel layout and rotating testimonial
- Implement login page with email/password validation and remember me
- Implement signup page with password strength indicator and ToS checkbox
- Implement forgot-password page with email submission and success state
- Implement reset-password page with token validation from query params
- Implement 4-step onboarding flow (plan selection, watchlist, invites, success)
- Add ToastProvider to root app
- Write 28 tests for all auth components and form validation
2026-05-25 15:20:01 -04:00

419 lines
15 KiB
TypeScript

import { createSignal, For, Show } from "solid-js";
import { Title } from "@solidjs/meta";
import { useNavigate } from "@solidjs/router";
import { Button, Input, Badge } from "~/components/ui";
import { submitOnboarding } from "~/lib/auth";
import type { OnboardingData } from "~/lib/auth";
const plans = [
{
id: "basic",
name: "Basic",
price: "Free",
features: ["Basic monitoring", "1 email address", "1 phone number"],
},
{
id: "plus",
name: "Plus",
price: "$9.99/mo",
features: [
"Advanced monitoring",
"Unlimited emails & phones",
"Dark web scanning",
"Real-time alerts",
],
},
{
id: "premium",
name: "Premium",
price: "$19.99/mo",
features: [
"Everything in Plus",
"Family members (up to 5)",
"Priority support",
"Identity restoration assistance",
],
},
];
const steps = [
"Choose Plan",
"Add Watchlist",
"Invite Family",
"Complete",
];
export default function OnboardingPage() {
const navigate = useNavigate();
const [step, setStep] = createSignal(0);
const [plan, setPlan] = createSignal("plus");
const [watchlistItem, setWatchlistItem] = createSignal("");
const [watchlistItems, setWatchlistItems] = createSignal<string[]>([]);
const [familyInput, setFamilyInput] = createSignal("");
const [familyInvites, setFamilyInvites] = createSignal<string[]>([]);
const [submitting, setSubmitting] = createSignal(false);
const [watchlistError, setWatchlistError] = createSignal("");
function addWatchlistItem() {
const val = watchlistItem().trim();
if (!val) {
setWatchlistError("Please enter an email or phone number");
return;
}
if (watchlistItems().includes(val)) {
setWatchlistError("This item is already in your watchlist");
return;
}
setWatchlistError("");
setWatchlistItems((prev) => [...prev, val]);
setWatchlistItem("");
}
function removeWatchlistItem(idx: number) {
setWatchlistItems((prev) => prev.filter((_, i) => i !== idx));
}
function addFamilyInvite() {
const val = familyInput().trim();
if (!val) return;
if (familyInvites().includes(val)) return;
setFamilyInvites((prev) => [...prev, val]);
setFamilyInput("");
}
function removeFamilyInvite(idx: number) {
setFamilyInvites((prev) => prev.filter((_, i) => i !== idx));
}
async function completeOnboarding() {
setSubmitting(true);
try {
const data: OnboardingData = {
plan: plan(),
watchlistItems: watchlistItems(),
familyInvites: familyInvites(),
};
await submitOnboarding(data);
setStep(3);
} finally {
setSubmitting(false);
}
}
return (
<main class="min-h-screen flex flex-col items-center justify-center py-8 md:py-12 px-4">
<Title>Set Up Your Account ShieldAI</Title>
<div class="w-full max-w-2xl">
<div class="flex items-center justify-center gap-2 mb-8">
<For each={steps}>
{(label, i) => (
<>
<div class="flex flex-col items-center gap-1">
<div
class={`h-8 w-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-300 ${
i() <= step()
? "gradient-primary text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
}`}
>
{i() < step() ? (
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<path d="M20 6L9 17L4 12" />
</svg>
) : (
i() + 1
)}
</div>
<span
class={`text-xs hidden sm:block ${
i() <= step()
? "text-[var(--color-text-primary)] font-medium"
: "text-[var(--color-text-tertiary)]"
}`}
>
{label}
</span>
</div>
{i() < steps.length - 1 && (
<div
class={`flex-1 h-0.5 ${
i() < step()
? "gradient-primary"
: "bg-[var(--color-bg-tertiary)]"
}`}
/>
)}
</>
)}
</For>
</div>
<div class="gradient-card border border-[var(--color-border)]/50 rounded-xl p-6 md:p-8">
<Show when={step() === 0}>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-xl font-bold text-[var(--color-text-primary)]">
Choose your plan
</h2>
<p class="text-sm text-[var(--color-text-secondary)] mt-1">
Select the plan that works best for you. You can upgrade anytime.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<For each={plans}>
{(p) => (
<button
type="button"
onClick={() => setPlan(p.id)}
class={`relative flex flex-col gap-3 p-4 rounded-lg border-2 text-left transition-all duration-200 cursor-pointer ${
plan() === p.id
? "border-[var(--color-brand-primary)] bg-[var(--color-brand-primary)]/5"
: "border-[var(--color-border)] hover:border-[var(--color-border-dark)]"
}`}
>
{p.id === "premium" && (
<Badge
variant="info"
class="absolute -top-2.5 right-3"
>
Popular
</Badge>
)}
<div>
<p class="font-semibold text-[var(--color-text-primary)]">
{p.name}
</p>
<p class="text-lg font-bold text-[var(--color-brand-primary)] mt-1">
{p.price}
</p>
</div>
<ul class="flex flex-col gap-1.5">
<For each={p.features}>
{(f) => (
<li class="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mt-0.5 shrink-0 text-[var(--color-success)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 6L9 17L4 12" />
</svg>
{f}
</li>
)}
</For>
</ul>
</button>
)}
</For>
</div>
<Button onClick={() => setStep(1)} class="w-full">
Continue
</Button>
</div>
</Show>
<Show when={step() === 1}>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-xl font-bold text-[var(--color-text-primary)]">
Add your first watchlist item
</h2>
<p class="text-sm text-[var(--color-text-secondary)] mt-1">
Add an email address or phone number to monitor.
</p>
</div>
<div class="flex gap-2">
<div class="flex-1">
<Input
type="text"
placeholder="Email or phone number"
value={watchlistItem()}
onInput={(e) => {
setWatchlistItem(e.currentTarget.value);
setWatchlistError("");
}}
error={watchlistError()}
/>
</div>
<Button onClick={addWatchlistItem} variant="secondary">
Add
</Button>
</div>
<Show when={watchlistItems().length > 0}>
<div class="flex flex-col gap-2">
<For each={watchlistItems()}>
{(item, i) => (
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-bg-secondary)]">
<span class="text-sm text-[var(--color-text-primary)]">
{item}
</span>
<button
type="button"
onClick={() => removeWatchlistItem(i())}
class="text-[var(--color-text-tertiary)] hover:text-[var(--color-error)] transition-colors cursor-pointer"
aria-label={`Remove ${item}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6L18 18" />
</svg>
</button>
</div>
)}
</For>
</div>
</Show>
<div class="flex gap-3">
<Button
variant="ghost"
onClick={() => setStep(0)}
>
Back
</Button>
<Button
onClick={() => setStep(2)}
class="flex-1"
>
{watchlistItems().length > 0
? "Continue"
: "Skip for now"}
</Button>
</div>
</div>
</Show>
<Show when={step() === 2}>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-xl font-bold text-[var(--color-text-primary)]">
Invite family members
</h2>
<p class="text-sm text-[var(--color-text-secondary)] mt-1">
Optional. Add family members to extend protection.
</p>
</div>
<div class="flex gap-2">
<div class="flex-1">
<Input
type="email"
placeholder="Family member's email"
value={familyInput()}
onInput={(e) => setFamilyInput(e.currentTarget.value)}
/>
</div>
<Button onClick={addFamilyInvite} variant="secondary">
Add
</Button>
</div>
<Show when={familyInvites().length > 0}>
<div class="flex flex-col gap-2">
<For each={familyInvites()}>
{(email, i) => (
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-bg-secondary)]">
<span class="text-sm text-[var(--color-text-primary)]">
{email}
</span>
<button
type="button"
onClick={() => removeFamilyInvite(i())}
class="text-[var(--color-text-tertiary)] hover:text-[var(--color-error)] transition-colors cursor-pointer"
aria-label={`Remove ${email}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6L18 18" />
</svg>
</button>
</div>
)}
</For>
</div>
</Show>
<div class="flex gap-3">
<Button variant="ghost" onClick={() => setStep(1)}>
Back
</Button>
<Button
onClick={() => setStep(3)}
class="flex-1"
>
{familyInvites().length > 0
? "Continue"
: "Skip for now"}
</Button>
</div>
</div>
</Show>
<Show when={step() === 3}>
<div class="flex flex-col items-center gap-6 py-8 text-center">
<div class="h-16 w-16 rounded-full gradient-primary flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 6L9 17L4 12" />
</svg>
</div>
<div>
<h2 class="text-2xl font-bold text-[var(--color-text-primary)]">
You're all set!
</h2>
<p class="text-sm text-[var(--color-text-secondary)] mt-2 max-w-sm">
Your ShieldAI account is ready. We're already monitoring your
selected items and will alert you of any threats.
</p>
</div>
<div class="flex flex-col gap-2 w-full max-w-xs">
<Button
onClick={() => navigate("/dashboard", { replace: true })}
class="w-full"
>
Go to Dashboard
</Button>
{!submitting && (
<Button
variant="ghost"
onClick={completeOnboarding}
class="w-full"
>
Submit onboarding data
</Button>
)}
</div>
</div>
</Show>
</div>
</div>
</main>
);
}