mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 17:48:35 -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:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Sidebar } from "./components/sidebar/Sidebar";
|
import { Sidebar } from "./components/sidebar/Sidebar";
|
||||||
import { Feed } from "./components/feed/Feed";
|
import { Feed } from "./components/feed/Feed";
|
||||||
import { SearchView } from "./components/search/SearchView";
|
import { SearchView } from "./components/search/SearchView";
|
||||||
@@ -6,10 +7,18 @@ import { SettingsView } from "./components/shared/SettingsView";
|
|||||||
import { ProfileView } from "./components/profile/ProfileView";
|
import { ProfileView } from "./components/profile/ProfileView";
|
||||||
import { ThreadView } from "./components/thread/ThreadView";
|
import { ThreadView } from "./components/thread/ThreadView";
|
||||||
import { ArticleEditor } from "./components/article/ArticleEditor";
|
import { ArticleEditor } from "./components/article/ArticleEditor";
|
||||||
|
import { OnboardingFlow } from "./components/onboarding/OnboardingFlow";
|
||||||
import { useUIStore } from "./stores/ui";
|
import { useUIStore } from "./stores/ui";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const currentView = useUIStore((s) => s.currentView);
|
const currentView = useUIStore((s) => s.currentView);
|
||||||
|
const [onboardingDone, setOnboardingDone] = useState(
|
||||||
|
() => !!localStorage.getItem("wrystr_pubkey")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onboardingDone) {
|
||||||
|
return <OnboardingFlow onComplete={() => setOnboardingDone(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen bg-bg">
|
<div className="flex h-screen w-screen bg-bg">
|
||||||
|
|||||||
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