Electronics Catalog: API, MCP server, frontend and deployment
Verified catalogue of mobiles and laptops sold in India, collected from real retail listings (FastAPI backend, React frontend, Postgres/pgvector). - REST API under /api/elec (read-only catalogue; admin endpoints need login) - MCP server (FastMCP) at /mcp/ with list_categories, search_products, get_product and price_history tools - Real ratings and reviews read from product pages and search results - Production Dockerfile (requirements-api.txt, no PyTorch) and .env.production.example; remote database only via an explicit ELEC_ALLOW_REMOTE_DB host/name allowlist - docs/API.md: endpoint and MCP reference with live examples Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
frontend/.env.development
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# Local dev: blank = relative /api/* requests, proxied by Vite to http://127.0.0.1:8000.
|
||||
VITE_API_BASE_URL=
|
||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Leave unset for local development: Vite proxies /api/* to http://127.0.0.1:8000.
|
||||
# VITE_API_BASE_URL=
|
||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
22
frontend/index.html
Normal file
22
frontend/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet" />
|
||||
<title>Electronics Catalog</title>
|
||||
<meta name="description" content="Local electronics catalogue: real products, prices and availability across Indian retailers, with a Tamil Nadu focus." />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
2176
frontend/package-lock.json
generated
Normal file
2176
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:unified": "vite build --mode unified",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"oxlint": "^1.69.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
}
|
||||
13
frontend/public/favicon.svg
Normal file
13
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Electronics Catalog">
|
||||
<!--
|
||||
Electrical-products mark: a lightning bolt inside a plug face.
|
||||
Same drawing as src/components/BrandMark.jsx. Colours are the app's
|
||||
tokens (ink-950 #0f1626, amber-500 #e2a33d, amber-400 #f0b94f).
|
||||
Flat shapes, strokes >= 4px, so it stays legible at 16px.
|
||||
-->
|
||||
<rect width="64" height="64" rx="16" fill="#0f1626"/>
|
||||
<rect x="23" y="7" width="5" height="11" rx="2.5" fill="#e2a33d"/>
|
||||
<rect x="36" y="7" width="5" height="11" rx="2.5" fill="#e2a33d"/>
|
||||
<circle cx="32" cy="37" r="17" fill="none" stroke="#e2a33d" stroke-width="4.5"/>
|
||||
<path d="M34.5 25 24 39.5h7.2L28.8 50 40 34.8h-7.3L34.5 25z" fill="#f0b94f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 774 B |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
98
frontend/src/App.jsx
Normal file
98
frontend/src/App.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { useAuth } from './context/useAuth';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
|
||||
/*
|
||||
* Every page except the login screen is loaded on demand.
|
||||
*
|
||||
* Imported eagerly, the whole app landed in one ~790kB bundle, and every
|
||||
* visitor downloaded all of it before they could reach the login form.
|
||||
* Splitting per route means each page's code arrives when it is first opened.
|
||||
* (The charting library that dominated that bundle went away with the
|
||||
* analytics pages, but the split is still worth keeping.)
|
||||
*
|
||||
* LoginPage stays eager: it is the first thing an unauthenticated visitor
|
||||
* sees, so deferring it would only add a round trip before the form appears.
|
||||
*/
|
||||
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })));
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
|
||||
function PageFallback({ label }) {
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-slate-950 text-slate-400 flex items-center justify-center text-sm">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, allowedRoles }) {
|
||||
const { user, role, restoring } = useAuth();
|
||||
|
||||
// A token restored from sessionStorage is still being checked against
|
||||
// /api/auth/me. Redirecting to /login now would bounce a valid session out
|
||||
// on every refresh; rendering now would flash a dashboard about to be torn
|
||||
// down if the token turns out to be expired.
|
||||
if (restoring) {
|
||||
return <PageFallback label="Restoring session..." />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const effectiveRole = role || (user.role === 'store' ? 'user' : user.role);
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(effectiveRole)) {
|
||||
// Every remaining route is admin-only, so there is nowhere else in the app
|
||||
// to send a non-admin. This can still be reached by a token minted before
|
||||
// the `user` account was disabled - those stay valid for their 12h TTL -
|
||||
// so it has to terminate somewhere public rather than redirect in a loop.
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
{/* Covers the network fetch for a lazily-loaded page chunk. */}
|
||||
<Suspense fallback={<PageFallback label="Loading..." />}>
|
||||
<Routes>
|
||||
{/* Public Login Route */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Catalog Route - RESTRICTED TO ADMIN ONLY */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Panel Route - RESTRICTED TO ADMIN ONLY */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch-all redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
130
frontend/src/api/client.js
Normal file
130
frontend/src/api/client.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
// Where the access token lives. sessionStorage, not localStorage: the token is
|
||||
// a bearer credential, and a tab-scoped store means closing the tab ends the
|
||||
// session rather than leaving a working credential on disk.
|
||||
export const TOKEN_STORAGE_KEY = 'app_access_token';
|
||||
|
||||
// Read at module load so a page refresh is already authenticated before
|
||||
// AuthContext mounts and the first request goes out.
|
||||
let authToken = (() => {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
let onUnauthorized = null;
|
||||
|
||||
/** Called by AuthContext on login/logout. Pass null to clear. */
|
||||
export function setAuthToken(token) {
|
||||
authToken = token || null;
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
else sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
} catch {
|
||||
/* private browsing with storage disabled - the in-memory copy still works */
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered by AuthContext so an expired token anywhere in the app drops the
|
||||
* session once, rather than leaving every panel to render its own 401 error.
|
||||
*/
|
||||
export function setUnauthorizedHandler(fn) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
`Could not reach the API at ${BASE || '(same origin)'}${path}. Is the backend running ` +
|
||||
`(uvicorn app.main:app) and reachable?`,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch {
|
||||
/* ignore parse errors, keep generic message */
|
||||
}
|
||||
// 401 means the token is missing, expired or invalid - the session is over.
|
||||
// 403 is a live session lacking a permission, so it must NOT log you out.
|
||||
if (res.status === 401 && onUnauthorized) onUnauthorized(detail);
|
||||
throw new ApiError(detail, res.status);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function qs(params = {}) {
|
||||
const usp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '' && v !== false) usp.set(k, v);
|
||||
});
|
||||
const s = usp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getHealth: () => request('/api/health'),
|
||||
|
||||
// --- Auth ---
|
||||
login: (username, password) =>
|
||||
request('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
getMe: () => request('/api/auth/me'),
|
||||
getRoles: () => request('/api/auth/roles'),
|
||||
|
||||
// --- Electronics catalogue (verified products only) ---
|
||||
getCategories: () => request('/api/elec/categories'),
|
||||
getBrands: (category) => request(`/api/elec/brands${qs({ category })}`),
|
||||
getProducts: (params) => request(`/api/elec/products${qs(params)}`),
|
||||
getProduct: (id) => request(`/api/elec/products/${id}`),
|
||||
getPriceHistory: (id) => request(`/api/elec/products/${id}/price-history`),
|
||||
getSites: () => request('/api/elec/sites'),
|
||||
|
||||
// --- Admin ---
|
||||
startRun: (body) => request('/api/elec/admin/runs', { method: 'POST', body: JSON.stringify(body) }),
|
||||
getRunJob: (jobId) => request(`/api/elec/admin/runs/${jobId}`),
|
||||
getRuns: () => request('/api/elec/admin/runs'),
|
||||
probeSite: (domain) => request(`/api/elec/admin/sites/${encodeURIComponent(domain)}/probe`, { method: 'POST' }),
|
||||
getReviewQueue: () => request('/api/elec/admin/review'),
|
||||
reviewMatch: (listingId, approve) =>
|
||||
request(`/api/elec/admin/review/${listingId}`, { method: 'POST', body: JSON.stringify({ approve }) }),
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
19
frontend/src/components/Atoms.jsx
Normal file
19
frontend/src/components/Atoms.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function Spinner({ label = 'Loading…', className = '' }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-sm text-slate-500 ${className}`}>
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-ink-700/25 border-t-amber-500" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, title, subtitle, action }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-ink-700/20 bg-paper-50 px-6 py-14 text-center">
|
||||
{Icon && <Icon className="mb-1 h-9 w-9 text-ink-700/40" strokeWidth={1.5} />}
|
||||
<p className="font-display text-base font-semibold text-ink-900">{title}</p>
|
||||
{subtitle && <p className="max-w-sm text-sm text-slate-500">{subtitle}</p>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/BrandMark.jsx
Normal file
21
frontend/src/components/BrandMark.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
/*
|
||||
* Electrical-products mark: a lightning bolt inside a plug face (two prongs
|
||||
* above, a round socket ring around the bolt). Same drawing as
|
||||
* public/favicon.svg, kept inline so it scales crisply and needs no asset.
|
||||
*/
|
||||
export function BrandMark({ className = 'h-12 w-12', title = 'Electronics Catalog' }) {
|
||||
return (
|
||||
<svg viewBox="0 0 64 64" role="img" aria-label={title} className={className}>
|
||||
<rect width="64" height="64" rx="16" fill="#0f1626" />
|
||||
{/* Plug prongs */}
|
||||
<rect x="23" y="7" width="5" height="11" rx="2.5" fill="#e2a33d" />
|
||||
<rect x="36" y="7" width="5" height="11" rx="2.5" fill="#e2a33d" />
|
||||
{/* Socket ring */}
|
||||
<circle cx="32" cy="37" r="17" fill="none" stroke="#e2a33d" strokeWidth="4.5" />
|
||||
{/* Bolt */}
|
||||
<path d="M34.5 25 24 39.5h7.2L28.8 50 40 34.8h-7.3L34.5 25z" fill="#f0b94f" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/ErrorBoundary.jsx
Normal file
48
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
export class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-8 m-4 rounded-2xl border border-rose-500/30 bg-rose-50/50 text-slate-900 shadow-lg text-center space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-rose-500/20 text-rose-600">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-rose-950">Something went wrong in this section</h3>
|
||||
<p className="text-xs text-rose-700 mt-1 max-w-md mx-auto">
|
||||
{this.state.error?.message || 'An unexpected rendering error occurred.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
}}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs shadow-md transition cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" /> Reload Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
132
frontend/src/components/NavigationHeader.jsx
Normal file
132
frontend/src/components/NavigationHeader.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Wrench, ShoppingBag, AlertTriangle, UserCheck, ShieldCheck, LogOut } from 'lucide-react';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
|
||||
export function NavigationHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon: TitleIcon,
|
||||
health,
|
||||
onResetHome,
|
||||
children,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const path = location.pathname;
|
||||
const navigate = useNavigate();
|
||||
const { user, role, logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const getRoleBadge = () => {
|
||||
if (role === 'admin') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 border border-amber-500/30 px-2.5 py-0.5 text-[11px] font-bold text-amber-600">
|
||||
<ShieldCheck className="h-3 w-3" /> Admin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 border border-blue-500/30 px-2.5 py-0.5 text-[11px] font-bold text-blue-600">
|
||||
<UserCheck className="h-3 w-3" /> User
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-30 flex flex-col gap-3 border-b border-ink-900/10 bg-paper-50/95 px-6 py-3.5 backdrop-blur-md transition-all shadow-xs">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
{/* Left Section: Page Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
{title && (
|
||||
<div className="flex items-center gap-2.5 py-0.5">
|
||||
{TitleIcon && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-ink-900/5 text-ink-900">
|
||||
<TitleIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="font-display text-sm font-bold text-ink-950 flex items-center gap-1.5">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="text-[11px] text-slate-400 font-normal leading-tight">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Section: System Health + Role-Based Nav Tabs + Logout Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{health && health.status !== 'ok' && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-maroon-100 px-3 py-1 text-xs font-medium text-maroon-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Database offline - run `docker compose up -d`
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* User Role Profile Badge */}
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs shadow-2xs">
|
||||
<span className="font-bold text-slate-800">{user?.display_name || user?.username || 'Guest'}</span>
|
||||
{getRoleBadge()}
|
||||
</div>
|
||||
|
||||
{/* Role-tailored Navigation Tabs */}
|
||||
<nav className="flex items-center gap-1 rounded-full bg-ink-100/90 p-1 text-xs font-medium backdrop-blur">
|
||||
{/* Catalog tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => {
|
||||
if (onResetHome) onResetHome();
|
||||
}}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="h-3.5 w-3.5" /> Catalog
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Admin Panel tab ONLY visible to Admin role */}
|
||||
{role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className={`flex items-center gap-1.5 rounded-full px-3.5 py-1.5 transition-all ${
|
||||
path === '/admin' ? 'bg-ink-950 text-paper-100 shadow-sm font-semibold' : 'text-slate-600 hover:text-ink-950 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 text-amber-400" /> Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</header>
|
||||
|
||||
{/*
|
||||
Logout is pinned to the bottom-right of the viewport rather than sitting
|
||||
in the header row. It is rendered as a sibling of <header>, not inside
|
||||
it: the header creates a stacking/blur context via `sticky` and
|
||||
`backdrop-blur`, and a fixed child of that context is positioned
|
||||
against the header rather than the viewport.
|
||||
|
||||
Icon-only, so the accessible name comes from aria-label - without it the
|
||||
button would be announced as just "button".
|
||||
*/}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Logout"
|
||||
aria-label="Logout"
|
||||
className="fixed bottom-6 right-6 z-50 flex items-center justify-center rounded-full bg-rose-600 hover:bg-rose-700 text-white p-4 shadow-lg transition-all active:scale-95 cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-6 w-6" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/ProductCard.jsx
Normal file
60
frontend/src/components/ProductCard.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Store, MapPin, ImageOff } from 'lucide-react';
|
||||
import { formatINR, variantText } from '../lib/format';
|
||||
|
||||
export function ProductCard({ product, onOpen }) {
|
||||
const price = formatINR(product.best_price);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(product)}
|
||||
className="group flex flex-col overflow-hidden rounded-xl border border-ink-900/10 bg-white text-left shadow-2xs transition hover:-translate-y-0.5 hover:shadow-md"
|
||||
>
|
||||
<div className="flex h-44 items-center justify-center bg-paper-50 p-3">
|
||||
{product.image_url ? (
|
||||
<img
|
||||
src={product.image_url}
|
||||
alt={product.display_name}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-xs text-slate-400">
|
||||
<ImageOff className="h-6 w-6" /> No verified image yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5 p-3.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-400">{product.brand}</p>
|
||||
<h3 className="font-display text-sm font-semibold leading-snug text-ink-950 group-hover:text-ink-700">
|
||||
{product.display_name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">{variantText(product)}</p>
|
||||
<div className="mt-auto flex items-end justify-between gap-2 pt-2">
|
||||
<div>
|
||||
{price ? (
|
||||
<>
|
||||
<p className="text-[11px] text-slate-400">from</p>
|
||||
<p className="font-display text-lg font-bold text-ink-950">{price}</p>
|
||||
<p className="text-[11px] text-slate-400">at {product.best_price_site}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">Price not published in search results</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-ink-100 px-2 py-0.5 text-[11px] font-medium text-ink-800">
|
||||
<Store className="h-3 w-3" /> {product.platform_count} platform{product.platform_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
{product.sold_by_tn_retailer && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-maroon-100 px-2 py-0.5 text-[11px] font-medium text-maroon-600">
|
||||
<MapPin className="h-3 w-3" /> TN retailer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
313
frontend/src/components/ProductModal.jsx
Normal file
313
frontend/src/components/ProductModal.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { X, ExternalLink, MapPin, Info, Star, MessageSquareText } from 'lucide-react';
|
||||
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { api } from '../api/client';
|
||||
import { Spinner } from './Atoms';
|
||||
import { formatINR, timeAgo, variantText } from '../lib/format';
|
||||
|
||||
const SPEC_LABELS = {
|
||||
ram_gb: 'RAM (GB)', storage_gb: 'Storage (GB)', display_inch: 'Display (inch)', display_type: 'Display type',
|
||||
refresh_hz: 'Refresh rate (Hz)', processor: 'Processor', rear_camera_mp: 'Rear camera (MP)',
|
||||
front_camera_mp: 'Front camera (MP)', battery_mah: 'Battery (mAh)', os: 'OS', network: 'Network',
|
||||
colour: 'Colour', storage_type: 'Storage type', resolution: 'Resolution', gpu: 'Graphics',
|
||||
weight_kg: 'Weight (kg)', battery_wh: 'Battery (Wh)',
|
||||
};
|
||||
const LINE_COLOURS = ['#16213e', '#e2a33d', '#3f8556', '#8b2e3c', '#44557d', '#c2872a', '#2f6a43'];
|
||||
|
||||
const SENTIMENT_STYLE = {
|
||||
positive: { dot: 'bg-leaf-500', label: 'Positive' },
|
||||
neutral: { dot: 'bg-slate-400', label: 'Neutral' },
|
||||
negative: { dot: 'bg-maroon-500', label: 'Negative' },
|
||||
};
|
||||
|
||||
function Stars({ value, size = 'h-3.5 w-3.5' }) {
|
||||
const v = Number(value) || 0;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5" aria-label={`${v} out of 5 stars`}>
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const fill = Math.max(0, Math.min(1, v - (i - 1)));
|
||||
return (
|
||||
<span key={i} className={`relative inline-block ${size}`}>
|
||||
<Star className={`absolute inset-0 ${size} text-ink-100`} fill="currentColor" strokeWidth={0} />
|
||||
<span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
|
||||
<Star className={`${size} text-amber-500`} fill="currentColor" strokeWidth={0} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatReviewDate(value) {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function RatingsAndReviews({ rating, reviews }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Ratings & reviews</h3>
|
||||
<div className="grid gap-3 lg:grid-cols-[220px_1fr]">
|
||||
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
||||
{rating ? (
|
||||
<>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="font-display text-4xl font-bold text-ink-950">{rating.value.toFixed(1)}</span>
|
||||
<span className="mb-1 text-sm text-slate-500">/ 5</span>
|
||||
</div>
|
||||
<Stars value={rating.value} size="h-4 w-4" />
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
{rating.count ? `${rating.count.toLocaleString('en-IN')} ratings across ` : 'Across '}
|
||||
{rating.sources.length} platform{rating.sources.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<ul className="mt-3 flex flex-col gap-1.5 border-t border-ink-900/5 pt-2 text-xs">
|
||||
{rating.sources.map((s) => (
|
||||
<li key={s.source_url} className="flex items-center justify-between gap-2">
|
||||
<a href={s.source_url} target="_blank" rel="noreferrer" className="truncate text-ink-700 hover:underline">
|
||||
{s.site}
|
||||
</a>
|
||||
<span className="shrink-0 font-medium text-ink-950">
|
||||
{s.rating.toFixed(1)} <Star className="inline h-3 w-3 -translate-y-px text-amber-500" fill="currentColor" strokeWidth={0} />
|
||||
{s.review_count ? <span className="ml-1 font-normal text-slate-400">({s.review_count.toLocaleString('en-IN')})</span> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">No rating published by the platforms we can read yet.</p>
|
||||
)}
|
||||
</div>
|
||||
{reviews.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{reviews.map((r, i) => {
|
||||
const tone = SENTIMENT_STYLE[r.sentiment] || SENTIMENT_STYLE.neutral;
|
||||
return (
|
||||
<article key={`${r.source_url}-${i}`} className="flex flex-col gap-1.5 rounded-xl border border-ink-900/10 bg-white p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Stars value={r.rating} />
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-slate-500">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} /> {tone.label}
|
||||
</span>
|
||||
</div>
|
||||
{r.title && <p className="text-sm font-semibold text-ink-950">{r.title}</p>}
|
||||
<p className="line-clamp-5 whitespace-pre-line text-xs text-ink-800">{r.body}</p>
|
||||
<p className="mt-auto pt-1 text-[11px] text-slate-400">
|
||||
{[r.author, formatReviewDate(r.review_date)].filter(Boolean).join(' · ')}
|
||||
{(r.author || r.review_date) && ' · '}
|
||||
<a href={r.source_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-0.5 text-ink-700 hover:underline">
|
||||
from {r.site} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-dashed border-ink-900/15 bg-white/60 p-4 text-xs text-slate-500">
|
||||
<MessageSquareText className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
No verified reviews found on readable product pages.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceHistory({ rows }) {
|
||||
const { data, sites } = useMemo(() => {
|
||||
const siteNames = [...new Set(rows.map((r) => r.site))];
|
||||
const byDay = new Map();
|
||||
rows.forEach((r) => {
|
||||
const day = r.observed_at.slice(0, 10);
|
||||
const entry = byDay.get(day) || { day };
|
||||
entry[r.site] = Number(r.price);
|
||||
byDay.set(day, entry);
|
||||
});
|
||||
return { data: [...byDay.values()], sites: siteNames };
|
||||
}, [rows]);
|
||||
if (data.length < 2) {
|
||||
return <p className="text-xs text-slate-500">Price history appears after the product has been observed on more than one day.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data} margin={{ top: 8, right: 12, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e7eaf2" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => formatINR(v)} width={80} />
|
||||
<Tooltip formatter={(v) => formatINR(v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{sites.map((s, i) => (
|
||||
<Line key={s} type="monotone" dataKey={s} stroke={LINE_COLOURS[i % LINE_COLOURS.length]} dot connectNulls />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductModal({ productId, onClose }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [imageIndex, setImageIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
Promise.all([api.getProduct(productId), api.getPriceHistory(productId)])
|
||||
.then(([d, h]) => {
|
||||
if (!alive) return;
|
||||
setDetail(d);
|
||||
setHistory(h);
|
||||
})
|
||||
.catch((e) => alive && setError(e.message));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const specs = detail ? Object.entries(detail.canonical_specs || {}) : [];
|
||||
const image = detail?.images?.[imageIndex];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-ink-950/50 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative my-8 w-full max-w-5xl rounded-2xl bg-paper-50 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button onClick={onClose} aria-label="Close" className="absolute right-4 top-4 rounded-full p-1.5 text-slate-500 hover:bg-ink-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
{!detail && !error && <Spinner className="p-10" label="Loading product…" />}
|
||||
{error && <p className="p-10 text-sm text-maroon-600">{error}</p>}
|
||||
{detail && (
|
||||
<div className="grid gap-6 p-6 md:grid-cols-[280px_1fr]">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex h-72 items-center justify-center rounded-xl border border-ink-900/10 bg-white p-3">
|
||||
{image ? (
|
||||
<img src={image.url} alt={detail.display_name} referrerPolicy="no-referrer" className="max-h-full max-w-full object-contain" />
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">No verified image yet</p>
|
||||
)}
|
||||
</div>
|
||||
{image && (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Image from {image.site} ({image.source_type === 'search_image' ? 'image search, matched to this listing' : 'the product page'}).{' '}
|
||||
<a className="underline" href={image.found_on} target="_blank" rel="noreferrer">Source page</a>
|
||||
</p>
|
||||
)}
|
||||
{detail.images.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{detail.images.map((img, i) => (
|
||||
<button
|
||||
key={img.url}
|
||||
onClick={() => setImageIndex(i)}
|
||||
className={`h-14 w-14 overflow-hidden rounded-lg border bg-white p-1 ${i === imageIndex ? 'border-ink-900' : 'border-ink-900/10'}`}
|
||||
>
|
||||
<img src={img.url} alt="" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">{detail.brand} · {detail.category}</p>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-950">{detail.display_name}</h2>
|
||||
<p className="text-sm text-slate-500">{variantText(detail)}</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Where it is sold</h3>
|
||||
<div className="overflow-x-auto rounded-xl border border-ink-900/10 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-ink-50 text-left text-[11px] uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Platform</th>
|
||||
<th className="px-3 py-2">Price</th>
|
||||
<th className="px-3 py-2">Seen</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.offers.map((o) => (
|
||||
<tr key={o.listing_id} className="border-t border-ink-900/5">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-medium text-ink-950">{o.site}</span>
|
||||
{o.site_region === 'TN' && (
|
||||
<span className="ml-1.5 inline-flex items-center gap-0.5 rounded-full bg-maroon-100 px-1.5 py-0.5 text-[10px] font-medium text-maroon-600">
|
||||
<MapPin className="h-2.5 w-2.5" /> TN
|
||||
</span>
|
||||
)}
|
||||
{o.colour && <p className="text-[11px] text-slate-400">{o.colour}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold">
|
||||
{o.price_outlier ? (
|
||||
<span title="Search-engine price that disagrees with this product's page prices; not used as best price.">
|
||||
<span className="font-normal text-slate-400 line-through">{formatINR(o.price)}</span>
|
||||
<span className="ml-1 text-[10px] font-medium text-maroon-600">unconfirmed</span>
|
||||
</span>
|
||||
) : (
|
||||
formatINR(o.price) || <span className="text-xs font-normal text-slate-400">not stated</span>
|
||||
)}
|
||||
{o.mrp && <p className="text-[11px] font-normal text-slate-400 line-through">{formatINR(o.mrp)}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{timeAgo(o.observed_at)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<a href={o.source_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-700 hover:underline">
|
||||
Open <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-1.5 flex items-start gap-1 text-[11px] text-slate-500">
|
||||
<Info className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
Prices are national listing prices as published by each platform; none was checked against a Tamil Nadu
|
||||
pincode unless marked. Platforms that are not fetched directly show only what their search results state.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<RatingsAndReviews rating={detail.rating} reviews={detail.reviews || []} />
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Specifications</h3>
|
||||
{specs.length ? (
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-1.5 rounded-xl border border-ink-900/10 bg-white p-3 text-sm sm:grid-cols-2">
|
||||
{specs.map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 border-b border-ink-900/5 py-1" title={detail.spec_sources?.[k]?.from || ''}>
|
||||
<dt className="text-slate-500">{SPEC_LABELS[k] || k}</dt>
|
||||
<dd className="text-right font-medium text-ink-950">{String(v)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">No specifications were readable from this product's pages yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 font-display text-sm font-semibold text-ink-900">Price history</h3>
|
||||
<PriceHistory rows={history} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/context/AuthContext.jsx
Normal file
119
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { api, setAuthToken, setUnauthorizedHandler, getAuthToken } from '../api/client';
|
||||
import { AuthContext } from './useAuth';
|
||||
|
||||
const USER_STORAGE_KEY = 'app_user_session';
|
||||
|
||||
/*
|
||||
* The server is now the only authority on identity.
|
||||
*
|
||||
* An earlier version of this file kept a VALID_CREDENTIALS table and signed
|
||||
* people in locally whenever /api/auth/login was unreachable or rejected them.
|
||||
* That made sense while the backend had no real auth - the profile only chose
|
||||
* which buttons to draw. It does not any more: every write endpoint checks the
|
||||
* token, so a locally-minted session would render a full dashboard whose every
|
||||
* action then failed with a 401. Login lives entirely on the server.
|
||||
*/
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() => {
|
||||
// Only trust a stored profile if its token is still present alongside it.
|
||||
try {
|
||||
if (!getAuthToken()) return null;
|
||||
const saved = sessionStorage.getItem(USER_STORAGE_KEY);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
} catch (e) {
|
||||
console.warn('Could not restore auth session', e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// True until a restored token has been checked against the server, so the
|
||||
// app can hold off rendering rather than flashing a dashboard that is about
|
||||
// to be torn down by a 401.
|
||||
const [restoring, setRestoring] = useState(() => Boolean(getAuthToken()));
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setUser(null);
|
||||
setAuthToken(null);
|
||||
try {
|
||||
sessionStorage.removeItem(USER_STORAGE_KEY);
|
||||
localStorage.removeItem(USER_STORAGE_KEY);
|
||||
} catch {
|
||||
/* storage unavailable - in-memory state is already cleared */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Any 401 from anywhere in the app ends the session exactly once.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => clearSession());
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [clearSession]);
|
||||
|
||||
// Validate a restored token before trusting the profile stored next to it.
|
||||
// Without this, a token that expired overnight would let the app mount and
|
||||
// then fail every request individually.
|
||||
useEffect(() => {
|
||||
if (!restoring) return;
|
||||
let cancelled = false;
|
||||
|
||||
api.getMe()
|
||||
.then((profile) => {
|
||||
if (cancelled) return;
|
||||
setUser(profile);
|
||||
sessionStorage.setItem(USER_STORAGE_KEY, JSON.stringify(profile));
|
||||
})
|
||||
.catch(() => {
|
||||
// 401 already cleared the session via the handler above; anything else
|
||||
// (backend down mid-refresh) is also safest treated as signed out.
|
||||
if (!cancelled) clearSession();
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setRestoring(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [restoring, clearSession]);
|
||||
|
||||
useEffect(() => {
|
||||
// A previous version persisted to localStorage. Clear any leftover so an
|
||||
// old profile can't outlive the tab it was created in.
|
||||
localStorage.removeItem(USER_STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sign in. The `role` argument the login form passes is ignored on purpose:
|
||||
* the role now comes from the account the password belongs to, not from the
|
||||
* tab the user happened to click. Picking "Admin" and typing the user
|
||||
* password signs you in as a user.
|
||||
*/
|
||||
const login = async (username, password) => {
|
||||
const res = await api.login(username, password);
|
||||
setAuthToken(res.access_token);
|
||||
setUser(res.user);
|
||||
try {
|
||||
sessionStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res.user));
|
||||
} catch {
|
||||
/* storage unavailable - the session still works for this page load */
|
||||
}
|
||||
setRestoring(false);
|
||||
return res.user;
|
||||
};
|
||||
|
||||
const logout = () => clearSession();
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission) =>
|
||||
Boolean(user) && (user.role === 'admin' || (user.permissions || []).includes(permission)),
|
||||
[user]
|
||||
);
|
||||
|
||||
const effectiveRole = user?.role === 'store' ? 'user' : (user?.role || 'user');
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, role: effectiveRole, restoring, login, logout, hasPermission }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
20
frontend/src/context/useAuth.js
Normal file
20
frontend/src/context/useAuth.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/*
|
||||
* The context object and its hook live here, apart from AuthContext.jsx.
|
||||
*
|
||||
* That split is what React Fast Refresh needs: a module that exports a
|
||||
* component alongside non-component values cannot be hot-swapped, so every
|
||||
* edit to the provider would remount the whole tree and drop the session you
|
||||
* were testing with. AuthContext.jsx now exports only <AuthProvider>, and the
|
||||
* hook everything imports lives in this plain module.
|
||||
*/
|
||||
export const AuthContext = createContext(null);
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
88
frontend/src/index.css
Normal file
88
frontend/src/index.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
Design tokens - "Global Catalogue Ledger" direction.
|
||||
Price-tag and ledger styling carried over from the original catalogue,
|
||||
paired with a calm, modern data-tool palette. Navy/cream
|
||||
base (echoes the navy/light-blue tone already used in this project's
|
||||
engineering docs) with a turmeric-amber accent and a maroon highlight
|
||||
for badges/sale-style tags.
|
||||
*/
|
||||
@theme {
|
||||
--font-display: "Sora", "Inter", system-ui, sans-serif;
|
||||
--font-body: "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "IBM Plex Mono", monospace;
|
||||
|
||||
--color-ink-950: #0f1626;
|
||||
--color-ink-900: #16213e;
|
||||
--color-ink-800: #1f2d4f;
|
||||
--color-ink-700: #2c3e63;
|
||||
--color-ink-600: #44557d;
|
||||
--color-ink-100: #e7eaf2;
|
||||
--color-ink-50: #f2f4f8;
|
||||
|
||||
--color-paper-50: #fdfbf6;
|
||||
--color-paper-100: #faf6ed;
|
||||
--color-paper-200: #f1ead9;
|
||||
--color-paper-300: #e4d8bd;
|
||||
|
||||
--color-amber-100: #fbe9c6;
|
||||
--color-amber-400: #f0b94f;
|
||||
--color-amber-500: #e2a33d;
|
||||
--color-amber-600: #c2872a;
|
||||
|
||||
--color-maroon-100: #f3dde1;
|
||||
--color-maroon-500: #8b2e3c;
|
||||
--color-maroon-600: #732431;
|
||||
|
||||
--color-leaf-100: #dcefe1;
|
||||
--color-leaf-500: #3f8556;
|
||||
--color-leaf-600: #2f6a43;
|
||||
|
||||
--color-slate-400: #7c8392;
|
||||
--color-slate-500: #5b6472;
|
||||
--color-slate-600: #454c58;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: var(--color-paper-100);
|
||||
color: var(--color-ink-950);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* Receipt-style dashed divider, used between chat turns and price-tag cards */
|
||||
.dash-divider {
|
||||
background-image: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--color-ink-700) 0,
|
||||
var(--color-ink-700) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Price-tag "punch hole" notch used on product cards */
|
||||
.price-tag-notch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: -7px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--color-paper-100);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-ink-900);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-ink-700);
|
||||
border-radius: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
38
frontend/src/lib/format.js
Normal file
38
frontend/src/lib/format.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const inr = new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 });
|
||||
|
||||
/** Money arrives from the API as a decimal string ("74999.00"); null means unknown. */
|
||||
export function formatINR(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? inr.format(n) : null;
|
||||
}
|
||||
|
||||
export function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 90) return 'just now';
|
||||
const minutes = seconds / 60;
|
||||
if (minutes < 90) return `${Math.round(minutes)} min ago`;
|
||||
const hours = minutes / 60;
|
||||
if (hours < 36) return `${Math.round(hours)} h ago`;
|
||||
return `${Math.round(hours / 24)} days ago`;
|
||||
}
|
||||
|
||||
/** How a price/listing was obtained, in words a buyer understands. */
|
||||
export const SOURCE_LABEL = {
|
||||
scraped_page: { label: 'Read from product page', tone: 'bg-leaf-100 text-leaf-600' },
|
||||
brand_official: { label: 'Brand official site', tone: 'bg-ink-100 text-ink-800' },
|
||||
search_snippet: { label: 'From web search result', tone: 'bg-amber-100 text-amber-600' },
|
||||
search_image: { label: 'Image search', tone: 'bg-amber-100 text-amber-600' },
|
||||
};
|
||||
|
||||
export function variantText(p) {
|
||||
const parts = [];
|
||||
if (p.ram_gb) parts.push(`${Number(p.ram_gb)} GB RAM`);
|
||||
if (p.storage_gb) {
|
||||
const gb = Number(p.storage_gb);
|
||||
parts.push(gb >= 1024 && gb % 1024 === 0 ? `${gb / 1024} TB` : `${gb} GB`);
|
||||
}
|
||||
if (p.processor) parts.push(p.processor.toUpperCase());
|
||||
return parts.join(' · ');
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
213
frontend/src/pages/AdminPage.jsx
Normal file
213
frontend/src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Wrench, Play, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { Spinner } from '../components/Atoms';
|
||||
import { timeAgo } from '../lib/format';
|
||||
|
||||
const BRANDS = {
|
||||
mobiles: ['samsung', 'apple', 'xiaomi', 'oneplus', 'vivo', 'oppo', 'realme', 'motorola', 'google', 'nothing'],
|
||||
laptops: ['hp', 'dell', 'lenovo', 'asus', 'acer', 'apple', 'msi', 'samsung'],
|
||||
};
|
||||
// Recent runs are only a short-term progress aid: show the last few, and let
|
||||
// them age out of view (the rows stay in the database - price history
|
||||
// references them).
|
||||
const RECENT_RUN_LIMIT = 3;
|
||||
const RECENT_RUN_MAX_AGE_MS = 18 * 60 * 60 * 1000;
|
||||
|
||||
function Card({ title, children, action }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-ink-900/10 bg-white p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-display text-base font-semibold text-ink-950">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SitesPanel() {
|
||||
const [sites, setSites] = useState(null);
|
||||
const load = useCallback(() => api.getSites().then(setSites).catch(() => setSites([])), []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
const retail = (sites || []).filter((s) => s.kind !== 'brand_official');
|
||||
return (
|
||||
<Card title="Platforms and how they are read" action={<button onClick={load} className="text-slate-500"><RefreshCw className="h-4 w-4" /></button>}>
|
||||
{!sites ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-[11px] uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="py-1.5">Platform</th>
|
||||
<th>Region</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{retail.map((s) => (
|
||||
<tr key={s.domain} className="border-t border-ink-900/5 align-top">
|
||||
<td className="py-2">
|
||||
<p className="font-medium">{s.name}</p>
|
||||
<p className="text-[11px] text-slate-400">{s.domain}</p>
|
||||
</td>
|
||||
<td className="py-2 text-xs">{s.region === 'TN' ? 'Tamil Nadu' : 'National'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RunPanel() {
|
||||
const [category, setCategory] = useState('mobiles');
|
||||
const [selected, setSelected] = useState(['samsung']);
|
||||
const [limit, setLimit] = useState(10);
|
||||
const [useLlm, setUseLlm] = useState(true);
|
||||
const [job, setJob] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const timer = useRef(null);
|
||||
|
||||
const loadRuns = useCallback(() => api.getRuns().then(setRuns).catch(() => {}), []);
|
||||
useEffect(() => {
|
||||
loadRuns();
|
||||
// Re-evaluate the age cut-off every minute so old runs drop out of view.
|
||||
const tick = setInterval(() => setNow(Date.now()), 60000);
|
||||
return () => {
|
||||
clearInterval(timer.current);
|
||||
clearInterval(tick);
|
||||
};
|
||||
}, [loadRuns]);
|
||||
const recentRuns = runs
|
||||
.filter((r) => r.started_at && now - new Date(r.started_at).getTime() <= RECENT_RUN_MAX_AGE_MS)
|
||||
.slice(0, RECENT_RUN_LIMIT);
|
||||
const running = job?.status === 'running' || job?.status === 'queued';
|
||||
|
||||
const toggle = (b) => setSelected((s) => (s.includes(b) ? s.filter((x) => x !== b) : [...s, b]));
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { job_id } = await api.startRun({ category, brands: selected, limit, use_llm: useLlm });
|
||||
clearInterval(timer.current);
|
||||
timer.current = setInterval(async () => {
|
||||
const j = await api.getRunJob(job_id);
|
||||
setJob(j);
|
||||
if (j.status === 'done' || j.status === 'failed') {
|
||||
clearInterval(timer.current);
|
||||
loadRuns();
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Scrap Product list">
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex gap-2">
|
||||
{Object.keys(BRANDS).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => {
|
||||
setCategory(c);
|
||||
setSelected([BRANDS[c][0]]);
|
||||
}}
|
||||
className={`rounded-full px-3 py-1 ${category === c ? 'bg-ink-950 text-paper-100' : 'bg-ink-100'}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{BRANDS[category].map((b) => (
|
||||
<label key={b} className={`cursor-pointer rounded-full border px-2.5 py-1 text-xs ${selected.includes(b) ? 'border-ink-900 bg-ink-50' : 'border-ink-900/10'}`}>
|
||||
<input type="checkbox" className="mr-1" checked={selected.includes(b)} onChange={() => toggle(b)} />
|
||||
{b}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-1.5">
|
||||
Models per brand
|
||||
<input type="number" min="1" max="60" value={limit} onChange={(e) => setLimit(Number(e.target.value))} className="w-16 rounded-md border border-ink-900/10 px-2 py-1" />
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={useLlm} onChange={(e) => setUseLlm(e.target.checked)} /> Fill spec gaps with local LLM (grounded)
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Runs search the web for each brand, read pages only on platforms graded A/B (robots.txt obeyed, one request per 3 s per
|
||||
site), and store nothing that was not read from a real page or search result.
|
||||
</p>
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={!selected.length || running}
|
||||
className="group inline-flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-ink-950 to-ink-900 px-5 py-3 text-sm font-semibold text-paper-100 shadow-md shadow-ink-950/20 transition-all duration-200 hover:-translate-y-0.5 hover:from-ink-900 hover:to-ink-800 hover:shadow-lg hover:shadow-amber-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60 focus-visible:ring-offset-2 active:translate-y-0 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-md cursor-pointer"
|
||||
>
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Scraping…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 text-amber-400 transition-transform duration-200 group-hover:scale-110" /> Start to Scrap
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{error && <p className="rounded-md bg-maroon-100 px-3 py-2 text-xs text-maroon-600">{error}</p>}
|
||||
{job && (
|
||||
<div className="rounded-lg bg-ink-950 p-3 font-mono text-[11px] text-paper-100">
|
||||
<p className="mb-1 text-amber-400">status: {job.status}</p>
|
||||
{job.log.slice(-12).map((line, i) => (
|
||||
<p key={i} className="whitespace-pre-wrap">{line}</p>
|
||||
))}
|
||||
{job.error && <p className="text-maroon-100">{job.error}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold text-slate-500">Recent runs</p>
|
||||
{!recentRuns.length && <p className="py-1.5 text-xs text-slate-400">No runs in the last 18 hours.</p>}
|
||||
<ul className="divide-y divide-ink-900/5 text-xs">
|
||||
{recentRuns.map((r) => (
|
||||
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||
<span>
|
||||
#{r.id} {r.kind} {r.params?.category || ''} {(r.params?.brands || []).join(', ')}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{r.status} · {timeAgo(r.started_at)}
|
||||
{r.stats?.products_verified !== undefined && ` · ${r.stats.products_verified} verified`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<NavigationHeader title="Admin" subtitle="Platforms and product scraping runs" icon={Wrench} />
|
||||
<main className="mx-auto grid max-w-7xl gap-5 px-6 py-6 lg:grid-cols-2">
|
||||
<div className="lg:col-span-2">
|
||||
<SitesPanel />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<RunPanel />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
frontend/src/pages/HomePage.jsx
Normal file
155
frontend/src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Cpu, Search, Smartphone, Laptop, PackageSearch } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { ProductCard } from '../components/ProductCard';
|
||||
import { ProductModal } from '../components/ProductModal';
|
||||
import { EmptyState, Spinner } from '../components/Atoms';
|
||||
import { formatINR } from '../lib/format';
|
||||
|
||||
const CATEGORY_ICONS = { mobiles: Smartphone, laptops: Laptop };
|
||||
|
||||
export function HomePage() {
|
||||
const [health, setHealth] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [category, setCategory] = useState('mobiles');
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [brand, setBrand] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [maxPrice, setMaxPrice] = useState('');
|
||||
const [result, setResult] = useState({ total: 0, products: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [openId, setOpenId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getHealth().then(setHealth).catch(() => setHealth({ status: 'degraded', database: false }));
|
||||
api.getCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setBrand('');
|
||||
api.getBrands(category).then(setBrands).catch((e) => setError(e.message));
|
||||
}, [category]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getProducts({ category, brand, q: query, max_price: maxPrice, limit: 120 })
|
||||
.then(setResult)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [category, brand, query, maxPrice]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(load, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<NavigationHeader
|
||||
title="Electronics Catalog"
|
||||
subtitle="Verified products · real listings · Tamil Nadu focus"
|
||||
icon={Cpu}
|
||||
health={health}
|
||||
onResetHome={() => setBrand('')}
|
||||
/>
|
||||
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-5 px-6 py-6">
|
||||
{/* Category switch */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(categories.length ? categories : [{ slug: 'mobiles', name: 'Mobiles' }, { slug: 'laptops', name: 'Laptops' }]).map((c) => {
|
||||
const Icon = CATEGORY_ICONS[c.slug] || PackageSearch;
|
||||
return (
|
||||
<button
|
||||
key={c.slug}
|
||||
onClick={() => setCategory(c.slug)}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition ${
|
||||
category === c.slug ? 'bg-ink-950 text-paper-100' : 'bg-white text-ink-800 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" /> {c.name}
|
||||
{c.product_count !== undefined && <span className="text-xs opacity-70">{c.product_count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Brands */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
<button
|
||||
onClick={() => setBrand('')}
|
||||
className={`shrink-0 rounded-xl border px-4 py-2.5 text-left text-sm ${brand === '' ? 'border-ink-900 bg-white' : 'border-ink-900/10 bg-white/60'}`}
|
||||
>
|
||||
<p className="font-semibold">All brands</p>
|
||||
</button>
|
||||
{brands.map((b) => (
|
||||
<button
|
||||
key={b.brand_slug}
|
||||
onClick={() => setBrand(b.brand_slug === brand ? '' : b.brand_slug)}
|
||||
className={`shrink-0 rounded-xl border px-4 py-2.5 text-left text-sm transition ${
|
||||
brand === b.brand_slug ? 'border-ink-900 bg-white shadow-sm' : 'border-ink-900/10 bg-white/60 hover:bg-white'
|
||||
} ${b.product_count === 0 ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<p className="font-semibold text-ink-950">{b.brand}</p>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{b.product_count} product{b.product_count === 1 ? '' : 's'}
|
||||
{b.min_price ? ` · from ${formatINR(b.min_price)}` : ''}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-ink-900/10 bg-white px-4 py-3">
|
||||
<label className="flex min-w-60 flex-1 items-center gap-2 text-sm">
|
||||
<Search className="h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search models, e.g. Galaxy S25, Redmi Note"
|
||||
className="w-full bg-transparent outline-none placeholder:text-slate-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||
Max ₹
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1000"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className="w-28 rounded-md border border-ink-900/10 px-2 py-1"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
{loading ? 'Loading…' : `${result.total} verified product${result.total === 1 ? '' : 's'}`} · A product is shown only
|
||||
when real listings on at least two platforms confirm it.
|
||||
</p>
|
||||
|
||||
{error && <p className="rounded-lg bg-maroon-100 px-4 py-3 text-sm text-maroon-600">{error}</p>}
|
||||
{loading && !result.products.length ? (
|
||||
<Spinner label="Loading products…" />
|
||||
) : result.products.length ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{result.products.map((p) => (
|
||||
<ProductCard key={p.product_id} product={p} onOpen={(prod) => setOpenId(prod.product_id)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={PackageSearch}
|
||||
title="No verified products here yet"
|
||||
subtitle="Start a collection run from the Admin page (or `python -m app.electronics.cli collect`) to discover real listings."
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openId && <ProductModal productId={openId} onClose={() => setOpenId(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
frontend/src/pages/LoginPage.jsx
Normal file
215
frontend/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/useAuth';
|
||||
import { ShieldCheck, Lock, User, ArrowRight, Eye, EyeOff, Zap, BadgeCheck, IndianRupee, MapPin } from 'lucide-react';
|
||||
import { BrandMark } from '../components/BrandMark';
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
{ icon: BadgeCheck, text: 'Only products confirmed by real listings on two or more platforms' },
|
||||
{ icon: IndianRupee, text: 'Prices read from retailer pages and search results, never generated' },
|
||||
{ icon: MapPin, text: 'National chains and Tamil Nadu retailers side by side' },
|
||||
];
|
||||
|
||||
// Faint circuit traces behind the brand panel - decorative only.
|
||||
function CircuitPattern() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full opacity-[0.12]"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 400 600"
|
||||
>
|
||||
<g fill="none" stroke="#e2a33d" strokeWidth="1.5">
|
||||
<path d="M0 80h120l30 30h90l20-20h140" />
|
||||
<path d="M0 200h60l40 40h160l30-30h110" />
|
||||
<path d="M40 600V470l40-40V330l30-30" />
|
||||
<path d="M400 380H300l-30 30H150l-40 40H0" />
|
||||
<path d="M260 600V520l40-40h100" />
|
||||
<path d="M330 0v60l-30 30v70" />
|
||||
</g>
|
||||
<g fill="#e2a33d">
|
||||
<circle cx="260" cy="90" r="4" />
|
||||
<circle cx="150" cy="110" r="4" />
|
||||
<circle cx="100" cy="240" r="4" />
|
||||
<circle cx="110" cy="300" r="4" />
|
||||
<circle cx="270" cy="410" r="4" />
|
||||
<circle cx="300" cy="480" r="4" />
|
||||
<circle cx="300" cy="160" r="4" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* There is exactly one interactive account: `admin`.
|
||||
*
|
||||
* This screen used to offer an Admin/User tab pair, which was always more
|
||||
* suggestion than control - the server assigns the role from the account the
|
||||
* password belongs to (see AuthContext.login), so the tab never affected the
|
||||
* outcome. With the `user` account switched off in the backend config, the
|
||||
* tabs would have offered a sign-in that could only ever fail, so they are gone
|
||||
* and this is a single admin form.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleFormSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError('Please enter both username and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const user = await login(username, password);
|
||||
setLoading(false);
|
||||
// Only `admin` can sign in, and every route is admin-only, so there is
|
||||
// one destination. Sending a non-admin to /login instead would loop.
|
||||
navigate(user.role === 'admin' ? '/admin' : '/login');
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
// Surface the server's own message: it distinguishes bad credentials
|
||||
// from the lockout after repeated failures, which a generic string hides.
|
||||
setError(err?.message || 'Login failed. Check your username and password.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-paper-100 text-ink-950 font-sans flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="grid w-full max-w-5xl overflow-hidden rounded-3xl border border-ink-900/10 bg-paper-50 shadow-2xl shadow-ink-950/10 md:grid-cols-[1.05fr_1fr]">
|
||||
{/* Brand panel */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-ink-950 via-ink-900 to-ink-800 p-8 text-paper-100 sm:p-10">
|
||||
<CircuitPattern />
|
||||
<div className="pointer-events-none absolute -bottom-24 -right-24 h-72 w-72 rounded-full bg-amber-500/20 blur-3xl" />
|
||||
<div className="relative z-10 flex h-full flex-col gap-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandMark className="h-12 w-12 rounded-2xl ring-1 ring-amber-500/40" />
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold leading-tight">Electronics Catalog</p>
|
||||
<p className="text-xs text-amber-400">Electrical products · Tamil Nadu</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:mt-6">
|
||||
<p className="inline-flex items-center gap-1.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-400">
|
||||
<Zap className="h-3.5 w-3.5" /> Product platform
|
||||
</p>
|
||||
<h1 className="mt-4 font-display text-3xl font-bold leading-tight sm:text-4xl">
|
||||
Every appliance,
|
||||
<br />
|
||||
<span className="text-amber-400">every price,</span> verified.
|
||||
</h1>
|
||||
<p className="mt-3 max-w-sm text-sm text-paper-300">
|
||||
Mobiles, laptops and more - collected from Indian retailers and checked across platforms.
|
||||
</p>
|
||||
</div>
|
||||
<ul className="mt-auto hidden flex-col gap-3 sm:flex">
|
||||
{HIGHLIGHTS.map(({ icon: Icon, text }) => (
|
||||
<li key={text} className="flex items-start gap-3 text-sm text-paper-200">
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign-in panel */}
|
||||
<div className="flex flex-col justify-center p-8 sm:p-10">
|
||||
<div className="mb-6">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-amber-600">
|
||||
<ShieldCheck className="h-4 w-4" /> Administrator Sign-In
|
||||
</p>
|
||||
<h2 className="mt-2 font-display text-2xl font-bold text-ink-950">Welcome back</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Please enter your username and password to sign in.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1.5">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter admin username"
|
||||
className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-4 text-sm text-ink-950 placeholder-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
{/* pr-11, unlike the username field's pr-4, keeps a long
|
||||
password from running underneath the toggle button. */}
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full rounded-xl border border-ink-900/15 bg-white py-2.5 pl-9 pr-11 text-sm text-ink-950 placeholder-slate-400 transition focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/25"
|
||||
/>
|
||||
{/*
|
||||
type="button" is load-bearing: a <button> inside a <form>
|
||||
defaults to type="submit", so peeking at the password would
|
||||
fire a half-typed login attempt instead of revealing it.
|
||||
|
||||
The icon shows the ACTION, not the state - an open eye means
|
||||
"reveal", which is the convention every browser and password
|
||||
manager uses. aria-pressed carries the state for screen
|
||||
readers, which the glyph alone cannot.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((shown) => !shown)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-pressed={showPassword}
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1.5 text-slate-400 hover:text-slate-600 hover:bg-ink-900/5 focus:outline-none focus:ring-2 focus:ring-amber-500/25 transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group w-full flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-ink-950 to-ink-900 py-3 text-sm font-bold text-paper-100 shadow-md shadow-ink-950/20 transition-all duration-200 hover:-translate-y-0.5 hover:from-ink-900 hover:to-ink-800 hover:shadow-lg hover:shadow-amber-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60 focus-visible:ring-offset-2 active:translate-y-0 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
||||
>
|
||||
<Zap className="h-4 w-4 text-amber-400" />
|
||||
{loading ? 'Authenticating...' : 'Sign In as ADMIN'}
|
||||
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-slate-500">
|
||||
Real listings from Indian retailers, found by web search. Nothing generated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/vite.config.js
Normal file
30
frontend/vite.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// Both dev and preview forward /api/* to FastAPI, so a same-origin (relative)
|
||||
// request works whichever local server is delivering the page. Keep the two in
|
||||
// sync: `preview` serves dist/, and without this it would answer /api with a
|
||||
// 404 index.html, which surfaces in the UI as a login failure.
|
||||
const apiProxy = {
|
||||
'/api': {
|
||||
// Proxy all /api/* requests straight to FastAPI on port 8000
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: apiProxy,
|
||||
},
|
||||
preview: {
|
||||
port: 4173,
|
||||
proxy: apiProxy,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user