From 78dafbdbdfce2b052d389b950055b67056c75d4a Mon Sep 17 00:00:00 2001 From: Shubham-275 Date: Mon, 9 Feb 2026 04:50:15 +0530 Subject: [PATCH] Add Wing Command recipe: parallel food delivery price comparison Wing Command is a hyper-local chicken wing price comparison tool that uses TinyFish API to dispatch parallel web agents across DoorDash, UberEats, Grubhub, and Google, finding the best wing deals near any US zip code in real-time. Key TinyFish features demonstrated: - Promise.allSettled parallel scraping across 4 platforms - Natural language goals for structured JSON extraction - Per-restaurant menu scraping for price calculation - Deal aggregator scanning Tech stack: Next.js 14, TypeScript, Supabase, Upstash Redis, TinyFish API Live app: https://wingscommand.up.railway.app/ Co-Authored-By: Claude Opus 4.6 --- wing-command/.eslintrc.json | 3 + wing-command/.gitignore | 51 + wing-command/README.md | 137 + wing-command/app/api/deals/route.ts | 166 + wing-command/app/api/menu/route.ts | 174 + wing-command/app/api/scout/route.ts | 440 ++ wing-command/app/error.tsx | 47 + wing-command/app/fonts/BebasNeue-latin.woff2 | Bin 0 -> 13768 bytes wing-command/app/fonts/Inter-latin.woff2 | Bin 0 -> 25960 bytes .../app/fonts/PermanentMarker-latin.woff2 | Bin 0 -> 29564 bytes wing-command/app/fonts/RussoOne-latin.woff2 | Bin 0 -> 7368 bytes wing-command/app/global-error.tsx | 87 + wing-command/app/globals.css | 546 ++ wing-command/app/icon.svg | 4 + wing-command/app/layout.tsx | 80 + wing-command/app/loading.tsx | 20 + wing-command/app/page.tsx | 358 + .../components/AnimatedFieldBackground.tsx | 255 + wing-command/components/BannerBreak.tsx | 367 + wing-command/components/CoachHero.tsx | 162 + wing-command/components/CoachWingMascot.tsx | 448 ++ wing-command/components/CoinToss.tsx | 76 + wing-command/components/ComicHero.tsx | 219 + wing-command/components/CommandJumbotron.tsx | 260 + wing-command/components/CompareBar.tsx | 64 + wing-command/components/CompareModal.tsx | 274 + wing-command/components/DealsView.tsx | 301 + wing-command/components/FlavorSelector.tsx | 107 + wing-command/components/FlavorTarot.tsx | 178 + wing-command/components/FrostedGlassPanel.tsx | 30 + .../components/GlassBlitzEntrance.tsx | 405 + wing-command/components/HeroVisuals.tsx | 132 + wing-command/components/JumbotronSearch.tsx | 256 + wing-command/components/MenuModal.tsx | 437 ++ wing-command/components/PlaybookSearch.tsx | 217 + .../components/ScoutingReportCard.tsx | 457 ++ .../components/SunnyFieldEntrance.tsx | 373 + wing-command/components/TacticalCanvas.tsx | 166 + wing-command/components/TradingCardGrid.tsx | 229 + wing-command/components/TrashTalkTicker.tsx | 128 + wing-command/components/WingGrid.tsx | 208 + wing-command/components/ZipSearch.tsx | 199 + wing-command/components/ui/Accordion.tsx | 75 + wing-command/components/ui/Badge.tsx | 69 + wing-command/components/ui/Button.tsx | 51 + wing-command/components/ui/Input.tsx | 47 + wing-command/components/ui/Sheet.tsx | 81 + wing-command/components/ui/index.ts | 4 + wing-command/lib/agentql.ts | 546 ++ wing-command/lib/cache.ts | 537 ++ wing-command/lib/chain-prices.ts | 70 + wing-command/lib/deals.ts | 396 + wing-command/lib/env.ts | 73 + wing-command/lib/geocode.ts | 369 + wing-command/lib/menu.ts | 736 ++ wing-command/lib/seed-data.ts | 288 + wing-command/lib/supabase.ts | 198 + wing-command/lib/types.ts | 321 + wing-command/lib/utils.ts | 415 + wing-command/next.config.mjs | 32 + wing-command/package-lock.json | 6932 +++++++++++++++++ wing-command/package.json | 52 + wing-command/postcss.config.js | 6 + wing-command/public/coach-drool.png | Bin 0 -> 864471 bytes wing-command/public/coach-happy.png | Bin 0 -> 2861957 bytes wing-command/public/coach-heat.png | Bin 0 -> 2794028 bytes wing-command/public/coach-neutral.png | Bin 0 -> 2516920 bytes wing-command/public/field-bg.jpg | Bin 0 -> 683528 bytes wing-command/public/wing-hero.png | Bin 0 -> 130670 bytes wing-command/public/wingplosion.png | Bin 0 -> 289104 bytes wing-command/render.yaml | 71 + wing-command/scraper/requirements.txt | 2 + wing-command/scraper/scrape_wings.py | 265 + wing-command/scripts/cache-warmer.ts | 464 ++ wing-command/supabase/schema.sql | 152 + wing-command/tailwind.config.ts | 241 + wing-command/tsconfig.json | 42 + 77 files changed, 20596 insertions(+) create mode 100644 wing-command/.eslintrc.json create mode 100644 wing-command/.gitignore create mode 100644 wing-command/README.md create mode 100644 wing-command/app/api/deals/route.ts create mode 100644 wing-command/app/api/menu/route.ts create mode 100644 wing-command/app/api/scout/route.ts create mode 100644 wing-command/app/error.tsx create mode 100644 wing-command/app/fonts/BebasNeue-latin.woff2 create mode 100644 wing-command/app/fonts/Inter-latin.woff2 create mode 100644 wing-command/app/fonts/PermanentMarker-latin.woff2 create mode 100644 wing-command/app/fonts/RussoOne-latin.woff2 create mode 100644 wing-command/app/global-error.tsx create mode 100644 wing-command/app/globals.css create mode 100644 wing-command/app/icon.svg create mode 100644 wing-command/app/layout.tsx create mode 100644 wing-command/app/loading.tsx create mode 100644 wing-command/app/page.tsx create mode 100644 wing-command/components/AnimatedFieldBackground.tsx create mode 100644 wing-command/components/BannerBreak.tsx create mode 100644 wing-command/components/CoachHero.tsx create mode 100644 wing-command/components/CoachWingMascot.tsx create mode 100644 wing-command/components/CoinToss.tsx create mode 100644 wing-command/components/ComicHero.tsx create mode 100644 wing-command/components/CommandJumbotron.tsx create mode 100644 wing-command/components/CompareBar.tsx create mode 100644 wing-command/components/CompareModal.tsx create mode 100644 wing-command/components/DealsView.tsx create mode 100644 wing-command/components/FlavorSelector.tsx create mode 100644 wing-command/components/FlavorTarot.tsx create mode 100644 wing-command/components/FrostedGlassPanel.tsx create mode 100644 wing-command/components/GlassBlitzEntrance.tsx create mode 100644 wing-command/components/HeroVisuals.tsx create mode 100644 wing-command/components/JumbotronSearch.tsx create mode 100644 wing-command/components/MenuModal.tsx create mode 100644 wing-command/components/PlaybookSearch.tsx create mode 100644 wing-command/components/ScoutingReportCard.tsx create mode 100644 wing-command/components/SunnyFieldEntrance.tsx create mode 100644 wing-command/components/TacticalCanvas.tsx create mode 100644 wing-command/components/TradingCardGrid.tsx create mode 100644 wing-command/components/TrashTalkTicker.tsx create mode 100644 wing-command/components/WingGrid.tsx create mode 100644 wing-command/components/ZipSearch.tsx create mode 100644 wing-command/components/ui/Accordion.tsx create mode 100644 wing-command/components/ui/Badge.tsx create mode 100644 wing-command/components/ui/Button.tsx create mode 100644 wing-command/components/ui/Input.tsx create mode 100644 wing-command/components/ui/Sheet.tsx create mode 100644 wing-command/components/ui/index.ts create mode 100644 wing-command/lib/agentql.ts create mode 100644 wing-command/lib/cache.ts create mode 100644 wing-command/lib/chain-prices.ts create mode 100644 wing-command/lib/deals.ts create mode 100644 wing-command/lib/env.ts create mode 100644 wing-command/lib/geocode.ts create mode 100644 wing-command/lib/menu.ts create mode 100644 wing-command/lib/seed-data.ts create mode 100644 wing-command/lib/supabase.ts create mode 100644 wing-command/lib/types.ts create mode 100644 wing-command/lib/utils.ts create mode 100644 wing-command/next.config.mjs create mode 100644 wing-command/package-lock.json create mode 100644 wing-command/package.json create mode 100644 wing-command/postcss.config.js create mode 100644 wing-command/public/coach-drool.png create mode 100644 wing-command/public/coach-happy.png create mode 100644 wing-command/public/coach-heat.png create mode 100644 wing-command/public/coach-neutral.png create mode 100644 wing-command/public/field-bg.jpg create mode 100644 wing-command/public/wing-hero.png create mode 100644 wing-command/public/wingplosion.png create mode 100644 wing-command/render.yaml create mode 100644 wing-command/scraper/requirements.txt create mode 100644 wing-command/scraper/scrape_wings.py create mode 100644 wing-command/scripts/cache-warmer.ts create mode 100644 wing-command/supabase/schema.sql create mode 100644 wing-command/tailwind.config.ts create mode 100644 wing-command/tsconfig.json diff --git a/wing-command/.eslintrc.json b/wing-command/.eslintrc.json new file mode 100644 index 00000000..3f431b28 --- /dev/null +++ b/wing-command/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} \ No newline at end of file diff --git a/wing-command/.gitignore b/wing-command/.gitignore new file mode 100644 index 00000000..b0dafd1c --- /dev/null +++ b/wing-command/.gitignore @@ -0,0 +1,51 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ + +# Next.js +.next/ +out/ + +# Production +build/ + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env files +.env*.local +.env + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +venv/ +ENV/ +.venv/ + +# IDE +.idea/ +.vscode/ + +*.swp +*.swo +.claude/ diff --git a/wing-command/README.md b/wing-command/README.md new file mode 100644 index 00000000..74a1e671 --- /dev/null +++ b/wing-command/README.md @@ -0,0 +1,137 @@ +# Wing Command + +[**Live App**](https://wingscommand.up.railway.app/) + +A hyper-local chicken wing price comparison tool for Super Bowl watch parties. Wing Command uses the TinyFish API to dispatch parallel web agents across DoorDash, UberEats, Grubhub, and Google, finding the best wing deals near any US zip code in real-time. + +## TinyFish API Usage + +Wing Command fires 4 TinyFish agents simultaneously using `Promise.allSettled` for fault-tolerant parallel scraping: + +```typescript +// lib/agentql.ts — Core TinyFish API call +async function runMinoScrape(url: string, goal: string, timeoutMs: number) { + const response = await fetch(MINO_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': MINO_API_KEY, + }, + body: JSON.stringify({ url, goal }), + signal: AbortSignal.timeout(timeoutMs), + }); + const data = await response.json(); + return { success: true, data: data.result }; +} + +// Parallel scraping across 4 platforms +export async function scrapeAllSources(zipCode, lat, lng, flavor, city, state) { + const results = await Promise.allSettled([ + withTimeout(scrapeGoogle(zipCode, city, state), 120000, []), + withTimeout(scrapeDoorDash(zipCode, city, state), 120000, []), + withTimeout(scrapeGrubhub(zipCode, city, state), 120000, []), + withTimeout(scrapeUberEats(zipCode, city, state), 120000, []), + ]); + + // Merge results — if one platform fails, others still return data + const allRestaurants = []; + results.forEach((result) => { + if (result.status === 'fulfilled') { + allRestaurants.push(...result.value); + } + }); + return deduplicateAndProcess(allRestaurants); +} +``` + +Each platform scraper uses a natural language goal to extract structured JSON: + +```typescript +// Example: DoorDash scraper goal +const goal = `Find chicken wings restaurants that deliver to zip code ${zipCode}. +Extract a JSON array of restaurants with: name, address, delivery_time, +rating, image_url, is_open, store_url. Return as JSON array called "restaurants".`; + +const result = await runMinoScrape(searchUrl, goal); +``` + +## How to Run + +### Prerequisites + +- Node.js >= 18 +- TinyFish API key ([sign up at tinyfish.ai](https://tinyfish.ai)) +- Supabase project (free tier works) +- Upstash Redis (optional, recommended for caching) + +### Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Create `.env.local`: +```env +# Required +AGENTQL_API_KEY=your_tinyfish_api_key +SUPABASE_SERVICE_ROLE_KEY=your_service_role_key +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key + +# Optional (caching) +UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_redis_token +``` + +3. Run the database schema: +```bash +# Execute supabase/schema.sql in your Supabase SQL editor +``` + +4. Start the app: +```bash +npm run dev +``` + +5. Open http://localhost:3000 + +## Architecture + +``` +User Browser + | + v +Next.js 14 (App Router) + | + |-- GET /api/scout?zip=94306 ---------> TinyFish API (parallel agents) + | |-- DoorDash search + | |-- UberEats search + | |-- Grubhub search + | |-- Google search + | v + | Merge + Deduplicate + Score + | + |-- GET /api/menu?spot_id=xxx --------> TinyFish API (per-restaurant) + | |-- Extract menu items + prices + | v + | Calculate $/wing + | + |-- GET /api/deals?spot_id=xxx -------> TinyFish API + | |-- Scan deal roundups (KCL, TODAY.com) + | v + | Match deals to restaurants + | + |-- Supabase (PostgreSQL) -----------> Persistence (wing_spots, menus) + |-- Upstash Redis -------------------> Cache (15-min TTL, scouting locks) +``` + +## Tech Stack + +- **Framework:** Next.js 14 (App Router), TypeScript, Tailwind CSS +- **Animations:** Framer Motion +- **Database:** Supabase (PostgreSQL) +- **Cache:** Upstash Redis +- **Web Agents:** TinyFish API (parallel scraping) +- **Geocoding:** Nominatim (OpenStreetMap, no API key needed) +- **Deployment:** Railway diff --git a/wing-command/app/api/deals/route.ts b/wing-command/app/api/deals/route.ts new file mode 100644 index 00000000..9bd58cd7 --- /dev/null +++ b/wing-command/app/api/deals/route.ts @@ -0,0 +1,166 @@ +// =========================================== +// Wing Scout — Super Bowl Deals API Endpoint +// Aggregator-first: check global deals cache → fuzzy match → fallback +// =========================================== + +import { NextRequest, NextResponse } from 'next/server'; +import { createServerClient } from '@/lib/supabase'; +import { + getCachedDeals, + cacheDeals, + getCachedAggregatorDeals, + setAggregatorScoutingLock, + isAggregatorScoutingInProgress, + setDealsScoutingLock, + isDealsScoutingInProgress, +} from '@/lib/cache'; +import { + startBackgroundAggregatorScrape, + startBackgroundDealsScrape, + matchDealsToSpot, +} from '@/lib/deals'; +import { DealsResponse } from '@/lib/types'; + +export const runtime = 'nodejs'; +export const maxDuration = 300; // 5 minutes — Railway has no limit, but set generous max + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const spotId = searchParams.get('spot_id'); + const isPoll = searchParams.get('poll') === 'true'; + + if (!spotId) { + return NextResponse.json( + { success: false, deals: [], cached: false, message: 'spot_id is required' }, + { status: 400 } + ); + } + + try { + // =========================================== + // Stage 1: Check per-spot Redis cache (30-min TTL) + // =========================================== + const cachedDeals = await getCachedDeals(spotId); + if (cachedDeals) { + console.log(`Deals cache hit for ${spotId}: ${cachedDeals.length} deals`); + return NextResponse.json({ + success: true, + deals: cachedDeals, + cached: true, + message: cachedDeals.length > 0 + ? `${cachedDeals.length} Super Bowl deal(s) (cached)` + : 'No Super Bowl specials found (cached)', + }); + } + + // =========================================== + // Stage 2: Look up spot details from Supabase + // =========================================== + const supabase = createServerClient(); + const { data: spot, error: spotError } = await supabase + .from('wing_spots') + .select('name, address, platform_ids') + .eq('id', spotId) + .single(); + + if (!spot || spotError) { + console.log(`Deals: spot not found: ${spotId}`); + return NextResponse.json( + { success: false, deals: [], cached: false, message: 'Spot not found' }, + { status: 404 } + ); + } + + // =========================================== + // Stage 3: Check global aggregator cache → fuzzy match + // =========================================== + const aggregatorDeals = await getCachedAggregatorDeals(); + if (aggregatorDeals && aggregatorDeals.length > 0) { + // Aggregator data exists — try to match this spot + const matchedDeals = matchDealsToSpot(spot.name, aggregatorDeals); + + if (matchedDeals.length > 0) { + // Chain match found — cache per-spot and return + console.log(`Aggregator match for ${spotId} (${spot.name}): ${matchedDeals.length} deals`); + await cacheDeals(spotId, matchedDeals); + return NextResponse.json({ + success: true, + deals: matchedDeals, + cached: false, + message: `${matchedDeals.length} Super Bowl deal(s) found`, + }); + } + + // No aggregator match — this is likely a local restaurant. + // Fall through to Stage 5 (website-only fallback) below. + console.log(`No aggregator match for ${spotId} (${spot.name}) — trying website fallback`); + } + + // =========================================== + // Stage 4: Poll handling + // =========================================== + if (isPoll) { + // Check if either aggregator or per-spot scouting is in progress + const aggScouting = await isAggregatorScoutingInProgress(); + const spotScouting = await isDealsScoutingInProgress(spotId); + const anyScouting = aggScouting || spotScouting; + + return NextResponse.json({ + success: false, + deals: [], + cached: false, + scouting: anyScouting, + message: anyScouting + ? 'Still scouting Super Bowl deals...' + : 'No Super Bowl specials found', + }); + } + + // =========================================== + // Stage 5: Trigger background scrapes + // =========================================== + + // If no aggregator cache at all → trigger global aggregator scrape + if (!aggregatorDeals) { + const gotAggLock = await setAggregatorScoutingLock(); + if (gotAggLock) { + console.log('Launching background aggregator scrape (first request)'); + startBackgroundAggregatorScrape(); + } else { + console.log('Aggregator scrape already in progress'); + } + + return NextResponse.json({ + success: false, + deals: [], + cached: false, + scouting: true, + message: 'Scouting Super Bowl deals...', + }); + } + + // Aggregator cache exists but no match (local restaurant) + // → trigger website-only fallback for this specific spot + const gotSpotLock = await setDealsScoutingLock(spotId); + if (gotSpotLock) { + console.log(`Launching website-only fallback for ${spotId}: ${spot.name}`); + startBackgroundDealsScrape(spotId, spot.name, spot.address, spot.platform_ids); + } else { + console.log(`Website fallback already in progress for ${spotId}`); + } + + return NextResponse.json({ + success: false, + deals: [], + cached: false, + scouting: true, + message: 'Scouting website for deals...', + }); + } catch (error) { + console.error('Deals API error:', error); + return NextResponse.json( + { success: false, deals: [], cached: false, message: 'Failed to fetch deals' }, + { status: 500 } + ); + } +} diff --git a/wing-command/app/api/menu/route.ts b/wing-command/app/api/menu/route.ts new file mode 100644 index 00000000..ad1e1ecc --- /dev/null +++ b/wing-command/app/api/menu/route.ts @@ -0,0 +1,174 @@ +// =========================================== +// Wing Scout - Menu API Endpoint +// Redis-based dedup, background scraping, poll support +// =========================================== + +import { NextRequest, NextResponse } from 'next/server'; +import { createServerClient } from '@/lib/supabase'; +import { + getCachedMenu, cacheMenu, + getCachedChainMenu, cacheChainMenu, + setScoutingLock, isScoutingInProgress, +} from '@/lib/cache'; +import { startBackgroundMenuScrape } from '@/lib/menu'; +import { MenuResponse, Menu } from '@/lib/types'; + +export const runtime = 'nodejs'; +export const maxDuration = 60; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const spotId = searchParams.get('spot_id'); + const isPoll = searchParams.get('poll') === 'true'; + + // Validate spot_id parameter + if (!spotId) { + return NextResponse.json( + { success: false, menu: null, cached: false, message: 'spot_id is required' }, + { status: 400 } + ); + } + + // Seed data spots have no real restaurants — skip Mino entirely + if (spotId.startsWith('seed-')) { + return NextResponse.json({ + success: false, + menu: null, + cached: false, + message: 'Menu not available for demo restaurants. Search with a real zip code to see live menus!', + }); + } + + try { + // 1. Check Redis cache first (1-hour TTL) + const cachedMenu = await getCachedMenu(spotId); + if (cachedMenu) { + console.log(`Menu cache hit for ${spotId}`); + return NextResponse.json({ + success: true, + menu: { ...cachedMenu, source: 'cached' } as Menu, + cached: true, + message: 'Menu loaded from cache', + source_url: cachedMenu.source_url, + }); + } + + // 2. Check Supabase for persisted menu + const supabase = createServerClient(); + const { data: dbMenu, error: dbError } = await supabase + .from('menus') + .select('*') + .eq('spot_id', spotId) + .single(); + + if (dbMenu && !dbError) { + // Check if menu is fresh (less than 24 hours old) + const fetchedAt = new Date(dbMenu.fetched_at); + const ageHours = (Date.now() - fetchedAt.getTime()) / (1000 * 60 * 60); + + if (ageHours < 24) { + const menu: Menu = { + spot_id: dbMenu.spot_id, + sections: dbMenu.sections, + fetched_at: dbMenu.fetched_at, + source: 'cached', + has_wings: dbMenu.has_wings, + wing_section_index: dbMenu.wing_section_index, + source_url: dbMenu.source_url, + }; + await cacheMenu(spotId, menu); + console.log(`Menu loaded from database for ${spotId}`); + return NextResponse.json({ + success: true, + menu, + cached: true, + message: 'Menu loaded from database', + source_url: menu.source_url, + }); + } + } + + // 3. Fetch spot details for menu lookup + const { data: spot, error: spotError } = await supabase + .from('wing_spots') + .select('name, address, platform_ids') + .eq('id', spotId) + .single(); + + if (!spot || spotError) { + console.log(`Spot not found: ${spotId}`); + return NextResponse.json( + { success: false, menu: null, cached: false, message: 'Spot not found' }, + { status: 404 } + ); + } + + const sourceUrl = spot.platform_ids?.source_url || undefined; + + // 4. Check chain-level cache (shared across all locations of same restaurant) + const chainMenu = await getCachedChainMenu(spot.name); + if (chainMenu) { + console.log(`Chain cache hit for "${spot.name}" (spot ${spotId})`); + const spotMenu: Menu = { ...chainMenu, spot_id: spotId, source: 'cached', source_url: sourceUrl }; + await cacheMenu(spotId, spotMenu); + return NextResponse.json({ + success: true, + menu: spotMenu, + cached: true, + message: `Menu loaded from chain cache (${spot.name})`, + source_url: sourceUrl, + }); + } + + // 5. If this is a POLL request, just check if scouting is still running + // Poll requests NEVER trigger new scrapes — only cache checks above + if (isPoll) { + const scouting = await isScoutingInProgress(spotId); + return NextResponse.json({ + success: false, + menu: null, + cached: false, + scouting, + message: scouting + ? 'Still scouting wing items...' + : 'Menu not available. Try again.', + source_url: sourceUrl, + }); + } + + // 6. Initial request — acquire Redis scouting lock (atomic SET NX) + const gotLock = await setScoutingLock(spotId); + if (!gotLock) { + // Another Railway instance is already scraping this spot + console.log(`Scouting lock already held for ${spotId}`); + return NextResponse.json({ + success: false, + menu: null, + cached: false, + scouting: true, + message: 'Menu is being scouted. Check back in a moment!', + source_url: sourceUrl, + }); + } + + // 7. Launch background scrape (fire-and-forget) and return immediately + // This responds in <500ms instead of blocking for 45-120s + console.log(`Launching background wing scrape for ${spotId}: ${spot.name}`); + startBackgroundMenuScrape(spotId, spot.name, spot.address, spot.platform_ids); + + return NextResponse.json({ + success: false, + menu: null, + cached: false, + scouting: true, + message: 'Scouting wing items from the menu...', + source_url: sourceUrl, + }); + } catch (error) { + console.error('Menu API error:', error); + return NextResponse.json( + { success: false, menu: null, cached: false, message: 'Failed to fetch menu' }, + { status: 500 } + ); + } +} diff --git a/wing-command/app/api/scout/route.ts b/wing-command/app/api/scout/route.ts new file mode 100644 index 00000000..a5ec2cd4 --- /dev/null +++ b/wing-command/app/api/scout/route.ts @@ -0,0 +1,440 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createServerClient, getWingSpotsByZip, upsertWingSpots, deleteWingSpotsByZip } from '@/lib/supabase'; +import { getCachedWingSpots, cacheWingSpots, checkRateLimit, getCachedScrapeResult, cacheScrapeResult, purgeZipCache, setScoutingLock, getCachedMenu } from '@/lib/cache'; +import { geocodeZipCode } from '@/lib/geocode'; +import { scrapeAllSources } from '@/lib/agentql'; +import { generateSeedData } from '@/lib/seed-data'; +import { isValidZipCode, cleanZipCode, calculateAvailability } from '@/lib/utils'; +import { startBackgroundMenuScrape, getCheapestWingPrice } from '@/lib/menu'; +import { ScoutResponse, FlavorPersona, WingSpot, MenuSection } from '@/lib/types'; +import { getChainPriceEstimate } from '@/lib/chain-prices'; + +// Render.com: No timeout limit for Web Services (unlimited runtime) +// Setting Node.js runtime explicitly +export const runtime = 'nodejs'; + +// Render Web Services have no timeout constraint — we set a generous max here +// for Next.js route handler purposes. Render won't kill long-running requests. +export const maxDuration = 300; + +// In-flight request deduplication +const inFlightRequests = new Map>(); +const INFLIGHT_CLEANUP_INTERVAL = 5 * 60 * 1000; +let lastCleanup = Date.now(); + +function cleanupInFlightRequests() { + const now = Date.now(); + if (now - lastCleanup > INFLIGHT_CLEANUP_INTERVAL) { + inFlightRequests.clear(); + lastCleanup = now; + } +} + +const VALID_FLAVORS: FlavorPersona[] = ['face-melter', 'classicist', 'sticky-finger']; +const MAX_AUTO_SCRAPES = 10; // Auto-scrape top 10 spots for price data + +/** + * Enrich spots with wing prices from multiple sources: + * 1. Redis menu cache (fastest) + * 2. Supabase menus table (if Redis misses) + * 3. Supabase wing_spots table (if background scrape already wrote price_per_wing) + */ +async function enrichSpotsWithPrices(spots: WingSpot[]): Promise { + const enriched = [...spots]; + const allIds = enriched.map((s, i) => ({ id: s.id, idx: i })); + const missingPriceIds = allIds.filter(({ idx }) => enriched[idx].price_per_wing === null && enriched[idx].cheapest_item_price === null); + const missingPhoneIds = allIds.filter(({ idx }) => !enriched[idx].phone); + + if (missingPriceIds.length === 0 && missingPhoneIds.length === 0) return enriched; + + // Step 1: Try Redis menu cache first for prices (parallel) + if (missingPriceIds.length > 0) { + const redisPromises = missingPriceIds.map(async ({ id, idx }) => { + try { + const cachedMenu = await getCachedMenu(id); + if (cachedMenu?.sections) { + const result = getCheapestWingPrice(cachedMenu.sections); + if (result.price_per_wing !== null || result.cheapest_item_price !== null) { + enriched[idx] = { + ...enriched[idx], + price_per_wing: result.price_per_wing ?? enriched[idx].price_per_wing, + cheapest_item_price: result.cheapest_item_price ?? enriched[idx].cheapest_item_price, + }; + } + } + } catch { /* ignore */ } + }); + await Promise.all(redisPromises); + } + + // Step 2: Check Supabase wing_spots for prices AND phone numbers + const needsPriceFromDb = missingPriceIds.filter(({ idx }) => enriched[idx].price_per_wing === null && enriched[idx].cheapest_item_price === null); + const idsToQuery = new Set([ + ...needsPriceFromDb.map(m => m.id), + ...missingPhoneIds.map(m => m.id), + ]); + + if (idsToQuery.size > 0) { + try { + const supabase = createServerClient(); + const { data: dbRows } = await supabase + .from('wing_spots') + .select('id, price_per_wing, phone, address') + .in('id', Array.from(idsToQuery)); + + if (dbRows) { + const dbMap = new Map(dbRows.map(d => [d.id, d])); + for (const { id, idx } of allIds) { + const dbRow = dbMap.get(id); + if (!dbRow) continue; + // Enrich per-wing price + if (enriched[idx].price_per_wing === null && dbRow.price_per_wing !== null) { + enriched[idx] = { ...enriched[idx], price_per_wing: dbRow.price_per_wing }; + } + // Enrich phone + if (!enriched[idx].phone && dbRow.phone) { + enriched[idx] = { ...enriched[idx], phone: dbRow.phone }; + } + // Enrich address (if currently empty) + if (!enriched[idx].address && dbRow.address) { + enriched[idx] = { ...enriched[idx], address: dbRow.address }; + } + } + } + } catch { /* ignore */ } + } + + // Step 3: For STILL remaining price nulls, check Supabase menus table + const stillMissing2 = missingPriceIds.filter(({ idx }) => enriched[idx].price_per_wing === null && enriched[idx].cheapest_item_price === null); + if (stillMissing2.length > 0 && stillMissing2.length <= 10) { + try { + const supabase = createServerClient(); + const { data: dbMenus } = await supabase + .from('menus') + .select('spot_id, sections') + .in('spot_id', stillMissing2.map(m => m.id)); + + if (dbMenus) { + for (const dbMenu of dbMenus) { + const match = stillMissing2.find(m => m.id === dbMenu.spot_id); + if (match && dbMenu.sections) { + const result = getCheapestWingPrice(dbMenu.sections as MenuSection[]); + if (result.price_per_wing !== null || result.cheapest_item_price !== null) { + enriched[match.idx] = { + ...enriched[match.idx], + price_per_wing: result.price_per_wing ?? enriched[match.idx].price_per_wing, + cheapest_item_price: result.cheapest_item_price ?? enriched[match.idx].cheapest_item_price, + }; + } + } + } + } + } catch { /* ignore */ } + } + + return enriched; +} + +/** + * Fire-and-forget: trigger background menu scrapes for top non-red spots. + * Uses Redis SET NX lock to prevent duplicates. + */ +function autoTriggerMenuScrapes(spots: WingSpot[]): void { + const eligible = spots + .filter(s => s.status !== 'red') + .slice(0, MAX_AUTO_SCRAPES); + + for (const spot of eligible) { + (async () => { + try { + const gotLock = await setScoutingLock(spot.id); + if (gotLock) { + console.log(`Auto-triggering menu scrape for ${spot.id}: ${spot.name}`); + startBackgroundMenuScrape(spot.id, spot.name, spot.address, spot.platform_ids); + } + } catch { + // Ignore lock/scrape errors — non-critical + } + })(); + } +} + +/** + * Estimate prices for spots that still have no price data after enrichment. + * Hybrid approach: + * 1. Chain lookup: if the restaurant is a known chain, use hardcoded price midpoint + * 2. Zip-code average: for unknowns, average all real + chain prices in this batch + */ +function estimateMissingPrices(spots: WingSpot[]): WingSpot[] { + const result = [...spots]; + + // Step 1: Collect real per-wing prices + const realPrices: number[] = []; + for (const spot of result) { + if (spot.price_per_wing != null) { + realPrices.push(spot.price_per_wing); + } + } + + // Step 2: For spots with no price data, try chain lookup + for (let i = 0; i < result.length; i++) { + const spot = result[i]; + if (spot.price_per_wing != null || spot.cheapest_item_price != null) continue; + + const chainEst = getChainPriceEstimate(spot.name); + if (chainEst) { + const midpoint = Math.round(((chainEst.min + chainEst.max) / 2) * 100) / 100; + result[i] = { ...spot, estimated_price_per_wing: midpoint, is_price_estimated: true }; + realPrices.push(midpoint); // Include in zip average + } + } + + // Step 3: Calculate zip average (need >= 2 data points) + if (realPrices.length >= 2) { + const avg = Math.round( + (realPrices.reduce((sum, p) => sum + p, 0) / realPrices.length) * 100 + ) / 100; + + // Step 4: For remaining no-price spots, use zip average + for (let i = 0; i < result.length; i++) { + const spot = result[i]; + if ( + spot.price_per_wing == null && + spot.cheapest_item_price == null && + spot.estimated_price_per_wing == null + ) { + result[i] = { ...spot, estimated_price_per_wing: avg, is_price_estimated: true }; + } + } + } + + return result; +} + +export async function GET(request: NextRequest) { + const t0 = Date.now(); + const log = (msg: string) => console.log(`[scout ${Date.now() - t0}ms] ${msg}`); + + const searchParams = request.nextUrl.searchParams; + const rawZip = searchParams.get('zip'); + const rawFlavor = searchParams.get('flavor'); + const forceRefresh = searchParams.get('refresh') === 'true'; + const purge = searchParams.get('purge') === 'true'; + + log(`START zip=${rawZip} flavor=${rawFlavor}${purge ? ' PURGE=true' : ''}`); + + // Validate zip code + if (!rawZip || !isValidZipCode(rawZip)) { + return NextResponse.json( + { success: false, spots: [], cached: false, message: 'Valid 5-digit US zip code required' }, + { status: 400 } + ); + } + + const zipCode = cleanZipCode(rawZip); + const flavor: FlavorPersona | undefined = rawFlavor && VALID_FLAVORS.includes(rawFlavor as FlavorPersona) + ? rawFlavor as FlavorPersona + : undefined; + + // Rate limiting + log('checking rate limit...'); + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'; + const rateLimit = await checkRateLimit(ip, 20, 60); + log(`rate limit: allowed=${rateLimit.allowed} remaining=${rateLimit.remaining}`); + + if (!rateLimit.allowed) { + return NextResponse.json( + { success: false, spots: [], cached: false, message: `Rate limited. Try again in ${rateLimit.resetIn}s` }, + { status: 429 } + ); + } + + cleanupInFlightRequests(); + + // Skip in-flight deduplication — it can cause deadlocks in dev mode + // where HMR restarts leave stale promises in memory + + try { + // 0. Purge stale/incorrect data if requested + if (purge) { + log('PURGE: clearing Redis cache + Supabase data for zip...'); + const supabasePurge = createServerClient(); + await Promise.all([ + purgeZipCache(zipCode), + deleteWingSpotsByZip(supabasePurge, zipCode), + ]); + log('PURGE: done'); + } + + // 1. Check Redis cache first (skip if purging or force-refreshing) + if (!forceRefresh && !purge) { + log('checking Redis scrapeResult cache...'); + const cachedResult = await getCachedScrapeResult(zipCode); + if (cachedResult) { + log(`HIT scrapeResult cache: ${cachedResult.spots.length} spots`); + const enrichedSpots = estimateMissingPrices(await enrichSpotsWithPrices(cachedResult.spots)); + return NextResponse.json({ + ...cachedResult, + spots: enrichedSpots, + cached: true, + flavor, + message: `Cached data (${cachedResult.spots.length} spots)`, + }); + } + log('MISS scrapeResult cache'); + + log('checking Redis wingSpots cache...'); + const cachedSpots = await getCachedWingSpots(zipCode); + if (cachedSpots && cachedSpots.length > 0) { + log(`HIT wingSpots cache: ${cachedSpots.length} spots`); + const enrichedSpots = estimateMissingPrices(await enrichSpotsWithPrices(cachedSpots)); + const stats = calculateAvailability(enrichedSpots); + return NextResponse.json({ + success: true, + spots: enrichedSpots, + cached: true, + flavor, + message: `Cached ${cachedSpots.length} spots (${stats.percentage}% available)`, + }); + } + log('MISS wingSpots cache'); + } + + // 2. Check Supabase for recent data + log('checking Supabase...'); + const supabase = createServerClient(); + const { data: dbSpots } = await getWingSpotsByZip(supabase, zipCode); + log(`Supabase: ${dbSpots?.length ?? 0} rows`); + + if (dbSpots && dbSpots.length > 0 && !forceRefresh && !purge) { + const timestamps = dbSpots.map(s => new Date(s.last_updated).getTime()).filter(t => !isNaN(t)); + if (timestamps.length === 0) timestamps.push(0); + const latestUpdate = new Date(Math.max(...timestamps)); + const ageMinutes = (Date.now() - latestUpdate.getTime()) / (1000 * 60); + log(`Supabase data age: ${ageMinutes.toFixed(1)} min`); + + if (ageMinutes < 60) { // 1 hour — restaurant data (hours, menu, location) doesn't change fast + const enrichedDbSpots = estimateMissingPrices(await enrichSpotsWithPrices(dbSpots)); + await cacheWingSpots(zipCode, enrichedDbSpots); + const stats = calculateAvailability(enrichedDbSpots); + return NextResponse.json({ + success: true, + spots: enrichedDbSpots, + cached: true, + flavor, + message: `Fresh data: ${enrichedDbSpots.length} spots (${stats.percentage}% available)`, + }); + } + } + + // 3. Geocode zip code + log('geocoding...'); + const location = await geocodeZipCode(zipCode); + log(`geocode: ${location ? `${location.city}, ${location.state}` : 'FAILED'}`); + + if (!location) { + if (dbSpots && dbSpots.length > 0) { + const estimated = estimateMissingPrices(await enrichSpotsWithPrices(dbSpots)); + return NextResponse.json({ + success: true, + spots: estimated, + cached: true, + flavor, + message: 'Could not geocode zip, showing cached data', + }); + } + return NextResponse.json( + { success: false, spots: [], cached: false, message: 'Could not geocode zip code. Please try again.' }, + { status: 502 } + ); + } + + // 4. Scrape all sources in parallel + log('starting scrapers...'); + let scrapedSpots = await scrapeAllSources(zipCode, location.lat, location.lng, flavor, location.city, location.state); + log(`scrapers done: ${scrapedSpots.length} spots`); + + if (scrapedSpots.length === 0) { + if (dbSpots && dbSpots.length > 0) { + log('using stale DB data as fallback'); + const estimated = estimateMissingPrices(await enrichSpotsWithPrices(dbSpots)); + return NextResponse.json({ + success: true, + spots: estimated, + cached: true, + flavor, + message: 'No new data found, showing cached results', + }); + } + + // Fallback: Generate seed data so the app has something to display + log('generating seed data...'); + scrapedSpots = generateSeedData( + zipCode, + location.lat, + location.lng, + location.city, + location.state, + flavor, + ); + log(`seed data: ${scrapedSpots.length} spots`); + } + + // 5. Save to Supabase + log('saving to Supabase...'); + await upsertWingSpots(supabase, scrapedSpots); + log('saved'); + + // 6. Cache results + estimate missing prices + log('caching results...'); + await cacheWingSpots(zipCode, scrapedSpots); + const estimatedSpots = estimateMissingPrices(await enrichSpotsWithPrices(scrapedSpots)); + + const result: ScoutResponse = { + success: true, + spots: estimatedSpots, + cached: false, + flavor, + message: `Found ${scrapedSpots.length} wing spots`, + location, + }; + + await cacheScrapeResult(zipCode, result); + log(`DONE: ${scrapedSpots.length} spots in ${Date.now() - t0}ms`); + + // 7. Auto-trigger background menu scrapes for top spots (any non-red spot) + // This populates price_per_wing data without the user needing to open menus + autoTriggerMenuScrapes(scrapedSpots); + log(`Auto-triggered menu scrapes for up to ${MAX_AUTO_SCRAPES} spots`); + + return NextResponse.json(result); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + log(`ERROR: ${errorMessage}`); + console.error('Scout API error:', errorMessage); + + // Fallback to stale data + try { + const supabase = createServerClient(); + const { data: fallbackSpots } = await getWingSpotsByZip(supabase, zipCode); + if (fallbackSpots && fallbackSpots.length > 0) { + const estimated = estimateMissingPrices(await enrichSpotsWithPrices(fallbackSpots)); + return NextResponse.json({ + success: true, + spots: estimated, + cached: true, + flavor, + message: 'Error occurred, showing cached data', + }); + } + } catch (fallbackError) { + console.error('Fallback error:', fallbackError instanceof Error ? fallbackError.message : 'Unknown'); + } + + return NextResponse.json( + { success: false, spots: [], cached: false, message: 'An error occurred while fetching data' }, + { status: 500 } + ); + } +} diff --git a/wing-command/app/error.tsx b/wing-command/app/error.tsx new file mode 100644 index 00000000..5a3d3359 --- /dev/null +++ b/wing-command/app/error.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect } from 'react'; +import { Button } from '@/components/ui'; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function Error({ error, reset }: ErrorProps) { + useEffect(() => { + // Log the error to an error reporting service + console.error('Application error:', error); + }, [error]); + + return ( +
+
+
🏈
+

+ Fumble! +

+

+ Something went wrong while loading Wing Command. + Don't worry, we're on it! +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/wing-command/app/fonts/BebasNeue-latin.woff2 b/wing-command/app/fonts/BebasNeue-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..92d6a0f7297276c67ade85119be8996eae8b3d57 GIT binary patch literal 13768 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≠ueS2(Z(=p06JW_4vixfA?-eT;=$)( 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 literal 0 HcmV?d00001 diff --git a/wing-command/app/fonts/Inter-latin.woff2 b/wing-command/app/fonts/Inter-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..de83a9c7418a1121c8a59bbc7002c69c43cbe76a GIT binary patch literal 25960 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 bX8aVHA=YmlRlob!7u&+mJ0~xUw*UYD-|trf literal 0 HcmV?d00001 diff --git a/wing-command/app/fonts/PermanentMarker-latin.woff2 b/wing-command/app/fonts/PermanentMarker-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..203ccaeb9c2bab914dbb05e4501915a16315200b GIT binary patch literal 29564 zcmV)FK)=6tPew8T0RR910CRi*4gdfE0ThG)0COP#0RR9100000000000000000000 z00006U;u)62m%R$VG4t`7=pMb3xhBK0X7081BP4^*D3@c1%w(0gJ%o}8|l(H$h?*d z-0c8Wv-f2YRQcs>lVQs9xxWjwFFK-|+hfwJX{;X#@G;|DhKF z?(`5TGM)!$Zr!$9B#T=xY8xAo3!+^Vy}@Xa5hVtrZKBMlL@_A=n>6adN)U{EP_*B- z-Zx*SgMFBCkf7L){ocKYO30pFyH!H8R*7WDHl0MIk>yMBc05rBzjwRmY1n*EHOwMU z(%gfv*7sf0r~xPMEb8gDLu!U3{Dg^9|J->qP}qq{9NBr={Co|ncH^<-eds9%xOZCs z|MR-#&%cay;7BDwF4*rOsfeF^mr;kk%VckN`A|Kv06#g`|F5mK2)>xx_kN5;7u(ph zOAGCs34r2WVMr>jUi^9Q!c}fexBnS z7zE2!K2JJ|Eo1@nX#Ya7)Z5uu>*$D$QbA=?Xq9$@^Z1c2`_}0y)r<`Pj%^z+r-IN2<93%}g~rm_;+kDvUkMf_R%2v! zhE{26;M55zW(t1;+<=XHQE&q`{zXAEPJr6Zv$VclUvGcGHrUx0vy{T#Y zKR!S#jzrPL(S+ZEBrMm##{eZtW_k%3bHqRq4{LYd4K^*_(gz@9|%IvVbNR zs33MSmDg;*0{wjT`|ZDzq0LY<;8(D>_Pxv4w2((6VGm)m&4yY-j`&Ke|LI^;q?Lr7 zW$5a9t{ZE8hoi5ckVOI&qV27ItKIXG&`2YV1Q7`#gdlm>@IH4907N2T4Uho%bJhTW zbyp|D-brx5*jeSE_6>6W^pr;tQGgWZL4_}xvcQ{vRPJ&!^)g=w#}q;lHna#BSHZ}z zc^7Pu&-zNgEzyb2`P|RL7l^~=SaWzBJB}YmN&6nu->WRU{Zc4ua~g-mu`KgV?`Pe! z)_M5Tri&i&(cc9u>a{zbn{NBQb&p*dHMwO6l4e_M)~;8N zDVt1aupJRWo1Hc~VG>SQp*j+CKu)WiUFzZt74w z8A{kk5`Gip7K0jaFtAGE6d_>TfxVM}81Dq~+}FPEr*()wAU5tC1n>WUGaTT4xk-?< z{ylu{@cCeULpGiTA_gDxRQoP|&*0$K0e>TpU)=>nt*IZ3cGe0oIp4)>zg`Jd<-7nc4;n~{@g>K{1 zArOkZ76ga7ySWL)KFJPqpkRT?R^ht1e~Q!7x-Uy{x9bTZ@2Zdk<2)w;v8@0u3xxaN z>0{GTmM1pH$A{zKLRk)4ftK^?e4;o`QDVNPl%~YW>^<_!3<*psu=Lw^MNLjN1WbmQ zFJ6T}(29d?G@9lm={8?zTg@f;ho|FnMyZuafdF;=X{u`v;MqX16m`qb7!$VPI^CWU zP0oZ8ct6GEv$=NYeIncEGjQS~vu_3xig5rG@+1)flY0@iqtZ5=W@$1refBA;S1|kx zeX*eUI%2F7*syVVwR)RB2)k=>;SeA(U#D*9^tR&W{;pgIqE(=xfRHba{k7&6M;jQ< z`Y~n5(T4PqnD)%6g{rv$Wa$g%}{T z%Odx~0cvcoKTlTP;t$TPX8_5=1TzEV?2b!^IZ8f*{$n}L)Nm4Pa}F|+KO`5v3^&XGYE^2%V}3B zVK$x1a;#PZXXRWy#g<=@Sg>o08&Uo0ZlYU?=*l+ptPDClvAdQwzEYEGq$}4&{7CTFmZ$S6`RKi(6oT+MwDQApzFoQo$s;-;3i=g|DOC& zCNWyF8lTKOj@e4MM`)I;bzuO9{}9*H=JgHecuT zX47Zfk>r;KBbXF|uuK;UKxyvgLXu2r!~gWS{%+f{JrN-7P#dOH%z-g!odm}*3n5P$ zi|lD60SmU$l)#z47a4yjg&ZdG$@!e~v*dfzG-R7e0J&Bx21&)wE2&)h<4rWTcWvNMY48}y_iSEl&`=VcC zx=Uu>JPTd#QRe*u()4U>KOK|lr zV1Q8+))zrBwqxurQ=z`o4YB}7?mrrsz3|C@YhlYKfz?4VNzqPl=o>5D!XuV?~u5DY#QRKNw6IRTM8@tlhx zCa1SrEJ?zxNKXq&I++I&lm?AZqxtaRMx~TN&Mtf5At$suX#$5>R!l)5HDVkrff8~h zT1H6)^bVj6oTd%|YoC{AV;-~FCma!z7$AnSISS>l>I1u2p+%oDiHUzV+#hlt>x*a4 z4gmYoRfE4Pwq)vlxw5PZ|An{8Gl+!olYc8GN`TGC@$tL9Ay-iCE?~}oRBAwa&pK_I zGlDlH%#V+0d3a&^2W@F5^LeD2nu0y*LbMP;?^-@pk__fioQ$$32x*N14CFvjSbCAK z;u#U>(i8{)G#&cPn}w|`W_2!&Q~AEFcwZ}{n*4qo%zpLCofHF>-W~HWJ$YG7xx7#? zOis^{WMk(~Ceg4?-}Wd=Y{<4j8>0;DARr__A6CCcK<#$hjsEr^Ab%?kc6Z%&Pr5Fb zcU-no05Jla(m40GC!Xt&t3ZJuiHz@t_x6->r;|t#Ygr@8C9;FIdI~xDS}i6aaV($~ zjX{uYCU{(tr!oZTcrv-ZDrWrz*eg-|zCX*_2Kixa%<^V>KI7w)fVz=%Z z+)W{m;2wlD;7CB1~7$VMEn zuc`0^YEeV9r;(U3ayOl*DVJ*G?8XnC3G<2nF$HLE*Z#(} zZ9l1hvG9A1NcxZOE8iF-UQ%q*w*LIH-7rXA2AVV@oZzh)n+Je(DA%W9|A#}u8Zp^_f{*j4-K?MJA4q% z&!?9*BMH4*r8W6(qH3r-e&RUk^hlV)KS0N}TnPvmBlj|G+EUpjS1U)r^t5fZY0 z(DQm&zZ|(5%u_}Nm~fNb3CalC}VHApx5i4<5^7x^I(VcC(s%Sc0u2MHxXsws(~ym(wzUDqk*`TEPS($b>^!6uuB3`Y1V7p3&Pwigr@8$F(bgV6N{2By|U{qKNCvv-87nYZ%Scb#a zV}s5MTC((JF*3cWPn-|`Ld!3Bm)YJNn)R>VYhkFG@NjuF4#ry1pS$tL5vS5mRE-mx zed0yrZ#v=NQUUvGIe}(LaYrFWcN|yIC7F;{{79m%7FG5$Fuc42H*>&VrYXEp=J0RF zxmsmJ-I_KL2#^VD^j~&C({>vXio05a&tEHsYP}ZAc6dP8O+#(dQ{b>gpU6TcKAs=i zRV@U%enAkq=DOecYz>kVCgu~;nKW)eRw*CB^ zqyR}wLiTWS7*dYYW?o^XkDLn4N0a^C-5sME8`+Jp>I~G3>AWa-jU@*xuxFx5;MHqe zKAT=fvU=qX5pnKB>C1rfmj$IGw!3pwNx9FMBp&C?HEOutes>L2YaqZHVbA6V<)@Hl z$V8wzTkP;Ofm0mxcoLw6s^N%Ww0JyeiEReX|4HF(5$c^ki+SXWYXTLi?kCYLboK$Nl z#iVgZEP6aU_qQl9Mx!nR*S6(?^PY+rUf(o)C`@cc_&BzbQBfDZ$}M|-sV5OUTvyHl ziCc^lIGEgXl3S~EH-9Fa1s`)EspJViDUl`3gq<#o>bAHQa^E=e#sSU*cBZP-sFsv1 z-kfy4?CR4(s%M$h>b@5Kw;DcE6={aFA@rcnsX(^!TG7S>$>}SF8mrzd%5F|Z37E)` z0&>%X{F7+@oV~2pKM&-0a--|N=#e4X?8-^99E!X%)qw;!IOXU>;-q=FK2~$+u|8e8Oso?k?5_Pom|OQizDJE@h(<6#r1hmf#E4E01B+1eYU`Zwihm| zl1MQ10*jvvH*`I=BW0VEcdrC$~U z#u*kXZ>LKAGm`WRK4tf{8+-0;wk%ewWH&dpS{gr}Bp*#&+>3gUWAPm4=hJGEaP`7H z+_jLq`GoCg;gv-z=FHt^vU7Wq;RZGh|IHJ84-UMaIk#Da8DDe%5$9)28m)wvCiv=C zn+F(jKMm>TGtC0dz14izNITytHo{r=;#CL_t}}C9kg{};pp`h21g1no_>7qD@u{j| z^0Jt?F83yw|plT7WS)rjsRrI+7SK&JOd6EdPUNySf{b;R&_DZdi z;NQQEzZj);9|8><$YLVTvSdoX!pu|20zcSVz~zB)Vfq#uC?9XMVI5J=I(2~Hk>BCP@r8edDKno$o3lxGYB3D7HPrQfK}S z3e`%nncK%0F$)H}FK9E31Tr);HULY}Kwpj-wN#QX~Z9@6tN_q!O6tZv!OI2zA1zLX10HDAvNMLH{eKOc5{9XVxtWAd&j;J zP5HT7gFBe|ck6H*4&w(Ob90Fs3P8Jh%gIm^6LHo~FNI8JA?Zy=vMlBsaM)|Y%Hy(p zPl-0Kgvlx1gVk-^lJLTKDsBjnjn>*vfz0q`v)h-(p0@`gvew%g(UF|!losUUu84V(gU{_o zHWIi{yX>x-+nYqxJ{H<5m#ozd_kh{6tz>;U!_PR)|TOE03B&6@(i9@)CCI?fhQ=+Lrt3}|7BS@*yT}*BggIb8XnG0 zXf%dEXf}cXg7V~5CSsv5D6(NBtNv1#j@<&Nup#I$5dbk|&E-^VUv6xItlhi>fEw>} z(C$A@lEP@#;%=E|YEqP0r2$-qZbDYl{{M91#VR90i@nfkAiWbnv-2%NTYt_?r?hfs z!FLzR8+Q>y#f34{HQ0lq`4VIOhB4GTAI}E(_43qErk)Y1fpIBq%L;g$AfUr$k=8+X zpcbcVJ8c}dvqrhWMl;J1I0pQdjkYzsSp5=V>ywkF7>+Pbzic=-%lnJx*|VAsQkb}~ zqf8~}Q^GC?FDb+-%nnr?*hn1hUgR4KCLFe9oV;@AY>q!yrDCslKjas1w%LKyIn1*1 z#l`J)Loo&3Ms{n$I9WtPWIium&IRV>%f37aAq#ec=aG$)>h4jfHT$fTTY zjmcMq-f^i3wCjl~JQ~}+w3=fxZfA|j zt3X7OMzhY4a5sDF72WvQOsvtMP}-AiAr_;u++Cjr%?jfh!l77GTnBqu@MtI3jY66+ zxq=zH@tF`f$)k@M(WOW07TK)E%(Opc6Rr2{ptg8|xtjI~^tBSH6%%zy=+U2%wM_lw z5o^@MbXEfI#ygzrbyU#Z$K%wOW-RnIq#s)7cK8BPYfl%D*!abM(XJ3NVXqJ z3XOVltDg3z8`3uJDz(5Q-yEc~PrxS<$HigCrQl1j4)>pDEpuL|*Ja@gG9|r42D89z zukCR~J(OcXLnwEM`KW~Cb_C(eAZCb}dvocH^LHSkV839{s+1p36{I$=HV{&(!`(jM zEOF@5D5x}j7zj~kf3CtQy z(Anq(K0`k0yHEf--_RKw!H)=nJ3i845vEJ~)2^%eRs^`nsFv*$k}3+a=9u3rIHy z;wC_}7SdAv2Kzl)-$c+$jP)P*)P#>+{gsm&0yWG~`RqY0}g`Z)r3Az3D{m9p!Pr+A_xZa46{zy3etZcGtTu~ zNW=xGzbNLA!iR_|M^ICeG5rf6Djl5Z-;E8<+lQ+Oo?^Kj#yNzX6w_Cr*M^(*ef5Ft zmP=4ehjAKwYrfBoO9+v7@G3;nu0X(lp#Z{uL@>g+p5v+Fs_*0L7VarKpdBr;43pcN z9v7u2k>hppJ;Ng0o9%ny_y0_cRd`S{jFZ4i5FWk?;dF?Khoe{=5Xc}yb5=6grMs_( z`Qk+ph@R8%a@YU+zvG>iM!^PcWF{L83c@v+ZJ)Xqeo*YSI}=T$wtM*}%(BIgwXphT z#4K?zgVQp}TV8TW+>_Gjc@%$QpY#As4pb__E?T#4Uv2gFKGqv72c4P6;U}I?UQz;# zBaVlde7kdaRX=r=wY-X4Oz?ENSwB_}Ve3Zv-5=Ai$4Ia~$v2qLuZcyrR_;d5L^0s1 zvm5aHnW&WeES}HU zn(Rt2D4jf3S5xZW5rQJ>0djqs2hfVm=u{|t zY-#R7Ex0qreN-c;G9XdlJY1P2m-%0Nm%+D?8>czdm-aRqrwt4*jmj0xsMI06Cni2F z8mGr zBS~w@6()?rM`^)Xm=#(xxhp^HL5_X`6CbwRFm0X(#ra!dyjKn23WpQ1J>3ndpg}Nr z2=VjMy*P-tIP%Zg%H0Dw?lL8Cvme%FKhr^oJeyc@uocm+ZqmxjE*g!kQlq zhi$hhv?s2mde%gz_%=z@*?Z@-(y~w&>E|EES(&*svY!@1i8;i#{tEd~ytC;^Z^m(v z+Bkx81gxm;?W*ffA&uOdw#i8dZ=)j+VVVB%8p`IU2@}^(R*y97mf^>r@T=`pwE8!U zbuT3%sNr{f z5}R14h2m>scIuv8{ke&AnSUNR9! zi@y@T|Jt5cwc^7G#=yU?U$=~h7gAkY0I!P!BU^COl1=o2^}+7^oY~0T^Rxv*n*5TJ z05Ii(MH9R8V-$NN2-)jAqz3#E|M(52SH_Jm9n<>#<*D=fM)R(n7DEo92$3N*meu_; z4U0cuIEKMUB6|vGeYa|2yU{&e-@9z;u3&OA@iHu*-tW=o`D+MKHaHjlt$Vn-e>JKb zr`vBG$=W+P0l3YUxVzp`0aRKd4{CCk&DEwIcd|zmB91@4(wlj7!*UUcv-VP$jsSTj z$~X_*uCUqsG6ZN(kRfbNJWqO0QDTm~yx9(LL*&1D9ZJjX#$gF^PgMQ`E?5vNDfSk4 zbgJ}!FJ6T`{*`{wD4T%g2%SP#E=a(|7^-~YJRCHPJ)mq%9x**5C2ZAhX{5h9<#YXt zcmHbf81*Yqru4Y3hH{0!82&bM3D0D4z8qNHH= z0WyZbv1H4&v~90->q=mj$b)Z1Ux0asZAkpI9@~o$FCTXar0XfRgxB99aEkbuw3?b7 zPHn9`SsJG(fkJY)&%fx4F23i?viThOBA25?*txL9U=EC{?Jom-rT)a48xuR=1z1lb zqNCBli+;38oAmZMeqx{mZv!;r6d(Yto8v~?9wx!f-|q3)(aO{W>@lI>^0eE_tsKbS z5(LI`2sz9tq`)ku2VYh-#=v17U<=)F#{Uth^x6;2+0SO#EO?O7=`4+jWKIqY`x9K& zuj`?E5~HM*bsqyP!1;ev1$p=Wb}C7;z^lY{N%7XRflQ|Z#I#|_ZMLERL4J%s(<)A? zgqqXR_$BSScE)-o#(~Ntq0RQo7X{??dI~-k`-Y1Db_y4;wJ&Tn72YSSlf**t+%aEg z?Rb*ICcUIp-jHhd!M%K`8wLCLU6bwFb`;uf)UF3u`S{866&%)ytWT-Ef>67gOr^yj zjyNPTpc`y&w(8Ibl=RV-*|TeoR4zqeJS}aHWVPcU)qOr`Y+~l7*+8R=9YJ^S5A^Y* zNfKG(KjzI*IG&IeGPH?o^^Q4MmlSS!j{w879l zSkQMH4a4QbOfyyFWxaoxHe*&R&G*Hk=BHkR5?!=1hCar9vKv5v`o+Z8tpTv*Pt6k% ze=j`?>OC*Kcy5sY{QHNzIIct!qvV;?R?I-e?+$hUx9fX_VQv7-I>92W%zQBe(FFPj zBzpY=)cj)=Y_=p`(=uX1*}fN5>cL#A+?W7N^iaa={wuIacmkYUuqle^)?;>FoaxbB zHi!n`+)}IS?h$@xVg(clr9)XEH3=KY`9f*G-ZBi2gJ~0$HLad3?YiY)TvOe!2D@81 zr?**L#D&>$m4*1p+mwqCn$uZE{oy1Dr;E2-DbDZE!O86AOL<>0|-%qmAz|Dsr}M%tC^Xg_o~>$K%!yoyR?B4s$g{L^Owh zw_&&{b8o_|Sdb-v$oAN_TEDNWL_ZgmtWEF5j?6R-T9wPvYKNLlU2&HFNRS%G z(8@dK{;SE^At(+C?h1h$bC~V{y84X;)A=Y9jCFGzlI;&E3w7@?oLYV3?au zz3chVxs{K=hI$)FIy}k=3P*_fVN9vdCR@Tvb}bh)A_AOabL;fyqgKx+)jixwh2p?t zCK%vY^5>twc8OZykAm0X@()$cD?qOyRqG#@OE!H73S{{>ru{|@04--(i4X;%w(W|2 zv0ZKEj1#tZQ4Vjhg;Vh;l=Zy5&C|skJzUl(YlU_b@C1}FA6~S^n44^u#eqFCO{B(Y zaZWg>z2*M8F+ZY{9c8e`Ei)Cniw7K->WyN#jZkijThq_(zFAJD&Q7^J<^+X2tu@4O z2cjEg<#4vt&HzP5mp?1sXD(Y_!ZS$FBu3%hE| zYWeL0SP%Bqc+!2Ut~~hG;WZCE;B-{A-m7BycHpJs%)KBOwmgnt1QVN7$GSO5;~Vq5 z1MBiL_@CsW&m-hK!~{4#ECNmtZhdO0DP8!<1+^jU*T~)>jgNQcjz3OTY3;$N>~PbR za^{k2XrB4?N?yqQ;WgVp`|jV5>>vF2rHy7x!7C~3%XQ58k?7D6C#V5@b|#Hkjy&^Y zVsX6xQ^XvAb`Trv3l%^F4Ms}T&<=j(NB`idmz7YrtZ-*~vW-ky6&Mf{qIp8C{Xhyf zTUwa$e|VcSzpd_<6Pt z`1Ryy3Z<4@ru$VwihvAOA;wC{e;bGk&a!ein;G-g*6&%hVTCeZknnSq{_NLZ#fu6o zWIKOFQVXEiFNEr!*zPSxOGIPxoW;#V9erNex{a%9)c~@O|u>LycIP0h9g!29u zcS7(aKj-f8kEt*ujQF+-X?44Xthb2+hiXn_XlnB%LXG@NSFa$-;c0vF2CE zgN)0+RT(XMKUMY^Pq{?Qw?7_1IDPOV!3FalbUU0O(vRA}_E>xB4ou^L?IMVq|8yjp z5eVf;)7t5~N%cIr`X$mgwNU$bX}8u{CHE~5`;*&jHy0bSSbR%6j(uSAU1 z8Ea}gYP+XF8KmOdzHlIhj;5pR(v(iIz99 zqee5pvH1F_x81@L_(S{En3b)1=IS@5h(~{jsvfu2Lr(G#*pTCp#R%J~qbJ+9Gk4{GVk+V6>CjxOX847omXsn)Ql1tV^AWB`KlI27|b)-GbUQu ztBPBV&5&SBBc@D?Znjm^uAJb~s6o>%=h4|AiSqeg|5I^ef5{T1_P4!FTTH80$4RA9 zO^hLWT7~<}%9Jo5KUpGW$SK901**b5l}L)ASE+5oW=b%oU4Srf`f>yr`|4_3r&Z`* zKOXzo7g+cjVv~G~5)tPQ!^J#Jc&#a&|H-X+{M@K*_lO?R1qK8qYB1DRz->NdO`++= zc}#*2RM{ql_ZP!z}7(FHYPg}Jk3Ysr6 zN3foYU7q)@Ke;!!OE|LJ2||QfFyUB`jLvHQb&(yKSoPH5Z(q z&T)|yhGhBsCuY^gGu_f&N7C8r0ApFzG`i!0bI zpO!UrSf~m-PNYPHTzEC`z=nH*34d8==J9U|@XK-LQV
    S}D~Xz1ggD{L7{ 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 Jo`3ha{{z9RE`|UA literal 0 HcmV?d00001 diff --git a/wing-command/app/fonts/RussoOne-latin.woff2 b/wing-command/app/fonts/RussoOne-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..31a08f7bcf1b345099ac5d1aa94cbecd4003890b GIT binary patch literal 7368 zcmV;(95>^4Pew8T0RR910365w4*&oF07%pT032@s0RR9100000000000000000000 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 void; +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + return ( + + +
    +
    🏈
    +

    + Critical Error +

    +

    + Wing Command encountered a critical error. + Please try refreshing the page. +

    + {error.digest && ( +

    + Error ID: {error.digest} +

    + )} + + +
    + + + ); +} diff --git a/wing-command/app/globals.css b/wing-command/app/globals.css new file mode 100644 index 00000000..72640f1a --- /dev/null +++ b/wing-command/app/globals.css @@ -0,0 +1,546 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ===== CSS Variables — Locker Room Light Theme ===== */ +:root { + --bg-primary: #F3F4F6; + --bg-surface: #FFFFFF; + --bg-card: #FFFFFF; + --bg-elevated: #F9FAFB; + --border-color: #E5E7EB; + --border-light: #D1D5DB; + --text-primary: #1F2937; + --text-secondary: #4B5563; + --text-muted: #9CA3AF; + --stadium-green: #16A34A; + --stadium-green-light: #22C55E; + --whistle-orange: #F97316; + --wing-green: #22c55e; + --wing-yellow: #fbbf24; + --wing-red: #ef4444; + --manila: #FEF3C7; +} + +/* ===== Base Styles — Light Mode ===== */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; + background: transparent; + color: var(--text-primary); + font-family: var(--font-inter), system-ui, sans-serif; +} + +/* ===== Scrollbar — Clean Light ===== */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-light); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: #9CA3AF; +} + +/* ===== Locker Room Background — solid (AnimatedFieldBackground handles visuals) ===== */ +.locker-room-bg { + background-color: var(--bg-primary); +} + +/* ===== Whiteboard Panel ===== */ +.whiteboard-panel { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); + position: relative; +} + +.whiteboard-panel::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background-image: + linear-gradient(rgba(22,163,74,0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(22,163,74,0.02) 1px, transparent 1px); + background-size: 30px 30px; + pointer-events: none; +} + +/* ===== Clipboard Card ===== */ +.clipboard-card { + background: var(--bg-surface); + border: 2px solid var(--border-color); + border-radius: 16px; + position: relative; + transition: all 0.3s ease; + overflow: hidden; +} + +.clipboard-card::before { + content: ''; + position: absolute; + top: -4px; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 12px; + background: #9CA3AF; + border-radius: 0 0 6px 6px; + z-index: 2; +} + +.clipboard-card:hover { + border-color: var(--stadium-green); + box-shadow: 0 4px 12px rgba(22, 163, 74, 0.1); + transform: translateY(-4px); +} + +.clipboard-card.selected { + border-color: var(--stadium-green); + box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.12), 0 4px 12px rgba(22, 163, 74, 0.1); +} + +/* ===== Manila Folder Card ===== */ +.manila-folder { + background: linear-gradient(165deg, #FFFBEB 0%, #FEF3C7 40%, #FFFFFF 100%); + border: 1px solid #FDE68A; + border-left: 4px solid #FDE68A; + border-radius: 2px 12px 12px 2px; + position: relative; + overflow: visible; + transition: all 0.3s ease; + box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); +} + +.manila-folder:hover { + box-shadow: 0 8px 20px rgba(245, 158, 11, 0.12), 0 2px 6px rgba(0,0,0,0.06); + transform: translateY(-3px); +} + +/* Manila folder tab */ +.manila-tab { + position: absolute; + top: -10px; + left: 12px; + border-radius: 4px 4px 0 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 14px; +} + +/* ===== Notebook line for stats ===== */ +.notebook-line { + padding-bottom: 4px; + border-bottom: 1px solid rgba(22, 163, 74, 0.06); +} + +/* ===== Jumbotron Digit ===== */ +.jumbotron-digit { + background: linear-gradient(180deg, #1F2937 0%, #374151 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + position: relative; +} + +/* ===== Scouting Sheet (legacy compat) ===== */ +.scouting-sheet { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: 12px; + position: relative; + overflow: hidden; + transition: all 0.3s ease; +} + +.scouting-sheet::after { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: 12px 0 0 12px; +} + +.scouting-sheet.status-green::after { + background: var(--wing-green); +} + +.scouting-sheet.status-yellow::after { + background: var(--wing-yellow); +} + +.scouting-sheet.status-red::after { + background: var(--wing-red); +} + +.scouting-sheet:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + transform: translateY(-2px); +} + +/* ===== Glass (Light version) ===== */ +.glass { + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border-color); +} + +/* ===== Neon Text (green accent on light) ===== */ +.neon-text { + color: var(--stadium-green); +} + +.neon-text-subtle { + color: var(--stadium-green); +} + +.sauce-text { + color: #DC2626; +} + +/* ===== Skeleton Loading — Light ===== */ +.skeleton { + background: linear-gradient( + 90deg, + #E5E7EB 25%, + #F3F4F6 50%, + #E5E7EB 75% + ); + background-size: 936px 100%; + animation: shimmer 1.5s infinite; + border-radius: 8px; +} + +@keyframes shimmer { + 0% { background-position: -468px 0; } + 100% { background-position: 468px 0; } +} + +/* ===== Status Colors ===== */ +.status-green { + color: var(--wing-green); + background: rgba(34, 197, 94, 0.1); + border-color: var(--wing-green); +} + +.status-yellow { + color: var(--wing-yellow); + background: rgba(251, 191, 36, 0.1); + border-color: var(--wing-yellow); +} + +.status-red { + color: var(--wing-red); + background: rgba(239, 68, 68, 0.1); + border-color: var(--wing-red); +} + +/* ===== Ticker Bar — Light Theme ===== */ +.ticker-bar { + background: linear-gradient(90deg, var(--stadium-green) 0%, #15803D 50%, var(--stadium-green) 100%); + overflow: hidden; + white-space: nowrap; +} + +.ticker-content { + display: inline-block; + animation: ticker-scroll 14s linear infinite; +} + +@keyframes ticker-scroll { + 0% { transform: translateX(100%); } + 100% { transform: translateX(-100%); } +} + +/* ===== Coin Flip 3D ===== */ +.coin-3d { + transform-style: preserve-3d; + perspective: 600px; +} + +.coin-flipping { + animation: coin-flip 1.2s ease-in-out; +} + +@keyframes coin-flip { + 0% { transform: rotateY(0deg) scale(1); } + 50% { transform: rotateY(900deg) scale(1.3); } + 100% { transform: rotateY(1800deg) scale(1); } +} + +/* ===== Siren animation for LIVE badge ===== */ +@keyframes siren { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.animate-siren { + animation: siren 1s ease-in-out infinite; +} + +/* ===== Selection Color ===== */ +::selection { + background: rgba(22, 163, 74, 0.2); + color: #1F2937; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .desktop-only { + display: none; + } +} + +@media (min-width: 769px) { + .mobile-only { + display: none; + } +} + +/* ===== Handwritten Note Style ===== */ +.handwritten-note { + font-family: var(--font-marker), cursive; + color: var(--stadium-green); + transform: rotate(-2deg); +} + +/* ===== Paper Texture ===== */ +.paper-texture { + background-color: #FFFEF7; + background-image: + repeating-linear-gradient( + transparent, + transparent 31px, + rgba(22, 163, 74, 0.06) 31px, + rgba(22, 163, 74, 0.06) 32px + ); +} + +/* ===== Tape Strip Decoration ===== */ +.tape-strip { + background: rgba(249, 115, 22, 0.15); + border: 1px solid rgba(249, 115, 22, 0.2); + border-radius: 2px; +} + +/* ===== Coach Wing Speech Bubble ===== */ +.speech-bubble { + position: relative; + background: white; + border: 2px solid var(--border-color); + border-radius: 16px; + padding: 12px 16px; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); +} + +.speech-bubble::after { + content: ''; + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid white; + filter: drop-shadow(0 2px 1px rgba(0,0,0,0.04)); +} + +/* ===== Animated background for mascot side ===== */ +.mascot-bg { + background: linear-gradient( + 160deg, + rgba(22, 163, 74, 0.04) 0%, + rgba(249, 115, 22, 0.02) 50%, + rgba(22, 163, 74, 0.04) 100% + ); +} + +/* ===== Playbook grid overlay ===== */ +.playbook-grid { + background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' xmlns='http://www.w3.org/2000/svg'%3E%3Ctext x='10' y='20' font-size='12' fill='%2316A34A' opacity='0.05'%3EX%3C/text%3E%3Ccircle cx='45' cy='40' r='6' stroke='%2316A34A' fill='none' stroke-width='1' opacity='0.05'/%3E%3C/svg%3E"); +} + +/* ===== Scouting Report Card — Manila Folder v2 ===== */ +.report-card { + background-color: #F3E5AB; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); + border: 2px solid #D4C395; + border-radius: 4px 14px 14px 4px; + box-shadow: 8px 8px 0px 0px #1E3A8A; + position: relative; + overflow: visible; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.report-card:hover { + box-shadow: 10px 10px 0px 0px #1E3A8A; + transform: translateY(-5px) scale(1.02); +} + +/* Report card folder tab — clip-path trapezoid */ +.report-tab { + position: absolute; + top: -14px; + left: 16px; + height: 16px; + min-width: 90px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + clip-path: polygon(8% 0%, 92% 0%, 100% 100%, 0% 100%); + z-index: 5; +} + +/* Polaroid photo frame */ +.polaroid { + background: white; + padding: 8px 8px 28px 8px; + border: 2px solid #E5E7EB; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transform: rotate(-2deg); + transition: transform 0.3s ease; +} + +.report-card:hover .polaroid { + transform: rotate(-1deg) scale(1.02); +} + +/* Draft grade circle */ +.draft-grade { + width: 52px; + height: 52px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-marker), cursive; + font-size: 20px; + font-weight: bold; + color: white; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + transform: rotate(6deg); +} + +/* Red marker circle SVG annotation — shaky hand-drawn */ +.red-circle-annotation { + stroke: #DC2626; + stroke-width: 3; + fill: none; + stroke-linecap: round; + stroke-dasharray: 300; + stroke-dashoffset: 300; +} + +.red-circle-annotation.animate { + animation: draw-in 0.8s ease-out forwards; +} + +@keyframes draw-in { + 0% { stroke-dashoffset: 300; opacity: 0; } + 10% { opacity: 1; } + 100% { stroke-dashoffset: 0; opacity: 1; } +} + +/* Strikethrough marker line for flavor cards */ +.marker-strike { + position: absolute; + top: 50%; + left: -5%; + height: 4px; + background: #DC2626; + border-radius: 2px; + transform: rotate(-3deg) translateY(-50%); + opacity: 0; + width: 0; +} + +.marker-strike.active { + animation: strike-through 0.4s ease-out forwards; +} + +@keyframes strike-through { + 0% { width: 0; opacity: 0; } + 100% { width: 110%; opacity: 0.6; } +} + +/* Fumble overlay */ +.fumble-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(239, 68, 68, 0.08); + border-radius: inherit; + z-index: 15; + pointer-events: none; +} + +/* Perfect Play glow */ +.perfect-play-glow { + box-shadow: + 0 0 20px rgba(22, 163, 74, 0.25), + 0 0 40px rgba(22, 163, 74, 0.15), + 8px 8px 0px 0px #1E3A8A; + border-color: #16A34A !important; +} + +/* Tactical canvas X's and O's mark */ +.xo-mark { + position: fixed; + pointer-events: none; + z-index: 9999; + font-family: var(--font-marker), cursive; + animation: xo-fade 1.2s ease-out forwards; +} + +@keyframes xo-fade { + 0% { opacity: 0.45; transform: scale(1); } + 100% { opacity: 0; transform: scale(0.5); } +} + +/* SVG play diagram draw animation */ +.play-diagram-path { + stroke-dasharray: 200; + stroke-dashoffset: 200; + animation: play-draw 2.5s ease-in-out infinite; +} + +@keyframes play-draw { + 0% { stroke-dashoffset: 200; } + 50% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: 200; } +} + +/* Play diagram pulse when search focused */ +.play-diagram-active .play-diagram-path { + animation-duration: 1.5s; + stroke-width: 2.5; +} diff --git a/wing-command/app/icon.svg b/wing-command/app/icon.svg new file mode 100644 index 00000000..a9dc0b84 --- /dev/null +++ b/wing-command/app/icon.svg @@ -0,0 +1,4 @@ + + + 🍗 + diff --git a/wing-command/app/layout.tsx b/wing-command/app/layout.tsx new file mode 100644 index 00000000..a8fcfc54 --- /dev/null +++ b/wing-command/app/layout.tsx @@ -0,0 +1,80 @@ +import type { Metadata, Viewport } from 'next'; +import localFont from 'next/font/local'; +import './globals.css'; + +// Self-hosted fonts — avoids build-time Google Fonts downloads that fail on Render +const inter = localFont({ + src: './fonts/Inter-latin.woff2', + variable: '--font-inter', + display: 'swap', +}); + +const bebasNeue = localFont({ + src: './fonts/BebasNeue-latin.woff2', + weight: '400', + variable: '--font-bebas', + display: 'swap', +}); + +const russoOne = localFont({ + src: './fonts/RussoOne-latin.woff2', + weight: '400', + variable: '--font-russo', + display: 'swap', +}); + +// "Permanent Marker" handwriting font for coach notes: +const permanentMarker = localFont({ + src: './fonts/PermanentMarker-latin.woff2', + weight: '400', + variable: '--font-marker', + display: 'swap', +}); + +export const metadata: Metadata = { + title: 'Super Bowl LX: Wing Command | Your Game Day Wing HQ', + description: 'Your Super Bowl LX wing headquarters. Find the best chicken wings to order for your game day party — real-time deals, flavor matching, and AI-powered scouting. Powered by Coach Wing.', + keywords: ['chicken wings', 'super bowl', 'wing deals', 'game day food', 'wing command', 'super bowl lx', 'super bowl party', 'order wings'], + authors: [{ name: 'Wing Command' }], + icons: { + icon: '/icon.svg', + }, + openGraph: { + title: 'Super Bowl LX: Wing Command | Your Game Day Wing HQ', + description: 'Find the best chicken wings for your Super Bowl LX party. Real-time deals, flavor matching, and AI-powered scouting.', + type: 'website', + locale: 'en_US', + }, + twitter: { + card: 'summary_large_image', + title: 'Super Bowl LX: Wing Command | Your Game Day Wing HQ', + description: 'Find the best chicken wings for your Super Bowl LX party.', + }, + robots: { + index: true, + follow: true, + }, +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + themeColor: '#F3F4F6', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
    + {children} +
    + + + ); +} diff --git a/wing-command/app/loading.tsx b/wing-command/app/loading.tsx new file mode 100644 index 00000000..c3d2ee54 --- /dev/null +++ b/wing-command/app/loading.tsx @@ -0,0 +1,20 @@ +export default function Loading() { + return ( +
    +
    +
    +
    🍗
    +
    +
    +

    + Scouting for Wings... +

    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/wing-command/app/page.tsx b/wing-command/app/page.tsx new file mode 100644 index 00000000..5d1308c6 --- /dev/null +++ b/wing-command/app/page.tsx @@ -0,0 +1,358 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Trophy, Users } from 'lucide-react'; +import { GlassBlitzEntrance } from '@/components/GlassBlitzEntrance'; +import { CommandJumbotron } from '@/components/CommandJumbotron'; +import { CoachHero } from '@/components/CoachHero'; +import { TrashTalkTicker } from '@/components/TrashTalkTicker'; +import { TradingCardGrid } from '@/components/TradingCardGrid'; +import { CompareBar } from '@/components/CompareBar'; +import { CompareModal } from '@/components/CompareModal'; +import { FlavorPersona, ScoutResponse, AvailabilityStats } from '@/lib/types'; +import { calculateAvailability } from '@/lib/utils'; + +const LAST_ZIP_KEY = 'wing-command-last-zip'; +const LAST_FLAVOR_KEY = 'wing-command-last-flavor'; +const CACHE_DURATION_MS = 30 * 60 * 1000; // 30 min — discovery app, not inventory tracking + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: CACHE_DURATION_MS, + gcTime: 60 * 60 * 1000, // 1 hour — keep query data in memory longer + retry: 1, + refetchOnWindowFocus: false, + refetchOnMount: false, + }, + }, +}); + +// =========================================== +// Stats Bar — bright theme +// =========================================== +function StatsBar({ stats, locationName }: { stats: AvailabilityStats; locationName: string }) { + if (stats.total === 0) return null; + + return ( +
    + + {locationName && ( +
    + + {locationName.toUpperCase()} +
    + )} + +
    + +
    +
    + {stats.green} + OPEN +
    +
    +
    + {stats.yellow} + LIMITED +
    +
    +
    + {stats.red} + CLOSED +
    + +
    + +
    + + {stats.total} TOTAL +
    + +
    + ); +} + +// =========================================== +// Coach Wing speech bubbles — sunny comedy twist +// =========================================== +function getCoachSpeech(flavor: FlavorPersona | null, isSearching: boolean, hasResults: boolean): string | undefined { + if (flavor === 'face-melter') { + if (isSearching) return "Scouting the hottest spots... this sunshine ain't helping! \uD83D\uDD25"; + if (hasResults) return "Now THAT'S a roster! Pick your starter."; + return "You chose violence. On a sunny day. Bold."; + } + if (flavor === 'classicist') { + if (isSearching) return "Finding the OGs... perfect game day weather for it."; + if (hasResults) return "Now THAT'S a roster! Pick your starter."; + return "Smart play. The classics never miss."; + } + if (flavor === 'sticky-finger') { + if (isSearching) return "Tracking down the sauciest spots... \uD83E\uDD24"; + if (hasResults) return "Now THAT'S a roster! Pick your starter."; + return "Napkins? Where we're going, we don't need napkins."; + } + if (!flavor) return "Pick a play, rookie. What's your flavour?"; + return undefined; +} + +// =========================================== +// Main Wing Command Content +// =========================================== +function WingCommandContent() { + const [zipCode, setZipCode] = useState(''); + const [flavor, setFlavor] = useState(null); + const [isHydrated, setIsHydrated] = useState(false); + const [bannerDone, setBannerDone] = useState(false); + const [compareIds, setCompareIds] = useState>(new Set()); + const [isCompareOpen, setIsCompareOpen] = useState(false); + + const toggleCompare = useCallback((id: string) => { + setCompareIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else if (next.size < 4) { + next.add(id); + } + return next; + }); + }, []); + + const clearCompare = useCallback(() => { + setCompareIds(new Set()); + }, []); + useEffect(() => { + const savedZip = sessionStorage.getItem(LAST_ZIP_KEY); + const savedFlavor = sessionStorage.getItem(LAST_FLAVOR_KEY) as FlavorPersona | null; + if (savedZip && savedZip.length === 5) setZipCode(savedZip); + if (savedFlavor) setFlavor(savedFlavor); + setIsHydrated(true); + }, []); + + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: ['scout', zipCode, flavor], + queryFn: async ({ signal }) => { + if (!zipCode || !flavor) return { success: true, spots: [], cached: false, message: '' }; + + // Only abort if the user changed zip/flavor (new queryKey = new signal) + // Don't use our own abort — let React Query's signal handle cancellation + const params = new URLSearchParams({ zip: zipCode, flavor }); + const res = await fetch(`/api/scout?${params.toString()}`, { + signal, + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${res.status}`); + } + + return res.json(); + }, + enabled: zipCode.length === 5 && flavor !== null, + retry: (failureCount, error) => { + // Don't retry geocoding failures — all server-side fallbacks already exhausted + if (error instanceof Error && error.message.includes('Could not geocode')) return false; + // Don't retry rate limits + if (error instanceof Error && error.message.includes('Rate limited')) return false; + // Retry other transient errors up to 2 times + return failureCount < 2; + }, + retryDelay: 3000, + refetchInterval: CACHE_DURATION_MS, + refetchIntervalInBackground: false, + // Scraping can take up to 3 mins — don't kill stale queries early + staleTime: CACHE_DURATION_MS, + }); + + // Re-fetch at 45s and 120s to pick up price data from background menu scrapes. + // Background scrapes take 30-120s; two refetches catch both fast and slow completions. + useEffect(() => { + if (data && data.spots.length > 0) { + const hasMissingPrices = data.spots.some(s => s.price_per_wing == null && s.cheapest_item_price == null); + if (hasMissingPrices) { + const timer45 = setTimeout(() => refetch(), 45_000); + const timer120 = setTimeout(() => refetch(), 120_000); + return () => { + clearTimeout(timer45); + clearTimeout(timer120); + }; + } + } + }, [data, refetch]); + + const spots = data?.spots || []; + const stats = calculateAvailability(spots); + const locationName = data?.location ? `${data.location.city}, ${data.location.state}` : ''; + const hasResults = spots.length > 0; + const isSearching = isLoading || isFetching; + + const handleSearch = useCallback((zip: string) => { + sessionStorage.setItem(LAST_ZIP_KEY, zip); + setZipCode(zip); + }, []); + + const handleFlavorSelect = useCallback((f: FlavorPersona) => { + sessionStorage.setItem(LAST_FLAVOR_KEY, f); + setFlavor(f); + }, []); + + const coachSpeech = getCoachSpeech(flavor, isSearching, hasResults); + + return ( + setBannerDone(true)} + > +
    + {/* ===== Grass Field Background — the MAIN page bg behind dashboard ===== */} +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + + {/* Sunny washed-out overlay so UI is readable */} +
    +
    + + {/* ===== Command Jumbotron — bright header ===== */} + + + {/* ===== Hero Section — Coach Wing + Playbook ===== */} + {/* NO opaque wrapper — field shows through directly */} + + + {/* ===== Loading State — Trash Talk Ticker ===== */} + + {isSearching && ( + +
    + +
    +
    + )} +
    + + {/* ===== Results — Scouting Report (in frosted glass) ===== */} + + {(hasResults || isSearching) && ( + +
    + + + + Step 3: The Scouting Report + + + + + {!isSearching && spots.length === 0 && data?.message && ( + + ☀️ +

    {data.message}

    +

    + Coach Wing says: "Even the sun can't find wings here. Try another zip!" +

    +
    + )} +
    +
    + )} +
    + + {/* ===== Footer ===== */} +
    +
    +

    + SUPER BOWL LX: WING COMMAND · FEB 9, 2026 +

    +

    + Not affiliated with the NFL, but our wings hit harder. ☀️🏈 +

    +
    +
    + + {/* ===== Compare Mode ===== */} + setIsCompareOpen(true)} + onClear={clearCompare} + /> + compareIds.has(s.id))} + isOpen={isCompareOpen} + onClose={() => setIsCompareOpen(false)} + /> +
    + + ); +} + +// =========================================== +// Root Page Component +// =========================================== +export default function Home() { + return ( + + + + ); +} diff --git a/wing-command/components/AnimatedFieldBackground.tsx b/wing-command/components/AnimatedFieldBackground.tsx new file mode 100644 index 00000000..4757025f --- /dev/null +++ b/wing-command/components/AnimatedFieldBackground.tsx @@ -0,0 +1,255 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { motion } from 'framer-motion'; + +interface AnimatedFieldBackgroundProps { + isSearching?: boolean; +} + +/** Single floating football SVG */ +function Football({ size, initialX, initialY, duration, delay, opacity }: { + size: number; + initialX: number; + initialY: number; + duration: number; + delay: number; + opacity: number; +}) { + return ( + + + {/* Football body */} + + + {/* Laces */} + + + + + + + + ); +} + +export function AnimatedFieldBackground({ isSearching = false }: AnimatedFieldBackgroundProps) { + // Generate football data once + const footballs = useMemo(() => [ + { size: 50, initialX: 8, initialY: 15, duration: 12, delay: 0, opacity: 0.08 }, + { size: 35, initialX: 85, initialY: 25, duration: 10, delay: 1, opacity: 0.1 }, + { size: 60, initialX: 20, initialY: 70, duration: 14, delay: 2, opacity: 0.06 }, + { size: 40, initialX: 75, initialY: 60, duration: 11, delay: 0.5, opacity: 0.09 }, + { size: 30, initialX: 50, initialY: 10, duration: 9, delay: 1.5, opacity: 0.12 }, + { size: 45, initialX: 60, initialY: 80, duration: 13, delay: 3, opacity: 0.07 }, + { size: 55, initialX: 35, initialY: 45, duration: 15, delay: 2.5, opacity: 0.05 }, + ], []); + + const yardLineNumbers = ['10', '20', '30', '40', '50', '40', '30', '20', '10']; + + const speedMult = isSearching ? 0.6 : 1; + + return ( +
    + {/* Base stadium green tint */} +
    + + {/* Stadium lights — top corners */} + + + + {/* End zone tints */} +
    +
    + + {/* Animated yard lines — scrolling horizontally */} +
    + + {/* First set of yard lines */} +
    + {yardLineNumbers.map((num, i) => { + const leftPct = ((i + 1) / (yardLineNumbers.length + 1)) * 100; + return ( +
    + {/* Vertical line */} +
    + {/* Number */} + + {num} + +
    + ); + })} + {/* Horizontal hash marks */} + {[20, 40, 60, 80].map((topPct) => ( +
    + ))} +
    + + {/* Duplicate for seamless scroll */} +
    + {yardLineNumbers.map((num, i) => { + const leftPct = ((i + 1) / (yardLineNumbers.length + 1)) * 100; + return ( +
    +
    + + {num} + +
    + ); + })} + {[20, 40, 60, 80].map((topPct) => ( +
    + ))} +
    + +
    + + {/* Floating footballs */} + {footballs.map((fb, i) => ( + + ))} + + {/* Floating emojis — 🍗🔥🏈 bobbing around the field */} + {[ + { emoji: '🍗', x: 6, y: 20, size: 22, dur: 9, del: 0 }, + { emoji: '🏈', x: 88, y: 30, size: 26, dur: 11, del: 1 }, + { emoji: '🔥', x: 15, y: 75, size: 20, dur: 8, del: 2 }, + { emoji: '🍗', x: 75, y: 70, size: 24, dur: 10, del: 0.5 }, + { emoji: '🏈', x: 40, y: 5, size: 18, dur: 12, del: 1.5 }, + { emoji: '🔥', x: 92, y: 55, size: 20, dur: 9, del: 3 }, + { emoji: '🍗', x: 50, y: 90, size: 22, dur: 10, del: 2.5 }, + { emoji: '🏈', x: 25, y: 45, size: 16, dur: 13, del: 0.8 }, + ].map((e, i) => ( + + {e.emoji} + + ))} + + {/* Very subtle vignette */} +
    +
    + ); +} diff --git a/wing-command/components/BannerBreak.tsx b/wing-command/components/BannerBreak.tsx new file mode 100644 index 00000000..58f9240e --- /dev/null +++ b/wing-command/components/BannerBreak.tsx @@ -0,0 +1,367 @@ +'use client'; + +import React, { useState, useCallback, useMemo, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface BannerBreakProps { + /** Text on the banner before it shatters */ + text?: string; + /** Subtext below the main text */ + subtext?: string; + /** Called when shatter animation completes */ + onComplete?: () => void; + children?: React.ReactNode; +} + +// Jagged SVG tear mark component +function TearMark({ x, y, rotation }: { x: number; y: number; rotation: number }) { + return ( + + {/* Jagged tear crack lines */} + + + + + ); +} + +// Generate shard positions for the 4x4 grid +function generateShards(cols: number, rows: number) { + const shards: Array<{ + id: number; + col: number; + row: number; + exitX: number; + exitY: number; + exitRotate: number; + exitRotateY: number; + delay: number; + }> = []; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const centerCol = (cols - 1) / 2; + const centerRow = (rows - 1) / 2; + const dx = c - centerCol; + const dy = r - centerRow; + + shards.push({ + id: r * cols + c, + col: c, + row: r, + exitX: dx * (120 + Math.random() * 80) * (1 + Math.random()), + exitY: dy * (100 + Math.random() * 60) * (1 + Math.random()) + (Math.random() - 0.5) * 100, + exitRotate: (Math.random() - 0.5) * 120, + exitRotateY: (Math.random() - 0.5) * 90, + delay: Math.random() * 0.06, + }); + } + } + + return shards; +} + +export function BannerBreak({ + text = 'WING SCOUT', + subtext = 'SUPER BOWL LX EDITION', + onComplete, + children, +}: BannerBreakProps) { + const [hits, setHits] = useState(0); + const [phase, setPhase] = useState<'banner' | 'shattering' | 'done'>('banner'); + const [tearMarks, setTearMarks] = useState>([]); + const bannerRef = useRef(null); + + const COLS = 4; + const ROWS = 4; + const shards = useMemo(() => generateShards(COLS, ROWS), []); + + const handleBannerClick = useCallback((e: React.MouseEvent) => { + if (phase !== 'banner') return; + + const rect = bannerRef.current?.getBoundingClientRect(); + if (!rect) return; + + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + const nextHits = hits + 1; + + if (nextHits < 3) { + // Hits 1 & 2: shake + tear mark + setTearMarks(prev => [...prev, { + x: clickX, + y: clickY, + rotation: (Math.random() - 0.5) * 40, + }]); + setHits(nextHits); + } else { + // Hit 3: SHATTER + setHits(nextHits); + setPhase('shattering'); + + // Complete after shatter animation + setTimeout(() => { + setPhase('done'); + onComplete?.(); + }, 750); + } + }, [hits, phase, onComplete]); + + // Shake intensity based on hit count + const shakeVariants = { + idle: { x: 0, y: 0, rotate: 0 }, + hit1: { + x: [0, -8, 10, -6, 4, -2, 0], + y: [0, 4, -6, 3, -2, 0], + rotate: [0, -1, 1.5, -0.8, 0.4, 0], + transition: { duration: 0.5, ease: 'easeOut' }, + }, + hit2: { + x: [0, -14, 18, -12, 8, -4, 2, 0], + y: [0, 8, -10, 6, -4, 2, 0], + rotate: [0, -2, 3, -1.5, 0.8, -0.3, 0], + transition: { duration: 0.6, ease: 'easeOut' }, + }, + }; + + const getShakeKey = () => { + if (hits === 0) return 'idle'; + if (hits === 1) return 'hit1'; + return 'hit2'; + }; + + return ( +
    + {/* Content behind the banner */} + + {children} + + + {/* The Banner Overlay */} + + {phase !== 'done' && ( +
    + {/* Shatter mode: 4x4 grid of shards */} + {phase === 'shattering' ? ( +
    + {shards.map((shard) => { + const widthPct = 100 / COLS; + const heightPct = 100 / ROWS; + + return ( + + {/* Each shard clips the full banner content */} +
    + {/* Subtle paper texture */} +
    + + {/* Text inside shards */} +
    +

    + {text} +

    +

    + {subtext} +

    +
    +
    + + ); + })} +
    + ) : ( + /* Normal banner (pre-shatter) */ + + {/* Paper texture */} +
    + + {/* Decorative tape strips */} +
    +
    +
    +
    + + {/* Tear marks from previous hits */} + {tearMarks.map((mark, i) => ( + + ))} + + {/* Center content */} +
    + + {text} + + + {subtext} + + + {/* Click prompt */} + + + {hits === 0 && '👆 TAP TO BREAK THROUGH'} + {hits === 1 && '💥 HARDER! TAP AGAIN!'} + {hits === 2 && '🔥 ONE MORE HIT — BLITZ IT!'} + + + + {/* Hit counter */} + {hits > 0 && ( + + {[0, 1, 2].map((i) => ( + + ))} + + )} + + {/* Crack overlay as hits increase */} + {hits >= 2 && ( + + + + + + + )} +
    + + )} +
    + )} + +
    + ); +} diff --git a/wing-command/components/CoachHero.tsx b/wing-command/components/CoachHero.tsx new file mode 100644 index 00000000..e02e6132 --- /dev/null +++ b/wing-command/components/CoachHero.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React from 'react'; +import { motion } from 'framer-motion'; +import { CoachWingMascot } from '@/components/CoachWingMascot'; +import { FlavorTarot } from '@/components/FlavorTarot'; +import { CoinToss } from '@/components/CoinToss'; +import { PlaybookSearch } from '@/components/PlaybookSearch'; +import { FlavorPersona } from '@/lib/types'; + +interface CoachHeroProps { + flavor: FlavorPersona | null; + hasResults: boolean; + isSearching: boolean; + coachSpeech?: string; + bannerDone: boolean; + zipCode: string; + onFlavorSelect: (f: FlavorPersona) => void; + onSearch: (zip: string) => void; +} + +export function CoachHero({ + flavor, + hasResults, + isSearching, + coachSpeech, + bannerDone, + zipCode, + onFlavorSelect, + onSearch, +}: CoachHeroProps) { + return ( +
    + {/* ===== Left Side — Coach Wing Mascot (50%) ===== */} + + {/* Background decorations */} +
    + {/* Big faded "COACH" text — slightly more visible on field */} +
    + COACH +
    + {/* Decorative whistle icon-like circle */} +
    +
    +
    + + {/* Mascot area — transparent so field shows through */} +
    + + + {/* Decorative handwritten note */} + + “Trust the process” — Coach Wing + +
    + + + {/* ===== Right Side — Playbook Content (50%) — frosted glass so field peeks through ===== */} + + {/* Section Title */} +
    +

    + WING COMMAND +

    +

    + SUPER BOWL LX HEADQUARTERS +

    + + YOUR GAME DAY WING HQ + +
    + + {/* Step 1: Choose Your Play */} +
    + + Step 1: The Huddle + + +
    + + {/* Coin Toss */} + + + + + {/* Step 2: Call the Play */} +
    + + Step 2: Call the Play + + +
    + + {/* Prompt to pick flavor */} + {!flavor && zipCode.length === 5 && ( + + Pick your play above to start scouting! + + )} +
    +
    + ); +} diff --git a/wing-command/components/CoachWingMascot.tsx b/wing-command/components/CoachWingMascot.tsx new file mode 100644 index 00000000..c6699cbb --- /dev/null +++ b/wing-command/components/CoachWingMascot.tsx @@ -0,0 +1,448 @@ +'use client'; + +import React, { useRef, useEffect, useMemo } from 'react'; +import { motion, AnimatePresence, useMotionValue, useSpring, useTransform } from 'framer-motion'; +import Image from 'next/image'; +import { FlavorPersona } from '@/lib/types'; + +/** + * Mascot expression states — each maps to a unique illustration: + * - neutral: Serious/angry brows (landing state) + * - happy: Thumbs up, big grin (classicist / default after selection) + * - heat: Sweating, bloodshot eyes, steam (face-melter) + * - drool: Tongue out, dripping sauce (sticky-finger) + */ +type MascotState = 'neutral' | 'heat' | 'happy' | 'drool'; + +function getMascotState(flavor: FlavorPersona | null, hasResults: boolean, isSearching: boolean): MascotState { + if (flavor === 'face-melter') return 'heat'; + if (flavor === 'sticky-finger') return 'drool'; + if (flavor === 'classicist') return 'happy'; + if (hasResults) return 'happy'; + return 'neutral'; +} + +function getMascotImage(state: MascotState): string { + switch (state) { + case 'neutral': return '/coach-neutral.png'; + case 'heat': return '/coach-heat.png'; + case 'happy': return '/coach-happy.png'; + case 'drool': return '/coach-drool.png'; + } +} + +function getMascotLabel(state: MascotState): string | null { + switch (state) { + case 'heat': return '* sweating intensifies *'; + case 'drool': return '* drooling *'; + case 'happy': return '* let\'s gooo *'; + case 'neutral': return null; + } +} + +function getMascotLabelColor(state: MascotState): string { + switch (state) { + case 'heat': return 'text-red-500/60'; + case 'drool': return 'text-yellow-600/60'; + case 'happy': return 'text-white/60'; + default: return 'text-chalk-light/50'; + } +} + +function getGlowGradient(state: MascotState): string { + switch (state) { + case 'heat': return 'radial-gradient(circle, rgba(239,68,68,0.3), transparent 70%)'; + case 'happy': return 'radial-gradient(circle, rgba(22,163,74,0.25), transparent 70%)'; + case 'drool': return 'radial-gradient(circle, rgba(234,179,8,0.25), transparent 70%)'; + default: return 'radial-gradient(circle, rgba(107,114,128,0.1), transparent 70%)'; + } +} + +// ===== Fire/Steam Particles for Heat State ===== +function HeatParticles() { + const particles = useMemo(() => + Array.from({ length: 10 }, (_, i) => ({ + id: i, + x: 30 + Math.random() * 40, // Cluster around center + size: 4 + Math.random() * 8, + delay: Math.random() * 2, + duration: 1.5 + Math.random() * 1.5, + color: Math.random() > 0.5 ? '#EF4444' : '#F97316', + })), + []); + + return ( +
    + {particles.map((p) => ( + + ))} +
    + ); +} + +// ===== Sauce Drip + Splatter for Drool State ===== +function SauceDrip() { + const splatters = useMemo(() => + Array.from({ length: 5 }, (_, i) => ({ + id: i, + x: 25 + Math.random() * 50, + y: 40 + Math.random() * 40, + delay: 0.5 + Math.random() * 2, + size: 6 + Math.random() * 10, + })), + []); + + return ( +
    + {/* Animated SVG drip from mouth area */} + + + {/* Drip droplet at bottom */} + + + + {/* Sauce splatters */} + {splatters.map((s) => ( + + ))} +
    + ); +} + +// ===== Heat Haze Distortion Filter ===== +function HeatHazeOverlay() { + return ( +
    + {/* Inline SVG filter for heat haze distortion */} + + + + + + + + + + +
    +
    + ); +} + +interface CoachWingMascotProps { + flavor: FlavorPersona | null; + hasResults?: boolean; + isSearching?: boolean; + speechBubble?: string; +} + +export function CoachWingMascot({ flavor, hasResults = false, isSearching = false, speechBubble }: CoachWingMascotProps) { + const containerRef = useRef(null); + const mascotState = getMascotState(flavor, hasResults, isSearching); + const label = getMascotLabel(mascotState); + + // Subtle mouse-follow tilt + const mouseX = useMotionValue(0); + const mouseY = useMotionValue(0); + const springConfig = { damping: 30, stiffness: 100, mass: 0.5 }; + const smoothX = useSpring(mouseX, springConfig); + const smoothY = useSpring(mouseY, springConfig); + const tiltX = useTransform(smoothY, [-1, 1], [5, -5]); + const tiltY = useTransform(smoothX, [-1, 1], [-5, 5]); + + useEffect(() => { + function handleMouseMove(e: MouseEvent) { + const nx = (e.clientX / window.innerWidth) * 2 - 1; + const ny = (e.clientY / window.innerHeight) * 2 - 1; + mouseX.set(nx); + mouseY.set(ny); + } + window.addEventListener('mousemove', handleMouseMove); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, [mouseX, mouseY]); + + // CRANKED idle breathing — bigger float, more rotation + const breatheVariants = { + idle: { + y: [0, -14, 0], + rotate: [0, 2.5, -1.5, 0], + transition: { + duration: 4, + repeat: Infinity, + ease: 'easeInOut', + }, + }, + }; + + // CRANKED heat shake — more intense + const heatShake = { + x: [0, -4, 4, -4, 4, -2, 2, 0], + transition: { + duration: 0.4, + repeat: Infinity, + repeatDelay: 1, + }, + }; + + return ( +
    + {/* Speech bubble */} + + {speechBubble && ( + +

    {speechBubble}

    +
    + + )} + + + {/* Perspective wrapper */} +
    + {/* Heat haze overlay — renders behind/around mascot */} + + {mascotState === 'heat' && ( + + + + )} + + + {/* Heat particles */} + + {mascotState === 'heat' && ( + + + + )} + + + {/* Sauce drip + splatters */} + + {mascotState === 'drool' && ( + + + + )} + + + + {/* Glow effect behind mascot */} + + + {/* Ground shadow — soft elliptical shadow to anchor mascot to turf */} +
    + + {/* Animated mascot image swap */} + + + {/* Rim lighting — subtle white outer glow mimicking stadium floodlights */} +
    + {`Coach +
    + + {/* Atmospheric haze — very low opacity cool-tone overlay to match stadium atmosphere */} +
    + + + +
    + + {/* State label under mascot */} + + {label && ( + + + {label} + + + )} + +
    + ); +} diff --git a/wing-command/components/CoinToss.tsx b/wing-command/components/CoinToss.tsx new file mode 100644 index 00000000..e3b105b3 --- /dev/null +++ b/wing-command/components/CoinToss.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React, { useState, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { Shuffle } from 'lucide-react'; +import { FlavorPersona } from '@/lib/types'; + +interface CoinTossProps { + onResult: (flavor: FlavorPersona) => void; +} + +const FLAVORS: FlavorPersona[] = ['face-melter', 'classicist', 'sticky-finger']; +const FLAVOR_LABELS: Record = { + 'face-melter': 'HAIL MARY', + 'classicist': 'MILD PLAY', + 'sticky-finger': 'SAUCY PLAY', +}; +const FLAVOR_EMOJIS: Record = { + 'face-melter': '🔥', + 'classicist': '🛡️', + 'sticky-finger': '🍯', +}; + +export function CoinToss({ onResult }: CoinTossProps) { + const [isFlipping, setIsFlipping] = useState(false); + const [result, setResult] = useState(null); + + const handleFlip = useCallback(() => { + if (isFlipping) return; + setIsFlipping(true); + setResult(null); + + setTimeout(() => { + const picked = FLAVORS[Math.floor(Math.random() * FLAVORS.length)]; + setResult(picked); + setIsFlipping(false); + onResult(picked); + }, 1200); + }, [isFlipping, onResult]); + + return ( + + + {result ? ( + {FLAVOR_EMOJIS[result]} + ) : ( + 🪙 + )} + + + + {isFlipping + ? 'FLIPPING...' + : result + ? FLAVOR_LABELS[result] + : "CAN'T DECIDE? FLIP A COIN" + } + + + {!isFlipping && !result && ( + + )} + + ); +} diff --git a/wing-command/components/ComicHero.tsx b/wing-command/components/ComicHero.tsx new file mode 100644 index 00000000..3c4ff218 --- /dev/null +++ b/wing-command/components/ComicHero.tsx @@ -0,0 +1,219 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { motion } from 'framer-motion'; +import Image from 'next/image'; +import { FlavorPersona } from '@/lib/types'; + +interface Particle { + id: number; + x: number; + y: number; + size: number; + duration: number; + delay: number; + type: 'wing' | 'celery' | 'ranch' | 'football' | 'spark'; + emoji: string; + rotation: number; +} + +function generateParticles(count: number): Particle[] { + const emojis: Record = { + wing: ['🍗', '🍗', '🍗'], + celery: ['🥒', '🥬'], + ranch: ['💧', '🫗'], + football: ['🏈', '🏈'], + spark: ['💥', '⚡', '🔥', '✨'], + }; + + const types: Particle['type'][] = ['wing', 'wing', 'celery', 'ranch', 'football', 'spark', 'spark', 'wing']; + const particles: Particle[] = []; + + for (let i = 0; i < count; i++) { + const type = types[i % types.length]; + const emojiArr = emojis[type]; + particles.push({ + id: i, + x: Math.random() * 100, + y: Math.random() * 100, + size: type === 'wing' ? 28 + Math.random() * 18 : type === 'football' ? 22 + Math.random() * 10 : 14 + Math.random() * 10, + duration: 5 + Math.random() * 8, + delay: Math.random() * 4, + type, + emoji: emojiArr[Math.floor(Math.random() * emojiArr.length)], + rotation: Math.random() * 360, + }); + } + return particles; +} + +interface ComicHeroProps { + flavor: FlavorPersona | null; +} + +export function ComicHero({ flavor }: ComicHeroProps) { + const particles = useMemo(() => generateParticles(22), []); + const isHot = flavor === 'face-melter'; + + return ( +