feat: öffentliche Herkunftsseiten für Honigchargen + Kunden-QR-Code

Neuer unauthentifizierter Endpunkt GET /api/v1/public/batches/:lot
(Abschnitt 27 der Lastenheft) liefert kontrolliert freigegebene
Rückverfolgbarkeitsdaten einer Abfüllcharge – Produkt, Erntemonat,
Bienenstand-Namen, Schleuderdatum, Wassergehalt – ohne interne IDs,
Adressen oder Koordinaten preiszugeben. Die neue Seite /herkunft/{lot}
liegt außerhalb der (app)-Route-Group und damit ohne Login/Sidebar,
gedacht als Ziel eines QR-Codes auf dem Honigglas. Der bestehende
QR-Dienst kennt jetzt den Entity-Typ "bottling-batches" und verlinkt
intern auf diese neue öffentliche Seite; ein entsprechender
QR-Code-Button samt Klartext-Link steht ab sofort auf der
Abfüllungs-Detailseite bereit.
This commit is contained in:
Nick Szaunig committed 2026-08-25 01:35:47 +02:00
1 parent 711536f299
commit d56550c0e5
13 files changed
+538 -48

No files matched your search

@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Package } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchBottlingBatch } from "@/lib/bottling/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QrCodeDialog } from "@/components/qr-code-dialog";
export default function BottlingBatchDetailPage() {
const params = useParams<{ id: string }>();
@@ -27,14 +29,21 @@ export default function BottlingBatchDetailPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Package className="size-4" />
</span>
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
<CardHeader className="flex flex-row items-center justify-between gap-2.5">
<div className="flex items-center gap-2.5">
<span
className="flex size-8 items-center justify-center rounded-lg text-white shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Package className="size-4" />
</span>
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
</div>
<QrCodeDialog
entityType="bottling-batches"
id={bottlingBatch.id}
label="QR-Code für Etikett"
/>
</CardHeader>
<CardContent className="flex flex-col gap-2 text-sm">
<p>Honigcharge: {bottlingBatch.honeyBatch.batchNumber}</p>
@@ -48,6 +57,16 @@ export default function BottlingBatchDetailPage() {
Mindesthaltbar bis:{" "}
{new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")}
</p>
<p>
Öffentliche Herkunftsseite:{" "}
<Link
className="underline"
href={`/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`}
target="_blank"
>
/herkunft/{bottlingBatch.lotNumber}
</Link>
</p>
</CardContent>
</Card>
</main>
@@ -0,0 +1,116 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Hexagon, Droplet, MapPin, CalendarDays } from "lucide-react";
import { fetchPublicBatch } from "@/lib/public-batch/api";
import { HoneycombPattern } from "@/components/honeycomb-pattern";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
}
function formatMonthYear(iso: string): string {
return new Date(iso).toLocaleDateString("de-DE", { month: "long", year: "numeric" });
}
export default function PublicBatchPage() {
const params = useParams<{ lot: string }>();
const lot = decodeURIComponent(params.lot);
const { data: batch, isLoading, isError } = useQuery({
queryKey: ["public-batch", lot],
queryFn: () => fetchPublicBatch(lot),
retry: false,
});
const harvestMonths = batch
? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
: null;
const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null;
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
<HoneycombPattern />
<Card className="relative w-full max-w-md shadow-[var(--shadow-elevated)]">
<CardHeader>
<div className="mb-1 flex items-center gap-2.5">
<span
className="flex size-9 items-center justify-center rounded-xl shadow-[var(--shadow-soft)]"
style={{ background: "var(--gradient-honey)" }}
>
<Hexagon className="size-5 fill-white/25 text-white" strokeWidth={2.25} />
</span>
<div>
<CardTitle className="text-lg">Herkunftsnachweis</CardTitle>
<p className="text-xs text-muted-foreground">Wabenhain · ApiCore</p>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Keine Charge mit dieser Nummer gefunden.
</p>
) : null}
{batch ? (
<>
<div>
<h1 className="font-heading text-2xl font-semibold">
{batch.productName ?? "Honig"}
</h1>
<p className="font-mono text-sm text-muted-foreground">Charge {batch.lotNumber}</p>
</div>
{batch.productDescription ? (
<p className="text-sm text-muted-foreground">{batch.productDescription}</p>
) : null}
<dl className="flex flex-col gap-3 text-sm">
{apiaryNames ? (
<div className="flex items-start gap-2.5">
<MapPin className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Herkunft</dt>
<dd className="font-medium">{apiaryNames}</dd>
</div>
</div>
) : null}
{harvestMonths ? (
<div className="flex items-start gap-2.5">
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Geerntet</dt>
<dd className="font-medium">{harvestMonths}</dd>
</div>
</div>
) : null}
{batch.extractedDate ? (
<div className="flex items-start gap-2.5">
<CalendarDays className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Geschleudert</dt>
<dd className="font-medium">{formatDate(batch.extractedDate)}</dd>
</div>
</div>
) : null}
{batch.waterContent !== null ? (
<div className="flex items-start gap-2.5">
<Droplet className="mt-0.5 size-4 shrink-0 text-primary" />
<div>
<dt className="text-muted-foreground">Wassergehalt</dt>
<dd className="font-medium">{batch.waterContent} %</dd>
</div>
</div>
) : null}
</dl>
<p className="text-xs text-muted-foreground">
Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
</p>
</>
) : null}
</CardContent>
</Card>
</main>
);
}
@@ -0,0 +1,6 @@
import { apiFetch } from "@/lib/auth/api-client";
import { PublicBatch } from "./types";
export function fetchPublicBatch(lotNumber: string): Promise<PublicBatch> {
return apiFetch<PublicBatch>(`/public/batches/${encodeURIComponent(lotNumber)}`);
}
@@ -0,0 +1,18 @@
export interface PublicApiaryOrigin {
name: string;
location: string | null;
}
export interface PublicBatch {
lotNumber: string;
productName: string | null;
productDescription: string | null;
bottledDate: string;
bestBeforeDate: string;
jarSize: number;
honeyType: string | null;
waterContent: number | null;
extractedDate: string | null;
harvestDates: string[];
apiaries: PublicApiaryOrigin[];
}
+2 -1
View File
@@ -5,7 +5,8 @@ export type QrEntityType =
| "apiaries"
| "queens"
| "honey-batches"
| "storage-containers";
| "storage-containers"
| "bottling-batches";
export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Promise<Blob> {
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);