Files
Wabenhain/src/app/charge/[code]/page.tsx
T
Nick SzaunigandClaude Opus 5.5 467984d027 feat(ui): Ernten, Chargen-Nachweis und aktive Navigation
- Ernten: Jahrgang als grosse Serifenzahl mit auslaufender Linie, Karten
  heben sich mit Honigschimmer, Pfeil und Unterstrich
- Ernte-Detail: grosser Titel, mitlaufendes Bild, Fuellstandsbalken je
  Charge (verfuegbare von abgefuellten Glaesern)
- Chargen-Seite (QR-Code auf dem Glas): Echtheitssiegel stempelt sich ein,
  Zeitleiste mit Waben und sich fuellender Linie, Werte in Serifenschrift
- Produktverweise heben sich, gezeichneter Pfeil statt Textzeichen
- Fix: aktive Seite in der Kopfzeile zeigte den Unterstrich in Textfarbe
  statt in Honig (unlayered CSS ueberstimmte die Utility-Klasse)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:04:18 +02:00

170 lines
7.3 KiB
TypeScript

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Container } from "@/components/ui";
import { ContentImage } from "@/components/content-image";
import { ScrollReveal } from "@/components/scroll-reveal";
import { TrustBadges } from "@/components/trust-badges";
import { ShieldIcon } from "@/components/account-ui";
import { ProductTeaserLink } from "@/components/product-card";
import { getBatchByCode } from "@/lib/data";
import { formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
// Siehe Begruendung in app/honig/[slug]/page.tsx.
export const dynamic = "force-dynamic";
export async function generateMetadata(
props: PageProps<"/charge/[code]">
): Promise<Metadata> {
const { code } = await props.params;
return { title: `Charge ${code}`, alternates: { canonical: `/charge/${code}` } };
}
/**
* Chargen-Seite, die ueber den QR-Code auf jedem Wabenhain-Glas erreichbar
* ist (wabenhain.com/charge/{code}). Zeigt Herkunft, Erntezeitraum und
* Abfuelldatum der konkreten Charge, aus der das Glas stammt — der Moment,
* in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft.
*/
export default async function BatchPage(props: PageProps<"/charge/[code]">) {
const locale = await getLocale();
const t = getDictionary(locale);
const { code } = await props.params;
const batch = await getBatchByCode(code);
if (!batch) notFound();
const steps = [
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate, locale) },
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate, locale) },
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate, locale) },
];
// Eigene Chargenfotos (z.B. Etikett/Abfuellung dieser konkreten Charge)
// gehen PRO Slot vor, falls gepflegt — sonst faellt genau dieser Slot auf
// das entsprechende Erntebild zurueck. Bewusst NICHT alles-oder-nichts:
// wer nur ein Chargenfoto pflegt, soll nicht das zweite (Standort-)Bild
// verlieren, nur weil die Charge kein zweites eigenes Foto hat.
// "||" statt "??": ein im Admin geleerter Slot kommt als leerer String
// an, nicht als undefined (siehe imageSlots() in
// admin/actions/harvests-batches.ts). Mit "??" wuerde dieser leere String
// als gueltiger Wert durchgehen und das Erntebild darunter verdecken.
const displayImages = [
batch.images[0] || batch.harvest.images[0] || null,
batch.images[1] || batch.harvest.images[1] || null,
];
return (
<div className="py-20">
<Container className="max-w-3xl">
<ScrollReveal>
{/* Echtheitssiegel: wer den QR-Code vom Glas scannt, landet hier —
das Siegel stempelt sich ein, bevor die Details folgen. */}
<div className="flex items-center gap-4">
<span
className="motion-stamp relative flex h-16 w-14 flex-shrink-0 items-center justify-center text-cream animate-[wh-stamp_0.7s_var(--ease-arrive)_both]"
style={{ animationDelay: "150ms" }}
>
<svg viewBox="0 0 24 26" aria-hidden="true" className="absolute inset-0 h-full w-full">
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="var(--color-honey-dark)" />
<polygon
points="12,3.2 20.5,8.1 20.5,17.9 12,22.8 3.5,17.9 3.5,8.1"
fill="none"
stroke="var(--color-honey-pale)"
strokeWidth="0.6"
strokeDasharray="1.2 1"
/>
</svg>
<span className="relative">
<ShieldIcon />
</span>
</span>
<span className="tracking-wide-caps text-xs text-honey-dark">{t.chargePage.verifiedBatch}</span>
</div>
<h1 className="mt-6 text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">
{batch.harvest.type} {batch.harvest.year}
</h1>
<p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
</ScrollReveal>
<div className="mt-10 grid sm:grid-cols-2 gap-6">
<ScrollReveal>
<ContentImage
src={displayImages[0] ?? null}
alt={`${batch.harvest.type} ${batch.harvest.year}`}
tone="honey"
className="aspect-square"
label={`${batch.harvest.type} ${batch.harvest.year}`}
sizes="(min-width: 640px) 50vw, 100vw"
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<ContentImage
src={displayImages[1] ?? null}
alt={batch.harvest.location}
tone="forest"
className="aspect-square"
label={batch.harvest.location}
sizes="(min-width: 640px) 50vw, 100vw"
/>
</ScrollReveal>
</div>
<ScrollReveal className="mt-14">
<ol className="grid grid-cols-3 gap-4">
{steps.map((step, i) => (
<li key={step.label} className="relative">
<div className="flex items-center">
<svg viewBox="0 0 24 26" aria-hidden="true" className="h-4 w-4 flex-shrink-0">
<polygon
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
fill="var(--color-honey)"
stroke="var(--color-honey-dark)"
strokeWidth="2"
/>
</svg>
{i < steps.length - 1 ? (
<span className="relative ml-1 h-[2px] flex-1 overflow-hidden rounded-full bg-ink/10" aria-hidden="true">
<span
className="motion-grow absolute inset-0 origin-left bg-honey animate-[wh-grow_0.6s_var(--ease-arrive)_both]"
style={{ animationDelay: `${500 + i * 220}ms` }}
/>
</span>
) : null}
</div>
<p className="mt-3 text-xs tracking-wide-caps text-ink/70">{step.label}</p>
<p className="mt-1 font-serif text-xl text-forest tabular-nums">{step.value}</p>
</li>
))}
</ol>
</ScrollReveal>
<ScrollReveal className="mt-10 border-t border-ink/10 pt-8">
<p className="text-sm tracking-wide-caps text-ink/70 mb-2">{t.chargePage.regionLabel}</p>
<p className="text-ink/80">{batch.harvest.location}</p>
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p>
</ScrollReveal>
<TrustBadges className="mt-10 pt-8 border-t border-ink/10" />
{batch.products.length > 0 ? (
<div className="mt-14 border-t border-ink/10 pt-10">
<ScrollReveal>
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.chargePage.fromThisBatch}</p>
</ScrollReveal>
<div className="grid sm:grid-cols-2 gap-6">
{batch.products.map((product) => (
<ProductTeaserLink
key={product.id}
slug={product.slug}
name={product.name}
image={product.images[0] ?? null}
/>
))}
</div>
</div>
) : null}
</Container>
</div>
);
}