Migrate frontend from Streamlit to Next.js with shadcn/ui
Replace the Streamlit dashboard with a modern Next.js 15 frontend using TypeScript, Tailwind CSS, shadcn/ui components, Recharts, and TanStack Query. All four pages (Overview, Price Battle, Product History, Basket Compare) are fully reimplemented with responsive layouts, collapsible sidebar navigation, and proper data fetching with caching. Adds Docker Compose setup for db + api + frontend and removes streamlit/plotly deps.
This commit is contained in:
parent
82430864f7
commit
20f7c76cdf
69 changed files with 15380 additions and 1329 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -16,3 +16,8 @@ venv/
|
|||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
|
|
|
|||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: smartcart
|
||||
POSTGRES_PASSWORD: smartcart
|
||||
POSTGRES_DB: smartcart
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U smartcart"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://smartcart:smartcart@db:5432/smartcart
|
||||
DATABASE_URL_SYNC: postgresql://smartcart:smartcart@db:5432/smartcart
|
||||
API_HOST: "0.0.0.0"
|
||||
API_PORT: "8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
command: python -m uvicorn src.api.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: http://api:8000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
5
frontend/.dockerignore
Normal file
5
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
README.md
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
37
frontend/Dockerfile
Normal file
37
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Build the application
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy only necessary files
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Standalone output
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
23
frontend/components.json
Normal file
23
frontend/components.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
20
frontend/next.config.ts
Normal file
20
frontend/next.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "**" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
12185
frontend/package-lock.json
generated
Normal file
12185
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
216
frontend/src/app/basket-compare/page.tsx
Normal file
216
frontend/src/app/basket-compare/page.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { fetchProducts, compareBasket } from "@/lib/api";
|
||||
import { queryKeys, staleTimes } from "@/lib/query-keys";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { ProductSearchInput } from "@/components/products/product-search-input";
|
||||
import { ProductSelect } from "@/components/products/product-select";
|
||||
import { BasketTable, type BasketItem } from "@/components/basket/basket-table";
|
||||
import { StoreTotalCard } from "@/components/basket/store-total-card";
|
||||
import { BasketComparisonBarChart } from "@/components/charts/basket-comparison-bar-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ShoppingCart, Trash2 } from "lucide-react";
|
||||
import type { BasketCompareOut } from "@/lib/types";
|
||||
|
||||
const STORAGE_KEY = "smartcart-basket";
|
||||
|
||||
function loadBasket(): BasketItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveBasket(items: BasketItem[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}
|
||||
|
||||
export default function BasketComparePage() {
|
||||
const [basket, setBasket] = useState<BasketItem[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProductId, setSelectedProductId] = useState<string>();
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const debouncedSearch = useDebounce(search);
|
||||
|
||||
// Load basket from localStorage on mount
|
||||
useEffect(() => {
|
||||
setBasket(loadBasket());
|
||||
}, []);
|
||||
|
||||
// Persist basket changes
|
||||
const updateBasket = useCallback((items: BasketItem[]) => {
|
||||
setBasket(items);
|
||||
saveBasket(items);
|
||||
}, []);
|
||||
|
||||
// Search products
|
||||
const { data: productsData } = useQuery({
|
||||
queryKey: queryKeys.products({ search: debouncedSearch, limit: 30 }),
|
||||
queryFn: () => fetchProducts({ search: debouncedSearch, limit: 30 }),
|
||||
staleTime: staleTimes.products,
|
||||
enabled: debouncedSearch.length >= 2,
|
||||
});
|
||||
|
||||
const products = productsData?.items ?? [];
|
||||
|
||||
// Compare mutation
|
||||
const compareMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
compareBasket({
|
||||
name: "My Basket",
|
||||
items: basket.map((item) => ({
|
||||
product_id: item.product_id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedProductId) return;
|
||||
const product = products.find((p) => String(p.id) === selectedProductId);
|
||||
if (!product) return;
|
||||
|
||||
const newBasket = [
|
||||
...basket,
|
||||
{
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity,
|
||||
},
|
||||
];
|
||||
updateBasket(newBasket);
|
||||
setSelectedProductId(undefined);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newBasket = basket.filter((_, i) => i !== index);
|
||||
updateBasket(newBasket);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
updateBasket([]);
|
||||
compareMutation.reset();
|
||||
};
|
||||
|
||||
const result: BasketCompareOut | undefined = compareMutation.data;
|
||||
const activeStores = (result?.stores ?? []).filter(
|
||||
(s) => s.items_found > 0
|
||||
);
|
||||
const sortedStores = [...activeStores].sort(
|
||||
(a, b) => Number(a.total) - Number(b.total)
|
||||
);
|
||||
const cheapestTotal =
|
||||
sortedStores.length > 0 ? Number(sortedStores[0].total) : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Basket Compare"
|
||||
subtitle="Build a shopping list, then compare the total cost at each store."
|
||||
/>
|
||||
|
||||
{/* Add Items */}
|
||||
<h2 className="text-lg font-semibold mb-3">Add Items to Basket</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_200px_80px_auto] gap-3 items-end mb-6">
|
||||
<ProductSearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="e.g. milk, bread, chicken ..."
|
||||
/>
|
||||
{products.length > 0 && (
|
||||
<ProductSelect
|
||||
products={products}
|
||||
value={selectedProductId}
|
||||
onChange={setSelectedProductId}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Qty</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAdd} disabled={!selectedProductId}>
|
||||
Add to basket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-6" />
|
||||
|
||||
{/* Basket */}
|
||||
<h2 className="text-lg font-semibold mb-3">Your Basket</h2>
|
||||
<BasketTable items={basket} onRemove={handleRemove} />
|
||||
|
||||
{basket.length > 0 && (
|
||||
<div className="flex gap-3 mt-4">
|
||||
<Button
|
||||
onClick={() => compareMutation.mutate()}
|
||||
disabled={compareMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
{compareMutation.isPending ? "Comparing..." : "Compare Basket"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClear}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Clear Basket
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{compareMutation.isError && (
|
||||
<p className="text-destructive mt-4">
|
||||
Could not compare your basket. Make sure the API is running.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && sortedStores.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-6" />
|
||||
<h2 className="text-lg font-semibold mb-3">Comparison Results</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
||||
{sortedStores.map((st) => (
|
||||
<StoreTotalCard
|
||||
key={st.store.id}
|
||||
storeTotal={st}
|
||||
cheapestTotal={cheapestTotal}
|
||||
isCheapest={Number(st.total) === cheapestTotal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<BasketComparisonBarChart stores={sortedStores} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && sortedStores.length === 0 && (
|
||||
<p className="text-muted-foreground mt-6">
|
||||
None of the stores carry these products.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{compareMutation.isPending && (
|
||||
<div className="mt-6">
|
||||
<Skeleton className="h-48" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
126
frontend/src/app/globals.css
Normal file
126
frontend/src/app/globals.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
44
frontend/src/app/layout.tsx
Normal file
44
frontend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { QueryProvider } from "@/providers/query-provider";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SmartCart",
|
||||
description: "Irish Grocery Price Tracker",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<QueryProvider>
|
||||
<TooltipProvider>
|
||||
<Sidebar />
|
||||
<main className="lg:pl-64">
|
||||
<div className="p-6 pt-16 lg:pt-6">{children}</div>
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
175
frontend/src/app/page.tsx
Normal file
175
frontend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchStats, fetchBattle, fetchProducts } from "@/lib/api";
|
||||
import { queryKeys, staleTimes } from "@/lib/query-keys";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { formatCurrency, formatNumber } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { KpiCard } from "@/components/layout/kpi-card";
|
||||
import { BattlePieChart } from "@/components/charts/battle-pie-chart";
|
||||
import { ProductSearchInput } from "@/components/products/product-search-input";
|
||||
import { ProductTable } from "@/components/products/product-table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Package, Store, Database } from "lucide-react";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function OverviewPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const debouncedSearch = useDebounce(search);
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: queryKeys.stats,
|
||||
queryFn: fetchStats,
|
||||
staleTime: staleTimes.stats,
|
||||
});
|
||||
|
||||
const { data: battle } = useQuery({
|
||||
queryKey: queryKeys.battle(),
|
||||
queryFn: () => fetchBattle(),
|
||||
staleTime: staleTimes.battle,
|
||||
});
|
||||
|
||||
const { data: products, isLoading: productsLoading } = useQuery({
|
||||
queryKey: queryKeys.products({ page, limit: PAGE_SIZE, search: debouncedSearch }),
|
||||
queryFn: () =>
|
||||
fetchProducts({ page, limit: PAGE_SIZE, search: debouncedSearch || undefined }),
|
||||
staleTime: staleTimes.products,
|
||||
});
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const battleResults = battle?.results ?? [];
|
||||
const storesWithWins = battleResults.filter((r) => r.wins > 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
subtitle="Key performance indicators and product catalogue."
|
||||
/>
|
||||
|
||||
{/* KPI Cards */}
|
||||
{statsLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<KpiCard
|
||||
title="Products Tracked"
|
||||
value={formatNumber(stats.total_products)}
|
||||
icon={<Package className="h-4 w-4" />}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Stores"
|
||||
value={formatNumber(stats.total_stores)}
|
||||
icon={<Store className="h-4 w-4" />}
|
||||
/>
|
||||
<KpiCard
|
||||
title="Price Records"
|
||||
value={formatNumber(stats.total_price_records)}
|
||||
icon={<Database className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-destructive mb-6">
|
||||
Unable to reach the SmartCart API. Please ensure the backend is running.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Average Price by Store */}
|
||||
{stats && stats.avg_prices_by_store.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-3">Average Price by Store</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
{stats.avg_prices_by_store.map((entry) => (
|
||||
<div
|
||||
key={entry.store.id}
|
||||
className="border-l-4 rounded-lg"
|
||||
style={{ borderLeftColor: getStoreColor(entry.store.name) }}
|
||||
>
|
||||
<KpiCard
|
||||
title={entry.store.name}
|
||||
value={formatCurrency(entry.avg_price)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="mb-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cheapest Store Breakdown */}
|
||||
{storesWithWins.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-3">
|
||||
Cheapest Store Breakdown
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<BattlePieChart results={battleResults} />
|
||||
<div className="space-y-2">
|
||||
{battleResults
|
||||
.filter((r) => r.wins > 0 || Number(r.avg_price) > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={r.store.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getStoreColor(r.store.name) }}
|
||||
/>
|
||||
<span className="font-medium">{r.store.name}:</span>
|
||||
<span>
|
||||
{r.wins} wins ({r.cheapest_pct.toFixed(1)}%) | avg{" "}
|
||||
{formatCurrency(r.avg_price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mb-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Product Catalogue */}
|
||||
<h2 className="text-lg font-semibold mb-3">Product Catalogue</h2>
|
||||
<div className="mb-4 max-w-md">
|
||||
<ProductSearchInput
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="e.g. milk, bread, chicken ..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{productsLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : products && products.items.length > 0 ? (
|
||||
<ProductTable
|
||||
products={products.items}
|
||||
total={products.total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{search
|
||||
? "No products found for your search."
|
||||
: "No products in the database yet. Run a scraper first!"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
frontend/src/app/price-battle/page.tsx
Normal file
131
frontend/src/app/price-battle/page.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchBattle, searchPrices } from "@/lib/api";
|
||||
import { queryKeys, staleTimes } from "@/lib/query-keys";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { KpiCard } from "@/components/layout/kpi-card";
|
||||
import { ProductSearchInput } from "@/components/products/product-search-input";
|
||||
import { PopularSearchGrid } from "@/components/battle/popular-search-grid";
|
||||
import { PriceResultsTable } from "@/components/battle/price-results-table";
|
||||
import { BestDealsList } from "@/components/battle/best-deals-list";
|
||||
import { SearchAvgBarChart } from "@/components/charts/search-avg-bar-chart";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
|
||||
export default function PriceBattlePage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search);
|
||||
|
||||
const { data: battle } = useQuery({
|
||||
queryKey: queryKeys.battle(),
|
||||
queryFn: () => fetchBattle(),
|
||||
staleTime: staleTimes.battle,
|
||||
});
|
||||
|
||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||
queryKey: queryKeys.searchPrices(debouncedSearch, 60),
|
||||
queryFn: () => searchPrices(debouncedSearch, 60),
|
||||
staleTime: staleTimes.searchPrices,
|
||||
enabled: debouncedSearch.length >= 2,
|
||||
});
|
||||
|
||||
const storesWithData = (battle?.results ?? []).filter(
|
||||
(r) => Number(r.avg_price) > 0
|
||||
);
|
||||
|
||||
// Store counts for caption
|
||||
const storeCounts = searchResults
|
||||
? searchResults.reduce<Record<string, number>>((acc, r) => {
|
||||
acc[r.store] = (acc[r.store] || 0) + 1;
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Price Battle"
|
||||
subtitle="Compare real product prices across Irish supermarkets."
|
||||
/>
|
||||
|
||||
{/* Store Overview */}
|
||||
{storesWithData.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-3">Store Overview</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
{storesWithData.map((r) => (
|
||||
<div
|
||||
key={r.store.id}
|
||||
className="border-l-4 rounded-lg"
|
||||
style={{ borderLeftColor: getStoreColor(r.store.name) }}
|
||||
>
|
||||
<KpiCard
|
||||
title={r.store.name}
|
||||
value={`${formatCurrency(r.avg_price)} avg`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator className="mb-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Compare Products */}
|
||||
<h2 className="text-lg font-semibold mb-3">Compare Products</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<PopularSearchGrid onSelect={setSearch} />
|
||||
<div className="max-w-md">
|
||||
<ProductSearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="e.g. milk, bread, chicken ..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{debouncedSearch.length >= 2 && (
|
||||
<>
|
||||
{searchLoading ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : searchResults && searchResults.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Found {searchResults.length} products matching "{debouncedSearch}":{" "}
|
||||
{Object.entries(storeCounts)
|
||||
.map(([store, count]) => `${store} (${count})`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
|
||||
<PriceResultsTable results={searchResults} />
|
||||
|
||||
<h3 className="text-lg font-semibold">
|
||||
Average price for "{debouncedSearch}" by store
|
||||
</h3>
|
||||
<SearchAvgBarChart results={searchResults} />
|
||||
|
||||
<BestDealsList results={searchResults} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
No products found for "{debouncedSearch}".
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!debouncedSearch && (
|
||||
<p className="text-muted-foreground">
|
||||
Search for a product above or click a popular category to compare
|
||||
prices across stores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
frontend/src/app/product-history/page.tsx
Normal file
268
frontend/src/app/product-history/page.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchProducts,
|
||||
fetchPriceHistory,
|
||||
fetchComparison,
|
||||
searchPrices,
|
||||
} from "@/lib/api";
|
||||
import { queryKeys, staleTimes } from "@/lib/query-keys";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { ProductSearchInput } from "@/components/products/product-search-input";
|
||||
import { ProductSelect } from "@/components/products/product-select";
|
||||
import { PriceHistoryLineChart } from "@/components/charts/price-history-line-chart";
|
||||
import { StoreComparisonBarChart } from "@/components/charts/store-comparison-bar-chart";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
|
||||
export default function ProductHistoryPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProductId, setSelectedProductId] = useState<string>();
|
||||
const [days, setDays] = useState(90);
|
||||
const debouncedSearch = useDebounce(search);
|
||||
|
||||
// Search for products
|
||||
const { data: productsData } = useQuery({
|
||||
queryKey: queryKeys.products({ search: debouncedSearch, limit: 50 }),
|
||||
queryFn: () => fetchProducts({ search: debouncedSearch, limit: 50 }),
|
||||
staleTime: staleTimes.products,
|
||||
enabled: debouncedSearch.length >= 2,
|
||||
});
|
||||
|
||||
const products = productsData?.items ?? [];
|
||||
|
||||
// Auto-select first product when search results change
|
||||
const productId = selectedProductId ? Number(selectedProductId) : undefined;
|
||||
|
||||
// Price history
|
||||
const { data: history, isLoading: historyLoading } = useQuery({
|
||||
queryKey: queryKeys.priceHistory(productId!, days),
|
||||
queryFn: () => fetchPriceHistory(productId!, days),
|
||||
staleTime: staleTimes.priceHistory,
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
// Comparison
|
||||
const { data: comparison } = useQuery({
|
||||
queryKey: queryKeys.comparison(productId!),
|
||||
queryFn: () => fetchComparison(productId!),
|
||||
staleTime: staleTimes.comparison,
|
||||
enabled: !!productId,
|
||||
});
|
||||
|
||||
// Similar products
|
||||
const { data: similarResults } = useQuery({
|
||||
queryKey: queryKeys.searchPrices(debouncedSearch, 100),
|
||||
queryFn: () => searchPrices(debouncedSearch, 100),
|
||||
staleTime: staleTimes.searchPrices,
|
||||
enabled: debouncedSearch.length >= 2,
|
||||
});
|
||||
|
||||
// Build bar chart data from comparison
|
||||
const barData = useMemo(() => {
|
||||
if (!comparison?.stores) return [];
|
||||
return comparison.stores
|
||||
.filter((sp) => sp.latest_price != null || sp.promo_price != null)
|
||||
.map((sp) => ({
|
||||
store_name: sp.store.name,
|
||||
price: Number(sp.promo_price ?? sp.latest_price),
|
||||
}));
|
||||
}, [comparison]);
|
||||
|
||||
const sortedSimilar = useMemo(
|
||||
() =>
|
||||
[...(similarResults ?? [])].sort(
|
||||
(a, b) => a.effective_price - b.effective_price
|
||||
),
|
||||
[similarResults]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Product History"
|
||||
subtitle="Search for a product and explore its price history across stores."
|
||||
/>
|
||||
|
||||
{/* Search & Select */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<ProductSearchInput
|
||||
value={search}
|
||||
onChange={(v) => {
|
||||
setSearch(v);
|
||||
setSelectedProductId(undefined);
|
||||
}}
|
||||
placeholder="Search for a product..."
|
||||
/>
|
||||
{products.length > 0 && (
|
||||
<ProductSelect
|
||||
products={products}
|
||||
value={selectedProductId}
|
||||
onChange={setSelectedProductId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date range control */}
|
||||
{productId && (
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<label className="text-sm text-muted-foreground">History days:</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value) || 90)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty states */}
|
||||
{!debouncedSearch && (
|
||||
<p className="text-muted-foreground">
|
||||
Enter a search term above to find products.
|
||||
</p>
|
||||
)}
|
||||
{debouncedSearch && products.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
No products found. Try a different search term.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price History Chart */}
|
||||
{productId && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-3">Price History</h2>
|
||||
{historyLoading ? (
|
||||
<Skeleton className="h-80 mb-6" />
|
||||
) : history && history.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<PriceHistoryLineChart history={history} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground mb-6">
|
||||
No price history available for this product.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator className="mb-6" />
|
||||
|
||||
{/* Current Prices Across Stores */}
|
||||
<h2 className="text-lg font-semibold mb-3">
|
||||
Current Prices Across Stores
|
||||
</h2>
|
||||
{comparison?.stores && comparison.stores.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Store</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead>Promo</TableHead>
|
||||
<TableHead className="text-right">Promo Price</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{comparison.stores.map((sp) => (
|
||||
<TableRow
|
||||
key={sp.store.id}
|
||||
style={{
|
||||
borderLeft: `4px solid ${getStoreColor(sp.store.name)}`,
|
||||
}}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{sp.store.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sp.latest_price != null
|
||||
? formatCurrency(sp.latest_price)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{sp.promo_label || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sp.promo_price != null
|
||||
? formatCurrency(sp.promo_price)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{barData.length > 0 && (
|
||||
<StoreComparisonBarChart data={barData} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground mb-6">
|
||||
No comparison data available for this product.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator className="mb-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Similar Products */}
|
||||
{debouncedSearch && sortedSimilar.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-1">
|
||||
Similar Products Across Stores
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Other products matching "{debouncedSearch}" across all stores.
|
||||
</p>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Store</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Effective</TableHead>
|
||||
<TableHead>Promo</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedSimilar.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="font-medium">{item.store}</TableCell>
|
||||
<TableCell>{item.product_name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{formatCurrency(item.effective_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{item.promo_label || ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/basket/basket-table.tsx
Normal file
65
frontend/src/components/basket/basket-table.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export interface BasketItem {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface BasketTableProps {
|
||||
items: BasketItem[];
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
export function BasketTable({ items, onRemove }: BasketTableProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground">
|
||||
Your basket is empty. Search and add products above.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="w-20 text-center">Qty</TableHead>
|
||||
<TableHead className="w-16" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow key={`${item.product_id}-${idx}`}>
|
||||
<TableCell className="font-medium">{item.product_name}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(idx)}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/basket/store-total-card.tsx
Normal file
55
frontend/src/components/basket/store-total-card.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import type { BasketStoreTotal } from "@/lib/types";
|
||||
|
||||
interface StoreTotalCardProps {
|
||||
storeTotal: BasketStoreTotal;
|
||||
cheapestTotal: number;
|
||||
isCheapest: boolean;
|
||||
}
|
||||
|
||||
export function StoreTotalCard({
|
||||
storeTotal,
|
||||
cheapestTotal,
|
||||
isCheapest,
|
||||
}: StoreTotalCardProps) {
|
||||
const delta = Number(storeTotal.total) - cheapestTotal;
|
||||
const storeColor = getStoreColor(storeTotal.store.name);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={isCheapest ? "ring-2 ring-yellow-400" : ""}
|
||||
style={{ borderTop: `4px solid ${storeColor}` }}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{storeTotal.store.name}</CardTitle>
|
||||
{isCheapest && (
|
||||
<Badge className="bg-yellow-400 text-yellow-900 hover:bg-yellow-400">
|
||||
Cheapest
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(storeTotal.total)}
|
||||
</div>
|
||||
{delta > 0 ? (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
+{formatCurrency(delta)} more
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-green-600 mt-1">Best price</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{storeTotal.items_found} found, {storeTotal.items_missing} missing
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/battle/best-deals-list.tsx
Normal file
50
frontend/src/components/battle/best-deals-list.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import type { SearchPriceResult } from "@/lib/types";
|
||||
|
||||
interface BestDealsListProps {
|
||||
results: SearchPriceResult[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function BestDealsList({ results, limit = 5 }: BestDealsListProps) {
|
||||
const sorted = [...results]
|
||||
.sort((a, b) => a.effective_price - b.effective_price)
|
||||
.slice(0, limit);
|
||||
|
||||
if (sorted.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Best Deals</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{sorted.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getStoreColor(item.store) }}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-bold">
|
||||
{formatCurrency(item.effective_price)}
|
||||
</span>
|
||||
{" - "}
|
||||
{item.product_name} @ {item.store}
|
||||
{item.promo_label && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({item.promo_label})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/battle/popular-search-grid.tsx
Normal file
34
frontend/src/components/battle/popular-search-grid.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const POPULAR_SEARCHES = [
|
||||
"milk", "bread", "chicken", "rice", "butter", "cheese",
|
||||
"eggs", "pasta", "sugar", "tea", "coffee", "water",
|
||||
"beef", "salmon", "yoghurt", "cereal", "oil", "flour",
|
||||
];
|
||||
|
||||
interface PopularSearchGridProps {
|
||||
onSelect: (term: string) => void;
|
||||
}
|
||||
|
||||
export function PopularSearchGrid({ onSelect }: PopularSearchGridProps) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">Popular searches:</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 lg:grid-cols-9 gap-2">
|
||||
{POPULAR_SEARCHES.map((term) => (
|
||||
<Button
|
||||
key={term}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="capitalize"
|
||||
onClick={() => onSelect(term)}
|
||||
>
|
||||
{term}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/battle/price-results-table.tsx
Normal file
59
frontend/src/components/battle/price-results-table.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import type { SearchPriceResult } from "@/lib/types";
|
||||
|
||||
interface PriceResultsTableProps {
|
||||
results: SearchPriceResult[];
|
||||
}
|
||||
|
||||
export function PriceResultsTable({ results }: PriceResultsTableProps) {
|
||||
const sorted = [...results].sort(
|
||||
(a, b) => a.effective_price - b.effective_price
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Store</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="text-right">Effective</TableHead>
|
||||
<TableHead>Promo</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sorted.map((item, idx) => (
|
||||
<TableRow
|
||||
key={`${item.store}-${item.product_name}-${idx}`}
|
||||
style={{ borderLeft: `4px solid ${getStoreColor(item.store)}` }}
|
||||
>
|
||||
<TableCell className="font-medium">{item.store}</TableCell>
|
||||
<TableCell>{item.product_name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{formatCurrency(item.effective_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{item.promo_label || ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import type { BasketStoreTotal } from "@/lib/types";
|
||||
|
||||
interface BasketComparisonBarChartProps {
|
||||
stores: BasketStoreTotal[];
|
||||
}
|
||||
|
||||
export function BasketComparisonBarChart({
|
||||
stores,
|
||||
}: BasketComparisonBarChartProps) {
|
||||
const sorted = [...stores].sort(
|
||||
(a, b) => Number(a.total) - Number(b.total)
|
||||
);
|
||||
const minTotal = sorted.length > 0 ? Number(sorted[0].total) : 0;
|
||||
|
||||
const data = sorted.map((s) => ({
|
||||
store: s.store.name,
|
||||
total: Number(s.total),
|
||||
isCheapest: Number(s.total) === minTotal,
|
||||
}));
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="store" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
tickFormatter={(v) => `€${Number(v).toFixed(2)}`}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), "Total"]}
|
||||
/>
|
||||
<Bar dataKey="total" radius={[4, 4, 0, 0]}>
|
||||
<LabelList
|
||||
dataKey="total"
|
||||
position="top"
|
||||
formatter={(v) => formatCurrency(Number(v))}
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
{data.map((entry) => (
|
||||
<Cell
|
||||
key={entry.store}
|
||||
fill={getStoreColor(entry.store)}
|
||||
stroke={entry.isCheapest ? "gold" : "transparent"}
|
||||
strokeWidth={entry.isCheapest ? 3 : 0}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/charts/battle-pie-chart.tsx
Normal file
66
frontend/src/components/charts/battle-pie-chart.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import type { BattleResult } from "@/lib/types";
|
||||
|
||||
interface BattlePieChartProps {
|
||||
results: BattleResult[];
|
||||
}
|
||||
|
||||
export function BattlePieChart({ results }: BattlePieChartProps) {
|
||||
const data = results
|
||||
.filter((r) => r.wins > 0)
|
||||
.map((r) => ({
|
||||
name: r.store.name,
|
||||
value: r.wins,
|
||||
pct: r.cheapest_pct,
|
||||
avg: r.avg_price,
|
||||
}));
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const renderLabel = (props: any) => {
|
||||
const { name, pct } = props;
|
||||
return `${name} ${Number(pct).toFixed(0)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
label={renderLabel}
|
||||
labelLine={false}
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={getStoreColor(entry.name)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value, name, props) => [
|
||||
`${value} wins (avg ${formatCurrency((props.payload as { avg: number }).avg)})`,
|
||||
name,
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/charts/price-history-line-chart.tsx
Normal file
115
frontend/src/components/charts/price-history-line-chart.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
ReferenceDot,
|
||||
} from "recharts";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import type { PriceHistoryOut } from "@/lib/types";
|
||||
|
||||
interface PriceHistoryLineChartProps {
|
||||
history: PriceHistoryOut[];
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
date: string;
|
||||
timestamp: number;
|
||||
[store: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
interface PromoMarker {
|
||||
timestamp: number;
|
||||
store: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export function PriceHistoryLineChart({ history }: PriceHistoryLineChartProps) {
|
||||
const dateMap = new Map<string, Record<string, number>>();
|
||||
const promoMarkers: PromoMarker[] = [];
|
||||
|
||||
for (const entry of history) {
|
||||
const storeName = entry.store.name;
|
||||
for (const pr of entry.prices) {
|
||||
const dateKey = pr.scraped_at.split("T")[0];
|
||||
const effective = pr.promo_price != null ? Number(pr.promo_price) : Number(pr.price);
|
||||
|
||||
if (!dateMap.has(dateKey)) {
|
||||
dateMap.set(dateKey, {});
|
||||
}
|
||||
dateMap.get(dateKey)![storeName] = effective;
|
||||
|
||||
if (pr.promo_label != null) {
|
||||
promoMarkers.push({
|
||||
timestamp: new Date(dateKey).getTime(),
|
||||
store: storeName,
|
||||
price: effective,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const storeNames = [...new Set(history.map((h) => h.store.name))].sort();
|
||||
|
||||
const data: ChartPoint[] = Array.from(dateMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, stores]) => ({
|
||||
date,
|
||||
timestamp: new Date(date).getTime(),
|
||||
...stores,
|
||||
}));
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<LineChart data={data}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(d) => formatDate(String(d))}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => `€${Number(v).toFixed(2)}`}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(d) => formatDate(String(d))}
|
||||
formatter={(value, name) => [
|
||||
formatCurrency(Number(value)),
|
||||
String(name),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
{storeNames.map((store) => (
|
||||
<Line
|
||||
key={store}
|
||||
type="monotone"
|
||||
dataKey={store}
|
||||
stroke={getStoreColor(store)}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
{promoMarkers.map((m, idx) => (
|
||||
<ReferenceDot
|
||||
key={idx}
|
||||
x={data.find((d) => d.timestamp === m.timestamp)?.date ?? ""}
|
||||
y={m.price}
|
||||
r={6}
|
||||
fill="gold"
|
||||
stroke={getStoreColor(m.store)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/charts/search-avg-bar-chart.tsx
Normal file
64
frontend/src/components/charts/search-avg-bar-chart.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import type { SearchPriceResult } from "@/lib/types";
|
||||
|
||||
interface SearchAvgBarChartProps {
|
||||
results: SearchPriceResult[];
|
||||
}
|
||||
|
||||
export function SearchAvgBarChart({ results }: SearchAvgBarChartProps) {
|
||||
const storeMap = new Map<string, { total: number; count: number }>();
|
||||
for (const r of results) {
|
||||
const entry = storeMap.get(r.store) || { total: 0, count: 0 };
|
||||
entry.total += r.effective_price;
|
||||
entry.count += 1;
|
||||
storeMap.set(r.store, entry);
|
||||
}
|
||||
|
||||
const data = Array.from(storeMap.entries())
|
||||
.map(([store, { total, count }]) => ({
|
||||
store,
|
||||
avg: total / count,
|
||||
}))
|
||||
.sort((a, b) => a.avg - b.avg);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="store" tick={{ fontSize: 12 }} />
|
||||
<YAxis
|
||||
tickFormatter={(v) => `€${Number(v).toFixed(2)}`}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), "Avg Price"]}
|
||||
/>
|
||||
<Bar dataKey="avg" radius={[4, 4, 0, 0]}>
|
||||
<LabelList
|
||||
dataKey="avg"
|
||||
position="top"
|
||||
formatter={(v) => formatCurrency(Number(v))}
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.store} fill={getStoreColor(entry.store)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { getStoreColor } from "@/lib/store-colors";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
|
||||
interface StoreComparisonBarChartProps {
|
||||
data: { store_name: string; price: number }[];
|
||||
}
|
||||
|
||||
export function StoreComparisonBarChart({
|
||||
data,
|
||||
}: StoreComparisonBarChartProps) {
|
||||
const sorted = [...data].sort((a, b) => a.price - b.price);
|
||||
|
||||
if (sorted.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={sorted} layout="vertical">
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v) => `€${Number(v).toFixed(2)}`}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="store_name"
|
||||
width={100}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [formatCurrency(Number(value)), "Price"]}
|
||||
/>
|
||||
<Bar dataKey="price" radius={[0, 4, 4, 0]}>
|
||||
<LabelList
|
||||
dataKey="price"
|
||||
position="right"
|
||||
formatter={(v) => formatCurrency(Number(v))}
|
||||
style={{ fontSize: 11 }}
|
||||
/>
|
||||
{sorted.map((entry) => (
|
||||
<Cell key={entry.store_name} fill={getStoreColor(entry.store_name)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/layout/kpi-card.tsx
Normal file
35
frontend/src/components/layout/kpi-card.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KpiCard({ title, value, subtitle, icon, className }: KpiCardProps) {
|
||||
return (
|
||||
<Card className={cn("", className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/layout/nav-link.tsx
Normal file
32
frontend/src/components/layout/nav-link.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function NavLink({ href, icon, children }: NavLinkProps) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/layout/page-header.tsx
Normal file
15
frontend/src/components/layout/page-header.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="text-muted-foreground mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/layout/sidebar.tsx
Normal file
76
frontend/src/components/layout/sidebar.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "./nav-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
BarChart3,
|
||||
Swords,
|
||||
TrendingUp,
|
||||
ShoppingCart,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", icon: <BarChart3 className="h-4 w-4" />, label: "Overview" },
|
||||
{ href: "/price-battle", icon: <Swords className="h-4 w-4" />, label: "Price Battle" },
|
||||
{ href: "/product-history", icon: <TrendingUp className="h-4 w-4" />, label: "Product History" },
|
||||
{ href: "/basket-compare", icon: <ShoppingCart className="h-4 w-4" />, label: "Basket Compare" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed top-3 left-3 z-50 lg:hidden"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Overlay */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r bg-background transition-transform duration-200 lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
{/* Branding */}
|
||||
<div className="flex flex-col items-center gap-1 px-4 py-6">
|
||||
<ShoppingCart className="h-10 w-10 text-primary" />
|
||||
<h1 className="text-xl font-bold">SmartCart</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Irish Grocery Price Tracker
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink key={item.href} href={item.href} icon={item.icon}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/products/product-search-input.tsx
Normal file
29
frontend/src/components/products/product-search-input.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface ProductSearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ProductSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search products...",
|
||||
}: ProductSearchInputProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/products/product-select.tsx
Normal file
37
frontend/src/components/products/product-select.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ProductOut } from "@/lib/types";
|
||||
|
||||
interface ProductSelectProps {
|
||||
products: ProductOut[];
|
||||
value: string | undefined;
|
||||
onChange: (productId: string) => void;
|
||||
}
|
||||
|
||||
export function ProductSelect({
|
||||
products,
|
||||
value,
|
||||
onChange,
|
||||
}: ProductSelectProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a product" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/products/product-table.tsx
Normal file
107
frontend/src/components/products/product-table.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { ProductOut } from "@/lib/types";
|
||||
|
||||
interface ProductTableProps {
|
||||
products: ProductOut[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function ProductTable({
|
||||
products,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
}: ProductTableProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Unit</TableHead>
|
||||
<TableHead className="w-16">Image</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-mono text-xs">{p.id}</TableCell>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.brand || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.category?.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.unit_size && p.unit
|
||||
? `${p.unit_size} ${p.unit}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.image_url ? (
|
||||
<img
|
||||
src={p.image_url}
|
||||
alt={p.name}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {products.length} of {total} products (page {page}/
|
||||
{totalPages})
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/ui/badge.tsx
Normal file
48
frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
190
frontend/src/components/ui/select.tsx
Normal file
190
frontend/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
frontend/src/components/ui/separator.tsx
Normal file
28
frontend/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
13
frontend/src/components/ui/skeleton.tsx
Normal file
13
frontend/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
57
frontend/src/components/ui/tooltip.tsx
Normal file
57
frontend/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
12
frontend/src/hooks/use-debounce.ts
Normal file
12
frontend/src/hooks/use-debounce.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
89
frontend/src/lib/api.ts
Normal file
89
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type {
|
||||
BasketCompareOut,
|
||||
BasketIn,
|
||||
BattleOut,
|
||||
CategoryOut,
|
||||
ComparisonOut,
|
||||
PriceHistoryOut,
|
||||
ProductListOut,
|
||||
SearchPriceResult,
|
||||
StatsOut,
|
||||
StoreOut,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, init);
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Stores / Categories ────────────────────────────
|
||||
|
||||
export function fetchStores() {
|
||||
return fetchApi<StoreOut[]>("/api/stores");
|
||||
}
|
||||
|
||||
export function fetchCategories() {
|
||||
return fetchApi<CategoryOut[]>("/api/categories");
|
||||
}
|
||||
|
||||
// ──────────────────────────── Products ───────────────────────────────────────
|
||||
|
||||
export function fetchProducts(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
category_id?: number;
|
||||
store_id?: number;
|
||||
}) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.page) sp.set("page", String(params.page));
|
||||
if (params.limit) sp.set("limit", String(params.limit));
|
||||
if (params.search) sp.set("search", params.search);
|
||||
if (params.category_id) sp.set("category_id", String(params.category_id));
|
||||
if (params.store_id) sp.set("store_id", String(params.store_id));
|
||||
return fetchApi<ProductListOut>(`/api/products?${sp.toString()}`);
|
||||
}
|
||||
|
||||
// ──────────────────────────── Prices ─────────────────────────────────────────
|
||||
|
||||
export function fetchPriceHistory(productId: number, days = 30) {
|
||||
return fetchApi<PriceHistoryOut[]>(
|
||||
`/api/products/${productId}/prices?days=${days}`
|
||||
);
|
||||
}
|
||||
|
||||
export function searchPrices(q: string, limit = 60) {
|
||||
return fetchApi<SearchPriceResult[]>(
|
||||
`/api/search-prices?q=${encodeURIComponent(q)}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchStats() {
|
||||
return fetchApi<StatsOut>("/api/stats");
|
||||
}
|
||||
|
||||
// ──────────────────────────── Comparison ─────────────────────────────────────
|
||||
|
||||
export function fetchComparison(productId: number) {
|
||||
return fetchApi<ComparisonOut>(`/api/products/${productId}/compare`);
|
||||
}
|
||||
|
||||
export function fetchBattle(categoryId?: number) {
|
||||
const params = categoryId ? `?category_id=${categoryId}` : "";
|
||||
return fetchApi<BattleOut>(`/api/battle${params}`);
|
||||
}
|
||||
|
||||
// ──────────────────────────── Baskets ────────────────────────────────────────
|
||||
|
||||
export function compareBasket(basket: BasketIn) {
|
||||
return fetchApi<BasketCompareOut>("/api/baskets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(basket),
|
||||
});
|
||||
}
|
||||
29
frontend/src/lib/query-keys.ts
Normal file
29
frontend/src/lib/query-keys.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export const queryKeys = {
|
||||
stats: ["stats"] as const,
|
||||
stores: ["stores"] as const,
|
||||
categories: ["categories"] as const,
|
||||
battle: (categoryId?: number) =>
|
||||
categoryId ? (["battle", categoryId] as const) : (["battle"] as const),
|
||||
products: (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
}) => ["products", params] as const,
|
||||
searchPrices: (q: string, limit?: number) =>
|
||||
["search-prices", q, limit] as const,
|
||||
priceHistory: (productId: number, days?: number) =>
|
||||
["price-history", productId, days] as const,
|
||||
comparison: (productId: number) => ["comparison", productId] as const,
|
||||
};
|
||||
|
||||
// Stale time config (mirrors Streamlit TTLs)
|
||||
export const staleTimes = {
|
||||
stats: 2 * 60 * 1000, // 2 min
|
||||
battle: 2 * 60 * 1000, // 2 min
|
||||
products: 2 * 60 * 1000, // 2 min
|
||||
searchPrices: 1 * 60 * 1000, // 1 min
|
||||
priceHistory: 1 * 60 * 1000, // 1 min
|
||||
comparison: 1 * 60 * 1000, // 1 min
|
||||
stores: 5 * 60 * 1000, // 5 min
|
||||
categories: 5 * 60 * 1000, // 5 min
|
||||
};
|
||||
18
frontend/src/lib/store-colors.ts
Normal file
18
frontend/src/lib/store-colors.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const STORE_COLORS: Record<string, string> = {
|
||||
Tesco: "#00539F",
|
||||
Dunnes: "#6B2D5B",
|
||||
SuperValu: "#E31837",
|
||||
Aldi: "#00205B",
|
||||
Lidl: "#0050AA",
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR = "#888888";
|
||||
|
||||
export function getStoreColor(storeName: string): string {
|
||||
for (const [key, color] of Object.entries(STORE_COLORS)) {
|
||||
if (storeName.toLowerCase().includes(key.toLowerCase())) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
return DEFAULT_COLOR;
|
||||
}
|
||||
136
frontend/src/lib/types.ts
Normal file
136
frontend/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// TypeScript interfaces mirroring src/api/schemas.py
|
||||
// Decimal → number, datetime → string (ISO)
|
||||
|
||||
// ──────────────────────────── Stores / Categories ────────────────────────────
|
||||
|
||||
export interface StoreOut {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
base_url: string;
|
||||
logo_url: string | null;
|
||||
}
|
||||
|
||||
export interface CategoryOut {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Products ───────────────────────────────────────
|
||||
|
||||
export interface ProductOut {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
ean: string | null;
|
||||
category: CategoryOut | null;
|
||||
unit: string | null;
|
||||
unit_size: number | null;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
export interface ProductListOut {
|
||||
items: ProductOut[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Store Products & Prices ────────────────────────
|
||||
|
||||
export interface StoreProductOut {
|
||||
store: StoreOut;
|
||||
store_name: string;
|
||||
store_url: string | null;
|
||||
latest_price: number | null;
|
||||
promo_price: number | null;
|
||||
promo_label: string | null;
|
||||
}
|
||||
|
||||
export interface PriceRecordOut {
|
||||
price: number;
|
||||
promo_price: number | null;
|
||||
promo_label: string | null;
|
||||
unit_price: number | null;
|
||||
in_stock: boolean;
|
||||
scraped_at: string;
|
||||
}
|
||||
|
||||
export interface PriceHistoryOut {
|
||||
store: StoreOut;
|
||||
prices: PriceRecordOut[];
|
||||
}
|
||||
|
||||
// ──────────────────────────── Comparison ─────────────────────────────────────
|
||||
|
||||
export interface ComparisonOut {
|
||||
product: ProductOut;
|
||||
stores: StoreProductOut[];
|
||||
}
|
||||
|
||||
// ──────────────────────────── Store Battle ───────────────────────────────────
|
||||
|
||||
export interface BattleResult {
|
||||
store: StoreOut;
|
||||
wins: number;
|
||||
avg_price: number;
|
||||
cheapest_pct: number;
|
||||
}
|
||||
|
||||
export interface BattleOut {
|
||||
category: string | null;
|
||||
results: BattleResult[];
|
||||
}
|
||||
|
||||
// ──────────────────────────── Baskets ────────────────────────────────────────
|
||||
|
||||
export interface BasketItemIn {
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface BasketIn {
|
||||
name: string;
|
||||
items: BasketItemIn[];
|
||||
}
|
||||
|
||||
export interface BasketStoreTotal {
|
||||
store: StoreOut;
|
||||
total: number;
|
||||
items_found: number;
|
||||
items_missing: number;
|
||||
}
|
||||
|
||||
export interface BasketCompareOut {
|
||||
basket_name: string;
|
||||
stores: BasketStoreTotal[];
|
||||
}
|
||||
|
||||
// ──────────────────────────── Stats / KPIs ───────────────────────────────────
|
||||
|
||||
export interface AvgPriceByStore {
|
||||
store: StoreOut;
|
||||
avg_price: number;
|
||||
}
|
||||
|
||||
export interface StatsOut {
|
||||
total_products: number;
|
||||
total_stores: number;
|
||||
total_price_records: number;
|
||||
last_scrape: string | null;
|
||||
avg_prices_by_store: AvgPriceByStore[];
|
||||
}
|
||||
|
||||
// ──────────────────────────── Search Prices ──────────────────────────────────
|
||||
|
||||
export interface SearchPriceResult {
|
||||
product_name: string;
|
||||
store: string;
|
||||
store_slug: string;
|
||||
price: number;
|
||||
promo_price: number | null;
|
||||
promo_label: string | null;
|
||||
effective_price: number;
|
||||
unit_price: number | null;
|
||||
image_url: string | null;
|
||||
product_url: string | null;
|
||||
}
|
||||
25
frontend/src/lib/utils.ts
Normal file
25
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number | string | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return "—";
|
||||
return `€${num.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-IE", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return value.toLocaleString("en-IE");
|
||||
}
|
||||
22
frontend/src/providers/query-provider.tsx
Normal file
22
frontend/src/providers/query-provider.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -16,8 +16,6 @@ dependencies = [
|
|||
"beautifulsoup4>=4.12.0",
|
||||
"rapidfuzz>=3.10.0",
|
||||
"apscheduler>=3.10.0",
|
||||
"streamlit>=1.40.0",
|
||||
"plotly>=5.24.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
"""SmartCart Streamlit Dashboard - Main Application Entry Point."""
|
||||
|
||||
import streamlit as st
|
||||
|
||||
st.set_page_config(
|
||||
page_title="SmartCart",
|
||||
page_icon="\U0001f6d2",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidebar branding
|
||||
# ---------------------------------------------------------------------------
|
||||
with st.sidebar:
|
||||
st.markdown(
|
||||
"""
|
||||
<div style="text-align:center;padding:1rem 0 0.5rem 0;">
|
||||
<span style="font-size:2.4rem;">\U0001f6d2</span>
|
||||
<h2 style="margin:0;padding:0;">SmartCart</h2>
|
||||
<p style="color:grey;margin:0;font-size:0.85rem;">
|
||||
Irish Grocery Price Tracker
|
||||
</p>
|
||||
</div>
|
||||
<hr style="margin:0.5rem 0 1rem 0;">
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-page navigation (Streamlit >= 1.36 st.navigation API)
|
||||
# ---------------------------------------------------------------------------
|
||||
overview_page = st.Page(
|
||||
"pages/overview.py",
|
||||
title="Overview",
|
||||
icon="\U0001f4ca",
|
||||
default=True,
|
||||
)
|
||||
battle_page = st.Page(
|
||||
"pages/price_battle.py",
|
||||
title="Price Battle",
|
||||
icon="\u2694\ufe0f",
|
||||
)
|
||||
history_page = st.Page(
|
||||
"pages/product_history.py",
|
||||
title="Product History",
|
||||
icon="\U0001f4c8",
|
||||
)
|
||||
basket_page = st.Page(
|
||||
"pages/basket_compare.py",
|
||||
title="Basket Compare",
|
||||
icon="\U0001f6d2",
|
||||
)
|
||||
|
||||
pg = st.navigation(
|
||||
[overview_page, battle_page, history_page, basket_page],
|
||||
)
|
||||
|
||||
pg.run()
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
"""Reusable Plotly chart helpers for the SmartCart dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consistent colour palette keyed by store name
|
||||
# ---------------------------------------------------------------------------
|
||||
STORE_COLOURS: dict[str, str] = {
|
||||
"Tesco": "#00539F",
|
||||
"Dunnes": "#6B2D5B",
|
||||
"SuperValu": "#E31837",
|
||||
"Aldi": "#00205B",
|
||||
"Lidl": "#0050AA",
|
||||
}
|
||||
|
||||
_DEFAULT_COLOUR_SEQUENCE = list(STORE_COLOURS.values())
|
||||
|
||||
|
||||
def _colour_map(stores: list[str]) -> dict[str, str]:
|
||||
"""Return a colour mapping, using partial matching and falling back to the palette."""
|
||||
palette_iter = iter(_DEFAULT_COLOUR_SEQUENCE)
|
||||
mapping: dict[str, str] = {}
|
||||
for s in stores:
|
||||
matched = False
|
||||
for key, val in STORE_COLOURS.items():
|
||||
if key.lower() in s.lower():
|
||||
mapping[s] = val
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
mapping[s] = next(palette_iter, "#888888")
|
||||
return mapping
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Price history line chart
|
||||
# ---------------------------------------------------------------------------
|
||||
def price_history_chart(data: list[dict[str, Any]]) -> go.Figure:
|
||||
"""Line chart showing price over time, one line per store.
|
||||
|
||||
*data* is expected to be a list of dicts with keys:
|
||||
``date``, ``price``, ``store_name``, and optionally ``is_promo``.
|
||||
"""
|
||||
if not data:
|
||||
fig = go.Figure()
|
||||
fig.update_layout(title="No price history data available")
|
||||
return fig
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
stores = sorted(df["store_name"].unique())
|
||||
colour_map = _colour_map(stores)
|
||||
|
||||
fig = go.Figure()
|
||||
for store in stores:
|
||||
sdf = df[df["store_name"] == store].sort_values("date")
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=sdf["date"],
|
||||
y=sdf["price"],
|
||||
mode="lines+markers",
|
||||
name=store,
|
||||
line=dict(color=colour_map[store], width=2),
|
||||
marker=dict(size=5),
|
||||
hovertemplate=(
|
||||
"<b>%{fullData.name}</b><br>"
|
||||
"Date: %{x|%d %b %Y}<br>"
|
||||
"Price: \u20ac%{y:.2f}<extra></extra>"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Overlay promo markers if the field exists
|
||||
if "is_promo" in sdf.columns:
|
||||
promo = sdf[sdf["is_promo"] == True] # noqa: E712
|
||||
if not promo.empty:
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=promo["date"],
|
||||
y=promo["price"],
|
||||
mode="markers",
|
||||
name=f"{store} (promo)",
|
||||
marker=dict(
|
||||
symbol="star",
|
||||
size=12,
|
||||
color=colour_map[store],
|
||||
line=dict(width=1, color="gold"),
|
||||
),
|
||||
hovertemplate=(
|
||||
"<b>%{fullData.name}</b><br>"
|
||||
"Date: %{x|%d %b %Y}<br>"
|
||||
"Promo price: \u20ac%{y:.2f}<extra></extra>"
|
||||
),
|
||||
showlegend=False,
|
||||
)
|
||||
)
|
||||
|
||||
fig.update_layout(
|
||||
title="Price History",
|
||||
xaxis_title="Date",
|
||||
yaxis_title="Price (\u20ac)",
|
||||
yaxis_tickprefix="\u20ac",
|
||||
hovermode="x unified",
|
||||
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
||||
margin=dict(l=40, r=20, t=60, b=40),
|
||||
template="plotly_white",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Store comparison horizontal bar chart
|
||||
# ---------------------------------------------------------------------------
|
||||
def store_comparison_bar(data: list[dict[str, Any]]) -> go.Figure:
|
||||
"""Horizontal bar chart comparing stores for a single product.
|
||||
|
||||
*data* is expected to be a list of dicts with keys:
|
||||
``store_name`` and ``price``.
|
||||
"""
|
||||
if not data:
|
||||
fig = go.Figure()
|
||||
fig.update_layout(title="No comparison data available")
|
||||
return fig
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = pd.DataFrame(data).sort_values("price", ascending=True)
|
||||
stores = df["store_name"].tolist()
|
||||
colour_map = _colour_map(stores)
|
||||
colours = [colour_map.get(s, "#888888") for s in stores]
|
||||
|
||||
fig = go.Figure(
|
||||
go.Bar(
|
||||
y=df["store_name"],
|
||||
x=df["price"],
|
||||
orientation="h",
|
||||
marker_color=colours,
|
||||
text=df["price"].apply(lambda p: f"\u20ac{p:.2f}"),
|
||||
textposition="outside",
|
||||
hovertemplate="<b>%{y}</b>: \u20ac%{x:.2f}<extra></extra>",
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
title="Store Price Comparison",
|
||||
xaxis_title="Price (\u20ac)",
|
||||
xaxis_tickprefix="\u20ac",
|
||||
yaxis_title="",
|
||||
margin=dict(l=100, r=40, t=60, b=40),
|
||||
template="plotly_white",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Battle pie chart
|
||||
# ---------------------------------------------------------------------------
|
||||
def battle_pie_chart(data: dict[str, int]) -> go.Figure:
|
||||
"""Pie chart showing % of times each store is cheapest.
|
||||
|
||||
*data* is expected to be a dict mapping store name -> win count.
|
||||
"""
|
||||
if not data:
|
||||
fig = go.Figure()
|
||||
fig.update_layout(title="No battle data available")
|
||||
return fig
|
||||
|
||||
stores = list(data.keys())
|
||||
counts = list(data.values())
|
||||
colour_map = _colour_map(stores)
|
||||
colours = [colour_map.get(s, "#888888") for s in stores]
|
||||
|
||||
fig = go.Figure(
|
||||
go.Pie(
|
||||
labels=stores,
|
||||
values=counts,
|
||||
marker=dict(colors=colours),
|
||||
textinfo="label+percent",
|
||||
hovertemplate="<b>%{label}</b><br>Wins: %{value}<br>%{percent}<extra></extra>",
|
||||
hole=0.35,
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
title="Cheapest Store Breakdown",
|
||||
margin=dict(l=20, r=20, t=60, b=20),
|
||||
template="plotly_white",
|
||||
showlegend=True,
|
||||
legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5),
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Basket comparison grouped bar chart
|
||||
# ---------------------------------------------------------------------------
|
||||
def basket_comparison_bar(data: list[dict[str, Any]]) -> go.Figure:
|
||||
"""Grouped bar chart comparing basket cost per store.
|
||||
|
||||
*data* is expected to be a list of dicts with keys:
|
||||
``store_name`` and ``total``.
|
||||
"""
|
||||
if not data:
|
||||
fig = go.Figure()
|
||||
fig.update_layout(title="No basket data available")
|
||||
return fig
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = pd.DataFrame(data).sort_values("total", ascending=True)
|
||||
stores = df["store_name"].tolist()
|
||||
colour_map = _colour_map(stores)
|
||||
colours = [colour_map.get(s, "#888888") for s in stores]
|
||||
|
||||
min_total = df["total"].min()
|
||||
|
||||
fig = go.Figure(
|
||||
go.Bar(
|
||||
x=df["store_name"],
|
||||
y=df["total"],
|
||||
marker_color=colours,
|
||||
text=df["total"].apply(lambda t: f"\u20ac{t:.2f}"),
|
||||
textposition="outside",
|
||||
hovertemplate="<b>%{x}</b>: \u20ac%{y:.2f}<extra></extra>",
|
||||
)
|
||||
)
|
||||
|
||||
# Highlight the cheapest bar with a border
|
||||
bar_line_widths = [3 if t == min_total else 0 for t in df["total"]]
|
||||
fig.update_traces(
|
||||
marker_line_width=bar_line_widths,
|
||||
marker_line_color="gold",
|
||||
)
|
||||
|
||||
fig.update_layout(
|
||||
title="Basket Total by Store",
|
||||
yaxis_title="Total Cost (\u20ac)",
|
||||
yaxis_tickprefix="\u20ac",
|
||||
xaxis_title="",
|
||||
margin=dict(l=40, r=20, t=60, b=40),
|
||||
template="plotly_white",
|
||||
)
|
||||
return fig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Price trend sparkline
|
||||
# ---------------------------------------------------------------------------
|
||||
def price_trend_sparkline(prices: list[float], width: int = 150, height: int = 40) -> go.Figure:
|
||||
"""Tiny sparkline for inline use.
|
||||
|
||||
*prices* is a simple list of price floats in chronological order.
|
||||
"""
|
||||
if not prices:
|
||||
fig = go.Figure()
|
||||
fig.update_layout(width=width, height=height, margin=dict(l=0, r=0, t=0, b=0))
|
||||
return fig
|
||||
|
||||
colour = "#00539F"
|
||||
if len(prices) >= 2:
|
||||
colour = "#2ecc71" if prices[-1] <= prices[0] else "#e74c3c"
|
||||
|
||||
fig = go.Figure(
|
||||
go.Scatter(
|
||||
y=prices,
|
||||
mode="lines",
|
||||
line=dict(color=colour, width=1.5),
|
||||
fill="tozeroy",
|
||||
fillcolor=f"rgba({int(colour[1:3],16)},{int(colour[3:5],16)},{int(colour[5:7],16)},0.1)",
|
||||
hoverinfo="skip",
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
width=width,
|
||||
height=height,
|
||||
margin=dict(l=0, r=0, t=0, b=0),
|
||||
xaxis=dict(visible=False),
|
||||
yaxis=dict(visible=False),
|
||||
paper_bgcolor="rgba(0,0,0,0)",
|
||||
plot_bgcolor="rgba(0,0,0,0)",
|
||||
)
|
||||
return fig
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
"""Reusable Streamlit filter / input components for the SmartCart dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import streamlit as st
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
API = settings.api_base_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@st.cache_data(ttl=300, show_spinner=False)
|
||||
def _fetch_stores() -> list[dict[str, Any]]:
|
||||
"""Fetch the list of stores from the API (cached 5 min)."""
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/stores", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
@st.cache_data(ttl=300, show_spinner=False)
|
||||
def _fetch_categories() -> list[dict[str, Any]]:
|
||||
"""Fetch the list of categories from the API (cached 5 min)."""
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/categories", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public filter widgets
|
||||
# ---------------------------------------------------------------------------
|
||||
def store_filter(stores: list[dict[str, Any]] | None = None, key: str = "store_filter") -> list[int]:
|
||||
"""Render a multiselect widget for stores.
|
||||
|
||||
Returns a list of selected store IDs. If *stores* is ``None`` the list is
|
||||
fetched from the API automatically.
|
||||
"""
|
||||
if stores is None:
|
||||
stores = _fetch_stores()
|
||||
|
||||
if not stores:
|
||||
st.warning("Could not load stores from the API.")
|
||||
return []
|
||||
|
||||
options = {s["name"]: s["id"] for s in stores}
|
||||
selected_names: list[str] = st.multiselect(
|
||||
"Stores",
|
||||
options=list(options.keys()),
|
||||
default=list(options.keys()),
|
||||
key=key,
|
||||
)
|
||||
return [options[n] for n in selected_names]
|
||||
|
||||
|
||||
def category_filter(
|
||||
categories: list[dict[str, Any]] | None = None,
|
||||
key: str = "category_filter",
|
||||
include_all: bool = True,
|
||||
) -> int | None:
|
||||
"""Render a selectbox for category.
|
||||
|
||||
Returns the selected category ID, or ``None`` when *All Categories* is
|
||||
chosen.
|
||||
"""
|
||||
if categories is None:
|
||||
categories = _fetch_categories()
|
||||
|
||||
if not categories:
|
||||
st.warning("Could not load categories from the API.")
|
||||
return None
|
||||
|
||||
labels: list[str] = []
|
||||
id_map: dict[str, int | None] = {}
|
||||
|
||||
if include_all:
|
||||
labels.append("All Categories")
|
||||
id_map["All Categories"] = None
|
||||
|
||||
for cat in categories:
|
||||
labels.append(cat["name"])
|
||||
id_map[cat["name"]] = cat["id"]
|
||||
|
||||
selected = st.selectbox("Category", options=labels, key=key)
|
||||
return id_map.get(selected)
|
||||
|
||||
|
||||
def date_range_filter(
|
||||
key: str = "date_range_filter",
|
||||
default_days: int = 30,
|
||||
) -> tuple[datetime.date, datetime.date]:
|
||||
"""Render a date-range picker.
|
||||
|
||||
Returns ``(start_date, end_date)``. Defaults to the last
|
||||
*default_days* days.
|
||||
"""
|
||||
today = datetime.date.today()
|
||||
start_default = today - datetime.timedelta(days=default_days)
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
start = st.date_input("From", value=start_default, key=f"{key}_start")
|
||||
with col2:
|
||||
end = st.date_input("To", value=today, key=f"{key}_end")
|
||||
|
||||
if start > end:
|
||||
st.error("Start date must be before end date.")
|
||||
start = end
|
||||
|
||||
return start, end
|
||||
|
||||
|
||||
def search_filter(
|
||||
label: str = "Search products",
|
||||
key: str = "search_filter",
|
||||
placeholder: str = "e.g. milk, bread, chicken ...",
|
||||
) -> str:
|
||||
"""Render a text input for product search.
|
||||
|
||||
Returns the current search string (may be empty).
|
||||
"""
|
||||
return st.text_input(label, key=key, placeholder=placeholder)
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
"""SmartCart Dashboard -- Basket Compare page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from src.core.config import settings
|
||||
from src.dashboard.components.charts import basket_comparison_bar
|
||||
|
||||
API = settings.api_base_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _search_products(query: str) -> list[dict[str, Any]]:
|
||||
if not query:
|
||||
return []
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API}/api/products",
|
||||
params={"search": query, "limit": 30},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return payload.get("items", payload.get("results", []))
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
def _compare_basket(items: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""POST the basket to the API and return comparison results."""
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{API}/api/baskets",
|
||||
json={"name": "My Basket", "items": items},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
st.error(f"API error: {exc}")
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session state initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
if "basket_items" not in st.session_state:
|
||||
st.session_state.basket_items: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page content
|
||||
# ---------------------------------------------------------------------------
|
||||
st.title("Basket Compare")
|
||||
st.caption(
|
||||
"Build a shopping list, then compare the total cost at each store."
|
||||
)
|
||||
|
||||
# ---- Add items section ----------------------------------------------------
|
||||
st.subheader("Add Items to Basket")
|
||||
|
||||
add_col1, add_col2, add_col3 = st.columns([3, 1, 1])
|
||||
|
||||
with add_col1:
|
||||
search_query = st.text_input(
|
||||
"Search for a product",
|
||||
key="basket_search",
|
||||
placeholder="e.g. milk, bread, chicken ...",
|
||||
)
|
||||
|
||||
products = _search_products(search_query)
|
||||
|
||||
if search_query and not products:
|
||||
st.warning("No products found for your search.")
|
||||
|
||||
if products:
|
||||
product_map = {p.get("name", f"Product {p['id']}"): p for p in products}
|
||||
with add_col2:
|
||||
selected_product_name = st.selectbox(
|
||||
"Product",
|
||||
options=list(product_map.keys()),
|
||||
key="basket_product_select",
|
||||
)
|
||||
with add_col3:
|
||||
quantity = st.number_input(
|
||||
"Qty",
|
||||
min_value=1,
|
||||
max_value=99,
|
||||
value=1,
|
||||
key="basket_qty",
|
||||
)
|
||||
|
||||
if st.button("Add to basket", type="primary"):
|
||||
product = product_map[selected_product_name]
|
||||
st.session_state.basket_items.append(
|
||||
{
|
||||
"product_id": product["id"],
|
||||
"product_name": product.get("name", f"Product {product['id']}"),
|
||||
"quantity": quantity,
|
||||
}
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# ---- Shopping list display ------------------------------------------------
|
||||
st.subheader("Your Basket")
|
||||
|
||||
if not st.session_state.basket_items:
|
||||
st.info("Your basket is empty. Search and add products above.")
|
||||
else:
|
||||
# Show basket as a table
|
||||
basket_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"Product": item["product_name"],
|
||||
"Quantity": item["quantity"],
|
||||
}
|
||||
for item in st.session_state.basket_items
|
||||
]
|
||||
)
|
||||
|
||||
st.dataframe(basket_df, use_container_width=True, hide_index=True)
|
||||
|
||||
# Remove buttons
|
||||
remove_cols = st.columns(min(len(st.session_state.basket_items), 6))
|
||||
for idx, item in enumerate(st.session_state.basket_items):
|
||||
col = remove_cols[idx % len(remove_cols)]
|
||||
if col.button(
|
||||
f"Remove {item['product_name'][:20]}",
|
||||
key=f"remove_{idx}",
|
||||
):
|
||||
st.session_state.basket_items.pop(idx)
|
||||
st.rerun()
|
||||
|
||||
action_col1, action_col2 = st.columns(2)
|
||||
with action_col1:
|
||||
compare_clicked = st.button(
|
||||
"Compare Basket",
|
||||
type="primary",
|
||||
use_container_width=True,
|
||||
)
|
||||
with action_col2:
|
||||
if st.button("Clear Basket", use_container_width=True):
|
||||
st.session_state.basket_items = []
|
||||
st.rerun()
|
||||
|
||||
# ---- Comparison results -----------------------------------------------
|
||||
if compare_clicked:
|
||||
payload_items = [
|
||||
{"product_id": item["product_id"], "quantity": item["quantity"]}
|
||||
for item in st.session_state.basket_items
|
||||
]
|
||||
|
||||
with st.spinner("Comparing prices across stores..."):
|
||||
result = _compare_basket(payload_items)
|
||||
|
||||
if not result:
|
||||
st.error(
|
||||
"Could not compare your basket. Make sure the API is running "
|
||||
f"at **{API}**."
|
||||
)
|
||||
st.stop()
|
||||
|
||||
st.divider()
|
||||
st.subheader("Comparison Results")
|
||||
|
||||
# ---- Totals per store (from BasketCompareOut.stores) ------
|
||||
store_totals: list[dict[str, Any]] = result.get("stores", [])
|
||||
if store_totals:
|
||||
# Filter out stores with 0 items found
|
||||
active_stores = [s for s in store_totals if s.get("items_found", 0) > 0]
|
||||
if not active_stores:
|
||||
st.warning("None of the stores carry these products.")
|
||||
else:
|
||||
# Sort cheapest first
|
||||
active_sorted = sorted(active_stores, key=lambda s: float(s.get("total", 99999)))
|
||||
|
||||
# Metrics row
|
||||
metric_cols = st.columns(len(active_sorted))
|
||||
cheapest_total = float(active_sorted[0]["total"]) if active_sorted else 0
|
||||
for idx, st_total in enumerate(active_sorted):
|
||||
store_info = st_total.get("store", {})
|
||||
name = store_info.get("name", "Unknown")
|
||||
total = float(st_total.get("total", 0))
|
||||
found = st_total.get("items_found", 0)
|
||||
missing = st_total.get("items_missing", 0)
|
||||
delta = total - cheapest_total
|
||||
|
||||
metric_cols[idx].metric(
|
||||
label=name,
|
||||
value=f"\u20ac{total:.2f}",
|
||||
delta=f"+\u20ac{delta:.2f}" if delta > 0 else "Cheapest",
|
||||
delta_color="inverse" if delta > 0 else "off",
|
||||
)
|
||||
metric_cols[idx].caption(f"{found} found, {missing} missing")
|
||||
|
||||
# Bar chart
|
||||
chart_data = [
|
||||
{
|
||||
"store_name": s["store"]["name"],
|
||||
"total": float(s["total"]),
|
||||
}
|
||||
for s in active_sorted
|
||||
]
|
||||
fig = basket_comparison_bar(chart_data)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
else:
|
||||
st.info("No store comparison data available.")
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
"""SmartCart Dashboard -- Overview / KPI page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
API = settings.api_base_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _fetch_stats() -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/stats", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _fetch_products(page: int = 1, limit: int = 50, search: str = "") -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"page": page, "limit": limit}
|
||||
if search:
|
||||
params["search"] = search
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/products", params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _fetch_battle() -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/battle", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page content
|
||||
# ---------------------------------------------------------------------------
|
||||
st.title("Overview")
|
||||
st.caption("Key performance indicators and product catalogue.")
|
||||
|
||||
stats = _fetch_stats()
|
||||
|
||||
if not stats:
|
||||
st.error(
|
||||
"Unable to reach the SmartCart API. Please ensure the backend is running "
|
||||
f"at **{API}**."
|
||||
)
|
||||
st.stop()
|
||||
|
||||
# ---- KPI cards -----------------------------------------------------------
|
||||
kpi1, kpi2, kpi3 = st.columns(3)
|
||||
|
||||
kpi1.metric(
|
||||
label="Products Tracked",
|
||||
value=f"{stats.get('total_products', 0):,}",
|
||||
)
|
||||
kpi2.metric(
|
||||
label="Stores",
|
||||
value=f"{stats.get('total_stores', 0):,}",
|
||||
)
|
||||
kpi3.metric(
|
||||
label="Price Records",
|
||||
value=f"{stats.get('total_price_records', 0):,}",
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ---- Average Price by Store ----------------------------------------------
|
||||
avg_by_store = stats.get("avg_prices_by_store", [])
|
||||
if avg_by_store:
|
||||
st.subheader("Average Price by Store")
|
||||
store_cols = st.columns(len(avg_by_store))
|
||||
for idx, entry in enumerate(avg_by_store):
|
||||
store_info = entry.get("store", {})
|
||||
store_name = store_info.get("name", "Unknown")
|
||||
avg_price = entry.get("avg_price", "0")
|
||||
store_cols[idx].metric(
|
||||
label=store_name,
|
||||
value=f"\u20ac{float(avg_price):.2f}",
|
||||
)
|
||||
st.divider()
|
||||
|
||||
# ---- Battle summary (if multiple stores) ---------------------------------
|
||||
battle = _fetch_battle()
|
||||
battle_results = battle.get("results", [])
|
||||
stores_with_wins = [r for r in battle_results if r.get("wins", 0) > 0]
|
||||
|
||||
if stores_with_wins:
|
||||
from src.dashboard.components.charts import battle_pie_chart
|
||||
|
||||
st.subheader("Cheapest Store Breakdown")
|
||||
wins_dict = {r["store"]["name"]: r["wins"] for r in stores_with_wins}
|
||||
col_chart, col_stats = st.columns(2)
|
||||
with col_chart:
|
||||
fig = battle_pie_chart(wins_dict)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
with col_stats:
|
||||
for r in battle_results:
|
||||
store_name = r["store"]["name"]
|
||||
wins = r.get("wins", 0)
|
||||
avg = r.get("avg_price", 0)
|
||||
pct = r.get("cheapest_pct", 0)
|
||||
if wins > 0 or float(avg) > 0:
|
||||
st.markdown(
|
||||
f"**{store_name}**: {wins} wins ({pct}%) "
|
||||
f"| avg \u20ac{float(avg):.2f}"
|
||||
)
|
||||
st.divider()
|
||||
|
||||
# ---- Product catalogue table ---------------------------------------------
|
||||
st.subheader("Product Catalogue")
|
||||
|
||||
# Search bar
|
||||
search_query = st.text_input(
|
||||
"Search products",
|
||||
placeholder="e.g. milk, bread, chicken ...",
|
||||
key="overview_search",
|
||||
)
|
||||
|
||||
# Pagination
|
||||
if "overview_page" not in st.session_state:
|
||||
st.session_state.overview_page = 1
|
||||
|
||||
PAGE_SIZE = 25
|
||||
data = _fetch_products(
|
||||
page=st.session_state.overview_page, limit=PAGE_SIZE, search=search_query
|
||||
)
|
||||
|
||||
items = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
|
||||
if items:
|
||||
rows = []
|
||||
for p in items:
|
||||
cat = p.get("category")
|
||||
rows.append({
|
||||
"ID": p.get("id"),
|
||||
"Name": p.get("name", ""),
|
||||
"Brand": p.get("brand") or "\u2014",
|
||||
"Category": cat.get("name", "") if cat else "\u2014",
|
||||
"Unit": f"{p['unit_size']} {p['unit']}" if p.get("unit_size") and p.get("unit") else "\u2014",
|
||||
"Image": p.get("image_url") or "",
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
# Show image column if available
|
||||
has_images = any(r["Image"] for r in rows)
|
||||
if has_images:
|
||||
st.dataframe(
|
||||
df,
|
||||
use_container_width=True,
|
||||
hide_index=True,
|
||||
column_config={
|
||||
"Image": st.column_config.ImageColumn("Image", width="small"),
|
||||
"ID": st.column_config.NumberColumn("ID", width="small"),
|
||||
},
|
||||
height=min(len(rows) * 40 + 50, 700),
|
||||
)
|
||||
else:
|
||||
display_df = df.drop(columns=["Image"])
|
||||
st.dataframe(display_df, use_container_width=True, hide_index=True)
|
||||
|
||||
# Pagination controls
|
||||
st.caption(f"Showing {len(items)} of {total} products (page {st.session_state.overview_page}/{total_pages})")
|
||||
|
||||
nav_cols = st.columns([1, 1, 4])
|
||||
with nav_cols[0]:
|
||||
if st.button("Previous", disabled=st.session_state.overview_page <= 1):
|
||||
st.session_state.overview_page -= 1
|
||||
st.rerun()
|
||||
with nav_cols[1]:
|
||||
if st.button("Next", disabled=st.session_state.overview_page >= total_pages):
|
||||
st.session_state.overview_page += 1
|
||||
st.rerun()
|
||||
else:
|
||||
if search_query:
|
||||
st.warning("No products found for your search.")
|
||||
else:
|
||||
st.info("No products in the database yet. Run a scraper first!")
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
"""SmartCart Dashboard -- Price Battle page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from src.core.config import settings
|
||||
from src.dashboard.components.charts import STORE_COLOURS, battle_pie_chart
|
||||
from src.dashboard.components.filters import category_filter
|
||||
|
||||
API = settings.api_base_url
|
||||
|
||||
POPULAR_SEARCHES = [
|
||||
"milk", "bread", "chicken", "rice", "butter", "cheese",
|
||||
"eggs", "pasta", "sugar", "tea", "coffee", "water",
|
||||
"beef", "salmon", "yoghurt", "cereal", "oil", "flour",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
@st.cache_data(ttl=120, show_spinner=False)
|
||||
def _fetch_battle(category_id: int | None = None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
if category_id is not None:
|
||||
params["category_id"] = category_id
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/battle", params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _search_prices(query: str) -> list[dict[str, Any]]:
|
||||
if not query:
|
||||
return []
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API}/api/search-prices",
|
||||
params={"q": query, "limit": 60},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page content
|
||||
# ---------------------------------------------------------------------------
|
||||
st.title("Price Battle")
|
||||
st.caption("Compare real product prices across Irish supermarkets.")
|
||||
|
||||
# ---- Store Rankings (compact) --------------------------------------------
|
||||
battle = _fetch_battle()
|
||||
results = battle.get("results", [])
|
||||
stores_with_data = [r for r in results if float(r.get("avg_price", 0)) > 0]
|
||||
|
||||
if stores_with_data:
|
||||
st.subheader("Store Overview")
|
||||
metric_cols = st.columns(len(stores_with_data))
|
||||
for idx, r in enumerate(stores_with_data):
|
||||
store_name = r["store"]["name"]
|
||||
avg_price = float(r.get("avg_price", 0))
|
||||
product_count = r.get("wins", 0)
|
||||
metric_cols[idx].metric(
|
||||
label=store_name,
|
||||
value=f"\u20ac{avg_price:.2f} avg",
|
||||
)
|
||||
st.divider()
|
||||
|
||||
# ---- Product Price Comparison --------------------------------------------
|
||||
st.subheader("Compare Products")
|
||||
|
||||
# Popular search buttons
|
||||
st.caption("Popular searches:")
|
||||
button_cols = st.columns(9)
|
||||
for idx, term in enumerate(POPULAR_SEARCHES[:9]):
|
||||
with button_cols[idx]:
|
||||
if st.button(term.capitalize(), key=f"pop_{term}", use_container_width=True):
|
||||
st.session_state.battle_search_input = term
|
||||
st.rerun()
|
||||
|
||||
# Second row of popular searches
|
||||
button_cols2 = st.columns(9)
|
||||
for idx, term in enumerate(POPULAR_SEARCHES[9:18]):
|
||||
with button_cols2[idx]:
|
||||
if st.button(term.capitalize(), key=f"pop_{term}", use_container_width=True):
|
||||
st.session_state.battle_search_input = term
|
||||
st.rerun()
|
||||
|
||||
# Search input
|
||||
actual_query = st.text_input(
|
||||
"Search for a product to compare prices",
|
||||
placeholder="e.g. milk, bread, chicken ...",
|
||||
key="battle_search_input",
|
||||
)
|
||||
|
||||
if actual_query:
|
||||
results_data = _search_prices(actual_query)
|
||||
|
||||
if not results_data:
|
||||
st.warning(f"No products found for '{actual_query}'.")
|
||||
else:
|
||||
# Build comparison table
|
||||
rows = []
|
||||
for item in results_data:
|
||||
price = item["price"]
|
||||
promo = item.get("promo_price")
|
||||
effective = item["effective_price"]
|
||||
|
||||
row = {
|
||||
"Store": item["store"],
|
||||
"Product": item["product_name"],
|
||||
"Price": price,
|
||||
"Effective": effective,
|
||||
"Promo": item.get("promo_label") or "",
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
# Sort by effective price
|
||||
df = df.sort_values("Effective")
|
||||
|
||||
# Show count per store
|
||||
store_counts = df["Store"].value_counts()
|
||||
st.caption(
|
||||
f"Found {len(df)} products matching '{actual_query}': "
|
||||
+ ", ".join(f"{store} ({count})" for store, count in store_counts.items())
|
||||
)
|
||||
|
||||
# Format for display
|
||||
display_df = df.copy()
|
||||
display_df["Price"] = display_df["Price"].apply(lambda p: f"\u20ac{p:.2f}")
|
||||
display_df["Effective"] = display_df["Effective"].apply(lambda p: f"\u20ac{p:.2f}")
|
||||
|
||||
# Color-code by store
|
||||
def _style_store(row: pd.Series) -> list[str]:
|
||||
store = row.get("Store", "")
|
||||
color = STORE_COLOURS.get(store, "")
|
||||
# Match partial store names
|
||||
for key, val in STORE_COLOURS.items():
|
||||
if key.lower() in store.lower():
|
||||
color = val
|
||||
break
|
||||
if color:
|
||||
return [f"border-left: 4px solid {color}"] + [""] * (len(row) - 1)
|
||||
return [""] * len(row)
|
||||
|
||||
styled = display_df.style.apply(_style_store, axis=1)
|
||||
st.dataframe(
|
||||
styled,
|
||||
use_container_width=True,
|
||||
hide_index=True,
|
||||
height=min(len(display_df) * 38 + 50, 600),
|
||||
)
|
||||
|
||||
# Average price chart per store for this search
|
||||
st.subheader(f"Average price for '{actual_query}' by store")
|
||||
avg_by_store = df.groupby("Store")["Effective"].mean().sort_values()
|
||||
|
||||
colors = []
|
||||
for store in avg_by_store.index:
|
||||
color = "#888888"
|
||||
for key, val in STORE_COLOURS.items():
|
||||
if key.lower() in store.lower():
|
||||
color = val
|
||||
break
|
||||
colors.append(color)
|
||||
|
||||
fig = go.Figure(
|
||||
go.Bar(
|
||||
x=avg_by_store.index,
|
||||
y=avg_by_store.values,
|
||||
marker_color=colors,
|
||||
text=[f"\u20ac{v:.2f}" for v in avg_by_store.values],
|
||||
textposition="outside",
|
||||
)
|
||||
)
|
||||
fig.update_layout(
|
||||
yaxis_title="Average Price (\u20ac)",
|
||||
yaxis_tickprefix="\u20ac",
|
||||
margin=dict(l=40, r=20, t=20, b=40),
|
||||
template="plotly_white",
|
||||
height=350,
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
# Cheapest finds
|
||||
st.subheader("Best Deals")
|
||||
cheapest = df.nsmallest(5, "Effective")
|
||||
for _, row in cheapest.iterrows():
|
||||
promo_text = f" ({row['Promo']})" if row["Promo"] else ""
|
||||
st.markdown(
|
||||
f"**\u20ac{row['Effective']:.2f}** - {row['Product']} @ {row['Store']}{promo_text}"
|
||||
)
|
||||
else:
|
||||
st.info("Search for a product above or click a popular category to compare prices across stores.")
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
"""SmartCart Dashboard -- Product History page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from src.core.config import settings
|
||||
from src.dashboard.components.charts import STORE_COLOURS, price_history_chart, store_comparison_bar
|
||||
from src.dashboard.components.filters import date_range_filter, search_filter
|
||||
|
||||
API = settings.api_base_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ---------------------------------------------------------------------------
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _search_products(query: str) -> list[dict[str, Any]]:
|
||||
if not query:
|
||||
return []
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API}/api/products",
|
||||
params={"search": query, "limit": 50},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return payload.get("items", payload.get("results", []))
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _fetch_price_history(product_id: int, days: int = 90) -> list[dict[str, Any]]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API}/api/products/{product_id}/prices",
|
||||
params={"days": days},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _fetch_comparison(product_id: int) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.get(f"{API}/api/products/{product_id}/compare", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return {}
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def _search_prices(query: str) -> list[dict[str, Any]]:
|
||||
if not query:
|
||||
return []
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{API}/api/search-prices",
|
||||
params={"q": query, "limit": 100},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page content
|
||||
# ---------------------------------------------------------------------------
|
||||
st.title("Product History")
|
||||
st.caption("Search for a product and explore its price history across stores.")
|
||||
|
||||
# ---- Sidebar filters ------------------------------------------------------
|
||||
with st.sidebar:
|
||||
st.subheader("Filters")
|
||||
start_date, end_date = date_range_filter(key="history_date", default_days=90)
|
||||
|
||||
# ---- Search & select product ---------------------------------------------
|
||||
query = search_filter(key="product_history_search")
|
||||
|
||||
products = _search_products(query)
|
||||
|
||||
if query and not products:
|
||||
st.warning("No products found. Try a different search term.")
|
||||
st.stop()
|
||||
|
||||
if not products:
|
||||
st.info("Enter a search term above to find products.")
|
||||
st.stop()
|
||||
|
||||
product_options = {p.get("name", f"Product {p['id']}"): p["id"] for p in products}
|
||||
selected_name = st.selectbox(
|
||||
"Select a product",
|
||||
options=list(product_options.keys()),
|
||||
key="product_selector",
|
||||
)
|
||||
|
||||
if not selected_name:
|
||||
st.stop()
|
||||
|
||||
product_id: int = product_options[selected_name]
|
||||
|
||||
# ---- Calculate days from date range --------------------------------------
|
||||
days = (end_date - start_date).days
|
||||
if days < 1:
|
||||
days = 90
|
||||
|
||||
# ---- Price history time series chart -------------------------------------
|
||||
st.subheader("Price History")
|
||||
history = _fetch_price_history(product_id, days=days)
|
||||
|
||||
if history:
|
||||
# The API returns list of {store: {...}, prices: [{price, promo_price, scraped_at, ...}]}
|
||||
chart_data: list[dict[str, Any]] = []
|
||||
for entry in history:
|
||||
store_info = entry.get("store", {})
|
||||
store_name = store_info.get("name", "Unknown")
|
||||
prices = entry.get("prices", [])
|
||||
for pr in prices:
|
||||
scraped_at = pr.get("scraped_at", "")
|
||||
price = float(pr.get("price", 0))
|
||||
promo = pr.get("promo_price")
|
||||
effective = float(promo) if promo else price
|
||||
chart_data.append({
|
||||
"date": scraped_at,
|
||||
"price": effective,
|
||||
"store_name": store_name,
|
||||
"is_promo": pr.get("promo_label") is not None,
|
||||
})
|
||||
|
||||
if chart_data:
|
||||
fig = price_history_chart(chart_data)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
else:
|
||||
st.info("No price data in the selected date range.")
|
||||
else:
|
||||
st.info("No price history available for this product.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ---- Current prices across stores ----------------------------------------
|
||||
st.subheader("Current Prices Across Stores")
|
||||
comparison = _fetch_comparison(product_id)
|
||||
|
||||
if comparison:
|
||||
stores_list = comparison.get("stores", [])
|
||||
if stores_list:
|
||||
rows = []
|
||||
bar_data = []
|
||||
for sp in stores_list:
|
||||
store_info = sp.get("store", {})
|
||||
store_name = store_info.get("name", "Unknown")
|
||||
price = sp.get("latest_price")
|
||||
promo_price = sp.get("promo_price")
|
||||
promo_label = sp.get("promo_label")
|
||||
|
||||
effective_price = promo_price if promo_price is not None else price
|
||||
|
||||
row = {
|
||||
"Store": store_name,
|
||||
"Price": f"\u20ac{float(price):.2f}" if price is not None else "\u2014",
|
||||
"Promo": promo_label or "\u2014",
|
||||
}
|
||||
if promo_price is not None:
|
||||
row["Promo Price"] = f"\u20ac{float(promo_price):.2f}"
|
||||
rows.append(row)
|
||||
|
||||
if effective_price is not None:
|
||||
bar_data.append({
|
||||
"store_name": store_name,
|
||||
"price": float(effective_price),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||
|
||||
if bar_data:
|
||||
fig2 = store_comparison_bar(bar_data)
|
||||
st.plotly_chart(fig2, use_container_width=True)
|
||||
else:
|
||||
st.info("This product is not available in any store currently.")
|
||||
else:
|
||||
st.info("No comparison data available for this product.")
|
||||
|
||||
st.divider()
|
||||
|
||||
# ---- Similar products across stores (using search) -----------------------
|
||||
st.subheader("Similar Products Across Stores")
|
||||
st.caption(f"Other products matching '{query}' across all stores.")
|
||||
|
||||
similar = _search_prices(query) if query else []
|
||||
if similar:
|
||||
sim_rows = []
|
||||
for item in similar:
|
||||
price = item["price"]
|
||||
effective = item["effective_price"]
|
||||
sim_rows.append({
|
||||
"Store": item["store"],
|
||||
"Product": item["product_name"],
|
||||
"Price": f"\u20ac{price:.2f}",
|
||||
"Effective": f"\u20ac{effective:.2f}",
|
||||
"Promo": item.get("promo_label") or "",
|
||||
})
|
||||
sim_df = pd.DataFrame(sim_rows).sort_values("Effective")
|
||||
st.dataframe(sim_df, use_container_width=True, hide_index=True, height=min(len(sim_df) * 38 + 50, 400))
|
||||
else:
|
||||
if query:
|
||||
st.info("No similar products found across stores.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue