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:
2026-05-04 22:27:24 +02:00
commit 486749a890
40 changed files with 1596 additions and 0 deletions
+12
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+26
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+78
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+22
View File
@@ -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>
);
}
+70
View File
@@ -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>
);
}
+111
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+75
View File
@@ -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`
);
+15
View File
@@ -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;
+22
View File
@@ -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"]
}