diff --git a/lego-hunter/.gitignore b/lego-hunter/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/lego-hunter/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/lego-hunter/75339b8c-4e68-490d-89cf-96c62334598a.jpg b/lego-hunter/75339b8c-4e68-490d-89cf-96c62334598a.jpg new file mode 100644 index 0000000..5fb579a Binary files /dev/null and b/lego-hunter/75339b8c-4e68-490d-89cf-96c62334598a.jpg differ diff --git a/lego-hunter/README.md b/lego-hunter/README.md new file mode 100644 index 0000000..1b0d7ee --- /dev/null +++ b/lego-hunter/README.md @@ -0,0 +1,135 @@ +# Lego Restock Hunter - Global Inventory Finder + +## Demo + +![lego-hunter Demo](./75339b8c-4e68-490d-89cf-96c62334598a.jpg) + +**Live Demo:** https://lego-hunter.vercel.app/ + +The Lego Restock Hunter is a powerful inventory search tool designed to find rare or sold-out Lego sets across 15+ global retailers simultaneously. It uses AI to discover the best retailers for a specific set, deploys parallel Mino browser agents to check stock and pricing, and finishes with a Gemini-powered analysis to recommend the single best deal (balancing price and shipping). + +--- + +--- + +## Demo + +*[Demo video/screenshot to be added]* + +--- + +## How Mino API is Used + +The Mino API powers browser automation for this use case. See the code snippet below for implementation details. + +### Code Snippet + +```bash +curl -N -X POST "https://mino.ai/v1/automation/run-sse" \ + -H "X-API-Key: $MINO_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://www.lego.com/en-us/search?q=75192", + "goal": "Search for Millennium Falcon Lego set. Extract inStock, price, and shipping. Return JSON.", + "browser_profile": "lite" + }' +``` + +--- + +## How to Run + +### Prerequisites + +- Node.js 18+ +- Mino API key (get from [mino.ai](https://mino.ai)) + +### Setup + +1. Clone the repository: +```bash +git clone +cd lego-hunter +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Create `.env.local` file: +```bash +# Add your environment variables here +MINO_API_KEY=sk-mino-... +``` + +4. Run the development server: +```bash +npm run dev +``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser + +--- + +## Architecture Diagram + +```mermaid +graph TD + subgraph Frontend [Next.js Client] + UI[User Interface - Lego Brick Style] + State[Retailer Status & Best Deal] + end + + subgraph Backend [Next.js API Routes] + UrlGen[/api/generate-urls] + Search[/api/search-lego] + end + + subgraph External_APIs [External Services] + Gemini[Gemini 2.0 - URL Gen & Analysis] + Mino[Mino API - Browser Automation] + end + + %% User Interactions + UI -->|Lego Set Name| UrlGen + UrlGen -->|AI Discovery| Gemini + + %% Scrape Phase + UrlGen -->|Return 15 URLs| UI + UI -->|Trigger Parallel Scrape| Search + + Search -->|Deploy 15 Agents| Mino + Mino --.->|SSE Streams| UI + Mino --.->|Product JSON| Search + + %% Final Analysis + Search -->|Analyze All Deals| Gemini + Gemini -->|Best Retailer Recommendation| Search + Search --.->|Final Best Deal Event| UI +``` + +```mermaid +sequenceDiagram + participant U as User + participant S as API (/api/search-lego) + participant G as Gemini (AI) + participant M as Mino (15 Parallel Agents) + + U->>G: Discover Retailers for "Millennium Falcon" + G-->>U: List of 15 Shop URLs + + U->>S: POST Search (Set Name + 15 URLs) + + par Retailer 1 to 15 (Amazon, Walmart, Lego.com, etc.) + S->>M: Scrape Retailer (Goal: Find Stock/Price) + M-->>U: SSE: Progress Step + M-->>S: JSON Result (inStock, price, shipping) + end + + S->>G: Analyze All Results + G-->>S: Best Deal Recommendation + S->>U: Final Trophy Notification (Confetti Trigger) +``` + + diff --git a/lego-hunter/app/api/generate-urls/route.ts b/lego-hunter/app/api/generate-urls/route.ts new file mode 100644 index 0000000..2111660 --- /dev/null +++ b/lego-hunter/app/api/generate-urls/route.ts @@ -0,0 +1,22 @@ +import { generateRetailerUrls } from '@/lib/gemini-client' +import type { GenerateUrlsRequest } from '@/types' + +export async function POST(request: Request) { + try { + const body: GenerateUrlsRequest = await request.json() + + if (!body.legoSetName) { + return Response.json({ error: 'legoSetName is required' }, { status: 400 }) + } + + const retailers = await generateRetailerUrls(body.legoSetName) + + return Response.json({ retailers }) + } catch (error) { + console.error('Error generating URLs:', error) + return Response.json( + { error: 'Failed to generate retailer URLs' }, + { status: 500 } + ) + } +} diff --git a/lego-hunter/app/api/search-lego/route.ts b/lego-hunter/app/api/search-lego/route.ts new file mode 100644 index 0000000..417f046 --- /dev/null +++ b/lego-hunter/app/api/search-lego/route.ts @@ -0,0 +1,282 @@ +import { analyzeBestDeal } from '@/lib/gemini-client' +import type { Retailer, ProductData, SSEEvent, MinoSSEEvent } from '@/types' + +interface SearchLegoRequest { + legoSetName: string + maxBudget: number + retailers: Retailer[] +} + +export async function POST(request: Request) { + const body: SearchLegoRequest = await request.json() + const { legoSetName, maxBudget, retailers } = body + + if (!legoSetName || !retailers || retailers.length === 0) { + return Response.json( + { error: 'legoSetName and retailers are required' }, + { status: 400 } + ) + } + + // Create a TransformStream for SSE + const encoder = new TextEncoder() + const stream = new TransformStream() + const writer = stream.writable.getWriter() + + // Helper to send SSE events + const sendEvent = async (event: SSEEvent) => { + const data = `data: ${JSON.stringify({ ...event, timestamp: Date.now() })}\n\n` + await writer.write(encoder.encode(data)) + } + + // Start processing in background + processRetailers(retailers, legoSetName, maxBudget, sendEvent, writer) + + return new Response(stream.readable, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }) +} + +async function processRetailers( + retailers: Retailer[], + legoSetName: string, + maxBudget: number, + sendEvent: (event: SSEEvent) => Promise, + writer: WritableStreamDefaultWriter +) { + const results: ProductData[] = [] + + try { + // Launch all scraping tasks in parallel + const scrapePromises = retailers.map(retailer => + scrapeRetailer(retailer, legoSetName, sendEvent) + .then(data => { + if (data) { + results.push(data) + } + return data + }) + .catch(async error => { + console.error(`Error scraping ${retailer.name}:`, error) + await sendEvent({ + type: 'retailer_error', + retailer: retailer.name, + error: error.message || 'Scraping failed' + }) + return null + }) + ) + + // Wait for all scraping to complete + await Promise.allSettled(scrapePromises) + + // Analyze results with Gemini if we have any + if (results.length > 0) { + try { + const bestDeal = await analyzeBestDeal(legoSetName, maxBudget, results) + await sendEvent({ + type: 'analysis_complete', + bestDeal + }) + } catch (error) { + console.error('Error analyzing deals:', error) + await sendEvent({ + type: 'error', + error: 'Failed to analyze deals' + }) + } + } else { + await sendEvent({ + type: 'analysis_complete', + bestDeal: { + bestRetailer: 'None', + reason: 'No retailers returned results. Please try again.', + totalCost: 'N/A', + savings: 'N/A' + } + }) + } + } catch (error) { + console.error('Error processing retailers:', error) + await sendEvent({ + type: 'error', + error: 'Failed to process retailers' + }) + } finally { + await writer.close() + } +} + +async function scrapeRetailer( + retailer: Retailer, + legoSetName: string, + sendEvent: (event: SSEEvent) => Promise +): Promise { + const MINO_API_KEY = process.env.MINO_API_KEY + + if (!MINO_API_KEY) { + throw new Error('MINO_API_KEY not configured') + } + + // Send start event + await sendEvent({ + type: 'retailer_start', + retailer: retailer.name + }) + + const minoResponse = await fetch('https://mino.ai/v1/automation/run-sse', { + method: 'POST', + headers: { + 'X-API-Key': MINO_API_KEY, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + url: retailer.url, + goal: `Search for "${legoSetName}" Lego set on this retailer website and extract product information. + +Your task: +1. Look for the Lego set on this page (it may be a search results page) +2. Find the specific product that matches "${legoSetName}" +3. Extract the following information: + +Return the result as JSON with these exact fields: +{ + "inStock": true or false (whether the product is available to purchase), + "price": "99.99" (just the number, no currency symbol), + "currency": "USD" (or appropriate currency), + "shipping": "Free shipping" or "Shipping: $X.XX" or "Check website for shipping", + "productUrl": "full URL to the product page if found, otherwise the search page URL" +} + +If the product is not found on this page, return: +{ + "inStock": false, + "price": "0", + "currency": "USD", + "shipping": "N/A", + "productUrl": "${retailer.url}" +} + +Important: Return ONLY the JSON object, no additional text.`, + browser_profile: 'lite' + }) + }) + + if (!minoResponse.ok) { + throw new Error(`Mino API error: ${minoResponse.status}`) + } + + const reader = minoResponse.body?.getReader() + if (!reader) { + throw new Error('No response body from Mino') + } + + const decoder = new TextDecoder() + let buffer = '' + let streamingUrl: string | undefined + let finalResult: ProductData | null = null + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + + try { + const minoEvent: MinoSSEEvent = JSON.parse(line.slice(6)) + + // Capture streaming URL for browser preview + if (minoEvent.streamingUrl && !streamingUrl) { + streamingUrl = minoEvent.streamingUrl + await sendEvent({ + type: 'retailer_start', + retailer: retailer.name, + streamingUrl + }) + } + + // Forward step events for progress updates + if (minoEvent.type === 'STEP') { + await sendEvent({ + type: 'retailer_step', + retailer: retailer.name, + step: minoEvent.step || minoEvent.message || 'Processing...' + }) + } + + // Handle completion + if (minoEvent.type === 'COMPLETE' && minoEvent.status === 'COMPLETED') { + let resultData = minoEvent.resultJson + + // Try to parse if it's a string + if (typeof resultData === 'string') { + try { + resultData = JSON.parse(resultData) + } catch { + // If parsing fails, create default result + resultData = { + retailer: retailer.name, + inStock: false, + price: '0', + currency: 'USD', + shipping: 'N/A', + productUrl: retailer.url + } + } + } + + finalResult = { + retailer: retailer.name, + inStock: resultData?.inStock ?? false, + price: String(resultData?.price ?? '0'), + currency: resultData?.currency ?? 'USD', + shipping: resultData?.shipping ?? 'N/A', + productUrl: resultData?.productUrl ?? retailer.url + } + + // Send stock found event if in stock + if (finalResult.inStock) { + await sendEvent({ + type: 'retailer_stock_found', + retailer: retailer.name + }) + } + + // Send completion event + await sendEvent({ + type: 'retailer_complete', + retailer: retailer.name, + data: finalResult + }) + + break + } + + // Handle errors from Mino + if (minoEvent.type === 'ERROR' || minoEvent.status === 'FAILED') { + throw new Error(minoEvent.message || 'Mino scraping failed') + } + } catch (parseError) { + // Ignore parse errors for individual events + console.warn('Failed to parse Mino event:', parseError) + } + } + + if (finalResult) break + } + } finally { + reader.releaseLock() + } + + return finalResult +} diff --git a/lego-hunter/app/favicon.ico b/lego-hunter/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/lego-hunter/app/favicon.ico differ diff --git a/lego-hunter/app/globals.css b/lego-hunter/app/globals.css new file mode 100644 index 0000000..7edabc2 --- /dev/null +++ b/lego-hunter/app/globals.css @@ -0,0 +1,912 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* Lego Color Palette - Refined */ +:root { + /* Core Lego colors */ + --lego-yellow: #FFCF00; + --lego-yellow-light: #FFE566; + --lego-yellow-dark: #D4AC00; + --lego-red: #E4002B; + --lego-red-dark: #B8001F; + --lego-blue: #0055BF; + --lego-blue-light: #1A6FCF; + --lego-blue-dark: #003D8F; + --lego-green: #00852B; + --lego-orange: #FE5000; + + /* Neutrals */ + --lego-black: #0D0D0D; + --lego-white: #FFFFFF; + --lego-cream: #FAFAF8; + --lego-gray-100: #F5F5F4; + --lego-gray-200: #E8E8E6; + --lego-gray-300: #D4D4D2; + --lego-gray-400: #A3A3A1; + --lego-gray-500: #737371; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-body); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); +} + +:root { + --radius: 0.625rem; + --background: var(--lego-cream); + --foreground: var(--lego-black); + --card: var(--lego-white); + --card-foreground: var(--lego-black); + --popover: var(--lego-white); + --popover-foreground: var(--lego-black); + --primary: var(--lego-blue); + --primary-foreground: var(--lego-white); + --secondary: var(--lego-gray-100); + --secondary-foreground: var(--lego-black); + --muted: var(--lego-gray-100); + --muted-foreground: var(--lego-gray-500); + --accent: var(--lego-yellow); + --accent-foreground: var(--lego-black); + --destructive: var(--lego-red); + --border: var(--lego-gray-200); + --input: var(--lego-gray-200); + --ring: var(--lego-blue); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground antialiased; + font-family: var(--font-body); + } + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + } +} + +/* ============================================ + LEGO 3D BRICK EFFECTS + ============================================ */ + +/* Primary CTA Button - Yellow Brick */ +.brick-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.875rem 2rem; + font-family: var(--font-display); + font-weight: 800; + font-size: 0.9375rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--lego-black); + background: linear-gradient(180deg, var(--lego-yellow-light) 0%, var(--lego-yellow) 50%, var(--lego-yellow-dark) 100%); + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 0 0 #B89B00, + 0 6px 8px -2px rgba(0, 0, 0, 0.2), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); +} + +.brick-button:hover { + transform: translateY(-2px); + box-shadow: + 0 6px 0 0 #B89B00, + 0 10px 16px -4px rgba(0, 0, 0, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); +} + +.brick-button:active { + transform: translateY(2px); + box-shadow: + 0 2px 0 0 #B89B00, + 0 3px 4px -1px rgba(0, 0, 0, 0.15), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); +} + +.brick-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +/* Red Brick Button */ +.brick-button-red { + color: white; + background: linear-gradient(180deg, #FF1A40 0%, var(--lego-red) 50%, var(--lego-red-dark) 100%); + box-shadow: + 0 4px 0 0 #8C0015, + 0 6px 8px -2px rgba(0, 0, 0, 0.2), + inset 0 1px 0 0 rgba(255, 255, 255, 0.3); +} + +.brick-button-red:hover { + box-shadow: + 0 6px 0 0 #8C0015, + 0 10px 16px -4px rgba(0, 0, 0, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.3); +} + +/* Blue Brick Button */ +.brick-button-blue { + color: white; + background: linear-gradient(180deg, var(--lego-blue-light) 0%, var(--lego-blue) 50%, var(--lego-blue-dark) 100%); + box-shadow: + 0 4px 0 0 #002D66, + 0 6px 8px -2px rgba(0, 0, 0, 0.2), + inset 0 1px 0 0 rgba(255, 255, 255, 0.25); +} + +.brick-button-blue:hover { + box-shadow: + 0 6px 0 0 #002D66, + 0 10px 16px -4px rgba(0, 0, 0, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.25); +} + +/* ============================================ + CARD STYLES - LEGO BOX AESTHETIC + ============================================ */ + +.brick-card { + position: relative; + background: var(--lego-white); + border-radius: 12px; + overflow: hidden; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.08), + 0 4px 12px rgba(0, 0, 0, 0.04); +} + +.brick-card:hover { + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.1), + 0 8px 24px rgba(0, 0, 0, 0.06); +} + +/* Card with colored top border */ +.brick-card-accent { + border-top: 4px solid var(--lego-blue); +} + +.brick-card-accent-yellow { + border-top: 4px solid var(--lego-yellow); +} + +.brick-card-accent-red { + border-top: 4px solid var(--lego-red); +} + +.brick-card-accent-green { + border-top: 4px solid var(--lego-green); +} + +/* Interactive card */ +.brick-card-interactive { + cursor: pointer; +} + +.brick-card-interactive:hover { + transform: translateY(-2px); +} + +/* ============================================ + BRICK STUD DECORATIONS + ============================================ */ + +.brick-stud { + width: 12px; + height: 12px; + border-radius: 50%; + background: linear-gradient(145deg, rgba(255,255,255,0.5) 0%, transparent 60%); + box-shadow: + inset 0 -2px 3px rgba(0, 0, 0, 0.15), + 0 1px 1px rgba(0, 0, 0, 0.1); +} + +.brick-stud-yellow { + background: linear-gradient(145deg, var(--lego-yellow-light) 0%, var(--lego-yellow) 100%); + box-shadow: + inset 0 -2px 3px rgba(0, 0, 0, 0.2), + 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.brick-stud-red { + background: linear-gradient(145deg, #FF4D6A 0%, var(--lego-red) 100%); +} + +.brick-stud-blue { + background: linear-gradient(145deg, var(--lego-blue-light) 0%, var(--lego-blue) 100%); +} + +/* Stud row decoration */ +.brick-studs-row { + display: flex; + gap: 8px; +} + +/* ============================================ + INPUT STYLES + ============================================ */ + +.brick-input { + width: 100%; + padding: 0.875rem 1rem; + font-family: var(--font-body); + font-size: 1rem; + font-weight: 500; + color: var(--lego-black); + background: var(--lego-white); + border: 2px solid var(--lego-gray-200); + border-radius: 8px; + transition: all 0.2s ease; +} + +.brick-input::placeholder { + color: var(--lego-gray-400); + font-weight: 400; +} + +.brick-input:hover { + border-color: var(--lego-gray-300); +} + +.brick-input:focus { + outline: none; + border-color: var(--lego-blue); + box-shadow: 0 0 0 3px rgba(0, 85, 191, 0.15); +} + +.brick-input:disabled { + background: var(--lego-gray-100); + cursor: not-allowed; +} + +/* ============================================ + ANIMATIONS + ============================================ */ + +@keyframes brick-stack { + 0%, 100% { transform: translateY(0); } + 25% { transform: translateY(-6px); } + 50% { transform: translateY(-2px); } + 75% { transform: translateY(-8px); } +} + +@keyframes brick-click { + 0% { transform: scale(1) translateY(0); } + 50% { transform: scale(0.95) translateY(2px); } + 100% { transform: scale(1) translateY(0); } +} + +@keyframes glow-yellow { + 0%, 100% { box-shadow: 0 0 20px rgba(255, 207, 0, 0.3); } + 50% { box-shadow: 0 0 35px rgba(255, 207, 0, 0.6); } +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Animation utilities */ +.animate-brick-stack { + animation: brick-stack 1s ease-in-out infinite; +} + +.animate-brick-click { + animation: brick-click 0.3s ease-out; +} + +.animate-glow-yellow { + animation: glow-yellow 2s ease-in-out infinite; +} + +.animate-fade-up { + animation: fade-up 0.4s ease-out forwards; +} + +.animate-scale-in { + animation: scale-in 0.3s ease-out forwards; +} + +/* Staggered animations */ +.stagger-1 { animation-delay: 0.05s; } +.stagger-2 { animation-delay: 0.1s; } +.stagger-3 { animation-delay: 0.15s; } +.stagger-4 { animation-delay: 0.2s; } +.stagger-5 { animation-delay: 0.25s; } + +/* ============================================ + STATUS INDICATORS + ============================================ */ + +.status-searching { + border-left: 4px solid var(--lego-orange); +} + +.status-complete { + border-left: 4px solid var(--lego-green); +} + +.status-error { + border-left: 4px solid var(--lego-red); +} + +.status-idle { + border-left: 4px solid var(--lego-gray-300); +} + +.status-stock-found { + border-left: 4px solid var(--lego-yellow); + animation: glow-yellow 2s ease-in-out infinite; +} + +/* ============================================ + PROGRESS BAR + ============================================ */ + +.brick-progress { + height: 8px; + background: var(--lego-gray-200); + border-radius: 4px; + overflow: hidden; +} + +.brick-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--lego-yellow) 0%, var(--lego-yellow-light) 50%, var(--lego-yellow) 100%); + background-size: 200% 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.brick-progress-bar.loading { + animation: shimmer 1.5s infinite; +} + +/* ============================================ + DECORATIVE ELEMENTS + ============================================ */ + +/* Subtle brick pattern for backgrounds */ +.brick-pattern-subtle { + background-image: + radial-gradient(circle at 20px 20px, rgba(0, 0, 0, 0.02) 2px, transparent 2px); + background-size: 40px 40px; +} + +/* Colored accent stripe */ +.accent-stripe { + position: relative; +} + +.accent-stripe::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, + var(--lego-red) 0%, var(--lego-red) 20%, + var(--lego-yellow) 20%, var(--lego-yellow) 40%, + var(--lego-blue) 40%, var(--lego-blue) 60%, + var(--lego-green) 60%, var(--lego-green) 80%, + var(--lego-orange) 80%, var(--lego-orange) 100% + ); +} + +/* ============================================ + TABLE STYLES + ============================================ */ + +.brick-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.brick-table th { + padding: 0.875rem 1rem; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--lego-gray-500); + background: var(--lego-gray-100); + border-bottom: 2px solid var(--lego-gray-200); + text-align: left; +} + +.brick-table th:first-child { + border-top-left-radius: 8px; +} + +.brick-table th:last-child { + border-top-right-radius: 8px; +} + +.brick-table td { + padding: 1rem; + border-bottom: 1px solid var(--lego-gray-200); + font-size: 0.9375rem; +} + +.brick-table tr:last-child td { + border-bottom: none; +} + +.brick-table tr:last-child td:first-child { + border-bottom-left-radius: 8px; +} + +.brick-table tr:last-child td:last-child { + border-bottom-right-radius: 8px; +} + +.brick-table tbody tr { + transition: background-color 0.15s ease; +} + +.brick-table tbody tr:hover { + background: var(--lego-gray-100); +} + +/* Out of stock row */ +.brick-table tr.out-of-stock { + opacity: 0.5; +} + +.brick-table tr.out-of-stock:hover { + opacity: 0.7; +} + +/* ============================================ + TYPOGRAPHY UTILITIES + ============================================ */ + +.text-display { + font-family: var(--font-display); +} + +.text-body { + font-family: var(--font-body); +} + +/* ============================================ + BADGE STYLES + ============================================ */ + +.brick-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + font-family: var(--font-display); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + border-radius: 4px; +} + +.brick-badge-green { + color: #065F46; + background: #D1FAE5; +} + +.brick-badge-red { + color: #991B1B; + background: #FEE2E2; +} + +.brick-badge-yellow { + color: #92400E; + background: #FEF3C7; +} + +.brick-badge-blue { + color: #1E40AF; + background: #DBEAFE; +} + +.brick-badge-orange { + color: #9A3412; + background: #FFEDD5; +} + +/* ============================================ + LOADING SKELETON + ============================================ */ + +.brick-skeleton { + background: linear-gradient(90deg, + var(--lego-gray-200) 0%, + var(--lego-gray-100) 50%, + var(--lego-gray-200) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; +} + +/* ============================================ + LEGO BRICK RETAILER CARDS + ============================================ */ + +/* Base Lego Brick Structure */ +.lego-brick-blue, +.lego-brick-red, +.lego-brick-yellow, +.lego-brick-green, +.lego-brick-orange, +.lego-brick-gray { + position: relative; + border-radius: 8px; + overflow: visible; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.lego-brick-blue:hover, +.lego-brick-red:hover, +.lego-brick-yellow:hover, +.lego-brick-green:hover, +.lego-brick-orange:hover, +.lego-brick-gray:hover { + transform: translateY(-4px); +} + +/* Blue Brick */ +.lego-brick-blue { + background: linear-gradient(180deg, var(--lego-blue-light) 0%, var(--lego-blue) 50%, var(--lego-blue-dark) 100%); + box-shadow: + 0 6px 0 0 #002D66, + 0 8px 16px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-blue:hover { + box-shadow: + 0 8px 0 0 #002D66, + 0 12px 24px -4px rgba(0, 0, 0, 0.35), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-blue .lego-stud-3d { + background: linear-gradient(145deg, #3399FF 0%, var(--lego-blue) 100%); + box-shadow: + inset 0 -3px 4px rgba(0, 0, 0, 0.3), + 0 2px 3px rgba(0, 0, 0, 0.2); +} + +/* Red Brick */ +.lego-brick-red { + background: linear-gradient(180deg, #FF4D6A 0%, var(--lego-red) 50%, var(--lego-red-dark) 100%); + box-shadow: + 0 6px 0 0 #8C0015, + 0 8px 16px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-red:hover { + box-shadow: + 0 8px 0 0 #8C0015, + 0 12px 24px -4px rgba(0, 0, 0, 0.35), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-red .lego-stud-3d { + background: linear-gradient(145deg, #FF6680 0%, var(--lego-red) 100%); + box-shadow: + inset 0 -3px 4px rgba(0, 0, 0, 0.3), + 0 2px 3px rgba(0, 0, 0, 0.2); +} + +/* Yellow Brick */ +.lego-brick-yellow { + background: linear-gradient(180deg, var(--lego-yellow-light) 0%, var(--lego-yellow) 50%, var(--lego-yellow-dark) 100%); + box-shadow: + 0 6px 0 0 #B89B00, + 0 8px 16px -4px rgba(0, 0, 0, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); + animation: glow-yellow 2s ease-in-out infinite; +} + +.lego-brick-yellow:hover { + box-shadow: + 0 8px 0 0 #B89B00, + 0 12px 24px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.4); +} + +.lego-brick-yellow .lego-stud-3d { + background: linear-gradient(145deg, #FFEE99 0%, var(--lego-yellow) 100%); + box-shadow: + inset 0 -3px 4px rgba(0, 0, 0, 0.2), + 0 2px 3px rgba(0, 0, 0, 0.15); +} + +.lego-brick-yellow .lego-brick-body { + color: var(--lego-black); +} + +.lego-brick-yellow .lego-brick-body span, +.lego-brick-yellow .lego-brick-body p { + color: var(--lego-black); +} + +/* Green Brick */ +.lego-brick-green { + background: linear-gradient(180deg, #00A33D 0%, var(--lego-green) 50%, #006B23 100%); + box-shadow: + 0 6px 0 0 #004D16, + 0 8px 16px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-green:hover { + box-shadow: + 0 8px 0 0 #004D16, + 0 12px 24px -4px rgba(0, 0, 0, 0.35), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-green .lego-stud-3d { + background: linear-gradient(145deg, #00C44A 0%, var(--lego-green) 100%); + box-shadow: + inset 0 -3px 4px rgba(0, 0, 0, 0.3), + 0 2px 3px rgba(0, 0, 0, 0.2); +} + +/* Orange Brick */ +.lego-brick-orange { + background: linear-gradient(180deg, #FF7033 0%, var(--lego-orange) 50%, #CC4000 100%); + box-shadow: + 0 6px 0 0 #993000, + 0 8px 16px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-orange:hover { + box-shadow: + 0 8px 0 0 #993000, + 0 12px 24px -4px rgba(0, 0, 0, 0.35), + inset 0 1px 0 0 rgba(255, 255, 255, 0.2); +} + +.lego-brick-orange .lego-stud-3d { + background: linear-gradient(145deg, #FF8C5A 0%, var(--lego-orange) 100%); + box-shadow: + inset 0 -3px 4px rgba(0, 0, 0, 0.3), + 0 2px 3px rgba(0, 0, 0, 0.2); +} + +/* Gray Brick */ +.lego-brick-gray { + background: linear-gradient(180deg, var(--lego-gray-300) 0%, var(--lego-gray-400) 50%, var(--lego-gray-500) 100%); + box-shadow: + 0 6px 0 0 #5A5A58, + 0 8px 16px -4px rgba(0, 0, 0, 0.25), + inset 0 1px 0 0 rgba(255, 255, 255, 0.3); +} + +.lego-brick-gray:hover { + box-shadow: + 0 8px 0 0 #5A5A58, + 0 12px 24px -4px rgba(0, 0, 0, 0.3), + inset 0 1px 0 0 rgba(255, 255, 255, 0.3); +} + +.lego-brick-gray .lego-stud-3d { + background: linear-gradient(145deg, var(--lego-gray-200) 0%, var(--lego-gray-400) 100%); + box-shadow: + inset 0 -3px 4px rgba(0, 0, 0, 0.2), + 0 2px 3px rgba(0, 0, 0, 0.15); +} + +/* Lego Studs Container */ +.lego-studs { + display: flex; + justify-content: center; + gap: 10px; + padding: 8px 0; + position: relative; + z-index: 1; +} + +/* 3D Stud Effect */ +.lego-stud-3d { + width: 18px; + height: 18px; + border-radius: 50%; + position: relative; +} + +/* Lego Brick Body */ +.lego-brick-body { + padding: 0 16px 16px 16px; + color: white; +} + +/* Browser Preview Container */ +.lego-browser-preview { + width: 100%; + height: 140px; + border-radius: 6px; + overflow: hidden; + background: var(--lego-white); + border: 3px solid rgba(0, 0, 0, 0.15); + position: relative; +} + +.lego-browser-preview iframe { + width: 100%; + height: 100%; + border: none; + background: white; +} + +/* Browser preview loading state */ +.lego-browser-preview::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: shimmer 1.5s infinite; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.lego-brick-orange .lego-browser-preview::before { + opacity: 1; +} + +/* Brick Card Entrance Animation */ +.lego-brick-card { + animation: brick-entrance 0.4s ease-out forwards; + opacity: 1; +} + +@keyframes brick-entrance { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.95); + } + 60% { + transform: translateY(-4px) scale(1.02); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Success Glow Effect for In-Stock Items */ +.lego-brick-success { + animation: success-glow 2s ease-in-out infinite; +} + +@keyframes success-glow { + 0%, 100% { + filter: drop-shadow(0 0 8px rgba(0, 133, 43, 0.4)); + } + 50% { + filter: drop-shadow(0 0 20px rgba(0, 133, 43, 0.7)); + } +} + +/* Yellow brick (celebrating) glow override */ +.lego-brick-yellow.lego-brick-success { + animation: yellow-celebrate 1.5s ease-in-out infinite; +} + +@keyframes yellow-celebrate { + 0%, 100% { + filter: drop-shadow(0 0 10px rgba(255, 207, 0, 0.5)); + transform: scale(1); + } + 50% { + filter: drop-shadow(0 0 25px rgba(255, 207, 0, 0.9)); + transform: scale(1.02); + } +} + +/* Green brick (found) enhanced glow */ +.lego-brick-green.lego-brick-success { + position: relative; +} + +.lego-brick-green.lego-brick-success::after { + content: ''; + position: absolute; + inset: -2px; + border-radius: 10px; + background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.2), transparent); + pointer-events: none; + animation: shine-sweep 3s ease-in-out infinite; +} + +@keyframes shine-sweep { + 0% { + opacity: 0; + transform: translateX(-100%); + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(100%); + } +} diff --git a/lego-hunter/app/layout.tsx b/lego-hunter/app/layout.tsx new file mode 100644 index 0000000..da55156 --- /dev/null +++ b/lego-hunter/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Nunito, Plus_Jakarta_Sans } from "next/font/google"; +import "./globals.css"; + +const nunito = Nunito({ + variable: "--font-display", + subsets: ["latin"], + weight: ["400", "600", "700", "800", "900"], +}); + +const plusJakarta = Plus_Jakarta_Sans({ + variable: "--font-body", + subsets: ["latin"], + weight: ["400", "500", "600", "700"], +}); + +export const metadata: Metadata = { + title: "Lego Restock Hunter | Find In-Stock Lego Sets", + description: "Search 15 toy retailers simultaneously to find sold-out Lego sets that have been restocked. Never miss a Lego restock again!", + keywords: ["lego", "restock", "toys", "finder", "in stock", "sold out"], +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/lego-hunter/app/page.tsx b/lego-hunter/app/page.tsx new file mode 100644 index 0000000..d3266db --- /dev/null +++ b/lego-hunter/app/page.tsx @@ -0,0 +1,688 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Search, Loader2, Zap, Store, Trophy, ExternalLink, Package, PackageX, AlertCircle, Eye, EyeOff } from 'lucide-react' +import { triggerLegoConfetti, triggerVictoryConfetti } from '@/components/lego-confetti' +import { DEFAULT_RETAILERS } from '@/lib/retailers' +import type { + Retailer, + RetailerStatus, + ProductData, + DealAnalysis, + SSEEvent +} from '@/types' + +export default function LegoFinderPage() { + const [legoSetName, setLegoSetName] = useState('') + const [maxBudget, setMaxBudget] = useState('') + const [isSearching, setIsSearching] = useState(false) + const [isGeneratingUrls, setIsGeneratingUrls] = useState(false) + const [retailers, setRetailers] = useState>({}) + const [results, setResults] = useState([]) + const [bestDeal, setBestDeal] = useState(null) + const [error, setError] = useState(null) + const [showAgents, setShowAgents] = useState(true) + + const initializeRetailers = useCallback((retailerList: Retailer[]) => { + const initial: Record = {} + retailerList.forEach(r => { + initial[r.name] = { name: r.name, status: 'idle', steps: [] } + }) + setRetailers(initial) + }, []) + + const handleSSEEvent = useCallback((event: SSEEvent) => { + switch (event.type) { + case 'retailer_start': + setRetailers(prev => ({ + ...prev, + [event.retailer!]: { + ...prev[event.retailer!], + status: 'searching', + streamingUrl: event.streamingUrl || prev[event.retailer!]?.streamingUrl + } + })) + break + case 'retailer_step': + setRetailers(prev => ({ + ...prev, + [event.retailer!]: { + ...prev[event.retailer!], + steps: [...(prev[event.retailer!]?.steps || []).slice(-10), event.step!] + } + })) + break + case 'retailer_stock_found': + triggerLegoConfetti() + setRetailers(prev => ({ + ...prev, + [event.retailer!]: { ...prev[event.retailer!], stockFound: true } + })) + break + case 'retailer_complete': + setRetailers(prev => ({ + ...prev, + [event.retailer!]: { ...prev[event.retailer!], status: 'complete', data: event.data } + })) + if (event.data) setResults(prev => [...prev, event.data!]) + break + case 'retailer_error': + setRetailers(prev => ({ + ...prev, + [event.retailer!]: { ...prev[event.retailer!], status: 'error', error: event.error } + })) + break + case 'analysis_complete': + setBestDeal(event.bestDeal || null) + setIsSearching(false) + if (event.bestDeal && event.bestDeal.bestRetailer !== 'None') { + triggerVictoryConfetti() + } + break + case 'error': + setError(event.error || 'An error occurred') + setIsSearching(false) + break + } + }, []) + + const handleSearch = async () => { + if (!legoSetName.trim()) { + setError('Please enter a Lego set name or number') + return + } + setError(null) + setResults([]) + setBestDeal(null) + setIsSearching(true) + setIsGeneratingUrls(true) + + try { + const urlResponse = await fetch('/api/generate-urls', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ legoSetName: legoSetName.trim() }) + }) + if (!urlResponse.ok) throw new Error('Failed to generate retailer URLs') + const { retailers: generatedRetailers } = await urlResponse.json() + setIsGeneratingUrls(false) + initializeRetailers(generatedRetailers) + + const searchResponse = await fetch('/api/search-lego', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + legoSetName: legoSetName.trim(), + maxBudget: parseFloat(maxBudget) || 1000, + retailers: generatedRetailers + }) + }) + if (!searchResponse.ok) throw new Error('Failed to start search') + + const reader = searchResponse.body?.getReader() + if (!reader) throw new Error('No response stream') + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + handleSSEEvent(JSON.parse(line.slice(6))) + } catch (e) { + console.warn('Failed to parse SSE event:', e) + } + } + } + } + } catch (err) { + console.error('Search error:', err) + setError(err instanceof Error ? err.message : 'Search failed') + setIsSearching(false) + setIsGeneratingUrls(false) + } + } + + const getRetailerLogo = (name: string) => DEFAULT_RETAILERS.find(r => r.name === name)?.logo || '🏪' + const retailerList = Object.values(retailers) + const completedCount = retailerList.filter(r => r.status === 'complete' || r.status === 'error').length + const inStockCount = results.filter(r => r.inStock).length + const totalCount = retailerList.length + const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 + + return ( +
+ {/* Colored accent stripe at top */} +
+ + {/* Hero Section */} +
+
+
+ {/* Brick stud decoration */} +
+
+
+
+
+ +

+ Lego Restock + Hunter +

+ +

+ Search 15 retailers simultaneously to find sold-out Lego sets. + Powered by AI to find you the best deal. +

+
+
+
+ + {/* Search Section */} +
+
+
+
+ {/* Search inputs */} +
+
+ + setLegoSetName(e.target.value)} + placeholder="e.g., 75192 Millennium Falcon" + className="brick-input" + disabled={isSearching} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + /> +
+
+ +
+ $ + setMaxBudget(e.target.value)} + placeholder="1000" + className="brick-input pl-8" + disabled={isSearching} + /> +
+
+
+ + {/* Search button */} +
+ + + {!isSearching && ( +

+ + Searches 15 retailers in parallel +

+ )} +
+ + {/* Progress bar */} + {isSearching && ( +
+
+ + {isGeneratingUrls ? 'Generating search URLs with AI...' : `Checking ${completedCount} of ${totalCount} retailers`} + + {!isGeneratingUrls && ( + {Math.round(progress)}% + )} +
+
+
+
+ {inStockCount > 0 && ( +

+ + {inStockCount} in stock found! +

+ )} +
+ )} + + {/* Error message */} + {error && ( +
+ +

{error}

+
+ )} +
+
+
+
+ + {/* Best Deal Section */} + {bestDeal && bestDeal.bestRetailer !== 'None' && ( +
+
+
+
+
+ +
+
+

+ Best Deal Found +

+

+ {bestDeal.bestRetailer} +

+
+
+ +

+ {bestDeal.reason} +

+ +
+
+

Total Cost

+

{bestDeal.totalCost}

+
+ {bestDeal.savings && bestDeal.savings !== 'N/A' && ( +
+ {bestDeal.savings} +
+ )} +
+ + {results.find(r => r.retailer === bestDeal.bestRetailer)?.productUrl && ( + r.retailer === bestDeal.bestRetailer)?.productUrl} + target="_blank" + rel="noopener noreferrer" + className="brick-button mt-6 inline-flex text-lg px-8 py-4 animate-pulse hover:animate-none" + > + + 🛒 Buy Now - Get It Before It's Gone! + + )} +
+
+
+ )} + + {/* No Stock Found */} + {bestDeal && bestDeal.bestRetailer === 'None' && ( +
+
+
+
+ +
+

+ No Stock Found +

+

+ {bestDeal.reason} +

+
+
+
+ )} + + {/* Retailer Grid */} + {retailerList.length > 0 && ( +
+
+
+
+ +

+ Retailer Status +

+ + ({completedCount}/{totalCount} complete) + +
+ +
+ + {showAgents && ( +
+ {retailerList.map((r, i) => ( + + ))} +
+ )} +
+
+ )} + + {/* Results Table */} + {results.length > 0 && !isSearching && ( +
+
+

+ All Results +

+ +
+
+ )} + + {/* Empty State */} + {!isSearching && retailerList.length === 0 && ( +
+
+
+ {['red', 'yellow', 'blue'].map((color, i) => ( +
+ ))} +
+

+ Ready to Hunt +

+

+ Enter a Lego set name or number above to search across 15 retailers simultaneously. +

+
+
+ )} + + {/* Footer */} +
+
+
+
+
+
+
+
+ Lego Restock Hunter +
+

+ Powered by Mino AI + Gemini. Not affiliated with LEGO Group. +

+
+
+
+ ) +} + +/* Retailer Status Card Component - Lego Brick Style */ +function RetailerStatusCard({ retailer, logo, delay }: { retailer: RetailerStatus; logo: string; delay: number }) { + // Determine brick color based on status - prioritize complete status over stockFound + const getBrickColor = () => { + // First check if search is complete + if (retailer.status === 'complete') { + return retailer.data?.inStock ? 'lego-brick-green' : 'lego-brick-gray' + } + // Error state + if (retailer.status === 'error') { + return 'lego-brick-red' + } + // Searching states + if (retailer.status === 'searching') { + // Celebration moment when stock is found but not yet complete + return retailer.stockFound ? 'lego-brick-yellow' : 'lego-brick-orange' + } + // Idle state + return 'lego-brick-blue' + } + + // Check if this is a "success" card (in stock and complete) + const isInStock = retailer.status === 'complete' && retailer.data?.inStock + + // Determine if card should have celebration glow + const shouldGlow = retailer.stockFound || isInStock + + return ( +
+ {/* Lego Studs */} +
+
+
+
+
+
+ + {/* Brick Body */} +
+ {/* Header */} +
+
+ {logo} + {retailer.name} +
+ +
+ + {/* Browser Preview */} +
+ {retailer.streamingUrl ? ( +