feat: product detail page with chart, alert list, alert form
This commit is contained in:
55
src/app/products/[id]/page.tsx
Normal file
55
src/app/products/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/components/AlertForm.tsx
Normal file
54
src/components/AlertForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/components/AlertList.tsx
Normal file
32
src/components/AlertList.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
35
src/components/PriceChart.tsx
Normal file
35
src/components/PriceChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/components/ProductActions.tsx
Normal file
19
src/components/ProductActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user