Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ pnpm-lock.yaml
/playwright-report/
/test-results/
/playwright/.cache/
.lighthouseci/

# Vercel
.vercel
Expand Down
13 changes: 13 additions & 0 deletions app/(main)/copyrights/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LegalPlaceholderPage title="Copyrights" />;
}
15 changes: 13 additions & 2 deletions app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div className="min-h-[200px]" aria-hidden />,
}
);

function NavigationFallback() {
return (
<nav
Expand All @@ -31,7 +40,9 @@ export default function MainLayout({
{children}
</div>

<Footer />
<LazySection className="mt-8 sm:mt-12">
<Footer />
</LazySection>
</div>
);
}
13 changes: 13 additions & 0 deletions app/(main)/privacy-and-security/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LegalPlaceholderPage title="Privacy & Security" />;
}
17 changes: 17 additions & 0 deletions app/(main)/ranking/layout.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
154 changes: 154 additions & 0 deletions app/(main)/ranking/page.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean | null>(
null
);
const hasTierAccess =
pricingTier === "LOCAL" ||
pricingTier === "BYOK" ||
pricingTier === "HOSTED_AI";
const hasAIAccess = hasTierAccess || serverBYOKAccess === true;

const [category, setCategory] = useState<AIRankingCategory>("stock");
const [timeframe, setTimeframe] = useState<AIRankingTimeframe>("short");
const [data, setData] = useState<AIStockRankingsResult | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
};

load();

return () => {
cancelled = true;
};
}, [hasAIAccess, aiProviderVersion, pricingTier, timeframe, category]);
Comment thread
cursor[bot] marked this conversation as resolved.

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 (
<div className={DNA_PAGE_STACK} data-testid="ranking-page">
<header className="space-y-2">
<h1 className={DNA_DISPLAY}>Ranking</h1>
<p className={DNA_BODY_SECONDARY}>
Ranked buy and sell lists for ETFs, crypto, and stocks across short,
medium, and long horizons — each row includes a clear rationale.
</p>
<p className={`text-xs ${HOME_SUBTLE_TEXT}`}>
Related:{" "}
<Link
href="/stock-of-the-day"
className="underline underline-offset-2"
>
Stock of the day
</Link>
</p>
</header>

<AIStockRankingsPanel
data={data}
loading={loading}
locked={!hasAIAccess}
error={loadError}
pricingTier={pricingTier}
category={category}
onCategoryChange={setCategory}
timeframe={timeframe}
onTimeframeChange={setTimeframe}
/>
</div>
);
}
13 changes: 13 additions & 0 deletions app/(main)/sitemap/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LegalPlaceholderPage title="Sitemap" />;
}
8 changes: 8 additions & 0 deletions app/(main)/stock-of-the-day/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"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";
import { fetchStockOfTheDayForCurrentProvider } from "@/lib/local-ollama-stock-of-the-day";
import { MARKET_UI_COPY } from "@/lib/market-ui-copy";
import {
DNA_BODY_SECONDARY,
DNA_CAPTION,
DNA_DISPLAY,
DNA_PAGE_STACK,
} from "@/lib/design-dna";
Expand Down Expand Up @@ -100,6 +102,12 @@ export default function StockOfTheDayPage() {
<p className={DNA_BODY_SECONDARY}>
AI-ranked daily opportunity across stocks and select liquid assets.
</p>
<p className={DNA_CAPTION}>
Related:{" "}
<Link href="/ranking" className="underline underline-offset-2">
Ranking
</Link>
</p>
</header>

<StockOfTheDayPanel
Expand Down
13 changes: 13 additions & 0 deletions app/(main)/terms-of-use/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LegalPlaceholderPage title="Terms of Use" />;
}
Loading
Loading