slice 1g: Next.js M1 overview panel + Primary-Rule lint (ADR-0007)
Symbol command bar (Zustand active-symbol) -> OverviewPanel (price + day-change shape/color/label per P7 G3 + Recharts sparkline + sector) + signup/login + ADR-0007 footer. next.config rewrites proxy /api -> backend:3001 (same-origin cookies). Vanilla fetch tRPC client (decoupled from server Node types). Primary-Rule lint 2/2 green. Slice 1 tracer bullet COMPLETE: 38 tests green, live-verified end-to-end.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Proxy /api/* to the local Bun/Node backend (slice 1f) so the SPA and backend share an
|
||||
// origin — session cookies work without cross-origin/CORS gymnastics (DESIGN.md §2.1).
|
||||
const nextConfig: NextConfig = {
|
||||
rewrites: async () => [
|
||||
{ source: "/api/:path*", destination: "http://localhost:3001/api/:path*" },
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "investor-flow",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-switch": "^1.3.1",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-toast": "^1.2.17",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.9.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { UI_STRING_LIST } from "../lib/strings.ts";
|
||||
|
||||
// ADR-0007 golden master (DESIGN.md §7.3): no imperative trade directive in any curated UI
|
||||
// string or panel/page/client source. Stoplist of imperatives; "buy-zone estimate" (a noun) is allowed.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const STOPWORDS = ["buy", "sell", "you should", "add to your", "rotate into", "action needed"];
|
||||
const ALLOWED_PHRASES = ["buy-zone"];
|
||||
|
||||
function imperativeHits(text: string): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const w of STOPWORDS) {
|
||||
const re = new RegExp("\\b" + w.replace(/\s+/g, "\\s+") + "\\b", "gi");
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const ctx = text.slice(Math.max(0, m.index - 14), m.index + w.length + 14).replace(/\s+/g, " ").trim();
|
||||
if (ALLOWED_PHRASES.some((a) => ctx.toLowerCase().includes(a))) continue;
|
||||
hits.push(`"${w}" near: …${ctx}…`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
test("curated UI strings pass the Primary-Rule lint", () => {
|
||||
for (const s of UI_STRING_LIST) {
|
||||
assert.deepEqual(imperativeHits(s), [], `Primary-Rule violation in string: "${s}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test("panel/page/client source pass the Primary-Rule lint", () => {
|
||||
const files = [
|
||||
"../components/OverviewPanel.tsx",
|
||||
"../app/page.tsx",
|
||||
"../app/layout.tsx",
|
||||
"../lib/strings.ts",
|
||||
"../lib/trpc.ts",
|
||||
"../stores/active-symbol-store.ts",
|
||||
];
|
||||
for (const f of files) {
|
||||
const src = readFileSync(join(__dirname, f), "utf8");
|
||||
const hits = imperativeHits(src);
|
||||
assert.deepEqual(hits, [], `Primary-Rule violation in ${f}: ${hits.join("; ")}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0b0f;
|
||||
--bg-secondary: #121318;
|
||||
--bg-card: #1a1b24;
|
||||
--bg-input: #22232f;
|
||||
--border-color: #2a2b3a;
|
||||
--border-focus: #4f8cff;
|
||||
--text-primary: #e6e7ec;
|
||||
--text-secondary: #8a8b9a;
|
||||
--text-muted: #5a5b6a;
|
||||
--accent-blue: #4f8cff;
|
||||
--accent-green: #34d399;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #fbbf24;
|
||||
--accent-purple: #a78bfa;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #__next {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Utility: card */
|
||||
.card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Utility: button */
|
||||
.btn-primary {
|
||||
background-color: var(--accent-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background-color: var(--border-color); }
|
||||
|
||||
.btn-danger {
|
||||
background-color: transparent;
|
||||
color: var(--accent-red);
|
||||
border: 1px solid var(--accent-red);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.input {
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Layout helper */
|
||||
.page-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Tier colors */
|
||||
.tier-A_STAR { background: #064e3b; color: #34d399; }
|
||||
.tier-A { background: #065f46; color: #6ee7b7; }
|
||||
.tier-B { background: #3730a3; color: #a5b4fc; }
|
||||
.tier-C { background: #78350f; color: #fcd34d; }
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${UI_STRINGS.appTitle} — ${UI_STRINGS.appSubtitle}`,
|
||||
description: UI_STRINGS.appSubtitle,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="min-h-screen bg-[#0a0b0f] text-[#e6e7ec]">
|
||||
<header className="border-b border-[#2a2b3a] bg-[#121318] px-6 py-3 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-bold text-[#4f8cff]">{UI_STRINGS.appTitle}</h1>
|
||||
<p className="text-xs text-[#5a5b6a]">{UI_STRINGS.appSubtitle}</p>
|
||||
</header>
|
||||
<main className="max-w-5xl mx-auto p-6">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { OverviewPanel } from "@/components/OverviewPanel";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
export default function Page() {
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [draft, setDraft] = useState(activeSymbol);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={(e) => { e.preventDefault(); setActive(draft); }} className="flex gap-2">
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={UI_STRINGS.symbolPlaceholder}
|
||||
className="flex-1 bg-[#121318] border border-[#2a2b3a] rounded-md px-3 py-2 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]" />
|
||||
<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 />
|
||||
<PrimaryRuleFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSection() {
|
||||
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);
|
||||
|
||||
useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []);
|
||||
|
||||
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">
|
||||
<p className="text-sm text-[#e6e7ec]">Signed in as <span className="font-mono">{user.email}</span></p>
|
||||
<p className="text-xs text-[#5a5b6a] mt-1">Complexity: {user.complexity} · Risk tolerance: {user.riskTolerance}</p>
|
||||
<button onClick={async () => { await api.auth.logout(); setUser(null); }} className="mt-3 text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#ef4444] hover:text-[#ef4444]">{UI_STRINGS.logoutButton}</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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]" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-[#ef4444] mb-2">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => submit("signup")} className="px-3 py-1.5 rounded text-sm bg-[#4f8cff] text-white disabled:opacity-50">{UI_STRINGS.signupButton}</button>
|
||||
<button disabled={busy} onClick={() => submit("login")} className="px-3 py-1.5 rounded text-sm border border-[#2a2b3a] text-[#e6e7ec] disabled:opacity-50">{UI_STRINGS.loginButton}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryRuleFooter() {
|
||||
return (
|
||||
<footer className="border-t border-[#2a2b3a] pt-4">
|
||||
<p className="text-[11px] text-[#5a5b6a]">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type Snapshot } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
|
||||
|
||||
// M1 Symbol Overview (DESIGN.md §4). Reads market.snapshot from cache; polls so the
|
||||
// stale-while-revalidate background refresh shows up once the cache fills. P7: the day-change
|
||||
// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007).
|
||||
export function OverviewPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true); setError(null);
|
||||
const load = () => api.market.snapshot(symbol)
|
||||
.then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } })
|
||||
.catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : "request failed"); setLoading(false); } });
|
||||
load();
|
||||
const t = setInterval(load, 5000); // poll for SWR refresh
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, [symbol]);
|
||||
|
||||
const q = snap?.quote ?? null;
|
||||
const sector = snap?.sector ?? null;
|
||||
const candles = snap?.candles ?? null;
|
||||
const isCrypto = sector?.tickerKind === "crypto";
|
||||
const up = (q?.change ?? 0) >= 0;
|
||||
const spark = (candles ?? []).map((c, i) => ({ i, c: c.c }));
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<header className="flex items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-[#e6e7ec]">{symbol}</h2>
|
||||
<p className="text-sm text-[#8a8b9a]">{sector?.name ?? sector?.sector ?? ""}</p>
|
||||
</div>
|
||||
{snap && (snap.stale.quote || snap.stale.candles || snap.stale.sector) && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#fcd34d] border border-[#78350f] rounded px-2 py-0.5">{UI_STRINGS.staleBadge}</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{loading && !snap ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.loadingSnapshot}</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-[#ef4444] py-8">{error}</p>
|
||||
) : !q ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.noData}</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-end gap-6">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">{UI_STRINGS.priceLabel}</p>
|
||||
<p className="text-3xl font-mono font-bold text-[#e6e7ec]">${q.price?.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">{UI_STRINGS.dayChangeLabel}</p>
|
||||
<p className={`text-lg font-mono font-semibold flex items-center gap-1 ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
<span aria-hidden>{up ? "▲" : "▼"}</span>
|
||||
<span>{up ? "+" : ""}{q.change?.toFixed(2)} ({up ? "+" : ""}{(q.changePercent ?? 0).toFixed(2)}%)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spark.length > 1 && (
|
||||
<figure>
|
||||
<div className="h-24">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={spark} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
|
||||
<YAxis domain={["auto", "auto"]} hide />
|
||||
<Line type="monotone" dataKey="c" stroke="#4f8cff" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">{UI_STRINGS.sparklineCaption}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
{sector && (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.sectorLabel}</dt><dd className="text-[#e6e7ec]">{sector.sector ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.industryLabel}</dt><dd className="text-[#e6e7ec]">{sector.industry ?? "—"}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.tickerKindLabel}</dt><dd className="text-[#e6e7ec]">{sector.tickerKind}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{isCrypto && <p className="text-[11px] text-[#fcd34d] border border-[#78350f] rounded p-2">{UI_STRINGS.cryptoNotice}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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.
|
||||
export const UI_STRINGS = {
|
||||
appTitle: "Investor Flow",
|
||||
appSubtitle: "Educational investment research terminal",
|
||||
symbolPlaceholder: "Enter a symbol (e.g. NVDA)",
|
||||
loadingSnapshot: "Loading cached snapshot…",
|
||||
noData: "No cached data yet — the backend is fetching this in the background.",
|
||||
priceLabel: "Last price",
|
||||
dayChangeLabel: "Day change",
|
||||
sectorLabel: "Sector",
|
||||
industryLabel: "Industry",
|
||||
tickerKindLabel: "Type",
|
||||
sparklineCaption: "Recent daily close — context for where price has been, not a signal to act on.",
|
||||
cryptoNotice: "Crypto — limited research module (SEC-derived ownership and filings do not apply).",
|
||||
staleBadge: "cached — refreshing",
|
||||
primaryRuleFooter:
|
||||
"Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.",
|
||||
authEmailLabel: "Email",
|
||||
authPasswordLabel: "Password",
|
||||
signupButton: "Create account",
|
||||
loginButton: "Sign in",
|
||||
logoutButton: "Sign out",
|
||||
signupPrompt: "Create an account to track symbols and keep your research in one place.",
|
||||
} as const;
|
||||
|
||||
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
|
||||
@@ -0,0 +1,83 @@
|
||||
// Thin typed client over the tRPC backend (DESIGN.md §2). Slice 1 uses a fetch wrapper
|
||||
// rather than @trpc/client to keep the Next app decoupled from the server's Node-native
|
||||
// type graph; the server is a real tRPC server and full end-to-end client types can be
|
||||
// layered later without changing this surface.
|
||||
export interface Quote {
|
||||
symbol: string;
|
||||
price: number;
|
||||
bid?: number | null;
|
||||
ask?: number | null;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
iv?: number | null;
|
||||
}
|
||||
export interface PriceCandle {
|
||||
ts: string;
|
||||
o: number;
|
||||
h: number;
|
||||
l: number;
|
||||
c: number;
|
||||
v: number;
|
||||
adjClose?: number | null;
|
||||
}
|
||||
export interface SymbolMeta {
|
||||
symbol: string;
|
||||
name?: string | null;
|
||||
sector?: string | null;
|
||||
industry?: string | null;
|
||||
exchange?: string | null;
|
||||
tickerKind: string;
|
||||
peers?: string[] | null;
|
||||
}
|
||||
export interface Snapshot {
|
||||
symbol: string;
|
||||
quote: Quote | null;
|
||||
candles: PriceCandle[] | null;
|
||||
sector: SymbolMeta | null;
|
||||
stale: { quote: boolean; candles: boolean; sector: boolean };
|
||||
}
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email?: string;
|
||||
complexity?: string;
|
||||
riskTolerance?: string;
|
||||
convexityPosture?: string;
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
const json = await res.json();
|
||||
if (json?.error) {
|
||||
const msg = json.error.message ?? `tRPC error ${json.error.data?.code ?? ""}`;
|
||||
throw new Error(typeof msg === "string" ? msg : "tRPC error");
|
||||
}
|
||||
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",
|
||||
});
|
||||
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 const api = {
|
||||
market: { snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }) },
|
||||
auth: {
|
||||
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
|
||||
login: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.login", { email, password }),
|
||||
logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}),
|
||||
me: () => trpcQuery<AuthUser | null>("auth.me"),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
// Single-page active-symbol signal (DESIGN.md §4): the shell holds the active symbol in
|
||||
// Zustand; switching it rehydrates panels from cache with no route change.
|
||||
interface ActiveSymbolState {
|
||||
activeSymbol: string; // slice 1 default hardcoded to NVDA
|
||||
setActive: (s: string) => void;
|
||||
}
|
||||
|
||||
export const useActiveSymbol = create<ActiveSymbolState>((set) => ({
|
||||
activeSymbol: "NVDA",
|
||||
setActive: (s) => set({ activeSymbol: s.toUpperCase().trim() || "NVDA" }),
|
||||
}));
|
||||
Reference in New Issue
Block a user