Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064ca3ff6d | ||
|
|
4ee5bafe20 | ||
|
|
6ac7cc5d1f | ||
|
|
3de17ca070 | ||
|
|
3cb639c154 | ||
|
|
839bbff595 |
No files matched your search
+6
-5
@@ -22,12 +22,13 @@ MYSQL_DATABASE=wabenhain
|
||||
|
||||
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
|
||||
#
|
||||
# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig
|
||||
# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der
|
||||
# Browser wird lediglich weitergeleitet. Einen veroeffentlichbaren Schluessel
|
||||
# (pk_...) braeuchte erst eine clientseitige Einbindung wie Stripe Elements —
|
||||
# solange es die nicht gibt, waere er Konfiguration, die nichts tut.
|
||||
# Serverseitiger Schluessel fuer Checkout-Sessions, Erstattungen und Webhooks.
|
||||
# Empfohlen: ein eingeschraenkter Schluessel (rk_...) statt des vollen sk_...
|
||||
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Oeffentlicher Schluessel fuer das eingebettete Zahlungsformular auf /checkout.
|
||||
# Bewusst OHNE NEXT_PUBLIC_: wird zur Laufzeit gelesen, nicht beim Build
|
||||
# eingebrannt. Muss zum Modus des geheimen Schluessels passen (test/live).
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
|
||||
@@ -142,15 +142,27 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
|
||||
|
||||
## Stripe
|
||||
|
||||
1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet.
|
||||
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen: `STRIPE_SECRET_KEY` (empfohlen: eingeschränkter `rk_`-Schlüssel) und `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`). Der öffentliche Schlüssel wird zur Laufzeit gelesen (kein `NEXT_PUBLIC_`), damit dasselbe Docker-Image in Test und Produktion läuft.
|
||||
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
|
||||
3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden.
|
||||
3. Ohne `STRIPE_PUBLISHABLE_KEY` nimmt der Checkout keine Bestellung an und sagt das im Formular — eine Bestellung, die sich auf der Seite nicht bezahlen ließe, soll gar nicht erst entstehen. Für reine UI-Arbeit ohne Zahlung reicht es, die Keys leer zu lassen.
|
||||
|
||||
### Zahlungsmethoden (Karte, SEPA-Lastschrift, Klarna)
|
||||
### Eingebetteter Checkout
|
||||
|
||||
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.
|
||||
Bezahlt wird direkt auf `/checkout`, ohne Umleitung auf `checkout.stripe.com`: Die Checkout-Session läuft mit `ui_mode: "embedded_page"`, das Zahlungsformular ist ein Stripe-iframe. Kartendaten und PayPal-Anmeldung berühren den Server nie (PCI SAQ A bleibt).
|
||||
|
||||
**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.
|
||||
Ablauf:
|
||||
|
||||
1. Angaben ausfüllen → `POST /api/checkout` legt die Bestellung an, reserviert Bestand und liefert `clientSecret` + `cancelToken`.
|
||||
2. Das Formular tritt zurück, das Stripe-Formular erscheint (Stripe.js wird erst jetzt geladen). „Zurück zu deinen Angaben" ruft `POST /api/checkout/cancel` auf: Sitzung schließen, Bestand sofort freigeben, alle Eingaben bleiben erhalten.
|
||||
3. Nach jedem Zahlungsversuch — bei PayPal nach dem Umweg über paypal.com — leitet Stripe auf `/checkout/success?session_id=…`. Die Seite fragt **serverseitig** bei Stripe nach und gleicht die Sitzung mit der eigenen Bestellung ab; die URL allein beweist nichts. Bei `open` (abgebrochen/abgelehnt) wird dieselbe Sitzung erneut eingebettet, der Warenkorb bleibt erhalten — aber nur für den Browser, der die Bestellung angelegt hat: `POST /api/checkout` setzt dafür das HttpOnly-Cookie `wh_checkout_ref` (SameSite=Lax, Pfad `/checkout`, 40 Minuten). Die `session_id` in der Adresszeile allein öffnet weder das Zahlungsformular noch den Abbruch. Verbucht wird weiterhin ausschließlich im Webhook.
|
||||
|
||||
Die Content-Security-Policy in `src/proxy.ts` enthält die dafür nötigen Stripe-Hosts laut [Stripe-Sicherheitsleitfaden](https://docs.stripe.com/security/guide#content-security-policy). Farben und Schrift des Formulars stellt man im Dashboard unter [Branding](https://dashboard.stripe.com/settings/branding) ein.
|
||||
|
||||
### Zahlungsmethoden (PayPal, Karte, SEPA-Lastschrift, Klarna)
|
||||
|
||||
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 PayPal, 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. PayPal über Stripe ist für Konten in Deutschland verfügbar und muss für den Live-Betrieb einmal [aktiviert](https://docs.stripe.com/payments/paypal/activate) werden.
|
||||
|
||||
**Apple Pay / Google Pay**: werden bewusst nicht angeboten. Im eingebetteten Checkout erscheinen sie nur nach einer Domain-Registrierung unter [Dashboard → Zahlungsmethoden-Domains](https://dashboard.stripe.com/settings/payment_method_domains); ohne sie bleiben sie ausgeblendet. Die `Permissions-Policy` (`payment=(self)` in `next.config.ts`) müsste dafür zusätzlich für `https://js.stripe.com` geöffnet werden.
|
||||
|
||||
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:
|
||||
|
||||
@@ -174,7 +186,7 @@ Eine Bestellung entsteht **vor** der Zahlung und reserviert dabei sofort Lagerbe
|
||||
|
||||
| Wann | Was passiert |
|
||||
|------|--------------|
|
||||
| Klick auf „Abbrechen“ bei Stripe | `/checkout/cancel?ref=<Token>` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung |
|
||||
| „Zurück zu deinen Angaben“ / „Bestellung abbrechen“ | `POST /api/checkout/cancel` schließt die Stripe-Sitzung, bucht den Bestand **sofort** zurück und löscht die Bestellung (alte Links `/checkout/cancel?ref=<Token>` ändern nichts mehr — ein GET darf keinen Bestand freigeben, Link-Vorschauen und Mail-Scanner lösen ihn aus) |
|
||||
| Checkout einfach verlassen | Die Sitzung läuft nach **32 Minuten** ab, Stripe schickt `checkout.session.expired`, der Webhook bucht zurück und löscht |
|
||||
| Webhook kommt nicht an | Der CronJob `wabenhain-release-stale-orders` prüft alle 30 Minuten bei Stripe nach und bucht zurück, was dort abgelaufen ist |
|
||||
| Stripe-Sitzung lässt sich gar nicht erst anlegen | `api/checkout/route.ts` bucht den Bestand sofort zurück und hinterlässt die Bestellung als **STORNIERT** mit Notiz — sie war nie bezahlbar |
|
||||
@@ -197,7 +209,7 @@ Setzt die Imkerei eine Bestellung unter `/admin/bestellungen/<id>` auf **STORNIE
|
||||
|
||||
Die Stripe-Aufrufe laufen bewusst **nach** der Transaktion: ein Netzaufruf dauert ein Vielfaches einer Datenbankoperation und würde die Sperren auf Bestellung und Lagerbestand so lange halten. Schlägt einer davon fehl, wird die Stornierung **nicht** zurückgenommen — die Ware ist bereits wieder im Bestand und die Absicht war eindeutig. Stattdessen erscheint der offene Punkt als Fehlermeldung im Admin-Bereich („bitte im Stripe-Dashboard von Hand erstatten") und als Eintrag `order.cancel_incomplete` im Audit-Log.
|
||||
|
||||
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) steht ausschließlich in der `cancel_url` und wird nach Gebrauch aus der Adresszeile entfernt. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
|
||||
Der **Abbruch-Token** (`Order.cancelToken`, 32 Byte) geht ausschließlich an den Browser, der die Bestellung gerade anlegt (bzw. an die Rückkehrseite derselben, noch offenen Sitzung), und wird nur per POST mit Origin-Prüfung eingelöst. Die Bestellnummer taugt dafür nicht: sie steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich also die noch offene Zahlung einer fremden Person abbrechen. Vor dem Zurückbuchen wird immer erst bei Stripe nachgesehen — ist die Sitzung dort `complete`, war der Aufruf kein Abbruch, sondern ein Zurück-Klick nach erfolgreicher Zahlung, und es passiert nichts.
|
||||
|
||||
Der Aufräumlauf lässt sich auch von Hand anstoßen: `pnpm orders:release-stale`. Er bucht nur zurück, was bei Stripe nachweislich abgelaufen ist, nie nach Alter allein. Findet er eine Bestellung, die bei Stripe **bezahlt** ist, hier aber noch auf „NEU“ steht, rührt er sie nicht an, meldet sie und endet mit Exit-Code 1 — dieser Fall bedeutet, dass ein `completed`-Webhook verlorengegangen ist, und er gehört als fehlgeschlagener Job sichtbar gemacht statt in eine Logzeile.
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ services:
|
||||
# gelesen (siehe .env.example).
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
|
||||
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
|
||||
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
|
||||
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
|
||||
# OHNE Default-Wert: ein vorhersehbarer Wert wie "[email protected]"
|
||||
|
||||
@@ -107,6 +107,12 @@ data:
|
||||
# Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind.
|
||||
DHL_ENVIRONMENT: "sandbox"
|
||||
|
||||
# Oeffentlicher Stripe-Schluessel (pk_live_...) fuer das eingebettete
|
||||
# Zahlungsformular. Nicht geheim — er steht ohnehin im HTML der Kasse.
|
||||
# Muss zum STRIPE_SECRET_KEY im Secret passen (beide live). Leer = der
|
||||
# Checkout nimmt keine Bestellungen an.
|
||||
STRIPE_PUBLISHABLE_KEY: ""
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"@prisma/adapter-mariadb": "^7.10.0",
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"@stripe/react-stripe-js": "^6.12.0",
|
||||
"@stripe/stripe-js": "^9.17.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"jose": "^6.2.8",
|
||||
|
||||
Generated
+26
@@ -24,6 +24,12 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: ^7.10.0
|
||||
version: 7.10.0([email protected](@types/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])([email protected]))([email protected])
|
||||
'@stripe/react-stripe-js':
|
||||
specifier: ^6.12.0
|
||||
version: 6.12.0(@stripe/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@stripe/stripe-js':
|
||||
specifier: ^9.17.0
|
||||
version: 9.17.0
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
@@ -1045,6 +1051,17 @@ packages:
|
||||
'@standard-schema/[email protected]':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@stripe/[email protected]':
|
||||
resolution: {integrity: sha512-5RVfx92MTb66+ELuQTreUqX2oFvma5wybZ7MaifoNo0QgrjbJiLZcysGw5kIHU7p1TCd1Lx5JnasoqteSwlUsg==}
|
||||
peerDependencies:
|
||||
'@stripe/stripe-js': '>=9.16.0 <10.0.0'
|
||||
react: '>=16.8.0 <20.0.0'
|
||||
react-dom: '>=16.8.0 <20.0.0'
|
||||
|
||||
'@stripe/[email protected]':
|
||||
resolution: {integrity: sha512-uidL4OJum5kivsOAQnVfQmYLJ2zdgirtmT0zWgskASZ4TPXxpXQ0MNrzW4NsXB90uEqmPHpSEtciLFbF1QQvNA==}
|
||||
engines: {node: '>=12.16'}
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -4523,6 +4540,15 @@ snapshots:
|
||||
|
||||
'@standard-schema/[email protected]': {}
|
||||
|
||||
'@stripe/[email protected](@stripe/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@stripe/stripe-js': 9.17.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
'@stripe/[email protected]': {}
|
||||
|
||||
'@swc/[email protected]':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -133,6 +133,17 @@ mustWhenSet(
|
||||
(raw) => !raw.startsWith("sk_test"),
|
||||
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
|
||||
);
|
||||
must(
|
||||
"STRIPE_PUBLISHABLE_KEY gesetzt",
|
||||
isSet("STRIPE_PUBLISHABLE_KEY"),
|
||||
"Ohne oeffentlichen Schluessel laesst sich das eingebettete Zahlungsformular nicht anzeigen — der Checkout nimmt keine Bestellung an. Steht in k8s/01-configmap.yaml."
|
||||
);
|
||||
mustWhenSet(
|
||||
"STRIPE_PUBLISHABLE_KEY",
|
||||
"STRIPE_PUBLISHABLE_KEY ist ein Live-Schluessel (pk_live_...)",
|
||||
(raw) => raw.startsWith("pk_live_"),
|
||||
"Ein pk_test-Schluessel passt nicht zum Live-Geheimschluessel: Stripe weist das Zahlungsformular dann ab. Steht hier gar ein sk_/rk_-Schluessel, gehoert er sofort rotiert — dieser Wert landet im HTML jeder Kassenseite."
|
||||
);
|
||||
|
||||
// --- E-Mail ----------------------------------------------------------------
|
||||
must(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import type { CustomerRole } from "@/generated/prisma/client";
|
||||
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
|
||||
return admin;
|
||||
}
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
export { fail, succeed } from "@/lib/flash";
|
||||
|
||||
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
||||
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import type { ArchiveKind } from "@/lib/archive";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
import { requireAdmin, str, fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
const LIST_PATH: Record<ArchiveKind, string> = {
|
||||
orders: "/admin/bestellungen",
|
||||
@@ -35,7 +36,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||
const kind = str(formData, "kind");
|
||||
const id = str(formData, "id");
|
||||
const archive = str(formData, "mode") === "archive";
|
||||
if (!isArchiveKind(kind) || !id) return;
|
||||
if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
|
||||
|
||||
const data = archive
|
||||
? { archivedAt: new Date(), archivedManually: true }
|
||||
@@ -51,7 +52,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||
: kind === "returns"
|
||||
? await prisma.returnRequest.updateMany({ where, data })
|
||||
: await prisma.product.updateMany({ where, data });
|
||||
if (result.count === 0) return;
|
||||
if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden.");
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: archive ? "archive.manual_archive" : "archive.manual_restore",
|
||||
@@ -60,4 +61,5 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath(LIST_PATH[kind]);
|
||||
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
||||
@@ -70,5 +70,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
});
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/admin/inhalte?saved=1");
|
||||
return succeed("/admin/inhalte", "Inhalte gespeichert.");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -10,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
|
||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -21,12 +21,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
role: str(formData, "role") || "KUNDE",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (existing) {
|
||||
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||
return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||
}
|
||||
|
||||
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
||||
@@ -43,7 +43,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
await createAndSendPasswordResetEmail(customer);
|
||||
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.create",
|
||||
entityType: "Customer",
|
||||
@@ -52,7 +52,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
if (!mailSent) {
|
||||
return fail(
|
||||
"/admin/benutzer",
|
||||
`Konto ${customer.email} wurde angelegt, aber die Einladung zum Passwort-Setzen ging nicht raus. ${MAIL_FAILED_HINT} Danach auf der Benutzerseite erneut senden.`
|
||||
);
|
||||
}
|
||||
return succeed("/admin/benutzer", `Konto ${customer.email} wurde angelegt — die Einladung zum Passwort-Setzen ist verschickt.`);
|
||||
}
|
||||
|
||||
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||
@@ -67,23 +73,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||
role: str(formData, "role") || "KUNDE",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (id === admin.id && parsed.data.role !== admin.role) {
|
||||
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||
return fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||
}
|
||||
|
||||
const target = await prisma.customer.findUnique({ where: { id } });
|
||||
if (!target) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
if (!target) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
|
||||
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
||||
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||
return fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||
}
|
||||
|
||||
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (emailOwner && emailOwner.id !== id) {
|
||||
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||
return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||
}
|
||||
|
||||
const emailChanged = parsed.data.email !== target.email;
|
||||
@@ -116,7 +122,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
return succeed("/admin/benutzer", "Änderungen gespeichert.");
|
||||
}
|
||||
|
||||
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||
@@ -124,10 +130,10 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||
|
||||
const id = str(formData, "id");
|
||||
if (id === admin.id) {
|
||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||
}
|
||||
if (await isLastAdmin(id)) {
|
||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||
}
|
||||
|
||||
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
||||
@@ -139,6 +145,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
|
||||
}
|
||||
|
||||
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||
@@ -156,6 +163,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||
detail: unblocked.email,
|
||||
});
|
||||
revalidatePath("/admin/benutzer");
|
||||
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
|
||||
}
|
||||
|
||||
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||
@@ -163,16 +171,16 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||
|
||||
const id = str(formData, "id");
|
||||
if (id === admin.id) {
|
||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||
return fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||
}
|
||||
if (await isLastAdmin(id)) {
|
||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
const deleted = await prisma.customer.delete({ where: { id } });
|
||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
||||
@@ -181,14 +189,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi
|
||||
const id = str(formData, "id");
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
|
||||
await createAndSendPasswordResetEmail(customer);
|
||||
const mailSent = await createAndSendPasswordResetEmail(customer);
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.password_reset_sent",
|
||||
entityType: "Customer",
|
||||
entityId: id,
|
||||
detail: customer.email,
|
||||
});
|
||||
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt."));
|
||||
if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT);
|
||||
return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt.");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
|
||||
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
|
||||
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
if (!order) {
|
||||
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
}
|
||||
|
||||
if (order.fulfillmentMethod !== "VERSAND") {
|
||||
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||
return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
|
||||
}
|
||||
if (order.payment?.status !== "SUCCEEDED") {
|
||||
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||
return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
|
||||
}
|
||||
if (order.shipment?.trackingNumber) {
|
||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
|
||||
}
|
||||
|
||||
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
|
||||
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
claimed = false;
|
||||
}
|
||||
if (!claimed) {
|
||||
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
||||
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
|
||||
}
|
||||
|
||||
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
|
||||
@@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
.catch(() => {});
|
||||
|
||||
if (error instanceof DhlNotConfiguredError) {
|
||||
fail(detailPath, error.message);
|
||||
return fail(detailPath, error.message);
|
||||
}
|
||||
if (error instanceof DhlApiError) {
|
||||
fail(detailPath, error.message);
|
||||
return fail(detailPath, error.message);
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||
return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
|
||||
error
|
||||
);
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
|
||||
);
|
||||
@@ -168,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
revalidatePath(detailPath);
|
||||
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { discountCodeSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
function parseDiscountCodeForm(formData: FormData) {
|
||||
const rawMax = str(formData, "maxUses");
|
||||
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseDiscountCodeForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||
if (existing) {
|
||||
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
||||
return fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
||||
}
|
||||
|
||||
const code = await prisma.discountCode.create({ data: parsed.data });
|
||||
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
return succeed("/admin/gutscheine", "Gutschein angelegt.");
|
||||
}
|
||||
|
||||
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseDiscountCodeForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||
if (existing && existing.id !== id) {
|
||||
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
||||
return fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
||||
}
|
||||
|
||||
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
||||
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
return succeed("/admin/gutscheine", "Gutschein gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
||||
try {
|
||||
deleted = await prisma.discountCode.delete({ where: { id } });
|
||||
} catch {
|
||||
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||
return fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
return succeed("/admin/gutscheine", "Gutschein gelöscht.");
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
|
||||
@@ -55,7 +55,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Ernte angelegt.");
|
||||
}
|
||||
|
||||
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
@@ -80,7 +80,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Ernte gespeichert.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,10 +99,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
include: { _count: { select: { batches: true, products: true } } },
|
||||
});
|
||||
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden.");
|
||||
if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
|
||||
|
||||
if (harvest._count.batches > 0 || harvest._count.products > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/ernten",
|
||||
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
|
||||
);
|
||||
@@ -119,7 +119,7 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Ernte gelöscht.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +148,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise<
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
|
||||
}
|
||||
|
||||
export async function createBatchAction(formData: FormData): Promise<void> {
|
||||
@@ -169,7 +169,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
|
||||
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
return succeed("/admin/chargen", "Charge angelegt.");
|
||||
}
|
||||
|
||||
export async function updateBatchAction(formData: FormData): Promise<void> {
|
||||
@@ -190,7 +190,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
|
||||
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
return succeed("/admin/chargen", "Charge gespeichert.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,16 +210,16 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true, orderItems: true } } },
|
||||
});
|
||||
if (!batch) fail("/admin/chargen", "Charge nicht gefunden.");
|
||||
if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
|
||||
|
||||
if (batch._count.orderItems > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/chargen",
|
||||
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
|
||||
);
|
||||
}
|
||||
if (batch._count.products > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/chargen",
|
||||
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
|
||||
);
|
||||
@@ -230,5 +230,5 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
|
||||
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
return succeed("/admin/chargen", "Charge gelöscht.");
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
import { requireAdmin, str, fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const status = str(formData, "status");
|
||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return;
|
||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status.");
|
||||
|
||||
await prisma.businessInquiry.update({
|
||||
where: { id },
|
||||
@@ -22,6 +23,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
|
||||
entityId: id,
|
||||
detail: status,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/anfragen");
|
||||
await setFlash("success", "Status der Anfrage gespeichert.");
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
@@ -13,6 +12,7 @@ import { getStripeClient } from "@/lib/stripe";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
|
||||
const ORDER_STATUSES = [
|
||||
"NEU",
|
||||
@@ -124,7 +124,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||
const detailPath = `/admin/bestellungen/${orderId}`;
|
||||
if (!ORDER_STATUSES.includes(status)) {
|
||||
fail(detailPath, "Ungültiger Status.");
|
||||
return fail(detailPath, "Ungültiger Status.");
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
@@ -198,9 +198,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
|
||||
}
|
||||
|
||||
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||
let mailSent: boolean | undefined;
|
||||
|
||||
if (result) {
|
||||
await logAdminAction(admin, {
|
||||
action: "order.status_change",
|
||||
@@ -238,7 +241,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
|
||||
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
|
||||
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
|
||||
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +260,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
trackingNumber,
|
||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
||||
const { subject, html, text } = await pickupReadyEmail({
|
||||
@@ -265,11 +268,14 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
orderNumber: result.orderNumber,
|
||||
firstName: result.firstName,
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
mailSent = await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
const done = `Status von ${result.orderNumber} gespeichert.`;
|
||||
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,7 +330,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
if (!order) {
|
||||
fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
||||
return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
|
||||
}
|
||||
|
||||
const verdict = checkOrderDeletable({
|
||||
@@ -333,7 +339,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
hasInvoice: order.invoice !== null,
|
||||
});
|
||||
if (!verdict.deletable) {
|
||||
fail(detailPath, {
|
||||
return fail(detailPath, {
|
||||
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
|
||||
payment:
|
||||
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
|
||||
@@ -351,7 +357,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
} else if (session.status === "complete") {
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
|
||||
);
|
||||
@@ -365,7 +371,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
|
||||
error
|
||||
);
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
|
||||
);
|
||||
@@ -394,7 +400,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
fail(
|
||||
return fail(
|
||||
detailPath,
|
||||
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
|
||||
);
|
||||
@@ -410,9 +416,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
redirect(
|
||||
`/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}`
|
||||
);
|
||||
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -433,7 +437,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
||||
where: { id: orderId },
|
||||
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
||||
});
|
||||
if (!order) return;
|
||||
if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
|
||||
|
||||
await prisma.shipment.upsert({
|
||||
where: { orderId },
|
||||
@@ -448,6 +452,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
||||
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
||||
});
|
||||
|
||||
let mailSent: boolean | undefined;
|
||||
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
||||
const { subject, html, text } = await shipmentEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
@@ -457,9 +462,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
|
||||
trackingNumber,
|
||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
mailSent = await sendMail({ to: order.email, subject, html, text });
|
||||
}
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
revalidatePath(`/admin/bestellungen/${orderId}`);
|
||||
if (mailSent === false) {
|
||||
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
|
||||
}
|
||||
await setFlash(
|
||||
"success",
|
||||
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
@@ -8,6 +7,7 @@ import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||
import { adminProductNumbersSchema } from "@/lib/validation";
|
||||
import { isValidUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
|
||||
@@ -18,9 +18,9 @@ import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
|
||||
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
|
||||
* Produkt bestellbar.
|
||||
*/
|
||||
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
|
||||
async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
|
||||
if (isActive && priceCents <= 0) {
|
||||
fail(
|
||||
return fail(
|
||||
redirectPath,
|
||||
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
|
||||
);
|
||||
@@ -42,12 +42,12 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
vatRatePercent: str(formData, "vatRatePercent"),
|
||||
});
|
||||
if (!parsedNumbers.success) {
|
||||
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
const numbers = parsedNumbers.data;
|
||||
|
||||
const isActive = formData.get("isActive") === "on";
|
||||
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
||||
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
|
||||
|
||||
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
||||
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||
@@ -64,7 +64,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
// hervorgeht, dass lediglich die Adresse belegt ist.
|
||||
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
|
||||
if (slugTaken) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/produkte/neu",
|
||||
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
|
||||
);
|
||||
@@ -101,7 +101,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
return succeed("/admin/produkte", "Produkt angelegt.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,10 +131,10 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
||||
where: { id },
|
||||
include: { _count: { select: { orderItems: true } } },
|
||||
});
|
||||
if (!product) fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||
if (!product) return fail("/admin/produkte", "Produkt nicht gefunden.");
|
||||
|
||||
if (product._count.orderItems > 0) {
|
||||
fail(
|
||||
return fail(
|
||||
"/admin/produkte",
|
||||
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
|
||||
);
|
||||
@@ -150,7 +150,7 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
return succeed("/admin/produkte", "Produkt gelöscht.");
|
||||
}
|
||||
|
||||
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
@@ -168,12 +168,12 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
vatRatePercent: str(formData, "vatRatePercent"),
|
||||
});
|
||||
if (!parsedNumbers.success) {
|
||||
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
const numbers = parsedNumbers.data;
|
||||
|
||||
const isActive = formData.get("isActive") === "on";
|
||||
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
|
||||
|
||||
const previousInventory = await prisma.inventory.findUnique({
|
||||
where: { productId: id },
|
||||
@@ -216,5 +216,5 @@ export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
return succeed("/admin/produkte", "Produkt gespeichert.");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -24,6 +25,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/bewertungen");
|
||||
revalidatePath(`/honig/${review.product.slug}`);
|
||||
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
|
||||
}
|
||||
|
||||
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||
@@ -44,4 +46,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||
|
||||
revalidatePath("/admin/bewertungen");
|
||||
revalidatePath(`/honig/${review.product.slug}`);
|
||||
await setFlash("success", "Bewertung gelöscht.");
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { shippingTierSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
function parseShippingTierForm(formData: FormData) {
|
||||
const rawMax = str(formData, "maxQuantity");
|
||||
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseShippingTierForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const tier = await prisma.shippingTier.create({
|
||||
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
return succeed("/admin/versand", "Versandstufe angelegt.");
|
||||
}
|
||||
|
||||
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
||||
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
||||
|
||||
const parsed = parseShippingTierForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
await prisma.shippingTier.update({
|
||||
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
return succeed("/admin/versand", "Versandstufe gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
||||
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
return succeed("/admin/versand", "Versandstufe gelöscht.");
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error, message } = await props.searchParams;
|
||||
|
||||
const [admin, customer] = await Promise.all([
|
||||
getCurrentAdmin(),
|
||||
@@ -36,12 +35,6 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
|
||||
</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
|
||||
@@ -3,9 +3,8 @@ import { createCustomerAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) {
|
||||
export default async function AdminBenutzerNewPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
@@ -13,9 +12,6 @@ export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benu
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
|
||||
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
|
||||
const { error, message, q } = await props.searchParams;
|
||||
const { q } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
|
||||
const [admin, customers] = await Promise.all([
|
||||
@@ -42,12 +42,6 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||
<label className="text-sm">
|
||||
|
||||
@@ -19,7 +19,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { id } = await props.params;
|
||||
const { error, message } = await props.searchParams;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -47,12 +46,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive
|
||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { q, status, error, message, ansicht } = await props.searchParams;
|
||||
const { q, status, ansicht } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||
const isArchive = isArchiveView(ansicht);
|
||||
@@ -53,12 +53,6 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
|
||||
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
|
||||
aus wie ein folgenloser Klick. */}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<AdminArchiveTabs
|
||||
basePath="/admin/bestellungen"
|
||||
|
||||
@@ -7,9 +7,8 @@ import { createBatchAction, deleteBatchAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) {
|
||||
export default async function AdminBatchesPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const [batches, harvests] = await Promise.all([
|
||||
prisma.batch.findMany({
|
||||
orderBy: { fillDate: "desc" },
|
||||
@@ -22,9 +21,6 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const searchParams = await props.searchParams;
|
||||
const error = searchParams.error;
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||
include: { _count: { select: { batches: true, products: true } } },
|
||||
@@ -22,9 +21,6 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
||||
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
<ApiCoreSyncResult searchParams={searchParams} />
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
|
||||
@@ -13,7 +13,6 @@ function toDateInputValue(date: Date | null): string {
|
||||
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const code = await prisma.discountCode.findUnique({ where: { id } });
|
||||
if (!code) notFound();
|
||||
|
||||
@@ -22,9 +21,6 @@ export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gu
|
||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={code.id} />
|
||||
|
||||
@@ -3,18 +3,14 @@ import { createDiscountCodeAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminGutscheinNewPage(props: PageProps<"/admin/gutscheine/neu">) {
|
||||
export default async function AdminGutscheinNewPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
|
||||
|
||||
@@ -7,9 +7,8 @@ import { deleteDiscountCodeAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
|
||||
export default async function AdminGutscheinePage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||
|
||||
return (
|
||||
@@ -22,9 +21,6 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -197,10 +197,9 @@ function GroupForm({
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
|
||||
export default async function AdminInhaltePage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { saved } = await props.searchParams;
|
||||
const current = await loadCurrentValues();
|
||||
|
||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||
@@ -218,12 +217,6 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
||||
|
||||
{saved ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{t.adminContentPage.saved}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
|
||||
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { noticeUrl } from "@/lib/notice";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { logoutAction } from "@/app/konto/actions";
|
||||
import { AdminShell } from "@/components/admin-shell";
|
||||
@@ -30,7 +31,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
|
||||
const admin = await getCurrentAdmin();
|
||||
if (!admin) {
|
||||
redirect("/konto?error=" + encodeURIComponent("Bitte melde dich mit einem Admin-Konto an."));
|
||||
redirect(noticeUrl("admin-login"));
|
||||
}
|
||||
|
||||
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
||||
@@ -38,10 +39,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
||||
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
||||
if (!admin.twoFactorEnabled) {
|
||||
redirect(
|
||||
"/konto/zwei-faktor?error=" +
|
||||
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
|
||||
);
|
||||
redirect(noticeUrl("admin-2fa"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const rawProduct = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { harvest: true, batch: true, inventory: true },
|
||||
@@ -36,12 +35,6 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
|
||||
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
|
||||
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
||||
|
||||
@@ -5,9 +5,8 @@ import { createProductAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) {
|
||||
export default async function AdminProductNewPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const [harvests, batches] = await Promise.all([
|
||||
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
||||
@@ -17,12 +16,6 @@ export default async function AdminProductNewPage(props: PageProps<"/admin/produ
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
|
||||
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
|
||||
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
|
||||
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
|
||||
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { fail, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/app/admin/actions";
|
||||
@@ -15,15 +15,12 @@ function str(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const status = str(formData, "status");
|
||||
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return;
|
||||
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return fail("/admin/retouren", "Ungültiger Status.");
|
||||
|
||||
const returnRequest = await prisma.returnRequest.findUnique({
|
||||
where: { id },
|
||||
@@ -31,7 +28,9 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
|
||||
});
|
||||
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
|
||||
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
|
||||
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return;
|
||||
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") {
|
||||
return fail("/admin/retouren", "Diese Retoure wurde bereits entschieden oder existiert nicht mehr.");
|
||||
}
|
||||
|
||||
const updated = await prisma.returnRequest.update({
|
||||
where: { id },
|
||||
@@ -51,9 +50,12 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
|
||||
status === "ANGENOMMEN"
|
||||
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
|
||||
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
|
||||
await sendMail({ to: updated.order.email, subject, html, text });
|
||||
const mailSent = await sendMail({ to: updated.order.email, subject, html, text });
|
||||
|
||||
revalidatePath("/admin/retouren");
|
||||
const done = `Retoure ${updated.order.orderNumber} ${status === "ANGENOMMEN" ? "angenommen" : "abgelehnt"}.`;
|
||||
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
|
||||
}
|
||||
|
||||
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
|
||||
@@ -68,11 +70,13 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
||||
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") {
|
||||
return fail("/admin/retouren", "Diese Retoure kann nicht (mehr) erstattet werden.");
|
||||
}
|
||||
|
||||
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
||||
if (!paymentIntentId) {
|
||||
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
||||
return fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
||||
}
|
||||
|
||||
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
|
||||
@@ -87,7 +91,7 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
where: { id, status: "ANGENOMMEN" },
|
||||
data: { status: "ERSTATTET" },
|
||||
});
|
||||
if (claimed.count === 0) return;
|
||||
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
|
||||
|
||||
const stripe = getStripeClient();
|
||||
try {
|
||||
@@ -116,7 +120,10 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
orderNumber: returnRequest.order.orderNumber,
|
||||
firstName: returnRequest.order.firstName,
|
||||
});
|
||||
await sendMail({ to: returnRequest.order.email, subject, html, text });
|
||||
const mailSent = await sendMail({ to: returnRequest.order.email, subject, html, text });
|
||||
|
||||
revalidatePath("/admin/retouren");
|
||||
const done = `Retoure ${returnRequest.order.orderNumber} erstattet — der Betrag geht über Stripe zurück.`;
|
||||
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
|
||||
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const tier = await prisma.shippingTier.findUnique({ where: { id } });
|
||||
if (!tier) notFound();
|
||||
|
||||
@@ -17,9 +16,6 @@ export default async function AdminVersandEditPage(props: PageProps<"/admin/vers
|
||||
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={tier.id} />
|
||||
|
||||
@@ -3,18 +3,14 @@ import { createShippingTierAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminVersandNewPage(props: PageProps<"/admin/versand/neu">) {
|
||||
export default async function AdminVersandNewPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -7,9 +7,8 @@ import { deleteShippingTierAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) {
|
||||
export default async function AdminVersandPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||
|
||||
return (
|
||||
@@ -24,9 +23,6 @@ export default async function AdminVersandPage(props: PageProps<"/admin/versand"
|
||||
|
||||
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withFlash } from "@/lib/flash";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import {
|
||||
@@ -50,9 +51,7 @@ export async function GET(request: Request) {
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
function redirectWithError(message: string): NextResponse {
|
||||
const url = new URL("/konto", baseUrl);
|
||||
url.searchParams.set("error", message);
|
||||
return NextResponse.redirect(url);
|
||||
return withFlash(NextResponse.redirect(new URL("/konto", baseUrl)), "error", message);
|
||||
}
|
||||
|
||||
if (!isEntraConfigured()) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withFlash } from "@/lib/flash";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { setEntraOAuthCookie } from "@/lib/auth";
|
||||
import { signEntraOAuthState } from "@/lib/session";
|
||||
@@ -12,9 +13,11 @@ import { isEntraConfigured, buildEntraAuthorizeUrl, generatePkcePair, generateOA
|
||||
*/
|
||||
export async function GET() {
|
||||
if (!isEntraConfigured()) {
|
||||
const url = new URL("/konto", getBaseUrl());
|
||||
url.searchParams.set("error", "SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an.");
|
||||
return NextResponse.redirect(url);
|
||||
return withFlash(
|
||||
NextResponse.redirect(new URL("/konto", getBaseUrl())),
|
||||
"error",
|
||||
"SSO ist noch nicht konfiguriert. Bitte melde dich mit E-Mail und Passwort an."
|
||||
);
|
||||
}
|
||||
|
||||
const { codeVerifier, codeChallenge } = generatePkcePair();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
|
||||
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
|
||||
|
||||
const releaseCheckout = vi.fn<(token: string) => Promise<void>>(async () => undefined);
|
||||
vi.mock("@/lib/checkout-release", () => ({
|
||||
CANCEL_TOKEN_PATTERN: /^[0-9a-f]{64}$/,
|
||||
releaseCheckout: (token: string) => releaseCheckout(token),
|
||||
}));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
const TOKEN = "c".repeat(64);
|
||||
|
||||
function post(body: unknown): Promise<Response> {
|
||||
return POST(
|
||||
new Request("https://wabenhain.test/api/checkout/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isTrustedOrigin.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/checkout/cancel", () => {
|
||||
test("gibt die Bestellung zum Token frei", async () => {
|
||||
const response = await post({ cancelToken: TOKEN });
|
||||
expect(response.status).toBe(200);
|
||||
expect(releaseCheckout).toHaveBeenCalledWith(TOKEN);
|
||||
});
|
||||
|
||||
test("weist einen fremden Origin ab, ohne etwas freizugeben", async () => {
|
||||
isTrustedOrigin.mockReturnValue(false);
|
||||
const response = await post({ cancelToken: TOKEN });
|
||||
expect(response.status).toBe(403);
|
||||
expect(releaseCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("weist die Bestellnummer als Ersatz fuer den Token ab", async () => {
|
||||
const response = await post({ cancelToken: "WH-2026-AABBCCDD" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(releaseCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("weist einen Body ohne Token ab", async () => {
|
||||
const response = await post(null);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { releaseCheckout, CANCEL_TOKEN_PATTERN } from "@/lib/checkout-release";
|
||||
|
||||
/**
|
||||
* Bricht eine offene, eingebettete Stripe-Zahlung ab und gibt den dafuer
|
||||
* reservierten Bestand sofort frei ("Zurueck zu deinen Angaben" im
|
||||
* Zahlungsschritt, "Zahlung abbrechen" auf der Rueckkehrseite).
|
||||
*
|
||||
* POST statt eines Links: der Aufruf aendert Daten und darf weder von einem
|
||||
* Prefetch noch von einem fremden <img src> ausgeloest werden. Der
|
||||
* Origin-Check schliesst gefaelschte Cross-Site-POSTs aus (siehe lib/csrf.ts).
|
||||
*
|
||||
* Die Antwort ist bei jedem wohlgeformten Token dieselbe — ob es ihn gab und
|
||||
* ob tatsaechlich etwas freigegeben wurde, verraet sie nicht.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const cancelToken =
|
||||
body && typeof body === "object" && "cancelToken" in body
|
||||
? (body as { cancelToken?: unknown }).cancelToken
|
||||
: undefined;
|
||||
|
||||
if (typeof cancelToken !== "string" || !CANCEL_TOKEN_PATTERN.test(cancelToken)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
await releaseCheckout(cancelToken);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -121,7 +121,7 @@ beforeEach(() => {
|
||||
);
|
||||
sessionsCreate.mockResolvedValue({
|
||||
id: "cs_test_1",
|
||||
url: "https://checkout.stripe.com/c/pay/cs_test_1",
|
||||
client_secret: "cs_test_1_secret_abc",
|
||||
});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
@@ -243,15 +243,45 @@ describe("Checkout-Route", () => {
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
url: "https://checkout.stripe.com/c/pay/cs_test_1",
|
||||
});
|
||||
const body = await response.json();
|
||||
expect(body.clientSecret).toBe("cs_test_1_secret_abc");
|
||||
// Der Abbruch-Token ist das, was "Zurueck zu deinen Angaben" braucht —
|
||||
// und derselbe, der an der Bestellung gespeichert wird.
|
||||
expect(body.cancelToken).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(db.order.create.mock.calls[0][0].data.cancelToken).toBe(body.cancelToken);
|
||||
|
||||
// Derselbe Token als HttpOnly-Cookie: bindet den erneuten Versuch nach
|
||||
// PayPal an genau diesen Browser (siehe lib/checkout-return.ts).
|
||||
const cookie = response.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toContain(`wh_checkout_ref=${body.cancelToken}`);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/SameSite=Lax/i);
|
||||
expect(cookie).toMatch(/Secure/i);
|
||||
expect(cookie).toMatch(/Path=\/checkout/i);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(db.order.update).toHaveBeenCalledWith({
|
||||
where: { id: ORDER.id },
|
||||
data: { stripeCheckoutSessionId: "cs_test_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("bettet die Zahlung ein und schickt jede Zahlungsart auf die serverseitig pruefende Rueckkehrseite", async () => {
|
||||
await post();
|
||||
|
||||
const params = sessionsCreate.mock.calls[0][0];
|
||||
expect(params.ui_mode).toBe("embedded_page");
|
||||
expect(params.redirect_on_completion).toBe("always");
|
||||
// Feste Basis-URL statt Origin-Header, Sitzungs-ID statt Bestellnummer.
|
||||
expect(params.return_url).toBe(
|
||||
"https://wabenhain.test/checkout/success?session_id={CHECKOUT_SESSION_ID}"
|
||||
);
|
||||
// Eingebettete Sitzungen kennen diese beiden nicht — Stripe wiese sie ab.
|
||||
expect(params).not.toHaveProperty("success_url");
|
||||
expect(params).not.toHaveProperty("cancel_url");
|
||||
// Dynamische Zahlungsarten: PayPal & Co. kommen aus dem Dashboard.
|
||||
expect(params).not.toHaveProperty("payment_method_types");
|
||||
});
|
||||
|
||||
test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => {
|
||||
await post();
|
||||
|
||||
@@ -352,7 +382,7 @@ describe("Checkout-Route", () => {
|
||||
test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => {
|
||||
// Sonst entstuende genau der Zustand, den es nicht geben darf: eine
|
||||
// Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei
|
||||
// gefahrlos verfallen — ihre URL steht nur in der Antwort, die wir
|
||||
// gefahrlos verfallen — ihr client_secret steht nur in der Antwort, die wir
|
||||
// daraufhin nicht ausliefern.
|
||||
db.order.update.mockRejectedValueOnce(new Error("connection reset"));
|
||||
releaseUnpaidOrder.mockResolvedValue({
|
||||
@@ -368,5 +398,20 @@ describe("Checkout-Route", () => {
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
|
||||
test("behandelt eine Sitzung ohne client_secret wie eine gescheiterte", async () => {
|
||||
// Ohne client_secret laesst sich das eingebettete Formular nicht
|
||||
// zeigen. Ein 200er ohne Zahlungsweg haette die Ware bis zum Ablauf
|
||||
// der Sitzung festgehalten.
|
||||
sessionsCreate.mockResolvedValue({ id: "cs_test_1", client_secret: null });
|
||||
sessionsExpire.mockResolvedValue({});
|
||||
releaseUnpaidOrder.mockResolvedValue(null);
|
||||
|
||||
const response = await post();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
|
||||
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,11 @@ import { findCustomsExcludedArea, isCountryAllowed } from "@/lib/shipping-region
|
||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import {
|
||||
CHECKOUT_COOKIE,
|
||||
CHECKOUT_COOKIE_MAX_AGE_SECONDS,
|
||||
CHECKOUT_COOKIE_PATH,
|
||||
} from "@/lib/checkout-release";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { vatRateForProduct } from "@/lib/vat";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -360,11 +365,12 @@ export async function POST(request: Request) {
|
||||
|
||||
const locale = await getLocale();
|
||||
|
||||
// Steht gleich ausschliesslich in der cancel_url der Stripe-Sitzung und
|
||||
// gibt den reservierten Bestand frei, sobald die Kundschaft dort abbricht
|
||||
// (siehe checkout/cancel). 32 Byte, damit er nicht durchprobiert werden
|
||||
// kann — mit einem ratbaren Wert liesse sich die offene Zahlung fremder
|
||||
// Leute abbrechen.
|
||||
// Geht ausschliesslich in der Antwort an den Browser, der diese Bestellung
|
||||
// gerade anlegt, und gibt den reservierten Bestand frei, sobald die
|
||||
// Kundschaft den Zahlungsschritt verlaesst (siehe api/checkout/cancel).
|
||||
// 32 Byte, damit er nicht durchprobiert werden kann — mit einem ratbaren
|
||||
// Wert liesse sich die offene Zahlung fremder Leute abbrechen. Die
|
||||
// Bestellnummer taugt dafuer nicht: sie steht in jeder Bestaetigungsmail.
|
||||
const cancelToken = randomBytes(32).toString("hex");
|
||||
|
||||
// Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
|
||||
@@ -493,7 +499,7 @@ export async function POST(request: Request) {
|
||||
const stripe = getStripeClient();
|
||||
// Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei
|
||||
// waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde
|
||||
// sich sonst in success_url/cancel_url der Stripe-Session einschleusen —
|
||||
// sich sonst in die return_url der Stripe-Session einschleusen —
|
||||
// ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
|
||||
// eigener Checkout-Seite waere hochgradig phishing-tauglich.
|
||||
const origin = getBaseUrl();
|
||||
@@ -514,13 +520,15 @@ export async function POST(request: Request) {
|
||||
|
||||
// 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.
|
||||
// Zahlungsmethoden — PayPal eingeschlossen, sobald es dort aktiv ist.
|
||||
//
|
||||
// "embedded_page": das Zahlungsformular erscheint als Stripe-iframe
|
||||
// direkt auf /checkout statt auf checkout.stripe.com. Kartendaten
|
||||
// beruehren diesen Server trotzdem nie (PCI SAQ A bleibt). Apple Pay und
|
||||
// Google Pay braeuchten dafuer eine Domain-Registrierung im Dashboard;
|
||||
// ohne sie erscheinen sie schlicht nicht.
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
ui_mode: "embedded_page",
|
||||
mode: "payment",
|
||||
customer_email: input.email,
|
||||
line_items: [
|
||||
@@ -549,14 +557,27 @@ export async function POST(request: Request) {
|
||||
// Ohne diese Angabe laeuft die Sitzung erst nach 24 Stunden ab und
|
||||
// haelt so lange Lagerbestand fest (siehe CHECKOUT_SESSION_TTL_MINUTES).
|
||||
expires_at: Math.floor(Date.now() / 1000) + CHECKOUT_SESSION_TTL_MINUTES * 60,
|
||||
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
|
||||
// Der Token statt der Bestellnummer: die Bestellnummer steht in jeder
|
||||
// Bestaetigungsmail und auf jeder Rechnung, mit ihr liesse sich also
|
||||
// die noch offene Zahlung einer fremden Person abbrechen.
|
||||
cancel_url: `${origin}/checkout/cancel?ref=${cancelToken}`,
|
||||
// Jede Zahlungsart endet hier — Karte ebenso wie PayPal, das die
|
||||
// Kundschaft zwangslaeufig zu PayPal und zurueck schickt. Ein einziger
|
||||
// Rueckweg statt zweier (onComplete im Browser vs. Weiterleitung) heisst
|
||||
// auch: nur eine Stelle entscheidet ueber "bezahlt", und die fragt
|
||||
// serverseitig bei Stripe nach (siehe checkout/success). Den Platzhalter
|
||||
// ersetzt Stripe selbst; die Bestellnummer steht bewusst nicht in der
|
||||
// URL, sie wird erst nach der Pruefung aus der Datenbank gelesen.
|
||||
return_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
redirect_on_completion: "always",
|
||||
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||
});
|
||||
|
||||
// Ohne client_secret laesst sich das eingebettete Formular nicht
|
||||
// anzeigen — dieselbe Lage wie eine gescheiterte Sitzungserstellung.
|
||||
if (!session.client_secret) {
|
||||
await stripe.checkout.sessions.expire(session.id).catch((expireError: unknown) => {
|
||||
console.error(`Schliessen der Session ${session.id} fehlgeschlagen:`, expireError);
|
||||
});
|
||||
throw new Error(`Stripe-Sitzung ${session.id} ohne client_secret.`);
|
||||
}
|
||||
|
||||
// KEIN Best-effort mehr: ohne hinterlegte Sitzungs-ID laesst sich die
|
||||
// Bestellung auf keinem Weg mehr aufloesen. Der Abbruch-Klick
|
||||
// (checkout/cancel) braucht sie, um bei Stripe nachzusehen, und der
|
||||
@@ -569,8 +590,9 @@ export async function POST(request: Request) {
|
||||
// Schlaegt das Speichern fehl, wird deshalb die gerade erzeugte Sitzung
|
||||
// sofort wieder geschlossen und die Bestellung aufgeloest (aeusseres
|
||||
// catch). Dass dabei eine bezahlbare Sitzung verlorengeht, ist folgenlos:
|
||||
// ihre URL steht ausschliesslich in der Antwort, die wir daraufhin NICHT
|
||||
// ausliefern — niemand ausser diesem Prozess hat sie je gesehen.
|
||||
// ihr client_secret steht ausschliesslich in der Antwort, die wir
|
||||
// daraufhin NICHT ausliefern — niemand ausser diesem Prozess hat ihn je
|
||||
// gesehen.
|
||||
try {
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
@@ -592,7 +614,24 @@ export async function POST(request: Request) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
// client_secret: oeffnet ausschliesslich das Zahlungsformular dieser einen
|
||||
// Sitzung, sonst nichts. cancelToken: erlaubt "Zurueck zu deinen
|
||||
// Angaben", ohne dass die Ware bis zum Sitzungsablauf blockiert bleibt.
|
||||
// Beides geht nur an die Person, die gerade diese Bestellung angelegt hat.
|
||||
const response = NextResponse.json(
|
||||
{ clientSecret: session.client_secret, cancelToken },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
// Siehe CHECKOUT_COOKIE: nur dieser Browser darf nach einem Umweg ueber
|
||||
// PayPal dieselbe Zahlung erneut oeffnen oder abbrechen.
|
||||
response.cookies.set(CHECKOUT_COOKIE, cancelToken, {
|
||||
httpOnly: true,
|
||||
secure: origin.startsWith("https://"),
|
||||
sameSite: "lax",
|
||||
path: CHECKOUT_COOKIE_PATH,
|
||||
maxAge: CHECKOUT_COOKIE_MAX_AGE_SECONDS,
|
||||
});
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
|
||||
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withFlash } from "@/lib/flash-core";
|
||||
import { NOTICES, isNoticeCode } from "@/lib/notice";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
|
||||
/**
|
||||
* Zwischenstation fuer Weiterleitungen aus Server Components (Layouts,
|
||||
* Seiten): haengt die Flash-Meldung zu einem festen Code an und leitet auf
|
||||
* dessen festes Ziel weiter (siehe lib/notice.ts).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const code = new URL(request.url).searchParams.get("code");
|
||||
const baseUrl = getBaseUrl();
|
||||
if (!isNoticeCode(code)) return NextResponse.redirect(new URL("/", baseUrl));
|
||||
const notice = NOTICES[code];
|
||||
return withFlash(NextResponse.redirect(new URL(notice.to, baseUrl)), "error", await notice.message());
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashVerificationToken } from "@/lib/email-verification";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { withFlash } from "@/lib/flash";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
const fail = (message: string) =>
|
||||
NextResponse.redirect(`${baseUrl}/konto?error=${encodeURIComponent(message)}`);
|
||||
const fail = (message: string) => withFlash(NextResponse.redirect(`${baseUrl}/konto`), "error", message);
|
||||
|
||||
if (!token) {
|
||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||
@@ -34,7 +34,9 @@ export async function GET(request: Request) {
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}`
|
||||
return withFlash(
|
||||
NextResponse.redirect(`${baseUrl}/konto`),
|
||||
"success",
|
||||
"Deine E-Mail-Adresse wurde bestätigt. Du kannst jetzt bestellen."
|
||||
);
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export default async function TrackOrderPage(props: PageProps<"/bestellung-verfo
|
||||
/>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
{order.invoice ? (
|
||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||
{t.trackOrderPage.viewInvoice}
|
||||
|
||||
@@ -1,118 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { releaseUnpaidOrder } from "@/lib/order-release";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestellung abgebrochen", robots: { index: false } };
|
||||
// Diese Seite aendert Daten (siehe releaseCheckout) und darf deshalb nie aus
|
||||
// einem Cache beantwortet werden.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CANCEL_ATTEMPTS_PER_WINDOW = 20;
|
||||
const CANCEL_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Gibt den beim Checkout reservierten Lagerbestand sofort wieder frei, wenn
|
||||
* die Kundschaft die Zahlung bei Stripe abbricht.
|
||||
*
|
||||
* Ohne das bliebe die Ware bis zum Ablauf der Stripe-Sitzung blockiert. Die
|
||||
* Sitzung laeuft zwar nur noch 32 Minuten statt 24 Stunden (siehe
|
||||
* api/checkout/route.ts), aber eine halbe Stunde ist immer noch eine halbe
|
||||
* Stunde, in der das letzte Glas fuer niemanden kaufbar ist, obwohl die
|
||||
* Person gerade ausdruecklich abgebrochen hat.
|
||||
*
|
||||
* Reihenfolge ist hier entscheidend: erst bei Stripe nachsehen, dann erst
|
||||
* etwas zurueckbuchen. Wuerden wir umgekehrt vorgehen, koennte eine bereits
|
||||
* bezahlte Bestellung storniert und ihr Bestand doppelt verkauft werden —
|
||||
* etwa wenn jemand nach erfolgreicher Zahlung den Zurueck-Knopf benutzt und
|
||||
* dabei wieder auf dieser Seite landet.
|
||||
*/
|
||||
async function releaseCheckout(cancelToken: string): Promise<void> {
|
||||
// Der Token hat 32 Byte und ist praktisch nicht zu erraten; die Bremse ist
|
||||
// trotzdem sinnvoll, weil jeder Aufruf sonst eine Stripe-Abfrage ausloest.
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout-cancel:${ip}`, CANCEL_ATTEMPTS_PER_WINDOW, CANCEL_WINDOW_MS)) return;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { cancelToken },
|
||||
select: { id: true, orderNumber: true, status: true, stripeCheckoutSessionId: true },
|
||||
});
|
||||
|
||||
// Unbekannter Token oder die Bestellung ist laengst nicht mehr offen
|
||||
// (bezahlt, storniert, versendet) — nichts zu tun. Bewusst ohne
|
||||
// unterscheidbare Rueckmeldung nach aussen: die Seite sieht in jedem Fall
|
||||
// gleich aus und verraet damit nicht, ob es den Token gibt.
|
||||
if (!order || order.status !== "NEU") return;
|
||||
|
||||
if (!order.stripeCheckoutSessionId) {
|
||||
// Kann vorkommen, wenn das Speichern der Sitzungs-ID fehlgeschlagen ist
|
||||
// (best-effort, siehe api/checkout/route.ts). Ohne sie laesst sich bei
|
||||
// Stripe nicht pruefen, ob doch bezahlt wurde — also nichts zurueckbuchen
|
||||
// und den Ablauf der Sitzung abwarten, der ueber den Webhook ohnehin
|
||||
// greift.
|
||||
console.warn(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} hat keine Stripe-Sitzung hinterlegt; Freigabe erst bei Ablauf der Sitzung.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
|
||||
|
||||
// "complete" heisst bezahlt oder in Bearbeitung — dann ist dieser
|
||||
// Seitenaufruf kein Abbruch, sondern ein Zurueck-Klick nach erfolgreicher
|
||||
// Zahlung. Finger weg.
|
||||
if (session.status === "complete") return;
|
||||
|
||||
// "open" heisst: noch bezahlbar. Erst die Sitzung schliessen, damit
|
||||
// niemand die bereits freigegebene Ware im naechsten Moment doch noch
|
||||
// bezahlt, und erst danach zurueckbuchen.
|
||||
if (session.status === "open") {
|
||||
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
|
||||
}
|
||||
|
||||
// Uebrig bleiben "open" (gerade geschlossen) und "expired" — in beiden
|
||||
// Faellen kann nicht mehr bezahlt werden. Stripe schickt dazu ein
|
||||
// checkout.session.expired-Ereignis; der Webhook findet die Bestellung
|
||||
// dann nicht mehr und tut nichts weiter (siehe lib/order-release.ts).
|
||||
//
|
||||
// "delete": hier wurde nie bezahlt und nie etwas bestaetigt. Eine
|
||||
// stornierte Bestellung stehen zu lassen wuerde nur verwirren — im
|
||||
// Admin-Bereich saehe jeder weggeklickte Warenkorb aus wie ein echter,
|
||||
// gescheiterter Verkauf.
|
||||
const released = await releaseUnpaidOrder(order.id, "delete");
|
||||
if (released) {
|
||||
console.info(
|
||||
`[checkout-cancel] Bestellung ${released.orderNumber} abgebrochen, Bestand freigegeben und Vormerkung entfernt.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Nicht weiterwerfen: die Person hat gerade abgebrochen und soll die
|
||||
// Abbruchseite sehen, nicht einen Serverfehler. Der Bestand kommt dann
|
||||
// spaetestens mit dem Ablauf der Sitzung zurueck.
|
||||
console.error(
|
||||
`[checkout-cancel] Bestellung ${order.orderNumber} konnte nicht freigegeben werden:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reine Anzeige, nachdem eine Zahlung per POST /api/checkout/cancel
|
||||
// abgebrochen wurde. Frueher gab ein GET mit ?ref=<Token> hier den Bestand
|
||||
// frei — ein Zustandswechsel beim blossen Aufruf, den auch Link-Vorschauen
|
||||
// und Mail-Scanner ausloesen. Alte Links fuehren deshalb nur noch hierher;
|
||||
// ihre Sitzung laeuft ohnehin nach spaetestens 32 Minuten ab und gibt den
|
||||
// Bestand dann ueber den Webhook frei.
|
||||
|
||||
export default async function CheckoutCancelPage(props: PageProps<"/checkout/cancel">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { ref } = await props.searchParams;
|
||||
|
||||
if (typeof ref === "string" && ref) {
|
||||
await releaseCheckout(ref);
|
||||
// Token aus Adresszeile, Verlauf und Referer nehmen, sobald er seinen
|
||||
// Zweck erfuellt hat. Er gibt zwar nur genau diese eine, gerade
|
||||
// abgebrochene Bestellung frei, aber ein Geheimnis gehoert nicht laenger
|
||||
// sichtbar herum, als es gebraucht wird.
|
||||
// Token aus Adresszeile, Verlauf und Referer nehmen.
|
||||
redirect("/checkout/cancel");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,26 @@ vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}));
|
||||
|
||||
// Stripe.js laesst sich in jsdom nicht laden — der Zahlungsschritt wird
|
||||
// durch einen Platzhalter ersetzt, der zeigt, was er bekommen hat, und den
|
||||
// Abbruch-Rueckruf ausloesen kann.
|
||||
vi.mock("./embedded-payment", () => ({
|
||||
EmbeddedPayment: (props: {
|
||||
clientSecret: string;
|
||||
cancelToken: string;
|
||||
cancelLabel: string;
|
||||
onCancelled: () => void;
|
||||
}) => (
|
||||
<div data-testid="embedded-payment" data-client-secret={props.clientSecret}>
|
||||
<button type="button" onClick={props.onCancelled}>
|
||||
{props.cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const PUBLISHABLE_KEY = "pk_test_abc123";
|
||||
|
||||
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
|
||||
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||
@@ -30,15 +49,29 @@ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderCheckout(items: CartItem[]) {
|
||||
function renderCheckout(items: CartItem[], stripePublishableKey: string | undefined = PUBLISHABLE_KEY) {
|
||||
useCartStore.setState({ items, isOpen: false });
|
||||
return render(
|
||||
<I18nProvider locale="de">
|
||||
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} />
|
||||
<CheckoutForm
|
||||
tiers={tiers}
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
stripePublishableKey={stripePublishableKey}
|
||||
/>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
|
||||
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
||||
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
||||
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
|
||||
await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1");
|
||||
await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450");
|
||||
await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster");
|
||||
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCartStore.setState({ items: [], isOpen: false });
|
||||
routerPush.mockClear();
|
||||
@@ -102,4 +135,48 @@ describe("CheckoutForm", () => {
|
||||
// Erfolgsseite erneut einbaut. Genau dafuer steht sie hier.
|
||||
expect(routerPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows the embedded payment step on this page instead of redirecting to Stripe", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
|
||||
} as Response);
|
||||
|
||||
renderCheckout([item()]);
|
||||
await fillAndSubmit(userEventInstance);
|
||||
|
||||
const payment = await screen.findByTestId("embedded-payment");
|
||||
expect(payment).toHaveAttribute("data-client-secret", "cs_test_1_secret_abc");
|
||||
// Das Formular tritt zurueck, bleibt aber erhalten.
|
||||
expect(screen.getByLabelText(/vorname/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("brings back the filled-in form after leaving the payment step", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
|
||||
} as Response);
|
||||
|
||||
renderCheckout([item()]);
|
||||
await fillAndSubmit(userEventInstance);
|
||||
await userEventInstance.click(await screen.findByRole("button", { name: /zurück zu deinen angaben/i }));
|
||||
|
||||
expect(screen.queryByTestId("embedded-payment")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/vorname/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/vorname/i)).toHaveValue("Erika");
|
||||
});
|
||||
|
||||
test("creates no order when no publishable key is configured", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
renderCheckout([item()], "");
|
||||
|
||||
await fillAndSubmit(userEventInstance);
|
||||
|
||||
expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||
@@ -12,6 +13,31 @@ import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||
import { allowedCountryCodes, type ShippingRegion } from "@/lib/shipping-region";
|
||||
|
||||
// Erst nachladen, wenn wirklich bezahlt wird: Stripe.js und das React-Paket
|
||||
// gehoeren nicht in den ersten Aufbau der Kasse, und serverseitig gibt es
|
||||
// ohnehin nichts zu zeichnen (das Formular ist ein iframe).
|
||||
const EmbeddedPayment = dynamic(
|
||||
() => import("./embedded-payment").then((module) => module.EmbeddedPayment),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
/** Was /api/checkout fuer den Zahlungsschritt zurueckgibt. */
|
||||
interface PaymentSession {
|
||||
clientSecret: string;
|
||||
cancelToken: string;
|
||||
}
|
||||
|
||||
function isPaymentSession(value: unknown): value is PaymentSession {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate.clientSecret === "string" &&
|
||||
candidate.clientSecret.length > 0 &&
|
||||
typeof candidate.cancelToken === "string" &&
|
||||
candidate.cancelToken.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
||||
|
||||
/**
|
||||
@@ -62,6 +88,7 @@ export function CheckoutForm({
|
||||
packstationSearchAvailable = false,
|
||||
turnstileSiteKey,
|
||||
shippingRegion = "DE",
|
||||
stripePublishableKey,
|
||||
}: {
|
||||
initialValues?: CheckoutInitialValues;
|
||||
tiers: ShippingTierLike[];
|
||||
@@ -81,6 +108,12 @@ export function CheckoutForm({
|
||||
* Laender anbieten, als der Shop beliefert.
|
||||
*/
|
||||
shippingRegion?: ShippingRegion;
|
||||
/**
|
||||
* Oeffentlicher Stripe-Schluessel (pk_...) fuer das eingebettete
|
||||
* Zahlungsformular. Fehlt er, wird gar nicht erst eine Bestellung
|
||||
* angelegt — sie liesse sich auf dieser Seite nicht bezahlen.
|
||||
*/
|
||||
stripePublishableKey?: string;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const items = useCartStore((s) => s.items);
|
||||
@@ -98,6 +131,13 @@ export function CheckoutForm({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
// Gesetzt, sobald die Bestellung angelegt ist: dann ersetzt der
|
||||
// Zahlungsschritt das Formular. Das Formular bleibt dabei nur versteckt,
|
||||
// damit "Zurueck zu deinen Angaben" alle Eingaben unveraendert vorfindet.
|
||||
const [payment, setPayment] = useState<PaymentSession | null>(null);
|
||||
// Ein Turnstile-Token gilt genau einmal und ist mit dem ersten Versuch
|
||||
// verbraucht. Nach "Zurueck" braucht es deshalb ein frisches Widget.
|
||||
const [turnstileKey, setTurnstileKey] = useState(0);
|
||||
|
||||
const [deliveryTarget, setDeliveryTarget] = useState<DeliveryTarget>("ADRESSE");
|
||||
const [packstationZip, setPackstationZip] = useState("");
|
||||
@@ -161,6 +201,11 @@ export function CheckoutForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stripePublishableKey) {
|
||||
setError(t.checkoutPage.paymentUnavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
@@ -217,13 +262,26 @@ export function CheckoutForm({
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = data.url;
|
||||
if (!isPaymentSession(data)) {
|
||||
setError(t.checkoutPage.genericError);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPayment({ clientSecret: data.clientSecret, cancelToken: data.cancelToken });
|
||||
setSubmitting(false);
|
||||
} catch {
|
||||
setError(t.checkoutPage.genericError);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaymentCancelled() {
|
||||
setPayment(null);
|
||||
setTurnstileToken(null);
|
||||
setTurnstileKey((value) => value + 1);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -246,7 +304,21 @@ export function CheckoutForm({
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container className="grid lg:grid-cols-3 gap-14">
|
||||
<form onSubmit={handleSubmit} className="lg:col-span-2 space-y-8">
|
||||
{payment && stripePublishableKey ? (
|
||||
<div className="lg:col-span-3">
|
||||
<EmbeddedPayment
|
||||
publishableKey={stripePublishableKey}
|
||||
clientSecret={payment.clientSecret}
|
||||
cancelToken={payment.cancelToken}
|
||||
cancelLabel={t.checkoutPage.paymentBack}
|
||||
onCancelled={handlePaymentCancelled}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stripe zeigt im Zahlungsschritt selbst Positionen und Summe —
|
||||
Formular und eigene Zusammenfassung treten dann zurueck. */}
|
||||
<form onSubmit={handleSubmit} hidden={payment !== null} className="lg:col-span-2 space-y-8">
|
||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
@@ -520,7 +592,7 @@ export function CheckoutForm({
|
||||
|
||||
{turnstileSiteKey ? (
|
||||
<div className="mb-4">
|
||||
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||
<TurnstileWidget key={turnstileKey} siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||
@@ -531,7 +603,7 @@ export function CheckoutForm({
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
||||
<aside hidden={payment !== null} className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
||||
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js/pure";
|
||||
import type { Stripe } from "@stripe/stripe-js";
|
||||
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
/**
|
||||
* Stripe.js genau einmal je Schluessel laden. "pure" statt des Standard-
|
||||
* Einstiegs: der haengt das Skript bereits beim blossen Import ein, also auf
|
||||
* jeder Seite, die dieses Modul irgendwann mitbringt. So kommt Stripe erst
|
||||
* ins Spiel, wenn wirklich bezahlt wird.
|
||||
*/
|
||||
const stripeByKey = new Map<string, Promise<Stripe | null>>();
|
||||
|
||||
function getStripe(publishableKey: string): Promise<Stripe | null> {
|
||||
const cached = stripeByKey.get(publishableKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = loadStripe(publishableKey);
|
||||
stripeByKey.set(publishableKey, pending);
|
||||
// Nach einem Fehlschlag (Netz, Werbeblocker) darf "Erneut versuchen" nicht
|
||||
// auf demselben abgelehnten Versprechen haengen bleiben.
|
||||
pending.catch(() => stripeByKey.delete(publishableKey));
|
||||
return pending;
|
||||
}
|
||||
|
||||
type LoadState = { status: "loading" } | { status: "ready"; stripe: Stripe } | { status: "failed" };
|
||||
|
||||
export interface EmbeddedPaymentProps {
|
||||
publishableKey: string;
|
||||
clientSecret: string;
|
||||
/** Abbruch-Token der Bestellung (siehe api/checkout/cancel). */
|
||||
cancelToken: string;
|
||||
/** Beschriftung des Abbruch-Knopfs — je nach Ort "Zurueck" oder "Abbrechen". */
|
||||
cancelLabel: string;
|
||||
/** Wird erst aufgerufen, nachdem der Server den Bestand freigegeben hat. */
|
||||
onCancelled: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Stripe-Zahlungsformular als iframe auf der eigenen Seite.
|
||||
*
|
||||
* Kartendaten, PayPal-Anmeldung und 3-D-Secure laufen vollstaendig innerhalb
|
||||
* von Stripes iframe bzw. bei PayPal — dieser Code sieht davon nichts. Ueber
|
||||
* "bezahlt" entscheidet er ebenfalls nicht: Stripe leitet nach jeder Zahlung
|
||||
* auf /checkout/success weiter, und erst dort wird serverseitig nachgefragt.
|
||||
*/
|
||||
export function EmbeddedPayment({
|
||||
publishableKey,
|
||||
clientSecret,
|
||||
cancelToken,
|
||||
cancelLabel,
|
||||
onCancelled,
|
||||
}: EmbeddedPaymentProps) {
|
||||
const { t } = useI18n();
|
||||
const [load, setLoad] = useState<LoadState>({ status: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelError, setCancelError] = useState(false);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// Fokus auf die neue Ueberschrift: der Schritt ersetzt das Formular, auf
|
||||
// dem der Fokus eben noch lag. Ohne das stuende er fuer Screenreader im
|
||||
// Nichts, und auf dem Handy bliebe die Seite unten am alten Knopf stehen.
|
||||
useEffect(() => {
|
||||
headingRef.current?.focus();
|
||||
headingRef.current?.scrollIntoView?.({ block: "start" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStripe(publishableKey)
|
||||
.then((stripe) => {
|
||||
if (cancelled) return;
|
||||
setLoad(stripe ? { status: "ready", stripe } : { status: "failed" });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
console.error("[checkout] Stripe.js konnte nicht geladen werden:", error);
|
||||
setLoad({ status: "failed" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [publishableKey, attempt]);
|
||||
|
||||
async function handleCancel() {
|
||||
setCancelling(true);
|
||||
setCancelError(false);
|
||||
try {
|
||||
const response = await fetch("/api/checkout/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cancelToken }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
onCancelled();
|
||||
} catch (error: unknown) {
|
||||
// Stehen bleiben statt trotzdem weiterzuschalten: sonst legte der
|
||||
// naechste Versuch eine zweite Bestellung an, waehrend die erste ihren
|
||||
// Bestand noch bis zum Sitzungsablauf festhaelt.
|
||||
console.error("[checkout] Abbruch fehlgeschlagen:", error);
|
||||
setCancelError(true);
|
||||
setCancelling(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="payment-heading" className="space-y-6">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<h2
|
||||
id="payment-heading"
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-24 text-2xl text-forest outline-none"
|
||||
>
|
||||
{t.checkoutPage.paymentHeading}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelling}
|
||||
className="text-sm text-ink/70 underline disabled:opacity-50"
|
||||
>
|
||||
{cancelling ? t.checkoutPage.paymentCancelling : cancelLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cancelError ? (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{t.checkoutPage.paymentCancelError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Mindesthoehe haelt den Platz frei, bis Stripe das iframe gezeichnet
|
||||
hat — sonst springt alles darunter beim Laden nach oben. */}
|
||||
<div className="min-h-[36rem]">
|
||||
{load.status === "loading" ? (
|
||||
<p className="text-sm text-ink/70" aria-live="polite">
|
||||
{t.checkoutPage.paymentLoading}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{load.status === "failed" ? (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{t.checkoutPage.paymentLoadError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLoad({ status: "loading" });
|
||||
setAttempt((value) => value + 1);
|
||||
}}
|
||||
className="underline"
|
||||
>
|
||||
{t.common.turnstile.retry}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{load.status === "ready" ? (
|
||||
<EmbeddedCheckoutProvider stripe={load.stripe} options={{ clientSecret }}>
|
||||
<EmbeddedCheckout id="stripe-embedded-checkout" />
|
||||
</EmbeddedCheckoutProvider>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
|
||||
import { isPackstationSearchConfigured } from "@/lib/dhl-locations";
|
||||
import { getStripePublishableKey } from "@/lib/stripe";
|
||||
|
||||
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
|
||||
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
|
||||
@@ -41,6 +42,7 @@ export default async function CheckoutPage() {
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||
shippingRegion={shippingRegion}
|
||||
stripePublishableKey={getStripePublishableKey()}
|
||||
/>;
|
||||
|
||||
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
||||
@@ -66,5 +68,6 @@ export default async function CheckoutPage() {
|
||||
bulkDiscountTiers={bulkDiscountTiers}
|
||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||
shippingRegion={shippingRegion}
|
||||
stripePublishableKey={getStripePublishableKey()}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
/**
|
||||
* Leert den Warenkorb, sobald die Rueckkehrseite eine abgeschlossene Zahlung
|
||||
* bestaetigt hat. Eigene Komponente, weil die Seite selbst serverseitig
|
||||
* rendert (sie fragt dort bei Stripe nach), der Warenkorb aber nur im
|
||||
* Browser liegt.
|
||||
*/
|
||||
export function ClearCartOnMount() {
|
||||
const clear = useCartStore((s) => s.clear);
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
}, [clear]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,47 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { use } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { CHECKOUT_COOKIE } from "@/lib/checkout-release";
|
||||
import { resolveCheckoutReturn, type CheckoutReturn } from "@/lib/checkout-return";
|
||||
import { getStripePublishableKey } from "@/lib/stripe";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { ClearCartOnMount } from "./clear-cart-on-mount";
|
||||
import { PaymentRetry } from "./payment-retry";
|
||||
|
||||
export default function CheckoutSuccessPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ order?: string; pending?: string }>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { order, pending } = use(searchParams);
|
||||
const clear = useCartStore((s) => s.clear);
|
||||
// Liest bei jedem Aufruf den aktuellen Zahlungsstand bei Stripe — nie aus
|
||||
// einem Cache.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
/**
|
||||
* Rueckkehrseite des eingebetteten Checkouts. Stripe leitet nach jedem
|
||||
* Zahlungsversuch hierher (return_url in api/checkout/route.ts), bei PayPal
|
||||
* nach dem Umweg ueber paypal.com.
|
||||
*/
|
||||
export default async function CheckoutSuccessPage(props: PageProps<"/checkout/success">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { session_id: sessionId, order: legacyOrder } = await props.searchParams;
|
||||
|
||||
let result: CheckoutReturn;
|
||||
if (typeof sessionId === "string" && sessionId) {
|
||||
const browserCancelToken = (await cookies()).get(CHECKOUT_COOKIE)?.value;
|
||||
result = await resolveCheckoutReturn(sessionId, browserCancelToken);
|
||||
} else if (typeof legacyOrder === "string" && legacyOrder) {
|
||||
// Sitzungen, die noch vor der Umstellung auf der gehosteten Stripe-Seite
|
||||
// geoeffnet wurden, kehren mit ?order= zurueck. Pruefen laesst sich das
|
||||
// nicht; die neutrale "in Bearbeitung"-Ansicht verspricht nichts, was
|
||||
// nicht die Bestaetigungsmail ohnehin haelt. Die Nummer aus der URL wird
|
||||
// bewusst nicht angezeigt.
|
||||
result = { kind: "processing", orderNumber: "" };
|
||||
} else {
|
||||
result = { kind: "notCompleted" };
|
||||
}
|
||||
|
||||
if (result.kind === "open") {
|
||||
const publishableKey = getStripePublishableKey();
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container className="max-w-3xl">
|
||||
<SectionHeading eyebrow={t.checkoutSuccessPage.openEyebrow} title={t.checkoutSuccessPage.openTitle} />
|
||||
<p role="status" className="mt-4 mb-10 text-ink/70">
|
||||
{t.checkoutSuccessPage.openText}
|
||||
</p>
|
||||
{publishableKey ? (
|
||||
<PaymentRetry
|
||||
publishableKey={publishableKey}
|
||||
clientSecret={result.clientSecret}
|
||||
cancelToken={result.cancelToken}
|
||||
/>
|
||||
) : (
|
||||
<LinkButton href="/warenkorb">{t.checkoutSuccessPage.backToCart}</LinkButton>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.kind === "notCompleted") {
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={pending ? t.checkoutSuccessPage.pendingEyebrow : t.checkoutSuccessPage.successEyebrow}
|
||||
title={pending ? t.checkoutSuccessPage.pendingTitle : t.checkoutSuccessPage.successTitle}
|
||||
eyebrow={t.checkoutSuccessPage.notCompletedEyebrow}
|
||||
title={t.checkoutSuccessPage.notCompletedTitle}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
{order ? (
|
||||
<p className="mt-6 text-ink/70">{t.checkoutSuccessPage.notCompletedText}</p>
|
||||
<LinkButton href="/warenkorb" className="mt-10">
|
||||
{t.checkoutSuccessPage.backToCart}
|
||||
</LinkButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPaid = result.kind === "paid";
|
||||
const { orderNumber } = result;
|
||||
|
||||
return (
|
||||
<div className="py-24">
|
||||
{/* Erst hier, nach der Pruefung bei Stripe: ein fehlgeschlagener oder
|
||||
abgebrochener Versuch laesst den Warenkorb unangetastet. */}
|
||||
<ClearCartOnMount />
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={isPaid ? t.checkoutSuccessPage.successEyebrow : t.checkoutSuccessPage.pendingEyebrow}
|
||||
title={isPaid ? t.checkoutSuccessPage.successTitle : t.checkoutSuccessPage.pendingTitle}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
{orderNumber ? (
|
||||
<p className="mt-4 text-ink/70">
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{order}</span>
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{orderNumber}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-ink/70">
|
||||
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText}
|
||||
{isPaid ? t.checkoutSuccessPage.successText : t.checkoutSuccessPage.pendingText}
|
||||
</p>
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton>
|
||||
{order ? (
|
||||
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(order)}`} variant="secondary">
|
||||
{orderNumber ? (
|
||||
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(orderNumber)}`} variant="secondary">
|
||||
{t.checkoutSuccessPage.trackOrder}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
const EmbeddedPayment = dynamic(
|
||||
() => import("../embedded-payment").then((module) => module.EmbeddedPayment),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface PaymentRetryProps {
|
||||
publishableKey: string;
|
||||
clientSecret: string;
|
||||
cancelToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zweiter Versuch nach einer fehlgeschlagenen oder bei PayPal abgebrochenen
|
||||
* Zahlung: dieselbe, noch offene Sitzung erneut einbetten, statt die
|
||||
* Kundschaft alles noch einmal eintippen zu lassen. Wer stattdessen
|
||||
* abbricht, bekommt den Bestand sofort freigegeben und landet auf der
|
||||
* Abbruchseite.
|
||||
*/
|
||||
export function PaymentRetry({ publishableKey, clientSecret, cancelToken }: PaymentRetryProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<EmbeddedPayment
|
||||
publishableKey={publishableKey}
|
||||
clientSecret={clientSecret}
|
||||
cancelToken={cancelToken}
|
||||
cancelLabel={t.checkoutSuccessPage.cancelPayment}
|
||||
onCancelled={() => router.replace("/checkout/cancel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+190
-2
@@ -34,16 +34,56 @@
|
||||
|
||||
--font-serif: var(--font-cormorant), Georgia, serif;
|
||||
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||
|
||||
/* Eine Bewegungssprache fuer die ganze Seite: selbstbewusstes Ankommen,
|
||||
schnelles Gehen (siehe impeccable animate: exit faster than entrance). */
|
||||
--ease-arrive: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-leave: cubic-bezier(0.7, 0, 0.84, 0);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Browserflaechen tragen die Marke mit: Scrollbar im Waldton statt Grau. */
|
||||
scrollbar-color: color-mix(in oklab, var(--color-forest) 45%, transparent) transparent;
|
||||
accent-color: var(--color-forest);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
caret-color: var(--color-honey-dark);
|
||||
}
|
||||
|
||||
/* Feine Papierkoernung ueber allem: bricht die digitale Glaette der
|
||||
Cremeflaechen, wie Etikettenpapier auf dem Glas. Fest, klickdurchlaessig,
|
||||
sehr leise — auf dunklen Flaechen kaum, auf hellen gerade spuerbar. */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70;
|
||||
pointer-events: none;
|
||||
opacity: 0.032;
|
||||
mix-blend-mode: multiply;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.14 0 0 0 0 0.12 0 0 0 0 0.08 0 0 0 1 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-honey-dark);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-underline-offset: 0.22em;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* Preise, Mengen, Datumsangaben: gleich breite Ziffern, damit Spalten und
|
||||
wechselnde Summen nicht springen. */
|
||||
.tabular-nums,
|
||||
[data-numeric] {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -66,6 +106,154 @@ p {
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-honey);
|
||||
color: var(--color-cream);
|
||||
/* Honig auf Creme erreichte nur ~2,6:1 — markierter Text war kaum lesbar. */
|
||||
background: var(--color-honey-pale);
|
||||
color: var(--color-forest);
|
||||
}
|
||||
|
||||
/* Unterstrich, der beim Hover von links "einlaeuft" — fuer Textlinks in
|
||||
Fliesstext und Navigation. */
|
||||
.link-draw {
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
background-size: 0% 1px;
|
||||
background-position: 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
transition: background-size 350ms var(--ease-arrive);
|
||||
}
|
||||
.link-draw:hover,
|
||||
.link-draw:focus-visible,
|
||||
.link-draw[aria-current="page"] {
|
||||
background-size: 100% 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link-draw {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Knopf eines gerade laufenden Server-Action-Formulars (components/
|
||||
submit-feedback.tsx): sichtbar "arbeitet", gegen Doppelklick gesperrt. */
|
||||
[data-pending="true"] {
|
||||
cursor: progress;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
[data-pending="true"]::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
margin-left: 0.5em;
|
||||
vertical-align: -0.1em;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: wh-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes wh-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-pending="true"]::after {
|
||||
animation: none;
|
||||
border-right-color: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hero: das Bild "atmet" kaum merklich (langsamer Zoom hin und zurueck). */
|
||||
@keyframes wh-drift {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.07) translate(-1%, -1%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll-Hinweis im Hero: ein Honigtropfen loest sich und faellt. */
|
||||
@keyframes wh-drip {
|
||||
0%,
|
||||
15% {
|
||||
transform: translateY(0) scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
transform: translateY(26px) scale(0.9, 1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(34px) scale(0.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-drift,
|
||||
.motion-drip {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Konto: erledigte Abschnitte der Statusleiste "fliessen" nacheinander voll. */
|
||||
@keyframes wh-grow {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Aktueller Schritt / laufender Status: sanfter Puls um die Wabe. */
|
||||
@keyframes wh-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in oklab, var(--color-honey) 55%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 7px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sechseck als Form fuer Icon-Flaechen (gleiche Geometrie wie Logomark). */
|
||||
.clip-hex {
|
||||
clip-path: polygon(50% 0, 93.3% 25%, 93.3% 75%, 50% 100%, 6.7% 75%, 6.7% 25%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-grow,
|
||||
.motion-pulse {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Formularfelder: weicher Honig-Schein beim Fokus statt nur einer
|
||||
Randfarbe — auf allen Formularen einheitlich, ohne jedes einzeln
|
||||
anzufassen. Viele Felder setzen focus:outline-none; der Schein
|
||||
(box-shadow) bleibt davon unberuehrt. */
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
|
||||
select,
|
||||
textarea {
|
||||
transition:
|
||||
border-color 200ms var(--ease-arrive),
|
||||
box-shadow 250ms var(--ease-arrive);
|
||||
}
|
||||
input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-honey) 22%, transparent);
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: color-mix(in oklab, var(--color-ink) 45%, transparent);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { allowedCountryCodes } from "@/lib/shipping-region";
|
||||
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||
import { TurnstileField } from "@/components/turnstile-widget";
|
||||
import { HoneycombPattern, HexCheck } from "@/components/honeycomb";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import type { WissenSlug } from "@/i18n/dictionary-type";
|
||||
@@ -81,7 +82,6 @@ export default async function ProductDetailPage(
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { slug } = await props.params;
|
||||
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
||||
const product = await getProductBySlug(slug);
|
||||
if (!product) notFound();
|
||||
|
||||
@@ -246,22 +246,29 @@ export default async function ProductDetailPage(
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<Container className="grid md:grid-cols-[1.1fr_1fr] gap-12 lg:gap-20">
|
||||
{/* Das Glas bleibt beim Lesen der Details stehen, statt eine leere
|
||||
Spalte zu hinterlassen — und liegt auf dem Wabenraster der Marke. */}
|
||||
<div className="relative self-start md:sticky md:top-28">
|
||||
<HoneycombPattern className="-inset-10 h-[calc(100%+5rem)] w-[calc(100%+5rem)] text-honey/25" size={26} />
|
||||
<div className="relative">
|
||||
<ProductGallery
|
||||
images={product.images}
|
||||
productName={product.name}
|
||||
description={product.tasteTags.join(", ")}
|
||||
/>
|
||||
</div>
|
||||
</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-4">
|
||||
{company.name} · {t.common.categories[product.category]}
|
||||
</p>
|
||||
<h1 className="text-4xl text-forest">{product.name}</h1>
|
||||
<h1 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em] text-forest">{product.name}</h1>
|
||||
{/* Ohne gepflegte Noten sonst ein leerer Absatz, der nur Abstand
|
||||
zwischen Ueberschrift und Bewertung erzeugt. */}
|
||||
{product.tasteTags.length > 0 ? (
|
||||
<p className="mt-2 text-ink/70">{product.tasteTags.join(" · ")}</p>
|
||||
<p className="mt-3 font-serif text-xl italic text-ink/70">{product.tasteTags.join(" · ")}</p>
|
||||
) : null}
|
||||
{reviewSummary.count > 0 ? (
|
||||
<Link href="#bewertungen" className="mt-4 flex items-center gap-2 text-sm">
|
||||
@@ -274,13 +281,13 @@ export default async function ProductDetailPage(
|
||||
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviews}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-baseline gap-3">
|
||||
<span className="text-2xl text-forest">
|
||||
<div className="mt-8 flex items-baseline gap-3">
|
||||
<span className="font-serif text-4xl text-forest tabular-nums">
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink/65">
|
||||
<p className="mt-1 text-xs text-ink/65 tabular-nums">
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||
</p>
|
||||
<Link
|
||||
@@ -294,7 +301,7 @@ export default async function ProductDetailPage(
|
||||
<p className="mt-2 text-xs text-ink/65">{t.productPage.giftHint}</p>
|
||||
|
||||
{isHoney ? (
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="mt-8 space-y-3 border-y border-ink/10 py-5">
|
||||
<TasteScale label={t.product.tasteMild} value={product.intensityMild} />
|
||||
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
|
||||
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
|
||||
@@ -303,7 +310,7 @@ export default async function ProductDetailPage(
|
||||
|
||||
{isPurchasable ? (
|
||||
<>
|
||||
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
|
||||
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-sm" />
|
||||
{lowStock ? (
|
||||
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
|
||||
) : null}
|
||||
@@ -349,12 +356,6 @@ export default async function ProductDetailPage(
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
{notifyError ? (
|
||||
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
||||
) : null}
|
||||
{notifyMessage ? (
|
||||
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
@@ -379,10 +380,11 @@ export default async function ProductDetailPage(
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-8 space-y-2 text-sm text-ink/70">
|
||||
<ul className="mt-8 grid gap-x-6 gap-y-2.5 text-sm text-ink/75 sm:grid-cols-2">
|
||||
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<span className="text-honey-dark">✓</span> {item}
|
||||
<li key={item} className="flex items-start gap-2.5">
|
||||
<HexCheck className="mt-px h-4 w-4 flex-shrink-0 text-honey-dark" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -435,15 +437,9 @@ export default async function ProductDetailPage(
|
||||
|
||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
<h2 className="text-3xl md:text-4xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-4 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
|
||||
@@ -517,11 +513,11 @@ export default async function ProductDetailPage(
|
||||
{relatedProducts.length > 0 ? (
|
||||
<Container className="mt-20">
|
||||
<ScrollReveal>
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
||||
<h2 className="text-3xl md:text-4xl text-forest mb-10">{t.productPage.relatedProductsHeading}</h2>
|
||||
</ScrollReveal>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{relatedProducts.map((related) => (
|
||||
<ProductCard key={related.id} product={related} />
|
||||
{relatedProducts.map((related, index) => (
|
||||
<ProductCard key={related.id} product={related} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
+14
-25
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
@@ -16,9 +17,6 @@ const REVIEW_WINDOW_MS = 60 * 60 * 1000;
|
||||
const STOCK_NOTIFY_ATTEMPTS_PER_WINDOW = 5;
|
||||
const STOCK_NOTIFY_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
// redirectPath kommt aus einem Formularfeld (siehe toggleWishlistAction) und
|
||||
// darf deshalb nicht ungeprueft an redirect()/revalidatePath() durchgereicht
|
||||
@@ -31,11 +29,11 @@ function isSafeRedirectPath(path: string): boolean {
|
||||
|
||||
export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben.");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`review:${ip}`, REVIEW_ATTEMPTS_PER_WINDOW, REVIEW_WINDOW_MS)) {
|
||||
fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut.");
|
||||
return fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const parsed = reviewSchema.safeParse({
|
||||
@@ -44,14 +42,14 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
comment: formData.get("comment"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: parsed.data.productId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!product) fail("/shop", "Produkt nicht gefunden.");
|
||||
if (!product) return fail("/shop", "Produkt nicht gefunden.");
|
||||
|
||||
const redirectPath = `/honig/${product.slug}`;
|
||||
|
||||
@@ -59,12 +57,12 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
// bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen.
|
||||
const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id);
|
||||
if (!purchased) {
|
||||
fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben.");
|
||||
return fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben.");
|
||||
}
|
||||
|
||||
const existing = await getCustomerReview(parsed.data.productId, customer.id);
|
||||
if (existing) {
|
||||
fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
||||
return fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
||||
}
|
||||
|
||||
await prisma.review.create({
|
||||
@@ -77,13 +75,13 @@ export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath(redirectPath);
|
||||
redirect(`${redirectPath}?message=${encodeURIComponent("Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.")}`);
|
||||
return succeed(redirectPath, "Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.");
|
||||
}
|
||||
|
||||
/** Fuegt ein Produkt der Wunschliste hinzu bzw. entfernt es, je nach aktuellem Zustand. */
|
||||
export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an, um Produkte zu merken.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an, um Produkte zu merken.");
|
||||
|
||||
const productId = String(formData.get("productId") ?? "");
|
||||
const submittedPath = String(formData.get("redirectPath") ?? "/shop");
|
||||
@@ -114,13 +112,6 @@ export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
||||
redirect(redirectPath);
|
||||
}
|
||||
|
||||
// Eigene Query-Parameter statt der von submitReviewAction genutzten
|
||||
// error/message: beide Actions redirecten auf dieselbe Produktseite, ein
|
||||
// gemeinsamer Parametername wuerde sonst z.B. eine Bewertungsbestaetigung
|
||||
// faelschlich auch im Ausverkauft-Block der Seite anzeigen lassen.
|
||||
function failNotify(path: string, message: string): never {
|
||||
redirect(`${path}?notifyError=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet eine E-Mail-Adresse fuer die Wiederverfuegbarkeits-Benachrichtigung
|
||||
@@ -130,14 +121,14 @@ function failNotify(path: string, message: string): never {
|
||||
export async function subscribeStockNotificationAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`stock-notify:${ip}`, STOCK_NOTIFY_ATTEMPTS_PER_WINDOW, STOCK_NOTIFY_WINDOW_MS)) {
|
||||
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
return fail("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
// Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter
|
||||
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
|
||||
// unseren Absender anzuschreiben.
|
||||
if (!(await isTurnstileValid(formData, ip))) {
|
||||
failNotify("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
return fail("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = stockNotificationSchema.safeParse({
|
||||
@@ -145,14 +136,14 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
||||
email: formData.get("email"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
failNotify("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: parsed.data.productId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!product) failNotify("/shop", "Produkt nicht gefunden.");
|
||||
if (!product) return fail("/shop", "Produkt nicht gefunden.");
|
||||
|
||||
const redirectPath = `/honig/${product.slug}`;
|
||||
|
||||
@@ -165,7 +156,5 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
||||
update: { notifiedAt: null, locale },
|
||||
});
|
||||
|
||||
redirect(
|
||||
`${redirectPath}?notifyMessage=${encodeURIComponent("Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.")}`
|
||||
);
|
||||
return succeed(redirectPath, "Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.");
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
export { fail, succeed } from "@/lib/flash";
|
||||
|
||||
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
|
||||
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
@@ -8,6 +7,7 @@ import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
/**
|
||||
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
||||
@@ -19,10 +19,10 @@ import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CO
|
||||
*/
|
||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
if (customer.role === "ADMIN") {
|
||||
fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
||||
return fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "");
|
||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
fail("/konto", "Passwort ist falsch.");
|
||||
return fail("/konto", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||
@@ -47,7 +47,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
|
||||
await prisma.customer.delete({ where: { id: customer.id } });
|
||||
await destroySession();
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
|
||||
return succeed("/konto", "Dein Konto wurde endgültig gelöscht.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
*/
|
||||
export async function logoutAllDevicesAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
@@ -67,5 +67,5 @@ export async function logoutAllDevicesAction(): Promise<void> {
|
||||
});
|
||||
|
||||
await destroySession();
|
||||
redirect("/konto?message=" + encodeURIComponent("Alle Geräte wurden abgemeldet. Bitte melde dich erneut an."));
|
||||
return succeed("/konto", "Alle Geräte wurden abgemeldet. Bitte melde dich erneut an.");
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { addressSchema } from "@/lib/validation";
|
||||
import { fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const parsed = addressSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
@@ -21,7 +22,7 @@ export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
isDefault: formData.get("isDefault") === "on",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (parsed.data.isDefault) {
|
||||
@@ -30,26 +31,29 @@ export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
|
||||
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
await setFlash("success", "Adresse gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const addressId = String(formData.get("addressId") ?? "");
|
||||
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
await setFlash("success", "Adresse gelöscht.");
|
||||
}
|
||||
|
||||
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const addressId = String(formData.get("addressId") ?? "");
|
||||
const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } });
|
||||
if (!address) fail("/konto/adressen", "Adresse nicht gefunden.");
|
||||
if (!address) return fail("/konto/adressen", "Adresse nicht gefunden.");
|
||||
|
||||
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
|
||||
await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } });
|
||||
revalidatePath("/konto/adressen");
|
||||
await setFlash("success", "Standardadresse geändert.");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import type { Customer } from "@/generated/prisma/client";
|
||||
import { fail } from "./_shared";
|
||||
import { isTurnstileValid } from "@/lib/turnstile";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
@@ -37,13 +38,13 @@ async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginA
|
||||
export async function loginAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
||||
return fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
||||
}
|
||||
|
||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
if (!(await isTurnstileValid(formData, ip))) {
|
||||
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse({
|
||||
@@ -51,23 +52,23 @@ export async function loginAction(formData: FormData): Promise<void> {
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto", "Bitte E-Mail und Passwort angeben.");
|
||||
return fail("/konto", "Bitte E-Mail und Passwort angeben.");
|
||||
}
|
||||
|
||||
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
|
||||
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
|
||||
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
|
||||
return fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
if (customer?.blockedAt) {
|
||||
fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
||||
return fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
||||
}
|
||||
|
||||
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto",
|
||||
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
|
||||
);
|
||||
@@ -79,7 +80,7 @@ export async function loginAction(formData: FormData): Promise<void> {
|
||||
|
||||
if (!customer || !passwordValid) {
|
||||
if (customer) await registerFailedLogin(customer);
|
||||
fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
|
||||
return fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
|
||||
}
|
||||
|
||||
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
|
||||
@@ -102,24 +103,24 @@ export async function loginAction(formData: FormData): Promise<void> {
|
||||
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
if (!customerId) {
|
||||
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||
return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) {
|
||||
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
return fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
if (!(await isTurnstileValid(formData, ip))) {
|
||||
fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
return fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
||||
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||
return fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||
}
|
||||
|
||||
const totpStep =
|
||||
@@ -152,7 +153,7 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
|
||||
return fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
|
||||
}
|
||||
|
||||
await clearTwoFactorChallengeCookie();
|
||||
@@ -162,5 +163,5 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
await destroySession();
|
||||
redirect("/konto");
|
||||
return succeed("/konto", "Du wurdest abgemeldet.");
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { fail } from "./_shared";
|
||||
import { setFlash } from "@/lib/flash";
|
||||
|
||||
export async function toggleNewsletterAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
|
||||
|
||||
@@ -28,4 +29,5 @@ export async function toggleNewsletterAction(): Promise<void> {
|
||||
}
|
||||
|
||||
revalidatePath("/konto");
|
||||
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail } from "./_shared";
|
||||
import { isTurnstileValid } from "@/lib/turnstile";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
||||
@@ -26,18 +27,18 @@ const MIN_RESET_REQUEST_RESPONSE_MS = 400;
|
||||
export async function requestPasswordResetAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) {
|
||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
if (!(await isTurnstileValid(formData, ip))) {
|
||||
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
||||
if (!parsed.success) {
|
||||
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
return fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
@@ -57,13 +58,13 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
|
||||
export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) {
|
||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
return fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||
if (!(await isTurnstileValid(formData, ip))) {
|
||||
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
return fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = resetPasswordSchema.safeParse({
|
||||
@@ -71,7 +72,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
@@ -80,7 +81,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
|
||||
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
|
||||
if (!isValid) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||
);
|
||||
@@ -92,7 +93,7 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link
|
||||
// eigenstaendig umgehbar.
|
||||
if (targetCustomer?.blockedAt) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||
);
|
||||
@@ -103,10 +104,9 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
// Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes
|
||||
// Passwort versuchen kann, statt einen komplett neuen Reset-Link
|
||||
// anfordern zu muessen.
|
||||
redirect(
|
||||
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent(
|
||||
return fail(
|
||||
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}`,
|
||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,5 +147,5 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
}
|
||||
|
||||
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert."));
|
||||
return succeed("/konto", "Dein Passwort wurde geändert.");
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||
import { fail } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
||||
@@ -18,12 +19,12 @@ const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
|
||||
export async function registerAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
|
||||
return fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const turnstileToken = String(formData.get("turnstileToken") ?? "");
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
return fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse({
|
||||
@@ -33,15 +34,15 @@ export async function registerAction(formData: FormData): Promise<void> {
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (formData.get("password") !== formData.get("passwordConfirm")) {
|
||||
fail("/konto", "Die Passwörter stimmen nicht überein.");
|
||||
return fail("/konto", "Die Passwörter stimmen nicht überein.");
|
||||
}
|
||||
|
||||
if (await isPasswordBreached(parsed.data.password)) {
|
||||
fail(
|
||||
return fail(
|
||||
"/konto",
|
||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||
);
|
||||
@@ -54,7 +55,7 @@ export async function registerAction(formData: FormData): Promise<void> {
|
||||
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
|
||||
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
|
||||
await hashPassword(parsed.data.password);
|
||||
fail(
|
||||
return fail(
|
||||
"/konto",
|
||||
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
|
||||
);
|
||||
@@ -72,22 +73,37 @@ export async function registerAction(formData: FormData): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
await createAndSendVerificationEmail(customer);
|
||||
const mailSent = await createAndSendVerificationEmail(customer);
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
if (!mailSent) {
|
||||
return fail(
|
||||
"/konto",
|
||||
"Konto erstellt — aber die Bestätigungs-E-Mail konnte nicht versendet werden. Bitte nutze gleich „Link erneut senden“."
|
||||
);
|
||||
}
|
||||
return succeed(
|
||||
"/konto",
|
||||
`Willkommen! Bitte bestätige deine E-Mail-Adresse über den Link, den wir an ${customer.email} geschickt haben.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
|
||||
export async function resendVerificationEmailAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
if (customer.emailVerifiedAt) redirect("/konto");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
return fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
await createAndSendVerificationEmail(customer);
|
||||
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
|
||||
const mailSent = await createAndSendVerificationEmail(customer);
|
||||
if (!mailSent) {
|
||||
return fail(
|
||||
"/konto",
|
||||
"Die E-Mail konnte gerade nicht versendet werden. Bitte versuche es in ein paar Minuten erneut oder schreib uns an [email protected]."
|
||||
);
|
||||
}
|
||||
return succeed("/konto", `Wir haben dir einen neuen Bestätigungslink an ${customer.email} geschickt.`);
|
||||
}
|
||||
@@ -19,13 +19,14 @@ import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||
import { succeed } from "@/lib/flash";
|
||||
|
||||
const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
|
||||
const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function startTwoFactorSetupAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor");
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
@@ -36,11 +37,11 @@ export async function startTwoFactorSetupAction(): Promise<void> {
|
||||
|
||||
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const setup = await getTwoFactorSetup();
|
||||
if (!setup || setup.customerId !== customer.id) {
|
||||
fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
|
||||
return fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
@@ -51,12 +52,12 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
|
||||
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
return fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
if (!verifyTotpCode(setup.secret, code, customer.email)) {
|
||||
fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
|
||||
return fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const { plain, hashes } = generateBackupCodes();
|
||||
@@ -83,12 +84,14 @@ export async function confirmTwoFactorSetupAction(formData: FormData): Promise<v
|
||||
|
||||
export async function dismissBackupCodesAction(): Promise<void> {
|
||||
await clearTwoFactorBackupCodesDisplayCookie();
|
||||
redirect("/konto/zwei-faktor");
|
||||
// Zurueck ins Profil statt auf die 2FA-Seite: dort steht als einzige
|
||||
// Aktion "deaktivieren" — direkt nach dem Einrichten die falsche Einladung.
|
||||
return succeed("/konto", "Zwei-Faktor-Authentifizierung ist jetzt aktiv. Dein Konto ist zusätzlich geschützt.");
|
||||
}
|
||||
|
||||
export async function disableTwoFactorAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
@@ -98,13 +101,13 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
return fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "");
|
||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
||||
return fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
@@ -121,5 +124,5 @@ export async function disableTwoFactorAction(formData: FormData): Promise<void>
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
|
||||
await sendMail({ to: updated.email, subject, html, text });
|
||||
|
||||
redirect("/konto/zwei-faktor?message=" + encodeURIComponent("Zwei-Faktor-Authentifizierung wurde deaktiviert."));
|
||||
return succeed("/konto", "Zwei-Faktor-Authentifizierung wurde deaktiviert.");
|
||||
}
|
||||
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Meine Adressen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoAdressenPage(props: PageProps<"/konto/adressen">) {
|
||||
export default async function KontoAdressenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
@@ -27,9 +26,6 @@ export default async function KontoAdressenPage(props: PageProps<"/konto/adresse
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 space-y-4">
|
||||
{addresses.map((address) => (
|
||||
|
||||
@@ -21,7 +21,6 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
@@ -55,9 +54,6 @@ export default async function RetourePage(props: PageProps<"/konto/bestellungen/
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={requestReturnAction} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
@@ -17,13 +17,10 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
|
||||
if (!customer) return fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
|
||||
|
||||
const parsed = returnRequestSchema.safeParse({
|
||||
orderId: formData.get("orderId"),
|
||||
@@ -31,7 +28,7 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
if (!parsed.success) {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
return fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
@@ -40,13 +37,13 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
if (!order || order.customerId !== customer.id) {
|
||||
fail("/konto/bestellungen", "Bestellung nicht gefunden.");
|
||||
return fail("/konto/bestellungen", "Bestellung nicht gefunden.");
|
||||
}
|
||||
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) {
|
||||
fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
|
||||
return fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
|
||||
}
|
||||
if (order.returnRequest) {
|
||||
fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
|
||||
return fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
|
||||
}
|
||||
|
||||
await prisma.returnRequest.create({
|
||||
@@ -54,7 +51,5 @@ export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/konto/bestellungen");
|
||||
redirect(
|
||||
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
|
||||
);
|
||||
return succeed("/konto/bestellungen", "Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.");
|
||||
}
|
||||
@@ -25,12 +25,11 @@ const RETURN_ELIGIBLE_STATUSES = [
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
||||
export default async function KontoBestellungenPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
@@ -42,12 +41,6 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
@@ -139,7 +132,7 @@ export default async function KontoBestellungenPage(props: PageProps<"/konto/bes
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<p className="font-serif text-2xl text-forest tabular-nums">{formatPrice(order.totalCents, order.currency, locale)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.invoice ? (
|
||||
<Link
|
||||
|
||||
+8
-29
@@ -22,9 +22,8 @@ import {
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
export default async function KontoPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
if (!customer) {
|
||||
@@ -32,19 +31,9 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
||||
<form action={loginAction} className="space-y-4 md:pr-12">
|
||||
<h2 className="text-lg text-forest">{t.account.login}</h2>
|
||||
<h2 className="text-3xl text-forest">{t.account.login}</h2>
|
||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||
{/* Das Turnstile-Widget ist ein iframe mit FESTER Breite (rund
|
||||
@@ -66,7 +55,7 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
</form>
|
||||
|
||||
<form action={registerAction} className="space-y-4 md:pl-12">
|
||||
<h2 className="text-lg text-forest">{t.account.register}</h2>
|
||||
<h2 className="text-3xl text-forest">{t.account.register}</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
|
||||
@@ -134,16 +123,6 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
<Container className="max-w-2xl">
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!customer.emailVerifiedAt ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
|
||||
@@ -197,7 +176,7 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
|
||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg text-forest">{t.account.newsletterHeading}</h2>
|
||||
<h2 className="text-2xl text-forest">{t.account.newsletterHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
@@ -211,7 +190,7 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg text-forest">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<h2 className="text-2xl text-forest">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{t.account.logoutAllDevicesDescription}
|
||||
</p>
|
||||
@@ -229,10 +208,10 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="text-lg text-forest mb-4">{t.account.privacy.heading}</h2>
|
||||
<h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2>
|
||||
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm text-forest">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
@@ -241,7 +220,7 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||
<h3 className="text-sm text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||
<ConfirmForm
|
||||
action={deleteOwnAccountAction}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = { title: "Passwort vergessen" };
|
||||
|
||||
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, sent } = await props.searchParams;
|
||||
const { sent } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
@@ -18,9 +18,6 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
||||
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
{sent ? (
|
||||
<p className="mt-6 text-sm text-ink/70 leading-relaxed">
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function PasswortZuruecksetzenPage(
|
||||
props: PageProps<"/konto/passwort-zuruecksetzen">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { token, error } = await props.searchParams;
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
@@ -21,9 +21,6 @@ export default async function PasswortZuruecksetzenPage(
|
||||
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
{!token ? (
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
|
||||
@@ -11,9 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Anmeldung bestätigen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-faktor-code">) {
|
||||
export default async function ZweiFaktorCodePage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
if (!customerId) redirect("/konto");
|
||||
@@ -28,9 +27,6 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
|
||||
subtitle={t.twoFactorCodePage.subtitle}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={verifyTwoFactorAction} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
|
||||
@@ -11,11 +11,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorEinrichtenPage(
|
||||
props: PageProps<"/konto/zwei-faktor/einrichten">
|
||||
) {
|
||||
export default async function ZweiFaktorEinrichtenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
@@ -35,9 +32,6 @@ export default async function ZweiFaktorEinrichtenPage(
|
||||
subtitle={t.twoFactorSetupPage.subtitle}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 border border-ink/10 p-6 flex flex-col items-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
|
||||
|
||||
@@ -13,9 +13,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-faktor">) {
|
||||
export default async function ZweiFaktorPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
@@ -34,12 +33,6 @@ export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-fakto
|
||||
subtitle={t.twoFactorPage.subtitle}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{customer.twoFactorEnabled ? (
|
||||
<div className="mt-8 space-y-6">
|
||||
|
||||
+6
-1
@@ -4,6 +4,9 @@ import "./globals.css";
|
||||
import { Header, CartDrawer } from "@/components/site-chrome";
|
||||
import { Footer } from "@/components/site-footer";
|
||||
import { CookieConsentBanner } from "@/components/cookie-consent";
|
||||
import { FlashToast } from "@/components/flash-toast";
|
||||
import { SubmitFeedback } from "@/components/submit-feedback";
|
||||
import { readFlash } from "@/lib/flash";
|
||||
import { getSiteUrl } from "@/lib/url";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getSocialLinks } from "@/lib/social-links";
|
||||
@@ -43,7 +46,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const locale = await getLocale();
|
||||
const dict = getDictionary(locale);
|
||||
const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]);
|
||||
const [company, socialLinks, flash] = await Promise.all([getCompanyInfo(), getSocialLinks(), readFlash()]);
|
||||
const base = getSiteUrl();
|
||||
|
||||
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
||||
@@ -133,6 +136,8 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
<Footer companyInfo={company} socialLinks={socialLinks} />
|
||||
<CartDrawer />
|
||||
<CookieConsentBanner />
|
||||
<FlashToast flash={flash} />
|
||||
<SubmitFeedback flashId={flash?.id ?? null} />
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,40 @@ export default async function NotFound() {
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
{/* Eine Wabe, in der genau eine Zelle fehlt — diese Seite. */}
|
||||
<svg viewBox="0 0 120 104" className="mx-auto mb-10 h-28 w-32" fill="none" aria-hidden="true">
|
||||
{/* Exakte Wabengeometrie: Spaltenabstand sqrt(3)*r, Zeilenabstand 1,5*r,
|
||||
jede zweite Zeile um eine halbe Spalte versetzt. */}
|
||||
{[
|
||||
[0.5, 0],
|
||||
[1.5, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[0.5, 2],
|
||||
[1.5, 2],
|
||||
].map(([col, row], i) => {
|
||||
const r = 17;
|
||||
const cx = 60 + (col - 1) * Math.sqrt(3) * (r + 1);
|
||||
const cy = 52 + (row - 1) * 1.5 * (r + 1);
|
||||
const points = Array.from({ length: 6 }, (_, k) => {
|
||||
const a = (Math.PI / 3) * k - Math.PI / 2;
|
||||
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
|
||||
}).join(" ");
|
||||
const missing = i === 4;
|
||||
return (
|
||||
<polygon
|
||||
key={i}
|
||||
points={points}
|
||||
stroke={missing ? "var(--color-honey-dark)" : "var(--color-honey)"}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={missing ? "3 3" : undefined}
|
||||
fill={missing ? "none" : i % 3 === 0 ? "var(--color-honey)" : "color-mix(in oklab, var(--color-honey) 35%, transparent)"}
|
||||
className={missing ? "motion-pulse animate-pulse" : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<SectionHeading
|
||||
eyebrow={t.notFoundPage.eyebrow}
|
||||
title={t.notFoundPage.title}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { noticeUrl } from "@/lib/notice";
|
||||
import { noIndexPrivate } from "@/lib/seo";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Container } from "@/components/ui";
|
||||
@@ -20,7 +21,7 @@ export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNu
|
||||
const t = getDictionary(await getLocale());
|
||||
const { invoiceNumber } = await props.params;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect(`/konto?error=${encodeURIComponent(t.invoicePage.loginRequiredError)}`);
|
||||
if (!customer) redirect(noticeUrl("invoice-login"));
|
||||
|
||||
const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id);
|
||||
if (!invoice) notFound();
|
||||
|
||||
+56
-11
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
@@ -106,19 +107,21 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
<Link
|
||||
key={filter.label}
|
||||
href={href}
|
||||
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`inline-flex h-11 items-center gap-2 px-5 text-sm tracking-wide-caps border transition-[color,background-color,border-color] duration-300 ${
|
||||
isActive
|
||||
? "bg-forest text-cream border-forest"
|
||||
: "border-ink/20 text-ink/70 hover:border-forest"
|
||||
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
|
||||
}`}
|
||||
>
|
||||
{isActive ? <HexMark className="h-2.5 w-2.5 text-honey-light" /> : null}
|
||||
{filter.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-4">
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3 border-t border-ink/10 pt-5">
|
||||
{/* GET-Formular statt Client-Component: funktioniert ohne JavaScript,
|
||||
die Auswahl wird per Seiten-Neuladen als Query-Parameter uebernommen.
|
||||
Die Suche ist bewusst Teil DIESES Formulars (statt nur der kleinen
|
||||
@@ -131,20 +134,36 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
<label htmlFor="shop-search" className="sr-only">
|
||||
{t.shop.searchInputLabel}
|
||||
</label>
|
||||
<span className="relative">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ink/45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<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"
|
||||
className="h-11 w-60 border border-ink/15 bg-background pl-10 pr-3 transition-colors placeholder:text-ink/45 hover:border-ink/35 focus:border-forest focus:outline-none"
|
||||
/>
|
||||
<label htmlFor="sortierung">{t.shop.sortLabel}</label>
|
||||
</span>
|
||||
<label htmlFor="sortierung" className="ml-2 text-xs tracking-wide-caps text-ink/60">
|
||||
{t.shop.sortLabel}
|
||||
</label>
|
||||
<span className="relative">
|
||||
<select
|
||||
id="sortierung"
|
||||
name="sortierung"
|
||||
defaultValue={sort ?? ""}
|
||||
className="border border-ink/20 bg-background px-2 py-2"
|
||||
className="h-11 appearance-none border border-ink/15 bg-background pl-3 pr-9 transition-colors hover:border-ink/35 focus:border-forest focus:outline-none"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -152,7 +171,21 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="border border-ink/20 px-3 py-2 hover:border-forest">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink/55"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 bg-forest px-5 text-xs tracking-wide-caps text-cream transition-colors hover:bg-honey-dark"
|
||||
>
|
||||
{t.shop.apply}
|
||||
</button>
|
||||
</form>
|
||||
@@ -163,12 +196,24 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
sortierung: sort,
|
||||
verfuegbar: onlyAvailable ? undefined : "1",
|
||||
})}
|
||||
className={`text-sm tracking-wide-caps px-4 py-2 border transition-colors ${
|
||||
aria-pressed={onlyAvailable}
|
||||
className={`ml-auto inline-flex h-11 items-center gap-2.5 px-4 text-xs tracking-wide-caps border transition-colors ${
|
||||
onlyAvailable
|
||||
? "bg-forest text-cream border-forest"
|
||||
: "border-ink/20 text-ink/70 hover:border-forest"
|
||||
: "border-ink/15 text-ink/70 hover:border-forest hover:text-forest"
|
||||
}`}
|
||||
>
|
||||
{/* Kleiner Schalter statt reinem Textknopf — der Zustand ist auf einen Blick lesbar. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative h-4 w-7 rounded-full transition-colors ${onlyAvailable ? "bg-honey" : "bg-ink/15"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-cream shadow transition-[left] duration-300 ${
|
||||
onlyAvailable ? "left-3.5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
{t.shop.onlyAvailable}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -186,8 +231,8 @@ export default async function ShopPage(props: PageProps<"/shop">) {
|
||||
) : null}
|
||||
<h2 className="sr-only">{t.shop.productsHeading}</h2>
|
||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} />
|
||||
{products.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} reviewSummary={reviewSummaries.get(product.id)} index={index} />
|
||||
))}
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
|
||||
@@ -24,23 +24,41 @@ export function AccountNavCard({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`group flex items-start gap-4 border p-6 transition-all duration-300 hover:-translate-y-0.5 ${
|
||||
className={`group relative flex items-start gap-5 border p-6 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-1 ${
|
||||
accent
|
||||
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_16px_32px_-20px_rgba(184,138,68,0.35)]"
|
||||
: "border-ink/10 hover:border-forest hover:shadow-[0_16px_32px_-20px_rgba(36,53,43,0.25)]"
|
||||
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_22px_40px_-24px_rgba(184,138,68,0.45)]"
|
||||
: "border-ink/10 bg-background hover:border-forest/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Icon in einer Wabe, die sich beim Hover mit Honig fuellt. */}
|
||||
<span className="relative flex-shrink-0 flex items-center justify-center w-12 h-[3.4rem]">
|
||||
<span aria-hidden="true" className={`clip-hex absolute inset-0 ${accent ? "bg-honey/30" : "bg-ink/10"}`} />
|
||||
<span
|
||||
className={`flex-shrink-0 flex items-center justify-center w-11 h-11 border transition-colors ${
|
||||
accent ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
|
||||
aria-hidden="true"
|
||||
className="clip-hex absolute inset-[1.5px] bg-background transition-colors duration-500 group-hover:bg-honey"
|
||||
/>
|
||||
<span
|
||||
className={`relative transition-colors duration-500 group-hover:text-cream ${
|
||||
accent ? "text-honey-dark" : "text-forest"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-lg text-forest">{title}</span>
|
||||
<span className="block mt-1 text-sm text-ink/70">{description}</span>
|
||||
</span>
|
||||
<span className="flex-1 pt-1">
|
||||
<span className="block font-serif text-2xl leading-tight text-forest">{title}</span>
|
||||
<span className="block mt-1.5 text-sm text-ink/70">{description}</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 -translate-x-2 text-forest opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-0 group-hover:opacity-100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -57,10 +75,19 @@ const ORDER_STATUS_STYLES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Farbcodiertes Status-Label statt reinem Fließtext, damit der Bestellstatus auf einen Blick erkennbar ist. */
|
||||
/** Status, bei denen gerade etwas passiert — ihr Punkt pulsiert sanft. */
|
||||
const ORDER_STATUS_IN_PROGRESS = new Set(["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"]);
|
||||
|
||||
export function OrderStatusBadge({ status, label }: { status: string; label: string }) {
|
||||
const style = ORDER_STATUS_STYLES[status] ?? "border-ink/20 text-ink/70";
|
||||
return (
|
||||
<span className={`inline-flex items-center border px-2.5 py-1 text-xs tracking-wide-caps ${style}`}>
|
||||
<span className={`inline-flex items-center gap-2 border px-2.5 py-1 text-xs tracking-wide-caps ${style}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full bg-current ${
|
||||
ORDER_STATUS_IN_PROGRESS.has(status) ? "motion-pulse animate-[wh-pulse_2s_ease-out_infinite]" : ""
|
||||
}`}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -128,18 +155,34 @@ export function OrderStatusTimeline({
|
||||
return (
|
||||
<div key={step.key} role="listitem" className="flex-1 min-w-0 relative">
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`flex-shrink-0 w-2.5 h-2.5 rounded-full ${isDone ? "bg-forest" : "bg-ink/20"}`}
|
||||
<svg
|
||||
viewBox="0 0 24 26"
|
||||
aria-hidden="true"
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 rounded-full ${
|
||||
i === currentIndex && i < steps.length - 1 ? "motion-pulse animate-[wh-pulse_2.2s_ease-out_infinite]" : ""
|
||||
}`}
|
||||
>
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill={isDone ? "var(--color-honey)" : "var(--color-background)"}
|
||||
stroke={isDone ? "var(--color-honey-dark)" : "color-mix(in oklab, var(--color-ink) 25%, transparent)"}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
{i < steps.length - 1 ? (
|
||||
<span className="relative ml-1 h-[2px] flex-1 overflow-hidden rounded-full bg-ink/10" aria-hidden="true">
|
||||
{isLineDone ? (
|
||||
// Erledigte Abschnitte fliessen nacheinander voll — der
|
||||
// Fortschritt wird beim Oeffnen der Seite nachvollzogen.
|
||||
<span
|
||||
className={`ml-1 h-px flex-1 ${isLineDone ? "bg-forest" : "bg-ink/15"}`}
|
||||
aria-hidden="true"
|
||||
className="motion-grow absolute inset-0 origin-left bg-honey animate-[wh-grow_0.55s_var(--ease-arrive)_both]"
|
||||
style={{ animationDelay: `${150 + i * 140}ms` }}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/45"}`}>
|
||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/55"}`}>
|
||||
{step.label}
|
||||
</p>
|
||||
</div>
|
||||
@@ -162,11 +205,20 @@ export function AccountEmptyState({
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-10 border border-dashed border-ink/15 px-8 py-14 text-center">
|
||||
<span className="inline-flex items-center justify-center w-12 h-12 border border-ink/15 text-ink/40 mx-auto">
|
||||
{icon}
|
||||
<div className="mt-10 border border-dashed border-ink/15 px-8 py-16 text-center">
|
||||
<span className="relative mx-auto flex h-16 w-14 items-center justify-center text-honey-dark">
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="absolute inset-0 h-full w-full text-honey">
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.8"
|
||||
strokeDasharray="2 1.6"
|
||||
/>
|
||||
</svg>
|
||||
<span className="relative">{icon}</span>
|
||||
</span>
|
||||
<p className="mt-4 text-forest">{title}</p>
|
||||
<p className="mt-5 font-serif text-2xl text-forest">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
|
||||
const FLIGHT_EVENT = "wh:cart-flight";
|
||||
const LAND_EVENT = "wh:cart-landed";
|
||||
const FLIGHT_MS = 700;
|
||||
|
||||
/**
|
||||
* Startet den Flug eines Honigtropfens vom geklickten Knopf zum
|
||||
* Warenkorb-Symbol. Aufrufer legen den Artikel mit openDrawer: false in den
|
||||
* Store — geoeffnet wird die Schublade erst bei der Landung (CartFlight).
|
||||
*/
|
||||
export function launchCartFlight(from: Element): void {
|
||||
const rect = from.getBoundingClientRect();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FLIGHT_EVENT, { detail: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } })
|
||||
);
|
||||
}
|
||||
|
||||
/** Fuer das Warenkorb-Symbol: meldet jede Landung (zum Wippen). */
|
||||
export function onCartLanded(handler: () => void): () => void {
|
||||
window.addEventListener(LAND_EVENT, handler);
|
||||
return () => window.removeEventListener(LAND_EVENT, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Hoehepunkt des Einkaufens: ein Tropfen in Wabenform loest sich vom
|
||||
* Knopf, fliegt im Bogen zum Warenkorb und "tropft" hinein. Danach wippt
|
||||
* der Korb, der Zaehler springt, und die Schublade oeffnet sich — Ursache
|
||||
* und Wirkung werden sichtbar verbunden, statt dass die Schublade ohne
|
||||
* Vorwarnung von der Seite hereinschiebt.
|
||||
*
|
||||
* Reduzierte Bewegung / Symbol nicht sichtbar (z.B. weggescrollter Header
|
||||
* auf kleinen Geraeten): kein Flug, die Schublade oeffnet sofort.
|
||||
*/
|
||||
export function CartFlight() {
|
||||
const openCart = useCartStore((s) => s.openCart);
|
||||
|
||||
useEffect(() => {
|
||||
const land = () => {
|
||||
window.dispatchEvent(new Event(LAND_EVENT));
|
||||
window.setTimeout(openCart, 180);
|
||||
};
|
||||
|
||||
const onFlight = (event: Event) => {
|
||||
const { x, y } = (event as CustomEvent<{ x: number; y: number }>).detail;
|
||||
const target = document.querySelector<HTMLElement>("[data-cart-target]");
|
||||
const targetRect = target?.getBoundingClientRect();
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const targetVisible =
|
||||
targetRect && targetRect.width > 0 && targetRect.bottom > 0 && targetRect.top < window.innerHeight;
|
||||
|
||||
if (reduceMotion || !targetRect || !targetVisible) {
|
||||
land();
|
||||
return;
|
||||
}
|
||||
|
||||
const tx = targetRect.left + targetRect.width / 2;
|
||||
const ty = targetRect.top + targetRect.height / 2;
|
||||
const dx = tx - x;
|
||||
const dy = ty - y;
|
||||
// Scheitel des Bogens: ueber der hoeheren der beiden Positionen.
|
||||
const lift = Math.min(-80, dy / 2 - 120);
|
||||
|
||||
const drop = document.createElement("div");
|
||||
drop.setAttribute("aria-hidden", "true");
|
||||
drop.style.cssText = `position:fixed;left:${x - 11}px;top:${y - 12}px;width:22px;height:24px;z-index:80;pointer-events:none;will-change:transform,opacity;`;
|
||||
// Fester Markup-String ohne jede Nutzereingabe — kein XSS-Pfad.
|
||||
drop.innerHTML =
|
||||
'<svg viewBox="0 0 24 26" width="22" height="24"><defs><radialGradient id="wh-honey" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#e4cfa4"/><stop offset="0.45" stop-color="#b88a44"/><stop offset="1" stop-color="#8a6531"/></radialGradient></defs><polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="url(#wh-honey)" stroke="#8a6531" stroke-width="1"/><ellipse cx="8.5" cy="8.5" rx="2.4" ry="1.4" fill="#fff" opacity="0.55"/></svg>';
|
||||
document.body.appendChild(drop);
|
||||
|
||||
const animation = drop.animate(
|
||||
[
|
||||
{ transform: "translate(0, 0) scale(0.4) rotate(0deg)", opacity: 0 },
|
||||
{ transform: "translate(0, -18px) scale(1.15) rotate(-12deg)", opacity: 1, offset: 0.12 },
|
||||
{ transform: `translate(${dx * 0.5}px, ${lift}px) scale(1) rotate(90deg)`, offset: 0.55 },
|
||||
{ transform: `translate(${dx}px, ${dy}px) scale(0.35) rotate(180deg)`, opacity: 0.9 },
|
||||
],
|
||||
{ duration: FLIGHT_MS, easing: "cubic-bezier(0.45, 0, 0.2, 1)" }
|
||||
);
|
||||
animation.onfinish = () => {
|
||||
drop.remove();
|
||||
land();
|
||||
};
|
||||
animation.oncancel = () => {
|
||||
drop.remove();
|
||||
land();
|
||||
};
|
||||
};
|
||||
|
||||
window.addEventListener(FLIGHT_EVENT, onFlight);
|
||||
return () => window.removeEventListener(FLIGHT_EVENT, onFlight);
|
||||
}, [openCart]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -23,7 +23,12 @@ export function ConfirmForm({
|
||||
action={action}
|
||||
className={className}
|
||||
onSubmit={(e) => {
|
||||
if (!confirm(confirmMessage)) e.preventDefault();
|
||||
if (!confirm(confirmMessage)) {
|
||||
e.preventDefault();
|
||||
// Fuer SubmitFeedback: dieser Klick loest keine Aktion aus, der
|
||||
// Knopf darf also nicht als "laeuft" markiert werden.
|
||||
e.currentTarget.dataset.submitCancelled = "true";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -49,8 +49,8 @@ export function CookieConsentBanner() {
|
||||
</p>
|
||||
<div className="flex flex-shrink-0 flex-wrap gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest whitespace-nowrap"
|
||||
variant="inverted"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => choose("essential")}
|
||||
>
|
||||
{t.cookieConsent.essentialOnly}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { FLASH_COOKIE, type Flash } from "@/lib/flash-core";
|
||||
|
||||
|
||||
// Fehler bleiben laenger stehen: sie verlangen meist eine Reaktion, und wer
|
||||
// gerade woanders hinschaut, soll sie nicht verpassen.
|
||||
const AUTO_HIDE_MS: Record<Flash["kind"], number> = { success: 6000, info: 8000, error: 12000 };
|
||||
|
||||
const STYLES: Record<Flash["kind"], string> = {
|
||||
success: "bg-forest text-cream border-forest",
|
||||
info: "bg-background text-ink border-ink/20",
|
||||
error: "bg-red-50 text-red-800 border-red-300",
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeigt die Rueckmeldung der letzten Aktion (lib/flash.ts) als Toast.
|
||||
* Das Root-Layout reicht die Meldung aus dem Cookie herein; hier wird sie
|
||||
* angezeigt und das Cookie sofort geloescht, damit sie beim naechsten
|
||||
* Seitenaufruf nicht erneut erscheint. Jede Meldung traegt eine eigene id —
|
||||
* dieselbe Aktion zweimal ausgeloest zeigt die Meldung also zweimal.
|
||||
*/
|
||||
export function FlashToast({ flash }: { flash: Flash | null }) {
|
||||
const { t } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
// Gemerkt wird nur, welche Meldung geschlossen wurde — die sichtbare
|
||||
// Meldung selbst leitet sich daraus ab, statt im Effect gesetzt zu werden.
|
||||
const [dismissedId, setDismissedId] = useState<string | null>(null);
|
||||
const visible = flash && flash.id !== dismissedId ? flash : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flash) return;
|
||||
document.cookie = `${FLASH_COOKIE}=; Max-Age=0; path=/; SameSite=Lax`;
|
||||
const timer = window.setTimeout(() => setDismissedId(flash.id), AUTO_HIDE_MS[flash.kind]);
|
||||
return () => window.clearTimeout(timer);
|
||||
// flash.id statt flash: nach jedem Server-Render ist das Objekt neu, die Meldung aber dieselbe.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flash?.id]);
|
||||
|
||||
return (
|
||||
// Die Live-Region bleibt dauerhaft im DOM, damit Screenreader den
|
||||
// hinzukommenden Text zuverlaessig ansagen.
|
||||
<div
|
||||
className="fixed inset-x-4 top-20 z-[60] flex justify-center pointer-events-none sm:inset-x-auto sm:right-6 sm:top-24"
|
||||
aria-live={visible?.kind === "error" ? "assertive" : "polite"}
|
||||
role={visible?.kind === "error" ? "alert" : "status"}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<motion.div
|
||||
key={visible.id}
|
||||
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`pointer-events-auto flex max-w-md items-start gap-3 border px-4 py-3 text-sm shadow-lg ${STYLES[visible.kind]}`}
|
||||
>
|
||||
<p className="flex-1 leading-relaxed">
|
||||
<span className="sr-only">{t.flash[visible.kind]}: </span>
|
||||
{visible.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissedId(visible.id)}
|
||||
className="-mr-1 -mt-0.5 px-1 text-lg leading-none opacity-70 hover:opacity-100"
|
||||
aria-label={t.flash.close}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
-9
@@ -3,6 +3,7 @@
|
||||
import { motion, useReducedMotion, type Variants } from "motion/react";
|
||||
import { Container, LinkButton } from "@/components/ui";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
const itemContainerVariants: Variants = {
|
||||
@@ -45,7 +46,7 @@ export function Hero({
|
||||
// weiter unten bleibt unveraendert, der laeuft unabhaengig davon.
|
||||
<section className="relative">
|
||||
{video ? (
|
||||
<div className="relative h-[78vh] min-h-[520px] w-full overflow-hidden">
|
||||
<div className="relative h-[82vh] min-h-[560px] w-full overflow-hidden">
|
||||
{/* Standbild als eigene Ebene UNTER dem Video statt im
|
||||
poster-Attribut. poster nimmt nur eine fertige URL und laedt die
|
||||
hochgeladene Datei damit in voller gespeicherter Groesse (bis
|
||||
@@ -55,7 +56,7 @@ export function Hero({
|
||||
Bildschirm, WebP statt JPEG, und mit "priority" als erstes
|
||||
angefordert. Das Video liegt darueber und ist transparent, bis es
|
||||
eigene Bilddaten hat — der Uebergang ist deshalb nicht sichtbar. */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="motion-drift absolute inset-0 animate-[wh-drift_26s_ease-in-out_infinite_alternate]">
|
||||
<ContentImage
|
||||
src={image}
|
||||
alt=""
|
||||
@@ -94,15 +95,19 @@ export function Hero({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[82vh] min-h-[560px] w-full overflow-hidden">
|
||||
<div className="motion-drift absolute inset-0 animate-[wh-drift_26s_ease-in-out_infinite_alternate]">
|
||||
<ContentImage
|
||||
src={image}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="h-[78vh] min-h-[520px] w-full"
|
||||
className="h-full w-full"
|
||||
label="Bienenstand im Morgenlicht · Altenmünster"
|
||||
rounded={false}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Gleichmaessige Abdunklung ueber die gesamte Flaeche — noetig, weil
|
||||
redaktionell gepflegtes Bild-/Videomaterial (z.B. helle Etiketten,
|
||||
@@ -110,6 +115,8 @@ export function Hero({
|
||||
unlesbar machen kann. Der gerichtete Verlauf darunter verstaerkt das
|
||||
gezielt dort, wo die Textspalte tatsaechlich sitzt (unten links). */}
|
||||
<div className="absolute inset-0 bg-forest/25" />
|
||||
{/* Wabenraster ueber der rechten Bildhaelfte — die Marke im ersten Blick. */}
|
||||
<HoneycombPattern className="text-honey-pale/[0.12] hidden md:block" size={34} fade="right" />
|
||||
{/* Senkrecht auf schmalen Schirmen, waagerecht ab md.
|
||||
|
||||
Der frueher hier stehende to-tr-Verlauf liess die Textspalte auf
|
||||
@@ -127,7 +134,7 @@ export function Hero({
|
||||
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/85 via-forest/70 to-forest/10 md:bg-gradient-to-r md:from-forest/85 md:via-forest/70 md:to-transparent">
|
||||
<Container>
|
||||
<motion.div
|
||||
className="max-w-xl text-cream"
|
||||
className="max-w-2xl text-cream"
|
||||
initial={shouldReduceMotion ? false : "hidden"}
|
||||
animate="visible"
|
||||
variants={itemContainerVariants}
|
||||
@@ -138,18 +145,23 @@ export function Hero({
|
||||
>
|
||||
{location}
|
||||
</motion.p>
|
||||
<motion.h1 data-reveal="" variants={itemVariants} className="text-4xl md:text-6xl leading-tight">
|
||||
<motion.h1
|
||||
data-reveal=""
|
||||
variants={itemVariants}
|
||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl leading-[0.95] tracking-[-0.02em]"
|
||||
>
|
||||
{t.footer.tagline}
|
||||
</motion.h1>
|
||||
<motion.p data-reveal="" variants={itemVariants} className="mt-6 text-cream/85 max-w-md leading-relaxed">
|
||||
<motion.p data-reveal="" variants={itemVariants} className="mt-8 text-lg text-cream/85 max-w-md leading-relaxed">
|
||||
{intro}
|
||||
</motion.p>
|
||||
<motion.div data-reveal="" variants={itemVariants} className="mt-10 flex flex-wrap gap-4">
|
||||
<LinkButton href="/shop">{t.home.hero.ctaPrimary}</LinkButton>
|
||||
<LinkButton href="/shop" variant="accent">
|
||||
{t.home.hero.ctaPrimary}
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
href="/imkerei"
|
||||
variant="secondary"
|
||||
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest"
|
||||
variant="inverted"
|
||||
>
|
||||
{t.home.hero.ctaSecondary}
|
||||
</LinkButton>
|
||||
@@ -160,6 +172,14 @@ export function Hero({
|
||||
</motion.div>
|
||||
</Container>
|
||||
</div>
|
||||
{/* Scroll-Hinweis: ein Tropfen loest sich aus einer Wabe. Rein
|
||||
dekorativ; bei reduzierter Bewegung steht er still. */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute bottom-8 left-1/2 hidden -translate-x-1/2 flex-col items-center md:flex">
|
||||
<svg viewBox="0 0 24 26" className="h-6 w-6 text-honey-pale/80" fill="none">
|
||||
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" stroke="currentColor" strokeWidth="1.2" />
|
||||
</svg>
|
||||
<span className="motion-drip -mt-1 block h-2.5 w-2 rounded-b-full rounded-t-[40%] bg-honey animate-[wh-drip_2.6s_var(--ease-arrive)_infinite]" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { Container, SectionHeading, LinkButton, FramedMedia } from "@/components/ui";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
|
||||
@@ -27,11 +28,12 @@ export async function CurrentHarvest({
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{products.map((product) => (
|
||||
{products.map((product, index) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
reviewSummary={reviewSummaries?.get(product.id)}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -54,9 +56,10 @@ export async function OriginStory() {
|
||||
);
|
||||
const facts = parseListSimple(content["home.originStory.facts"]);
|
||||
return (
|
||||
<section className="py-24 bg-cream/60">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<section className="py-28 bg-cream/60">
|
||||
<Container className="grid md:grid-cols-2 gap-16 lg:gap-24 items-center">
|
||||
<ScrollReveal>
|
||||
<FramedMedia>
|
||||
<ContentImage
|
||||
src={content["home.originStory.image"] || null}
|
||||
alt=""
|
||||
@@ -65,6 +68,7 @@ export async function OriginStory() {
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</FramedMedia>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<SectionHeading
|
||||
@@ -73,9 +77,12 @@ export async function OriginStory() {
|
||||
/>
|
||||
<p className="mt-6 text-ink/75 leading-relaxed">{content["home.originStory.paragraph1"]}</p>
|
||||
<p className="mt-4 text-ink/75 leading-relaxed">{content["home.originStory.paragraph2"]}</p>
|
||||
<ul className="mt-6 space-y-1 text-sm text-ink/70">
|
||||
<ul className="mt-8 grid gap-2.5 text-sm text-ink/75 sm:grid-cols-2">
|
||||
{facts.map((fact) => (
|
||||
<li key={fact}>{fact}</li>
|
||||
<li key={fact} className="flex items-start gap-2.5">
|
||||
<HexMark className="mt-1.5 h-2 w-2 flex-shrink-0 text-honey" />
|
||||
{fact}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<LinkButton href="/herkunft" variant="secondary" className="mt-8">
|
||||
@@ -107,11 +114,10 @@ export async function Philosophy() {
|
||||
{items.map((item, i) => (
|
||||
<ScrollReveal key={item.title} delay={i * 0.08}>
|
||||
<div className={`border-t border-ink/15 pt-6 ${i % 2 === 1 ? "lg:mt-10" : ""}`}>
|
||||
<span className="font-serif text-3xl text-honey-dark">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h3 className="mt-3 text-lg text-forest">{item.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{/* Wabe statt "01"-Nummer: die vier Punkte sind gleichrangig, keine Reihenfolge. */}
|
||||
<HexMark filled={i % 2 === 0} className="h-5 w-5 text-honey" />
|
||||
<h3 className="mt-4 text-2xl text-forest">{item.title}</h3>
|
||||
<p className="mt-3 text-sm text-ink/70 leading-relaxed">
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -132,8 +138,9 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
return (
|
||||
<section className="py-24 bg-forest text-cream">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
<section className="relative overflow-hidden py-28 bg-forest text-cream">
|
||||
<HoneycombPattern className="text-honey-light/[0.08]" size={28} fade="right" />
|
||||
<Container className="relative grid md:grid-cols-2 gap-14 lg:gap-24 items-center">
|
||||
<ScrollReveal>
|
||||
<ContentImage
|
||||
src={product.images[0] ?? null}
|
||||
@@ -148,7 +155,7 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
<p className="tracking-wide-caps text-xs text-honey-light mb-3">
|
||||
{product.harvest.type} {product.harvest.year}
|
||||
</p>
|
||||
<h2 className="text-3xl md:text-4xl">{product.name}</h2>
|
||||
<h2 className="text-5xl md:text-6xl leading-[1] tracking-[-0.015em]">{product.name}</h2>
|
||||
<p className="mt-4 text-cream/75 leading-relaxed">
|
||||
{t.home.productSpotlight.description}
|
||||
</p>
|
||||
@@ -177,19 +184,20 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-cream/65">{t.home.productSpotlight.price}</dt>
|
||||
<dd>{formatPrice(product.priceCents, product.currency, locale)}</dd>
|
||||
<dd className="font-serif text-2xl text-cream tabular-nums">
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<LinkButton
|
||||
href={`/honig/${product.slug}`}
|
||||
variant="secondary"
|
||||
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest"
|
||||
variant="inverted"
|
||||
>
|
||||
{t.home.productSpotlight.cta}
|
||||
</LinkButton>
|
||||
<AddToCartButton product={product} />
|
||||
<AddToCartButton product={product} variant="accent" />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { Container, SectionHeading, LinkButton, FramedMedia } from "@/components/ui";
|
||||
import { ProcessPath } from "@/components/process-path";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
@@ -23,21 +25,7 @@ export async function ProcessTimeline() {
|
||||
className="mx-auto"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-16 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-8">
|
||||
{steps.map((step, i) => (
|
||||
<ScrollReveal key={step.number} delay={i * 0.06}>
|
||||
<div>
|
||||
<span className="text-honey-dark text-sm tracking-wide-caps">
|
||||
{step.number}
|
||||
</span>
|
||||
<h3 className="mt-2 text-lg text-forest">{step.title}</h3>
|
||||
<p className="mt-2 text-xs text-ink/70 leading-relaxed">
|
||||
{step.text}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<ProcessPath steps={steps} />
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -56,7 +44,7 @@ export async function RegionalSection() {
|
||||
);
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<Container className="grid md:grid-cols-2 gap-16 lg:gap-24 items-center">
|
||||
<ScrollReveal className="order-2 md:order-1">
|
||||
<SectionHeading
|
||||
eyebrow={t.home.regionalSection.eyebrow}
|
||||
@@ -74,6 +62,7 @@ export async function RegionalSection() {
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal className="order-1 md:order-2" delay={0.1}>
|
||||
<FramedMedia side="left">
|
||||
<ContentImage
|
||||
src={content["home.regionalSection.image"] || null}
|
||||
alt=""
|
||||
@@ -82,6 +71,7 @@ export async function RegionalSection() {
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</FramedMedia>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
@@ -94,26 +84,30 @@ export async function Sustainability() {
|
||||
const content = await getContentValues(["home.sustainability.items"], locale);
|
||||
const items = parseListTitled(content["home.sustainability.items"]);
|
||||
return (
|
||||
<section className="py-24 bg-cream/60">
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
eyebrow={t.home.sustainability.eyebrow}
|
||||
title={t.home.sustainability.title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
<div className="mt-14 grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{items.map((item, i) => (
|
||||
<ScrollReveal key={item.title} delay={i * 0.08}>
|
||||
<h3 className="text-lg text-forest">{item.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{item.text}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
<section className="py-28 bg-cream/60">
|
||||
{/* Geteilt statt Viererreihe: die Ueberschrift laeuft mit, die Punkte
|
||||
werden als ruhige Liste gelesen. */}
|
||||
<Container className="grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
<ScrollReveal className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-32">
|
||||
<SectionHeading eyebrow={t.home.sustainability.eyebrow} title={t.home.sustainability.title} />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
<ul className="divide-y divide-ink/10 border-y border-ink/10 lg:col-span-7">
|
||||
{items.map((item, i) => (
|
||||
<li key={item.title}>
|
||||
<ScrollReveal delay={i * 0.08}>
|
||||
<div className="group grid grid-cols-[auto_1fr] gap-x-5 py-7">
|
||||
<HexMark className="mt-2 h-3 w-3 text-honey transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:rotate-90" />
|
||||
<div>
|
||||
<h3 className="text-2xl text-forest">{item.title}</h3>
|
||||
<p className="mt-2 max-w-[55ch] text-ink/70 leading-relaxed">{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -128,8 +122,9 @@ export async function BusinessTeaser() {
|
||||
);
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<Container className="grid md:grid-cols-2 gap-16 lg:gap-24 items-center">
|
||||
<ScrollReveal>
|
||||
<FramedMedia>
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
@@ -138,6 +133,7 @@ export async function BusinessTeaser() {
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</FramedMedia>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<SectionHeading
|
||||
@@ -173,8 +169,9 @@ export async function Reviews() {
|
||||
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5, authorUrl: null }));
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-forest text-cream">
|
||||
<Container>
|
||||
<section className="relative overflow-hidden py-28 bg-forest text-cream">
|
||||
<HoneycombPattern className="text-honey-light/[0.07]" size={30} />
|
||||
<Container className="relative">
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
eyebrow={t.home.reviews.eyebrow}
|
||||
@@ -192,10 +189,13 @@ export async function Reviews() {
|
||||
<div className="mt-14 grid md:grid-cols-3 gap-10">
|
||||
{items.slice(0, 6).map((review, i) => (
|
||||
<ScrollReveal key={`${review.author}-${i}`} delay={i * 0.08}>
|
||||
<span aria-hidden="true" className="block font-serif text-7xl leading-[0.6] text-honey-light/70">
|
||||
„
|
||||
</span>
|
||||
<StarRating value={review.rating} />
|
||||
<span className="sr-only">{t.home.reviews.starsOutOf5(review.rating)}</span>
|
||||
<p className="mt-4 text-cream/85 leading-relaxed italic">
|
||||
„{review.quote}“
|
||||
<p className="mt-4 font-serif text-xl text-cream/90 leading-relaxed italic">
|
||||
{review.quote}
|
||||
</p>
|
||||
{/* Zuordnung zur Quelle: die Nutzungsbedingungen der Places API
|
||||
verlangen, dass eine angezeigte Rezension zu Google
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useId } from "react";
|
||||
|
||||
/**
|
||||
* Wabenraster als Markensignatur: dieselbe spitz stehende Sechseckform wie
|
||||
* im Logomark, als feines Linienmuster. Liegt absolut im naechsten
|
||||
* relativen Elternteil, ist rein dekorativ (aria-hidden) und laeuft ueber
|
||||
* eine Maske zu den Raendern hin aus, damit es nie als harte Kachelkante
|
||||
* endet.
|
||||
*
|
||||
* Farbe ueber currentColor (text-*-Klasse am Aufrufer), Staerke ueber
|
||||
* opacity — so passt dasselbe Muster auf Creme wie auf Waldgruen.
|
||||
*/
|
||||
export function HoneycombPattern({
|
||||
className = "",
|
||||
size = 22,
|
||||
fade = "radial",
|
||||
}: {
|
||||
className?: string;
|
||||
/** Radius eines Sechsecks in px. */
|
||||
size?: number;
|
||||
/** Wohin das Muster auslaeuft. */
|
||||
fade?: "radial" | "right" | "bottom" | "none";
|
||||
}) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const r = size;
|
||||
const w = Math.sqrt(3) * r;
|
||||
const tileH = 3 * r;
|
||||
|
||||
// Zwei Sechsecke je Kachel (versetzte Reihen), spitz nach oben wie im Logo.
|
||||
const hex = (cx: number, cy: number) =>
|
||||
Array.from({ length: 6 }, (_, i) => {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 2;
|
||||
return `${(cx + r * Math.cos(angle)).toFixed(2)},${(cy + r * Math.sin(angle)).toFixed(2)}`;
|
||||
}).join(" ");
|
||||
|
||||
const masks: Record<typeof fade, string | undefined> = {
|
||||
radial: "radial-gradient(ellipse at center, black 20%, transparent 75%)",
|
||||
right: "linear-gradient(to left, black, transparent 80%)",
|
||||
bottom: "linear-gradient(to top, black, transparent 85%)",
|
||||
none: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-0 h-full w-full ${className}`}
|
||||
style={masks[fade] ? { maskImage: masks[fade], WebkitMaskImage: masks[fade] } : undefined}
|
||||
>
|
||||
<defs>
|
||||
<pattern id={`wabe-${id}`} width={w} height={tileH} patternUnits="userSpaceOnUse">
|
||||
<polygon points={hex(w / 2, r)} fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
<polygon points={hex(0, r * 2.5)} fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
<polygon points={hex(w, r * 2.5)} fill="none" stroke="currentColor" strokeWidth="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#wabe-${id})`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleines Sechseck als Aufzaehlungs- und Statuszeichen (statt Unicode-Glyphen). */
|
||||
export function HexMark({ className = "h-3 w-3", filled = true }: { className?: string; filled?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<polygon
|
||||
points="12,2 20.66,7 20.66,17 12,22 3.34,17 3.34,7"
|
||||
fill={filled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Haekchen im Sechseck — fuer Vorteilslisten (ersetzt das Textzeichen "✓"). */
|
||||
export function HexCheck({ className = "h-4 w-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
|
||||
<polygon
|
||||
points="12,2 20.66,7 20.66,17 12,22 3.34,17 3.34,7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M8.2 12.3l2.5 2.5 5-5.3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||
@@ -50,8 +52,13 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
|
||||
return (
|
||||
<section className="py-24">
|
||||
<Container className="max-w-2xl text-center">
|
||||
<Container className="max-w-4xl">
|
||||
<ScrollReveal>
|
||||
{/* Eigene Flaeche mit Wabenraster: die Anmeldung ist ein Angebot,
|
||||
kein Nachsatz unter der Seite. */}
|
||||
<div className="relative overflow-hidden border border-honey/30 bg-honey/[0.06] px-6 py-14 text-center sm:px-14">
|
||||
<HoneycombPattern className="text-honey/25" size={24} />
|
||||
<div className="relative mx-auto max-w-2xl">
|
||||
<SectionHeading
|
||||
eyebrow={t.home.newsletter.eyebrow}
|
||||
title={t.home.newsletter.title}
|
||||
@@ -61,7 +68,38 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
<p className="mt-4 text-ink/70">{t.home.newsletter.description}</p>
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="mt-8 text-forest">{t.home.newsletter.successMessage}</p>
|
||||
// Bestaetigung als kleine Zeremonie: das Haekchen zeichnet sich
|
||||
// in der Wabe, dann erscheint der Text.
|
||||
<div className="mt-8 flex flex-col items-center" role="status">
|
||||
<svg viewBox="0 0 24 26" className="h-14 w-14" fill="none" aria-hidden="true">
|
||||
<motion.polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill="var(--color-honey)"
|
||||
initial={{ scale: 0.4, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 16 }}
|
||||
style={{ transformOrigin: "center" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M7.5 13.2l3 3 6-6.4"
|
||||
stroke="var(--color-cream)"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.25 }}
|
||||
/>
|
||||
</svg>
|
||||
<motion.p
|
||||
className="mt-4 font-serif text-2xl text-forest"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
>
|
||||
{t.home.newsletter.successMessage}
|
||||
</motion.p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-lg mx-auto">
|
||||
{/* Eingabefeld und Knopf bilden die Zeile, das CAPTCHA steht
|
||||
@@ -92,7 +130,9 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
</form>
|
||||
)}
|
||||
{status === "error" ? (
|
||||
<p className="mt-4 text-sm text-red-700">{t.home.newsletter.errorMessage}</p>
|
||||
<p className="mt-4 text-sm text-red-700" role="alert">
|
||||
{t.home.newsletter.errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="mt-6 text-xs text-ink/65">
|
||||
@@ -102,6 +142,8 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
</a>
|
||||
{t.home.newsletter.consentAfterLink}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { motion, useReducedMotion, useScroll, useSpring, useTransform, type MotionValue } from "motion/react";
|
||||
|
||||
interface Step {
|
||||
number: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Vom Bienenstock ins Glas" als Weg, den der Honig nimmt: eine Honiglinie
|
||||
* fuellt sich im Takt des Scrollens und laesst die Waben der einzelnen
|
||||
* Schritte der Reihe nach aufleuchten. Hier traegt die Bewegung Bedeutung —
|
||||
* die Reihenfolge IST der Inhalt.
|
||||
*
|
||||
* Ab lg waagerecht (sechs Spalten), darunter senkrecht mit Linie links.
|
||||
* Bei reduzierter Bewegung ist der Weg von Anfang an gefuellt.
|
||||
*/
|
||||
export function ProcessPath({ steps }: { steps: Step[] }) {
|
||||
const ref = useRef<HTMLOListElement>(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ["start 85%", "end 55%"] });
|
||||
// Gefedert statt direkt am Scrollwert: die Linie "fliesst" nach, wie Honig.
|
||||
const smooth = useSpring(scrollYProgress, { stiffness: 90, damping: 22, mass: 0.6 });
|
||||
const full = useTransform(smooth, () => 1);
|
||||
const progress = reduceMotion ? full : smooth;
|
||||
|
||||
return (
|
||||
<ol ref={ref} className="relative mt-16 grid gap-10 pl-10 lg:grid-cols-6 lg:gap-8 lg:pl-0 lg:pt-12">
|
||||
{/* Leitung (Grundlinie) und Honigfuellung — waagerecht ab lg, sonst senkrecht. */}
|
||||
<span aria-hidden="true" className="absolute left-[11px] top-2 bottom-2 w-px bg-ink/15 lg:hidden" />
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
style={{ scaleY: progress }}
|
||||
className="absolute left-[10px] top-2 bottom-2 w-[3px] origin-top rounded-full bg-honey lg:hidden"
|
||||
/>
|
||||
<span aria-hidden="true" className="absolute left-3 right-[calc((100%_-_10rem)/6_-_12px)] top-[11px] hidden h-px bg-ink/15 lg:block" />
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
style={{ scaleX: progress }}
|
||||
className="absolute left-3 right-[calc((100%_-_10rem)/6_-_12px)] top-[10px] hidden h-[3px] origin-left rounded-full bg-honey lg:block"
|
||||
/>
|
||||
|
||||
{steps.map((step, i) => (
|
||||
<ProcessStep key={step.number} step={step} index={i} total={steps.length} progress={progress} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessStep({
|
||||
step,
|
||||
index,
|
||||
total,
|
||||
progress,
|
||||
}: {
|
||||
step: Step;
|
||||
index: number;
|
||||
total: number;
|
||||
progress: MotionValue<number>;
|
||||
}) {
|
||||
// Die Wabe "entzuendet" sich, sobald die Linie ihre Position erreicht.
|
||||
const at = total > 1 ? index / (total - 1) : 0;
|
||||
const lit = useTransform(progress, [Math.max(0, at - 0.06), at + 0.02], [0, 1]);
|
||||
const scale = useTransform(lit, [0, 1], [0.85, 1]);
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
style={{ scale }}
|
||||
className="absolute -left-10 top-0 flex h-6 w-6 items-center justify-center lg:-top-12 lg:left-0"
|
||||
>
|
||||
<svg viewBox="0 0 24 26" className="h-6 w-6">
|
||||
<polygon
|
||||
points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7"
|
||||
fill="var(--color-background)"
|
||||
stroke="var(--color-honey-dark)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<motion.polygon
|
||||
points="12,4.5 19.4,8.75 19.4,17.25 12,21.5 4.6,17.25 4.6,8.75"
|
||||
fill="var(--color-honey)"
|
||||
style={{ opacity: lit }}
|
||||
/>
|
||||
</svg>
|
||||
</motion.span>
|
||||
<span className="font-serif text-sm text-honey-dark tabular-nums">{step.number}</span>
|
||||
<h3 className="mt-1 text-xl text-forest">{step.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/70 leading-relaxed">{step.text}</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+110
-28
@@ -2,13 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { Button } from "@/components/ui";
|
||||
import { formatPrice, formatPricePerKg } from "@/lib/format";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { launchCartFlight } from "@/components/cart-flight";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { ProductWithRelations } from "@/lib/data";
|
||||
import type { ProductCategory } from "@/generated/prisma/client";
|
||||
@@ -68,17 +69,75 @@ export function TasteScale({
|
||||
<span className={`w-16 ${tone === "inverted" ? "text-cream/70" : "text-ink/70"}`}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{/* Ausloeser ist die ganze Skala: die Fuellsegmente starten mit
|
||||
Breite 0 und wuerden selbst nie als "sichtbar" gemeldet. */}
|
||||
<motion.div
|
||||
className="flex gap-1"
|
||||
role="img"
|
||||
aria-label={`${label}: ${value} / 5`}
|
||||
initial="empty"
|
||||
whileInView="full"
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 w-5 rounded-full ${
|
||||
i < value ? "bg-honey" : tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
|
||||
className={`relative h-1.5 w-6 overflow-hidden rounded-full ${
|
||||
tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
|
||||
}`}
|
||||
>
|
||||
{i < value ? (
|
||||
// Die Skala "fuellt sich" beim Erscheinen Segment fuer Segment
|
||||
// wie ein Glas — sichtbar, dass es um Intensitaet geht.
|
||||
<motion.span
|
||||
className="absolute inset-0 origin-left rounded-full bg-honey"
|
||||
variants={{
|
||||
empty: { scaleX: 0 },
|
||||
full: { scaleX: 1, transition: { duration: 0.35, delay: 0.15 + i * 0.09, ease: [0.16, 1, 0.3, 1] } },
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Knopfbeschriftung, die nach dem Hinzufuegen zu "Hinzugefuegt" mit
|
||||
* gezeichnetem Haekchen wechselt — die Bestaetigung bleibt am Ort des
|
||||
* Klicks, waehrend der Tropfen zum Warenkorb fliegt.
|
||||
*/
|
||||
function AddedLabel({ justAdded, idle, done }: { justAdded: boolean; idle: string; done: string }) {
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={justAdded ? "done" : "idle"}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
{justAdded ? (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
|
||||
<motion.path
|
||||
d="M5 12.5l4.5 4.5L19 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.05 }}
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
{/* Das Woerterbuch haengt ein "✓" an — hier zeichnet das SVG das Haekchen. */}
|
||||
{justAdded ? done.replace(/\s*✓\s*$/, "") : idle}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,7 +160,7 @@ export function AddToCartButton({
|
||||
}: {
|
||||
product: ProductWithRelations;
|
||||
quantity?: number;
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
variant?: "primary" | "secondary" | "ghost" | "inverted" | "accent";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -113,13 +172,14 @@ export function AddToCartButton({
|
||||
<Button
|
||||
variant={variant}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
addItem(toCartItem(product), quantity);
|
||||
onClick={(event) => {
|
||||
launchCartFlight(event.currentTarget);
|
||||
addItem(toCartItem(product), quantity, { openDrawer: false });
|
||||
setJustAdded(true);
|
||||
window.setTimeout(() => setJustAdded(false), 1500);
|
||||
window.setTimeout(() => setJustAdded(false), 1800);
|
||||
}}
|
||||
>
|
||||
{justAdded ? t.product.added : t.product.addToCart}
|
||||
<AddedLabel justAdded={justAdded} idle={t.product.addToCart} done={t.product.added} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -151,13 +211,14 @@ export function AddToCartWithQuantity({
|
||||
<motion.div whileTap={{ scale: 0.97 }} className="flex-1">
|
||||
<Button
|
||||
className="w-full h-full"
|
||||
onClick={() => {
|
||||
addItem(toCartItem(product), quantity);
|
||||
onClick={(event) => {
|
||||
launchCartFlight(event.currentTarget);
|
||||
addItem(toCartItem(product), quantity, { openDrawer: false });
|
||||
setJustAdded(true);
|
||||
window.setTimeout(() => setJustAdded(false), 1500);
|
||||
window.setTimeout(() => setJustAdded(false), 1800);
|
||||
}}
|
||||
>
|
||||
{justAdded ? t.product.added : t.product.addToCart}
|
||||
<AddedLabel justAdded={justAdded} idle={t.product.addToCart} done={t.product.added} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -168,10 +229,13 @@ export function AddToCartWithQuantity({
|
||||
export function ProductCard({
|
||||
product,
|
||||
reviewSummary,
|
||||
index = 0,
|
||||
}: {
|
||||
product: ProductWithRelations;
|
||||
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
|
||||
reviewSummary?: { average: number | null; count: number };
|
||||
/** Position im Raster — staffelt das Erscheinen (gedeckelt, damit lange Listen nicht warten lassen). */
|
||||
index?: number;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const stock = product.inventory?.quantity ?? 0;
|
||||
@@ -180,21 +244,21 @@ export function ProductCard({
|
||||
<motion.article
|
||||
// Siehe scroll-reveal.tsx: Gegenstueck zum noscript-Block im Root-Layout.
|
||||
data-reveal=""
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-60px" }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="group"
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: Math.min(index, 5) * 0.08 }}
|
||||
className="group flex flex-col"
|
||||
>
|
||||
<Link
|
||||
href={`/honig/${product.slug}`}
|
||||
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
|
||||
className="relative block overflow-hidden transition-[box-shadow,transform] duration-500 ease-[var(--ease-arrive)] group-hover:-translate-y-1 group-hover:shadow-[0_28px_50px_-24px_rgba(36,53,43,0.5)]"
|
||||
>
|
||||
<ContentImage
|
||||
src={product.images[0] ?? null}
|
||||
alt={product.name}
|
||||
tone={categoryTone[product.category]}
|
||||
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
className="aspect-[4/5] w-full transition-transform duration-700 ease-[var(--ease-arrive)] group-hover:scale-[1.05]"
|
||||
label={
|
||||
product.harvest
|
||||
? `${product.harvest.year} · ${product.harvest.type}`
|
||||
@@ -202,19 +266,25 @@ export function ProductCard({
|
||||
}
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
/>
|
||||
{/* Warmer Honigschimmer, der beim Hover diagonal ueber das Glas
|
||||
zieht — wie Licht, das durch Honig faellt. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 -translate-x-full bg-[linear-gradient(115deg,transparent_30%,rgba(228,207,164,0.35)_48%,rgba(184,138,68,0.18)_55%,transparent_70%)] transition-transform duration-1000 ease-[var(--ease-arrive)] group-hover:translate-x-full motion-reduce:hidden"
|
||||
/>
|
||||
{stock === 0 ? (
|
||||
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
{t.product.outOfStock}
|
||||
</span>
|
||||
) : stock <= 5 ? (
|
||||
<span className="absolute top-3 left-3 bg-honey text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
<span className="absolute top-3 left-3 bg-honey-dark text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
{t.product.onlyLeft(stock)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="mt-4">
|
||||
<Link href={`/honig/${product.slug}`}>
|
||||
<h3 className="text-xl text-forest">{product.name}</h3>
|
||||
<div className="mt-5 flex flex-1 flex-col">
|
||||
<Link href={`/honig/${product.slug}`} className="self-start">
|
||||
<h3 className="link-draw text-2xl leading-tight text-forest">{product.name}</h3>
|
||||
</Link>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{product.tasteTags.join(" · ")}
|
||||
@@ -229,13 +299,13 @@ export function ProductCard({
|
||||
{(reviewSummary?.average ?? 0).toFixed(1)} ({reviewSummary?.count ?? 0})
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||
<span className="text-base text-forest">
|
||||
<div className="mt-auto flex items-baseline justify-between pt-3">
|
||||
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
|
||||
<span className="font-serif text-2xl text-forest tabular-nums">
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink/65">
|
||||
<p className="text-xs text-ink/65 text-right tabular-nums">
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency, locale)}
|
||||
</p>
|
||||
{stock > 0 ? (
|
||||
@@ -243,9 +313,21 @@ export function ProductCard({
|
||||
) : null}
|
||||
<Link
|
||||
href={`/honig/${product.slug}`}
|
||||
className="mt-4 inline-block text-sm tracking-wide-caps text-forest border-b border-forest/40 pb-0.5 hover:border-forest"
|
||||
className="group/more mt-4 inline-flex items-center gap-2 self-start text-sm tracking-wide-caps text-forest"
|
||||
>
|
||||
<span className="link-draw pb-0.5">
|
||||
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5 transition-transform duration-300 ease-[var(--ease-arrive)] group-hover/more:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.article>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Mengen-Steller (−/Zahl/+), bisher als Inline-Markup nur auf der
|
||||
* Produktdetailseite vorhanden, waehrend Warenkorb-Drawer und Warenkorb-Seite
|
||||
* noch ein natives <select> nutzten — vereinheitlicht das Bedienelement.
|
||||
*
|
||||
* Die Zahl "tickt" bei jeder Aenderung, die Knoepfe geben beim Druecken nach
|
||||
* — die Menge fuehlt sich an wie ein Zaehlwerk statt wie ein Textfeld.
|
||||
*/
|
||||
export function QuantityStepper({
|
||||
quantity,
|
||||
@@ -14,30 +21,45 @@ export function QuantityStepper({
|
||||
max?: number;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const buttonSize = size === "sm" ? "w-8 h-8 text-base" : "w-10 h-10 text-lg";
|
||||
const buttonSize = size === "sm" ? "w-8 h-8" : "w-10 h-10";
|
||||
const buttonClass = `${buttonSize} flex items-center justify-center text-ink/70 transition-[color,background-color,transform] duration-200 hover:bg-honey/10 hover:text-forest active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink/70 disabled:active:scale-100`;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-stretch border border-ink/20">
|
||||
<div className="inline-flex items-stretch border border-ink/20 bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.max(1, quantity - 1))}
|
||||
disabled={quantity <= 1}
|
||||
aria-label="Menge verringern"
|
||||
className={`${buttonSize} flex items-center justify-center text-ink/70 hover:text-forest disabled:opacity-30 disabled:hover:text-ink/70`}
|
||||
className={buttonClass}
|
||||
>
|
||||
−
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="w-8 flex items-center justify-center text-sm" aria-live="polite">
|
||||
<span className="relative w-8 overflow-hidden flex items-center justify-center text-sm tabular-nums" aria-live="polite">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={quantity}
|
||||
initial={{ y: -12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 12, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{quantity}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(Math.min(max, quantity + 1))}
|
||||
disabled={quantity >= max}
|
||||
aria-label="Menge erhöhen"
|
||||
className={`${buttonSize} flex items-center justify-center text-ink/70 hover:text-forest disabled:opacity-30 disabled:hover:text-ink/70`}
|
||||
className={buttonClass}
|
||||
>
|
||||
+
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M5 12h14M12 5v14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+169
-28
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef, type RefObject } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { AnimatePresence, motion, useAnimationControls } from "motion/react";
|
||||
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
|
||||
import { useCartStock } from "@/hooks/use-cart-stock";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
@@ -13,6 +14,8 @@ import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
|
||||
import { QuantityStepper } from "@/components/quantity-stepper";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
@@ -214,31 +217,108 @@ function CartIconButton() {
|
||||
const items = useCartStore((s) => s.items);
|
||||
const openCart = useCartStore((s) => s.openCart);
|
||||
const count = cartItemCount(items);
|
||||
const basketControls = useAnimationControls();
|
||||
|
||||
// Landet ein Honigtropfen (components/cart-flight.tsx), faengt der Korb
|
||||
// ihn sichtbar auf: kurzes Einsinken und Nachwippen.
|
||||
useEffect(
|
||||
() =>
|
||||
onCartLanded(() => {
|
||||
void basketControls.start({
|
||||
y: [0, 3, -2, 0],
|
||||
rotate: [0, -8, 5, 0],
|
||||
transition: { duration: 0.45, ease: "easeOut" },
|
||||
});
|
||||
}),
|
||||
[basketControls]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={openCart}
|
||||
aria-label={t.nav.openCart(count)}
|
||||
className="relative flex items-center justify-center w-10 h-10 text-forest"
|
||||
data-cart-target=""
|
||||
className="relative flex items-center justify-center w-10 h-10 text-forest transition-colors hover:text-honey-dark"
|
||||
>
|
||||
<motion.svg
|
||||
animate={basketControls}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6h15l-1.5 9h-12z" />
|
||||
<path d="M6 6 5 3H2" />
|
||||
<circle cx="9" cy="20" r="1" />
|
||||
<circle cx="18" cy="20" r="1" />
|
||||
</svg>
|
||||
</motion.svg>
|
||||
<AnimatePresence>
|
||||
{count > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 bg-honey text-cream text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
|
||||
<motion.span
|
||||
// Neuer key je Anzahl: der Zaehler "springt" bei jeder Aenderung.
|
||||
key={count}
|
||||
initial={{ scale: 0.4, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.4, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 18 }}
|
||||
className="absolute -top-1 -right-1 bg-honey-dark text-cream text-[10px] min-w-4 h-4 px-1 rounded-full flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopfzeile reagiert aufs Scrollen: ab etwas Abstand zum Seitenanfang
|
||||
* bekommt sie Schatten und staerkeres Glas; beim Weiterlesen nach unten
|
||||
* weicht sie aus, beim kleinsten Hochscrollen ist sie sofort wieder da.
|
||||
* Waehrend Warenkorb oder Menue offen sind, bleibt sie stehen.
|
||||
*/
|
||||
function useHeaderScrollState(locked: boolean) {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let lastY = window.scrollY;
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const y = window.scrollY;
|
||||
setScrolled(y > 12);
|
||||
// Kleine Zitterbewegungen (Trackpad) ignorieren.
|
||||
if (Math.abs(y - lastY) > 6) {
|
||||
setHidden(y > lastY && y > 240);
|
||||
lastY = y;
|
||||
}
|
||||
});
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { scrolled, hidden: hidden && !locked };
|
||||
}
|
||||
|
||||
function isActivePath(pathname: string, href: string): boolean {
|
||||
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
export function Header({ companyName }: { companyName: string }) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const cartOpen = useCartStore((s) => s.isOpen);
|
||||
const { scrolled, hidden } = useHeaderScrollState(mobileOpen || cartOpen);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -252,7 +332,15 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-ink/10">
|
||||
<header
|
||||
className={`sticky top-0 z-40 border-b transition-[transform,background-color,box-shadow,border-color] duration-500 ease-[var(--ease-arrive)] motion-reduce:transition-none ${
|
||||
hidden ? "-translate-y-full" : "translate-y-0"
|
||||
} ${
|
||||
scrolled
|
||||
? "bg-background/80 backdrop-blur-md border-transparent shadow-[0_10px_30px_-20px_rgba(36,53,43,0.45)]"
|
||||
: "bg-background/95 backdrop-blur border-ink/10"
|
||||
}`}
|
||||
>
|
||||
{/*
|
||||
Eigener, breiterer Wrapper statt der gemeinsamen <Container> (max-w-6xl):
|
||||
Logo + 7-Punkte-Navigation + CTA-Button + Icons brauchen mehr Raum als die
|
||||
@@ -263,15 +351,21 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
<Logo t={t} companyName={companyName} />
|
||||
|
||||
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
|
||||
{mainNav.map((item) => (
|
||||
{mainNav.map((item) => {
|
||||
const active = isActivePath(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm text-ink/80 hover:text-forest transition-colors whitespace-nowrap"
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`link-draw pb-1 text-sm whitespace-nowrap transition-colors ${
|
||||
active ? "text-forest [background-image:linear-gradient(var(--color-honey),var(--color-honey))] [background-size:100%_2px]" : "text-ink/80 hover:text-forest"
|
||||
}`}
|
||||
>
|
||||
{t.nav[item.key]}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -286,6 +380,7 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
<AccountIconButton />
|
||||
</div>
|
||||
<CartIconButton />
|
||||
<CartFlight />
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
aria-label={t.nav.openMenu}
|
||||
@@ -360,8 +455,12 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="text-3xl font-serif"
|
||||
aria-current={isActivePath(pathname, item.href) ? "page" : undefined}
|
||||
className={`flex items-center gap-3 text-3xl font-serif ${
|
||||
isActivePath(pathname, item.href) ? "text-honey-light" : ""
|
||||
}`}
|
||||
>
|
||||
{isActivePath(pathname, item.href) ? <HexMark className="h-3 w-3" /> : null}
|
||||
{t.nav[item.key]}
|
||||
</Link>
|
||||
</motion.div>
|
||||
@@ -383,8 +482,8 @@ export function Header({ companyName }: { companyName: string }) {
|
||||
<Container className="pb-10">
|
||||
<LinkButton
|
||||
href="/shop"
|
||||
variant="secondary"
|
||||
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest w-full"
|
||||
variant="inverted"
|
||||
className="w-full"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{t.nav.discoverHoney}
|
||||
@@ -439,21 +538,33 @@ export function CartDrawer() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-ink/40 z-50"
|
||||
className="fixed inset-0 bg-forest/35 backdrop-blur-[2px] z-50"
|
||||
onClick={closeCart}
|
||||
/>
|
||||
<motion.aside
|
||||
ref={asideRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cart-drawer-title"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 28, stiffness: 260 }}
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-xl outline-none"
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-[-24px_0_60px_-30px_rgba(36,53,43,0.45)] outline-none"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
|
||||
<h2 className="text-xl font-serif text-forest">{t.cart.title}</h2>
|
||||
<button aria-label={t.cart.close} onClick={closeCart} className="w-9 h-9">
|
||||
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-forest">
|
||||
{t.cart.title}
|
||||
{items.length > 0 ? (
|
||||
<span className="font-sans text-xs text-ink/55 tabular-nums">({cartItemCount(items)})</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<button
|
||||
aria-label={t.cart.close}
|
||||
onClick={closeCart}
|
||||
className="flex w-9 h-9 items-center justify-center text-ink/70 transition-[color,transform] duration-300 hover:rotate-90 hover:text-forest"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
@@ -462,18 +573,39 @@ export function CartDrawer() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-ink/70 mt-10 text-center">
|
||||
{t.cart.empty}
|
||||
</p>
|
||||
// Leerer Korb als ruhiger Einstieg statt toter Flaeche: leere
|
||||
// Wabe, eine Zeile, ein Weg zurueck in den Shop.
|
||||
<div className="relative mt-16 flex flex-col items-center text-center">
|
||||
<svg viewBox="0 0 100 100" className="h-20 w-20 text-honey" fill="none" aria-hidden="true">
|
||||
<polygon
|
||||
points="50,6 88,28 88,72 50,94 12,72 12,28"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 5"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-6 font-serif text-2xl text-forest">{t.cart.empty}</p>
|
||||
<LinkButton href="/shop" variant="secondary" onClick={closeCart} className="mt-8">
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink/10">
|
||||
{items.map((item) => {
|
||||
<AnimatePresence initial={false}>
|
||||
{items.map((item, index) => {
|
||||
const itemStock = stock?.[item.productId];
|
||||
return (
|
||||
<li key={item.productId} className="py-4 flex gap-4">
|
||||
<motion.li
|
||||
key={item.productId}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: 0.12 + Math.min(index, 6) * 0.05, ease: [0.16, 1, 0.3, 1] } }}
|
||||
exit={{ opacity: 0, x: 40, transition: { duration: 0.2 } }}
|
||||
className="py-4 flex gap-4"
|
||||
>
|
||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink">{item.name}</p>
|
||||
<p className="font-serif text-lg leading-tight text-forest">{item.name}</p>
|
||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
||||
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
||||
@@ -503,21 +635,30 @@ export function CartDrawer() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-ink">
|
||||
<p className="text-sm text-ink tabular-nums">
|
||||
{formatPrice(item.priceCents * item.quantity, "EUR", locale)}
|
||||
</p>
|
||||
</li>
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="border-t border-ink/10 p-6 space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(total, "EUR", locale)}</span>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-sm text-ink/70">{t.cart.subtotal}</span>
|
||||
<motion.span
|
||||
// Summe "tickt" sichtbar bei jeder Aenderung der Menge.
|
||||
key={total}
|
||||
initial={{ opacity: 0.4, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-serif text-2xl text-forest tabular-nums"
|
||||
>
|
||||
{formatPrice(total, "EUR", locale)}
|
||||
</motion.span>
|
||||
</div>
|
||||
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
|
||||
{t.cart.checkout}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { HoneycombPattern } from "@/components/honeycomb";
|
||||
import type { ReactNode } from "react";
|
||||
import { Container } from "@/components/ui";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
@@ -116,8 +117,9 @@ export async function Footer({
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-forest text-cream mt-24">
|
||||
<Container className="py-16">
|
||||
<footer className="relative overflow-hidden bg-forest text-cream mt-24">
|
||||
<HoneycombPattern className="text-honey-light/[0.06]" size={26} fade="right" />
|
||||
<Container className="relative py-16">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -180,7 +182,7 @@ function FooterColumn({
|
||||
<ul className="space-y-2">
|
||||
{links.map((link) => (
|
||||
<li key={link.key}>
|
||||
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream">
|
||||
<Link href={link.href} className="link-draw pb-0.5 text-sm text-cream/75 transition-colors hover:text-cream">
|
||||
{labels[link.key]}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { launchCartFlight } from "@/components/cart-flight";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { ProductWithRelations } from "@/lib/data";
|
||||
|
||||
@@ -37,12 +38,13 @@ export function StickyAddToCartBar({
|
||||
<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>
|
||||
<p className="text-sm text-forest tabular-nums">{formatPrice(product.priceCents, product.currency, locale)}</p>
|
||||
</div>
|
||||
{inStock ? (
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
launchCartFlight(event.currentTarget);
|
||||
addItem(
|
||||
{
|
||||
productId: product.id,
|
||||
@@ -52,7 +54,8 @@ export function StickyAddToCartBar({
|
||||
priceCents: product.priceCents,
|
||||
image: product.images[0] ?? null,
|
||||
},
|
||||
1
|
||||
1,
|
||||
{ openDrawer: false }
|
||||
);
|
||||
setJustAdded(true);
|
||||
window.setTimeout(() => setJustAdded(false), 1500);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/** Obergrenze, falls eine Aktion weder weiterleitet noch eine Meldung setzt. */
|
||||
const MAX_PENDING_MS = 20_000;
|
||||
|
||||
function isServerActionForm(form: HTMLFormElement): boolean {
|
||||
// React rendert fuer Formulare mit Server Action versteckte $ACTION_-Felder
|
||||
// (progressive enhancement). Formulare mit eigener Client-Logik
|
||||
// (onSubmit + fetch) haben sie nicht und verwalten ihren Zustand selbst.
|
||||
return form.querySelector('input[name^="$ACTION_"]') !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sofortige Rueckmeldung beim Absenden: der geklickte Knopf eines
|
||||
* Server-Action-Formulars wird als "laeuft" markiert (Spinner, siehe
|
||||
* globals.css) und bis zur Antwort gesperrt — ohne das sah ein Klick bis zur
|
||||
* Serverantwort wie "nichts passiert" aus und wurde oft wiederholt
|
||||
* (doppelte Aktion, doppelte E-Mail).
|
||||
*
|
||||
* Zentral statt in jedem der vielen Formulare: freigegeben wird, sobald eine
|
||||
* neue Flash-Meldung (flashId) oder eine neue Seite kommt, spaetestens nach
|
||||
* MAX_PENDING_MS.
|
||||
*/
|
||||
export function SubmitFeedback({ flashId }: { flashId: string | null }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const onSubmit = (event: SubmitEvent) => {
|
||||
const form = event.target;
|
||||
const button = event.submitter;
|
||||
if (!(form instanceof HTMLFormElement) || !(button instanceof HTMLButtonElement)) return;
|
||||
if (!isServerActionForm(form)) return;
|
||||
|
||||
// Erst nach den React-Handlern pruefen: ConfirmForm markiert dort einen
|
||||
// im Dialog abgebrochenen Klick. Das Sperren nach dem Absenden aendert
|
||||
// die bereits erfassten Formulardaten nicht mehr.
|
||||
window.setTimeout(() => {
|
||||
if (form.dataset.submitCancelled === "true") {
|
||||
delete form.dataset.submitCancelled;
|
||||
return;
|
||||
}
|
||||
button.dataset.pending = "true";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.disabled = true;
|
||||
window.setTimeout(() => release(button), MAX_PENDING_MS);
|
||||
}, 0);
|
||||
};
|
||||
document.addEventListener("submit", onSubmit, true);
|
||||
return () => document.removeEventListener("submit", onSubmit, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelectorAll<HTMLButtonElement>('button[data-pending="true"]').forEach(release);
|
||||
}, [flashId, pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function release(button: HTMLButtonElement): void {
|
||||
if (button.dataset.pending !== "true") return;
|
||||
delete button.dataset.pending;
|
||||
button.removeAttribute("aria-busy");
|
||||
button.disabled = false;
|
||||
}
|
||||
+70
-11
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
|
||||
export function Container({
|
||||
id,
|
||||
@@ -17,16 +18,29 @@ export function Container({
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost";
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "inverted" | "accent";
|
||||
|
||||
// Signatur-Hover: der Knopf fuellt sich von unten wie ein Glas — beim
|
||||
// Primaerknopf mit Honig, beim Sekundaerknopf mit Waldgruen. Umgesetzt als
|
||||
// Hintergrund-Verlauf, dessen Hoehe waechst (kein Layout-Eigenschafts-
|
||||
// Tweening, kein zusaetzliches Element).
|
||||
const fillBase =
|
||||
"bg-no-repeat bg-bottom bg-[length:100%_0%] hover:bg-[length:100%_100%] focus-visible:bg-[length:100%_100%] motion-reduce:transition-colors";
|
||||
|
||||
const buttonStyles: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-forest text-cream hover:bg-[#1a271f] border border-forest",
|
||||
secondary:
|
||||
"bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream",
|
||||
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink",
|
||||
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-dark),var(--color-honey-dark))] text-cream border border-forest hover:border-honey-dark`,
|
||||
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-forest border border-forest hover:text-cream focus-visible:text-cream`,
|
||||
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
|
||||
// Fuer dunkle Flaechen (Hero, Waldgruen-Abschnitte, Menue): Creme-Rahmen,
|
||||
// fuellt sich mit Creme, Text wechselt auf Waldgruen.
|
||||
inverted: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-cream hover:text-forest focus-visible:text-forest`,
|
||||
// Hauptaktion auf dunklen Flaechen, wo Waldgruen im Grund verschwinden wuerde.
|
||||
accent: `${fillBase} bg-honey-dark bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
|
||||
};
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-size,border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:shadow-[0_10px_24px_-14px_rgba(36,53,43,0.55)] active:scale-[0.97] active:duration-100";
|
||||
|
||||
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
@@ -39,7 +53,7 @@ export function LinkButton({
|
||||
}: LinkButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
className={`inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] ${buttonStyles[variant]} ${className}`}
|
||||
className={`${buttonBase} ${buttonStyles[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -59,7 +73,7 @@ export function Button({
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 ${buttonStyles[variant]} ${className}`}
|
||||
className={`${buttonBase} disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 disabled:hover:bg-[length:100%_0%] disabled:hover:shadow-none ${buttonStyles[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -93,15 +107,60 @@ export function SectionHeading({
|
||||
>
|
||||
{eyebrow ? (
|
||||
<p
|
||||
className={`tracking-wide-caps text-xs mb-3 ${tone === "inverted" ? "text-honey-light" : "text-honey-dark"}`}
|
||||
className={`flex items-center gap-2 tracking-wide-caps text-xs mb-4 ${
|
||||
align === "center" ? "justify-center" : ""
|
||||
} ${tone === "inverted" ? "text-honey-light" : "text-honey-dark"}`}
|
||||
>
|
||||
<HexMark className="h-2.5 w-2.5 flex-shrink-0" />
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<Heading className="text-3xl md:text-4xl text-forest">{title}</Heading>
|
||||
<Heading
|
||||
className={`text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] ${
|
||||
tone === "inverted" ? "text-cream" : "text-forest"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{subtitle ? (
|
||||
<p className="mt-4 text-ink/70 leading-relaxed">{subtitle}</p>
|
||||
<p
|
||||
className={`mt-5 max-w-[60ch] leading-relaxed ${align === "center" ? "mx-auto" : ""} ${
|
||||
tone === "inverted" ? "text-cream/75" : "text-ink/70"
|
||||
}`}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bild mit versetztem Honigrahmen dahinter — gibt den Bildspalten der
|
||||
* Startseite Tiefe, statt sie flach neben den Text zu legen. Der Rahmen
|
||||
* weicht beim Hover etwas zurueck, das Bild bleibt ruhig.
|
||||
*/
|
||||
export function FramedMedia({
|
||||
children,
|
||||
side = "right",
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** Richtung, in die der Rahmen versetzt ist. */
|
||||
side?: "left" | "right";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`group/frame relative isolate ${className}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 -z-10 border border-honey/60 transition-transform duration-700 ease-[var(--ease-arrive)] ${
|
||||
side === "right"
|
||||
? "translate-x-4 translate-y-4 group-hover/frame:translate-x-6 group-hover/frame:translate-y-6"
|
||||
: "-translate-x-4 translate-y-4 group-hover/frame:-translate-x-6 group-hover/frame:translate-y-6"
|
||||
}`}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -927,6 +927,12 @@ export const de: Dictionary = {
|
||||
unknown: "Status unbekannt",
|
||||
},
|
||||
},
|
||||
flash: {
|
||||
close: "Meldung schließen",
|
||||
success: "Erledigt",
|
||||
error: "Fehler",
|
||||
info: "Hinweis",
|
||||
},
|
||||
adminArchive: {
|
||||
tabsAriaLabel: "Ansicht",
|
||||
tabActive: "Aktiv",
|
||||
@@ -1409,15 +1415,23 @@ export const de: Dictionary = {
|
||||
save: "Passwort speichern",
|
||||
},
|
||||
checkoutSuccessPage: {
|
||||
pendingEyebrow: "Bestellung vorgemerkt",
|
||||
pendingEyebrow: "Zahlung in Bearbeitung",
|
||||
successEyebrow: "Vielen Dank",
|
||||
pendingTitle: "Deine Bestellung ist eingegangen.",
|
||||
successTitle: "Deine Bestellung wurde bestätigt.",
|
||||
orderNumberLabel: "Bestellnummer",
|
||||
pendingText: "Die Zahlungsabwicklung war kurzfristig nicht erreichbar. Wir melden uns per E-Mail bei dir, um die Zahlung abzuschließen.",
|
||||
pendingText: "Deine Zahlung wird noch bestätigt (bei SEPA-Lastschrift kann das einige Werktage dauern). Sobald sie eingegangen ist, bekommst du eine Bestätigung per E-Mail.",
|
||||
successText: "Wir bereiten dein Glas Wabenhain-Honig mit Sorgfalt vor. Sobald deine Zahlung eingegangen ist, senden wir dir eine Bestätigung per E-Mail (bei SEPA-Lastschrift kann das einige Werktage dauern).",
|
||||
continueShopping: "Weiter stöbern",
|
||||
trackOrder: "Bestellung verfolgen",
|
||||
openEyebrow: "Zahlung nicht abgeschlossen",
|
||||
openTitle: "Das hat leider nicht geklappt.",
|
||||
openText: "Die Zahlung wurde abgebrochen oder abgelehnt, es wurde nichts abgebucht. Deine Bestellung ist noch für dich reserviert — versuche es einfach erneut, gern auch mit einer anderen Zahlungsart.",
|
||||
cancelPayment: "Bestellung abbrechen",
|
||||
notCompletedEyebrow: "Nicht abgeschlossen",
|
||||
notCompletedTitle: "Diese Bestellung wurde nicht bezahlt.",
|
||||
notCompletedText: "Die Zahlung ist abgelaufen oder wurde abgebrochen, es wurde nichts abgebucht. Dein Warenkorb ist noch gefüllt, du kannst jederzeit neu bestellen.",
|
||||
backToCart: "Zurück zum Warenkorb",
|
||||
},
|
||||
checkoutCancelPage: {
|
||||
eyebrow: "Abgebrochen",
|
||||
@@ -1427,6 +1441,13 @@ export const de: Dictionary = {
|
||||
backToCart: "Zurück zum Warenkorb",
|
||||
},
|
||||
checkoutPage: {
|
||||
paymentHeading: "Zahlung",
|
||||
paymentBack: "Zurück zu deinen Angaben",
|
||||
paymentCancelling: "Einen Moment…",
|
||||
paymentCancelError: "Das hat gerade nicht geklappt. Bitte versuche es noch einmal.",
|
||||
paymentLoading: "Sichere Zahlung wird geladen…",
|
||||
paymentLoadError: "Das Zahlungsformular konnte nicht geladen werden. Ein Werbeblocker kann das verhindern.",
|
||||
paymentUnavailable: "Die Zahlung ist gerade nicht verfügbar. Bitte versuche es später erneut oder melde dich bei uns.",
|
||||
eyebrow: "Checkout",
|
||||
emptyCart: "Dein Warenkorb ist leer",
|
||||
yourDetails: "Deine Daten",
|
||||
@@ -1468,7 +1489,7 @@ export const de: Dictionary = {
|
||||
submitting: "Wird verarbeitet…",
|
||||
placeOrder: "Kostenpflichtig bestellen",
|
||||
paymentMethodsNote:
|
||||
"Zahlungsmethoden: PayPal, Kreditkarte, Apple Pay, Google Pay, SEPA-Lastschrift, Klarna, bereitgestellt über Stripe.",
|
||||
"Im nächsten Schritt bezahlst du sicher direkt hier auf der Seite: PayPal, Kreditkarte, SEPA-Lastschrift oder Klarna, abgewickelt über Stripe.",
|
||||
yourOrderHeading: "Deine Bestellung",
|
||||
shippingWithCount: (count) => `Versand (${count} Stück)`,
|
||||
shippingPickup: "Versand (Abholung)",
|
||||
|
||||
@@ -915,6 +915,12 @@ export const en: Dictionary = {
|
||||
unknown: "Status unknown",
|
||||
},
|
||||
},
|
||||
flash: {
|
||||
close: "Close message",
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
info: "Notice",
|
||||
},
|
||||
adminArchive: {
|
||||
tabsAriaLabel: "View",
|
||||
tabActive: "Active",
|
||||
@@ -1395,15 +1401,23 @@ export const en: Dictionary = {
|
||||
save: "Save password",
|
||||
},
|
||||
checkoutSuccessPage: {
|
||||
pendingEyebrow: "Order Received",
|
||||
pendingEyebrow: "Payment Processing",
|
||||
successEyebrow: "Thank You",
|
||||
pendingTitle: "Your order has been received.",
|
||||
successTitle: "Your order has been confirmed.",
|
||||
orderNumberLabel: "Order number",
|
||||
pendingText: "Payment processing was briefly unreachable. We'll email you to complete the payment.",
|
||||
pendingText: "Your payment is still being confirmed (this can take a few business days for SEPA direct debit). We'll email you as soon as it arrives.",
|
||||
successText: "We're carefully preparing your jar of Wabenhain honey. As soon as your payment arrives, we'll send you a confirmation by email (this can take a few business days for SEPA direct debit).",
|
||||
continueShopping: "Keep browsing",
|
||||
trackOrder: "Track order",
|
||||
openEyebrow: "Payment Not Completed",
|
||||
openTitle: "That didn't work, unfortunately.",
|
||||
openText: "The payment was cancelled or declined and nothing was charged. Your order is still reserved for you — just try again, with another payment method if you like.",
|
||||
cancelPayment: "Cancel order",
|
||||
notCompletedEyebrow: "Not Completed",
|
||||
notCompletedTitle: "This order was not paid.",
|
||||
notCompletedText: "The payment expired or was cancelled and nothing was charged. Your cart is still filled, so you can order again at any time.",
|
||||
backToCart: "Back to cart",
|
||||
},
|
||||
checkoutCancelPage: {
|
||||
eyebrow: "Cancelled",
|
||||
@@ -1413,6 +1427,13 @@ export const en: Dictionary = {
|
||||
backToCart: "Back to cart",
|
||||
},
|
||||
checkoutPage: {
|
||||
paymentHeading: "Payment",
|
||||
paymentBack: "Back to your details",
|
||||
paymentCancelling: "One moment…",
|
||||
paymentCancelError: "That didn't work just now. Please try again.",
|
||||
paymentLoading: "Loading secure payment…",
|
||||
paymentLoadError: "The payment form could not be loaded. An ad blocker can prevent this.",
|
||||
paymentUnavailable: "Payment is currently unavailable. Please try again later or get in touch with us.",
|
||||
eyebrow: "Checkout",
|
||||
emptyCart: "Your cart is empty",
|
||||
yourDetails: "Your Details",
|
||||
@@ -1454,7 +1475,7 @@ export const en: Dictionary = {
|
||||
submitting: "Processing…",
|
||||
placeOrder: "Place order",
|
||||
paymentMethodsNote:
|
||||
"Payment methods: PayPal, credit card, Apple Pay, Google Pay, SEPA direct debit, Klarna, provided via Stripe.",
|
||||
"In the next step you pay securely right here on this page: PayPal, credit card, SEPA direct debit or Klarna, processed by Stripe.",
|
||||
yourOrderHeading: "Your Order",
|
||||
shippingWithCount: (count) => `Shipping (${count} item${count === 1 ? "" : "s"})`,
|
||||
shippingPickup: "Shipping (pickup)",
|
||||
|
||||
@@ -603,6 +603,12 @@ export interface Dictionary {
|
||||
unknown: string;
|
||||
};
|
||||
};
|
||||
flash: {
|
||||
close: string;
|
||||
success: string;
|
||||
error: string;
|
||||
info: string;
|
||||
};
|
||||
adminArchive: {
|
||||
tabsAriaLabel: string;
|
||||
tabActive: string;
|
||||
@@ -1067,6 +1073,14 @@ export interface Dictionary {
|
||||
successText: string;
|
||||
continueShopping: string;
|
||||
trackOrder: string;
|
||||
openEyebrow: string;
|
||||
openTitle: string;
|
||||
openText: string;
|
||||
cancelPayment: string;
|
||||
notCompletedEyebrow: string;
|
||||
notCompletedTitle: string;
|
||||
notCompletedText: string;
|
||||
backToCart: string;
|
||||
};
|
||||
checkoutCancelPage: {
|
||||
eyebrow: string;
|
||||
@@ -1076,6 +1090,13 @@ export interface Dictionary {
|
||||
backToCart: string;
|
||||
};
|
||||
checkoutPage: {
|
||||
paymentHeading: string;
|
||||
paymentBack: string;
|
||||
paymentCancelling: string;
|
||||
paymentCancelError: string;
|
||||
paymentLoading: string;
|
||||
paymentLoadError: string;
|
||||
paymentUnavailable: string;
|
||||
eyebrow: string;
|
||||
emptyCart: string;
|
||||
yourDetails: string;
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user