Initial project scaffold
Full-stack Dutch supermarket price tracker with FastAPI backend, PostgreSQL/SQLAlchemy, Albert Heijn scraper, and Next.js frontend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=development
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "dutch-food-price-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.15",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheapestProduct, formatPrice, getCheapestProducts } from "@/lib/api";
|
||||
|
||||
export default function CheapestPage() {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const [date, setDate] = useState(today);
|
||||
const [products, setProducts] = useState<CheapestProduct[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getCheapestProducts(date)
|
||||
.then(setProducts)
|
||||
.catch(() => setError("Kon data niet laden."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-4 mb-6">
|
||||
<h1 className="text-2xl font-bold">Goedkoopste producten</h1>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={today}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="border rounded-lg px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-gray-400 text-sm">Laden…</p>}
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
{!loading && !error && products.length === 0 && (
|
||||
<p className="text-gray-400 text-sm">
|
||||
Geen data voor {date}. Voer een scrape uit voor deze datum.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{products.map(({ product, price, unit_price, unit_description, is_on_sale }) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
href={`/products/${product.id}`}
|
||||
className="flex items-center justify-between bg-white border rounded-lg px-4 py-3 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-400">{product.store?.name}</p>
|
||||
<p className="font-medium text-sm truncate">{product.name}</p>
|
||||
{product.brand && <p className="text-xs text-gray-400">{product.brand}</p>}
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4 shrink-0">
|
||||
<p className={`font-bold text-base ${is_on_sale ? "text-red-600" : ""}`}>
|
||||
{formatPrice(price)}
|
||||
</p>
|
||||
{unit_price != null && unit_description && (
|
||||
<p className="text-xs text-gray-400">
|
||||
{formatPrice(unit_price)} {unit_description}
|
||||
</p>
|
||||
)}
|
||||
{is_on_sale && (
|
||||
<span className="text-xs bg-red-50 text-red-600 px-1.5 py-0.5 rounded">
|
||||
aanbieding
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Nav from "@/components/Nav";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dutch Food Price Tracker",
|
||||
description: "Track supermarket food prices in the Netherlands",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="nl">
|
||||
<body className={`${inter.className} bg-gray-50 min-h-screen`}>
|
||||
<Nav />
|
||||
<main className="container mx-auto px-4 py-8 max-w-5xl">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { searchProducts, Product } from "@/lib/api";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await searchProducts(query.trim());
|
||||
setResults(data);
|
||||
setSearched(true);
|
||||
} catch {
|
||||
setError("Kon producten niet laden. Is de backend bereikbaar?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">Zoek producten</h1>
|
||||
<p className="text-gray-500 mb-6 text-sm">
|
||||
Zoek in de database van bijgehouden supermarktprijzen.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="bijv. melk, brood, kaas..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
{loading ? "Zoeken…" : "Zoeken"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
|
||||
|
||||
{searched && results.length === 0 && !error && (
|
||||
<p className="text-gray-400 text-sm">
|
||||
Geen producten gevonden voor <strong>{query}</strong>. Voer eerst een scrape uit via de
|
||||
CLI.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{results.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import PriceChart from "@/components/PriceChart";
|
||||
import { PriceSnapshot, Product, formatPrice, getProduct, getProductPrices } from "@/lib/api";
|
||||
|
||||
export default function ProductPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const productId = Number(id);
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [prices, setPrices] = useState<PriceSnapshot[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getProduct(productId), getProductPrices(productId)])
|
||||
.then(([p, ps]) => {
|
||||
setProduct(p);
|
||||
setPrices(ps);
|
||||
})
|
||||
.catch(() => setNotFound(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [productId]);
|
||||
|
||||
if (loading) return <p className="text-gray-400 text-sm">Laden…</p>;
|
||||
if (notFound || !product) return <p className="text-red-500">Product niet gevonden.</p>;
|
||||
|
||||
const latest = prices[prices.length - 1];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<Link href="/" className="text-blue-600 hover:underline text-sm">
|
||||
← Terug naar zoeken
|
||||
</Link>
|
||||
|
||||
<div className="bg-white border rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide mb-1">
|
||||
{product.store?.name}
|
||||
</p>
|
||||
<h1 className="text-xl font-bold">{product.name}</h1>
|
||||
{product.brand && <p className="text-gray-500 text-sm">{product.brand}</p>}
|
||||
{product.category && <p className="text-gray-400 text-xs">{product.category}</p>}
|
||||
{product.ean && (
|
||||
<p className="text-gray-300 text-xs mt-1 font-mono">EAN {product.ean}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latest && (
|
||||
<div className="flex flex-wrap gap-6 items-end border-t pt-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-0.5">Huidige prijs</p>
|
||||
<p className={`text-3xl font-bold ${latest.is_on_sale ? "text-red-600" : ""}`}>
|
||||
{formatPrice(latest.price)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{latest.unit_price != null && latest.unit_description && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-0.5">Per eenheid</p>
|
||||
<p className="text-lg text-gray-700">
|
||||
{formatPrice(latest.unit_price)}{" "}
|
||||
<span className="text-sm text-gray-400">{latest.unit_description}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latest.is_on_sale && latest.was_price != null && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-0.5">Was</p>
|
||||
<p className="text-lg text-gray-400 line-through">{formatPrice(latest.was_price)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latest?.discount_description && (
|
||||
<p className="inline-block bg-red-50 text-red-700 text-sm px-3 py-1 rounded-full">
|
||||
{latest.discount_description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{product.url && (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Bekijk op {product.store?.name ?? "winkel"} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<h2 className="font-semibold mb-4">Prijsgeschiedenis</h2>
|
||||
{prices.length < 2 ? (
|
||||
<p className="text-gray-400 text-sm">
|
||||
Nog niet genoeg datapunten voor een grafiek ({prices.length}/2).
|
||||
</p>
|
||||
) : (
|
||||
<PriceChart snapshots={prices} />
|
||||
)}
|
||||
<p className="text-xs text-gray-300 mt-3">{prices.length} meetpunt(en) opgeslagen</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Nav() {
|
||||
return (
|
||||
<nav className="bg-blue-700 text-white shadow">
|
||||
<div className="container mx-auto px-4 max-w-5xl flex items-center gap-6 h-14">
|
||||
<Link href="/" className="font-bold text-lg tracking-tight">
|
||||
Prijstracker NL
|
||||
</Link>
|
||||
<Link href="/" className="text-blue-100 hover:text-white transition-colors text-sm">
|
||||
Zoeken
|
||||
</Link>
|
||||
<Link href="/cheapest" className="text-blue-100 hover:text-white transition-colors text-sm">
|
||||
Goedkoopste vandaag
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { PriceSnapshot } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
snapshots: PriceSnapshot[];
|
||||
}
|
||||
|
||||
export default function PriceChart({ snapshots }: Props) {
|
||||
const data = snapshots.map((s) => ({
|
||||
date: new Date(s.timestamp).toLocaleDateString("nl-NL", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}),
|
||||
prijs: +(s.price / 100).toFixed(2),
|
||||
was: s.was_price != null ? +(s.was_price / 100).toFixed(2) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 16, left: 8, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
tickFormatter={(v) => `€${v.toFixed(2)}`}
|
||||
width={68}
|
||||
tick={{ fontSize: 11 }}
|
||||
domain={["auto", "auto"]}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
`€${value.toFixed(2).replace(".", ",")}`,
|
||||
name === "prijs" ? "Prijs" : "Was",
|
||||
]}
|
||||
labelStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="prijs"
|
||||
stroke="#1d4ed8"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="was"
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 2"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { Product, formatPrice } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: Props) {
|
||||
return (
|
||||
<Link
|
||||
href={`/products/${product.id}`}
|
||||
className="bg-white border rounded-lg p-4 hover:shadow-md transition-shadow flex justify-between items-start gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-gray-400 mb-0.5">{product.store?.name ?? "Onbekend"}</p>
|
||||
<h3 className="font-medium text-sm leading-snug">{product.name}</h3>
|
||||
{product.brand && <p className="text-xs text-gray-400 mt-0.5">{product.brand}</p>}
|
||||
{product.category && <p className="text-xs text-gray-300">{product.category}</p>}
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
{product.latest_price != null ? (
|
||||
<>
|
||||
<p className={`font-bold ${product.is_on_sale ? "text-red-600" : "text-gray-900"}`}>
|
||||
{formatPrice(product.latest_price)}
|
||||
</p>
|
||||
{product.is_on_sale && (
|
||||
<span className="text-xs bg-red-50 text-red-600 px-1.5 py-0.5 rounded">
|
||||
aanbieding
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-300 text-sm">—</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
export interface Store {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
country: string;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
store_id: number;
|
||||
external_id: string;
|
||||
ean: string | null;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
category: string | null;
|
||||
unit_size: string | null;
|
||||
url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
store: Store | null;
|
||||
latest_price: number | null;
|
||||
latest_price_timestamp: string | null;
|
||||
is_on_sale: boolean;
|
||||
}
|
||||
|
||||
export interface PriceSnapshot {
|
||||
id: number;
|
||||
product_id: number;
|
||||
scrape_run_id: number;
|
||||
price: number;
|
||||
unit_price: number | null;
|
||||
unit_description: string | null;
|
||||
currency: string;
|
||||
discount_label: string | null;
|
||||
discount_description: string | null;
|
||||
was_price: number | null;
|
||||
is_on_sale: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CheapestProduct {
|
||||
product: Product;
|
||||
price: number;
|
||||
unit_price: number | null;
|
||||
unit_description: string | null;
|
||||
is_on_sale: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2).replace(".", ",")}`;
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`);
|
||||
if (!res.ok) throw new Error(`API error ${res.status}: ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const searchProducts = (query: string) =>
|
||||
apiFetch<Product[]>(`/api/products?search=${encodeURIComponent(query)}`);
|
||||
|
||||
export const getProduct = (id: number) =>
|
||||
apiFetch<Product>(`/api/products/${id}`);
|
||||
|
||||
export const getProductPrices = (id: number) =>
|
||||
apiFetch<PriceSnapshot[]>(`/api/products/${id}/prices`);
|
||||
|
||||
export const getCheapestProducts = (date?: string) =>
|
||||
apiFetch<CheapestProduct[]>(
|
||||
date ? `/api/prices/cheapest?date=${date}` : `/api/prices/cheapest`
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user