From 423d912afbe1798aeec34507db5f080bd0290924 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 16:01:59 +0000 Subject: [PATCH 01/15] Add AI stock rankings page with subscription gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce /ai-rankings with short, medium, and long horizon tabs. Each ranked stock includes AI rationale validated against live market data. Reuse existing AI tier checks, SubscriptionGate, and local Ollama patterns. Co-authored-by: ✅ Daniel Danielecki --- app/(main)/ai-rankings/layout.tsx | 17 ++ app/(main)/ai-rankings/page.tsx | 130 +++++++++++++ app/(main)/stock-of-the-day/page.tsx | 8 + app/api/market/ai-rankings/route.ts | 169 ++++++++++++++++ components/AIStockRankingsPanel.tsx | 256 +++++++++++++++++++++++++ components/HomeHub.tsx | 25 ++- lib/ai-stock-rankings.ts | 94 +++++++++ lib/local-ollama-ai-rankings.ts | 84 ++++++++ lib/market-ui-copy.ts | 3 + lib/site-seo.ts | 5 + services/ai-market-insights.service.ts | 225 ++++++++++++++++++++++ types/index.ts | 17 ++ 12 files changed, 1027 insertions(+), 6 deletions(-) create mode 100644 app/(main)/ai-rankings/layout.tsx create mode 100644 app/(main)/ai-rankings/page.tsx create mode 100644 app/api/market/ai-rankings/route.ts create mode 100644 components/AIStockRankingsPanel.tsx create mode 100644 lib/ai-stock-rankings.ts create mode 100644 lib/local-ollama-ai-rankings.ts diff --git a/app/(main)/ai-rankings/layout.tsx b/app/(main)/ai-rankings/layout.tsx new file mode 100644 index 0000000..a40bf13 --- /dev/null +++ b/app/(main)/ai-rankings/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { buildPageMetadata } from "@/lib/site-seo"; + +export const metadata: Metadata = buildPageMetadata({ + title: "AI Stock Rankings", + description: + "AI-ranked lists of the most promising stocks for short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.", + path: "/ai-rankings", +}); + +export default function AIStockRankingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/(main)/ai-rankings/page.tsx b/app/(main)/ai-rankings/page.tsx new file mode 100644 index 0000000..3f319ee --- /dev/null +++ b/app/(main)/ai-rankings/page.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePricingTier } from "@/lib/use-pricing-tier"; +import { EXPLANATIONS_PROVIDER_CHANGED_EVENT } from "@/lib/explanation-provider"; +import { fetchAIStockRankingsForCurrentProvider } from "@/lib/local-ollama-ai-rankings"; +import { MARKET_UI_COPY } from "@/lib/market-ui-copy"; +import { + DNA_BODY_SECONDARY, + DNA_CAPTION, + DNA_DISPLAY, + DNA_PAGE_STACK, +} from "@/lib/design-dna"; +import type { AIRankingTimeframe } from "@/lib/ai-stock-rankings"; +import type { AIStockRankingsResult } from "@/types"; +import { AIStockRankingsPanel } from "@/components/AIStockRankingsPanel"; + +export default function AIStockRankingsPage() { + const pricingTier = usePricingTier(); + const [serverBYOKAccess, setServerBYOKAccess] = useState( + null + ); + const hasTierAccess = + pricingTier === "LOCAL" || + pricingTier === "BYOK" || + pricingTier === "HOSTED_AI"; + const hasAIAccess = hasTierAccess || serverBYOKAccess === true; + + const [timeframe, setTimeframe] = useState("short"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [aiProviderVersion, setAiProviderVersion] = useState(0); + + useEffect(() => { + const onProviderChanged = () => setAiProviderVersion((v) => v + 1); + window.addEventListener( + EXPLANATIONS_PROVIDER_CHANGED_EVENT, + onProviderChanged + ); + return () => + window.removeEventListener( + EXPLANATIONS_PROVIDER_CHANGED_EVENT, + onProviderChanged + ); + }, []); + + useEffect(() => { + const load = async () => { + if (!hasAIAccess) { + setData(null); + setLoadError(null); + return; + } + + setLoading(true); + try { + const result = await fetchAIStockRankingsForCurrentProvider( + timeframe, + pricingTier + ); + setData(result); + setLoadError(null); + } catch (err) { + setData(null); + setLoadError( + err instanceof Error ? err.message : MARKET_UI_COPY.load.aiStockRankings + ); + } finally { + setLoading(false); + } + }; + + load(); + }, [hasAIAccess, aiProviderVersion, pricingTier, timeframe]); + + useEffect(() => { + const loadBYOKAccess = async () => { + try { + const response = await fetch("/api/ai/keys", { + method: "GET", + credentials: "include", + cache: "no-store", + }); + setServerBYOKAccess(response.ok); + } catch { + setServerBYOKAccess(false); + } + }; + void loadBYOKAccess(); + const onAuthChanged = () => void loadBYOKAccess(); + if (typeof window !== "undefined") { + window.addEventListener("auth-state-changed", onAuthChanged); + } + return () => { + if (typeof window !== "undefined") { + window.removeEventListener("auth-state-changed", onAuthChanged); + } + }; + }, []); + + return ( +
+
+

AI stock rankings

+

+ AI-ranked lists of the most promising stocks across short, medium, and + long horizons — each with a clear rationale. +

+

+ Related:{" "} + + Stock of the day + +

+
+ + +
+ ); +} diff --git a/app/(main)/stock-of-the-day/page.tsx b/app/(main)/stock-of-the-day/page.tsx index 0182cc7..dcbe482 100644 --- a/app/(main)/stock-of-the-day/page.tsx +++ b/app/(main)/stock-of-the-day/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import { usePricingTier } from "@/lib/use-pricing-tier"; import { EXPLANATIONS_PROVIDER_CHANGED_EVENT } from "@/lib/explanation-provider"; @@ -7,6 +8,7 @@ import { fetchStockOfTheDayForCurrentProvider } from "@/lib/local-ollama-stock-o import { MARKET_UI_COPY } from "@/lib/market-ui-copy"; import { DNA_BODY_SECONDARY, + DNA_CAPTION, DNA_DISPLAY, DNA_PAGE_STACK, } from "@/lib/design-dna"; @@ -100,6 +102,12 @@ export default function StockOfTheDayPage() {

AI-ranked daily opportunity across stocks and select liquid assets.

+

+ Related:{" "} + + AI stock rankings + +

null)) as { + timeframe?: unknown; + candidates?: unknown; + } | null; + + const timeframeRaw = + typeof body?.timeframe === "string" + ? body.timeframe.trim().toLowerCase() + : "short"; + if (!isAIRankingTimeframe(timeframeRaw)) { + return NextResponse.json( + { + success: false, + error: "Invalid timeframe. Use short, medium, or long.", + }, + { status: 400 } + ); + } + + const candidates = parseAIStockCandidates(body?.candidates); + if (candidates.length < 4) { + return NextResponse.json( + { + success: false, + error: "AI returned an incomplete stock ranking candidate set.", + timestamp: new Date(), + }, + { status: 400 } + ); + } + + const data = await aiMarketInsightsService.enrichAIStockRankingsCandidates( + timeframeRaw, + candidates + ); + + return NextResponse.json({ + success: true, + data, + timestamp: new Date(), + }); + } catch (error) { + logger.error( + "Failed to validate local AI stock ranking candidates", + error as Error + ); + + return NextResponse.json( + { + success: false, + error: userFacingApiError( + error, + MARKET_UI_COPY.load.aiStockRankingsValidate + ), + timestamp: new Date(), + }, + { status: 500 } + ); + } +} diff --git a/components/AIStockRankingsPanel.tsx b/components/AIStockRankingsPanel.tsx new file mode 100644 index 0000000..85ddb74 --- /dev/null +++ b/components/AIStockRankingsPanel.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import Link from "next/link"; +import { + DNA_BODY, + DNA_BODY_SECONDARY, + DNA_CAPTION, + DNA_EYEBROW, + DNA_SUBHEADING, +} from "@/lib/design-dna"; +import { + AI_RANKING_TIMEFRAMES, + type AIRankingTimeframe, +} from "@/lib/ai-stock-rankings"; +import type { AIStockRankingsResult, PricingTier } from "@/types"; +import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; +import { ConfidenceInfoTooltip } from "@/components/ConfidenceInfoTooltip"; +import { InsightPanel, InsightPanelHeader } from "@/components/InsightPanel"; +import { SubscriptionGate } from "@/components/ProductShell"; +import { AiFeatureErrorNotice } from "@/components/AiFeatureErrorNotice"; +import { + HOME_INSTRUMENT_PANEL, + HOME_PRIMARY_BUTTON, + HOME_SEGMENTED_NAV, + homeSegmentedTabClasses, +} from "@/lib/home-ui"; +import { useTheme } from "@/lib/theme-context"; + +interface AIStockRankingsPanelProps { + data: AIStockRankingsResult | null; + loading: boolean; + locked: boolean; + error?: string | null; + pricingTier?: PricingTier | null; + timeframe: AIRankingTimeframe; + onTimeframeChange: (timeframe: AIRankingTimeframe) => void; +} + +function RankedStockCard({ + rank, + symbol, + name, + confidence, + rationale, +}: { + rank: number; + symbol: string; + name: string; + confidence: number; + rationale: string[]; +}) { + return ( +
+
+
+

Rank #{rank}

+

+ {symbol} + · {name} +

+
+
+

+ Confidence {Math.round(confidence * 100)}% + +

+

{rationale.join(" ")}

+
+ ); +} + +export function AIStockRankingsPanel({ + data, + loading, + locked, + error, + pricingTier, + timeframe, + onTimeframeChange, +}: AIStockRankingsPanelProps) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); + const showLockedOverlay = locked && Boolean(data); + const showLockedGateOnly = locked && !data && !loading; + const gateMessage = getAiSubscriptionGateMessage(pricingTier ?? undefined); + const activeHorizon = + AI_RANKING_TIMEFRAMES.find((item) => item.id === timeframe)?.horizon ?? + "Days to weeks"; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, index: number) => { + let nextIndex: number | null = null; + + switch (e.key) { + case "ArrowRight": + nextIndex = (index + 1) % AI_RANKING_TIMEFRAMES.length; + break; + case "ArrowLeft": + nextIndex = + (index - 1 + AI_RANKING_TIMEFRAMES.length) % + AI_RANKING_TIMEFRAMES.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = AI_RANKING_TIMEFRAMES.length - 1; + break; + default: + return; + } + + e.preventDefault(); + onTimeframeChange(AI_RANKING_TIMEFRAMES[nextIndex].id); + tabRefs.current[nextIndex]?.focus(); + }, + [onTimeframeChange] + ); + + const shell = ( +
+ {showLockedGateOnly ? ( + + ) : ( + <> +
+
+ + Generated {new Date(data.generatedAt).toLocaleDateString()} +

+ ) : undefined + } + /> +
+ + + +

+ Horizon: {activeHorizon} +

+ + {loading && ( +

+ Ranking stocks for the {activeHorizon.toLowerCase()} horizon... +

+ )} + + {!loading && data && ( +
    + {data.stocks.map((stock) => ( +
  1. + +
  2. + ))} +
+ )} + + {!loading && !data && !locked && error && ( + + )} + + {!loading && !data && !locked && !error && ( +

+ No ranking result yet. Switch time frames or refresh to try again. +

+ )} +
+ + {showLockedOverlay && ( + + )} + + )} +
+ ); + + return ( + + {shell} +

+ Also see{" "} + + Stock of the day + {" "} + for daily buy and sell ideas. +

+
+ ); +} diff --git a/components/HomeHub.tsx b/components/HomeHub.tsx index ba6a461..7bb7697 100644 --- a/components/HomeHub.tsx +++ b/components/HomeHub.tsx @@ -262,12 +262,25 @@ export function HomeHub({ data-testid="home-ai-outlook" className={`${HOME_SECTION} flex flex-col`} > -

- AI outlook -

+
+

+ AI outlook +

+
+ + Stock of the day + + + AI stock rankings + +
+
{stockOfTheDay} diff --git a/lib/ai-stock-rankings.ts b/lib/ai-stock-rankings.ts new file mode 100644 index 0000000..1009024 --- /dev/null +++ b/lib/ai-stock-rankings.ts @@ -0,0 +1,94 @@ +import { + extractFirstJsonObject, + parseAIStockCandidates, + type AIStockCandidate, +} from "@/lib/stock-of-the-day-ai"; +import type { AIRankingTimeframe } from "@/types"; + +export type { AIRankingTimeframe }; + +export const AI_RANKING_TIMEFRAMES = [ + { + id: "short" as const, + label: "Short term", + horizon: "Days to weeks", + }, + { + id: "medium" as const, + label: "Medium term", + horizon: "1–6 months", + }, + { + id: "long" as const, + label: "Long term", + horizon: "1+ years", + }, +] as const; + +export const AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS = 4096; + +export const AI_STOCK_RANKINGS_CANDIDATE_COUNT = 12; + +export const AI_STOCK_RANKINGS_DISPLAY_COUNT = 10; + +const TIMEFRAME_PROMPT_CONTEXT: Record< + AIRankingTimeframe, + { horizon: string; focus: string } +> = { + short: { + horizon: "days to a few weeks", + focus: + "near-term catalysts, momentum, technical breakouts, earnings reactions, and short-swing setups", + }, + medium: { + horizon: "one to six months", + focus: + "intermediate trends, estimate revisions, sector rotation, and setups that need a few quarters to play out", + }, + long: { + horizon: "one year or longer", + focus: + "durable growth narratives, compounding businesses, valuation vs runway, and structural winners", + }, +}; + +export function isAIRankingTimeframe(value: string): value is AIRankingTimeframe { + return value === "short" || value === "medium" || value === "long"; +} + +export function buildAIStockRankingsPrompt( + timeframe: AIRankingTimeframe +): string { + const context = TIMEFRAME_PROMPT_CONTEXT[timeframe]; + + return `You are an equity research analyst building a premium AI stock ranking list for the ${context.horizon} horizon. +Return ONLY valid JSON, no markdown, in this exact shape: +{ + "candidates": [{"symbol": string, "name": string, "thesis": string}] +} + +Task: +- Rank the most promising US-listed common stocks for a ${context.horizon} investment horizon. +- Focus on ${context.focus}. +- Return exactly ${AI_STOCK_RANKINGS_CANDIDATE_COUNT} distinct candidates ordered from most to least compelling for this horizon. + +Rules: +- Do not include ETFs, crypto, ADRs, funds, warrants, or preferred shares. +- Prefer liquid public companies that Yahoo Finance can quote. +- thesis must be one short sentence explaining why the stock ranks highly for this horizon. +- Use tickers only, no exchange suffixes. +- Avoid repeating the same mega-cap default unless the thesis is unusually differentiated for this horizon.`; +} + +export function parseAIStockRankingsCandidates( + raw: string +): AIStockCandidate[] { + const parsed = extractFirstJsonObject(raw); + const candidates = parseAIStockCandidates(parsed?.candidates); + + if (candidates.length < 4) { + throw new Error("AI returned an incomplete stock ranking candidate set."); + } + + return candidates.slice(0, AI_STOCK_RANKINGS_CANDIDATE_COUNT); +} diff --git a/lib/local-ollama-ai-rankings.ts b/lib/local-ollama-ai-rankings.ts new file mode 100644 index 0000000..d1b716d --- /dev/null +++ b/lib/local-ollama-ai-rankings.ts @@ -0,0 +1,84 @@ +"use client"; + +import { + generateWithBrowserLocalOllama, + isAppOpenedOnLoopbackHost, + shouldUseBrowserLocalOllama, +} from "@/lib/browser-local-ollama"; +import { + buildAIStockRankingsPrompt, + parseAIStockRankingsCandidates, +} from "@/lib/ai-stock-rankings"; +import { getAIProviderHeaders } from "@/lib/explanation-provider"; +import { MARKET_UI_COPY } from "@/lib/market-ui-copy"; +import type { AIStockRankingsResult, AIRankingTimeframe, PricingTier } from "@/types"; + +type AIStockRankingsApiResponse = { + data?: AIStockRankingsResult; + error?: string; +}; + +export async function fetchAIStockRankingsForCurrentProvider( + timeframe: AIRankingTimeframe, + pricingTier: PricingTier | null +): Promise { + const useLocalOllama = shouldUseBrowserLocalOllama(pricingTier); + if (useLocalOllama && !isAppOpenedOnLoopbackHost()) { + return fetchAIStockRankingsWithBrowserLocalOllama(timeframe); + } + + return fetchAIStockRankingsViaServerGet(timeframe); +} + +async function fetchAIStockRankingsViaServerGet( + timeframe: AIRankingTimeframe +): Promise { + const response = await fetch( + `/api/market/ai-rankings?timeframe=${encodeURIComponent(timeframe)}`, + { + headers: getAIProviderHeaders(), + credentials: "include", + cache: "no-store", + } + ); + + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(body.error ?? MARKET_UI_COPY.load.aiStockRankings); + } + + const result = (await response.json()) as AIStockRankingsApiResponse; + return result.data ?? null; +} + +async function fetchAIStockRankingsWithBrowserLocalOllama( + timeframe: AIRankingTimeframe +): Promise { + const raw = await generateWithBrowserLocalOllama( + buildAIStockRankingsPrompt(timeframe) + ); + const candidates = parseAIStockRankingsCandidates(raw); + + const response = await fetch("/api/market/ai-rankings", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-ai-provider": "OLLAMA", + }, + credentials: "include", + cache: "no-store", + body: JSON.stringify({ timeframe, candidates }), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(body.error ?? MARKET_UI_COPY.load.aiStockRankingsValidate); + } + + const result = (await response.json()) as AIStockRankingsApiResponse; + return result.data ?? null; +} diff --git a/lib/market-ui-copy.ts b/lib/market-ui-copy.ts index 2adbba9..4f3e438 100644 --- a/lib/market-ui-copy.ts +++ b/lib/market-ui-copy.ts @@ -26,6 +26,9 @@ export const MARKET_UI_COPY = { aiPredictionValidate: "We couldn't validate the AI prediction. Try again.", stockOfTheDayValidate: "We couldn't validate today's stock pick. Try again.", + aiStockRankings: "We couldn't load AI stock rankings. Try again.", + aiStockRankingsValidate: + "We couldn't validate AI stock rankings. Try again.", checkout: "We couldn't start checkout. Try again.", checkoutUrl: "We couldn't open checkout. Try again.", confirmCheckout: "We couldn't confirm checkout. Try again.", diff --git a/lib/site-seo.ts b/lib/site-seo.ts index c817888..652cd78 100644 --- a/lib/site-seo.ts +++ b/lib/site-seo.ts @@ -222,5 +222,10 @@ export const PUBLIC_ROUTES = [ priority: 0.9, changeFrequency: "daily" as const, }, + { + path: "/ai-rankings", + priority: 0.9, + changeFrequency: "daily" as const, + }, { path: "/pricing", priority: 0.7, changeFrequency: "weekly" as const }, ]; diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts index 25e51a1..262f62a 100644 --- a/services/ai-market-insights.service.ts +++ b/services/ai-market-insights.service.ts @@ -14,6 +14,13 @@ import { import { marketDataService } from "@/services/market-data.service"; import { AIIntegrationService } from "@/services/ai-integration.service"; import { logger } from "@/lib/logger"; +import { + AI_STOCK_RANKINGS_DISPLAY_COUNT, + AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS, + buildAIStockRankingsPrompt, + parseAIStockRankingsCandidates, + type AIRankingTimeframe, +} from "@/lib/ai-stock-rankings"; import { parseStockOfTheDayCandidates, STOCK_OF_THE_DAY_CANDIDATES_PROMPT, @@ -24,6 +31,8 @@ import { import type { AIPredictionReport, AIProvider, + AIStockRankingsResult, + AIRankedStock, ForecastData, StockOfTheDay, StockOfTheDayResult, @@ -303,6 +312,49 @@ export class AIMarketInsightsService { }; } + async getAIStockRankings( + timeframe: AIRankingTimeframe, + llmConfig?: LLMConfig + ): Promise { + const llm = llmConfig ?? getLLMConfigFromEnv(); + if (!llm) { + throw new Error( + "An active AI provider is required for dynamic AI stock rankings." + ); + } + + const generatedCandidates = await this.generateAIStockRankingsCandidates( + timeframe, + llm + ); + return this.enrichAIStockRankingsCandidates(timeframe, generatedCandidates); + } + + async enrichAIStockRankingsCandidates( + timeframe: AIRankingTimeframe, + candidates: AIStockCandidate[] + ): Promise { + const enriched = await this.enrichRankingCandidates(candidates, timeframe); + const ranked = enriched + .sort((a, b) => b.score - a.score) + .slice(0, AI_STOCK_RANKINGS_DISPLAY_COUNT); + + if (ranked.length < 4) { + throw new Error( + "AI did not return enough valid public stock candidates for this ranking." + ); + } + + const generatedAt = new Date(); + return { + timeframe, + generatedAt, + stocks: ranked.map((candidate, index) => + this.toAIRankedStock(candidate, index + 1) + ), + }; + } + private async generateStockOfTheDayCandidates(llm: LLMConfig): Promise<{ buyCandidates: AIStockCandidate[]; sellCandidates: AIStockCandidate[]; @@ -321,6 +373,86 @@ export class AIMarketInsightsService { return parseStockOfTheDayCandidates(raw); } + private async generateAIStockRankingsCandidates( + timeframe: AIRankingTimeframe, + llm: LLMConfig + ): Promise { + const service = new AIIntegrationService(); + await service.setAIProvider(llm.provider, { + provider: llm.provider, + apiKey: llm.apiKey, + model: llm.model, + settings: {}, + }); + + const raw = await service.runRawPrompt( + buildAIStockRankingsPrompt(timeframe), + { + maxOutputTokens: AI_STOCK_RANKINGS_MAX_OUTPUT_TOKENS, + } + ); + return parseAIStockRankingsCandidates(raw); + } + + private async enrichRankingCandidates( + candidates: AIStockCandidate[], + timeframe: AIRankingTimeframe + ): Promise { + const enriched = await Promise.all( + candidates.map(async (candidate) => { + try { + const [quote, indicators, forecast] = await Promise.all([ + marketDataService.getSymbolData(candidate.symbol), + marketDataService.getTechnicalIndicators(candidate.symbol), + marketDataService.getForecastData(candidate.symbol), + ]); + + const score = this.scoreRankingCandidate({ + timeframe, + price: quote.price, + changePercent: quote.changePercent, + volume: quote.volume, + marketCap: quote.marketCap, + fiftyTwoWeekHigh: quote.fiftyTwoWeekHigh, + fiftyTwoWeekLow: quote.fiftyTwoWeekLow, + overallSentiment: indicators.overallSentiment, + averageTarget: forecast.priceTargets.average, + analystRatings: forecast.analystRatings, + }); + + return { + symbol: quote.symbol || candidate.symbol, + name: quote.name || candidate.name || candidate.symbol, + thesis: + candidate.thesis || + "AI surfaced this as a differentiated candidate for this horizon.", + score, + confidence: scoreToConfidence(score, 1.25), + rationale: this.buildRankingRationale(timeframe, { + thesis: candidate.thesis, + changePercent: quote.changePercent, + marketCap: quote.marketCap, + overallSentiment: indicators.overallSentiment, + averageTarget: forecast.priceTargets.average, + price: quote.price, + }), + }; + } catch (error) { + logger.warn("Failed to validate AI stock ranking candidate", { + symbol: candidate.symbol, + timeframe, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }) + ); + + return enriched.filter( + (item): item is EnrichedStockCandidate => item !== null + ); + } + private async enrichStockCandidates( candidates: AIStockCandidate[], direction: "buy" | "sell" @@ -382,6 +514,20 @@ export class AIMarketInsightsService { ); } + private toAIRankedStock( + candidate: EnrichedStockCandidate, + rank: number + ): AIRankedStock { + return { + rank, + symbol: candidate.symbol, + name: candidate.name, + assetType: "stock", + confidence: candidate.confidence, + rationale: candidate.rationale, + }; + } + private toStockOfTheDay( recommendation: "buy" | "sell", candidate: EnrichedStockCandidate, @@ -398,6 +544,58 @@ export class AIMarketInsightsService { }; } + private scoreRankingCandidate(args: { + timeframe: AIRankingTimeframe; + price: number; + changePercent: number; + volume: number; + marketCap: number; + fiftyTwoWeekHigh: number; + fiftyTwoWeekLow: number; + overallSentiment: TechnicalIndicators["overallSentiment"]; + averageTarget: number; + analystRatings: ForecastData["analystRatings"]; + }): number { + const baseScore = this.scoreStockOfTheDayCandidate({ + direction: "buy", + price: args.price, + changePercent: args.changePercent, + volume: args.volume, + marketCap: args.marketCap, + fiftyTwoWeekHigh: args.fiftyTwoWeekHigh, + fiftyTwoWeekLow: args.fiftyTwoWeekLow, + overallSentiment: args.overallSentiment, + averageTarget: args.averageTarget, + analystRatings: args.analystRatings, + }); + + if (args.timeframe === "short") { + const momentum = Math.min(1, Math.max(-1, args.changePercent / 8)); + const sentiment = + args.overallSentiment === "underpriced" + ? 0.15 + : args.overallSentiment === "overpriced" + ? -0.15 + : 0; + return baseScore + momentum * 0.25 + sentiment; + } + + if (args.timeframe === "long") { + const marketCapBillions = args.marketCap / 1_000_000_000; + const runway = + marketCapBillions <= 0 + ? 0 + : marketCapBillions <= 30 + ? Math.max(0, 1 - marketCapBillions / 30) + : -0.1; + const targetUpside = + args.price > 0 ? (args.averageTarget - args.price) / args.price : 0; + return baseScore + runway * 0.2 + targetUpside * 0.15; + } + + return baseScore; + } + private scoreStockOfTheDayCandidate(args: { direction: "buy" | "sell"; price: number; @@ -467,6 +665,33 @@ export class AIMarketInsightsService { ); } + private buildRankingRationale( + timeframe: AIRankingTimeframe, + args: { + thesis?: string; + changePercent: number; + marketCap: number; + overallSentiment: TechnicalIndicators["overallSentiment"]; + averageTarget: number; + price: number; + } + ): string[] { + const base = this.buildStockOfTheDayRationale("buy", args); + const horizonLabel = + timeframe === "short" + ? "short-term" + : timeframe === "medium" + ? "medium-term" + : "long-term"; + + return [ + args.thesis || + `AI identified a compelling ${horizonLabel} setup versus peers in this horizon.`, + base[1], + base[2], + ]; + } + private buildStockOfTheDayRationale( direction: "buy" | "sell", args: { diff --git a/types/index.ts b/types/index.ts index adaf309..76da0db 100644 --- a/types/index.ts +++ b/types/index.ts @@ -471,3 +471,20 @@ export interface StockOfTheDayResult { buy: StockOfTheDay; sell: StockOfTheDay; } + +export type AIRankingTimeframe = "short" | "medium" | "long"; + +export interface AIRankedStock { + rank: number; + symbol: string; + name: string; + assetType: "stock" | "crypto" | "commodity" | "forex" | "etf" | "unknown"; + confidence: number; + rationale: string[]; +} + +export interface AIStockRankingsResult { + timeframe: AIRankingTimeframe; + generatedAt: Date; + stocks: AIRankedStock[]; +} From 3e17601833c730df736306ab3ce4bb5942a538dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 16:03:38 +0000 Subject: [PATCH 02/15] Fix Prettier formatting for AI rankings files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ✅ Daniel Danielecki --- app/(main)/ai-rankings/page.tsx | 9 +++++++-- components/AIStockRankingsPanel.tsx | 11 ++++++++--- lib/ai-stock-rankings.ts | 4 +++- lib/local-ollama-ai-rankings.ts | 6 +++++- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/(main)/ai-rankings/page.tsx b/app/(main)/ai-rankings/page.tsx index 3f319ee..47190fd 100644 --- a/app/(main)/ai-rankings/page.tsx +++ b/app/(main)/ai-rankings/page.tsx @@ -65,7 +65,9 @@ export default function AIStockRankingsPage() { } catch (err) { setData(null); setLoadError( - err instanceof Error ? err.message : MARKET_UI_COPY.load.aiStockRankings + err instanceof Error + ? err.message + : MARKET_UI_COPY.load.aiStockRankings ); } finally { setLoading(false); @@ -110,7 +112,10 @@ export default function AIStockRankingsPage() {

Related:{" "} - + Stock of the day

diff --git a/components/AIStockRankingsPanel.tsx b/components/AIStockRankingsPanel.tsx index 85ddb74..4c37007 100644 --- a/components/AIStockRankingsPanel.tsx +++ b/components/AIStockRankingsPanel.tsx @@ -146,7 +146,8 @@ export function AIStockRankingsPanel({ right={ data ? (

- Generated {new Date(data.generatedAt).toLocaleDateString()} + Generated{" "} + {new Date(data.generatedAt).toLocaleDateString()}

) : undefined } @@ -184,7 +185,10 @@ export function AIStockRankingsPanel({ })} -

+

Horizon: {activeHorizon}

@@ -220,7 +224,8 @@ export function AIStockRankingsPanel({ {!loading && !data && !locked && !error && (

- No ranking result yet. Switch time frames or refresh to try again. + No ranking result yet. Switch time frames or refresh to try + again.

)} diff --git a/lib/ai-stock-rankings.ts b/lib/ai-stock-rankings.ts index 1009024..3a87031 100644 --- a/lib/ai-stock-rankings.ts +++ b/lib/ai-stock-rankings.ts @@ -52,7 +52,9 @@ const TIMEFRAME_PROMPT_CONTEXT: Record< }, }; -export function isAIRankingTimeframe(value: string): value is AIRankingTimeframe { +export function isAIRankingTimeframe( + value: string +): value is AIRankingTimeframe { return value === "short" || value === "medium" || value === "long"; } diff --git a/lib/local-ollama-ai-rankings.ts b/lib/local-ollama-ai-rankings.ts index d1b716d..a2729a2 100644 --- a/lib/local-ollama-ai-rankings.ts +++ b/lib/local-ollama-ai-rankings.ts @@ -11,7 +11,11 @@ import { } from "@/lib/ai-stock-rankings"; import { getAIProviderHeaders } from "@/lib/explanation-provider"; import { MARKET_UI_COPY } from "@/lib/market-ui-copy"; -import type { AIStockRankingsResult, AIRankingTimeframe, PricingTier } from "@/types"; +import type { + AIStockRankingsResult, + AIRankingTimeframe, + PricingTier, +} from "@/types"; type AIStockRankingsApiResponse = { data?: AIStockRankingsResult; From 5055077b5a8dc3b0ae36e030feb52dc46b29bc88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 16:13:08 +0000 Subject: [PATCH 03/15] Rename AI rankings to Ranking at /ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move page and API paths, add MAIN_NAV entry, redirect /ai-rankings, and update user-facing copy and titles to Ranking. Co-authored-by: ✅ Daniel Danielecki --- app/(main)/ai-rankings/layout.tsx | 17 ----------------- app/(main)/ranking/layout.tsx | 17 +++++++++++++++++ app/(main)/{ai-rankings => ranking}/page.tsx | 6 +++--- app/(main)/stock-of-the-day/page.tsx | 4 ++-- .../market/{ai-rankings => ranking}/route.ts | 9 +++------ components/AIStockRankingsPanel.tsx | 15 ++++++--------- components/HomeHub.tsx | 4 ++-- components/__tests__/Navigation.test.tsx | 4 ++++ lib/local-ollama-ai-rankings.ts | 4 ++-- lib/market-ui-copy.ts | 5 ++--- lib/nav-routes.ts | 2 ++ lib/site-seo.ts | 2 +- next.config.ts | 5 +++++ services/ai-market-insights.service.ts | 2 +- 14 files changed, 50 insertions(+), 46 deletions(-) delete mode 100644 app/(main)/ai-rankings/layout.tsx create mode 100644 app/(main)/ranking/layout.tsx rename app/(main)/{ai-rankings => ranking}/page.tsx (94%) rename app/api/market/{ai-rankings => ranking}/route.ts (94%) diff --git a/app/(main)/ai-rankings/layout.tsx b/app/(main)/ai-rankings/layout.tsx deleted file mode 100644 index a40bf13..0000000 --- a/app/(main)/ai-rankings/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Metadata } from "next"; -import { buildPageMetadata } from "@/lib/site-seo"; - -export const metadata: Metadata = buildPageMetadata({ - title: "AI Stock Rankings", - description: - "AI-ranked lists of the most promising stocks for short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.", - path: "/ai-rankings", -}); - -export default function AIStockRankingsLayout({ - children, -}: { - children: React.ReactNode; -}) { - return children; -} diff --git a/app/(main)/ranking/layout.tsx b/app/(main)/ranking/layout.tsx new file mode 100644 index 0000000..3f3a002 --- /dev/null +++ b/app/(main)/ranking/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { buildPageMetadata } from "@/lib/site-seo"; + +export const metadata: Metadata = buildPageMetadata({ + title: "Ranking", + description: + "Ranked lists of the most promising stocks for short, medium, and long horizons — each pick validated with live quotes, technicals, and analyst data.", + path: "/ranking", +}); + +export default function RankingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/app/(main)/ai-rankings/page.tsx b/app/(main)/ranking/page.tsx similarity index 94% rename from app/(main)/ai-rankings/page.tsx rename to app/(main)/ranking/page.tsx index 47190fd..0899605 100644 --- a/app/(main)/ai-rankings/page.tsx +++ b/app/(main)/ranking/page.tsx @@ -103,11 +103,11 @@ export default function AIStockRankingsPage() { }, []); return ( -
+
-

AI stock rankings

+

Ranking

- AI-ranked lists of the most promising stocks across short, medium, and + Ranked lists of the most promising stocks across short, medium, and long horizons — each with a clear rationale.

diff --git a/app/(main)/stock-of-the-day/page.tsx b/app/(main)/stock-of-the-day/page.tsx index dcbe482..b23ac1f 100644 --- a/app/(main)/stock-of-the-day/page.tsx +++ b/app/(main)/stock-of-the-day/page.tsx @@ -104,8 +104,8 @@ export default function StockOfTheDayPage() {

Related:{" "} - - AI stock rankings + + Ranking

diff --git a/app/api/market/ai-rankings/route.ts b/app/api/market/ranking/route.ts similarity index 94% rename from app/api/market/ai-rankings/route.ts rename to app/api/market/ranking/route.ts index 7230b8d..380aba6 100644 --- a/app/api/market/ai-rankings/route.ts +++ b/app/api/market/ranking/route.ts @@ -48,7 +48,7 @@ export async function GET(request: NextRequest) { requestedProviderRaw, }); if (!resolved.ok) { - logger.warn("AI stock rankings: no LLM credentials", { + logger.warn("Ranking: no LLM credentials", { userId: auth.id, tier, detail: resolved.error, @@ -76,7 +76,7 @@ export async function GET(request: NextRequest) { timestamp: new Date(), }); } catch (error) { - logger.error("Failed to load AI stock rankings", error as Error); + logger.error("Failed to load Ranking", error as Error); return NextResponse.json( { @@ -149,10 +149,7 @@ export async function POST(request: NextRequest) { timestamp: new Date(), }); } catch (error) { - logger.error( - "Failed to validate local AI stock ranking candidates", - error as Error - ); + logger.error("Failed to validate local Ranking candidates", error as Error); return NextResponse.json( { diff --git a/components/AIStockRankingsPanel.tsx b/components/AIStockRankingsPanel.tsx index 4c37007..054e8ea 100644 --- a/components/AIStockRankingsPanel.tsx +++ b/components/AIStockRankingsPanel.tsx @@ -122,11 +122,11 @@ export function AIStockRankingsPanel({ const shell = (
{showLockedGateOnly ? ( {item.label}
diff --git a/components/__tests__/Navigation.test.tsx b/components/__tests__/Navigation.test.tsx index 1a54881..bd5bed4 100644 --- a/components/__tests__/Navigation.test.tsx +++ b/components/__tests__/Navigation.test.tsx @@ -68,6 +68,10 @@ describe("Navigation", () => { "href", "/screener" ); + expect(screen.getByRole("link", { name: "Ranking" })).toHaveAttribute( + "href", + "/ranking" + ); expect(screen.getByRole("link", { name: "Pricing" })).toHaveAttribute( "href", "/pricing" diff --git a/lib/local-ollama-ai-rankings.ts b/lib/local-ollama-ai-rankings.ts index a2729a2..c4274ac 100644 --- a/lib/local-ollama-ai-rankings.ts +++ b/lib/local-ollama-ai-rankings.ts @@ -38,7 +38,7 @@ async function fetchAIStockRankingsViaServerGet( timeframe: AIRankingTimeframe ): Promise { const response = await fetch( - `/api/market/ai-rankings?timeframe=${encodeURIComponent(timeframe)}`, + `/api/market/ranking?timeframe=${encodeURIComponent(timeframe)}`, { headers: getAIProviderHeaders(), credentials: "include", @@ -65,7 +65,7 @@ async function fetchAIStockRankingsWithBrowserLocalOllama( ); const candidates = parseAIStockRankingsCandidates(raw); - const response = await fetch("/api/market/ai-rankings", { + const response = await fetch("/api/market/ranking", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/lib/market-ui-copy.ts b/lib/market-ui-copy.ts index 4f3e438..d87b2e6 100644 --- a/lib/market-ui-copy.ts +++ b/lib/market-ui-copy.ts @@ -26,9 +26,8 @@ export const MARKET_UI_COPY = { aiPredictionValidate: "We couldn't validate the AI prediction. Try again.", stockOfTheDayValidate: "We couldn't validate today's stock pick. Try again.", - aiStockRankings: "We couldn't load AI stock rankings. Try again.", - aiStockRankingsValidate: - "We couldn't validate AI stock rankings. Try again.", + aiStockRankings: "We couldn't load Ranking. Try again.", + aiStockRankingsValidate: "We couldn't validate Ranking. Try again.", checkout: "We couldn't start checkout. Try again.", checkoutUrl: "We couldn't open checkout. Try again.", confirmCheckout: "We couldn't confirm checkout. Try again.", diff --git a/lib/nav-routes.ts b/lib/nav-routes.ts index 2725462..fc8df04 100644 --- a/lib/nav-routes.ts +++ b/lib/nav-routes.ts @@ -8,6 +8,7 @@ export const MAIN_NAV = [ { id: "calendars", label: "Calendars", href: "/calendars" }, { id: "heatmaps", label: "Heatmaps", href: "/heatmaps" }, { id: "screener", label: "Screener", href: "/screener" }, + { id: "ranking", label: "Ranking", href: "/ranking" }, { id: "pricing", label: "Pricing", href: "/pricing" }, ] as const; @@ -22,6 +23,7 @@ export function pathnameToNavId(pathname: string): MainNavId | string { first === "calendars" || first === "heatmaps" || first === "screener" || + first === "ranking" || first === "pricing" || first === "profile" || first === "compare" || diff --git a/lib/site-seo.ts b/lib/site-seo.ts index 652cd78..c7c0a37 100644 --- a/lib/site-seo.ts +++ b/lib/site-seo.ts @@ -223,7 +223,7 @@ export const PUBLIC_ROUTES = [ changeFrequency: "daily" as const, }, { - path: "/ai-rankings", + path: "/ranking", priority: 0.9, changeFrequency: "daily" as const, }, diff --git a/next.config.ts b/next.config.ts index 1225f63..a3a9663 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,6 +15,11 @@ const nextConfig: NextConfig = { }, async redirects() { return [ + { + source: "/ai-rankings", + destination: "/ranking", + permanent: true, + }, { source: "/vs/:slug", destination: "/compare/:slug", diff --git a/services/ai-market-insights.service.ts b/services/ai-market-insights.service.ts index 262f62a..0deca4a 100644 --- a/services/ai-market-insights.service.ts +++ b/services/ai-market-insights.service.ts @@ -319,7 +319,7 @@ export class AIMarketInsightsService { const llm = llmConfig ?? getLLMConfigFromEnv(); if (!llm) { throw new Error( - "An active AI provider is required for dynamic AI stock rankings." + "An active AI provider is required for dynamic Ranking picks." ); } From 401569dc43bdfe115d76c7c13cdf3b2edfa3debc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 17:11:03 +0000 Subject: [PATCH 04/15] Replace Ranking cards with screener-style table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show rank, symbol, name, and why in a readable table with horizontal scroll on narrow viewports. Keeps timeframe tabs and subscription gating. Co-authored-by: ✅ Daniel Danielecki --- components/AIStockRankingsPanel.tsx | 114 ++++++++++++++++------------ 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/components/AIStockRankingsPanel.tsx b/components/AIStockRankingsPanel.tsx index 054e8ea..45b3b27 100644 --- a/components/AIStockRankingsPanel.tsx +++ b/components/AIStockRankingsPanel.tsx @@ -6,8 +6,7 @@ import { DNA_BODY, DNA_BODY_SECONDARY, DNA_CAPTION, - DNA_EYEBROW, - DNA_SUBHEADING, + DNA_LABEL_STRONG, } from "@/lib/design-dna"; import { AI_RANKING_TIMEFRAMES, @@ -15,7 +14,6 @@ import { } from "@/lib/ai-stock-rankings"; import type { AIStockRankingsResult, PricingTier } from "@/types"; import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; -import { ConfidenceInfoTooltip } from "@/components/ConfidenceInfoTooltip"; import { InsightPanel, InsightPanelHeader } from "@/components/InsightPanel"; import { SubscriptionGate } from "@/components/ProductShell"; import { AiFeatureErrorNotice } from "@/components/AiFeatureErrorNotice"; @@ -37,37 +35,8 @@ interface AIStockRankingsPanelProps { onTimeframeChange: (timeframe: AIRankingTimeframe) => void; } -function RankedStockCard({ - rank, - symbol, - name, - confidence, - rationale, -}: { - rank: number; - symbol: string; - name: string; - confidence: number; - rationale: string[]; -}) { - return ( -
-
-
-

Rank #{rank}

-

- {symbol} - · {name} -

-
-
-

- Confidence {Math.round(confidence * 100)}% - -

-

{rationale.join(" ")}

-
- ); +function formatRankingRationale(rationale: string[]): string { + return rationale.filter(Boolean).join(" "); } export function AIStockRankingsPanel({ @@ -196,19 +165,70 @@ export function AIStockRankingsPanel({ )} {!loading && data && ( -
    - {data.stocks.map((stock) => ( -
  1. - -
  2. - ))} -
+
+
+ + + + + + + + + + + {data.stocks.map((stock) => ( + + + + + + + ))} + +
+ Rank + + Symbol + + Name + + Why +
+ + #{stock.rank} + + + {stock.symbol} + + {stock.name} + + {formatRankingRationale(stock.rationale)} +
+
+
)} {!loading && !data && !locked && error && ( From 3f56dcd54010743ab325d3999259a68e477619d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 08:46:00 +0000 Subject: [PATCH 05/15] Slim main nav and add Ditectrev-style footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep MAIN_NAV to Home, News, Ranking, and Pricing. Move Sectors, Calendars, Heatmaps, and Screener to a footer Tools section. Add Resources legal placeholders, Ditectrev contact details, social links, and dynamic copyright. Co-authored-by: ✅ Daniel Danielecki --- app/(main)/copyrights/page.tsx | 13 ++ app/(main)/privacy-and-security/page.tsx | 13 ++ app/(main)/sitemap/page.tsx | 13 ++ app/(main)/terms-of-use/page.tsx | 13 ++ components/Footer.tsx | 221 +++++++++++++----- components/LegalPlaceholderPage.tsx | 9 + components/__tests__/Accessibility.test.tsx | 10 +- components/__tests__/ColorContrast.test.tsx | 6 +- components/__tests__/Navigation.test.tsx | 24 +- .../__tests__/ResponsiveDesign.test.tsx | 11 +- e2e/home.spec.ts | 3 +- lib/footer-content.ts | 60 +++++ lib/nav-routes.ts | 10 +- 13 files changed, 312 insertions(+), 94 deletions(-) create mode 100644 app/(main)/copyrights/page.tsx create mode 100644 app/(main)/privacy-and-security/page.tsx create mode 100644 app/(main)/sitemap/page.tsx create mode 100644 app/(main)/terms-of-use/page.tsx create mode 100644 components/LegalPlaceholderPage.tsx create mode 100644 lib/footer-content.ts diff --git a/app/(main)/copyrights/page.tsx b/app/(main)/copyrights/page.tsx new file mode 100644 index 0000000..d633ed3 --- /dev/null +++ b/app/(main)/copyrights/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { LegalPlaceholderPage } from "@/components/LegalPlaceholderPage"; +import { buildPageMetadata } from "@/lib/site-seo"; + +export const metadata: Metadata = buildPageMetadata({ + title: "Copyrights", + description: "Copyrights for The Open Stock.", + path: "/copyrights", +}); + +export default function CopyrightsPage() { + return ; +} diff --git a/app/(main)/privacy-and-security/page.tsx b/app/(main)/privacy-and-security/page.tsx new file mode 100644 index 0000000..cbe8915 --- /dev/null +++ b/app/(main)/privacy-and-security/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { LegalPlaceholderPage } from "@/components/LegalPlaceholderPage"; +import { buildPageMetadata } from "@/lib/site-seo"; + +export const metadata: Metadata = buildPageMetadata({ + title: "Privacy & Security", + description: "Privacy and security for The Open Stock.", + path: "/privacy-and-security", +}); + +export default function PrivacyAndSecurityPage() { + return ; +} diff --git a/app/(main)/sitemap/page.tsx b/app/(main)/sitemap/page.tsx new file mode 100644 index 0000000..60de885 --- /dev/null +++ b/app/(main)/sitemap/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { LegalPlaceholderPage } from "@/components/LegalPlaceholderPage"; +import { buildPageMetadata } from "@/lib/site-seo"; + +export const metadata: Metadata = buildPageMetadata({ + title: "Sitemap", + description: "Sitemap for The Open Stock.", + path: "/sitemap", +}); + +export default function SitemapPage() { + return ; +} diff --git a/app/(main)/terms-of-use/page.tsx b/app/(main)/terms-of-use/page.tsx new file mode 100644 index 0000000..f9cd4c5 --- /dev/null +++ b/app/(main)/terms-of-use/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import { LegalPlaceholderPage } from "@/components/LegalPlaceholderPage"; +import { buildPageMetadata } from "@/lib/site-seo"; + +export const metadata: Metadata = buildPageMetadata({ + title: "Terms of Use", + description: "Terms of use for The Open Stock.", + path: "/terms-of-use", +}); + +export default function TermsOfUsePage() { + return ; +} diff --git a/components/Footer.tsx b/components/Footer.tsx index 639f4f4..77b46be 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,77 +1,182 @@ "use client"; -import { DNA_BODY, DNA_BODY_SECONDARY, DNA_CAPTION } from "@/lib/design-dna"; -import { normalizeReleaseTag } from "@/lib/release-tag"; -import dynamic from "next/dynamic"; import Link from "next/link"; -import React, { useEffect, useState } from "react"; -import packageJson from "../package.json"; +import type { ReactNode } from "react"; +import { + DNA_BODY, + DNA_CAPTION, + DNA_HEADING, + DNA_SUBHEADING, +} from "@/lib/design-dna"; +import { + FOOTER_CONTACT, + FOOTER_RESOURCES, + FOOTER_SOCIAL, + FOOTER_TOOLS, +} from "@/lib/footer-content"; +import { SITE_NAME } from "@/lib/site-seo"; -const GitHubButton = dynamic(() => import("react-github-btn"), { ssr: false }); +const footerLinkClass = + "inline-block italic uppercase tracking-wide text-stone-300 transition-colors hover:text-stone-50"; + +function ContactIcon({ children }: { children: ReactNode }) { + return ( + + ); +} export function Footer() { const currentYear = new Date().getFullYear(); - const [version, setVersion] = useState(packageJson.version); - const [showGitHubStar, setShowGitHubStar] = useState(false); - - useEffect(() => { - let cancelled = false; - setShowGitHubStar(true); - - fetch("/api/version") - .then((response) => (response.ok ? response.json() : null)) - .then((payload: { version?: string } | null) => { - if (cancelled || !payload?.version) return; - setVersion(normalizeReleaseTag(payload.version)); - }) - .catch(() => { - // Keep package.json version when GitHub is unreachable. - }); - - return () => { - cancelled = true; - }; - }, []); return (
-
- {showGitHubStar ? ( - +
+
+ + {SITE_NAME} + +
+

+ + + + + + {FOOTER_CONTACT.address} +

+

+ + + + + + + {FOOTER_CONTACT.phone} + +

+

+ + + + + + + {FOOTER_CONTACT.email} + +

+

+ + + + + + {FOOTER_CONTACT.taxId} +

+
+
+ +
+

Tools

+
+ {FOOTER_TOOLS.map((item) => ( + + {item.label} + + ))} +
+ -

- v{version} (open alpha, might contain bugs) -

+ - + -

- © {currentYear} The Open Stock -

+
+

+ © {currentYear} {SITE_NAME} +

+
+
+
); } diff --git a/components/LegalPlaceholderPage.tsx b/components/LegalPlaceholderPage.tsx new file mode 100644 index 0000000..d8659aa --- /dev/null +++ b/components/LegalPlaceholderPage.tsx @@ -0,0 +1,9 @@ +import { DNA_DISPLAY, DNA_PAGE_STACK } from "@/lib/design-dna"; + +export function LegalPlaceholderPage({ title }: { title: string }) { + return ( +
+

{title}

+
+ ); +} diff --git a/components/__tests__/Accessibility.test.tsx b/components/__tests__/Accessibility.test.tsx index 659d54e..ca240a9 100644 --- a/components/__tests__/Accessibility.test.tsx +++ b/components/__tests__/Accessibility.test.tsx @@ -124,12 +124,16 @@ describe("Accessibility - Footer (Req 18.4)", () => { render(