summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/AppShell.tsx20
-rw-r--r--frontend/components/prism/ChartCard.tsx55
-rw-r--r--frontend/components/prism/KPIStrip.tsx16
-rw-r--r--frontend/components/prism/Sidebar.tsx101
-rw-r--r--frontend/components/prism/TickerHeader.tsx51
-rw-r--r--frontend/components/prism/TopBar.tsx61
6 files changed, 304 insertions, 0 deletions
diff --git a/frontend/components/prism/AppShell.tsx b/frontend/components/prism/AppShell.tsx
new file mode 100644
index 0000000..63fd62b
--- /dev/null
+++ b/frontend/components/prism/AppShell.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+type Props = {
+ sidebar: ReactNode;
+ topbar: ReactNode;
+ children: ReactNode;
+};
+
+export function AppShell({ sidebar, topbar, children }: Props) {
+ return (
+ <main className="prism-app">
+ {sidebar}
+ <div className="psm-main">
+ {topbar}
+ <section className="psm-content">{children}</section>
+ </div>
+ </main>
+ );
+}
+
diff --git a/frontend/components/prism/ChartCard.tsx b/frontend/components/prism/ChartCard.tsx
new file mode 100644
index 0000000..bc650d7
--- /dev/null
+++ b/frontend/components/prism/ChartCard.tsx
@@ -0,0 +1,55 @@
+import { PriceChart } from "@/components/PriceChart";
+import type { HistoryPoint } from "@/types/api";
+
+const PERIODS = [
+ { key: "1m", label: "1M" },
+ { key: "3m", label: "3M" },
+ { key: "6m", label: "6M" },
+ { key: "1y", label: "1Y" },
+ { key: "5y", label: "5Y" }
+];
+
+type Props = {
+ symbol: string;
+ period: string;
+ points: HistoryPoint[];
+ chartState: "idle" | "loading" | "ready" | "error";
+ chartError: string | null;
+ onChangePeriod: (period: string) => void;
+};
+
+export function ChartCard({ symbol, period, points, chartState, chartError, onChangePeriod }: Props) {
+ return (
+ <section className="psm-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Price History</div>
+ <h2 className="psm-card-title">{symbol}</h2>
+ </div>
+ <div className="psm-tabs" role="tablist" aria-label="Chart range">
+ {PERIODS.map((option) => (
+ <button
+ key={option.key}
+ type="button"
+ role="tab"
+ className={`psm-tab${period === option.key ? " active" : ""}`}
+ aria-selected={period === option.key}
+ onClick={() => onChangePeriod(option.key)}
+ >
+ {option.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <p className="psm-chart-meta">Chart loading is isolated from the rest of Overview. A history miss only affects this card.</p>
+
+ <div className="psm-chart-frame">
+ {chartState === "loading" ? <div className="psm-card-empty">Loading {period.toUpperCase()} history…</div> : null}
+ {chartState === "error" ? <div className="psm-card-empty psm-error-copy">{chartError || "Could not load chart history."}</div> : null}
+ {chartState === "ready" ? <PriceChart symbol={symbol} points={points} /> : null}
+ </div>
+ </section>
+ );
+}
+
diff --git a/frontend/components/prism/KPIStrip.tsx b/frontend/components/prism/KPIStrip.tsx
new file mode 100644
index 0000000..b3cd34c
--- /dev/null
+++ b/frontend/components/prism/KPIStrip.tsx
@@ -0,0 +1,16 @@
+import type { KpiItem } from "@/lib/overview";
+
+export function KPIStrip({ items }: { items: KpiItem[] }) {
+ return (
+ <section className="psm-card psm-kpis" aria-label="Key metrics">
+ {items.map((item) => (
+ <div className="psm-kpi" key={item.key}>
+ <span className="psm-kpi-key">{item.key}</span>
+ <span className={`psm-kpi-value${item.missing ? " missing" : ""}`}>{item.value}</span>
+ <span className="psm-kpi-sub">{item.sublabel}</span>
+ </div>
+ ))}
+ </section>
+ );
+}
+
diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx
new file mode 100644
index 0000000..15a2947
--- /dev/null
+++ b/frontend/components/prism/Sidebar.tsx
@@ -0,0 +1,101 @@
+import Image from "next/image";
+import type { NavItem } from "@/lib/overview";
+import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format";
+import { watchlistSubtitle } from "@/lib/overview";
+import type { WatchlistResponse } from "@/types/api";
+
+type Props = {
+ navItems: NavItem[];
+ selectedKey: string;
+ currentTicker: string;
+ watchlist: WatchlistResponse;
+ watchlistError: string | null;
+ onSelectTicker: (symbol: string) => void;
+ onRemoveTicker: (symbol: string) => void;
+};
+
+export function Sidebar({
+ navItems,
+ selectedKey,
+ currentTicker,
+ watchlist,
+ watchlistError,
+ onSelectTicker,
+ onRemoveTicker
+}: Props) {
+ return (
+ <aside className="psm-side">
+ <div className="psm-brand">
+ <Image className="psm-brand-mark" src="/design-system/logo-monogram.svg" alt="" width={34} height={34} />
+ <div className="psm-brand-copy">
+ <div className="psm-brand-name">Prism</div>
+ <div className="psm-brand-sub">Market Workbench</div>
+ </div>
+ </div>
+
+ <div className="psm-side-section">
+ <div className="psm-side-label">Workspace</div>
+ </div>
+
+ <nav className="psm-nav" aria-label="Primary">
+ {navItems.map((item) => {
+ const active = item.key === selectedKey;
+ return (
+ <button
+ key={item.key}
+ type="button"
+ className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`}
+ aria-disabled={item.disabled ? "true" : undefined}
+ >
+ <span className={`psm-icon icon-${item.icon}`} aria-hidden />
+ <span className="psm-nav-copy">
+ <span>{item.label}</span>
+ {item.disabled ? <span className="psm-nav-coming">Soon</span> : null}
+ </span>
+ </button>
+ );
+ })}
+ </nav>
+
+ <div className="psm-side-section">
+ <div className="psm-side-label">Watchlist</div>
+ </div>
+
+ <div className="psm-watch">
+ <div className="psm-watch-toolbar">
+ <div className="psm-watch-limit">
+ {watchlist.items.length}/{watchlist.limit}
+ </div>
+ </div>
+
+ {watchlist.items.length === 0 ? <div className="psm-watch-empty">Saved tickers will appear here.</div> : null}
+
+ {watchlist.items.map((item) => {
+ const active = item.symbol === currentTicker;
+ return (
+ <div key={item.symbol} className={`psm-watch-row${active ? " active" : ""}`}>
+ <button type="button" className="psm-watch-select" onClick={() => onSelectTicker(item.symbol)}>
+ <span className="psm-watch-main">
+ <span className="psm-watch-symbol">{item.symbol}</span>
+ <span className="psm-watch-date">{watchlistSubtitle(item)}</span>
+ </span>
+ <span className="psm-watch-price">{fmtCurrency(item.quote?.price)}</span>
+ <span className={`psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span>
+ </button>
+ <button
+ type="button"
+ aria-label={`Remove ${item.symbol} from watchlist`}
+ className="psm-watch-remove"
+ onClick={() => onRemoveTicker(item.symbol)}
+ >
+ ×
+ </button>
+ </div>
+ );
+ })}
+
+ {watchlistError ? <p className="psm-muted-copy psm-error-copy">{watchlistError}</p> : null}
+ </div>
+ </aside>
+ );
+}
diff --git a/frontend/components/prism/TickerHeader.tsx b/frontend/components/prism/TickerHeader.tsx
new file mode 100644
index 0000000..23254f8
--- /dev/null
+++ b/frontend/components/prism/TickerHeader.tsx
@@ -0,0 +1,51 @@
+import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format";
+import { buildIdentityLine, rangePercent } from "@/lib/overview";
+import type { TickerOverview } from "@/types/api";
+
+type Props = {
+ overview: TickerOverview;
+ onToggleWatchlist: () => void;
+ isSaved: boolean;
+};
+
+export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) {
+ const pct = rangePercent(overview);
+
+ return (
+ <header className="psm-ticker-head">
+ <div className="psm-header-left">
+ <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span>
+ <div className="psm-heading-row">
+ <span className="psm-symbol">{overview.profile.symbol}</span>
+ <span className="psm-company-name">{overview.profile.name || "Name unavailable"}</span>
+ {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null}
+ </div>
+ <p className="psm-subline">{buildIdentityLine(overview)}</p>
+ </div>
+
+ <div className="psm-range">
+ <div className="psm-eyebrow">52 Week Range</div>
+ <div className="psm-range-values">
+ <span>{fmtCurrency(overview.range_52w.low)}</span>
+ <span>{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span>
+ <span>{fmtCurrency(overview.range_52w.high)}</span>
+ </div>
+ <div className="psm-range-rail" aria-hidden>
+ {pct != null ? <span className="psm-range-indicator" style={{ left: `${pct}%` }} /> : null}
+ </div>
+ </div>
+
+ <div className="psm-price-stack">
+ <span className="psm-price">{fmtCurrency(overview.quote.price)}</span>
+ <span className={`psm-change ${deltaClass(overview.quote.change_pct)}`}>
+ {fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)}
+ </span>
+ <span className="psm-quote-line">Prev close {fmtCurrency(overview.quote.prev_close)}</span>
+ <button type="button" className={`psm-primary-action${isSaved ? " subtle" : ""}`} onClick={onToggleWatchlist}>
+ {isSaved ? "Remove From Watchlist" : "Save To Watchlist"}
+ </button>
+ </div>
+ </header>
+ );
+}
+
diff --git a/frontend/components/prism/TopBar.tsx b/frontend/components/prism/TopBar.tsx
new file mode 100644
index 0000000..38e8447
--- /dev/null
+++ b/frontend/components/prism/TopBar.tsx
@@ -0,0 +1,61 @@
+import type { FormEvent } from "react";
+import type { SearchResult } from "@/types/api";
+
+type Props = {
+ query: string;
+ searching: boolean;
+ results: SearchResult[];
+ marketStatus: { isOpen: boolean; status: string; time: string };
+ onChangeQuery: (value: string) => void;
+ onSubmit: (event: FormEvent<HTMLFormElement>) => void;
+ onSelectTicker: (symbol: string) => void;
+};
+
+export function TopBar({ query, searching, results, marketStatus, onChangeQuery, onSubmit, onSelectTicker }: Props) {
+ const showDropdown = query.trim().length >= 2;
+
+ return (
+ <header className="psm-top">
+ <div className="psm-search-shell">
+ <form className="psm-search-form" onSubmit={onSubmit}>
+ <span className="psm-icon icon-search" aria-hidden />
+ <input value={query} onChange={(event) => onChangeQuery(event.target.value)} placeholder="Search ticker or company" aria-label="Search ticker" />
+ <span className="psm-kbd">Enter</span>
+ </form>
+
+ {showDropdown ? (
+ <div className="psm-search-dropdown">
+ {searching ? <div className="psm-search-status">Searching...</div> : null}
+ {!searching && results.length === 0 ? <div className="psm-search-status">No matches</div> : null}
+ {!searching
+ ? results.map((result) => (
+ <button key={`${result.symbol}-${result.exchange}`} type="button" className="psm-search-result" onClick={() => onSelectTicker(result.symbol)}>
+ <span className="psm-search-result-symbol">{result.symbol}</span>
+ <span className="psm-search-result-copy">
+ {result.name}
+ {result.exchange ? ` · ${result.exchange}` : ""}
+ </span>
+ </button>
+ ))
+ : null}
+ </div>
+ ) : null}
+ </div>
+
+ <div className="psm-clock-group">
+ <div className="psm-market-status">
+ <span className={`psm-market-dot${marketStatus.isOpen ? " open" : ""}`} aria-hidden />
+ <span>{marketStatus.status}</span>
+ <span>{marketStatus.time}</span>
+ </div>
+
+ <div className="psm-account">
+ <span className="psm-icon icon-user" aria-hidden />
+ <span className="psm-account-avatar">T</span>
+ <span>Local Profile</span>
+ </div>
+ </div>
+ </header>
+ );
+}
+