diff --git a/wing-command/.env.example b/wing-command/.env.example index 7244fa3f..9db1c9db 100644 --- a/wing-command/.env.example +++ b/wing-command/.env.example @@ -1,17 +1,7 @@ -# =========================================== -# Wing Command — Environment Variables -# =========================================== +# Wing Command v4 — Environment Variables -# Supabase (required) — https://supabase.com/dashboard -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Google Gemini (required) — https://aistudio.google.com/apikey +GEMINI_API_KEY=your-gemini-api-key -# TinyFish Scraper (required) — https://docs.tinyfish.ai +# TinyFish (required) — https://agent.tinyfish.ai/api-keys TINYFISH_API_KEY=your-tinyfish-api-key -# TINYFISH_API_URL=https://agent.tinyfish.ai/v1/automation/run # sync (default) -# TINYFISH_API_URL=https://agent.tinyfish.ai/v1/automation/run-sse # SSE streaming - -# Upstash Redis (optional, recommended) — https://upstash.com -# UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io -# UPSTASH_REDIS_REST_TOKEN=your-redis-token diff --git a/wing-command/.gitignore b/wing-command/.gitignore index b0dafd1c..b3815ef7 100644 --- a/wing-command/.gitignore +++ b/wing-command/.gitignore @@ -1,51 +1,19 @@ -# Dependencies +# dependencies node_modules/ -.pnp -.pnp.js - -# Testing -coverage/ # Next.js .next/ out/ -# Production -build/ +# env files — never commit these +.env +.env.local +.env*.local -# Misc +# 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 index c52727b1..7d24ce3e 100644 --- a/wing-command/README.md +++ b/wing-command/README.md @@ -1,231 +1,86 @@ -# Wing Scout - Super Bowl LX War Room -**Live Demo: https://wings-command.up.railway.app/** +# Wing Command v4 -**Flavor-first, mesmerizing, hyper-local chicken wing tracker for Super Bowl LX (Feb 8, 2026).** +**Find the best chicken wings near you — powered by TinyFish + Gemini.** -A "War Room" interface that scouts the best chicken wings near you in real-time using AI-powered parallel scraping from DoorDash, Uber Eats, Grubhub, and Google. +Enter a zip code and flavor preference, and Wing Command dispatches parallel TinyFish browser agents across DoorDash, UberEats, Grubhub, Yelp, and Google — streaming live results back as each agent completes. -## Visual Identity +## Demo -- **Theme:** "Midnight Turf" - Dark #050505 background with subtle grass texture grid -- **Accents:** Neon Green (#39FF14) glows, stadium lighting effects -- **Typography:** Bebas Neue (scoreboard style) + Inter (body) -- **Cards:** Glassmorphism "Scout Cards" with backdrop blur -- **Animations:** Framer Motion parallax, floating particles, "tackle-in" card entrances +Live agents scrape real delivery platforms in parallel. Results stream in as each source finishes. -## Architecture +## How It Works ``` -Frontend (Next.js 14 App Router) - |-- page.tsx -> War Room hero + flavor selector + results grid - |-- HeroVisuals.tsx -> Parallax field lines + floating wing/confetti particles - |-- FlavorSelector.tsx -> 3 flavor personas with pulsing selection - |-- ZipSearch.tsx -> Stadium-light illuminated zip input - |-- WingGrid.tsx -> Glassmorphism Scout Cards grid - -Backend (API Route) - |-- /api/scout -> Main endpoint (zip + flavor) - |-- lib/tinyfish-scraper.ts -> TinyFish parallel scraping engine - |-- lib/geocode.ts -> Nominatim (OpenStreetMap) geocoding - |-- lib/cache.ts -> Upstash Redis caching layer - -Data - |-- Supabase (PostgreSQL + PostGIS) - |-- Upstash Redis (15-min TTL cache) +User enters zip code + flavor + ↓ +/api/discover — Gemini (gemini-2.0-flash) finds 5-7 relevant restaurant source URLs + ↓ +/api/scout — TinyFish agents scrape each source in parallel via SSE + ↓ +Results stream back to UI as each agent completes ``` -## Flavor Personas - -Users pick a team/flavor before searching: - -| Persona | Keywords | Emoji | -|---------|----------|-------| -| **The Face-Melter** | Habanero, Ghost Pepper, Carolina Reaper, Atomic | 🔥 | -| **The Classicist** | Buffalo, Hot, Mild, Traditional, Cayenne | 🦬 | -| **The Sticky Finger** | Honey BBQ, Garlic Parm, Teriyaki, Korean | 🍯 | - -Spots are scored 0-100 against the selected persona. +## TinyFish API Usage -## Scraping Flow - -1. User enters ZIP + selects Flavor Persona -2. **Geocode** via Nominatim (free, no API key) -3. **Parallel scrape** (`Promise.allSettled`): - - DoorDash search results - - Uber Eats search results - - Grubhub search results - - Google search (hidden gem detection) -4. **Deduplicate** by normalized name + address -5. **Score** against flavor persona keywords -6. **Cache** in Redis (15-min TTL) + persist to Supabase - -## Setup +```typescript +import { TinyFish, EventType, RunStatus } from '@tiny-fish/sdk'; -### Prerequisites +const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }); -- Node.js >= 18 -- Supabase project (free tier works) -- TinyFish API key -- Upstash Redis (optional but recommended) +const stream = await client.agent.stream({ url, goal }); -### Environment Variables - -Create `.env.local`: - -```env -# Required - Server -SUPABASE_SERVICE_ROLE_KEY=your_service_role_key -TINYFISH_API_KEY=your-tinyfish-api-key - -# Required - Client -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key - -# Optional - Caching (highly recommended) -UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io -UPSTASH_REDIS_REST_TOKEN=your_redis_token - -# Optional - Custom TinyFish endpoint -TINYFISH_API_URL=https://agent.tinyfish.ai/v1/automation/run +for await (const event of stream) { + if (event.type === EventType.COMPLETE) { + if (event.status === RunStatus.COMPLETED) { + // event.result contains extracted restaurant data + } + break; + } +} ``` -### Database Setup +Results are streamed back to the browser via SSE as each agent finishes. -Run the schema in your Supabase SQL editor: +## Architecture -```bash -# The schema file is at: -supabase/schema.sql +``` +Browser (Next.js) + ↓ POST /api/discover +Gemini — discovers restaurant source URLs for the zip code + ↓ GET /api/scout (SSE) +TinyFish Agents (parallel) — scrape each source + ↓ +DoorDash / UberEats / Grubhub / Yelp / Google + ↓ +Results stream back → TradingCardGrid renders live ``` -This creates: -- `wing_spots` table with `flavor_tags` (TEXT[]), `menu_json` (JSONB), and PostGIS spatial indexing -- `geocode_cache` table (permanent zip-to-lat/lng mapping) -- `scrape_queue` table (for background cron jobs) -- `menus` table (cached restaurant menus) -- PostGIS trigger for auto-computing `location` from `lat`/`lng` -- Row Level Security policies - -### Install & Run +## Setup ```bash +cd wing-command npm install +cp .env.example .env.local +# Fill in your API keys npm run dev ``` -Open http://localhost:3000 - -## Render.com Deployment (Migration from Vercel) - -### Why Render? - -Vercel serverless functions have a **60-second timeout** (300s on Hobby with Fluid Compute). Parallel scraping across 4 platforms can exceed this. Render Web Services have **no timeout limit**. - -### Render Setup - -1. **Create a Web Service** on Render - - Connect your GitHub repository - - **Build Command:** `npm install && npm run build` - - **Start Command:** `npm start` - - **Environment:** Node - - **Plan:** Starter ($7/mo) or Free (with limitations) - -2. **Environment Variables** - - Add all env vars from the `.env.local` section above - - Set `NODE_ENV=production` - -3. **Render Config (`render.yaml`)** - -```yaml -services: - - type: web - name: wing-scout - runtime: node - buildCommand: npm install && npm run build - startCommand: npm start - envVars: - - key: NODE_ENV - value: production - - key: NEXT_PUBLIC_SUPABASE_URL - sync: false - - key: NEXT_PUBLIC_SUPABASE_ANON_KEY - sync: false - - key: SUPABASE_SERVICE_ROLE_KEY - sync: false - - key: TINYFISH_API_KEY - sync: false - - key: UPSTASH_REDIS_REST_URL - sync: false - - key: UPSTASH_REDIS_REST_TOKEN - sync: false -``` - -4. **Next.js Standalone Output** - - `next.config.mjs` includes `output: 'standalone'` which is required for Render deployment - -### Cron Job (Optional) - -Set up a Render Cron Job to pre-populate the database: - -- **Schedule:** `0 */4 * * *` (every 4 hours) -- **Command:** `python scraper/scrape_wings.py` -- This pre-scrapes 150+ major US zip codes - -## Project Structure - -``` -wing-scout/ -├── app/ -│ ├── layout.tsx # Root layout (Bebas Neue + Inter fonts) -│ ├── page.tsx # War Room main page -│ ├── globals.css # Midnight Turf theme styles -│ ├── loading.tsx # Loading state -│ ├── error.tsx # Error boundary -│ └── api/ -│ ├── scout/route.ts # GET /api/scout?zip=xxxxx&flavor=classicist -│ └── menu/route.ts # GET /api/menu?spot_id=xxxxx -├── components/ -│ ├── HeroVisuals.tsx # Parallax + floating particles (Framer Motion) -│ ├── FlavorSelector.tsx # 3 flavor persona cards with pulse animation -│ ├── ZipSearch.tsx # Stadium-lit glowing zip input -│ ├── WingGrid.tsx # Scout Cards grid (glassmorphism) -│ └── ui/ # Reusable UI primitives -├── lib/ -│ ├── tinyfish-scraper.ts # TinyFish scraper (parallel, flavor-aware) -│ ├── types.ts # TypeScript definitions -│ ├── utils.ts # Flavor scoring, dedup, formatting -│ ├── supabase.ts # Database client -│ ├── cache.ts # Upstash Redis caching -│ ├── geocode.ts # Nominatim geocoding -│ ├── env.ts # Environment validation -│ └── menu.ts # Menu fetching -├── supabase/ -│ └── schema.sql # Full database schema -├── scraper/ -│ └── scrape_wings.py # Python cron pre-scraper -├── tailwind.config.ts # Midnight Turf theme -├── next.config.mjs # Standalone output for Render -└── package.json # framer-motion, lucide-react, etc. -``` +Open [http://localhost:3000](http://localhost:3000) -## Constraint Checklist +## Environment Variables -| Constraint | Status | -|-----------|--------| -| Google Places API used? | NO (OSM/Nominatim) | -| Yelp API used? | NO | -| Vercel deployment? | NO (Render.com) | -| UI "Mesmerizing"? | YES (Framer Motion, glassmorphism, neon glow) | -| Menu scraping parallel? | YES (`Promise.allSettled` across 4 sources) | -| Flavor persona filtering? | YES (keyword matching, 0-100 scoring) | +| Variable | Description | +|---|---| +| `TINYFISH_API_KEY` | TinyFish browser agents. Get yours at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | +| `GEMINI_API_KEY` | URL discovery via gemini-2.0-flash. Get yours at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) | ## Tech Stack -- **Framework:** Next.js 14 (App Router), TypeScript, Tailwind CSS -- **Animations:** Framer Motion -- **Icons:** Lucide React -- **Database:** Supabase (PostgreSQL + PostGIS) -- **Cache:** Upstash Redis -- **Scraper:** TinyFish -- **Geocoding:** Nominatim (OpenStreetMap) -- **Deployment:** Render.com (Web Service) +| Layer | Technology | +|---|---| +| Framework | Next.js 15 (App Router) | +| Language | TypeScript | +| Web scraping | TinyFish Agent API (`@tiny-fish/sdk`) | +| URL discovery | Google Gemini (`@google/generative-ai`) | +| Streaming | Server-Sent Events (SSE) | +| Styling | Tailwind CSS | diff --git a/wing-command/app/api/deals/route.ts b/wing-command/app/api/deals/route.ts deleted file mode 100644 index 9bd58cd7..00000000 --- a/wing-command/app/api/deals/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -// =========================================== -// 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 deleted file mode 100644 index 4f64b883..00000000 --- a/wing-command/app/api/menu/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -// =========================================== -// 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 TinyFish scraping 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 index 9c5d92a8..f0f925e6 100644 --- a/wing-command/app/api/scout/route.ts +++ b/wing-command/app/api/scout/route.ts @@ -1,440 +1,158 @@ 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 { GoogleGenerativeAI } from '@google/generative-ai'; +import { TinyFish, EventType, RunStatus } from '@tiny-fish/sdk'; import { geocodeZipCode } from '@/lib/geocode'; -import { scrapeAllSources } from '@/lib/tinyfish-scraper'; -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'; +import type { WingSpot, WingSource } from '@/lib/types'; -// Render.com: No timeout limit for Web Services (unlimited runtime) -// Setting Node.js runtime explicitly export const runtime = 'nodejs'; +export const maxDuration = 120; +export const dynamic = 'force-dynamic'; -// 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; +function deriveStatus(spot: Partial): 'green' | 'yellow' | 'red' { + if (!spot.isOpen) return 'red'; + if (spot.rating && spot.rating >= 4.0) return 'green'; + return 'yellow'; } -/** - * 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 - } - })(); - } +function parseSpots(raw: unknown[], source: string, siteName: string): WingSpot[] { + return (raw || []) + .map((r: unknown, i: number) => { + const item = r as Record; + const spot: WingSpot = { + id: `${source}-${Date.now()}-${i}`, + name: String(item.name || ''), + address: String(item.address || ''), + rating: item.rating ? Number(item.rating) : undefined, + deliveryTime: item.deliveryTime ? String(item.deliveryTime) : undefined, + deliveryFee: item.deliveryFee ? String(item.deliveryFee) : undefined, + isOpen: item.isOpen !== false, + imageUrl: item.imageUrl ? String(item.imageUrl) : undefined, + sourceUrl: item.sourceUrl ? String(item.sourceUrl) : undefined, + phone: item.phone ? String(item.phone) : undefined, + priceRange: item.priceRange ? String(item.priceRange) : undefined, + source: (source || 'google') as WingSource, + siteName, + status: 'yellow', + }; + spot.status = deriveStatus(spot); + return spot; + }) + .filter((s) => s.name && s.name.trim() !== ''); } -/** - * 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); - } - } +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const zip = searchParams.get('zip') || ''; + const flavor = searchParams.get('flavor') || ''; - // 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; + if (!zip || zip.length !== 5) { + return NextResponse.json({ success: false, spots: [], message: 'Invalid zip code.' }); + } - 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 - } - } + const encoder = new TextEncoder(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); - // 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 + const send = (data: object) => { + writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + // Run everything in the background — return the stream immediately + (async () => { 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'); - } + // Step 1 — geocode + const geo = await geocodeZipCode(zip); + if (!geo) { + send({ type: 'ERROR', message: `Could not geocode zip ${zip}` }); + writer.close(); + return; + } - // 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'}`); + const locationHint = `${geo.city}, ${geo.state}`; + send({ type: 'LOCATION', location: { city: geo.city, state: geo.state } }); - 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 } - ); - } + // Step 2 — Gemini discovers URLs (init inside handler) + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!); + const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); - // 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`); + const prompt = `Find 5-7 real URLs of pages listing chicken wing restaurants for ${locationHint}.${flavor ? ` User prefers "${flavor}" wings.` : ''} - 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', - }); - } +Return ONLY a JSON array: +[{ "url": "https://...", "siteName": "Site Name", "source": "doordash|ubereats|grubhub|google|yelp" }] - // 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`); - } +Rules: +- Real currently active search result pages (not homepages) +- Mix of DoorDash, UberEats, Grubhub city chicken-wings pages + a Google search URL +- DoorDash: https://www.doordash.com/food-delivery/CITY-STATE-restaurants/chicken-wings/ +- Google: https://www.google.com/search?q=best+chicken+wings+near+${zip} +- JSON only, no markdown`; - // 5. Save to Supabase - log('saving to Supabase...'); - await upsertWingSpots(supabase, scrapedSpots); - log('saved'); + let urls: { url: string; siteName: string; source: string }[] = []; + try { + const result = await model.generateContent(prompt); + const text = result.response.text() || '[]'; + const match = text.replace(/```json|```/g, '').trim().match(/\[[\s\S]*\]/); + if (match) urls = JSON.parse(match[0]); + } catch { urls = []; } - // 6. Cache results + estimate missing prices - log('caching results...'); - await cacheWingSpots(zipCode, scrapedSpots); - const estimatedSpots = estimateMissingPrices(await enrichSpotsWithPrices(scrapedSpots)); + if (!urls.length) { + send({ type: 'DONE', spots: [], message: 'No sources found.' }); + writer.close(); + return; + } - const result: ScoutResponse = { - success: true, - spots: estimatedSpots, - cached: false, - flavor, - message: `Found ${scrapedSpots.length} wing spots`, - location, - }; + send({ type: 'SOURCES', count: urls.length }); - await cacheScrapeResult(zipCode, result); - log(`DONE: ${scrapedSpots.length} spots in ${Date.now() - t0}ms`); + // Step 3 — TinyFish agents in parallel + const client = new TinyFish({ apiKey: process.env.TINYFISH_API_KEY }); - // 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`); + const goal = `Scout chicken wing restaurants in ${locationHint}.${flavor ? ` Flavor: "${flavor}".` : ''} - return NextResponse.json(result); +Extract all chicken wing restaurants visible on this page. +RULES: No scrolling beyond one scroll. No navigation. Up to 8 restaurants. Wings only. - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - log(`ERROR: ${errorMessage}`); - console.error('Scout API error:', errorMessage); +Return ONLY valid JSON: { "restaurants": [{ "name", "address", "rating", "deliveryTime", "deliveryFee", "isOpen", "imageUrl", "sourceUrl", "phone", "priceRange" }] }`; - // Fallback to stale data + const agentPromises = urls.map(async ({ url, siteName, source }) => { 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', - }); + const tfStream = await client.agent.stream({ url, goal }); + for await (const evt of tfStream) { + const e = evt as Record; + if (e.type === EventType.COMPLETE) { + if (e.status === RunStatus.COMPLETED) { + const result = e.result as Record | null; + let restaurants: unknown[] = []; + if (Array.isArray(result?.restaurants)) { + restaurants = result.restaurants; + } + const spots = parseSpots(restaurants, source, siteName); + if (spots.length > 0) send({ type: 'SPOTS', spots }); + } + break; } - } catch (fallbackError) { - console.error('Fallback error:', fallbackError instanceof Error ? fallbackError.message : 'Unknown'); + } + } catch (agentErr) { + send({ type: 'AGENT_ERROR', siteName, message: agentErr instanceof Error ? agentErr.message : 'Agent failed' }); } + }); + + await Promise.allSettled(agentPromises); + send({ type: 'DONE', message: `Scouted ${urls.length} sources near ${locationHint}` }); - return NextResponse.json( - { success: false, spots: [], cached: false, message: 'An error occurred while fetching data' }, - { status: 500 } - ); + } catch (err) { + send({ type: 'ERROR', message: err instanceof Error ? err.message : 'Search failed' }); + } finally { + writer.close(); } + })(); + + // Return immediately — stream stays open while background async runs + return new Response(readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); } diff --git a/wing-command/app/page.tsx b/wing-command/app/page.tsx index 5d1308c6..a9dbc462 100644 --- a/wing-command/app/page.tsx +++ b/wing-command/app/page.tsx @@ -1,7 +1,6 @@ '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'; @@ -11,31 +10,15 @@ 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 { FlavorPersona, AvailabilityStats } from '@/lib/types'; import { calculateAvailability } from '@/lib/utils'; +import { useWingSearch } from '@/hooks/useWingSearch'; 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.toUpperCase()}
)} -
-
{stats.green} @@ -72,9 +53,7 @@ function StatsBar({ stats, locationName }: { stats: AvailabilityStats; locationN {stats.red} CLOSED
-
-
{stats.total} TOTAL @@ -84,12 +63,9 @@ function StatsBar({ stats, locationName }: { stats: AvailabilityStats; locationN ); } -// =========================================== -// 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 (isSearching) return "Scouting the hottest spots... this sunshine ain't helping! 🔥"; if (hasResults) return "Now THAT'S a roster! Pick your starter."; return "You chose violence. On a sunny day. Bold."; } @@ -99,7 +75,7 @@ function getCoachSpeech(flavor: FlavorPersona | null, isSearching: boolean, hasR return "Smart play. The classics never miss."; } if (flavor === 'sticky-finger') { - if (isSearching) return "Tracking down the sauciest spots... \uD83E\uDD24"; + if (isSearching) return "Tracking down the sauciest spots... 🤤"; if (hasResults) return "Now THAT'S a roster! Pick your starter."; return "Napkins? Where we're going, we don't need napkins."; } @@ -107,109 +83,59 @@ function getCoachSpeech(flavor: FlavorPersona | null, isSearching: boolean, hasR 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); + // Use the streaming hook instead of React Query + const { spots, isSearching, isDone, location, message, search, reset } = useWingSearch(); + 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); - } + if (next.has(id)) next.delete(id); + else if (next.size < 4) next.add(id); return next; }); }, []); - const clearCompare = useCallback(() => { - setCompareIds(new Set()); - }, []); + const clearCompare = useCallback(() => setCompareIds(new Set()), []); + + // Restore last search from session storage 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. + // Kick off search when both zip and flavor are set 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); - }; - } + if (zipCode.length === 5 && flavor) { + search(zipCode, flavor); } - }, [data, refetch]); + }, [zipCode, flavor, search]); - const spots = data?.spots || []; const stats = calculateAvailability(spots); - const locationName = data?.location ? `${data.location.city}, ${data.location.state}` : ''; + const locationName = location ? `${location.city}, ${location.state}` : ''; const hasResults = spots.length > 0; - const isSearching = isLoading || isFetching; + const coachSpeech = getCoachSpeech(flavor, isSearching, hasResults); const handleSearch = useCallback((zip: string) => { sessionStorage.setItem(LAST_ZIP_KEY, zip); setZipCode(zip); + setCompareIds(new Set()); }, []); const handleFlavorSelect = useCallback((f: FlavorPersona) => { sessionStorage.setItem(LAST_FLAVOR_KEY, f); setFlavor(f); + setCompareIds(new Set()); }, []); - 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 ===== */} + {/* Loading ticker */} {isSearching && ( - {/* ===== Results — Scouting Report (in frosted glass) ===== */} + {/* Results — shown as soon as ANY agent returns spots */} {(hasResults || isSearching) && ( + {isSearching && hasResults && ( + + 🔍 {spots.length} spot{spots.length !== 1 ? 's' : ''} found so far — more loading... + + )} + - {!isSearching && spots.length === 0 && data?.message && ( + {isDone && spots.length === 0 && message && ( ☀️ -

{data.message}

+

{message}

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

@@ -315,7 +243,6 @@ function WingCommandContent() { )}
- {/* ===== Footer ===== */}
- {/* ===== Compare Mode ===== */} setIsCompareOpen(true)} @@ -346,13 +272,6 @@ function WingCommandContent() { ); } -// =========================================== -// Root Page Component -// =========================================== export default function Home() { - return ( - - - - ); + return ; } diff --git a/wing-command/components/AnimatedFieldBackground.tsx b/wing-command/components/AnimatedFieldBackground.tsx deleted file mode 100644 index 4757025f..00000000 --- a/wing-command/components/AnimatedFieldBackground.tsx +++ /dev/null @@ -1,255 +0,0 @@ -'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 deleted file mode 100644 index 58f9240e..00000000 --- a/wing-command/components/BannerBreak.tsx +++ /dev/null @@ -1,367 +0,0 @@ -'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/ComicHero.tsx b/wing-command/components/ComicHero.tsx deleted file mode 100644 index 3c4ff218..00000000 --- a/wing-command/components/ComicHero.tsx +++ /dev/null @@ -1,219 +0,0 @@ -'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 ( -