diff --git a/wing-command/.eslintrc.json b/wing-command/.eslintrc.json new file mode 100644 index 0000000..3f431b2 --- /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 0000000..b0dafd1 --- /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 0000000..74a1e67 --- /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 0000000..9bd58cd --- /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 0000000..ad1e1ec --- /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 0000000..a5ec2cd --- /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 0000000..5a3d335 --- /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 0000000..92d6a0f Binary files /dev/null and b/wing-command/app/fonts/BebasNeue-latin.woff2 differ diff --git a/wing-command/app/fonts/Inter-latin.woff2 b/wing-command/app/fonts/Inter-latin.woff2 new file mode 100644 index 0000000..de83a9c Binary files /dev/null and b/wing-command/app/fonts/Inter-latin.woff2 differ diff --git a/wing-command/app/fonts/PermanentMarker-latin.woff2 b/wing-command/app/fonts/PermanentMarker-latin.woff2 new file mode 100644 index 0000000..203ccae Binary files /dev/null and b/wing-command/app/fonts/PermanentMarker-latin.woff2 differ diff --git a/wing-command/app/fonts/RussoOne-latin.woff2 b/wing-command/app/fonts/RussoOne-latin.woff2 new file mode 100644 index 0000000..31a08f7 Binary files /dev/null and b/wing-command/app/fonts/RussoOne-latin.woff2 differ diff --git a/wing-command/app/global-error.tsx b/wing-command/app/global-error.tsx new file mode 100644 index 0000000..d8f27c7 --- /dev/null +++ b/wing-command/app/global-error.tsx @@ -0,0 +1,87 @@ +'use client'; + +interface GlobalErrorProps { + error: Error & { digest?: string }; + reset: () => 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 0000000..72640f1 --- /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 0000000..a9dc0b8 --- /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 0000000..a8fcfc5 --- /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 0000000..c3d2ee5 --- /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 0000000..5d1308c --- /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 0000000..4757025 --- /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 0000000..58f9240 --- /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 0000000..e02e613 --- /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 0000000..c6699cb --- /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 0000000..e3b105b --- /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 0000000..3c4ff21 --- /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 ( +