From 712ed314733641875d1efdc655a3ba8834dc7f04 Mon Sep 17 00:00:00 2001 From: pranavjana Date: Mon, 26 Jan 2026 04:21:17 +0800 Subject: [PATCH] Add credit-aggregator - Mino use case - ## Demo - Live demo: https://credit-aggregator.vercel.app/ - Contributor: Pranav Janakiraman (@pranavjana) --- credit-aggregator/.gitignore | 41 + credit-aggregator/README.md | 163 + .../app/api/compare-cards/route.ts | 459 ++ credit-aggregator/app/favicon.ico | Bin 0 -> 25931 bytes credit-aggregator/app/globals.css | 133 + credit-aggregator/app/layout.tsx | 34 + credit-aggregator/app/page.tsx | 435 ++ .../e74cd381-8291-4a76-b23d-c5220cc33260.jpg | Bin 0 -> 48301 bytes credit-aggregator/components.json | 22 + credit-aggregator/components/ui/badge.tsx | 46 + credit-aggregator/components/ui/button.tsx | 62 + credit-aggregator/components/ui/card.tsx | 92 + .../components/ui/dot-pattern.tsx | 56 + credit-aggregator/components/ui/input.tsx | 21 + credit-aggregator/components/ui/stepper.tsx | 258 + credit-aggregator/components/ui/textarea.tsx | 18 + credit-aggregator/eslint.config.mjs | 18 + credit-aggregator/lib/mino-client.ts | 184 + credit-aggregator/lib/utils.ts | 49 + credit-aggregator/next.config.ts | 7 + credit-aggregator/package-lock.json | 6637 +++++++++++++++++ credit-aggregator/package.json | 33 + credit-aggregator/postcss.config.mjs | 7 + credit-aggregator/public/file.svg | 1 + credit-aggregator/public/globe.svg | 1 + credit-aggregator/public/next.svg | 1 + credit-aggregator/public/vercel.svg | 1 + credit-aggregator/public/window.svg | 1 + credit-aggregator/tsconfig.json | 34 + 29 files changed, 8814 insertions(+) create mode 100644 credit-aggregator/.gitignore create mode 100644 credit-aggregator/README.md create mode 100644 credit-aggregator/app/api/compare-cards/route.ts create mode 100644 credit-aggregator/app/favicon.ico create mode 100644 credit-aggregator/app/globals.css create mode 100644 credit-aggregator/app/layout.tsx create mode 100644 credit-aggregator/app/page.tsx create mode 100644 credit-aggregator/assets/e74cd381-8291-4a76-b23d-c5220cc33260.jpg create mode 100644 credit-aggregator/components.json create mode 100644 credit-aggregator/components/ui/badge.tsx create mode 100644 credit-aggregator/components/ui/button.tsx create mode 100644 credit-aggregator/components/ui/card.tsx create mode 100644 credit-aggregator/components/ui/dot-pattern.tsx create mode 100644 credit-aggregator/components/ui/input.tsx create mode 100644 credit-aggregator/components/ui/stepper.tsx create mode 100644 credit-aggregator/components/ui/textarea.tsx create mode 100644 credit-aggregator/eslint.config.mjs create mode 100644 credit-aggregator/lib/mino-client.ts create mode 100644 credit-aggregator/lib/utils.ts create mode 100644 credit-aggregator/next.config.ts create mode 100644 credit-aggregator/package-lock.json create mode 100644 credit-aggregator/package.json create mode 100644 credit-aggregator/postcss.config.mjs create mode 100644 credit-aggregator/public/file.svg create mode 100644 credit-aggregator/public/globe.svg create mode 100644 credit-aggregator/public/next.svg create mode 100644 credit-aggregator/public/vercel.svg create mode 100644 credit-aggregator/public/window.svg create mode 100644 credit-aggregator/tsconfig.json diff --git a/credit-aggregator/.gitignore b/credit-aggregator/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/credit-aggregator/.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/credit-aggregator/README.md b/credit-aggregator/README.md new file mode 100644 index 0000000..c7532e2 --- /dev/null +++ b/credit-aggregator/README.md @@ -0,0 +1,163 @@ +# TinyFish - Singapore Credit Card Aggregator + +## Demo + +![credit-aggregator Demo](./assets/e74cd381-8291-4a76-b23d-c5220cc33260.jpg) + +**Live Demo:** https://credit-aggregator.vercel.app/ + +A real-time credit card comparison tool that aggregates data from 6 major Singapore financial comparison websites using parallel Mino browser agents. Users describe their requirements in natural language, and the system dispatches concurrent AI agents to scrape and consolidate matching credit cards. + +**Status**: ✅ Working + +--- + +## Demo + +*[Demo video/screenshot to be added]* + +--- + +## How Mino API is Used + +The Mino API powers browser automation for this use case. See the code snippet below for implementation details. + +### Code Snippet + +```bash +npm install +export MINO_API_KEY=your_api_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/credit-aggregator +``` + +2. Install dependencies: +```bash +npm install +``` + +3. Create `.env.local` file: +```bash +# Add your environment variables here +MINO_API_KEY=sk-mino-... +``` + +4. Run the development server: +```bash +npm run dev +``` + +5. Open [http://localhost:3000](http://localhost:3000) in your browser + +--- + +## Architecture Diagram + +```mermaid +flowchart TB + subgraph UI[USER INTERFACE] + Input[Natural Language Input] + Progress[Site Progress Grid] + Results[Results Display] + end + subgraph API[API LAYER] + SSE[SSE Stream Handler] + Orchestrator[Parallel Orchestrator] + end + subgraph Agents[MINO BROWSER AGENTS] + A1[SingSaver] + A2[MoneySmart] + A3[Seedly] + A4[MileLion] + A5[SuiteSmile] + A6[MainlyMiles] + end + subgraph Processing[RESULT PROCESSING] + Parser[Result Parser] + Dedup[Deduplication Engine] + end + Input --> SSE + SSE --> Orchestrator + Orchestrator --> A1 + Orchestrator --> A2 + Orchestrator --> A3 + Orchestrator --> A4 + Orchestrator --> A5 + Orchestrator --> A6 + A1 --> Parser + A2 --> Parser + A3 --> Parser + A4 --> Parser + A5 --> Parser + A6 --> Parser + Parser --> Dedup + Dedup --> Results + SSE -.-> Progress +``` + +```mermaid +sequenceDiagram + participant U as User + participant FE as Frontend + participant API as API Route + participant M as Mino Agents + U->>FE: Enter requirements + FE->>API: POST /api/compare-cards + API->>M: Launch 6 parallel agents + M-->>API: Stream site_step events + API-->>FE: Forward SSE updates + FE-->>U: Update progress UI + M-->>API: site_complete with cards + API->>API: Deduplicate and merge + API-->>FE: Final results + FE-->>U: Display cards +``` + +```mermaid +stateDiagram-v2 + [*] --> site_start + site_start --> site_step + site_step --> site_step + site_step --> site_complete + site_step --> site_error + site_complete --> [*] + site_error --> [*] +``` + +```mermaid +classDiagram + class CreditCard { + +string name + +string issuer + +string annualFee + +string rewards + +string signUpBonus + +string apr + +string[] highlights + +string source + } + class SiteStatus { + +string name + +string status + +string[] steps + +string error + } +``` + + diff --git a/credit-aggregator/app/api/compare-cards/route.ts b/credit-aggregator/app/api/compare-cards/route.ts new file mode 100644 index 0000000..d1f8d2f --- /dev/null +++ b/credit-aggregator/app/api/compare-cards/route.ts @@ -0,0 +1,459 @@ +interface CreditCard { + name: string; + issuer?: string; + annualFee?: string; + rewards?: string; + signUpBonus?: string; + apr?: string; + highlights?: string[]; + source?: string; +} + +// Singapore-specific credit card comparison sites +const SINGAPORE_SITES = [ + { url: "https://www.singsaver.com.sg/credit-cards", name: "SingSaver" }, + { url: "https://www.moneysmart.sg/credit-cards", name: "MoneySmart" }, + { url: "https://seedly.sg/reviews/credit-cards", name: "Seedly" }, + { url: "https://milelion.com/credit-cards/", name: "MileLion" }, + { url: "https://www.suitesmile.com/credit-cards/", name: "SuiteSmile" }, + { url: "https://mainlymiles.com/credit-cards/", name: "MainlyMiles" }, +]; + +export async function POST(request: Request) { + const encoder = new TextEncoder(); + + // Create a TransformStream for SSE + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + let isClosed = false; + + const sendEvent = async (data: object) => { + if (isClosed) return; + try { + await writer.write(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + } catch { + // Stream closed, client disconnected + isClosed = true; + } + }; + + const closeWriter = async () => { + if (isClosed) return; + try { + isClosed = true; + await writer.close(); + } catch { + // Already closed, ignore + } + }; + + // Start processing in the background + (async () => { + try { + const { requirements } = await request.json(); + + if (!requirements) { + await sendEvent({ type: "error", error: "Requirements are required" }); + await closeWriter(); + return; + } + + const apiKey = process.env.MINO_API_KEY; + if (!apiKey) { + await sendEvent({ type: "error", error: "API key not configured" }); + await closeWriter(); + return; + } + + await sendEvent({ type: "step", step: "Initializing Mino AI...", timestamp: Date.now() }); + await sendEvent({ + type: "step", + step: `Launching concurrent searches across ${SINGAPORE_SITES.length} Singapore credit card sites...`, + timestamp: Date.now() + }); + + // Launch ALL sites in parallel - no timeout, let each complete naturally + // Like 6 friends each searching one website, then consolidating results + const results = await Promise.allSettled( + SINGAPORE_SITES.map((site) => fetchFromSite(site, requirements, apiKey, sendEvent)) + ); + + await sendEvent({ + type: "step", + step: "All site searches completed, aggregating results...", + timestamp: Date.now() + }); + + // Collect all successful results + const allCards: CreditCard[] = []; + let successCount = 0; + + results.forEach((result, index) => { + if (result.status === "fulfilled" && result.value.length > 0) { + successCount++; + allCards.push(...result.value); + } else if (result.status === "rejected") { + console.error(`Failed to fetch from ${SINGAPORE_SITES[index].name}:`, result.reason); + } + }); + + await sendEvent({ + type: "step", + step: `Successfully gathered data from ${successCount}/${SINGAPORE_SITES.length} sites`, + timestamp: Date.now() + }); + + // Deduplicate and rank cards + const uniqueCards = deduplicateCards(allCards); + + await sendEvent({ + type: "step", + step: `Analysis complete - found ${uniqueCards.length} matching credit cards`, + timestamp: Date.now(), + }); + + // Send final results + await sendEvent({ + type: "complete", + cards: uniqueCards.slice(0, 10), // Return top 10 cards + }); + + } catch (error) { + console.error("Error in compare-cards API:", error); + await sendEvent({ + type: "error", + error: error instanceof Error ? error.message : "Unknown error", + }); + } finally { + await closeWriter(); + } + })(); + + return new Response(stream.readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); +} + +async function fetchFromSite( + site: { url: string; name: string }, + requirements: string, + apiKey: string, + sendEvent: (data: object) => Promise +): Promise { + try { + await sendEvent({ + type: "site_start", + site: site.name, + timestamp: Date.now(), + }); + + const minoResponse = await fetch("https://mino.ai/v1/automation/run-sse", { + method: "POST", + headers: { + "X-API-Key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: site.url, + goal: `User wants: "${requirements}" + +TASK: Extract top 3-5 credit cards from THIS PAGE ONLY that match the user's needs. + +RULES: +1. Stay on this page - do NOT click into individual card pages +2. Extract info visible on the listing/comparison page +3. Be quick - just get the key details shown + +Return JSON: +{ + "cards": [ + { + "name": "Card Name", + "issuer": "Bank", + "annualFee": "S$XXX or No fee", + "rewards": "Brief rewards summary", + "signUpBonus": "Bonus or null", + "apr": "XX% or null", + "highlights": ["key benefit 1", "key benefit 2"] + } + ] +} + +Return valid JSON only.`, + browser_profile: "lite", + }), + }); + + if (!minoResponse.ok) { + throw new Error(`Mino API returned ${minoResponse.status}`); + } + + const reader = minoResponse.body?.getReader(); + if (!reader) { + throw new Error("No response body"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + let finalResult: unknown = 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: ")) { + try { + const event = JSON.parse(line.slice(6)); + + // Handle completion first + if (event.type === "COMPLETE" && event.status === "COMPLETED") { + finalResult = event.resultJson; + // Parse and send cards with site_complete so frontend can show partial results + const siteCards = parseMinoResult(finalResult).map((card) => ({ ...card, source: site.name })); + await sendEvent({ + type: "site_complete", + site: site.name, + cards: siteCards, + timestamp: Date.now(), + }); + break; + } + + // Handle errors + if (event.type === "ERROR" || event.status === "FAILED") { + const errorMsg = event.message || event.error || "Site may have bot protection"; + throw new Error(errorMsg); + } + + // Forward detailed step info for the popup + // Filter out internal/system events + const stepMessage = + event.purpose || + event.action || + event.message || + event.step || + event.description || + event.text || + event.content; + + // Skip internal system events + const systemEvents = ["STARTED", "STREAMING_URL", "HEARTBEAT", "PING", "CONNECTED", "INIT"]; + const isSystemEvent = systemEvents.some(se => + stepMessage?.toUpperCase?.()?.includes(se) || + event.type?.toUpperCase?.()?.includes(se) + ); + + if (stepMessage && !isSystemEvent) { + await sendEvent({ + type: "site_step", + site: site.name, + detail: stepMessage, + timestamp: Date.now(), + }); + } + } catch (parseError) { + if (!(parseError instanceof SyntaxError)) { + throw parseError; + } + } + } + } + + if (finalResult) break; + } + + // Parse and tag results with source + const cards = parseMinoResult(finalResult); + return cards.map((card) => ({ ...card, source: site.name })); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error(`Error fetching from ${site.name}:`, errorMessage); + + // Notify frontend that this site failed + await sendEvent({ + type: "site_error", + site: site.name, + error: errorMessage, + timestamp: Date.now(), + }); + + return []; // Return empty array on failure, don't break other requests + } +} + +function parseMinoResult(result: unknown): CreditCard[] { + if (!result) return []; + + try { + // Try to find an array of cards in various formats + // The Mino API returns different keys depending on the site + const possibleArrayKeys = [ + 'parsed_data', + 'top_cards', + 'credit_cards', + 'cards', + 'results', + 'data', + ]; + + // Type guard: check if result is an object + if (typeof result === 'object' && result !== null) { + const resultObj = result as Record; + + for (const key of possibleArrayKeys) { + if (resultObj[key] && Array.isArray(resultObj[key])) { + return (resultObj[key] as unknown[]).map(parseCard); + } + } + } + + // If result is already an array of cards + if (Array.isArray(result)) { + return result.map(parseCard); + } + + // If result is a single card object (check common card name fields) + if (typeof result === 'object' && result !== null) { + const resultObj = result as Record; + if (resultObj.name || resultObj.cardName || resultObj.card_name) { + return [parseCard(result)]; + } + + // Last resort: check if any key contains an array of objects with card-like properties + for (const key of Object.keys(resultObj)) { + const value = resultObj[key]; + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object') { + const firstItem = value[0] as Record; + // Check if it looks like a card object + if (firstItem.card_name || firstItem.name || firstItem.issuer || firstItem.annual_fee) { + return value.map(parseCard); + } + } + } + } + + // Try to extract from text if result is a string + if (typeof result === "string") { + return parseTextResult(result); + } + + console.log("Unexpected result format:", result); + return []; + } catch (error) { + console.error("Error parsing Mino result:", error); + return []; + } +} + +function parseCard(cardData: unknown): CreditCard { + // Type guard to ensure cardData is an object + if (!cardData || typeof cardData !== 'object') { + return { name: 'Unknown Card' }; + } + const card = cardData as Record; + // Ensure highlights is always an array + // Handle various field names for highlights/benefits + let highlights = + card.key_highlights_benefits || // e.g., "2 complimentary lounge visits..." + card.key_highlights || + card.highlights || + card.benefits || + card.features; + + if (typeof highlights === "string") { + // Split by semicolons or periods for multi-sentence highlights + highlights = highlights.split(/[;.]/).map((s: string) => s.trim()).filter(Boolean); + } else if (!Array.isArray(highlights)) { + highlights = []; + } + + // Handle annual fee - could be number (SGD) or string + let annualFee = card.annual_fee_sgd || card.annual_fee || card.annualFee || card.fee; + if (typeof annualFee === "number") { + annualFee = annualFee === 0 ? "No annual fee" : `S$${annualFee.toFixed(0)}`; + } else if (typeof annualFee === "string") { + // Format string annual fees nicely + const numMatch = annualFee.match(/^[\d.]+$/); + if (numMatch) { + const num = parseFloat(annualFee); + annualFee = num === 0 ? "No annual fee" : `S$${num.toFixed(2)}`; + } + } + + return { + name: (card.card_name || card.name || card.cardName || "Unknown Card") as string, + issuer: card.issuer || card.issuer_bank || card.bank, + annualFee, + rewards: + card.rewards_cashback_structure || // e.g., "1.2 Citi Miles per S$1..." + card.rewards_structure || + card.rewards || + card.rewardsStructure || + card.cashBack || + card.cash_back, + signUpBonus: card.sign_up_bonus || card.signUpBonus || card.signup_bonus || card.bonus, + apr: + card.interest_rate_apr || // e.g., "26.90%" + card.apr_range || + card.apr || + card.APR || + card.interestRate || + card.interest_rate, + highlights, + } as CreditCard; +} + +function parseTextResult(text: string): CreditCard[] { + const cards: CreditCard[] = []; + const sections = text.split(/\n\n+/); + + for (const section of sections) { + if (section.trim()) { + const card: CreditCard = { + name: "Credit Card", + highlights: section.split("\n").filter((line) => line.trim()), + }; + cards.push(card); + } + } + + return cards.length > 0 ? cards : []; +} + +function deduplicateCards(cards: CreditCard[]): CreditCard[] { + const seen = new Map(); + + for (const card of cards) { + // Normalize card name for deduplication + const normalizedName = card.name.toLowerCase().replace(/[^a-z0-9]/g, ""); + + if (!seen.has(normalizedName)) { + seen.set(normalizedName, card); + } else { + // Merge data if we have more info + const existing = seen.get(normalizedName)!; + seen.set(normalizedName, { + ...existing, + issuer: existing.issuer || card.issuer, + annualFee: existing.annualFee || card.annualFee, + rewards: existing.rewards || card.rewards, + signUpBonus: existing.signUpBonus || card.signUpBonus, + apr: existing.apr || card.apr, + highlights: [...new Set([...(existing.highlights || []), ...(card.highlights || [])])], + source: existing.source ? `${existing.source}, ${card.source}` : card.source, + }); + } + } + + return Array.from(seen.values()); +} diff --git a/credit-aggregator/app/favicon.ico b/credit-aggregator/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/credit-aggregator/app/globals.css b/credit-aggregator/app/globals.css new file mode 100644 index 0000000..f3a8936 --- /dev/null +++ b/credit-aggregator/app/globals.css @@ -0,0 +1,133 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --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); +} + +:root { + --radius: 0.625rem; + /* Mino Brand Colors */ + --mino-bg: #F4F3F2; + --mino-orange: #D76228; + --mino-teal: #165762; + + --background: #F4F3F2; + --foreground: #1a1a1a; + --card: #ffffff; + --card-foreground: #1a1a1a; + --popover: #ffffff; + --popover-foreground: #1a1a1a; + --primary: #D76228; + --primary-foreground: #ffffff; + --secondary: #165762; + --secondary-foreground: #ffffff; + --muted: #e8e7e6; + --muted-foreground: #6b6b6b; + --accent: #165762; + --accent-foreground: #ffffff; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #e0dfde; + --input: #e0dfde; + --ring: #D76228; + --chart-1: #D76228; + --chart-2: #165762; + --chart-3: #398089; + --chart-4: #e8854a; + --chart-5: #1d7a89; + --sidebar: #ffffff; + --sidebar-foreground: #1a1a1a; + --sidebar-primary: #D76228; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #165762; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #e0dfde; + --sidebar-ring: #D76228; +} + +.dark { + --background: #1a1a1a; + --foreground: #F4F3F2; + --card: #2a2a2a; + --card-foreground: #F4F3F2; + --popover: #2a2a2a; + --popover-foreground: #F4F3F2; + --primary: #D76228; + --primary-foreground: #ffffff; + --secondary: #165762; + --secondary-foreground: #ffffff; + --muted: #3a3a3a; + --muted-foreground: #a0a0a0; + --accent: #165762; + --accent-foreground: #ffffff; + --destructive: #ef4444; + --destructive-foreground: #ffffff; + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --ring: #D76228; + --chart-1: #D76228; + --chart-2: #2a8a99; + --chart-3: #e8854a; + --chart-4: #165762; + --chart-5: #f09d5c; + --sidebar: #2a2a2a; + --sidebar-foreground: #F4F3F2; + --sidebar-primary: #D76228; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #165762; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: rgba(255, 255, 255, 0.1); + --sidebar-ring: #D76228; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/credit-aggregator/app/layout.tsx b/credit-aggregator/app/layout.tsx new file mode 100644 index 0000000..03a9c87 --- /dev/null +++ b/credit-aggregator/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Credit Card Finder - Find Your Perfect Credit Card", + description: "AI-powered credit card comparison tool. Describe your needs and find the best credit cards for you.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/credit-aggregator/app/page.tsx b/credit-aggregator/app/page.tsx new file mode 100644 index 0000000..d0a8a8c --- /dev/null +++ b/credit-aggregator/app/page.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2, X, ChevronRight } from "lucide-react"; + +interface CreditCard { + name: string; + issuer?: string; + annualFee?: string; + rewards?: string; + signUpBonus?: string; + apr?: string; + highlights?: string[]; + source?: string; +} + +interface SiteStatus { + name: string; + status: "searching" | "complete" | "error" | "cancelled"; + steps: string[]; + error?: string; +} + +export default function Home() { + const [requirements, setRequirements] = useState(""); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [sites, setSites] = useState>({}); + const [error, setError] = useState(""); + const [selectedSite, setSelectedSite] = useState(null); + + // Check if all active (non-cancelled) sites are done + useEffect(() => { + const siteList = Object.values(sites); + if (siteList.length === 0) return; + + const activeSites = siteList.filter(s => s.status !== "cancelled"); + const allDone = activeSites.length > 0 && activeSites.every(s => s.status === "complete" || s.status === "error"); + + if (allDone && loading) { + setLoading(false); + } + }, [sites, loading]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!requirements.trim()) { + setError("Please enter your credit card requirements"); + return; + } + + setLoading(true); + setError(""); + setResults([]); + setSites({}); + setSelectedSite(null); + + try { + const response = await fetch("/api/compare-cards", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requirements }), + }); + + if (!response.ok) { + throw new Error("Failed to fetch results"); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No response body"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const event = JSON.parse(line.slice(6)); + + if (event.type === "site_start") { + setSites((prev) => ({ + ...prev, + [event.site]: { + name: event.site, + status: "searching", + steps: [], + }, + })); + } else if (event.type === "site_step") { + setSites((prev) => ({ + ...prev, + [event.site]: { + ...prev[event.site], + steps: [...(prev[event.site]?.steps || []), event.detail], + }, + })); + } else if (event.type === "site_complete") { + setSites((prev) => ({ + ...prev, + [event.site]: { + ...prev[event.site], + status: "complete", + }, + })); + // Accumulate cards from each completed site + if (event.cards && Array.isArray(event.cards)) { + setResults((prev) => [...prev, ...event.cards]); + } + } else if (event.type === "site_error") { + setSites((prev) => ({ + ...prev, + [event.site]: { + ...prev[event.site], + status: "error", + error: event.error, + }, + })); + } else if (event.type === "step") { + // Legacy step event - ignore + } else if (event.type === "complete") { + setResults(event.cards || []); + setLoading(false); + } else if (event.type === "error") { + throw new Error(event.error); + } + } catch (parseError) { + console.error("Error parsing event:", parseError); + } + } + } + } + } catch (err) { + setError("Failed to compare credit cards. Please try again."); + console.error(err); + setLoading(false); + } + }; + + const siteList = Object.values(sites); + + return ( +
+
+ + {/* Header - Minimal */} +
+

+ Credit Card Finder +

+ + Powered by + mino.ai + + + + +
+ + {/* Search Input */} +
+
+
+ +