diff --git a/home-snipe/.gitignore b/home-snipe/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/home-snipe/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/home-snipe/README.md b/home-snipe/README.md new file mode 100644 index 0000000..f9998f8 --- /dev/null +++ b/home-snipe/README.md @@ -0,0 +1,169 @@ +# TinyFish - HDB Deal Sniper + +**Live Demo:** https://home-snipe.vercel.app + +Real-time Singapore HDB resale deal finder that uses the **Source → Extract → Present** pipeline pattern. Gemini generates property listing URLs, Mino browser agents scrape them in parallel, and Gemini analyzes results to identify underpriced deals. + +**Status**: ✅ Working + +--- + +## Demo + +![HDB Deal Sniper Demo](demo.jpg) + +--- + +## How Mino API is Used + +The Mino API powers browser automation for this use case. See the code snippet below for implementation details. + +### Code Snippet + +```bash +npm install +export MINO_API_KEY=your_key +export GEMINI_API_KEY=your_key +npm run dev +``` + +--- + +## How to Run + +### Prerequisites + +- Node.js 18+ +- Mino API key (get from [mino.ai](https://mino.ai)) + +### Setup + +1. Clone the repository: +```bash +git clone https://github.com/tinyfish-io/TinyFish-cookbook +cd TinyFish-cookbook/home-snipe +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Create `.env.local` file: +```bash +MINO_API_KEY=xxx # Browser automation +GEMINI_API_KEY=xxx # URL generation + deal analysis +``` + +4. Run the development server: +```bash +npm run dev +``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser + +--- + +## Architecture Diagram + +```mermaid +flowchart TB + subgraph Input[USER INPUT] + Form[Town + Flat Type + Discount %] + end + subgraph Phase1[PHASE 1: URL GENERATION] + Gemini1[Gemini AI] + URLs[10 Property URLs] + end + subgraph Phase2[PHASE 2: PARALLEL SCRAPING] + A1[Agent 1] + A2[Agent 2] + A3[Agent 3] + A10[Agent 10...] + end + subgraph Phase3[PHASE 3: DEAL ANALYSIS] + Gemini2[Gemini AI] + Deals[Underpriced Deals] + end + subgraph Output[RESULTS] + UI[Live Results Sidebar] + end + Form --> Gemini1 + Gemini1 --> URLs + URLs --> A1 + URLs --> A2 + URLs --> A3 + URLs --> A10 + A1 --> Gemini2 + A2 --> Gemini2 + A3 --> Gemini2 + A10 --> Gemini2 + Gemini2 --> Deals + Deals --> UI + A1 -.-> UI + A2 -.-> UI + A3 -.-> UI + A10 -.-> UI +``` + +```mermaid +sequenceDiagram + participant U as User + participant FE as Frontend + participant API as API Route + participant G as Gemini AI + participant M as Mino Agents + U->>FE: Select town, flat type, discount + FE->>API: POST /api/search + API->>G: Generate property URLs + G-->>API: 10 URLs from property sites + API->>M: Launch 10 parallel agents + M-->>API: Stream live URLs + API-->>FE: Forward streaming URLs + FE-->>U: Show live agent grid + M-->>API: Extract listings + API-->>FE: Stream raw listings + FE-->>U: Update results sidebar + API->>G: Analyze for deals + G-->>API: Underpriced listings + API-->>FE: DEALS_FOUND + FE-->>U: Highlight deals +``` + +```mermaid +classDiagram + class RawListing { + +string address + +string block + +string street + +string town + +string flatType + +string floorLevel + +string sqft + +number askingPrice + +string timePosted + +string agentName + +string agentPhone + +string listingUrl + +string source + } + class DealResult { + +string address + +string town + +string flatType + +number askingPrice + +number marketValue + +number discountPercent + +string timePosted + +string reasoning + } + class AgentStatus { + +number id + +string url + +string status + +string streamingUrl + +string[] steps + } +``` + + diff --git a/home-snipe/app/api/search/route.ts b/home-snipe/app/api/search/route.ts new file mode 100644 index 0000000..5371b75 --- /dev/null +++ b/home-snipe/app/api/search/route.ts @@ -0,0 +1,463 @@ +import { NextRequest } from "next/server"; + +const MINO_API_URL = "https://mino.ai/v1/automation/run-sse"; +const GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"; + +export const maxDuration = 300; // 5 minutes max for this complex flow + +interface SearchParams { + town: string; + flatType: string; + discountThreshold: number; +} + +// Helper to call Gemini API +async function callGemini(prompt: string): Promise { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) throw new Error("GEMINI_API_KEY not configured"); + + const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0.7, + maxOutputTokens: 4096, + }, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Gemini API error: ${response.status} ${error}`); + } + + const data = await response.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text || ""; +} + +// Helper to scrape a URL with Mino +async function scrapeWithMino( + url: string, + goal: string, + agentId: number, + sendEvent: (data: object) => void +): Promise { + const apiKey = process.env.MINO_API_KEY; + if (!apiKey) throw new Error("MINO_API_KEY not configured"); + + sendEvent({ type: "AGENT_STATUS", agentId, status: "navigating" }); + + try { + const response = await fetch(MINO_API_URL, { + method: "POST", + headers: { + "X-API-Key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url, + goal, + browser_profile: "stealth", // Property sites often have bot protection + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Mino API error: ${response.status} ${error}`); + } + + if (!response.body) throw new Error("No response body"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let result = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + + try { + const event = JSON.parse(line.slice(6)); + + // Handle STREAMING_URL event type (from Mino docs) + if (event.type === "STREAMING_URL") { + const streamUrl = event.streamingUrl || event.url || event.liveUrl; + if (streamUrl) { + sendEvent({ + type: "AGENT_STATUS", + agentId, + status: "navigating", + streamingUrl: streamUrl + }); + } + } + + // Also check for streamingUrl in any event (backward compatibility) + if (event.streamingUrl && event.type !== "STREAMING_URL") { + sendEvent({ + type: "AGENT_STATUS", + agentId, + status: "navigating", + streamingUrl: event.streamingUrl + }); + } + + // Handle PROGRESS events (from Mino docs) + if (event.type === "PROGRESS" && event.purpose) { + sendEvent({ type: "AGENT_STEP", agentId, step: event.purpose }); + + // Update status based on action + if (event.purpose.toLowerCase().includes("extract") || event.purpose.toLowerCase().includes("collect")) { + sendEvent({ type: "AGENT_STATUS", agentId, status: "extracting" }); + } + } + + // Forward other step info + const stepMessage = event.purpose || event.action || event.message || event.step; + if (stepMessage && event.type !== "COMPLETE" && event.type !== "PROGRESS" && event.type !== "STREAMING_URL" && event.type !== "STARTED" && event.type !== "HEARTBEAT") { + sendEvent({ type: "AGENT_STEP", agentId, step: stepMessage }); + + // Update status based on action + if (stepMessage.toLowerCase().includes("extract") || stepMessage.toLowerCase().includes("collect")) { + sendEvent({ type: "AGENT_STATUS", agentId, status: "extracting" }); + } + } + + // Handle completion + if (event.type === "COMPLETE" && event.status === "COMPLETED") { + result = event.resultJson; + sendEvent({ type: "AGENT_COMPLETE", agentId, result }); + return result; + } + + // Handle errors + if (event.type === "ERROR" || event.status === "FAILED") { + throw new Error(event.message || "Scraping failed"); + } + } catch { + // Ignore parse errors + } + } + } + + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + sendEvent({ type: "AGENT_ERROR", agentId, error: message }); + return null; + } +} + +export async function POST(request: NextRequest) { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const sendEvent = (data: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + try { + const { town, flatType, discountThreshold }: SearchParams = await request.json(); + + if (!town || !flatType) { + sendEvent({ type: "ERROR", message: "Town and flat type are required" }); + controller.close(); + return; + } + + // Phase 1: Generate URLs with Gemini + sendEvent({ type: "PHASE", phase: "generating_urls", message: "Asking Gemini to find property listing URLs..." }); + + // Map flat type to bedroom count for better URL generation + const bedroomMap: Record = { + "2-room": { min: 1, max: 1 }, + "3-room": { min: 2, max: 2 }, + "4-room": { min: 3, max: 3 }, + "5-room": { min: 4, max: 4 }, + "executive": { min: 4, max: 5 }, + }; + const bedrooms = bedroomMap[flatType] || { min: 3, max: 3 }; + + const urlPrompt = `You are a Singapore property search expert. I need to find ${flatType} HDB resale flats in ${town}, Singapore. + +Generate exactly 10 search URLs from these Singapore property listing websites. Use the EXACT URL patterns below: + +**PropertyGuru (generate 4 URLs with different filters):** +- Base: https://www.propertyguru.com.sg/property-for-sale +- Parameters: ?market=residential&property_type=H&listing_type=sale&freetext=TOWN&minbed=${bedrooms.min}&maxbed=${bedrooms.max}&sort=date&order=desc +- Variations: different sort orders, add "hdb" to freetext, use property_type_code[]=HDB + +**99.co (generate 3 URLs with different filters):** +- Pattern: https://www.99.co/singapore/sale/hdb?query=TOWN&property_segments=hdb&listing_type=resale&room_type=${flatType} +- Variations: different room_type filters, add sort_field=updated_at + +**SRX (generate 3 URLs with different filters):** +- Pattern: https://www.srx.com.sg/search/sale/hdb?search=TOWN&propertyType=HDB +- Variations: add bedrooms, sort by date + +Replace TOWN with "${town}" (URL encoded if needed). + +IMPORTANT: Return ONLY a valid JSON array of 10 complete, working URLs. No markdown, no explanation: +["https://...", "https://...", ...]`; + + let urls: string[] = []; + + // Helper to generate fallback URLs from top 10 Singapore property sites + // Note: URLs are general (no room filter) - agents extract ALL listings with flatType field + // Client-side filtering happens in the UI based on user's flat type selection + const generateFallbackUrls = (searchTown: string): string[] => { + const townEncoded = encodeURIComponent(searchTown); + const townSlug = searchTown.toLowerCase().replace(/ /g, '-'); + + return [ + // 1. PropertyGuru - Market leader with highest volume + `https://www.propertyguru.com.sg/property-for-sale?market=residential&property_type=H&listing_type=sale&freetext=${townEncoded}&sort=date&order=desc`, + + // 2. 99.co - Clean interface, map-based search + `https://www.99.co/singapore/sale/hdb?query=${townEncoded}&property_segments=hdb&listing_type=resale`, + + // 3. SRX - Known for X-Value pricing tools + `https://www.srx.com.sg/search/sale/hdb?search=${townEncoded}&propertyType=HDB`, + + // 4. HDB Flat Portal - Official government source + `https://homes.hdb.gov.sg/home/finding-a-flat/resale`, + + // 5. Ohmyhome - DIY/lower fee platform + `https://ohmyhome.com/en-sg/buy?propertyType=hdb&location=${townEncoded}`, + + // 6. Carousell Property - Direct from owners + `https://www.carousell.sg/property-for-sale/hdb/?search=${townEncoded}`, + + // 7. Mogul.sg - Smart location/geospatial search + `https://mogul.sg/hdb-for-sale?location=${townEncoded}`, + + // 8. EdgeProp - Data and investment analysis + `https://www.edgeprop.sg/property-for-sale?property_type=hdb&district=${townSlug}`, + + // 9. PropNex - Largest agency in Singapore + `https://www.propnex.com/hdb-for-sale?location=${townEncoded}`, + + // 10. ERA Singapore - Second largest agency + `https://www.era.com.sg/property-search/?type=hdb&location=${townEncoded}&listing_type=sale`, + ]; + }; + + try { + sendEvent({ type: "GEMINI_THINKING", message: "Gemini is researching property sites..." }); + + const urlResponse = await callGemini(urlPrompt); + + // Extract JSON array from response (handle markdown code blocks too) + let jsonStr = urlResponse; + + // Remove markdown code blocks if present + const codeBlockMatch = urlResponse.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + jsonStr = codeBlockMatch[1]; + } + + // Find JSON array + const jsonMatch = jsonStr.match(/\[[\s\S]*\]/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + if (Array.isArray(parsed) && parsed.length > 0) { + // Validate URLs - only keep valid ones + urls = parsed.filter((url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } + }); + } + } + + if (urls.length === 0) { + throw new Error("Gemini did not return valid property URLs"); + } + + sendEvent({ + type: "GEMINI_SUCCESS", + message: `Gemini found ${urls.length} property listing URLs`, + source: "gemini" + }); + + } catch (e) { + // Fallback to hardcoded URLs from top 10 Singapore property sites + const errorMessage = e instanceof Error ? e.message : "Unknown error"; + sendEvent({ + type: "GEMINI_FALLBACK", + message: `Gemini unavailable (${errorMessage.slice(0, 50)}...), using top 10 Singapore property sites`, + }); + + urls = generateFallbackUrls(town); + + sendEvent({ + type: "FALLBACK_URLS", + message: `Using ${urls.length} URLs from popular Singapore property sites`, + source: "fallback" + }); + } + + // Limit to 10 URLs + urls = urls.slice(0, 10); + sendEvent({ type: "URLS_GENERATED", urls }); + + // Phase 2: Scrape URLs with Mino in parallel + sendEvent({ type: "PHASE", phase: "scraping", message: `Deploying ${urls.length} browser agents...` }); + + const scrapeGoal = `Navigate to this HDB listing page, extract all visible properties including: exact address, block number, street name, town, flat type (2-room, 3-room, 4-room, 5-room, executive), floor level, square footage, asking price in SGD, listing timestamp or date posted, agent name, agent phone, listing URL. Return as structured JSON array of listings. If the page shows multiple listings, extract all of them (up to 10). Return JSON format: +{ + "listings": [ + { + "address": "Block 123 Street Name", + "block": "123", + "street": "Street Name", + "town": "Town Name", + "flatType": "4-room", + "floorLevel": "10-12", + "sqft": "1000", + "askingPrice": 500000, + "timePosted": "2 days ago", + "agentName": "Agent Name", + "agentPhone": "9123 4567", + "listingUrl": "https://..." + } + ] +}`; + + // Run all scraping tasks in parallel + const scrapePromises = urls.map((url, i) => + scrapeWithMino(url, scrapeGoal, i, sendEvent) + ); + + const scrapeResults = await Promise.all(scrapePromises); + + // Collect all listings from all agents + const allListings: Array> = []; + for (const result of scrapeResults) { + if (result) { + if (Array.isArray(result)) { + allListings.push(...result); + } else if (typeof result === "object" && result !== null) { + const resultObj = result as Record; + if (resultObj.listings && Array.isArray(resultObj.listings)) { + allListings.push(...resultObj.listings); + } else { + // Try to find array in result + for (const key of Object.keys(resultObj)) { + if (Array.isArray(resultObj[key])) { + allListings.push(...(resultObj[key] as Array>)); + break; + } + } + } + } + } + } + + sendEvent({ + type: "PHASE", + phase: "analyzing", + message: `Analyzing ${allListings.length} listings with Gemini...` + }); + + // Phase 3: Analyze with Gemini to find deals + if (allListings.length > 0) { + const analyzePrompt = `Analyze these HDB listings: ${JSON.stringify(allListings.slice(0, 50))} + +Based on typical market prices for ${town} ${flatType} flats in Singapore, identify which listings are underpriced by ${discountThreshold}% or more. + +For reference, typical ${flatType} HDB prices in ${town}: +- 3-room: $280,000 - $400,000 +- 4-room: $400,000 - $600,000 +- 5-room: $550,000 - $800,000 +- Executive: $650,000 - $950,000 + +For each potentially underpriced unit, calculate: +1. Estimated market value based on location, size, floor +2. Discount percentage from market value + +Return ONLY a JSON array of underpriced listings: +[ + { + "address": "Full address", + "block": "Block number", + "street": "Street name", + "town": "${town}", + "flatType": "${flatType}", + "floorLevel": "Floor range", + "sqft": "Size in sqft", + "askingPrice": 450000, + "marketValue": 550000, + "discountPercent": 18.2, + "timePosted": "When posted", + "agentName": "Agent name", + "agentPhone": "Phone number", + "listingUrl": "URL", + "reasoning": "Brief reason why this is underpriced" + } +] + +If no listings meet the ${discountThreshold}% discount threshold, return an empty array [].`; + + try { + const analysisResponse = await callGemini(analyzePrompt); + const jsonMatch = analysisResponse.match(/\[[\s\S]*\]/); + + if (jsonMatch) { + const deals = JSON.parse(jsonMatch[0]); + sendEvent({ type: "DEALS_FOUND", deals }); + sendEvent({ type: "COMPLETE", dealCount: deals.length }); + } else { + sendEvent({ type: "DEALS_FOUND", deals: [] }); + sendEvent({ type: "COMPLETE", dealCount: 0 }); + } + } catch { + // If Gemini analysis fails, return raw listings as potential deals + const rawDeals = allListings.slice(0, 10).map(l => ({ + address: l.address || "Unknown", + town: l.town || town, + flatType: l.flatType || flatType, + askingPrice: parseInt(String(l.askingPrice || l.price || "0").replace(/[^0-9]/g, "")) || 0, + timePosted: l.timePosted || l.posted || "-", + agentName: l.agentName || l.agent || "", + agentPhone: l.agentPhone || l.phone || "", + })); + sendEvent({ type: "DEALS_FOUND", deals: rawDeals }); + sendEvent({ type: "COMPLETE", dealCount: rawDeals.length }); + } + } else { + sendEvent({ type: "DEALS_FOUND", deals: [] }); + sendEvent({ type: "COMPLETE", dealCount: 0 }); + } + + controller.close(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "ERROR", message })}\n\n`)); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); +} diff --git a/home-snipe/app/favicon.ico b/home-snipe/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/home-snipe/app/favicon.ico differ diff --git a/home-snipe/app/globals.css b/home-snipe/app/globals.css new file mode 100644 index 0000000..c79f7d8 --- /dev/null +++ b/home-snipe/app/globals.css @@ -0,0 +1,193 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: Plus Jakarta Sans, sans-serif; + --font-mono: Roboto Mono, monospace; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + + --font-serif: Lora, serif; + --radius: 1.25rem; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xl: var(--shadow-2xl); + --shadow-xl: var(--shadow-xl); + --shadow-lg: var(--shadow-lg); + --shadow-md: var(--shadow-md); + --shadow: var(--shadow); + --shadow-sm: var(--shadow-sm); + --shadow-xs: var(--shadow-xs); + --shadow-2xs: var(--shadow-2xs); + --spacing: var(--spacing); + --letter-spacing: var(--letter-spacing); + --shadow-offset-y: var(--shadow-offset-y); + --shadow-offset-x: var(--shadow-offset-x); + --shadow-spread: var(--shadow-spread); + --shadow-blur: var(--shadow-blur); + --shadow-opacity: var(--shadow-opacity); + --color-shadow-color: var(--shadow-color); + --color-destructive-foreground: var(--destructive-foreground); +} + +:root { + --radius: 1.25rem; + --background: oklch(0.9232 0.0026 48.7171); + --foreground: oklch(0.2795 0.0368 260.0310); + --card: oklch(0.9699 0.0013 106.4238); + --card-foreground: oklch(0.2795 0.0368 260.0310); + --popover: oklch(0.9699 0.0013 106.4238); + --popover-foreground: oklch(0.2795 0.0368 260.0310); + --primary: oklch(0.5854 0.2041 277.1173); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.8687 0.0043 56.3660); + --secondary-foreground: oklch(0.4461 0.0263 256.8018); + --muted: oklch(0.9232 0.0026 48.7171); + --muted-foreground: oklch(0.5510 0.0234 264.3637); + --accent: oklch(0.9376 0.0260 321.9388); + --accent-foreground: oklch(0.3729 0.0306 259.7328); + --destructive: oklch(0.6368 0.2078 25.3313); + --border: oklch(0.8687 0.0043 56.3660); + --input: oklch(0.8687 0.0043 56.3660); + --ring: oklch(0.5854 0.2041 277.1173); + --chart-1: oklch(0.5854 0.2041 277.1173); + --chart-2: oklch(0.5106 0.2301 276.9656); + --chart-3: oklch(0.4568 0.2146 277.0229); + --chart-4: oklch(0.3984 0.1773 277.3662); + --chart-5: oklch(0.3588 0.1354 278.6973); + --sidebar: oklch(0.8687 0.0043 56.3660); + --sidebar-foreground: oklch(0.2795 0.0368 260.0310); + --sidebar-primary: oklch(0.5854 0.2041 277.1173); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.9376 0.0260 321.9388); + --sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328); + --sidebar-border: oklch(0.8687 0.0043 56.3660); + --sidebar-ring: oklch(0.5854 0.2041 277.1173); + --destructive-foreground: oklch(1.0000 0 0); + --font-sans: Plus Jakarta Sans, sans-serif; + --font-serif: Lora, serif; + --font-mono: Roboto Mono, monospace; + --shadow-color: hsl(240 4% 60%); + --shadow-opacity: 0.18; + --shadow-blur: 10px; + --shadow-spread: 4px; + --shadow-offset-x: 2px; + --shadow-offset-y: 2px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09); + --shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09); + --shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18); + --shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18); + --shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18); + --shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18); + --shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18); + --shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45); + --tracking-normal: 0em; +} + +.dark { + --background: oklch(0.2244 0.0074 67.4370); + --foreground: oklch(0.9288 0.0126 255.5078); + --card: oklch(0.2801 0.0080 59.3379); + --card-foreground: oklch(0.9288 0.0126 255.5078); + --popover: oklch(0.2801 0.0080 59.3379); + --popover-foreground: oklch(0.9288 0.0126 255.5078); + --primary: oklch(0.6801 0.1583 276.9349); + --primary-foreground: oklch(0.2244 0.0074 67.4370); + --secondary: oklch(0.3359 0.0077 59.4197); + --secondary-foreground: oklch(0.8717 0.0093 258.3382); + --muted: oklch(0.2287 0.0074 67.4469); + --muted-foreground: oklch(0.7137 0.0192 261.3246); + --accent: oklch(0.3896 0.0074 59.4734); + --accent-foreground: oklch(0.8717 0.0093 258.3382); + --destructive: oklch(0.6368 0.2078 25.3313); + --border: oklch(0.3359 0.0077 59.4197); + --input: oklch(0.3359 0.0077 59.4197); + --ring: oklch(0.6801 0.1583 276.9349); + --chart-1: oklch(0.6801 0.1583 276.9349); + --chart-2: oklch(0.5854 0.2041 277.1173); + --chart-3: oklch(0.5106 0.2301 276.9656); + --chart-4: oklch(0.4568 0.2146 277.0229); + --chart-5: oklch(0.3984 0.1773 277.3662); + --sidebar: oklch(0.3359 0.0077 59.4197); + --sidebar-foreground: oklch(0.9288 0.0126 255.5078); + --sidebar-primary: oklch(0.6801 0.1583 276.9349); + --sidebar-primary-foreground: oklch(0.2244 0.0074 67.4370); + --sidebar-accent: oklch(0.3896 0.0074 59.4734); + --sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382); + --sidebar-border: oklch(0.3359 0.0077 59.4197); + --sidebar-ring: oklch(0.6801 0.1583 276.9349); + --destructive-foreground: oklch(0.2244 0.0074 67.4370); + --radius: 1.25rem; + --font-sans: Plus Jakarta Sans, sans-serif; + --font-serif: Lora, serif; + --font-mono: Roboto Mono, monospace; + --shadow-color: hsl(0 0% 0%); + --shadow-opacity: 0.18; + --shadow-blur: 10px; + --shadow-spread: 4px; + --shadow-offset-x: 2px; + --shadow-offset-y: 2px; + --letter-spacing: 0em; + --spacing: 0.25rem; + --shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09); + --shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09); + --shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18); + --shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18); + --shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18); + --shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18); + --shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18); + --shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); + } +} \ No newline at end of file diff --git a/home-snipe/app/layout.tsx b/home-snipe/app/layout.tsx new file mode 100644 index 0000000..de1afff --- /dev/null +++ b/home-snipe/app/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { Plus_Jakarta_Sans, JetBrains_Mono, Source_Serif_4 } from "next/font/google"; +import "./globals.css"; + +const plusJakarta = Plus_Jakarta_Sans({ + variable: "--font-sans", + subsets: ["latin"], + display: "swap", +}); + +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-mono", + subsets: ["latin"], + display: "swap", +}); + +const sourceSerif = Source_Serif_4({ + variable: "--font-serif", + subsets: ["latin"], + display: "swap", +}); + +export const metadata: Metadata = { + title: "HDB Deal Sniper | Find Underpriced Resale Flats", + description: "AI-powered real-time HDB resale deal finder for Singapore", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/home-snipe/app/page.tsx b/home-snipe/app/page.tsx new file mode 100644 index 0000000..45ed42f --- /dev/null +++ b/home-snipe/app/page.tsx @@ -0,0 +1,916 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Loader2, Search, Home as HomeIcon, TrendingDown, ExternalLink, Eye, EyeOff, ListFilter, Sparkles, Bot } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { GridBackground } from "@/components/ui/background-snippets"; + +// Singapore HDB Towns +const HDB_TOWNS = [ + "Ang Mo Kio", "Bedok", "Bishan", "Bukit Batok", "Bukit Merah", + "Bukit Panjang", "Choa Chu Kang", "Clementi", "Geylang", "Hougang", + "Jurong East", "Jurong West", "Kallang/Whampoa", "Marine Parade", + "Pasir Ris", "Punggol", "Queenstown", "Sembawang", "Sengkang", + "Serangoon", "Tampines", "Toa Payoh", "Woodlands", "Yishun" +]; + +const FLAT_TYPES = [ + { value: "2-room", label: "2-Room" }, + { value: "3-room", label: "3-Room" }, + { value: "4-room", label: "4-Room" }, + { value: "5-room", label: "5-Room" }, + { value: "executive", label: "Executive" }, +]; + +interface AgentStatus { + id: number; + url: string; + status: "launching" | "navigating" | "extracting" | "complete" | "error"; + streamingUrl?: string; + steps: string[]; + result?: unknown; + error?: string; +} + +interface RawListing { + address?: string; + block?: string; + street?: string; + town?: string; + flatType?: string; + floorLevel?: string; + sqft?: string; + askingPrice?: number | string; + price?: number | string; + timePosted?: string; + agentName?: string; + agentPhone?: string; + listingUrl?: string; + source?: string; + agentId?: number; +} + +interface DealResult { + address: string; + town: string; + flatType: string; + askingPrice: number; + marketValue?: number; + discountPercent?: number; + timePosted?: string; + agentName?: string; + agentPhone?: string; + listingUrl?: string; + reasoning?: string; +} + +type SearchPhase = "idle" | "generating_urls" | "scraping" | "analyzing" | "complete" | "error"; + +export default function HomePage() { + const [town, setTown] = useState(""); + const [flatType, setFlatType] = useState("4-room"); + const [discountThreshold, setDiscountThreshold] = useState("10"); + + const [phase, setPhase] = useState("idle"); + const [statusMessage, setStatusMessage] = useState(""); + const [agents, setAgents] = useState([]); + const [rawListings, setRawListings] = useState([]); + const [deals, setDeals] = useState([]); + const [selectedAgent, setSelectedAgent] = useState(null); + const [showAgentGrid, setShowAgentGrid] = useState(true); + const [error, setError] = useState(""); + + const resultsEndRef = useRef(null); + + // Filter listings by selected flat type + const filteredListings = useMemo(() => { + if (!flatType) return rawListings; + + return rawListings.filter(listing => { + // If listing has no flat type, include it (might be relevant) + if (!listing.flatType) return true; + + // Normalize both for comparison (handle "4-Room", "4 room", "4-room", etc.) + const listingType = listing.flatType.toLowerCase().replace(/[\s-]/g, ''); + const selectedType = flatType.toLowerCase().replace(/[\s-]/g, ''); + + // Check for match (e.g., "4room" matches "4room") + return listingType.includes(selectedType) || selectedType.includes(listingType); + }); + }, [rawListings, flatType]); + + // Auto-scroll results when new listings come in + useEffect(() => { + if (resultsEndRef.current) { + resultsEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [filteredListings]); + + // Extract listings from agent result + const extractListings = useCallback((result: unknown, agentId: number): RawListing[] => { + let listings: Array> = []; + + if (Array.isArray(result)) { + listings = result; + } else if (typeof result === "object" && result !== null) { + const resultObj = result as Record; + if (resultObj.listings && Array.isArray(resultObj.listings)) { + listings = resultObj.listings as Array>; + } else { + for (const key of Object.keys(resultObj)) { + if (Array.isArray(resultObj[key])) { + listings = resultObj[key] as Array>; + break; + } + } + } + } + + return listings.map(l => ({ + ...l, + agentId, + source: agents[agentId]?.url ? new URL(agents[agentId].url).hostname : `Agent ${agentId + 1}`, + })); + }, [agents]); + + const handleSearch = useCallback(async () => { + if (!town) return; + + // Reset state + setPhase("generating_urls"); + setStatusMessage("Asking Gemini to generate property listing URLs..."); + setAgents([]); + setRawListings([]); + setDeals([]); + setError(""); + + try { + const response = await fetch("/api/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + town, + flatType, + discountThreshold: parseInt(discountThreshold), + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response stream"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + + try { + const event = JSON.parse(line.slice(6)); + + switch (event.type) { + case "PHASE": + setPhase(event.phase); + setStatusMessage(event.message); + break; + + case "GEMINI_THINKING": + setStatusMessage(event.message); + break; + + case "GEMINI_SUCCESS": + setStatusMessage(event.message); + break; + + case "GEMINI_FALLBACK": + setStatusMessage(event.message); + break; + + case "FALLBACK_URLS": + setStatusMessage(event.message); + break; + + case "URLS_GENERATED": + setStatusMessage(`Generated ${event.urls.length} URLs to scan`); + const initialAgents: AgentStatus[] = event.urls.map((url: string, i: number) => ({ + id: i, + url, + status: "launching", + steps: [], + })); + setAgents(initialAgents); + break; + + case "AGENT_STATUS": + setAgents(prev => prev.map(agent => + agent.id === event.agentId + ? { + ...agent, + status: event.status, + streamingUrl: event.streamingUrl || agent.streamingUrl, + } + : agent + )); + break; + + case "AGENT_STEP": + setAgents(prev => prev.map(agent => + agent.id === event.agentId + ? { ...agent, steps: [...agent.steps, event.step] } + : agent + )); + break; + + case "AGENT_COMPLETE": + setAgents(prev => prev.map(agent => + agent.id === event.agentId + ? { ...agent, status: "complete", result: event.result } + : agent + )); + // Add raw listings from this agent immediately + if (event.result) { + const listings = extractListings(event.result, event.agentId); + if (listings.length > 0) { + setRawListings(prev => [...prev, ...listings]); + } + } + break; + + case "AGENT_ERROR": + setAgents(prev => prev.map(agent => + agent.id === event.agentId + ? { ...agent, status: "error", error: event.error } + : agent + )); + break; + + case "DEALS_FOUND": + setDeals(event.deals); + break; + + case "COMPLETE": + setPhase("complete"); + setStatusMessage(`Found ${event.dealCount} underpriced listings!`); + break; + + case "ERROR": + setPhase("error"); + setError(event.message); + break; + } + } catch { + // Ignore parse errors + } + } + } + } catch (err) { + setPhase("error"); + setError(err instanceof Error ? err.message : "Unknown error"); + } + }, [town, flatType, discountThreshold, extractListings]); + + const getStatusBadge = (status: AgentStatus["status"]) => { + switch (status) { + case "launching": + return ( + + + Launch + + ); + case "navigating": + return ( + + + Navigate + + ); + case "extracting": + return ( + + + Extract + + ); + case "complete": + return ( + + + + + Done + + ); + case "error": + return ( + + + + + Error + + ); + default: + return ( + + Pending + + ); + } + }; + + const formatPrice = (price: unknown): string => { + if (!price) return "-"; + const num = typeof price === "number" ? price : parseInt(String(price).replace(/[^0-9]/g, "")); + if (isNaN(num) || num === 0) return "-"; + return `$${num.toLocaleString()}`; + }; + + const isSearching = phase !== "idle" && phase !== "complete" && phase !== "error"; + + return ( +
+ {/* Background */} + + + {/* Header */} +
+
+ {/* Logo & Title - Left */} +
+
+ +
+
+

HDB Deal Sniper

+

Find underpriced resale flats

+
+
+ + {/* Status Indicator - Right */} +
+ {isSearching && ( + +
+ Scanning... + + )} +
+
+
+ + {/* Main Content - Split Layout */} +
+ {/* Left Panel - Search & Agents */} +
+ {/* Search Form */} +
+
+
+
+ +
+
+

Find HDB Deals

+

Search for underpriced resale flats

+
+
+ +
+ {/* Town Select */} +
+ + +
+ + {/* Flat Type */} +
+ +
+ {FLAT_TYPES.slice(1, 4).map((type) => ( + + ))} +
+
+ + {/* Discount */} +
+ +
+ setDiscountThreshold(e.target.value)} + className="bg-transparent border-0 p-0 w-12 h-auto text-sm font-medium focus-visible:ring-0" + disabled={isSearching} + /> + % below market +
+
+
+ + {/* Search Button */} + + + {error && phase === "error" && ( +

{error}

+ )} +
+
+ + {/* Status */} + {phase !== "idle" && ( +
+ {isSearching && } + {statusMessage} + {agents.length > 0 && ( + + )} +
+ )} + + {/* Agent Grid */} + + {agents.length > 0 && showAgentGrid && ( + +
+ {/* Section Header */} + +
+
+ +
+
+

Live Agents

+

+ {agents.filter(a => a.status === "complete").length} of {agents.length} complete +

+
+
+
+
+
+ Live +
+
+ + + {/* Agent Cards Grid */} + + {agents.map((agent) => { + const isActive = agent.status === "navigating" || agent.status === "extracting"; + const isComplete = agent.status === "complete"; + const isError = agent.status === "error"; + + return ( + setSelectedAgent(agent)} + > + {/* Browser Preview */} +
+ {agent.streamingUrl ? ( +