feat(site): public landing page, marketing pages and sponsors

- Landing at / (app moved to /dashboard) with a live NYSE session dial and the
  real product preview; About, Help, Architecture and Terms made public and rebuilt.
- /sponsor page with monthly tiers, one-time and custom options, prefilled GitHub
  Sponsors checkout links, and a sponsors band (open slots, previous sponsors) on
  every public page. Siray moved to previous sponsors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ravixalgorithm
2026-09-25 15:28:19 +05:30
co-authored by Claude Opus 5.5
parent 149199bc9c
commit 0aadaaa4db
22 changed files with 1267 additions and 607 deletions
+34 -5
View File
@@ -284,6 +284,19 @@ GEMINI_API_KEY=your_gemini_api_key
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key
# Market data (optional)
# Several free Finnhub keys, rotated per request: each adds 60 requests/min.
# FINNHUB_API_KEYS=key_one,key_two
# "cached" (default) refreshes quotes hourly for everyone; "realtime" refreshes every 15s.
# NEXT_PUBLIC_OPENSTOCK_DATA_MODE=cached
# Social sign-in (optional; each provider is hidden server-side until set)
# Callback URLs: <BETTER_AUTH_URL>/api/auth/callback/google and /api/auth/callback/github
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# Email (Nodemailer via Gmail; consider App Passwords if 2FA)
NODEMAILER_EMAIL=youraddress@gmail.com
NODEMAILER_PASSWORD=your_gmail_app_password
@@ -325,6 +338,19 @@ GEMINI_API_KEY=your_gemini_api_key
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key
# Market data (optional)
# Several free Finnhub keys, rotated per request: each adds 60 requests/min.
# FINNHUB_API_KEYS=key_one,key_two
# "cached" (default) refreshes quotes hourly for everyone; "realtime" refreshes every 15s.
# NEXT_PUBLIC_OPENSTOCK_DATA_MODE=cached
# Social sign-in (optional; each provider is hidden server-side until set)
# Callback URLs: <BETTER_AUTH_URL>/api/auth/callback/google and /api/auth/callback/github
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# Email (Nodemailer via Gmail; consider App Passwords if 2FA)
NODEMAILER_EMAIL=youraddress@gmail.com
NODEMAILER_PASSWORD=your_gmail_app_password
@@ -482,13 +508,16 @@ OpenStock is and will remain free and open for everyone. This project is license
## ❤️ Partners & Backers
## ❤️ Sponsors
<a href="https://www.siray.ai/">
<img src="public/assets/icons/siray.svg" alt="Siray.ai Logo" width="100" />
</a>
OpenStock is open for sponsors. It stays free because people and companies back it, and sponsors are shown on the website, in the app and here. See the tiers on the `/sponsor` page of the site, or [talk with us](mailto:opendevsociety@gmail.com) about a custom partnership.
**[Siray.ai](https://www.siray.ai/)** — The robust AI infrastructure backing OpenStock. Siray.ai ensures our market insights never sleep.
- **Sponsor:** [GitHub Sponsors](https://github.com/sponsors/ravixalgorithm), monthly tiers from $5 or one-time
- **Contribute:** pick a [good first issue](https://github.com/Open-Dev-Society/OpenStock/issues?q=is%3Aopen+label%3A%22good+first+issue%22)
- **Spread the word:** star the repo and share it
**Current sponsors:** your logo here.
**Previously backed by:** [Siray.ai](https://www.siray.ai/) (2026)
## Special thanks
Huge thanks to [Adrian Hajdin (JavaScript Mastery)](https://github.com/adrianhajdin) — his excellent Stock Market App tutorial was instrumental in building OpenStock for the open-source community under the Open Dev Society.
+64
View File
@@ -0,0 +1,64 @@
import Image from 'next/image';
import { Code, Globe, Heart } from 'lucide-react';
import PageHero from '@/components/marketing/PageHero';
import SectionHead from '@/components/marketing/SectionHead';
import IconCard from '@/components/marketing/IconCard';
import { GOOD_FIRST_ISSUES_URL, REPO_URL } from '@/lib/constants';
export const metadata = {
title: 'About | OpenStock',
description: 'The story behind OpenStock and the Open Dev Society.',
};
const PRINCIPLES = [
{ icon: Globe, title: 'Open access', body: 'No premium tiers for core features. Charts, watchlists and alerts stay free for everyone.' },
{ icon: Code, title: 'Open source', body: 'Fully transparent codebase. Audit our algorithms, contribute features, and build with us.' },
{ icon: Heart, title: 'Community driven', body: 'Powered by donations and volunteers. We answer to our users, not shareholders.' },
];
export default function AboutPage() {
return (
<>
<PageHero
kicker="About"
title="Tools for everyone."
sub="We believe financial intelligence shouldn’t be locked behind paywalls. OpenStock is built by the community, for the community."
>
<a href={REPO_URL} target="_blank" rel="noreferrer" className="btn btn-primary h-11 px-5 text-[15px]">See the code</a>
<a href={GOOD_FIRST_ISSUES_URL} target="_blank" rel="noreferrer" className="btn btn-ghost h-11 px-5 text-[15px]">Contribute</a>
</PageHero>
<section className="mx-auto mt-20 max-w-[1200px] px-5">
<SectionHead kicker="Principles" title="What we won’t compromise on." />
<div className="grid gap-3 md:grid-cols-3">
{PRINCIPLES.map(({ icon, title, body }) => <IconCard key={title} icon={icon} title={title}>{body}</IconCard>)}
</div>
</section>
<section className="mx-auto mt-24 max-w-[1200px] px-5">
<div className="hatch">
<div className="card grid items-center gap-10 p-8 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:p-12">
<div className="flex flex-col gap-4">
<p className="kicker text-brand-ink">Open Dev Society</p>
<h2 className="text-[30px] font-bold leading-tight tracking-[-0.04em]">Born from a simple frustration.</h2>
<p className="text-[16px] leading-relaxed text-muted-foreground">
Why are powerful financial tools so expensive? OpenStock is our answer.
</p>
<p className="text-[16px] leading-relaxed text-muted-foreground">
We are a collective of developers, designers and financial enthusiasts working under the Open Dev Society
banner. Our mission is to democratise software by building high-quality, open-source alternatives to
proprietary platforms.
</p>
<a href="https://github.com/Open-Dev-Society" target="_blank" rel="noreferrer" className="btn btn-ghost mt-2 self-start">
Open Dev Society on GitHub
</a>
</div>
<div className="relative grid aspect-square max-h-[340px] place-items-center rounded-[16px] bg-page shadow-[inset_0_0_0_1px_var(--line)]">
<Image src="/assets/icons/odslogo.svg" alt="Open Dev Society" fill className="object-contain p-16 opacity-90" />
</div>
</div>
</div>
</section>
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { BarChart2, Clock, Mail, UserX } from 'lucide-react';
import PageHero from '@/components/marketing/PageHero';
import SectionHead from '@/components/marketing/SectionHead';
import IconCard from '@/components/marketing/IconCard';
import { REPO_URL } from '@/lib/constants';
export const metadata = {
title: 'Architecture | OpenStock',
description: 'How OpenStock works: market data, background jobs and the AI behind the emails.',
};
const DATA_MODES = [
{ name: 'Cached', cadence: 'Hourly', body: 'Default for the public site. Quotes are fetched once an hour and shared by every user, so cost grows with symbols, not people.' },
{ name: 'Realtime', cadence: 'Every 15 seconds', body: 'For OpenStock Cloud and self-hosting. Set NEXT_PUBLIC_OPENSTOCK_DATA_MODE=realtime and on-screen prices refresh live.' },
];
const COVERAGE = [
['US stocks, crypto', 'Live', 'Yes'],
['Forex', 'Live', 'No'],
['Canada, Australia', 'Delayed', 'No'],
['India (BSE), Germany', 'End of day', 'No'],
];
const FLOW = ['A user signs up, or a cron fires', 'Inngest runs the function', 'Gemini writes the text', 'MiniMax takes over if Gemini fails', 'The email goes out'];
const JOBS = [
{ icon: Mail, title: 'Welcome email', trigger: 'On sign-up', body: 'An AI-written intro tailored to the country, goal, risk and industry picked at sign-up.' },
{ icon: BarChart2, title: 'Weekly news', trigger: 'Mon 09:00', body: 'Summarises the week’s market news and sends it as a Kit broadcast.' },
{ icon: Clock, title: 'Price alerts', trigger: 'Every 5 min', body: 'Checks every active alert against Finnhub quotes and emails the owner when one fires.' },
{ icon: UserX, title: 'Re-engagement', trigger: 'Daily 10:00', body: 'Finds dormant accounts and sends a gentle nudge.' },
];
const STACK = [
['Finnhub', 'Quotes, company profiles, search and news. A pool of keys, rotated per request.', 'https://finnhub.io'],
['TradingView', 'Charts, heatmaps, technicals and market widgets, embedded live.', 'https://www.tradingview.com/widget/'],
['Inngest', 'Background jobs and schedules, with retries.', 'https://www.inngest.com'],
['Better Auth', 'Email, Google and GitHub sign-in with MongoDB sessions.', 'https://www.better-auth.com'],
['MongoDB Atlas', 'Users, watchlists and alerts.', 'https://www.mongodb.com/atlas'],
['Kit', 'Newsletter broadcasts for the weekly digest.', 'https://kit.com'],
];
export default function ArchitecturePage() {
return (
<>
<PageHero
kicker="Architecture"
title="How OpenStock works."
sub="A transparent look at the event-driven, multi-provider system behind your market data and emails."
>
<span className="pill h-8 px-3">v1.0.0</span>
<span className="pill h-8 px-3">Gemini with MiniMax fallback</span>
<span className="pill h-8 px-3">Open source · AGPL-3.0</span>
</PageHero>
<section className="mx-auto mt-20 max-w-[1200px] px-5">
<SectionHead kicker="Market data" title="Two data modes, one cache." sub="Charts always stream from TradingView. Everything we price ourselves goes through a shared cache with timeouts, so a slow provider never stalls a page." />
<div className="grid gap-3 md:grid-cols-2">
{DATA_MODES.map(({ name, cadence, body }) => (
<div key={name} className="hatch">
<div className="card flex h-full flex-col gap-2 p-5">
<div className="flex items-center justify-between">
<h3 className="text-[16px] font-bold">{name}</h3>
<span className="pill is-brand">{cadence}</span>
</div>
<p className="text-[14px] leading-relaxed text-muted-foreground">{body}</p>
</div>
</div>
))}
</div>
<div className="hatch mt-3">
<div className="card overflow-x-auto">
<table className="data-table">
<thead><tr><th>Market</th><th>Charts</th><th>Our quotes and alerts</th></tr></thead>
<tbody>
{COVERAGE.map(([market, charts, quotes]) => (
<tr key={market}>
<td className="font-semibold text-foreground">{market}</td>
<td className="text-muted-foreground">{charts}</td>
<td><span className={quotes === 'Yes' ? 'pill is-up' : 'pill'}>{quotes}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<a href={`${REPO_URL}/blob/main/MARKET_SUPPORT.md`} target="_blank" rel="noreferrer" className="btn btn-ghost mt-4">Full market support notes</a>
</section>
<section className="mx-auto mt-24 grid max-w-[1200px] items-start gap-10 px-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div>
<SectionHead kicker="AI" title="Emails that don’t go down with a provider." sub="Welcome emails and news summaries are written by Gemini 2.5 Flash-Lite. If it fails, the request is retried on MiniMax-M3, or any OpenAI-compatible provider you configure." />
</div>
<div className="hatch">
<ol className="card flex flex-col gap-1 p-4">
{FLOW.map((step, i) => (
<li key={step} className="flex items-center gap-3 rounded-[10px] px-2 py-2.5">
<span className="mono grid size-7 flex-none place-items-center rounded-full bg-page text-[12px] text-brand-ink shadow-[inset_0_0_0_1px_var(--line)]">{i + 1}</span>
<span className="font-semibold">{step}</span>
</li>
))}
</ol>
</div>
</section>
<section className="mx-auto mt-24 max-w-[1200px] px-5">
<SectionHead kicker="Background jobs" title="Four jobs keep things moving." />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{JOBS.map(({ icon, title, trigger, body }) => (
<IconCard key={title} icon={icon} title={title} footer={<span className="pill mono">{trigger}</span>}>{body}</IconCard>
))}
</div>
</section>
<section className="mx-auto mt-24 max-w-[1200px] px-5">
<SectionHead kicker="Stack" title="Built on services you can inspect." />
<div className="hatch">
<div className="card row-list">
{STACK.map(([name, body, url]) => (
<a key={name} href={url} target="_blank" rel="noreferrer" className="group flex flex-col gap-1 px-5 py-4 transition-colors hover:bg-hover/40 sm:flex-row sm:items-center sm:gap-6">
<span className="w-40 flex-none font-bold text-foreground group-hover:text-brand-ink">{name}</span>
<span className="text-muted-foreground">{body}</span>
</a>
))}
</div>
</div>
</section>
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Metadata } from 'next';
import { BookOpen, ChevronDown, Github, MessageCircle } from 'lucide-react';
import PageHero from '@/components/marketing/PageHero';
import SectionHead from '@/components/marketing/SectionHead';
import IconCard from '@/components/marketing/IconCard';
import { DISCORD_URL, REPO_URL } from '@/lib/constants';
export const metadata: Metadata = {
title: 'Help | OpenStock',
description: 'Community-driven support for OpenStock. No paywalls, just help.',
};
const FAQS = [
{
question: 'Is OpenStock really free?',
answer: 'Yes. We run on sponsors and community contributions. Core features (charts, watchlists, alerts) stay free. OpenStock Cloud, coming soon, adds live quotes for $5 a month.',
},
{
question: 'How do I add a stock to my watchlist?',
answer: 'Press ⌘K (or Search stocks in the sidebar), open the company, then press Watch. It shows up in your watchlist and in the sidebar.',
},
{
question: 'Where does the market data come from?',
answer: 'Charts come live from TradingView. Quotes, watchlist prices and alerts come from Finnhub; on this site they refresh hourly and every price shows when it was last traded.',
},
{
question: 'Which markets are supported?',
answer: 'US stocks, crypto and forex live; Canada and Australia delayed; India (through BSE) and Germany end of day. Alerts work for US stocks and crypto.',
},
{
question: 'My alert hasn’t fired.',
answer: 'Alerts are checked every five minutes and emailed to your account address when the price crosses your target. Check the alert’s status on the watchlist page.',
},
{
question: 'Can I contribute code or designs?',
answer: 'Absolutely. Issues labelled “good first issue” are a good start. We welcome designers, developers and writers alike.',
},
];
export default function HelpPage() {
return (
<>
<PageHero kicker="Help" title="How can we help?" sub="Community-powered support for everyone." />
<section className="mx-auto mt-14 grid max-w-[1200px] gap-3 px-5 md:grid-cols-3">
<IconCard icon={BookOpen} title="How it works" footer={<a href="/api-docs" className="btn btn-ghost w-full">Read the architecture</a>}>
Data sources, background jobs and the AI behind the emails.
</IconCard>
<IconCard icon={MessageCircle} title="Community chat" footer={<a href={DISCORD_URL} target="_blank" rel="noreferrer" className="btn btn-ghost w-full">Join Discord</a>}>
Get answers from other users and the maintainers.
</IconCard>
<IconCard icon={Github} title="Report a bug" footer={<a href={`${REPO_URL}/issues`} target="_blank" rel="noreferrer" className="btn btn-ghost w-full">Open an issue</a>}>
Found something broken? Tell us on GitHub.
</IconCard>
</section>
<section className="mx-auto mt-24 max-w-[1200px] px-5">
<SectionHead kicker="FAQ" title="Common questions." />
<div className="hatch">
<div className="card row-list overflow-hidden">
{FAQS.map(({ question, answer }) => (
<details key={question} className="group">
{/* Padding lives on the summary so the whole row is the click target, in every browser */}
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 font-semibold text-foreground transition-colors hover:bg-hover/40 [&::-webkit-details-marker]:hidden">
{question}
<ChevronDown className="size-4 flex-none text-faint transition-transform duration-200 group-open:rotate-180" />
</summary>
<p className="max-w-3xl px-5 pb-5 text-[15px] leading-relaxed text-muted-foreground">{answer}</p>
</details>
))}
</div>
</div>
</section>
<section className="mx-auto mt-24 max-w-[1200px] px-5">
<div className="hatch">
<div className="card flex flex-col items-start justify-between gap-5 p-8 md:flex-row md:items-center">
<div>
<h2 className="text-[22px] font-bold tracking-[-0.03em]">Still stuck?</h2>
<p className="mt-1 text-muted-foreground">Our team and community answer emails, for free.</p>
</div>
<a href="mailto:opendevsociety@gmail.com" className="btn btn-primary h-11 px-5 text-[15px]">Email support</a>
</div>
</div>
</section>
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
import Link from "next/link";
import Image from "next/image";
import { Github } from "lucide-react";
import { getSession } from "@/lib/better-auth/auth";
import { formatCount, getRepoStats } from "@/lib/github";
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
import SponsorsBand from "@/components/sponsors/SponsorsBand";
import { DISCORD_URL, REPO_URL } from "@/lib/constants";
const FOOTER_LINKS = [
{ href: '/about', label: 'About' },
{ href: '/help', label: 'Help' },
{ href: '/api-docs', label: 'Architecture' },
{ href: '/terms', label: 'Terms' },
{ href: '/sponsor', label: 'Sponsor' },
{ href: REPO_URL, label: 'GitHub' },
{ href: DISCORD_URL, label: 'Discord' },
{ href: 'https://www.linkedin.com/company/opendevsociety-in/', label: 'LinkedIn' },
];
export default async function MarketingLayout({ children }: { children: React.ReactNode }) {
const [session, repo] = await Promise.all([getSession(), getRepoStats()]);
const signedIn = !!session?.user;
return (
<div className="min-h-dvh bg-page">
<header className="sticky top-0 z-40 border-b border-transparent bg-page/80 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-[1200px] items-center gap-6 px-5">
<Link href="/" aria-label="OpenStock home" className="shrink-0">
<Image src="/assets/images/logo.png" alt="OpenStock" width={140} height={35} priority />
</Link>
<nav className="hidden items-center gap-1 md:flex">
{[['/#inside', 'Product'], ['/#data', 'Data'], ['/#self-host', 'Self-host'], ['/about', 'About']].map(([href, label]) => (
<Link key={href} href={href} className="side-item h-8 font-semibold">{label}</Link>
))}
<Link href="/sponsor" className="side-item h-8 gap-2 font-semibold text-brand-ink hover:text-brand-ink">
<span className="live-dot" /> Sponsor
</Link>
</nav>
<div className="ml-auto flex items-center gap-2">
<a href={REPO_URL} target="_blank" rel="noreferrer" className="btn btn-ghost hidden h-9 px-3 sm:inline-flex" aria-label="OpenStock on GitHub">
<Github />
{repo && <span className="num">{formatCount(repo.stars)}</span>}
</a>
{signedIn ? (
<Link href="/dashboard" className="btn btn-primary h-9">Open dashboard</Link>
) : (
<>
<Link href="/sign-in" className="btn h-9 px-3 text-muted-foreground hover:text-foreground">Sign in</Link>
<Link href="/sign-up" className="btn btn-primary h-9">Get started</Link>
</>
)}
</div>
</div>
</header>
<main>{children}</main>
<SponsorsBand />
<footer className="mx-auto mt-24 flex max-w-[1200px] flex-col gap-6 px-5 pb-10 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-3">
<Image src="/assets/images/logo.png" alt="OpenStock" width={120} height={30} />
<p className="text-[13px] text-faint">© {new Date().getFullYear()} Open Dev Society · AGPL-3.0</p>
</div>
<nav className="flex flex-wrap gap-x-5 gap-y-2 text-[13px] font-semibold text-muted-foreground">
{FOOTER_LINKS.map(({ href, label }) => (
<Link key={label} href={href} className="transition-colors hover:text-foreground" {...(href.startsWith('http') ? { target: '_blank', rel: 'noreferrer' } : {})}>
{label}
</Link>
))}
</nav>
<OpenDevSocietyBranding />
</footer>
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import Link from "next/link";
import { Bell, CandlestickChart, Command, GitPullRequest, Github, Grid3x3, HeartHandshake, MailOpen, MessagesSquare, Star } from "lucide-react";
import { MarketDial, MarketStatus } from "@/components/landing/MarketClock";
import ProductPreview from "@/components/landing/ProductPreview";
import SectionHead from "@/components/marketing/SectionHead";
import IconCard from "@/components/marketing/IconCard";
import { getSession } from "@/lib/better-auth/auth";
import { formatCount, getRepoStats } from "@/lib/github";
import { cn } from "@/lib/utils";
import { DISCORD_URL, GOOD_FIRST_ISSUES_URL, REPO_URL } from "@/lib/constants";
const FEATURES = [
{ icon: CandlestickChart, title: 'Charts that move', body: 'TradingView candles, technicals and financials for any listed company, full screen when you need room.' },
{ icon: Bell, title: 'Alerts by email', body: 'Set a target above or below the price. We check every five minutes and write when it crosses.' },
{ icon: MessagesSquare, title: 'Sentiment in one read', body: 'Buzz and bullishness from Reddit, X.com, news and Polymarket, side by side for each stock.' },
{ icon: Grid3x3, title: 'The whole market', body: 'Sector heatmap, movers and top stories on one screen before the bell.' },
{ icon: Command, title: 'Find anything with ⌘K', body: 'Search every exchange Finnhub covers and open a stock as a tab, like a browser.' },
{ icon: MailOpen, title: 'A Monday digest', body: 'A short, AI-written summary of the week’s news for the stocks you watch.' },
];
const TIERS = [
{
name: 'Community', price: 'Free', cadence: 'Hourly', perHour: 1,
body: 'This site. Quotes refresh every hour and are shared by everyone from the edge. Charts stay live.',
cta: { label: 'Get started', href: '/sign-up' },
},
{
name: 'OpenStock Cloud', price: '$5', per: '/month', tag: 'Coming soon', cadence: 'Every 15 seconds', perHour: 240,
body: 'The same app with live quotes, hosted on a pool of market data keys. Nothing to set up.',
cta: { label: 'Get notified on Discord', href: DISCORD_URL },
},
{
name: 'Self-host', price: 'Free', cadence: 'Your call', perHour: 240,
body: 'Run it on your own server with your own Finnhub keys, realtime for you and your team.',
cta: { label: 'Setup guide', href: '#self-host' },
},
];
const SUPPORT = [
{
icon: HeartHandshake, title: 'Sponsor', primary: true,
body: 'Monthly tiers from $5, a one-time gift, or a custom partnership. Sponsors are shown on this site, in the app and in the README.',
cta: { label: 'See sponsor tiers', href: '/sponsor' },
},
{
icon: GitPullRequest, title: 'Contribute',
body: 'Pick a good first issue, improve the docs or design a screen. Every merged pull request keeps OpenStock moving.',
cta: { label: 'Good first issues', href: GOOD_FIRST_ISSUES_URL },
},
{
icon: Star, title: 'Spread the word',
body: 'Star the repo and share it with someone paying for a terminal they barely use.',
cta: { label: 'Star on GitHub', href: REPO_URL },
},
];
// 60 minutes of updates drawn as ticks: one line per refresh.
const CadenceRail = ({ perHour }: { perHour: number }) => (
<div
className="h-7 rounded-md bg-page shadow-[inset_0_0_0_1px_var(--line)]"
style={{
backgroundImage: perHour > 1
? `repeating-linear-gradient(90deg, var(--brand) 0 1px, transparent 1px calc(100% / ${perHour}))`
: 'linear-gradient(90deg, var(--brand) 0 2px, transparent 2px)',
}}
aria-hidden
/>
);
export default async function LandingPage() {
const [session, repo] = await Promise.all([getSession(), getRepoStats()]);
const start = session?.user ? { href: '/dashboard', label: 'Open dashboard' } : { href: '/sign-up', label: 'Get started free' };
return (
<>
<section className="mx-auto grid max-w-[1200px] items-center gap-12 px-5 pt-14 md:pt-20 lg:grid-cols-[minmax(0,1fr)_320px]">
<div>
<MarketStatus />
<h1 className="mt-6 text-[44px] font-bold leading-[1.02] tracking-[-0.05em] md:text-[68px]">
Markets, minus<br />the paywall.
</h1>
<p className="mt-5 max-w-xl text-[17px] leading-relaxed text-muted-foreground">
OpenStock is an open-source alternative to expensive market platforms. Track prices, set alerts and dig into
company insights, built openly, for everyone.
</p>
<div className="mt-8 flex flex-wrap items-center gap-2">
<Link href={start.href} className="btn btn-primary h-11 px-5 text-[15px]">{start.label}</Link>
<a href={REPO_URL} target="_blank" rel="noreferrer" className="btn btn-ghost h-11 px-5 text-[15px]">
<Github /> Self-host it
{repo && <span className="num text-faint">{formatCount(repo.stars)} stars</span>}
</a>
</div>
</div>
<div className="hatch">
<div className="card px-6 pb-5 pt-6"><MarketDial /></div>
</div>
</section>
<section className="mx-auto mt-16 max-w-[1200px] px-5">
<ProductPreview />
</section>
<section id="inside" className="mx-auto mt-28 max-w-[1200px] scroll-mt-24 px-5">
<SectionHead kicker="What's inside" title="A terminal for people who aren't at a bank." sub="Everything you need to follow the market, in one place, without a subscription." />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map(({ icon, title, body }) => (
<IconCard key={title} icon={icon} title={title}>{body}</IconCard>
))}
</div>
</section>
<section id="data" className="mx-auto mt-28 max-w-[1200px] scroll-mt-24 px-5">
<SectionHead
kicker="Data"
title="Pick how fresh your numbers are."
sub="Charts always stream live from TradingView. Quotes, watchlists and alerts come from Finnhub, and this is how often they update."
/>
<div className="grid gap-3 lg:grid-cols-3">
{TIERS.map((tier) => (
<div key={tier.name} className="hatch">
<div className="card flex h-full flex-col gap-4 p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="text-[18px] font-bold tracking-[-0.02em]">{tier.name}</h3>
{tier.tag && <span className="pill is-brand">{tier.tag}</span>}
</div>
<p className="bento-value text-[30px]">{tier.price}{tier.per && <small className="text-faint"> {tier.per}</small>}</p>
<p className="text-[14px] leading-relaxed text-muted-foreground">{tier.body}</p>
<div className="mt-auto flex flex-col gap-2">
<CadenceRail perHour={tier.perHour} />
<p className="num flex justify-between text-[12px] text-faint">
<span>{tier.cadence}</span>
<span>{tier.perHour} {tier.perHour === 1 ? 'update' : 'updates'} an hour</span>
</p>
</div>
<Link
href={tier.cta.href}
className={cn('btn', tier.name === 'Community' ? 'btn-primary' : 'btn-ghost')}
{...(tier.cta.href.startsWith('http') ? { target: '_blank', rel: 'noreferrer' } : {})}
>
{tier.cta.label}
</Link>
</div>
</div>
))}
</div>
</section>
<section id="self-host" className="mx-auto mt-28 grid max-w-[1200px] scroll-mt-24 items-start gap-10 px-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<div>
<SectionHead
kicker="Self-host"
title="Your server, your keys, live quotes."
sub="Add as many free Finnhub keys as you like: each one adds 60 requests a minute. Switch the data mode to realtime and prices refresh every 15 seconds."
/>
<a href={`${REPO_URL}#docker-setup`} target="_blank" rel="noreferrer" className="btn btn-ghost">Full setup in the README</a>
</div>
<div className="hatch">
<pre className="card mono overflow-x-auto p-5 text-[13px] leading-7 text-muted-foreground">
<span className="text-faint">$ </span><span className="text-foreground">git clone {REPO_URL}.git</span>{'\n'}
<span className="text-faint">$ </span><span className="text-foreground">cd OpenStock</span>{'\n\n'}
<span className="text-faint"># .env (every variable is in the README)</span>{'\n'}
<span className="text-brand-ink">FINNHUB_API_KEYS</span>=key_one,key_two,key_three{'\n'}
<span className="text-brand-ink">NEXT_PUBLIC_OPENSTOCK_DATA_MODE</span>=realtime{'\n\n'}
<span className="text-faint">$ </span><span className="text-foreground">docker compose up -d --build</span>
</pre>
</div>
</section>
<section id="sponsor" className="mx-auto mt-28 max-w-[1200px] scroll-mt-24 px-5">
<SectionHead
kicker="Support"
title="OpenStock is open for sponsors."
sub="It stays free because people and companies back it. Pay what you can, or give time instead."
/>
<div className="grid gap-3 lg:grid-cols-3">
{SUPPORT.map(({ icon, title, body, cta, primary }) => (
<IconCard
key={title}
icon={icon}
title={title}
footer={
<a href={cta.href} {...(cta.href.startsWith('http') ? { target: '_blank', rel: 'noreferrer' } : {})} className={cn('btn w-full', primary ? 'btn-primary' : 'btn-ghost')}>
{cta.label}
</a>
}
>
{body}
</IconCard>
))}
</div>
</section>
<section className="mx-auto mt-28 max-w-[1200px] px-5">
<div className="hatch">
<div className="card grid gap-10 p-8 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] md:p-12">
<div>
<p className="kicker text-brand-ink">Open Dev Society</p>
<blockquote className="mt-4 text-[26px] font-semibold leading-snug tracking-[-0.03em] md:text-[30px]">
Technology should belong to everyone. Knowledge should be open, free, and accessible.
</blockquote>
<div className="mt-8 flex flex-wrap gap-2">
<Link href={start.href} className="btn btn-primary h-11 px-5 text-[15px]">{start.label}</Link>
<Link href="/about" className="btn btn-ghost h-11 px-5 text-[15px]">Read the manifesto</Link>
</div>
</div>
{repo && (
<dl className="grid grid-cols-2 content-end gap-3">
{[
['Stars', formatCount(repo.stars)],
['Forks', formatCount(repo.forks)],
['License', repo.license ?? 'AGPL-3.0'],
['Written in', 'TypeScript'],
].map(([label, value]) => (
<div key={label} className="bento-tile col-span-1 bg-page">
<dt className="kicker">{label}</dt>
<dd className="bento-value text-[26px]">{value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</section>
</>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { Check, HandCoins, MessageCircle, Minus } from "lucide-react";
import { getRepoStats, formatCount } from "@/lib/github";
import { DISCORD_URL, sponsorCheckoutUrl } from "@/lib/constants";
import { ACTIVE_SPONSORS, SPONSOR_CONTACT_EMAIL, SPONSOR_GOAL, SPONSOR_TIERS, type SponsorTierId } from "@/lib/sponsors";
import { cn } from "@/lib/utils";
export const metadata = {
title: 'Sponsor OpenStock',
description: 'Back free, open-source market data. Monthly tiers, one-time gifts, or talk with us about a partnership.',
};
const MAILTO = `mailto:${SPONSOR_CONTACT_EMAIL}?subject=Sponsoring%20OpenStock`;
// Where each tier shows up. Mirrors the perks in lib/sponsors.ts.
const PLACEMENTS: { label: string; from: SponsorTierId }[] = [
{ label: 'Name in the README sponsor list', from: 'backer' },
{ label: 'Name and avatar on the sponsor wall', from: 'supporter' },
{ label: 'Logo in the footer of every public page', from: 'company' },
{ label: 'Logo in the README', from: 'company' },
{ label: 'Logo on the landing page', from: 'partner' },
{ label: 'Sponsor slot in the app sidebar', from: 'partner' },
];
const TIER_ORDER: SponsorTierId[] = ['backer', 'supporter', 'company', 'partner'];
const FAQ = [
['How do I pay?', 'Through GitHub Sponsors, by card or PayPal. Pick a monthly amount or give once.'],
['Can I stop?', 'Yes, any time from your GitHub Sponsors settings.'],
['Can we sponsor as a company?', 'Yes. Sponsor from your organisation’s GitHub account, then send us your logo and link. For invoices or a custom deal, talk with us.'],
];
export default async function SponsorPage() {
const repo = await getRepoStats();
const goalPercent = Math.min(100, (SPONSOR_GOAL.current / SPONSOR_GOAL.target) * 100);
return (
<div className="mx-auto max-w-[1200px] px-5">
<section className="grid items-end gap-10 pt-14 md:pt-20 lg:grid-cols-[minmax(0,1fr)_380px]">
<div>
<p className="kicker flex items-center gap-2 text-brand-ink"><span className="live-dot" /> Open for sponsors</p>
<h1 className="mt-4 text-[44px] font-bold leading-[1.02] tracking-[-0.05em] md:text-[64px]">Back free<br />market data.</h1>
<p className="mt-5 max-w-xl text-[17px] leading-relaxed text-muted-foreground">
OpenStock is an open-source market terminal{repo ? ` with ${formatCount(repo.stars)} stars on GitHub` : ''}.
Sponsors keep it free and independent for everyone who can’t pay for a terminal.
</p>
<div className="mt-8 flex flex-wrap gap-2">
<a href="#tiers" className="btn btn-primary h-11 px-5 text-[15px]">See tiers</a>
<a href={MAILTO} className="btn btn-ghost h-11 px-5 text-[15px]">Talk with us</a>
</div>
</div>
<div className="hatch">
<div className="card flex flex-col gap-4 p-5">
<div className="flex items-baseline justify-between gap-3">
<p className="kicker">Goal</p>
<p className="num text-[13px] text-faint">{SPONSOR_GOAL.current} of {SPONSOR_GOAL.target.toLocaleString('en-US')}</p>
</div>
<p className="text-[20px] font-bold tracking-[-0.02em]">{SPONSOR_GOAL.target.toLocaleString('en-US')} {SPONSOR_GOAL.label}</p>
<div className="h-2.5 overflow-hidden rounded-full bg-page shadow-[inset_0_0_0_1px_var(--line)]" role="progressbar" aria-valuenow={SPONSOR_GOAL.current} aria-valuemax={SPONSOR_GOAL.target}>
<div className="h-full rounded-full bg-brand" style={{ width: `max(${goalPercent}%, 6px)` }} />
</div>
{repo && (
<dl className="grid grid-cols-2 gap-2">
{[['Stars', formatCount(repo.stars)], ['Forks', formatCount(repo.forks)]].map(([label, value]) => (
<div key={label} className="rounded-[12px] bg-page px-3.5 py-3 shadow-[inset_0_0_0_1px_var(--line)]">
<dt className="kicker">{label}</dt>
<dd className="bento-value mt-1 text-[22px]">{value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</section>
<section id="tiers" className="mt-24 scroll-mt-24">
<p className="kicker text-brand-ink">Monthly</p>
<h2 className="mt-2 text-[32px] font-bold tracking-[-0.04em] md:text-[40px]">Pick a tier.</h2>
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{SPONSOR_TIERS.map((tier) => {
const filled = ACTIVE_SPONSORS.filter((s) => s.tier === tier.id).length;
const taken = tier.slots ? filled >= tier.slots : false;
return (
<div key={tier.id} className={cn('hatch', tier.featured && 'shadow-[0_0_0_1px_oklch(0.8_0.13_176/0.45),0_6px_18px_oklch(0_0_0/0.22)]')}>
<div className="card flex h-full flex-col gap-4 p-5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-[17px] font-bold tracking-[-0.02em]">{tier.name}</h3>
{tier.featured && <span className="pill is-brand">Best for teams</span>}
{tier.slots && <span className={cn('pill', !taken && 'is-up')}>{taken ? 'All taken' : `${tier.slots - filled} of ${tier.slots} open`}</span>}
</div>
<p className="bento-value text-[36px]"><small>$</small>{tier.monthly}<small className="text-faint"> /month</small></p>
<p className="text-[14px] leading-relaxed text-muted-foreground">{tier.blurb}</p>
<ul className="flex flex-col gap-2 text-[13.5px]">
{tier.perks.map((perk) => (
<li key={perk} className="flex gap-2">
<Check className="mt-0.5 size-4 flex-none text-brand-ink" />
<span>{perk}</span>
</li>
))}
</ul>
<a
href={taken ? MAILTO : sponsorCheckoutUrl(tier.monthly)}
target={taken ? undefined : '_blank'}
rel="noreferrer"
className={cn('btn mt-auto', tier.featured ? 'btn-primary' : 'btn-ghost')}
>
{taken ? 'Join the waitlist' : `Sponsor $${tier.monthly}/month`}
</a>
</div>
</div>
);
})}
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="hatch">
<div className="card flex h-full flex-col gap-3 p-5 sm:flex-row sm:items-center">
<span className="bento-ico size-10 bg-brand-soft text-brand-ink"><HandCoins className="size-5" /></span>
<div className="flex-1">
<h3 className="text-[16px] font-bold">Give once</h3>
<p className="text-[14px] text-muted-foreground">Any amount, one time, through GitHub Sponsors.</p>
</div>
<a href={sponsorCheckoutUrl(undefined, 'one-time')} target="_blank" rel="noreferrer" className="btn btn-ghost">Give once</a>
</div>
</div>
<div className="hatch">
<div className="card flex h-full flex-col gap-3 p-5 sm:flex-row sm:items-center">
<span className="bento-ico size-10 bg-brand-soft text-brand-ink"><MessageCircle className="size-5" /></span>
<div className="flex-1">
<h3 className="text-[16px] font-bold">Something else in mind?</h3>
<p className="text-[14px] text-muted-foreground">Data or infrastructure credits, a custom placement or a partnership.</p>
</div>
<div className="flex gap-2">
<a href={MAILTO} className="btn btn-primary">Talk with us</a>
<a href={DISCORD_URL} target="_blank" rel="noreferrer" className="btn btn-ghost">Discord</a>
</div>
</div>
</div>
</div>
</section>
<section className="mt-24">
<p className="kicker text-brand-ink">Placements</p>
<h2 className="mt-2 text-[28px] font-bold tracking-[-0.04em] md:text-[34px]">Where your name shows up.</h2>
<div className="hatch mt-8">
<div className="card overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Placement</th>
{TIER_ORDER.map((id) => <th key={id} className="text-center">{SPONSOR_TIERS.find((t) => t.id === id)!.name}</th>)}
</tr>
</thead>
<tbody>
{PLACEMENTS.map(({ label, from }) => (
<tr key={label}>
<td className="font-semibold text-foreground">{label}</td>
{TIER_ORDER.map((id) => (
<td key={id} className="text-center">
{TIER_ORDER.indexOf(id) >= TIER_ORDER.indexOf(from)
? <Check className="mx-auto size-4 text-brand-ink" aria-label="Included" />
: <Minus className="mx-auto size-4 text-line-strong" aria-label="Not included" />}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
<section className="mt-24 grid gap-3 md:grid-cols-3">
{FAQ.map(([q, a]) => (
<div key={q} className="hatch">
<div className="card h-full p-5">
<h3 className="font-bold">{q}</h3>
<p className="mt-2 text-[14px] leading-relaxed text-muted-foreground">{a}</p>
</div>
</div>
))}
</section>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { Metadata } from 'next';
import { AlertTriangle, Check, X } from 'lucide-react';
import PageHero from '@/components/marketing/PageHero';
import SectionHead from '@/components/marketing/SectionHead';
export const metadata: Metadata = {
title: 'Terms of Service | OpenStock',
description: 'Fair, transparent, and open terms for our community.',
};
const PROMISES = [
'Core features will remain free forever.',
'We will never sell your personal data.',
'Terms changes will be discussed openly.',
'You own your watchlists and analysis.',
];
const RULES = {
do: ['Share knowledge freely', 'Use the API for personal projects', 'Respect other members'],
dont: ['Scrape data excessively', 'Share API keys', 'Use OpenStock for high-frequency trading'],
};
export default function TermsPage() {
return (
<>
<PageHero
kicker="Terms · Last updated October 2025"
title="Terms of Service"
sub="Built on trust, transparency and community values. No hidden gotchas, just clear rules."
/>
<section className="mx-auto mt-14 max-w-[1200px] px-5">
<div className="hatch">
<div className="card flex gap-4 p-6 shadow-[inset_0_0_0_1px_oklch(0.76_0.119_70/0.35)]">
<AlertTriangle className="mt-0.5 size-5 flex-none text-warn" />
<div>
<h2 className="font-bold text-warn">Investment disclaimer</h2>
<p className="mt-2 max-w-3xl leading-relaxed text-muted-foreground">
<strong className="text-foreground">OpenStock is an educational and analysis tool, not a financial advisor.</strong>{' '}
Data is provided as is, for information only. Never invest money you cannot afford to lose. Always do your own
research or consult a certified professional before making financial decisions.
</p>
</div>
</div>
</div>
</section>
<section className="mx-auto mt-20 max-w-[1200px] px-5">
<SectionHead kicker="Our promise" title="What you can count on." />
<div className="grid gap-3 sm:grid-cols-2">
{PROMISES.map((promise) => (
<div key={promise} className="hatch">
<div className="card flex h-full items-center gap-3 p-5">
<span className="bento-ico bg-brand-soft text-brand-ink"><Check /></span>
<span className="font-semibold">{promise}</span>
</div>
</div>
))}
</div>
</section>
<section className="mx-auto mt-20 max-w-[1200px] px-5">
<SectionHead kicker="Community rules" title="Do’s and don’ts." />
<div className="grid gap-3 md:grid-cols-2">
{([['Do', RULES.do, Check, 'text-up'], ['Don’t', RULES.dont, X, 'text-down']] as const).map(([title, items, Icon, tone]) => (
<div key={title} className="hatch">
<div className="card h-full p-5">
<h3 className="kicker">{title}</h3>
<ul className="mt-3 flex flex-col gap-2.5">
{items.map((item) => (
<li key={item} className="flex items-center gap-2.5">
<Icon className={`size-4 flex-none ${tone}`} />
<span className="text-muted-foreground">{item}</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
<p className="mt-8 text-muted-foreground">
Questions about these terms? Email <a href="mailto:opendevsociety@gmail.com" className="font-semibold text-brand-ink hover:underline">opendevsociety@gmail.com</a>.
</p>
</section>
</>
);
}
-133
View File
@@ -1,133 +0,0 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import {
Users,
Globe,
Heart,
Code,
Github,
Twitter,
Linkedin,
ArrowRight
} from 'lucide-react';
export const metadata = {
title: 'About Us | OpenStock',
description: 'The story behind OpenStock and the Open Dev Society.',
};
export default function AboutPage() {
return (
<div className="max-w-5xl mx-auto pb-20 px-4">
{/* Hero Section */}
<section className="text-center space-y-8 pt-16 mb-20">
<div className="flex justify-center mb-6">
<div className="p-4 rounded-2xl border border-teal-500/20 backdrop-blur-sm">
<img src="/assets/images/logo.png" alt="Open Dev Society" className="h-10 w-auto" />
</div>
</div>
<h1 className="text-5xl md:text-7xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white via-gray-200 to-gray-500 tracking-tight">
Tools for Everyone.
</h1>
<p className="text-xl md:text-2xl text-gray-400 max-w-3xl mx-auto leading-relaxed font-light">
We believe financial intelligence shouldn't be locked behind paywalls.
OpenStock is built by the community, for the community.
</p>
</section>
{/* Mission Grid */}
<section className="grid md:grid-cols-3 gap-6 mb-24">
<FeatureCard
icon={<Globe className="text-blue-400" />}
title="Open Access"
desc="No premium tiers for core features. Real-time data and insights available to all, forever."
color="blue"
/>
<FeatureCard
icon={<Code className="text-purple-400" />}
title="Open Source"
desc="Fully transparent codebase. Audit our algorithms, contribute features, and build with us."
color="purple"
/>
<FeatureCard
icon={<Heart className="text-red-400" />}
title="Community Driven"
desc="Powered by donations and volunteers. We answer to our users, not shareholders."
color="red"
/>
</section>
{/* Story Section */}
<section className="grid md:grid-cols-2 gap-12 items-center mb-24 bg-gray-900/30 p-8 md:p-12 rounded-3xl border border-gray-800">
<div className="space-y-6">
<h2 className="text-3xl font-bold text-white">The Open Dev Society</h2>
<p className="text-gray-400 leading-relaxed text-lg">
OpenStock was born from a simple frustration: why are powerful financial tools so expensive?
</p>
<p className="text-gray-400 leading-relaxed text-lg">
We are a collective of developers, designers, and financial enthusiasts working under the <span className="text-teal-400 font-semibold">Open Dev Society</span> banner. Our mission is to democratize software by building high-quality, open-source alternatives to proprietary platforms.
</p>
<div className="pt-4">
<Link href="https://github.com/Open-Dev-Society" target="_blank" className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300 font-medium transition-colors group">
Visit our GitHub <ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
</Link>
</div>
</div>
<div className="relative h-[400px] w-full bg-gradient-to-br from-gray-800 to-black rounded-2xl overflow-hidden border border-gray-700 shadow-2xl group">
<Image
src="/assets/icons/odslogo.svg"
alt="Open Dev Society"
fill
className="object-contain p-20 opacity-80 group-hover:scale-105 transition-transform duration-700"
/>
</div>
</section>
{/* Team / Contributors */}
<section className="text-center mb-20">
<h2 className="text-3xl font-bold text-white mb-10">Backed by Amazing Partners</h2>
<div className="flex flex-wrap justify-center items-center gap-8 md:gap-16 opacity-80 grayscale hover:grayscale-0 transition-all duration-500">
<div className="h-8 w-px bg-gray-700"></div>
<Link href="https://www.siray.ai" target="_blank" className="hover:opacity-100 transition-opacity flex items-center gap-2">
<img src="/assets/icons/siray.svg" alt="Siray" className="h-6 w-auto invert brightness-0" />
<span className="text-xl font-bold text-teal-500">Siray.ai</span>
</Link>
<div className="h-8 w-px bg-gray-700"></div>
</div>
</section>
</div>
);
}
function FeatureCard({ icon, title, desc, color }: any) {
const borders: any = {
blue: 'hover:border-blue-500/50',
purple: 'hover:border-purple-500/50',
red: 'hover:border-red-500/50',
};
return (
<div className={`bg-gray-900/50 border border-gray-800 p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 ${borders[color]}`}>
<div className="mb-6 p-3 bg-gray-800 w-fit rounded-xl">{icon}</div>
<h3 className="text-xl font-bold text-white mb-3">{title}</h3>
<p className="text-gray-400 leading-relaxed font-light">{desc}</p>
</div>
);
}
function SocialButton({ href, icon, label }: any) {
return (
<a
href={href}
target="_blank"
className="flex items-center gap-3 px-6 py-3 bg-gray-800 hover:bg-gray-700 text-white rounded-xl transition-all duration-200 border border-gray-700 hover:border-gray-600 font-medium"
>
{icon}
<span>{label}</span>
</a>
);
}
-247
View File
@@ -1,247 +0,0 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import {
Server,
Cpu,
ShieldCheck,
Clock,
Database,
Mail,
BarChart2,
Zap,
ArrowRight,
CheckCircle2,
AlertTriangle
} from 'lucide-react';
export const metadata = {
title: 'API & Architecture | OpenStock',
description: 'Technical documentation for OpenStock architecture, AI integrations, and background jobs.',
};
export default function ApiDocsPage() {
return (
<div className="max-w-5xl mx-auto space-y-16 pb-20">
{/* Hero Section */}
<section className="text-center space-y-6 pt-10">
<div className="flex justify-center items-center gap-4 mb-8">
<div className="bg-gray-800 p-3 rounded-2xl border border-gray-700 shadow-xl">
<img src="/assets/images/logo.png" alt="openstock" className="h-10 w-auto invert brightness-0" />
</div>
<span className="text-gray-600 text-2xl">+</span>
<div className="bg-gray-800 p-3 rounded-2xl border border-gray-700 shadow-xl">
<img src="/assets/icons/siray.svg" alt="Siray" className="h-10 w-auto invert brightness-0" />
</div>
</div>
<h1 className="text-4xl md:text-6xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-gray-400">
OpenStock Architecture
</h1>
<p className="text-xl text-gray-400 max-w-2xl mx-auto leading-relaxed">
A transparent look at the event-driven, multi-provider system powering your market insights.
</p>
<div className="flex flex-wrap justify-center gap-3 pt-2">
<Badge color="green">v1.0.0 Active</Badge>
<Badge color="purple">Gemini + Siray AI</Badge>
<Badge color="blue">Open Source AGPL-3.0</Badge>
</div>
</section>
{/* AI Architecture Section */}
<section className="grid md:grid-cols-2 gap-8 items-start">
<div className="space-y-6">
<div className="flex items-center gap-3">
<Cpu className="text-teal-400 h-8 w-8" />
<h2 className="text-3xl font-bold text-gray-100">Intelligent UI</h2>
</div>
<p className="text-gray-400 leading-relaxed">
We prioritize uptime for generative features (Welcome Emails, News Summaries) using a robust
multi-provider strategy. Our system automatically routes around outages.
</p>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6 space-y-4">
<div className="flex items-start gap-4">
<div className="bg-teal-500/10 p-2 rounded-lg text-teal-400">
<Zap size={20} />
</div>
<div>
<h3 className="text-white font-semibold flex items-center gap-2">
Primary: Google Gemini
<span className="text-[10px] bg-teal-500/10 text-teal-400 px-2 py-0.5 rounded-full border border-teal-500/20">Flash Lite 2.5</span>
</h3>
<p className="text-sm text-gray-500 mt-1">
Handles high-volume inference for news summarization and personalization.
</p>
</div>
</div>
<div className="h-px bg-gray-700 w-full" />
<div className="flex items-start gap-4">
<div className="bg-blue-500/10 p-2 rounded-lg text-blue-400">
<ShieldCheck size={20} />
</div>
<div>
<h3 className="text-white font-semibold flex items-center gap-2">
Fallback: Siray.ai
<span className="text-[10px] bg-blue-500/10 text-blue-400 px-2 py-0.5 rounded-full border border-blue-500/20">Ultra 1.0</span>
</h3>
<p className="text-sm text-gray-500 mt-1">
Instant failover protection. If Gemini wavers, Siray takes over to ensure zero dropped requests.
</p>
</div>
</div>
</div>
</div>
{/* Diagram / Visual */}
<div className="bg-[#0A0A0A] border border-gray-800 rounded-xl p-8 flex flex-col justify-center items-center relative overflow-hidden group">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-teal-900/10 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
{/* Visual Flowchart */}
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm">
<div className="bg-gray-800 text-gray-300 px-4 py-2 rounded-lg text-sm border border-gray-700 w-full text-center">
User Action / Cron Job
</div>
<div className="h-6 w-px bg-gray-700" />
<div className="bg-gray-800 p-4 rounded-xl border border-gray-600 w-full flex flex-col gap-3 relative shadow-2xl">
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-1 h-12 bg-teal-500 rounded-full" />
<span className="text-xs font-mono text-teal-500 mb-1">Inngest Function</span>
<div className="flex items-center justify-between text-sm text-gray-200 bg-black/40 p-2 rounded border border-gray-700">
<span>Attempt Gemini</span>
<CheckCircle2 size={14} className="text-teal-500" />
</div>
<div className="flex items-center justify-between text-sm text-gray-200 bg-blue-900/20 p-2 rounded border border-blue-800/50">
<span className="flex items-center gap-2">
Fallback to Siray
<ShieldCheck size={12} className="text-blue-400" />
</span>
<ArrowRight size={14} className="text-blue-400" />
</div>
</div>
<div className="h-6 w-px bg-gray-700" />
<div className="bg-green-900/20 text-green-400 px-4 py-2 rounded-lg text-sm border border-green-900/50 w-full text-center font-medium">
Content Delivered
</div>
</div>
</div>
</section>
{/* Background Jobs */}
<section>
<div className="flex items-center gap-3 mb-6">
<Server className="text-purple-400 h-8 w-8" />
<h2 className="text-3xl font-bold text-gray-100">Serverless Infrastructure</h2>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
<JobCard
icon={<Mail size={20} />}
title="Sign Up Email"
trigger="Event"
desc="Generates personalized welcome/onboarding email via AI."
color="purple"
/>
<JobCard
icon={<BarChart2 size={20} />}
title="Weekly News"
trigger="Cron: Mon 9am"
desc="Summarizes market news and broadcasts via ConvertKit."
color="teal"
/>
<JobCard
icon={<Clock size={20} />}
title="Stock Alerts"
trigger="Cron: 5m"
desc="Checks user price targets against real-time data."
color="yellow"
/>
<JobCard
icon={<AlertTriangle size={20} />}
title="Re-engagement"
trigger="Cron: Daily"
desc="Identifies dormant users and sends nudges."
color="red"
/>
</div>
</section>
{/* Integration Stack */}
<section className="space-y-6">
<div className="flex items-center gap-3">
<Database className="text-blue-400 h-8 w-8" />
<h2 className="text-3xl font-bold text-gray-100">Tech Stack & Data</h2>
</div>
<div className="grid gap-4">
<StackItem
title="Finnhub"
desc="Real-time quotes, technical indicators, and market news."
url="https://finnhub.io"
/>
<StackItem
title="ConvertKit (Kit)"
desc="High-volume newsletter broadcasts and user tagging."
url="https://kit.com"
/>
<StackItem
title="MongoDB Atlas"
desc="Distributed data on AWS. SRV-bypassed connection for maximum reliability."
url="https://mongodb.com"
/>
</div>
</section>
</div>
);
}
// Helper Components
function Badge({ children, color }: { children: React.ReactNode, color: 'green' | 'purple' | 'blue' }) {
const colors = {
green: 'bg-green-500/10 text-green-400 border-green-500/20',
purple: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
blue: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
};
return (
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${colors[color]}`}>
{children}
</span>
);
}
function JobCard({ icon, title, trigger, desc, color }: any) {
const colorClasses: any = {
purple: 'text-purple-400 bg-purple-500/10 border-purple-500/20 hover:border-purple-500/40',
teal: 'text-teal-400 bg-teal-500/10 border-teal-500/20 hover:border-teal-500/40',
yellow: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20 hover:border-yellow-500/40',
red: 'text-red-400 bg-red-500/10 border-red-500/20 hover:border-red-500/40',
};
return (
<div className={`p-5 rounded-xl border transition-all duration-300 ${colorClasses[color]}`}>
<div className="mb-4">{icon}</div>
<h3 className="font-bold text-gray-100 text-lg mb-1">{title}</h3>
<div className="text-xs font-mono opacity-70 mb-3 uppercase tracking-wider">{trigger}</div>
<p className="text-sm opacity-80 leading-relaxed">{desc}</p>
</div>
);
}
function StackItem({ title, desc, url }: any) {
return (
<Link href={url} target="_blank" className="block group">
<div className="bg-gray-800/40 hover:bg-gray-800 p-6 rounded-xl border border-gray-700 hover:border-gray-600 transition-all flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-gray-200 group-hover:text-teal-400 transition-colors">{title}</h3>
<p className="text-gray-500 mt-1">{desc}</p>
</div>
<ArrowRight className="text-gray-600 group-hover:text-teal-400 transition-colors" />
</div>
</Link>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { Metadata } from 'next';
import {
HelpCircle,
MessageCircle,
BookOpen,
Lightbulb,
Mail,
Github,
ChevronDown
} from 'lucide-react';
export const metadata: Metadata = {
title: 'Help Center | OpenStock',
description: 'Community-driven support for OpenStock. No paywalls, just help.',
};
export default function HelpPage() {
const faqs = [
{
question: "Is OpenStock really free forever?",
answer: "Yes! We run on donations and community contribution. Core features (tracking, alerts, analysis) will remain free. We believe financial tools shouldn't be luxury items."
},
{
question: "How do I add stocks to my watchlist?",
answer: "Use the search bar at the top or in the header to find a company. On the stock's detail page, click the 'Heart' or 'Star' icon to instantly add it to your dashboard."
},
{
question: "Where does the market data come from?",
answer: "We partner with Finnhub and other providers to offer real-time and delayed data. While robust, please use it for analysis rather than high-frequency trading."
},
{
question: "Can I contribute code or designs?",
answer: "Absolutely! Check our GitHub repository. We label issues as 'good first issue' for beginners. We welcome designers, developers, and writers alike."
},
{
question: "My alerts aren't triggering.",
answer: "Alerts run every 5 minutes via our background jobs. Ensure you've confirmed your email address, as we send notifications primarily via email."
}
];
return (
<div className="max-w-4xl mx-auto px-4 pb-20">
{/* Header */}
<div className="text-center pt-16 pb-12 space-y-4">
<div className="inline-flex p-3 bg-blue-500/10 rounded-2xl border border-blue-500/20 mb-4">
<HelpCircle className="text-blue-400 h-8 w-8" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white">How can we help?</h1>
<p className="text-xl text-gray-400">Community-powered support for everyone.</p>
</div>
{/* Quick Action Grid */}
<div className="grid md:grid-cols-3 gap-4 mb-16">
<HelpCard
icon={<BookOpen className="text-teal-400" />}
title="Read Docs"
desc="Deep dive into features and API integration."
link="/api-docs"
linkText="View Documentation"
/>
<HelpCard
icon={<MessageCircle className="text-purple-400" />}
title="Community Chat"
desc="Get real-time answers from other users."
link="https://discord.gg/JkJ8kfxgxB"
linkText="Join Discord"
/>
<HelpCard
icon={<Github className="text-white" />}
title="Report Bugs"
desc="Found an issue? Let our developers know."
link="https://github.com/Open-Dev-Society/OpenStock/issues"
linkText="Open Issue"
/>
</div>
{/* FAQs */}
<div className="space-y-8">
<h2 className="text-2xl font-bold text-white border-b border-gray-800 pb-4">Frequently Asked Questions</h2>
<div className="grid gap-4">
{faqs.map((faq, idx) => (
<div key={idx} className="bg-gray-900/50 border border-gray-800 rounded-xl p-6 hover:bg-gray-800/50 transition-colors">
<h3 className="font-semibold text-lg text-gray-200 mb-2 flex items-start gap-3">
<Lightbulb size={20} className="text-yellow-500/50 mt-1 shrink-0" />
{faq.question}
</h3>
<p className="text-gray-400 leading-relaxed ml-8 pl-1 border-l-2 border-gray-800">
{faq.answer}
</p>
</div>
))}
</div>
</div>
{/* Direct Contact */}
<div className="mt-20 bg-gradient-to-br from-gray-900 to-black border border-gray-800 rounded-2xl p-8 text-center">
<h3 className="text-xl font-bold text-white mb-2">Still stuck?</h3>
<p className="text-gray-400 mb-6">Our team (and community) answers emails, usually entirely for free.</p>
<a
href="mailto:opendevsociety@gmail.com"
className="inline-flex items-center gap-2 bg-white text-black px-6 py-3 rounded-lg font-medium hover:bg-gray-200 transition-colors"
>
<Mail size={18} />
Contact Support
</a>
</div>
</div>
);
}
function HelpCard({ icon, title, desc, link, linkText }: any) {
return (
<div className="bg-gray-900 border border-gray-800 p-6 rounded-xl flex flex-col items-start hover:border-gray-700 transition-colors">
<div className="mb-4 bg-gray-800 p-2 rounded-lg">{icon}</div>
<h3 className="font-bold text-white text-lg mb-2">{title}</h3>
<p className="text-sm text-gray-400 mb-6 flex-grow">{desc}</p>
<a href={link} className="text-teal-400 text-sm font-medium hover:underline flex items-center gap-1">
{linkText} <ChevronDown size={14} className="-rotate-90" />
</a>
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
import { Metadata } from 'next';
import { Shield, FileText, Check, AlertTriangle, Scale } from 'lucide-react';
export const metadata: Metadata = {
title: 'Terms of Service | OpenStock',
description: 'Fair, transparent, and open terms for our community.',
};
export default function TermsPage() {
return (
<div className="max-w-4xl mx-auto px-4 pb-20">
{/* Hero */}
<div className="text-center pt-16 pb-12 space-y-4">
<div className="inline-flex p-3 bg-teal-500/10 rounded-2xl border border-teal-500/20 mb-4">
<Scale className="text-teal-400 h-8 w-8" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white">Terms of Service</h1>
<p className="text-xl text-gray-400 max-w-2xl mx-auto">
Built on trust, transparency, and community values. No hidden gotchas, just clear rules.
</p>
<p className="text-sm text-gray-500">Last updated: October 2025</p>
</div>
<div className="space-y-12">
{/* Core Philosophy */}
<section className="bg-gray-900 border border-gray-800 rounded-2xl p-8">
<h2 className="text-2xl font-bold text-white mb-6 flex items-center gap-2">
<Shield className="text-teal-500" />
Our Promise
</h2>
<div className="grid md:grid-cols-2 gap-6">
<PromiseItem text="Core features will remain free forever." />
<PromiseItem text="We will never sell your personal data." />
<PromiseItem text="Terms changes will be discussed openly." />
<PromiseItem text="You own your watchlists and analysis." />
</div>
</section>
{/* Disclaimer */}
<section className="bg-yellow-900/10 border border-yellow-500/20 rounded-2xl p-8">
<div className="flex items-start gap-4">
<AlertTriangle className="text-yellow-500 shrink-0 mt-1" size={24} />
<div>
<h3 className="text-xl font-bold text-yellow-100 mb-2">Investment Disclaimer</h3>
<p className="text-yellow-200/80 leading-relaxed">
**OpenStock is an educational and analysis tool, not a financial advisor.**
Data is provided "as is" for informational purposes. Never invest money you cannot afford to lose.
Always conduct your own research or consult a certified professional before making financial decisions.
</p>
</div>
</div>
</section>
{/* User Responsibilities */}
<section>
<h2 className="text-2xl font-bold text-white mb-6">Community Rules</h2>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-gray-900 border border-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold text-blue-400 mb-4">✅ Do's</h3>
<ul className="space-y-3 text-gray-400">
<li className="flex gap-2"><Check size={16} className="text-blue-500 mt-1" /> Share knowledge freely</li>
<li className="flex gap-2"><Check size={16} className="text-blue-500 mt-1" /> Use API for personal projects</li>
<li className="flex gap-2"><Check size={16} className="text-blue-500 mt-1" /> Respect other members</li>
</ul>
</div>
<div className="bg-gray-900 border border-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold text-red-400 mb-4">❌ Don'ts</h3>
<ul className="space-y-3 text-gray-400">
<li className="flex gap-2"><span className="text-red-500 font-bold">×</span> Scrape data excessively</li>
<li className="flex gap-2"><span className="text-red-500 font-bold">×</span> Share API keys</li>
<li className="flex gap-2"><span className="text-red-500 font-bold">×</span> Use for high-frequency trading</li>
</ul>
</div>
</div>
</section>
{/* Footer Note */}
<div className="text-center pt-8 border-t border-gray-800">
<p className="text-gray-500">
Questions about these terms? Email us at <a href="mailto:opendevsociety@gmail.com" className="text-teal-400 hover:underline">opendevsociety@gmail.com</a>
</p>
</div>
</div>
</div>
);
}
function PromiseItem({ text }: { text: string }) {
return (
<div className="flex items-center gap-3 bg-gray-800/50 p-4 rounded-lg">
<div className="bg-teal-500/10 p-1 rounded-full">
<Check size={14} className="text-teal-400" />
</div>
<span className="text-gray-300 font-medium">{text}</span>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { AFTER_END, CLOSE, DAY, OPEN, PRE, isWeekday, newYorkNow, sessionOf, type Phase } from "@/lib/market-session";
function useNewYorkClock() {
const [now, setNow] = useState<ReturnType<typeof newYorkNow> | null>(null);
useEffect(() => {
const tick = () => setNow(newYorkNow(new Date()));
tick();
const id = setInterval(tick, 15_000);
return () => clearInterval(id);
}, []);
return now;
}
// The live session line for the hero; renders a stable line until the client knows the time.
export function MarketStatus() {
const now = useNewYorkClock();
const session = now ? sessionOf(now.weekday, now.minutes) : null;
return (
<span className="inline-flex h-8 items-center gap-2 rounded-full bg-card px-3.5 text-[13px] font-semibold text-muted-foreground shadow-[inset_0_0_0_1px_var(--line)]">
<span className={cn('size-1.5 rounded-full', session?.phase === 'open' ? 'bg-up shadow-[0_0_0_3px_oklch(0.70_0.164_150/0.2)]' : session?.phase === 'closed' ? 'bg-faint' : 'bg-warn')} />
<span className="num">{session?.line ?? 'NYSE · New York time'}</span>
</span>
);
}
const R = 88;
const C = 2 * Math.PI * R;
const ARCS: { phase: Phase; from: number; to: number; label: string }[] = [
{ phase: 'pre', from: PRE, to: OPEN, label: 'Pre' },
{ phase: 'open', from: OPEN, to: CLOSE, label: 'Regular' },
{ phase: 'after', from: CLOSE, to: AFTER_END, label: 'After' },
];
// A 24h dial of the New York trading day. Sessions that aren't happening now are hatched.
export function MarketDial() {
const now = useNewYorkClock();
const session = now ? sessionOf(now.weekday, now.minutes) : null;
const weekend = now ? !isWeekday(now.weekday) : false;
const markerAngle = now ? (now.minutes / DAY) * 360 : null;
return (
<figure className="mx-auto w-full max-w-[280px]" aria-label="New York trading sessions">
<div className="relative aspect-square">
<svg viewBox="0 0 220 220" className="size-full -rotate-90">
<defs>
<pattern id="dial-hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="5" height="5" fill="var(--frame)" />
<line x1="0" y1="0" x2="0" y2="5" stroke="var(--line-strong)" strokeWidth="1.4" />
</pattern>
</defs>
<circle cx="110" cy="110" r={R} fill="none" stroke="url(#dial-hatch)" strokeWidth="18" />
{ARCS.map(({ phase, from, to }) => {
const active = !weekend && session?.phase === phase;
return (
<circle
key={phase}
cx="110" cy="110" r={R} fill="none"
strokeWidth="18"
strokeDasharray={`${((to - from) / DAY) * C} ${C}`}
strokeDashoffset={-(from / DAY) * C}
className={cn('transition-[stroke] duration-300', active ? 'stroke-brand' : phase === 'open' ? 'stroke-line-strong' : 'stroke-hover')}
/>
);
})}
{markerAngle !== null && (
<g style={{ transform: `rotate(${markerAngle}deg)`, transformOrigin: '110px 110px' }}>
<line x1={110 + R - 14} y1="110" x2={110 + R + 14} y2="110" stroke="var(--text)" strokeWidth="2.5" strokeLinecap="round" />
</g>
)}
</svg>
<div className="absolute inset-0 grid place-items-center text-center">
<span>
<span className="mono block text-[34px] font-medium tracking-[-0.04em] text-foreground">{now?.time ?? '--:--'}</span>
<span className="kicker">New York</span>
</span>
</div>
</div>
<ul className="mt-4 flex justify-center gap-4 text-[12px] text-faint">
{ARCS.map(({ phase, label }) => (
<li key={phase} className="flex items-center gap-1.5">
<span className={cn('size-2 rounded-full', !weekend && session?.phase === phase ? 'bg-brand' : phase === 'open' ? 'bg-line-strong' : 'bg-hover')} />
{label}
</li>
))}
</ul>
</figure>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { Suspense } from "react";
import { Plus } from "lucide-react";
import IndexPulse, { PulseSkeleton } from "@/components/IndexPulse";
import DataFreshness from "@/components/DataFreshness";
import TradingViewWidget from "@/components/TradingViewWidget";
import { cn } from "@/lib/utils";
const MINI_CHART = 'https://s3.tradingview.com/external-embedding/embed-widget-mini-symbol-overview.js';
const miniChart = (symbol: string) => ({
symbol, locale: 'en', dateRange: '1M', colorTheme: 'dark', isTransparent: true, autosize: true,
trendLineColor: '#47d9bb', underLineColor: 'rgba(71, 217, 187, 0.14)', underLineBottomColor: 'rgba(71, 217, 187, 0)',
});
// The real app chrome with the same live data a signed-in user sees. Used on the landing and auth pages,
// so marketing never shows a stale screenshot.
export default function ProductPreview() {
return (
<div className="overflow-hidden rounded-[18px] bg-side p-[3px] shadow-[0_0_0_1px_oklch(1_0_0/0.05),0_30px_80px_oklch(0_0_0/0.55)]">
<div className="flex items-end gap-1 px-3 pt-2" aria-hidden>
{[['Overview', true], ['AAPL', false], ['NVDA', false]].map(([label, active]) => (
<span
key={label as string}
className={cn('flex h-9 min-w-[104px] items-center rounded-t-xl px-4 font-semibold', active ? 'bg-canvas text-foreground' : 'text-faint', label !== 'Overview' && 'mono text-[13px]')}
>
{label}
</span>
))}
<span className="chrome-btn"><Plus /></span>
</div>
<div className="flex flex-col gap-3 rounded-[15px] bg-canvas p-3 md:p-4">
<section className="hatch">
<div className="section-head">
<div>
<h3 className="section-title">Index pulse</h3>
<p className="section-sub">Tracking ETFs via Finnhub</p>
</div>
<DataFreshness />
</div>
<Suspense fallback={<PulseSkeleton />}>
<IndexPulse />
</Suspense>
</section>
<div className="grid gap-3 md:grid-cols-2">
{['AMEX:SPY', 'NASDAQ:QQQ'].map((symbol) => (
<section key={symbol} className="hatch">
<div className="card overflow-hidden">
<TradingViewWidget scriptUrl={MINI_CHART} config={miniChart(symbol)} height={220} />
</div>
</section>
))}
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import type { LucideIcon } from "lucide-react";
// Hatch shell holding one card: icon well, title, short body, optional footer (a link or a pill).
export default function IconCard({ icon: Icon, title, children, footer }: { icon: LucideIcon; title: string; children: React.ReactNode; footer?: React.ReactNode }) {
return (
<div className="hatch">
<div className="card flex h-full flex-col gap-3 p-5">
<span className="bento-ico size-9 bg-brand-soft text-brand-ink"><Icon className="size-[18px]" /></span>
<h3 className="text-[16px] font-bold tracking-[-0.02em]">{title}</h3>
<div className="text-[14px] leading-relaxed text-muted-foreground">{children}</div>
{footer && <div className="mt-auto pt-1">{footer}</div>}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
// Top of every public page: kicker, one headline, one supporting line, optional actions.
export default function PageHero({ kicker, title, sub, children }: { kicker: string; title: React.ReactNode; sub?: string; children?: React.ReactNode }) {
return (
<section className="mx-auto max-w-[1200px] px-5 pt-14 md:pt-20">
<p className="kicker text-brand-ink">{kicker}</p>
<h1 className="mt-3 max-w-3xl text-[40px] font-bold leading-[1.04] tracking-[-0.05em] md:text-[60px]">{title}</h1>
{sub && <p className="mt-5 max-w-2xl text-[17px] leading-relaxed text-muted-foreground">{sub}</p>}
{children && <div className="mt-8 flex flex-wrap gap-2">{children}</div>}
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
export default function SectionHead({ kicker, title, sub }: { kicker: string; title: string; sub?: string }) {
return (
<div className="mb-8 max-w-2xl">
<p className="kicker text-brand-ink">{kicker}</p>
<h2 className="mt-2 text-[32px] font-bold leading-[1.1] tracking-[-0.04em] md:text-[40px]">{title}</h2>
{sub && <p className="mt-3 text-[16px] text-muted-foreground">{sub}</p>}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from "next/link";
import { ACTIVE_SPONSORS, PREVIOUS_SPONSORS, SPONSOR_CONTACT_EMAIL, SPONSOR_TIERS, type Sponsor } from "@/lib/sponsors";
import { cn } from "@/lib/utils";
const SponsorMark = ({ sponsor, muted }: { sponsor: Sponsor; muted?: boolean }) => (
<a
href={sponsor.url}
target="_blank"
rel="noreferrer"
title={sponsor.name}
className={cn(
'flex h-14 min-w-[140px] items-center justify-center gap-2 rounded-[12px] bg-page px-5 shadow-[inset_0_0_0_1px_var(--line)] transition-opacity',
muted ? 'opacity-55 grayscale hover:opacity-90' : 'hover:opacity-90',
)}
>
{sponsor.logo ? <img src={sponsor.logo} alt={sponsor.name} className="h-6 w-auto" /> : <span className="font-bold">{sponsor.name}</span>}
{muted && sponsor.period && <span className="num text-[11px] text-faint">{sponsor.period}</span>}
</a>
);
// Open slots are real: each one is a tier someone can take today.
const OpenSlot = ({ tier }: { tier: string }) => (
<Link
href="/sponsor#tiers"
className="flex h-14 min-w-[140px] items-center justify-center rounded-[12px] px-5 text-[13px] font-semibold text-faint transition-colors hover:text-brand-ink"
style={{ backgroundImage: 'repeating-linear-gradient(-45deg, oklch(0.4 0.01 95 / 0.22) 0 1px, transparent 1px 5px)', boxShadow: 'inset 0 0 0 1px var(--line)' }}
>
{tier} slot open
</Link>
);
export default function SponsorsBand() {
const openTiers = SPONSOR_TIERS.filter((t) => t.id === 'company' || t.id === 'partner')
.filter((t) => !t.slots || ACTIVE_SPONSORS.filter((s) => s.tier === t.id).length < t.slots);
return (
<section className="mx-auto mt-24 max-w-[1200px] px-5" aria-labelledby="sponsors-band">
<div className="hatch">
<div className="card grid items-center gap-8 p-6 md:p-8 lg:grid-cols-[minmax(0,1fr)_auto]">
<div className="flex min-w-0 flex-col gap-5">
<div>
<p className="kicker flex items-center gap-2 text-brand-ink"><span className="live-dot" /> Open for sponsors</p>
<h2 id="sponsors-band" className="mt-2 text-[24px] font-bold tracking-[-0.03em]">Keep market data free for everyone.</h2>
</div>
<div className="flex flex-col gap-2">
<p className="kicker">Current sponsors</p>
<div className="flex flex-wrap gap-2">
{ACTIVE_SPONSORS.map((s) => <SponsorMark key={s.name} sponsor={s} />)}
{openTiers.map((t) => <OpenSlot key={t.id} tier={t.name} />)}
</div>
</div>
{PREVIOUS_SPONSORS.length > 0 && (
<div className="flex flex-col gap-2">
<p className="kicker">Previously backed by</p>
<div className="flex flex-wrap gap-2">
{PREVIOUS_SPONSORS.map((s) => <SponsorMark key={s.name} sponsor={s} muted />)}
</div>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 lg:flex-col">
<Link href="/sponsor" className="btn btn-primary h-11 px-5 text-[15px]">Become a sponsor</Link>
<a href={`mailto:${SPONSOR_CONTACT_EMAIL}?subject=Sponsoring%20OpenStock`} className="btn btn-ghost h-11 px-5 text-[15px]">Talk with us</a>
</div>
</div>
</div>
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { cache } from "react";
// Real repo numbers for the public site, refreshed daily. Null hides the stat instead of guessing.
export const getRepoStats = cache(async () => {
try {
const res = await fetch('https://api.github.com/repos/Open-Dev-Society/OpenStock', {
next: { revalidate: 86400 },
signal: AbortSignal.timeout(4000),
});
if (!res.ok) return null;
const repo = await res.json();
return { stars: repo.stargazers_count as number, forks: repo.forks_count as number, license: repo.license?.spdx_id as string | undefined };
} catch {
return null;
}
});
export const formatCount = (n: number) =>
new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(n);
+40
View File
@@ -0,0 +1,40 @@
// NYSE trading sessions, in New York minutes-of-day.
// ponytail: exchange holidays and half-days are ignored; add a holiday list if it matters.
export const PRE = 4 * 60, OPEN = 9 * 60 + 30, CLOSE = 16 * 60, AFTER_END = 20 * 60;
export const DAY = 24 * 60;
export type Phase = 'pre' | 'open' | 'after' | 'closed';
export function newYorkNow(date: Date) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', weekday: 'short', hour: 'numeric', minute: 'numeric', hourCycle: 'h23' })
.formatToParts(date).map((p) => [p.type, p.value]),
);
const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(parts.weekday);
return { weekday, minutes: Number(parts.hour) * 60 + Number(parts.minute), time: `${parts.hour.padStart(2, '0')}:${parts.minute}` };
}
export const isWeekday = (d: number) => d >= 1 && d <= 5;
export function sessionOf(weekday: number, minutes: number): { phase: Phase; line: string } {
const until = (target: number) => formatSpan(target - minutes);
if (isWeekday(weekday)) {
if (minutes >= OPEN && minutes < CLOSE) return { phase: 'open', line: `Market open · closes in ${until(CLOSE)}` };
if (minutes >= PRE && minutes < OPEN) return { phase: 'pre', line: `Pre-market · opens in ${until(OPEN)}` };
if (minutes >= CLOSE && minutes < AFTER_END) return { phase: 'after', line: `After hours · ends in ${until(AFTER_END)}` };
if (minutes < PRE) return { phase: 'closed', line: `Market closed · opens in ${until(OPEN)}` };
}
// Next weekday open, counting whole days from today
let days = 1;
while (!isWeekday((weekday + days) % 7)) days++;
return { phase: 'closed', line: `Market closed · opens in ${formatSpan(days * DAY + OPEN - minutes)}` };
}
export function formatSpan(totalMinutes: number) {
const d = Math.floor(totalMinutes / DAY);
const h = Math.floor((totalMinutes % DAY) / 60);
const m = totalMinutes % 60;
if (d > 0) return `${d}d ${h}h`;
return h > 0 ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`;
}
+60
View File
@@ -0,0 +1,60 @@
// Everything sponsor-related lives here: edit this file when someone starts or stops sponsoring.
// Perks only name placements the code actually renders (README, sponsor wall, site footer,
// landing page, in-app sidebar), so the page never promises something the site doesn't do.
export type Sponsor = {
name: string;
url: string;
logo?: string; // white/transparent logo in /public, shown on dark surfaces
tier?: SponsorTierId;
period?: string; // e.g. "2026"
};
export type SponsorTierId = 'backer' | 'supporter' | 'company' | 'partner';
// Partners share the app sidebar; the Partner tier sells exactly this many slots.
export const SIDEBAR_SPONSOR_SLOTS = 3;
export const SPONSOR_TIERS: {
id: SponsorTierId;
name: string;
monthly: number;
blurb: string;
perks: string[];
slots?: number;
featured?: boolean;
}[] = [
{
id: 'backer', name: 'Backer', monthly: 5,
blurb: 'For people who use OpenStock and want it to stay free.',
perks: ['Your name in the README sponsor list', 'Our thanks, every month'],
},
{
id: 'supporter', name: 'Supporter', monthly: 25,
blurb: 'For regulars who want to be seen backing open source.',
perks: ['Everything in Backer', 'Name and avatar on the sponsor wall', 'Listed on this page'],
},
{
id: 'company', name: 'Company', monthly: 100, featured: true,
blurb: 'For teams and products that reach developers and investors.',
perks: ['Logo in the footer of every public page', 'Logo in the README', 'Logo on this page, linked to your site'],
},
{
id: 'partner', name: 'Partner', monthly: 500, slots: SIDEBAR_SPONSOR_SLOTS,
blurb: 'The most visible spots in OpenStock, limited to three.',
perks: ['Everything in Company', 'One of three sponsor slots in the app sidebar, seen by every signed-in user', 'Logo on the landing page'],
},
];
export const ACTIVE_SPONSORS: Sponsor[] = [];
export const PREVIOUS_SPONSORS: Sponsor[] = [
{ name: 'Siray.ai', url: 'https://www.siray.ai', logo: '/assets/icons/siray.svg', period: '2026' },
];
// Counted from GitHub Sponsors; individual sponsors stay anonymous unless they sponsor publicly.
export const SPONSOR_GOAL = { label: 'monthly sponsors', target: 1000, current: 1 };
export const SPONSOR_CONTACT_EMAIL = 'opendevsociety@gmail.com';
export const sidebarSponsors = () => ACTIVE_SPONSORS.filter((s) => s.tier === 'partner').slice(0, SIDEBAR_SPONSOR_SLOTS);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB