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:
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[];
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
Reference in new issue
Block a user