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:
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
oauth_provider TEXT, -- 'github' | 'google' | NULL
|
||||
complexity TEXT NOT NULL DEFAULT 'beginner', -- beginner|intermediate|advanced
|
||||
risk_tolerance TEXT NOT NULL DEFAULT 'moderate',
|
||||
drawdown_tolerance REAL, -- onboarding max-drawdown % (beginner -20, etc.)
|
||||
convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction
|
||||
backup_codes_hashed TEXT, -- JSON array of scrypt-hashed backup codes (slice 2)
|
||||
created_at TEXT NOT NULL
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
STARTER_WATCHLIST,
|
||||
ONBOARDING_DISCLAIMER,
|
||||
defaultDrawdownTolerancePct,
|
||||
defaultRiskTolerance,
|
||||
type StarterSymbol,
|
||||
} from "../starter.ts";
|
||||
|
||||
describe("onboarding/starter", () => {
|
||||
describe("STARTER_WATCHLIST", () => {
|
||||
it("has exactly 9 entries", () => {
|
||||
assert.equal(STARTER_WATCHLIST.length, 9);
|
||||
});
|
||||
|
||||
it("matches the prescribed symbol+tickerKind order", () => {
|
||||
const expected: Array<{ symbol: string; tickerKind: string }> = [
|
||||
{ symbol: "IREN", tickerKind: "equity" },
|
||||
{ symbol: "CIFR", tickerKind: "equity" },
|
||||
{ symbol: "ASST", tickerKind: "equity" },
|
||||
{ symbol: "SLNH", tickerKind: "equity" },
|
||||
{ symbol: "BKKT", tickerKind: "equity" },
|
||||
{ symbol: "NUAI", tickerKind: "equity" },
|
||||
{ symbol: "NVDA", tickerKind: "equity" },
|
||||
{ symbol: "BTC", tickerKind: "crypto" },
|
||||
{ symbol: "SATA", tickerKind: "equity" },
|
||||
];
|
||||
assert.equal(STARTER_WATCHLIST.length, expected.length);
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
const got: StarterSymbol = STARTER_WATCHLIST[i];
|
||||
assert.equal(got.symbol, expected[i].symbol, `item #${i} symbol`);
|
||||
assert.equal(got.tickerKind, expected[i].tickerKind, `item #${i} tickerKind`);
|
||||
}
|
||||
});
|
||||
|
||||
it("BTC has tickerKind 'crypto' while all others are 'equity'", () => {
|
||||
for (const item of STARTER_WATCHLIST) {
|
||||
if (item.symbol === "BTC") {
|
||||
assert.equal(item.tickerKind, "crypto");
|
||||
} else {
|
||||
assert.equal(item.tickerKind, "equity");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every entry has a non-empty reason string", () => {
|
||||
for (const item of STARTER_WATCHLIST) {
|
||||
assert.ok(item.reason.length > 0, `reason for ${item.symbol} is empty`);
|
||||
}
|
||||
});
|
||||
|
||||
it("reasons contain no imperative trade verbs (ADR-0007 / P6)", () => {
|
||||
const forbidden = [
|
||||
/buy\b/i,
|
||||
/sell\b/i,
|
||||
/you should\b/i,
|
||||
/add to your\b/i,
|
||||
/rotate into\b/i,
|
||||
/action needed\b/i,
|
||||
];
|
||||
for (const item of STARTER_WATCHLIST) {
|
||||
const hit = forbidden.find((rx) => rx.test(item.reason));
|
||||
assert.equal(hit, undefined, `forbidden verb in reason for ${item.symbol}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultDrawdownTolerancePct", () => {
|
||||
it("returns -20 for beginner", () => {
|
||||
assert.equal(defaultDrawdownTolerancePct("beginner"), -20);
|
||||
});
|
||||
|
||||
it("returns -30 for intermediate", () => {
|
||||
assert.equal(defaultDrawdownTolerancePct("intermediate"), -30);
|
||||
});
|
||||
|
||||
it("returns -40 for advanced", () => {
|
||||
assert.equal(defaultDrawdownTolerancePct("advanced"), -40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultRiskTolerance", () => {
|
||||
it("returns 'moderate' for beginner", () => {
|
||||
assert.equal(defaultRiskTolerance("beginner"), "moderate");
|
||||
});
|
||||
|
||||
it("returns 'moderate' for intermediate", () => {
|
||||
assert.equal(defaultRiskTolerance("intermediate"), "moderate");
|
||||
});
|
||||
|
||||
it("returns 'aggressive' for advanced", () => {
|
||||
assert.equal(defaultRiskTolerance("advanced"), "aggressive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ONBOARDING_DISCLAIMER", () => {
|
||||
it("contains the required keywords", () => {
|
||||
assert.ok(
|
||||
ONBOARDING_DISCLAIMER.toLowerCase().includes("educational"),
|
||||
"missing 'educational'",
|
||||
);
|
||||
assert.ok(
|
||||
ONBOARDING_DISCLAIMER.toLowerCase().includes("not financial advice"),
|
||||
"missing 'not financial advice'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// ADR-0007: Zero imperative trade verbs anywhere in this module.
|
||||
// Pure data + helpers for the first-login onboarding wizard — no deps.
|
||||
|
||||
export type Complexity = "beginner" | "intermediate" | "advanced";
|
||||
|
||||
/**
|
||||
* One entry for the starter watchlist shown to a new user during onboarding.
|
||||
* The reason field is plain-English and purely educational/descriptive.
|
||||
*/
|
||||
export interface StarterSymbol {
|
||||
symbol: string;
|
||||
tickerKind: "equity" | "crypto" | "etf" | "index";
|
||||
/** One-line plain-English description — factual, no trade verbs (P6 / ADR-0007). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The starter watchlist presented to every new user.
|
||||
* Exactly 9 entries, fixed order.
|
||||
*/
|
||||
export const STARTER_WATCHLIST: StarterSymbol[] = [
|
||||
{
|
||||
symbol: "IREN",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Iris Energy — a Bitcoin-mining and energy-infrastructure company focused on sustainable operations.",
|
||||
},
|
||||
{
|
||||
symbol: "CIFR",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Cipher Mining — a digital-asset mining operator with large-scale energy infrastructure.",
|
||||
},
|
||||
{
|
||||
symbol: "ASST",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Asseco Systems — an enterprise technology and blockchain-services provider.",
|
||||
},
|
||||
{
|
||||
symbol: "SLNH",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Solena AI — a data-center infrastructure company supporting AI and compute workloads.",
|
||||
},
|
||||
{
|
||||
symbol: "BKKT",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Bakkt — a regulated digital-asset exchange built on institutional-grade infrastructure.",
|
||||
},
|
||||
{
|
||||
symbol: "NUAI",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"NuAerospace — an aerospace-and-defense company pursuing advanced space systems.",
|
||||
},
|
||||
{
|
||||
symbol: "NVDA",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"NVIDIA — a semiconductor leader in GPUs and AI-accelerator hardware.",
|
||||
},
|
||||
{
|
||||
symbol: "BTC",
|
||||
tickerKind: "crypto",
|
||||
reason:
|
||||
"Bitcoin — the original cryptocurrency; price and sentiment tracked, SEC disclosure modules do not apply to on-chain assets.",
|
||||
},
|
||||
{
|
||||
symbol: "SATA",
|
||||
tickerKind: "equity",
|
||||
reason:
|
||||
"Solid Alpha Technology Acquisition — a SPAC holding a technology-focused portfolio.",
|
||||
},
|
||||
];
|
||||
|
||||
/** Default drawdown tolerance (percent) per complexity level. */
|
||||
export function defaultDrawdownTolerancePct(complexity: Complexity): number {
|
||||
switch (complexity) {
|
||||
case "beginner":
|
||||
return -20;
|
||||
case "intermediate":
|
||||
return -30;
|
||||
case "advanced":
|
||||
return -40;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default risk tolerance per complexity level. */
|
||||
export function defaultRiskTolerance(
|
||||
complexity: Complexity,
|
||||
): "conservative" | "moderate" | "aggressive" {
|
||||
switch (complexity) {
|
||||
case "beginner":
|
||||
return "moderate";
|
||||
case "intermediate":
|
||||
return "moderate";
|
||||
case "advanced":
|
||||
return "aggressive";
|
||||
}
|
||||
}
|
||||
|
||||
/** Legal disclaimer shown during onboarding. */
|
||||
export const ONBOARDING_DISCLAIMER =
|
||||
"This is an educational tool, not financial advice.";
|
||||
@@ -172,3 +172,21 @@ test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallbac
|
||||
delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Slice 3: onboarding ---
|
||||
test('onboarding.complete writes complexity/risk/drawdown + starter watchlist + subscribes demand', async () => {
|
||||
const { db, cache, freshCtx } = setup();
|
||||
const signupCtx = freshCtx();
|
||||
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
|
||||
const oCtx = { db, cache, resHeaders: new Headers(), userId, cookies: {} as Record<string, string> };
|
||||
const res = await appRouter.createCaller(oCtx).onboarding.complete({ complexity: 'beginner' });
|
||||
assert.ok(res.watchlistId);
|
||||
const u = db.prepare('SELECT complexity, risk_tolerance, drawdown_tolerance FROM users WHERE id=?').get(userId) as { complexity: string; risk_tolerance: string; drawdown_tolerance: number };
|
||||
assert.equal(u.complexity, 'beginner');
|
||||
assert.equal(u.risk_tolerance, 'moderate');
|
||||
assert.equal(u.drawdown_tolerance, -20);
|
||||
const wl = db.prepare('SELECT symbols FROM watchlists WHERE owner_id=?').get(userId) as { symbols: string };
|
||||
assert.equal(JSON.parse(wl.symbols).length, 9);
|
||||
const demand = await cache.demandSet();
|
||||
assert.ok(demand.includes('NVDA') && demand.includes('BTC'), 'starter symbols subscribed to demand set');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { hashPassword, verifyPassword, createSession, clearCookie, oauthStateCoo
|
||||
import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts';
|
||||
import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts';
|
||||
import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts';
|
||||
import { STARTER_WATCHLIST, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts';
|
||||
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
@@ -57,7 +58,8 @@ const authRouter = router({
|
||||
me: publicProcedure.query(({ ctx }) => {
|
||||
if (!ctx.userId) return null;
|
||||
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined;
|
||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture } : null;
|
||||
const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId);
|
||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl } : null;
|
||||
}),
|
||||
enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => {
|
||||
const userId = ctx.userId as string;
|
||||
@@ -118,6 +120,39 @@ const authRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const onboardingRouter = router({
|
||||
// Public: the starter watchlist + disclaimer shown in the wizard before completing.
|
||||
starter: publicProcedure.query(() => ({ watchlist: STARTER_WATCHLIST, disclaimer: ONBOARDING_DISCLAIMER })),
|
||||
// Protected: write complexity/risk/drawdown + first watchlist + optional portfolio; subscribe symbols to demand.
|
||||
complete: protectedProcedure
|
||||
.input(z.object({
|
||||
complexity: z.enum(['beginner', 'intermediate', 'advanced']),
|
||||
riskTolerance: z.enum(['conservative', 'moderate', 'aggressive']).optional(),
|
||||
drawdownTolerancePct: z.number().optional(),
|
||||
firstWatchlistSymbols: z.array(z.string()).optional(),
|
||||
portfolio: z.array(z.object({ symbol: z.string(), qty: z.number(), avgCost: z.number(), acquiredAt: z.string() })).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.userId as string;
|
||||
const complexity = input.complexity as Complexity;
|
||||
const riskTolerance = input.riskTolerance ?? defaultRiskTolerance(complexity);
|
||||
const drawdown = input.drawdownTolerancePct ?? defaultDrawdownTolerancePct(complexity);
|
||||
ctx.db.prepare('UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=? WHERE id=?').run(complexity, riskTolerance, drawdown, userId);
|
||||
const symbols = input.firstWatchlistSymbols ?? STARTER_WATCHLIST.map((s) => s.symbol);
|
||||
const wlId = randomUUID();
|
||||
ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run(wlId, userId, 'Starter', JSON.stringify(symbols), new Date().toISOString(), 0);
|
||||
for (const sym of symbols) {
|
||||
const kind = (STARTER_WATCHLIST.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index';
|
||||
await ctx.cache.subscribe(sym, kind);
|
||||
}
|
||||
if (input.portfolio) {
|
||||
const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)');
|
||||
for (const h of input.portfolio) ins.run(randomUUID(), userId, h.symbol.toUpperCase(), h.qty, h.avgCost, h.acquiredAt, 'open');
|
||||
}
|
||||
return { ok: true, watchlistId: wlId };
|
||||
}),
|
||||
});
|
||||
|
||||
const marketRouter = router({
|
||||
snapshot: publicProcedure
|
||||
.input(z.object({ symbol: z.string().min(1) }))
|
||||
@@ -138,5 +173,5 @@ const marketRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({ auth: authRouter, market: marketRouter });
|
||||
export const appRouter = router({ auth: authRouter, onboarding: onboardingRouter, market: marketRouter });
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
+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