diff --git a/_first/basilic/DESIGN.md b/_first/basilic/DESIGN.md index ff4536d0..b5d16124 100644 --- a/_first/basilic/DESIGN.md +++ b/_first/basilic/DESIGN.md @@ -14,7 +14,8 @@ The interface follows a coherent design system: tokens, primitives, composition ## Artifacts -- **Fact:** No agent-readable `DESIGN.md` yet. Do not invent a second palette to complete the template. When added, use [DESIGN.md Format](https://raw.githubusercontent.com/google-labs-code/design.md/refs/heads/main/docs/spec.md) at `_first/DESIGN.md` (or one path listed in [../FIRST.md](../FIRST.md)). +- **Fact:** Demo shell brand is **Basilic** (sidebar). Markets home uses `@repo/ui` + `tokens.css` (`text-chart-2` / `text-destructive` for 24h change). No second palette. +- **Fact:** No Google-format `_first/DESIGN.md` yet. Do not invent a second palette to complete the template. When added, use [DESIGN.md Format](https://raw.githubusercontent.com/google-labs-code/design.md/refs/heads/main/docs/spec.md) at `_first/DESIGN.md` (or one path listed in [../FIRST.md](../FIRST.md)). - **Fact:** Tokens: [`../../packages/ui/src/styles/tokens.css`](../../packages/ui/src/styles/tokens.css) — semantic colors, sidebar, radius, `@theme inline`, Inter / Poppins / mono - **Fact:** Components: `@repo/ui` (shadcn/ui, Radix, Tailwind 4). ADR [004](../../apps/docu/content/docs/adrs/004-design-system.mdx). Frontend: [frontend.mdx](../../apps/docu/content/docs/architecture/frontend.mdx) - **Fact:** Apps consume `@repo/ui`; app-only UI collocated in `apps/web` / `apps/mobile` / `apps/docu` diff --git a/_first/basilic/JOURNEYS.md b/_first/basilic/JOURNEYS.md index f4dc84d7..a3c2f66e 100644 --- a/_first/basilic/JOURNEYS.md +++ b/_first/basilic/JOURNEYS.md @@ -25,7 +25,7 @@ Actors, entry points, happy paths, alternates, error paths, permission gates, an - **Fact:** Account: last-method guardrail (`LAST_SIGN_IN_METHOD`); change/link email; OAuth/wallet/passkey link; API keys `bask_` shown once. - **Fact:** CLI: API key only; JWT auth endpoints excluded ([cli.mdx](../../apps/docu/content/docs/development/cli.mdx)) - **Drift:** Web3 verify exists on Fastify; **web has no wallet UI**. Do not map a wallet-connect journey as shipped. -- **Fact:** Assistant demo job (web, in-shell chat): entry = composer or suggestion; happy = `getAccountInfo` output with `__render: 'user-info'` rendered; errors = stream/tool failure or stop; completion = `assistant_turn` with `outcome: 'completed'` **and** `accountRender: true`. A text-only successful reply is **not** this job. No GenUI CTA (`actions: {}`). +- **Fact:** Assistant demo jobs (web, in-shell chat): (1) account — `getAccountInfo` + `__render: 'user-info'`; (2) markets — `getMarketSnapshot` + `__render: 'market-card'`. Entry = composer or suggestion. Completion for (1) = `accountRender: true`. Completion for (2) = market card rendered. A text-only reply is **not** either job. No GenUI CTA (`actions: {}`). - **Unresolved:** named journey files beyond auth MDX; mobile completion ```mermaid @@ -55,13 +55,13 @@ stateDiagram-v2 Assistant job (demo, same shape): - actor: web end user (signed in) -- job: retrieve account context in-conversation -- entry: `/` assistant chrome; suggestions “Who am I?” / “What can you help with?” -- happy path: user sends a turn → `getAccountInfo` → `__render: 'user-info'` card -- alternates: text-only assistant reply (turn completed, account job not) +- job: retrieve account context or a market snapshot in-conversation +- entry: `/` assistant chrome; suggestions “What moved?” / “Explain BTC” / “Who am I?” +- happy path: user sends a turn → `getAccountInfo` → `__render: 'user-info'` **or** `getMarketSnapshot` → `__render: 'market-card'` +- alternates: text-only assistant reply (turn completed, demo job not) - errors: stream error; user stop - gates: proxy + Bearer JWT -- completion: `user-info` rendered. Not “the model replied.” +- completion: `user-info` **or** `market-card` rendered. Not “the model replied.” ## Recipe diff --git a/_first/basilic/PRODUCT.md b/_first/basilic/PRODUCT.md index 65a7d563..e147dd72 100644 --- a/_first/basilic/PRODUCT.md +++ b/_first/basilic/PRODUCT.md @@ -17,7 +17,7 @@ The project has an inspectable answer to what, why, and how we will know. Non-go - **Fact:** Canonical product brief: [product/index.mdx](../../apps/docu/content/docs/product/index.mdx), [product/features.mdx](../../apps/docu/content/docs/product/features.mdx), [product/roadmap.mdx](../../apps/docu/content/docs/product/roadmap.mdx) - **Fact:** [`../../README.md`](../../README.md) — fork-and-run TypeScript fullstack starter (Fastify, OpenAPI, Next, Expo scaffold). No Wagmi, no first-class OpenAI SDK, no web wallet UI - **Fact:** [`../../apps/docu/content/docs/index.mdx`](../../apps/docu/content/docs/index.mdx) — toolkit intro; Product is in the docs nav -- **Fact:** Two audiences: **adopters** (developers using the starter) and **demo users** (web news `/`, markets, settings, in-shell assistant; auth is the shipped job) +- **Fact:** Two audiences: **adopters** (developers using the starter) and **demo users** (web markets `/`, headlines strip, settings, in-shell assistant; auth is the shipped job) - **Fact:** GTM: clone + [Getting Started](../../apps/docu/content/docs/development/index.mdx) (`db:start`, `pnpm reset`) + first local login. Bar: [Product Ready](../../apps/docu/content/docs/testing/product-ready.mdx). Finance **N/A** (toolkit) - **Fact:** Owner: Gabo Esquivel (named on product index) - **Fact:** New-device sign-in alerts are transactional email via Fastify `emailProvider` + `@repo/email`, not a notification product @@ -25,7 +25,7 @@ The project has an inspectable answer to what, why, and how we will know. Non-go - **Fact:** Non-goals: mobile not an API client; no web wallet UI; Cache Components off; `@repo/react` hooks handwritten; PostHog not installed; Sentry inactive - **Fact:** PostHog **chosen, not installed**. Product events are **specified** in types + [analytics.mdx](../../apps/docu/content/docs/architecture/analytics.mdx) and **instrumented** via `apps/web/lib/analytics.ts` `capture()`. They are **not collected** and therefore **not measured** - **Fact:** Two demo questions are specified: (1) auth — `auth_succeeded` / `auth_failed` by `method`; (2) assistant — `__render: 'user-info'` (`assistant_turn` with `accountRender`). Unmeasured -- **Fact:** PD (Markets + GenAI artifacts) is named on the feature map as **intended**, not shipped +- **Fact:** PD (Markets + GenAI artifacts) is shipped on the feature map: CoinGecko or mock, `getMarketSnapshot` / `market-card` - **Unresolved:** PostHog install / consent / retention; keep / iterate / kill board; whether adopters copy `lib/analytics` `pnpm qa` going green is Pipelines, not product success. Quality for R0 is [Product Ready](../../apps/docu/content/docs/testing/product-ready.mdx). @@ -67,7 +67,7 @@ Product intent is documented or explicitly deferred with named owners. Implement Apply Product First to Basilic. -Read root README, `apps/docu/content/docs/product/`, analytics MDX and ADR 011, and what the web, mobile, and API actually do. PostHog is not installed. `capture()` is a no-op — do not claim events are collected or measured. PD is named on the feature map, not shipped. +Read root README, `apps/docu/content/docs/product/`, analytics MDX and ADR 011, and what the web, mobile, and API actually do. PostHog is not installed. `capture()` is a no-op — do not claim events are collected or measured. PD is shipped on the feature map. Preserve intentional existing product choices. Do not silently decide scope, priorities, pricing, TAM, LTV, or event names. This is a toolkit — say so rather than filling finance blanks. diff --git a/apps/api/src/lib/ai/tools/account-info.ts b/apps/api/src/lib/ai/tools/account-info.ts index 584fa030..51869c17 100644 --- a/apps/api/src/lib/ai/tools/account-info.ts +++ b/apps/api/src/lib/ai/tools/account-info.ts @@ -5,6 +5,7 @@ import { getDb } from '../../../db/index.js' import { users } from '../../../db/schema/index.js' import { env } from '../../env.js' import { createBraveSearchTool } from './brave-search.js' +import { createMarketSnapshotTool } from './market-snapshot.js' const userInfoSpecRoot = 'user-info-1' @@ -64,6 +65,7 @@ function createAccountInfoTool(userId: string) { export function getMergedTools(userId: string, log: import('fastify').FastifyBaseLogger): ToolSet { return { getAccountInfo: createAccountInfoTool(userId), + getMarketSnapshot: createMarketSnapshotTool(), ...(env.BRAVE_SEARCH_API_KEY && { braveSearch: createBraveSearchTool(env.BRAVE_SEARCH_API_KEY, log), }), diff --git a/apps/api/src/lib/ai/tools/market-snapshot.spec.ts b/apps/api/src/lib/ai/tools/market-snapshot.spec.ts new file mode 100644 index 00000000..ea0ff502 --- /dev/null +++ b/apps/api/src/lib/ai/tools/market-snapshot.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + buildMarketCardSpec, + marketSnapshotMock, + pickMovers, + toMarketRows, +} from './market-snapshot.js' + +describe('market snapshot helpers', () => { + it('parses CoinGecko-shaped rows and ignores junk', () => { + const rows = toMarketRows([ + { + id: 'bitcoin', + symbol: 'btc', + name: 'Bitcoin', + current_price: 1, + price_change_percentage_24h: 2, + market_cap_rank: 1, + }, + { id: 'bad' }, + ]) + expect(rows).toHaveLength(1) + expect(rows[0]?.id).toBe('bitcoin') + expect(rows[0]?.currentPrice).toBe(1) + }) + + it('picks largest absolute 24h moves', () => { + const movers = pickMovers(marketSnapshotMock) + expect(movers.length).toBeGreaterThan(0) + expect(movers.length).toBeLessThanOrEqual(5) + }) + + it('builds market-card spec', () => { + const spec = buildMarketCardSpec({ movers: marketSnapshotMock.slice(0, 2), source: 'mock' }) + expect(spec.root).toBe('market-card-1') + expect(spec.elements['market-card-1']?.type).toBe('MarketCard') + expect(spec.elements['market-card-1']?.props.source).toBe('mock') + }) +}) diff --git a/apps/api/src/lib/ai/tools/market-snapshot.ts b/apps/api/src/lib/ai/tools/market-snapshot.ts new file mode 100644 index 00000000..a72bf43d --- /dev/null +++ b/apps/api/src/lib/ai/tools/market-snapshot.ts @@ -0,0 +1,166 @@ +import { tool } from 'ai' +import { z } from 'zod' + +export const coingeckoMarketsUrl = + 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false&price_change_percentage=24h' + +export type MarketRow = { + id: string + symbol: string + name: string + currentPrice: number + change24h: number + marketCapRank: number +} + +export const marketSnapshotMock: MarketRow[] = [ + { + id: 'bitcoin', + symbol: 'btc', + name: 'Bitcoin', + currentPrice: 67_420.12, + change24h: 2.14, + marketCapRank: 1, + }, + { + id: 'ethereum', + symbol: 'eth', + name: 'Ethereum', + currentPrice: 3_412.5, + change24h: -1.08, + marketCapRank: 2, + }, + { + id: 'solana', + symbol: 'sol', + name: 'Solana', + currentPrice: 178.4, + change24h: 4.62, + marketCapRank: 5, + }, + { + id: 'ripple', + symbol: 'xrp', + name: 'XRP', + currentPrice: 0.62, + change24h: 0.41, + marketCapRank: 4, + }, + { + id: 'cardano', + symbol: 'ada', + name: 'Cardano', + currentPrice: 0.45, + change24h: -2.3, + marketCapRank: 9, + }, + { + id: 'dogecoin', + symbol: 'doge', + name: 'Dogecoin', + currentPrice: 0.12, + change24h: 6.11, + marketCapRank: 8, + }, +] + +const marketCardRoot = 'market-card-1' + +export function toMarketRows(raw: unknown): MarketRow[] { + if (!Array.isArray(raw)) return [] + return raw.flatMap(item => { + if (!item || typeof item !== 'object') return [] + const row = item as Record + const id = typeof row.id === 'string' ? row.id : '' + const symbol = typeof row.symbol === 'string' ? row.symbol : '' + const name = typeof row.name === 'string' ? row.name : '' + const currentPrice = typeof row.current_price === 'number' ? row.current_price : Number.NaN + const change24h = + typeof row.price_change_percentage_24h === 'number' ? row.price_change_percentage_24h : 0 + const marketCapRank = typeof row.market_cap_rank === 'number' ? row.market_cap_rank : 0 + if (!id || !symbol || !name || Number.isNaN(currentPrice)) return [] + return [{ id, symbol, name, currentPrice, change24h, marketCapRank }] + }) +} + +export function pickMovers(rows: MarketRow[], query?: string) { + const q = query?.trim().toLowerCase() + const filtered = q + ? rows.filter( + r => + r.symbol.toLowerCase() === q || + r.id.toLowerCase() === q || + r.name.toLowerCase().includes(q), + ) + : rows + const source = filtered.length > 0 ? filtered : rows + const sorted = [...source].toSorted((a, b) => Math.abs(b.change24h) - Math.abs(a.change24h)) + return sorted.slice(0, 5) +} + +export function buildMarketCardSpec({ + movers, + source, +}: { + movers: MarketRow[] + source: 'live' | 'mock' +}) { + const top = movers[0] + const headline = top + ? `${top.name} ${top.change24h >= 0 ? 'led' : 'lagged'} the board` + : 'Market snapshot' + return { + root: marketCardRoot, + elements: { + [marketCardRoot]: { + type: 'MarketCard', + props: { + headline, + source, + movers: movers.map(m => ({ + symbol: m.symbol.toUpperCase(), + name: m.name, + price: m.currentPrice, + change24h: m.change24h, + })), + }, + children: [], + }, + }, + } as const +} + +export async function loadMarketRows(): Promise<{ rows: MarketRow[]; source: 'live' | 'mock' }> { + try { + const res = await fetch(coingeckoMarketsUrl, { signal: AbortSignal.timeout(8_000) }) + if (!res.ok) return { rows: marketSnapshotMock, source: 'mock' } + const rows = toMarketRows(await res.json()) + if (rows.length === 0) return { rows: marketSnapshotMock, source: 'mock' } + return { rows, source: 'live' } + } catch { + return { rows: marketSnapshotMock, source: 'mock' } + } +} + +export function createMarketSnapshotTool() { + return tool({ + description: + 'Returns a crypto market snapshot (prices and 24h change). Use when the user asks what moved, BTC, ETH, top coins, or a market overview. Optional query filters by symbol or name.', + inputSchema: z.object({ + query: z.string().max(64).optional(), + }), + execute: async ({ query }: { query?: string }) => { + const { rows, source } = await loadMarketRows() + const movers = pickMovers(rows, query) + const spec = buildMarketCardSpec({ movers, source }) + const summary = movers + .map(m => `${m.symbol.toUpperCase()} ${m.change24h.toFixed(2)}%`) + .join('; ') + return { + __render: 'market-card', + spec, + summary: summary.length > 0 ? summary : 'No market rows.', + } + }, + }) +} diff --git a/apps/api/src/routes/ai/chat.test.ts b/apps/api/src/routes/ai/chat.test.ts index 4e4c73a7..522edbe0 100644 --- a/apps/api/src/routes/ai/chat.test.ts +++ b/apps/api/src/routes/ai/chat.test.ts @@ -423,6 +423,24 @@ describe('POST /ai/chat', () => { expect(data.text.toLowerCase()).toMatch(/test@test\.ai|joined|email/) }, 60000) + it('should return 200 when user asks what moved (getMarketSnapshot tool)', async ctx => { + const response = await fastify.inject({ + method: 'POST', + url: '/ai/chat', + headers: { Authorization: `Bearer ${testToken}` }, + payload: { + messages: [{ role: 'user', content: 'What moved? Use getMarketSnapshot.' }], + }, + }) + skipIfInsufficientCredits(ctx, response, 'what moved tool') + skipIfProviderUnavailable(ctx, response, 'what moved tool') + expect(response.statusCode).toBe(200) + const data = JSON.parse(response.body) + expect(() => ChatResponseSchema.parse(data)).not.toThrow() + expect(data.text).toBeTypeOf('string') + expect(data.text.length).toBeGreaterThan(0) + }, 60000) + it('should return 400 for overlong message content', async () => { const response = await fastify.inject({ method: 'POST', diff --git a/apps/docu/content/docs/architecture/ai.mdx b/apps/docu/content/docs/architecture/ai.mdx index 626c6eba..f6269882 100644 --- a/apps/docu/content/docs/architecture/ai.mdx +++ b/apps/docu/content/docs/architecture/ai.mdx @@ -30,10 +30,11 @@ apps/api/src/lib/ai/ download.ts denyRemoteChatFileDownload (blocks non-data: fetches) runtime.ts abort, SSE piping, upstream error mapping upstream-error.ts isInsufficientCreditsError - tools/ - index.ts getMergedTools - account-info.ts getAccountInfo tool - brave-search.ts braveSearch tool (when BRAVE_SEARCH_API_KEY set) + tools/ + index.ts getMergedTools + account-info.ts getAccountInfo tool + market-snapshot.ts getMarketSnapshot tool + brave-search.ts braveSearch tool (when BRAVE_SEARCH_API_KEY set) ``` Chat uses `streamText` / `generateText` with `stopWhen: isStepCount(AI_TOOL_MAX_STEPS)`. Generate is prompt-only (no tools). Shared runtime handles client abort + `AI_UPSTREAM_TIMEOUT_MS`, v7 UI streams (`toUIMessageStream` + `createUIMessageStreamResponse`), and catalog errors via `captureError` then `sendCatalogError`. @@ -61,13 +62,23 @@ Templates: `apps/api/.env.defaults.example`, `apps/api/.env.test.example`. The web app uses `useChatFromConfig` (`@repo/react`) → `POST {baseUrl}/ai/chat` with Bearer auth and `DefaultChatTransport` (UI-message SSE). -**Tool:** `getAccountInfo` — output shape: +**Tools:** `getAccountInfo` and `getMarketSnapshot`. + +`getAccountInfo` output: ```json { "__render": "user-info", "spec": { "root": "...", "elements": { ... } }, "summary": "..." } ``` -`assistant-chat.tsx` matches `tool-getAccountInfo` parts and renders via `user-info-catalog.tsx`. Do not rename the tool or change the output shape without updating the web UI. `__render: 'user-info'` is the product name for the demo account-context job. Product analytics records `assistant_turn` with `accountRender` when that surface is present — not merely when the assistant replies. See [Product analytics](/docs/architecture/analytics). +`getMarketSnapshot` output: + +```json +{ "__render": "market-card", "spec": { "root": "...", "elements": { ... } }, "summary": "..." } +``` + +`assistant-chat.tsx` matches `tool-getAccountInfo` → `user-info-catalog.tsx` and `tool-getMarketSnapshot` → `market-card-catalog.tsx`. Do not rename a tool or change the output shape without updating the web UI. `__render: 'user-info'` is the account-context demo job; `__render: 'market-card'` is the markets demo job. Product analytics records `assistant_turn` with `accountRender` when the account surface is present — not merely when the assistant replies. See [Product analytics](/docs/architecture/analytics). + +`getMarketSnapshot` reads the same public CoinGecko markets URL as the web board. On 429 or network failure it returns a checked-in mock so fork-and-run does not need a CoinGecko key. Composer send (textarea + suggestions) is disabled while `status !== 'ready'`; stop remains available during `streaming`. diff --git a/apps/docu/content/docs/architecture/frontend.mdx b/apps/docu/content/docs/architecture/frontend.mdx index 7eb58cf1..5f9b3e84 100644 --- a/apps/docu/content/docs/architecture/frontend.mdx +++ b/apps/docu/content/docs/architecture/frontend.mdx @@ -60,7 +60,7 @@ Stream with `loading.tsx` and Suspense. Mark search/filter updates as transition - Do not export `dynamic = 'force-dynamic'` from the root layout. That sets `fetchCache: 'force-no-store'` for the route and voids fetch-level `next.revalidate`. - [`proxy.ts`](/docs/architecture/authentication) gates requests. Cookie reads there do **not** make RSC segments dynamic. A page becomes dynamic when a Server Component calls `cookies()` (for example `getUserInfo` → `getServerAuthToken`). -- Semi-static RSC data (news, markets): `fetch` with `next: { revalidate: N }`. +- Semi-static RSC data (markets, headlines): `fetch` with `next: { revalidate: N }`. - Auth / Fastify: `cache: 'no-store'`. Identical GET `fetch` calls are request-memoized; do not wrap them in `React.cache()`. - No `revalidateTag` until something is tagged. Mutations stay on Fastify, not Server Actions. @@ -85,7 +85,7 @@ Pending and optimistic UI for mutations stays on TanStack mutation flags (`isPen ### Server vs client fetch -- **RSC:** semi-static pages (news, markets) and profile (`getUserInfo` in the page, `initialData` on `useUser`). +- **RSC:** semi-static pages (markets, headlines) and profile (`getUserInfo` in the page, `initialData` on `useUser`). - **Client TanStack:** settings/security (API keys, passkeys, TOTP). These surfaces are mutation-first and need WebAuthn or OTP browser APIs. Do not duplicate their GETs in Server Components; an empty first paint until hydration is accepted. ```tsx diff --git a/apps/docu/content/docs/product/features.mdx b/apps/docu/content/docs/product/features.mdx index cd841d62..6c7eec4f 100644 --- a/apps/docu/content/docs/product/features.mdx +++ b/apps/docu/content/docs/product/features.mdx @@ -19,11 +19,11 @@ Must work after clone → `pnpm setup` → `db:start` → `pnpm reset` → `pnpm - `@repo/email` for auth mail; CLI with API key only - Pino `reqId`; `GET /health` `{ ok, dbReady }` -## Demo chrome (thin on purpose) +## Demo chrome -- News home (`/`) — NewsAPI or explicit empty state when the key is unset -- Markets (`/markets`) — public CoinGecko table, not on-chain -- In-shell assistant — `getAccountInfo` / `__render: 'user-info'` (needs an AI provider key; Ollama is the free local path) +- Markets home (`/`) — CoinGecko public prices or a checked-in sample board (no CoinGecko key) +- Headlines strip — NewsAPI or a one-line empty state +- In-shell assistant — `getAccountInfo` / `__render: 'user-info'` and `getMarketSnapshot` / `__render: 'market-card'` (Ollama is the free local path) ## Shipped in API or packages, not in web UX @@ -42,11 +42,9 @@ Must work after clone → `pnpm setup` → `db:start` → `pnpm reset` → `pnpm Wallet UI / Wagmi / Solana adapter in web · mobile as API client · PostHog install · Sentry on · Cache Components on · billed SaaS · first-class OpenAI SDK · GCP/AWS as first-class deploy · FIRST CLI / `first.json` / FIRST as a Cursor skill. -## PD (intended — not shipped) +## PD (shipped) -Reshape the signed-in demo around **Markets + GenAI artifacts**: CoinGecko or a checked-in mock (no CoinGecko key required), one new chat tool and one json-render catalog, keep `getAccountInfo`. No wallet UI. No Fastify markets CRUD. No extra CI workflow. News stays thin or becomes supporting copy. - -Until PD ships, do not describe Basilic as a markets product or a wallet demo. +Signed-in demo is **Markets + GenAI artifacts**: CoinGecko or mock, `getMarketSnapshot` + market-card catalog, `getAccountInfo` kept. No wallet UI. No Fastify markets CRUD. No extra CI workflow. News is a supporting headlines strip. ## Related diff --git a/apps/docu/content/docs/product/index.mdx b/apps/docu/content/docs/product/index.mdx index 5d861eae..5d3b61d5 100644 --- a/apps/docu/content/docs/product/index.mdx +++ b/apps/docu/content/docs/product/index.mdx @@ -11,7 +11,7 @@ Owner until this page says otherwise: **Gabo Esquivel**. **Adopters** clone the repo, run the stack locally, and copy patterns. First successful use is [Product Ready](/docs/testing/product-ready): clone → [Getting Started](/docs/development) (`db:start`, `pnpm reset`, `pnpm dev`) → magic-link (or `ALLOW_TEST` + `test@test.ai`) to `/`. -**Demo users** sign in to the web app. The shipped job is auth (sessions, API keys, settings). News, Markets, and the in-shell assistant are demo chrome. The intended reshape of that chrome is named on [Features](/docs/product/features) (PD) — not shipped yet. +**Demo users** sign in to the web app. The shipped job is auth (sessions, API keys, settings). Markets, a headlines strip, and the in-shell assistant are demo chrome. See [Features](/docs/product/features). ## Goal diff --git a/apps/web/app/(dashboard)/(news)/news-list.tsx b/apps/web/app/(dashboard)/(news)/news-list.tsx index cb1ec133..8bc60243 100644 --- a/apps/web/app/(dashboard)/(news)/news-list.tsx +++ b/apps/web/app/(dashboard)/(news)/news-list.tsx @@ -17,10 +17,12 @@ type NewsListProps = { articles?: NewsListArticle[] error?: string fallback?: ReactNode + compact?: boolean } -export function NewsList({ articles, error, fallback }: NewsListProps) { - if (fallback) return
{fallback}
+export function NewsList({ articles, error, fallback, compact }: NewsListProps) { + if (fallback) + return
{fallback}
if (error) return (
@@ -29,11 +31,29 @@ export function NewsList({ articles, error, fallback }: NewsListProps) { ) if (!articles?.length) return ( -
+

No headlines available.

) + if (compact) + return ( +
    + {articles.map((a, i) => ( +
  • + {a.url ? ( + + {a.title ?? 'Untitled'} + + ) : ( + {a.title ?? 'Untitled'} + )} + {a.source?.name ?? ''} +
  • + ))} +
+ ) + return (
{articles.map((a, i) => ( diff --git a/apps/web/app/(dashboard)/(news)/page.tsx b/apps/web/app/(dashboard)/(news)/page.tsx index 5506ba65..72f6ed26 100644 --- a/apps/web/app/(dashboard)/(news)/page.tsx +++ b/apps/web/app/(dashboard)/(news)/page.tsx @@ -1,5 +1,7 @@ import { getErrorMessage } from '@repo/error' import { env } from '@/lib/env' +import { fetchMarkets } from '../markets/fetch-markets' +import { MarketsTable } from '../markets/markets-table' import { NewsList, type NewsListArticle } from './news-list' const newsQuery = @@ -11,30 +13,49 @@ async function fetchHeadlines() { try { const res = await fetch( - `https://newsapi.org/v2/everything?q=${encodeURIComponent(newsQuery)}&pageSize=20&sortBy=publishedAt&language=en`, + `https://newsapi.org/v2/everything?q=${encodeURIComponent(newsQuery)}&pageSize=5&sortBy=publishedAt&language=en`, { headers: { 'X-Api-Key': key }, next: { revalidate: 300 } }, ) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = (await res.json()) as { status?: string; articles?: NewsListArticle[] } if (data.status !== 'ok') throw new Error('NewsAPI returned an error') - return { articles: data.articles ?? [], error: null, hasKey: true } + return { articles: (data.articles ?? []).slice(0, 3), error: null, hasKey: true } } catch (error) { return { articles: null, error: getErrorMessage(error), hasKey: true } } } export default async function Home() { - const { articles, error, hasKey } = await fetchHeadlines() - - const fallback = !hasKey ? ( -

- Add NEWSAPI_KEY to .env.local to load headlines. -

- ) : undefined + const [markets, headlines] = await Promise.all([fetchMarkets(), fetchHeadlines()]) return ( -
- +
+
+

+ Public prices (CoinGecko or sample). Ask the assistant what moved. +

+ {markets.source === 'mock' ? ( +

+ Showing a sample board. CoinGecko was unavailable or rate-limited. +

+ ) : null} + +
+
+

Headlines

+ + Headlines need NEWSAPI_KEY in .env.local. +

+ ) + } + /> +
) } diff --git a/apps/web/app/(dashboard)/markets/fetch-markets.ts b/apps/web/app/(dashboard)/markets/fetch-markets.ts new file mode 100644 index 00000000..c6976cb9 --- /dev/null +++ b/apps/web/app/(dashboard)/markets/fetch-markets.ts @@ -0,0 +1,18 @@ +import { getErrorMessage } from '@repo/error' +import type { CoinMarket } from './markets-table' +import { marketsMock } from './mock-snapshot' + +const coingeckoUrl = + 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false&price_change_percentage=24h' + +export async function fetchMarkets() { + try { + const res = await fetch(coingeckoUrl, { next: { revalidate: 60 } }) + if (!res.ok) return { coins: marketsMock, source: 'mock' as const, error: null } + const data = (await res.json()) as CoinMarket[] + if (!data?.length) return { coins: marketsMock, source: 'mock' as const, error: null } + return { coins: data, source: 'live' as const, error: null } + } catch (error) { + return { coins: marketsMock, source: 'mock' as const, error: getErrorMessage(error) } + } +} diff --git a/apps/web/app/(dashboard)/markets/markets-table.tsx b/apps/web/app/(dashboard)/markets/markets-table.tsx index d05eea4b..d948d646 100644 --- a/apps/web/app/(dashboard)/markets/markets-table.tsx +++ b/apps/web/app/(dashboard)/markets/markets-table.tsx @@ -54,8 +54,8 @@ function Change24hBadge({ value }: { value: number | null | undefined }) { isNeutral ? 'bg-muted/50 text-muted-foreground' : isPositive - ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400' - : 'bg-red-500/12 text-red-700 dark:bg-red-500/15 dark:text-red-400', + ? 'bg-chart-2/15 text-chart-2' + : 'bg-destructive/12 text-destructive', )} > {formatChange24h(value)} @@ -182,8 +182,8 @@ export function MarketsTable({ coins, error }: MarketsTableProps) { c.price_change_percentage_24h == null ? 'text-muted-foreground' : c.price_change_percentage_24h >= 0 - ? 'text-green-600' - : 'text-red-600', + ? 'text-chart-2' + : 'text-destructive', )} > {formatChange24h(c.price_change_percentage_24h)} diff --git a/apps/web/app/(dashboard)/markets/mock-snapshot.ts b/apps/web/app/(dashboard)/markets/mock-snapshot.ts new file mode 100644 index 00000000..c29411d6 --- /dev/null +++ b/apps/web/app/(dashboard)/markets/mock-snapshot.ts @@ -0,0 +1,54 @@ +export type CoinMarket = { + id?: string + symbol?: string + name?: string + image?: string + current_price?: number + market_cap?: number + market_cap_rank?: number + price_change_percentage_24h?: number | null + total_volume?: number +} + +export const marketsMock: CoinMarket[] = [ + { + id: 'bitcoin', + symbol: 'btc', + name: 'Bitcoin', + current_price: 67_420.12, + price_change_percentage_24h: 2.14, + market_cap_rank: 1, + market_cap: 1_320_000_000_000, + total_volume: 28_000_000_000, + }, + { + id: 'ethereum', + symbol: 'eth', + name: 'Ethereum', + current_price: 3_412.5, + price_change_percentage_24h: -1.08, + market_cap_rank: 2, + market_cap: 410_000_000_000, + total_volume: 14_000_000_000, + }, + { + id: 'solana', + symbol: 'sol', + name: 'Solana', + current_price: 178.4, + price_change_percentage_24h: 4.62, + market_cap_rank: 5, + market_cap: 82_000_000_000, + total_volume: 3_200_000_000, + }, + { + id: 'ripple', + symbol: 'xrp', + name: 'XRP', + current_price: 0.62, + price_change_percentage_24h: 0.41, + market_cap_rank: 4, + market_cap: 35_000_000_000, + total_volume: 1_100_000_000, + }, +] diff --git a/apps/web/app/(dashboard)/markets/page.tsx b/apps/web/app/(dashboard)/markets/page.tsx index e219bcd0..4e57d72f 100644 --- a/apps/web/app/(dashboard)/markets/page.tsx +++ b/apps/web/app/(dashboard)/markets/page.tsx @@ -1,27 +1,17 @@ -import { getErrorMessage } from '@repo/error' -import type { CoinMarket } from './markets-table' +import { fetchMarkets } from './fetch-markets' import { MarketsTable } from './markets-table' -const coingeckoUrl = - 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false&price_change_percentage=24h' - -async function fetchMarkets() { - try { - const res = await fetch(coingeckoUrl, { next: { revalidate: 60 } }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = (await res.json()) as CoinMarket[] - return { coins: data ?? [], error: null } - } catch (error) { - return { coins: null, error: getErrorMessage(error) } - } -} - export default async function MarketsPage() { - const { coins, error } = await fetchMarkets() + const { coins, source } = await fetchMarkets() return ( -
- +
+ {source === 'mock' ? ( +

+ Showing a sample board. CoinGecko was unavailable or rate-limited. +

+ ) : null} +
) } diff --git a/apps/web/app/(dashboard)/page-title.tsx b/apps/web/app/(dashboard)/page-title.tsx index 2cd1120e..8f0303d7 100644 --- a/apps/web/app/(dashboard)/page-title.tsx +++ b/apps/web/app/(dashboard)/page-title.tsx @@ -3,7 +3,7 @@ import { usePathname } from 'next/navigation' export const pageTitles: Record = { - '/': 'Latest News', + '/': 'Markets', '/markets': 'Markets', '/settings': 'Profile', '/settings/security': 'Passkeys', diff --git a/apps/web/app/(dashboard)/sidebar.tsx b/apps/web/app/(dashboard)/sidebar.tsx index daab8167..1d835fbd 100644 --- a/apps/web/app/(dashboard)/sidebar.tsx +++ b/apps/web/app/(dashboard)/sidebar.tsx @@ -12,21 +12,11 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@repo/ui/components/sidebar' -import { - BarChart3Icon, - GalleryVerticalEnd, - LogOut, - NewspaperIcon, - ShieldIcon, - UserIcon, -} from 'lucide-react' +import { BarChart3Icon, GalleryVerticalEnd, LogOut, ShieldIcon, UserIcon } from 'lucide-react' import Link from 'next/link' import { usePathname } from 'next/navigation' -const navItems = [ - { href: '/', label: 'News', icon: NewspaperIcon }, - { href: '/markets', label: 'Markets', icon: BarChart3Icon }, -] as const +const navItems = [{ href: '/', label: 'Markets', icon: BarChart3Icon }] as const const settingsItems: Array<{ href: string @@ -53,7 +43,7 @@ export function DashboardSidebar() {
- Acme Inc. + Basilic
diff --git a/apps/web/app/auth/callback/magiclink/route.ts b/apps/web/app/auth/callback/magiclink/route.ts index 813801e9..90caf6fc 100644 --- a/apps/web/app/auth/callback/magiclink/route.ts +++ b/apps/web/app/auth/callback/magiclink/route.ts @@ -25,7 +25,7 @@ function renderCodeEntryForm(verificationId: string, callbackURL: string): NextR const html = ` -Enter code - Acme +Enter code - Basilic