diff --git a/game-buying-guide/.env.example b/game-buying-guide/.env.example
new file mode 100644
index 00000000..697b3237
--- /dev/null
+++ b/game-buying-guide/.env.example
@@ -0,0 +1 @@
+MINO_API_KEY = YOUR_API_KEY
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/README.md b/game-buying-guide/README.md
new file mode 100644
index 00000000..d11a9e6a
--- /dev/null
+++ b/game-buying-guide/README.md
@@ -0,0 +1,172 @@
+# GamePulse
+
+**Live:** [https://v0-game-buying-guide.vercel.app/](https://v0-game-buying-guide.vercel.app/)
+
+GamePulse helps users decide **whether to buy a video game now or wait for a better deal**.
+It compares pricing, discounts, and store signals across **10 major gaming platforms in parallel** using **Mino autonomous browser agents**, then surfaces a clear recommendation for each store.
+
+Instead of relying on price-tracking APIs or scraped datasets, GamePulse launches real browser agents that visit each store, observe the live page, and return structured pricing analysis in real time.
+
+---
+
+## Demo
+
+https://github.com/user-attachments/assets/61c22b80-2cfc-40a6-bc3a-7d5917cf71a9
+
+---
+
+## Mino API Usage
+
+GamePulse uses the **TinyFish SSE Browser Automation API** to analyze multiple game stores simultaneously.
+
+For each platform (Steam, Epic, PlayStation Store, etc.), the app launches a Mino agent that:
+- Navigates to the store search page
+- Locates the requested game
+- Extracts pricing, discounts, and sale signals
+- Returns a structured JSON recommendation
+
+### Example API Call
+
+```ts
+const response = await fetch("https://mino.ai/v1/automation/run-sse", {
+ method: "POST",
+ headers: {
+ "X-API-Key": process.env.MINO_API_KEY,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ url: platformSearchUrl,
+ goal: `
+You are analyzing a game store page to help a user decide
+whether to buy "${gameTitle}" now or wait.
+
+Observe:
+- Current price
+- Sale or discount indicators
+- User ratings and review signals
+
+Return a JSON object with pricing and a recommendation.
+`,
+ timeout: 300000,
+ }),
+})
+```
+
+The response streams **Server-Sent Events (SSE)**, including:
+
+- `STREAMING_URL` → live browser preview of the agent
+
+- `STATUS` → navigation and extraction progress
+
+- `COMPLETE` → final structured pricing analysis JSON
+
+## How It Works
+
+1. User enters a game title (e.g., Elden Ring)
+
+2. Platform discovery generates search URLs from a curated list of 10 stores
+
+3. Parallel Mino agents launch (one per platform)
+
+4. Live browser previews stream into the UI
+
+5. Results aggregate into a buy / wait / consider recommendation dashboard
+
+## Supported Platforms
+
+GamePulse checks the following platforms for every search:
+
+- Steam
+
+- Epic Games Store
+
+- GOG
+
+- PlayStation Store
+
+- Xbox Store
+
+- Nintendo eShop
+
+- Humble Bundle
+
+- Green Man Gaming
+
+- Fanatical
+
+- CDKeys
+
+No external discovery APIs or LLMs are used — the platform list is curated and deterministic.
+
+
+## How to Run
+**Prerequisites**
+- Node.js 18+
+- A Mino API key [get one here](https://mino.ai/api-keys)
+
+## Setup
+
+1. Install dependencies:
+```bash
+cd game-buying-guide
+npm install
+```
+
+
+2. Create a .env.local file:
+```bash
+MINO_API_KEY=your_mino_api_key_here
+```
+
+3. Start the dev server:
+```bash
+npm run dev
+```
+
+Open http://localhost:3000
+
+## Architecture Diagram
+```bash
+┌─────────────────────────────────────────────────────────┐
+│ User (Browser) │
+│ ┌─────────────────────────────────────────────────┐ │
+│ │ Next.js Frontend │ │
+│ │ │ │
+│ │ 1. Enter game title │ │
+│ │ 2. View 10 live agent cards │ │
+│ │ 3. See buy / wait recommendations │ │
+│ └──────────────────┬──────────────────────────────┘ │
+└─────────────────────┼───────────────────────────────────┘
+ │ POST /api/analyze-platform (x10, parallel)
+ ▼
+┌─────────────────────────────────────────────────────────┐
+│ Next.js API Routes │
+│ │
+│ - /api/discover-platforms │
+│ - /api/analyze-platform → Mino SSE proxy │
+└─────────────────────┬───────────────────────────────────┘
+ │ POST /v1/automation/run-sse
+ ▼
+┌─────────────────────────────────────────────────────────┐
+│ Mino API │
+│ │
+│ - Spins up autonomous browser agents │
+│ - Streams live previews and status │
+│ - Returns structured pricing JSON │
+└──────────┬──────────┬──────────┬──────────┬────────────┘
+ ▼ ▼ ▼ ▼
+ Steam Epic PlayStation Xbox ... (10 platforms)
+```
+
+## Environment Variables
+
+MINO_API_KEY - API key for Mino browser automation
+
+
+## Notes
+
+- All platform analysis is performed via live browser automation
+
+- No price databases, scraping services, or AI discovery APIs are used
+
+- Results reflect real-time store state, not cached data
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..8b137891
--- /dev/null
+++ b/game-buying-guide/app/globals.css
@@ -0,0 +1 @@
+
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}
+
+
+ Try Again
+
+
+
+ )}
+
+ {/* 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 */}
+
+
+ )
+}
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 ? (
+
+
+ )}
+
+ {/* Results */}
+ {agent.status === 'complete' && agent.result && (
+
+ {/* Price Info */}
+
+ {agent.result.current_price}
+ {agent.result.original_price && agent.result.is_on_sale && (
+ <>
+
+ {agent.result.original_price}
+
+ {agent.result.discount_percentage && (
+
+ -{agent.result.discount_percentage}
+
+ )}
+ >
+ )}
+
+
+ {/* Sale Info */}
+ {agent.result.sale_ends && (
+
Sale ends: {agent.result.sale_ends}
+ )}
+
+ {/* Rating */}
+ {agent.result.user_rating && (
+
+ Rating: {agent.result.user_rating}
+ {agent.result.review_count && ` (${agent.result.review_count} reviews)`}
+
+ )}
+
+ {/* Reasoning */}
+
{agent.result.reasoning}
+
+ {/* Pros & Cons */}
+
+ {agent.result.pros.length > 0 && (
+
+
Pros
+
+ {agent.result.pros.slice(0, 3).map((pro, i) => (
+
+ +
+ {pro}
+
+ ))}
+
+
+ )}
+ {agent.result.cons.length > 0 && (
+
+
Cons
+
+ {agent.result.cons.slice(0, 3).map((con, i) => (
+
+ -
+ {con}
+
+ ))}
+
+
+ )}
+
+
+ {/* Buy Button */}
+
+
+ Buy on {agent.platformName}
+
+
+
+
+ )}
+
+ {/* Error State */}
+ {agent.status === 'error' && (
+
+
+
No content available
+
+ )}
+
+ {/* Pending State */}
+ {agent.status === 'pending' && (
+
+ Waiting to start analysis...
+
+ )}
+
+
+
+ )
+}
diff --git a/game-buying-guide/components/agent-grid.tsx b/game-buying-guide/components/agent-grid.tsx
new file mode 100644
index 00000000..de903f2f
--- /dev/null
+++ b/game-buying-guide/components/agent-grid.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { useState } from 'react'
+import { AnimatePresence } from 'framer-motion'
+import { AgentCard } from '@/components/agent-card'
+import { LiveBrowserPreview } from '@/components/live-browser-preview'
+import type { AgentStatus } from '@/lib/types'
+
+interface AgentGridProps {
+ agents: AgentStatus[]
+}
+
+export function AgentGrid({ agents }: AgentGridProps) {
+ const [expandedAgent, setExpandedAgent] = useState(null)
+
+ if (agents.length === 0) return null
+
+ const runningCount = agents.filter((a) => a.status === 'running').length
+ const completeCount = agents.filter((a) => a.status === 'complete').length
+ const pendingCount = agents.filter((a) => a.status === 'pending').length
+
+ return (
+ <>
+
+
Platform Analysis
+
+ {runningCount > 0 && (
+
+
+
+
+
+ {runningCount} analyzing
+
+ )}
+ {completeCount > 0 && {completeCount} complete }
+ {pendingCount > 0 && {pendingCount} pending }
+
+
+
+
+ {agents.map((agent) => (
+
setExpandedAgent(a)}
+ />
+ ))}
+
+
+
+ {expandedAgent && expandedAgent.streamingUrl && (
+ setExpandedAgent(null)}
+ />
+ )}
+
+ >
+ )
+}
diff --git a/game-buying-guide/components/live-browser-preview.tsx b/game-buying-guide/components/live-browser-preview.tsx
new file mode 100644
index 00000000..c9a47b5a
--- /dev/null
+++ b/game-buying-guide/components/live-browser-preview.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { motion } from 'framer-motion'
+import { Monitor, X, Maximize2, Minimize2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+
+interface LiveBrowserPreviewProps {
+ streamingUrl: string
+ platformName: string
+ onClose: () => void
+}
+
+export function LiveBrowserPreview({ streamingUrl, platformName, onClose }: LiveBrowserPreviewProps) {
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ setIsLoading(true)
+ }, [streamingUrl])
+
+ return (
+
+ {/* Header */}
+
+
+
+ Live: {platformName}
+
+
+
+
+
+
+ setIsExpanded(!isExpanded)}>
+ {isExpanded ? : }
+
+
+
+
+
+
+
+ {/* Browser Content */}
+
+ {isLoading && (
+
+
+
+
Connecting to browser...
+
+
+ )}
+
+
+ )
+}
diff --git a/game-buying-guide/components/results-summary.tsx b/game-buying-guide/components/results-summary.tsx
new file mode 100644
index 00000000..f35dd634
--- /dev/null
+++ b/game-buying-guide/components/results-summary.tsx
@@ -0,0 +1,132 @@
+'use client'
+
+import { motion } from 'framer-motion'
+import { TrendingUp, TrendingDown, Minus, ExternalLink, Trophy, Clock, Sparkles } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import type { AgentStatus } from '@/lib/types'
+
+interface ResultsSummaryProps {
+ agents: AgentStatus[]
+ gameName: string
+}
+
+export function ResultsSummary({ agents, gameName }: ResultsSummaryProps) {
+ const completedAgents = agents.filter((a) => a.status === 'complete' && a.result)
+
+ if (completedAgents.length === 0) return null
+
+ // Find best deal
+ const buyNowAgents = completedAgents.filter((a) => a.result?.recommendation === 'buy_now')
+ const waitAgents = completedAgents.filter((a) => a.result?.recommendation === 'wait')
+ const considerAgents = completedAgents.filter((a) => a.result?.recommendation === 'consider')
+
+ // Find lowest price
+ const pricesWithPlatforms = completedAgents
+ .map((a) => {
+ const priceStr = a.result?.current_price || ''
+ const price = parseFloat(priceStr.replace(/[^0-9.]/g, '')) || Infinity
+ return { agent: a, price }
+ })
+ .filter((p) => p.price !== Infinity)
+ .sort((a, b) => a.price - b.price)
+
+ const lowestPriceAgent = pricesWithPlatforms[0]?.agent
+
+ const overallRecommendation =
+ buyNowAgents.length > waitAgents.length ? 'buy_now' : waitAgents.length > buyNowAgents.length ? 'wait' : 'consider'
+
+ return (
+
+
+
+
+
+ Analysis Summary for {gameName}
+
+
+
+
+ {/* Overall Recommendation */}
+
+ {overallRecommendation === 'buy_now' ? (
+ <>
+
+
+ Buy Now
+
+
+ {buyNowAgents.length} of {completedAgents.length} platforms recommend buying now
+
+ >
+ ) : overallRecommendation === 'wait' ? (
+ <>
+
+
+ Wait for Sale
+
+
+ {waitAgents.length} of {completedAgents.length} platforms suggest waiting
+
+ >
+ ) : (
+ <>
+
+
+ Consider
+
+
Mixed recommendations - your choice
+ >
+ )}
+
+
+ {/* Best Price */}
+ {lowestPriceAgent && lowestPriceAgent.result && (
+
+
+
Best Price
+
{lowestPriceAgent.result.current_price}
+
{lowestPriceAgent.platformName}
+
+
+ Buy Now
+
+
+
+ )}
+
+ {/* Quick Stats */}
+
+
Platform Breakdown
+
+ {buyNowAgents.length > 0 && (
+
+
+ {buyNowAgents.length} Buy Now
+
+ )}
+ {waitAgents.length > 0 && (
+
+
+ {waitAgents.length} Wait
+
+ )}
+ {considerAgents.length > 0 && (
+
+
+ {considerAgents.length} Consider
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/game-buying-guide/components/search-form.tsx b/game-buying-guide/components/search-form.tsx
new file mode 100644
index 00000000..718f639f
--- /dev/null
+++ b/game-buying-guide/components/search-form.tsx
@@ -0,0 +1,80 @@
+'use client'
+
+import React from "react"
+
+import { useState } from 'react'
+import { Search, Gamepad2, Loader2 } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+
+interface SearchFormProps {
+ onSearch: (query: string) => void
+ isLoading: boolean
+}
+
+export function SearchForm({ onSearch, isLoading }: SearchFormProps) {
+ const [query, setQuery] = useState('')
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (query.trim() && !isLoading) {
+ onSearch(query.trim())
+ }
+ }
+
+ return (
+
+
+
+
+
GamePulse
+
+
+ Should you buy now or wait? Let AI analyze prices across platforms.
+
+
+
+
+
+
+ {['Elden Ring', 'Cyberpunk 2077', 'Baldurs Gate 3', 'Red Dead Redemption 2'].map((game) => (
+ setQuery(game)}
+ className="px-3 py-1.5 text-sm rounded-full bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
+ >
+ {game}
+
+ ))}
+
+
+ )
+}
diff --git a/game-buying-guide/components/steamdb-price-card.tsx b/game-buying-guide/components/steamdb-price-card.tsx
new file mode 100644
index 00000000..5460e7fe
--- /dev/null
+++ b/game-buying-guide/components/steamdb-price-card.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import type { SteamDBAgentStatus } from '@/lib/types'
+import { TrendingDown, History, ExternalLink, Loader2, AlertCircle, Monitor, Calendar, DollarSign } from 'lucide-react'
+import { useState } from 'react'
+
+interface SteamDBPriceCardProps {
+ agent: SteamDBAgentStatus
+ gameName: string
+}
+
+export function SteamDBPriceCard({ agent, gameName }: SteamDBPriceCardProps) {
+ const [expanded, setExpanded] = useState(false)
+
+ if (agent.status === 'pending') {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ Steam Historic Price Data
+
+ via SteamDB
+
+
+
All-time lowest price on Steam
+
+
+ {agent.status === 'running' && (
+
+
+ {agent.currentAction || 'Analyzing SteamDB...'}
+
+ )}
+
+
+
+
+ {/* Running State with Live Preview */}
+ {agent.status === 'running' && (
+
+ {agent.streamingUrl ? (
+
setExpanded(!expanded)}
+ >
+
+
+
+ Live Browser Preview
+
+
+
+
+
+
Click to {expanded ? 'collapse' : 'expand'}
+
+
+
+
+
+ ) : (
+
+
+
+ Connecting to browser...
+
+
+ )}
+
+ )}
+
+ {/* Complete State */}
+ {agent.status === 'complete' && agent.result && (
+
+
+ {/* Historic Lowest Price */}
+
+
+
+ Historic Low
+
+
+ {agent.result.historic_lowest_price || 'N/A'}
+
+ {agent.result.historic_lowest_discount && (
+
+ {agent.result.historic_lowest_discount} off
+
+ )}
+
+
+ {/* Date of Historic Low */}
+
+
+
+ When
+
+
+ {agent.result.historic_lowest_date || 'Unknown'}
+
+
+
+ {/* Current Steam Price */}
+
+
+
+ Current Steam Price
+
+
+ {agent.result.current_steam_price || 'N/A'}
+
+ {agent.result.current_discount && (
+
+ {agent.result.current_discount} off
+
+ )}
+
+
+
+ {/* Historic Low Alert */}
+ {agent.result.is_current_historic_low && (
+
+
+
+ Current price matches or beats the historic low! Great time to buy.
+
+
+ )}
+
+ {/* Recommendation */}
+ {agent.result.recommendation && (
+
+
{agent.result.recommendation}
+
+ )}
+
+ {/* Link to SteamDB */}
+
+ View full price history on SteamDB
+
+
+
+ )}
+
+ {/* Error State */}
+ {agent.status === 'error' && (
+
+ )}
+
+
+ )
+}
diff --git a/game-buying-guide/components/theme-provider.tsx b/game-buying-guide/components/theme-provider.tsx
new file mode 100644
index 00000000..55c2f6eb
--- /dev/null
+++ b/game-buying-guide/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ThemeProvider as NextThemesProvider,
+ type ThemeProviderProps,
+} from 'next-themes'
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children}
+}
diff --git a/game-buying-guide/components/ui/accordion.tsx b/game-buying-guide/components/ui/accordion.tsx
new file mode 100644
index 00000000..e538a33b
--- /dev/null
+++ b/game-buying-guide/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import * as AccordionPrimitive from '@radix-ui/react-accordion'
+import { ChevronDownIcon } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/game-buying-guide/components/ui/alert-dialog.tsx b/game-buying-guide/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..97044526
--- /dev/null
+++ b/game-buying-guide/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/game-buying-guide/components/ui/alert.tsx b/game-buying-guide/components/ui/alert.tsx
new file mode 100644
index 00000000..e6751abe
--- /dev/null
+++ b/game-buying-guide/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/game-buying-guide/components/ui/aspect-ratio.tsx b/game-buying-guide/components/ui/aspect-ratio.tsx
new file mode 100644
index 00000000..40bb1208
--- /dev/null
+++ b/game-buying-guide/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/game-buying-guide/components/ui/avatar.tsx b/game-buying-guide/components/ui/avatar.tsx
new file mode 100644
index 00000000..aa98465a
--- /dev/null
+++ b/game-buying-guide/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/game-buying-guide/components/ui/badge.tsx b/game-buying-guide/components/ui/badge.tsx
new file mode 100644
index 00000000..fc4126b7
--- /dev/null
+++ b/game-buying-guide/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/game-buying-guide/components/ui/breadcrumb.tsx b/game-buying-guide/components/ui/breadcrumb.tsx
new file mode 100644
index 00000000..1750ff26
--- /dev/null
+++ b/game-buying-guide/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a'
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/game-buying-guide/components/ui/button-group.tsx b/game-buying-guide/components/ui/button-group.tsx
new file mode 100644
index 00000000..09d44309
--- /dev/null
+++ b/game-buying-guide/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'div'
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/game-buying-guide/components/ui/button.tsx b/game-buying-guide/components/ui/button.tsx
new file mode 100644
index 00000000..f64632d1
--- /dev/null
+++ b/game-buying-guide/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/game-buying-guide/components/ui/calendar.tsx b/game-buying-guide/components/ui/calendar.tsx
new file mode 100644
index 00000000..eaa373e2
--- /dev/null
+++ b/game-buying-guide/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'flex gap-4 flex-col md:flex-row relative',
+ defaultClassNames.months,
+ ),
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
+ nav: cn(
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute bg-popover inset-0 opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'select-none font-medium',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
+ defaultClassNames.caption_label,
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('flex w-full mt-2', defaultClassNames.week),
+ week_number_header: cn(
+ 'select-none w-(--cell-size)',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] select-none text-muted-foreground',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+ span]:text-xs [&>span]:opacity-70',
+ defaultClassNames.day,
+ className,
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Calendar, CalendarDayButton }
diff --git a/game-buying-guide/components/ui/card.tsx b/game-buying-guide/components/ui/card.tsx
new file mode 100644
index 00000000..db7dd3cc
--- /dev/null
+++ b/game-buying-guide/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/game-buying-guide/components/ui/carousel.tsx b/game-buying-guide/components/ui/carousel.tsx
new file mode 100644
index 00000000..d4a768e7
--- /dev/null
+++ b/game-buying-guide/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+'use client'
+
+import * as React from 'react'
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from 'embla-carousel-react'
+import { ArrowLeft, ArrowRight } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: 'horizontal' | 'vertical'
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error('useCarousel must be used within a ')
+ }
+
+ return context
+}
+
+function Carousel({
+ orientation = 'horizontal',
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<'div'> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === 'horizontal' ? 'x' : 'y',
+ },
+ plugins,
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext],
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) return
+ onSelect(api)
+ api.on('reInit', onSelect)
+ api.on('select', onSelect)
+
+ return () => {
+ api?.off('select', onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+}
+
+function CarouselPrevious({
+ className,
+ variant = 'outline',
+ size = 'icon',
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+
+ Previous slide
+
+ )
+}
+
+function CarouselNext({
+ className,
+ variant = 'outline',
+ size = 'icon',
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+
+ Next slide
+
+ )
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/game-buying-guide/components/ui/chart.tsx b/game-buying-guide/components/ui/chart.tsx
new file mode 100644
index 00000000..421fe589
--- /dev/null
+++ b/game-buying-guide/components/ui/chart.tsx
@@ -0,0 +1,353 @@
+'use client'
+
+import * as React from 'react'
+import * as RechartsPrimitive from 'recharts'
+
+import { cn } from '@/lib/utils'
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: '', dark: '.dark' } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error('useChart must be used within a ')
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<'div'> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >['children']
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color,
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/game-buying-guide/public/placeholder-logo.png b/game-buying-guide/public/placeholder-logo.png
new file mode 100644
index 00000000..8a792ac2
Binary files /dev/null and b/game-buying-guide/public/placeholder-logo.png differ
diff --git a/game-buying-guide/public/placeholder-logo.svg b/game-buying-guide/public/placeholder-logo.svg
new file mode 100644
index 00000000..b1695aaf
--- /dev/null
+++ b/game-buying-guide/public/placeholder-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/game-buying-guide/public/placeholder-user.jpg b/game-buying-guide/public/placeholder-user.jpg
new file mode 100644
index 00000000..6fa7543d
Binary files /dev/null and b/game-buying-guide/public/placeholder-user.jpg differ
diff --git a/game-buying-guide/public/placeholder.jpg b/game-buying-guide/public/placeholder.jpg
new file mode 100644
index 00000000..6bfe9633
Binary files /dev/null and b/game-buying-guide/public/placeholder.jpg differ
diff --git a/game-buying-guide/public/placeholder.svg b/game-buying-guide/public/placeholder.svg
new file mode 100644
index 00000000..e763910b
--- /dev/null
+++ b/game-buying-guide/public/placeholder.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/game-buying-guide/styles/globals.css b/game-buying-guide/styles/globals.css
new file mode 100644
index 00000000..dc2aea17
--- /dev/null
+++ b/game-buying-guide/styles/globals.css
@@ -0,0 +1,125 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.145 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.145 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.396 0.141 25.723);
+ --destructive-foreground: oklch(0.637 0.237 25.331);
+ --border: oklch(0.269 0 0);
+ --input: oklch(0.269 0 0);
+ --ring: oklch(0.439 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.439 0 0);
+}
+
+@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);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/game-buying-guide/tsconfig.json b/game-buying-guide/tsconfig.json
new file mode 100644
index 00000000..4b2dc7ba
--- /dev/null
+++ b/game-buying-guide/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "target": "ES6",
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}