Compare commits

..
12 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Sonnet 5 6814e9e2c8 feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero
(home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem
Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden
Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der
Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt,
Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher
nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder
(Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder
(Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt
statt voller Viewport-Breite.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 14:53:14 +02:00
Nick SzaunigandClaude Sonnet 5 398db5c5ec feat: make /unternehmen hero image admin-editable too
Same gap as /imkerei and /herkunft (previous commit): last remaining
hardcoded PlaceholderImage in the app, now wired to the same
content-registry "pages" group / ContentImage pattern.

Verified: tsc clean, 151/151 tests pass, page still returns 200.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:33:17 +02:00
Nick SzaunigandClaude Sonnet 5 8d988515f3 feat: make /imkerei and /herkunft hero images admin-editable
Both pages are the ones most directly meant to give customers "ein
gutes Bild von der Imkerei" (per the active /goal), but their hero
images were still hardcoded PlaceholderImage tiles with no way to
ever swap in a real photo — unlike every homepage section, which
already went through this exact upload flow.

Added imkerei.image / herkunft.image to the content registry (new
"pages" group, since neither belongs under "Homepage") and switched
both pages from PlaceholderImage to ContentImage + getContentValue,
the same pattern already proven on the homepage sections. Falls back
to the identical placeholder tile until a photo is uploaded, so this
is a pure addition with no visual change until the owner uploads one.

Verified: tsc clean, 151/151 tests pass, both pages still render
correctly (curl 200 + screenshot) with the placeholder fallback.
Could not verify the admin upload UI end-to-end live — the local
admin account has 2FA enabled and I don't have the second factor;
confidence instead comes from reusing the identical, already-verified
AdminImageField/ContentImage wiring used for the four home.*.image
fields, plus generic (group-agnostic) admin field loading confirmed
by reading admin/inhalte/page.tsx.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 22:32:00 +02:00
Nick SzaunigandClaude Sonnet 5 3627f4e5c1 fix: close remaining TOCTOU races, self-correct a regression from the prior fix
Second silent-failure-hunter pass (stock/auth/admin-action focus,
explicitly scoped away from the payment paths fixed in the previous
commit) plus a self-review of that previous commit's own diff.

Self-correction: my prior fix to fulfillOrder's NEU->BEZAHLT guard
replaced the original atomic `updateMany({status:"NEU"})` claim with a
plain findUnique+update to let a retry backfill Payment/Invoice/email
after BEZAHLT. That accidentally dropped the atomicity the original
code relied on to prevent double-counting a discount code on two
concurrent webhook deliveries. Restored it: the NEU->BEZAHLT
transition is an atomic updateMany claim again (count===1 decides
"firstTime", which now also gates the discount-code increment); a
losing/retry call separately checks for "already BEZAHLT" to still
allow the backfill.

Three further real gaps, confirmed by hand before fixing:

- failOrder (same webhook file) and admin's updateOrderStatusAction
  had the identical TOCTOU pattern: findUnique then unconditional
  update then restockOrderItems. Two concurrent webhook deliveries
  (Stripe documents at-least-once delivery) or two admin tabs
  double-clicking "Stornieren" could both pass the stale read before
  either write landed, silently double-crediting inventory. Both now
  use the same atomic-claim pattern as fulfillOrder.

- 2FA backup-code verification used findFirst({usedAt:null}) then a
  separate update — two concurrent submissions of the same code could
  both pass the check and both authenticate, defeating single-use.
  Replaced with one atomic updateMany claim.

- Two silent no-ops: an invalid/stale order-status update returned
  with zero admin-visible feedback despite revalidatePath still
  firing; discount-code deletion had no existence check, so a
  double-submitted delete threw an unhandled Prisma P2025 (500)
  instead of a clear message. Both now use the existing fail() helper.

Verified: tsc clean, 151/151 existing tests still pass.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-08 21:05:05 +02:00
Nick SzaunigandClaude Sonnet 5 8b5d5d095c fix: harden payment/shipping paths against partial-failure edge cases
Dispatched a read-only silent-failure audit (silent-failure-hunter
agent) over the checkout/webhook/DHL/mail code paths, then verified
each finding by hand before fixing. Four confirmed, real gaps:

- Stripe webhook (fulfillOrder): Payment/Invoice creation and the
  confirmation email ran after the order's status flip to "BEZAHLT",
  outside any try/catch. If any of those three failed (DB blip), the
  handler threw uncaught -> Stripe retried -> the retry's
  status==="NEU" guard now found 0 rows and silently skipped the
  retry forever, leaving a paid order with no invoice, no payment
  record, and no confirmation email, with nothing logged. Fixed by
  allowing "BEZAHLT" as a valid retry entry point (idempotent
  upserts) and wrapping the post-transaction block in try/catch that
  logs loudly and re-throws (so Stripe still retries).

- DHL label creation: the already-billed tracking number's DB write
  sat outside the try/catch that protects the (paid) DHL API call.
  A failed write there lost the tracking number with zero record of
  it, while the claim sentinel permanently blocked any retry through
  the UI. Fixed by wrapping the write, logging the tracking number
  for manual reconciliation, and deliberately leaving the claim in
  place (not resettable) to avoid triggering a second, separately
  billed label.

- Checkout route: a DB write failure for the (currently unread,
  write-only) stripeCheckoutSessionId field discarded an already
  live, payable Stripe session and showed the customer a generic
  error, risking a duplicate order + stock reservation on retry.
  Made that specific write best-effort/non-fatal.

- DHL fetch calls had no request timeout, so a hung (non-erroring)
  DHL API could block an admin action indefinitely. Added a 20s
  AbortSignal.timeout to both.

Verified: tsc clean, 151/151 existing tests still pass. No dedicated
DB-integration test harness exists yet for route/webhook handlers in
this codebase (only pure-function unit tests) — building one is a
separate, larger undertaking than this fix warrants.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 13:35:04 +02:00
Nick SzaunigandClaude Sonnet 5 229a1dc435 fix: raise text-ink/50 contrast to WCAG AA across 8 files
Live Lighthouse audit of /konto found the same class of bug as the
prior checkout/cart commit, one shade lighter: text-ink/50 measures
3.08:1 against the cream background, under the 4.5:1 WCAG AA minimum
for normal-size text. Grepped for every other use of the same token
(login divider, language switcher, sold-out label, admin image-upload
status text, admin hint text) and bumped them all to text-ink/70
(5.61:1, same value used in the earlier checkout fix) for one
consistent, compliant muted-text shade. Verified: tsc clean, 151/151
tests pass, /konto Lighthouse accessibility 96 -> 100.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 21:43:43 +02:00
Nick SzaunigandClaude Sonnet 5 0402f7510d fix: eliminate checkout/cart layout shift and low-contrast muted text
Live Lighthouse + performance-trace audits against the running dev app
found two real, measurable defects:

- /checkout and /warenkorb rendered the cart-store's default empty
  state before Zustand's persist middleware finished reading
  localStorage, so any customer who already had items in their cart
  saw an "empty cart" flash before the real form appeared (CLS 0.82).
  Fixed by tracking hasHydrated in the store and rendering the
  existing route skeleton until rehydration completes. Verified via
  Chrome DevTools performance trace: CLS 0.82 -> 0.00 on both pages.

- axe-core (via Lighthouse) flagged text-ink/60 on the cream
  background at 4.09:1 contrast, under the 4.5:1 WCAG AA minimum for
  normal-size text. Bumped to text-ink/70 (5.61:1) everywhere the same
  token is used at text-xs/text-sm across 7 files.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 03:35:57 +02:00
Nick Szaunig 6143e5704d fix: Zahlungsmethoden-Text vereinheitlichen, Retry-Button in Zahlungsfehler-Mail
- Apple Pay/Google Pay geprueft (Stripe-Doku via `stripe docs`): bei der hier
  verwendeten vollstaendig gehosteten Checkout-Seite (kein payment_method_types,
  kein ui_mode: "embedded") bereits automatisch enthalten, sobald Kartenzahlung
  aktiv ist - keine Codeaenderung noetig, keine Domain-Registrierung noetig
  (die betrifft laut Stripe nur Elements/eingebettete Checkout-Seite). Klaerenden
  Kommentar in api/checkout/route.ts und README-Abschnitt "Stripe" ergaenzt.
- versand-zahlung-Seite nannte nur 4 von 6 tatsaechlich akzeptierten
  Zahlungsmethoden (SEPA-Lastschrift und Klarna fehlten, obwohl sie laut README
  aktiv unterstuetzt werden und auf der Checkout-Seite selbst bereits korrekt
  gelistet sind) - Text in beiden Sprachen an die vollstaendige Liste angeglichen.
- Transaktions-E-Mails durchgesehen: Layout/Branding/Track-Order-Button bei
  Bestellbestaetigung und Versand-Mail bereits solide, keine Aenderung noetig.
  Die Zahlungsfehler-Mail forderte im Fliesstext zur erneuten Bestellung auf,
  hatte dafuer aber keinen anklickbaren Link - jetzt ein echter Button zum Shop.

Verifiziert: tsc --noEmit sauber, pnpm test 151/151 gruen, pnpm build erfolgreich.
2026-09-06 03:16:34 +02:00
Nick Szaunig a579ebabeb feat: Geschenk- und Honigwissen-Hinweise auf der Produktseite, Umlaut-Suche fixen
- Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass
  beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann
  (Feature existierte bereits, war aber nur im Checkout-Formular selbst
  sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen)
- Produktdetailseite (Honig): kontextuelle Links zu passenden
  Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht
  wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt
  einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion
- Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche
  Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete"
  findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte
  eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der
  Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner
  lib/search-match.ts mit eigenen Tests ausgelagert
- Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/
  Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss
  (bereits vorhanden auf Produktseite/Warenkorb/Checkout)

Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien,
+9 neue Tests fuer search-match), pnpm build erfolgreich.
2026-09-06 03:07:24 +02:00
Nick Szaunig de0f6ee249 fix: Preis-/Datumsformat an Kunden-Locale anpassen, Produktgalerie-LCP verbessern
- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen
  jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte
  Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt
  hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz
  gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026".
  Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell-
  verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege,
  Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch.
- en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)"
  jetzt korrekt (vorher z.B. "Open cart, 1 items").
- product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image
  statt <img> (hochgeladene Produktfotos sind immer same-origin unter
  /uploads/..., brauchen also keine remotePatterns-Konfiguration wie der
  entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem
  sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist.
- content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer
  das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat).
- Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits
  korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung
  ohne Intervall) -- keine Aenderung noetig.

Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich.
2026-09-06 02:57:57 +02:00
Nick Szaunig ac432e261b fix: SEO- und Barrierefreiheits-Luecken auf Kaufentscheidungsseiten schliessen
Konkrete, verifizierte Luecken auf den Seiten, die Kund:innen von Google
aus oder per Link erreichen:

- Product-JSON-LD auf der Produktdetailseite ergaenzt um "image" (absolute
  URLs der hochgeladenen Produktfotos) — fehlte bisher trotz vorhandener
  Bilder und ist fuer Google Rich-Results/Merchant-Listing mit Sternebewertung
  + Preis im Suchergebnis erforderlich.
- Alt-Text fuer echte (hochgeladene) Bilder aussagekraeftiger: ContentImage
  uebernimmt jetzt das bereits vorhandene, bisher nur auf Platzhalterkacheln
  sichtbare "label" (Ernte-Jahr/Typ, Kategorie, Ort) mit in den Alt-Text —
  wirkt projektweit auf Produktkarten, Ernte-Uebersicht/-Detail und
  Produkt-Teaser. Produktgalerie-Hauptbild zeigt zusaetzlich die
  Geschmacksnoten im Alt-Text statt nur des Produktnamens.
- Checkout: allgemeine Fehlermeldung nach fehlgeschlagenem Absenden bekommt
  role="alert" und Fokus wird aktiv dorthin verschoben — bisher rein farblich
  markiert, Screenreader-Nutzer:innen haetten den Fehler beim geldwerten
  Bestellabschluss sonst gar nicht mitbekommen.
- Cart-Drawer und Warenkorb-Seite: Lagerbestand-Hinweise (Ausverkauft/Nur
  noch N) treffen asynchron erst nach dem ersten Rendern ein — jetzt in
  einem persistenten aria-live-Wrapper, damit der Uebergang tatsaechlich
  angesagt wird statt stumm zu bleiben.

Geprueft und bereits solide befunden (keine Aenderung noetig): Shop-Kanonical
ignoriert bewusst Such-/Filter-Query-Parameter (kein Duplicate-Content-Risiko),
Sitemap deckt Produkt- und Ernte-Detailseiten mit echtem lastmod ab,
Produktgalerie-Tastaturbedienung ueber echte <button>-Kacheln mit
aria-label/aria-current, Cart-Drawer-Fokusfalle/Fokus-Rueckgabe, Mengen-Steller-
aria-live, Farbkontrast von text-honey-dark/text-red-700 auf dem Hintergrund
(~4.9:1 bzw. ~6.1:1, beide AA-konform).

tsc/vitest/build bleiben gruen (142 Tests).
2026-09-06 02:46:06 +02:00
Nick Szaunig 0b2196460a feat: Kaufjourney-Optimierungen fuer Kund:innen (Shop, Produktseite, Warenkorb, Checkout)
Konkrete Reibungspunkte entlang der Kauf-Journey behoben:
- Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur
  in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit
  lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig
  zurueckzusetzen; plus "Suche zuruecksetzen"-Link.
- Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite
  (bisher erst im Warenkorb/Checkout sichtbar).
- Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die
  Vorschau-Kacheln (mobile Kaufentscheidung).
- Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der
  Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht
  wieder erst erscrollt werden muss.
- Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und
  auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer
  Endpunkt /api/shop/cart-stock + useCartStock-Hook.
- Checkout- und Adressformulare: autoComplete/inputMode/type auf allen
  Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und
  passende Bildschirmtastaturen.
- Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung
  verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die
  Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren
  auffindbar.

Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build
bleiben gruen (142 Tests).
2026-09-05 18:02:38 +02:00
62 changed files with 1568 additions and 397 deletions

No files matched your search

+2
View File
@@ -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).
+6 -1
View File
@@ -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 });
} }
} }
+23 -5
View File
@@ -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",
+9 -1
View File
@@ -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",
+27 -8
View File
@@ -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",
+1 -1
View File
@@ -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>
+18 -5
View File
@@ -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}`}
+88
View File
@@ -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}` });
}
+26 -4
View File
@@ -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) {
+101 -62
View File
@@ -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).
+43
View File
@@ -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 });
}
+3 -1
View File
@@ -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 });
+11 -7
View File
@@ -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}
+45 -33
View File
@@ -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
+90 -15
View File
@@ -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"
/> />
+16 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+85 -19
View File
@@ -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} />
+18 -5
View File
@@ -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>
); );
+8 -5
View File
@@ -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>
); );
+11 -5
View File
@@ -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) {
+26 -6
View File
@@ -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"
/> />
+11 -7
View File
@@ -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
+1 -1
View File
@@ -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" />
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+27 -14
View File
@@ -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>
); );
+8 -3
View File
@@ -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];
+33 -2
View File
@@ -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();
});
}); });
+62 -35
View File
@@ -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" />
+1 -1
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+90
View File
@@ -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>
);
}
+12 -1
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -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>
+2
View File
@@ -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}>
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
+5 -4
View File
@@ -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" />
+53 -8
View File
@@ -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" />
)} )}
+49 -27
View File
@@ -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}
+69
View File
@@ -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>
);
}
+41
View File
@@ -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 : {};
}
+13 -1
View File
@@ -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.",
+16 -4
View File
@@ -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.",
+14
View File
@@ -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;
+13 -2
View File
@@ -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
View File
@@ -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));
}); });
} }
+8
View File
@@ -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";
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+46
View File
@@ -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);
});
});
+29
View File
@@ -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));
}
+61
View File
@@ -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
View File
@@ -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-
+12
View File
@@ -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);
},
} }
) )
); );