slice 3 COMPLETE: onboarding wizard (complexity/risk/drawdown + starter watchlist + demand subscribe)
starter.ts by ornith (9 symbols w/ ticker-kind + ADR-0007 reasons, defaults, 12 tests). onboarding.complete + onboarding.starter + me().onboarded by orchestrator; schema +drawdown_tolerance. Wizard UI (complexity picker, starter list, disclaimer, finish). onboarding subscribes starter symbols to the demand set (adapter fetches them). 87/87 backend + 2/2 lint green. Slice 3 done; slices 1-3 complete.
This commit is contained in:
+55
-10
@@ -9,6 +9,8 @@ export default function Page() {
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [draft, setDraft] = useState(activeSymbol);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={(e) => { e.preventDefault(); setActive(draft); }} className="flex gap-2">
|
||||
@@ -17,32 +19,75 @@ export default function Page() {
|
||||
<button type="submit" className="px-4 py-2 rounded-md text-sm font-medium bg-[#1a1b24] border border-[#2a2b3a] text-[#e6e7ec] hover:border-[#4f8cff]">Open</button>
|
||||
</form>
|
||||
<OverviewPanel />
|
||||
<AuthSection />
|
||||
{user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />}
|
||||
<AuthSection user={user} setUser={setUser} />
|
||||
<PrimaryRuleFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSection() {
|
||||
function OnboardingSection({ onDone }: { onDone: () => void }) {
|
||||
const [starter, setStarter] = useState<{ watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>; disclaimer: string } | null>(null);
|
||||
const [complexity, setComplexity] = useState<"beginner" | "intermediate" | "advanced">("beginner");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => { api.onboarding.starter().then(setStarter).catch(() => {}); }, []);
|
||||
const complete = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try { await api.onboarding.complete({ complexity }); onDone(); }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : "request failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.onboardingTitle}</h2>
|
||||
<div>
|
||||
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.complexityLabel}</p>
|
||||
<div className="flex gap-2">
|
||||
{(["beginner", "intermediate", "advanced"] as const).map((c) => (
|
||||
<button key={c} onClick={() => setComplexity(c)}
|
||||
className={`px-3 py-1.5 rounded text-sm border ${complexity === c ? "bg-[#4f8cff] text-white border-[#4f8cff]" : "border-[#2a2b3a] text-[#8a8b9a]"}`}>
|
||||
{c === "beginner" ? UI_STRINGS.complexityBeginner : c === "intermediate" ? UI_STRINGS.complexityIntermediate : UI_STRINGS.complexityAdvanced}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#5a5b6a] mt-2">{UI_STRINGS.drawdownNote}</p>
|
||||
</div>
|
||||
{starter && (
|
||||
<div>
|
||||
<p className="text-xs text-[#5a5b6a] mb-1">{UI_STRINGS.starterWatchlistLabel}</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{starter.watchlist.map((s) => (
|
||||
<li key={s.symbol} className="flex gap-2">
|
||||
<span className="font-mono text-[#4f8cff] w-14">{s.symbol}</span>
|
||||
<span className="text-[#8a8b9a]">{s.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[10px] text-[#5a5b6a] mt-2 italic">{starter.disclaimer}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-[#ef4444]">{err}</p>}
|
||||
<button disabled={busy} onClick={complete} className="px-4 py-2 rounded text-sm bg-[#4f8cff] text-white disabled:opacity-50">{UI_STRINGS.completeOnboardingButton}</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: AuthUser | null) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// 2FA enrollment flow state
|
||||
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
|
||||
|
||||
useEffect(() => { if (user === null) api.auth.me().then(setUser).catch(() => {}); }, [user, setUser]);
|
||||
const submit = async (mode: "signup" | "login") => {
|
||||
setBusy(true); setError(null);
|
||||
try { await api.auth[mode](email, password); setUser(await api.auth.me()); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : "request failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
@@ -80,8 +125,8 @@ function AuthSection() {
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<p className="text-sm text-[#8a8b9a] mb-3">{UI_STRINGS.signupPrompt}</p>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder={UI_STRINGS.authEmailLabel} type="email" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-2 text-sm text-[#e6e7ec]" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={UI_STRINGS.authPasswordLabel} type="password" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-2 text-sm text-[#e6e7ec]" />
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder={UI_STRINGS.authEmailLabel} type="email" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-2 text-sm text-[#e6e7ec]" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={UI_STRINGS.authPasswordLabel} type="password" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-2 text-sm text-[#e6e7ec]" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-[#ef4444] mb-2">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
// Curated user-facing strings (P6 plain English; ADR-0007 Primary-Rule compliant).
|
||||
// The Primary-Rule lint test (src/__tests__/primary-rule-lint.test.ts) scans these and the
|
||||
// panel/page source for imperative trade verbs (a stoplist of directives). Approved noun
|
||||
// phrases such as the buy-zone estimate are allowed.
|
||||
// panel/page/client source for imperative trade verbs (a stoplist of directives). Approved
|
||||
// noun phrases such as the buy-zone estimate are allowed.
|
||||
export const UI_STRINGS = {
|
||||
appTitle: "Investor Flow",
|
||||
appSubtitle: "Educational investment research terminal",
|
||||
@@ -33,6 +33,15 @@ export const UI_STRINGS = {
|
||||
twoFactorEnabled: "Two-factor is now enabled. Login will ask for a code.",
|
||||
oauthGithub: "Continue with GitHub",
|
||||
oauthGoogle: "Continue with Google",
|
||||
onboardingTitle: "Welcome — set up your research defaults.",
|
||||
complexityLabel: "Experience level",
|
||||
complexityBeginner: "Beginner",
|
||||
complexityIntermediate: "Intermediate",
|
||||
complexityAdvanced: "Advanced",
|
||||
starterWatchlistLabel: "Your starter watchlist",
|
||||
drawdownNote: "A default max-drawdown tolerance is set from your experience level (you can change it later).",
|
||||
completeOnboardingButton: "Finish setup",
|
||||
onboardingDone: "Setup complete — your starter watchlist is tracking.",
|
||||
} as const;
|
||||
|
||||
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
|
||||
|
||||
+14
-14
@@ -42,6 +42,7 @@ export interface AuthUser {
|
||||
complexity?: string;
|
||||
riskTolerance?: string;
|
||||
convexityPosture?: string;
|
||||
onboarded?: boolean;
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
@@ -52,25 +53,19 @@ async function parse<T>(res: Response): Promise<T> {
|
||||
}
|
||||
return json.result.data as T;
|
||||
}
|
||||
|
||||
async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
||||
const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : "";
|
||||
const res = await fetch(`/api/trpc/${procedure}${search}`, {
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await fetch(`/api/trpc/${procedure}${search}`, { headers: { "content-type": "application/json" }, credentials: "include" });
|
||||
return parse<T>(res);
|
||||
}
|
||||
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
||||
const res = await fetch(`/api/trpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json" }, credentials: "include", body: JSON.stringify(input) });
|
||||
return parse<T>(res);
|
||||
}
|
||||
|
||||
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
||||
const res = await fetch(`/api/trpc/${procedure}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return parse<T>(res);
|
||||
}
|
||||
export type Complexity = "beginner" | "intermediate" | "advanced";
|
||||
export interface StarterEntry { symbol: string; tickerKind: string; reason: string; }
|
||||
export interface PortfolioEntry { symbol: string; qty: number; avgCost: number; acquiredAt: string; }
|
||||
|
||||
export const api = {
|
||||
market: { snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }) },
|
||||
@@ -83,4 +78,9 @@ export const api = {
|
||||
confirm2fa: (totp: string) => trpcMutate<{ ok: boolean }>("auth.confirm2fa", { totp }),
|
||||
oauthStart: (provider: "github" | "google", redirectUri: string) => trpcMutate<{ redirectUrl: string; state: string }>("auth.oauthStart", { provider, redirectUri }),
|
||||
},
|
||||
onboarding: {
|
||||
starter: () => trpcQuery<{ watchlist: StarterEntry[]; disclaimer: string }>("onboarding.starter"),
|
||||
complete: (input: { complexity: Complexity; drawdownTolerancePct?: number; firstWatchlistSymbols?: string[]; portfolio?: PortfolioEntry[] }) =>
|
||||
trpcMutate<{ ok: boolean; watchlistId: string }>("onboarding.complete", input),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user