Spaces:
Sleeping
Sleeping
Commit ·
b8f6c99
0
Parent(s):
initial
Browse files- .env.example +14 -0
- .github/workflows/keep-alive.yml +13 -0
- .gitignore +39 -0
- ABOUTME.md +84 -0
- AI_NOTES.md +68 -0
- Dockerfile +16 -0
- LICENSE +21 -0
- PROMPTS_USED.md +96 -0
- README.md +170 -0
- backend/.env.example +6 -0
- backend/Dockerfile +14 -0
- backend/app/__init__.py +0 -0
- backend/app/config.py +31 -0
- backend/app/database.py +44 -0
- backend/app/main.py +36 -0
- backend/app/models.py +69 -0
- backend/app/routers/__init__.py +0 -0
- backend/app/routers/health.py +36 -0
- backend/app/routers/shortlist.py +159 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/llm.py +279 -0
- backend/app/services/scraper.py +77 -0
- backend/requirements.txt +12 -0
- docker-compose.yml +31 -0
- frontend/Dockerfile +13 -0
- frontend/index.html +39 -0
- frontend/nginx.conf +15 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +29 -0
- frontend/postcss.config.js +6 -0
- frontend/public/manifest.json +17 -0
- frontend/public/robots.txt +4 -0
- frontend/public/vendor-lens-logo-sm.png +0 -0
- frontend/public/vendor-lens-logo.png +0 -0
- frontend/src/App.tsx +37 -0
- frontend/src/api/client.ts +45 -0
- frontend/src/components/ComparisonTable.tsx +442 -0
- frontend/src/components/Header.tsx +110 -0
- frontend/src/components/ShortlistForm.tsx +258 -0
- frontend/src/components/VendorCard.tsx +221 -0
- frontend/src/index.css +92 -0
- frontend/src/main.tsx +10 -0
- frontend/src/pages/History.tsx +174 -0
- frontend/src/pages/Home.tsx +168 -0
- frontend/src/pages/Results.tsx +232 -0
- frontend/src/pages/Status.tsx +141 -0
- frontend/src/types/index.ts +63 -0
- frontend/tailwind.config.js +60 -0
- frontend/tsconfig.json +20 -0
- 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 |
+
})
|