From 99d1d8529377603c40b95c7cf1c538fa194fd2c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:35:59 +0000 Subject: [PATCH] feat: product detail page with chart, alert list, alert form --- src/app/products/[id]/page.tsx | 55 +++++++++++++++++++++++++++++++ src/components/AlertForm.tsx | 54 ++++++++++++++++++++++++++++++ src/components/AlertList.tsx | 32 ++++++++++++++++++ src/components/PriceChart.tsx | 35 ++++++++++++++++++++ src/components/ProductActions.tsx | 19 +++++++++++ 5 files changed, 195 insertions(+) create mode 100644 src/app/products/[id]/page.tsx create mode 100644 src/components/AlertForm.tsx create mode 100644 src/components/AlertList.tsx create mode 100644 src/components/PriceChart.tsx create mode 100644 src/components/ProductActions.tsx diff --git a/src/app/products/[id]/page.tsx b/src/app/products/[id]/page.tsx new file mode 100644 index 0000000..c6188bd --- /dev/null +++ b/src/app/products/[id]/page.tsx @@ -0,0 +1,55 @@ +import { notFound } from 'next/navigation' +import { eq, desc } from 'drizzle-orm' +import { db, products, priceSnapshots, alerts } from '@/lib/db' +import { PriceChart } from '@/components/PriceChart' +import { AlertList } from '@/components/AlertList' +import { AlertForm } from '@/components/AlertForm' +import { ProductActions } from '@/components/ProductActions' + +export const dynamic = 'force-dynamic' + +export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const [product] = await db.select().from(products).where(eq(products.id, id)) + if (!product) notFound() + + const snaps = await db.select().from(priceSnapshots) + .where(eq(priceSnapshots.productId, id)) + .orderBy(desc(priceSnapshots.scrapedAt)) + .limit(500) + const chartData = snaps + .filter((s) => s.price !== null) + .reverse() + .map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })) + + const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id)) + + return ( +
+ ← Zurück +
+ {product.imageUrl && } +
+
{product.shop}
+

{product.name}

+ Zum Shop ↗ +
+
+ +
+

Preisverlauf

+ +
+ +
+

Alerts

+ ({ id: a.id, type: a.type, config: a.config as Record, enabled: a.enabled }))} /> +
+
+ +
+ +
+
+ ) +} diff --git a/src/components/AlertForm.tsx b/src/components/AlertForm.tsx new file mode 100644 index 0000000..a9a07e3 --- /dev/null +++ b/src/components/AlertForm.tsx @@ -0,0 +1,54 @@ +'use client' +import { useState } from 'react' +import { useRouter } from 'next/navigation' + +export function AlertForm({ productId }: { productId: string }) { + const [type, setType] = useState<'target_price' | 'all_time_low' | 'percent_drop'>('target_price') + const [threshold, setThreshold] = useState('') + const [percent, setPercent] = useState('10') + const [lookback, setLookback] = useState('7') + const router = useRouter() + + async function submit(e: React.FormEvent) { + e.preventDefault() + let config: Record = {} + if (type === 'target_price') config = { threshold: Number(threshold) } + if (type === 'percent_drop') config = { percent: Number(percent), lookback_days: Number(lookback) } + const res = await fetch('/api/alerts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId, type, config }), + }) + if (res.ok) { + setThreshold('') + router.refresh() + } + } + + return ( +
+
+ + {type === 'target_price' && ( + setThreshold(e.target.value)} + placeholder="€" className="w-24 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> + )} + {type === 'percent_drop' && ( + <> + setPercent(e.target.value)} + className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> + % in + setLookback(e.target.value)} + className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> + Tagen + + )} + +
+
+ ) +} diff --git a/src/components/AlertList.tsx b/src/components/AlertList.tsx new file mode 100644 index 0000000..6f2c5ee --- /dev/null +++ b/src/components/AlertList.tsx @@ -0,0 +1,32 @@ +'use client' +import { useRouter } from 'next/navigation' + +interface Alert { id: string; type: string; config: Record; enabled: boolean } + +export function AlertList({ alerts }: { alerts: Alert[] }) { + const router = useRouter() + async function del(id: string) { + await fetch(`/api/alerts/${id}`, { method: 'DELETE' }) + router.refresh() + } + if (alerts.length === 0) return

Keine Alerts.

+ return ( +
    + {alerts.map((a) => ( +
  • + {labelFor(a)} + +
  • + ))} +
+ ) +} + +function labelFor(a: Alert): string { + switch (a.type) { + case 'target_price': return `Zielpreis ≤ ${a.config.threshold}€` + case 'all_time_low': return `Allzeit-Tief` + case 'percent_drop': return `−${a.config.percent}% in ${a.config.lookback_days} Tagen` + default: return a.type + } +} diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx new file mode 100644 index 0000000..05d945a --- /dev/null +++ b/src/components/PriceChart.tsx @@ -0,0 +1,35 @@ +'use client' +import { useState } from 'react' +import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts' + +interface Snap { price: number; t: string } + +export function PriceChart({ data }: { data: Snap[] }) { + const [range, setRange] = useState<'30d' | '90d' | 'all'>('30d') + const cutoff = range === 'all' ? 0 : Date.now() - (range === '30d' ? 30 : 90) * 86400_000 + const filtered = data.filter((d) => new Date(d.t).getTime() >= cutoff) + + return ( +
+
+ {(['30d', '90d', 'all'] as const).map((r) => ( + + ))} +
+ + + + new Date(v).toLocaleDateString('de-DE')} /> + `${v}€`} domain={['dataMin', 'dataMax']} /> + new Date(v).toLocaleString('de-DE')} + formatter={(v) => [`${v}€`, 'Preis']} /> + + + +
+ ) +} diff --git a/src/components/ProductActions.tsx b/src/components/ProductActions.tsx new file mode 100644 index 0000000..7f7489e --- /dev/null +++ b/src/components/ProductActions.tsx @@ -0,0 +1,19 @@ +'use client' + +export function ProductActions({ productId }: { productId: string }) { + async function refresh() { + const res = await fetch(`/api/products/${productId}/scrape`, { method: 'POST' }) + if (res.ok) location.reload() + } + async function del() { + if (!confirm('Wirklich löschen?')) return + const res = await fetch(`/api/products/${productId}`, { method: 'DELETE' }) + if (res.ok) location.href = '/' + } + return ( +
+ + +
+ ) +}