From 3fcfa9bb8e15fe022c9fc6138b9f97165eb4d2c2 Mon Sep 17 00:00:00 2001 From: Urugonda Vishnu Date: Thu, 29 Jan 2026 16:04:28 +0530 Subject: [PATCH 1/5] Add game buying guide mini-app --- game-buying-guide/.gitignore | 27 + .../app/api/analyze-platform/route.ts | 271 +++++++ .../app/api/discover-platforms/route.ts | 76 ++ .../app/api/steamdb-price-history/route.ts | 209 +++++ game-buying-guide/app/globals.css | 131 ++++ game-buying-guide/app/layout.tsx | 46 ++ game-buying-guide/app/loading.tsx | 3 + game-buying-guide/app/page.tsx | 94 +++ game-buying-guide/components.json | 21 + game-buying-guide/components/agent-card.tsx | 232 ++++++ game-buying-guide/components/agent-grid.tsx | 62 ++ .../components/live-browser-preview.tsx | 79 ++ .../components/results-summary.tsx | 132 ++++ game-buying-guide/components/search-form.tsx | 80 ++ .../components/steamdb-price-card.tsx | 185 +++++ .../components/theme-provider.tsx | 11 + game-buying-guide/components/ui/accordion.tsx | 66 ++ .../components/ui/alert-dialog.tsx | 157 ++++ game-buying-guide/components/ui/alert.tsx | 66 ++ .../components/ui/aspect-ratio.tsx | 11 + game-buying-guide/components/ui/avatar.tsx | 53 ++ game-buying-guide/components/ui/badge.tsx | 46 ++ .../components/ui/breadcrumb.tsx | 109 +++ .../components/ui/button-group.tsx | 83 ++ game-buying-guide/components/ui/button.tsx | 60 ++ game-buying-guide/components/ui/calendar.tsx | 213 +++++ game-buying-guide/components/ui/card.tsx | 92 +++ game-buying-guide/components/ui/carousel.tsx | 241 ++++++ game-buying-guide/components/ui/chart.tsx | 353 +++++++++ game-buying-guide/components/ui/checkbox.tsx | 32 + .../components/ui/collapsible.tsx | 33 + game-buying-guide/components/ui/command.tsx | 184 +++++ .../components/ui/context-menu.tsx | 252 ++++++ game-buying-guide/components/ui/dialog.tsx | 143 ++++ game-buying-guide/components/ui/drawer.tsx | 135 ++++ .../components/ui/dropdown-menu.tsx | 257 +++++++ game-buying-guide/components/ui/empty.tsx | 104 +++ game-buying-guide/components/ui/field.tsx | 244 ++++++ game-buying-guide/components/ui/form.tsx | 167 ++++ .../components/ui/hover-card.tsx | 44 ++ .../components/ui/input-group.tsx | 169 ++++ game-buying-guide/components/ui/input-otp.tsx | 77 ++ game-buying-guide/components/ui/input.tsx | 21 + game-buying-guide/components/ui/item.tsx | 193 +++++ game-buying-guide/components/ui/kbd.tsx | 28 + game-buying-guide/components/ui/label.tsx | 24 + game-buying-guide/components/ui/menubar.tsx | 276 +++++++ .../components/ui/navigation-menu.tsx | 166 ++++ .../components/ui/pagination.tsx | 127 +++ game-buying-guide/components/ui/popover.tsx | 48 ++ game-buying-guide/components/ui/progress.tsx | 31 + .../components/ui/radio-group.tsx | 45 ++ game-buying-guide/components/ui/resizable.tsx | 56 ++ .../components/ui/scroll-area.tsx | 58 ++ game-buying-guide/components/ui/select.tsx | 185 +++++ game-buying-guide/components/ui/separator.tsx | 28 + game-buying-guide/components/ui/sheet.tsx | 139 ++++ game-buying-guide/components/ui/sidebar.tsx | 726 ++++++++++++++++++ game-buying-guide/components/ui/skeleton.tsx | 13 + game-buying-guide/components/ui/slider.tsx | 63 ++ game-buying-guide/components/ui/sonner.tsx | 25 + game-buying-guide/components/ui/spinner.tsx | 16 + game-buying-guide/components/ui/switch.tsx | 31 + game-buying-guide/components/ui/table.tsx | 116 +++ game-buying-guide/components/ui/tabs.tsx | 66 ++ game-buying-guide/components/ui/textarea.tsx | 18 + game-buying-guide/components/ui/toast.tsx | 129 ++++ game-buying-guide/components/ui/toaster.tsx | 35 + .../components/ui/toggle-group.tsx | 73 ++ game-buying-guide/components/ui/toggle.tsx | 47 ++ game-buying-guide/components/ui/tooltip.tsx | 61 ++ .../components/ui/use-mobile.tsx | 19 + game-buying-guide/components/ui/use-toast.ts | 191 +++++ .../docs/mino-api-integration.md | 445 +++++++++++ game-buying-guide/hooks/use-game-search.ts | 370 +++++++++ game-buying-guide/hooks/use-mobile.ts | 19 + game-buying-guide/hooks/use-toast.ts | 191 +++++ game-buying-guide/lib/types.ts | 53 ++ game-buying-guide/lib/utils.ts | 6 + game-buying-guide/next.config.mjs | 11 + game-buying-guide/package.json | 76 ++ game-buying-guide/pnpm-lock.yaml | 5 + game-buying-guide/postcss.config.mjs | 8 + game-buying-guide/public/apple-icon.png | Bin 0 -> 2626 bytes game-buying-guide/public/icon-dark-32x32.png | Bin 0 -> 585 bytes game-buying-guide/public/icon-light-32x32.png | Bin 0 -> 566 bytes game-buying-guide/public/icon.svg | 26 + game-buying-guide/public/placeholder-logo.png | Bin 0 -> 568 bytes game-buying-guide/public/placeholder-logo.svg | 1 + game-buying-guide/public/placeholder-user.jpg | Bin 0 -> 1635 bytes game-buying-guide/public/placeholder.jpg | Bin 0 -> 1064 bytes game-buying-guide/public/placeholder.svg | 1 + game-buying-guide/styles/globals.css | 125 +++ game-buying-guide/tsconfig.json | 27 + 94 files changed, 9468 insertions(+) create mode 100644 game-buying-guide/.gitignore create mode 100644 game-buying-guide/app/api/analyze-platform/route.ts create mode 100644 game-buying-guide/app/api/discover-platforms/route.ts create mode 100644 game-buying-guide/app/api/steamdb-price-history/route.ts create mode 100644 game-buying-guide/app/globals.css create mode 100644 game-buying-guide/app/layout.tsx create mode 100644 game-buying-guide/app/loading.tsx create mode 100644 game-buying-guide/app/page.tsx create mode 100644 game-buying-guide/components.json create mode 100644 game-buying-guide/components/agent-card.tsx create mode 100644 game-buying-guide/components/agent-grid.tsx create mode 100644 game-buying-guide/components/live-browser-preview.tsx create mode 100644 game-buying-guide/components/results-summary.tsx create mode 100644 game-buying-guide/components/search-form.tsx create mode 100644 game-buying-guide/components/steamdb-price-card.tsx create mode 100644 game-buying-guide/components/theme-provider.tsx create mode 100644 game-buying-guide/components/ui/accordion.tsx create mode 100644 game-buying-guide/components/ui/alert-dialog.tsx create mode 100644 game-buying-guide/components/ui/alert.tsx create mode 100644 game-buying-guide/components/ui/aspect-ratio.tsx create mode 100644 game-buying-guide/components/ui/avatar.tsx create mode 100644 game-buying-guide/components/ui/badge.tsx create mode 100644 game-buying-guide/components/ui/breadcrumb.tsx create mode 100644 game-buying-guide/components/ui/button-group.tsx create mode 100644 game-buying-guide/components/ui/button.tsx create mode 100644 game-buying-guide/components/ui/calendar.tsx create mode 100644 game-buying-guide/components/ui/card.tsx create mode 100644 game-buying-guide/components/ui/carousel.tsx create mode 100644 game-buying-guide/components/ui/chart.tsx create mode 100644 game-buying-guide/components/ui/checkbox.tsx create mode 100644 game-buying-guide/components/ui/collapsible.tsx create mode 100644 game-buying-guide/components/ui/command.tsx create mode 100644 game-buying-guide/components/ui/context-menu.tsx create mode 100644 game-buying-guide/components/ui/dialog.tsx create mode 100644 game-buying-guide/components/ui/drawer.tsx create mode 100644 game-buying-guide/components/ui/dropdown-menu.tsx create mode 100644 game-buying-guide/components/ui/empty.tsx create mode 100644 game-buying-guide/components/ui/field.tsx create mode 100644 game-buying-guide/components/ui/form.tsx create mode 100644 game-buying-guide/components/ui/hover-card.tsx create mode 100644 game-buying-guide/components/ui/input-group.tsx create mode 100644 game-buying-guide/components/ui/input-otp.tsx create mode 100644 game-buying-guide/components/ui/input.tsx create mode 100644 game-buying-guide/components/ui/item.tsx create mode 100644 game-buying-guide/components/ui/kbd.tsx create mode 100644 game-buying-guide/components/ui/label.tsx create mode 100644 game-buying-guide/components/ui/menubar.tsx create mode 100644 game-buying-guide/components/ui/navigation-menu.tsx create mode 100644 game-buying-guide/components/ui/pagination.tsx create mode 100644 game-buying-guide/components/ui/popover.tsx create mode 100644 game-buying-guide/components/ui/progress.tsx create mode 100644 game-buying-guide/components/ui/radio-group.tsx create mode 100644 game-buying-guide/components/ui/resizable.tsx create mode 100644 game-buying-guide/components/ui/scroll-area.tsx create mode 100644 game-buying-guide/components/ui/select.tsx create mode 100644 game-buying-guide/components/ui/separator.tsx create mode 100644 game-buying-guide/components/ui/sheet.tsx create mode 100644 game-buying-guide/components/ui/sidebar.tsx create mode 100644 game-buying-guide/components/ui/skeleton.tsx create mode 100644 game-buying-guide/components/ui/slider.tsx create mode 100644 game-buying-guide/components/ui/sonner.tsx create mode 100644 game-buying-guide/components/ui/spinner.tsx create mode 100644 game-buying-guide/components/ui/switch.tsx create mode 100644 game-buying-guide/components/ui/table.tsx create mode 100644 game-buying-guide/components/ui/tabs.tsx create mode 100644 game-buying-guide/components/ui/textarea.tsx create mode 100644 game-buying-guide/components/ui/toast.tsx create mode 100644 game-buying-guide/components/ui/toaster.tsx create mode 100644 game-buying-guide/components/ui/toggle-group.tsx create mode 100644 game-buying-guide/components/ui/toggle.tsx create mode 100644 game-buying-guide/components/ui/tooltip.tsx create mode 100644 game-buying-guide/components/ui/use-mobile.tsx create mode 100644 game-buying-guide/components/ui/use-toast.ts create mode 100644 game-buying-guide/docs/mino-api-integration.md create mode 100644 game-buying-guide/hooks/use-game-search.ts create mode 100644 game-buying-guide/hooks/use-mobile.ts create mode 100644 game-buying-guide/hooks/use-toast.ts create mode 100644 game-buying-guide/lib/types.ts create mode 100644 game-buying-guide/lib/utils.ts create mode 100644 game-buying-guide/next.config.mjs create mode 100644 game-buying-guide/package.json create mode 100644 game-buying-guide/pnpm-lock.yaml create mode 100644 game-buying-guide/postcss.config.mjs create mode 100644 game-buying-guide/public/apple-icon.png create mode 100644 game-buying-guide/public/icon-dark-32x32.png create mode 100644 game-buying-guide/public/icon-light-32x32.png create mode 100644 game-buying-guide/public/icon.svg create mode 100644 game-buying-guide/public/placeholder-logo.png create mode 100644 game-buying-guide/public/placeholder-logo.svg create mode 100644 game-buying-guide/public/placeholder-user.jpg create mode 100644 game-buying-guide/public/placeholder.jpg create mode 100644 game-buying-guide/public/placeholder.svg create mode 100644 game-buying-guide/styles/globals.css create mode 100644 game-buying-guide/tsconfig.json diff --git a/game-buying-guide/.gitignore b/game-buying-guide/.gitignore new file mode 100644 index 00000000..f650315f --- /dev/null +++ b/game-buying-guide/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/game-buying-guide/app/api/analyze-platform/route.ts b/game-buying-guide/app/api/analyze-platform/route.ts new file mode 100644 index 00000000..f60a34c3 --- /dev/null +++ b/game-buying-guide/app/api/analyze-platform/route.ts @@ -0,0 +1,271 @@ +import { NextResponse } from 'next/server' + +// Allow streaming responses up to 300 seconds (requires Vercel Pro plan) +export const maxDuration = 300 + +const MINO_API_KEY = process.env.MINO_API_KEY + +export async function POST(request: Request) { + try { + const { platformName, url, gameTitle } = await request.json() + + if (!platformName || !url || !gameTitle) { + return NextResponse.json({ error: 'Platform name, URL, and game title are required' }, { status: 400 }) + } + + if (!MINO_API_KEY) { + return NextResponse.json({ error: 'Mino API key not configured' }, { status: 500 }) + } + + const currentDate = new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + + const goal = `You are analyzing a game store page to help a user decide whether to buy "${gameTitle}" now or wait. + +CURRENT DATE: ${currentDate} + +STEP 1 - NAVIGATE & OBSERVE: +Navigate to the store page and observe: +- Current price displayed +- Any sale/discount indicators +- Original price (if on sale) +- User ratings and review scores +- Any visible sale end dates or timers +- Bundle options or editions available + +STEP 2 - ANALYZE PURCHASE TIMING: +Consider these factors: +- Is there an active discount? How significant? +- Are there any visible sale patterns (seasonal sales, etc.)? +- What do user reviews say about the game's value? +- Are there any upcoming DLCs or editions that might affect price? + +STEP 3 - RETURN STRUCTURED ANALYSIS: +Return a JSON object with this exact format: +{ + "platform_name": "${platformName}", + "store_url": "${url}", + "current_price": "$XX.XX or regional equivalent", + "original_price": "$XX.XX if on sale, null otherwise", + "discount_percentage": "XX%" if on sale, null otherwise", + "is_on_sale": true/false, + "sale_ends": "Date/time if visible, null otherwise", + "user_rating": "Rating score if available (e.g., '9/10', '95%', '4.5/5')", + "review_count": "Number of reviews if visible", + "recommendation": "buy_now" | "wait" | "consider", + "reasoning": "2-3 sentence explanation of your recommendation", + "pros": ["Up to 3 reasons to buy from this platform"], + "cons": ["Up to 3 potential drawbacks or reasons to wait"] +} + +RECOMMENDATION GUIDELINES: +- "buy_now": Significant discount (30%+), historic low price, or sale ending soon +- "wait": Full price with known upcoming sales, or better deals elsewhere +- "consider": Moderate discount, decent value, user's preference matters + +Be accurate with prices and factual with observations. If you cannot find certain information, use null for that field.` + + // Create SSE stream + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + // Create abort controller for 300 second timeout + const abortController = new AbortController() + const timeoutId = setTimeout(() => { + console.log(`[v0] Timeout reached for ${platformName}, aborting...`) + abortController.abort() + }, 295000) // 295 seconds (leaving buffer for response) + + try { + console.log(`[v0] Starting Mino agent for ${platformName} at ${url}`) + + const response = await fetch('https://mino.ai/v1/automation/run-sse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': MINO_API_KEY, + }, + body: JSON.stringify({ + url, + goal, + timeout: 300000, // 300 second timeout + }), + signal: abortController.signal, + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Mino API error:', errorText) + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: 'ERROR', error: 'Failed to start browser agent' })}\n\n` + ) + ) + controller.close() + return + } + + if (!response.body) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'No response body' })}\n\n`)) + controller.close() + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let hasCompleted = false + let lastResult: unknown = null + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + // Process complete lines + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6).trim() + if (!jsonStr || jsonStr === '[DONE]') continue + + const data = JSON.parse(jsonStr) + console.log(`[v0] ${platformName} event:`, JSON.stringify(data).slice(0, 200)) + + // Forward streaming URL - check multiple possible field names + const streamingUrl = data.streamingUrl || data.liveUrl || data.previewUrl || data.live_url || data.preview_url || data.browserUrl || data.browser_url + if (streamingUrl) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'STREAMING_URL', streamingUrl })}\n\n`) + ) + } + + // Forward status updates - check multiple possible formats + const statusMessage = data.message || data.status || data.action || data.step || data.event + if (statusMessage && typeof statusMessage === 'string' && !data.result && !data.output) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'STATUS', message: statusMessage })}\n\n`) + ) + } + + // Handle completion - check multiple possible field names + const resultData = data.result || data.resultJson || data.output || data.response || data.answer || data.data + if (data.type === 'COMPLETE' || data.type === 'complete' || data.type === 'done' || data.type === 'finished' || data.completed || data.done || (resultData && typeof resultData === 'object')) { + hasCompleted = true + let resultJson = resultData + + // Try to parse if it's a string + if (typeof resultJson === 'string') { + try { + const jsonMatch = resultJson.match(/\{[\s\S]*\}/) + if (jsonMatch) { + resultJson = JSON.parse(jsonMatch[0]) + } + } catch { + // Keep as string if parsing fails + } + } + + if (resultJson) { + lastResult = resultJson + console.log(`[v0] ${platformName} completed with result`) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'COMPLETE', result: resultJson })}\n\n`) + ) + } + } + + // Handle errors + if (data.type === 'ERROR' || data.type === 'error' || data.error) { + hasCompleted = true + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: data.error || data.message || 'Unknown error' })}\n\n`) + ) + } + } catch (parseError) { + // Log non-JSON lines for debugging + console.log(`[v0] ${platformName} non-JSON line:`, line.slice(0, 100)) + } + } + } + } + + // Process remaining buffer + if (buffer.trim() && buffer.startsWith('data: ')) { + try { + const jsonStr = buffer.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + const data = JSON.parse(jsonStr) + if (data.result || data.resultJson || data.output) { + hasCompleted = true + let resultJson = data.result || data.resultJson || data.output + if (typeof resultJson === 'string') { + const jsonMatch = resultJson.match(/\{[\s\S]*\}/) + if (jsonMatch) { + resultJson = JSON.parse(jsonMatch[0]) + } + } + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'COMPLETE', result: resultJson })}\n\n`) + ) + } + } + } catch { + // Ignore + } + } + + // Clear timeout since we completed normally + clearTimeout(timeoutId) + + console.log(`[v0] Stream ended for ${platformName}, hasCompleted: ${hasCompleted}`) + + // Ensure we send a completion event if stream ended without one + if (!hasCompleted) { + console.log(`[v0] No completion received for ${platformName}, sending error`) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'Analysis ended without results - the agent may still be processing on the Mino dashboard' })}\n\n`) + ) + } + + controller.close() + } catch (error) { + clearTimeout(timeoutId) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + console.error(`[v0] Stream error for ${platformName}:`, errorMessage) + + // Check if it was an abort + if (error instanceof Error && error.name === 'AbortError') { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'Analysis timed out' })}\n\n`) + ) + } else { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: `Stream processing failed: ${errorMessage}` })}\n\n`) + ) + } + controller.close() + } + }, + }) + + return new NextResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + } catch (error) { + console.error('Error in analyze-platform:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/game-buying-guide/app/api/discover-platforms/route.ts b/game-buying-guide/app/api/discover-platforms/route.ts new file mode 100644 index 00000000..b4470ec0 --- /dev/null +++ b/game-buying-guide/app/api/discover-platforms/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server' + +// Curated list of trusted gaming platforms with search URL patterns +const GAMING_PLATFORMS = [ + { + name: 'Steam', + searchUrl: (query: string) => `https://store.steampowered.com/search/?term=${encodeURIComponent(query)}`, + icon: 'steam', + }, + { + name: 'Epic Games Store', + searchUrl: (query: string) => `https://store.epicgames.com/en-US/browse?q=${encodeURIComponent(query)}`, + icon: 'epic', + }, + { + name: 'GOG', + searchUrl: (query: string) => `https://www.gog.com/en/games?query=${encodeURIComponent(query)}`, + icon: 'gog', + }, + { + name: 'PlayStation Store', + searchUrl: (query: string) => `https://store.playstation.com/en-us/search/${encodeURIComponent(query)}`, + icon: 'playstation', + }, + { + name: 'Xbox Store', + searchUrl: (query: string) => `https://www.xbox.com/en-US/search?q=${encodeURIComponent(query)}`, + icon: 'xbox', + }, + { + name: 'Nintendo eShop', + searchUrl: (query: string) => `https://www.nintendo.com/us/search/#q=${encodeURIComponent(query)}`, + icon: 'nintendo', + }, + { + name: 'Humble Bundle', + searchUrl: (query: string) => `https://www.humblebundle.com/store/search?search=${encodeURIComponent(query)}`, + icon: 'humble', + }, + { + name: 'Green Man Gaming', + searchUrl: (query: string) => `https://www.greenmangaming.com/search/?query=${encodeURIComponent(query)}`, + icon: 'gmg', + }, + { + name: 'Fanatical', + searchUrl: (query: string) => `https://www.fanatical.com/en/search?search=${encodeURIComponent(query)}`, + icon: 'fanatical', + }, + { + name: 'CDKeys', + searchUrl: (query: string) => `https://www.cdkeys.com/catalogsearch/result/?q=${encodeURIComponent(query)}`, + icon: 'cdkeys', + }, +] + +export async function POST(request: Request) { + try { + const { gameTitle } = await request.json() + + if (!gameTitle) { + return NextResponse.json({ error: 'Game title is required' }, { status: 400 }) + } + + // Generate platform URLs for the game + const platforms = GAMING_PLATFORMS.map((platform) => ({ + name: platform.name, + url: platform.searchUrl(gameTitle), + })) + + return NextResponse.json({ platforms }) + } catch (error) { + console.error('Error in discover-platforms:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/game-buying-guide/app/api/steamdb-price-history/route.ts b/game-buying-guide/app/api/steamdb-price-history/route.ts new file mode 100644 index 00000000..e7b61546 --- /dev/null +++ b/game-buying-guide/app/api/steamdb-price-history/route.ts @@ -0,0 +1,209 @@ +import { NextResponse } from 'next/server' + +// Allow streaming responses up to 300 seconds (requires Vercel Pro plan) +export const maxDuration = 300 + +const MINO_API_KEY = process.env.MINO_API_KEY + +export async function POST(request: Request) { + try { + const { gameTitle } = await request.json() + + if (!gameTitle) { + return NextResponse.json({ error: 'Game title is required' }, { status: 400 }) + } + + if (!MINO_API_KEY) { + return NextResponse.json({ error: 'Mino API key not configured' }, { status: 500 }) + } + + const url = `https://steamdb.info/search/?a=app&q=${encodeURIComponent(gameTitle)}` + + const goal = `You are analyzing SteamDB to find the historic lowest price for "${gameTitle}". + +STEP 1 - SEARCH & NAVIGATE: +1. You are on the SteamDB search page with results for "${gameTitle}" +2. Find the correct game in the search results (match the title as closely as possible) +3. Click on the game to go to its detail page + +STEP 2 - FIND PRICE HISTORY: +1. Look for the "Price History" section or chart on the game's page +2. Find the "Lowest recorded price" or historic low price information +3. Note the date when this historic low occurred +4. Note what discount percentage that was + +STEP 3 - COMPARE WITH CURRENT: +1. Find the current Steam price +2. Check if there's an active discount +3. Determine if the current price matches or is close to the historic low + +STEP 4 - RETURN STRUCTURED DATA: +Return a JSON object with this exact format: +{ + "game_name": "Full game name as shown on SteamDB", + "historic_lowest_price": "$XX.XX (the all-time lowest price)", + "historic_lowest_date": "Date when historic low occurred (e.g., 'June 2024')", + "historic_lowest_discount": "XX% (the discount when at historic low)", + "current_steam_price": "$XX.XX (current price on Steam)", + "current_discount": "XX% or null if no discount", + "is_current_historic_low": true/false, + "recommendation": "Brief recommendation based on price history (1-2 sentences)" +} + +Be accurate with the prices. If you cannot find certain information, use null for that field. +Focus on finding the LOWEST price the game has EVER been sold for on Steam.` + + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + const abortController = new AbortController() + const timeoutId = setTimeout(() => { + abortController.abort() + }, 295000) + + try { + const response = await fetch('https://mino.ai/v1/automation/run-sse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': MINO_API_KEY, + }, + body: JSON.stringify({ + url, + goal, + timeout: 300000, + }), + signal: abortController.signal, + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Mino API error:', errorText) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'Failed to start SteamDB analysis' })}\n\n`) + ) + controller.close() + clearTimeout(timeoutId) + return + } + + if (!response.body) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'No response stream from Mino' })}\n\n`) + ) + controller.close() + clearTimeout(timeoutId) + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let hasCompleted = false + + 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 { + const jsonStr = line.slice(6).trim() + if (!jsonStr || jsonStr === '[DONE]') continue + + const data = JSON.parse(jsonStr) + + const streamingUrl = data.streamingUrl || data.liveUrl || data.previewUrl || data.live_url || data.browser_url + if (streamingUrl) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'STREAMING_URL', streamingUrl })}\n\n`) + ) + } + + const statusMessage = data.message || data.status || data.action || data.step + if (statusMessage && typeof statusMessage === 'string' && !data.result && !data.output) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'STATUS', message: statusMessage })}\n\n`) + ) + } + + const resultData = data.result || data.resultJson || data.output || data.response || data.data + if (data.type === 'COMPLETE' || data.type === 'complete' || data.type === 'done' || (resultData && typeof resultData === 'object')) { + hasCompleted = true + let resultJson = resultData + + if (typeof resultJson === 'string') { + try { + const jsonMatch = resultJson.match(/\{[\s\S]*\}/) + if (jsonMatch) { + resultJson = JSON.parse(jsonMatch[0]) + } + } catch { + // Keep as string + } + } + + if (resultJson) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'COMPLETE', result: resultJson })}\n\n`) + ) + } + } + + if (data.type === 'ERROR' || data.type === 'error' || data.error) { + hasCompleted = true + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: data.error || data.message || 'Unknown error' })}\n\n`) + ) + } + } catch { + // Skip non-JSON lines + } + } + } + } + + clearTimeout(timeoutId) + + if (!hasCompleted) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'SteamDB analysis ended without results' })}\n\n`) + ) + } + + controller.close() + } catch (error) { + clearTimeout(timeoutId) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + if (error instanceof Error && error.name === 'AbortError') { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: 'SteamDB analysis timed out' })}\n\n`) + ) + } else { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'ERROR', error: `SteamDB analysis failed: ${errorMessage}` })}\n\n`) + ) + } + controller.close() + } + }, + }) + + return new NextResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + } catch (error) { + console.error('Error in steamdb-price-history:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/game-buying-guide/app/globals.css b/game-buying-guide/app/globals.css new file mode 100644 index 00000000..c2f0ee81 --- /dev/null +++ b/game-buying-guide/app/globals.css @@ -0,0 +1,131 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.09 0 0); + --foreground: oklch(0.95 0 0); + --card: oklch(0.12 0 0); + --card-foreground: oklch(0.95 0 0); + --popover: oklch(0.12 0 0); + --popover-foreground: oklch(0.95 0 0); + --primary: oklch(0.65 0.2 145); + --primary-foreground: oklch(0.1 0 0); + --secondary: oklch(0.18 0 0); + --secondary-foreground: oklch(0.95 0 0); + --muted: oklch(0.18 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.55 0.15 280); + --accent-foreground: oklch(0.95 0 0); + --destructive: oklch(0.55 0.2 25); + --destructive-foreground: oklch(0.95 0 0); + --border: oklch(0.22 0 0); + --input: oklch(0.18 0 0); + --ring: oklch(0.65 0.2 145); + --chart-1: oklch(0.65 0.2 145); + --chart-2: oklch(0.55 0.15 280); + --chart-3: oklch(0.6 0.15 45); + --chart-4: oklch(0.6 0.2 200); + --chart-5: oklch(0.55 0.2 25); + --radius: 0.625rem; + --sidebar: oklch(0.1 0 0); + --sidebar-foreground: oklch(0.95 0 0); + --sidebar-primary: oklch(0.65 0.2 145); + --sidebar-primary-foreground: oklch(0.1 0 0); + --sidebar-accent: oklch(0.18 0 0); + --sidebar-accent-foreground: oklch(0.95 0 0); + --sidebar-border: oklch(0.22 0 0); + --sidebar-ring: oklch(0.65 0.2 145); + --success: oklch(0.65 0.2 145); + --warning: oklch(0.75 0.15 85); +} + +.dark { + --background: oklch(0.09 0 0); + --foreground: oklch(0.95 0 0); + --card: oklch(0.12 0 0); + --card-foreground: oklch(0.95 0 0); + --popover: oklch(0.12 0 0); + --popover-foreground: oklch(0.95 0 0); + --primary: oklch(0.65 0.2 145); + --primary-foreground: oklch(0.1 0 0); + --secondary: oklch(0.18 0 0); + --secondary-foreground: oklch(0.95 0 0); + --muted: oklch(0.18 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.55 0.15 280); + --accent-foreground: oklch(0.95 0 0); + --destructive: oklch(0.55 0.2 25); + --destructive-foreground: oklch(0.95 0 0); + --border: oklch(0.22 0 0); + --input: oklch(0.18 0 0); + --ring: oklch(0.65 0.2 145); + --chart-1: oklch(0.65 0.2 145); + --chart-2: oklch(0.55 0.15 280); + --chart-3: oklch(0.6 0.15 45); + --chart-4: oklch(0.6 0.2 200); + --chart-5: oklch(0.55 0.2 25); + --sidebar: oklch(0.1 0 0); + --sidebar-foreground: oklch(0.95 0 0); + --sidebar-primary: oklch(0.65 0.2 145); + --sidebar-primary-foreground: oklch(0.1 0 0); + --sidebar-accent: oklch(0.18 0 0); + --sidebar-accent-foreground: oklch(0.95 0 0); + --sidebar-border: oklch(0.22 0 0); + --sidebar-ring: oklch(0.65 0.2 145); + --success: oklch(0.65 0.2 145); + --warning: oklch(0.75 0.15 85); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-success: var(--success); + --color-warning: var(--warning); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/game-buying-guide/app/layout.tsx b/game-buying-guide/app/layout.tsx new file mode 100644 index 00000000..4169f4b2 --- /dev/null +++ b/game-buying-guide/app/layout.tsx @@ -0,0 +1,46 @@ +import React from "react" +import type { Metadata } from 'next' +import { Geist, Geist_Mono } from 'next/font/google' +import { Analytics } from '@vercel/analytics/next' +import './globals.css' + +const _geist = Geist({ subsets: ["latin"] }); +const _geistMono = Geist_Mono({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: 'GamePulse - Buy Now or Wait', + description: 'AI-powered game purchase decision tool. Analyze prices across platforms to decide the best time to buy.', + generator: 'v0.app', + icons: { + icon: [ + { + url: '/icon-light-32x32.png', + media: '(prefers-color-scheme: light)', + }, + { + url: '/icon-dark-32x32.png', + media: '(prefers-color-scheme: dark)', + }, + { + url: '/icon.svg', + type: 'image/svg+xml', + }, + ], + apple: '/apple-icon.png', + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + ) +} diff --git a/game-buying-guide/app/loading.tsx b/game-buying-guide/app/loading.tsx new file mode 100644 index 00000000..f15322a8 --- /dev/null +++ b/game-buying-guide/app/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/game-buying-guide/app/page.tsx b/game-buying-guide/app/page.tsx new file mode 100644 index 00000000..3d3cd3fe --- /dev/null +++ b/game-buying-guide/app/page.tsx @@ -0,0 +1,94 @@ +'use client' + +import { SearchForm } from '@/components/search-form' +import { AgentGrid } from '@/components/agent-grid' +import { ResultsSummary } from '@/components/results-summary' +import { SteamDBPriceCard } from '@/components/steamdb-price-card' +import { useGameSearch } from '@/hooks/use-game-search' +import { AlertCircle, RotateCcw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' + +export default function HomePage() { + const { search, reset, isLoading, agents, error, gameName, steamDBAgent } = useGameSearch() + + const hasResults = agents.length > 0 + const hasCompleted = agents.some((a) => a.status === 'complete') + + return ( +
+ {/* Header Section */} +
+
+ +
+
+ + {/* Main Content */} +
+ {/* Error State */} + {error && ( + + + + {error} + + + + )} + + {/* SteamDB Historic Price - Always show at top when searching */} + {hasResults && steamDBAgent.status !== 'pending' && ( + + )} + + {/* Results Summary */} + {hasCompleted && } + + {/* Agent Grid */} + {hasResults && } + + {/* Empty State */} + {!hasResults && !isLoading && !error && ( +
+
+

Ready to find the best deal?

+

+ Enter a game title above and our AI agents will analyze prices across multiple platforms to help you + decide whether to buy now or wait for a better deal. +

+
+
+ )} + + {/* Loading State Info */} + {isLoading && agents.length === 0 && ( +
+
+
+
+

Discovering Platforms

+

+ Using AI to find where {gameName || 'your game'} is available... +

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

+ GamePulse uses AI-powered browser agents to analyze real-time pricing data. Prices and availability may + vary. +

+
+
+
+ ) +} diff --git a/game-buying-guide/components.json b/game-buying-guide/components.json new file mode 100644 index 00000000..4ee62ee1 --- /dev/null +++ b/game-buying-guide/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/game-buying-guide/components/agent-card.tsx b/game-buying-guide/components/agent-card.tsx new file mode 100644 index 00000000..fdd50d62 --- /dev/null +++ b/game-buying-guide/components/agent-card.tsx @@ -0,0 +1,232 @@ +'use client' + +import { useState } from 'react' +import { motion } from 'framer-motion' +import { + Monitor, + ExternalLink, + CheckCircle2, + AlertCircle, + Loader2, + Clock, + TrendingUp, + TrendingDown, + Minus, + Maximize2, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import type { AgentStatus } from '@/lib/types' +import { cn } from '@/lib/utils' + +interface AgentCardProps { + agent: AgentStatus + onExpandPreview?: (agent: AgentStatus) => void +} + +export function AgentCard({ agent, onExpandPreview }: AgentCardProps) { + const [imageError, setImageError] = useState(false) + + const getStatusIcon = () => { + switch (agent.status) { + case 'pending': + return + case 'running': + return + case 'complete': + return + case 'error': + return + } + } + + const getRecommendationBadge = () => { + if (!agent.result) return null + const rec = agent.result.recommendation + switch (rec) { + case 'buy_now': + return ( + + + Buy Now + + ) + case 'wait': + return ( + + + Wait + + ) + case 'consider': + return ( + + + Consider + + ) + } + } + + return ( + + + +
+
+ {getStatusIcon()} +

{agent.platformName}

+
+ {agent.status === 'complete' && getRecommendationBadge()} +
+ {agent.status === 'running' && agent.currentAction && ( +

{agent.currentAction}

+ )} +
+ + + {/* Live Preview - show when running */} + {agent.status === 'running' && ( +
agent.streamingUrl && onExpandPreview?.(agent)} + > +
+
+ + Live Preview + + + + +
+ {agent.streamingUrl && } +
+
+ {agent.streamingUrl && !imageError ? ( +