Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem Satz Klassen entstehen: - heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme - surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen - forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe, Admin-Leiste), cream der helle Text darauf - honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird im Dunkelmodus heller und bleibt lesbar Codemod ueber src: text-/border-forest -> *-heading, bg-cream -> bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich. Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme" (nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der Systemeinstellung. Gewaehlter und automatischer Dunkelblock in globals.css werden per Test auf Gleichheit geprueft. Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst ab 1680 px. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
170 lines
7.3 KiB
TypeScript
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-heading">
|
|
{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-heading 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>
|
|
);
|
|
}
|