feat: Feature 3.1 — Landing page + blog + Phase 3 specs

Next.js 14+ web app in web/ directory:
- Landing page: Hero, How It Works, Features, 3-tier Pricing with
  founder badges, Footer with email capture
- Blog system: MDX-powered, /blog index + /blog/[slug] pages,
  reading time, Open Graph tags, JSON-LD structured data
- Auth pages: /login + /signup (Supabase Auth ready)
- Design system: dark theme, grade colors (A/B/C/D), BetonBLK voice
- 1 seed blog post: "How to Read Line Movement Like a Sharp"
- Specs for 3.2 (Scan UI), 3.3 (Bet Tracker), 3.4 (Stripe)

Build passes clean: 7 static pages generated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-22 09:43:38 -04:00
parent ed6502a880
commit bfa8345ebf
26 changed files with 5142 additions and 31 deletions
+192
View File
@@ -0,0 +1,192 @@
# Feature 3.1 — Landing Page
## Overview
Next.js marketing site. Hero section, How It Works, 3-tier pricing with founder badges, email capture CTA, and a blog for SEO content. Deployed on Vercel. This is the first thing a visitor sees — it needs to convert. All copy uses BetonBLK voice: direct, confident, no fluff, speaks like a sharp bettor who respects your time.
## Dependencies
- None (static marketing page, no backend calls)
- Feature 3.4 (Stripe) links from pricing CTAs
## Tech
- **Framework:** Next.js 14+ (App Router)
- **Styling:** Tailwind CSS
- **Deployment:** Vercel
- **Directory:** `web/` in the betonblk repo (monorepo)
## Pages
### / (Home)
Single-page scroll with sections:
**1. Hero**
- Headline: "Stop guessing. Start grading."
- Subheadline: "BetonBLK scans your parlay in seconds. AI-powered prop analysis across DraftKings, FanDuel, and BetMGM."
- CTA button: "Scan Your First Parlay — Free" → links to /scan (Feature 3.2)
- Background: dark, clean, sports-betting aesthetic. No stock photos.
**2. How It Works**
3-step visual flow:
1. "Paste your parlay" — illustration of leg input
2. "Get your grade" — A/B/C/D grade card with color
3. "See the edge" — reasoning breakdown preview
**3. Features Grid**
- Prop Analysis: 6-step grading pipeline
- Correlation Detection: flags conflicting legs
- Line Movement: sharp money alerts
- Bet Tracking: ROI and win rate over time
- Kill Conditions: 6 hard checks before you bet
**4. Pricing**
3-tier card layout:
| | Free | Analyst | Desk |
|---|---|---|---|
| Price | $0 | $19.99/mo | $49.99/mo |
| Founder Price | — | $14.99/mo | $34.99/mo |
| Scans | 5/month | Unlimited | Unlimited |
| Line Movement | View | View + Alerts | View + Alerts + Priority |
| Bet Tracking | No | Yes | Yes |
| Cascade Alerts | No | Yes | Priority |
| Performance Analytics | No | Basic | Full + Patterns |
Founder badge: "Founder Rate — Locked for Life" visual badge on Analyst/Desk cards.
CTA: "Get Started" on Free, "Subscribe" on paid → links to Stripe checkout (Feature 3.4).
**5. Social Proof / Trust**
- "Built by bettors, for bettors"
- Number counters: props analyzed, parlays scanned (can be seeded/estimated)
**6. Footer**
- Email capture: "Get early access + founder pricing"
- Links: Terms, Privacy, Twitter/X, Discord (placeholder)
### /login
Supabase Auth login page (email + password, or magic link).
### /signup
Supabase Auth signup page → auto-creates user profile (trigger from Feature 1.4).
### /blog
MDX-powered blog for SEO content. Static generation at build time.
**Content types:**
- Betting strategy guides ("How to Read Line Movement Like a Sharp")
- Prop analysis breakdowns ("Why PRA Props Are the Most Profitable Market")
- Product updates ("New: Cascade Alerts When Your Player Gets Scratched")
- Stat explainers ("What Back-to-Back Games Do to Over/Under Props")
**Implementation:**
- MDX files in `content/blog/` directory
- `@next/mdx` or `contentlayer` for parsing
- Each post: frontmatter (title, date, slug, description, tags) + MDX body
- `/blog` index page with post list, sorted by date
- `/blog/[slug]` dynamic route for individual posts
- SEO: Open Graph tags, JSON-LD structured data, sitemap.xml
- Reading time estimate in post header
**Blog post frontmatter:**
```mdx
---
title: "How to Read Line Movement Like a Sharp"
date: "2026-03-22"
slug: "line-movement-guide"
description: "Line moves tell a story. Here's how to read it."
tags: ["strategy", "line-movement", "sharp-money"]
---
```
**Blog component structure:**
```
web/
├── app/
│ └── blog/
│ ├── page.tsx # Blog index
│ └── [slug]/page.tsx # Individual post
├── content/
│ └── blog/
│ ├── line-movement-guide.mdx
│ └── pra-props-explained.mdx
```
## BetonBLK Voice Guide
All user-facing copy follows these rules:
- **Direct.** No hedging. "This line is soft" not "This line might be worth considering."
- **Confident.** The system did the work. Present findings with authority.
- **Concise.** Bettors scan fast. Short sentences. No filler.
- **Respectful.** Never condescending. Assume the reader knows the game.
- **Honest.** If the edge is thin, say so. A D grade is "no edge — avoid" not "proceed with caution."
Examples:
- Hero: "Stop guessing. Start grading."
- Grade A: "Strong edge. Clear play."
- Grade D: "No edge. Walk away."
- Kill condition: "Back-to-back game. High-minute player. Historically underperforms."
- Upgrade pitch: "You've got a good eye. Unlock unlimited scans."
## Component Structure
```
web/
├── app/
│ ├── layout.tsx # Root layout, fonts, metadata
│ ├── page.tsx # Landing page (home)
│ ├── login/page.tsx # Login
│ ├── signup/page.tsx # Signup
│ ├── scan/page.tsx # Feature 3.2
│ ├── tracker/page.tsx # Feature 3.3
│ ├── blog/
│ │ ├── page.tsx # Blog index
│ │ └── [slug]/page.tsx # Individual post
│ └── api/ # Next.js API routes (proxy to backend if needed)
├── components/
│ ├── Hero.tsx
│ ├── HowItWorks.tsx
│ ├── Features.tsx
│ ├── Pricing.tsx
│ ├── Footer.tsx
│ ├── GradeCard.tsx # Reusable A/B/C/D grade display
│ ├── BlogCard.tsx # Post preview card for blog index
│ └── ui/ # Shared UI primitives
├── content/
│ └── blog/ # MDX blog posts
├── lib/
│ ├── supabase.ts # Supabase client (browser)
│ ├── api.ts # Backend API client
│ └── blog.ts # MDX parsing + post listing helpers
├── public/
│ └── ... # Static assets
├── tailwind.config.ts
├── next.config.ts
└── package.json
```
## Design System
- **Colors:** Dark background (#0a0a0a), card backgrounds (#141414), accent green (#22c55e for A grade), yellow (#eab308 for B), orange (#f97316 for C), red (#ef4444 for D)
- **Typography:** Inter for body, JetBrains Mono for grades/numbers
- **Cards:** Rounded, subtle border, slight glow on hover
- **Grade colors:** A=green, B=yellow, C=orange, D=red — consistent across all UI
## Acceptance Criteria
1. Landing page loads with hero, how it works, features, pricing, footer
2. Pricing cards show all 3 tiers with correct prices and feature lists
3. Founder badges visible on Analyst and Desk cards
4. CTA buttons link to correct destinations (scan, checkout)
5. Login/signup pages functional with Supabase Auth
6. Email capture form in footer collects email (store in Supabase or simple list)
7. Blog index at /blog lists posts sorted by date
8. Individual blog posts render MDX with correct formatting
9. Blog posts include Open Graph tags and JSON-LD structured data
10. All copy follows BetonBLK voice (direct, confident, concise)
11. Responsive: works on mobile, tablet, desktop
12. Deploys to Vercel
13. Lighthouse performance score > 90
14. Dark theme throughout, grade colors consistent
## Test Plan
- Visual regression: screenshots at 3 breakpoints (mobile/tablet/desktop)
- Link verification: all CTAs navigate correctly
- Auth flow: signup → login → redirect to /scan
- Email capture: submit email → stored
- Blog: index page renders, individual post renders MDX
- Blog: SEO tags present in page source
- Lighthouse audit: performance > 90, accessibility > 85
+149
View File
@@ -0,0 +1,149 @@
# Feature 3.2 — Scan UI
## Overview
The parlay scanner interface. Users input legs via a manual form builder, submit for analysis, and see color-coded grades with full reasoning. Displays scan count for free tier users, upgrade prompts at the limit.
## Dependencies
- Feature 2.1 — Parlay Scan API (`POST /api/scan/parlay`)
- Feature 3.1 — Landing Page (shared layout, auth, design system)
## Page: /scan
### Layout
```
┌─────────────────────────────────────────────┐
│ Header: BetonBLK logo + nav + scan counter │
├─────────────────────────────────────────────┤
│ │
│ ┌─── Leg Builder ────────────────────────┐ │
│ │ Player: [___________] (autocomplete) │ │
│ │ Stat: [points ▼] Line: [26.5] │ │
│ │ Direction: [Over ▼] Book: [DK ▼] │ │
│ │ [+ Add Leg] │ │
│ └────────────────────────────────────────┘ │
│ │
│ Legs: ┌──┐ ┌──┐ ┌──┐ │
│ │L1│ │L2│ │L3│ (removable chips) │
│ └──┘ └──┘ └──┘ │
│ │
│ [🔍 Scan Parlay] │
│ │
├─────────────────────────────────────────────┤
│ Results (after scan): │
│ │
│ ┌─ Overall ──────────────────────────────┐ │
│ │ PARLAY GRADE: [B] Confidence: 68% │ │
│ │ Correlations: 1 flag │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌─ Leg 1 ────────────────────────────────┐ │
│ │ Jokic Over 26.5 pts [A] 85% │ │
│ │ Season: 26.3 | Last 10: 28.1 │ │
│ │ Home: 27.8 | vs LAL: 30.5 │ │
│ │ Kill conditions: none │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌─ Leg 2 ────────────────────────────────┐ │
│ │ LeBron Over 8.5 reb [C] 52% │ │
│ │ ... │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌─ Correlations ─────────────────────────┐ │
│ │ ⚠ Same game, same team: LeBron + AD │ │
│ └────────────────────────────────────────┘ │
│ │
│ [📋 Save to Bet Tracker] [🔄 New Scan] │
│ │
└─────────────────────────────────────────────┘
```
### Components
**ScanCounter** (header)
- Shows "3 of 5 scans used" for free tier
- Shows "Unlimited" for paid tiers
- Progress bar visual
**LegBuilder**
- Player input with autocomplete (calls GET /players/search on NBA stats service)
- Stat type dropdown: points, rebounds, assists, threes, blocks, steals, pra, turnovers
- Line: numeric input
- Direction: over/under toggle
- Book: DraftKings, FanDuel, BetMGM dropdown
- "Add Leg" button → adds to legs list (2-12 legs)
**LegChip**
- Compact display: "Jokic O26.5 pts DK"
- Remove button (X)
- Reorderable (nice-to-have)
**ScanButton**
- Disabled until 2+ legs added
- Loading state during API call
- Calls `POST /api/scan/parlay` with auth token
**ResultsPanel**
- Overall parlay grade card (large, color-coded)
- Individual leg cards with grade, confidence, edge, reasoning summary
- Correlation flags section with icons
- Kill conditions listed per leg
**UpgradePitch** (modal or inline)
- Appears at scan 5 (from API response `upgrade_pitch`)
- Shows personalized hook, insight, CTA
- "Subscribe" button → Stripe checkout
- "Maybe Later" dismisses
**GradeCard** (shared component from 3.1)
- Letter grade (A/B/C/D) with background color
- Confidence percentage
- Edge percentage
### State Management
```
scanState = {
legs: [], // array of leg objects
isScanning: false, // loading state
results: null, // API response
error: null, // error message
scanCount: 0, // from user profile
scansRemaining: 5, // computed
upgradePitch: null, // from API when applicable
}
```
### API Integration
```
1. Player search: GET http://localhost:8000/players/search?name=...
→ Autocomplete results (debounced, 300ms)
2. Scan: POST /api/scan/parlay
Headers: { Authorization: Bearer <jwt> }
Body: { legs: [...] }
→ Full parlay analysis response
3. Save to tracker: POST /api/bets/quickslip
→ Creates bet from scan results
```
## Acceptance Criteria
1. Leg builder allows adding 2-12 legs with all required fields
2. Player autocomplete searches as user types (debounced)
3. Scan button calls API and displays results with grades
4. Grade cards show correct colors (A=green, B=yellow, C=orange, D=red)
5. Correlation flags displayed with appropriate severity icons
6. Kill conditions listed per leg
7. Scan counter shows remaining scans for free tier
8. Upgrade pitch modal appears at scan 5 with personalized content
9. "Save to Bet Tracker" creates a bet via quickslip API
10. Error states handled: API down, invalid input, auth expired
11. Responsive: works on mobile and desktop
12. Loading states during API calls
## Test Plan
- Component tests: LegBuilder adds/removes legs correctly
- Component tests: GradeCard renders correct color per grade
- Integration: full scan flow → results displayed
- Upgrade pitch: appears at correct scan count
- Error handling: API error → user-friendly message
- Responsive: mobile leg builder usable
+141
View File
@@ -0,0 +1,141 @@
# Feature 3.3 — Bet Tracker UI
## Overview
Dashboard for tracking bets, viewing performance, and discovering behavioral patterns. Shows win rate, ROI, and at 30+ bets, surfaces a behavioral pattern card with insights.
## Dependencies
- Feature 1.5 — Bet Submission API (`GET /api/bets`, `GET /api/bets/performance`, `PATCH /api/bets/:id/settle`)
- Feature 3.1 — Landing Page (shared layout, auth, design system)
## Page: /tracker
### Layout
```
┌──────────────────────────────────────────────┐
│ Header: BetonBLK logo + nav │
├──────────────────────────────────────────────┤
│ │
│ ┌─ Performance Cards ─────────────────────┐ │
│ │ [ROI: +8.3%] [Win Rate: 55%] [Bets: 40]│ │
│ │ Period: [Weekly ▼] [Monthly] [All Time] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─ Behavioral Pattern Card ───────────────┐ │
│ │ (appears at 30+ bets) │ │
│ │ "You hit 68% on player points overs │ │
│ │ but only 35% on rebounds. Consider │ │
│ │ focusing on your edge." │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─ Quick Submit ──────────────────────────┐ │
│ │ [📸 Screenshot] [✏️ Quick Slip] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─ Bet History ───────────────────────────┐ │
│ │ Filter: [All ▼] [DraftKings ▼] │ │
│ │ │ │
│ │ ┌─ Bet Row ──────────────────────────┐ │ │
│ │ │ Jokic O26.5 pts | DK | $20 │ │ │
│ │ │ Status: PENDING [Settle ▶] │ │ │
│ │ └───────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ Bet Row ──────────────────────────┐ │ │
│ │ │ 3-leg parlay | FD | $10 │ │ │
│ │ │ Status: WON ✅ +$81.18 │ │ │
│ │ └───────────────────────────────────┘ │ │
│ │ │ │
│ │ [Load More] │ │
│ └─────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────┘
```
### Components
**PerformanceCards**
- 3 stat cards: ROI %, Win Rate %, Total Bets
- Period toggle: weekly / monthly / all_time
- Color: green if positive ROI, red if negative
- Calls `GET /api/bets/performance`
**BehavioralPatternCard**
- Only appears when sample_size >= 30
- Analyzes bet history to find:
- Best stat type (highest win rate)
- Worst stat type (lowest win rate)
- Best book (highest ROI)
- Streak data (current win/loss streak)
- Computed client-side from bet history data
- Card with insight text + recommendation
**QuickSubmitBar**
- Two buttons: Screenshot upload, Quick Slip form
- Screenshot: opens file picker → calls `POST /api/bets/screenshot` → confirm modal
- Quick Slip: opens inline form → calls `POST /api/bets/quickslip`
**BetHistory**
- Paginated list of bets from `GET /api/bets`
- Filters: status (all/pending/won/lost), book
- Each row shows: legs summary, book, amount, status, profit/loss
- Pending bets have a "Settle" button → opens settle modal
**SettleModal**
- Select outcome: Won / Lost / Push / Void
- For each leg: actual value input (optional, for detailed tracking)
- Submit → calls `PATCH /api/bets/:id/settle`
- On success: refreshes performance cards + bet row
**BetDetailExpander**
- Click a bet row to expand full details:
- All legs with odds
- Linked scan session grade (if any)
- Placed at / Settled at timestamps
- Profit calculation breakdown
### State
```
trackerState = {
performance: { weekly: {}, monthly: {}, all_time: {} },
selectedPeriod: 'monthly',
bets: [],
total: 0,
filters: { status: null, book: null },
page: 0,
loading: false,
patternCard: null, // computed when bets.length >= 30
}
```
### Behavioral Pattern Analysis (Client-Side)
```
When bets.length >= 30 AND settled bets >= 30:
1. Group bets by stat_type from slip_data.legs
2. For each stat_type: compute win_rate
3. Best = highest win_rate (min 5 bets in category)
4. Worst = lowest win_rate (min 5 bets in category)
5. Generate insight text:
"You hit {best_rate}% on {best_stat} {best_dir}s
but only {worst_rate}% on {worst_stat}. Consider
focusing on your edge."
```
## Acceptance Criteria
1. Performance cards show ROI, win rate, and bet count for selected period
2. Period toggle switches between weekly/monthly/all_time
3. Behavioral pattern card appears at 30+ settled bets with personalized insight
4. Quick submit bar opens screenshot or quickslip flow
5. Bet history loads with pagination (20 per page)
6. Status and book filters work correctly
7. Settle modal updates bet status and refreshes performance
8. Bet detail expander shows full leg breakdown
9. Linked scan session grade shown when available
10. Positive ROI shown in green, negative in red
11. Responsive: works on mobile and desktop
## Test Plan
- Component tests: PerformanceCards renders correct values
- Component tests: BetHistory filters and paginates
- Component tests: SettleModal sends correct API call
- Integration: submit bet → appears in history → settle → performance updates
- Pattern card: appears at 30+ bets with correct insight
- Responsive: mobile layout usable
+165
View File
@@ -0,0 +1,165 @@
# Feature 3.4 — Stripe Integration
## Overview
Subscription billing for Analyst ($19.99/mo) and Desk ($49.99/mo) tiers. Founder codes lock a permanent discounted rate. Handles checkout, subscription management, webhooks for tier changes, and cancellation.
## Dependencies
- Feature 1.4 — Database Schema (`users.tier`, `users.stripe_customer_id`, `users.founder_status`)
- Feature 3.1 — Landing Page (pricing cards link to checkout)
## Stripe Products
| Product | Price ID (create in Stripe) | Monthly | Founder Monthly |
|---|---|---|---|
| Analyst | price_analyst_monthly | $19.99 | $14.99 |
| Analyst Founder | price_analyst_founder | $14.99 | — |
| Desk | price_desk_monthly | $49.99 | $34.99 |
| Desk Founder | price_desk_founder | $34.99 | — |
Founder pricing: separate Stripe Price objects with `metadata.is_founder: true`. Once subscribed to a founder price, it's locked — no price increase even if founder pricing ends.
## Endpoints
### POST /api/stripe/checkout
Creates a Stripe Checkout session. Redirects user to Stripe's hosted checkout.
**Request body:**
```json
{
"tier": "analyst",
"founder_code": "FOUNDER2026"
}
```
**Response (200):**
```json
{
"checkout_url": "https://checkout.stripe.com/c/pay/cs_...",
"session_id": "cs_..."
}
```
**Logic:**
1. Look up user from auth token
2. If user already has a Stripe customer ID, use it. Otherwise create one.
3. Determine price ID:
- If `founder_code` is valid → use founder price
- Otherwise → use standard price
4. Create Stripe Checkout Session with:
- `customer`: Stripe customer ID
- `line_items`: [{ price: price_id, quantity: 1 }]
- `mode`: 'subscription'
- `success_url`: `{BASE_URL}/scan?upgraded=true`
- `cancel_url`: `{BASE_URL}/pricing`
- `metadata`: { user_id, tier, is_founder }
5. Return checkout URL
### POST /api/stripe/webhook
Stripe webhook endpoint. Handles subscription lifecycle events.
**Events handled:**
| Event | Action |
|---|---|
| `checkout.session.completed` | Set user tier + stripe_customer_id + founder_status |
| `customer.subscription.updated` | Update tier if plan changed |
| `customer.subscription.deleted` | Revert user to free tier |
| `invoice.payment_failed` | Log warning, Stripe handles retry |
**Webhook verification:** Validate `stripe-signature` header using webhook signing secret.
### POST /api/stripe/portal
Creates a Stripe Customer Portal session for subscription management (cancel, update payment, view invoices).
**Response (200):**
```json
{
"portal_url": "https://billing.stripe.com/p/session/..."
}
```
### GET /api/stripe/status
Returns current subscription status for the authenticated user.
**Response (200):**
```json
{
"tier": "analyst",
"is_founder": true,
"subscription_status": "active",
"current_period_end": "2026-04-21T00:00:00Z",
"cancel_at_period_end": false
}
```
## Founder Code System
Simple validation — not a full promo code engine:
```
VALID_FOUNDER_CODES = ['FOUNDER2026', 'BETONBLK', 'EARLYBIRD']
```
Stored in environment variable: `FOUNDER_CODES=FOUNDER2026,BETONBLK,EARLYBIRD`
When a valid founder code is used:
1. Checkout uses the founder price ID
2. `users.founder_status` set to `true`
3. Founder badge displayed in UI
Founder codes have an expiry date (env var `FOUNDER_CODE_EXPIRY`). After expiry, new subscriptions use standard pricing, but existing founder subscribers keep their rate.
## Webhook Security
- Stripe sends signature in `stripe-signature` header
- Verify using `stripe.webhooks.constructEvent(body, sig, webhook_secret)`
- Raw body required (not parsed JSON) — needs `express.raw()` middleware on webhook route
- Webhook secret stored in env: `STRIPE_WEBHOOK_SECRET`
## Service Architecture
```
src/
├── services/
│ └── stripeService.js # Checkout, portal, webhook handling, tier updates
├── routes/
│ └── stripe.js # POST /checkout, POST /webhook, POST /portal, GET /status
```
## Environment Variables
```
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
FOUNDER_CODES=FOUNDER2026,BETONBLK,EARLYBIRD
FOUNDER_CODE_EXPIRY=2026-06-30
BASE_URL=https://betonblk.com
```
## Acceptance Criteria
1. `POST /api/stripe/checkout` creates a Stripe Checkout session and returns URL
2. Founder code applies founder pricing when valid
3. Invalid/expired founder code falls back to standard pricing
4. Webhook `checkout.session.completed` updates user tier and stripe_customer_id
5. Webhook `customer.subscription.deleted` reverts user to free tier
6. Webhook signature verification rejects invalid signatures
7. `POST /api/stripe/portal` returns Stripe Customer Portal URL
8. `GET /api/stripe/status` returns current subscription info
9. Founder subscribers keep their rate permanently (locked price in Stripe)
10. All Stripe operations use the service role Supabase client (bypasses RLS)
## Test Plan
### Unit Tests (stripeService.js)
- Correct price ID selected for analyst + founder code
- Correct price ID selected for desk without founder code
- Expired founder code falls back to standard
- Tier mapping: checkout metadata → correct tier string
- Webhook event parsing: checkout.session.completed → tier update
### Integration Tests
- Checkout flow: request → returns valid URL
- Webhook: mock checkout.session.completed → user tier updated in DB
- Webhook: mock subscription.deleted → user reverted to free
- Webhook: invalid signature → 400
- Portal: returns URL for existing customer
- Status: returns correct tier and subscription info
- Auth required on checkout/portal/status (not on webhook)
## Open Questions
- **Stripe test mode:** Build and test entirely in Stripe test mode. Switch to live keys at launch. No code changes needed — just env var swap.