mirror of
https://github.com/hoornet/vega.git
synced 2026-05-09 13:49:12 -07:00
Add onboarding flow for new users
- OnboardingFlow replaces app layout on first visit (no wrystr_pubkey in localStorage) - Welcome screen: plain-language explanation of Nostr/keys, two paths - Create path: NDKPrivateKeySigner.generate(), show npub with copy button - Backup step: show nsec with danger styling, copy button, checkbox confirmation required before proceeding — login happens only after user confirms backup - Login path: existing nsec or read-only npub, back button to welcome - On complete: normal app layout shown; pubkey in localStorage acts as the flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
302
src/components/onboarding/OnboardingFlow.tsx
Normal file
302
src/components/onboarding/OnboardingFlow.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
|
||||
type Step = "welcome" | "create" | "backup" | "login";
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
// ─── Shared layout ───────────────────────────────────────────────────────────
|
||||
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-bg flex items-center justify-center">
|
||||
<div className="w-full max-w-md px-8">
|
||||
<div className="text-text-dim text-[10px] font-bold tracking-[0.3em] uppercase mb-8">WRYSTR</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Heading({ children }: { children: React.ReactNode }) {
|
||||
return <h1 className="text-text text-xl font-medium mb-3 leading-snug">{children}</h1>;
|
||||
}
|
||||
|
||||
function Body({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-text-dim text-[13px] leading-relaxed mb-6">{children}</p>;
|
||||
}
|
||||
|
||||
// ─── Step: Welcome ───────────────────────────────────────────────────────────
|
||||
|
||||
function WelcomeStep({ onCreateNew, onHaveKey }: { onCreateNew: () => void; onHaveKey: () => void }) {
|
||||
return (
|
||||
<Shell>
|
||||
<Heading>Welcome to Wrystr.</Heading>
|
||||
<Body>
|
||||
Wrystr is a Nostr client — a social platform where you own your identity,
|
||||
your content, and your social graph. No company can delete your account or
|
||||
censor your posts.
|
||||
</Body>
|
||||
<Body>
|
||||
To get started, you need a key pair. Think of it like a username and password
|
||||
combined into one — except you control it completely.
|
||||
</Body>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="w-full py-2.5 text-[13px] font-medium bg-accent hover:bg-accent-hover text-white transition-colors"
|
||||
>
|
||||
Create a new identity
|
||||
</button>
|
||||
<button
|
||||
onClick={onHaveKey}
|
||||
className="w-full py-2.5 text-[13px] border border-border text-text-muted hover:text-text hover:border-accent/40 transition-colors"
|
||||
>
|
||||
I already have a key
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Create key ────────────────────────────────────────────────────────
|
||||
|
||||
function CreateStep({ onNext }: { onNext: (signer: NDKPrivateKeySigner) => void }) {
|
||||
const [signer] = useState(() => NDKPrivateKeySigner.generate());
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(signer.npub).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Heading>Your identity is ready.</Heading>
|
||||
<Body>
|
||||
We generated a unique key pair for you. Your <strong className="text-text">public key</strong> is
|
||||
your identity on Nostr — like a username, but cryptographically yours. Share it freely.
|
||||
</Body>
|
||||
|
||||
<div className="border border-border mb-2">
|
||||
<div className="px-3 py-1.5 border-b border-border">
|
||||
<span className="text-text-dim text-[10px] uppercase tracking-widest">Your public key (npub)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="text-text font-mono text-[11px] truncate flex-1 select-all">{signer.npub}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-[10px] text-text-dim hover:text-accent transition-colors shrink-0"
|
||||
>
|
||||
{copied ? "copied ✓" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-dim text-[11px] mb-6">Safe to share with anyone. This is how people find you on Nostr.</p>
|
||||
|
||||
<button
|
||||
onClick={() => onNext(signer)}
|
||||
className="w-full py-2.5 text-[13px] font-medium bg-accent hover:bg-accent-hover text-white transition-colors"
|
||||
>
|
||||
Next: back up your secret key →
|
||||
</button>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Backup nsec ───────────────────────────────────────────────────────
|
||||
|
||||
function BackupStep({ signer, onComplete }: { signer: NDKPrivateKeySigner; onComplete: () => void }) {
|
||||
const { loginWithNsec, loginError } = useUserStore();
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(signer.nsec).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!confirmed) return;
|
||||
setSaving(true);
|
||||
await loginWithNsec(signer.nsec);
|
||||
setSaving(false);
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Heading>Save your secret key.</Heading>
|
||||
<Body>
|
||||
Your <strong className="text-text">secret key</strong> is the only way to recover your
|
||||
account. Save it in a password manager, notes app, or write it down. Wrystr never
|
||||
stores it — if you lose it, your account is gone.
|
||||
</Body>
|
||||
|
||||
<div className="border border-danger/40 mb-2">
|
||||
<div className="px-3 py-1.5 border-b border-danger/40 bg-danger/5">
|
||||
<span className="text-danger text-[10px] uppercase tracking-widest">Secret key (nsec) — keep private</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="text-text font-mono text-[11px] truncate flex-1 select-all">{signer.nsec}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-[10px] text-text-dim hover:text-danger transition-colors shrink-0"
|
||||
>
|
||||
{copied ? "copied ✓" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-dim text-[11px] mb-6">
|
||||
Never share this with anyone. Anyone who has it controls your account.
|
||||
</p>
|
||||
|
||||
<label className="flex items-start gap-3 mb-5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 accent-accent w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span className="text-text-dim text-[12px] group-hover:text-text transition-colors">
|
||||
I've saved my secret key in a safe place and understand that losing it means losing access to my account.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{loginError && <p className="text-danger text-[11px] mb-3">{loginError}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={!confirmed || saving}
|
||||
className="w-full py-2.5 text-[13px] font-medium bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Setting up…" : "Start using Wrystr"}
|
||||
</button>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step: Login with existing key ───────────────────────────────────────────
|
||||
|
||||
function LoginStep({ onBack, onComplete }: { onBack: () => void; onComplete: () => void }) {
|
||||
const { loginWithNsec, loginWithPubkey, loginError, loggedIn } = useUserStore();
|
||||
const [mode, setMode] = useState<"nsec" | "npub">("nsec");
|
||||
const [value, setValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn) onComplete();
|
||||
}, [loggedIn]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!value.trim() || loading) return;
|
||||
setLoading(true);
|
||||
if (mode === "nsec") {
|
||||
await loginWithNsec(value.trim());
|
||||
} else {
|
||||
await loginWithPubkey(value.trim());
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") handleLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<Heading>Log in with your key.</Heading>
|
||||
|
||||
<div className="flex border border-border mb-4">
|
||||
{(["nsec", "npub"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setMode(m); setValue(""); }}
|
||||
className={`flex-1 py-2 text-[11px] transition-colors ${
|
||||
mode === m ? "bg-accent/10 text-accent" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{m === "nsec" ? "Secret key (nsec)" : "Public key only (read-only)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={mode === "nsec" ? "nsec1…" : "npub1…"}
|
||||
autoFocus
|
||||
className="w-full bg-bg border border-border px-3 py-2 text-text text-[12px] font-mono focus:outline-none focus:border-accent/50 placeholder:text-text-dim mb-2"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
{mode === "npub" && (
|
||||
<p className="text-text-dim text-[11px] mb-4">Read-only mode — you can browse but not post, react, or zap.</p>
|
||||
)}
|
||||
|
||||
{loginError && <p className="text-danger text-[11px] mb-3">{loginError}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={!value.trim() || loading}
|
||||
className="w-full py-2.5 text-[13px] font-medium bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Logging in…" : "Log in"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="w-full py-2 text-[12px] text-text-dim hover:text-text transition-colors"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Orchestrator ────────────────────────────────────────────────────────────
|
||||
|
||||
export function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||
const [step, setStep] = useState<Step>("welcome");
|
||||
const [generatedSigner, setGeneratedSigner] = useState<NDKPrivateKeySigner | null>(null);
|
||||
|
||||
if (step === "welcome") {
|
||||
return (
|
||||
<WelcomeStep
|
||||
onCreateNew={() => setStep("create")}
|
||||
onHaveKey={() => setStep("login")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "create") {
|
||||
return (
|
||||
<CreateStep
|
||||
onNext={(signer) => {
|
||||
setGeneratedSigner(signer);
|
||||
setStep("backup");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "backup" && generatedSigner) {
|
||||
return <BackupStep signer={generatedSigner} onComplete={onComplete} />;
|
||||
}
|
||||
|
||||
if (step === "login") {
|
||||
return <LoginStep onBack={() => setStep("welcome")} onComplete={onComplete} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user