Compare commits
12
Commits
54919d82ed
...
6814e9e2c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6814e9e2c8 | ||
|
|
398db5c5ec | ||
|
|
8d988515f3 | ||
|
|
3627f4e5c1 | ||
|
|
8b5d5d095c | ||
|
|
229a1dc435 | ||
|
|
0402f7510d | ||
|
|
6143e5704d | ||
|
|
a579ebabeb | ||
|
|
de0f6ee249 | ||
|
|
ac432e261b | ||
|
|
0b2196460a |
No files matched your search
@@ -150,6 +150,8 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
|
||||
|
||||
Die Checkout Session gibt bewusst kein `payment_method_types` vor — Stripe zeigt Kundinnen und Kunden dadurch automatisch alle im Dashboard aktivierten, für Betrag/Währung/Land berechtigten Zahlungsmethoden an ("dynamische Zahlungsmethoden"). Um SEPA-Lastschrift und Klarna neben Kartenzahlung anzubieten, reicht es, sie unter [Dashboard → Zahlungsmethoden](https://dashboard.stripe.com/settings/payment_methods) zu aktivieren — dafür ist keine Codeänderung nötig.
|
||||
|
||||
**Apple Pay / Google Pay**: Diese Wallets sind bei der von Stripe gehosteten Checkout-Seite (der hier verwendete Modus, `stripe.checkout.sessions.create(...)` ohne `ui_mode: "embedded"`) laut Stripe-Dokumentation "ohne Änderungen an der Integration" automatisch enthalten, solange Kartenzahlung aktiviert ist (Standardeinstellung) — sie erscheinen als zusätzliche Option innerhalb des Kartenformulars auf unterstützten Geräten/Browsern (Safari mit hinterlegter Karte in Apple Wallet, Chrome/Android mit hinterlegter Karte in Google Pay). Die separate Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains) ist laut Stripe nur für Elements bzw. die *eingebettete* Checkout-Zahlungsseite nötig, **nicht** für die vollständig gehostete Checkout-Seite (eigene, von Stripe bereits verifizierte Domain `checkout.stripe.com`) — hier also kein weiterer Schritt nötig. Falls die Wallets bei einem Testkauf trotzdem nicht erscheinen, liegt es fast immer am Testgerät/-browser (kein Apple Pay/Google Pay eingerichtet) oder daran, dass Kartenzahlung im Dashboard deaktiviert wurde, nicht am Code.
|
||||
|
||||
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
|
||||
|
||||
- `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall).
|
||||
|
||||
@@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
/**
|
||||
@@ -43,6 +43,11 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
|
||||
continue;
|
||||
}
|
||||
// Analog zum Bild-Feld: ein Video-Feld darf nur einen Pfad enthalten,
|
||||
// den app/api/admin/upload-video/route.ts tatsaechlich erzeugt hat.
|
||||
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
|
||||
continue;
|
||||
}
|
||||
updates.push({ key: def.key, valueDe, valueEn: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +131,29 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||
}
|
||||
|
||||
await prisma.shipment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||
});
|
||||
try {
|
||||
await prisma.shipment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// Die DHL-Marke wurde bereits kostenpflichtig erstellt — geht die
|
||||
// Sendungsnummer hier verloren, ist sie ueber die DHL-API nicht erneut
|
||||
// abrufbar. Deshalb laut loggen (fuer manuelle Nacherfassung ueber das
|
||||
// Bestellungs-Detail, siehe admin/actions/orders.ts) statt sie
|
||||
// stillschweigend verschwinden zu lassen. Der Sentinel-Claim bleibt
|
||||
// bewusst unangetastet, damit kein zweiter — erneut kostenpflichtiger —
|
||||
// Versuch ueber die UI ausgeloest werden kann.
|
||||
console.error(
|
||||
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
||||
error
|
||||
);
|
||||
fail(
|
||||
detailPath,
|
||||
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
||||
);
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "order.dhl_label_created",
|
||||
|
||||
@@ -82,7 +82,15 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const deleted = await prisma.discountCode.delete({ where: { id } });
|
||||
// Doppeltes Absenden (zwei Tabs, Retry nach Timeout) darf nicht als
|
||||
// unbehandelter Prisma-P2025-Fehler (500) enden, wenn der Gutschein beim
|
||||
// ersten Versuch schon geloescht wurde.
|
||||
let deleted;
|
||||
try {
|
||||
deleted = await prisma.discountCode.delete({ where: { id } });
|
||||
} catch {
|
||||
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "discount_code.delete",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import { requireAdmin, str, strOrNull } from "./_shared";
|
||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||
|
||||
const ORDER_STATUSES = [
|
||||
"NEU",
|
||||
@@ -25,7 +25,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
const admin = await requireAdmin();
|
||||
const orderId = str(formData, "orderId");
|
||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||
if (!ORDER_STATUSES.includes(status)) return;
|
||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||
if (!ORDER_STATUSES.includes(status)) {
|
||||
fail(detailPath, "Ungültiger Status.");
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.order.findUnique({
|
||||
@@ -41,12 +44,24 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
});
|
||||
if (!previous) return null;
|
||||
|
||||
await tx.order.update({ where: { id: orderId }, data: { status } });
|
||||
|
||||
// Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht
|
||||
// (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status).
|
||||
if (status === "STORNIERT" && previous.status !== "STORNIERT") {
|
||||
await restockOrderItems(tx, previous.items);
|
||||
if (status === "STORNIERT") {
|
||||
// Atomarer Claim statt einfachem update(): verhindert, dass zwei
|
||||
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
|
||||
// Admin-Tabs auf derselben Bestellung) beide restockOrderItems
|
||||
// ausloesen und den Lagerbestand doppelt gutschreiben. Ein getrenntes
|
||||
// findUnique+update koennte das nicht verhindern, da beide Versuche
|
||||
// noch denselben "nicht schon storniert"-Stand lesen wuerden, bevor
|
||||
// einer von ihnen schreibt — die WHERE-Klausel im UPDATE selbst wird
|
||||
// dagegen atomar gegen den aktuellen Zeilenstand ausgewertet.
|
||||
const claim = await tx.order.updateMany({
|
||||
where: { id: orderId, status: { not: "STORNIERT" } },
|
||||
data: { status },
|
||||
});
|
||||
if (claim.count === 1) {
|
||||
await restockOrderItems(tx, previous.items);
|
||||
}
|
||||
} else {
|
||||
await tx.order.update({ where: { id: orderId }, data: { status } });
|
||||
}
|
||||
|
||||
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
|
||||
@@ -76,6 +91,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
return { ...previous, shipment };
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||
}
|
||||
|
||||
if (result) {
|
||||
await logAdminAction(admin, {
|
||||
action: "order.status_change",
|
||||
|
||||
@@ -94,7 +94,7 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
|
||||
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
||||
{t.adminHarvestEditPage.isPublishedLabel}
|
||||
</label>
|
||||
<p className="text-xs text-ink/50">{t.adminHarvestEditPage.isPublishedHint}</p>
|
||||
<p className="text-xs text-ink/70">{t.adminHarvestEditPage.isPublishedHint}</p>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestEditPage.save}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminImageField } from "@/components/admin-image-field";
|
||||
import { AdminVideoField } from "@/components/admin-video-field";
|
||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||
import { updateSiteContentAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -13,10 +14,11 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||
"list-titled": 5,
|
||||
"list-numbered": 6,
|
||||
"richtext-sections": 14,
|
||||
// Bild-Feld hat kein <textarea>, nutzt ROWS_BY_TYPE also nicht — Wert nur
|
||||
// vorhanden, damit dieses Record<ContentFieldDef["type"], number> weiter
|
||||
// vollstaendig ist.
|
||||
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
|
||||
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
|
||||
// weiter vollstaendig ist.
|
||||
image: 0,
|
||||
video: 0,
|
||||
};
|
||||
|
||||
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
||||
@@ -33,6 +35,7 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||
{ key: "home", heading: t.adminContentPage.groupHome },
|
||||
{ key: "pages", heading: t.adminContentPage.groupPages },
|
||||
{ key: "social", heading: t.adminContentPage.groupSocial },
|
||||
{ key: "legal", heading: t.adminContentPage.groupLegal },
|
||||
];
|
||||
@@ -76,6 +79,16 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (def.type === "video") {
|
||||
return (
|
||||
<AdminVideoField
|
||||
key={def.key}
|
||||
name={def.key}
|
||||
label={label}
|
||||
initialValue={row?.valueDe ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label key={def.key} className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
@@ -106,7 +119,7 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
<p className="text-sm text-ink/70 mb-2">{label}</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<label className="block text-xs">
|
||||
<span className="text-ink/50">{t.adminContentPage.deLabel}</span>
|
||||
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={`de::${def.key}`}
|
||||
@@ -126,7 +139,7 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
)}
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-ink/50">{t.adminContentPage.enLabel}</span>
|
||||
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={`en::${def.key}`}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
MAX_UPLOAD_VIDEO_BYTES,
|
||||
detectVideoExtension,
|
||||
generateUploadFilename,
|
||||
} from "@/lib/uploads";
|
||||
import { requireAdminForApi } from "../_shared";
|
||||
|
||||
// node:fs braucht die Node-Runtime (nicht Edge).
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
|
||||
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
|
||||
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
|
||||
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, ein
|
||||
* Video wird deshalb NICHT neu enkodiert (kein Pixel-fuer-Pixel-Rebuild wie
|
||||
* bei Bildern moeglich), sondern nur anhand seiner Magic-Bytes als MP4/WebM
|
||||
* verifiziert und unveraendert gespeichert. Das ist vertretbar, weil (1) nur
|
||||
* eingeloggte 2FA-Admins diesen Endpunkt erreichen (requireAdminForApi),
|
||||
* (2) ausgelieferte Dateien einen festen, aus der Endung abgeleiteten
|
||||
* Content-Type + "X-Content-Type-Options: nosniff" bekommen (siehe
|
||||
* app/uploads/[...path]/route.ts) und daher nie als HTML/Skript interpretiert
|
||||
* werden koennen, und (3) der zufaellige Dateiname + die feste Endungs-
|
||||
* Allowlist jede andere Nutzung des Pfads verhindern.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const auth = await requireAdminForApi();
|
||||
if ("response" in auth) return auth.response;
|
||||
const { admin } = auth;
|
||||
|
||||
// Videos sind ein seltener redaktioneller Vorgang (ein Hero-Video pro
|
||||
// Startseite) — deutlich niedrigeres Limit als der Bild-Upload reicht.
|
||||
if (!rateLimit(`admin-upload-video:${admin.id}`, 10, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Uploads. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData().catch(() => null);
|
||||
const file = formData?.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "Keine Datei übermittelt." }, { status: 400 });
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return NextResponse.json({ error: "Die Datei ist leer." }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_VIDEO_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Die Datei ist zu groß (max. ${Math.floor(MAX_UPLOAD_VIDEO_BYTES / (1024 * 1024))} MB).` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const extension = detectVideoExtension(buffer);
|
||||
if (!extension) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
const filename = generateUploadFilename(extension);
|
||||
await writeFile(path.join(UPLOAD_DIR, filename), buffer);
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "media.upload",
|
||||
entityType: "Upload",
|
||||
entityId: filename,
|
||||
detail: `${extension}, ${Math.ceil(buffer.length / 1024)} KB`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ path: `/uploads/${filename}` });
|
||||
}
|
||||
@@ -298,6 +298,14 @@ export async function POST(request: Request) {
|
||||
discounts = [{ coupon: coupon.id }];
|
||||
}
|
||||
|
||||
// Kein payment_method_types (s. README "Zahlungsmethoden"): Stripe zeigt
|
||||
// dadurch dynamisch alle im Dashboard aktivierten, berechtigten
|
||||
// Zahlungsmethoden. Apple Pay/Google Pay sind bei dieser vollstaendig
|
||||
// gehosteten Checkout-Seite (kein ui_mode: "embedded") laut Stripe-Doku
|
||||
// automatisch enthalten, sobald Kartenzahlung aktiv ist — keine
|
||||
// Codeaenderung und (anders als bei Elements/eingebetteter Checkout-Seite)
|
||||
// keine Domain-Registrierung noetig, da checkout.stripe.com bereits
|
||||
// Stripes eigene, verifizierte Domain ist.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer_email: input.email,
|
||||
@@ -329,10 +337,24 @@ export async function POST(request: Request) {
|
||||
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
// Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad
|
||||
// ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber
|
||||
// session.metadata.orderId, nicht ueber dieses Feld) — schlaegt nur
|
||||
// dieser Schreibzugriff fehl, waere es falsch, die bereits erstellte,
|
||||
// echte und bezahlbare Stripe-Session deswegen zu verwerfen und der
|
||||
// Kundschaft einen Fehler zu zeigen (Risiko: Retry legt eine zweite
|
||||
// Bestellung samt zweiter Bestandsreservierung an).
|
||||
try {
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Speichern von stripeCheckoutSessionId fuer Bestellung ${order.orderNumber} fehlgeschlagen (Session ${session.id} bleibt trotzdem gueltig):`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -83,19 +83,43 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
// doppelt zaehlen, und verhindert, dass eine im Admin-Bereich bereits
|
||||
// stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete
|
||||
// Zahlungsbestaetigung faelschlich wieder auf "BEZAHLT" gesetzt wird.
|
||||
// "BEZAHLT" ist als Ausgangsstatus bewusst mit erlaubt (nicht nur "NEU"):
|
||||
// schlaegt der Payment/Invoice/E-Mail-Block weiter unten fehl, nachdem der
|
||||
// Status hier schon auf "BEZAHLT" stand, liefert Stripe denselben Webhook
|
||||
// erneut aus — ohne diese Erweiterung wuerde der Guard diesen Retry jedes
|
||||
// Mal stillschweigend abweisen und Payment/Invoice/E-Mail blieben fuer
|
||||
// immer unvollstaendig, obwohl die Bestellung laengst bezahlt ist.
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.order.updateMany({
|
||||
// Atomarer Versuch, von "NEU" auf "BEZAHLT" zu wechseln: ein einzelnes
|
||||
// UPDATE mit "status: NEU" in der WHERE-Klausel laesst bei zwei parallel
|
||||
// laufenden Webhook-Zustellungen fuer dieselbe Bestellung garantiert nur
|
||||
// eine davon gewinnen (count === 1) — anders als ein getrenntes
|
||||
// findUnique+update, bei dem beide Transaktionen den alten Status noch
|
||||
// lesen koennten, bevor eine von ihnen schreibt, und der Gutschein-Zaehler
|
||||
// unten doppelt inkrementiert wuerde.
|
||||
const claim = await tx.order.updateMany({
|
||||
where: { id: orderId, status: "NEU" },
|
||||
data: { status: "BEZAHLT" },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
const firstTime = claim.count === 1;
|
||||
|
||||
if (!firstTime) {
|
||||
// Entweder ein Retry, der die Bestellung bereits "BEZAHLT" vorfindet
|
||||
// (siehe Kommentar oben bei fulfillOrder), oder ein anderer Endstatus
|
||||
// (z.B. "STORNIERT") — im zweiten Fall nichts weiter tun.
|
||||
const existing = await tx.order.findUnique({ where: { id: orderId }, select: { status: true } });
|
||||
if (!existing || existing.status !== "BEZAHLT") return null;
|
||||
}
|
||||
|
||||
const updated = await tx.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
if (updated.discountCodeId) {
|
||||
// Nur beim Gewinner des obigen atomaren Claims zaehlen, nie bei einem
|
||||
// Retry — sonst wuerde derselbe Gutschein bei jedem erneut zugestellten
|
||||
// Webhook nochmal gezaehlt.
|
||||
if (firstTime && updated.discountCodeId) {
|
||||
await tx.discountCode.update({
|
||||
where: { id: updated.discountCodeId },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
@@ -105,61 +129,71 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Bereits verarbeitet (Webhook-Replay) oder Bestellung war nicht mehr
|
||||
// im Status "NEU" (z.B. schon storniert) — nichts weiter zu tun.
|
||||
// Bestellung existiert nicht (mehr) oder war weder "NEU" noch "BEZAHLT"
|
||||
// (z.B. bereits storniert) — nichts weiter zu tun.
|
||||
if (!order) return;
|
||||
|
||||
await prisma.payment.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
stripePaymentIntentId:
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
||||
status: "SUCCEEDED",
|
||||
amountCents: session.amount_total ?? order.totalCents,
|
||||
paidAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "SUCCEEDED",
|
||||
paidAt: new Date(),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await prisma.payment.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
stripePaymentIntentId:
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
||||
status: "SUCCEEDED",
|
||||
amountCents: session.amount_total ?? order.totalCents,
|
||||
paidAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "SUCCEEDED",
|
||||
paidAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.invoice.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
||||
await prisma.invoice.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
||||
totalCents: order.totalCents,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const { subject, html, text } = await orderConfirmationEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
firstName: order.firstName,
|
||||
fulfillmentMethod: order.fulfillmentMethod,
|
||||
items: order.items.map((item) => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
weightGrams: item.weightGrams,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
subtotalCents: order.subtotalCents,
|
||||
shippingCents: order.shippingCents,
|
||||
discountCents: order.discountCents,
|
||||
totalCents: order.totalCents,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
// E-Mail-Fehler duerfen die Zahlungsbestaetigung selbst nicht scheitern
|
||||
// lassen — Stripe wiederholt Webhooks bei Fehlerantworten, ein
|
||||
// Mailserver-Ausfall soll das nicht auf die Bestellabwicklung durchschlagen.
|
||||
const { subject, html, text } = await orderConfirmationEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
firstName: order.firstName,
|
||||
fulfillmentMethod: order.fulfillmentMethod,
|
||||
items: order.items.map((item) => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
weightGrams: item.weightGrams,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
subtotalCents: order.subtotalCents,
|
||||
shippingCents: order.shippingCents,
|
||||
discountCents: order.discountCents,
|
||||
totalCents: order.totalCents,
|
||||
street: order.street,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
giftMessage: order.giftMessage,
|
||||
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
street: order.street,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
giftMessage: order.giftMessage,
|
||||
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
} catch (error: unknown) {
|
||||
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
||||
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
||||
// an Stripe loest den naechsten automatischen Retry aus, der dank des
|
||||
// "BEZAHLT"-Zweigs oben Payment/Invoice/E-Mail dann nachholen kann,
|
||||
// statt sie fuer immer zu verlieren.
|
||||
console.error(
|
||||
`fulfillOrder: Payment/Invoice/Bestaetigungsmail fehlgeschlagen fuer Order ${order.id} (Stripe-Session ${session.id})`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
@@ -167,10 +201,21 @@ async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
if (!orderId) return;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.order.findUnique({
|
||||
// Atomarer Claim (gleiches Muster wie oben bei fulfillOrder): ein
|
||||
// getrenntes findUnique+update wuerde bei zwei parallel zugestellten
|
||||
// "expired"/"async_payment_failed"-Webhooks fuer dieselbe Bestellung
|
||||
// beide Kopien noch "NEU" lesen lassen, bevor eine von ihnen schreibt —
|
||||
// restockOrderItems liefe dann zweimal und der Lagerbestand wuerde
|
||||
// stillschweigend doppelt gutgeschrieben.
|
||||
const claim = await tx.order.updateMany({
|
||||
where: { id: orderId, status: "NEU" },
|
||||
data: { status: "STORNIERT" },
|
||||
});
|
||||
if (claim.count === 0) return null;
|
||||
|
||||
const previous = await tx.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
status: true,
|
||||
orderNumber: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
@@ -178,12 +223,6 @@ async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
items: { select: { productId: true, quantity: true } },
|
||||
},
|
||||
});
|
||||
// Nur von "NEU" aus stornieren: verhindert doppelte Bestandsrueckbuchung,
|
||||
// falls Stripe denselben Webhook erneut zustellt, und laesst bereits
|
||||
// bezahlte/stornierte Bestellungen unangetastet.
|
||||
if (!previous || previous.status !== "NEU") return null;
|
||||
|
||||
await tx.order.update({ where: { id: orderId }, data: { status: "STORNIERT" } });
|
||||
|
||||
// Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie
|
||||
// bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProductsByIds } from "@/lib/data";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const MAX_IDS_PER_REQUEST = 50;
|
||||
const STOCK_ATTEMPTS_PER_WINDOW = 60;
|
||||
const STOCK_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Aktueller Lagerbestand fuer eine Liste von Produkt-IDs — der Warenkorb
|
||||
* (Zustand/localStorage) kennt nur den Stand beim letzten "Hinzufuegen",
|
||||
* nicht den aktuellen. Cart-Drawer und Warenkorb-Seite fragen das hierueber
|
||||
* live ab, damit "Ausverkauft"/"Nur noch N"-Hinweise auch dort erscheinen,
|
||||
* nicht nur auf der Produktkarte/-detailseite (siehe product-card.tsx).
|
||||
* Rein oeffentliche, bereits im Shop sichtbare Bestandsdaten — keine
|
||||
* Authentifizierung noetig, aber wie jeder andere oeffentliche Endpunkt
|
||||
* rate-limitiert.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`cart-stock:${ip}`, STOCK_ATTEMPTS_PER_WINDOW, STOCK_WINDOW_MS)) {
|
||||
return NextResponse.json({ stock: {} }, { status: 429 });
|
||||
}
|
||||
|
||||
const idsParam = new URL(request.url).searchParams.get("ids") ?? "";
|
||||
const ids = idsParam
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_IDS_PER_REQUEST);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ stock: {} });
|
||||
}
|
||||
|
||||
const products = await getProductsByIds(ids);
|
||||
const stock: Record<string, number> = {};
|
||||
for (const product of products) {
|
||||
stock[product.id] = product.isActive ? (product.inventory?.quantity ?? 0) : 0;
|
||||
}
|
||||
|
||||
return NextResponse.json({ stock });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getActiveProducts } from "@/lib/data";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
const SUGGESTION_LIMIT = 5;
|
||||
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
|
||||
@@ -26,11 +27,12 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const locale = await getLocale();
|
||||
const products = await getActiveProducts({ query, onlyAvailable: true });
|
||||
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
price: formatPrice(product.priceCents, product.currency),
|
||||
price: formatPrice(product.priceCents, product.currency, locale),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ suggestions });
|
||||
|
||||
@@ -16,7 +16,8 @@ const TRACK_ATTEMPTS_PER_WINDOW = 10;
|
||||
const TRACK_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { order: orderNumber, email } = await props.searchParams;
|
||||
const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email;
|
||||
|
||||
@@ -80,7 +81,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt, locale)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<OrderStatusBadge
|
||||
@@ -90,12 +91,12 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
<p className="mt-1.5 text-xs text-ink/65">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? order.shipment?.shippedAt
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
|
||||
: t.ordersPage.shippingPending
|
||||
: order.pickup?.pickedUpAt
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
|
||||
: order.pickup?.readyAt
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
|
||||
: t.ordersPage.pickupPending}
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,7 +105,10 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
{item.quantity} ×{" "}
|
||||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||||
{item.productName}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -147,7 +151,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
{order.invoice ? (
|
||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||
{t.trackOrderPage.viewInvoice}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -27,47 +28,56 @@ export async function generateMetadata(
|
||||
* in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft.
|
||||
*/
|
||||
export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
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) },
|
||||
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate) },
|
||||
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate) },
|
||||
{ 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) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<div className="flex items-center gap-2 text-honey-dark">
|
||||
<ShieldIcon />
|
||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||
<ScrollReveal>
|
||||
<div className="flex items-center gap-2 text-honey-dark">
|
||||
<ShieldIcon />
|
||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-4xl md:text-5xl 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">
|
||||
<ContentImage
|
||||
src={batch.harvest.images[0] ?? null}
|
||||
alt={`${batch.harvest.type} ${batch.harvest.year}`}
|
||||
tone="honey"
|
||||
className="aspect-square"
|
||||
label={`${batch.harvest.type} ${batch.harvest.year}`}
|
||||
/>
|
||||
<ContentImage
|
||||
src={batch.harvest.images[1] ?? null}
|
||||
alt={batch.harvest.location}
|
||||
tone="forest"
|
||||
className="aspect-square"
|
||||
label={batch.harvest.location}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<ContentImage
|
||||
src={batch.harvest.images[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={batch.harvest.images[1] ?? null}
|
||||
alt={batch.harvest.location}
|
||||
tone="forest"
|
||||
className="aspect-square"
|
||||
label={batch.harvest.location}
|
||||
sizes="(min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="mt-14">
|
||||
<ScrollReveal className="mt-14">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="relative">
|
||||
@@ -77,24 +87,26 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
<span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs tracking-wide-caps text-ink/60">{step.label}</p>
|
||||
<p className="mt-3 text-xs tracking-wide-caps text-ink/70">{step.label}</p>
|
||||
<p className="mt-1 text-forest">{step.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-10 border-t border-ink/10 pt-8">
|
||||
<p className="text-sm tracking-wide-caps text-ink/60 mb-2">{t.chargePage.regionLabel}</p>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p>
|
||||
<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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||
import CheckoutLoading from "./loading";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
@@ -31,8 +32,9 @@ export function CheckoutForm({
|
||||
tiers: ShippingTierLike[];
|
||||
bulkDiscountTiers: BulkDiscountTierLike[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const items = useCartStore((s) => s.items);
|
||||
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||
const subtotal = cartTotalCents(items);
|
||||
const totalQuantity = cartItemCount(items);
|
||||
const router = useRouter();
|
||||
@@ -41,6 +43,17 @@ export function CheckoutForm({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
const errorRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// Fokus aktiv auf die Fehlermeldung verschieben statt sie nur farblich
|
||||
// einzublenden — sonst bleibt der Fokus auf dem "Bestellen"-Button stehen
|
||||
// und Screenreader-Nutzer:innen erfahren beim fehlgeschlagenen, geldwerten
|
||||
// Checkout-Absenden nichts vom Fehler (role="alert" allein wird von
|
||||
// manchen Screenreadern bei neu eingefuegten Elementen unzuverlaessig
|
||||
// vorgelesen, Fokusverschiebung ist der robustere Mechanismus).
|
||||
useEffect(() => {
|
||||
if (error) errorRef.current?.focus();
|
||||
}, [error]);
|
||||
|
||||
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||
@@ -98,6 +111,15 @@ export function CheckoutForm({
|
||||
}
|
||||
}
|
||||
|
||||
// Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage)
|
||||
// und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche
|
||||
// wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung
|
||||
// sehen, bevor auf das echte Formular umgeschaltet wird — ein grosser,
|
||||
// messbarer Layout-Sprung (siehe CLS) und verwirrend fuer echte Kaeufer:innen.
|
||||
if (!hasHydrated) {
|
||||
return <CheckoutLoading />;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="py-20">
|
||||
@@ -115,15 +137,37 @@ export function CheckoutForm({
|
||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.checkoutPage.firstName} defaultValue={initialValues?.firstName} required />
|
||||
<Field name="lastName" label={t.checkoutPage.lastName} defaultValue={initialValues?.lastName} required />
|
||||
<Field
|
||||
name="firstName"
|
||||
label={t.checkoutPage.firstName}
|
||||
defaultValue={initialValues?.firstName}
|
||||
autoComplete="given-name"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="lastName"
|
||||
label={t.checkoutPage.lastName}
|
||||
defaultValue={initialValues?.lastName}
|
||||
autoComplete="family-name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field name="email" label={t.checkoutPage.email} type="email" defaultValue={initialValues?.email} required />
|
||||
<Field
|
||||
name="email"
|
||||
label={t.checkoutPage.email}
|
||||
type="email"
|
||||
defaultValue={initialValues?.email}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="phone"
|
||||
label={t.checkoutPage.phoneOptional}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
defaultValue={initialValues?.phone}
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +190,7 @@ export function CheckoutForm({
|
||||
))}
|
||||
</div>
|
||||
{fulfillment === "VERSAND" ? (
|
||||
<p className="mt-2 text-xs text-ink/60">
|
||||
<p className="mt-2 text-xs text-ink/70">
|
||||
{t.checkoutPage.deliveryTimeNote}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -158,16 +202,35 @@ export function CheckoutForm({
|
||||
name="street"
|
||||
label={t.checkoutPage.streetLabel}
|
||||
defaultValue={initialValues?.street}
|
||||
autoComplete="street-address"
|
||||
required
|
||||
/>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field name="zip" label={t.checkoutPage.zipLabel} defaultValue={initialValues?.zip} required />
|
||||
<Field name="city" label={t.checkoutPage.cityLabel} defaultValue={initialValues?.city} required />
|
||||
<Field
|
||||
name="zip"
|
||||
label={t.checkoutPage.zipLabel}
|
||||
defaultValue={initialValues?.zip}
|
||||
autoComplete="postal-code"
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="city"
|
||||
label={t.checkoutPage.cityLabel}
|
||||
defaultValue={initialValues?.city}
|
||||
autoComplete="address-level2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field name="discountCode" label={t.checkoutPage.discountCodeLabel} placeholder={t.checkoutPage.discountCodePlaceholder} />
|
||||
<Field
|
||||
name="discountCode"
|
||||
label={t.checkoutPage.discountCodeLabel}
|
||||
placeholder={t.checkoutPage.discountCodePlaceholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
||||
@@ -193,7 +256,11 @@ export function CheckoutForm({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-700">{error}</p> : null}
|
||||
{error ? (
|
||||
<p ref={errorRef} role="alert" tabIndex={-1} className="text-sm text-red-700 outline-none">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||
@@ -211,14 +278,14 @@ export function CheckoutForm({
|
||||
<span>
|
||||
{item.quantity} × {item.name}
|
||||
</span>
|
||||
<span>{formatPrice(item.priceCents * item.quantity)}</span>
|
||||
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
|
||||
<div className="flex justify-between text-ink/70">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
<span>{formatPrice(subtotal, "EUR", locale)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-ink/70">
|
||||
<span>
|
||||
@@ -226,20 +293,20 @@ export function CheckoutForm({
|
||||
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||||
: t.checkoutPage.shippingPickup}
|
||||
</span>
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||
</div>
|
||||
{activeBulkDiscount ? (
|
||||
<div className="flex justify-between text-forest">
|
||||
<span>
|
||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||
</span>
|
||||
<span>-{formatPrice(bulkDiscountCents)}</span>
|
||||
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
||||
<span>{t.cartPage.total}</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice(total, "EUR", locale)}</span>
|
||||
</div>
|
||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||
</aside>
|
||||
@@ -252,15 +319,21 @@ function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
inputMode,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
/** Zeigt auf Mobilgeraeten die passende Bildschirmtastatur (z.B. Ziffernblock fuer die PLZ). */
|
||||
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
/** Browser-/Mobil-Autofill fuer Adress- und Kontaktfelder (siehe MDN autofill-Tokens) — ohne das muss jede Bestellung komplett von Hand eingetippt werden. */
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -269,8 +342,10 @@ function Field({
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
inputMode={inputMode}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ProductTeaserLink } from "@/components/product-card";
|
||||
import { getHarvestByYearType } from "@/lib/data";
|
||||
@@ -30,7 +31,8 @@ export async function generateMetadata(
|
||||
export default async function HarvestDetailPage(
|
||||
props: PageProps<"/ernten/[year]/[type]">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { year, type } = await props.params;
|
||||
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
|
||||
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
|
||||
@@ -44,16 +46,19 @@ export default async function HarvestDetailPage(
|
||||
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||
</Container>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={harvest.location}
|
||||
/>
|
||||
<div>
|
||||
<ScrollReveal>
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={harvest.location}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
{harvest.year} · {formatDate(harvest.harvestDate)}
|
||||
{harvest.year} · {formatDate(harvest.harvestDate, locale)}
|
||||
</p>
|
||||
<h1 className="text-4xl text-forest">{harvest.type}</h1>
|
||||
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||
@@ -85,7 +90,7 @@ export default async function HarvestDetailPage(
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
+27
-19
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getHarvests } from "@/lib/data";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -17,7 +18,8 @@ export const metadata: Metadata = {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ErntenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const harvests = await getHarvests();
|
||||
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
|
||||
acc[h.year] = [...(acc[h.year] ?? []), h];
|
||||
@@ -28,29 +30,35 @@ export default async function ErntenPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container>
|
||||
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
|
||||
<ScrollReveal>
|
||||
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{years.map((year) => (
|
||||
<div key={year}>
|
||||
<h2 className="text-2xl text-forest mb-6">{year}</h2>
|
||||
<ScrollReveal>
|
||||
<h2 className="text-2xl text-forest mb-6">{year}</h2>
|
||||
</ScrollReveal>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{byYear[year].map((harvest) => (
|
||||
<Link
|
||||
key={harvest.id}
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group"
|
||||
>
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
label={formatMonthYear(harvest.harvestDate)}
|
||||
/>
|
||||
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
|
||||
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
|
||||
</Link>
|
||||
{byYear[year].map((harvest, i) => (
|
||||
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
||||
<Link
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group block"
|
||||
>
|
||||
<ContentImage
|
||||
src={harvest.images[0] ?? null}
|
||||
alt={harvest.type}
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
label={formatMonthYear(harvest.harvestDate, locale)}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
|
||||
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
-12
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
@@ -14,27 +16,34 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function HerkunftPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const image = await getContentValue("herkunft.image", locale);
|
||||
return (
|
||||
<div className="py-20">
|
||||
<PlaceholderImage
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.herkunftPage.eyebrow}
|
||||
tone="forest"
|
||||
className="h-[50vh] min-h-[360px] w-full mb-20"
|
||||
label={t.herkunftPage.eyebrow}
|
||||
priority
|
||||
/>
|
||||
<Container>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
||||
{t.herkunftPage.aspects.map((a) => (
|
||||
<div key={a.title}>
|
||||
{t.herkunftPage.aspects.map((a, i) => (
|
||||
<ScrollReveal key={a.title} delay={i * 0.08}>
|
||||
<h3 className="text-lg text-forest">{a.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-16 text-xs text-ink/65 max-w-md">
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Fragment } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container, Button } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ProductGallery } from "@/components/product-gallery";
|
||||
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
|
||||
import { RecordProductView } from "@/components/record-product-view";
|
||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar";
|
||||
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
||||
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
@@ -16,9 +18,12 @@ import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
||||
import { isProductWishlisted } from "@/lib/wishlist";
|
||||
import { getShippingTiers } from "@/lib/shipping";
|
||||
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import type { WissenSlug } from "@/i18n/dictionary-type";
|
||||
|
||||
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
|
||||
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
|
||||
@@ -42,27 +47,45 @@ export async function generateMetadata(
|
||||
export default async function ProductDetailPage(
|
||||
props: PageProps<"/honig/[slug]">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { slug } = await props.params;
|
||||
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
||||
const product = await getProductBySlug(slug);
|
||||
if (!product) notFound();
|
||||
|
||||
const isHoney = product.category === "HONIG" && product.harvest && product.batch;
|
||||
// Verlinkt die Ernteart auf den passenden Honigwissen-Artikel, sofern der
|
||||
// Ernte-Typ (freier Admin-Text, siehe Harvest.type) eindeutig als Frueh-
|
||||
// oder Sommertracht erkennbar ist — sonst einfach kein zusaetzlicher Link,
|
||||
// statt eine falsche Zuordnung zu raten.
|
||||
const harvestTypeLower = product.harvest?.type.toLowerCase() ?? "";
|
||||
const harvestWissenSlug: WissenSlug | null = harvestTypeLower.includes("früh")
|
||||
? "was-bedeutet-fruehtracht"
|
||||
: harvestTypeLower.includes("sommer")
|
||||
? "was-bedeutet-sommertracht"
|
||||
: null;
|
||||
const stock = product.inventory?.quantity ?? 0;
|
||||
const inStock = stock > 0;
|
||||
const lowStock = inStock && stock <= 5;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company] = await Promise.all([
|
||||
getApprovedReviews(product.id),
|
||||
getReviewSummary(product.id),
|
||||
getRelatedProducts(product.id, product.category),
|
||||
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
|
||||
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
||||
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
||||
getCompanyInfo(),
|
||||
]);
|
||||
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
|
||||
await Promise.all([
|
||||
getApprovedReviews(product.id),
|
||||
getReviewSummary(product.id),
|
||||
getRelatedProducts(product.id, product.category),
|
||||
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
|
||||
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
||||
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
||||
getCompanyInfo(),
|
||||
getShippingTiers(),
|
||||
]);
|
||||
// "Ab"-Preis fuer eine einzelne Einheit — die Staffel, die beim Kauf genau
|
||||
// dieses einen Produkts greifen wuerde. Macht Versandkosten schon auf der
|
||||
// Produktseite sichtbar statt erst im Warenkorb/Checkout (Kaufentscheidung
|
||||
// haengt bei einem kleinen Versandhandel oft direkt daran).
|
||||
const baseShippingCents = resolveShippingCostCents(shippingTiers, 1);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -72,6 +95,14 @@ export default async function ProductDetailPage(
|
||||
sku: product.batch?.code ?? product.slug,
|
||||
brand: { "@type": "Brand", name: company.name },
|
||||
category: t.common.categories[product.category],
|
||||
// Nur bei echter Produktfotografie gesetzt (siehe ProductGallery) — ohne
|
||||
// hochgeladene Bilder faellt die Galerie auf reine CSS-Platzhalterkacheln
|
||||
// zurueck, die als "image" im strukturierten Datensatz falsche Tatsachen
|
||||
// vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute
|
||||
// URL zu einem echten Produktfoto, keine Platzhaltergrafik).
|
||||
...(product.images.length > 0
|
||||
? { image: product.images.map((src) => `https://${siteConfig.domain}${src}`) }
|
||||
: {}),
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
priceCurrency: product.currency,
|
||||
@@ -110,8 +141,9 @@ export default async function ProductDetailPage(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="py-16 pb-28 md:pb-16">
|
||||
<RecordProductView productId={product.id} />
|
||||
<StickyAddToCartBar product={product} inStock={inStock} />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
||||
@@ -123,7 +155,11 @@ export default async function ProductDetailPage(
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<ProductGallery images={product.images} productName={product.name} />
|
||||
<ProductGallery
|
||||
images={product.images}
|
||||
productName={product.name}
|
||||
description={product.tasteTags.join(", ")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
@@ -144,13 +180,22 @@ export default async function ProductDetailPage(
|
||||
|
||||
<div className="mt-6 flex items-baseline gap-3">
|
||||
<span className="text-2xl text-forest">
|
||||
{formatPrice(product.priceCents, product.currency)}
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink/65">
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||
</p>
|
||||
<Link
|
||||
href="/versand-zahlung"
|
||||
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
|
||||
>
|
||||
{t.productPage.shippingHint(
|
||||
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
|
||||
)}
|
||||
</Link>
|
||||
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
|
||||
|
||||
{isHoney ? (
|
||||
<div className="mt-8 space-y-3">
|
||||
@@ -241,7 +286,10 @@ export default async function ProductDetailPage(
|
||||
label={t.productPage.detailLabels.origin}
|
||||
value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`}
|
||||
/>
|
||||
<DetailRow label={t.productPage.detailLabels.harvest} value={formatDate(product.harvest.harvestDate)} />
|
||||
<DetailRow
|
||||
label={t.productPage.detailLabels.harvest}
|
||||
value={formatDate(product.harvest.harvestDate, locale)}
|
||||
/>
|
||||
<DetailRow label={t.productPage.detailLabels.batch} value={product.batch.code} />
|
||||
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
|
||||
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
|
||||
@@ -251,10 +299,24 @@ export default async function ProductDetailPage(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHoney ? (
|
||||
<div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<span className="text-ink/65">{t.productPage.learnMoreLabel}</span>
|
||||
<Link href="/wissen/warum-kristallisiert-honig" className="text-forest underline">
|
||||
{t.wissenArticles["warum-kristallisiert-honig"].title}
|
||||
</Link>
|
||||
{harvestWissenSlug ? (
|
||||
<Link href={`/wissen/${harvestWissenSlug}`} className="text-forest underline">
|
||||
{t.wissenArticles[harvestWissenSlug].title}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isHoney && product.batch ? (
|
||||
<Link
|
||||
href={`/charge/${product.batch.code}`}
|
||||
className="mt-6 inline-block text-sm text-forest underline"
|
||||
className="mt-3 inline-block text-sm text-forest underline"
|
||||
>
|
||||
{t.productPage.viewBatchOrigin}
|
||||
</Link>
|
||||
@@ -263,7 +325,9 @@ export default async function ProductDetailPage(
|
||||
</Container>
|
||||
|
||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
<ScrollReveal>
|
||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
@@ -282,7 +346,7 @@ export default async function ProductDetailPage(
|
||||
<span className="sr-only">{t.productPage.starsOutOf5(review.rating)}</span>
|
||||
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
|
||||
<p className="mt-2 text-xs text-ink/65">
|
||||
{review.customer.firstName} · {formatDate(review.createdAt)}
|
||||
{review.customer.firstName} · {formatDate(review.createdAt, locale)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
@@ -343,7 +407,9 @@ export default async function ProductDetailPage(
|
||||
|
||||
{relatedProducts.length > 0 ? (
|
||||
<Container className="mt-20">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
||||
<ScrollReveal>
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
||||
</ScrollReveal>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{relatedProducts.map((related) => (
|
||||
<ProductCard key={related.id} product={related} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
@@ -14,11 +16,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function ImkereiPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const image = await getContentValue("imkerei.image", locale);
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
<div>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.imkereiPage.eyebrow}
|
||||
@@ -29,8 +33,17 @@ export default async function ImkereiPage() {
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PlaceholderImage tone="honey" className="aspect-[4/5] w-full" label={t.imkereiPage.imageLabel} />
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.imkereiPage.imageLabel}
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.imkereiPage.imageLabel}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -19,9 +20,11 @@ export default async function KontaktPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
|
||||
<ScrollReveal>
|
||||
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-10 space-y-2 text-ink/75">
|
||||
<ScrollReveal delay={0.1} className="mt-10 space-y-2 text-ink/75">
|
||||
<p>{company.name}</p>
|
||||
<p>{company.address.street}</p>
|
||||
<p>
|
||||
@@ -33,14 +36,14 @@ export default async function KontaktPage() {
|
||||
</a>
|
||||
</p>
|
||||
<p>{company.phone}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 border-t border-ink/10 pt-8">
|
||||
<ScrollReveal delay={0.18} className="mt-12 border-t border-ink/10 pt-8">
|
||||
<h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{t.kontaktPage.pickupText}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,13 +123,19 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
|
||||
if (!authenticated && code.length > 0) {
|
||||
const codeHash = hashBackupCode(code.toUpperCase());
|
||||
const backupCode = await prisma.twoFactorBackupCode.findFirst({
|
||||
// Atomarer Claim statt findFirst+update: ein getrenntes Lesen und
|
||||
// Schreiben liesse zwei parallele Anfragen mit demselben (z.B.
|
||||
// abgefangenen) Backup-Code beide noch den "unbenutzt"-Stand sehen,
|
||||
// bevor eine von ihnen schreibt — beide wuerden dann authentifiziert
|
||||
// und die Einmal-Verwendbarkeit des Codes waere ausgehebelt. Die
|
||||
// WHERE-Klausel im UPDATE selbst wird dagegen atomar gegen den
|
||||
// aktuellen Zeilenstand ausgewertet, sodass hoechstens ein Aufruf
|
||||
// "gewinnt" (count === 1).
|
||||
const claim = await prisma.twoFactorBackupCode.updateMany({
|
||||
where: { customerId: customer.id, codeHash, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (backupCode) {
|
||||
await prisma.twoFactorBackupCode.update({ where: { id: backupCode.id }, data: { usedAt: new Date() } });
|
||||
authenticated = true;
|
||||
}
|
||||
authenticated = claim.count === 1;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
@@ -84,15 +85,27 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
|
||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
||||
<Field name="label" label={t.addressesPage.labelField} defaultValue={t.addressesPage.labelDefaultValue} required />
|
||||
<Field
|
||||
name="label"
|
||||
label={t.addressesPage.labelField}
|
||||
defaultValue={t.addressesPage.labelDefaultValue}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required />
|
||||
<Field name="firstName" label={t.account.firstNameLabel} autoComplete="given-name" required />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} autoComplete="family-name" required />
|
||||
</div>
|
||||
<Field name="street" label={t.checkoutPage.streetLabel} required />
|
||||
<Field name="street" label={t.checkoutPage.streetLabel} autoComplete="street-address" required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="zip" label={t.checkoutPage.zipLabel} required />
|
||||
<Field name="city" label={t.checkoutPage.cityLabel} required />
|
||||
<Field
|
||||
name="zip"
|
||||
label={t.checkoutPage.zipLabel}
|
||||
autoComplete="postal-code"
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
<Field name="city" label={t.checkoutPage.cityLabel} autoComplete="address-level2" required />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isDefault" />
|
||||
@@ -110,11 +123,16 @@ function Field({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
autoComplete,
|
||||
inputMode,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
/** Browser-/Mobil-Autofill fuer Adressfelder — dieselben Tokens wie im Checkout-Formular. */
|
||||
autoComplete?: string;
|
||||
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -123,6 +141,8 @@ function Field({
|
||||
<input
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,8 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
];
|
||||
|
||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||
const { error, message } = await props.searchParams;
|
||||
@@ -65,19 +66,19 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt, locale)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
<p className="mt-1.5 text-xs text-ink/65">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? order.shipment?.shippedAt
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
|
||||
: t.ordersPage.shippingPending
|
||||
: order.pickup?.pickedUpAt
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
|
||||
: order.pickup?.readyAt
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
|
||||
: t.ordersPage.pickupPending}
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,7 +87,10 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
{item.quantity} ×{" "}
|
||||
<Link href={`/honig/${item.product.slug}#bewertungen`} className="hover:text-forest hover:underline">
|
||||
{item.productName}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -135,7 +139,7 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.invoice ? (
|
||||
<Link
|
||||
|
||||
@@ -81,7 +81,7 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center gap-4 text-xs text-ink/50">
|
||||
<div className="mt-10 flex items-center gap-4 text-xs text-ink/70">
|
||||
<span className="h-px flex-1 bg-ink/10" />
|
||||
{t.account.ssoDivider}
|
||||
<span className="h-px flex-1 bg-ink/10" />
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function NewsletterBestaetigtPage(props: PageProps<"/newsle
|
||||
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}{" "}
|
||||
<span className="font-medium">{NEWSLETTER_DISCOUNT_CODE}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink/50">{t.newsletterBestaetigtPage.discountCopyHint}</p>
|
||||
<p className="mt-2 text-xs text-ink/70">{t.newsletterBestaetigtPage.discountCopyHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
+5
-1
@@ -29,7 +29,10 @@ export const dynamic = "force-dynamic";
|
||||
export default async function HomePage() {
|
||||
const locale = await getLocale();
|
||||
const [heroContent, company] = await Promise.all([
|
||||
getContentValues(["home.hero.intro", "home.hero.footnote", "home.hero.image"], locale),
|
||||
getContentValues(
|
||||
["home.hero.intro", "home.hero.footnote", "home.hero.image", "home.hero.video"],
|
||||
locale
|
||||
),
|
||||
getCompanyInfo(),
|
||||
]);
|
||||
const products = await getActiveProducts();
|
||||
@@ -49,6 +52,7 @@ export default async function HomePage() {
|
||||
footnote={heroContent["home.hero.footnote"]}
|
||||
location={company.location}
|
||||
image={heroContent["home.hero.image"] || null}
|
||||
video={heroContent["home.hero.video"] || null}
|
||||
/>
|
||||
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
||||
<OriginStory />
|
||||
|
||||
+33
-10
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||
import { getActiveProducts, type ProductSort } from "@/lib/data";
|
||||
@@ -82,12 +83,14 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<Container>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.shop.eyebrow}
|
||||
title={t.shop.title}
|
||||
subtitle={t.shop.subtitle}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.shop.eyebrow}
|
||||
title={t.shop.title}
|
||||
subtitle={t.shop.subtitle}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{categoryFilters.map((filter) => {
|
||||
@@ -116,11 +119,25 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
||||
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen. */}
|
||||
<form action="/shop" method="GET" className="flex items-center gap-2 text-sm text-ink/70">
|
||||
{query ? <input type="hidden" name="q" value={query} /> : null}
|
||||
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
|
||||
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
|
||||
Kopfzeilen-Suche): so werden Kategorie/Verfuegbarkeit-Filter beim
|
||||
Aendern der Suche innerhalb der Shop-Seite nicht verworfen — anders
|
||||
als bei der Kopfzeilen-Suche, die immer neu auf /shop startet. */}
|
||||
<form action="/shop" method="GET" className="flex flex-wrap items-center gap-2 text-sm text-ink/70">
|
||||
{category ? <input type="hidden" name="kategorie" value={category} /> : null}
|
||||
{onlyAvailable ? <input type="hidden" name="verfuegbar" value="1" /> : null}
|
||||
<label htmlFor="shop-search" className="sr-only">
|
||||
{t.shop.searchInputLabel}
|
||||
</label>
|
||||
<input
|
||||
id="shop-search"
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={query ?? ""}
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
className="border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
||||
<select
|
||||
id="sortierung"
|
||||
@@ -157,7 +174,13 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
|
||||
{query ? (
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
{t.shop.searchResults(query, products.length)}
|
||||
{t.shop.searchResults(query, products.length)}{" "}
|
||||
<Link
|
||||
href={buildHref({ kategorie: category, sortierung: sort, verfuegbar: onlyAvailable ? "1" : undefined })}
|
||||
className="underline hover:text-forest"
|
||||
>
|
||||
{t.shop.clearSearch}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { BusinessInquiryForm } from "@/components/business-inquiry-form";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getContentValue } from "@/lib/content";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
@@ -15,31 +17,42 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function UnternehmenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const image = await getContentValue("unternehmen.image", locale);
|
||||
return (
|
||||
<div>
|
||||
<PlaceholderImage tone="honey" className="h-[45vh] min-h-[320px] w-full" label={t.unternehmenPage.eyebrow} />
|
||||
<ContentImage
|
||||
src={image || null}
|
||||
alt={t.unternehmenPage.eyebrow}
|
||||
tone="honey"
|
||||
className="h-[45vh] min-h-[320px] w-full"
|
||||
label={t.unternehmenPage.eyebrow}
|
||||
priority
|
||||
/>
|
||||
<Container className="py-20">
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.unternehmenPage.eyebrow}
|
||||
title={t.unternehmenPage.title}
|
||||
subtitle={t.unternehmenPage.subtitle}
|
||||
/>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.unternehmenPage.eyebrow}
|
||||
title={t.unternehmenPage.title}
|
||||
subtitle={t.unternehmenPage.subtitle}
|
||||
/>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-14 grid sm:grid-cols-3 gap-8">
|
||||
{t.unternehmenPage.packages.map((pkg) => (
|
||||
<div key={pkg.key} className="border border-ink/10 p-8">
|
||||
{t.unternehmenPage.packages.map((pkg, i) => (
|
||||
<ScrollReveal key={pkg.key} delay={i * 0.08} className="border border-ink/10 p-8">
|
||||
<h3 className="text-lg text-forest">{pkg.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-20 max-w-2xl">
|
||||
<ScrollReveal className="mt-20 max-w-2xl">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.unternehmenPage.inquiryHeading}</h2>
|
||||
<BusinessInquiryForm />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,15 +12,20 @@ export const metadata: Metadata = { title: "Versand und Zahlung", alternates: {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VersandZahlungPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const tiers = await getShippingTiers();
|
||||
|
||||
const tierParagraphs =
|
||||
tiers.length > 0
|
||||
? tiers.map((tier) =>
|
||||
tier.maxQuantity === null
|
||||
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents))
|
||||
: t.versandZahlungPage.tierRange(tier.minQuantity, tier.maxQuantity, formatPrice(tier.priceCents))
|
||||
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents, "EUR", locale))
|
||||
: t.versandZahlungPage.tierRange(
|
||||
tier.minQuantity,
|
||||
tier.maxQuantity,
|
||||
formatPrice(tier.priceCents, "EUR", locale)
|
||||
)
|
||||
)
|
||||
: [t.versandZahlungPage.tierFallback];
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { WarenkorbContent } from "./warenkorb-content";
|
||||
import { useCartStore, type CartItem } from "@/store/cart-store";
|
||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||
@@ -35,8 +35,16 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
|
||||
beforeEach(() => {
|
||||
useCartStore.setState({ items: [], isOpen: false });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubStockFetch(stock: Record<string, number>) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ json: () => Promise.resolve({ stock }) })
|
||||
);
|
||||
}
|
||||
|
||||
describe("WarenkorbContent", () => {
|
||||
test("shows the empty-cart message when there are no items", () => {
|
||||
renderCart([]);
|
||||
@@ -62,4 +70,27 @@ describe("WarenkorbContent", () => {
|
||||
expect(screen.getByText(/Mengenrabatt/)).toBeInTheDocument();
|
||||
expect(screen.getByText("-3,00 €")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows an out-of-stock hint when a cart item's live stock has dropped to zero", async () => {
|
||||
stubStockFetch({ p1: 0 });
|
||||
renderCart([item({ quantity: 2 })]);
|
||||
|
||||
expect(await screen.findByText("Ausverkauft")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows a low-stock hint and caps the quantity stepper at the live stock", async () => {
|
||||
stubStockFetch({ p1: 3 });
|
||||
renderCart([item({ quantity: 3 })]);
|
||||
|
||||
expect(await screen.findByText("Nur noch 3")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByLabelText("Menge erhöhen")).toBeDisabled());
|
||||
});
|
||||
|
||||
test("shows no stock hint while the live stock is still unknown or comfortably available", () => {
|
||||
stubStockFetch({ p1: 20 });
|
||||
renderCart([item({ quantity: 1 })]);
|
||||
|
||||
expect(screen.queryByText("Ausverkauft")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Nur noch/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||
import WarenkorbLoading from "./loading";
|
||||
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
@@ -24,10 +26,15 @@ export function WarenkorbContent({
|
||||
availableProducts: ProductWithRelations[];
|
||||
bulkDiscountTiers: BulkDiscountTierLike[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const items = useCartStore((s) => s.items);
|
||||
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||
const setQuantity = useCartStore((s) => s.setQuantity);
|
||||
const removeItem = useCartStore((s) => s.removeItem);
|
||||
// Live-Bestand je Position — derselbe Hinweis wie auf der Produktkarte/
|
||||
// -detailseite, aber hier fuer den ggf. seit dem Hinzufuegen veraenderten
|
||||
// aktuellen Bestand (siehe use-cart-stock.ts).
|
||||
const stock = useCartStock(items.map((item) => item.productId));
|
||||
const subtotal = cartTotalCents(items);
|
||||
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
|
||||
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
|
||||
@@ -48,6 +55,12 @@ export function WarenkorbContent({
|
||||
.filter((product) => !cartProductIds.has(product.id))
|
||||
.slice(0, CROSS_SELL_LIMIT);
|
||||
|
||||
// Siehe checkout-form.tsx: ohne diese Weiche flackert kurz die
|
||||
// "leer"-Meldung auf, bevor der Cart-Store aus localStorage nachliest.
|
||||
if (!hasHydrated) {
|
||||
return <WarenkorbLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container>
|
||||
@@ -63,48 +76,62 @@ export function WarenkorbContent({
|
||||
) : (
|
||||
<div className="mt-14 grid lg:grid-cols-3 gap-14">
|
||||
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
|
||||
{items.map((item) => (
|
||||
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
||||
{items.map((item) => {
|
||||
const itemStock = stock?.[item.productId];
|
||||
return (
|
||||
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
||||
{/* Siehe Kommentar im Cart-Drawer (site-chrome.tsx): persistenter
|
||||
aria-live-Wrapper, damit der erst nach dem ersten Rendern
|
||||
eintreffende Live-Bestand als Ansage bei Screenreadern ankommt. */}
|
||||
<div aria-live="polite">
|
||||
{itemStock !== undefined && itemStock === 0 ? (
|
||||
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
||||
) : itemStock !== undefined && itemStock <= 5 ? (
|
||||
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<QuantityStepper
|
||||
quantity={item.quantity}
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
max={itemStock ?? 10}
|
||||
/>
|
||||
<span className="w-20 text-right text-ink">
|
||||
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeItem(item.productId)}
|
||||
aria-label={t.cart.remove}
|
||||
className="text-ink/65 hover:text-ink"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<QuantityStepper
|
||||
quantity={item.quantity}
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
/>
|
||||
<span className="w-20 text-right text-ink">
|
||||
{formatPrice(item.priceCents * item.quantity)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeItem(item.productId)}
|
||||
aria-label={t.cart.remove}
|
||||
className="text-ink/65 hover:text-ink"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border border-ink/10 p-8 h-fit">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
<span>{formatPrice(subtotal, "EUR", locale)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t.cartPage.shippingLabel(totalQuantity)}</span>
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||
</div>
|
||||
{nextTier && nextTier.unitsUntilNext <= 2 ? (
|
||||
<p className="text-xs text-ink/60">
|
||||
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents))}
|
||||
<p className="text-xs text-ink/70">
|
||||
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))}
|
||||
</p>
|
||||
) : null}
|
||||
{activeBulkDiscount ? (
|
||||
@@ -112,22 +139,22 @@ export function WarenkorbContent({
|
||||
<span>
|
||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||
</span>
|
||||
<span>-{formatPrice(bulkDiscountCents)}</span>
|
||||
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||
</div>
|
||||
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
|
||||
<p className="text-xs text-ink/60">
|
||||
<p className="text-xs text-ink/70">
|
||||
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
|
||||
<span>{t.cartPage.total}</span>
|
||||
<span>{formatPrice(subtotal - bulkDiscountCents + shipping)}</span>
|
||||
<span>{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<LinkButton href="/checkout" className="w-full mt-6">
|
||||
{t.cart.checkout}
|
||||
</LinkButton>
|
||||
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/60 hover:text-forest underline">
|
||||
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/70 hover:text-forest underline">
|
||||
{t.cartPage.pickupHint}
|
||||
</Link>
|
||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function WissenArticlePage(
|
||||
|
||||
{otherArticles.length > 0 ? (
|
||||
<div className="mt-16 border-t border-ink/10 pt-10">
|
||||
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.wissenArticlePage.otherArticlesHeading}</p>
|
||||
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.wissenArticlePage.otherArticlesHeading}</p>
|
||||
<div className="space-y-6">
|
||||
{otherArticles.map((otherSlug) => {
|
||||
const other = t.wissenArticles[otherSlug];
|
||||
|
||||
+11
-6
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { wissenSlugs } from "@/lib/site-config";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
@@ -19,15 +20,19 @@ export default async function WissenPage() {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} />
|
||||
<ScrollReveal>
|
||||
<SectionHeading level="h1" eyebrow={t.wissenListPage.eyebrow} title={t.wissenListPage.title} />
|
||||
</ScrollReveal>
|
||||
<div className="mt-14 space-y-10">
|
||||
{wissenSlugs.map((slug) => {
|
||||
{wissenSlugs.map((slug, i) => {
|
||||
const article = t.wissenArticles[slug];
|
||||
return (
|
||||
<Link key={slug} href={`/wissen/${slug}`} className="block group">
|
||||
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
||||
</Link>
|
||||
<ScrollReveal key={slug} delay={(i % 5) * 0.06}>
|
||||
<Link href={`/wissen/${slug}`} className="block group">
|
||||
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -73,14 +73,14 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
|
||||
className="block text-xs"
|
||||
/>
|
||||
{status === "uploading" ? (
|
||||
<p className="mt-1 text-xs text-ink/50">{t.adminImageField.uploading}</p>
|
||||
<p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p>
|
||||
) : null}
|
||||
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue("")}
|
||||
className="mt-1 text-xs text-ink/50 underline"
|
||||
className="mt-1 text-xs text-ink/70 underline"
|
||||
>
|
||||
{t.adminImageField.remove}
|
||||
</button>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
|
||||
className="block text-xs"
|
||||
/>
|
||||
{status === "uploading" ? (
|
||||
<p className="mt-1 text-xs text-ink/50">{t.adminImageField.uploading}</p>
|
||||
<p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p>
|
||||
) : null}
|
||||
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
interface AdminVideoFieldProps {
|
||||
/** Formularfeld-Name, unter dem der aktuelle Videopfad beim Speichern des
|
||||
* umgebenden <form> mitgeschickt wird (siehe admin/actions/content.ts). */
|
||||
name: string;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video-Upload-Feld fuer admin/inhalte (Content-Registry-Feldtyp "video",
|
||||
* aktuell nur home.hero.video). Analog zu AdminImageField: laedt die Datei
|
||||
* sofort per fetch() an app/api/admin/upload-video hoch und haelt den
|
||||
* zurueckgegebenen Pfad in einem versteckten Formularfeld — das eigentliche
|
||||
* Speichern passiert weiterhin ueber das umgebende
|
||||
* <form action={updateSiteContentAction}>.
|
||||
*/
|
||||
export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldProps) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setStatus("uploading");
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const response = await fetch("/api/admin/upload-video", { method: "POST", body });
|
||||
const data: { path?: string; error?: string } = await response.json();
|
||||
if (!response.ok || !data.path) {
|
||||
throw new Error(data.error ?? t.adminVideoField.uploadError);
|
||||
}
|
||||
setValue(data.path);
|
||||
setStatus("idle");
|
||||
} catch (error: unknown) {
|
||||
setStatus("error");
|
||||
setErrorMessage(error instanceof Error ? error.message : t.adminVideoField.uploadError);
|
||||
} finally {
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-sm text-ink/70">{label}</span>
|
||||
<input type="hidden" name={name} value={value} />
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
{value ? (
|
||||
<video src={value} className="h-20 w-32 object-cover border border-ink/10" muted loop autoPlay playsInline />
|
||||
) : (
|
||||
<div className="h-20 w-32 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1">
|
||||
{t.adminVideoField.none}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="video/mp4,video/webm"
|
||||
onChange={handleFileChange}
|
||||
disabled={status === "uploading"}
|
||||
className="block text-xs"
|
||||
/>
|
||||
{status === "uploading" ? (
|
||||
<p className="mt-1 text-xs text-ink/70">{t.adminVideoField.uploading}</p>
|
||||
) : null}
|
||||
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue("")}
|
||||
className="mt-1 text-xs text-ink/70 underline"
|
||||
>
|
||||
{t.adminVideoField.remove}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,10 @@ interface ContentImageProps {
|
||||
tone?: "forest" | "cream" | "honey";
|
||||
label?: string;
|
||||
sizes?: string;
|
||||
/** Fuer das wahrscheinlichste LCP-Bild einer Seite (z.B. Startseiten-Hero)
|
||||
* true setzen — laedt das Bild mit hoher Prioritaet ohne Lazy-Loading,
|
||||
* statt es erst beim Scannen weiter unten im Dokument zu entdecken. */
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,13 +33,20 @@ export function ContentImage({
|
||||
tone = "forest",
|
||||
label,
|
||||
sizes = "100vw",
|
||||
priority = false,
|
||||
}: ContentImageProps) {
|
||||
if (!src) {
|
||||
return <PlaceholderImage tone={tone} className={className} label={label} />;
|
||||
}
|
||||
// "label" traegt bereits eine kurze inhaltliche Einordnung (Ernte-Jahr/Typ,
|
||||
// Kategorie, Ort — siehe Aufrufer), die bisher nur auf der Platzhalterkachel
|
||||
// sichtbar war und bei echten Fotos verworfen wurde. Fuer echte Bilder in
|
||||
// den Alt-Text uebernommen: aussagekraeftiger fuer Screenreader UND
|
||||
// Bildersuche als der Produkt-/Ernteame allein.
|
||||
const descriptiveAlt = label ? `${alt} – ${label}` : alt;
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<Image src={src} alt={alt} fill sizes={sizes} className="object-cover" />
|
||||
<Image src={src} alt={descriptiveAlt} fill sizes={sizes} priority={priority} className="object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+27
-7
@@ -29,11 +29,13 @@ export function Hero({
|
||||
footnote,
|
||||
location,
|
||||
image,
|
||||
video,
|
||||
}: {
|
||||
intro: string;
|
||||
footnote: string;
|
||||
location: string;
|
||||
image: string | null;
|
||||
video: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
@@ -45,13 +47,31 @@ export function Hero({
|
||||
animate="visible"
|
||||
variants={sectionVariants}
|
||||
>
|
||||
<ContentImage
|
||||
src={image}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="h-[78vh] min-h-[520px] w-full"
|
||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||
/>
|
||||
{video ? (
|
||||
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
||||
<video
|
||||
src={video}
|
||||
poster={image ?? undefined}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
// Nutzer mit "Bewegung reduzieren" bekommen nur das Standbild
|
||||
// (poster) statt der automatisch laufenden Videoschleife (WCAG
|
||||
// 2.2.2 "Pause, Stop, Hide" fuer automatisch startende Bewegung).
|
||||
autoPlay={!shouldReduceMotion}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ContentImage
|
||||
src={image}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="h-[78vh] min-h-[520px] w-full"
|
||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent">
|
||||
<Container>
|
||||
<motion.div
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function OriginStory() {
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
@@ -128,7 +129,8 @@ type HoneySpotlightProduct = ProductWithRelations & {
|
||||
};
|
||||
|
||||
export async function ProductSpotlight({ product }: { product: HoneySpotlightProduct }) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
return (
|
||||
<section className="py-24 bg-forest text-cream">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
@@ -139,6 +141,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
tone="honey"
|
||||
className="aspect-square w-full"
|
||||
label={product.name}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
@@ -162,7 +165,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm text-cream/70">
|
||||
<div>
|
||||
<dt className="text-cream/65">{t.home.productSpotlight.harvest}</dt>
|
||||
<dd>{formatDate(product.harvest.harvestDate)}</dd>
|
||||
<dd>{formatDate(product.harvest.harvestDate, locale)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-cream/65">{t.home.productSpotlight.batch}</dt>
|
||||
@@ -174,7 +177,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-cream/65">{t.home.productSpotlight.price}</dt>
|
||||
<dd>{formatPrice(product.priceCents, product.currency)}</dd>
|
||||
<dd>{formatPrice(product.priceCents, product.currency, locale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function RegionalSection() {
|
||||
tone="forest"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
@@ -135,6 +136,7 @@ export async function BusinessTeaser() {
|
||||
tone="honey"
|
||||
className="aspect-[4/5] w-full"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function LanguageSwitcher({ className = "" }: { className?: string }) {
|
||||
aria-current={code === locale}
|
||||
title={localeLabels[code]}
|
||||
className={`uppercase tracking-wide transition-colors disabled:opacity-50 ${
|
||||
code === locale ? "text-forest font-medium" : "text-ink/50 hover:text-forest"
|
||||
code === locale ? "text-forest font-medium" : "text-ink/70 hover:text-forest"
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function PasswordField({
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs text-ink/60 whitespace-nowrap">{tierLabel}</span>
|
||||
<span className="text-xs text-ink/70 whitespace-nowrap">{tierLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ProductTeaserLink({
|
||||
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
|
||||
<span>
|
||||
<span className="block text-forest">{name}</span>
|
||||
<span className="block mt-1 text-sm text-ink/60 group-hover:text-forest transition-colors">
|
||||
<span className="block mt-1 text-sm text-ink/70 group-hover:text-forest transition-colors">
|
||||
{t.product.viewProduct}
|
||||
</span>
|
||||
</span>
|
||||
@@ -173,7 +173,7 @@ export function ProductCard({
|
||||
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
|
||||
reviewSummary?: { average: number | null; count: number };
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const stock = product.inventory?.quantity ?? 0;
|
||||
|
||||
return (
|
||||
@@ -198,6 +198,7 @@ export function ProductCard({
|
||||
? `${product.harvest.year} · ${product.harvest.type}`
|
||||
: t.common.categories[product.category]
|
||||
}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
{stock === 0 ? (
|
||||
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
@@ -229,11 +230,11 @@ export function ProductCard({
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||
<span className="text-base text-forest">
|
||||
{formatPrice(product.priceCents, product.currency)}
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink/65">
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||
</p>
|
||||
{stock > 0 ? (
|
||||
<AddToCartButton product={product} variant="secondary" className="mt-4" />
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type TouchEvent } from "react";
|
||||
import Image from "next/image";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
|
||||
// Mindest-Wischstrecke in Pixeln, ab der ein Touch-Wisch als Bildwechsel
|
||||
// gilt statt als (vertikales) Scrollen oder ein leichtes Zittern der Hand.
|
||||
const SWIPE_THRESHOLD_PX = 40;
|
||||
|
||||
type Slide = { src: string } | { tone: "forest" | "cream" | "honey"; label: string };
|
||||
|
||||
function isImageSlide(slide: Slide): slide is { src: string } {
|
||||
@@ -23,19 +28,60 @@ const PLACEHOLDER_SLIDES: Slide[] = [
|
||||
interface ProductGalleryProps {
|
||||
images: string[];
|
||||
productName: string;
|
||||
/** Kurzbeschreibung (z.B. Geschmacksnoten) fuer aussagekraeftigeren Alt-Text
|
||||
* auf dem Hauptbild — reiner Produktname allein ist fuer Screenreader UND
|
||||
* Bildersuche weniger nuetzlich als Name + kurze inhaltliche Einordnung. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ProductGallery({ images, productName }: ProductGalleryProps) {
|
||||
export function ProductGallery({ images, productName, description }: ProductGalleryProps) {
|
||||
const slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const active = slides[activeIndex] ?? slides[0];
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const mainImageAlt = description ? `${productName} – ${description}` : productName;
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
touchStartX.current = event.touches[0].clientX;
|
||||
}
|
||||
|
||||
// Wischen auf dem Hauptbild wechselt das Bild — auf dem Handy ist das die
|
||||
// erwartete Bediengeste fuer eine Produktgalerie, nicht nur ein Tap auf die
|
||||
// (auf kleinen Bildschirmen ohnehin winzigen) Vorschau-Kacheln darunter.
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
if (touchStartX.current === null || slides.length <= 1) return;
|
||||
const deltaX = event.changedTouches[0].clientX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
if (Math.abs(deltaX) < SWIPE_THRESHOLD_PX) return;
|
||||
|
||||
setActiveIndex((current) => {
|
||||
const direction = deltaX < 0 ? 1 : -1;
|
||||
return (current + direction + slides.length) % slides.length;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="aspect-[4/3] overflow-hidden">
|
||||
<div
|
||||
className="relative aspect-[4/3] overflow-hidden touch-pan-y"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{isImageSlide(active) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
|
||||
<img src={active.src} alt={productName} className="w-full h-full object-cover" />
|
||||
// Hochgeladene Produktbilder liegen immer same-origin unter
|
||||
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
|
||||
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
|
||||
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
|
||||
// wahrscheinlichstes LCP-Element der Produktseite mit "priority"
|
||||
// geladen, statt erst beim Scannen des Bild-Tags entdeckt zu werden.
|
||||
<Image
|
||||
src={active.src}
|
||||
alt={mainImageAlt}
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
|
||||
)}
|
||||
@@ -49,13 +95,12 @@ export function ProductGallery({ images, productName }: ProductGalleryProps) {
|
||||
onClick={() => setActiveIndex(index)}
|
||||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||||
aria-current={index === activeIndex}
|
||||
className={`aspect-square overflow-hidden border-2 transition-colors ${
|
||||
className={`relative aspect-square overflow-hidden border-2 transition-colors ${
|
||||
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
||||
}`}
|
||||
>
|
||||
{isImageSlide(slide) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- s.o.
|
||||
<img src={slide.src} alt="" className="w-full h-full object-cover" />
|
||||
<Image src={slide.src} alt="" fill sizes="120px" className="object-cover" />
|
||||
) : (
|
||||
<PlaceholderImage tone={slide.tone} label={slide.label} className="w-full h-full" />
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { useState, useEffect, useRef, type RefObject } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { mainNav } from "@/lib/site-config";
|
||||
import { Container, LinkButton } from "@/components/ui";
|
||||
@@ -162,7 +163,7 @@ function SearchButton() {
|
||||
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
<span className="text-ink/60 whitespace-nowrap">{s.price}</span>
|
||||
<span className="text-ink/70 whitespace-nowrap">{s.price}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -392,7 +393,7 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
}
|
||||
|
||||
export function CartDrawer() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const isOpen = useCartStore((s) => s.isOpen);
|
||||
const closeCart = useCartStore((s) => s.closeCart);
|
||||
const items = useCartStore((s) => s.items);
|
||||
@@ -417,6 +418,10 @@ export function CartDrawer() {
|
||||
const removeItem = useCartStore((s) => s.removeItem);
|
||||
const total = cartTotalCents(items);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
// Live-Bestand fuer die Positionen im Drawer — ohne das wuerde ein
|
||||
// zwischenzeitlich ausverkaufter Artikel hier unauffaellig weiter als
|
||||
// normal bestellbar erscheinen (siehe use-cart-stock.ts).
|
||||
const stock = useCartStock(items.map((item) => item.productId));
|
||||
|
||||
useEscapeKey(isOpen, closeCart);
|
||||
useFocusTrap(isOpen, asideRef);
|
||||
@@ -457,31 +462,48 @@ export function CartDrawer() {
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink/10">
|
||||
{items.map((item) => (
|
||||
<li key={item.productId} className="py-4 flex gap-4">
|
||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<QuantityStepper
|
||||
quantity={item.quantity}
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
size="sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(item.productId)}
|
||||
className="text-xs text-ink/65 hover:text-ink underline"
|
||||
>
|
||||
{t.cart.remove}
|
||||
</button>
|
||||
{items.map((item) => {
|
||||
const itemStock = stock?.[item.productId];
|
||||
return (
|
||||
<li key={item.productId} className="py-4 flex gap-4">
|
||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
||||
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
||||
"kein Hinweis" zu "Ausverkauft"/"Nur noch N" bestehen, damit
|
||||
Screenreader die neu erscheinende Warnung tatsaechlich ansagen
|
||||
statt sie stumm zu uebergehen (ein erst mit dem Hinweis selbst
|
||||
eingefuegtes aria-live-Element wird von mancher AT ignoriert). */}
|
||||
<div aria-live="polite">
|
||||
{itemStock !== undefined && itemStock === 0 ? (
|
||||
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
||||
) : itemStock !== undefined && itemStock <= 5 ? (
|
||||
<p className="mt-1 text-xs text-honey-dark">{t.product.onlyLeft(itemStock)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<QuantityStepper
|
||||
quantity={item.quantity}
|
||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
||||
max={itemStock ?? 10}
|
||||
size="sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeItem(item.productId)}
|
||||
className="text-xs text-ink/65 hover:text-ink underline"
|
||||
>
|
||||
{t.cart.remove}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-ink">
|
||||
{formatPrice(item.priceCents * item.quantity)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
<p className="text-sm text-ink">
|
||||
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
@@ -490,7 +512,7 @@ export function CartDrawer() {
|
||||
<div className="border-t border-ink/10 p-6 space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
<span>{formatPrice(total, "EUR", locale)}</span>
|
||||
</div>
|
||||
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
||||
{t.cart.checkout}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { ProductWithRelations } from "@/lib/data";
|
||||
|
||||
/**
|
||||
* Immer sichtbare Kaufleiste am unteren Bildschirmrand — nur auf Mobilgeraeten
|
||||
* (md:hidden). Die Produktdetailseite ist mit Bewertungen und "Das koennte
|
||||
* dir auch gefallen" oft laenger als ein Bildschirm; ohne das muesste man auf
|
||||
* dem Handy nach dem Lesen der Bewertungen wieder ganz nach oben scrollen, um
|
||||
* den Warenkorb-Button zu erreichen. Bewusst mit eigenem, schlankem
|
||||
* Button statt AddToCartButton (dessen Button-Variante ist fest auf volle
|
||||
* Breite gesetzt) und ohne Mengen-Steller — das haelt die Leiste flach genug,
|
||||
* um auf kleinen Bildschirmen nicht zu viel Platz zu rauben; die Menge laesst
|
||||
* sich danach jederzeit im Warenkorb anpassen.
|
||||
*/
|
||||
export function StickyAddToCartBar({
|
||||
product,
|
||||
inStock,
|
||||
}: {
|
||||
product: ProductWithRelations;
|
||||
inStock: boolean;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const addItem = useCartStore((s) => s.addItem);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="md:hidden fixed inset-x-0 bottom-0 z-40 border-t border-ink/10 bg-background/95 px-4 py-3 backdrop-blur"
|
||||
style={{ paddingBottom: "calc(0.75rem + env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{product.name}</p>
|
||||
<p className="text-sm text-forest">{formatPrice(product.priceCents, product.currency, locale)}</p>
|
||||
</div>
|
||||
{inStock ? (
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
addItem(
|
||||
{
|
||||
productId: product.id,
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
weightGrams: product.weightGrams,
|
||||
priceCents: product.priceCents,
|
||||
image: product.images[0] ?? null,
|
||||
},
|
||||
1
|
||||
);
|
||||
setJustAdded(true);
|
||||
window.setTimeout(() => setJustAdded(false), 1500);
|
||||
}}
|
||||
>
|
||||
{justAdded ? t.product.added : t.product.addToCart}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="flex-shrink-0 text-xs tracking-wide-caps text-ink/70">{t.productPage.soldOut}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Fragt den aktuellen Lagerbestand fuer die uebergebenen Produkt-IDs ab
|
||||
* (/api/shop/cart-stock) — der Warenkorb selbst (Zustand/localStorage) kennt
|
||||
* nur den Stand vom Hinzufuegen, der sich seitdem geaendert haben kann
|
||||
* (verkauft, im Admin-Bereich angepasst, Produkt deaktiviert). Genutzt vom
|
||||
* Cart-Drawer UND der Warenkorb-Seite, damit "Ausverkauft"/"Nur noch
|
||||
* N"-Hinweise ueberall dort erscheinen, wo eine Kaufentscheidung faellt —
|
||||
* nicht nur auf der Produktkarte.
|
||||
*
|
||||
* Gibt `null` zurueck, bis die erste Antwort da ist (verhindert einen
|
||||
* kurzen Ausverkauft-Blitzer fuer tatsaechlich verfuegbare Artikel), danach
|
||||
* eine Map productId -> aktueller Bestand. Fehlt eine ID in der Map (z.B.
|
||||
* Netzwerkfehler), zeigen die Aufrufer bewusst KEINEN Hinweis statt eines
|
||||
* falschen "ausverkauft".
|
||||
*/
|
||||
export function useCartStock(productIds: string[]): Record<string, number> | null {
|
||||
const key = [...productIds].sort().join(",");
|
||||
const [stock, setStock] = useState<Record<string, number> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Leerer Warenkorb: keine Anfrage noetig, der leere Fall wird unten beim
|
||||
// Rueckgabewert direkt (ohne setState im Effekt) behandelt.
|
||||
if (!key) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
fetch(`/api/shop/cart-stock?ids=${encodeURIComponent(key)}`, { signal: controller.signal })
|
||||
.then((res) => res.json())
|
||||
.then((data: { stock?: Record<string, number> }) => setStock(data.stock ?? {}))
|
||||
.catch(() => {});
|
||||
|
||||
return () => controller.abort();
|
||||
// "key" fasst die relevanten IDs zusammen — er reicht als Abhaengigkeit,
|
||||
// eine neue Anfrage nur bei tatsaechlich geaenderter ID-Menge.
|
||||
}, [key]);
|
||||
|
||||
return key ? stock : {};
|
||||
}
|
||||
@@ -388,7 +388,7 @@ export const de: Dictionary = {
|
||||
tierFallback: "Die aktuellen Versandkosten werden dir vor Abschluss der Bestellung transparent angezeigt.",
|
||||
pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen — dabei fallen keine Versandkosten an.",
|
||||
paymentHeading: "Zahlungsmethoden",
|
||||
paymentText: "Wir bieten PayPal, Kreditkarte, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
|
||||
paymentText: "Wir bieten Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
|
||||
deliveryTimeHeading: "Lieferzeit",
|
||||
deliveryTimeText: "Die übliche Lieferzeit innerhalb Deutschlands beträgt 2–4 Werktage nach Zahlungseingang.",
|
||||
},
|
||||
@@ -1114,6 +1114,7 @@ export const de: Dictionary = {
|
||||
"Diese Texte erscheinen auf der Website. Ein leeres Feld bedeutet nicht \"leer auf der Seite\" — dann wird der ursprüngliche Text weiter angezeigt. Trage nur ein, was du ändern möchtest.",
|
||||
groupCompany: "Firmendaten",
|
||||
groupHome: "Homepage",
|
||||
groupPages: "Weitere Seiten",
|
||||
groupLegal: "Rechtstexte",
|
||||
groupSocial: "Social Media",
|
||||
deLabel: "Deutsch",
|
||||
@@ -1128,6 +1129,12 @@ export const de: Dictionary = {
|
||||
removeImageAt: (position) => `Bild ${position} entfernen`,
|
||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||
},
|
||||
adminVideoField: {
|
||||
none: "Kein Video",
|
||||
uploading: "Wird hochgeladen…",
|
||||
remove: "Video entfernen",
|
||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||
},
|
||||
adminShell: {
|
||||
navDashboard: "Dashboard",
|
||||
navOrders: "Bestellungen",
|
||||
@@ -1332,6 +1339,7 @@ export const de: Dictionary = {
|
||||
productPage: {
|
||||
reviewsLink: (count) => `${count} Bewertung${count === 1 ? "" : "en"}`,
|
||||
noReviews: "Noch keine Bewertungen",
|
||||
shippingHint: (price) => `Versand ab ${price} · Lieferzeit 2–4 Werktage`,
|
||||
onlyLeftInStock: (count) => `Nur noch ${count} auf Lager`,
|
||||
soldOut: "Ausverkauft",
|
||||
currentlyUnavailable: "Aktuell nicht verfügbar — schau bald wieder vorbei.",
|
||||
@@ -1373,6 +1381,8 @@ export const de: Dictionary = {
|
||||
infantWarningText: "Aufgrund des natürlichen Sporengehalts ist Honig nicht für Säuglinge unter 12 Monaten geeignet.",
|
||||
nonHoneyOriginText: "Aus eigener Imkerei und Produktion in Altenmünster.",
|
||||
viewBatchOrigin: "Chargen-Herkunft dieses Glases ansehen →",
|
||||
giftHint: "Verschenkst du dieses Glas? Beim Checkout kannst du eine persönliche Nachricht beilegen lassen.",
|
||||
learnMoreLabel: "Mehr erfahren:",
|
||||
reviewsHeading: "Bewertungen",
|
||||
noReviewsForProduct: "Für dieses Produkt gibt es noch keine Bewertungen.",
|
||||
loginToReview: "um dieses Produkt zu bewerten.",
|
||||
@@ -1399,6 +1409,8 @@ export const de: Dictionary = {
|
||||
sortAvailability: "Verfügbarkeit",
|
||||
apply: "Anwenden",
|
||||
onlyAvailable: "Nur verfügbare",
|
||||
searchInputLabel: "Im Sortiment suchen",
|
||||
clearSearch: "Suche zurücksetzen",
|
||||
searchResults: (query, count) => `Suchergebnisse für „${query}“ (${count})`,
|
||||
noSearchResults: "Keine Treffer für deine Suche.",
|
||||
noProducts: "Aktuell sind hier keine Produkte verfügbar. Schau bald wieder vorbei.",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const en: Dictionary = {
|
||||
openSearch: "Open search",
|
||||
openMenu: "Open menu",
|
||||
closeMenu: "Close menu",
|
||||
openCart: (count) => (count > 0 ? `Open cart, ${count} items` : "Open cart"),
|
||||
openCart: (count) => (count > 0 ? `Open cart, ${count} item${count === 1 ? "" : "s"}` : "Open cart"),
|
||||
skipToContent: "Skip to content",
|
||||
},
|
||||
footer: {
|
||||
@@ -388,7 +388,7 @@ export const en: Dictionary = {
|
||||
tierFallback: "The current shipping costs will be shown to you transparently before you complete your order.",
|
||||
pickupNote: "Alternatively, you can pick up your order directly from us in Altenmünster by arrangement — no shipping costs apply in that case.",
|
||||
paymentHeading: "Payment Methods",
|
||||
paymentText: "We offer PayPal, credit card, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request.",
|
||||
paymentText: "We offer credit card, SEPA direct debit, Klarna, PayPal, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request.",
|
||||
deliveryTimeHeading: "Delivery Time",
|
||||
deliveryTimeText: "Typical delivery time within Germany is 2–4 business days after payment is received.",
|
||||
},
|
||||
@@ -1114,6 +1114,7 @@ export const en: Dictionary = {
|
||||
"These texts appear on the website. An empty field does not mean \"blank on the page\" — the original text keeps showing. Only fill in what you want to change.",
|
||||
groupCompany: "Company details",
|
||||
groupHome: "Homepage",
|
||||
groupPages: "Other pages",
|
||||
groupLegal: "Legal pages",
|
||||
groupSocial: "Social media",
|
||||
deLabel: "German",
|
||||
@@ -1128,6 +1129,12 @@ export const en: Dictionary = {
|
||||
removeImageAt: (position) => `Remove image ${position}`,
|
||||
uploadError: "The upload failed.",
|
||||
},
|
||||
adminVideoField: {
|
||||
none: "No video",
|
||||
uploading: "Uploading…",
|
||||
remove: "Remove video",
|
||||
uploadError: "The upload failed.",
|
||||
},
|
||||
adminShell: {
|
||||
navDashboard: "Dashboard",
|
||||
navOrders: "Orders",
|
||||
@@ -1297,13 +1304,13 @@ export const en: Dictionary = {
|
||||
paymentMethodsNote:
|
||||
"Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna — provided via Stripe.",
|
||||
yourOrderHeading: "Your Order",
|
||||
shippingWithCount: (count) => `Shipping (${count} items)`,
|
||||
shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
|
||||
shippingPickup: "Shipping (pickup)",
|
||||
},
|
||||
cartPage: {
|
||||
title: "Your Cart",
|
||||
goToShop: "Go to shop",
|
||||
shippingLabel: (count) => `Shipping (${count} items)`,
|
||||
shippingLabel: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
|
||||
free: "free",
|
||||
total: "Total",
|
||||
nextTierHint: (unitsUntilNext, nextPrice) =>
|
||||
@@ -1332,6 +1339,7 @@ export const en: Dictionary = {
|
||||
productPage: {
|
||||
reviewsLink: (count) => `${count} review${count === 1 ? "" : "s"}`,
|
||||
noReviews: "No reviews yet",
|
||||
shippingHint: (price) => `Shipping from ${price} · Delivery in 2–4 business days`,
|
||||
onlyLeftInStock: (count) => `Only ${count} in stock`,
|
||||
soldOut: "Sold out",
|
||||
currentlyUnavailable: "Currently unavailable — check back soon.",
|
||||
@@ -1373,6 +1381,8 @@ export const en: Dictionary = {
|
||||
infantWarningText: "Due to its natural spore content, honey is not suitable for infants under 12 months.",
|
||||
nonHoneyOriginText: "From our own apiary and production in Altenmünster.",
|
||||
viewBatchOrigin: "View this jar's batch origin →",
|
||||
giftHint: "Giving this as a gift? You can add a personal message at checkout.",
|
||||
learnMoreLabel: "Learn more:",
|
||||
reviewsHeading: "Reviews",
|
||||
noReviewsForProduct: "There are no reviews for this product yet.",
|
||||
loginToReview: "to review this product.",
|
||||
@@ -1399,6 +1409,8 @@ export const en: Dictionary = {
|
||||
sortAvailability: "Availability",
|
||||
apply: "Apply",
|
||||
onlyAvailable: "In stock only",
|
||||
searchInputLabel: "Search the shop",
|
||||
clearSearch: "Clear search",
|
||||
searchResults: (query, count) => `Search results for "${query}" (${count})`,
|
||||
noSearchResults: "No results for your search.",
|
||||
noProducts: "No products available here right now. Check back soon.",
|
||||
|
||||
@@ -813,6 +813,7 @@ export interface Dictionary {
|
||||
intro: string;
|
||||
groupCompany: string;
|
||||
groupHome: string;
|
||||
groupPages: string;
|
||||
groupLegal: string;
|
||||
groupSocial: string;
|
||||
deLabel: string;
|
||||
@@ -829,6 +830,14 @@ export interface Dictionary {
|
||||
removeImageAt: (position: number) => string;
|
||||
uploadError: string;
|
||||
};
|
||||
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
|
||||
* admin/inhalte). */
|
||||
adminVideoField: {
|
||||
none: string;
|
||||
uploading: string;
|
||||
remove: string;
|
||||
uploadError: string;
|
||||
};
|
||||
adminShell: {
|
||||
navDashboard: string;
|
||||
navOrders: string;
|
||||
@@ -1018,6 +1027,7 @@ export interface Dictionary {
|
||||
productPage: {
|
||||
reviewsLink: (count: number) => string;
|
||||
noReviews: string;
|
||||
shippingHint: (price: string) => string;
|
||||
onlyLeftInStock: (count: number) => string;
|
||||
soldOut: string;
|
||||
currentlyUnavailable: string;
|
||||
@@ -1045,6 +1055,8 @@ export interface Dictionary {
|
||||
infantWarningText: string;
|
||||
nonHoneyOriginText: string;
|
||||
viewBatchOrigin: string;
|
||||
giftHint: string;
|
||||
learnMoreLabel: string;
|
||||
reviewsHeading: string;
|
||||
noReviewsForProduct: string;
|
||||
loginToReview: string;
|
||||
@@ -1071,6 +1083,8 @@ export interface Dictionary {
|
||||
sortAvailability: string;
|
||||
apply: string;
|
||||
onlyAvailable: string;
|
||||
searchInputLabel: string;
|
||||
clearSearch: string;
|
||||
searchResults: (query: string, count: number) => string;
|
||||
noSearchResults: string;
|
||||
noProducts: string;
|
||||
|
||||
@@ -10,11 +10,12 @@ export type ContentFieldType =
|
||||
| "list-titled"
|
||||
| "list-numbered"
|
||||
| "richtext-sections"
|
||||
| "image";
|
||||
| "image"
|
||||
| "video";
|
||||
|
||||
export interface ContentFieldDef {
|
||||
key: string;
|
||||
group: "company" | "home" | "legal" | "social";
|
||||
group: "company" | "home" | "pages" | "legal" | "social";
|
||||
labelDe: string;
|
||||
labelEn: string;
|
||||
type: ContentFieldType;
|
||||
@@ -79,10 +80,20 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
|
||||
// dessen eigenes Product.images (siehe admin/produkte).
|
||||
{ key: "home.hero.image", group: "home", labelDe: "Hero: Bild", labelEn: "Hero: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
// Optionales Hintergrundvideo fuer den Hero (MP4/WebM, siehe
|
||||
// app/api/admin/upload-video/route.ts). Wenn gepflegt, zeigt hero.tsx das
|
||||
// Video statt des Hero-Bilds/Platzhalters an derselben Stelle — home.hero.image
|
||||
// bleibt als Fallback/Poster-Bild erhalten, falls das Video wieder entfernt wird.
|
||||
{ key: "home.hero.video", group: "home", labelDe: "Hero: Hintergrundvideo (MP4/WebM, optional — ersetzt das Hero-Bild)", labelEn: "Hero: Background video (MP4/WebM, optional — replaces the hero image)", type: "video", localized: false, getDefault: () => "" },
|
||||
{ key: "home.originStory.image", group: "home", labelDe: "Herkunft: Bild", labelEn: "Origin: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "home.regionalSection.image", group: "home", labelDe: "Regional: Bild", labelEn: "Regional: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "home.businessTeaser.image", group: "home", labelDe: "Geschäftskunden-Hinweis: Bild", labelEn: "Business teaser: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
|
||||
// --- Weitere Seiten (nicht lokalisiert) ---
|
||||
{ key: "imkerei.image", group: "pages", labelDe: "Imkerei-Seite: Bild", labelEn: "Imkerei page: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "herkunft.image", group: "pages", labelDe: "Herkunft-Seite: Bild", labelEn: "Herkunft page: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "unternehmen.image", group: "pages", labelDe: "Geschäftskunden-Seite: Bild", labelEn: "Business page: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
|
||||
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
||||
// angezeigt, siehe components/site-footer.tsx Footer()) ---
|
||||
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
||||
|
||||
+6
-5
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { matchesSearchQuery } from "@/lib/search-match";
|
||||
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
// tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet,
|
||||
@@ -70,12 +71,12 @@ export async function getActiveProducts(
|
||||
// ueberschaubaren Katalogsgroesse wird deshalb hier statt in der DB-Query
|
||||
// gefiltert. Das behebt zugleich, dass mehrwortige, gemischt geschriebene
|
||||
// Tags wie "reines Bienenwachs" bei case-insensitiver Suche nie trafen.
|
||||
const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
// matchesSearchQuery gleicht zusaetzlich Umlaute/ASCII-Transliteration ab
|
||||
// (z.B. "Bluete" <-> "Blüte"), da Produktnamen/-tags durchgaengig echte
|
||||
// Umlaute verwenden, aber nicht jede Kundin/jeder Kunde sie eintippt.
|
||||
return products.filter((product) => {
|
||||
const haystack = [product.name, product.description, ...product.tasteTags]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return words.every((word) => haystack.includes(word));
|
||||
const haystack = [product.name, product.description, ...product.tasteTags].join(" ");
|
||||
return matchesSearchQuery(haystack, trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ export class DhlApiError extends Error {
|
||||
// Konfiguration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Ohne Timeout wuerde eine haengende (nicht fehlerhafte, sondern einfach nie
|
||||
// antwortende) DHL-API die aufrufende Admin-Server-Action unbegrenzt
|
||||
// blockieren und dabei den DHL_CLAIM_SENTINEL-Claim (siehe admin/actions/dhl.ts)
|
||||
// auf unbestimmte Zeit offen halten.
|
||||
const DHL_REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
interface DhlConfig {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
@@ -124,6 +130,7 @@ async function fetchAccessToken(config: DhlConfig): Promise<string> {
|
||||
client_id: config.apiKey,
|
||||
client_secret: config.apiSecret,
|
||||
}),
|
||||
signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||
@@ -291,6 +298,7 @@ export async function createDhlShipmentLabel(
|
||||
"dhl-api-key": config.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||
|
||||
@@ -98,6 +98,7 @@ interface EmailStrings {
|
||||
heading: (firstName: string) => string;
|
||||
intro: (orderNumber: string) => string;
|
||||
retryNote: string;
|
||||
button: string;
|
||||
subject: (orderNumber: string) => string;
|
||||
};
|
||||
}
|
||||
@@ -198,6 +199,7 @@ const de: EmailStrings = {
|
||||
intro: (orderNumber) =>
|
||||
`Der Zahlungseinzug für deine Bestellung <strong>${orderNumber}</strong> konnte nicht abgeschlossen werden. Wir haben die Bestellung daher storniert, es sind dir keine Kosten entstanden.`,
|
||||
retryNote: "Du kannst die Bestellung gerne erneut aufgeben, zum Beispiel mit einer anderen Zahlungsmethode.",
|
||||
button: "ERNEUT BESTELLEN",
|
||||
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`,
|
||||
},
|
||||
};
|
||||
@@ -298,6 +300,7 @@ const en: EmailStrings = {
|
||||
intro: (orderNumber) =>
|
||||
`The payment for your order <strong>${orderNumber}</strong> could not be completed. We've cancelled the order, so no costs were incurred.`,
|
||||
retryNote: "Feel free to place the order again, for example with a different payment method.",
|
||||
button: "ORDER AGAIN",
|
||||
subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`,
|
||||
},
|
||||
};
|
||||
|
||||
+15
-10
@@ -76,7 +76,7 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
|
||||
const rows = order.items
|
||||
.map(
|
||||
(item) =>
|
||||
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents)}</td></tr>`
|
||||
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
@@ -101,10 +101,10 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
|
||||
${rows}
|
||||
</table>
|
||||
<table role="presentation" width="100%" style="font-size:14px;margin-top:12px;">
|
||||
<tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents)}</td></tr>
|
||||
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)}</td></tr>
|
||||
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents)}</td></tr>` : ""}
|
||||
<tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents)}</td></tr>
|
||||
<tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents, "EUR", order.locale)}</td></tr>
|
||||
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)}</td></tr>
|
||||
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents, "EUR", order.locale)}</td></tr>` : ""}
|
||||
<tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents, "EUR", order.locale)}</td></tr>
|
||||
</table>
|
||||
${deliveryHtml}
|
||||
${giftHtml}
|
||||
@@ -113,10 +113,10 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
|
||||
);
|
||||
|
||||
const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items
|
||||
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents)}`)
|
||||
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents)}\n${s.shipping}: ${
|
||||
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)
|
||||
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents)}` : ""}\n${s.total}: ${formatPrice(order.totalCents)}${
|
||||
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents, "EUR", order.locale)}`)
|
||||
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents, "EUR", order.locale)}\n${s.shipping}: ${
|
||||
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)
|
||||
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents, "EUR", order.locale)}` : ""}\n${s.total}: ${formatPrice(order.totalCents, "EUR", order.locale)}${
|
||||
order.giftMessage ? `\n\n${s.giftNote}` : ""
|
||||
}${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`;
|
||||
|
||||
@@ -354,6 +354,10 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis
|
||||
|
||||
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||||
const s = getEmailStrings(order.locale).paymentFailed;
|
||||
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
|
||||
// einen tatsaechlich anklickbaren Button dazu bliebe das ein reiner
|
||||
// Lauftext-Hinweis ohne direkten Weg zurueck in den Shop.
|
||||
const shopUrl = `https://${siteConfig.domain}/shop`;
|
||||
const html = await emailLayout(
|
||||
order.locale,
|
||||
s.preheader(order.orderNumber),
|
||||
@@ -361,11 +365,12 @@ export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
|
||||
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
|
||||
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
|
||||
`
|
||||
);
|
||||
return {
|
||||
subject: s.subject(order.orderNumber),
|
||||
html,
|
||||
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")} ${s.retryNote}`,
|
||||
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")} ${s.retryNote}\n\n${s.button}: ${shopUrl}`,
|
||||
};
|
||||
}
|
||||
+27
-11
@@ -1,8 +1,24 @@
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
// BCP-47-Tag je Locale fuer Intl.*Format — "en-GB" statt "en-US" fuer
|
||||
// Englisch, damit die Tagesreihenfolge (10 May 2026) der deutschen
|
||||
// Konvention (10. Mai 2026) naeher bleibt als das umgestellte US-Format
|
||||
// (May 10, 2026). Zahlungs-/Preisformatierung unterscheidet sich zwischen
|
||||
// beiden englischen Varianten fuer EUR ohnehin nicht.
|
||||
const INTL_LOCALE: Record<Locale, string> = {
|
||||
de: "de-DE",
|
||||
en: "en-GB",
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatiert Cent-Betraege als deutsche Euro-Preisangabe, z.B. 990 -> "9,90 €".
|
||||
* Formatiert Cent-Betraege als Euro-Preisangabe in der uebergebenen Sprache,
|
||||
* z.B. 990 -> "9,90 €" (de) oder "€9.90" (en). Ohne "locale" faellt die
|
||||
* Funktion auf Deutsch zurueck — das gilt fuer interne/rechtlich gebundene
|
||||
* Ausgaben wie Rechnungen und den Admin-Bereich, die bewusst nicht der
|
||||
* Kunden-Locale folgen.
|
||||
*/
|
||||
export function formatPrice(cents: number, currency = "EUR"): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
export function formatPrice(cents: number, currency = "EUR", locale: Locale = "de"): string {
|
||||
return new Intl.NumberFormat(INTL_LOCALE[locale], {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(cents / 100);
|
||||
@@ -11,20 +27,20 @@ export function formatPrice(cents: number, currency = "EUR"): string {
|
||||
/**
|
||||
* Formatiert den Grundpreis (Preis pro Kilogramm), z.B. fuer Vergleichbarkeit im Shop.
|
||||
*/
|
||||
export function formatPricePerKg(cents: number, currency = "EUR"): string {
|
||||
return `${formatPrice(cents, currency)} / kg`;
|
||||
export function formatPricePerKg(cents: number, currency = "EUR", locale: Locale = "de"): string {
|
||||
return `${formatPrice(cents, currency, locale)} / kg`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
export function formatDate(date: Date, locale: Locale = "de"): string {
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
export function formatDateTime(date: Date, locale: Locale = "de"): string {
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
@@ -33,8 +49,8 @@ export function formatDateTime(date: Date): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatMonthYear(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
export function formatMonthYear(date: Date, locale: Locale = "de"): string {
|
||||
return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { normalizeSearchText, matchesSearchQuery } from "./search-match";
|
||||
|
||||
describe("normalizeSearchText", () => {
|
||||
test("lowercases and transliterates umlauts to their ASCII equivalent", () => {
|
||||
expect(normalizeSearchText("Frühtracht")).toBe("fruehtracht");
|
||||
expect(normalizeSearchText("kräftig")).toBe("kraeftig");
|
||||
expect(normalizeSearchText("Altenmünster")).toBe("altenmuenster");
|
||||
expect(normalizeSearchText("rußarm")).toBe("russarm");
|
||||
});
|
||||
|
||||
test("leaves already-ASCII text unchanged apart from casing", () => {
|
||||
expect(normalizeSearchText("Honig")).toBe("honig");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesSearchQuery", () => {
|
||||
test("matches when the query uses real umlauts and the haystack does too", () => {
|
||||
expect(matchesSearchQuery("Frühtracht 2026", "Früh")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches when the query uses the ASCII transliteration of an umlaut", () => {
|
||||
expect(matchesSearchQuery("Frühtracht 2026", "fruehtracht")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches when the haystack uses the ASCII transliteration and the query the real umlaut", () => {
|
||||
expect(matchesSearchQuery("kraeftiger Sommerhonig", "kräftig")).toBe(true);
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(matchesSearchQuery("Blütenhonig", "BLUETE")).toBe(true);
|
||||
});
|
||||
|
||||
test("requires every word of a multi-word query to match", () => {
|
||||
expect(matchesSearchQuery("kräftiger Sommerhonig aus Altenmünster", "kraeftig honig")).toBe(true);
|
||||
expect(matchesSearchQuery("kräftiger Sommerhonig aus Altenmünster", "kraeftig kerze")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when no word matches", () => {
|
||||
expect(matchesSearchQuery("Bienenwachskerze", "propolis")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for an empty query (no filter applied)", () => {
|
||||
expect(matchesSearchQuery("Bienenwachskerze", "")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Normalisiert deutsche Umlaute/scharfes S fuer die Produktsuche: "ü", "ö",
|
||||
* "ä" und "ß" werden auf ihre gaengige ASCII-Transliteration ("ue", "oe",
|
||||
* "ae", "ss") abgebildet. Ohne das liefert eine Suche nach "Bluete" keine
|
||||
* Treffer fuer "Blüte" und umgekehrt — viele Kundinnen und Kunden tippen aus
|
||||
* Gewohnheit oder auf einer nicht-deutschen Tastatur die ASCII-Schreibweise,
|
||||
* waehrend Produktnamen/-tags im Katalog durchgaengig echte Umlaute nutzen
|
||||
* (z.B. "Frühtracht", "kräftig", "Altenmünster").
|
||||
*/
|
||||
export function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrwortige Teilstring-Suche: jedes Wort aus `query` muss irgendwo in
|
||||
* `haystack` vorkommen (Reihenfolge egal), umlaut-unabhaengig in beide
|
||||
* Richtungen (echter Umlaut <-> ASCII-Transliteration).
|
||||
*/
|
||||
export function matchesSearchQuery(haystack: string, query: string): boolean {
|
||||
const words = normalizeSearchText(query).split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return true;
|
||||
const normalizedHaystack = normalizeSearchText(haystack);
|
||||
return words.every((word) => normalizedHaystack.includes(word));
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
generateUploadFilename,
|
||||
isValidUploadPath,
|
||||
resolveUploadPath,
|
||||
detectVideoExtension,
|
||||
isValidVideoUploadPath,
|
||||
} from "./uploads";
|
||||
|
||||
describe("extensionForFormat", () => {
|
||||
@@ -39,6 +41,11 @@ describe("contentTypeForExtension", () => {
|
||||
expect(contentTypeForExtension("avif")).toBe("image/avif");
|
||||
});
|
||||
|
||||
test("returns the correct MIME type for the video extensions", () => {
|
||||
expect(contentTypeForExtension("mp4")).toBe("video/mp4");
|
||||
expect(contentTypeForExtension("webm")).toBe("video/webm");
|
||||
});
|
||||
|
||||
test("returns null for an extension outside the fixed allow-list", () => {
|
||||
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
|
||||
// siehe app/uploads/[...path]/route.ts.
|
||||
@@ -48,6 +55,33 @@ describe("contentTypeForExtension", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectVideoExtension", () => {
|
||||
function bufferWithFtypAt4(): Buffer {
|
||||
// Reale MP4-Dateien haben eine variable Box-Groesse in den ersten 4
|
||||
// Bytes, gefolgt von der literalen ASCII-Kennung "ftyp" — die konkreten
|
||||
// ersten 4 Bytes sind fuer die Erkennung irrelevant.
|
||||
return Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from("ftypisom", "ascii")]);
|
||||
}
|
||||
|
||||
test("recognizes an MP4 file via its ftyp box", () => {
|
||||
expect(detectVideoExtension(bufferWithFtypAt4())).toBe("mp4");
|
||||
});
|
||||
|
||||
test("recognizes a WebM file via its EBML header", () => {
|
||||
expect(detectVideoExtension(Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x02]))).toBe("webm");
|
||||
});
|
||||
|
||||
test("rejects a buffer that is neither MP4 nor WebM (e.g. a JPEG or arbitrary bytes)", () => {
|
||||
expect(detectVideoExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBeNull();
|
||||
expect(detectVideoExtension(Buffer.from("not a video", "ascii"))).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a buffer too short to contain either signature", () => {
|
||||
expect(detectVideoExtension(Buffer.from([0x1a, 0x45]))).toBeNull();
|
||||
expect(detectVideoExtension(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUploadFilename", () => {
|
||||
test("produces a 32-char lowercase hex name with the given extension", () => {
|
||||
const name = generateUploadFilename("webp");
|
||||
@@ -90,6 +124,33 @@ describe("isValidUploadPath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidVideoUploadPath", () => {
|
||||
test("accepts a path in exactly the shape the video upload route produces", () => {
|
||||
expect(isValidVideoUploadPath(`/uploads/${generateUploadFilename("mp4")}`)).toBe(true);
|
||||
expect(isValidVideoUploadPath(`/uploads/${generateUploadFilename("webm")}`)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an image path (different endpoint's output)", () => {
|
||||
expect(isValidVideoUploadPath(`/uploads/${generateUploadFilename("png")}`)).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a path traversal attempt", () => {
|
||||
expect(isValidVideoUploadPath("/uploads/../../etc/passwd")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an absolute external URL (not an upload-endpoint path)", () => {
|
||||
expect(isValidVideoUploadPath("https://attacker.example/x.mp4")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a client-chosen filename instead of the generated random one", () => {
|
||||
expect(isValidVideoUploadPath("/uploads/my-video.mp4")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an empty string", () => {
|
||||
expect(isValidVideoUploadPath("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUploadPath", () => {
|
||||
test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
|
||||
const resolved = resolveUploadPath(["abc123.png"]);
|
||||
|
||||
+48
-3
@@ -32,7 +32,18 @@ export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPL
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 40 MB: ein kurzes (wenige Sekunden), fuer Web komprimiertes Hero-Video
|
||||
* passt bequem darunter. Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil
|
||||
* Video-Dateien inhaerent groesser sind — Videos werden aber (anders als
|
||||
* Bilder) NICHT neu enkodiert (dafuer gibt es hier keine sharp-Entsprechung
|
||||
* ohne eine ffmpeg-Abhaengigkeit), die Groessengrenze ist daher die einzige
|
||||
* Bremse gegen uebermaessig grosse Uploads.
|
||||
*/
|
||||
export const MAX_UPLOAD_VIDEO_BYTES = 40 * 1024 * 1024;
|
||||
|
||||
export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif";
|
||||
export type AllowedVideoExtension = "mp4" | "webm";
|
||||
|
||||
/**
|
||||
* Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes,
|
||||
@@ -52,6 +63,30 @@ export function extensionForFormat(format: string | undefined): AllowedImageExte
|
||||
return EXTENSION_BY_FORMAT[format] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt MP4/WebM ausschliesslich anhand der tatsaechlichen Magic-Bytes am
|
||||
* Dateianfang (analog zu extensionForFormat fuer Bilder via sharp) — nie
|
||||
* ueber den Client-Dateinamen oder den vom Client gesendeten Content-Type,
|
||||
* beide sind frei faelschbar. MP4 traegt seine "ftyp"-Box-Kennung ab Byte 4
|
||||
* (die ersten 4 Bytes sind eine variable Box-Groesse), WebM beginnt mit dem
|
||||
* fixen EBML-Header 0x1A45DFA3.
|
||||
*/
|
||||
export function detectVideoExtension(buffer: Buffer): AllowedVideoExtension | null {
|
||||
if (buffer.length >= 12 && buffer.toString("ascii", 4, 8) === "ftyp") {
|
||||
return "mp4";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 4 &&
|
||||
buffer[0] === 0x1a &&
|
||||
buffer[1] === 0x45 &&
|
||||
buffer[2] === 0xdf &&
|
||||
buffer[3] === 0xa3
|
||||
) {
|
||||
return "webm";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits
|
||||
* validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client-
|
||||
@@ -59,15 +94,17 @@ export function extensionForFormat(format: string | undefined): AllowedImageExte
|
||||
* interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe
|
||||
* app/uploads/[...path]/route.ts.
|
||||
*/
|
||||
const CONTENT_TYPE_BY_EXTENSION: Record<AllowedImageExtension, string> = {
|
||||
const CONTENT_TYPE_BY_EXTENSION: Record<AllowedImageExtension | AllowedVideoExtension, string> = {
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
};
|
||||
|
||||
export function contentTypeForExtension(extension: string): string | null {
|
||||
return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension] ?? null;
|
||||
return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension | AllowedVideoExtension] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,11 +113,12 @@ export function contentTypeForExtension(extension: string): string | null {
|
||||
* (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen)
|
||||
* sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite.
|
||||
*/
|
||||
export function generateUploadFilename(extension: AllowedImageExtension): string {
|
||||
export function generateUploadFilename(extension: AllowedImageExtension | AllowedVideoExtension): string {
|
||||
return `${randomBytes(16).toString("hex")}.${extension}`;
|
||||
}
|
||||
|
||||
const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/;
|
||||
const VIDEO_UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(mp4|webm)$/;
|
||||
|
||||
/**
|
||||
* Prueft, ob ein gespeicherter Bildpfad exakt der Form entspricht, die
|
||||
@@ -95,6 +133,13 @@ export function isValidUploadPath(value: string): boolean {
|
||||
return UPLOAD_PATH_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** Wie isValidUploadPath, aber fuer Video-Feldwerte (siehe home.hero.video
|
||||
* in lib/content-registry.ts) — eigenes Endungs-Set, damit die Bild-
|
||||
* Validierung oben nicht mit-gelockert wird. */
|
||||
export function isValidVideoUploadPath(value: string): boolean {
|
||||
return VIDEO_UPLOAD_PATH_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen
|
||||
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-
|
||||
|
||||
@@ -16,6 +16,13 @@ export interface CartItem {
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
isOpen: boolean;
|
||||
// Client-only: waehrend des Server-Renders und des allerersten Client-
|
||||
// Renders (bevor persist localStorage gelesen hat) ist dies immer false,
|
||||
// damit UI, die auf "items.length === 0" verzweigt (Checkout/Warenkorb),
|
||||
// nicht kurz einen falschen "leerer Warenkorb"-Zustand zeigt, bevor die
|
||||
// echten, aus localStorage wiederhergestellten Positionen ankommen.
|
||||
hasHydrated: boolean;
|
||||
setHasHydrated: (hasHydrated: boolean) => void;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
addItem: (item: Omit<CartItem, "quantity">, quantity?: number) => void;
|
||||
@@ -29,6 +36,8 @@ export const useCartStore = create<CartState>()(
|
||||
(set) => ({
|
||||
items: [],
|
||||
isOpen: false,
|
||||
hasHydrated: false,
|
||||
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
addItem: (item, quantity = 1) =>
|
||||
@@ -64,6 +73,9 @@ export const useCartStore = create<CartState>()(
|
||||
// UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt
|
||||
// werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf).
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setHasHydrated(true);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user