feat: product detail page with chart, alert list, alert form

This commit is contained in:
2026-05-25 14:35:59 +00:00
parent 04c014d48b
commit 99d1d85293
5 changed files with 195 additions and 0 deletions

View File

@@ -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 (
<main className="mx-auto max-w-4xl p-6">
<a href="/" className="text-sm text-zinc-500 hover:underline"> Zurück</a>
<div className="mt-2 flex items-start gap-4">
{product.imageUrl && <img src={product.imageUrl} alt="" className="h-24 w-24 rounded bg-white object-contain" />}
<div className="flex-1">
<div className="text-xs uppercase text-zinc-500">{product.shop}</div>
<h1 className="text-xl font-bold">{product.name}</h1>
<a href={product.url} target="_blank" rel="noreferrer" className="text-sm text-emerald-400 hover:underline">Zum Shop </a>
</div>
</div>
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Preisverlauf</h2>
<PriceChart data={chartData} />
</section>
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Alerts</h2>
<AlertList alerts={productAlerts.map((a) => ({ id: a.id, type: a.type, config: a.config as Record<string, unknown>, enabled: a.enabled }))} />
<div className="mt-3"><AlertForm productId={id} /></div>
</section>
<section className="mt-6">
<ProductActions productId={id} />
</section>
</main>
)
}

View File

@@ -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<string, unknown> = {}
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 (
<form onSubmit={submit} className="space-y-2 rounded border border-zinc-800 p-3">
<div className="flex gap-2">
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className="rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm">
<option value="target_price">Zielpreis</option>
<option value="all_time_low">Allzeit-Tief</option>
<option value="percent_drop">% Drop</option>
</select>
{type === 'target_price' && (
<input type="number" step="0.01" required value={threshold} onChange={(e) => 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' && (
<>
<input type="number" required value={percent} onChange={(e) => setPercent(e.target.value)}
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
<span className="self-center text-xs">% in</span>
<input type="number" required value={lookback} onChange={(e) => setLookback(e.target.value)}
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
<span className="self-center text-xs">Tagen</span>
</>
)}
<button type="submit" className="rounded bg-emerald-600 px-3 py-1 text-sm hover:bg-emerald-500">+ Alert</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,32 @@
'use client'
import { useRouter } from 'next/navigation'
interface Alert { id: string; type: string; config: Record<string, unknown>; 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 <p className="text-sm text-zinc-500">Keine Alerts.</p>
return (
<ul className="space-y-2">
{alerts.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded border border-zinc-800 px-3 py-2 text-sm">
<span>{labelFor(a)}</span>
<button onClick={() => del(a.id)} className="text-xs text-red-400 hover:underline">Löschen</button>
</li>
))}
</ul>
)
}
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
}
}

View File

@@ -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 (
<div>
<div className="mb-2 flex gap-2">
{(['30d', '90d', 'all'] as const).map((r) => (
<button key={r} onClick={() => setRange(r)}
className={`rounded px-2 py-1 text-xs ${range === r ? 'bg-emerald-600' : 'bg-zinc-800'}`}>
{r}
</button>
))}
</div>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={filtered}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis dataKey="t" tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => new Date(v).toLocaleDateString('de-DE')} />
<YAxis tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => `${v}`} domain={['dataMin', 'dataMax']} />
<Tooltip contentStyle={{ background: '#18181b', border: '1px solid #3f3f46' }}
labelFormatter={(v) => new Date(v).toLocaleString('de-DE')}
formatter={(v) => [`${v}`, 'Preis']} />
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -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 (
<div className="flex gap-2">
<button onClick={refresh} className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Jetzt aktualisieren</button>
<button onClick={del} className="rounded bg-red-900 px-3 py-1.5 text-sm hover:bg-red-800">Löschen</button>
</div>
)
}