Vishwajeet07 commited on
Commit
b8f6c99
·
0 Parent(s):
Files changed (50) hide show
  1. .env.example +14 -0
  2. .github/workflows/keep-alive.yml +13 -0
  3. .gitignore +39 -0
  4. ABOUTME.md +84 -0
  5. AI_NOTES.md +68 -0
  6. Dockerfile +16 -0
  7. LICENSE +21 -0
  8. PROMPTS_USED.md +96 -0
  9. README.md +170 -0
  10. backend/.env.example +6 -0
  11. backend/Dockerfile +14 -0
  12. backend/app/__init__.py +0 -0
  13. backend/app/config.py +31 -0
  14. backend/app/database.py +44 -0
  15. backend/app/main.py +36 -0
  16. backend/app/models.py +69 -0
  17. backend/app/routers/__init__.py +0 -0
  18. backend/app/routers/health.py +36 -0
  19. backend/app/routers/shortlist.py +159 -0
  20. backend/app/services/__init__.py +0 -0
  21. backend/app/services/llm.py +279 -0
  22. backend/app/services/scraper.py +77 -0
  23. backend/requirements.txt +12 -0
  24. docker-compose.yml +31 -0
  25. frontend/Dockerfile +13 -0
  26. frontend/index.html +39 -0
  27. frontend/nginx.conf +15 -0
  28. frontend/package-lock.json +0 -0
  29. frontend/package.json +29 -0
  30. frontend/postcss.config.js +6 -0
  31. frontend/public/manifest.json +17 -0
  32. frontend/public/robots.txt +4 -0
  33. frontend/public/vendor-lens-logo-sm.png +0 -0
  34. frontend/public/vendor-lens-logo.png +0 -0
  35. frontend/src/App.tsx +37 -0
  36. frontend/src/api/client.ts +45 -0
  37. frontend/src/components/ComparisonTable.tsx +442 -0
  38. frontend/src/components/Header.tsx +110 -0
  39. frontend/src/components/ShortlistForm.tsx +258 -0
  40. frontend/src/components/VendorCard.tsx +221 -0
  41. frontend/src/index.css +92 -0
  42. frontend/src/main.tsx +10 -0
  43. frontend/src/pages/History.tsx +174 -0
  44. frontend/src/pages/Home.tsx +168 -0
  45. frontend/src/pages/Results.tsx +232 -0
  46. frontend/src/pages/Status.tsx +141 -0
  47. frontend/src/types/index.ts +63 -0
  48. frontend/tailwind.config.js +60 -0
  49. frontend/tsconfig.json +20 -0
  50. frontend/vite.config.ts +32 -0
.env.example ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Google Gemini API key (required)
2
+ GEMINI_API_KEY=your_gemini_api_key_here
3
+
4
+ # Optional: comma-separated pool of keys (backend rotates through them to avoid rate limits)
5
+ # GEMINI_API_KEYS_RAW=key1,key2,key3
6
+
7
+ # Gemini model to use
8
+ GEMINI_MODEL=gemini-2.5-flash
9
+
10
+ # SQLite database path
11
+ DATABASE_URL=sqlite:///./data/vendorlens.db
12
+
13
+ # CORS origins for frontend
14
+ CORS_ORIGINS_RAW=http://localhost:5173,http://localhost:3000,http://localhost:8000
.github/workflows/keep-alive.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Keep Alive
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '*/14 * * * *'
6
+
7
+ jobs:
8
+ ping:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Ping health endpoint
12
+ run: |
13
+ curl -f ${{ secrets.APP_URL }}/api/health || echo "Ping failed"
.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment
2
+ .env
3
+ *.env
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+ .Python
11
+ venv/
12
+ env/
13
+ .venv/
14
+ *.egg-info/
15
+ dist/
16
+ build/
17
+ .pytest_cache/
18
+
19
+ # Node
20
+ node_modules/
21
+ frontend/dist/
22
+ frontend/.vite/
23
+
24
+ # Database
25
+ data/
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Screenshots (binary, not needed in deployment)
30
+ screenshots/
31
+
32
+ # OS
33
+ .DS_Store
34
+ Thumbs.db
35
+
36
+ # IDE
37
+ .vscode/
38
+ .idea/
39
+ *.swp
ABOUTME.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # About Me
2
+
3
+ ## Vishwajeet Kumar
4
+
5
+ Software engineer with ~2 years of experience across full-stack, backend systems, and AI/ML integration. I've worked at a logistics startup, an edtech fintech, and done freelance work — mostly building things from scratch or scaling things that were already in production. I like working across the whole stack but I'm most comfortable in backend and systems work.
6
+
7
+ **Contact:** vishwajeet.7t@gmail.com | +91-9661241777 | Bengaluru, India
8
+ **Links:** [LinkedIn](https://linkedin.com/in/vishwajeet-kumar) | [GitHub](https://github.com/vishwajeet-kumar) | [Portfolio](https://portfolio.vishwajeet.dev)
9
+
10
+ ---
11
+
12
+ ## Education
13
+
14
+ **B.Tech — Electrical and Electronics Engineering**
15
+ National Institute of Technology, Sikkim
16
+ Dec 2021 – May 2025
17
+
18
+ Relevant coursework: Data Structures & Algorithms, Operating Systems, Machine Learning, Python for Data Science, Microprocessors
19
+
20
+ ---
21
+
22
+ ## Work Experience
23
+
24
+ **Software Development Engineer 1**
25
+ Logizee Solutions LLP (Dasho) — Pune | Jul 2025 – Nov 2025
26
+
27
+ - Engineered a scalable logistics platform using TypeScript/Kotlin microservices and Flutter, supporting 3,000+ monthly deliveries on auto-scaling infrastructure
28
+ - Improved ML-based demand forecasting accuracy by 18%, reducing dispatch delays and enhancing real-time operational insights
29
+ - Built a production-grade inverted-index search engine and integrated secure payment rails (Zoho, Razorpay), enabling low-latency transactions
30
+ - Automated CI/CD pipelines with canary deployments and instant rollback, increasing deployment stability and minimising downtime
31
+
32
+ **Software Development Engineer**
33
+ Eduvanz Financing Pvt. Ltd (Wizr) — Mumbai | Feb 2025 – Jun 2025
34
+
35
+ - Integrated Monster and OpenAI APIs via a TypeScript/Node.js backend with Redis caching and vector search, serving optimised APIs to Next.js and React UIs for 10,000+ users
36
+ - Delivered scalable backend workflow services using FastAPI and Node.js, cutting manual processing by 30% through automated orchestration
37
+ - Accelerated Next.js UI performance through rendering optimisations, code splitting, and lazy loading, achieving 22% faster page loads
38
+
39
+ **Freelance Software Developer**
40
+ Groozo — Sikkim | Sep 2024 – Jan 2025
41
+
42
+ - Built a real-time, workflow-driven delivery system using Node.js, Flutter, and Firebase, processing 12,000+ orders with clean architecture principles
43
+ - Applied AI-assisted routing with Mapbox optimisations, lowering delivery ETAs by ~25%
44
+ - Improved backend throughput using Redis caching, load-balanced microservices, and monitoring, reducing API latency by 30% under peak load
45
+
46
+ **Junior Embedded and IoT Engineer**
47
+ 9Pointers Tech Pvt. Ltd — Jaipur | Jun 2024 – Aug 2024
48
+
49
+ - Built a real-time computer-vision access control system with Python, OpenCV, and TensorFlow, reaching 98.5% accuracy and cutting access latency by 40% via device-side optimisation
50
+ - Provisioned IoT control and monitoring services using ESP32, FastAPI, and MQTT, achieving 99.8% command reliability with sub-300ms latency across distributed devices
51
+
52
+ ---
53
+
54
+ ## Skills
55
+
56
+ **Languages:** Java, Python, TypeScript, Kotlin, C++, C#
57
+
58
+ **Frontend & UI:** React.js, Next.js, React Native (Expo), Flutter, Tailwind CSS, Figma
59
+
60
+ **Backend & APIs:** Node.js, Spring Boot, FastAPI, REST APIs, gRPC, JWT Auth
61
+
62
+ **Databases:** PostgreSQL, MySQL, MongoDB, Redis, Firebase, Vector Databases
63
+
64
+ **DevOps & Cloud:** Git, Docker, Kubernetes, Terraform, Prometheus, Grafana
65
+
66
+ **AI / ML:** LLM Integration, RAG Pipelines, Embeddings, Prompt Engineering, LangChain, OpenAI APIs, TensorFlow
67
+
68
+ ---
69
+
70
+ ## Other Projects
71
+
72
+ **X-Ray System — Pipeline Decision Debugging Platform** (Jan 2026)
73
+ Multi-tenant async observability system for non-deterministic pipelines (RAG, search, recommendations). Captures 100% step-level decision context across 5,000+ candidates per step with zero production latency impact. FastAPI + PostgreSQL (JSONB) backend with a Python SDK for millisecond-level trace queries.
74
+
75
+ **Tina AI — AI Interview Agent Platform** (May–Jun 2025)
76
+ Adaptive screening engine using Next.js, FastAPI, WebRTC, and LLM orchestration. Responsive voice interactions with scenario-driven questioning at <200ms latency. Context-aware evaluation pipeline with 97% scoring consistency and timestamped transcript search.
77
+
78
+ ---
79
+
80
+ ## Achievements
81
+
82
+ - Scaled a real-time delivery platform to 12,000+ production orders through performance optimisation, monitoring, and cross-team collaboration
83
+ - Led development of NIT Sikkim's React-based conference website serving 6,000+ users
84
+ - Contributed to FastAPI and Material UI open-source; secured 2nd place at IIT-BHU TechNex
AI_NOTES.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Usage Notes
2
+
3
+ ## LLM Used Inside the App
4
+
5
+ **Model:** `gemini-2.5-flash` via Google's `google-genai` Python SDK
6
+
7
+ Chose Gemini 2.5 Flash because it's fast enough for an interactive web app (20–40s end-to-end is already on the edge for UX), has a large enough context window to handle scraped page content + requirements in a single call, and is consistently good at structured JSON output — which this pipeline depends on.
8
+
9
+ ---
10
+
11
+ ## AI Tools During Development
12
+
13
+ Used Claude as a coding assistant — similar to GitHub Copilot, useful for boilerplate and syntax but not for architecture or logic decisions. I've used both Copilot and Claude on previous projects (Tina AI, X-Ray System) so the workflow was familiar: use it to move faster on the parts that don't need thinking, do the actual design and debugging yourself.
14
+
15
+ ---
16
+
17
+ ## Architecture and Key Decisions
18
+
19
+ **Two-phase LLM pipeline**
20
+ The core design decision. Splitting vendor research into two calls:
21
+ - Phase 1: identify vendors + their pricing/feature page URLs
22
+ - Phase 2: scrape those URLs, pass the content as grounding data, generate the comparison
23
+
24
+ I tried one-pass first. Gemini would confidently hallucinate pricing numbers. Adding the scraping step and grounding the comparison call on real page content fixed it — the difference in accuracy was obvious when testing with real queries.
25
+
26
+ **Scraper targeting**
27
+ The scraper prioritises elements whose class or id attributes match pricing/feature keywords before falling back to full-page text. Vendor pages are noisy (nav, footer, cookie banners, testimonials) so dumping raw text into the LLM context gave poor results. Tuned the keyword list and tested against real pages — Mailgun, Supabase, Pinecone, Zoho Payments.
28
+
29
+ **Vendor diversity**
30
+ Early results were too US-centric — always the same 4 or 5 big names. I added explicit prompt rules: don't default to the obvious names, include regional and niche alternatives, prioritise lowest TCO when cost is mentioned. The trigger was testing "payment gateway India" — Zoho Payments, which charges significantly less than Stripe for Indian transactions, wasn't showing up.
31
+
32
+ **Backend architecture**
33
+ - FastAPI background tasks over Celery — no Redis, no separate worker, right for this scale
34
+ - SQLite with a Docker named volume — appropriate for single-instance, swap to Postgres when needed
35
+ - Polling over WebSockets — 20–40s is long but polling every 2.5s is fine and much simpler to debug
36
+ - Multiple Gemini keys via `GEMINI_API_KEYS_RAW` — pool rotation without any queue infrastructure
37
+
38
+ **Docker**
39
+ `depends_on: condition: service_healthy` on the frontend container — without this, nginx starts before FastAPI is ready and the first cold-boot requests fail. The plain `depends_on` only waits for the container process to start, not the app inside it.
40
+
41
+ **Frontend performance**
42
+ - jsPDF is ~400KB and only needed when the user clicks Export — dynamic `import()` so it doesn't load on page open
43
+ - Vite manual chunk splitting for react, router, lucide-react — main bundle from ~220KB down to ~50KB
44
+ - Page-level state, no global store — app is simple enough that lifting state is cleaner than Redux or Zustand
45
+
46
+ ---
47
+
48
+ ## Code Review Notes
49
+
50
+ Things I caught and changed that weren't right:
51
+
52
+ - `duckduckgo_search()` in scraper.py — fully implemented function, never called anywhere, just dead code with a dead dependency. Removed both.
53
+ - Vendor identification prompt — initial version didn't enforce variety. Fixed after testing showed it always picking the same names.
54
+ - PDF export — first version was a flat text dump. Rewrote to a structured report: dark header, score bars drawn as filled rects, per-vendor sections, risk breakdown, evidence block, recommendation, page footers.
55
+ - Mobile nav — initial version opened a dropdown. Replaced with a side drawer (slide from right with backdrop) — the dropdown felt broken on small screens with the notch navbar.
56
+ - Navbar background — transparent `<header>` with bg-white only on the inner pill div. Earlier version had a full-width white strip across the whole page top, which killed the gradient background on the Discover page.
57
+ - docker-compose had a named `data:` volume declared at the bottom but the service was using a bind mount — inconsistent. Switched to a proper named volume used consistently.
58
+ - CORS tested manually between Vite dev server (5173) and FastAPI (8000) — preflight and actual requests.
59
+ - Stripped all inline comments from source files (llm.py, scraper.py, config.py, main.py, frontend components) — they were generated noise, not useful documentation.
60
+
61
+ ---
62
+
63
+ ## Security
64
+
65
+ - No API keys or secrets in any source code file
66
+ - `.env` and `*.env` both gitignored — covers root `.env` and `backend/.env`
67
+ - `.env.example` has placeholder values only
68
+ - Keys read from environment variables at runtime — works locally via `.env`, works in production via platform environment variables
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS frontend-builder
2
+ WORKDIR /app/frontend
3
+ COPY frontend/package*.json ./
4
+ RUN npm ci
5
+ COPY frontend/ .
6
+ RUN npm run build
7
+
8
+ FROM python:3.11-slim
9
+ WORKDIR /app/backend
10
+ COPY backend/requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+ COPY backend/ .
13
+ COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
14
+ RUN mkdir -p data
15
+ EXPOSE 7860
16
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vishwajeet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
PROMPTS_USED.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Prompts Used — Development Log
2
+
3
+ Questions and prompts I used during development, mostly to sanity-check approaches or look up specific API behaviour. Architecture decisions and debugging are in AI_NOTES.md.
4
+
5
+ ---
6
+
7
+ ## Architecture
8
+
9
+ **Question:**
10
+ > fastapi background tasks vs celery for a 30 second async job — do i need celery or is backgroundtasks fine for a single instance?
11
+
12
+ Went with built-in `BackgroundTasks`. No Redis, no worker process, keeps Docker to two containers. Only limitation is tasks don't survive a server restart — acceptable for this use case.
13
+
14
+ ---
15
+
16
+ ## LLM Pipeline
17
+
18
+ **Question:**
19
+ > if i ground a gemini call with scraped webpage text will it use the actual text or still make things up about pricing
20
+
21
+ Testing confirmed: grounding with real scraped content makes a clear difference on pricing accuracy. Two-pass approach (identify → scrape → compare) is the right design.
22
+
23
+ **Follow-up:**
24
+ > gemini keeps wrapping json output in ```json``` markdown fences even when i tell it not to, how to extract reliably
25
+
26
+ Regex strip before JSON parse, with a fallback to try raw parse first. Added to the `_extract_json()` utility in llm.py.
27
+
28
+ ---
29
+
30
+ ## Scraper
31
+
32
+ **Question:**
33
+ > beautifulsoup — how to target specific sections of a page by class/id keyword instead of getting everything
34
+
35
+ Check class and id attributes of each element against a keyword list (pricing, plan, cost, feature, etc.), collect matching sections first, fall back to full page text if none found. Tested on Mailgun, Supabase, Pinecone — the targeted approach cuts a lot of noise.
36
+
37
+ ---
38
+
39
+ ## SQLAlchemy
40
+
41
+ **Question:**
42
+ > sqlalchemy json column — i'm doing dict update in place then commit() but the change doesn't save, assignment works though
43
+
44
+ SQLAlchemy mutation tracking only works at the column level, not inside nested objects. In-place dict changes are invisible to it. Fix: call `flag_modified(instance, "field_name")` before committing. This was the actual bug in the exclude/include feature — looked like it worked in memory, gone on next request.
45
+
46
+ ---
47
+
48
+ ## Docker
49
+
50
+ **Question:**
51
+ > docker-compose depends_on — how to make frontend actually wait for backend to be ready not just started
52
+
53
+ `depends_on: condition: service_healthy` + a healthcheck on the backend container. Plain `depends_on` only waits for the container process, not the app. Without this, nginx starts and immediately tries to proxy requests to a FastAPI that's still booting.
54
+
55
+ ---
56
+
57
+ ## Frontend Polling
58
+
59
+ **Question:**
60
+ > react setinterval polling — start on mount, stop when status is done/error, cleanup on unmount
61
+
62
+ `useEffect` + `setInterval` with cleanup. Store interval ID in a ref, clear in the effect cleanup and whenever status hits a terminal state. No library needed for something this simple.
63
+
64
+ ---
65
+
66
+ ## PDF Export
67
+
68
+ **Question:**
69
+ > jspdf is huge, can i load it only when user clicks export instead of on page load
70
+
71
+ Dynamic `import()` inside the async click handler — `const { default: jsPDF } = await import('jspdf')`. Downloads on demand, not upfront. Combined with Vite manual chunk splitting (react, router, icons into separate chunks), main bundle went from ~220KB to ~50KB.
72
+
73
+ ---
74
+
75
+ ## Gemini API Key Verification
76
+
77
+ Before wiring up the full pipeline, verified the key works with a raw HTTP call:
78
+
79
+ ```python
80
+ import urllib.request, json
81
+
82
+ url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=YOUR_KEY"
83
+ body = json.dumps({"contents": [{"parts": [{"text": "Reply with exactly: GEMINI_OK"}]}]}).encode()
84
+ req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"})
85
+ print(urllib.request.urlopen(req).read())
86
+ ```
87
+
88
+ Got back `GEMINI_OK`. Then moved on to wiring up the SDK properly.
89
+
90
+ ---
91
+
92
+ ## Notes
93
+
94
+ - All of the above were clarification questions or API lookups, not code generation for core logic
95
+ - The two-phase pipeline design, scraper targeting approach, and SQLAlchemy flag_modified fix all came from testing and debugging — not from any of these prompts
96
+ - Gemini JSON wrapping issue was discovered from actual API responses during testing, not anticipated upfront
README.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VendorLens
2
+
3
+ A tool I built to solve something I kept running into — evaluating software vendors is a pain. You end up with 6 browser tabs open, half the pricing pages are behind a "contact sales" wall, and by the time you've gone through everything you've forgotten what you were comparing in the first place.
4
+
5
+ VendorLens automates that first research pass. Give it what you're looking for in plain English, set some weighted requirements, and it goes and researches 4 vendors for you — scraping their actual pricing and feature pages, running them through Gemini 2.5 Flash, and handing you a scored comparison with real evidence links and a list of risks.
6
+
7
+ ---
8
+
9
+ ## What it does
10
+
11
+ - Describe your need in plain English — "email delivery service for India with high deliverability"
12
+ - Add requirements and weight them 1–5 (so "GDPR compliant" can matter a lot more than "has a Slack integration")
13
+ - Optionally exclude vendors you've already ruled out before the search even runs
14
+ - It picks 4 vendors (with deliberate variety — not just the 4 obvious big names), scrapes their pages, and runs them through Gemini
15
+ - You get a scored comparison table — match %, price range, features, risks, evidence URLs
16
+ - Export a full PDF report you can actually send to someone
17
+ - Last 5 shortlists are saved automatically, re-open anytime
18
+ - Status page shows backend health, DB, and LLM connectivity
19
+
20
+ ---
21
+
22
+ ## Stack
23
+
24
+ | Layer | Choice |
25
+ |-----------|-------------------------------------|
26
+ | Backend | FastAPI + Python 3.11 |
27
+ | LLM | Google Gemini 2.5 Flash |
28
+ | Database | SQLite via SQLAlchemy |
29
+ | Scraping | httpx + BeautifulSoup4 |
30
+ | Frontend | React 18 + TypeScript + Vite |
31
+ | Styling | Tailwind CSS |
32
+ | Container | Docker + docker-compose |
33
+
34
+ ---
35
+
36
+ ## Running it
37
+
38
+ Only thing you need is a Gemini API key (free at [aistudio.google.com](https://aistudio.google.com/app/apikey)).
39
+
40
+ ```bash
41
+ git clone <repo-url>
42
+ cd VendorLens
43
+
44
+ cp .env.example .env
45
+ # open .env and paste your GEMINI_API_KEY
46
+
47
+ docker-compose up --build
48
+ ```
49
+
50
+ That's it. Frontend is at **http://localhost:3000**, API docs at **http://localhost:8000/api/docs**.
51
+
52
+ The frontend waits for the backend health check to pass before starting, so no race condition on cold boot.
53
+
54
+ ---
55
+
56
+ ### Running without Docker (dev mode)
57
+
58
+ **Backend:**
59
+ ```bash
60
+ cd backend
61
+ python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
62
+ pip install -r requirements.txt
63
+
64
+ cp ../.env.example .env # add your GEMINI_API_KEY
65
+
66
+ uvicorn app.main:app --reload --port 8000
67
+ ```
68
+
69
+ **Frontend (separate terminal):**
70
+ ```bash
71
+ cd frontend
72
+ npm install
73
+ npm run dev
74
+ ```
75
+
76
+ Frontend at http://localhost:5173, backend at http://localhost:8000.
77
+
78
+ ---
79
+
80
+ ## Environment variables
81
+
82
+ | Variable | Required | Default | Notes |
83
+ |-----------------------|----------|-----------------------------------|--------------------------------------------------|
84
+ | `GEMINI_API_KEY` | Yes | — | Main Gemini key |
85
+ | `GEMINI_API_KEYS_RAW` | No | — | Comma-separated pool — backend rotates through them to handle rate limits |
86
+ | `GEMINI_MODEL` | No | `gemini-2.5-flash` | |
87
+ | `DATABASE_URL` | No | `sqlite:///./data/vendorlens.db` | |
88
+ | `CORS_ORIGINS_RAW` | No | `http://localhost:5173,...` | Comma-separated list |
89
+
90
+ ---
91
+
92
+ ## API
93
+
94
+ | Method | Path | Description |
95
+ |--------|--------------------------------------|-----------------------------------------------|
96
+ | POST | `/api/shortlist` | Create a shortlist (kicks off background task)|
97
+ | GET | `/api/shortlist/{id}` | Poll status / fetch result |
98
+ | GET | `/api/shortlists` | Last 5 shortlists |
99
+ | POST | `/api/shortlist/{id}/exclude-vendor` | Exclude a vendor from results |
100
+ | POST | `/api/shortlist/{id}/include-vendor` | Re-include an excluded vendor |
101
+ | DELETE | `/api/shortlist/{id}` | Delete a shortlist |
102
+ | GET | `/api/health` | Backend + DB + LLM health |
103
+
104
+ Interactive Swagger UI at `/api/docs`.
105
+
106
+ ---
107
+
108
+ ## Project structure
109
+
110
+ ```
111
+ VendorLens/
112
+ ├── backend/
113
+ │ ├── app/
114
+ │ │ ├── main.py # FastAPI app setup, CORS, startup
115
+ │ │ ├── config.py # Settings via pydantic-settings + .env
116
+ │ │ ├── database.py # SQLAlchemy ORM models + init_db
117
+ │ │ ├── models.py # Pydantic request/response schemas
118
+ │ │ ├── routers/
119
+ │ │ │ ├── shortlist.py # All shortlist endpoints + background processor
120
+ │ │ │ └── health.py # /api/health
121
+ │ │ └── services/
122
+ │ │ ├── llm.py # Two-phase Gemini pipeline (identify → scrape → compare)
123
+ │ │ └── scraper.py # Async httpx + BeautifulSoup scraper
124
+ │ ├── requirements.txt
125
+ │ └── Dockerfile
126
+ ├── frontend/
127
+ │ ├── src/
128
+ │ │ ├── api/client.ts # Typed fetch wrapper for all API calls
129
+ │ │ ├── components/ # Header, ShortlistForm, ComparisonTable, VendorCard
130
+ │ │ ├── pages/ # Home, Results, History, Status
131
+ │ │ └── types/index.ts # Shared TypeScript types
132
+ │ ├── public/
133
+ │ ├── package.json
134
+ │ ├── vite.config.ts
135
+ │ ├── nginx.conf
136
+ │ └── Dockerfile
137
+ ├── docker-compose.yml
138
+ ├── .env.example
139
+ ├── ABOUTME.md
140
+ ├── AI_NOTES.md
141
+ ├── PROMPTS_USED.md
142
+ └── README.md
143
+ ```
144
+
145
+ ---
146
+
147
+ ## What's working
148
+
149
+ - Two-phase LLM pipeline — Gemini first identifies vendors, then compares them using scraped data
150
+ - Async web scraping — targets pricing/feature sections specifically, falls back to full-page text
151
+ - Weighted requirements scoring — weights are factored into the final score calculation, not just labels
152
+ - Vendor variety — prompt explicitly avoids picking 4 from the same ecosystem; includes regional/niche alternatives when cost is a factor
153
+ - Pre-search exclusion — exclude vendors before the search, not just after
154
+ - Post-result exclude/include toggle — change your mind after seeing results
155
+ - PDF export — properly formatted report: scores, evidence, risk breakdown, recommendation
156
+ - History — last 5 shortlists auto-saved, re-openable, deletable
157
+ - Status page — live health check for backend, database, and LLM
158
+ - Mobile responsive — works properly on phone and tablet, not just "technically usable"
159
+ - Docker one-command setup — frontend waits for backend health before starting
160
+
161
+ ## What's missing / known gaps
162
+
163
+ - No auth — shortlists aren't tied to users, so anyone with the URL can see them
164
+ - JS-heavy SPAs won't scrape well — falls back to what Gemini already knows, which is usually fine
165
+ - SQLite for now needs Postgres for production
166
+ - No automated tests — didn't have time to write them properly
167
+
168
+ ---
169
+
170
+ Takes about 20–40 seconds per shortlist depending on how cooperative the vendor pages are. The scraping is the slow part.
backend/.env.example ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ GEMINI_API_KEY=your_primary_gemini_api_key_here
2
+ # Optional: comma-separated pool of keys for automatic rotation on quota errors
3
+ GEMINI_API_KEYS_RAW=key1,key2,key3
4
+ DATABASE_URL=sqlite:///./data/vendorlens.db
5
+ CORS_ORIGINS_RAW=http://localhost:5173,http://localhost:3000
6
+ GEMINI_MODEL=gemini-2.5-flash
backend/Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ RUN mkdir -p data
11
+
12
+ EXPOSE 8000
13
+
14
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/app/__init__.py ADDED
File without changes
backend/app/config.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ gemini_api_key: str = ""
6
+ gemini_api_keys_raw: str = ""
7
+ database_url: str = "sqlite:///./data/vendorlens.db"
8
+ cors_origins_raw: str = "http://localhost:5173,http://localhost:3000,http://localhost:8000"
9
+ gemini_model: str = "gemini-2.5-flash"
10
+
11
+ @property
12
+ def cors_origins(self) -> list[str]:
13
+ return [o.strip() for o in self.cors_origins_raw.split(",") if o.strip()]
14
+
15
+ @property
16
+ def gemini_api_keys(self) -> list[str]:
17
+ pool: list[str] = []
18
+ for k in self.gemini_api_keys_raw.split(","):
19
+ k = k.strip()
20
+ if k and k not in pool:
21
+ pool.append(k)
22
+ if self.gemini_api_key and self.gemini_api_key not in pool:
23
+ pool.append(self.gemini_api_key)
24
+ return pool
25
+
26
+ class Config:
27
+ env_file = ".env"
28
+ env_file_encoding = "utf-8"
29
+
30
+
31
+ settings = Settings()
backend/app/database.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import datetime
4
+ from sqlalchemy import create_engine, Column, String, DateTime, Text, JSON
5
+ from sqlalchemy.orm import declarative_base, sessionmaker
6
+ from app.config import settings
7
+
8
+ # Ensure data directory exists (relative to CWD = backend/)
9
+ _db_path = settings.database_url.replace("sqlite:///", "")
10
+ os.makedirs(os.path.dirname(_db_path) if os.path.dirname(_db_path) else ".", exist_ok=True)
11
+
12
+ engine = create_engine(
13
+ settings.database_url,
14
+ connect_args={"check_same_thread": False},
15
+ echo=False,
16
+ )
17
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
18
+ Base = declarative_base()
19
+
20
+
21
+ class ShortlistModel(Base):
22
+ __tablename__ = "shortlists"
23
+
24
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
25
+ need = Column(String, nullable=False)
26
+ requirements = Column(JSON, nullable=False) # [{"text": str, "weight": int}]
27
+ excluded_vendors = Column(JSON, default=list)
28
+ result = Column(JSON, nullable=True) # full comparison JSON
29
+ status = Column(String, default="pending") # pending | processing | done | error
30
+ progress = Column(String, nullable=True) # human-readable step label
31
+ error_message = Column(Text, nullable=True)
32
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
33
+
34
+
35
+ def get_db():
36
+ db = SessionLocal()
37
+ try:
38
+ yield db
39
+ finally:
40
+ db.close()
41
+
42
+
43
+ def init_db():
44
+ Base.metadata.create_all(bind=engine)
backend/app/main.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.staticfiles import StaticFiles
5
+ from app.config import settings
6
+ from app.database import init_db
7
+ from app.routers import shortlist, health
8
+
9
+ app = FastAPI(
10
+ title="VendorLens API",
11
+ description="Vendor Discovery and Shortlist Builder powered by Gemini 2.5 Flash",
12
+ version="1.0.0",
13
+ docs_url="/api/docs",
14
+ redoc_url="/api/redoc",
15
+ )
16
+
17
+ app.add_middleware(
18
+ CORSMiddleware,
19
+ allow_origins=settings.cors_origins + ["*"],
20
+ allow_credentials=True,
21
+ allow_methods=["*"],
22
+ allow_headers=["*"],
23
+ )
24
+
25
+ app.include_router(shortlist.router, prefix="/api")
26
+ app.include_router(health.router, prefix="/api")
27
+
28
+
29
+ @app.on_event("startup")
30
+ async def startup():
31
+ init_db()
32
+
33
+
34
+ _frontend_dist = os.path.join(os.path.dirname(__file__), "..", "..", "frontend", "dist")
35
+ if os.path.isdir(_frontend_dist):
36
+ app.mount("/", StaticFiles(directory=_frontend_dist, html=True), name="frontend")
backend/app/models.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime
2
+ from typing import Optional, List
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ # ── Input models ─────────────────────────────────────────────────────────────
7
+
8
+ class RequirementInput(BaseModel):
9
+ text: str = Field(..., min_length=2, max_length=200)
10
+ weight: int = Field(default=3, ge=1, le=5)
11
+
12
+
13
+ class ShortlistCreate(BaseModel):
14
+ need: str = Field(..., min_length=10, max_length=500)
15
+ requirements: List[RequirementInput] = Field(..., min_length=1, max_length=8)
16
+ excluded_vendors: List[str] = Field(default_factory=list)
17
+
18
+
19
+ class ExcludeVendorRequest(BaseModel):
20
+ vendor_name: str
21
+
22
+
23
+ # ── Output models ─────────────────────────────────────────────────────────────
24
+
25
+ class EvidenceLink(BaseModel):
26
+ url: str
27
+ snippet: str
28
+
29
+
30
+ class MatchedFeature(BaseModel):
31
+ requirement: str
32
+ satisfied: bool
33
+ notes: str
34
+ weight: int = 3
35
+
36
+
37
+ class VendorResult(BaseModel):
38
+ name: str
39
+ website: str
40
+ priceRange: str
41
+ matchedFeatures: List[MatchedFeature]
42
+ risks: List[str]
43
+ evidenceLinks: List[EvidenceLink]
44
+ overallScore: int
45
+ matchScore: int
46
+ tags: List[str] = Field(default_factory=list)
47
+ excluded: bool = False
48
+
49
+
50
+ class ShortlistResult(BaseModel):
51
+ vendors: List[VendorResult]
52
+ summary: str
53
+ recommendation: str
54
+ markdownReport: Optional[str] = None
55
+
56
+
57
+ class ShortlistResponse(BaseModel):
58
+ id: str
59
+ need: str
60
+ requirements: List[RequirementInput]
61
+ excluded_vendors: List[str]
62
+ result: Optional[ShortlistResult] = None
63
+ status: str
64
+ progress: Optional[str] = None
65
+ error_message: Optional[str] = None
66
+ created_at: datetime.datetime
67
+
68
+ class Config:
69
+ from_attributes = True
backend/app/routers/__init__.py ADDED
File without changes
backend/app/routers/health.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from fastapi import APIRouter
3
+ from sqlalchemy import text
4
+ from app.database import engine
5
+ from app.services.llm import check_llm_health
6
+
7
+ router = APIRouter(tags=["health"])
8
+
9
+
10
+ @router.get("/health")
11
+ async def health_check():
12
+ start = time.time()
13
+
14
+ # Database check
15
+ db_ok = False
16
+ try:
17
+ with engine.connect() as conn:
18
+ conn.execute(text("SELECT 1"))
19
+ db_ok = True
20
+ except Exception:
21
+ db_ok = False
22
+
23
+ # LLM check
24
+ llm_ok = await check_llm_health()
25
+
26
+ elapsed = round((time.time() - start) * 1000)
27
+ overall = "ok" if (db_ok and llm_ok) else "degraded"
28
+
29
+ return {
30
+ "status": overall,
31
+ "backend": True,
32
+ "database": db_ok,
33
+ "llm": llm_ok,
34
+ "llm_model": "gemini-2.5-flash",
35
+ "latency_ms": elapsed,
36
+ }
backend/app/routers/shortlist.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import datetime
3
+ from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
4
+ from sqlalchemy.orm import Session
5
+ from sqlalchemy.orm.attributes import flag_modified
6
+
7
+ from app.database import get_db, ShortlistModel, SessionLocal
8
+ from app.models import ShortlistCreate, ShortlistResponse, ExcludeVendorRequest
9
+ from app.services.llm import generate_shortlist, _friendly_error
10
+
11
+ router = APIRouter(tags=["shortlist"])
12
+
13
+
14
+ # ── Progress helper ──────────────────────────────────────────────────────────
15
+
16
+ def _set_progress(shortlist_id: str, step: str):
17
+ """Update progress label for real-time frontend feedback."""
18
+ db = SessionLocal()
19
+ try:
20
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
21
+ if row:
22
+ row.progress = step
23
+ db.commit()
24
+ finally:
25
+ db.close()
26
+
27
+
28
+ # ── Background processor ─────────────────────────────────────────────────────
29
+
30
+ async def _process(shortlist_id: str, need: str, requirements: list, excluded: list):
31
+ db = SessionLocal()
32
+ try:
33
+ _set_progress(shortlist_id, "Identifying top vendors…")
34
+ result = await generate_shortlist(
35
+ need, requirements, excluded,
36
+ on_progress=lambda step: _set_progress(shortlist_id, step),
37
+ )
38
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
39
+ if row:
40
+ row.result = result
41
+ row.status = "done"
42
+ row.progress = "Done"
43
+ db.commit()
44
+ except Exception as exc:
45
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
46
+ if row:
47
+ row.status = "error"
48
+ row.error_message = _friendly_error(exc)
49
+ row.progress = None
50
+ db.commit()
51
+ print(f"[shortlist] Error for {shortlist_id}: {exc}")
52
+ finally:
53
+ db.close()
54
+
55
+
56
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
57
+
58
+ @router.post("/shortlist", response_model=ShortlistResponse, status_code=202)
59
+ async def create_shortlist(
60
+ data: ShortlistCreate,
61
+ bg: BackgroundTasks,
62
+ db: Session = Depends(get_db),
63
+ ):
64
+ if not data.need.strip():
65
+ raise HTTPException(status_code=422, detail="Need cannot be empty.")
66
+ if len(data.requirements) == 0:
67
+ raise HTTPException(status_code=422, detail="Provide at least one requirement.")
68
+
69
+ row = ShortlistModel(
70
+ id=str(uuid.uuid4()),
71
+ need=data.need.strip(),
72
+ requirements=[r.model_dump() for r in data.requirements],
73
+ excluded_vendors=data.excluded_vendors,
74
+ status="processing",
75
+ progress="Queued…",
76
+ created_at=datetime.datetime.utcnow(),
77
+ )
78
+ db.add(row)
79
+ db.commit()
80
+ db.refresh(row)
81
+
82
+ bg.add_task(_process, row.id, row.need, row.requirements, row.excluded_vendors)
83
+ return row
84
+
85
+
86
+ @router.get("/shortlist/{shortlist_id}", response_model=ShortlistResponse)
87
+ async def get_shortlist(shortlist_id: str, db: Session = Depends(get_db)):
88
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
89
+ if not row:
90
+ raise HTTPException(status_code=404, detail="Shortlist not found.")
91
+ return row
92
+
93
+
94
+ @router.get("/shortlists", response_model=list[ShortlistResponse])
95
+ async def list_shortlists(db: Session = Depends(get_db)):
96
+ return (
97
+ db.query(ShortlistModel)
98
+ .order_by(ShortlistModel.created_at.desc())
99
+ .limit(5)
100
+ .all()
101
+ )
102
+
103
+
104
+ @router.post("/shortlist/{shortlist_id}/exclude-vendor")
105
+ async def exclude_vendor(
106
+ shortlist_id: str,
107
+ body: ExcludeVendorRequest,
108
+ db: Session = Depends(get_db),
109
+ ):
110
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
111
+ if not row:
112
+ raise HTTPException(status_code=404, detail="Shortlist not found.")
113
+ if not row.result or "vendors" not in row.result:
114
+ raise HTTPException(status_code=400, detail="No results yet.")
115
+
116
+ matched = any(
117
+ v["name"].lower() == body.vendor_name.lower()
118
+ for v in row.result["vendors"]
119
+ )
120
+ if not matched:
121
+ raise HTTPException(status_code=404, detail=f"Vendor '{body.vendor_name}' not found.")
122
+
123
+ for v in row.result["vendors"]:
124
+ if v["name"].lower() == body.vendor_name.lower():
125
+ v["excluded"] = True
126
+
127
+ flag_modified(row, "result")
128
+ db.commit()
129
+ return {"message": f"'{body.vendor_name}' excluded."}
130
+
131
+
132
+ @router.post("/shortlist/{shortlist_id}/include-vendor")
133
+ async def include_vendor(
134
+ shortlist_id: str,
135
+ body: ExcludeVendorRequest,
136
+ db: Session = Depends(get_db),
137
+ ):
138
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
139
+ if not row:
140
+ raise HTTPException(status_code=404, detail="Shortlist not found.")
141
+ if not row.result or "vendors" not in row.result:
142
+ raise HTTPException(status_code=400, detail="No results yet.")
143
+
144
+ for v in row.result["vendors"]:
145
+ if v["name"].lower() == body.vendor_name.lower():
146
+ v["excluded"] = False
147
+
148
+ flag_modified(row, "result")
149
+ db.commit()
150
+ return {"message": f"'{body.vendor_name}' re-included."}
151
+
152
+
153
+ @router.delete("/shortlist/{shortlist_id}", status_code=204)
154
+ async def delete_shortlist(shortlist_id: str, db: Session = Depends(get_db)):
155
+ row = db.query(ShortlistModel).filter(ShortlistModel.id == shortlist_id).first()
156
+ if not row:
157
+ raise HTTPException(status_code=404, detail="Shortlist not found.")
158
+ db.delete(row)
159
+ db.commit()
backend/app/services/__init__.py ADDED
File without changes
backend/app/services/llm.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ import asyncio
4
+ from typing import Any, List, Dict, Callable, Optional
5
+ from google import genai
6
+ from app.config import settings
7
+ from app.services.scraper import scrape_vendor_pages
8
+
9
+
10
+ def _build_clients() -> List[genai.Client]:
11
+ keys = settings.gemini_api_keys
12
+ if not keys:
13
+ raise RuntimeError("No Gemini API keys configured. Set GEMINI_API_KEYS_RAW in .env")
14
+ return [genai.Client(api_key=k) for k in keys]
15
+
16
+
17
+ _clients: List[genai.Client] = _build_clients()
18
+ _BACKOFF_DELAYS = [0.0, 0.5, 1.0, 2.0]
19
+
20
+
21
+ def _is_quota_error(exc: Exception) -> bool:
22
+ msg = str(exc).upper()
23
+ return "429" in msg or "RESOURCE_EXHAUSTED" in msg or "QUOTA" in msg
24
+
25
+
26
+ def _friendly_error(exc: Exception) -> str:
27
+ msg = str(exc)
28
+ if _is_quota_error(exc):
29
+ return "All API keys have reached their quota limit. Please wait a few minutes and try again."
30
+ if "JSON" in msg or "format" in msg.lower() or "unexpected" in msg.lower():
31
+ return "Gemini returned an unexpected response. Please try again with a clearer description."
32
+ if "network" in msg.lower() or "timeout" in msg.lower() or "connect" in msg.lower():
33
+ return "Network error while contacting Gemini. Please check your connection and try again."
34
+ clean = msg.split("\n")[0][:160]
35
+ return clean if clean else "Processing failed. Please try again."
36
+
37
+
38
+ async def _generate(prompt: str) -> str:
39
+ last_exc: Optional[Exception] = None
40
+ for attempt, delay in enumerate(_BACKOFF_DELAYS):
41
+ if delay > 0:
42
+ await asyncio.sleep(delay)
43
+ for client in _clients:
44
+ try:
45
+ resp = client.models.generate_content(
46
+ model=settings.gemini_model,
47
+ contents=prompt,
48
+ )
49
+ return resp.text
50
+ except Exception as exc:
51
+ if _is_quota_error(exc):
52
+ last_exc = exc
53
+ continue
54
+ raise
55
+ raise RuntimeError(
56
+ _friendly_error(last_exc) if last_exc
57
+ else "All Gemini API keys failed. Please try again later."
58
+ )
59
+
60
+
61
+ def _extract_json(text: str) -> Any:
62
+ fence = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
63
+ if fence:
64
+ try:
65
+ return json.loads(fence.group(1))
66
+ except json.JSONDecodeError:
67
+ pass
68
+ try:
69
+ return json.loads(text)
70
+ except json.JSONDecodeError:
71
+ pass
72
+ for pattern in (r"\{[\s\S]*\}", r"\[[\s\S]*\]"):
73
+ m = re.search(pattern, text)
74
+ if m:
75
+ try:
76
+ return json.loads(m.group())
77
+ except json.JSONDecodeError:
78
+ continue
79
+ return None
80
+
81
+
82
+ def _build_markdown(need: str, requirements: List[Dict], result: Dict) -> str:
83
+ lines = [
84
+ "# VendorLens — Shortlist Report",
85
+ f"\n## Need\n> {need}",
86
+ "\n## Requirements",
87
+ ]
88
+ for r in requirements:
89
+ lines.append(f"- **{r['text']}** (weight {r['weight']}/5)")
90
+
91
+ vendors = result.get("vendors", [])
92
+ if vendors:
93
+ lines.append("\n## Comparison Overview\n")
94
+ lines.append("| Vendor | Price Range | Match Score | Overall Score |")
95
+ lines.append("|--------|-------------|-------------|---------------|")
96
+ for v in vendors:
97
+ if not v.get("excluded"):
98
+ lines.append(
99
+ f"| [{v['name']}]({v['website']}) | {v['priceRange']} "
100
+ f"| {v.get('matchScore', 'N/A')}/100 | {v.get('overallScore', 'N/A')}/100 |"
101
+ )
102
+
103
+ lines.append("\n---\n## Detailed Vendor Analysis\n")
104
+ for v in vendors:
105
+ if v.get("excluded"):
106
+ continue
107
+ lines += [
108
+ f"### {v['name']}",
109
+ f"**Website:** <{v['website']}>",
110
+ f"**Price Range:** {v['priceRange']}",
111
+ f"**Match Score:** {v.get('matchScore', 'N/A')}/100",
112
+ f"**Overall Score:** {v.get('overallScore', 'N/A')}/100",
113
+ "",
114
+ "**Tags:** " + ", ".join(f"`{t}`" for t in v.get("tags", [])),
115
+ "",
116
+ "#### Requirement Analysis",
117
+ ]
118
+ for feat in v.get("matchedFeatures", []):
119
+ icon = "✅" if feat.get("satisfied") else "❌"
120
+ lines.append(f"- {icon} **{feat['requirement']}** (w:{feat.get('weight', 3)}): {feat['notes']}")
121
+ lines.append("\n#### Risks")
122
+ for risk in v.get("risks", []):
123
+ lines.append(f"- ⚠️ {risk}")
124
+ lines.append("\n#### Evidence")
125
+ for ev in v.get("evidenceLinks", []):
126
+ lines.append(f"- [{ev['url']}]({ev['url']})")
127
+ if ev.get("snippet"):
128
+ lines.append(f" > {ev['snippet'][:200]}")
129
+ lines.append("")
130
+
131
+ lines += [
132
+ f"\n## Summary\n{result.get('summary', '')}",
133
+ f"\n## Recommendation\n{result.get('recommendation', '')}",
134
+ "\n---\n*Generated by VendorLens · Gemini 2.5 Flash*",
135
+ ]
136
+ return "\n".join(lines)
137
+
138
+
139
+ async def generate_shortlist(
140
+ need: str,
141
+ requirements: List[Dict],
142
+ excluded_vendors: List[str] = [],
143
+ on_progress: Optional[Callable[[str], None]] = None,
144
+ ) -> Dict:
145
+ def progress(msg: str):
146
+ if on_progress:
147
+ on_progress(msg)
148
+
149
+ req_text = "\n".join(
150
+ f" - {r['text']} (importance {r['weight']}/5)" for r in requirements
151
+ )
152
+ excl_note = (
153
+ f"\nDo NOT include these vendors: {', '.join(excluded_vendors)}."
154
+ if excluded_vendors else ""
155
+ )
156
+
157
+ progress("Identifying top vendors…")
158
+
159
+ vendor_prompt = f"""You are a vendor research assistant.
160
+
161
+ User need: {need}
162
+
163
+ Requirements:
164
+ {req_text}
165
+ {excl_note}
166
+
167
+ Identify the BEST 4 real, reputable vendors/products that genuinely match this need.
168
+
169
+ Selection rules:
170
+ - Do NOT default to only the most famous/popular vendors. Evaluate actual fit.
171
+ - Include regional and niche alternatives when they are cost-effective or specialised for the user's context (e.g. country, industry, budget).
172
+ - If the user mentions cost, pricing, cheap, free, affordable, or low charges, prioritise vendors with the lowest real-world total cost of ownership — even if they are less well-known (e.g. Zoho, Mailersend, Postmark, regional payment gateways, etc.).
173
+ - Ensure variety: do not pick 4 vendors from the same company family.
174
+
175
+ Return ONLY a valid JSON array — no markdown, no commentary:
176
+
177
+ [
178
+ {{
179
+ "name": "VendorName",
180
+ "website": "https://vendor.com",
181
+ "pricingUrl": "https://vendor.com/pricing",
182
+ "featuresUrl": "https://vendor.com/features"
183
+ }}
184
+ ]
185
+
186
+ Use accurate, real URLs. Return only JSON."""
187
+
188
+ vendor_text = await _generate(vendor_prompt)
189
+ vendors_list: List[Dict] = _extract_json(vendor_text) or []
190
+ if not isinstance(vendors_list, list):
191
+ vendors_list = []
192
+
193
+ progress(f"Scraping pricing pages for {len(vendors_list)} vendors…")
194
+
195
+ scraped_data: Dict[str, str] = {}
196
+ for vendor in vendors_list[:4]:
197
+ name = vendor.get("name", "Unknown")
198
+ pages = await scrape_vendor_pages(vendor)
199
+ if pages:
200
+ combined = "\n\n".join(f"[From {p['url']}]\n{p['content']}" for p in pages)
201
+ scraped_data[name] = combined[:3500]
202
+ else:
203
+ scraped_data[name] = "(No web data — using AI knowledge only)"
204
+
205
+ progress("Analysing vendors against your requirements…")
206
+
207
+ comparison_prompt = f"""You are a senior technology analyst. Create a detailed vendor comparison.
208
+
209
+ User need: {need}
210
+
211
+ Requirements (weight 1-5, higher = more important):
212
+ {req_text}
213
+
214
+ Vendors to compare:
215
+ {json.dumps(vendors_list, indent=2)}
216
+
217
+ Scraped web data for each vendor:
218
+ {json.dumps(scraped_data, indent=2)}
219
+
220
+ Return ONLY valid JSON with EXACTLY this structure:
221
+ {{
222
+ "vendors": [
223
+ {{
224
+ "name": "string",
225
+ "website": "https://...",
226
+ "priceRange": "e.g. Free – $99/mo (free tier: 10k units/mo)",
227
+ "matchedFeatures": [
228
+ {{
229
+ "requirement": "exact requirement text from the list above",
230
+ "satisfied": true,
231
+ "notes": "specific, concise notes using scraped data where available",
232
+ "weight": 3
233
+ }}
234
+ ],
235
+ "risks": ["Specific risk 1", "Specific risk 2"],
236
+ "evidenceLinks": [
237
+ {{
238
+ "url": "https://...",
239
+ "snippet": "Short quoted or paraphrased text from that page"
240
+ }}
241
+ ],
242
+ "overallScore": 85,
243
+ "matchScore": 90,
244
+ "tags": ["free-tier", "india-support", "open-source"],
245
+ "excluded": false
246
+ }}
247
+ ],
248
+ "summary": "2-3 sentence overall comparison summary",
249
+ "recommendation": "Specific recommendation referencing top-weighted requirements"
250
+ }}
251
+
252
+ Rules:
253
+ - Include ALL {len(requirements)} requirements in matchedFeatures for EVERY vendor.
254
+ - overallScore and matchScore are integers 0-100.
255
+ - Be specific and accurate. Use scraped data for evidence snippets.
256
+ - Return ONLY the JSON object. Nothing else."""
257
+
258
+ comparison_text = await _generate(comparison_prompt)
259
+ result = _extract_json(comparison_text)
260
+
261
+ if not result or not isinstance(result, dict) or "vendors" not in result:
262
+ raise ValueError("Gemini returned an unexpected response. Please try again.")
263
+
264
+ req_map = {r["text"].lower(): r["weight"] for r in requirements}
265
+ for vendor in result.get("vendors", []):
266
+ for feat in vendor.get("matchedFeatures", []):
267
+ feat["weight"] = req_map.get(feat.get("requirement", "").lower(), 3)
268
+
269
+ progress("Generating markdown report…")
270
+ result["markdownReport"] = _build_markdown(need, requirements, result)
271
+ return result
272
+
273
+
274
+ async def check_llm_health() -> bool:
275
+ try:
276
+ await _generate("Respond with exactly one word: OK")
277
+ return True
278
+ except Exception:
279
+ return False
backend/app/services/scraper.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import asyncio
3
+ from typing import Optional, List, Dict
4
+ import httpx
5
+ from bs4 import BeautifulSoup
6
+
7
+ HEADERS = {
8
+ "User-Agent": (
9
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
10
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
11
+ "Chrome/120.0.0.0 Safari/537.36"
12
+ ),
13
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
14
+ "Accept-Language": "en-US,en;q=0.5",
15
+ "Accept-Encoding": "gzip, deflate",
16
+ }
17
+
18
+ PRICE_KEYWORDS = ["pricing", "price", "plan", "cost", "billing", "subscription", "tier"]
19
+ FEATURE_KEYWORDS = ["feature", "capability", "integration", "support", "api", "doc"]
20
+
21
+
22
+ async def scrape_url(url: str, timeout: int = 12) -> Optional[str]:
23
+ try:
24
+ async with httpx.AsyncClient(
25
+ headers=HEADERS,
26
+ timeout=timeout,
27
+ follow_redirects=True,
28
+ verify=False,
29
+ ) as client:
30
+ response = await client.get(url)
31
+
32
+ if response.status_code != 200:
33
+ return None
34
+
35
+ content_type = response.headers.get("content-type", "")
36
+ if "text/html" not in content_type and "application/xhtml" not in content_type:
37
+ return None
38
+
39
+ soup = BeautifulSoup(response.text, "lxml")
40
+
41
+ for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "svg"]):
42
+ tag.decompose()
43
+
44
+ target_sections = []
45
+ for element in soup.find_all(["section", "div", "article", "main"]):
46
+ classes = " ".join(element.get("class", []))
47
+ id_attr = element.get("id", "")
48
+ combined = (classes + " " + id_attr).lower()
49
+ if any(kw in combined for kw in PRICE_KEYWORDS + FEATURE_KEYWORDS):
50
+ text = element.get_text(separator=" ", strip=True)
51
+ if len(text) > 100:
52
+ target_sections.append(text)
53
+
54
+ raw = " ".join(target_sections[:4]) if target_sections else soup.get_text(separator=" ", strip=True)
55
+ cleaned = re.sub(r"\s+", " ", raw).strip()
56
+ return cleaned[:3500] if cleaned else None
57
+
58
+ except Exception as e:
59
+ print(f"[scraper] Failed to scrape {url}: {e}")
60
+ return None
61
+
62
+
63
+ async def scrape_vendor_pages(vendor: Dict) -> List[Dict]:
64
+ urls_to_try = []
65
+ for key in ("pricingUrl", "featuresUrl", "website"):
66
+ url = vendor.get(key)
67
+ if url and url not in [v.get("url") for v in urls_to_try]:
68
+ urls_to_try.append({"url": url, "label": key})
69
+
70
+ tasks = [scrape_url(item["url"]) for item in urls_to_try]
71
+ contents = await asyncio.gather(*tasks, return_exceptions=True)
72
+
73
+ return [
74
+ {"url": item["url"], "content": content}
75
+ for item, content in zip(urls_to_try, contents)
76
+ if isinstance(content, str) and content
77
+ ]
backend/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ sqlalchemy==2.0.23
4
+ pydantic==2.5.0
5
+ pydantic-settings==2.1.0
6
+ google-genai>=1.0.0
7
+ httpx==0.25.2
8
+ beautifulsoup4==4.12.2
9
+ lxml==4.9.3
10
+ python-multipart==0.0.6
11
+ python-dotenv==1.0.0
12
+ aiohttp==3.9.1
docker-compose.yml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.9'
2
+
3
+ services:
4
+ backend:
5
+ build: ./backend
6
+ container_name: vendorlens-backend
7
+ ports:
8
+ - "8000:8000"
9
+ env_file:
10
+ - .env
11
+ volumes:
12
+ - vendorlens-data:/app/data
13
+ restart: unless-stopped
14
+ healthcheck:
15
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
16
+ interval: 30s
17
+ timeout: 10s
18
+ retries: 3
19
+
20
+ frontend:
21
+ build: ./frontend
22
+ container_name: vendorlens-frontend
23
+ ports:
24
+ - "3000:80"
25
+ depends_on:
26
+ backend:
27
+ condition: service_healthy
28
+ restart: unless-stopped
29
+
30
+ volumes:
31
+ vendorlens-data:
frontend/Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS builder
2
+
3
+ WORKDIR /app
4
+ COPY package*.json ./
5
+ RUN npm ci
6
+ COPY . .
7
+ RUN npm run build
8
+
9
+ FROM nginx:alpine
10
+ COPY --from=builder /app/dist /usr/share/nginx/html
11
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
12
+ EXPOSE 80
13
+ CMD ["nginx", "-g", "daemon off;"]
frontend/index.html ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
7
+ <!-- Primary meta -->
8
+ <title>VendorLens — AI Vendor Discovery & Comparison</title>
9
+ <meta name="description" content="Describe your need, set requirements, and get a researched vendor shortlist with pricing, risks, and real evidence links. Powered by Gemini 2.5 Flash." />
10
+ <meta name="author" content="Vishwajeet Trivedi" />
11
+ <meta name="theme-color" content="#0f172a" />
12
+
13
+ <!-- Open Graph -->
14
+ <meta property="og:type" content="website" />
15
+ <meta property="og:title" content="VendorLens — AI Vendor Discovery & Comparison" />
16
+ <meta property="og:description" content="Find the right vendor in seconds. AI-powered shortlists with pricing, risks, and evidence links." />
17
+ <meta property="og:image" content="/vendor-lens-logo.png" />
18
+
19
+ <!-- Twitter Card -->
20
+ <meta name="twitter:card" content="summary" />
21
+ <meta name="twitter:title" content="VendorLens" />
22
+ <meta name="twitter:description" content="AI-powered vendor shortlisting in seconds." />
23
+
24
+ <!-- Favicon & manifest -->
25
+ <link rel="icon" type="image/png" href="/vendor-lens-logo.png" />
26
+ <link rel="apple-touch-icon" href="/vendor-lens-logo.png" />
27
+ <link rel="manifest" href="/manifest.json" />
28
+
29
+ <!-- Preload above-the-fold logo -->
30
+ <link rel="preload" as="image" href="/vendor-lens-logo-sm.png" />
31
+
32
+ <!-- Canonical (update to real domain on deploy) -->
33
+ <link rel="canonical" href="https://vendorlens.app/" />
34
+ </head>
35
+ <body>
36
+ <div id="root"></div>
37
+ <script type="module" src="/src/main.tsx"></script>
38
+ </body>
39
+ </html>
frontend/nginx.conf ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ root /usr/share/nginx/html;
4
+ index index.html;
5
+
6
+ location /api/ {
7
+ proxy_pass http://backend:8000;
8
+ proxy_set_header Host $host;
9
+ proxy_set_header X-Real-IP $remote_addr;
10
+ }
11
+
12
+ location / {
13
+ try_files $uri $uri/ /index.html;
14
+ }
15
+ }
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "vendorlens-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "jspdf": "^4.1.0",
13
+ "jspdf-autotable": "^5.0.7",
14
+ "lucide-react": "^0.303.0",
15
+ "react": "^18.2.0",
16
+ "react-dom": "^18.2.0",
17
+ "react-router-dom": "^6.21.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/react": "^18.2.45",
21
+ "@types/react-dom": "^18.2.18",
22
+ "@vitejs/plugin-react": "^4.2.1",
23
+ "autoprefixer": "^10.4.16",
24
+ "postcss": "^8.4.32",
25
+ "tailwindcss": "^3.4.0",
26
+ "typescript": "^5.2.2",
27
+ "vite": "^5.0.8"
28
+ }
29
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/manifest.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "VendorLens",
3
+ "short_name": "VendorLens",
4
+ "description": "AI-powered vendor discovery and comparison. Find the best vendors in seconds.",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#ffffff",
8
+ "theme_color": "#0f172a",
9
+ "icons": [
10
+ {
11
+ "src": "/vendor-lens-logo.png",
12
+ "sizes": "212x163",
13
+ "type": "image/png",
14
+ "purpose": "any maskable"
15
+ }
16
+ ]
17
+ }
frontend/public/robots.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ User-agent: *
2
+ Allow: /
3
+
4
+ Sitemap: https://vendorlens.app/sitemap.xml
frontend/public/vendor-lens-logo-sm.png ADDED
frontend/public/vendor-lens-logo.png ADDED
frontend/src/App.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BrowserRouter, Routes, Route } from 'react-router-dom';
2
+ import Header from './components/Header';
3
+ import Home from './pages/Home';
4
+ import Results from './pages/Results';
5
+ import History from './pages/History';
6
+ import Status from './pages/Status';
7
+
8
+ export default function App() {
9
+ return (
10
+ <BrowserRouter>
11
+ <div className="min-h-screen bg-white flex flex-col">
12
+ <Header />
13
+ <main className="flex-1">
14
+ <Routes>
15
+ <Route path="/" element={<Home />} />
16
+ <Route path="/results/:id" element={<Results />} />
17
+ <Route path="/history" element={<History />} />
18
+ <Route path="/status" element={<Status />} />
19
+ <Route path="*" element={
20
+ <div className="flex flex-col items-center justify-center min-h-[60vh] text-center p-6">
21
+ <p className="text-8xl font-black text-ink-100 mb-4">404</p>
22
+ <p className="text-ink-600 text-sm mb-6">This page doesn't exist.</p>
23
+ <a href="/" className="btn-primary">← Back to home</a>
24
+ </div>
25
+ } />
26
+ </Routes>
27
+ </main>
28
+ <footer className="relative z-10 border-t border-ink-200 py-4 px-4 sm:px-6 bg-white">
29
+ <div className="max-w-6xl mx-auto flex items-center justify-between text-xs text-ink-400">
30
+ <span className="font-medium text-ink-600">VendorLens</span>
31
+ <span>Made by Vishwajeet</span>
32
+ </div>
33
+ </footer>
34
+ </div>
35
+ </BrowserRouter>
36
+ );
37
+ }
frontend/src/api/client.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Shortlist, HealthStatus, ShortlistFormData } from '../types';
2
+
3
+ const BASE = '/api';
4
+
5
+ async function req<T>(path: string, options?: RequestInit): Promise<T> {
6
+ const res = await fetch(`${BASE}${path}`, {
7
+ headers: { 'Content-Type': 'application/json' },
8
+ ...options,
9
+ });
10
+ if (!res.ok) {
11
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
12
+ throw new Error(err.detail || `Request failed (${res.status})`);
13
+ }
14
+ if (res.status === 204) return undefined as T;
15
+ return res.json() as Promise<T>;
16
+ }
17
+
18
+ export const api = {
19
+ createShortlist: (data: ShortlistFormData): Promise<Shortlist> =>
20
+ req('/shortlist', { method: 'POST', body: JSON.stringify(data) }),
21
+
22
+ getShortlist: (id: string): Promise<Shortlist> =>
23
+ req(`/shortlist/${id}`),
24
+
25
+ listShortlists: (): Promise<Shortlist[]> =>
26
+ req('/shortlists'),
27
+
28
+ excludeVendor: (id: string, vendorName: string): Promise<void> =>
29
+ req(`/shortlist/${id}/exclude-vendor`, {
30
+ method: 'POST',
31
+ body: JSON.stringify({ vendor_name: vendorName }),
32
+ }),
33
+
34
+ includeVendor: (id: string, vendorName: string): Promise<void> =>
35
+ req(`/shortlist/${id}/include-vendor`, {
36
+ method: 'POST',
37
+ body: JSON.stringify({ vendor_name: vendorName }),
38
+ }),
39
+
40
+ deleteShortlist: (id: string): Promise<void> =>
41
+ req(`/shortlist/${id}`, { method: 'DELETE' }),
42
+
43
+ getHealth: (): Promise<HealthStatus> =>
44
+ req('/health'),
45
+ };
frontend/src/components/ComparisonTable.tsx ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { Download, ArrowUpDown, RotateCcw, Loader2 } from 'lucide-react';
3
+ import type jsPDFType from 'jspdf';
4
+ import { ShortlistResult, Vendor, Requirement } from '../types';
5
+ import VendorCard from './VendorCard';
6
+
7
+ interface Props {
8
+ result: ShortlistResult;
9
+ shortlistId: string;
10
+ need?: string;
11
+ requirements?: Requirement[];
12
+ onExclude: (name: string) => void;
13
+ onInclude: (name: string) => void;
14
+ }
15
+
16
+ type SortKey = 'overallScore' | 'matchScore' | 'name';
17
+
18
+ // ── Brand colours (matches Tailwind config) ──────────────────────────────────
19
+ type RGB = [number, number, number];
20
+ const C: Record<string, RGB> = {
21
+ dark: [15, 23, 42],
22
+ mid: [71, 85, 105],
23
+ light: [229, 229, 229],
24
+ xlight: [245, 245, 245],
25
+ white: [255, 255, 255],
26
+ blue: [37, 99, 235],
27
+ blueLight: [219, 234, 254],
28
+ yellow: [234, 179, 8],
29
+ yellowLight: [254, 249, 195],
30
+ green: [22, 163, 74],
31
+ greenLight: [220, 252, 231],
32
+ red: [220, 38, 38],
33
+ redLight: [254, 226, 226],
34
+ };
35
+
36
+ // ── PDF helpers ───────────────────────────────────────────────────────────────
37
+
38
+ type jsPDF = InstanceType<typeof jsPDFType>;
39
+
40
+ function fill(doc: jsPDF, c: RGB) { doc.setFillColor(c[0], c[1], c[2]); }
41
+ function ink(doc: jsPDF, c: RGB) { doc.setTextColor(c[0], c[1], c[2]); }
42
+ function stroke(doc: jsPDF, c: RGB) { doc.setDrawColor(c[0], c[1], c[2]); }
43
+
44
+ function addPageBand(doc: jsPDF, date: string) {
45
+ fill(doc, C.dark);
46
+ doc.rect(0, 0, 210, 11, 'F');
47
+ ink(doc, C.white);
48
+ doc.setFontSize(8); doc.setFont('helvetica', 'bold');
49
+ doc.text('VendorLens', 15, 7.5);
50
+ doc.setFont('helvetica', 'normal');
51
+ doc.text(date, 195, 7.5, { align: 'right' });
52
+ }
53
+
54
+ function newPage(doc: jsPDF, date: string): number {
55
+ doc.addPage();
56
+ addPageBand(doc, date);
57
+ return 18; // y after header
58
+ }
59
+
60
+ function checkPage(doc: jsPDF, y: number, needed: number, date: string): number {
61
+ if (y + needed > 272) return newPage(doc, date);
62
+ return y;
63
+ }
64
+
65
+ function scoreBar(doc: jsPDF, x: number, y: number, w: number, score: number) {
66
+ const h = 3.5;
67
+ fill(doc, C.light); doc.rect(x, y, w, h, 'F');
68
+ const barColor: RGB = score >= 7 ? C.green : score >= 5 ? C.yellow : C.red;
69
+ fill(doc, barColor); doc.rect(x, y, (w * score) / 10, h, 'F');
70
+ }
71
+
72
+ function weightLabel(w: number): string {
73
+ return ['', 'Low', 'Minor', 'Medium', 'High', 'Critical'][w] ?? String(w);
74
+ }
75
+
76
+ function getLastY(doc: jsPDF): number {
77
+ return (doc as unknown as { lastAutoTable: { finalY: number } }).lastAutoTable?.finalY ?? 0;
78
+ }
79
+
80
+ // ── Main PDF generator ────────────────────────────────────────────────────────
81
+
82
+ async function generatePDF(
83
+ result: ShortlistResult,
84
+ shortlistId: string,
85
+ need: string,
86
+ requirements: Requirement[],
87
+ ) {
88
+ const { default: jsPDF } = await import('jspdf');
89
+ const { default: autoTable } = await import('jspdf-autotable');
90
+ const doc = new jsPDF({ unit: 'mm', format: 'a4' });
91
+ const W = 210, ML = 15, MR = 15, CW = W - ML - MR;
92
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
93
+ const activeVendors = result.vendors.filter((v: Vendor) => !v.excluded);
94
+
95
+ // ── Page 1 header band ───────────────────────────────────────────────────
96
+ addPageBand(doc, date);
97
+
98
+ // Title
99
+ let y = 20;
100
+ ink(doc, C.dark); doc.setFontSize(22); doc.setFont('helvetica', 'bold');
101
+ doc.text('Vendor Shortlist Report', ML, y); y += 7;
102
+ ink(doc, C.mid); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
103
+ doc.text('Powered by VendorLens · Gemini 2.5 Flash', ML, y); y += 9;
104
+
105
+ stroke(doc, C.light); doc.setLineWidth(0.3);
106
+ doc.line(ML, y, W - MR, y); y += 8;
107
+
108
+ // Search query box
109
+ fill(doc, C.xlight); doc.roundedRect(ML, y, CW, 16, 2, 2, 'F');
110
+ ink(doc, C.mid); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
111
+ doc.text('YOUR SEARCH', ML + 4, y + 5);
112
+ ink(doc, C.dark); doc.setFontSize(10); doc.setFont('helvetica', 'normal');
113
+ const needLine = doc.splitTextToSize(need || '—', CW - 8);
114
+ doc.text(needLine[0], ML + 4, y + 12);
115
+ y += 22;
116
+
117
+ // Requirements table
118
+ if (requirements.length > 0) {
119
+ ink(doc, C.mid); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
120
+ doc.text('REQUIREMENTS', ML, y); y += 4;
121
+
122
+ autoTable(doc, {
123
+ startY: y,
124
+ margin: { left: ML, right: MR },
125
+ head: [['#', 'Requirement', 'Importance']],
126
+ body: requirements.map((r, i) => [
127
+ `${i + 1}`,
128
+ r.text,
129
+ `${weightLabel(r.weight)} (${r.weight}/5)`,
130
+ ]),
131
+ styles: { fontSize: 9, cellPadding: 2.5, overflow: 'linebreak' },
132
+ headStyles: { fillColor: C.dark, textColor: C.white, fontSize: 7.5, fontStyle: 'bold' },
133
+ columnStyles: {
134
+ 0: { cellWidth: 8, halign: 'center' },
135
+ 2: { cellWidth: 35, halign: 'center' },
136
+ },
137
+ alternateRowStyles: { fillColor: C.xlight },
138
+ didDrawPage: () => { addPageBand(doc, date); },
139
+ });
140
+ y = getLastY(doc) + 8;
141
+ }
142
+
143
+ // Overview table
144
+ y = checkPage(doc, y, 40, date);
145
+ ink(doc, C.mid); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
146
+ doc.text('VENDOR OVERVIEW', ML, y); y += 4;
147
+
148
+ autoTable(doc, {
149
+ startY: y,
150
+ margin: { left: ML, right: MR },
151
+ head: [['Rank', 'Vendor', 'Price Range', 'Match', 'Overall']],
152
+ body: activeVendors.map((v: Vendor, i: number) => [
153
+ `#${i + 1}`,
154
+ v.name,
155
+ v.priceRange,
156
+ `${v.matchScore}/10`,
157
+ `${v.overallScore}/10`,
158
+ ]),
159
+ styles: { fontSize: 9, cellPadding: 2.5 },
160
+ headStyles: { fillColor: C.dark, textColor: C.white, fontSize: 7.5, fontStyle: 'bold' },
161
+ columnStyles: {
162
+ 0: { cellWidth: 14, halign: 'center' },
163
+ 3: { cellWidth: 22, halign: 'center' },
164
+ 4: { cellWidth: 22, halign: 'center' },
165
+ },
166
+ alternateRowStyles: { fillColor: C.xlight },
167
+ didParseCell: (data) => {
168
+ if (data.section === 'body' && (data.column.index === 3 || data.column.index === 4)) {
169
+ const score = parseFloat(String(data.cell.text));
170
+ if (score >= 7) data.cell.styles.textColor = C.green;
171
+ else if (score <= 4) data.cell.styles.textColor = C.red;
172
+ }
173
+ },
174
+ didDrawPage: () => { addPageBand(doc, date); },
175
+ });
176
+ y = getLastY(doc) + 10;
177
+
178
+ // ── Per-vendor detail sections ─────────────────────────────────────────────
179
+ for (let vi = 0; vi < activeVendors.length; vi++) {
180
+ const v = activeVendors[vi];
181
+
182
+ // Vendor header — needs ~55 mm minimum; push to new page if tight
183
+ y = checkPage(doc, y, 55, date);
184
+
185
+ // Dark header block
186
+ fill(doc, C.dark); doc.rect(ML, y, CW, 15, 'F');
187
+
188
+ // Yellow rank badge
189
+ fill(doc, C.yellow); doc.rect(ML, y, 13, 15, 'F');
190
+ ink(doc, C.dark); doc.setFontSize(10); doc.setFont('helvetica', 'bold');
191
+ doc.text(`#${vi + 1}`, ML + 6.5, y + 9.5, { align: 'center' });
192
+
193
+ // Vendor name
194
+ ink(doc, C.white); doc.setFontSize(12); doc.setFont('helvetica', 'bold');
195
+ doc.text(v.name, ML + 17, y + 9.5);
196
+
197
+ // Scores (right-aligned)
198
+ doc.setFontSize(8); doc.setFont('helvetica', 'normal');
199
+ doc.text(`Match ${v.matchScore}/10 · Overall ${v.overallScore}/10`, W - MR, y + 9.5, { align: 'right' });
200
+ y += 18;
201
+
202
+ // Website + price
203
+ ink(doc, C.blue); doc.setFontSize(8); doc.setFont('helvetica', 'normal');
204
+ doc.text(v.website || '—', ML, y);
205
+ ink(doc, C.mid);
206
+ doc.text(`Price: ${v.priceRange}`, W - MR, y, { align: 'right' });
207
+ y += 6;
208
+
209
+ // Score bar
210
+ scoreBar(doc, ML, y, CW, v.overallScore);
211
+ ink(doc, C.mid); doc.setFontSize(6.5);
212
+ doc.text(`Overall score: ${v.overallScore}/10`, W - MR, y - 1, { align: 'right' });
213
+ y += 7;
214
+
215
+ // Requirements analysis table
216
+ if (v.matchedFeatures.length > 0) {
217
+ y = checkPage(doc, y, 20, date);
218
+ ink(doc, C.mid); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
219
+ doc.text('REQUIREMENTS ANALYSIS', ML, y); y += 4;
220
+
221
+ autoTable(doc, {
222
+ startY: y,
223
+ margin: { left: ML, right: MR },
224
+ head: [['Requirement', 'Status', 'Notes']],
225
+ body: v.matchedFeatures.map((mf) => [
226
+ mf.requirement,
227
+ mf.satisfied ? '✓ Met' : '✗ Not met',
228
+ mf.notes || '—',
229
+ ]),
230
+ styles: { fontSize: 8.5, cellPadding: 2.5, overflow: 'linebreak' },
231
+ headStyles: { fillColor: C.mid, textColor: C.white, fontSize: 7, fontStyle: 'bold' },
232
+ columnStyles: { 1: { cellWidth: 26, halign: 'center' } },
233
+ alternateRowStyles: { fillColor: C.xlight },
234
+ didParseCell: (data) => {
235
+ if (data.section === 'body' && data.column.index === 1) {
236
+ const isMet = String(data.cell.text).startsWith('✓');
237
+ data.cell.styles.textColor = isMet ? C.green : C.red;
238
+ data.cell.styles.fontStyle = 'bold';
239
+ }
240
+ },
241
+ didDrawPage: () => { addPageBand(doc, date); },
242
+ });
243
+ y = getLastY(doc) + 6;
244
+ }
245
+
246
+ // Risks table
247
+ if (v.risks.length > 0) {
248
+ y = checkPage(doc, y, 20, date);
249
+ ink(doc, C.red); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
250
+ doc.text('RISKS & LIMITATIONS', ML, y); y += 4;
251
+
252
+ autoTable(doc, {
253
+ startY: y,
254
+ margin: { left: ML, right: MR },
255
+ body: v.risks.slice(0, 6).map((r) => [`• ${r}`]),
256
+ styles: { fontSize: 8.5, cellPadding: 2.5, overflow: 'linebreak', fillColor: C.redLight, textColor: C.dark },
257
+ columnStyles: { 0: { fontStyle: 'normal' } },
258
+ didDrawPage: () => { addPageBand(doc, date); },
259
+ });
260
+ y = getLastY(doc) + 6;
261
+ }
262
+
263
+ // Evidence links
264
+ if (v.evidenceLinks.length > 0) {
265
+ y = checkPage(doc, y, 20, date);
266
+ ink(doc, C.mid); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
267
+ doc.text('EVIDENCE SOURCES', ML, y); y += 5;
268
+
269
+ v.evidenceLinks.slice(0, 4).forEach((ev) => {
270
+ y = checkPage(doc, y, 14, date);
271
+ ink(doc, C.blue); doc.setFontSize(7.5); doc.setFont('helvetica', 'normal');
272
+ const url = ev.url.length > 80 ? ev.url.slice(0, 77) + '…' : ev.url;
273
+ doc.text(`• ${url}`, ML, y); y += 4.5;
274
+ if (ev.snippet) {
275
+ ink(doc, C.mid); doc.setFontSize(7);
276
+ const snippetLines = doc.splitTextToSize(`"${ev.snippet.slice(0, 140)}"`, CW - 6);
277
+ doc.text(snippetLines, ML + 4, y); y += snippetLines.length * 3.8;
278
+ }
279
+ y += 2;
280
+ });
281
+ y += 4;
282
+ }
283
+
284
+ y += 8; // gap between vendors
285
+ }
286
+
287
+ // ── Summary & Recommendation ───────────────────────────────────────────────
288
+ if (result.summary || result.recommendation) {
289
+ y = checkPage(doc, y, 30, date);
290
+ stroke(doc, C.light); doc.setLineWidth(0.3);
291
+ doc.line(ML, y, W - MR, y); y += 8;
292
+
293
+ if (result.summary) {
294
+ const lines = doc.splitTextToSize(result.summary, CW - 12);
295
+ const boxH = lines.length * 4.8 + 14;
296
+ y = checkPage(doc, y, boxH, date);
297
+ fill(doc, C.blueLight); doc.rect(ML, y, CW, boxH, 'F');
298
+ fill(doc, C.blue); doc.rect(ML, y, 3, boxH, 'F');
299
+ ink(doc, C.blue); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
300
+ doc.text('SUMMARY', ML + 7, y + 6);
301
+ ink(doc, C.dark); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
302
+ doc.text(lines, ML + 7, y + 11);
303
+ y += boxH + 7;
304
+ }
305
+
306
+ if (result.recommendation) {
307
+ const lines = doc.splitTextToSize(result.recommendation, CW - 12);
308
+ const boxH = lines.length * 4.8 + 14;
309
+ y = checkPage(doc, y, boxH, date);
310
+ fill(doc, C.yellowLight); doc.rect(ML, y, CW, boxH, 'F');
311
+ fill(doc, C.yellow); doc.rect(ML, y, 3, boxH, 'F');
312
+ ink(doc, [120, 90, 0] as RGB); doc.setFontSize(6.5); doc.setFont('helvetica', 'bold');
313
+ doc.text('RECOMMENDATION', ML + 7, y + 6);
314
+ ink(doc, C.dark); doc.setFontSize(9); doc.setFont('helvetica', 'normal');
315
+ doc.text(lines, ML + 7, y + 11);
316
+ }
317
+ }
318
+
319
+ // ── Footer on every page ───────────────────────────────────────────────────
320
+ const total = doc.getNumberOfPages();
321
+ for (let p = 1; p <= total; p++) {
322
+ doc.setPage(p);
323
+ fill(doc, C.dark); doc.rect(0, 284, 210, 13, 'F');
324
+ ink(doc, [100, 116, 139] as RGB); doc.setFontSize(7); doc.setFont('helvetica', 'normal');
325
+ doc.text('VendorLens · Powered by Gemini 2.5 Flash', ML, 291);
326
+ doc.text(`Page ${p} of ${total} · ${date}`, W - MR, 291, { align: 'right' });
327
+ }
328
+
329
+ doc.save(`vendorlens-${shortlistId.slice(0, 8)}.pdf`);
330
+ }
331
+
332
+ // ── Component ─────────────────────────────────────────────────────────────────
333
+ export default function ComparisonTable({ result, shortlistId, need, requirements, onExclude, onInclude }: Props) {
334
+ const [sortKey, setSortKey] = useState<SortKey>('overallScore');
335
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
336
+ const [showExcluded, setShowExcluded] = useState(false);
337
+ const [pdfLoading, setPdfLoading] = useState(false);
338
+
339
+ const toggleSort = (key: SortKey) => {
340
+ if (sortKey === key) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
341
+ else { setSortKey(key); setSortDir('desc'); }
342
+ };
343
+
344
+ const sorted = [...result.vendors].sort((a, b) => {
345
+ const av = sortKey === 'name' ? a.name : a[sortKey];
346
+ const bv = sortKey === 'name' ? b.name : b[sortKey];
347
+ if (typeof av === 'string' && typeof bv === 'string')
348
+ return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
349
+ return sortDir === 'asc' ? (av as number) - (bv as number) : (bv as number) - (av as number);
350
+ });
351
+
352
+ const activeVendors = sorted.filter((v) => !v.excluded);
353
+ const excludedCount = result.vendors.filter((v) => v.excluded).length;
354
+ const visible = showExcluded ? sorted : activeVendors;
355
+
356
+ const SortBtn = ({ k, label }: { k: SortKey; label: string }) => (
357
+ <button
358
+ onClick={() => toggleSort(k)}
359
+ className={`flex items-center gap-1 text-[11px] font-semibold px-2.5 py-1.5 rounded transition-colors
360
+ ${sortKey === k ? 'bg-ink-950 text-white' : 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'}`}
361
+ >
362
+ {label}
363
+ {sortKey === k && <ArrowUpDown size={10} />}
364
+ </button>
365
+ );
366
+
367
+ const handleExportPDF = async () => {
368
+ setPdfLoading(true);
369
+ try {
370
+ await generatePDF(result, shortlistId, need ?? '', requirements ?? []);
371
+ } finally {
372
+ setPdfLoading(false);
373
+ }
374
+ };
375
+
376
+ return (
377
+ <div className="space-y-5">
378
+ {/* Controls */}
379
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pb-4 border-b border-ink-100">
380
+ <div className="flex items-center gap-2 flex-wrap">
381
+ <span className="label-xs">Sort</span>
382
+ <SortBtn k="overallScore" label="Overall" />
383
+ <SortBtn k="matchScore" label="Match" />
384
+ <SortBtn k="name" label="Name" />
385
+ {excludedCount > 0 && (
386
+ <button
387
+ onClick={() => setShowExcluded((v) => !v)}
388
+ className="flex items-center gap-1 text-[11px] text-ink-500 hover:text-ink-800 hover:bg-ink-100 px-2.5 py-1.5 rounded transition-colors"
389
+ >
390
+ <RotateCcw size={10} />
391
+ {showExcluded ? 'Hide' : 'Show'} excluded ({excludedCount})
392
+ </button>
393
+ )}
394
+ </div>
395
+ <div className="flex items-center justify-between sm:justify-end gap-3">
396
+ <span className="text-[11px] text-ink-400">{activeVendors.length} vendor{activeVendors.length !== 1 ? 's' : ''}</span>
397
+ <button
398
+ onClick={handleExportPDF}
399
+ disabled={pdfLoading}
400
+ className="btn-secondary text-[12px] py-1.5 px-3 disabled:opacity-60"
401
+ >
402
+ {pdfLoading
403
+ ? <><Loader2 size={12} className="animate-spin" /> Generating…</>
404
+ : <><Download size={12} /> Export PDF</>
405
+ }
406
+ </button>
407
+ </div>
408
+ </div>
409
+
410
+ {/* Cards grid */}
411
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
412
+ {visible.map((vendor: Vendor, i) => (
413
+ <VendorCard
414
+ key={vendor.name}
415
+ vendor={vendor}
416
+ rank={i + 1}
417
+ onExclude={() => onExclude(vendor.name)}
418
+ onInclude={() => onInclude(vendor.name)}
419
+ />
420
+ ))}
421
+ </div>
422
+
423
+ {/* Summary + Recommendation */}
424
+ {(result.summary || result.recommendation) && (
425
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-ink-100">
426
+ {result.summary && (
427
+ <div className="bg-info-50 border border-info-200 rounded-xl p-4">
428
+ <p className="label-xs text-info-600 mb-2">Summary</p>
429
+ <p className="text-sm text-ink-800 leading-relaxed">{result.summary}</p>
430
+ </div>
431
+ )}
432
+ {result.recommendation && (
433
+ <div className="bg-hi-50 border border-hi-300 rounded-xl p-4">
434
+ <p className="label-xs text-hi-600 mb-2">Recommendation</p>
435
+ <p className="text-sm text-ink-800 leading-relaxed">{result.recommendation}</p>
436
+ </div>
437
+ )}
438
+ </div>
439
+ )}
440
+ </div>
441
+ );
442
+ }
frontend/src/components/Header.tsx ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+ import { Search, Clock, Activity, Menu, X } from 'lucide-react';
4
+
5
+ const NAV = [
6
+ { to: '/', label: 'Discover', Icon: Search },
7
+ { to: '/history', label: 'History', Icon: Clock },
8
+ { to: '/status', label: 'Status', Icon: Activity },
9
+ ];
10
+
11
+ export default function Header() {
12
+ const { pathname } = useLocation();
13
+ const [menuOpen, setMenuOpen] = useState(false);
14
+
15
+ // Close drawer on route change
16
+ useEffect(() => { setMenuOpen(false); }, [pathname]);
17
+
18
+ return (
19
+ <>
20
+ <header className="sticky top-0 z-50 bg-transparent">
21
+ <div
22
+ className="relative max-w-5xl mx-auto px-5 h-14 flex items-center justify-between
23
+ bg-white border-b border-x border-ink-200 rounded-b-2xl"
24
+ style={{ boxShadow: '0 6px 28px rgba(0,0,0,0.06)' }}
25
+ >
26
+ <Link to="/" className="flex-shrink-0 flex items-center gap-2">
27
+ <img
28
+ src="/vendor-lens-logo-sm.png"
29
+ alt=""
30
+ width="42" height="33"
31
+ className="h-8 w-auto"
32
+ fetchPriority="high"
33
+ />
34
+ <span className="font-bold text-ink-950 tracking-tight text-[15px]">VendorLens</span>
35
+ </Link>
36
+ <nav className="hidden sm:flex items-center gap-2">
37
+ {NAV.map(({ to, label, Icon }) => {
38
+ const active = pathname === to;
39
+ return (
40
+ <Link
41
+ key={to}
42
+ to={to}
43
+ className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg text-[13px] font-medium transition-colors
44
+ ${active
45
+ ? 'bg-ink-950 text-white'
46
+ : 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
47
+ }`}
48
+ >
49
+ <Icon size={13} />
50
+ {label}
51
+ </Link>
52
+ );
53
+ })}
54
+ </nav>
55
+ <button
56
+ className="sm:hidden p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors"
57
+ onClick={() => setMenuOpen(true)}
58
+ aria-label="Open menu"
59
+ >
60
+ <Menu size={19} />
61
+ </button>
62
+ </div>
63
+ </header>
64
+ <div
65
+ className={`fixed inset-0 bg-black/40 z-[60] sm:hidden transition-opacity duration-300
66
+ ${menuOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
67
+ onClick={() => setMenuOpen(false)}
68
+ />
69
+ <div
70
+ className={`fixed top-0 right-0 h-full w-64 bg-white z-[70] flex flex-col sm:hidden
71
+ transition-transform duration-300 ease-in-out
72
+ ${menuOpen ? 'translate-x-0' : 'translate-x-full'}`}
73
+ style={{ boxShadow: menuOpen ? '-8px 0 40px rgba(0,0,0,0.15)' : 'none' }}
74
+ >
75
+ <div className="flex items-center justify-between px-5 py-4 border-b border-ink-100">
76
+ <div className="flex items-center gap-2">
77
+ <img src="/vendor-lens-logo-sm.png" alt="" width="38" height="29" className="h-7 w-auto" />
78
+ <span className="font-bold text-ink-950 text-[14px]">VendorLens</span>
79
+ </div>
80
+ <button
81
+ onClick={() => setMenuOpen(false)}
82
+ className="p-1.5 rounded-lg hover:bg-ink-100 text-ink-400 hover:text-ink-800 transition-colors"
83
+ aria-label="Close menu"
84
+ >
85
+ <X size={17} />
86
+ </button>
87
+ </div>
88
+ <nav className="flex-1 px-3 py-4 space-y-1">
89
+ {NAV.map(({ to, label, Icon }) => {
90
+ const active = pathname === to;
91
+ return (
92
+ <Link
93
+ key={to}
94
+ to={to}
95
+ className={`flex items-center gap-3 px-4 py-3 rounded-xl text-[14px] font-medium transition-colors
96
+ ${active
97
+ ? 'bg-ink-950 text-white'
98
+ : 'text-ink-700 hover:bg-ink-100 hover:text-ink-950'
99
+ }`}
100
+ >
101
+ <Icon size={16} />
102
+ {label}
103
+ </Link>
104
+ );
105
+ })}
106
+ </nav>
107
+ </div>
108
+ </>
109
+ );
110
+ }
frontend/src/components/ShortlistForm.tsx ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { Plus, Trash2, ChevronRight, AlertCircle, Search } from 'lucide-react';
3
+ import { ShortlistFormData, Requirement } from '../types';
4
+
5
+ interface Props {
6
+ onSubmit: (data: ShortlistFormData) => void;
7
+ loading: boolean;
8
+ }
9
+
10
+ const EXAMPLES = [
11
+ 'Email delivery service for India with high deliverability',
12
+ 'Vector database for a small ML team on a tight budget',
13
+ 'Hosted PostgreSQL with a generous free tier',
14
+ 'Payment gateway supporting Indian UPI and international cards',
15
+ ];
16
+
17
+ const WEIGHT_META: Record<number, { label: string; hi: boolean }> = {
18
+ 1: { label: 'Low', hi: false },
19
+ 2: { label: 'Minor', hi: false },
20
+ 3: { label: 'Medium', hi: false },
21
+ 4: { label: 'High', hi: true },
22
+ 5: { label: 'Critical', hi: true },
23
+ };
24
+
25
+ function WeightPicker({ value, onChange }: { value: number; onChange: (v: number) => void }) {
26
+ const { label, hi } = WEIGHT_META[value];
27
+ return (
28
+ <div className="flex items-center gap-1.5 flex-shrink-0">
29
+ <div className="flex gap-[3px]">
30
+ {[1, 2, 3, 4, 5].map((w) => (
31
+ <button
32
+ key={w}
33
+ type="button"
34
+ title={WEIGHT_META[w].label}
35
+ onClick={() => onChange(w)}
36
+ className={`w-[14px] rounded-[2px] transition-all duration-150 hover:opacity-75
37
+ ${w <= value
38
+ ? hi ? 'bg-hi-500' : 'bg-ink-700'
39
+ : 'bg-ink-200'
40
+ }`}
41
+ style={{ height: `${8 + w * 2}px`, alignSelf: 'flex-end' }}
42
+ />
43
+ ))}
44
+ </div>
45
+ <span className={`hidden sm:inline text-[10px] font-semibold w-[42px] ${hi ? 'text-hi-600' : 'text-ink-500'}`}>
46
+ {label}
47
+ </span>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ export default function ShortlistForm({ onSubmit, loading }: Props) {
53
+ const [need, setNeed] = useState('');
54
+ const [expanded, setExpanded] = useState(false);
55
+ const [requirements, setRequirements] = useState<Requirement[]>([
56
+ { text: '', weight: 3 },
57
+ { text: '', weight: 3 },
58
+ { text: '', weight: 3 },
59
+ ]);
60
+ const [excludedInput, setExcludedInput] = useState('');
61
+ const [error, setError] = useState('');
62
+ const expandRef = useRef<HTMLDivElement>(null);
63
+ const inputRef = useRef<HTMLInputElement>(null);
64
+
65
+ useEffect(() => {
66
+ if (need.length > 0 && !expanded) setExpanded(true);
67
+ }, [need]);
68
+
69
+ const addReq = () => {
70
+ if (requirements.length < 8) setRequirements([...requirements, { text: '', weight: 3 }]);
71
+ };
72
+
73
+ const removeReq = (i: number) => {
74
+ if (requirements.length > 1) setRequirements(requirements.filter((_, idx) => idx !== i));
75
+ };
76
+
77
+ const updateReq = (i: number, field: keyof Requirement, value: string | number) => {
78
+ const next = [...requirements];
79
+ next[i] = { ...next[i], [field]: value };
80
+ setRequirements(next);
81
+ };
82
+
83
+ const handleSubmit = (e: React.FormEvent) => {
84
+ e.preventDefault();
85
+ setError('');
86
+
87
+ if (need.trim().length < 10) {
88
+ setExpanded(true);
89
+ inputRef.current?.focus();
90
+ setError('Please describe your need (at least 10 characters).');
91
+ return;
92
+ }
93
+ const validReqs = requirements.filter((r) => r.text.trim().length > 1);
94
+ const excluded = excludedInput.split(',').map((s) => s.trim()).filter(Boolean);
95
+ onSubmit({ need: need.trim(), requirements: validReqs, excluded_vendors: excluded });
96
+ };
97
+
98
+ return (
99
+ <form onSubmit={handleSubmit} className="space-y-0">
100
+
101
+ <div className={`flex items-center gap-2 transition-all duration-200
102
+ ${expanded ? 'pb-4 border-b border-ink-100 mb-4' : ''}`}
103
+ >
104
+ <div className="relative flex-1">
105
+ <Search
106
+ size={15}
107
+ className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-400 pointer-events-none"
108
+ />
109
+ <input
110
+ ref={inputRef}
111
+ type="text"
112
+ value={need}
113
+ onChange={(e) => setNeed(e.target.value)}
114
+ onFocus={() => setExpanded(true)}
115
+ placeholder="What do you need? e.g. Email service for India…"
116
+ maxLength={500}
117
+ className="w-full border border-ink-300 rounded-xl pl-9 pr-3 py-3 text-sm text-ink-950
118
+ placeholder-ink-400 transition-all duration-150 bg-white
119
+ focus:outline-none focus:border-ink-900 focus:ring-1 focus:ring-ink-900"
120
+ />
121
+ </div>
122
+ <button
123
+ type="submit"
124
+ disabled={loading}
125
+ className="btn-primary rounded-xl py-3 px-5 whitespace-nowrap flex-shrink-0 text-[13px]"
126
+ >
127
+ {loading ? (
128
+ <span className="flex items-center gap-1.5">
129
+ <div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
130
+ Building…
131
+ </span>
132
+ ) : (
133
+ <span className="flex items-center gap-1.5">
134
+ <span className="hidden sm:inline">Build Shortlist</span>
135
+ <span className="sm:hidden">Build</span>
136
+ <ChevronRight size={14} />
137
+ </span>
138
+ )}
139
+ </button>
140
+ </div>
141
+ <div
142
+ ref={expandRef}
143
+ className="overflow-hidden transition-all duration-400 ease-in-out"
144
+ style={{
145
+ maxHeight: expanded ? '900px' : '0px',
146
+ opacity: expanded ? 1 : 0,
147
+ transitionProperty: 'max-height, opacity',
148
+ transitionDuration: '350ms, 200ms',
149
+ transitionTimingFunction: 'ease-in-out, ease',
150
+ }}
151
+ >
152
+ <div className="space-y-5">
153
+ <div>
154
+ <p className="text-[10px] font-semibold text-ink-400 uppercase tracking-wider mb-2">Try an example</p>
155
+ <div className="flex flex-wrap gap-1.5">
156
+ {EXAMPLES.map((ex) => (
157
+ <button
158
+ key={ex}
159
+ type="button"
160
+ onClick={() => { setNeed(ex); inputRef.current?.focus(); }}
161
+ className="text-[11px] text-ink-600 bg-ink-100 hover:bg-ink-200 hover:text-ink-900
162
+ px-2.5 py-1 rounded-lg transition-colors text-left leading-tight max-w-[220px] truncate"
163
+ >
164
+ {ex}
165
+ </button>
166
+ ))}
167
+ </div>
168
+ <div className="flex justify-end mt-1">
169
+ <span className="text-[10px] text-ink-300">{need.length}/500</span>
170
+ </div>
171
+ </div>
172
+
173
+ <div className="h-px bg-ink-100" />
174
+ <div>
175
+ <div className="flex items-center justify-between mb-3">
176
+ <div className="flex items-center gap-2">
177
+ <p className="text-[11px] font-semibold text-ink-700 uppercase tracking-wider">
178
+ Requirements
179
+ </p>
180
+ <span className="text-[11px] text-ink-400">
181
+ {requirements.length}/8
182
+ </span>
183
+ </div>
184
+ <button
185
+ type="button"
186
+ onClick={addReq}
187
+ disabled={requirements.length >= 8}
188
+ className="flex items-center gap-1 text-[11px] text-info-600 hover:text-info-700
189
+ disabled:opacity-30 disabled:cursor-not-allowed font-medium"
190
+ >
191
+ <Plus size={11} /> Add
192
+ </button>
193
+ </div>
194
+
195
+ <div className="space-y-2">
196
+ {requirements.map((req, i) => (
197
+ <div key={i} className="flex items-center gap-2">
198
+ <span className="text-[10px] text-ink-300 font-mono w-3.5 text-right flex-shrink-0">{i + 1}</span>
199
+ <input
200
+ type="text"
201
+ value={req.text}
202
+ onChange={(e) => updateReq(i, 'text', e.target.value)}
203
+ placeholder={`e.g. "Free tier available"`}
204
+ maxLength={200}
205
+ className="flex-1 border border-ink-200 rounded-lg px-3 py-2 text-[13px] text-ink-900
206
+ placeholder-ink-300 focus:outline-none focus:border-ink-700 focus:ring-1
207
+ focus:ring-ink-700 transition-colors bg-white"
208
+ />
209
+ <WeightPicker
210
+ value={req.weight}
211
+ onChange={(v) => updateReq(i, 'weight', v)}
212
+ />
213
+ <button
214
+ type="button"
215
+ onClick={() => removeReq(i)}
216
+ disabled={requirements.length <= 1}
217
+ className="p-1.5 text-ink-300 hover:text-ink-600 hover:bg-ink-100
218
+ rounded disabled:opacity-20 transition-colors flex-shrink-0"
219
+ >
220
+ <Trash2 size={12} />
221
+ </button>
222
+ </div>
223
+ ))}
224
+ </div>
225
+ </div>
226
+
227
+ <div className="h-px bg-ink-100" />
228
+ <div>
229
+ <div className="flex items-center gap-2 mb-1.5">
230
+ <p className="text-[11px] font-semibold text-ink-700 uppercase tracking-wider">
231
+ Exclude vendors
232
+ </p>
233
+ <span className="text-[10px] text-ink-400 normal-case">optional · comma-separated</span>
234
+ </div>
235
+ <input
236
+ type="text"
237
+ value={excludedInput}
238
+ onChange={(e) => setExcludedInput(e.target.value)}
239
+ placeholder="e.g. AWS SES, SendGrid"
240
+ className="w-full border border-ink-200 rounded-lg px-3 py-2 text-[13px] text-ink-900
241
+ placeholder-ink-300 focus:outline-none focus:border-ink-700 focus:ring-1
242
+ focus:ring-ink-700 transition-colors bg-white"
243
+ />
244
+ </div>
245
+ {error && (
246
+ <div className="flex items-center gap-2 text-[13px] text-hi-700 bg-hi-50 border border-hi-200 rounded-lg px-3 py-2.5">
247
+ <AlertCircle size={13} className="flex-shrink-0" />
248
+ {error}
249
+ </div>
250
+ )}
251
+ <p className="text-center text-[11px] text-ink-400 pt-1 pb-2">
252
+ Takes ~20–40 seconds · 4 vendors researched
253
+ </p>
254
+ </div>
255
+ </div>
256
+ </form>
257
+ );
258
+ }
frontend/src/components/VendorCard.tsx ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { ExternalLink, ChevronDown, ChevronUp, Check, X, EyeOff, Eye, Minus } from 'lucide-react';
3
+ import { Vendor } from '../types';
4
+
5
+ interface Props {
6
+ vendor: Vendor;
7
+ rank: number;
8
+ onExclude: () => void;
9
+ onInclude: () => void;
10
+ }
11
+
12
+ function scoreBadge(v: number) {
13
+ if (v >= 80) return 'score-hi';
14
+ if (v >= 60) return 'score-mid';
15
+ return 'score-lo';
16
+ }
17
+
18
+ function ScoreBar({ value, label }: { value: number; label: string }) {
19
+ const filled = Math.round(value / 10);
20
+ return (
21
+ <div>
22
+ <div className="flex justify-between items-center mb-1">
23
+ <span className="text-[11px] text-ink-500">{label}</span>
24
+ <span className={`text-[11px] font-bold px-1.5 py-0.5 rounded ${scoreBadge(value)}`}>{value}</span>
25
+ </div>
26
+ <div className="flex gap-0.5">
27
+ {Array.from({ length: 10 }).map((_, i) => (
28
+ <div
29
+ key={i}
30
+ className={`h-1.5 flex-1 rounded-sm transition-all duration-500 ${i < filled ? 'bg-ink-900' : 'bg-ink-100'}`}
31
+ style={{ transitionDelay: `${i * 40}ms` }}
32
+ />
33
+ ))}
34
+ </div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ export default function VendorCard({ vendor, rank, onExclude, onInclude }: Props) {
40
+ const [showFeatures, setShowFeatures] = useState(true);
41
+ const [showEvidence, setShowEvidence] = useState(false);
42
+
43
+ if (vendor.excluded) {
44
+ return (
45
+ <div className="border border-ink-200 rounded-xl p-4 bg-ink-50 flex items-center justify-between">
46
+ <div className="flex items-center gap-2.5 text-ink-400">
47
+ <EyeOff size={14} />
48
+ <span className="text-sm font-medium">{vendor.name}</span>
49
+ <span className="text-xs">excluded</span>
50
+ </div>
51
+ <button onClick={onInclude} className="flex items-center gap-1 text-xs text-info-600 hover:text-info-700 font-medium">
52
+ <Eye size={11} /> Restore
53
+ </button>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ const satisfied = vendor.matchedFeatures.filter((f) => f.satisfied).length;
59
+ const total = vendor.matchedFeatures.length;
60
+ const isTopPick = rank === 1;
61
+
62
+ return (
63
+ <div className={`card hover:shadow-card-hover transition-shadow duration-200 animate-slide-up overflow-hidden
64
+ ${isTopPick ? 'ring-2 ring-hi-400' : ''}`}
65
+ style={{ animationDelay: `${rank * 60}ms` }}
66
+ >
67
+ {/* Top pick ribbon */}
68
+ {isTopPick && (
69
+ <div className="bg-hi-400 text-hi-900 text-[10px] font-black uppercase tracking-widest px-4 py-1 text-center">
70
+ Top Pick
71
+ </div>
72
+ )}
73
+
74
+ {/* Header */}
75
+ <div className="p-5 pb-4 border-b border-ink-100">
76
+ <div className="flex items-start justify-between gap-3">
77
+ <div className="flex-1 min-w-0">
78
+ <div className="flex items-baseline gap-2 flex-wrap mb-0.5">
79
+ <h3 className="font-bold text-ink-950 text-base">{vendor.name}</h3>
80
+ <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${scoreBadge(vendor.overallScore)}`}>
81
+ {vendor.overallScore}/100
82
+ </span>
83
+ </div>
84
+ <a
85
+ href={vendor.website}
86
+ target="_blank"
87
+ rel="noopener noreferrer"
88
+ className="flex items-center gap-1 text-[11px] text-info-600 hover:underline truncate"
89
+ >
90
+ <ExternalLink size={10} />
91
+ {vendor.website.replace(/^https?:\/\//, '')}
92
+ </a>
93
+ </div>
94
+ <button
95
+ onClick={onExclude}
96
+ className="flex items-center gap-1 text-[11px] text-ink-400 hover:text-ink-700 hover:bg-ink-100 px-2 py-1 rounded transition-colors whitespace-nowrap"
97
+ >
98
+ <EyeOff size={11} /> Exclude
99
+ </button>
100
+ </div>
101
+ </div>
102
+
103
+ <div className="p-5 space-y-5">
104
+ {/* Price */}
105
+ <div className="flex items-start gap-3">
106
+ <span className="label-xs w-12 pt-0.5">Price</span>
107
+ <span className="text-sm text-ink-800 font-medium leading-tight">{vendor.priceRange}</span>
108
+ </div>
109
+
110
+ {/* Tags */}
111
+ {vendor.tags.length > 0 && (
112
+ <div className="flex items-center gap-1 flex-wrap">
113
+ {vendor.tags.map((tag) => (
114
+ <span key={tag} className="text-[10px] font-medium bg-ink-100 text-ink-600 px-2 py-0.5 rounded-full">
115
+ {tag}
116
+ </span>
117
+ ))}
118
+ </div>
119
+ )}
120
+
121
+ {/* Score bars */}
122
+ <div className="grid grid-cols-2 gap-3">
123
+ <ScoreBar value={vendor.matchScore} label="Req. match" />
124
+ <ScoreBar value={vendor.overallScore} label="Overall" />
125
+ </div>
126
+
127
+ {/* Requirements */}
128
+ <div>
129
+ <button
130
+ onClick={() => setShowFeatures((v) => !v)}
131
+ className="flex items-center justify-between w-full text-[12px] font-semibold text-ink-700 hover:text-ink-950 mb-2 group"
132
+ >
133
+ <span>
134
+ Requirements
135
+ <span className="ml-1 font-normal text-ink-400">({satisfied}/{total} met)</span>
136
+ </span>
137
+ {showFeatures ? <ChevronUp size={13} className="text-ink-400" /> : <ChevronDown size={13} className="text-ink-400" />}
138
+ </button>
139
+
140
+ {showFeatures && (
141
+ <div className="space-y-1.5 animate-fade-in">
142
+ {vendor.matchedFeatures.map((feat, i) => (
143
+ <div key={i} className="flex items-start gap-2">
144
+ <div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center
145
+ ${feat.satisfied ? 'bg-ink-950' : 'bg-ink-200'}`}
146
+ >
147
+ {feat.satisfied
148
+ ? <Check size={9} className="text-white" />
149
+ : <X size={9} className="text-ink-500" />
150
+ }
151
+ </div>
152
+ <div className="flex-1 min-w-0">
153
+ <div className="flex items-center gap-1.5 flex-wrap">
154
+ <span className="text-[12px] text-ink-800 font-medium">{feat.requirement}</span>
155
+ {feat.weight >= 4 && (
156
+ <span className="text-[9px] font-black bg-hi-100 text-hi-700 px-1 rounded uppercase tracking-wide">
157
+ w:{feat.weight}
158
+ </span>
159
+ )}
160
+ </div>
161
+ <p className="text-[11px] text-ink-500 mt-0.5 leading-relaxed">{feat.notes}</p>
162
+ </div>
163
+ </div>
164
+ ))}
165
+ </div>
166
+ )}
167
+ </div>
168
+
169
+ {/* Risks */}
170
+ {vendor.risks.length > 0 && (
171
+ <div>
172
+ <p className="text-[12px] font-semibold text-ink-700 mb-1.5">Limitations</p>
173
+ <div className="space-y-1">
174
+ {vendor.risks.map((risk, i) => (
175
+ <div key={i} className="flex items-start gap-2 text-[11px] text-hi-700 bg-hi-50 border border-hi-200 rounded-md px-2.5 py-1.5">
176
+ <Minus size={10} className="flex-shrink-0 mt-0.5" />
177
+ {risk}
178
+ </div>
179
+ ))}
180
+ </div>
181
+ </div>
182
+ )}
183
+
184
+ {/* Evidence */}
185
+ {vendor.evidenceLinks.length > 0 && (
186
+ <div>
187
+ <button
188
+ onClick={() => setShowEvidence((v) => !v)}
189
+ className="flex items-center gap-1 text-[11px] text-info-600 hover:text-info-700 font-medium mb-2"
190
+ >
191
+ {showEvidence ? <ChevronUp size={11} /> : <ChevronDown size={11} />}
192
+ {showEvidence ? 'Hide' : 'View'} sources ({vendor.evidenceLinks.length})
193
+ </button>
194
+ {showEvidence && (
195
+ <div className="space-y-1.5 animate-fade-in">
196
+ {vendor.evidenceLinks.map((ev, i) => (
197
+ <div key={i} className="bg-ink-50 border border-ink-200 rounded-md p-2.5">
198
+ <a
199
+ href={ev.url}
200
+ target="_blank"
201
+ rel="noopener noreferrer"
202
+ className="flex items-center gap-1 text-[11px] text-info-600 hover:underline truncate mb-1"
203
+ >
204
+ <ExternalLink size={9} />
205
+ {ev.url}
206
+ </a>
207
+ {ev.snippet && (
208
+ <p className="text-[11px] text-ink-500 italic line-clamp-2">
209
+ "{ev.snippet}"
210
+ </p>
211
+ )}
212
+ </div>
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ )}
218
+ </div>
219
+ </div>
220
+ );
221
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ *, *::before, *::after { box-sizing: border-box; }
7
+
8
+ html {
9
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif;
10
+ font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
11
+ -webkit-font-smoothing: antialiased;
12
+ -moz-osx-font-smoothing: grayscale;
13
+ color: #0a0a0a;
14
+ background: #ffffff;
15
+ }
16
+
17
+ input, textarea, select {
18
+ outline: none;
19
+ }
20
+
21
+ ::selection {
22
+ background: #fef9c3;
23
+ color: #0a0a0a;
24
+ }
25
+ }
26
+
27
+ @layer components {
28
+ /* ── Inputs ── */
29
+ .input-base {
30
+ @apply w-full border border-ink-300 rounded-md px-3 py-2.5 text-sm text-ink-950
31
+ placeholder-ink-400 transition-colors duration-150
32
+ focus:border-ink-900 focus:ring-1 focus:ring-ink-900
33
+ disabled:bg-ink-50 disabled:cursor-not-allowed;
34
+ }
35
+
36
+ /* ── Buttons ── */
37
+ .btn-primary {
38
+ @apply inline-flex items-center justify-center gap-2 bg-ink-950 text-white text-sm font-semibold
39
+ px-4 py-2.5 rounded-md transition-all duration-150
40
+ hover:bg-ink-800 active:scale-[0.98]
41
+ disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100;
42
+ }
43
+
44
+ .btn-secondary {
45
+ @apply inline-flex items-center justify-center gap-2 bg-white text-ink-700 text-sm font-medium
46
+ border border-ink-300 px-4 py-2.5 rounded-md transition-all duration-150
47
+ hover:bg-ink-50 hover:border-ink-400 active:scale-[0.98]
48
+ disabled:opacity-40 disabled:cursor-not-allowed;
49
+ }
50
+
51
+ .btn-ghost {
52
+ @apply inline-flex items-center justify-center gap-1.5 text-ink-500 text-sm font-medium
53
+ px-3 py-1.5 rounded-md transition-colors duration-150
54
+ hover:text-ink-900 hover:bg-ink-100;
55
+ }
56
+
57
+ /* ── Cards ── */
58
+ .card {
59
+ @apply bg-white border border-ink-200 rounded-xl shadow-card;
60
+ }
61
+
62
+ /* ── Labels ── */
63
+ .label-xs {
64
+ @apply text-xs font-semibold tracking-wider uppercase text-ink-500;
65
+ }
66
+
67
+ /* ── Score badge ── */
68
+ .score-hi { @apply bg-hi-100 text-hi-700 border border-hi-300; }
69
+ .score-mid { @apply bg-info-50 text-info-700 border border-info-200; }
70
+ .score-lo { @apply bg-ink-100 text-ink-600 border border-ink-300; }
71
+ }
72
+
73
+ @layer utilities {
74
+ .line-clamp-2 {
75
+ display: -webkit-box;
76
+ -webkit-line-clamp: 2;
77
+ -webkit-box-orient: vertical;
78
+ overflow: hidden;
79
+ }
80
+ .line-clamp-3 {
81
+ display: -webkit-box;
82
+ -webkit-line-clamp: 3;
83
+ -webkit-box-orient: vertical;
84
+ overflow: hidden;
85
+ }
86
+ }
87
+
88
+ /* Scrollbar */
89
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
90
+ ::-webkit-scrollbar-track { background: transparent; }
91
+ ::-webkit-scrollbar-thumb { background: #d4d4d4; border-radius: 3px; }
92
+ ::-webkit-scrollbar-thumb:hover { background: #a3a3a3; }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/src/pages/History.tsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { Clock, ArrowRight, Trash2, AlertCircle, Inbox, Loader2 } from 'lucide-react';
4
+ import { api } from '../api/client';
5
+ import { Shortlist } from '../types';
6
+
7
+ function StatusPill({ status }: { status: string }) {
8
+ const styles: Record<string, string> = {
9
+ done: 'bg-ink-950 text-white',
10
+ processing: 'bg-info-100 text-info-700 animate-pulse',
11
+ pending: 'bg-ink-100 text-ink-500',
12
+ error: 'bg-hi-100 text-hi-700',
13
+ };
14
+ return (
15
+ <span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${styles[status] ?? 'bg-ink-100 text-ink-500'}`}>
16
+ {status}
17
+ </span>
18
+ );
19
+ }
20
+
21
+ function ShortlistRow({ shortlist, onDelete }: { shortlist: Shortlist; onDelete: () => void }) {
22
+ const vendors = shortlist.result?.vendors ?? [];
23
+ const active = vendors.filter((v) => !v.excluded);
24
+ const topVendor = [...active].sort((a, b) => b.overallScore - a.overallScore)[0];
25
+ const createdAt = new Date(shortlist.created_at);
26
+
27
+ return (
28
+ <div className="card p-5 hover:shadow-card-hover transition-shadow duration-200 animate-slide-up">
29
+ <div className="flex items-start justify-between gap-3 mb-3">
30
+ <div className="flex-1 min-w-0">
31
+ <div className="flex items-center gap-2 mb-1.5 flex-wrap">
32
+ <StatusPill status={shortlist.status} />
33
+ <span className="text-[11px] text-ink-400">
34
+ {createdAt.toLocaleDateString()} · {createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
35
+ </span>
36
+ </div>
37
+ <p className="text-sm font-semibold text-ink-900 leading-tight line-clamp-2">
38
+ "{shortlist.need}"
39
+ </p>
40
+ </div>
41
+ <button
42
+ onClick={onDelete}
43
+ className="p-1.5 rounded text-ink-300 hover:text-ink-700 hover:bg-ink-100 transition-colors flex-shrink-0"
44
+ title="Delete"
45
+ >
46
+ <Trash2 size={13} />
47
+ </button>
48
+ </div>
49
+
50
+ {/* Requirements chips */}
51
+ {shortlist.requirements.length > 0 && (
52
+ <div className="flex flex-wrap gap-1 mb-3">
53
+ {shortlist.requirements.slice(0, 4).map((r, i) => (
54
+ <span key={i} className="text-[10px] bg-ink-100 text-ink-600 px-2 py-0.5 rounded-full">
55
+ {r.text.slice(0, 32)}{r.text.length > 32 ? '…' : ''}
56
+ </span>
57
+ ))}
58
+ {shortlist.requirements.length > 4 && (
59
+ <span className="text-[10px] text-ink-400">+{shortlist.requirements.length - 4} more</span>
60
+ )}
61
+ </div>
62
+ )}
63
+
64
+ {/* Top pick highlight */}
65
+ {topVendor && (
66
+ <div className="flex items-center gap-2 bg-hi-50 border border-hi-200 rounded-lg px-3 py-1.5 mb-3">
67
+ <span className="text-[10px] font-bold text-hi-600 uppercase tracking-wider">Top pick</span>
68
+ <span className="text-[12px] font-semibold text-ink-900">{topVendor.name}</span>
69
+ <span className="text-[10px] text-ink-500 ml-auto">{topVendor.overallScore}/100</span>
70
+ </div>
71
+ )}
72
+
73
+ {/* Error */}
74
+ {shortlist.status === 'error' && shortlist.error_message && (
75
+ <div className="flex items-center gap-1.5 text-[11px] text-hi-600 mb-3">
76
+ <AlertCircle size={11} />
77
+ {shortlist.error_message.slice(0, 90)}
78
+ </div>
79
+ )}
80
+
81
+ {/* CTA */}
82
+ {shortlist.status === 'done' && (
83
+ <Link
84
+ to={`/results/${shortlist.id}`}
85
+ className="flex items-center gap-1.5 text-[12px] font-semibold text-info-600 hover:text-info-700 group"
86
+ >
87
+ View comparison
88
+ <span className="text-ink-300">({vendors.length} vendor{vendors.length !== 1 ? 's' : ''})</span>
89
+ <ArrowRight size={12} className="group-hover:translate-x-0.5 transition-transform" />
90
+ </Link>
91
+ )}
92
+ {shortlist.status === 'processing' && (
93
+ <div className="flex items-center gap-1.5 text-[12px] text-info-600">
94
+ <Loader2 size={11} className="animate-spin" />
95
+ {(shortlist as any).progress ?? 'Processing…'}
96
+ </div>
97
+ )}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export default function History() {
103
+ const [shortlists, setShortlists] = useState<Shortlist[]>([]);
104
+ const [loading, setLoading] = useState(true);
105
+ const [error, setError] = useState('');
106
+
107
+ const load = async () => {
108
+ setLoading(true);
109
+ try {
110
+ setShortlists(await api.listShortlists());
111
+ } catch (e) {
112
+ setError(e instanceof Error ? e.message : 'Failed to load history.');
113
+ } finally {
114
+ setLoading(false);
115
+ }
116
+ };
117
+
118
+ useEffect(() => { load(); }, []);
119
+
120
+ const handleDelete = async (id: string) => {
121
+ if (!confirm('Delete this shortlist?')) return;
122
+ try {
123
+ await api.deleteShortlist(id);
124
+ setShortlists((prev) => prev.filter((s) => s.id !== id));
125
+ } catch {
126
+ alert('Delete failed.');
127
+ }
128
+ };
129
+
130
+ return (
131
+ <div className="min-h-screen bg-white">
132
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 py-10">
133
+ <div className="flex items-start justify-between gap-4 mb-7">
134
+ <div>
135
+ <h1 className="text-xl font-black text-ink-950 flex items-center gap-2">
136
+ <Clock size={18} className="text-ink-400" /> Recent Shortlists
137
+ </h1>
138
+ <p className="text-[12px] text-ink-500 mt-0.5">Your last 5 searches, saved automatically.</p>
139
+ </div>
140
+ <Link to="/" className="btn-secondary text-[12px] py-1.5 px-3 flex-shrink-0">
141
+ + New search
142
+ </Link>
143
+ </div>
144
+
145
+ {loading && (
146
+ <div className="flex items-center justify-center py-16">
147
+ <Loader2 size={22} className="animate-spin text-ink-400" />
148
+ </div>
149
+ )}
150
+
151
+ {error && (
152
+ <div className="flex items-center gap-2 text-hi-700 bg-hi-50 border border-hi-200 rounded-xl p-4 text-sm">
153
+ <AlertCircle size={15} /> {error}
154
+ </div>
155
+ )}
156
+
157
+ {!loading && !error && shortlists.length === 0 && (
158
+ <div className="text-center py-16 border border-ink-100 rounded-2xl bg-ink-50">
159
+ <Inbox size={32} className="text-ink-300 mx-auto mb-3" />
160
+ <p className="text-ink-600 font-semibold text-sm">No shortlists yet</p>
161
+ <p className="text-[12px] text-ink-400 mt-1 mb-5">Build your first shortlist to see it here.</p>
162
+ <Link to="/" className="btn-primary text-[13px] py-2 px-4">Get started</Link>
163
+ </div>
164
+ )}
165
+
166
+ <div className="space-y-3">
167
+ {shortlists.map((s) => (
168
+ <ShortlistRow key={s.id} shortlist={s} onDelete={() => handleDelete(s.id)} />
169
+ ))}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
frontend/src/pages/Home.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { Zap, X, Scale, BookOpen, ShieldAlert, Clock, ChevronRight, Info } from 'lucide-react';
4
+ import ShortlistForm from '../components/ShortlistForm';
5
+ import { api } from '../api/client';
6
+ import { ShortlistFormData } from '../types';
7
+
8
+ /* ── Info panel (slides from right) ───────────────────────────────────────── */
9
+ function InfoPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
10
+ const steps = [
11
+ 'Describe what you need in plain English',
12
+ 'Add 1–8 requirements with an importance weight (1–5)',
13
+ 'Optionally exclude vendors you\'ve already ruled out',
14
+ 'VendorLens identifies, scrapes, and analyses 4 vendors',
15
+ 'Review scores, risks, evidence links',
16
+ 'Export a Markdown report to share',
17
+ ];
18
+
19
+ const features = [
20
+ { icon: Scale, title: 'Weighted scoring', body: 'Assign importance 1–5 to each requirement. Scores reflect your priorities.' },
21
+ { icon: BookOpen, title: 'Evidence links', body: 'Real URLs and quoted snippets scraped from vendor pages.' },
22
+ { icon: ShieldAlert,title: 'Risks surfaced', body: 'Every vendor\'s limitations and gotchas, clearly listed.' },
23
+ { icon: Clock, title: 'History', body: 'Last 5 shortlists saved automatically. Revisit anytime.' },
24
+ ];
25
+
26
+ return (
27
+ <>
28
+ <div
29
+ className={`fixed inset-0 bg-black/20 z-40 transition-opacity duration-300 ${open ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
30
+ onClick={onClose}
31
+ />
32
+ <div className={`fixed top-0 right-0 h-full w-80 bg-white border-l border-ink-200 z-50 flex flex-col
33
+ transition-transform duration-300 ease-in-out ${open ? 'translate-x-0' : 'translate-x-full'}`}
34
+ style={{ boxShadow: open ? '-8px 0 32px rgba(0,0,0,0.10)' : 'none' }}
35
+ >
36
+ <div className="flex items-center justify-between px-5 py-4 border-b border-ink-100">
37
+ <span className="font-bold text-ink-950 text-sm">How it works</span>
38
+ <button onClick={onClose} className="p-1.5 rounded hover:bg-ink-100 text-ink-400 hover:text-ink-800 transition-colors">
39
+ <X size={15} />
40
+ </button>
41
+ </div>
42
+
43
+ <div className="flex-1 overflow-y-auto px-5 py-5 space-y-7">
44
+ <div>
45
+ <p className="label-xs mb-3">Steps</p>
46
+ <ol className="space-y-3">
47
+ {steps.map((step, i) => (
48
+ <li key={i} className="flex items-start gap-2.5 text-[12px] text-ink-600 leading-relaxed">
49
+ <span className="w-4 h-4 rounded bg-ink-950 text-white flex items-center justify-center text-[9px] font-bold flex-shrink-0 mt-0.5">
50
+ {i + 1}
51
+ </span>
52
+ {step}
53
+ </li>
54
+ ))}
55
+ </ol>
56
+ </div>
57
+
58
+ <div className="h-px bg-ink-100" />
59
+ <div>
60
+ <p className="label-xs mb-3">What you get</p>
61
+ <div className="space-y-4">
62
+ {features.map(({ icon: Icon, title, body }) => (
63
+ <div key={title} className="flex items-start gap-3">
64
+ <div className="w-7 h-7 rounded-md bg-ink-100 flex items-center justify-center flex-shrink-0">
65
+ <Icon size={13} className="text-ink-600" />
66
+ </div>
67
+ <div>
68
+ <p className="text-[12px] font-semibold text-ink-900">{title}</p>
69
+ <p className="text-[11px] text-ink-500 mt-0.5 leading-relaxed">{body}</p>
70
+ </div>
71
+ </div>
72
+ ))}
73
+ </div>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </>
78
+ );
79
+ }
80
+
81
+ /* ── Main page ─────────────────────────────────────────────────────────────── */
82
+ export default function Home() {
83
+ const navigate = useNavigate();
84
+ const [loading, setLoading] = useState(false);
85
+ const [apiError, setApiError] = useState('');
86
+ const [panelOpen, setPanelOpen] = useState(false);
87
+
88
+ const handleSubmit = async (data: ShortlistFormData) => {
89
+ setLoading(true);
90
+ setApiError('');
91
+ try {
92
+ const shortlist = await api.createShortlist(data);
93
+ navigate(`/results/${shortlist.id}`);
94
+ } catch (err) {
95
+ setApiError(err instanceof Error ? err.message : 'Something went wrong.');
96
+ setLoading(false);
97
+ }
98
+ };
99
+
100
+ return (
101
+ <>
102
+ <InfoPanel open={panelOpen} onClose={() => setPanelOpen(false)} />
103
+
104
+ <div
105
+ className="fixed inset-0 pointer-events-none"
106
+ style={{ background: 'linear-gradient(180deg, #dde3ec 0%, #eaecf0 40%, #f0f2f5 100%)', zIndex: 0 }}
107
+ />
108
+ <div
109
+ className="fixed inset-0 pointer-events-none"
110
+ style={{
111
+ backgroundImage: 'radial-gradient(circle, rgba(71,85,105,0.20) 1px, transparent 1px)',
112
+ backgroundSize: '26px 26px',
113
+ maskImage: 'linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.25) 60%, rgba(0,0,0,0.1) 100%)',
114
+ WebkitMaskImage: 'linear-gradient(180deg, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.25) 60%, rgba(0,0,0,0.1) 100%)',
115
+ zIndex: 1,
116
+ }}
117
+ />
118
+ <div className="relative min-h-[calc(100vh-56px)]" style={{ zIndex: 2 }}>
119
+ <div className="relative max-w-3xl mx-auto px-4 sm:px-6 pt-8 sm:pt-12 pb-6 sm:pb-8 text-center">
120
+ <div className="inline-flex items-center gap-1.5 bg-white/40 backdrop-blur-sm border border-white/60 text-ink-700 text-[11px] font-semibold px-3 py-1 rounded-full mb-6">
121
+ <Zap size={10} className="text-hi-500" fill="currentColor" />
122
+ Powered by Gemini 2.5 Flash
123
+ </div>
124
+
125
+ <h1 className="text-[2.6rem] sm:text-[3.2rem] font-black leading-[1.07] tracking-tight text-ink-950 mb-4">
126
+ Find the right vendor.
127
+ <br />
128
+ <span className="text-ink-600">In seconds.</span>
129
+ </h1>
130
+ <p className="text-[15px] text-ink-600 max-w-md mx-auto leading-relaxed">
131
+ Describe your need, set requirements, and get a researched vendor shortlist — with pricing, risks, and real evidence links.
132
+ </p>
133
+ </div>
134
+
135
+ <div className="max-w-2xl mx-auto px-4 sm:px-6 py-10 relative">
136
+ <div className="flex justify-end mb-3">
137
+ <button
138
+ onClick={() => setPanelOpen(true)}
139
+ className="flex items-center gap-1.5 text-[11px] font-medium text-ink-500 hover:text-ink-800
140
+ hover:bg-white/60 px-2.5 py-1.5 rounded-lg transition-colors"
141
+ >
142
+ <Info size={11} />
143
+ How it works
144
+ <ChevronRight size={10} />
145
+ </button>
146
+ </div>
147
+
148
+ {apiError && (
149
+ <div className="mb-5 text-sm text-hi-700 bg-hi-50 border border-hi-300 rounded-lg px-4 py-3">
150
+ {apiError}
151
+ </div>
152
+ )}
153
+
154
+ <div
155
+ className="border border-white/70 rounded-2xl shadow-card p-4 sm:p-5"
156
+ style={{
157
+ background: 'rgba(255,255,255,0.82)',
158
+ backdropFilter: 'blur(12px)',
159
+ WebkitBackdropFilter: 'blur(12px)',
160
+ }}
161
+ >
162
+ <ShortlistForm onSubmit={handleSubmit} loading={loading} />
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </>
167
+ );
168
+ }
frontend/src/pages/Results.tsx ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useCallback } from 'react';
2
+ import { useParams, Link } from 'react-router-dom';
3
+ import { ArrowLeft, AlertCircle, RefreshCw } from 'lucide-react';
4
+ import { api } from '../api/client';
5
+ import { Shortlist } from '../types';
6
+ import ComparisonTable from '../components/ComparisonTable';
7
+
8
+ const POLL_MS = 2000;
9
+
10
+ const STEPS = [
11
+ 'Queued…',
12
+ 'Identifying top vendors…',
13
+ 'Scraping pricing pages…',
14
+ 'Analysing vendors against your requirements…',
15
+ 'Generating markdown report…',
16
+ ];
17
+
18
+ function ProgressPulse({ step }: { step: string | null }) {
19
+ const idx = STEPS.indexOf(step || '') + 1 || 1;
20
+ const pct = Math.round((idx / STEPS.length) * 100);
21
+
22
+ return (
23
+ <div className="min-h-screen bg-white flex items-center justify-center p-4">
24
+ <div className="w-full max-w-md">
25
+ {/* Spinner */}
26
+ <div className="flex justify-center mb-8">
27
+ <div className="relative w-16 h-16">
28
+ <div className="absolute inset-0 rounded-full border-4 border-ink-100" />
29
+ <div className="absolute inset-0 rounded-full border-4 border-ink-950 border-t-transparent animate-spin" />
30
+ </div>
31
+ </div>
32
+
33
+ {/* Step label */}
34
+ <div className="text-center mb-6">
35
+ <p className="text-base font-bold text-ink-950 mb-1">Building your shortlist</p>
36
+ <p className="text-sm text-info-600 font-medium animate-pulse">
37
+ {step || 'Starting up…'}
38
+ </p>
39
+ </div>
40
+
41
+ {/* Progress bar */}
42
+ <div className="mb-6">
43
+ <div className="flex justify-between text-[11px] text-ink-400 mb-1.5">
44
+ <span>Progress</span>
45
+ <span>{pct}%</span>
46
+ </div>
47
+ <div className="h-1.5 bg-ink-100 rounded-full overflow-hidden">
48
+ <div
49
+ className="h-full bg-ink-950 rounded-full transition-all duration-700"
50
+ style={{ width: `${pct}%` }}
51
+ />
52
+ </div>
53
+ </div>
54
+
55
+ {/* Steps checklist */}
56
+ <div className="space-y-2">
57
+ {STEPS.map((s, i) => {
58
+ const done = i < idx - 1;
59
+ const active = i === idx - 1;
60
+ return (
61
+ <div key={s} className={`flex items-center gap-2.5 text-[12px] transition-opacity
62
+ ${done ? 'opacity-40' : active ? 'opacity-100' : 'opacity-25'}`}
63
+ >
64
+ <div className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0
65
+ ${done ? 'bg-ink-950' : active ? 'bg-hi-400 animate-pulse' : 'bg-ink-200'}`}
66
+ >
67
+ {done && (
68
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none">
69
+ <path d="M1.5 4L3.5 6L6.5 2" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
70
+ </svg>
71
+ )}
72
+ </div>
73
+ <span className={active ? 'font-semibold text-ink-900' : 'text-ink-500'}>{s}</span>
74
+ </div>
75
+ );
76
+ })}
77
+ </div>
78
+
79
+ <p className="text-center text-[11px] text-ink-400 mt-6">
80
+ Usually takes 20–40 seconds
81
+ </p>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export default function Results() {
88
+ const { id } = useParams<{ id: string }>();
89
+ const [shortlist, setShortlist] = useState<Shortlist | null>(null);
90
+ const [error, setError] = useState('');
91
+
92
+ const fetchData = useCallback(async () => {
93
+ if (!id) return 'error';
94
+ try {
95
+ const data = await api.getShortlist(id);
96
+ setShortlist(data);
97
+ return data.status;
98
+ } catch (err) {
99
+ setError(err instanceof Error ? err.message : 'Failed to load.');
100
+ return 'error';
101
+ }
102
+ }, [id]);
103
+
104
+ useEffect(() => {
105
+ let timer: ReturnType<typeof setInterval>;
106
+ fetchData().then((status) => {
107
+ if (status === 'processing' || status === 'pending') {
108
+ timer = setInterval(async () => {
109
+ const s = await fetchData();
110
+ if (s === 'done' || s === 'error') clearInterval(timer);
111
+ }, POLL_MS);
112
+ }
113
+ });
114
+ return () => clearInterval(timer);
115
+ }, [fetchData]);
116
+
117
+ const refresh = () => fetchData();
118
+
119
+ const handleExclude = async (name: string) => {
120
+ if (!id) return;
121
+ await api.excludeVendor(id, name);
122
+ refresh();
123
+ };
124
+
125
+ const handleInclude = async (name: string) => {
126
+ if (!id) return;
127
+ await api.includeVendor(id, name);
128
+ refresh();
129
+ };
130
+
131
+ // Network error
132
+ if (error) {
133
+ return (
134
+ <div className="min-h-screen flex items-center justify-center p-4">
135
+ <div className="card p-8 max-w-sm text-center">
136
+ <AlertCircle size={32} className="text-hi-500 mx-auto mb-3" />
137
+ <p className="font-bold text-ink-950 mb-1">Network error</p>
138
+ <p className="text-sm text-ink-500 mb-5">{error}</p>
139
+ <Link to="/" className="btn-primary">← Start over</Link>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // Processing
146
+ if (!shortlist || shortlist.status === 'processing' || shortlist.status === 'pending') {
147
+ return <ProgressPulse step={shortlist?.progress ?? null} />;
148
+ }
149
+
150
+ // Error from backend
151
+ if (shortlist.status === 'error') {
152
+ const errMsg = shortlist.error_message || 'An unexpected error occurred.';
153
+ const isQuota = errMsg.toLowerCase().includes('quota') || errMsg.toLowerCase().includes('rate limit');
154
+ return (
155
+ <div className="min-h-screen flex items-center justify-center p-4"
156
+ style={{ background: 'linear-gradient(180deg,#dde3ec 0%,#eaecf0 40%,#f0f2f5 100%)' }}
157
+ >
158
+ <div
159
+ className="border border-white/70 rounded-2xl p-8 max-w-sm w-full text-center"
160
+ style={{ background: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(12px)' }}
161
+ >
162
+ {/* Icon */}
163
+ <div className={`w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4
164
+ ${isQuota ? 'bg-hi-100' : 'bg-ink-100'}`}
165
+ >
166
+ <AlertCircle size={22} className={isQuota ? 'text-hi-600' : 'text-ink-500'} />
167
+ </div>
168
+
169
+ <p className="font-bold text-ink-950 text-base mb-2">
170
+ {isQuota ? 'Rate limit reached' : 'Processing failed'}
171
+ </p>
172
+ <p className="text-[13px] text-ink-600 mb-1 leading-relaxed">{errMsg}</p>
173
+ {isQuota && (
174
+ <p className="text-[11px] text-ink-400 mb-5">
175
+ We automatically tried all API keys. Please wait a moment before retrying.
176
+ </p>
177
+ )}
178
+ {!isQuota && (
179
+ <p className="text-[11px] text-ink-400 mb-5">
180
+ Try rephrasing your need or simplifying your requirements.
181
+ </p>
182
+ )}
183
+
184
+ <Link to="/" className="btn-primary gap-2 w-full justify-center">
185
+ <RefreshCw size={13} /> Try again
186
+ </Link>
187
+ </div>
188
+ </div>
189
+ );
190
+ }
191
+
192
+ // Done
193
+ return (
194
+ <div className="min-h-screen bg-white">
195
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
196
+ {/* Breadcrumb + title */}
197
+ <div className="mb-7">
198
+ <Link to="/" className="inline-flex items-center gap-1.5 text-[12px] text-ink-400 hover:text-ink-700 mb-3 transition-colors">
199
+ <ArrowLeft size={12} /> New search
200
+ </Link>
201
+ <h1 className="text-xl font-black text-ink-950 mb-1.5">Shortlist results</h1>
202
+ <p className="text-sm text-ink-600 italic mb-3 break-words">"{shortlist.need}"</p>
203
+ {shortlist.requirements.length > 0 && (
204
+ <div className="flex flex-wrap gap-1.5">
205
+ {shortlist.requirements.map((r, i) => (
206
+ <span key={i} className="inline-flex items-center gap-1 text-[11px] bg-ink-100 text-ink-600 px-2 py-0.5 rounded-full">
207
+ {r.text}
208
+ <span className={`text-[9px] font-black px-1 rounded ml-0.5 ${r.weight >= 4 ? 'bg-hi-200 text-hi-700' : 'text-ink-400'}`}>
209
+ w{r.weight}
210
+ </span>
211
+ </span>
212
+ ))}
213
+ </div>
214
+ )}
215
+ </div>
216
+
217
+ {shortlist.result ? (
218
+ <ComparisonTable
219
+ result={shortlist.result}
220
+ shortlistId={shortlist.id}
221
+ need={shortlist.need}
222
+ requirements={shortlist.requirements}
223
+ onExclude={handleExclude}
224
+ onInclude={handleInclude}
225
+ />
226
+ ) : (
227
+ <p className="text-ink-400 text-sm">No results available.</p>
228
+ )}
229
+ </div>
230
+ </div>
231
+ );
232
+ }
frontend/src/pages/Status.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from 'react';
2
+ import { Activity, Database, Cpu, Server, RefreshCw, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
3
+ import { api } from '../api/client';
4
+ import { HealthStatus } from '../types';
5
+
6
+ interface RowProps {
7
+ icon: React.ElementType;
8
+ label: string;
9
+ sublabel: string;
10
+ ok: boolean | null;
11
+ }
12
+
13
+ function StatusRow({ icon: Icon, label, sublabel, ok }: RowProps) {
14
+ return (
15
+ <div className="flex items-center justify-between gap-3 py-4 border-b border-ink-100 last:border-0">
16
+ <div className="flex items-center gap-3 min-w-0">
17
+ <div className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0
18
+ ${ok === null ? 'bg-ink-50 border-ink-200' : ok ? 'bg-ink-950 border-ink-950' : 'bg-hi-50 border-hi-200'}`}
19
+ >
20
+ <Icon size={16} className={ok === null ? 'text-ink-400' : ok ? 'text-white' : 'text-hi-600'} />
21
+ </div>
22
+ <div>
23
+ <p className="text-sm font-semibold text-ink-900">{label}</p>
24
+ <p className="text-[11px] text-ink-400 mt-0.5">{sublabel}</p>
25
+ </div>
26
+ </div>
27
+ <div className="flex items-center gap-1.5">
28
+ {ok === null
29
+ ? <Loader2 size={16} className="animate-spin text-ink-400" />
30
+ : ok
31
+ ? <CheckCircle2 size={18} className="text-ink-950" />
32
+ : <XCircle size={18} className="text-hi-500" />
33
+ }
34
+ <span className={`text-[11px] font-bold ${ok === null ? 'text-ink-400' : ok ? 'text-ink-950' : 'text-hi-600'}`}>
35
+ {ok === null ? 'Checking' : ok ? 'Healthy' : 'Down'}
36
+ </span>
37
+ </div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export default function Status() {
43
+ const [health, setHealth] = useState<HealthStatus | null>(null);
44
+ const [loading, setLoading] = useState(true);
45
+ const [error, setError] = useState('');
46
+ const [checkedAt, setCheckedAt] = useState<Date | null>(null);
47
+
48
+ const check = async () => {
49
+ setLoading(true);
50
+ setError('');
51
+ try {
52
+ setHealth(await api.getHealth());
53
+ setCheckedAt(new Date());
54
+ } catch (e) {
55
+ setError(e instanceof Error ? e.message : 'Health check failed.');
56
+ } finally {
57
+ setLoading(false);
58
+ }
59
+ };
60
+
61
+ useEffect(() => { check(); }, []);
62
+
63
+ const ok = health?.status === 'ok';
64
+
65
+ return (
66
+ <div className="min-h-screen bg-white">
67
+ <div className="max-w-xl mx-auto px-4 sm:px-6 py-10">
68
+ {/* Header */}
69
+ <div className="flex items-start justify-between gap-4 mb-7">
70
+ <div>
71
+ <h1 className="text-xl font-black text-ink-950 flex items-center gap-2">
72
+ <Activity size={18} className="text-ink-400" /> System Status
73
+ </h1>
74
+ {checkedAt && (
75
+ <p className="text-[11px] text-ink-400 mt-0.5">
76
+ Last checked {checkedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
77
+ </p>
78
+ )}
79
+ </div>
80
+ <button
81
+ onClick={check}
82
+ disabled={loading}
83
+ className="btn-ghost disabled:opacity-40 flex-shrink-0"
84
+ >
85
+ <RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
86
+ Refresh
87
+ </button>
88
+ </div>
89
+
90
+ {/* Overall banner */}
91
+ {health && (
92
+ <div className={`rounded-xl border px-4 py-3 mb-5 flex items-center gap-2 text-sm font-semibold
93
+ ${ok
94
+ ? 'bg-ink-950 border-ink-950 text-white'
95
+ : 'bg-hi-50 border-hi-300 text-hi-800'
96
+ }`}
97
+ >
98
+ {ok ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
99
+ {ok ? 'All systems operational' : 'Partial degradation detected'}
100
+ {health.latency_ms > 0 && (
101
+ <span className={`ml-auto text-[11px] font-normal ${ok ? 'text-white/60' : 'text-hi-600'}`}>
102
+ {health.latency_ms}ms
103
+ </span>
104
+ )}
105
+ </div>
106
+ )}
107
+
108
+ {error && !health && (
109
+ <div className="bg-hi-50 border border-hi-200 rounded-xl p-4 text-sm text-hi-700 mb-5">
110
+ {error}
111
+ </div>
112
+ )}
113
+
114
+ {/* Status rows */}
115
+ <div className="card overflow-hidden">
116
+ <div className="px-5">
117
+ <StatusRow
118
+ icon={Server}
119
+ label="Backend API"
120
+ sublabel="FastAPI · Python 3.11"
121
+ ok={loading && !health ? null : (health?.backend ?? false)}
122
+ />
123
+ <StatusRow
124
+ icon={Database}
125
+ label="Database"
126
+ sublabel="SQLite · shortlist storage"
127
+ ok={loading && !health ? null : (health?.database ?? false)}
128
+ />
129
+ <StatusRow
130
+ icon={Cpu}
131
+ label="LLM Connection"
132
+ sublabel={health?.llm_model ? `Google ${health.llm_model}` : 'Google Gemini 2.5 Flash'}
133
+ ok={loading && !health ? null : (health?.llm ?? false)}
134
+ />
135
+ </div>
136
+ </div>
137
+
138
+ </div>
139
+ </div>
140
+ );
141
+ }
frontend/src/types/index.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Requirement {
2
+ text: string;
3
+ weight: number;
4
+ }
5
+
6
+ export interface EvidenceLink {
7
+ url: string;
8
+ snippet: string;
9
+ }
10
+
11
+ export interface MatchedFeature {
12
+ requirement: string;
13
+ satisfied: boolean;
14
+ notes: string;
15
+ weight: number;
16
+ }
17
+
18
+ export interface Vendor {
19
+ name: string;
20
+ website: string;
21
+ priceRange: string;
22
+ matchedFeatures: MatchedFeature[];
23
+ risks: string[];
24
+ evidenceLinks: EvidenceLink[];
25
+ overallScore: number;
26
+ matchScore: number;
27
+ tags: string[];
28
+ excluded: boolean;
29
+ }
30
+
31
+ export interface ShortlistResult {
32
+ vendors: Vendor[];
33
+ summary: string;
34
+ recommendation: string;
35
+ markdownReport?: string;
36
+ }
37
+
38
+ export interface Shortlist {
39
+ id: string;
40
+ need: string;
41
+ requirements: Requirement[];
42
+ excluded_vendors: string[];
43
+ result: ShortlistResult | null;
44
+ status: 'pending' | 'processing' | 'done' | 'error';
45
+ progress: string | null;
46
+ error_message: string | null;
47
+ created_at: string;
48
+ }
49
+
50
+ export interface HealthStatus {
51
+ status: string;
52
+ backend: boolean;
53
+ database: boolean;
54
+ llm: boolean;
55
+ llm_model: string;
56
+ latency_ms: number;
57
+ }
58
+
59
+ export interface ShortlistFormData {
60
+ need: string;
61
+ requirements: Requirement[];
62
+ excluded_vendors: string[];
63
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4
+ theme: {
5
+ extend: {
6
+ colors: {
7
+ ink: {
8
+ 50: '#fafafa',
9
+ 100: '#f5f5f5',
10
+ 200: '#e5e5e5',
11
+ 300: '#d4d4d4',
12
+ 400: '#a3a3a3',
13
+ 500: '#737373',
14
+ 600: '#525252',
15
+ 700: '#404040',
16
+ 800: '#262626',
17
+ 900: '#171717',
18
+ 950: '#0a0a0a',
19
+ },
20
+ info: {
21
+ 50: '#eff6ff',
22
+ 100: '#dbeafe',
23
+ 200: '#bfdbfe',
24
+ 500: '#3b82f6',
25
+ 600: '#2563eb',
26
+ 700: '#1d4ed8',
27
+ },
28
+ hi: {
29
+ 50: '#fefce8',
30
+ 100: '#fef9c3',
31
+ 300: '#fde047',
32
+ 400: '#facc15',
33
+ 500: '#eab308',
34
+ 600: '#ca8a04',
35
+ 700: '#a16207',
36
+ },
37
+ },
38
+ fontFamily: {
39
+ sans: ['-apple-system', 'BlinkMacSystemFont', '"Inter"', '"Segoe UI"', 'Roboto', 'sans-serif'],
40
+ mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
41
+ },
42
+ boxShadow: {
43
+ 'card': '0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.08)',
44
+ 'card-hover': '0 4px 12px 0 rgb(0 0 0 / 0.1), 0 2px 4px -1px rgb(0 0 0 / 0.06)',
45
+ 'dropdown': '0 8px 24px rgb(0 0 0 / 0.12)',
46
+ },
47
+ animation: {
48
+ 'fade-in': 'fadeIn 0.2s ease-out',
49
+ 'slide-up': 'slideUp 0.3s ease-out',
50
+ 'bar-fill': 'barFill 0.8s ease-out forwards',
51
+ },
52
+ keyframes: {
53
+ fadeIn: { from: { opacity: '0' }, to: { opacity: '1' } },
54
+ slideUp: { from: { opacity: '0', transform: 'translateY(8px)' }, to: { opacity: '1', transform: 'translateY(0)' } },
55
+ barFill: { from: { width: '0%' }, to: { width: 'var(--bar-width)' } },
56
+ },
57
+ },
58
+ },
59
+ plugins: [],
60
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src"]
20
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ '/api': {
10
+ target: 'http://localhost:8000',
11
+ changeOrigin: true,
12
+ },
13
+ },
14
+ },
15
+ build: {
16
+ outDir: 'dist',
17
+ emptyOutDir: true,
18
+ target: 'es2015',
19
+ cssMinify: true,
20
+ rollupOptions: {
21
+ output: {
22
+ manualChunks: {
23
+ // React core — cached separately, rarely changes
24
+ 'vendor-react': ['react', 'react-dom'],
25
+ 'vendor-router': ['react-router-dom'],
26
+ // Icons tree-shaken but split for caching
27
+ 'vendor-icons': ['lucide-react'],
28
+ },
29
+ },
30
+ },
31
+ },
32
+ })