diff --git a/.env.example b/.env.example index 515e0c6..b456edf 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,9 @@ AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= + +# Stripe (optional) — astro coin purchases on /dashboard +# Dashboard: https://dashboard.stripe.com/apikeys +STRIPE_SECRET_KEY= +# Webhook signing secret (Stripe CLI local: stripe listen --forward-to localhost:8066/api/stripe/webhook) +STRIPE_WEBHOOK_SECRET= diff --git a/Makefile b/Makefile index 616d0d1..c6bed57 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,35 @@ -.PHONY: dev server generate build start lint typecheck test coverage check install clean +.PHONY: dev server generate migrate migrate-dev predict-build build start lint typecheck test coverage check install clean -# Development server (alias: dev) +# Development server. Stops whatever is bound to 8066 (usually a stray `next dev`) +# and clears a stale Turbopack lock so a fresh server can start. dev: + @for pid in $$(lsof -ti:8066 2>/dev/null); do kill $$pid 2>/dev/null || true; done + @sleep 0.4 + @rm -f .next/dev/lock npm run dev # Prisma generate: npx prisma generate +# Generate predict runtime JSON from source strings. +predict-build: + npm run predict:build + +# Apply pending migrations (production / CI). Requires DIRECT_URL or DATABASE_URL in .env. +migrate: + npx prisma migrate deploy + +# Create/apply migrations in development (interactive). Requires DIRECT_URL or DATABASE_URL. +migrate-dev: + npx prisma migrate dev + # Build and run build: npx prisma generate npm run build -server: +server:build npm run start # Lint and typecheck diff --git a/README.md b/README.md index a32faba..de3d073 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,59 @@ -# 🔮 future +# Future AI + +*Natal charts — Western astrology, whole sign houses, tropical zodiac.* + +Next.js app: birth date, time, and place → planetary positions, houses, chart wheel, and placement tables. + +## Quick start + +```bash +make install +make dev +``` + +Open [http://localhost:8066](http://localhost:8066). + +## Development vs production + +| Command | What it does | +|--------|----------------| +| `make dev` | `next dev` — serves **current source** (hot reload). | +| `make build` | Production build into `.next` (runs `prisma generate` first). | +| `make server` | `next start` — serves the **last** production build only. | + +`make server` does not compile your latest edits. Run `make build` before `make server` whenever you want production mode to match recent changes: + +```bash +make build && make server +``` + +Both dev and production use **port 8066**; only one can listen at a time. + +## Common commands + +- `make dev` — development server +- `make build` — production build +- `make server` — run production server (after `make build`) +- `make lint` / `make typecheck` / `make test` — quality checks (`make check` runs all three) +- `make generate` — Prisma client from schema +- `make migrate` — apply migrations (needs `DATABASE_URL` / `DIRECT_URL` in `.env`) +- `make migrate-dev` — create/apply migrations in development +- `make clean` — remove `.next` and `node_modules` + +Stripe and Astro Coins setup: [docs/STRIPE.md](docs/STRIPE.md). + +## Stack + +Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4, Prisma, Vitest. + +
+ +--- + +## TODO
-### *"ancient wisdom meets the AI age"* +- training / fine-tuning the AI for astro +- infra, deplopyment, etc. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 9abba61..0000000 --- a/TODO.md +++ /dev/null @@ -1,15 +0,0 @@ -## TODO - -
- -- add trine, square, opposition, sextile, and square to the charts, to the compatibility ppage and to influcnces -- predict page -- grab actually astronomical news -- add a "learn" page, and a course for whoever is subscribed -- fine tune compatibility between two charts -- tarot mapping to charts -- add news and subscription -- add login with web3 -- add alerting for important event by personal chart (for subscribed users) -- add a tiny $2-4 subscription - diff --git a/app/api/astro-coins/ledger/route.ts b/app/api/astro-coins/ledger/route.ts new file mode 100644 index 0000000..41b6fc7 --- /dev/null +++ b/app/api/astro-coins/ledger/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { prisma } from '@/lib/db'; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export async function GET(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const rawLimit = searchParams.get('limit'); + let limit = DEFAULT_LIMIT; + if (rawLimit != null) { + const n = Number.parseInt(rawLimit, 10); + if (Number.isFinite(n) && n > 0) { + limit = Math.min(n, MAX_LIMIT); + } + } + + const entries = await prisma.astroCoinLedger.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + delta: true, + balanceAfter: true, + reason: true, + refId: true, + createdAt: true, + }, + }); + + return NextResponse.json({ entries }); +} diff --git a/app/api/astro-coins/route.ts b/app/api/astro-coins/route.ts new file mode 100644 index 0000000..6cd4b66 --- /dev/null +++ b/app/api/astro-coins/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { prisma } from '@/lib/db'; +import { getStripeSecretKey } from '@/lib/stripe-server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { astroCoins: true }, + }); + + if (!user) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return NextResponse.json({ + coins: user.astroCoins, + stripeEnabled: Boolean(getStripeSecretKey()), + }); + } catch (err) { + console.error('[astro-coins] GET', err); + return NextResponse.json({ error: 'Database error' }, { status: 500 }); + } +} diff --git a/app/api/charts/route.ts b/app/api/charts/route.ts index ad88596..bc129d5 100644 --- a/app/api/charts/route.ts +++ b/app/api/charts/route.ts @@ -34,7 +34,7 @@ export async function POST(request: Request) { typeof body.label === 'string' ? body.label.trim() : isPrimary - ? 'My chart' + ? 'my chart' : ''; const birthData = body.birthData; const chartResult = body.chartResult; diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 1cec7de..72c5bf8 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -55,7 +55,12 @@ export async function POST(request: Request) { if (!res.ok) { const err = await res.text(); - console.error('Anthropic API error', res.status, err); + const requestId = res.headers.get('request-id') ?? res.headers.get('x-request-id'); + console.error('Anthropic API error', { + status: res.status, + requestId, + details: process.env.NODE_ENV === 'development' ? err : undefined, + }); return NextResponse.json( { error: diff --git a/app/api/predict/bet/route.ts b/app/api/predict/bet/route.ts new file mode 100644 index 0000000..a59c1ed --- /dev/null +++ b/app/api/predict/bet/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { + applyAstroCoinDelta, + InsufficientAstroCoinsError, +} from '@/lib/astro-coins-ledger'; +import { isValidPredictQuestionId, normalizePredictBetSide } from '@/lib/predict-validate'; +import { prisma } from '@/lib/db'; + +const MAX_COINS_PER_BET = 1_000_000; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const questionId = + typeof body === 'object' && + body !== null && + 'questionId' in body && + typeof (body as { questionId: unknown }).questionId === 'number' + ? (body as { questionId: number }).questionId + : Number.NaN; + + const sideRaw = + typeof body === 'object' && + body !== null && + 'side' in body && + typeof (body as { side: unknown }).side === 'string' + ? (body as { side: string }).side.trim() + : ''; + + const coinsRaw = + typeof body === 'object' && + body !== null && + 'coins' in body && + typeof (body as { coins: unknown }).coins === 'number' + ? (body as { coins: number }).coins + : Number.NaN; + + const coins = Math.floor(Number(coinsRaw)); + if (!Number.isFinite(coins) || coins < 1 || coins > MAX_COINS_PER_BET) { + return NextResponse.json( + { error: `Invest between 1 and ${MAX_COINS_PER_BET} coins` }, + { status: 400 }, + ); + } + + const normalizedSide = normalizePredictBetSide(questionId, sideRaw); + if (normalizedSide === null || !isValidPredictQuestionId(questionId)) { + return NextResponse.json( + { error: 'Unknown question or invalid choice' }, + { status: 400 }, + ); + } + + const refId = `${questionId}:${normalizedSide}`; + + try { + const balance = await prisma.$transaction(async tx => { + const afterDebit = await applyAstroCoinDelta( + tx, + session.user.id, + -coins, + 'predict_bet', + refId, + ); + await tx.predictBet.create({ + data: { + userId: session.user.id, + questionId, + side: normalizedSide, + coins, + }, + }); + return afterDebit; + }); + return NextResponse.json({ balance }); + } catch (e) { + if (e instanceof InsufficientAstroCoinsError) { + return NextResponse.json( + { error: 'Not enough astro coins' }, + { status: 402 }, + ); + } + throw e; + } +} diff --git a/app/api/predict/bets/[id]/add/route.ts b/app/api/predict/bets/[id]/add/route.ts new file mode 100644 index 0000000..e455a14 --- /dev/null +++ b/app/api/predict/bets/[id]/add/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { + applyAstroCoinDelta, + InsufficientAstroCoinsError, +} from '@/lib/astro-coins-ledger'; +import { + getMcOptionsForQuestion, + isValidBinaryPredictQuestionId, + isValidPredictQuestionId, +} from '@/lib/predict-validate'; +import { prisma } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +const MAX_COINS_PER_BET = 1_000_000; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: betId } = await params; + if (!betId || typeof betId !== 'string') { + return NextResponse.json({ error: 'Invalid bet id' }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const coinsRaw = + typeof body === 'object' && + body !== null && + 'coins' in body && + typeof (body as { coins: unknown }).coins === 'number' + ? (body as { coins: number }).coins + : Number.NaN; + + const coins = Math.floor(Number(coinsRaw)); + if (!Number.isFinite(coins) || coins < 1 || coins > MAX_COINS_PER_BET) { + return NextResponse.json( + { error: `Add between 1 and ${MAX_COINS_PER_BET} coins` }, + { status: 400 }, + ); + } + + try { + const balance = await prisma.$transaction(async tx => { + const bet = await tx.predictBet.findFirst({ + where: { id: betId, userId: session.user.id }, + select: { id: true, questionId: true, side: true, coins: true }, + }); + + if (!bet) { + throw new Error('NOT_FOUND'); + } + + if (!isValidPredictQuestionId(bet.questionId)) { + throw new Error('BAD_QUESTION'); + } + if (isValidBinaryPredictQuestionId(bet.questionId)) { + const side = bet.side.trim().toLowerCase(); + if (side !== 'yes' && side !== 'no') throw new Error('BAD_QUESTION'); + } else { + const opts = getMcOptionsForQuestion(bet.questionId); + if (!opts || !opts.includes(bet.side)) throw new Error('BAD_QUESTION'); + } + + const refId = `add:${bet.id}`; + const afterDebit = await applyAstroCoinDelta( + tx, + session.user.id, + -coins, + 'predict_bet', + refId, + ); + + await tx.predictBet.update({ + where: { id: bet.id }, + data: { coins: { increment: coins } }, + }); + + return afterDebit; + }); + + return NextResponse.json({ balance }); + } catch (e) { + if (e instanceof Error && e.message === 'NOT_FOUND') { + return NextResponse.json({ error: 'Bet not found' }, { status: 404 }); + } + if (e instanceof Error && e.message === 'BAD_QUESTION') { + return NextResponse.json({ error: 'Question no longer available' }, { status: 400 }); + } + if (e instanceof InsufficientAstroCoinsError) { + return NextResponse.json( + { error: 'Not enough astro coins' }, + { status: 402 }, + ); + } + throw e; + } +} diff --git a/app/api/predict/bets/[id]/withdraw/route.ts b/app/api/predict/bets/[id]/withdraw/route.ts new file mode 100644 index 0000000..b81dd14 --- /dev/null +++ b/app/api/predict/bets/[id]/withdraw/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { applyAstroCoinDelta } from '@/lib/astro-coins-ledger'; +import { prisma } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +const MAX_COINS_PER_BET = 1_000_000; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id: betId } = await params; + if (!betId || typeof betId !== 'string') { + return NextResponse.json({ error: 'Invalid bet id' }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const coinsRaw = + typeof body === 'object' && + body !== null && + 'coins' in body && + typeof (body as { coins: unknown }).coins === 'number' + ? (body as { coins: number }).coins + : Number.NaN; + + const remove = Math.floor(Number(coinsRaw)); + if (!Number.isFinite(remove) || remove < 1 || remove > MAX_COINS_PER_BET) { + return NextResponse.json({ error: 'Invalid withdraw amount' }, { status: 400 }); + } + + try { + const result = await prisma.$transaction(async tx => { + const bet = await tx.predictBet.findFirst({ + where: { id: betId, userId: session.user.id }, + select: { id: true, coins: true, questionId: true, side: true }, + }); + + if (!bet) { + return { type: 'not_found' as const }; + } + + if (remove > bet.coins) { + return { type: 'too_many' as const }; + } + + const refId = `withdraw:${bet.id}`; + const balance = await applyAstroCoinDelta( + tx, + session.user.id, + remove, + 'predict_withdraw', + refId, + ); + + if (remove === bet.coins) { + await tx.predictBet.delete({ where: { id: bet.id } }); + return { + type: 'ok' as const, + balance, + removed: remove, + deleted: true, + remainingCoins: 0, + }; + } + + await tx.predictBet.update({ + where: { id: bet.id }, + data: { coins: bet.coins - remove }, + }); + + return { + type: 'ok' as const, + balance, + removed: remove, + deleted: false, + remainingCoins: bet.coins - remove, + }; + }); + + if (result.type === 'not_found') { + return NextResponse.json({ error: 'Bet not found' }, { status: 404 }); + } + if (result.type === 'too_many') { + return NextResponse.json({ error: 'Cannot withdraw more than you staked' }, { status: 400 }); + } + + return NextResponse.json({ + balance: result.balance, + removed: result.removed, + deleted: result.deleted, + remainingCoins: result.remainingCoins, + }); + } catch (err) { + console.error('[predict/bets/withdraw] POST', err); + return NextResponse.json({ error: 'Database error' }, { status: 500 }); + } +} diff --git a/app/api/predict/bets/route.ts b/app/api/predict/bets/route.ts new file mode 100644 index 0000000..4beac54 --- /dev/null +++ b/app/api/predict/bets/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { prisma } from '@/lib/db'; +import { getPredictQuestionMeta } from '@/lib/predict-question-map'; +import { + formatPermilleAsPercent, + marketPermilleByChoice, + marketPermilleFromSideCoins, +} from '@/lib/predict-market-odds'; +import { getMcOptionsForQuestion, isValidBinaryPredictQuestionId } from '@/lib/predict-validate'; + +export const dynamic = 'force-dynamic'; + +function leadingMarketPercentForQuestion( + fullMap: Map>, + questionId: number, +): string { + const sideMap = fullMap.get(questionId); + if (!sideMap || sideMap.size === 0) return '50%'; + + if (isValidBinaryPredictQuestionId(questionId)) { + const yes = sideMap.get('yes') ?? 0; + const no = sideMap.get('no') ?? 0; + const { yesPermille, noPermille } = marketPermilleFromSideCoins(yes, no); + return formatPermilleAsPercent(Math.max(yesPermille, noPermille)); + } + + const opts = getMcOptionsForQuestion(questionId); + if (opts) { + const permilles = marketPermilleByChoice(sideMap, opts); + const maxP = Math.max(0, ...permilles.values()); + return formatPermilleAsPercent(maxP); + } + + const total = [...sideMap.values()].reduce((a, b) => a + b, 0); + if (total <= 0) return '50%'; + const maxCoins = Math.max(...sideMap.values()); + return formatPermilleAsPercent(Math.round((maxCoins / total) * 1000)); +} + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const rows = await prisma.predictBet.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + questionId: true, + side: true, + coins: true, + createdAt: true, + }, + }); + + const questionIds = [...new Set(rows.map(r => r.questionId))]; + + const byQuestion = new Map>(); + if (questionIds.length > 0) { + const agg = await prisma.predictBet.groupBy({ + by: ['questionId', 'side'], + where: { questionId: { in: questionIds } }, + _sum: { coins: true }, + }); + for (const row of agg) { + const coins = row._sum.coins ?? 0; + const inner = byQuestion.get(row.questionId) ?? new Map(); + const key = isValidBinaryPredictQuestionId(row.questionId) + ? row.side.trim().toLowerCase() + : row.side; + inner.set(key, (inner.get(key) ?? 0) + coins); + byQuestion.set(row.questionId, inner); + } + } + + const bets = rows.map(row => { + const meta = getPredictQuestionMeta(row.questionId); + return { + id: row.id, + questionId: row.questionId, + side: row.side, + coins: row.coins, + createdAt: row.createdAt.toISOString(), + question: + meta && meta.question.trim() !== '' + ? meta.question + : `question #${row.questionId}`, + category: meta && meta.category.trim() !== '' ? meta.category : null, + expiresAt: meta && meta.expiresAt.trim() !== '' ? meta.expiresAt : null, + leadingMarketPercent: leadingMarketPercentForQuestion(byQuestion, row.questionId), + }; + }); + + return NextResponse.json({ bets }); + } catch (err) { + console.error('[predict/bets] GET', err); + return NextResponse.json({ error: 'Database error' }, { status: 500 }); + } +} diff --git a/app/api/predict/footer-bets/route.ts b/app/api/predict/footer-bets/route.ts new file mode 100644 index 0000000..7887069 --- /dev/null +++ b/app/api/predict/footer-bets/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getHomepageFooterBets } from '@/lib/predict-footer-bets'; + +export async function GET() { + try { + const bets = await getHomepageFooterBets(); + return NextResponse.json( + { bets }, + { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' } }, + ); + } catch (err) { + console.error('[predict/footer-bets] GET', err); + return NextResponse.json({ error: 'Database error' }, { status: 500 }); + } +} diff --git a/app/api/predict/odds/route.ts b/app/api/predict/odds/route.ts new file mode 100644 index 0000000..f0d4d12 --- /dev/null +++ b/app/api/predict/odds/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { + formatPermilleAsPercent, + marketPermilleByChoice, + marketPermilleFromSideCoins, +} from '@/lib/predict-market-odds'; +import { getMcOptionsForQuestion, isValidBinaryPredictQuestionId } from '@/lib/predict-validate'; + +export const dynamic = 'force-dynamic'; + +type OddsEntry = { + yesPermille: number; + noPermille: number; + yesCoins: number; + noCoins: number; + yesPercent: string; + noPercent: string; + /** Multiple-choice: option label → display percent (sums to ~100%). */ + choicePercents?: Record; +}; + +/** + * Aggregates `PredictBet` rows: yes/no per binary question; per-option for multiple choice. + */ +export async function GET() { + try { + const rows = await prisma.predictBet.groupBy({ + by: ['questionId', 'side'], + _sum: { coins: true }, + }); + + const coinsByQuestion = new Map>(); + + for (const row of rows) { + const coins = row._sum.coins ?? 0; + const inner = coinsByQuestion.get(row.questionId) ?? new Map(); + const key = isValidBinaryPredictQuestionId(row.questionId) + ? row.side.trim().toLowerCase() + : row.side; + inner.set(key, (inner.get(key) ?? 0) + coins); + coinsByQuestion.set(row.questionId, inner); + } + + const odds: Record = {}; + + for (const [questionId, sideMap] of coinsByQuestion) { + if (isValidBinaryPredictQuestionId(questionId)) { + const yesCoins = sideMap.get('yes') ?? 0; + const noCoins = sideMap.get('no') ?? 0; + const { yesPermille, noPermille } = marketPermilleFromSideCoins(yesCoins, noCoins); + odds[String(questionId)] = { + yesPermille, + noPermille, + yesCoins, + noCoins, + yesPercent: formatPermilleAsPercent(yesPermille), + noPercent: formatPermilleAsPercent(noPermille), + }; + continue; + } + + const mcOptions = getMcOptionsForQuestion(questionId); + if (!mcOptions) continue; + + const choiceMap = new Map(); + for (const opt of mcOptions) { + choiceMap.set(opt, sideMap.get(opt) ?? 0); + } + const permilles = marketPermilleByChoice(choiceMap, mcOptions); + const choicePercents: Record = {}; + for (const opt of mcOptions) { + choicePercents[opt] = formatPermilleAsPercent(permilles.get(opt) ?? 0); + } + odds[String(questionId)] = { + yesPermille: 0, + noPermille: 0, + yesCoins: 0, + noCoins: 0, + yesPercent: '50%', + noPercent: '50%', + choicePercents, + }; + } + + return NextResponse.json({ odds }); + } catch (err) { + console.error('[predict/odds] GET', err); + return NextResponse.json({ error: 'Database error' }, { status: 500 }); + } +} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..d12ae23 --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/auth'; +import { coinsFromAmountTotalCents } from '@/lib/astro-coins'; +import { getAppBaseUrl } from '@/lib/app-base-url'; +import { getStripeClient, getStripeSecretKey } from '@/lib/stripe-server'; + +const MIN_USD = 10; +const MAX_USD = 500; + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!getStripeSecretKey()) { + return NextResponse.json( + { error: 'Stripe is not configured' }, + { status: 503 }, + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const raw = + typeof body === 'object' && + body !== null && + 'amountUsd' in body && + (typeof (body as { amountUsd: unknown }).amountUsd === 'number' || + typeof (body as { amountUsd: unknown }).amountUsd === 'string') + ? (body as { amountUsd: number | string }).amountUsd + : null; + + const amountUsd = + typeof raw === 'number' + ? raw + : typeof raw === 'string' + ? Number.parseFloat(raw.replace(',', '.')) + : Number.NaN; + + if (!Number.isFinite(amountUsd) || amountUsd < MIN_USD || amountUsd > MAX_USD) { + return NextResponse.json( + { error: `Amount must be between $${MIN_USD} and $${MAX_USD}` }, + { status: 400 }, + ); + } + + const amountCents = Math.round(amountUsd * 100); + if (amountCents < Math.round(MIN_USD * 100)) { + return NextResponse.json({ error: 'Amount too small' }, { status: 400 }); + } + + const coinsPreview = coinsFromAmountTotalCents(amountCents); + if (coinsPreview <= 0) { + return NextResponse.json({ error: 'Invalid amount' }, { status: 400 }); + } + + const base = getAppBaseUrl(); + const stripe = getStripeClient(); + + let checkoutSession; + try { + checkoutSession = await stripe.checkout.sessions.create({ + mode: 'payment', + customer_email: session.user.email ?? undefined, + line_items: [ + { + price_data: { + currency: 'usd', + unit_amount: amountCents, + product_data: { + name: 'Astro coins', + description: `${coinsPreview.toLocaleString()} astro coins`, + }, + }, + quantity: 1, + }, + ], + success_url: `${base}/dashboard?astro_purchase=success`, + cancel_url: `${base}/dashboard?astro_purchase=cancelled`, + metadata: { + userId: session.user.id, + }, + }); + } catch (e) { + console.error('[stripe checkout]', e); + return NextResponse.json( + { error: 'Could not start checkout' }, + { status: 502 }, + ); + } + + if (!checkoutSession.url) { + return NextResponse.json( + { error: 'Could not create checkout session' }, + { status: 500 }, + ); + } + + return NextResponse.json({ url: checkoutSession.url }); +} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..88e1aeb --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import { Prisma } from '@prisma/client'; +import Stripe from 'stripe'; +import { coinsFromAmountTotalCents } from '@/lib/astro-coins'; +import { applyAstroCoinDelta } from '@/lib/astro-coins-ledger'; +import { prisma } from '@/lib/db'; +import { getStripeClient, getStripeSecretKey } from '@/lib/stripe-server'; + +export const runtime = 'nodejs'; + +function isUniqueViolation(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002' + ); +} + +async function fulfillCheckoutSession(session: Stripe.Checkout.Session) { + if (session.mode !== 'payment') return; + if (session.payment_status !== 'paid') return; + + const userId = session.metadata?.userId?.trim(); + if (!userId) { + console.error('[stripe webhook] missing metadata.userId', session.id); + return; + } + + const amountTotal = session.amount_total; + if (amountTotal == null) { + console.error('[stripe webhook] missing amount_total', session.id); + return; + } + + const coins = coinsFromAmountTotalCents(amountTotal); + if (coins <= 0) { + console.error('[stripe webhook] zero coins for session', session.id); + return; + } + + try { + await prisma.$transaction(async tx => { + await tx.stripePurchase.create({ + data: { + sessionId: session.id, + userId, + coins, + amountUsdCents: amountTotal, + }, + }); + await applyAstroCoinDelta(tx, userId, coins, 'stripe_checkout', session.id); + }); + } catch (error) { + if (isUniqueViolation(error)) { + return; + } + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + console.error('[stripe webhook] user not found for session', session.id, userId); + return; + } + throw error; + } +} + +export async function POST(request: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim(); + if (!secret) { + console.error('[stripe webhook] STRIPE_WEBHOOK_SECRET is not set'); + return NextResponse.json({ error: 'Not configured' }, { status: 503 }); + } + + if (!getStripeSecretKey()) { + return NextResponse.json({ error: 'Not configured' }, { status: 503 }); + } + + const signature = request.headers.get('stripe-signature'); + if (!signature) { + return NextResponse.json({ error: 'Missing signature' }, { status: 400 }); + } + + const rawBody = await request.text(); + const stripe = getStripeClient(); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(rawBody, signature, secret); + } catch (err) { + console.error('[stripe webhook] signature verification failed:', err); + return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + await fulfillCheckoutSession(session); + } + + return NextResponse.json({ received: true }); +} diff --git a/app/api/timezone/route.ts b/app/api/timezone/route.ts index a9b0267..6c7e627 100644 --- a/app/api/timezone/route.ts +++ b/app/api/timezone/route.ts @@ -11,14 +11,27 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'lat and lon are required' }, { status: 400 }); } + const parsedLat = Number(lat); + const parsedLon = Number(lon); + if ( + !Number.isFinite(parsedLat) || + !Number.isFinite(parsedLon) || + parsedLat < -90 || + parsedLat > 90 || + parsedLon < -180 || + parsedLon > 180 + ) { + return NextResponse.json({ error: 'lat/lon must be valid coordinates' }, { status: 400 }); + } + const apiKey = process.env.TIMEZONEDB_API_KEY; if (!apiKey) { return NextResponse.json({ error: 'timezone backend not configured' }, { status: 500 }); } const url = `${TIMEZONEDB_BASE}?key=${encodeURIComponent(apiKey)}&format=json&by=position&lat=${encodeURIComponent( - lat, - )}&lng=${encodeURIComponent(lon)}`; + String(parsedLat), + )}&lng=${encodeURIComponent(String(parsedLon))}`; try { const res = await fetch(url); diff --git a/app/chart/[id]/page.tsx b/app/chart/[id]/page.tsx index 9168e8a..a777c39 100644 --- a/app/chart/[id]/page.tsx +++ b/app/chart/[id]/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { useSession } from 'next-auth/react'; import { ChartResults } from '@/components/chart/ChartResults'; import { copy } from '@/lib/copy'; +import { savedChartHeadingLabel } from '@/lib/utils'; import type { ChartResult } from '@/lib/astro/types'; export default function ViewSavedChartPage() { @@ -56,7 +57,7 @@ export default function ViewSavedChartPage() { return (
- ← {copy.dashboard.title} + ← {copy.dashboard.backToDashboard}

{error || 'loading…'}

@@ -72,10 +73,10 @@ export default function ViewSavedChartPage() { href="/dashboard" className="text-muted-foreground text-sm hover:text-violet-400 transition-colors" > - ← {copy.dashboard.title} + ← {copy.dashboard.backToDashboard} -

- {copy.chart.titlePrefix} {chart.label} +

+ {copy.chart.titlePrefix} {savedChartHeadingLabel(chart.label)} {copy.chart.titleSuffix}

diff --git a/app/chart/[id]/transits/page.tsx b/app/chart/[id]/transits/page.tsx index d9f6b2f..6db1b23 100644 --- a/app/chart/[id]/transits/page.tsx +++ b/app/chart/[id]/transits/page.tsx @@ -6,9 +6,11 @@ import Link from 'next/link'; import { useSession } from 'next-auth/react'; import { TransitsWheel } from '@/components/chart/TransitsWheel'; import { TransitsTable } from '@/components/chart/TransitsTable'; +import { TransitNatalAspectsSnapshot } from '@/components/chart/TransitNatalAspectsSnapshot'; import { TransitNatalGaussianPlot } from '@/components/chart/TransitNatalGaussianPlot'; import { Button } from '@/components/ui/Button'; import { copy } from '@/lib/copy'; +import { savedChartHeadingLabel } from '@/lib/utils'; import { calculateChart } from '@/lib/astro/calculate'; import { CHART_OF_MOMENT_OPTIONS } from '@/lib/astro/types'; import type { BirthData, ChartResult } from '@/lib/astro/types'; @@ -56,7 +58,11 @@ export default function ChartTransitsPage() { const router = useRouter(); const { status } = useSession(); const id = typeof params.id === 'string' ? params.id : ''; - const [chart, setChart] = useState<{ label: string; chartResult: ChartResult } | null>(null); + const [chart, setChart] = useState<{ + label: string; + isPrimary?: boolean; + chartResult: ChartResult; + } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [transitDate, setTransitDate] = useState(() => new Date()); @@ -107,7 +113,7 @@ export default function ChartTransitsPage() { if (status === 'loading' || status === 'unauthenticated') { return ( -
+
loading…
); @@ -115,9 +121,9 @@ export default function ChartTransitsPage() { if (loading || error) { return ( -
+
- ← {copy.dashboard.title} + ← {copy.dashboard.backToDashboard}

{error || 'loading…'}

@@ -133,22 +139,26 @@ export default function ChartTransitsPage() { timeStyle: 'short', }); + const transitsHeadingText = chart.isPrimary + ? copy.chart.transitsForMyChart + : copy.chart.transitsForSavedChart(savedChartHeadingLabel(chart.label)); + return ( -
-
+
+
- ← {copy.dashboard.title} + ← {copy.dashboard.backToDashboard} -

- {copy.chart.titlePrefix} {copy.chart.transitsTitle} +

+ {copy.chart.titlePrefix} {transitsHeadingText} {copy.chart.titleSuffix}

-

+

{copy.chart.transitsSubtitle(transitLabel)}

-
+
@@ -179,6 +189,7 @@ export default function ChartTransitsPage() {
+ (DEFAULT_CHART_META); + const [showDashboardAfterSave, setShowDashboardAfterSave] = useState(false); const hasUnknownTimeOrCity = chartMeta.timeNotKnown || chartMeta.cityNotKnown; const handleSubmit = (data: BirthData, options: ChartOptions, meta: ChartFormMeta) => { + setShowDashboardAfterSave(false); setChartMeta(meta); const hasUnknownTimeOrCity = meta.timeNotKnown || meta.cityNotKnown; @@ -45,13 +49,14 @@ export default function ChartPage() { const handleSaved = () => { reset(); setFormKey((k) => k + 1); + setShowDashboardAfterSave(true); }; return (
-

- {copy.chart.titlePrefix} {copy.chart.title} +

+ {copy.chart.titlePrefix} {copy.chart.title} {copy.chart.titleSuffix}

@@ -61,6 +66,16 @@ export default function ChartPage() { isLoading={state.status === 'calculating'} /> + {showDashboardAfterSave && state.status === 'idle' ? ( +
+ + + +
+ ) : null} + {state.status === 'error' && (

{state.message}

)} diff --git a/app/compatibility/page.tsx b/app/compatibility/page.tsx index e56bf5c..bae7b03 100644 --- a/app/compatibility/page.tsx +++ b/app/compatibility/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useState, useCallback } from 'react'; import { useSession } from 'next-auth/react'; +import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; @@ -10,11 +11,11 @@ import { useChartCalculation } from '@/hooks/useChartCalculation'; import { copy } from '@/lib/copy'; import { computeCompatibility, planetGlyph } from '@/lib/astro/compatibility'; import { - getCompatibilityExplanation, - getPlacementScore, + getMergedCrossChartExplanation, + getSymmetricPlacementScore, getOverallScore, getSameSignPlanets, - getElementCompatiblePlanets, + getElementRelationPerPlanet, } from '@/lib/astro/compatibilityInterpretations'; import type { ChartResult } from '@/lib/astro/types'; import type { PlanetName } from '@/lib/astro/types'; @@ -31,6 +32,19 @@ interface SavedChartItem { chartResult: string; } +/** Selected chart 1 / chart 2 mode tab — same ink as body text; violet wash only on the background. */ +const compatModeToggleActiveClass = + 'bg-violet-500/15 text-foreground ring-1 ring-violet-500/30 dark:bg-violet-400/10 dark:text-foreground dark:ring-violet-400/25'; + +/** Unselected tab — same ink as body; only the background differs from active. */ +const compatModeToggleInactiveClass = 'text-foreground'; + +/** Primary chart label may be stored as "My chart"; show copy.dashboard.myChart on this page. */ +function compatibilityChartLabel(label: string): string { + if (label.trim().toLowerCase() === 'my chart') return copy.dashboard.myChart; + return label; +} + function CompatibilityContent() { const searchParams = useSearchParams(); const prefillChartId = searchParams.get('chart'); @@ -89,35 +103,46 @@ function CompatibilityContent() { const compatibilityResult = chartA && chartB - ? computeCompatibility(chartA.result, chartB.result, chartA.label, chartB.label) + ? computeCompatibility( + chartA.result, + chartB.result, + compatibilityChartLabel(chartA.label), + compatibilityChartLabel(chartB.label), + ) : null; return (
-
-

- {copy.compatibility.title} +
+ + ← {copy.dashboard.backToDashboard} + +

+ {copy.chart.titlePrefix} {copy.compatibility.title} {copy.chart.titleSuffix}

{/* Chart 1 */} -

+

{copy.compatibility.chart1}

@@ -137,6 +162,9 @@ function CompatibilityContent() { try { const result = JSON.parse(c.chartResult) as ChartResult; setChartA({ type: 'saved', id: c.id, label: c.label, result }); + if (chartB?.type === 'saved' && chartB.id === c.id) { + setChartB(null); + } } catch { setChartA(null); } @@ -144,12 +172,14 @@ function CompatibilityContent() { }} className="w-full bg-background border border-border rounded-lg px-3 py-2 text-foreground text-sm mb-2" > - - {savedCharts.map(c => ( - - ))} + + ))} {status !== 'authenticated' && (

{copy.compatibility.noCharts}

@@ -189,27 +219,27 @@ function CompatibilityContent() { )} {chartA && modeA === 'saved' && ( -

✓ {chartA.label}

+

✓ {compatibilityChartLabel(chartA.label)}

)} {/* Chart 2 */} -

+

{copy.compatibility.chart2}

@@ -229,6 +259,9 @@ function CompatibilityContent() { try { const result = JSON.parse(c.chartResult) as ChartResult; setChartB({ type: 'saved', id: c.id, label: c.label, result }); + if (chartA?.type === 'saved' && chartA.id === c.id) { + setChartA(null); + } } catch { setChartB(null); } @@ -236,12 +269,14 @@ function CompatibilityContent() { }} className="w-full bg-background border border-border rounded-lg px-3 py-2 text-foreground text-sm mb-2" > - - {savedCharts.map(c => ( - - ))} + + ))} {status !== 'authenticated' && (

{copy.compatibility.noCharts}

@@ -281,7 +316,7 @@ function CompatibilityContent() { )} {chartB && modeB === 'saved' && ( -

✓ {chartB.label}

+

✓ {compatibilityChartLabel(chartB.label)}

)}
@@ -289,7 +324,10 @@ function CompatibilityContent() { {/* Compatibility result */} {compatibilityResult && (() => { const sameSignPlanets = getSameSignPlanets(compatibilityResult.aInB, compatibilityResult.bInA); - const elementCompatiblePlanets = getElementCompatiblePlanets(compatibilityResult.aInB, compatibilityResult.bInA); + const elementRelationByPlanet = getElementRelationPerPlanet( + compatibilityResult.aInB, + compatibilityResult.bInA, + ); const overall = getOverallScore(compatibilityResult.aInB, compatibilityResult.bInA); const scoreLabelClass = (label: string) => label === 'high' @@ -304,7 +342,7 @@ function CompatibilityContent() { {copy.compatibility.overall}

- + {overall.scoreOutOf100} / 100 @@ -314,116 +352,117 @@ function CompatibilityContent() {

{overall.summary}

-
- -

- {copy.compatibility.planetsInHouses(compatibilityResult.chartALabel)} -

- + +
+
- - - - - + + + + + + + + + + + - {compatibilityResult.aInB.map((row, i) => { - const placement = getPlacementScore( - row.planet.name as PlanetName, - row.house, + {compatibilityResult.aInB.map((rowA) => { + const rowB = compatibilityResult.bInA.find((r) => r.planet.name === rowA.planet.name); + if (!rowB) return null; + const name = rowA.planet.name as PlanetName; + const placement = getSymmetricPlacementScore( + name, + rowA.house, + rowB.house, sameSignPlanets, - elementCompatiblePlanets, + elementRelationByPlanet.get(name) ?? 'none', ); return ( - - - + - - - - - ); - })} - -
{copy.compatibility.planet}{copy.compatibility.sign}{copy.compatibility.house}{copy.compatibility.score}{copy.compatibility.explanation} + {copy.compatibility.planet} + + {copy.compatibility.score} + + {copy.compatibility.explanation} + + {compatibilityResult.chartALabel} + + {compatibilityResult.chartBLabel} +
+ {copy.compatibility.sign} + + {copy.compatibility.house} + + {copy.compatibility.sign} + + {copy.compatibility.house} +
- {planetGlyph(row.planet.name)} {row.planet.name} - - {row.glyph} {row.inSign} +
+ + {planetGlyph(rowA.planet.name)} + {rowA.planet.name} + house {row.house} + {placement.score}/10 - {getCompatibilityExplanation( - row.planet.name as PlanetName, - row.house, + + {placement.note} + {' '} + {getMergedCrossChartExplanation( + name, + rowA.house, + rowB.house, + compatibilityResult.chartALabel, + compatibilityResult.chartBLabel, )}
-
- -

- {copy.compatibility.planetsInHouses(compatibilityResult.chartBLabel)} -

- - - - - - - - - - - - {compatibilityResult.bInA.map((row, i) => { - const placement = getPlacementScore( - row.planet.name as PlanetName, - row.house, - sameSignPlanets, - elementCompatiblePlanets, - ); - return ( - - - - - - ); })}
{copy.compatibility.planet}{copy.compatibility.sign}{copy.compatibility.house}{copy.compatibility.score}{copy.compatibility.explanation}
- {planetGlyph(row.planet.name)} {row.planet.name} + + + {rowA.glyph} + {rowA.inSign} + - {row.glyph} {row.inSign} + + {rowA.house} house {row.house} - - {placement.score}/10 + + + {rowB.glyph} + {rowB.inSign} - {getCompatibilityExplanation( - row.planet.name as PlanetName, - row.house, - )} + + {rowB.house}
-
-
+
+ ); })()} diff --git a/app/dashboard/DashboardClient.tsx b/app/dashboard/DashboardClient.tsx new file mode 100644 index 0000000..a009011 --- /dev/null +++ b/app/dashboard/DashboardClient.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSession } from 'next-auth/react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { MiniChartWheel } from '@/components/chart/MiniChartWheel'; +import { DashboardTransitNews } from '@/components/chart/DashboardTransitNews'; +import { AstroCoinsPanel } from '@/components/dashboard/AstroCoinsPanel'; +import { + MyPredictionsSection, + type DashboardPredictionBet, +} from '@/components/dashboard/MyPredictionsSection'; +import { copy } from '@/lib/copy'; +import { cn } from '@/lib/utils'; +import { reapplyWholeSignHouses } from '@/lib/astro/calculate'; +import type { ChartResult } from '@/lib/astro/types'; + +interface SavedChartItem { + id: string; + label: string; + isPrimary: boolean; + birthData: string; + chartResult: string; + createdAt: string; +} + +type Props = { + initialAstroCoins: number; +}; + +export default function DashboardClient({ initialAstroCoins }: Props) { + const { status } = useSession(); + const router = useRouter(); + const [charts, setCharts] = useState([]); + const [predictions, setPredictions] = useState([]); + const [loading, setLoading] = useState(true); + const [deletingId, setDeletingId] = useState(null); + const [dataRefreshKey, setDataRefreshKey] = useState(0); + const [walletRefreshTick, setWalletRefreshTick] = useState(0); + useEffect(() => { + if (status === 'unauthenticated') { + router.replace('/login?callbackUrl=/dashboard'); + return; + } + if (status !== 'authenticated') return; + + let cancelled = false; + setLoading(true); + Promise.all([ + fetch('/api/charts').then(r => r.json()), + fetch('/api/predict/bets', { credentials: 'same-origin', cache: 'no-store' }).then(r => + r.ok ? r.json() : Promise.resolve({ bets: [] }), + ), + ]) + .then(([chartsData, betsData]) => { + if (cancelled) return; + if (chartsData.charts) setCharts(chartsData.charts); + const raw = betsData as { bets?: unknown }; + if (Array.isArray(raw.bets)) { + setPredictions(raw.bets as DashboardPredictionBet[]); + } else { + setPredictions([]); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [status, router, dataRefreshKey]); + + async function handleDelete(id: string) { + setDeletingId(id); + try { + const res = await fetch(`/api/charts/${id}`, { method: 'DELETE' }); + if (res.ok) setCharts(prev => prev.filter(c => c.id !== id)); + } finally { + setDeletingId(null); + } + } + + if (status === 'loading' || status === 'unauthenticated') { + return ( +
+
loading…
+
+ ); + } + + return ( +
+
+

+ {copy.chart.titlePrefix} {copy.dashboard.title} {copy.chart.titleSuffix} +

+
+ + + + {loading ? ( +

loading your dashboard…

+ ) : ( + <> + setDataRefreshKey(k => k + 1)} + onWalletRefresh={() => setWalletRefreshTick(t => t + 1)} + /> + {(() => { + const primary = charts.find(c => c.isPrimary === true); + if (!primary) return null; + const showPrimaryTitle = + primary.label.trim().toLowerCase() !== copy.dashboard.myChart; + const primarySectionHeading = ( +

+ {copy.dashboard.yourChartAndTransits} +

+ ); + let result: ChartResult; + try { + result = JSON.parse(primary.chartResult) as ChartResult; + } catch { + return ( +
+ {primarySectionHeading} +
+ {showPrimaryTitle ? ( +

+ {primary.label} +

+ ) : null} + + + +
+
+ ); + } + const displayResult = reapplyWholeSignHouses(result); + return ( +
+ {primarySectionHeading} + {showPrimaryTitle ? ( +

+ {primary.label} +

+ ) : null} +
+
+
+ + + + + +
+ } + /> +
+
+ +
+ + ); + })()} +
+

+ {copy.dashboard.otherCharts} +

+ {charts.filter(c => c.isPrimary !== true).length === 0 ? ( + +

{copy.dashboard.noCharts}

+ + + +
+ ) : ( + <> +
    + {charts + .filter(c => c.isPrimary !== true) + .map(chart => { + let birthLabel = ''; + try { + const b = JSON.parse(chart.birthData) as { + date?: string; + }; + birthLabel = b.date || chart.label; + } catch { + birthLabel = chart.label; + } + return ( +
  • + +
    +

    + {chart.label} +

    +

    + {birthLabel} + + · + + + {copy.dashboard.createdAt}{' '} + {new Date(chart.createdAt).toLocaleDateString(undefined, { + dateStyle: 'medium', + })} + +

    +
    +
    + + + + + + + + + + +
    +
    +
  • + ); + })} +
+
+ + + +
+ + )} +
+ + )} +
+ ); +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..c870dfb --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'dashboard — future', +}; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index b8a14bd..d90510f 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,238 +1,25 @@ -'use client'; +import { redirect } from 'next/navigation'; +import { auth } from '@/auth'; +import { prisma } from '@/lib/db'; +import DashboardClient from './DashboardClient'; -import { useEffect, useState } from 'react'; -import { useSession } from 'next-auth/react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; -import { Card } from '@/components/ui/Card'; -import { MiniChartWheel } from '@/components/chart/MiniChartWheel'; -import { DashboardTransitNews } from '@/components/chart/DashboardTransitNews'; -import { copy } from '@/lib/copy'; -import { reapplyWholeSignHouses } from '@/lib/astro/calculate'; -import type { ChartResult } from '@/lib/astro/types'; +export const dynamic = 'force-dynamic'; -interface SavedChartItem { - id: string; - label: string; - isPrimary: boolean; - birthData: string; - chartResult: string; - createdAt: string; -} - -export default function DashboardPage() { - const { status } = useSession(); - const router = useRouter(); - const [charts, setCharts] = useState([]); - const [loading, setLoading] = useState(true); - const [deletingId, setDeletingId] = useState(null); - useEffect(() => { - if (status === 'unauthenticated') { - router.replace('/login?callbackUrl=/dashboard'); - return; - } - if (status !== 'authenticated') return; - - let cancelled = false; - fetch('/api/charts') - .then(res => res.json()) - .then(data => { - if (!cancelled && data.charts) setCharts(data.charts); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [status, router]); - - async function handleDelete(id: string) { - setDeletingId(id); - try { - const res = await fetch(`/api/charts/${id}`, { method: 'DELETE' }); - if (res.ok) setCharts(prev => prev.filter(c => c.id !== id)); - } finally { - setDeletingId(null); - } +export default async function DashboardPage() { + const session = await auth(); + if (!session?.user?.id) { + redirect('/login?callbackUrl=/dashboard'); } - if (status === 'loading' || status === 'unauthenticated') { - return ( -
-
loading…
-
- ); - } + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { astroCoins: true }, + }); - return ( -
-
-

- {copy.chart.titlePrefix} {copy.dashboard.title} -

-
+ const initialAstroCoins = + typeof user?.astroCoins === 'number' && Number.isFinite(user.astroCoins) + ? Math.max(0, Math.floor(user.astroCoins)) + : 0; - {loading ? ( -

loading your charts…

- ) : ( - <> - {(() => { - const primary = charts.find(c => c.isPrimary === true); - if (!primary) return null; - let result: ChartResult; - try { - result = JSON.parse(primary.chartResult) as ChartResult; - } catch { - return ( -
-

{primary.label}

- - - -
- ); - } - const displayResult = reapplyWholeSignHouses(result); - return ( -
-
-
-
- - - - - -
- } - /> -
-
- -
-
- ); - })()} -
-

- {copy.dashboard.otherCharts} -

- {charts.filter(c => c.isPrimary !== true).length === 0 ? ( - -

{copy.dashboard.noCharts}

- - - -
- ) : ( -
    - {charts - .filter(c => c.isPrimary !== true) - .map(chart => { - let birthLabel = ''; - try { - const b = JSON.parse(chart.birthData) as { - date?: string; - }; - birthLabel = b.date || chart.label; - } catch { - birthLabel = chart.label; - } - return ( -
  • - -
    -

    - {chart.label} -

    -

    {birthLabel}

    -

    - {copy.dashboard.createdAt}{' '} - {new Date(chart.createdAt).toLocaleDateString(undefined, { - dateStyle: 'medium', - })} -

    -
    -
    - - - - - - - - - - {!chart.isPrimary && ( - - )} - -
    -
    -
  • - ); - })} -
- )} -
- - )} -
- ); + return ; } diff --git a/app/influence/page.tsx b/app/influence/page.tsx index cd6f524..33f4ce9 100644 --- a/app/influence/page.tsx +++ b/app/influence/page.tsx @@ -22,8 +22,8 @@ export default function InfluencePage() { return (
-

- {copy.influence.title} +

+ {copy.chart.titlePrefix} {copy.influence.title} {copy.chart.titleSuffix}

{copy.influence.subtitle} diff --git a/app/login/error/page.tsx b/app/login/error/page.tsx index 9a6b666..6afc893 100644 --- a/app/login/error/page.tsx +++ b/app/login/error/page.tsx @@ -37,8 +37,8 @@ export default function LoginErrorPage() { {copy.auth.errors.backToLogin} -

- {copy.auth.errors.configTitle} +

+ {copy.chart.titlePrefix} {copy.auth.errors.configTitle} {copy.chart.titleSuffix}

loading…

}> diff --git a/app/login/page.tsx b/app/login/page.tsx index 42fa242..a40e60a 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, useEffect, Suspense } from 'react'; -import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { signIn } from 'next-auth/react'; import { Button } from '@/components/ui/Button'; @@ -77,13 +76,10 @@ function LoginForm() { export default function LoginPage() { return ( -
-
- - ← {copy.site.title} - -

- {copy.auth.loginTitle} +
+
+

+ {copy.chart.titlePrefix} {copy.auth.loginTitle} {copy.chart.titleSuffix}

loading…

}> diff --git a/app/not-found.tsx b/app/not-found.tsx index 397f95d..bf7288c 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -5,8 +5,8 @@ export default function NotFound() { return (

404

-

- {copy.notFound.title} +

+ {copy.chart.titlePrefix} {copy.notFound.title} {copy.chart.titleSuffix}

{copy.notFound.message} diff --git a/app/page.tsx b/app/page.tsx index 31cde93..fb573f0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,292 +1,11 @@ -'use client'; - -import { useEffect, useState, useCallback, useMemo } from 'react'; -import { useSession } from 'next-auth/react'; -import { ChartResults } from '@/components/chart/ChartResults'; -import { ConjunctionPlot } from '@/components/chart/ConjunctionPlot'; -import { NextConjunctions } from '@/components/chart/NextConjunctions'; -import { CitySearch } from '@/components/chart/CitySearch'; -import { Button } from '@/components/ui/Button'; -import { calculateChart } from '@/lib/astro/calculate'; -import { type BirthData, type ChartResult, type GeoResult, CHART_OF_MOMENT_OPTIONS } from '@/lib/astro/types'; -import { copy } from '@/lib/copy'; - -const SAN_FRANCISCO_TZ = 'America/Los_Angeles'; -const SAN_FRANCISCO_LAT = 37.7749; -const SAN_FRANCISCO_LON = -122.4194; -const SAN_FRANCISCO_UTC_OFFSET = -8; // Pacific Time (standard) -const DENVER_TZ = 'America/Denver'; - -type TodayLocation = { - latitude: number; - longitude: number; - utcOffset: number; - cityLabel: string; - timeZone?: string; -}; - -const DEFAULT_LOCATION: TodayLocation = { - latitude: SAN_FRANCISCO_LAT, - longitude: SAN_FRANCISCO_LON, - utcOffset: SAN_FRANCISCO_UTC_OFFSET, - cityLabel: 'San Francisco', - timeZone: SAN_FRANCISCO_TZ, -}; - -function inferTimeZone(cityLabel: string): string | undefined { - const name = cityLabel.toLowerCase(); - if (name.includes('san francisco')) return SAN_FRANCISCO_TZ; - if (name.includes('denver')) return DENVER_TZ; - // Fallback: simple US guess by longitude if needed later - return undefined; -} - -function buildBirthDataForMoment(moment: Date, location: TodayLocation): BirthData { - if (location.timeZone) { - const parts = moment.toLocaleString('sv-SE', { timeZone: location.timeZone }).split(' '); - const [datePart, timePart] = parts; - const [y, m, d] = datePart.split('-'); - const [hh, mm] = timePart.split(':'); - const localHours = parseInt(hh, 10) + parseInt(mm, 10) / 60; - const utcHours = moment.getUTCHours() + moment.getUTCMinutes() / 60; - const utcOffset = Math.round((localHours - utcHours) * 2) / 2; - return { - date: `${y}-${m}-${d}`, - time: `${hh.padStart(2, '0')}:${mm.padStart(2, '0')}`, - latitude: location.latitude, - longitude: location.longitude, - utcOffset, - cityLabel: location.cityLabel, - }; - } - - const utcMs = moment.getTime(); - const localMs = utcMs + location.utcOffset * 3600 * 1000; - const local = new Date(localMs); - - const y = local.getUTCFullYear(); - const m = local.getUTCMonth() + 1; - const d = local.getUTCDate(); - const hh = local.getUTCHours(); - const mm = local.getUTCMinutes(); - - return { - date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`, - time: `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`, - latitude: location.latitude, - longitude: location.longitude, - utcOffset: location.utcOffset, - cityLabel: location.cityLabel, - }; -} - -function formatChartDate(d: Date, location: TodayLocation): string { - if (location.timeZone) { - return d.toLocaleDateString('en-US', { - timeZone: location.timeZone, - month: 'short', - day: 'numeric', - year: 'numeric', - }); - } - - const utcMs = d.getTime(); - const localMs = utcMs + location.utcOffset * 3600 * 1000; - const local = new Date(localMs); - - return local.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -} - -export default function Home() { - const { data: session, status } = useSession(); - const [chartMoment, setChartMoment] = useState(() => new Date()); - const [location, setLocation] = useState(DEFAULT_LOCATION); - const [showCustomize, setShowCustomize] = useState(false); - - // Load saved city for authenticated users from localStorage on mount / auth change. - useEffect(() => { - if (status !== 'authenticated' || !session?.user?.id) return; - const key = `future:todayCity:${session.user.id}`; - try { - const raw = window.localStorage.getItem(key); - if (!raw) return; - const parsed = JSON.parse(raw) as Partial; - if ( - typeof parsed.latitude === 'number' && - typeof parsed.longitude === 'number' && - typeof parsed.utcOffset === 'number' && - typeof parsed.cityLabel === 'string' - ) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setLocation({ - latitude: parsed.latitude, - longitude: parsed.longitude, - utcOffset: parsed.utcOffset, - cityLabel: parsed.cityLabel, - timeZone: parsed.timeZone ?? inferTimeZone(parsed.cityLabel), - }); - } - } catch { - // ignore - } - }, [status, session?.user?.id]); - - // When not authenticated, always fall back to the San Francisco default. - useEffect(() => { - if (status !== 'authenticated') { - // eslint-disable-next-line react-hooks/set-state-in-effect - setLocation(DEFAULT_LOCATION); - } - }, [status]); - - const result = useMemo(() => { - const data = buildBirthDataForMoment(chartMoment, location); - return calculateChart(data, CHART_OF_MOMENT_OPTIONS); - }, [chartMoment, location]); - - const adjust = useCallback((unit: 'day' | 'month' | 'year', delta: number) => { - setChartMoment((prev) => { - const next = new Date(prev); - if (unit === 'day') next.setDate(next.getDate() + delta); - else if (unit === 'month') next.setMonth(next.getMonth() + delta); - else next.setFullYear(next.getFullYear() + delta); - return next; - }); - }, []); - - const adjustHours = useCallback((deltaHours: number) => { - setChartMoment((prev) => new Date(prev.getTime() + deltaHours * 3600 * 1000)); - }, []); - - const influenceRange = useMemo(() => { - const now = new Date(); - const start = new Date(now); - start.setHours(0, 0, 0, 0); - const end = new Date(start); - end.setDate(start.getDate() + 6); - end.setHours(23, 59, 59, 999); - return { start, end }; - }, []); - - const todaySubtitle = - location.cityLabel === 'San Francisco' - ? copy.today.subtitle - : `current transits in ${location.cityLabel}`; - - function handleCityResult(geo: GeoResult) { - const next: TodayLocation = { - latitude: geo.latitude, - longitude: geo.longitude, - utcOffset: geo.utcOffset, - cityLabel: geo.displayName, - timeZone: inferTimeZone(geo.displayName), - }; - setLocation(next); - if (status !== 'authenticated' || !session?.user?.id) return; - const key = `future:todayCity:${session.user.id}`; - try { - window.localStorage.setItem(key, JSON.stringify(next)); - } catch { - // ignore - } - } - - function handleCityReset() { - const next = DEFAULT_LOCATION; - setLocation(next); - if (status !== 'authenticated' || !session?.user?.id) return; - const key = `future:todayCity:${session.user.id}`; - try { - window.localStorage.setItem(key, JSON.stringify(next)); - } catch { - // ignore - } - } +import { HeroSection } from '@/components/home/HeroSection'; +import { PredictQuestionCards } from '@/components/home/PredictQuestionCards'; +export default function HomePage() { return ( -

-
-

- {copy.chart.titlePrefix} {copy.today.title} -

-
-

- {todaySubtitle} -

- {status === 'authenticated' && ( - - )} -
- -
-
- - - -
- - {formatChartDate(chartMoment, location)} - - -
- - - -
-
- - {status === 'authenticated' && showCustomize && ( -
-
- - -
-
- )} -
- - {result ? ( -
- -
- ) : ( -

loading…

- )} - -
- - - - - - - -
- - - -
- -
+
+ +
); } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 63dea29..83926b6 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -15,36 +15,36 @@ export default function PrivacyPage() { > {copy.privacy.backHome} -

- {copy.privacy.title} +

+ {copy.chart.titlePrefix} {copy.privacy.title} {copy.chart.titleSuffix}

{copy.privacy.lastUpdated}

{copy.privacy.intro}

-

{copy.privacy.sections.collection}

+

{copy.privacy.sections.collection}

We may collect information you provide when you create an account, save a chart, or contact us — such as email, name, and birth data you choose to save. We also collect basic usage data to improve the service.

-

{copy.privacy.sections.use}

+

{copy.privacy.sections.use}

We use your information to provide and improve our services, to communicate with you, and to comply with legal obligations. We do not sell your personal information.

-

{copy.privacy.sections.sharing}

+

{copy.privacy.sections.sharing}

We may share information with service providers who assist in operating the site, or when required by law. We do not share your data with third parties for their marketing purposes.

-

{copy.privacy.sections.security}

+

{copy.privacy.sections.security}

We take reasonable steps to protect your information using industry-standard practices. No method of transmission over the internet is fully secure; we encourage you to use strong passwords and keep your @@ -52,7 +52,7 @@ export default function PrivacyPage() {

-

{copy.privacy.sections.contact}

+

{copy.privacy.sections.contact}

If you have questions about this privacy policy or your data, please contact us through the contact information provided on the site. diff --git a/app/register/page.tsx b/app/register/page.tsx index 828328a..4812b08 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -32,8 +32,8 @@ export default function RegisterPage() { ← {copy.site.title} -

- {copy.auth.registerTitle} +

+ {copy.chart.titlePrefix} {copy.auth.registerTitle} {copy.chart.titleSuffix}

@@ -50,8 +50,8 @@ export default function RegisterPage() { ← {copy.site.title} -

- {copy.auth.registerTitle} +

+ {copy.chart.titlePrefix} {copy.auth.registerTitle} {copy.chart.titleSuffix}

@@ -70,8 +70,8 @@ export default function RegisterPage() { ← {copy.site.title} -

- {copy.auth.registerTitle} +

+ {copy.chart.titlePrefix} {copy.auth.registerTitle} {copy.chart.titleSuffix}

diff --git a/app/solar-system/page.tsx b/app/solar-system/page.tsx index 2c85c62..71802f2 100644 --- a/app/solar-system/page.tsx +++ b/app/solar-system/page.tsx @@ -17,8 +17,8 @@ export default function SolarSystemPage() { return (
-

- {copy.chart.titlePrefix} {copy.solarSystem.title} +

+ {copy.chart.titlePrefix} {copy.solarSystem.title} {copy.chart.titleSuffix}

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 38422ed..eaa8964 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -15,22 +15,22 @@ export default function TermsPage() { > {copy.terms.backHome} -

- {copy.terms.title} +

+ {copy.chart.titlePrefix} {copy.terms.title} {copy.chart.titleSuffix}

{copy.terms.lastUpdated}

{copy.terms.intro}

-

{copy.terms.sections.acceptance}

+

{copy.terms.sections.acceptance}

By accessing or using future, you agree to be bound by these terms. If you do not agree, please do not use our services.

-

{copy.terms.sections.use}

+

{copy.terms.sections.use}

You may use our services for personal, non-commercial use. You agree not to misuse the service, attempt to gain unauthorized access, or use it for any illegal purpose. We provide astrological content for @@ -38,14 +38,14 @@ export default function TermsPage() {

-

{copy.terms.sections.account}

+

{copy.terms.sections.account}

If you create an account, you are responsible for keeping your credentials secure and for all activity under your account. We may suspend or terminate accounts that violate these terms.

-

{copy.terms.sections.content}

+

{copy.terms.sections.content}

Content you save (e.g. charts, labels) remains yours. By using the service you grant us the limited rights needed to store and display that content. Do not upload content that infringes others’ rights or is @@ -53,7 +53,7 @@ export default function TermsPage() {

-

{copy.terms.sections.contact}

+

{copy.terms.sections.contact}

For questions about these terms, please contact us through the contact information provided on the site.

diff --git a/app/today/page.tsx b/app/today/page.tsx index fc83c05..0eb35d9 100644 --- a/app/today/page.tsx +++ b/app/today/page.tsx @@ -1,5 +1,300 @@ -import { redirect } from 'next/navigation'; +'use client'; + +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useSession } from 'next-auth/react'; +import { ChartResults } from '@/components/chart/ChartResults'; +import { ConjunctionPlot } from '@/components/chart/ConjunctionPlot'; +import { NextConjunctions } from '@/components/chart/NextConjunctions'; +import { CitySearch } from '@/components/chart/CitySearch'; +import { Button } from '@/components/ui/Button'; +import { calculateChart } from '@/lib/astro/calculate'; +import { type BirthData, type ChartResult, type GeoResult, CHART_OF_MOMENT_OPTIONS } from '@/lib/astro/types'; +import { copy } from '@/lib/copy'; + +const SAN_FRANCISCO_TZ = 'America/Los_Angeles'; +const SAN_FRANCISCO_LAT = 37.7749; +const SAN_FRANCISCO_LON = -122.4194; +const SAN_FRANCISCO_UTC_OFFSET = -8; // Pacific Time (standard) +const DENVER_TZ = 'America/Denver'; + +type TodayLocation = { + latitude: number; + longitude: number; + utcOffset: number; + cityLabel: string; + timeZone?: string; +}; + +const DEFAULT_LOCATION: TodayLocation = { + latitude: SAN_FRANCISCO_LAT, + longitude: SAN_FRANCISCO_LON, + utcOffset: SAN_FRANCISCO_UTC_OFFSET, + cityLabel: 'San Francisco', + timeZone: SAN_FRANCISCO_TZ, +}; + +function inferTimeZone(cityLabel: string): string | undefined { + const name = cityLabel.toLowerCase(); + if (name.includes('san francisco')) return SAN_FRANCISCO_TZ; + if (name.includes('denver')) return DENVER_TZ; + // Fallback: simple US guess by longitude if needed later + return undefined; +} + +function buildBirthDataForMoment(moment: Date, location: TodayLocation): BirthData { + if (location.timeZone) { + const parts = moment.toLocaleString('sv-SE', { timeZone: location.timeZone }).split(' '); + const [datePart, timePart] = parts; + const [y, m, d] = datePart.split('-'); + const [hh, mm] = timePart.split(':'); + const localHours = parseInt(hh, 10) + parseInt(mm, 10) / 60; + const utcHours = moment.getUTCHours() + moment.getUTCMinutes() / 60; + const utcOffset = Math.round((localHours - utcHours) * 2) / 2; + return { + date: `${y}-${m}-${d}`, + time: `${hh.padStart(2, '0')}:${mm.padStart(2, '0')}`, + latitude: location.latitude, + longitude: location.longitude, + utcOffset, + cityLabel: location.cityLabel, + }; + } + + const utcMs = moment.getTime(); + const localMs = utcMs + location.utcOffset * 3600 * 1000; + const local = new Date(localMs); + + const y = local.getUTCFullYear(); + const m = local.getUTCMonth() + 1; + const d = local.getUTCDate(); + const hh = local.getUTCHours(); + const mm = local.getUTCMinutes(); + + return { + date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`, + time: `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`, + latitude: location.latitude, + longitude: location.longitude, + utcOffset: location.utcOffset, + cityLabel: location.cityLabel, + }; +} + +function formatChartDate(d: Date, location: TodayLocation): string { + if (location.timeZone) { + return d.toLocaleDateString('en-US', { + timeZone: location.timeZone, + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + + const utcMs = d.getTime(); + const localMs = utcMs + location.utcOffset * 3600 * 1000; + const local = new Date(localMs); + + return local.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} export default function TodayPage() { - redirect('/'); + const { data: session, status } = useSession(); + const [chartMoment, setChartMoment] = useState(() => new Date()); + const [location, setLocation] = useState(DEFAULT_LOCATION); + const [showCustomize, setShowCustomize] = useState(false); + + // Load saved city for authenticated users from localStorage on mount / auth change. + useEffect(() => { + if (status !== 'authenticated' || !session?.user?.id) return; + const key = `future:todayCity:${session.user.id}`; + try { + const raw = window.localStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.latitude === 'number' && + typeof parsed.longitude === 'number' && + typeof parsed.utcOffset === 'number' && + typeof parsed.cityLabel === 'string' + ) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setLocation({ + latitude: parsed.latitude, + longitude: parsed.longitude, + utcOffset: parsed.utcOffset, + cityLabel: parsed.cityLabel, + timeZone: parsed.timeZone ?? inferTimeZone(parsed.cityLabel), + }); + } + } catch { + // ignore + } + }, [status, session?.user?.id]); + + // When not authenticated, always fall back to the San Francisco default. + useEffect(() => { + if (status !== 'authenticated') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setLocation(DEFAULT_LOCATION); + } + }, [status]); + + const result = useMemo(() => { + const data = buildBirthDataForMoment(chartMoment, location); + return calculateChart(data, CHART_OF_MOMENT_OPTIONS); + }, [chartMoment, location]); + + const adjust = useCallback((unit: 'day' | 'month' | 'year', delta: number) => { + setChartMoment((prev) => { + const next = new Date(prev); + if (unit === 'day') next.setDate(next.getDate() + delta); + else if (unit === 'month') next.setMonth(next.getMonth() + delta); + else next.setFullYear(next.getFullYear() + delta); + return next; + }); + }, []); + + const adjustHours = useCallback((deltaHours: number) => { + setChartMoment((prev) => new Date(prev.getTime() + deltaHours * 3600 * 1000)); + }, []); + + const influenceRange = useMemo(() => { + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(start.getDate() + 6); + end.setHours(23, 59, 59, 999); + return { start, end }; + }, []); + + const todaySubtitle = + location.cityLabel === 'San Francisco' + ? copy.today.subtitle + : `current transits in ${location.cityLabel}`; + + function handleCityResult(geo: GeoResult) { + const next: TodayLocation = { + latitude: geo.latitude, + longitude: geo.longitude, + utcOffset: geo.utcOffset, + cityLabel: geo.displayName, + timeZone: inferTimeZone(geo.displayName), + }; + setLocation(next); + if (status !== 'authenticated' || !session?.user?.id) return; + const key = `future:todayCity:${session.user.id}`; + try { + window.localStorage.setItem(key, JSON.stringify(next)); + } catch { + // ignore + } + } + + function handleCityReset() { + const next = DEFAULT_LOCATION; + setLocation(next); + if (status !== 'authenticated' || !session?.user?.id) return; + const key = `future:todayCity:${session.user.id}`; + try { + window.localStorage.setItem(key, JSON.stringify(next)); + } catch { + // ignore + } + } + + return ( +
+
+

+ {copy.chart.titlePrefix} {copy.today.title} {copy.chart.titleSuffix} +

+
+

+ {todaySubtitle} +

+ {status === 'authenticated' && ( + + )} +
+ +
+
+ + + +
+ + {formatChartDate(chartMoment, location)} + + +
+ + + +
+
+ + {status === 'authenticated' && showCustomize && ( +
+
+ + +
+
+ )} +
+ + {result ? ( +
+ +
+ ) : ( +

loading…

+ )} + +
+ + + + + + + +
+ + + +
+ +
+
+ ); } diff --git a/auth.ts b/auth.ts index 3cff182..0c3b8b3 100644 --- a/auth.ts +++ b/auth.ts @@ -18,7 +18,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET, - allowDangerousEmailAccountLinking: true, }), ] : []), @@ -27,7 +26,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET, - allowDangerousEmailAccountLinking: true, }), ] : []), diff --git a/claude.md b/claude.md index 6110dc6..d199141 100644 --- a/claude.md +++ b/claude.md @@ -29,12 +29,17 @@ - `make dev` or `npm run dev` — development server - `make build` / `make start` — production build and run +- `make generate` — `prisma generate` (client from schema) +- `make migrate` — `prisma migrate deploy` (apply migrations; needs `DIRECT_URL` / `DATABASE_URL`) +- `make migrate-dev` — `prisma migrate dev` (local schema changes) - `make lint` — ESLint - `make typecheck` — TypeScript check - `make test` — run tests (Vitest) - `make install` — install dependencies - `make clean` — remove `.next` and `node_modules` +**Astro coins / Stripe:** balance and ledger live in Postgres (`User.astroCoins`, `AstroCoinLedger`); setup is in **docs/STRIPE.md**. + ## Pre-commit Husky runs `lint-staged` on commit; staged `.ts`/`.tsx`/`.js`/`.jsx`/`.mjs` files are linted with ESLint (with fix). diff --git a/codex.md b/codex.md index b7f619b..33118ba 100644 --- a/codex.md +++ b/codex.md @@ -56,6 +56,7 @@ future-ai-app/ - **Lint**: ESLint (Next.js config); `npm run lint` / `npm run lint:fix`. - **Typecheck**: `npm run typecheck` (`tsc --noEmit`). +- **Database**: `make migrate` (`prisma migrate deploy`), `make migrate-dev`, `make generate`; see **docs/SETUP.md** / **docs/DEPLOYMENT.md**. Astro coins + Stripe: **docs/STRIPE.md**. - **Pre-commit**: Husky + lint-staged; ESLint --fix on staged TS/JS files. ## Docs diff --git a/components/chart/AscendantCard.tsx b/components/chart/AscendantCard.tsx index 498fca6..1515efa 100644 --- a/components/chart/AscendantCard.tsx +++ b/components/chart/AscendantCard.tsx @@ -14,7 +14,7 @@ export function AscendantCard({ result, showMc = true }: Props) { return ( -

+

{copy.ascendant.title}

@@ -22,8 +22,8 @@ export function AscendantCard({ result, showMc = true }: Props) {
{ascInfo.glyph} {ascInfo.sign}
-
- {ascInfo.deg}° {ascInfo.min}' {copy.ascendant.risingSuffix} +
+ {ascInfo.deg}° {ascInfo.min}'
diff --git a/components/chart/BirthDataForm.tsx b/components/chart/BirthDataForm.tsx index 761a21b..e4b7999 100644 --- a/components/chart/BirthDataForm.tsx +++ b/components/chart/BirthDataForm.tsx @@ -103,11 +103,15 @@ export function BirthDataForm({ onSubmit, isLoading }: Props) { return ( -

{copy.form.sectionTitle}

+

+ {copy.form.sectionTitle} +

- +
- +
- +
+
+ + ); +} diff --git a/components/dashboard/MyPredictionsSection.tsx b/components/dashboard/MyPredictionsSection.tsx new file mode 100644 index 0000000..e6c2c8a --- /dev/null +++ b/components/dashboard/MyPredictionsSection.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { PredictBetMoreModal } from '@/components/dashboard/PredictBetMoreModal'; +import { PredictWithdrawModal } from '@/components/dashboard/PredictWithdrawModal'; +import { copy } from '@/lib/copy'; + +export type DashboardPredictionBet = { + id: string; + questionId: number; + side: string; + question: string; + category: string | null; + expiresAt: string | null; + coins: number; + createdAt: string; + /** Larger of yes/no market share (same engine as predict cards). */ + leadingMarketPercent?: string; +}; + +function formatExpires(isoDate: string): string { + const d = new Date(`${isoDate}T12:00:00`); + if (Number.isNaN(d.getTime())) return isoDate; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +type Props = { + bets: DashboardPredictionBet[]; + onPredictionsRefresh: () => void; + onWalletRefresh: () => void; +}; + +export function MyPredictionsSection({ + bets, + onPredictionsRefresh, + onWalletRefresh, +}: Props) { + const [withdrawBet, setWithdrawBet] = useState(null); + const [betMoreBet, setBetMoreBet] = useState(null); + const headingClass = + 'text-base sm:text-lg md:text-xl font-extrabold text-violet-500 dark:text-violet-400 tracking-widest uppercase mb-8'; + + if (bets.length === 0) { + return ( +
+

{copy.dashboard.myPredictions}

+ +

{copy.dashboard.myPredictionsEmpty}

+ + + +
+
+ ); + } + + return ( +
+ setWithdrawBet(null)} + onSuccess={() => { + onPredictionsRefresh(); + onWalletRefresh(); + }} + /> + setBetMoreBet(null)} + onSuccess={() => { + onPredictionsRefresh(); + onWalletRefresh(); + }} + /> +

{copy.dashboard.myPredictions}

+
    + {bets.map(bet => { + const sideLabel = + bet.side === 'yes' ? copy.predict.yes : bet.side === 'no' ? copy.predict.no : bet.side; + return ( +
  • + +

    + {bet.leadingMarketPercent ?? '50%'} +

    +
    + {bet.category ? ( +

    + {bet.category} +

    + ) : null} +

    + {bet.question} +

    +

    + + {bet.coins.toLocaleString()} + {' '} + {copy.dashboard.myPredictionsCoins}{' '} + {copy.dashboard.myPredictionsOn}{' '} + {sideLabel} + + {' · '} + {copy.dashboard.myPredictionsInvested}{' '} + {new Date(bet.createdAt).toLocaleDateString(undefined, { + dateStyle: 'medium', + })} + +

    + {bet.expiresAt ? ( +

    + {copy.predict.questionExpiresPrefix} {formatExpires(bet.expiresAt)} +

    + ) : null} +
    +
    +
    + + +
    +
    +
    +
  • + ); + })} +
+
+ ); +} diff --git a/components/dashboard/PredictBetMoreModal.tsx b/components/dashboard/PredictBetMoreModal.tsx new file mode 100644 index 0000000..5deca88 --- /dev/null +++ b/components/dashboard/PredictBetMoreModal.tsx @@ -0,0 +1,340 @@ +'use client'; + +import { useCallback, useEffect, useId, useState } from 'react'; +import Link from 'next/link'; +import { useSession } from 'next-auth/react'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { copy } from '@/lib/copy'; +import { adjustBetAmountInput, parseBetAmountInput } from '@/lib/predict-bet-amount-step'; +import { cn } from '@/lib/utils'; +import type { DashboardPredictionBet } from '@/components/dashboard/MyPredictionsSection'; + +const predictTitleRainbow = + 'bg-gradient-to-r from-violet-400 to-fuchsia-300 bg-clip-text text-transparent'; + +const NOT_ENOUGH_KEY = 'not_enough'; + +type PredictCopy = typeof copy.predict & { + investBalanceLabel?: string; + investAmountLabel?: string; + investConfirm?: string; + investCancel?: string; + investSubmitting?: string; + investErrorGeneric?: string; + investInvalidAmount?: string; + investNotEnoughCoins?: string; + investAddCoins?: string; + investGoDashboard?: string; + investLoadingBalance?: string; + investSignInPrompt?: string; + investGoSignIn?: string; +}; + +type Props = { + open: boolean; + bet: DashboardPredictionBet | null; + onClose: () => void; + onSuccess: () => void; +}; + +export function PredictBetMoreModal({ open, bet, onClose, onSuccess }: Props) { + const { status } = useSession(); + const titleId = useId(); + const p = copy.predict as PredictCopy; + const d = copy.dashboard; + + const [balance, setBalance] = useState(0); + const [loadingBalance, setLoadingBalance] = useState(false); + const [authRequired, setAuthRequired] = useState(false); + const [amount, setAmount] = useState('1'); + const [submitting, setSubmitting] = useState(false); + const [errorKey, setErrorKey] = useState(null); + + const fetchBalance = useCallback(async () => { + if (status !== 'authenticated') return; + setLoadingBalance(true); + setAuthRequired(false); + try { + const opts: RequestInit = { credentials: 'same-origin', cache: 'no-store' }; + let res = await fetch('/api/astro-coins', opts); + if (res.status === 401) { + setAuthRequired(true); + setBalance(0); + return; + } + if (!res.ok) { + await new Promise(r => setTimeout(r, 400)); + res = await fetch('/api/astro-coins', opts); + } + if (res.status === 401) { + setAuthRequired(true); + setBalance(0); + return; + } + if (!res.ok) { + setBalance(0); + return; + } + const data = (await res.json()) as { coins?: number }; + if (typeof data.coins === 'number' && Number.isFinite(data.coins)) { + setBalance(Math.max(0, Math.floor(data.coins))); + } + } catch { + setBalance(0); + } finally { + setLoadingBalance(false); + } + }, [status]); + + useEffect(() => { + if (!open || !bet) return; + setErrorKey(null); + setAmount('1'); + setAuthRequired(false); + if (status === 'authenticated') void fetchBalance(); + else setBalance(0); + }, [open, bet, status, fetchBalance]); + + useEffect(() => { + if (!open || loadingBalance || status !== 'authenticated' || !bet) return; + if (balance >= 1) { + setAmount(String(Math.min(10, balance))); + } + }, [open, loadingBalance, status, balance, bet]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!bet || status !== 'authenticated' || authRequired) return; + setErrorKey(null); + const n = Number.parseInt(amount.replace(/\D/g, ''), 10); + if (!Number.isFinite(n) || n < 1) { + setErrorKey('invalid'); + return; + } + if (n > balance) { + setErrorKey(NOT_ENOUGH_KEY); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`/api/predict/bets/${bet.id}/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ coins: n }), + }); + if (res.status === 402) { + setErrorKey(NOT_ENOUGH_KEY); + void fetchBalance(); + return; + } + if (!res.ok) { + setErrorKey('generic'); + return; + } + onSuccess(); + onClose(); + } catch { + setErrorKey('generic'); + } finally { + setSubmitting(false); + } + } + + if (!open || !bet) return null; + + const sideLabel = + bet.side === 'yes' || bet.side === 'no' + ? bet.side === 'yes' + ? copy.predict.yes + : copy.predict.no + : bet.side; + const notEnoughMessage = p.investNotEnoughCoins ?? "you don't have enough coins."; + + return ( +
+
+ e.stopPropagation()} + > +

+ {sideLabel} +

+

+ {bet.question} +

+

+ {d.myPredictionsBetMoreCurrent}:{' '} + + {bet.coins.toLocaleString()} + {' '} + {d.myPredictionsCoins} {d.myPredictionsOn} {sideLabel} +

+ + {status === 'unauthenticated' || status === 'loading' ? ( +
+

{p.investSignInPrompt ?? 'Sign in.'}

+
+ + + + +
+
+ ) : authRequired ? ( +
+

{p.investSignInPrompt ?? 'Sign in.'}

+
+ + + +
+
+ ) : loadingBalance ? ( +
+

+ {p.investLoadingBalance ?? 'loading…'} +

+
+ ) : balance < 1 ? ( +
+

{notEnoughMessage}

+ + + +
+ ) : ( +
+

+ {p.investBalanceLabel ?? 'Balance'}:{' '} + {balance.toLocaleString()} +

+

{d.myPredictionsBetMoreHowMany}

+
+ + { + setAmount(e.target.value); + if (errorKey != null) setErrorKey(null); + }} + disabled={submitting} + className="w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none focus:border-violet-500/50 focus:ring-2 ring-violet-500/20" + /> +
+
+ + + +
+ {errorKey === 'invalid' ? ( +

{p.investInvalidAmount ?? 'Invalid amount.'}

+ ) : null} + {errorKey === NOT_ENOUGH_KEY ? ( +

{notEnoughMessage}

+ ) : null} + {errorKey === 'generic' ? ( +

{p.investErrorGeneric ?? 'Error'}

+ ) : null} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/components/dashboard/PredictWithdrawModal.tsx b/components/dashboard/PredictWithdrawModal.tsx new file mode 100644 index 0000000..1c4855c --- /dev/null +++ b/components/dashboard/PredictWithdrawModal.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useCallback, useEffect, useId, useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { copy } from '@/lib/copy'; +import { cn } from '@/lib/utils'; +import type { DashboardPredictionBet } from '@/components/dashboard/MyPredictionsSection'; + +type Props = { + open: boolean; + bet: DashboardPredictionBet | null; + onClose: () => void; + onSuccess: () => void; +}; + +export function PredictWithdrawModal({ open, bet, onClose, onSuccess }: Props) { + const titleId = useId(); + const [amount, setAmount] = useState('1'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const maxCoins = bet?.coins ?? 0; + + useEffect(() => { + if (!open || !bet) return; + setAmount(String(Math.min(10, maxCoins))); + setError(null); + }, [open, bet, maxCoins]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!bet) return; + setError(null); + const n = Number.parseInt(amount.replace(/\D/g, ''), 10); + if (!Number.isFinite(n) || n < 1) { + setError(copy.dashboard.myPredictionsWithdrawInvalid); + return; + } + if (n > bet.coins) { + setError(copy.dashboard.myPredictionsWithdrawTooMany); + return; + } + + setSubmitting(true); + try { + const res = await fetch(`/api/predict/bets/${bet.id}/withdraw`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ coins: n }), + }); + const data = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) { + setError( + typeof data.error === 'string' && data.error.trim() !== '' + ? data.error + : copy.dashboard.myPredictionsWithdrawError, + ); + return; + } + onSuccess(); + onClose(); + } catch { + setError(copy.dashboard.myPredictionsWithdrawError); + } finally { + setSubmitting(false); + } + }, + [amount, bet, onClose, onSuccess], + ); + + if (!open || !bet) return null; + + const sideLabel = + bet.side === 'yes' ? copy.predict.yes : bet.side === 'no' ? copy.predict.no : bet.side; + + return ( +
+
+ e.stopPropagation()} + > +

+ {copy.dashboard.myPredictionsWithdraw} +

+

{bet.question}

+

+ {copy.dashboard.myPredictionsWithdrawStaked}{' '} + {bet.coins.toLocaleString()}{' '} + {copy.dashboard.myPredictionsCoins} {copy.dashboard.myPredictionsOn}{' '} + {sideLabel} +

+ +
+
+ + { + setAmount(e.target.value); + if (error) setError(null); + }} + disabled={submitting} + className="w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none focus:border-violet-500/50 focus:ring-2 ring-violet-500/20 disabled:opacity-50" + /> +

+ {copy.dashboard.myPredictionsWithdrawHint} +

+
+ + {error ?

{error}

: null} + +
+ + +
+
+
+
+ ); +} diff --git a/components/home/HeroSection.tsx b/components/home/HeroSection.tsx index 943ff27..baa53e1 100644 --- a/components/home/HeroSection.tsx +++ b/components/home/HeroSection.tsx @@ -1,58 +1,34 @@ -import Link from 'next/link'; -import { Button } from '@/components/ui/Button'; import { copy } from '@/lib/copy'; +/** Gradient on ✦ + title (matches other page titles). */ +const predictHeroGradient = + 'bg-gradient-to-r from-violet-400 to-fuchsia-300 bg-clip-text text-transparent'; + export function HeroSection() { return ( -
-
{copy.home.heroIcon}
-

- {copy.home.title} +
+

+ + {copy.predict.heroIcon} + + + {copy.predict.title} + + + {copy.predict.titleSuffix} +

-

- {copy.home.subtitle} +

+ {copy.predict.subtitle}

- - - -
- {copy.home.features.map(({ icon, label }) => ( -
-
- {icon === 'handshake' ? ( - - - - - - ) : ( - {icon} - )} -
- {label} -
- ))} -
- - - check today’s chart -
); } diff --git a/components/home/PredictInvestModal.tsx b/components/home/PredictInvestModal.tsx new file mode 100644 index 0000000..2903ae3 --- /dev/null +++ b/components/home/PredictInvestModal.tsx @@ -0,0 +1,415 @@ +'use client'; + +import { useCallback, useEffect, useId, useState } from 'react'; +import Link from 'next/link'; +import { useSession } from 'next-auth/react'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { copy } from '@/lib/copy'; +import { adjustBetAmountInput, parseBetAmountInput } from '@/lib/predict-bet-amount-step'; +import { cn } from '@/lib/utils'; + +type PredictQuestionItem = { + id: number; + question: string; +}; + +type PredictInvestBetKind = 'binary' | 'mc'; + +type Props = { + open: boolean; + onClose: () => void; + question: PredictQuestionItem | null; + cardIndex: number; + betKind: PredictInvestBetKind; + /** Binary: `yes` | `no`. Multiple choice: exact option label from the question. */ + side: string; + onInvested: (cardIndex: number, side: string, coins: number) => void; +}; + +const NOT_ENOUGH_KEY = 'not_enough'; + +/** Same gradient treatment as `HeroSection` predict title. */ +const predictTitleRainbow = + 'bg-gradient-to-r from-violet-400 to-fuchsia-300 bg-clip-text text-transparent'; + +type PredictInvestCopy = typeof copy.predict & { + investModalHowMany?: string; + investBalanceLabel?: string; + investSignInPrompt?: string; + investGoDashboard?: string; + investGoSignIn?: string; + investAmountLabel?: string; + investConfirm?: string; + investCancel?: string; + investSubmitting?: string; + investSuccessToast?: string; + investErrorGeneric?: string; + investInvalidAmount?: string; + investNotEnoughCoins?: string; + investAddCoins?: string; + investLoadingBalance?: string; +}; + +export function PredictInvestModal({ + open, + onClose, + question, + cardIndex, + betKind, + side, + onInvested, +}: Props) { + const { status } = useSession(); + const titleId = useId(); + const p = copy.predict as PredictInvestCopy; + + const [balance, setBalance] = useState(0); + const [loadingBalance, setLoadingBalance] = useState(false); + const [authRequired, setAuthRequired] = useState(false); + const [amount, setAmount] = useState('1'); + const [submitting, setSubmitting] = useState(false); + const [errorKey, setErrorKey] = useState(null); + + const fetchBalance = useCallback(async () => { + if (status !== 'authenticated') return; + setLoadingBalance(true); + setAuthRequired(false); + try { + const opts: RequestInit = { + credentials: 'same-origin', + cache: 'no-store', + }; + + let res = await fetch('/api/astro-coins', opts); + if (res.status === 401) { + setAuthRequired(true); + setBalance(0); + return; + } + if (!res.ok) { + await new Promise(r => setTimeout(r, 400)); + res = await fetch('/api/astro-coins', opts); + } + if (res.status === 401) { + setAuthRequired(true); + setBalance(0); + return; + } + if (!res.ok) { + setBalance(0); + return; + } + const data = (await res.json()) as { coins?: number }; + const n = + typeof data.coins === 'number' && Number.isFinite(data.coins) + ? Math.max(0, Math.floor(data.coins)) + : 0; + setBalance(n); + } catch { + setBalance(0); + } finally { + setLoadingBalance(false); + } + }, [status]); + + useEffect(() => { + if (!open) return; + setErrorKey(null); + setAmount('1'); + setAuthRequired(false); + if (status === 'authenticated') { + void fetchBalance(); + } else { + setBalance(0); + } + }, [open, status, fetchBalance]); + + useEffect(() => { + if (!open || loadingBalance || status !== 'authenticated') return; + if (balance >= 1) { + setAmount(String(Math.min(10, balance))); + } + }, [open, loadingBalance, status, balance]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!question || status !== 'authenticated' || authRequired) return; + setErrorKey(null); + const n = Number.parseInt(amount.replace(/\D/g, ''), 10); + if (!Number.isFinite(n) || n < 1) { + setErrorKey('invalid'); + return; + } + if (n > balance) { + setErrorKey(NOT_ENOUGH_KEY); + return; + } + + setSubmitting(true); + try { + const res = await fetch('/api/predict/bet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + questionId: question.id, + side, + coins: n, + }), + }); + const data = (await res.json().catch(() => ({}))) as { + error?: string; + balance?: number; + }; + if (res.status === 402) { + setErrorKey(NOT_ENOUGH_KEY); + void fetchBalance(); + return; + } + if (!res.ok) { + setErrorKey('generic'); + return; + } + onInvested(cardIndex, side, n); + if (typeof data.balance === 'number' && Number.isFinite(data.balance)) { + setBalance(Math.max(0, Math.floor(data.balance))); + } + onClose(); + } catch { + setErrorKey('generic'); + } finally { + setSubmitting(false); + } + } + + if (!open || !question) return null; + + const sideLabel = + betKind === 'mc' + ? side + : side === 'yes' + ? copy.predict.yes + : copy.predict.no; + const notEnoughMessage = p.investNotEnoughCoins ?? "you don't have enough coins."; + const showInsufficient = + !loadingBalance && status === 'authenticated' && !authRequired && balance < 1; + const showForm = + status === 'authenticated' && !authRequired && !loadingBalance && balance >= 1; + + return ( +
+
+ e.stopPropagation()} + > +

+ {sideLabel} +

+

+ {question.question} +

+ + {status === 'unauthenticated' || status === 'loading' ? ( +
+

+ {p.investSignInPrompt ?? 'Sign in to invest.'} +

+
+ + + + +
+
+ ) : authRequired ? ( +
+

+ {p.investSignInPrompt ?? 'Sign in to continue.'} +

+
+ + + + +
+
+ ) : loadingBalance ? ( +
+

+ {p.investLoadingBalance ?? 'loading balance'} +

+
+ ) : showInsufficient ? ( +
+

{notEnoughMessage}

+
+ + + + +
+
+ ) : showForm ? ( +
+

+ {p.investBalanceLabel ?? 'Balance'}:{' '} + + {balance.toLocaleString()} + +

+

{p.investModalHowMany}

+
+ + { + setAmount(e.target.value); + if (errorKey != null) setErrorKey(null); + }} + disabled={submitting} + className="w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none focus:border-violet-500/50 focus:ring-2 ring-violet-500/20" + /> +
+
+ + + +
+ {errorKey === 'invalid' ? ( +

{p.investInvalidAmount ?? 'Invalid amount.'}

+ ) : null} + {errorKey === NOT_ENOUGH_KEY ? ( +

{notEnoughMessage}

+ ) : null} + {errorKey === 'generic' ? ( +

{p.investErrorGeneric ?? 'Error'}

+ ) : null} + {errorKey === NOT_ENOUGH_KEY ? ( +
+ + + + +
+ ) : null} +
+ + {errorKey !== NOT_ENOUGH_KEY ? ( + + ) : null} +
+
+ ) : null} +
+
+ ); +} diff --git a/components/home/PredictQuestionCards.tsx b/components/home/PredictQuestionCards.tsx new file mode 100644 index 0000000..cd11d67 --- /dev/null +++ b/components/home/PredictQuestionCards.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { PredictInvestModal } from '@/components/home/PredictInvestModal'; +import { copy } from '@/lib/copy'; +import { cn } from '@/lib/utils'; + +type PredictOutcomeType = 'Binary' | 'Multiple Choice'; + +type PredictQuestionItem = { + id: number; + category: string; + question: string; + outcome_type: PredictOutcomeType; + /** ISO date (YYYY-MM-DD), e.g. market close / resolution window. */ + expiresAt: string; + options?: string[]; +}; + +function formatQuestionExpires(isoDate: string): string { + const d = new Date(`${isoDate}T12:00:00`); + if (Number.isNaN(d.getTime())) return isoDate; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +/** Matches `grid-cols-2 lg:grid-cols-4` (Tailwind lg = 1024px). */ +const LG_MEDIA_QUERY = '(min-width: 1024px)'; + +function useCardsPerRow(): number { + return useSyncExternalStore( + onStoreChange => { + const mq = window.matchMedia(LG_MEDIA_QUERY); + mq.addEventListener('change', onStoreChange); + return () => mq.removeEventListener('change', onStoreChange); + }, + () => (window.matchMedia(LG_MEDIA_QUERY).matches ? 4 : 2), + () => 2, + ); +} + +/** Deterministic “random” percentage per card (stable SSR + hydration). */ +function randomEstimationPercent(cardIndex: number): number { + let s = (cardIndex + 1) * 0x6d2b79f5 + 0x9e3779b9; + s ^= s << 13; + s ^= s >>> 7; + s ^= s << 17; + return ((s >>> 0) % 73) + 18; +} + +/** Shown on yes/no buttons before odds load or when a question has no bets yet. */ +const MARKET_ODDS_PLACEHOLDER = '50%'; + +/** Equal-split placeholder % per choice so options sum to 100 (e.g. 4 → 25% each). */ +function equalOptionPercent(optionIndex: number, optionCount: number): number { + if (optionCount <= 0) return 0; + const base = Math.floor(100 / optionCount); + const remainder = 100 - base * optionCount; + return optionIndex < remainder ? base + 1 : base; +} + +function isPredictQuestionItem(q: unknown): q is PredictQuestionItem { + return ( + typeof q === 'object' && + q !== null && + 'id' in q && + typeof (q as PredictQuestionItem).id === 'number' && + 'category' in q && + typeof (q as PredictQuestionItem).category === 'string' && + 'question' in q && + typeof (q as PredictQuestionItem).question === 'string' && + 'outcome_type' in q && + ((q as PredictQuestionItem).outcome_type === 'Binary' || + (q as PredictQuestionItem).outcome_type === 'Multiple Choice') && + 'expiresAt' in q && + typeof (q as PredictQuestionItem).expiresAt === 'string' + ); +} + +function normalizePool(raw: unknown): PredictQuestionItem[] { + if (!Array.isArray(raw)) return []; + return raw.filter(isPredictQuestionItem); +} + +function selectionAt(map: Record, i: number): string | null { + const v = map[i]; + return typeof v === 'string' && v.length > 0 ? v : null; +} + +type InvestState = { + item: PredictQuestionItem; + index: number; +} & ( + | { kind: 'binary'; side: 'yes' | 'no' } + | { kind: 'mc'; choice: string } +); + +type MarketOddsRow = { yes: string; no: string; choicePercents?: Record }; + +export function PredictQuestionCards() { + const pool = useMemo(() => normalizePool(copy.predict.questions), []); + const cardsPerRow = useCardsPerRow(); + /** Number of grid rows currently shown (each load more adds one row). */ + const [rowsShown, setRowsShown] = useState(1); + const [answers, setAnswers] = useState>({}); + const [invest, setInvest] = useState(null); + const [toast, setToast] = useState(null); + const [marketOdds, setMarketOdds] = useState>({}); + const [oddsRefreshKey, setOddsRefreshKey] = useState(0); + + useEffect(() => { + let cancelled = false; + fetch('/api/predict/odds', { cache: 'no-store' }) + .then(res => (res.ok ? res.json() : null)) + .then((data: unknown) => { + if (cancelled || !data || typeof data !== 'object' || data === null) return; + const raw = (data as { odds?: unknown }).odds; + if (typeof raw !== 'object' || raw === null) return; + const next: Record = {}; + for (const [key, entry] of Object.entries(raw)) { + const id = Number(key); + if (!Number.isFinite(id)) continue; + if ( + typeof entry !== 'object' || + entry === null || + !('yesPercent' in entry) || + !('noPercent' in entry) + ) { + continue; + } + const yesPercent = (entry as { yesPercent?: unknown }).yesPercent; + const noPercent = (entry as { noPercent?: unknown }).noPercent; + if (typeof yesPercent !== 'string' || typeof noPercent !== 'string') continue; + const row: MarketOddsRow = { yes: yesPercent, no: noPercent }; + const cp = (entry as { choicePercents?: unknown }).choicePercents; + if (typeof cp === 'object' && cp !== null && !Array.isArray(cp)) { + const choicePercents: Record = {}; + for (const [k, v] of Object.entries(cp)) { + if (typeof v === 'string') choicePercents[k] = v; + } + if (Object.keys(choicePercents).length > 0) row.choicePercents = choicePercents; + } + next[id] = row; + } + setMarketOdds(next); + }) + .catch(() => { + /* keep previous odds */ + }); + return () => { + cancelled = true; + }; + }, [oddsRefreshKey]); + + const visibleCount = Math.min(rowsShown * cardsPerRow, pool.length); + + const questions = useMemo(() => pool.slice(0, visibleCount), [pool, visibleCount]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const hash = window.location.hash.slice(1); + if (!hash.startsWith('predict-question-')) return; + const idNum = Number(hash.replace('predict-question-', '')); + if (!Number.isFinite(idNum)) return; + const idx = pool.findIndex(q => q.id === idNum); + if (idx < 0) return; + const perRow = Math.max(1, cardsPerRow); + const needRows = Math.ceil((idx + 1) / perRow); + if (needRows > rowsShown) { + const expand = window.setTimeout(() => setRowsShown(needRows), 0); + return () => window.clearTimeout(expand); + } + const t = window.setTimeout(() => { + document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 80); + return () => window.clearTimeout(t); + }, [pool, rowsShown, cardsPerRow]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + const qRaw = params.get('predict'); + const sideRaw = params.get('side')?.toLowerCase(); + if (!qRaw || (sideRaw !== 'yes' && sideRaw !== 'no')) return; + + const idNum = Number(qRaw); + if (!Number.isFinite(idNum)) { + params.delete('predict'); + params.delete('side'); + const qs = params.toString(); + window.history.replaceState( + {}, + '', + `${window.location.pathname}${qs ? `?${qs}` : ''}${window.location.hash}`, + ); + return; + } + + const idx = pool.findIndex( + x => x.id === idNum && x.outcome_type === 'Binary', + ); + if (idx < 0) { + params.delete('predict'); + params.delete('side'); + const qs = params.toString(); + window.history.replaceState( + {}, + '', + `${window.location.pathname}${qs ? `?${qs}` : ''}${window.location.hash}`, + ); + return; + } + + const item = pool[idx]; + const perRow = Math.max(1, cardsPerRow); + const needRows = Math.ceil((idx + 1) / perRow); + if (needRows > rowsShown) { + const expand = window.setTimeout(() => setRowsShown(needRows), 0); + return () => window.clearTimeout(expand); + } + + params.delete('predict'); + params.delete('side'); + const qs = params.toString(); + window.history.replaceState( + {}, + '', + `${window.location.pathname}${qs ? `?${qs}` : ''}${window.location.hash}`, + ); + + const open = window.setTimeout(() => { + setInvest({ + item, + index: idx, + kind: 'binary', + side: sideRaw as 'yes' | 'no', + }); + }, 0); + return () => window.clearTimeout(open); + }, [pool, rowsShown, cardsPerRow]); + + function handleLoadMore() { + setRowsShown((r) => r + 1); + } + + const canLoadMore = visibleCount < pool.length; + + function setSelection(index: number, value: string) { + setAnswers(prev => ({ ...prev, [index]: value })); + } + + function openInvestModal( + item: PredictQuestionItem, + index: number, + bet: { kind: 'binary'; side: 'yes' | 'no' } | { kind: 'mc'; choice: string }, + ) { + setInvest({ item, index, ...bet }); + } + + return ( + <> + setInvest(null)} + question={invest?.item ?? null} + cardIndex={invest?.index ?? 0} + betKind={invest?.kind ?? 'binary'} + side={invest ? (invest.kind === 'binary' ? invest.side : invest.choice) : 'yes'} + onInvested={(cardIndex, side, coins) => { + setSelection(cardIndex, side); + setOddsRefreshKey(k => k + 1); + const predictCopy = copy.predict as { investSuccessToast?: string }; + const tpl = predictCopy.investSuccessToast ?? ''; + const sideDisplay = + side === 'yes' ? copy.predict.yes : side === 'no' ? copy.predict.no : side; + const msg = tpl + .replace('{coins}', String(coins)) + .replace('{side}', sideDisplay); + setToast(msg.trim() !== '' ? msg : `Invested ${coins} on ${sideDisplay}.`); + window.setTimeout(() => setToast(null), 5000); + }} + /> + {toast ? ( +
+ {toast} +
+ ) : null} +
    + {questions.map((item, i) => { + const pct = randomEstimationPercent(i); + const selected = selectionAt(answers, i); + const isBinary = item.outcome_type === 'Binary'; + const oddsRow = marketOdds[item.id]; + const yesBtnPct = oddsRow?.yes ?? MARKET_ODDS_PLACEHOLDER; + const noBtnPct = oddsRow?.no ?? MARKET_ODDS_PLACEHOLDER; + const options = item.options ?? []; + const cardKey = `${item.id}-${item.question.slice(0, 24)}`; + const isMc = item.outcome_type === 'Multiple Choice' && options.length > 0; + + return ( +
  • + +

    + {item.question} +

    + +
    +
    +

    + {copy.predict.estimationPrefix} +

    +

    + {pct}% +

    +
    + + {isBinary ? ( +
    + + +
    + ) : ( +
    +
    + {options.map((opt, optIdx) => { + const mcPct = + oddsRow?.choicePercents?.[opt] ?? + equalOptionPercent(optIdx, options.length) + '%'; + return ( + + ); + })} +
    +
    + )} +
    + +
    + + {item.category} + + + {copy.predict.questionExpiresPrefix}{' '} + {formatQuestionExpires(item.expiresAt)} + +
    +
    +
  • + ); + })} +
+ {canLoadMore && ( +
+ +
+ )} + + ); +} diff --git a/components/home/TodayChartSection.tsx b/components/home/TodayChartSection.tsx index 600f351..71106d3 100644 --- a/components/home/TodayChartSection.tsx +++ b/components/home/TodayChartSection.tsx @@ -68,8 +68,8 @@ export function TodayChartSection() { >
-

- {copy.chart.titlePrefix} {copy.today.title} +

+ {copy.chart.titlePrefix} {copy.today.title} {copy.chart.titleSuffix}

{copy.today.subtitle} @@ -80,7 +80,7 @@ export function TodayChartSection() {

- + {formatChartDate(chartMoment)}