Compare commits
4 Commits
3b12c6ca98
...
5f5c004416
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f5c004416 | |||
| 78c19291c9 | |||
| 889e8621b4 | |||
| ae3cff9dbd |
@@ -0,0 +1,61 @@
|
|||||||
|
# VYNDR Backup Runbook (security follow-up item 2)
|
||||||
|
|
||||||
|
Supabase free tier has **zero** backups (no scheduled, no PITR). `scripts/backup-db.sh`
|
||||||
|
is the safety net: a nightly full-database `pg_dump`, 14 days kept locally, a
|
||||||
|
weekly copy pushed off-box, ntfy alert on any failure.
|
||||||
|
|
||||||
|
## What Kev needs to set (one env var)
|
||||||
|
|
||||||
|
**`SUPABASE_DB_URL`** — the Supabase **direct** connection string (session mode).
|
||||||
|
Supabase → Project → Settings → Database → **Connection string → URI**, the
|
||||||
|
`db.<ref>.supabase.co:5432` one (NOT the `:6543` transaction pooler — `pg_dump`
|
||||||
|
needs a real session). Paste it in Coolify as an env var; never commit it.
|
||||||
|
|
||||||
|
Optional: `BACKUP_REMOTE` (off-box rsync target for the weekly copy — see below).
|
||||||
|
|
||||||
|
## Install the cron (on the Hetzner box)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Ensure the client is present
|
||||||
|
sudo apt-get install -y postgresql-client rsync
|
||||||
|
|
||||||
|
# 2. Nightly at 03:10 UTC. Pass the env the script needs (or source an env file).
|
||||||
|
sudo crontab -e
|
||||||
|
# add:
|
||||||
|
10 3 * * * SUPABASE_DB_URL='postgresql://...' BACKUP_REMOTE='u123456@u123456.your-storagebox.de:vyndr-backups/' /path/to/vyndr/scripts/backup-db.sh >> /var/log/vyndr-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the cron runs inside the Coolify container instead, the env vars are already
|
||||||
|
present — just schedule `scripts/backup-db.sh`.)
|
||||||
|
|
||||||
|
## Off-box target (simplest reliable pick)
|
||||||
|
|
||||||
|
**Hetzner Storage Box** over `rsync`/SSH — you're already on Hetzner, it's ~€3/mo
|
||||||
|
for 1TB, and needs no extra tooling. Create one, add the box's SSH key to it, set
|
||||||
|
`BACKUP_REMOTE=u<id>@u<id>.your-storagebox.de:vyndr-backups/`. The script pushes
|
||||||
|
the latest dump every Sunday. (Alternative: Backblaze B2 via `rclone` if you'd
|
||||||
|
rather keep it off Hetzner entirely — swap the `rsync` line for `rclone copy`.)
|
||||||
|
|
||||||
|
## Restore / FINGERPRINT (proves it's a real backup, not just a file)
|
||||||
|
|
||||||
|
A dump only counts once a restore of it succeeds. Load one into a scratch DB:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# spin a throwaway local postgres, restore the newest dump, count a known table
|
||||||
|
docker run -d --name vyndr-restore-test -e POSTGRES_PASSWORD=x -p 55432:5432 postgres:15
|
||||||
|
sleep 5
|
||||||
|
newest=$(ls -t /var/backups/vyndr/vyndr-*.dump | head -1)
|
||||||
|
pg_restore --no-owner --no-privileges -d "postgresql://postgres:x@localhost:55432/postgres" "$newest"
|
||||||
|
psql "postgresql://postgres:x@localhost:55432/postgres" -c "select count(*) from public.ledger_entries;"
|
||||||
|
docker rm -f vyndr-restore-test
|
||||||
|
```
|
||||||
|
|
||||||
|
A non-zero `ledger_entries` count from the restored dump = the backup is real and
|
||||||
|
restorable. Record the date + row count as the fingerprint.
|
||||||
|
|
||||||
|
## Alerting
|
||||||
|
|
||||||
|
Any hard failure (missing env, `pg_dump` error, empty/undersized dump) pages
|
||||||
|
`ntfy` topic `vyndr-backups-kev2026` at urgent priority. The weekly off-box push
|
||||||
|
failing (or `BACKUP_REMOTE` unset) pages at high priority but does not fail the
|
||||||
|
run — the local dump still succeeded. Subscribe the phone to that topic.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Migration 023 — Security hardening (Chrome ops session follow-up)
|
||||||
|
-- Author: security follow-up items 1, 3, 5. Apply in the Supabase SQL editor.
|
||||||
|
-- Idempotent + guarded so a re-run is safe. After applying, re-run the Security
|
||||||
|
-- Advisor to confirm lint 0010 (security_definer_view) and 0011
|
||||||
|
-- (function_search_path_mutable) are cleared.
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- ── Item 1 (CRITICAL, advisor lint 0010) ────────────────────────────────────
|
||||||
|
-- public.founder_pricing_seats is a SECURITY DEFINER view — it runs with the
|
||||||
|
-- creator's privileges and ignores RLS, exposed via the public API. Recreate
|
||||||
|
-- it as security_invoker so it runs with the CALLER's privileges + respects RLS.
|
||||||
|
-- (The founder counter no longer depends on this view — it now counts real
|
||||||
|
-- active Stripe subscriptions directly — so this is purely closing the surface.)
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (select 1 from pg_views where schemaname = 'public' and viewname = 'founder_pricing_seats') then
|
||||||
|
execute 'alter view public.founder_pricing_seats set (security_invoker = on)';
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ── Item 3 — waitlist write hole ────────────────────────────────────────────
|
||||||
|
-- public.waitlist had always-true (USING(true)/WITH CHECK(true)) write policies:
|
||||||
|
-- the anon API could insert/update/delete rows. Drop ALL existing policies and
|
||||||
|
-- allow anon to INSERT only; updates/deletes/reads go through the service role
|
||||||
|
-- (which bypasses RLS). Signups are additionally rate-limited at the API layer.
|
||||||
|
alter table public.waitlist enable row level security;
|
||||||
|
do $$
|
||||||
|
declare pol record;
|
||||||
|
begin
|
||||||
|
for pol in select policyname from pg_policies where schemaname = 'public' and tablename = 'waitlist' loop
|
||||||
|
execute format('drop policy %I on public.waitlist', pol.policyname);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
create policy waitlist_anon_insert on public.waitlist for insert to anon with check (true);
|
||||||
|
-- Remove any lingering table-level write grants from anon; keep INSERT only.
|
||||||
|
revoke update, delete, truncate on public.waitlist from anon;
|
||||||
|
revoke select on public.waitlist from anon; -- a signup list is not public
|
||||||
|
grant insert on public.waitlist to anon;
|
||||||
|
|
||||||
|
-- ── Item 5 — function search_path hardening (advisor lint 0011) ──────────────
|
||||||
|
-- Flagged functions have a mutable search_path (hijackable). Pin an explicit,
|
||||||
|
-- safe search_path (pg_catalog, public) — resolves the advisor without the
|
||||||
|
-- breakage risk of '' on functions that reference public objects unqualified.
|
||||||
|
-- Handles any overload signature.
|
||||||
|
do $$
|
||||||
|
declare fn record;
|
||||||
|
begin
|
||||||
|
for fn in
|
||||||
|
select p.oid::regprocedure as sig
|
||||||
|
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||||
|
where n.nspname = 'public'
|
||||||
|
and p.proname in ('touch_updated_at', 'update_updated_at', 'reset_scan_count')
|
||||||
|
loop
|
||||||
|
execute format('alter function %s set search_path = pg_catalog, public', fn.sig);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- NOTE: item 5 lists "and the other flagged functions". Run the Security Advisor
|
||||||
|
-- (Supabase -> Advisors -> Security, lint 0011) for the full list; add each to
|
||||||
|
-- the proname IN (...) set above and re-apply. All are the same low-risk change.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Migration 024 — Revoke anon table discoverability (advisor: anon GraphQL/API access)
|
||||||
|
-- Author: security follow-up item 4. Apply in the Supabase SQL editor.
|
||||||
|
--
|
||||||
|
-- ARCHITECTURE FACT this rests on: the VYNDR frontend NEVER reads app-data
|
||||||
|
-- tables with the Supabase anon key. All data flows browser -> Next proxy ->
|
||||||
|
-- Express (service role, which bypasses RLS). The anon key is used ONLY for
|
||||||
|
-- Supabase Auth (login/session). So revoking anon SELECT on app-data tables
|
||||||
|
-- does not break the app — it just closes the discovery hole the advisor flags.
|
||||||
|
--
|
||||||
|
-- REVOKE/KEEP decision (deliberate):
|
||||||
|
-- REVOKE anon SELECT — every app-data table. The "public record" surfaces
|
||||||
|
-- (model ledger, accuracy, public profiles) are served by Express under the
|
||||||
|
-- service role, so even they need no direct anon read.
|
||||||
|
-- KEEP — nothing needs direct anon table SELECT. (If a future feature reads a
|
||||||
|
-- genuinely-public view straight from the browser, grant anon SELECT on
|
||||||
|
-- that SPECIFIC view only, never a base table.)
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- Explicitly revoke on the advisor-named tables (definitely safe).
|
||||||
|
do $$
|
||||||
|
declare t text;
|
||||||
|
begin
|
||||||
|
foreach t in array array[
|
||||||
|
'accuracy_tracking', 'bets', 'cascade_alerts', 'closing_lines',
|
||||||
|
'coach_profiles', 'daily_scan'
|
||||||
|
]
|
||||||
|
loop
|
||||||
|
if exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = t) then
|
||||||
|
execute format('revoke select on public.%I from anon', t);
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- BROAD SWEEP (recommended — apply after a quick review). Revoke anon SELECT on
|
||||||
|
-- EVERY existing public table, then leave anon with no base-table discovery.
|
||||||
|
-- Uncomment to apply; the app does not read tables as anon so this is safe.
|
||||||
|
--
|
||||||
|
-- revoke select on all tables in schema public from anon;
|
||||||
|
-- alter default privileges in schema public revoke select on tables from anon;
|
||||||
|
--
|
||||||
|
-- After applying, re-run the Security Advisor to confirm the anon-access lint is
|
||||||
|
-- cleared. Grant anon SELECT back ONLY on a specific public VIEW if a browser
|
||||||
|
-- feature ever needs one.
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# VYNDR nightly database backup (security follow-up item 2).
|
||||||
|
#
|
||||||
|
# Supabase free tier has ZERO backups (no scheduled, no PITR) — the ledger and
|
||||||
|
# everything else have no safety net. This dumps the WHOLE database nightly via
|
||||||
|
# the direct connection string, keeps 14 days locally, pushes a weekly copy
|
||||||
|
# off-box, and pages ntfy on ANY failure. Runs on the Hetzner box via cron.
|
||||||
|
#
|
||||||
|
# REQUIRED env (set on the box / in the container that runs the cron):
|
||||||
|
# SUPABASE_DB_URL the Supabase DIRECT connection string (session mode, the
|
||||||
|
# db.<ref>.supabase.co:5432 URL — NOT the :6543 pooler;
|
||||||
|
# pg_dump needs a real session). Kev pastes this in Coolify.
|
||||||
|
# OPTIONAL env:
|
||||||
|
# BACKUP_DIR local dump dir (default /var/backups/vyndr)
|
||||||
|
# BACKUP_KEEP_DAYS local retention (default 14)
|
||||||
|
# BACKUP_REMOTE off-box rsync target for the weekly copy, e.g.
|
||||||
|
# u123456@u123456.your-storagebox.de:vyndr-backups/
|
||||||
|
# (empty = skip the off-box push; a WARN is paged)
|
||||||
|
# NTFY_URL (default https://ntfy.sh)
|
||||||
|
# NTFY_TOPIC (default vyndr-backups-kev2026)
|
||||||
|
#
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/vyndr}"
|
||||||
|
KEEP_DAYS="${BACKUP_KEEP_DAYS:-14}"
|
||||||
|
NTFY_URL="${NTFY_URL:-https://ntfy.sh}"
|
||||||
|
NTFY_TOPIC="${NTFY_TOPIC:-vyndr-backups-kev2026}"
|
||||||
|
STAMP="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
DUMP="${BACKUP_DIR}/vyndr-${STAMP}.dump"
|
||||||
|
MIN_BYTES="${BACKUP_MIN_BYTES:-50000}" # a real dump of this DB is far bigger; guards an empty/failed dump
|
||||||
|
|
||||||
|
notify() { # notify <title> <priority> <message>
|
||||||
|
curl -fsS --max-time 15 \
|
||||||
|
-H "Title: ${1}" -H "Priority: ${2}" -H "Tags: floppy_disk" \
|
||||||
|
-d "${3}" "${NTFY_URL}/${NTFY_TOPIC}" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { notify "VYNDR backup FAILED" "urgent" "${1}"; echo "ERROR: ${1}" >&2; exit 1; }
|
||||||
|
trap 'fail "backup script errored near line ${LINENO}"' ERR
|
||||||
|
|
||||||
|
[ -n "${SUPABASE_DB_URL:-}" ] || fail "SUPABASE_DB_URL is not set — cannot back up"
|
||||||
|
command -v pg_dump >/dev/null 2>&1 || fail "pg_dump not installed (apt-get install postgresql-client)"
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
# 1. Dump the whole DB in custom format (-Fc: compressed, restorable with pg_restore).
|
||||||
|
pg_dump "${SUPABASE_DB_URL}" -Fc --no-owner --no-privileges -f "${DUMP}" \
|
||||||
|
|| fail "pg_dump failed"
|
||||||
|
|
||||||
|
# 2. Sanity: a real dump is not tiny. An empty/near-empty file is a silent failure.
|
||||||
|
SIZE="$(stat -c%s "${DUMP}" 2>/dev/null || echo 0)"
|
||||||
|
[ "${SIZE}" -ge "${MIN_BYTES}" ] || fail "dump is only ${SIZE} bytes (< ${MIN_BYTES}) — treating as a failed backup"
|
||||||
|
|
||||||
|
# 3. Rotate: drop local dumps older than KEEP_DAYS.
|
||||||
|
find "${BACKUP_DIR}" -name 'vyndr-*.dump' -type f -mtime "+${KEEP_DAYS}" -delete || true
|
||||||
|
|
||||||
|
# 4. Weekly off-box copy (Sundays). A single failure of the off-box push is a
|
||||||
|
# WARN, not a hard failure — the local dump still succeeded.
|
||||||
|
if [ "$(date -u +%u)" = "7" ]; then
|
||||||
|
if [ -n "${BACKUP_REMOTE:-}" ]; then
|
||||||
|
if rsync -az --timeout=120 "${DUMP}" "${BACKUP_REMOTE}"; then
|
||||||
|
notify "VYNDR backup OK (+off-box)" "default" "Nightly dump ${STAMP} (${SIZE} bytes) + weekly off-box copy pushed."
|
||||||
|
else
|
||||||
|
notify "VYNDR off-box push FAILED" "high" "Local dump ${STAMP} is fine (${SIZE} bytes) but the weekly off-box rsync failed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
notify "VYNDR off-box push SKIPPED" "high" "Local dump ${STAMP} OK but BACKUP_REMOTE is unset — no off-box copy this week."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "backup ok: ${DUMP} (${SIZE} bytes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -13,6 +13,8 @@ Two design components, both open directly in a browser. All styling is inline (n
|
|||||||
|
|
||||||
Desktop /u profile also carries the TIER RECORD calibration strip (matches mobile screen 10 and the landing band).
|
Desktop /u profile also carries the TIER RECORD calibration strip (matches mobile screen 10 and the landing band).
|
||||||
|
|
||||||
|
> **BOOK ROSTER NOTE (2026-07, security follow-up item 7):** ESPN BET is defunct — PENN rebranded it to **theScore Bet** (Dec 1 2025); ESPN is now exclusive with DraftKings. The product BookChip set (`web/src/lib/books.js`) was updated (ESPN BET removed, theScore Bet added). The **design mockups' BookChip row still shows ESPN BET** — it needs the same one-swap to theScore Bet (mono `TS`) whenever the design files are refreshed.
|
||||||
|
|
||||||
## Tokens (exact)
|
## Tokens (exact)
|
||||||
- Surfaces: `#06060B` void · `#0E0E14` card · `#14141E` elevated · row hairline `#101018` · deep panel `#0A0A10`
|
- Surfaces: `#06060B` void · `#0E0E14` card · `#14141E` elevated · row hairline `#101018` · deep panel `#0A0A10`
|
||||||
- Signal green `#00D4A0` — ONE meaning: edge / active / A-tier / primary CTA. Never decorative.
|
- Signal green `#00D4A0` — ONE meaning: edge / active / A-tier / primary CTA. Never decorative.
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const { getAbbreviation } = require('./teamMap');
|
|||||||
// prop data through Pinnacle survives normalization. (bovada deliberately
|
// prop data through Pinnacle survives normalization. (bovada deliberately
|
||||||
// left OUT — it's the canonical "not-allowed" example in the tests, and
|
// left OUT — it's the canonical "not-allowed" example in the tests, and
|
||||||
// VYNDR surfaces regulated US books.)
|
// VYNDR surfaces regulated US books.)
|
||||||
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']);
|
// 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7).
|
||||||
|
const ALLOWED_BOOKS = new Set(['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore']);
|
||||||
|
|
||||||
const MARKET_MAP = {
|
const MARKET_MAP = {
|
||||||
// NBA / WNBA props
|
// NBA / WNBA props
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const path = require('path');
|
|||||||
const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
|
const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books');
|
||||||
|
|
||||||
// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
|
// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline.
|
||||||
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle'];
|
// 'thescore' = theScore Bet (PENN), successor to the defunct ESPN BET (item 7).
|
||||||
|
const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle', 'thescore'];
|
||||||
const DEFAULT_FG = '#B8BCC8';
|
const DEFAULT_FG = '#B8BCC8';
|
||||||
|
|
||||||
const REPO = path.resolve(__dirname, '../..');
|
const REPO = path.resolve(__dirname, '../..');
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { jsonError } from '@/lib/auth-helpers';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe billing-portal proxy (security follow-up item 6) — Next → Express →
|
||||||
|
* Stripe. Forwards the browser's bearer token (Express's requireAuth verifies
|
||||||
|
* the same one) and returns the hosted portal URL. The portal is fully
|
||||||
|
* configured in Stripe (cancellations, plan switching, invoice history); this
|
||||||
|
* is the app-side link from account settings.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const authHeader = req.headers.get('authorization');
|
||||||
|
if (!authHeader) return jsonError(401, 'Log in to manage billing.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/stripe/portal`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||||
|
});
|
||||||
|
const data = (await upstream.json().catch(() => ({}))) as { portal_url?: string; error?: string };
|
||||||
|
if (!upstream.ok || !data.portal_url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: data.error || 'Billing portal is unavailable right now.' },
|
||||||
|
{ status: upstream.ok ? 502 : upstream.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ portal_url: data.portal_url }, { status: 200 });
|
||||||
|
} catch {
|
||||||
|
return jsonError(503, 'Billing portal is unavailable right now.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,26 @@ export default function ProfilePage() {
|
|||||||
.catch(() => setProfile(null));
|
.catch(() => setProfile(null));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
// Item 6 — open the Stripe billing portal (payment method, invoices, plan
|
||||||
|
// switching, cancellation). The portal itself is configured in Stripe; this
|
||||||
|
// just mints a session and redirects.
|
||||||
|
const handleManageBilling = async () => {
|
||||||
|
setWorking(true);
|
||||||
|
setError('');
|
||||||
|
const token = currentAccessToken();
|
||||||
|
const res = await fetch('/api/stripe/portal', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
setWorking(false);
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && body.portal_url) {
|
||||||
|
window.location.href = body.portal_url;
|
||||||
|
} else {
|
||||||
|
setError(body.error || 'Billing portal is unavailable right now.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (!confirm('Cancel your subscription at the end of the current period?')) return;
|
if (!confirm('Cancel your subscription at the end of the current period?')) return;
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
@@ -153,6 +173,19 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manage billing — Stripe customer portal (item 6) */}
|
||||||
|
{tier !== 'free' && (
|
||||||
|
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>Billing</h3>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||||
|
Update your payment method, switch plans, or download invoices in the secure Stripe portal.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleManageBilling} disabled={working} className="btn-ghost">
|
||||||
|
{working ? 'Opening…' : 'Manage billing →'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Subscription actions */}
|
{/* Subscription actions */}
|
||||||
{tier !== 'free' && !profile.cancel_at_period_end && (
|
{tier !== 'free' && !profile.cancel_at_period_end && (
|
||||||
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
<section className="surface" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ const BOOKS = {
|
|||||||
BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
// Book roster update (security follow-up item 7): ESPN BET is defunct — PENN
|
||||||
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
// rebranded it to theScore Bet (Dec 1 2025); ESPN is now exclusive with
|
||||||
|
// DraftKings. theScore Bet (PENN) is the successor.
|
||||||
|
TS: { name: 'theScore Bet', mono: 'TS', slug: 'thescore', bg: '#08150F', fg: '#2FBF71', bd: '#2FBF7155' },
|
||||||
|
THESCORE: { name: 'theScore Bet', mono: 'TS', slug: 'thescore', bg: '#08150F', fg: '#2FBF71', bd: '#2FBF7155' },
|
||||||
|
'THESCORE BET': { name: 'theScore Bet', mono: 'TS', slug: 'thescore', bg: '#08150F', fg: '#2FBF71', bd: '#2FBF7155' },
|
||||||
// BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
|
// BetRivers is a blue book (its logo is blue "BetRivers"), not purple.
|
||||||
BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
||||||
BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' },
|
||||||
|
|||||||
Reference in New Issue
Block a user