diff --git a/.gitignore b/.gitignore index 8ae2a21..d9d9a20 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ pnpm-lock.yaml /playwright-report/ /test-results/ /playwright/.cache/ +.lighthouseci/ # Vercel .vercel 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)/layout.tsx b/app/(main)/layout.tsx index e4ce58f..25613b4 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,10 +1,19 @@ "use client"; +import dynamic from "next/dynamic"; import { Suspense } from "react"; -import { Footer } from "@/components/Footer"; +import { LazySection } from "@/components/LazySection"; import { Navigation } from "@/components/Navigation"; import { HOME_PAGE_BACKGROUND } from "@/lib/home-ui"; +const Footer = dynamic( + () => import("@/components/Footer").then((mod) => ({ default: mod.Footer })), + { + ssr: false, + loading: () =>
, + } +); + function NavigationFallback() { 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)/ranking/layout.tsx b/app/(main)/ranking/layout.tsx new file mode 100644 index 0000000..54e64c8 --- /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 buy and sell lists for ETFs, crypto, and stocks across 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)/ranking/page.tsx b/app/(main)/ranking/page.tsx new file mode 100644 index 0000000..9005068 --- /dev/null +++ b/app/(main)/ranking/page.tsx @@ -0,0 +1,154 @@ +"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_DISPLAY, + DNA_PAGE_STACK, +} from "@/lib/design-dna"; +import { HOME_SUBTLE_TEXT } from "@/lib/home-ui"; +import type { + AIRankingCategory, + AIRankingTimeframe, + 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 [category, setCategory] = useState("stock"); + 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(() => { + let cancelled = false; + + const load = async () => { + if (!hasAIAccess) { + setData(null); + setLoadError(null); + setLoading(false); + return; + } + + setLoading(true); + setData(null); + try { + const result = await fetchAIStockRankingsForCurrentProvider( + timeframe, + category, + pricingTier + ); + if (cancelled) return; + setData(result); + setLoadError(null); + } catch (err) { + if (cancelled) return; + setData(null); + setLoadError( + err instanceof Error + ? err.message + : MARKET_UI_COPY.load.aiStockRankings + ); + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [hasAIAccess, aiProviderVersion, pricingTier, timeframe, category]); + + 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 ( +
+
+

Ranking

+

+ Ranked buy and sell lists for ETFs, crypto, and stocks across short, + medium, and long horizons — each row includes a clear rationale. +

+

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

+
+ + +
+ ); +} 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)/stock-of-the-day/page.tsx b/app/(main)/stock-of-the-day/page.tsx index 0182cc7..b23ac1f 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:{" "} + + Ranking + +

; +} diff --git a/app/api/market/ranking/route.ts b/app/api/market/ranking/route.ts new file mode 100644 index 0000000..2e72c9c --- /dev/null +++ b/app/api/market/ranking/route.ts @@ -0,0 +1,208 @@ +import { NextRequest, NextResponse } from "next/server"; +import { MARKET_UI_COPY, userFacingApiError } from "@/lib/api-user-error"; +import { + isAIRankingCategory, + isAIRankingTimeframe, + parseAIRankingCandidates, + rankingIncompleteCandidatesMessage, +} from "@/lib/ai-stock-rankings"; +import { aiMarketInsightsService } from "@/services/ai-market-insights.service"; +import { logger } from "@/lib/logger"; +import { getAuthenticatedUser } from "@/lib/server-auth"; +import { subscriptionService } from "@/services/subscription.service"; +import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config"; + +export async function GET(request: NextRequest) { + try { + const auth = await getAuthenticatedUser(request); + if (!auth) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + const tier = await subscriptionService.getCurrentTier(auth.id); + if (!["LOCAL", "BYOK", "HOSTED_AI"].includes(tier)) { + return NextResponse.json( + { success: false, error: "Paid AI tier required" }, + { status: 403 } + ); + } + + const timeframeRaw = + request.nextUrl.searchParams.get("timeframe")?.trim().toLowerCase() ?? + "short"; + if (!isAIRankingTimeframe(timeframeRaw)) { + return NextResponse.json( + { + success: false, + error: "Invalid timeframe. Use short, medium, or long.", + }, + { status: 400 } + ); + } + + const categoryRaw = + request.nextUrl.searchParams.get("category")?.trim().toLowerCase() ?? + "stock"; + if (!isAIRankingCategory(categoryRaw)) { + return NextResponse.json( + { + success: false, + error: "Invalid category. Use etf, crypto, or stock.", + }, + { status: 400 } + ); + } + + const requestedProviderRaw = + request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? ""; + + const resolved = await resolveMarketRouteLLMConfig({ + tier, + userId: auth.id, + requestedProviderRaw, + }); + if (!resolved.ok) { + logger.warn("Ranking: no LLM credentials", { + userId: auth.id, + tier, + detail: resolved.error, + }); + return NextResponse.json( + { + success: false, + error: + resolved.error ?? + "AI is available on your plan, but this deployment has no active provider configuration for rankings.", + timestamp: new Date(), + }, + { status: 400 } + ); + } + + const data = await aiMarketInsightsService.getAIStockRankings( + timeframeRaw, + categoryRaw, + resolved.llmConfig + ); + + return NextResponse.json({ + success: true, + data, + timestamp: new Date(), + }); + } catch (error) { + logger.error("Failed to load Ranking", error as Error); + + return NextResponse.json( + { + success: false, + error: userFacingApiError(error, MARKET_UI_COPY.load.aiStockRankings), + timestamp: new Date(), + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const auth = await getAuthenticatedUser(request); + if (!auth) { + return NextResponse.json( + { success: false, error: "Authentication required" }, + { status: 401 } + ); + } + + const tier = await subscriptionService.getCurrentTier(auth.id); + if (!["LOCAL", "BYOK"].includes(tier)) { + return NextResponse.json( + { success: false, error: "Local or BYOK AI tier required" }, + { status: 403 } + ); + } + + const body = (await request.json().catch(() => null)) as { + timeframe?: unknown; + category?: unknown; + buyCandidates?: unknown; + sellCandidates?: 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 categoryRaw = + typeof body?.category === "string" + ? body.category.trim().toLowerCase() + : "stock"; + if (!isAIRankingCategory(categoryRaw)) { + return NextResponse.json( + { + success: false, + error: "Invalid category. Use etf, crypto, or stock.", + }, + { status: 400 } + ); + } + + const buyCandidates = parseAIRankingCandidates( + body?.buyCandidates, + categoryRaw + ); + const sellCandidates = parseAIRankingCandidates( + body?.sellCandidates, + categoryRaw + ); + if (buyCandidates.length < 4 || sellCandidates.length < 4) { + return NextResponse.json( + { + success: false, + error: rankingIncompleteCandidatesMessage(categoryRaw), + timestamp: new Date(), + }, + { status: 400 } + ); + } + + const data = await aiMarketInsightsService.enrichAIStockRankingsCandidates( + timeframeRaw, + categoryRaw, + { buyCandidates, sellCandidates } + ); + + return NextResponse.json({ + success: true, + data, + timestamp: new Date(), + }); + } catch (error) { + logger.error("Failed to validate local 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..d3b906c --- /dev/null +++ b/components/AIStockRankingsPanel.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import Link from "next/link"; +import { DNA_BODY, DNA_EYEBROW, DNA_LABEL_STRONG } from "@/lib/design-dna"; +import { + AI_RANKING_TIMEFRAMES, + getAIRankingCategoryLabel, + type AIRankingTimeframe, +} from "@/lib/ai-stock-rankings"; +import type { + AIRankedStock, + AIRankingCategory, + AIStockRankingsResult, + PricingTier, +} from "@/types"; +import { HeatmapNavigation } from "@/components/HeatmapNavigation"; +import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux"; +import { InsightPanel, InsightPanelHeader } from "@/components/InsightPanel"; +import { SubscriptionGate } from "@/components/ProductShell"; +import { AiFeatureErrorNotice } from "@/components/AiFeatureErrorNotice"; +import { + HOME_INSTRUMENT_PANEL, + HOME_MUTED_TEXT, + HOME_PRIMARY_BUTTON, + HOME_SEGMENTED_NAV, + HOME_SUBTLE_TEXT, + homeSegmentedTabClasses, + rankingTabSublabelClasses, +} from "@/lib/home-ui"; +import { MARKET_DOWN_TEXT, MARKET_UP_TEXT } from "@/lib/market-semantics"; +import { useTheme } from "@/lib/theme-context"; + +interface AIStockRankingsPanelProps { + data: AIStockRankingsResult | null; + loading: boolean; + locked: boolean; + error?: string | null; + pricingTier?: PricingTier | null; + category: AIRankingCategory; + onCategoryChange: (category: AIRankingCategory) => void; + timeframe: AIRankingTimeframe; + onTimeframeChange: (timeframe: AIRankingTimeframe) => void; +} + +function formatRankingRationale(rationale: string[]): string { + return rationale.filter(Boolean).join(" "); +} + +function RankingSideTable({ + title, + variant, + stocks, + category, + timeframe, +}: { + title: string; + variant: "buy" | "sell"; + stocks: AIRankedStock[]; + category: AIRankingCategory; + timeframe: AIRankingTimeframe; +}) { + const borderClass = + variant === "buy" + ? "border-l-emerald-600 dark:border-l-emerald-500" + : "border-l-rose-600 dark:border-l-rose-500"; + const stanceClass = variant === "buy" ? MARKET_UP_TEXT : MARKET_DOWN_TEXT; + + return ( +
+
+

{title}

+
+
+ + + + + + + + + + + {stocks.map((stock) => ( + + + + + + + ))} + +
+ Rank + + Symbol + + Name + + Why +
+ #{stock.rank} + + {stock.symbol} + + {stock.name} + + {formatRankingRationale(stock.rationale)} +
+
+
+ ); +} + +export function AIStockRankingsPanel({ + data, + loading, + locked, + error, + pricingTier, + category, + onCategoryChange, + 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 categoryLabel = getAIRankingCategoryLabel(category); + + 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 {categoryLabel} buy and sell ideas for the{" "} + {activeHorizon.toLowerCase()} horizon... +

+ )} + + {!loading && data && ( +
+ + +
+ )} + + {!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/Footer.tsx b/components/Footer.tsx index 639f4f4..ea50896 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -1,77 +1,283 @@ "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 { useEffect, useRef, useState, type ReactNode } from "react"; +import { DNA_HEADING } from "@/lib/design-dna"; +import { + FOOTER_CONTACT, + FOOTER_RESOURCES, + FOOTER_SOCIAL, + FOOTER_TOOLS, + type FooterSocialId, +} 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 text-stone-200 transition-colors hover:text-stone-50"; + +const footerHeadingClass = `${DNA_HEADING} text-stone-100 uppercase tracking-wide`; + +const footerBodyClass = "text-sm leading-relaxed text-stone-200"; + +function ContactIcon({ children }: { children: ReactNode }) { + return ( + + ); +} + +function FooterSocialIcon({ id }: { id: FooterSocialId }) { + const className = "h-6 w-6 fill-current"; + + switch (id) { + case "discord": + return ( + + ); + case "facebook": + return ( + + ); + case "github": + return ( + + ); + case "instagram": + return ( + + ); + case "linkedin": + return ( + + ); + case "medium": + return ( + + ); + case "x": + return ( + + ); + case "youtube": + return ( + + ); + } +} + export function Footer() { const currentYear = new Date().getFullYear(); - const [version, setVersion] = useState(packageJson.version); + const starSlotRef = useRef(null); 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; - }; + const slot = starSlotRef.current; + if (!slot) return; + + if (typeof IntersectionObserver === "undefined") { + setShowGitHubStar(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setShowGitHubStar(true); + observer.disconnect(); + } + }, + { rootMargin: "120px" } + ); + + observer.observe(slot); + return () => observer.disconnect(); }, []); return (
-
- {showGitHubStar ? ( - +
+ {showGitHubStar ? ( + + Star + + ) : null} +
+ +
+
+ + {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/HomeHub.tsx b/components/HomeHub.tsx index ba6a461..a5577fe 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 + + + Ranking + +
+
{stockOfTheDay} 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(