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.
|
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:
|
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).
|
- `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 { prisma } from "@/lib/prisma";
|
||||||
import { logAdminAction } from "@/lib/audit-log";
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||||
import { isValidUploadPath } from "@/lib/uploads";
|
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||||
import { requireAdmin } from "./_shared";
|
import { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +43,11 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
|||||||
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
|
if (def.type === "image" && valueDe !== "" && !isValidUploadPath(valueDe)) {
|
||||||
continue;
|
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 });
|
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}`);
|
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.shipment.upsert({
|
try {
|
||||||
where: { orderId },
|
await prisma.shipment.upsert({
|
||||||
create: { orderId, carrier: "DHL", trackingNumber: result.trackingNumber },
|
where: { orderId },
|
||||||
update: { carrier: "DHL", trackingNumber: result.trackingNumber },
|
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, {
|
await logAdminAction(admin, {
|
||||||
action: "order.dhl_label_created",
|
action: "order.dhl_label_created",
|
||||||
|
|||||||
@@ -82,7 +82,15 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
|||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const id = str(formData, "id");
|
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, {
|
await logAdminAction(admin, {
|
||||||
action: "discount_code.delete",
|
action: "discount_code.delete",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
|||||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||||
import { restockOrderItems } from "@/lib/inventory";
|
import { restockOrderItems } from "@/lib/inventory";
|
||||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
import { requireAdmin, str, strOrNull } from "./_shared";
|
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||||
|
|
||||||
const ORDER_STATUSES = [
|
const ORDER_STATUSES = [
|
||||||
"NEU",
|
"NEU",
|
||||||
@@ -25,7 +25,10 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
const orderId = str(formData, "orderId");
|
const orderId = str(formData, "orderId");
|
||||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
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 result = await prisma.$transaction(async (tx) => {
|
||||||
const previous = await tx.order.findUnique({
|
const previous = await tx.order.findUnique({
|
||||||
@@ -41,12 +44,24 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
|||||||
});
|
});
|
||||||
if (!previous) return null;
|
if (!previous) return null;
|
||||||
|
|
||||||
await tx.order.update({ where: { id: orderId }, data: { status } });
|
if (status === "STORNIERT") {
|
||||||
|
// Atomarer Claim statt einfachem update(): verhindert, dass zwei
|
||||||
// Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht
|
// schnell aufeinanderfolgende Stornierungs-Versuche (z.B. zwei offene
|
||||||
// (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status).
|
// Admin-Tabs auf derselben Bestellung) beide restockOrderItems
|
||||||
if (status === "STORNIERT" && previous.status !== "STORNIERT") {
|
// ausloesen und den Lagerbestand doppelt gutschreiben. Ein getrenntes
|
||||||
await restockOrderItems(tx, previous.items);
|
// 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;
|
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 };
|
return { ...previous, shipment };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "order.status_change",
|
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} />
|
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
||||||
{t.adminHarvestEditPage.isPublishedLabel}
|
{t.adminHarvestEditPage.isPublishedLabel}
|
||||||
</label>
|
</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">
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
{t.adminHarvestEditPage.save}
|
{t.adminHarvestEditPage.save}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { AdminImageField } from "@/components/admin-image-field";
|
import { AdminImageField } from "@/components/admin-image-field";
|
||||||
|
import { AdminVideoField } from "@/components/admin-video-field";
|
||||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||||
import { updateSiteContentAction } from "../actions";
|
import { updateSiteContentAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -13,10 +14,11 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
|||||||
"list-titled": 5,
|
"list-titled": 5,
|
||||||
"list-numbered": 6,
|
"list-numbered": 6,
|
||||||
"richtext-sections": 14,
|
"richtext-sections": 14,
|
||||||
// Bild-Feld hat kein <textarea>, nutzt ROWS_BY_TYPE also nicht — Wert nur
|
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
|
||||||
// vorhanden, damit dieses Record<ContentFieldDef["type"], number> weiter
|
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
|
||||||
// vollstaendig ist.
|
// weiter vollstaendig ist.
|
||||||
image: 0,
|
image: 0,
|
||||||
|
video: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
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 }[] = [
|
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||||
{ key: "home", heading: t.adminContentPage.groupHome },
|
{ key: "home", heading: t.adminContentPage.groupHome },
|
||||||
|
{ key: "pages", heading: t.adminContentPage.groupPages },
|
||||||
{ key: "social", heading: t.adminContentPage.groupSocial },
|
{ key: "social", heading: t.adminContentPage.groupSocial },
|
||||||
{ key: "legal", heading: t.adminContentPage.groupLegal },
|
{ 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 (
|
return (
|
||||||
<label key={def.key} className="block text-sm">
|
<label key={def.key} className="block text-sm">
|
||||||
<span className="text-ink/70">{label}</span>
|
<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>
|
<p className="text-sm text-ink/70 mb-2">{label}</p>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<label className="block text-xs">
|
<label className="block text-xs">
|
||||||
<span className="text-ink/50">{t.adminContentPage.deLabel}</span>
|
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
|
||||||
{isTextarea ? (
|
{isTextarea ? (
|
||||||
<textarea
|
<textarea
|
||||||
name={`de::${def.key}`}
|
name={`de::${def.key}`}
|
||||||
@@ -126,7 +139,7 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs">
|
<label className="block text-xs">
|
||||||
<span className="text-ink/50">{t.adminContentPage.enLabel}</span>
|
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
|
||||||
{isTextarea ? (
|
{isTextarea ? (
|
||||||
<textarea
|
<textarea
|
||||||
name={`en::${def.key}`}
|
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 }];
|
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({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
customer_email: input.email,
|
customer_email: input.email,
|
||||||
@@ -329,10 +337,24 @@ export async function POST(request: Request) {
|
|||||||
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.order.update({
|
// Best-effort: stripeCheckoutSessionId wird aktuell von keinem Lesepfad
|
||||||
where: { id: order.id },
|
// ausgewertet (fulfillOrder im Webhook identifiziert die Bestellung ueber
|
||||||
data: { stripeCheckoutSessionId: session.id },
|
// 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 });
|
return NextResponse.json({ url: session.url });
|
||||||
} catch (error: unknown) {
|
} 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
|
// doppelt zaehlen, und verhindert, dass eine im Admin-Bereich bereits
|
||||||
// stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete
|
// stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete
|
||||||
// Zahlungsbestaetigung faelschlich wieder auf "BEZAHLT" gesetzt wird.
|
// 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 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" },
|
where: { id: orderId, status: "NEU" },
|
||||||
data: { status: "BEZAHLT" },
|
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({
|
const updated = await tx.order.findUniqueOrThrow({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
include: { items: true },
|
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({
|
await tx.discountCode.update({
|
||||||
where: { id: updated.discountCodeId },
|
where: { id: updated.discountCodeId },
|
||||||
data: { usedCount: { increment: 1 } },
|
data: { usedCount: { increment: 1 } },
|
||||||
@@ -105,61 +129,71 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bereits verarbeitet (Webhook-Replay) oder Bestellung war nicht mehr
|
// Bestellung existiert nicht (mehr) oder war weder "NEU" noch "BEZAHLT"
|
||||||
// im Status "NEU" (z.B. schon storniert) — nichts weiter zu tun.
|
// (z.B. bereits storniert) — nichts weiter zu tun.
|
||||||
if (!order) return;
|
if (!order) return;
|
||||||
|
|
||||||
await prisma.payment.upsert({
|
try {
|
||||||
where: { orderId: order.id },
|
await prisma.payment.upsert({
|
||||||
create: {
|
where: { orderId: order.id },
|
||||||
orderId: order.id,
|
create: {
|
||||||
stripePaymentIntentId:
|
orderId: order.id,
|
||||||
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
stripePaymentIntentId:
|
||||||
status: "SUCCEEDED",
|
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
||||||
amountCents: session.amount_total ?? order.totalCents,
|
status: "SUCCEEDED",
|
||||||
paidAt: new Date(),
|
amountCents: session.amount_total ?? order.totalCents,
|
||||||
},
|
paidAt: new Date(),
|
||||||
update: {
|
},
|
||||||
status: "SUCCEEDED",
|
update: {
|
||||||
paidAt: new Date(),
|
status: "SUCCEEDED",
|
||||||
},
|
paidAt: new Date(),
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.invoice.upsert({
|
await prisma.invoice.upsert({
|
||||||
where: { orderId: order.id },
|
where: { orderId: order.id },
|
||||||
create: {
|
create: {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
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,
|
totalCents: order.totalCents,
|
||||||
},
|
street: order.street,
|
||||||
update: {},
|
zip: order.zip,
|
||||||
});
|
city: order.city,
|
||||||
|
giftMessage: order.giftMessage,
|
||||||
// E-Mail-Fehler duerfen die Zahlungsbestaetigung selbst nicht scheitern
|
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||||
// lassen — Stripe wiederholt Webhooks bei Fehlerantworten, ein
|
});
|
||||||
// Mailserver-Ausfall soll das nicht auf die Bestellabwicklung durchschlagen.
|
await sendMail({ to: order.email, subject, html, text });
|
||||||
const { subject, html, text } = await orderConfirmationEmail({
|
} catch (error: unknown) {
|
||||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
||||||
orderNumber: order.orderNumber,
|
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
||||||
firstName: order.firstName,
|
// an Stripe loest den naechsten automatischen Retry aus, der dank des
|
||||||
fulfillmentMethod: order.fulfillmentMethod,
|
// "BEZAHLT"-Zweigs oben Payment/Invoice/E-Mail dann nachholen kann,
|
||||||
items: order.items.map((item) => ({
|
// statt sie fuer immer zu verlieren.
|
||||||
productName: item.productName,
|
console.error(
|
||||||
quantity: item.quantity,
|
`fulfillOrder: Payment/Invoice/Bestaetigungsmail fehlgeschlagen fuer Order ${order.id} (Stripe-Session ${session.id})`,
|
||||||
weightGrams: item.weightGrams,
|
error
|
||||||
totalCents: item.totalCents,
|
);
|
||||||
})),
|
throw error;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
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;
|
if (!orderId) return;
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
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 },
|
where: { id: orderId },
|
||||||
select: {
|
select: {
|
||||||
status: true,
|
|
||||||
orderNumber: true,
|
orderNumber: true,
|
||||||
email: true,
|
email: true,
|
||||||
firstName: true,
|
firstName: true,
|
||||||
@@ -178,12 +223,6 @@ async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
|||||||
items: { select: { productId: true, quantity: true } },
|
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
|
// Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie
|
||||||
// bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts).
|
// 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 { getActiveProducts } from "@/lib/data";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
const SUGGESTION_LIMIT = 5;
|
const SUGGESTION_LIMIT = 5;
|
||||||
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
|
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
|
||||||
@@ -26,11 +27,12 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ suggestions: [] });
|
return NextResponse.json({ suggestions: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
const products = await getActiveProducts({ query, onlyAvailable: true });
|
const products = await getActiveProducts({ query, onlyAvailable: true });
|
||||||
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
|
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
|
||||||
slug: product.slug,
|
slug: product.slug,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
price: formatPrice(product.priceCents, product.currency),
|
price: formatPrice(product.priceCents, product.currency, locale),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ suggestions });
|
return NextResponse.json({ suggestions });
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const TRACK_ATTEMPTS_PER_WINDOW = 10;
|
|||||||
const TRACK_WINDOW_MS = 15 * 60 * 1000;
|
const TRACK_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) {
|
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 { order: orderNumber, email } = await props.searchParams;
|
||||||
const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email;
|
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 className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-forest">{order.orderNumber}</p>
|
<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>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<OrderStatusBadge
|
<OrderStatusBadge
|
||||||
@@ -90,12 +91,12 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
<p className="mt-1.5 text-xs text-ink/65">
|
<p className="mt-1.5 text-xs text-ink/65">
|
||||||
{order.fulfillmentMethod === "VERSAND"
|
{order.fulfillmentMethod === "VERSAND"
|
||||||
? order.shipment?.shippedAt
|
? order.shipment?.shippedAt
|
||||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
|
||||||
: t.ordersPage.shippingPending
|
: t.ordersPage.shippingPending
|
||||||
: order.pickup?.pickedUpAt
|
: order.pickup?.pickedUpAt
|
||||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
|
||||||
: order.pickup?.readyAt
|
: order.pickup?.readyAt
|
||||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
|
||||||
: t.ordersPage.pickupPending}
|
: t.ordersPage.pickupPending}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||||
{order.items.map((item) => (
|
{order.items.map((item) => (
|
||||||
<li key={item.id}>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -147,7 +151,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-4">
|
<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 ? (
|
{order.invoice ? (
|
||||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||||
{t.trackOrderPage.viewInvoice}
|
{t.trackOrderPage.viewInvoice}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Container } from "@/components/ui";
|
import { Container } from "@/components/ui";
|
||||||
import { ContentImage } from "@/components/content-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
import { ShieldIcon } from "@/components/account-ui";
|
import { ShieldIcon } from "@/components/account-ui";
|
||||||
import { ProductTeaserLink } from "@/components/product-card";
|
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.
|
* in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft.
|
||||||
*/
|
*/
|
||||||
export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
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 { code } = await props.params;
|
||||||
const batch = await getBatchByCode(code);
|
const batch = await getBatchByCode(code);
|
||||||
if (!batch) notFound();
|
if (!batch) notFound();
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate) },
|
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate, locale) },
|
||||||
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate) },
|
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate, locale) },
|
||||||
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate) },
|
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate, locale) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-3xl">
|
<Container className="max-w-3xl">
|
||||||
<div className="flex items-center gap-2 text-honey-dark">
|
<ScrollReveal>
|
||||||
<ShieldIcon />
|
<div className="flex items-center gap-2 text-honey-dark">
|
||||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
<ShieldIcon />
|
||||||
</div>
|
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||||
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
</div>
|
||||||
{batch.harvest.type} {batch.harvest.year}
|
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||||
</h1>
|
{batch.harvest.type} {batch.harvest.year}
|
||||||
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
|
</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">
|
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||||
<ContentImage
|
<ScrollReveal>
|
||||||
src={batch.harvest.images[0] ?? null}
|
<ContentImage
|
||||||
alt={`${batch.harvest.type} ${batch.harvest.year}`}
|
src={batch.harvest.images[0] ?? null}
|
||||||
tone="honey"
|
alt={`${batch.harvest.type} ${batch.harvest.year}`}
|
||||||
className="aspect-square"
|
tone="honey"
|
||||||
label={`${batch.harvest.type} ${batch.harvest.year}`}
|
className="aspect-square"
|
||||||
/>
|
label={`${batch.harvest.type} ${batch.harvest.year}`}
|
||||||
<ContentImage
|
sizes="(min-width: 640px) 50vw, 100vw"
|
||||||
src={batch.harvest.images[1] ?? null}
|
/>
|
||||||
alt={batch.harvest.location}
|
</ScrollReveal>
|
||||||
tone="forest"
|
<ScrollReveal delay={0.1}>
|
||||||
className="aspect-square"
|
<ContentImage
|
||||||
label={batch.harvest.location}
|
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>
|
||||||
|
|
||||||
<div className="mt-14">
|
<ScrollReveal className="mt-14">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{steps.map((step, i) => (
|
{steps.map((step, i) => (
|
||||||
<div key={step.label} className="relative">
|
<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" />
|
<span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
<p className="mt-1 text-forest">{step.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollReveal>
|
||||||
|
|
||||||
<div className="mt-10 border-t border-ink/10 pt-8">
|
<ScrollReveal 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>
|
<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="text-ink/80">{batch.harvest.location}</p>
|
||||||
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</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" />
|
<TrustBadges className="mt-10 pt-8 border-t border-ink/10" />
|
||||||
|
|
||||||
{batch.products.length > 0 ? (
|
{batch.products.length > 0 ? (
|
||||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
<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">
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
{batch.products.map((product) => (
|
{batch.products.map((product) => (
|
||||||
<ProductTeaserLink
|
<ProductTeaserLink
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||||
|
import CheckoutLoading from "./loading";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
@@ -31,8 +32,9 @@ export function CheckoutForm({
|
|||||||
tiers: ShippingTierLike[];
|
tiers: ShippingTierLike[];
|
||||||
bulkDiscountTiers: BulkDiscountTierLike[];
|
bulkDiscountTiers: BulkDiscountTierLike[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
|
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||||
const subtotal = cartTotalCents(items);
|
const subtotal = cartTotalCents(items);
|
||||||
const totalQuantity = cartItemCount(items);
|
const totalQuantity = cartItemCount(items);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -41,6 +43,17 @@ export function CheckoutForm({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isGift, setIsGift] = useState(false);
|
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
|
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
||||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
// 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) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
@@ -115,15 +137,37 @@ export function CheckoutForm({
|
|||||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<Field name="firstName" label={t.checkoutPage.firstName} defaultValue={initialValues?.firstName} required />
|
<Field
|
||||||
<Field name="lastName" label={t.checkoutPage.lastName} defaultValue={initialValues?.lastName} required />
|
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>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<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
|
<Field
|
||||||
name="phone"
|
name="phone"
|
||||||
label={t.checkoutPage.phoneOptional}
|
label={t.checkoutPage.phoneOptional}
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
defaultValue={initialValues?.phone}
|
defaultValue={initialValues?.phone}
|
||||||
|
autoComplete="tel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +190,7 @@ export function CheckoutForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{fulfillment === "VERSAND" ? (
|
{fulfillment === "VERSAND" ? (
|
||||||
<p className="mt-2 text-xs text-ink/60">
|
<p className="mt-2 text-xs text-ink/70">
|
||||||
{t.checkoutPage.deliveryTimeNote}
|
{t.checkoutPage.deliveryTimeNote}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -158,16 +202,35 @@ export function CheckoutForm({
|
|||||||
name="street"
|
name="street"
|
||||||
label={t.checkoutPage.streetLabel}
|
label={t.checkoutPage.streetLabel}
|
||||||
defaultValue={initialValues?.street}
|
defaultValue={initialValues?.street}
|
||||||
|
autoComplete="street-address"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<Field name="zip" label={t.checkoutPage.zipLabel} defaultValue={initialValues?.zip} required />
|
<Field
|
||||||
<Field name="city" label={t.checkoutPage.cityLabel} defaultValue={initialValues?.city} required />
|
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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
<div>
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
||||||
@@ -193,7 +256,11 @@ export function CheckoutForm({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||||
@@ -211,14 +278,14 @@ export function CheckoutForm({
|
|||||||
<span>
|
<span>
|
||||||
{item.quantity} × {item.name}
|
{item.quantity} × {item.name}
|
||||||
</span>
|
</span>
|
||||||
<span>{formatPrice(item.priceCents * item.quantity)}</span>
|
<span>{formatPrice(item.priceCents * item.quantity, "EUR", locale)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
|
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
|
||||||
<div className="flex justify-between text-ink/70">
|
<div className="flex justify-between text-ink/70">
|
||||||
<span>{t.cart.subtotal}</span>
|
<span>{t.cart.subtotal}</span>
|
||||||
<span>{formatPrice(subtotal)}</span>
|
<span>{formatPrice(subtotal, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-ink/70">
|
<div className="flex justify-between text-ink/70">
|
||||||
<span>
|
<span>
|
||||||
@@ -226,20 +293,20 @@ export function CheckoutForm({
|
|||||||
? t.checkoutPage.shippingWithCount(totalQuantity)
|
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||||||
: t.checkoutPage.shippingPickup}
|
: t.checkoutPage.shippingPickup}
|
||||||
</span>
|
</span>
|
||||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
|
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
{activeBulkDiscount ? (
|
{activeBulkDiscount ? (
|
||||||
<div className="flex justify-between text-forest">
|
<div className="flex justify-between text-forest">
|
||||||
<span>
|
<span>
|
||||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||||
</span>
|
</span>
|
||||||
<span>-{formatPrice(bulkDiscountCents)}</span>
|
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
||||||
<span>{t.cartPage.total}</span>
|
<span>{t.cartPage.total}</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice(total, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -252,15 +319,21 @@ function Field({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
type = "text",
|
type = "text",
|
||||||
|
inputMode,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
autoComplete,
|
||||||
required = false,
|
required = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
/** Zeigt auf Mobilgeraeten die passende Bildschirmtastatur (z.B. Ziffernblock fuer die PLZ). */
|
||||||
|
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
placeholder?: 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;
|
required?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -269,8 +342,10 @@ function Field({
|
|||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
type={type}
|
type={type}
|
||||||
|
inputMode={inputMode}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autoComplete={autoComplete}
|
||||||
required={required}
|
required={required}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
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 Link from "next/link";
|
||||||
import { Container } from "@/components/ui";
|
import { Container } from "@/components/ui";
|
||||||
import { ContentImage } from "@/components/content-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ProductTeaserLink } from "@/components/product-card";
|
import { ProductTeaserLink } from "@/components/product-card";
|
||||||
import { getHarvestByYearType } from "@/lib/data";
|
import { getHarvestByYearType } from "@/lib/data";
|
||||||
@@ -30,7 +31,8 @@ export async function generateMetadata(
|
|||||||
export default async function HarvestDetailPage(
|
export default async function HarvestDetailPage(
|
||||||
props: PageProps<"/ernten/[year]/[type]">
|
props: PageProps<"/ernten/[year]/[type]">
|
||||||
) {
|
) {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const { year, type } = await props.params;
|
const { year, type } = await props.params;
|
||||||
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
|
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
|
||||||
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
|
// 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} />
|
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||||
</Container>
|
</Container>
|
||||||
<Container className="grid md:grid-cols-2 gap-14">
|
<Container className="grid md:grid-cols-2 gap-14">
|
||||||
<ContentImage
|
<ScrollReveal>
|
||||||
src={harvest.images[0] ?? null}
|
<ContentImage
|
||||||
alt={harvest.type}
|
src={harvest.images[0] ?? null}
|
||||||
tone="forest"
|
alt={harvest.type}
|
||||||
className="aspect-[4/5] w-full"
|
tone="forest"
|
||||||
label={harvest.location}
|
className="aspect-[4/5] w-full"
|
||||||
/>
|
label={harvest.location}
|
||||||
<div>
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
|
<ScrollReveal delay={0.1}>
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||||
{harvest.year} · {formatDate(harvest.harvestDate)}
|
{harvest.year} · {formatDate(harvest.harvestDate, locale)}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl text-forest">{harvest.type}</h1>
|
<h1 className="text-4xl text-forest">{harvest.type}</h1>
|
||||||
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
|
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||||
@@ -85,7 +90,7 @@ export default async function HarvestDetailPage(
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollReveal>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+27
-19
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
import { ContentImage } from "@/components/content-image";
|
import { ContentImage } from "@/components/content-image";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { getHarvests } from "@/lib/data";
|
import { getHarvests } from "@/lib/data";
|
||||||
import { formatMonthYear } from "@/lib/format";
|
import { formatMonthYear } from "@/lib/format";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -17,7 +18,8 @@ export const metadata: Metadata = {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ErntenPage() {
|
export default async function ErntenPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const harvests = await getHarvests();
|
const harvests = await getHarvests();
|
||||||
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
|
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
|
||||||
acc[h.year] = [...(acc[h.year] ?? []), h];
|
acc[h.year] = [...(acc[h.year] ?? []), h];
|
||||||
@@ -28,29 +30,35 @@ export default async function ErntenPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container>
|
<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">
|
<div className="mt-16 space-y-16">
|
||||||
{years.map((year) => (
|
{years.map((year) => (
|
||||||
<div key={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">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{byYear[year].map((harvest) => (
|
{byYear[year].map((harvest, i) => (
|
||||||
<Link
|
<ScrollReveal key={harvest.id} delay={(i % 3) * 0.08}>
|
||||||
key={harvest.id}
|
<Link
|
||||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||||
className="group"
|
className="group block"
|
||||||
>
|
>
|
||||||
<ContentImage
|
<ContentImage
|
||||||
src={harvest.images[0] ?? null}
|
src={harvest.images[0] ?? null}
|
||||||
alt={harvest.type}
|
alt={harvest.type}
|
||||||
tone="forest"
|
tone="forest"
|
||||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||||
label={formatMonthYear(harvest.harvestDate)}
|
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>
|
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
|
||||||
</Link>
|
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
|
||||||
|
</Link>
|
||||||
|
</ScrollReveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-12
@@ -1,8 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
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 { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { getContentValue } from "@/lib/content";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
@@ -14,27 +16,34 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function HerkunftPage() {
|
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 (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<PlaceholderImage
|
<ContentImage
|
||||||
|
src={image || null}
|
||||||
|
alt={t.herkunftPage.eyebrow}
|
||||||
tone="forest"
|
tone="forest"
|
||||||
className="h-[50vh] min-h-[360px] w-full mb-20"
|
className="h-[50vh] min-h-[360px] w-full mb-20"
|
||||||
label={t.herkunftPage.eyebrow}
|
label={t.herkunftPage.eyebrow}
|
||||||
|
priority
|
||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<SectionHeading
|
<ScrollReveal>
|
||||||
level="h1"
|
<SectionHeading
|
||||||
eyebrow={t.herkunftPage.eyebrow}
|
level="h1"
|
||||||
title={t.herkunftPage.title}
|
eyebrow={t.herkunftPage.eyebrow}
|
||||||
subtitle={t.herkunftPage.subtitle}
|
title={t.herkunftPage.title}
|
||||||
/>
|
subtitle={t.herkunftPage.subtitle}
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
||||||
{t.herkunftPage.aspects.map((a) => (
|
{t.herkunftPage.aspects.map((a, i) => (
|
||||||
<div key={a.title}>
|
<ScrollReveal key={a.title} delay={i * 0.08}>
|
||||||
<h3 className="text-lg text-forest">{a.title}</h3>
|
<h3 className="text-lg text-forest">{a.title}</h3>
|
||||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
||||||
</div>
|
</ScrollReveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-16 text-xs text-ink/65 max-w-md">
|
<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 { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, Button } from "@/components/ui";
|
import { Container, Button } from "@/components/ui";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { ProductGallery } from "@/components/product-gallery";
|
import { ProductGallery } from "@/components/product-gallery";
|
||||||
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
|
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
|
||||||
import { RecordProductView } from "@/components/record-product-view";
|
import { RecordProductView } from "@/components/record-product-view";
|
||||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||||
import { StarRating } from "@/components/star-rating";
|
import { StarRating } from "@/components/star-rating";
|
||||||
import { TrustBadges } from "@/components/trust-badges";
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
|
import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar";
|
||||||
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
||||||
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
||||||
import { siteConfig } from "@/lib/site-config";
|
import { siteConfig } from "@/lib/site-config";
|
||||||
@@ -16,9 +18,12 @@ import { getCompanyInfo } from "@/lib/company-info";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
||||||
import { isProductWishlisted } from "@/lib/wishlist";
|
import { isProductWishlisted } from "@/lib/wishlist";
|
||||||
|
import { getShippingTiers } from "@/lib/shipping";
|
||||||
|
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import type { WissenSlug } from "@/i18n/dictionary-type";
|
||||||
|
|
||||||
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
|
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
|
||||||
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
|
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
|
||||||
@@ -42,27 +47,45 @@ export async function generateMetadata(
|
|||||||
export default async function ProductDetailPage(
|
export default async function ProductDetailPage(
|
||||||
props: PageProps<"/honig/[slug]">
|
props: PageProps<"/honig/[slug]">
|
||||||
) {
|
) {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const { slug } = await props.params;
|
const { slug } = await props.params;
|
||||||
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
||||||
const product = await getProductBySlug(slug);
|
const product = await getProductBySlug(slug);
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
|
||||||
const isHoney = product.category === "HONIG" && product.harvest && product.batch;
|
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 stock = product.inventory?.quantity ?? 0;
|
||||||
const inStock = stock > 0;
|
const inStock = stock > 0;
|
||||||
const lowStock = inStock && stock <= 5;
|
const lowStock = inStock && stock <= 5;
|
||||||
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company] = await Promise.all([
|
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company, shippingTiers] =
|
||||||
getApprovedReviews(product.id),
|
await Promise.all([
|
||||||
getReviewSummary(product.id),
|
getApprovedReviews(product.id),
|
||||||
getRelatedProducts(product.id, product.category),
|
getReviewSummary(product.id),
|
||||||
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
|
getRelatedProducts(product.id, product.category),
|
||||||
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
|
||||||
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
||||||
getCompanyInfo(),
|
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 = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -72,6 +95,14 @@ export default async function ProductDetailPage(
|
|||||||
sku: product.batch?.code ?? product.slug,
|
sku: product.batch?.code ?? product.slug,
|
||||||
brand: { "@type": "Brand", name: company.name },
|
brand: { "@type": "Brand", name: company.name },
|
||||||
category: t.common.categories[product.category],
|
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: {
|
offers: {
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
priceCurrency: product.currency,
|
priceCurrency: product.currency,
|
||||||
@@ -110,8 +141,9 @@ export default async function ProductDetailPage(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-16">
|
<div className="py-16 pb-28 md:pb-16">
|
||||||
<RecordProductView productId={product.id} />
|
<RecordProductView productId={product.id} />
|
||||||
|
<StickyAddToCartBar product={product} inStock={inStock} />
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
||||||
@@ -123,7 +155,11 @@ export default async function ProductDetailPage(
|
|||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||||
/>
|
/>
|
||||||
<Container className="grid md:grid-cols-2 gap-14">
|
<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>
|
<div>
|
||||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
<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">
|
<div className="mt-6 flex items-baseline gap-3">
|
||||||
<span className="text-2xl text-forest">
|
<span className="text-2xl text-forest">
|
||||||
{formatPrice(product.priceCents, product.currency)}
|
{formatPrice(product.priceCents, product.currency, locale)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink/65">
|
<p className="text-xs text-ink/65">
|
||||||
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||||
</p>
|
</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 ? (
|
{isHoney ? (
|
||||||
<div className="mt-8 space-y-3">
|
<div className="mt-8 space-y-3">
|
||||||
@@ -241,7 +286,10 @@ export default async function ProductDetailPage(
|
|||||||
label={t.productPage.detailLabels.origin}
|
label={t.productPage.detailLabels.origin}
|
||||||
value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`}
|
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.batch} value={product.batch.code} />
|
||||||
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
|
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
|
||||||
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
|
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
|
||||||
@@ -251,10 +299,24 @@ export default async function ProductDetailPage(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{isHoney && product.batch ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/charge/${product.batch.code}`}
|
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}
|
{t.productPage.viewBatchOrigin}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -263,7 +325,9 @@ export default async function ProductDetailPage(
|
|||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
<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 ? (
|
{error ? (
|
||||||
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
<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>
|
<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}
|
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
|
||||||
<p className="mt-2 text-xs text-ink/65">
|
<p className="mt-2 text-xs text-ink/65">
|
||||||
{review.customer.firstName} · {formatDate(review.createdAt)}
|
{review.customer.firstName} · {formatDate(review.createdAt, locale)}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -343,7 +407,9 @@ export default async function ProductDetailPage(
|
|||||||
|
|
||||||
{relatedProducts.length > 0 ? (
|
{relatedProducts.length > 0 ? (
|
||||||
<Container className="mt-20">
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||||
{relatedProducts.map((related) => (
|
{relatedProducts.map((related) => (
|
||||||
<ProductCard key={related.id} product={related} />
|
<ProductCard key={related.id} product={related} />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
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 { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { getContentValue } from "@/lib/content";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
@@ -14,11 +16,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ImkereiPage() {
|
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 (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||||
<div>
|
<ScrollReveal>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
level="h1"
|
level="h1"
|
||||||
eyebrow={t.imkereiPage.eyebrow}
|
eyebrow={t.imkereiPage.eyebrow}
|
||||||
@@ -29,8 +33,17 @@ export default async function ImkereiPage() {
|
|||||||
<p key={paragraph}>{paragraph}</p>
|
<p key={paragraph}>{paragraph}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollReveal>
|
||||||
<PlaceholderImage tone="honey" className="aspect-[4/5] w-full" label={t.imkereiPage.imageLabel} />
|
<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>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { getCompanyInfo } from "@/lib/company-info";
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -19,9 +20,11 @@ export default async function KontaktPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-2xl">
|
<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.name}</p>
|
||||||
<p>{company.address.street}</p>
|
<p>{company.address.street}</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -33,14 +36,14 @@ export default async function KontaktPage() {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>{company.phone}</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>
|
<h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2>
|
||||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||||
{t.kontaktPage.pickupText}
|
{t.kontaktPage.pickupText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</ScrollReveal>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,13 +123,19 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
if (!authenticated && code.length > 0) {
|
if (!authenticated && code.length > 0) {
|
||||||
const codeHash = hashBackupCode(code.toUpperCase());
|
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 },
|
where: { customerId: customer.id, codeHash, usedAt: null },
|
||||||
|
data: { usedAt: new Date() },
|
||||||
});
|
});
|
||||||
if (backupCode) {
|
authenticated = claim.count === 1;
|
||||||
await prisma.twoFactorBackupCode.update({ where: { id: backupCode.id }, data: { usedAt: new Date() } });
|
|
||||||
authenticated = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import type { HTMLAttributes } from "react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
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">
|
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
<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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field name="firstName" label={t.account.firstNameLabel} required />
|
<Field name="firstName" label={t.account.firstNameLabel} autoComplete="given-name" required />
|
||||||
<Field name="lastName" label={t.account.lastNameLabel} required />
|
<Field name="lastName" label={t.account.lastNameLabel} autoComplete="family-name" required />
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field name="zip" label={t.checkoutPage.zipLabel} required />
|
<Field
|
||||||
<Field name="city" label={t.checkoutPage.cityLabel} required />
|
name="zip"
|
||||||
|
label={t.checkoutPage.zipLabel}
|
||||||
|
autoComplete="postal-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field name="city" label={t.checkoutPage.cityLabel} autoComplete="address-level2" required />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
<input type="checkbox" name="isDefault" />
|
<input type="checkbox" name="isDefault" />
|
||||||
@@ -110,11 +123,16 @@ function Field({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
autoComplete,
|
||||||
|
inputMode,
|
||||||
required = false,
|
required = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
|
/** Browser-/Mobil-Autofill fuer Adressfelder — dieselben Tokens wie im Checkout-Formular. */
|
||||||
|
autoComplete?: string;
|
||||||
|
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -123,6 +141,8 @@ function Field({
|
|||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
inputMode={inputMode}
|
||||||
required={required}
|
required={required}
|
||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
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">) {
|
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 statusLabels = t.orderStatusBadge;
|
||||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||||
const { error, message } = await props.searchParams;
|
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 className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-forest">{order.orderNumber}</p>
|
<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>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||||
<p className="mt-1.5 text-xs text-ink/65">
|
<p className="mt-1.5 text-xs text-ink/65">
|
||||||
{order.fulfillmentMethod === "VERSAND"
|
{order.fulfillmentMethod === "VERSAND"
|
||||||
? order.shipment?.shippedAt
|
? order.shipment?.shippedAt
|
||||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt, locale))
|
||||||
: t.ordersPage.shippingPending
|
: t.ordersPage.shippingPending
|
||||||
: order.pickup?.pickedUpAt
|
: order.pickup?.pickedUpAt
|
||||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt, locale))
|
||||||
: order.pickup?.readyAt
|
: order.pickup?.readyAt
|
||||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
? t.ordersPage.readySince(formatDate(order.pickup.readyAt, locale))
|
||||||
: t.ordersPage.pickupPending}
|
: t.ordersPage.pickupPending}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||||
{order.items.map((item) => (
|
{order.items.map((item) => (
|
||||||
<li key={item.id}>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -135,7 +139,7 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{order.invoice ? (
|
{order.invoice ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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" />
|
<span className="h-px flex-1 bg-ink/10" />
|
||||||
{t.account.ssoDivider}
|
{t.account.ssoDivider}
|
||||||
<span className="h-px flex-1 bg-ink/10" />
|
<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)}{" "}
|
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}{" "}
|
||||||
<span className="font-medium">{NEWSLETTER_DISCOUNT_CODE}</span>
|
<span className="font-medium">{NEWSLETTER_DISCOUNT_CODE}</span>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -29,7 +29,10 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const [heroContent, company] = await Promise.all([
|
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(),
|
getCompanyInfo(),
|
||||||
]);
|
]);
|
||||||
const products = await getActiveProducts();
|
const products = await getActiveProducts();
|
||||||
@@ -49,6 +52,7 @@ export default async function HomePage() {
|
|||||||
footnote={heroContent["home.hero.footnote"]}
|
footnote={heroContent["home.hero.footnote"]}
|
||||||
location={company.location}
|
location={company.location}
|
||||||
image={heroContent["home.hero.image"] || null}
|
image={heroContent["home.hero.image"] || null}
|
||||||
|
video={heroContent["home.hero.video"] || null}
|
||||||
/>
|
/>
|
||||||
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
|
||||||
<OriginStory />
|
<OriginStory />
|
||||||
|
|||||||
+33
-10
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { ProductCard } from "@/components/product-card";
|
import { ProductCard } from "@/components/product-card";
|
||||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||||
import { getActiveProducts, type ProductSort } from "@/lib/data";
|
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") }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<SectionHeading
|
<ScrollReveal>
|
||||||
level="h1"
|
<SectionHeading
|
||||||
eyebrow={t.shop.eyebrow}
|
level="h1"
|
||||||
title={t.shop.title}
|
eyebrow={t.shop.eyebrow}
|
||||||
subtitle={t.shop.subtitle}
|
title={t.shop.title}
|
||||||
/>
|
subtitle={t.shop.subtitle}
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
{categoryFilters.map((filter) => {
|
{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">
|
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||||
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
||||||
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen. */}
|
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">
|
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
|
||||||
{query ? <input type="hidden" name="q" value={query} /> : null}
|
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}
|
{category ? <input type="hidden" name="kategorie" value={category} /> : null}
|
||||||
{onlyAvailable ? <input type="hidden" name="verfuegbar" value="1" /> : 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>
|
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
||||||
<select
|
<select
|
||||||
id="sortierung"
|
id="sortierung"
|
||||||
@@ -157,7 +174,13 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
|||||||
|
|
||||||
{query ? (
|
{query ? (
|
||||||
<p className="mt-6 text-sm text-ink/70">
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
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 { BusinessInquiryForm } from "@/components/business-inquiry-form";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { getContentValue } from "@/lib/content";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
@@ -15,31 +17,42 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function UnternehmenPage() {
|
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 (
|
return (
|
||||||
<div>
|
<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">
|
<Container className="py-20">
|
||||||
<SectionHeading
|
<ScrollReveal>
|
||||||
level="h1"
|
<SectionHeading
|
||||||
eyebrow={t.unternehmenPage.eyebrow}
|
level="h1"
|
||||||
title={t.unternehmenPage.title}
|
eyebrow={t.unternehmenPage.eyebrow}
|
||||||
subtitle={t.unternehmenPage.subtitle}
|
title={t.unternehmenPage.title}
|
||||||
/>
|
subtitle={t.unternehmenPage.subtitle}
|
||||||
|
/>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
<div className="mt-14 grid sm:grid-cols-3 gap-8">
|
<div className="mt-14 grid sm:grid-cols-3 gap-8">
|
||||||
{t.unternehmenPage.packages.map((pkg) => (
|
{t.unternehmenPage.packages.map((pkg, i) => (
|
||||||
<div key={pkg.key} className="border border-ink/10 p-8">
|
<ScrollReveal key={pkg.key} delay={i * 0.08} className="border border-ink/10 p-8">
|
||||||
<h3 className="text-lg text-forest">{pkg.title}</h3>
|
<h3 className="text-lg text-forest">{pkg.title}</h3>
|
||||||
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
|
<p className="mt-2 text-sm text-ink/65">{pkg.text}</p>
|
||||||
</div>
|
</ScrollReveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
<h2 className="text-2xl text-forest mb-8">{t.unternehmenPage.inquiryHeading}</h2>
|
||||||
<BusinessInquiryForm />
|
<BusinessInquiryForm />
|
||||||
</div>
|
</ScrollReveal>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ export const metadata: Metadata = { title: "Versand und Zahlung", alternates: {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function VersandZahlungPage() {
|
export default async function VersandZahlungPage() {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
const tiers = await getShippingTiers();
|
const tiers = await getShippingTiers();
|
||||||
|
|
||||||
const tierParagraphs =
|
const tierParagraphs =
|
||||||
tiers.length > 0
|
tiers.length > 0
|
||||||
? tiers.map((tier) =>
|
? tiers.map((tier) =>
|
||||||
tier.maxQuantity === null
|
tier.maxQuantity === null
|
||||||
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents))
|
? t.versandZahlungPage.tierMinimum(tier.minQuantity, formatPrice(tier.priceCents, "EUR", locale))
|
||||||
: t.versandZahlungPage.tierRange(tier.minQuantity, tier.maxQuantity, formatPrice(tier.priceCents))
|
: t.versandZahlungPage.tierRange(
|
||||||
|
tier.minQuantity,
|
||||||
|
tier.maxQuantity,
|
||||||
|
formatPrice(tier.priceCents, "EUR", locale)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
: [t.versandZahlungPage.tierFallback];
|
: [t.versandZahlungPage.tierFallback];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect, beforeEach } from "vitest";
|
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { WarenkorbContent } from "./warenkorb-content";
|
import { WarenkorbContent } from "./warenkorb-content";
|
||||||
import { useCartStore, type CartItem } from "@/store/cart-store";
|
import { useCartStore, type CartItem } from "@/store/cart-store";
|
||||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||||
@@ -35,8 +35,16 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useCartStore.setState({ items: [], isOpen: false });
|
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", () => {
|
describe("WarenkorbContent", () => {
|
||||||
test("shows the empty-cart message when there are no items", () => {
|
test("shows the empty-cart message when there are no items", () => {
|
||||||
renderCart([]);
|
renderCart([]);
|
||||||
@@ -62,4 +70,27 @@ describe("WarenkorbContent", () => {
|
|||||||
expect(screen.getByText(/Mengenrabatt/)).toBeInTheDocument();
|
expect(screen.getByText(/Mengenrabatt/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("-3,00 €")).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 { QuantityStepper } from "@/components/quantity-stepper";
|
||||||
import { ProductCard } from "@/components/product-card";
|
import { ProductCard } from "@/components/product-card";
|
||||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
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 { formatPrice } from "@/lib/format";
|
||||||
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
import { resolveBulkDiscount, getNextBulkDiscountTier, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
@@ -24,10 +26,15 @@ export function WarenkorbContent({
|
|||||||
availableProducts: ProductWithRelations[];
|
availableProducts: ProductWithRelations[];
|
||||||
bulkDiscountTiers: BulkDiscountTierLike[];
|
bulkDiscountTiers: BulkDiscountTierLike[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
|
const hasHydrated = useCartStore((s) => s.hasHydrated);
|
||||||
const setQuantity = useCartStore((s) => s.setQuantity);
|
const setQuantity = useCartStore((s) => s.setQuantity);
|
||||||
const removeItem = useCartStore((s) => s.removeItem);
|
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);
|
const subtotal = cartTotalCents(items);
|
||||||
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
|
// Gesamtmenge ueber alle Positionen hinweg — mehrere verschiedene Artikel
|
||||||
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
|
// werden fuer die Versandstaffel zusammengezaehlt, nicht getrennt bewertet.
|
||||||
@@ -48,6 +55,12 @@ export function WarenkorbContent({
|
|||||||
.filter((product) => !cartProductIds.has(product.id))
|
.filter((product) => !cartProductIds.has(product.id))
|
||||||
.slice(0, CROSS_SELL_LIMIT);
|
.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 (
|
return (
|
||||||
<div className="py-16">
|
<div className="py-16">
|
||||||
<Container>
|
<Container>
|
||||||
@@ -63,48 +76,62 @@ export function WarenkorbContent({
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-14 grid lg:grid-cols-3 gap-14">
|
<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">
|
<div className="lg:col-span-2 divide-y divide-ink/10 border-y border-ink/10">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
const itemStock = stock?.[item.productId];
|
||||||
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
|
return (
|
||||||
<div className="flex-1 min-w-[160px]">
|
<div key={item.productId} className="py-6 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
|
<CartItemThumbnail image={item.image} className="w-20 h-20 flex-shrink-0" />
|
||||||
{item.name}
|
<div className="flex-1 min-w-[160px]">
|
||||||
</Link>
|
<Link href={`/honig/${item.slug}`} className="text-lg text-forest">
|
||||||
<p className="text-sm text-ink/65">{item.weightGrams} g</p>
|
{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>
|
||||||
<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>
|
||||||
|
|
||||||
<div className="border border-ink/10 p-8 h-fit">
|
<div className="border border-ink/10 p-8 h-fit">
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t.cart.subtotal}</span>
|
<span>{t.cart.subtotal}</span>
|
||||||
<span>{formatPrice(subtotal)}</span>
|
<span>{formatPrice(subtotal, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>{t.cartPage.shippingLabel(totalQuantity)}</span>
|
<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>
|
</div>
|
||||||
{nextTier && nextTier.unitsUntilNext <= 2 ? (
|
{nextTier && nextTier.unitsUntilNext <= 2 ? (
|
||||||
<p className="text-xs text-ink/60">
|
<p className="text-xs text-ink/70">
|
||||||
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents))}
|
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{activeBulkDiscount ? (
|
{activeBulkDiscount ? (
|
||||||
@@ -112,22 +139,22 @@ export function WarenkorbContent({
|
|||||||
<span>
|
<span>
|
||||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||||
</span>
|
</span>
|
||||||
<span>-{formatPrice(bulkDiscountCents)}</span>
|
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
|
) : 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)}
|
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
|
<div className="flex justify-between text-base text-forest pt-3 border-t border-ink/10">
|
||||||
<span>{t.cartPage.total}</span>
|
<span>{t.cartPage.total}</span>
|
||||||
<span>{formatPrice(subtotal - bulkDiscountCents + shipping)}</span>
|
<span>{formatPrice(subtotal - bulkDiscountCents + shipping, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton href="/checkout" className="w-full mt-6">
|
<LinkButton href="/checkout" className="w-full mt-6">
|
||||||
{t.cart.checkout}
|
{t.cart.checkout}
|
||||||
</LinkButton>
|
</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}
|
{t.cartPage.pickupHint}
|
||||||
</Link>
|
</Link>
|
||||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default async function WissenArticlePage(
|
|||||||
|
|
||||||
{otherArticles.length > 0 ? (
|
{otherArticles.length > 0 ? (
|
||||||
<div className="mt-16 border-t border-ink/10 pt-10">
|
<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">
|
<div className="space-y-6">
|
||||||
{otherArticles.map((otherSlug) => {
|
{otherArticles.map((otherSlug) => {
|
||||||
const other = t.wissenArticles[otherSlug];
|
const other = t.wissenArticles[otherSlug];
|
||||||
|
|||||||
+11
-6
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Container, SectionHeading } from "@/components/ui";
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
|
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||||
import { wissenSlugs } from "@/lib/site-config";
|
import { wissenSlugs } from "@/lib/site-config";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -19,15 +20,19 @@ export default async function WissenPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="py-20">
|
<div className="py-20">
|
||||||
<Container className="max-w-3xl">
|
<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">
|
<div className="mt-14 space-y-10">
|
||||||
{wissenSlugs.map((slug) => {
|
{wissenSlugs.map((slug, i) => {
|
||||||
const article = t.wissenArticles[slug];
|
const article = t.wissenArticles[slug];
|
||||||
return (
|
return (
|
||||||
<Link key={slug} href={`/wissen/${slug}`} className="block group">
|
<ScrollReveal key={slug} delay={(i % 5) * 0.06}>
|
||||||
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
<Link href={`/wissen/${slug}`} className="block group">
|
||||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
<h2 className="text-xl text-forest group-hover:underline">{article.title}</h2>
|
||||||
</Link>
|
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{article.excerpt}</p>
|
||||||
|
</Link>
|
||||||
|
</ScrollReveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
|
|||||||
className="block text-xs"
|
className="block text-xs"
|
||||||
/>
|
/>
|
||||||
{status === "uploading" ? (
|
{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}
|
) : null}
|
||||||
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
||||||
{value ? (
|
{value ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setValue("")}
|
onClick={() => setValue("")}
|
||||||
className="mt-1 text-xs text-ink/50 underline"
|
className="mt-1 text-xs text-ink/70 underline"
|
||||||
>
|
>
|
||||||
{t.adminImageField.remove}
|
{t.adminImageField.remove}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
|
|||||||
className="block text-xs"
|
className="block text-xs"
|
||||||
/>
|
/>
|
||||||
{status === "uploading" ? (
|
{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}
|
) : null}
|
||||||
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
|
||||||
</div>
|
</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";
|
tone?: "forest" | "cream" | "honey";
|
||||||
label?: string;
|
label?: string;
|
||||||
sizes?: 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",
|
tone = "forest",
|
||||||
label,
|
label,
|
||||||
sizes = "100vw",
|
sizes = "100vw",
|
||||||
|
priority = false,
|
||||||
}: ContentImageProps) {
|
}: ContentImageProps) {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return <PlaceholderImage tone={tone} className={className} label={label} />;
|
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 (
|
return (
|
||||||
<div className={`relative overflow-hidden ${className}`}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+27
-7
@@ -29,11 +29,13 @@ export function Hero({
|
|||||||
footnote,
|
footnote,
|
||||||
location,
|
location,
|
||||||
image,
|
image,
|
||||||
|
video,
|
||||||
}: {
|
}: {
|
||||||
intro: string;
|
intro: string;
|
||||||
footnote: string;
|
footnote: string;
|
||||||
location: string;
|
location: string;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
video: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
@@ -45,13 +47,31 @@ export function Hero({
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
variants={sectionVariants}
|
variants={sectionVariants}
|
||||||
>
|
>
|
||||||
<ContentImage
|
{video ? (
|
||||||
src={image}
|
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
||||||
alt=""
|
<video
|
||||||
tone="forest"
|
src={video}
|
||||||
className="h-[78vh] min-h-[520px] w-full"
|
poster={image ?? undefined}
|
||||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
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">
|
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent">
|
||||||
<Container>
|
<Container>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export async function OriginStory() {
|
|||||||
tone="forest"
|
tone="forest"
|
||||||
className="aspect-[4/5] w-full"
|
className="aspect-[4/5] w-full"
|
||||||
label={t.home.originStory.eyebrow}
|
label={t.home.originStory.eyebrow}
|
||||||
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
<ScrollReveal delay={0.1}>
|
<ScrollReveal delay={0.1}>
|
||||||
@@ -128,7 +129,8 @@ type HoneySpotlightProduct = ProductWithRelations & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function ProductSpotlight({ product }: { product: HoneySpotlightProduct }) {
|
export async function ProductSpotlight({ product }: { product: HoneySpotlightProduct }) {
|
||||||
const t = getDictionary(await getLocale());
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
return (
|
return (
|
||||||
<section className="py-24 bg-forest text-cream">
|
<section className="py-24 bg-forest text-cream">
|
||||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||||
@@ -139,6 +141,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
|||||||
tone="honey"
|
tone="honey"
|
||||||
className="aspect-square w-full"
|
className="aspect-square w-full"
|
||||||
label={product.name}
|
label={product.name}
|
||||||
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
<ScrollReveal delay={0.1}>
|
<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">
|
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm text-cream/70">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-cream/65">{t.home.productSpotlight.harvest}</dt>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-cream/65">{t.home.productSpotlight.batch}</dt>
|
<dt className="text-cream/65">{t.home.productSpotlight.batch}</dt>
|
||||||
@@ -174,7 +177,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-cream/65">{t.home.productSpotlight.price}</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export async function RegionalSection() {
|
|||||||
tone="forest"
|
tone="forest"
|
||||||
className="aspect-[4/5] w-full"
|
className="aspect-[4/5] w-full"
|
||||||
label={t.home.regionalSection.eyebrow}
|
label={t.home.regionalSection.eyebrow}
|
||||||
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -135,6 +136,7 @@ export async function BusinessTeaser() {
|
|||||||
tone="honey"
|
tone="honey"
|
||||||
className="aspect-[4/5] w-full"
|
className="aspect-[4/5] w-full"
|
||||||
label={t.home.businessTeaser.eyebrow}
|
label={t.home.businessTeaser.eyebrow}
|
||||||
|
sizes="(min-width: 768px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
<ScrollReveal delay={0.1}>
|
<ScrollReveal delay={0.1}>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function LanguageSwitcher({ className = "" }: { className?: string }) {
|
|||||||
aria-current={code === locale}
|
aria-current={code === locale}
|
||||||
title={localeLabels[code]}
|
title={localeLabels[code]}
|
||||||
className={`uppercase tracking-wide transition-colors disabled:opacity-50 ${
|
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}
|
{code}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function PasswordField({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-ink/60 whitespace-nowrap">{tierLabel}</span>
|
<span className="text-xs text-ink/70 whitespace-nowrap">{tierLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</label>
|
</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" />
|
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
|
||||||
<span>
|
<span>
|
||||||
<span className="block text-forest">{name}</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}
|
{t.product.viewProduct}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -173,7 +173,7 @@ export function ProductCard({
|
|||||||
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
|
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
|
||||||
reviewSummary?: { average: number | null; count: number };
|
reviewSummary?: { average: number | null; count: number };
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const stock = product.inventory?.quantity ?? 0;
|
const stock = product.inventory?.quantity ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,6 +198,7 @@ export function ProductCard({
|
|||||||
? `${product.harvest.year} · ${product.harvest.type}`
|
? `${product.harvest.year} · ${product.harvest.type}`
|
||||||
: t.common.categories[product.category]
|
: t.common.categories[product.category]
|
||||||
}
|
}
|
||||||
|
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
/>
|
/>
|
||||||
{stock === 0 ? (
|
{stock === 0 ? (
|
||||||
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
<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">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||||
<span className="text-base text-forest">
|
<span className="text-base text-forest">
|
||||||
{formatPrice(product.priceCents, product.currency)}
|
{formatPrice(product.priceCents, product.currency, locale)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink/65">
|
<p className="text-xs text-ink/65">
|
||||||
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||||
</p>
|
</p>
|
||||||
{stock > 0 ? (
|
{stock > 0 ? (
|
||||||
<AddToCartButton product={product} variant="secondary" className="mt-4" />
|
<AddToCartButton product={product} variant="secondary" className="mt-4" />
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState, type TouchEvent } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import { PlaceholderImage } from "@/components/placeholder-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 };
|
type Slide = { src: string } | { tone: "forest" | "cream" | "honey"; label: string };
|
||||||
|
|
||||||
function isImageSlide(slide: Slide): slide is { src: string } {
|
function isImageSlide(slide: Slide): slide is { src: string } {
|
||||||
@@ -23,19 +28,60 @@ const PLACEHOLDER_SLIDES: Slide[] = [
|
|||||||
interface ProductGalleryProps {
|
interface ProductGalleryProps {
|
||||||
images: string[];
|
images: string[];
|
||||||
productName: 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 slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const active = slides[activeIndex] ?? slides[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 (
|
return (
|
||||||
<div>
|
<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) ? (
|
{isImageSlide(active) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
|
// Hochgeladene Produktbilder liegen immer same-origin unter
|
||||||
<img src={active.src} alt={productName} className="w-full h-full object-cover" />
|
// /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" />
|
<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)}
|
onClick={() => setActiveIndex(index)}
|
||||||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||||||
aria-current={index === activeIndex}
|
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"
|
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isImageSlide(slide) ? (
|
{isImageSlide(slide) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- s.o.
|
<Image src={slide.src} alt="" fill sizes="120px" className="object-cover" />
|
||||||
<img src={slide.src} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
) : (
|
||||||
<PlaceholderImage tone={slide.tone} label={slide.label} className="w-full h-full" />
|
<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 { useState, useEffect, useRef, type RefObject } from "react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||||
|
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { mainNav } from "@/lib/site-config";
|
import { mainNav } from "@/lib/site-config";
|
||||||
import { Container, LinkButton } from "@/components/ui";
|
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"
|
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>{s.name}</span>
|
||||||
<span className="text-ink/60 whitespace-nowrap">{s.price}</span>
|
<span className="text-ink/70 whitespace-nowrap">{s.price}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -392,7 +393,7 @@ export function Header({ companyName }: { companyName: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CartDrawer() {
|
export function CartDrawer() {
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const isOpen = useCartStore((s) => s.isOpen);
|
const isOpen = useCartStore((s) => s.isOpen);
|
||||||
const closeCart = useCartStore((s) => s.closeCart);
|
const closeCart = useCartStore((s) => s.closeCart);
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
@@ -417,6 +418,10 @@ export function CartDrawer() {
|
|||||||
const removeItem = useCartStore((s) => s.removeItem);
|
const removeItem = useCartStore((s) => s.removeItem);
|
||||||
const total = cartTotalCents(items);
|
const total = cartTotalCents(items);
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
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);
|
useEscapeKey(isOpen, closeCart);
|
||||||
useFocusTrap(isOpen, asideRef);
|
useFocusTrap(isOpen, asideRef);
|
||||||
@@ -457,31 +462,48 @@ export function CartDrawer() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-ink/10">
|
<ul className="divide-y divide-ink/10">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<li key={item.productId} className="py-4 flex gap-4">
|
const itemStock = stock?.[item.productId];
|
||||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
return (
|
||||||
<div className="flex-1">
|
<li key={item.productId} className="py-4 flex gap-4">
|
||||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
|
||||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||||
<QuantityStepper
|
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||||
quantity={item.quantity}
|
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
||||||
onChange={(quantity) => setQuantity(item.productId, quantity)}
|
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
||||||
size="sm"
|
"kein Hinweis" zu "Ausverkauft"/"Nur noch N" bestehen, damit
|
||||||
/>
|
Screenreader die neu erscheinende Warnung tatsaechlich ansagen
|
||||||
<button
|
statt sie stumm zu uebergehen (ein erst mit dem Hinweis selbst
|
||||||
onClick={() => removeItem(item.productId)}
|
eingefuegtes aria-live-Element wird von mancher AT ignoriert). */}
|
||||||
className="text-xs text-ink/65 hover:text-ink underline"
|
<div aria-live="polite">
|
||||||
>
|
{itemStock !== undefined && itemStock === 0 ? (
|
||||||
{t.cart.remove}
|
<p className="mt-1 text-xs text-red-700">{t.product.outOfStock}</p>
|
||||||
</button>
|
) : 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>
|
||||||
</div>
|
<p className="text-sm text-ink">
|
||||||
<p className="text-sm text-ink">
|
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||||
{formatPrice(item.priceCents * item.quantity)}
|
</p>
|
||||||
</p>
|
</li>
|
||||||
</li>
|
);
|
||||||
))}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -490,7 +512,7 @@ export function CartDrawer() {
|
|||||||
<div className="border-t border-ink/10 p-6 space-y-4">
|
<div className="border-t border-ink/10 p-6 space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>{t.cart.subtotal}</span>
|
<span>{t.cart.subtotal}</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice(total, "EUR", locale)}</span>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
||||||
{t.cart.checkout}
|
{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.",
|
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.",
|
pickupNote: "Alternativ kannst du deine Bestellung nach Absprache direkt bei uns in Altenmünster abholen — dabei fallen keine Versandkosten an.",
|
||||||
paymentHeading: "Zahlungsmethoden",
|
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",
|
deliveryTimeHeading: "Lieferzeit",
|
||||||
deliveryTimeText: "Die übliche Lieferzeit innerhalb Deutschlands beträgt 2–4 Werktage nach Zahlungseingang.",
|
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.",
|
"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",
|
groupCompany: "Firmendaten",
|
||||||
groupHome: "Homepage",
|
groupHome: "Homepage",
|
||||||
|
groupPages: "Weitere Seiten",
|
||||||
groupLegal: "Rechtstexte",
|
groupLegal: "Rechtstexte",
|
||||||
groupSocial: "Social Media",
|
groupSocial: "Social Media",
|
||||||
deLabel: "Deutsch",
|
deLabel: "Deutsch",
|
||||||
@@ -1128,6 +1129,12 @@ export const de: Dictionary = {
|
|||||||
removeImageAt: (position) => `Bild ${position} entfernen`,
|
removeImageAt: (position) => `Bild ${position} entfernen`,
|
||||||
uploadError: "Der Upload ist fehlgeschlagen.",
|
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||||
},
|
},
|
||||||
|
adminVideoField: {
|
||||||
|
none: "Kein Video",
|
||||||
|
uploading: "Wird hochgeladen…",
|
||||||
|
remove: "Video entfernen",
|
||||||
|
uploadError: "Der Upload ist fehlgeschlagen.",
|
||||||
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
navOrders: "Bestellungen",
|
navOrders: "Bestellungen",
|
||||||
@@ -1332,6 +1339,7 @@ export const de: Dictionary = {
|
|||||||
productPage: {
|
productPage: {
|
||||||
reviewsLink: (count) => `${count} Bewertung${count === 1 ? "" : "en"}`,
|
reviewsLink: (count) => `${count} Bewertung${count === 1 ? "" : "en"}`,
|
||||||
noReviews: "Noch keine Bewertungen",
|
noReviews: "Noch keine Bewertungen",
|
||||||
|
shippingHint: (price) => `Versand ab ${price} · Lieferzeit 2–4 Werktage`,
|
||||||
onlyLeftInStock: (count) => `Nur noch ${count} auf Lager`,
|
onlyLeftInStock: (count) => `Nur noch ${count} auf Lager`,
|
||||||
soldOut: "Ausverkauft",
|
soldOut: "Ausverkauft",
|
||||||
currentlyUnavailable: "Aktuell nicht verfügbar — schau bald wieder vorbei.",
|
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.",
|
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.",
|
nonHoneyOriginText: "Aus eigener Imkerei und Produktion in Altenmünster.",
|
||||||
viewBatchOrigin: "Chargen-Herkunft dieses Glases ansehen →",
|
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",
|
reviewsHeading: "Bewertungen",
|
||||||
noReviewsForProduct: "Für dieses Produkt gibt es noch keine Bewertungen.",
|
noReviewsForProduct: "Für dieses Produkt gibt es noch keine Bewertungen.",
|
||||||
loginToReview: "um dieses Produkt zu bewerten.",
|
loginToReview: "um dieses Produkt zu bewerten.",
|
||||||
@@ -1399,6 +1409,8 @@ export const de: Dictionary = {
|
|||||||
sortAvailability: "Verfügbarkeit",
|
sortAvailability: "Verfügbarkeit",
|
||||||
apply: "Anwenden",
|
apply: "Anwenden",
|
||||||
onlyAvailable: "Nur verfügbare",
|
onlyAvailable: "Nur verfügbare",
|
||||||
|
searchInputLabel: "Im Sortiment suchen",
|
||||||
|
clearSearch: "Suche zurücksetzen",
|
||||||
searchResults: (query, count) => `Suchergebnisse für „${query}“ (${count})`,
|
searchResults: (query, count) => `Suchergebnisse für „${query}“ (${count})`,
|
||||||
noSearchResults: "Keine Treffer für deine Suche.",
|
noSearchResults: "Keine Treffer für deine Suche.",
|
||||||
noProducts: "Aktuell sind hier keine Produkte verfügbar. Schau bald wieder vorbei.",
|
noProducts: "Aktuell sind hier keine Produkte verfügbar. Schau bald wieder vorbei.",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const en: Dictionary = {
|
|||||||
openSearch: "Open search",
|
openSearch: "Open search",
|
||||||
openMenu: "Open menu",
|
openMenu: "Open menu",
|
||||||
closeMenu: "Close 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",
|
skipToContent: "Skip to content",
|
||||||
},
|
},
|
||||||
footer: {
|
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.",
|
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.",
|
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",
|
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",
|
deliveryTimeHeading: "Delivery Time",
|
||||||
deliveryTimeText: "Typical delivery time within Germany is 2–4 business days after payment is received.",
|
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.",
|
"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",
|
groupCompany: "Company details",
|
||||||
groupHome: "Homepage",
|
groupHome: "Homepage",
|
||||||
|
groupPages: "Other pages",
|
||||||
groupLegal: "Legal pages",
|
groupLegal: "Legal pages",
|
||||||
groupSocial: "Social media",
|
groupSocial: "Social media",
|
||||||
deLabel: "German",
|
deLabel: "German",
|
||||||
@@ -1128,6 +1129,12 @@ export const en: Dictionary = {
|
|||||||
removeImageAt: (position) => `Remove image ${position}`,
|
removeImageAt: (position) => `Remove image ${position}`,
|
||||||
uploadError: "The upload failed.",
|
uploadError: "The upload failed.",
|
||||||
},
|
},
|
||||||
|
adminVideoField: {
|
||||||
|
none: "No video",
|
||||||
|
uploading: "Uploading…",
|
||||||
|
remove: "Remove video",
|
||||||
|
uploadError: "The upload failed.",
|
||||||
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: "Dashboard",
|
navDashboard: "Dashboard",
|
||||||
navOrders: "Orders",
|
navOrders: "Orders",
|
||||||
@@ -1297,13 +1304,13 @@ export const en: Dictionary = {
|
|||||||
paymentMethodsNote:
|
paymentMethodsNote:
|
||||||
"Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna — provided via Stripe.",
|
"Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna — provided via Stripe.",
|
||||||
yourOrderHeading: "Your Order",
|
yourOrderHeading: "Your Order",
|
||||||
shippingWithCount: (count) => `Shipping (${count} items)`,
|
shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
|
||||||
shippingPickup: "Shipping (pickup)",
|
shippingPickup: "Shipping (pickup)",
|
||||||
},
|
},
|
||||||
cartPage: {
|
cartPage: {
|
||||||
title: "Your Cart",
|
title: "Your Cart",
|
||||||
goToShop: "Go to shop",
|
goToShop: "Go to shop",
|
||||||
shippingLabel: (count) => `Shipping (${count} items)`,
|
shippingLabel: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
|
||||||
free: "free",
|
free: "free",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
nextTierHint: (unitsUntilNext, nextPrice) =>
|
nextTierHint: (unitsUntilNext, nextPrice) =>
|
||||||
@@ -1332,6 +1339,7 @@ export const en: Dictionary = {
|
|||||||
productPage: {
|
productPage: {
|
||||||
reviewsLink: (count) => `${count} review${count === 1 ? "" : "s"}`,
|
reviewsLink: (count) => `${count} review${count === 1 ? "" : "s"}`,
|
||||||
noReviews: "No reviews yet",
|
noReviews: "No reviews yet",
|
||||||
|
shippingHint: (price) => `Shipping from ${price} · Delivery in 2–4 business days`,
|
||||||
onlyLeftInStock: (count) => `Only ${count} in stock`,
|
onlyLeftInStock: (count) => `Only ${count} in stock`,
|
||||||
soldOut: "Sold out",
|
soldOut: "Sold out",
|
||||||
currentlyUnavailable: "Currently unavailable — check back soon.",
|
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.",
|
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.",
|
nonHoneyOriginText: "From our own apiary and production in Altenmünster.",
|
||||||
viewBatchOrigin: "View this jar's batch origin →",
|
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",
|
reviewsHeading: "Reviews",
|
||||||
noReviewsForProduct: "There are no reviews for this product yet.",
|
noReviewsForProduct: "There are no reviews for this product yet.",
|
||||||
loginToReview: "to review this product.",
|
loginToReview: "to review this product.",
|
||||||
@@ -1399,6 +1409,8 @@ export const en: Dictionary = {
|
|||||||
sortAvailability: "Availability",
|
sortAvailability: "Availability",
|
||||||
apply: "Apply",
|
apply: "Apply",
|
||||||
onlyAvailable: "In stock only",
|
onlyAvailable: "In stock only",
|
||||||
|
searchInputLabel: "Search the shop",
|
||||||
|
clearSearch: "Clear search",
|
||||||
searchResults: (query, count) => `Search results for "${query}" (${count})`,
|
searchResults: (query, count) => `Search results for "${query}" (${count})`,
|
||||||
noSearchResults: "No results for your search.",
|
noSearchResults: "No results for your search.",
|
||||||
noProducts: "No products available here right now. Check back soon.",
|
noProducts: "No products available here right now. Check back soon.",
|
||||||
|
|||||||
@@ -813,6 +813,7 @@ export interface Dictionary {
|
|||||||
intro: string;
|
intro: string;
|
||||||
groupCompany: string;
|
groupCompany: string;
|
||||||
groupHome: string;
|
groupHome: string;
|
||||||
|
groupPages: string;
|
||||||
groupLegal: string;
|
groupLegal: string;
|
||||||
groupSocial: string;
|
groupSocial: string;
|
||||||
deLabel: string;
|
deLabel: string;
|
||||||
@@ -829,6 +830,14 @@ export interface Dictionary {
|
|||||||
removeImageAt: (position: number) => string;
|
removeImageAt: (position: number) => string;
|
||||||
uploadError: string;
|
uploadError: string;
|
||||||
};
|
};
|
||||||
|
/** Wie adminImageField, aber fuer AdminVideoField (Hero-Hintergrundvideo,
|
||||||
|
* admin/inhalte). */
|
||||||
|
adminVideoField: {
|
||||||
|
none: string;
|
||||||
|
uploading: string;
|
||||||
|
remove: string;
|
||||||
|
uploadError: string;
|
||||||
|
};
|
||||||
adminShell: {
|
adminShell: {
|
||||||
navDashboard: string;
|
navDashboard: string;
|
||||||
navOrders: string;
|
navOrders: string;
|
||||||
@@ -1018,6 +1027,7 @@ export interface Dictionary {
|
|||||||
productPage: {
|
productPage: {
|
||||||
reviewsLink: (count: number) => string;
|
reviewsLink: (count: number) => string;
|
||||||
noReviews: string;
|
noReviews: string;
|
||||||
|
shippingHint: (price: string) => string;
|
||||||
onlyLeftInStock: (count: number) => string;
|
onlyLeftInStock: (count: number) => string;
|
||||||
soldOut: string;
|
soldOut: string;
|
||||||
currentlyUnavailable: string;
|
currentlyUnavailable: string;
|
||||||
@@ -1045,6 +1055,8 @@ export interface Dictionary {
|
|||||||
infantWarningText: string;
|
infantWarningText: string;
|
||||||
nonHoneyOriginText: string;
|
nonHoneyOriginText: string;
|
||||||
viewBatchOrigin: string;
|
viewBatchOrigin: string;
|
||||||
|
giftHint: string;
|
||||||
|
learnMoreLabel: string;
|
||||||
reviewsHeading: string;
|
reviewsHeading: string;
|
||||||
noReviewsForProduct: string;
|
noReviewsForProduct: string;
|
||||||
loginToReview: string;
|
loginToReview: string;
|
||||||
@@ -1071,6 +1083,8 @@ export interface Dictionary {
|
|||||||
sortAvailability: string;
|
sortAvailability: string;
|
||||||
apply: string;
|
apply: string;
|
||||||
onlyAvailable: string;
|
onlyAvailable: string;
|
||||||
|
searchInputLabel: string;
|
||||||
|
clearSearch: string;
|
||||||
searchResults: (query: string, count: number) => string;
|
searchResults: (query: string, count: number) => string;
|
||||||
noSearchResults: string;
|
noSearchResults: string;
|
||||||
noProducts: string;
|
noProducts: string;
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ export type ContentFieldType =
|
|||||||
| "list-titled"
|
| "list-titled"
|
||||||
| "list-numbered"
|
| "list-numbered"
|
||||||
| "richtext-sections"
|
| "richtext-sections"
|
||||||
| "image";
|
| "image"
|
||||||
|
| "video";
|
||||||
|
|
||||||
export interface ContentFieldDef {
|
export interface ContentFieldDef {
|
||||||
key: string;
|
key: string;
|
||||||
group: "company" | "home" | "legal" | "social";
|
group: "company" | "home" | "pages" | "legal" | "social";
|
||||||
labelDe: string;
|
labelDe: string;
|
||||||
labelEn: string;
|
labelEn: string;
|
||||||
type: ContentFieldType;
|
type: ContentFieldType;
|
||||||
@@ -79,10 +80,20 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
|||||||
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
|
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
|
||||||
// dessen eigenes Product.images (siehe admin/produkte).
|
// dessen eigenes Product.images (siehe admin/produkte).
|
||||||
{ key: "home.hero.image", group: "home", labelDe: "Hero: Bild", labelEn: "Hero: Image", type: "image", localized: false, getDefault: () => "" },
|
{ 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.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.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: () => "" },
|
{ 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
|
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
||||||
// angezeigt, siehe components/site-footer.tsx Footer()) ---
|
// 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: () => "" },
|
{ 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 { prisma } from "@/lib/prisma";
|
||||||
import { asStringArray } from "@/lib/json-array";
|
import { asStringArray } from "@/lib/json-array";
|
||||||
|
import { matchesSearchQuery } from "@/lib/search-match";
|
||||||
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
|
import type { Product, Harvest, Batch, ProductCategory, Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
// tasteTags/images sind im Schema als Json modelliert (MySQL-Kompatibilitaet,
|
// 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
|
// ueberschaubaren Katalogsgroesse wird deshalb hier statt in der DB-Query
|
||||||
// gefiltert. Das behebt zugleich, dass mehrwortige, gemischt geschriebene
|
// gefiltert. Das behebt zugleich, dass mehrwortige, gemischt geschriebene
|
||||||
// Tags wie "reines Bienenwachs" bei case-insensitiver Suche nie trafen.
|
// 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) => {
|
return products.filter((product) => {
|
||||||
const haystack = [product.name, product.description, ...product.tasteTags]
|
const haystack = [product.name, product.description, ...product.tasteTags].join(" ");
|
||||||
.join(" ")
|
return matchesSearchQuery(haystack, trimmed);
|
||||||
.toLowerCase();
|
|
||||||
return words.every((word) => haystack.includes(word));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export class DhlApiError extends Error {
|
|||||||
// Konfiguration
|
// 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 {
|
interface DhlConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
apiSecret: string;
|
apiSecret: string;
|
||||||
@@ -124,6 +130,7 @@ async function fetchAccessToken(config: DhlConfig): Promise<string> {
|
|||||||
client_id: config.apiKey,
|
client_id: config.apiKey,
|
||||||
client_secret: config.apiSecret,
|
client_secret: config.apiSecret,
|
||||||
}),
|
}),
|
||||||
|
signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||||
@@ -291,6 +298,7 @@ export async function createDhlShipmentLabel(
|
|||||||
"dhl-api-key": config.apiKey,
|
"dhl-api-key": config.apiKey,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: AbortSignal.timeout(DHL_REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
const message = error instanceof Error ? error.message : "Unbekannter Netzwerkfehler";
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ interface EmailStrings {
|
|||||||
heading: (firstName: string) => string;
|
heading: (firstName: string) => string;
|
||||||
intro: (orderNumber: string) => string;
|
intro: (orderNumber: string) => string;
|
||||||
retryNote: string;
|
retryNote: string;
|
||||||
|
button: string;
|
||||||
subject: (orderNumber: string) => string;
|
subject: (orderNumber: string) => string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -198,6 +199,7 @@ const de: EmailStrings = {
|
|||||||
intro: (orderNumber) =>
|
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.`,
|
`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.",
|
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`,
|
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -298,6 +300,7 @@ const en: EmailStrings = {
|
|||||||
intro: (orderNumber) =>
|
intro: (orderNumber) =>
|
||||||
`The payment for your order <strong>${orderNumber}</strong> could not be completed. We've cancelled the order, so no costs were incurred.`,
|
`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.",
|
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`,
|
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
|
const rows = order.items
|
||||||
.map(
|
.map(
|
||||||
(item) =>
|
(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("");
|
.join("");
|
||||||
|
|
||||||
@@ -101,10 +101,10 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
|
|||||||
${rows}
|
${rows}
|
||||||
</table>
|
</table>
|
||||||
<table role="presentation" width="100%" style="font-size:14px;margin-top:12px;">
|
<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.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)}</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)}</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)}</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>
|
</table>
|
||||||
${deliveryHtml}
|
${deliveryHtml}
|
||||||
${giftHtml}
|
${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
|
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)}`)
|
.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)}\n${s.shipping}: ${
|
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents, "EUR", order.locale)}\n${s.shipping}: ${
|
||||||
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents)
|
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)
|
||||||
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents)}` : ""}\n${s.total}: ${formatPrice(order.totalCents)}${
|
}${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.giftMessage ? `\n\n${s.giftNote}` : ""
|
||||||
}${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`;
|
}${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> {
|
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||||||
const s = getEmailStrings(order.locale).paymentFailed;
|
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(
|
const html = await emailLayout(
|
||||||
order.locale,
|
order.locale,
|
||||||
s.preheader(order.orderNumber),
|
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>
|
<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 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
|
||||||
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
|
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
|
||||||
|
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
subject: s.subject(order.orderNumber),
|
subject: s.subject(order.orderNumber),
|
||||||
html,
|
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 {
|
export function formatPrice(cents: number, currency = "EUR", locale: Locale = "de"): string {
|
||||||
return new Intl.NumberFormat("de-DE", {
|
return new Intl.NumberFormat(INTL_LOCALE[locale], {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
}).format(cents / 100);
|
}).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.
|
* Formatiert den Grundpreis (Preis pro Kilogramm), z.B. fuer Vergleichbarkeit im Shop.
|
||||||
*/
|
*/
|
||||||
export function formatPricePerKg(cents: number, currency = "EUR"): string {
|
export function formatPricePerKg(cents: number, currency = "EUR", locale: Locale = "de"): string {
|
||||||
return `${formatPrice(cents, currency)} / kg`;
|
return `${formatPrice(cents, currency, locale)} / kg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date): string {
|
export function formatDate(date: Date, locale: Locale = "de"): string {
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(date: Date): string {
|
export function formatDateTime(date: Date, locale: Locale = "de"): string {
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -33,8 +49,8 @@ export function formatDateTime(date: Date): string {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMonthYear(date: Date): string {
|
export function formatMonthYear(date: Date, locale: Locale = "de"): string {
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(date);
|
}).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,
|
generateUploadFilename,
|
||||||
isValidUploadPath,
|
isValidUploadPath,
|
||||||
resolveUploadPath,
|
resolveUploadPath,
|
||||||
|
detectVideoExtension,
|
||||||
|
isValidVideoUploadPath,
|
||||||
} from "./uploads";
|
} from "./uploads";
|
||||||
|
|
||||||
describe("extensionForFormat", () => {
|
describe("extensionForFormat", () => {
|
||||||
@@ -39,6 +41,11 @@ describe("contentTypeForExtension", () => {
|
|||||||
expect(contentTypeForExtension("avif")).toBe("image/avif");
|
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", () => {
|
test("returns null for an extension outside the fixed allow-list", () => {
|
||||||
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
|
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
|
||||||
// siehe app/uploads/[...path]/route.ts.
|
// 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", () => {
|
describe("generateUploadFilename", () => {
|
||||||
test("produces a 32-char lowercase hex name with the given extension", () => {
|
test("produces a 32-char lowercase hex name with the given extension", () => {
|
||||||
const name = generateUploadFilename("webp");
|
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", () => {
|
describe("resolveUploadPath", () => {
|
||||||
test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
|
test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
|
||||||
const resolved = resolveUploadPath(["abc123.png"]);
|
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;
|
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 AllowedImageExtension = "jpg" | "png" | "webp" | "avif";
|
||||||
|
export type AllowedVideoExtension = "mp4" | "webm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes,
|
* 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;
|
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
|
* Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits
|
||||||
* validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client-
|
* 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
|
* interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe
|
||||||
* app/uploads/[...path]/route.ts.
|
* 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",
|
jpg: "image/jpeg",
|
||||||
png: "image/png",
|
png: "image/png",
|
||||||
webp: "image/webp",
|
webp: "image/webp",
|
||||||
avif: "image/avif",
|
avif: "image/avif",
|
||||||
|
mp4: "video/mp4",
|
||||||
|
webm: "video/webm",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function contentTypeForExtension(extension: string): string | null {
|
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)
|
* (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen)
|
||||||
* sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite.
|
* 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}`;
|
return `${randomBytes(16).toString("hex")}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/;
|
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
|
* 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);
|
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
|
* Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen
|
||||||
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-
|
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export interface CartItem {
|
|||||||
interface CartState {
|
interface CartState {
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
isOpen: boolean;
|
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;
|
openCart: () => void;
|
||||||
closeCart: () => void;
|
closeCart: () => void;
|
||||||
addItem: (item: Omit<CartItem, "quantity">, quantity?: number) => void;
|
addItem: (item: Omit<CartItem, "quantity">, quantity?: number) => void;
|
||||||
@@ -29,6 +36,8 @@ export const useCartStore = create<CartState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
items: [],
|
items: [],
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
hasHydrated: false,
|
||||||
|
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
|
||||||
openCart: () => set({ isOpen: true }),
|
openCart: () => set({ isOpen: true }),
|
||||||
closeCart: () => set({ isOpen: false }),
|
closeCart: () => set({ isOpen: false }),
|
||||||
addItem: (item, quantity = 1) =>
|
addItem: (item, quantity = 1) =>
|
||||||
@@ -64,6 +73,9 @@ export const useCartStore = create<CartState>()(
|
|||||||
// UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt
|
// UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt
|
||||||
// werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf).
|
// werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf).
|
||||||
partialize: (state) => ({ items: state.items }),
|
partialize: (state) => ({ items: state.items }),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
state?.setHasHydrated(true);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user