feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
# Datenbank: "postgresql" (Standard) oder "mysql". Nach einer Aenderung
|
||||||
|
# einmal `pnpm db:push` ausfuehren (schreibt prisma/schema.prisma neu und
|
||||||
|
# generiert den passenden Treiber-Adapter-Client) — siehe README, Abschnitt
|
||||||
|
# "Datenbank-Provider wechseln".
|
||||||
|
DATABASE_PROVIDER=postgresql
|
||||||
|
|
||||||
|
# PostgreSQL (Standard). Fuer docker compose siehe POSTGRES_* unten.
|
||||||
|
DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public"
|
||||||
|
# MySQL/MariaDB stattdessen:
|
||||||
|
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
||||||
|
|
||||||
|
# Postgres-Zugangsdaten fuer docker-compose.yml (Datenbank-Container)
|
||||||
|
POSTGRES_USER=wabenhain
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=wabenhain
|
||||||
|
|
||||||
|
# Nur noetig, wenn DATABASE_PROVIDER=mysql: Zugangsdaten fuer den
|
||||||
|
# db-mysql-Container ("docker compose --profile mysql up -d db-mysql")
|
||||||
|
MYSQL_USER=wabenhain
|
||||||
|
MYSQL_PASSWORD=change-me
|
||||||
|
MYSQL_DATABASE=wabenhain
|
||||||
|
|
||||||
|
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
|
||||||
|
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Admin-Zugang: Der interne /admin-Bereich wird NICHT per Basic-Auth
|
||||||
|
# geschuetzt, sondern ueber das normale Kundenkonto-Login (/konto) und die
|
||||||
|
# Rolle "ADMIN" am Konto (siehe README, Abschnitt "Admin-Zugang"). Diese
|
||||||
|
# beiden Werte legt `prisma db seed` als initiales Admin-Konto an, falls es
|
||||||
|
# noch nicht existiert. Eine UNVORHERSEHBARE E-Mail waehlen (nicht
|
||||||
|
# "admin@..." o.ae.) — sonst koennte sich vor dem ersten Seed-Lauf jemand
|
||||||
|
# genau diese Adresse selbst registrieren; prisma/seed.ts befoerdert ein
|
||||||
|
# bereits bestehendes Nicht-Admin-Konto zwar nicht mehr automatisch, ein
|
||||||
|
# unvorhersehbarer Wert ist trotzdem die robustere erste Verteidigungslinie.
|
||||||
|
ADMIN_EMAIL=[email protected]
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Geheimer Schluessel zum Signieren der Kundenkonto-JWTs (HS256).
|
||||||
|
# In Produktion MINDESTENS 32 Zeichen (wird beim Start erzwungen).
|
||||||
|
# Erzeugen z.B. mit: openssl rand -base64 32
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Basis-URL der Seite (fuer absolute Links, z.B. in E-Mails/Sitemaps).
|
||||||
|
# Lokale Entwicklung (pnpm dev): http://localhost:3000 (Standard hier).
|
||||||
|
# Vollstaendiger Docker-Betrieb: http://localhost (Caddy laeuft auf Port 80,
|
||||||
|
# siehe SITE_ADDRESS/Caddyfile unten) bzw. die echte Domain in Produktion.
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Nur fuer den vollstaendigen Docker-Betrieb (docker-compose.yml, Caddyfile):
|
||||||
|
# die oeffentliche Domain, unter der die Seite erreichbar sein soll. Leer
|
||||||
|
# gelassen laeuft Caddy lokal per HTTP auf Port 80 ohne TLS — fuer eine
|
||||||
|
# echte Domain mit automatischem HTTPS z.B. "wabenhain.com" eintragen.
|
||||||
|
SITE_ADDRESS=
|
||||||
|
|
||||||
|
# Google Rezensionen (optional). Ohne diese beiden Werte zeigt die Startseite
|
||||||
|
# automatisch die statischen Beispiel-Stimmen als Fallback.
|
||||||
|
# API-Key: Google-Cloud-Projekt mit aktivierter "Places API".
|
||||||
|
# Place-ID: https://developers.google.com/maps/documentation/places/web-service/place-id
|
||||||
|
GOOGLE_PLACES_API_KEY=
|
||||||
|
GOOGLE_PLACE_ID=
|
||||||
|
|
||||||
|
# Cloudflare Turnstile (optional). Ohne diese beiden Werte wird auf
|
||||||
|
# Registrierung, Newsletter und Firmenanfrage kein CAPTCHA angezeigt und die
|
||||||
|
# serverseitige Pruefung uebersprungen. Site-Key ist oeffentlich (NEXT_PUBLIC_),
|
||||||
|
# Secret-Key bleibt privat. Einrichtung: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
||||||
|
|
||||||
|
# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
|
||||||
|
# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt
|
||||||
|
# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup.
|
||||||
|
# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures
|
||||||
|
# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im
|
||||||
|
# SMTP-Modus).
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM="Wabenhain <[email protected]>"
|
||||||
@@ -1,36 +1,260 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Wabenhain
|
||||||
|
|
||||||
## Getting Started
|
Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
|
||||||
|
|
||||||
First, run the development server:
|
## Tech-Stack
|
||||||
|
|
||||||
```bash
|
| Bereich | Technologie |
|
||||||
npm run dev
|
|---|---|
|
||||||
# or
|
| Sprache | TypeScript |
|
||||||
yarn dev
|
| Framework | Next.js 16 (App Router) + React 19 |
|
||||||
# or
|
| Styling | Tailwind CSS v4 |
|
||||||
pnpm dev
|
| Scroll-/Hero-Animationen | GSAP + ScrollTrigger |
|
||||||
# or
|
| UI-Animationen | Motion for React (Menü, Warenkorb, Karten) |
|
||||||
bun dev
|
| Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" |
|
||||||
|
| ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) |
|
||||||
|
| Payment | Stripe Checkout |
|
||||||
|
| Hosting | Docker / Node.js |
|
||||||
|
|
||||||
|
## Was ist enthalten
|
||||||
|
|
||||||
|
- Startseite mit allen Sections aus dem Briefing (Hero, aktuelle Ernte, Herkunft, Philosophie, Produktbereich, Prozess-Timeline, Regionale Imkerei, Nachhaltigkeit, Firmenkunden-Teaser, Bewertungen, Newsletter) — die Bewertungen-Section zeigt echte Google-Rezensionen, sobald konfiguriert (siehe „Google-Rezensionen" unten), sonst statische Beispiel-Stimmen als Fallback
|
||||||
|
- Shop-Übersicht mit Suche (`?q=`, mehrwortig und case-insensitiv über Name, Beschreibung und Geschmacks-Tags), Sortierung (Neu eingetroffen/Preis auf-/absteigend/Verfügbarkeit), „Nur verfügbare"-Filter und `BreadcrumbList`-JSON-LD + Produktdetailseite (Geschmacksskala, Charge, strukturierte Daten/JSON-LD, Lagerbestands-Hinweis, „Das könnte dir auch gefallen" mit verwandten Produkten derselben Kategorie)
|
||||||
|
- Warenkorb (Drawer + Seite) mit Produktbild-Platzhaltern je Position und „Das könnte dir auch gefallen"-Cross-Sell auf der Warenkorb-Seite (verfügbare Produkte, die noch nicht im Warenkorb liegen)
|
||||||
|
- **Zuletzt angesehen**: rein lokal im Browser (localStorage, kein Tracking-Cookie) gemerkte zuletzt besuchte Produkte, als Sektion auf Produktdetail- und Shop-Seite
|
||||||
|
- **Echte Kaufsignale statt künstlicher Dringlichkeit**: Sternebewertungen (aus echten, freigeschalteten Bewertungen) jetzt auch direkt auf den Produktkarten der Shop-Übersicht, nicht nur auf der Detailseite — bewusst keine erfundenen Countdown-Timer oder Live-Besucherzahlen
|
||||||
|
- **Echte Produktbewertungen**: eingeloggte Kund:innen können ein Produkt nur bewerten, wenn sie es tatsächlich gekauft haben (verifizierter Kauf, eine Bewertung pro Konto und Produkt); neue Bewertungen erscheinen erst nach admin-Freischaltung (`/admin/bewertungen`) öffentlich, inkl. Sternebewertung, optionalem Kommentar und `AggregateRating`-JSON-LD auf der Produktseite
|
||||||
|
- **Rabattcodes/Gutscheine**: prozentual oder als fester Betrag, mit optionalem Mindestbestellwert, Gültigkeitszeitraum und Nutzungslimit, verwaltet unter `/admin/gutscheine`. Der Code wird beim Checkout eingelöst und als einmaliger Stripe-Coupon auf die Checkout-Session angewendet (Rabatt gilt nur auf den Warenwert, nicht auf den Versand); die Nutzung wird erst bei bestätigter Zahlung gezählt, nicht schon bei einem abgebrochenen Checkout
|
||||||
|
- **Lagerbestands-Hinweise**: „Ausverkauft"-/„Nur noch N"-Badges auf Produktkarte und Produktdetailseite (ab 5 Stück oder weniger), Bestellbutton wird bei Bestand 0 deaktiviert
|
||||||
|
- **Benachrichtigung bei Wiederverfügbarkeit**: bei ausverkauften Produkten kann eine E-Mail-Adresse hinterlegt werden (`StockNotification`-Modell); sobald ein Admin den Lagerbestand im Admin-Bereich wieder über 0 setzt, werden alle wartenden Adressen automatisch per E-Mail benachrichtigt (`lib/stock-notifications.ts`)
|
||||||
|
- **Retouren**: Kund:innen können im Konto (`/konto/bestellungen`) für bezahlte, nicht stornierte Bestellungen eine Rücksendung mit Begründung anfragen (`ReturnRequest`-Modell). Admin sieht alle Anfragen unter `/admin/retouren`, kann sie annehmen oder ablehnen und eine angenommene Retoure per Klick über die Stripe Refunds API erstatten — dabei wird automatisch der Lagerbestand zurückgebucht und `Payment.status` auf `REFUNDED` gesetzt. Jeder Schritt (Anfrage, Annahme/Ablehnung, Erstattung) löst eine E-Mail an die Kundschaft aus.
|
||||||
|
- **Schnell-Hinzufügen**: Produktkarten in der Shop-Übersicht haben einen direkten „In den Warenkorb"-Button für verfügbare Produkte, ohne die Produktseite öffnen zu müssen
|
||||||
|
- **Mengenauswahl**: auf der Produktseite kann die Menge vor dem Hinzufügen zum Warenkorb direkt per Mengen-Steller gewählt werden (begrenzt auf den verfügbaren Lagerbestand)
|
||||||
|
- **Wunschliste** (`/konto/wunschliste`): eingeloggte Kund:innen können Produkte auf der Produktdetailseite merken und später in ihrem Konto wiederfinden oder entfernen
|
||||||
|
- Warenkorb (Zustand-Store, persistiert im Browser) + Cart-Drawer
|
||||||
|
- Checkout mit Gastbestellung oder eingeloggtem Konto (bei Login werden Name/E-Mail/Standardadresse automatisch vorausgefüllt), Versand/Abholung, Stripe Checkout Session mit Karte, **SEPA-Lastschrift** und **Klarna** (siehe „Stripe" unten), admin-konfigurierbare **Versandkosten-Staffel** nach Gesamtmenge (siehe „Versandkosten" unten). Die Checkout-Seite zeigt Versandkosten und Gesamtsumme live an (reagiert auf Versand/Abholung-Auswahl) statt sie erst nach der Weiterleitung zu Stripe sichtbar zu machen.
|
||||||
|
- Chargen-Seite (`/charge/[code]`) für den QR-Code auf jedem Glas
|
||||||
|
- Ernten-Übersicht + verschachtelte Ernten-Detailseite (`/ernten/[jahr]/[typ]`)
|
||||||
|
- Herkunft, Imkerei, Kontakt, Unternehmen (B2B-Formular)
|
||||||
|
- Honigwissen mit allen 9 im Briefing genannten Artikeln
|
||||||
|
- Rechtliches: Impressum, Datenschutz, AGB, Widerrufsbelehrung, Versand und Zahlung (**Platzhaltertexte, rechtlich prüfen lassen**)
|
||||||
|
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
|
||||||
|
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
|
||||||
|
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten
|
||||||
|
- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
|
||||||
|
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
|
||||||
|
- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45 KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
|
||||||
|
- **Chargen-Seite (`/charge/[code]`, QR-Code auf jedem Glas) aufgewertet**: statt einer reinen Datenliste jetzt mit "Verifizierte Charge"-Kennzeichnung, einer Ernte-→-Abfüllung-→-MHD-Zeitleiste und einem Verweis auf das zugehörige Produkt — der Moment, in dem jemand mit dem Glas in der Hand die Herkunft nachprüft, bekommt entsprechend mehr Gewicht. Alle gezeigten Angaben sind echte, bereits vorhandene Chargendaten.
|
||||||
|
- **Lieferzeit im Checkout sichtbar**: bei Versand zeigt die Lieferart-Auswahl direkt die bereits auf „Versand und Zahlung" dokumentierte übliche Lieferzeit (2–4 Werktage nach Zahlungseingang) an, statt dass Kund:innen dafür extra die Versandseite aufsuchen müssen.
|
||||||
|
- **Live-Suchvorschläge**: die Kopfzeilen-Suche zeigt beim Tippen (ab 2 Zeichen, debounced) bis zu 5 passende Produkte mit Preis direkt zum Anklicken — `/api/shop/search-suggestions`, rate-limitiert wie jeder andere öffentliche Endpunkt, nutzt dieselbe Suchlogik wie die Shop-Übersicht.
|
||||||
|
- **Ernten-Detailseiten aufgewertet**: Zurück-Link, Chargen als klickbare Karten statt Fließtext-Links, Produkte über die neue geteilte `ProductTeaserLink`-Komponente (auch auf der Chargen-Seite verwendet — kein doppelter Code für dasselbe Muster).
|
||||||
|
- **Admin-2FA-Hinweis**: Admin-Konten ohne aktivierte Zwei-Faktor-Authentifizierung sehen einen dauerhaften, nicht blockierenden Hinweis mit Link zur Einrichtung — relevant, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt.
|
||||||
|
- **`.well-known/security.txt`** (RFC 9116): dokumentierter Kontaktweg für verantwortungsvolle Schwachstellenmeldungen.
|
||||||
|
- **Escape schließt Overlays + vollständiges Fokus-Management**: Cart-Drawer, mobiles Menü und die Kopfzeilen-Suche lassen sich jetzt auch per Escape-Taste schließen, nicht nur per Maus-Klick. Beim Öffnen wandert der Tastatur-Fokus aktiv in das Overlay hinein (statt auf dem jetzt verdeckten Ausloeser-Button stehen zu bleiben), Tab/Shift+Tab bleiben innerhalb des Overlays gefangen (kein Durchtabben zu unsichtbaren Links dahinter), und beim Schließen springt der Fokus zurück auf den auslösenden Button.
|
||||||
|
- **Mobile-Layout-Fixes**: die Warenkorb-Zeile (Menge/Preis/Entfernen wurde auf schmalen Viewports komplett aus dem sichtbaren Bereich gedrängt) und Platzhalterbild-Beschriftungen (hartes Abschneiden statt Kürzung mit „…") behoben — gefunden durch echte Viewport-Emulation, nicht nur Desktop-Tests.
|
||||||
|
- **Kundenkonto-Design**: der Konto-Bereich (Übersicht, Bestellungen, Adressen, Wunschliste, Zwei-Faktor) hat jetzt dieselbe optische Sorgfalt wie Shop/Checkout — Icon-Navigationskarten mit Hover-Anhebung statt reiner Text-Boxen, farbcodierte Bestellstatus-Badges statt Fließtext, und gestaltete Leer-Zustände mit Icon und Weiterleitung zum Shop statt einzelner Hinweissätze. Zusätzlich behoben: der Warenkorb-Button im Header hatte einen `aria-label`, der die sichtbare Artikelanzahl nicht einschloss (gleiches Muster wie bei der Bildergalerie).
|
||||||
|
- **Admin-Design**: alle 10 Tabellen im Admin-Bereich (Bestellungen, Produkte, Chargen, Ernten, Benutzer, Gutscheine, Versand, Bewertungen, Audit-Log) haben jetzt einen Hover-Zustand auf Zeilenebene statt komplett statischer Reihen. Das Dashboard zeigt jede Kennzahl mit einem passenden Icon, und der wichtigste Wert („Umsatz heute") ist optisch hervorgehoben statt gleichwertig neben sieben anderen Zahlen zu stehen. Die Bestellliste zeigt den Status jetzt zusätzlich als farbcodiertes Badge (wiederverwendet aus dem Kundenkonto) neben dem bestehenden Bearbeiten-Dropdown.
|
||||||
|
|
||||||
|
### Bewusst vereinfacht
|
||||||
|
|
||||||
|
- **Bilder**: Alle Foto-Flächen sind hochwertige, markenkonforme Platzhalter (Farbverläufe) statt echter Produkt-/Naturfotografie. Ich kann keine echten Fotos erzeugen oder beschaffen — hierfür werden eure tatsächlichen Aufnahmen benötigt.
|
||||||
|
- Rechtstexte sind Platzhalter und **müssen** vor Live-Gang anwaltlich geprüft werden.
|
||||||
|
|
||||||
|
## Admin-Zugang
|
||||||
|
|
||||||
|
Es gibt **kein separates Admin-Login** und keine Basic-Auth mehr. Der `/admin`-Bereich wird über dasselbe Login-Formular wie das Kundenkonto (`/konto`) erreicht — ob ein Konto zusätzlich Admin-Rechte hat, entscheidet einzig das Feld `role` (`KUNDE` | `ADMIN`) am `Customer`-Datensatz:
|
||||||
|
|
||||||
|
- Beim Login/bei der Registrierung wird die Rolle des Kontos in die Session (JWT) geschrieben.
|
||||||
|
- `src/proxy.ts` prüft bei jedem Zugriff auf `/admin/*`, ob die Session die Rolle `ADMIN` trägt, und leitet sonst mit einer erklärenden Meldung zu `/konto` um.
|
||||||
|
- Jede Admin-Server-Action (`src/app/admin/actions.ts`) und das Admin-Layout prüfen die Rolle zusätzlich **direkt gegen die Datenbank** (`getCurrentAdmin()`) — der Proxy ist nur der schnelle Vorfilter, nicht die einzige Kontrollinstanz.
|
||||||
|
- Neue Konten über das öffentliche Registrierungsformular erhalten immer die Rolle `KUNDE`; es gibt keinen Weg, sich selbst zum Admin zu machen.
|
||||||
|
|
||||||
|
**Erstes Admin-Konto anlegen:** `ADMIN_EMAIL` (unvorhersehbar wählen, nicht „admin@...") und `ADMIN_PASSWORD` in `.env` setzen und `pnpm db:seed` (bzw. `docker compose exec app pnpm prisma db seed`) **vor** dem ersten öffentlichen Start ausführen — legt das Konto neu an, oder erneuert bei einem bereits bestehenden Admin-Konto mit dieser E-Mail-Adresse nur dessen Passwort. Existiert unter dieser E-Mail-Adresse bereits ein **Nicht**-Admin-Konto (z. B. weil sich jemand die Adresse vorregistriert hat), befördert der Seed-Lauf es bewusst **nicht** automatisch — stattdessen erscheint eine Warnung im Log, und die Beförderung muss manuell über die Datenbank erfolgen (siehe unten). So lässt sich ein Admin-Konto nicht durch Vorregistrieren der erwarteten E-Mail-Adresse kapern. Login danach ganz normal über `/konto`.
|
||||||
|
|
||||||
|
**Benutzerverwaltung:** Unter `/admin/benutzer` können Admins alle Konten einsehen, anlegen, bearbeiten (inkl. Rolle `KUNDE`/`ADMIN`), sperren/entsperren und löschen. Beim Anlegen wird **kein Passwort vom Admin vergeben** — das neue Konto erhält sofort eine E-Mail mit einem Link, über den die Person ihr eigenes Passwort festlegt (derselbe Mechanismus wie „Passwort vergessen"). Ein Passwort-Reset für bestehende Konten läuft genauso: der Admin-Button löst nur den Versand des Reset-Links aus, ein Admin bekommt nie ein Kunden-Passwort im Klartext zu Gesicht.
|
||||||
|
|
||||||
|
Eingebaute Selbstschutz-Regeln verhindern, dass sich ein Admin versehentlich aussperrt:
|
||||||
|
- Ein Admin kann seine eigene Rolle nicht ändern und sich selbst nicht sperren oder löschen.
|
||||||
|
- Der letzte verbleibende `ADMIN`-Account kann nicht herabgestuft, gesperrt oder gelöscht werden.
|
||||||
|
|
||||||
|
Alternativ weiterhin direkt über die Datenbank möglich:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE customers SET role = 'ADMIN' WHERE email = '[email protected]';
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
oder per Prisma Studio (`pnpm db:studio`) am Datensatz das Feld `role` ändern.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Voraussetzungen
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
- Node.js 20.9+ (getestet mit 24.x)
|
||||||
|
- pnpm (`corepack enable` oder `npm i -g pnpm`)
|
||||||
|
- Docker + Docker Compose (für PostgreSQL lokal und für den Produktions-Build)
|
||||||
|
|
||||||
## Learn More
|
## Lokale Entwicklung
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env anpassen (mindestens SESSION_SECRET, ADMIN_EMAIL/ADMIN_PASSWORD; Stripe-Keys optional für UI-Arbeit)
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
pnpm install
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
# Nur die Datenbank per Docker starten:
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
## Deploy on Vercel
|
pnpm db:migrate # erstellt Tabellen (Prisma Migrate)
|
||||||
|
pnpm db:seed # legt 2 Beispiel-Ernten/Chargen/Produkte + das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD) an
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
pnpm dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Admin-Bereich: `http://localhost:3000/admin` — Login mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` über das normale Formular unter `/konto` (siehe „Admin-Zugang" unten).
|
||||||
|
|
||||||
|
## Datenbank-Provider wechseln
|
||||||
|
|
||||||
|
Die App läuft wahlweise mit PostgreSQL (Standard) oder MySQL/MariaDB — steuerbar über zwei Variablen in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_PROVIDER=postgresql # oder: mysql
|
||||||
|
DATABASE_URL="postgresql://wabenhain:wabenhain@localhost:5432/wabenhain?schema=public"
|
||||||
|
# bei mysql stattdessen z.B.:
|
||||||
|
# DATABASE_URL="mysql://wabenhain:wabenhain@localhost:3306/wabenhain"
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach einer Änderung von `DATABASE_PROVIDER` (z.B. lokal):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nur die MySQL-Datenbank per Docker starten (statt "docker compose up -d db"):
|
||||||
|
docker compose --profile mysql up -d db-mysql
|
||||||
|
|
||||||
|
pnpm db:push # legt Tabellen beim gewaehlten Provider an (schreibt kurzzeitig die passende provider-Zeile in schema.prisma, siehe unten)
|
||||||
|
pnpm db:seed # Beispieldaten + Admin-Konto
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ein Build/Image reicht für beide Provider gleichzeitig**: `scripts/generate-prisma-clients.mjs` generiert bei jedem `dev`/`build`/`start`/`postinstall` automatisch BEIDE Prisma-Client-Varianten (Postgres und MySQL) — `src/lib/prisma.ts`/`prisma/seed.ts` wählen zur Laufzeit rein anhand von `DATABASE_PROVIDER`, welche davon tatsächlich verwendet wird (technisch nötig, weil ein einzelner generierter Prisma-Client fest an einen Provider gebunden ist). Ein bereits gebautes Docker-Image lässt sich also allein durch Ändern von `DATABASE_PROVIDER`/`DATABASE_URL` und einen Neustart umschalten, **ohne Neubau** — praktisch für Kubernetes, siehe „Kubernetes / Gitea CI" unten.
|
||||||
|
|
||||||
|
Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich gegen EINE konkrete Datenbank Schema-Änderungen anwenden) schreiben vorher kurzzeitig die passende `provider`-Zeile in `prisma/schema.prisma` (Prisma erlaubt dort kein `env()`, siehe `scripts/prisma-provider.mjs`) — das betrifft nur diese Befehle, nicht die beiden generierten Clients selbst.
|
||||||
|
|
||||||
|
**Technischer Hintergrund**: `Harvest.images`, `Product.tasteTags` und `Product.images` sind als `Json` statt `String[]` modelliert, weil MySQL keine nativen Skalar-Array-Spalten kennt (Prisma-Einschränkung). Zur Laufzeit werden diese Felder in `lib/data.ts`/`lib/json-array.ts` transparent zu normalen String-Arrays normalisiert — der Rest der App merkt vom Provider-Wechsel nichts.
|
||||||
|
|
||||||
|
## Stripe
|
||||||
|
|
||||||
|
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`).
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Zahlungsmethoden (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 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.
|
||||||
|
|
||||||
|
SEPA-Lastschrift und teils Klarna bestätigen die Zahlung **verzögert** (bei SEPA i. d. R. einige Werktage), nicht sofort wie eine Kartenzahlung. Der Webhook-Handler (`/api/checkout/webhook`) berücksichtigt das:
|
||||||
|
|
||||||
|
- `checkout.session.completed` markiert die Bestellung nur dann als bezahlt, wenn `payment_status` nicht `unpaid` ist (bei Kartenzahlung sofort der Fall).
|
||||||
|
- `checkout.session.async_payment_succeeded` markiert die Bestellung nachträglich als bezahlt, sobald die verzögerte Zahlung tatsächlich eingegangen ist (löst Bestätigungs-E-Mail und Rechnung aus).
|
||||||
|
- `checkout.session.async_payment_failed` storniert die Bestellung, bucht reservierten Bestand zurück und informiert die Kundschaft per E-Mail, falls der Zahlungseinzug fehlschlägt.
|
||||||
|
- `checkout.session.expired` behandelt eine von der Kundschaft einfach verlassene (nicht bezahlte) Checkout-Sitzung genauso wie einen fehlgeschlagenen Zahlungseinzug — storniert die Bestellung und bucht den beim Checkout reservierten Bestand zurück, statt ihn dauerhaft zu blockieren.
|
||||||
|
|
||||||
|
Für einen produktiven Webhook-Endpoint müssen alle vier Events abonniert werden; `stripe listen` leitet lokal ohnehin alle Events weiter.
|
||||||
|
|
||||||
|
## Versandkosten
|
||||||
|
|
||||||
|
Die Versandkosten richten sich **nicht** nach dem Bestellwert (kein Freibetrag), sondern nach einer im Admin-Bereich unter `/admin/versand` gepflegten Mengenstaffel — die Gesamtmenge aller Positionen im Warenkorb wird dabei zusammengezählt, mehrere verschiedene Produkte also gemeinsam bewertet statt einzeln berechnet. Ab Werk sind drei Staffeln angelegt (0–3 Stück: 7,00 €, 4–9 Stück: 8,50 €, 10–19 Stück: 11,00 €), die jederzeit im Admin-Bereich angepasst, gelöscht oder um weitere Stufen ergänzt werden können — praktisch, wenn sich die Kosten der Versanddienstleister ändern. Eine Staffel ohne "Bis"-Wert gilt als offene, oberste Stufe ("ab X Stück"). Sind gar keine Staffeln gepflegt, greift ein fester Sicherheits-Fallback von 4,90 €, statt den Checkout zu blockieren. Abholung in Altenmünster ist immer versandkostenfrei. Änderungen wirken sich sofort auf Warenkorb, Checkout und die öffentliche Seite `/versand-zahlung` aus.
|
||||||
|
|
||||||
|
## Google-Rezensionen
|
||||||
|
|
||||||
|
Die „Bewertungen"-Section auf der Startseite zeigt echte Google-Rezensionen des Google-Maps-Eintrags, sobald zwei Umgebungsvariablen gesetzt sind — ohne sie greift automatisch der statische Beispieltext-Fallback, die Seite bleibt also auch ohne Setup voll funktionsfähig:
|
||||||
|
|
||||||
|
1. In der [Google Cloud Console](https://console.cloud.google.com/) ein Projekt anlegen (oder ein bestehendes verwenden) und darin die **„Places API"** aktivieren.
|
||||||
|
2. Einen API-Key unter „APIs & Dienste" → „Anmeldedaten" erstellen und als `GOOGLE_PLACES_API_KEY` in `.env` eintragen. Empfehlenswert: den Key auf die Places API einschränken.
|
||||||
|
3. Die Place-ID des Wabenhain-Google-Maps-Eintrags über den [Place ID Finder](https://developers.google.com/maps/documentation/places/web-service/place-id) ermitteln und als `GOOGLE_PLACE_ID` eintragen.
|
||||||
|
|
||||||
|
Die Rezensionen werden serverseitig eine Stunde gecacht (`src/lib/google-reviews.ts`), um API-Kosten gering zu halten.
|
||||||
|
|
||||||
|
## E-Mail-Versand
|
||||||
|
|
||||||
|
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (`src/lib/mail.ts`, `nodemailer`). Ohne `SMTP_HOST`/`SMTP_USER`/`SMTP_PASSWORD` in `.env` wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren.
|
||||||
|
|
||||||
|
Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam.
|
||||||
|
|
||||||
|
## CAPTCHA (Cloudflare Turnstile)
|
||||||
|
|
||||||
|
Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar:
|
||||||
|
|
||||||
|
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
|
||||||
|
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
|
||||||
|
|
||||||
|
Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudflare-Siteverify-API; das Widget selbst ist `src/components/turnstile-widget.tsx`.
|
||||||
|
|
||||||
|
## Vollständiger Docker-Betrieb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# .env ausfuellen
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec app pnpm db:deploy
|
||||||
|
docker compose exec app pnpm db:seed # Beispieldaten + legt/befördert das Admin-Konto (ADMIN_EMAIL/ADMIN_PASSWORD)
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`).
|
||||||
|
|
||||||
|
## Kubernetes / Gitea CI
|
||||||
|
|
||||||
|
`.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch zwei Docker-Image-Tags (schlankes Laufzeit-Image + ein `:migrate-*`-Tag mit Prisma-CLI für Migrationen) und veröffentlicht sie in der Gitea-eigenen Container-Registry. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, Migrations-Job, App-Deployment, Ingress) für den Betrieb dieser Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md).
|
||||||
|
|
||||||
|
Ein einzelnes Image unterstützt beide Datenbank-Provider gleichzeitig (siehe „Datenbank-Provider wechseln" oben) — welcher tatsächlich läuft, entscheidet allein `DATABASE_PROVIDER`/`DATABASE_URL` zur Laufzeit, kein separater Image-Build nötig.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **Sessions als JWT (HS256, `jose`)**: httpOnly-Cookie, `SameSite=Lax`, `Secure` in Produktion, trägt Kunden-ID, Rolle und `sessionVersion`. Gültigkeit 30 Tage für Kundenkonten, **7 Tage für Admin-Konten** (kürzeres Zeitfenster für ein gestohlenes/vergessenes Cookie, da Admin-Zugriff Rückerstattungen und Kundendaten einschließt). `SESSION_SECRET` muss in Produktion mindestens 32 Zeichen lang sein (wird beim Start erzwungen).
|
||||||
|
- **Session-Widerruf**: `Customer.sessionVersion` wird bei Passwort-Reset, 2FA-Ein-/Ausschalten und "Alle Geräte abmelden" hochgezählt; `getCurrentCustomer()` vergleicht diesen Wert gegen das JWT-Claim und behandelt eine Abweichung als abgelaufen — ein technisch noch gültiges Token verliert so sofort seine Wirkung, ohne eine serverseitige Session-Tabelle für jeden Request führen zu müssen.
|
||||||
|
- **Zwei-Faktor-Authentifizierung (TOTP)**: optional pro Konto unter `/konto/zwei-faktor`, kompatibel mit gängigen Authenticator-Apps (RFC 6238, `otpauth`). Bei Aktivierung werden 8 Einmal-Backup-Codes generiert (nur als SHA-256-Hash gespeichert, Klartext einmalig direkt nach der Einrichtung angezeigt). Login mit aktivem 2FA läuft zweistufig: Passwort korrekt → kurzlebiger Zwischenschritt-Cookie (5 Minuten, eigener Token-Typ, niemals mit einer vollständigen Session verwechselbar) → Code- oder Backup-Code-Eingabe auf `/konto/zwei-faktor-code`.
|
||||||
|
- **Passwörter**: bcrypt mit Cost-Faktor 12; Limit 72 Zeichen (bcrypt-Grenze) wird validiert statt still abgeschnitten.
|
||||||
|
- **Login-Härtung**: Vergleich gegen Dummy-Hash bei unbekannter E-Mail (kein User-Enumeration über Antwortzeiten); E-Mails werden normalisiert (trim/lowercase); Konto-Sperre für 15 Minuten nach 5 aufeinanderfolgenden Fehlversuchen (`Customer.failedLoginAttempts`/`lockedUntil`, überlebt im Gegensatz zum IP-Rate-Limit einen Server-Neustart).
|
||||||
|
- **Admin-Sperre** (`Customer.blockedAt`, unter `/admin/benutzer`): getrennt von der automatischen Fehlversuchs-Sperre — ein Admin kann ein Konto gezielt und dauerhaft sperren. Der Login wird dabei noch vor der Passwortprüfung mit einer eigenen Meldung abgelehnt, `getCurrentCustomer()` beendet zusätzlich jede bereits bestehende Session sofort (nicht erst beim natürlichen Cookie-Ablauf), und ein Passwort-Reset kann die Sperre nicht mehr eigenständig umgehen.
|
||||||
|
- **Rate-Limiting** (In-Memory): Login 10/15 min pro IP **und** 8/15 min pro E-Mail-Adresse (schützt auch gegen verteilte Versuche über viele IPs auf ein einzelnes Konto), Registrierung 5/h, Passwort-Reset-Anfrage 5/h, 2FA-Code-Prüfung 8/10 min, Newsletter & Firmenanfragen 5/h, Checkout 10/h. Die Client-IP wird aus dem **letzten** `X-Forwarded-For`-Eintrag gelesen (vom eigenen Reverse-Proxy angehängt), nicht dem ersten — der wäre vom Client frei wählbar und würde jedes Rate-Limit hier aushebeln. Bei horizontaler Skalierung auf einen gemeinsamen Store (z.B. Redis) umstellen.
|
||||||
|
- **CAPTCHA** (Cloudflare Turnstile, optional): siehe Abschnitt „CAPTCHA" oben — zusätzlicher Bot-Schutz auf Registrierung, Newsletter und Firmenanfrage.
|
||||||
|
- **E-Mail-Verifizierung**: neu registrierte Konten erhalten einen Bestätigungslink (`EmailVerificationToken`, SHA-256-Hash wie beim Passwort-Reset, 24 Stunden gültig) und müssen ihre E-Mail-Adresse bestätigen, bevor sie mit ihrem Konto bestellen können (`/api/checkout` prüft `Customer.emailVerifiedAt`) — Gastbestellungen sind davon nicht betroffen. Ein erfolgreich über einen zugesandten Link gesetztes Passwort (Reset oder admin-angelegtes Konto) gilt ebenfalls als Bestätigung, ohne eine weitere E-Mail zu benötigen.
|
||||||
|
- **Admin-Audit-Log** (`/admin/audit-log`, `AuditLog`-Modell): protokolliert jede sicherheitsrelevante Admin-Mutation (Produkte, Bestellstatus, Gutscheine, Versandkosten, Benutzerrollen, Bewertungsmoderation, …) mit Zeitstempel, Admin-E-Mail (als Snapshot, bleibt auch nach Kontolöschung lesbar), Aktion und Details.
|
||||||
|
- **Abhängigkeits-Sicherheitsscan**: `pnpm audit` vor jedem Release ausführen, um bekannte Schwachstellen in Paketen frühzeitig zu erkennen (siehe „Nützliche Befehle" unten). Aktuell 0 bekannte Schwachstellen (zuletzt geprüft: eine transitive `nanoid`-DoS-Lücke über `pnpm-workspace.yaml`s `overrides` gepatcht).
|
||||||
|
- **Security-Header**: CSP (keine externen Skript-Hosts außer dem optionalen Turnstile-Widget), `X-Frame-Options: DENY`, `nosniff`, Referrer-Policy, Permissions-Policy, HSTS.
|
||||||
|
- **Formularfelder**: korrekte `autocomplete`-Attribute (`email`, `current-password`, `new-password`, `one-time-code`) auf allen Auth-Formularen für Passwort-Manager und Autofill.
|
||||||
|
- **TOTP-Replay-Schutz** (`Customer.lastTotpStep`): ein beim Login akzeptierter 2FA-Code kann nicht ein zweites Mal eingelöst werden, selbst innerhalb seines eigentlichen 30-Sekunden-Gültigkeitsfensters.
|
||||||
|
- **Passwort-Reset respektiert 2FA**: ein erfolgreicher Reset loggt bei aktivem 2FA nicht direkt ein, sondern verlangt wie beim normalen Login den zweiten Faktor — sonst würde ein Reset-Link den zweiten Faktor genau in dem Szenario aushebeln, für das er gedacht ist.
|
||||||
|
- **Registrierung ohne E-Mail-Enumeration**: eine bereits vergebene E-Mail-Adresse erzeugt dieselbe generische Antwort (Text und Zeitaufwand) wie eine erfolgreiche Neuanmeldung, statt "Konto existiert bereits" zu bestätigen.
|
||||||
|
- **Gutschein-Einlösung race-sicher**: bei einem Nutzungslimit zählt die Prüfung beim Checkout zusätzlich zu `usedCount` auch bereits laufende, aber noch unbezahlte Bestellungen mit demselben Code — verhindert, dass mehrere parallele Checkout-Versuche einen `maxUses:1`-Code alle gleichzeitig als gültig durchgehen lassen.
|
||||||
|
- **Rate-Limit-IP vertrauenswürdig ermittelt**: die Client-IP für alle Limits (Login, 2FA, Checkout, Registrierung, Passwort-Reset) kommt aus dem letzten, vom eigenen Reverse-Proxy gesetzten `X-Forwarded-For`-Eintrag, nicht dem ersten (vom Client frei wählbaren).
|
||||||
|
- **Admin-Sperre invalidiert aktive Sessions sofort** statt erst beim natürlichen Cookie-Ablauf, und ein Passwort-Reset kann eine Sperre nicht mehr eigenständig umgehen.
|
||||||
|
- **Zahlungsbestätigung transaktional und idempotent**: der Webhook-Handler markiert eine Bestellung nur aus dem Status "NEU" heraus als bezahlt (verhindert doppelte Gutschein-Zählung bei wiederholter Webhook-Zustellung und verhindert, dass eine bereits stornierte Bestellung durch eine verspätete Zahlungsbestätigung wiederbelebt wird) und behandelt eine abgelaufene, nie bezahlte Checkout-Sitzung (`checkout.session.expired`) wie einen fehlgeschlagenen Zahlungseinzug.
|
||||||
|
- **Admin-Zugriff**: kein separates Passwort/Basic-Auth mehr — die Rolle des eingeloggten Kontos entscheidet (siehe „Admin-Zugang" oben). Zweistufig geprüft: Proxy auf Request-Ebene (JWT-Claim) und jede Admin-Server-Action zusätzlich autoritativ gegen die Datenbank.
|
||||||
|
- **Checkout**: Lagerbestand wird transaktional geprüft und reserviert (kein Überverkauf bei parallelen Bestellungen); Stornierung im Admin bucht den Bestand zurück. Bestell-/Rechnungsnummern kommen aus `crypto.randomBytes`.
|
||||||
|
- **Stripe-Webhook**: Signaturprüfung über `STRIPE_WEBHOOK_SECRET`.
|
||||||
|
- Server Actions prüfen Besitzrechte (Adressen/Bestellungen/Rechnungen nur für das eigene Konto); Next.js validiert bei Actions zusätzlich den Origin-Header (CSRF).
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
Siehe [`prisma/schema.prisma`](prisma/schema.prisma) für das vollständige Schema:
|
||||||
|
`Customer`, `Address`, `PasswordResetToken`, `EmailVerificationToken`, `TwoFactorBackupCode`, `Harvest`, `Batch`, `Product`, `Inventory`, `Review`, `WishlistItem`, `StockNotification`, `Order`, `OrderItem`, `Payment`, `Shipment`, `Pickup`, `Invoice`, `ReturnRequest`, `ShippingTier`, `DiscountCode`, `AuditLog`, `NewsletterSubscriber`, `BusinessInquiry`.
|
||||||
|
|
||||||
|
Ein `Product` ist optional einer `Harvest` und einer `Batch` zugeordnet (Pflicht bei Honig, leer bei Kerzen/Propolis); jede `OrderItem` speichert die `Batch`, aus der das jeweilige Glas stammt, sodass sich jede Honig-Bestellung bis zur Charge zurückverfolgen lässt. `Customer.role` (`KUNDE` | `ADMIN`) steuert den Zugriff auf `/admin`, `Customer.blockedAt` die admin-gesperrten Konten, siehe „Admin-Zugang" oben.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Unit-/Integrationstests** (`src/**/*.test.ts`, [Vitest](https://vitest.dev/)): decken die geldrelevante und sicherheitsrelevante Kernlogik ab, die absichtlich als reine Funktionen ohne Datenbankzugriff ausgelagert ist — `lib/shipping-calc.ts`, `lib/discount-calc.ts`, `lib/format.ts` sowie die Zod-Schemas in `lib/validation.ts`. `pnpm test` ausführen.
|
||||||
|
- **E2E-Tests** (`e2e/*.spec.ts`, [Playwright](https://playwright.dev/)): Smoke-Tests für die kritischsten Nutzerpfade (Startseite, Shop-Übersicht, Warenkorb inkl. Mengenänderung/Entfernen, Checkout-Preisanzeige inkl. Versand/Abholung-Umschaltung, Login-/Registrierungsvalidierung) gegen eine laufende Instanz. `pnpm test:e2e` ausführen — startet bei Bedarf automatisch `pnpm dev`, nutzt aber einen bereits laufenden Dev-Server, falls vorhanden.
|
||||||
|
|
||||||
|
Dies ist eine bewusst schlanke Startabdeckung der riskantesten Logik (Geldberechnung, Auth-Validierung, Kern-Flows), kein vollständiger Coverage-Anspruch — die Infrastruktur ist eingerichtet, weitere Tests lassen sich unkompliziert ergänzen.
|
||||||
|
|
||||||
|
Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bug ans Licht: der Cart-Store persistierte versehentlich auch den rein transienten „ist das Drawer offen"-Zustand in `localStorage` (`persist()` ohne `partialize`). Dadurch konnte sich das Cart-Drawer nach einem Seiten-Reload ungewollt wieder öffnen und die Seite dahinter blockieren, wenn es beim letzten Verlassen offen war. Behoben in `src/store/cart-store.ts`, indem nur noch `items` persistiert wird.
|
||||||
|
|
||||||
|
## Nützliche Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Entwicklungsserver
|
||||||
|
pnpm build # Produktions-Build
|
||||||
|
pnpm lint # ESLint
|
||||||
|
pnpm db:studio # Prisma Studio (DB-GUI)
|
||||||
|
pnpm db:migrate # neue Migration erstellen/anwenden (Dev)
|
||||||
|
pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("login with wrong credentials shows an error message", async ({ page }) => {
|
||||||
|
await page.goto("/konto");
|
||||||
|
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).first().fill("[email protected]");
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).first().fill("falschespasswort");
|
||||||
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/E-Mail-Adresse oder Passwort ist falsch/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registration rejects a password shorter than 8 characters", async ({ page }) => {
|
||||||
|
await page.goto("/konto");
|
||||||
|
|
||||||
|
await page.getByLabel("Vorname").fill("Test");
|
||||||
|
await page.getByLabel("Nachname").fill("Person");
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).nth(1).fill(`e2e-${Date.now()}@example.de`);
|
||||||
|
await page.getByLabel(/Passwort \(mind\. 8 Zeichen\)/).fill("kurz");
|
||||||
|
await page.getByRole("button", { name: "Konto erstellen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/mindestens 8 Zeichen/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registering, logging out, and logging back in reaches the logged-in account page", async ({ page }) => {
|
||||||
|
const email = `e2e-login-${Date.now()}@example.de`;
|
||||||
|
// Zufaellig statt eines festen Werts wie "Passwort123!" — der HIBP-
|
||||||
|
// Breach-Check (isPasswordBreached) lehnt bekannte Leak-Passwoerter ab,
|
||||||
|
// ein per Zeitstempel eindeutiges Passwort landet nicht in solchen Listen.
|
||||||
|
const password = `Xk9-qLp2-Zt${Date.now()}`;
|
||||||
|
|
||||||
|
await page.goto("/konto");
|
||||||
|
await page.getByLabel("Vorname").fill("Test");
|
||||||
|
await page.getByLabel("Nachname").fill("Person");
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).nth(1).fill(email);
|
||||||
|
await page.getByLabel(/Passwort \(mind\. 8 Zeichen\)/).fill(password);
|
||||||
|
await page.getByLabel("Passwort bestätigen").fill(password);
|
||||||
|
await page.getByRole("button", { name: "Konto erstellen" }).click();
|
||||||
|
|
||||||
|
// Registrierung meldet automatisch an — erst abmelden, um den separaten
|
||||||
|
// Login-Pfad (loginAction statt der impliziten Session bei der
|
||||||
|
// Registrierung) tatsaechlich zu pruefen. Zwei Buttons heissen "Abmelden"
|
||||||
|
// (auch "alle Geraete abmelden") — .last() ist der einfache Logout-Button,
|
||||||
|
// der tatsaechlich zur ausgeloggten Ansicht zurueckfuehrt.
|
||||||
|
await expect(page.getByRole("button", { name: "Abmelden" }).first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Abmelden" }).last().click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Anmelden oder Konto erstellen" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("E-Mail", { exact: true }).first().fill(email);
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).first().fill(password);
|
||||||
|
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Abmelden" }).first()).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Anmelden oder Konto erstellen" })).not.toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("cart page: increasing quantity updates the line total", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/warenkorb");
|
||||||
|
|
||||||
|
const lineTotalBefore = await page.getByText(/^\d+,\d{2}\s€$/).first().textContent();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Menge erhöhen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/^\d+,\d{2}\s€$/).first()).not.toHaveText(lineTotalBefore ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cart page: removing the only item shows the empty-cart state", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/warenkorb");
|
||||||
|
await expect(page.getByText("Zwischensumme")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Entfernen" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Dein Warenkorb ist noch leer.")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("checkout shows live shipping cost for Versand and switches to free for Abholung", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/checkout");
|
||||||
|
|
||||||
|
await expect(page.getByText("Zwischensumme")).toBeVisible();
|
||||||
|
await expect(page.getByText("Gesamt")).toBeVisible();
|
||||||
|
|
||||||
|
// Versand ist per Default aktiv und zeigt eine Versandstaffel-Kosten
|
||||||
|
// (nicht "kostenlos"), solange der Warenkorb unter der Freigrenze liegt.
|
||||||
|
const summary = page.locator("aside");
|
||||||
|
await expect(summary.getByText(/Versand/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Abholung in Altenmünster" }).click();
|
||||||
|
|
||||||
|
await expect(summary.getByText("kostenlos")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkout requires street address only when Versand is selected", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await page.goto("/checkout");
|
||||||
|
|
||||||
|
await expect(page.getByLabel("Straße und Hausnummer")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Abholung in Altenmünster" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel("Straße und Hausnummer")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("homepage shows the hero heading and primary navigation", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: /Honig aus den Westlichen Wäldern/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole("navigation").getByRole("link", { name: "Shop", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("homepage newsletter form is present and accepts an email", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const emailField = page.getByPlaceholder("[email protected]");
|
||||||
|
await expect(emailField).toBeVisible();
|
||||||
|
await emailField.fill("[email protected]");
|
||||||
|
await expect(emailField).toHaveValue("[email protected]");
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("shop overview lists products", async ({ page }) => {
|
||||||
|
await page.goto("/shop");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Unser Sortiment" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: /Zum Honig|Zum Produkt/i }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adding a product to the cart updates the cart count", async ({ page }) => {
|
||||||
|
await page.goto("/honig/fruehtracht-2026");
|
||||||
|
|
||||||
|
// "In den Warenkorb" erscheint mehrfach auf der Seite (Haupt-CTA oben,
|
||||||
|
// weitere in der "Das koennte dir auch gefallen"-Produktkarten-Sektion
|
||||||
|
// weiter unten) — .first() greift gezielt den Haupt-CTA des Produkts.
|
||||||
|
const cartButton = page.getByRole("button", { name: /Warenkorb öffnen/ });
|
||||||
|
const initialLabel = await cartButton.getAttribute("aria-label");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "In den Warenkorb" }).first().click();
|
||||||
|
|
||||||
|
await expect(cartButton).not.toHaveAttribute("aria-label", initialLabel ?? "");
|
||||||
|
});
|
||||||
+43
-1
@@ -1,7 +1,49 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// Verhindert den "X-Powered-By: Next.js"-Response-Header — verrimt keinen
|
||||||
|
// funktionalen Vorteil, gibt Angreifern aber unnoetig Framework-Fingerprinting.
|
||||||
|
poweredByHeader: false,
|
||||||
|
|
||||||
|
// "standalone" buendelt eine minimale Node-Laufzeit inkl. Dependencies,
|
||||||
|
// wodurch das Docker-Image (siehe Dockerfile) deutlich kleiner bleibt.
|
||||||
|
output: "standalone",
|
||||||
|
|
||||||
|
// pdfkit laedt seine Schriftart-Metadaten (.afm-Dateien) zur Laufzeit ueber
|
||||||
|
// einen eigenen __dirname-relativen Pfad. Wird das Paket vom Bundler
|
||||||
|
// (Turbopack/Webpack) mitgebuendelt, geht dieser Pfad verloren (ENOENT).
|
||||||
|
// "serverExternalPackages" laesst Next.js es stattdessen ganz normal per
|
||||||
|
// require() aus node_modules laden.
|
||||||
|
serverExternalPackages: ["pdfkit"],
|
||||||
|
|
||||||
|
// Turbopacks Dateisystem-Caches (.next/dev/cache & .next/cache/turbopack)
|
||||||
|
// kollidieren in diesem OneDrive-synchronisierten Arbeitsverzeichnis
|
||||||
|
// wiederholt mit der Cloud-Synchronisierung ("Failed to open database" /
|
||||||
|
// mmap-Timeout bei next dev, "Failed to compact database" beim
|
||||||
|
// next-build-Shutdown). Beide Caches werden daher deaktiviert.
|
||||||
|
experimental: {
|
||||||
|
turbopackFileSystemCacheForDev: false,
|
||||||
|
turbopackFileSystemCacheForBuild: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
async headers() {
|
||||||
|
// Content-Security-Policy wird bewusst NICHT hier gesetzt: sie braucht
|
||||||
|
// eine pro Request neue Nonce (siehe proxy.ts), was ein statischer
|
||||||
|
// headers()-Eintrag hier nicht kann.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(self)" },
|
||||||
|
// Wirkt nur ueber HTTPS; im lokalen HTTP-Betrieb ignoriert der Browser den Header.
|
||||||
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+44
-7
@@ -3,25 +3,62 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "node scripts/generate-prisma-clients.mjs && next dev",
|
||||||
"build": "next build",
|
"build": "node scripts/generate-prisma-clients.mjs && next build",
|
||||||
"start": "next start",
|
"start": "node scripts/generate-prisma-clients.mjs && next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"postinstall": "node scripts/generate-prisma-clients.mjs",
|
||||||
|
"db:push": "node scripts/prisma-provider.mjs && prisma db push",
|
||||||
|
"db:migrate": "node scripts/prisma-provider.mjs && prisma migrate dev",
|
||||||
|
"db:deploy": "node scripts/prisma-provider.mjs && prisma migrate deploy",
|
||||||
|
"db:seed": "prisma db seed",
|
||||||
|
"db:studio": "node scripts/prisma-provider.mjs && prisma studio",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
|
"@prisma/client": "^7.9.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.8",
|
||||||
|
"mariadb": "^3.5.3",
|
||||||
|
"motion": "^13.0.0",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
|
"nodemailer": "^9.0.5",
|
||||||
|
"otpauth": "^9.5.1",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
|
"prisma": "^7.9.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"stripe": "^22.4.0",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.4",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.3.0",
|
"eslint-config-next": "16.3.0",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"tsx": "^4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]"
|
"packageManager": "[email protected]"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
// Startet den Dev-Server automatisch, falls er nicht schon laeuft (z.B. lokal
|
||||||
|
// waehrend der Entwicklung) — CI-Umgebungen ohne laufenden Server profitieren
|
||||||
|
// davon am meisten.
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3188
-12
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -1,3 +1,11 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
sharp: false
|
'@prisma/engines': true
|
||||||
|
esbuild: true
|
||||||
|
prisma: true
|
||||||
|
sharp: true
|
||||||
unrs-resolver: false
|
unrs-resolver: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
# GHSA-2v37-7h3g-55p8: DoS bei size=0 in aelteren nanoid-Versionen, ueber
|
||||||
|
# postcss/vite transitiv eingezogen (next, @tailwindcss/postcss, vitest).
|
||||||
|
nanoid: '>=3.3.18'
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
seed: "tsx prisma/seed.ts",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStatus" AS ENUM ('NEU', 'BEZAHLT', 'IN_VORBEREITUNG', 'VERSANDBEREIT', 'VERSENDET', 'ABHOLBEREIT', 'ABGEHOLT', 'STORNIERT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "FulfillmentMethod" AS ENUM ('VERSAND', 'ABHOLUNG');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED', 'REFUNDED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BusinessPackage" AS ENUM ('KLASSIK', 'GESCHENK', 'INDIVIDUELL');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "InquiryStatus" AS ENUM ('NEU', 'IN_BEARBEITUNG', 'ABGESCHLOSSEN');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "customers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "addresses" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL DEFAULT 'Zuhause',
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"street" TEXT NOT NULL,
|
||||||
|
"zip" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL DEFAULT 'DE',
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "harvests" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"harvestDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"location" TEXT NOT NULL DEFAULT 'Altenmuenster',
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"images" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "harvests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "batches" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"harvestId" TEXT NOT NULL,
|
||||||
|
"fillDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"bestBeforeDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"glassCount" INTEGER NOT NULL,
|
||||||
|
"remainingGlasses" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "batches_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "products" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"harvestId" TEXT NOT NULL,
|
||||||
|
"batchId" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"tasteTags" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"intensityMild" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"intensityFloral" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"intensityKraeftig" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"consistency" TEXT NOT NULL DEFAULT 'feincremig',
|
||||||
|
"weightGrams" INTEGER NOT NULL,
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"pricePerKgCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"images" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "inventory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "inventory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "orders" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderNumber" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT,
|
||||||
|
"status" "OrderStatus" NOT NULL DEFAULT 'NEU',
|
||||||
|
"fulfillmentMethod" "FulfillmentMethod" NOT NULL DEFAULT 'VERSAND',
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"street" TEXT NOT NULL,
|
||||||
|
"zip" TEXT NOT NULL,
|
||||||
|
"city" TEXT NOT NULL,
|
||||||
|
"country" TEXT NOT NULL DEFAULT 'DE',
|
||||||
|
"subtotalCents" INTEGER NOT NULL,
|
||||||
|
"shippingCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"stripeCheckoutSessionId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "order_items" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"batchId" TEXT NOT NULL,
|
||||||
|
"productName" TEXT NOT NULL,
|
||||||
|
"unitPriceCents" INTEGER NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"weightGrams" INTEGER NOT NULL,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "order_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "payments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL DEFAULT 'stripe',
|
||||||
|
"stripePaymentIntentId" TEXT,
|
||||||
|
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"paidAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "shipments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"carrier" TEXT,
|
||||||
|
"trackingNumber" TEXT,
|
||||||
|
"shippedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "shipments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pickups" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"location" TEXT NOT NULL DEFAULT 'Altenmuenster',
|
||||||
|
"readyAt" TIMESTAMP(3),
|
||||||
|
"pickedUpAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pickups_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "invoices" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"invoiceNumber" TEXT NOT NULL,
|
||||||
|
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"totalCents" INTEGER NOT NULL,
|
||||||
|
"pdfUrl" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "invoices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "newsletter_subscribers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT,
|
||||||
|
"confirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"subscribedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"unsubscribedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "newsletter_subscribers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "business_inquiries" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"contactPerson" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"desiredQuantity" INTEGER NOT NULL,
|
||||||
|
"desiredDeliveryDate" TIMESTAMP(3),
|
||||||
|
"package" "BusinessPackage" NOT NULL DEFAULT 'KLASSIK',
|
||||||
|
"message" TEXT,
|
||||||
|
"status" "InquiryStatus" NOT NULL DEFAULT 'NEU',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "business_inquiries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "customers_email_key" ON "customers"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "harvests_slug_key" ON "harvests"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "harvests_year_type_key" ON "harvests"("year", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "batches_code_key" ON "batches"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "products_slug_key" ON "products"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "inventory_productId_key" ON "inventory"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "orders_orderNumber_key" ON "orders"("orderNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "payments_orderId_key" ON "payments"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "shipments_orderId_key" ON "shipments"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pickups_orderId_key" ON "pickups"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "invoices_orderId_key" ON "invoices"("orderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "invoices_invoiceNumber_key" ON "invoices"("invoiceNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "newsletter_subscribers_email_key" ON "newsletter_subscribers"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "newsletter_subscribers_customerId_key" ON "newsletter_subscribers"("customerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "batches" ADD CONSTRAINT "batches_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "products" ADD CONSTRAINT "products_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "harvests"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "products" ADD CONSTRAINT "products_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory" ADD CONSTRAINT "inventory_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "payments" ADD CONSTRAINT "payments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "shipments" ADD CONSTRAINT "shipments_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pickups" ADD CONSTRAINT "pickups_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "newsletter_subscribers" ADD CONSTRAINT "newsletter_subscribers_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProductCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "order_items" ALTER COLUMN "batchId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "products" ADD COLUMN "category" "ProductCategory" NOT NULL DEFAULT 'HONIG',
|
||||||
|
ALTER COLUMN "harvestId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "batchId" DROP NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CustomerRole" AS ENUM ('KUNDE', 'ADMIN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "role" "CustomerRole" NOT NULL DEFAULT 'KUNDE';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmationToken" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "password_reset_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "newsletter_subscribers_confirmationToken_key" ON "newsletter_subscribers"("confirmationToken");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "lockedUntil" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "twoFactorSecret" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "two_factor_backup_codes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "two_factor_backup_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "two_factor_backup_codes_codeHash_key" ON "two_factor_backup_codes"("codeHash");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "two_factor_backup_codes" ADD CONSTRAINT "two_factor_backup_codes_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "blockedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "shipping_tiers" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"minQuantity" INTEGER NOT NULL,
|
||||||
|
"maxQuantity" INTEGER,
|
||||||
|
"priceCents" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "shipping_tiers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DiscountType" AS ENUM ('PERCENT', 'FIXED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "discountCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "discountCodeId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "reviews" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"rating" INTEGER NOT NULL,
|
||||||
|
"comment" TEXT,
|
||||||
|
"isApproved" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "reviews_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "discount_codes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"type" "DiscountType" NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"minOrderCents" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"maxUses" INTEGER,
|
||||||
|
"usedCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"validFrom" TIMESTAMP(3),
|
||||||
|
"validUntil" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "discount_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "reviews_productId_customerId_key" ON "reviews"("productId", "customerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "discount_codes_code_key" ON "discount_codes"("code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "discount_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "customers" ADD COLUMN "emailVerifiedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "email_verification_tokens" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"tokenHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"usedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "wishlist_items" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "wishlist_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"adminId" TEXT,
|
||||||
|
"adminEmail" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"entityType" TEXT NOT NULL,
|
||||||
|
"entityId" TEXT,
|
||||||
|
"detail" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "wishlist_items_customerId_productId_key" ON "wishlist_items"("customerId", "productId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "wishlist_items" ADD CONSTRAINT "wishlist_items_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Bestandskonten, die vor Einfuehrung der E-Mail-Verifizierung registriert
|
||||||
|
-- wurden, gelten rueckwirkend als bestaetigt (createdAt als Zeitstempel) --
|
||||||
|
-- sonst waeren sie ploetzlich vom Checkout ausgesperrt, obwohl sie sich nie
|
||||||
|
-- verifizieren mussten.
|
||||||
|
UPDATE "customers" SET "emailVerifiedAt" = "createdAt" WHERE "emailVerifiedAt" IS NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "stock_notifications" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"notifiedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "stock_notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "stock_notifications_productId_email_key" ON "stock_notifications"("productId", "email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "stock_notifications" ADD CONSTRAINT "stock_notifications_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "InquiryStatus" ADD VALUE 'ABGELEHNT';
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ReturnStatus" AS ENUM ('ANGEFRAGT', 'ANGENOMMEN', 'ABGELEHNT', 'ERSTATTET');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "return_requests" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"orderId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"status" "ReturnStatus" NOT NULL DEFAULT 'ANGEFRAGT',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "return_requests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "return_requests_orderId_key" ON "return_requests"("orderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "return_requests" ADD CONSTRAINT "return_requests_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
// Wabenhain — Datenmodell
|
||||||
|
// Alle Geldbetraege werden als Cent-Integer gespeichert, um Rundungsfehler zu vermeiden.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
// "provider" wird NICHT hier per Hand gepflegt: Prisma erlaubt kein env() an
|
||||||
|
// dieser Stelle, daher schreibt scripts/prisma-provider.mjs diese Zeile vor
|
||||||
|
// jedem generate/db push/migrate anhand von DATABASE_PROVIDER ("postgresql"
|
||||||
|
// [Standard] oder "mysql") neu. Siehe README, Abschnitt "Datenbank-Provider
|
||||||
|
// wechseln".
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kunden
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum CustomerRole {
|
||||||
|
KUNDE
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model Customer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
phone String?
|
||||||
|
role CustomerRole @default(KUNDE)
|
||||||
|
|
||||||
|
/// Wird bei sicherheitsrelevanten Ereignissen (Passwort-Reset, "alle
|
||||||
|
/// Geraete abmelden", 2FA an/aus) hochgezaehlt. Im JWT eingebettet — bei
|
||||||
|
/// Abweichung von diesem Wert gilt das Token als widerrufen, obwohl es
|
||||||
|
/// technisch noch nicht abgelaufen ist.
|
||||||
|
sessionVersion Int @default(0)
|
||||||
|
/// Fuer die Konto-Sperre nach wiederholten Fehlversuchen (zusaetzlich zum
|
||||||
|
/// IP-basierten Rate-Limit — persistent und ueberlebt einen Neustart/
|
||||||
|
/// mehrere Server-Instanzen, anders als der In-Memory-Rate-Limiter).
|
||||||
|
failedLoginAttempts Int @default(0)
|
||||||
|
lockedUntil DateTime?
|
||||||
|
|
||||||
|
/// Base32-TOTP-Secret, erst gesetzt wenn 2FA aktiv bestaetigt wurde.
|
||||||
|
twoFactorSecret String?
|
||||||
|
twoFactorEnabled Boolean @default(false)
|
||||||
|
/// Zeitschritt (Unix-Zeit / 30s) des zuletzt beim Login akzeptierten
|
||||||
|
/// TOTP-Codes — verhindert, dass ein abgefangener/mitgelesener Code im
|
||||||
|
/// selben oder einem frueheren Zeitfenster ein zweites Mal funktioniert.
|
||||||
|
lastTotpStep Int?
|
||||||
|
|
||||||
|
/// Von einem Admin gesperrtes Konto (Benutzerverwaltung) — unabhaengig von
|
||||||
|
/// der automatischen Fehlversuchs-Sperre in lockedUntil. Verhindert den
|
||||||
|
/// Login bis ein Admin das Konto wieder entsperrt.
|
||||||
|
blockedAt DateTime?
|
||||||
|
|
||||||
|
/// Gesetzt, sobald die E-Mail-Adresse per Bestaetigungslink oder durch
|
||||||
|
/// erfolgreiches Setzen eines Passworts ueber einen zugesandten Reset-Link
|
||||||
|
/// nachweislich unter der Kontrolle des Kontoinhabers ist. Registrierte
|
||||||
|
/// Kund:innen benoetigen dies fuer den Checkout (siehe /api/checkout).
|
||||||
|
emailVerifiedAt DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
orders Order[]
|
||||||
|
addresses Address[]
|
||||||
|
newsletterSubscriber NewsletterSubscriber?
|
||||||
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
emailVerificationTokens EmailVerificationToken[]
|
||||||
|
twoFactorBackupCodes TwoFactorBackupCode[]
|
||||||
|
reviews Review[]
|
||||||
|
wishlistItems WishlistItem[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
|
@@map("customers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EmailVerificationToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String
|
||||||
|
/// SHA-256-Hash des per E-Mail zugesandten Tokens — wie beim
|
||||||
|
/// Passwort-Reset wird nie der Klartext gespeichert.
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("email_verification_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
model TwoFactorBackupCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String
|
||||||
|
/// SHA-256-Hash des Codes — der Klartext wird dem Kunden nur einmalig bei
|
||||||
|
/// der Einrichtung angezeigt und nie wieder gespeichert.
|
||||||
|
codeHash String @unique
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("two_factor_backup_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Address {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String
|
||||||
|
label String @default("Zuhause")
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
street String
|
||||||
|
zip String
|
||||||
|
city String
|
||||||
|
country String @default("DE")
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("addresses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ernten, Chargen, Lagerbestand, Produkte
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model Harvest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
year Int
|
||||||
|
/// z.B. "Fruehtracht", "Sommertracht"
|
||||||
|
type String
|
||||||
|
slug String @unique
|
||||||
|
harvestDate DateTime
|
||||||
|
location String @default("Altenmuenster")
|
||||||
|
description String
|
||||||
|
/// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend:
|
||||||
|
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
|
||||||
|
/// Normalisierungs-Helfer beim Lesen.
|
||||||
|
images Json @default("[]")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
batches Batch[]
|
||||||
|
products Product[]
|
||||||
|
|
||||||
|
@@unique([year, type])
|
||||||
|
@@map("harvests")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Batch {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// z.B. FH-26-01
|
||||||
|
code String @unique
|
||||||
|
harvestId String
|
||||||
|
fillDate DateTime
|
||||||
|
bestBeforeDate DateTime
|
||||||
|
glassCount Int
|
||||||
|
remainingGlasses Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
harvest Harvest @relation(fields: [harvestId], references: [id], onDelete: Restrict)
|
||||||
|
products Product[]
|
||||||
|
orderItems OrderItem[]
|
||||||
|
|
||||||
|
@@map("batches")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProductCategory {
|
||||||
|
HONIG
|
||||||
|
KERZEN
|
||||||
|
PROPOLIS
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
category ProductCategory @default(HONIG)
|
||||||
|
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
|
||||||
|
harvestId String?
|
||||||
|
batchId String?
|
||||||
|
description String
|
||||||
|
/// Geschmacksprofil-Tags als JSON-Array, z.B. ["mild", "floral", "feincremig"]
|
||||||
|
/// — String[] ist nicht Provider-uebergreifend (MySQL kennt keine nativen
|
||||||
|
/// Skalar-Arrays), siehe lib/json-array.ts fuer den Normalisierungs-Helfer.
|
||||||
|
tasteTags Json @default("[]")
|
||||||
|
/// Intensitaet 1-5 fuer die visuelle Geschmacksskala
|
||||||
|
intensityMild Int @default(3)
|
||||||
|
intensityFloral Int @default(3)
|
||||||
|
intensityKraeftig Int @default(3)
|
||||||
|
consistency String @default("feincremig")
|
||||||
|
weightGrams Int
|
||||||
|
priceCents Int
|
||||||
|
pricePerKgCents Int
|
||||||
|
currency String @default("EUR")
|
||||||
|
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
|
||||||
|
images Json @default("[]")
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
harvest Harvest? @relation(fields: [harvestId], references: [id], onDelete: Restrict)
|
||||||
|
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
||||||
|
inventory Inventory?
|
||||||
|
orderItems OrderItem[]
|
||||||
|
reviews Review[]
|
||||||
|
wishlistItems WishlistItem[]
|
||||||
|
stockNotifications StockNotification[]
|
||||||
|
|
||||||
|
@@map("products")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StockNotification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
email String
|
||||||
|
/// Spracheinstellung bei der Anmeldung — die Benachrichtigungs-E-Mail wird
|
||||||
|
/// spaeter asynchron (beim naechsten Bestandsupdate) versendet, ohne
|
||||||
|
/// Cookie-Kontext der urspruenglichen Anfrage.
|
||||||
|
locale String @default("de")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// Gesetzt, sobald die Benachrichtigungs-E-Mail versendet wurde — verhindert
|
||||||
|
/// Mehrfachversand, falls der Bestand mehrfach zwischen 0 und >0 wechselt,
|
||||||
|
/// ohne dass sich die Kundin/der Kunde neu angemeldet hat.
|
||||||
|
notifiedAt DateTime?
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Eine Anmeldung pro E-Mail-Adresse und Produkt.
|
||||||
|
@@unique([productId, email])
|
||||||
|
@@map("stock_notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WishlistItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String
|
||||||
|
productId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Ein Produkt kann pro Kunde nur einmal auf der Wunschliste stehen.
|
||||||
|
@@unique([customerId, productId])
|
||||||
|
@@map("wishlist_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Review {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
customerId String
|
||||||
|
/// 1-5
|
||||||
|
rating Int
|
||||||
|
comment String?
|
||||||
|
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
|
||||||
|
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
|
||||||
|
isApproved Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
/// Ein Kunde kann ein Produkt nur einmal bewerten.
|
||||||
|
@@unique([productId, customerId])
|
||||||
|
@@map("reviews")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Inventory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String @unique
|
||||||
|
quantity Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("inventory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bestellungen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
NEU
|
||||||
|
BEZAHLT
|
||||||
|
IN_VORBEREITUNG
|
||||||
|
VERSANDBEREIT
|
||||||
|
VERSENDET
|
||||||
|
ABHOLBEREIT
|
||||||
|
ABGEHOLT
|
||||||
|
STORNIERT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FulfillmentMethod {
|
||||||
|
VERSAND
|
||||||
|
ABHOLUNG
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderNumber String @unique
|
||||||
|
customerId String?
|
||||||
|
status OrderStatus @default(NEU)
|
||||||
|
fulfillmentMethod FulfillmentMethod @default(VERSAND)
|
||||||
|
/// Spracheinstellung beim Checkout ("de"/"en") — wird fuer spaeter
|
||||||
|
/// asynchron versendete E-Mails (Stripe-Webhook) benoetigt, da dort kein
|
||||||
|
/// Cookie-/Request-Kontext mehr vorhanden ist, um die Sprache erneut zu bestimmen.
|
||||||
|
locale String @default("de")
|
||||||
|
|
||||||
|
// Kontakt- und Versanddaten werden am Bestellzeitpunkt gespeichert,
|
||||||
|
// damit Gastbestellungen unabhaengig von einem Customer-Datensatz funktionieren.
|
||||||
|
email String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
phone String?
|
||||||
|
street String
|
||||||
|
zip String
|
||||||
|
city String
|
||||||
|
country String @default("DE")
|
||||||
|
|
||||||
|
subtotalCents Int
|
||||||
|
shippingCents Int @default(0)
|
||||||
|
discountCents Int @default(0)
|
||||||
|
totalCents Int
|
||||||
|
currency String @default("EUR")
|
||||||
|
|
||||||
|
discountCodeId String?
|
||||||
|
|
||||||
|
stripeCheckoutSessionId String?
|
||||||
|
notes String?
|
||||||
|
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
|
||||||
|
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
|
||||||
|
giftMessage String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||||
|
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
|
||||||
|
items OrderItem[]
|
||||||
|
payment Payment?
|
||||||
|
shipment Shipment?
|
||||||
|
pickup Pickup?
|
||||||
|
invoice Invoice?
|
||||||
|
returnRequest ReturnRequest?
|
||||||
|
|
||||||
|
@@map("orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReturnStatus {
|
||||||
|
ANGEFRAGT
|
||||||
|
ANGENOMMEN
|
||||||
|
ABGELEHNT
|
||||||
|
ERSTATTET
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Rueckgabeanfrage pro Bestellung (orderId @unique) — deckt den Fall
|
||||||
|
/// "gesamte Bestellung zurueckschicken" ab, keine positionsweise Teil-Retoure.
|
||||||
|
model ReturnRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @unique
|
||||||
|
reason String
|
||||||
|
status ReturnStatus @default(ANGEFRAGT)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("return_requests")
|
||||||
|
}
|
||||||
|
|
||||||
|
model OrderItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String
|
||||||
|
productId String
|
||||||
|
/// Nur gesetzt, wenn das Produkt einer Charge zugeordnet ist (Honig)
|
||||||
|
batchId String?
|
||||||
|
|
||||||
|
// Snapshot der Produktdaten zum Bestellzeitpunkt (Preisaenderungen duerfen
|
||||||
|
// bestehende Bestellungen nicht rueckwirkend veraendern).
|
||||||
|
productName String
|
||||||
|
unitPriceCents Int
|
||||||
|
quantity Int
|
||||||
|
weightGrams Int
|
||||||
|
totalCents Int
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
|
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@map("order_items")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PENDING
|
||||||
|
SUCCEEDED
|
||||||
|
FAILED
|
||||||
|
REFUNDED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @unique
|
||||||
|
provider String @default("stripe")
|
||||||
|
stripePaymentIntentId String?
|
||||||
|
status PaymentStatus @default(PENDING)
|
||||||
|
amountCents Int
|
||||||
|
currency String @default("EUR")
|
||||||
|
paidAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Shipment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @unique
|
||||||
|
carrier String?
|
||||||
|
trackingNumber String?
|
||||||
|
shippedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("shipments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Pickup {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @unique
|
||||||
|
location String @default("Altenmuenster")
|
||||||
|
readyAt DateTime?
|
||||||
|
pickedUpAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("pickups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invoice {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @unique
|
||||||
|
invoiceNumber String @unique
|
||||||
|
issuedAt DateTime @default(now())
|
||||||
|
totalCents Int
|
||||||
|
pdfUrl String?
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("invoices")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Marketing / Kontakt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model NewsletterSubscriber {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
customerId String? @unique
|
||||||
|
confirmed Boolean @default(false)
|
||||||
|
/// Dient sowohl der Bestaetigung (Double-Opt-In) als auch als dauerhafter
|
||||||
|
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
|
||||||
|
confirmationToken String? @unique
|
||||||
|
subscribedAt DateTime @default(now())
|
||||||
|
unsubscribedAt DateTime?
|
||||||
|
|
||||||
|
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@map("newsletter_subscribers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
customerId String
|
||||||
|
/// SHA-256-Hash des dem Kunden per E-Mail zugesandten Tokens — wie bei
|
||||||
|
/// Passwoertern wird nie der Klartext gespeichert.
|
||||||
|
tokenHash String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
usedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("password_reset_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BusinessPackage {
|
||||||
|
KLASSIK
|
||||||
|
GESCHENK
|
||||||
|
INDIVIDUELL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InquiryStatus {
|
||||||
|
NEU
|
||||||
|
IN_BEARBEITUNG
|
||||||
|
ABGESCHLOSSEN
|
||||||
|
ABGELEHNT
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Versand
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model ShippingTier {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Menge (Stueck ueber alle Artikel im Warenkorb zusammengezaehlt) ab der
|
||||||
|
/// diese Staffel gilt.
|
||||||
|
minQuantity Int
|
||||||
|
/// Bis einschliesslich dieser Menge — null bedeutet "und mehr" (offene,
|
||||||
|
/// oberste Staffel).
|
||||||
|
maxQuantity Int?
|
||||||
|
priceCents Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("shipping_tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rabattcodes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum DiscountType {
|
||||||
|
PERCENT
|
||||||
|
FIXED
|
||||||
|
}
|
||||||
|
|
||||||
|
model DiscountCode {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
type DiscountType
|
||||||
|
/// PERCENT: Prozentpunkte (1-100). FIXED: Cent-Betrag.
|
||||||
|
value Int
|
||||||
|
|
||||||
|
/// Mindestbestellwert (Cent) fuer die Anwendung, 0 = keine Mindestsumme.
|
||||||
|
minOrderCents Int @default(0)
|
||||||
|
/// Mindestmenge (Summe aller Artikel im Warenkorb) fuer die Anwendung —
|
||||||
|
/// null = keine Mengenschwelle. Gesetzt macht aus dem Code einen
|
||||||
|
/// Mengenrabatt: Checkout wendet ihn automatisch an, sobald die Schwelle
|
||||||
|
/// erreicht ist (siehe api/checkout/route.ts), ohne dass der Code
|
||||||
|
/// eingegeben werden muss.
|
||||||
|
minQuantity Int?
|
||||||
|
/// Nutzungslimit insgesamt ueber alle Kunden — null = unbegrenzt.
|
||||||
|
maxUses Int?
|
||||||
|
usedCount Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
validFrom DateTime?
|
||||||
|
validUntil DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
|
@@map("discount_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin-Audit-Log
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Protokolliert sicherheitsrelevante Admin-Mutationen (wer hat wann was
|
||||||
|
/// geaendert) — wichtig bei mehreren Admin-Konten, um Aenderungen
|
||||||
|
/// nachvollziehen zu koennen. adminEmail ist ein Snapshot, damit der
|
||||||
|
/// Eintrag auch nach Loeschung des Admin-Kontos lesbar bleibt.
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
adminId String?
|
||||||
|
adminEmail String
|
||||||
|
/// z.B. "product.update", "order.status_change", "discount_code.delete"
|
||||||
|
action String
|
||||||
|
entityType String
|
||||||
|
entityId String?
|
||||||
|
detail String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin-editierbare Website-Inhalte (Firmendaten, Homepage-Texte,
|
||||||
|
/// Rechtstexte) als generisches Key-Value-Overlay ueber den statischen
|
||||||
|
/// i18n-Dictionaries — siehe lib/content-registry.ts fuer die Liste aller
|
||||||
|
/// editierbaren Schluessel und deren Fallback-Werte. Fehlt eine Zeile (kein
|
||||||
|
/// Admin-Override gepflegt), liefert getContentValue() den urspruenglichen
|
||||||
|
/// Dictionary-/site-config-Text — die Seite bricht also nie, auch bevor die
|
||||||
|
/// erste Bearbeitung stattgefunden hat.
|
||||||
|
model SiteContent {
|
||||||
|
/// z.B. "company.email", "home.philosophy.items", "legal.impressum"
|
||||||
|
key String @id
|
||||||
|
/// Bei nicht lokalisierten Feldern (Firmendaten) einziger Speicherort;
|
||||||
|
/// bei lokalisierten Feldern (Homepage/Rechtstexte) die deutsche Fassung.
|
||||||
|
valueDe String @db.Text
|
||||||
|
/// Nur bei lokalisierten Feldern gesetzt — null bei Firmendaten.
|
||||||
|
valueEn String? @db.Text
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("site_content")
|
||||||
|
}
|
||||||
|
|
||||||
|
model BusinessInquiry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
companyName String
|
||||||
|
contactPerson String
|
||||||
|
email String
|
||||||
|
phone String?
|
||||||
|
desiredQuantity Int
|
||||||
|
desiredDeliveryDate DateTime?
|
||||||
|
package BusinessPackage @default(KLASSIK)
|
||||||
|
message String?
|
||||||
|
status InquiryStatus @default(NEU)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("business_inquiries")
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaClient as MySqlPrismaClient } from "../src/generated/prisma-mysql/client";
|
||||||
|
import { createDbAdapter } from "../src/lib/db-adapter";
|
||||||
|
import { isPasswordBreached } from "../src/lib/password-breach-check";
|
||||||
|
|
||||||
|
// Bewusst kein Import aus "@/lib/auth": das Modul zieht next/headers (cookies())
|
||||||
|
// nach sich, das ausserhalb eines Next.js-Request-Kontexts (hier: Standalone-
|
||||||
|
// Skript via tsx) fehlschlaegt. Der Hash-Rundenwert 12 ist mit auth.ts identisch.
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
const adapter = createDbAdapter();
|
||||||
|
// Gleiches Prinzip wie lib/prisma.ts: zwei generierte Clients im Image,
|
||||||
|
// Auswahl zur Laufzeit ueber DATABASE_PROVIDER.
|
||||||
|
const prisma: PrismaClient =
|
||||||
|
(process.env.DATABASE_PROVIDER ?? "postgresql").trim() === "mysql"
|
||||||
|
? (new MySqlPrismaClient({ adapter }) as unknown as PrismaClient)
|
||||||
|
: new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const fruehtracht = await prisma.harvest.upsert({
|
||||||
|
where: { slug: "fruehtracht-2026" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
year: 2026,
|
||||||
|
type: "Frühtracht",
|
||||||
|
slug: "fruehtracht-2026",
|
||||||
|
harvestDate: new Date("2026-05-10"),
|
||||||
|
location: "Altenmünster",
|
||||||
|
description:
|
||||||
|
"Die erste Ernte des Jahres entsteht aus dem Nektar von Obstblüte, Raps und Löwenzahn rund um Altenmünster. Mild, floral und feincremig — der Auftakt jeder Wabenhain-Saison.",
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sommertracht = await prisma.harvest.upsert({
|
||||||
|
where: { slug: "sommertracht-2026" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
year: 2026,
|
||||||
|
type: "Sommertracht",
|
||||||
|
slug: "sommertracht-2026",
|
||||||
|
harvestDate: new Date("2026-07-18"),
|
||||||
|
location: "Altenmünster",
|
||||||
|
description:
|
||||||
|
"Im Hochsommer sammeln unsere Völker Nektar von Linde, Wiesenblumen und Wald. Das Ergebnis ist kräftiger, aromatischer und charaktervoller als die Frühtracht.",
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fhBatch = await prisma.batch.upsert({
|
||||||
|
where: { code: "FH-26-01" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: "FH-26-01",
|
||||||
|
harvestId: fruehtracht.id,
|
||||||
|
fillDate: new Date("2026-06-02"),
|
||||||
|
bestBeforeDate: new Date("2028-06-02"),
|
||||||
|
glassCount: 200,
|
||||||
|
remainingGlasses: 167,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stBatch = await prisma.batch.upsert({
|
||||||
|
where: { code: "ST-26-01" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: "ST-26-01",
|
||||||
|
harvestId: sommertracht.id,
|
||||||
|
fillDate: new Date("2026-08-05"),
|
||||||
|
bestBeforeDate: new Date("2028-08-05"),
|
||||||
|
glassCount: 150,
|
||||||
|
remainingGlasses: 150,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fhProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "fruehtracht-2026" },
|
||||||
|
update: { tasteTags: ["mild", "floral", "feincremig"] },
|
||||||
|
create: {
|
||||||
|
slug: "fruehtracht-2026",
|
||||||
|
name: "Frühtracht 2026",
|
||||||
|
harvestId: fruehtracht.id,
|
||||||
|
batchId: fhBatch.id,
|
||||||
|
description:
|
||||||
|
"Mild, floral und feincremig. Unsere erste Ernte des Jahres aus dem Raum Altenmünster.",
|
||||||
|
tasteTags: ["mild", "floral", "feincremig"],
|
||||||
|
intensityMild: 4,
|
||||||
|
intensityFloral: 4,
|
||||||
|
intensityKraeftig: 2,
|
||||||
|
consistency: "feincremig",
|
||||||
|
weightGrams: 500,
|
||||||
|
priceCents: 990,
|
||||||
|
pricePerKgCents: 1980,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "sommertracht-2026" },
|
||||||
|
update: { tasteTags: ["kräftig", "aromatisch", "charaktervoll"] },
|
||||||
|
create: {
|
||||||
|
slug: "sommertracht-2026",
|
||||||
|
name: "Sommertracht 2026",
|
||||||
|
harvestId: sommertracht.id,
|
||||||
|
batchId: stBatch.id,
|
||||||
|
description: "Kräftiger, aromatischer und charaktervoller Sommerhonig aus Altenmünster.",
|
||||||
|
tasteTags: ["kräftig", "aromatisch", "charaktervoll"],
|
||||||
|
intensityMild: 2,
|
||||||
|
intensityFloral: 2,
|
||||||
|
intensityKraeftig: 5,
|
||||||
|
consistency: "cremig",
|
||||||
|
weightGrams: 500,
|
||||||
|
priceCents: 1090,
|
||||||
|
pricePerKgCents: 2180,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const candleProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "bienenwachskerze-rund" },
|
||||||
|
update: { tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"] },
|
||||||
|
create: {
|
||||||
|
slug: "bienenwachskerze-rund",
|
||||||
|
name: "Bienenwachskerze, rund",
|
||||||
|
category: "KERZEN",
|
||||||
|
description:
|
||||||
|
"Handgegossen aus reinem Bienenwachs unserer eigenen Völker. Brennt gleichmäßig, rußarm und verströmt einen dezenten Honigduft.",
|
||||||
|
tasteTags: ["handgegossen", "reines Bienenwachs", "rußarm"],
|
||||||
|
consistency: "fest",
|
||||||
|
weightGrams: 150,
|
||||||
|
priceCents: 1290,
|
||||||
|
pricePerKgCents: 8600,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const propolisProduct = await prisma.product.upsert({
|
||||||
|
where: { slug: "propolis-tinktur" },
|
||||||
|
update: { tasteTags: ["schonend verarbeitet", "ohne Zusätze"] },
|
||||||
|
create: {
|
||||||
|
slug: "propolis-tinktur",
|
||||||
|
name: "Propolis-Tinktur",
|
||||||
|
category: "PROPOLIS",
|
||||||
|
description:
|
||||||
|
"Schonend angesetzte Propolis-Tinktur aus dem Harz unserer Bienenstöcke rund um Altenmünster. Ohne synthetische Zusätze.",
|
||||||
|
tasteTags: ["schonend verarbeitet", "ohne Zusätze"],
|
||||||
|
consistency: "flüssig",
|
||||||
|
weightGrams: 30,
|
||||||
|
priceCents: 1490,
|
||||||
|
pricePerKgCents: 49666,
|
||||||
|
images: [],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: fhProduct.id },
|
||||||
|
update: { quantity: 167 },
|
||||||
|
create: { productId: fhProduct.id, quantity: 167 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: stProduct.id },
|
||||||
|
update: { quantity: 150 },
|
||||||
|
create: { productId: stProduct.id, quantity: 150 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: candleProduct.id },
|
||||||
|
update: { quantity: 60 },
|
||||||
|
create: { productId: candleProduct.id, quantity: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.inventory.upsert({
|
||||||
|
where: { productId: propolisProduct.id },
|
||||||
|
update: { quantity: 40 },
|
||||||
|
create: { productId: propolisProduct.id, quantity: 40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingShippingTiers = await prisma.shippingTier.count();
|
||||||
|
if (existingShippingTiers === 0) {
|
||||||
|
await prisma.shippingTier.createMany({
|
||||||
|
data: [
|
||||||
|
{ minQuantity: 0, maxQuantity: 3, priceCents: 700 },
|
||||||
|
{ minQuantity: 4, maxQuantity: 9, priceCents: 850 },
|
||||||
|
{ minQuantity: 10, maxQuantity: 19, priceCents: 1100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log("Versandstaffeln angelegt: 0-3=7,00€, 4-9=8,50€, 10-19=11,00€.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (adminEmail && adminPassword) {
|
||||||
|
// Dieselben Mindestanforderungen wie bei der normalen Kundenregistrierung
|
||||||
|
// (registration.ts) — ein Admin-Konto hat Zugriff auf Bestellungen,
|
||||||
|
// Rueckerstattungen und Kundendaten und verdient keine schwaecheren
|
||||||
|
// Passwort-Regeln nur weil es per Seed statt per Formular angelegt wird.
|
||||||
|
if (adminPassword.length < 8) {
|
||||||
|
throw new Error("ADMIN_PASSWORD muss mindestens 8 Zeichen lang sein.");
|
||||||
|
}
|
||||||
|
if (await isPasswordBreached(adminPassword)) {
|
||||||
|
throw new Error(
|
||||||
|
"ADMIN_PASSWORD wurde in bekannten Datenlecks gefunden (Have I Been Pwned). Bitte ein anderes Passwort waehlen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAtEmail = await prisma.customer.findUnique({ where: { email: adminEmail } });
|
||||||
|
|
||||||
|
if (!existingAtEmail) {
|
||||||
|
await prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS),
|
||||||
|
firstName: "Admin",
|
||||||
|
lastName: "Wabenhain",
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Admin-Konto angelegt: ${adminEmail} (Passwort aus ADMIN_PASSWORD).`);
|
||||||
|
} else if (existingAtEmail.role === "ADMIN") {
|
||||||
|
// Bereits Admin: Passwort-Rotation ueber ein geaendertes ADMIN_PASSWORD
|
||||||
|
// weiterhin unterstuetzen (dokumentierter Weg, ein Admin-Passwort zu
|
||||||
|
// erneuern) — unproblematisch, da das Konto schon Admin ist.
|
||||||
|
await prisma.customer.update({
|
||||||
|
where: { id: existingAtEmail.id },
|
||||||
|
data: { passwordHash: await bcrypt.hash(adminPassword, BCRYPT_ROUNDS) },
|
||||||
|
});
|
||||||
|
console.log(`Admin-Passwort synchronisiert: ${adminEmail}.`);
|
||||||
|
} else {
|
||||||
|
// Sicherheitsrelevant: seed befoerdert NIE automatisch ein bestehendes
|
||||||
|
// Nicht-Admin-Konto zu ADMIN, nur weil dessen E-Mail mit ADMIN_EMAIL
|
||||||
|
// uebereinstimmt — sonst koennte sich jemand die Admin-Mail vor dem
|
||||||
|
// ersten Deploy-Seed-Lauf selbst registrieren und beim naechsten
|
||||||
|
// "pnpm prisma db seed" automatisch Admin werden, mit selbstgewaehltem
|
||||||
|
// Passwort. Promotion eines bestehenden Kontos daher nur manuell ueber
|
||||||
|
// die Datenbank, siehe README "Admin-Zugang".
|
||||||
|
console.warn(
|
||||||
|
`WARNUNG: ${adminEmail} existiert bereits als Kundenkonto (nicht Admin) und wird NICHT automatisch befoerdert. ` +
|
||||||
|
'Siehe README, Abschnitt "Admin-Zugang", fuer die manuelle Befoerderung.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Kein Admin-Konto angelegt: ADMIN_EMAIL/ADMIN_PASSWORD sind nicht gesetzt. " +
|
||||||
|
"Siehe README, wie ein bestehendes Konto nachtraeglich zu ADMIN befoerdert wird."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed abgeschlossen: 2 Ernten, 2 Chargen, 4 Produkte (Honig, Kerzen, Propolis).");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Contact: mailto:[email protected]
|
||||||
|
Expires: 2027-08-14T00:00:00.000Z
|
||||||
|
Preferred-Languages: de, en
|
||||||
|
Canonical: https://wabenhain.com/.well-known/security.txt
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Generiert BEIDE Prisma-Client-Varianten (Postgres + MySQL) aus derselben
|
||||||
|
// prisma/schema.prisma, damit ein einzelnes Docker-Image zur Laufzeit per
|
||||||
|
// DATABASE_PROVIDER zwischen beiden waehlen kann (lib/prisma.ts,
|
||||||
|
// prisma/seed.ts) — ein einzelner generierter Client ist fest an EINEN
|
||||||
|
// Provider gebunden (Prisma unterstuetzt keinen Laufzeit-Wechsel desselben
|
||||||
|
// generierten Clients zwischen Providern). Anders als
|
||||||
|
// scripts/prisma-provider.mjs (schreibt genau EINEN Provider fuer
|
||||||
|
// db push/migrate/studio, die gegen eine konkrete Ziel-DB laufen) ist dieses
|
||||||
|
// Skript fuer dev/build/start/postinstall gedacht.
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const schemaPath = path.join(projectRoot, "prisma", "schema.prisma");
|
||||||
|
|
||||||
|
const VARIANTS = [
|
||||||
|
{ provider: "postgresql", output: "../src/generated/prisma" },
|
||||||
|
{ provider: "mysql", output: "../src/generated/prisma-mysql" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const originalSchema = readFileSync(schemaPath, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const { provider, output } of VARIANTS) {
|
||||||
|
let schema = originalSchema.replace(
|
||||||
|
/(datasource db \{[^}]*provider\s*=\s*")[a-z]+(")/,
|
||||||
|
`$1${provider}$2`
|
||||||
|
);
|
||||||
|
schema = schema.replace(
|
||||||
|
/(generator client \{[^}]*output\s*=\s*")[^"]*(")/,
|
||||||
|
`$1${output}$2`
|
||||||
|
);
|
||||||
|
writeFileSync(schemaPath, schema);
|
||||||
|
console.log(`Generiere Prisma Client (${provider}) -> ${output} ...`);
|
||||||
|
// execSync (statt execFileSync) laeuft ueber die Shell — auf Windows ist
|
||||||
|
// "npx" ein .cmd-Batch-Skript, das execFileSync ohne shell:true nicht
|
||||||
|
// aufloest.
|
||||||
|
execSync("npx prisma generate", { stdio: "inherit", cwd: projectRoot });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// schema.prisma unveraendert lassen: der eigentliche Provider fuer
|
||||||
|
// db push/migrate/studio wird separat von scripts/prisma-provider.mjs
|
||||||
|
// gesetzt, nicht von diesem Skript.
|
||||||
|
writeFileSync(schemaPath, originalSchema);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Prisma erlaubt kein env() fuer datasource.provider (nur fuer url) — dieses
|
||||||
|
// Skript schreibt die provider-Zeile in prisma/schema.prisma daher vor jedem
|
||||||
|
// generate/db push/migrate anhand von DATABASE_PROVIDER neu. Wird von den
|
||||||
|
// package.json-Skripten aufgerufen, nicht direkt.
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SUPPORTED_PROVIDERS = ["postgresql", "mysql"];
|
||||||
|
const DEFAULT_PROVIDER = "postgresql";
|
||||||
|
|
||||||
|
const schemaPath = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"prisma",
|
||||||
|
"schema.prisma"
|
||||||
|
);
|
||||||
|
|
||||||
|
const provider = (process.env.DATABASE_PROVIDER ?? DEFAULT_PROVIDER).trim();
|
||||||
|
|
||||||
|
if (!SUPPORTED_PROVIDERS.includes(provider)) {
|
||||||
|
console.error(
|
||||||
|
`DATABASE_PROVIDER="${provider}" wird nicht unterstuetzt. Erlaubt: ${SUPPORTED_PROVIDERS.join(", ")}.`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = readFileSync(schemaPath, "utf8");
|
||||||
|
const updated = schema.replace(
|
||||||
|
/(datasource db \{[^}]*provider\s*=\s*")([a-z]+)(")/,
|
||||||
|
`$1${provider}$3`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updated === schema && !schema.includes(`provider = "${provider}"`)) {
|
||||||
|
console.error("datasource-Block in prisma/schema.prisma nicht gefunden — provider konnte nicht gesetzt werden.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(schemaPath, updated);
|
||||||
|
console.log(`prisma/schema.prisma: datasource provider = "${provider}"`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Barrel-Datei: die eigentlichen Server Actions leben domainweise unter
|
||||||
|
// ./actions/* (waren zuvor alle in dieser einen 800+-Zeilen-Datei). Bestehende
|
||||||
|
// Importe ("@/app/admin/actions" bzw. "../actions" aus den Admin-Seiten)
|
||||||
|
// bleiben dadurch unveraendert funktionsfaehig.
|
||||||
|
export { requireAdmin } from "./actions/_shared";
|
||||||
|
export * from "./actions/orders";
|
||||||
|
export * from "./actions/harvests-batches";
|
||||||
|
export * from "./actions/products";
|
||||||
|
export * from "./actions/inquiries";
|
||||||
|
export * from "./actions/customers";
|
||||||
|
export * from "./actions/shipping";
|
||||||
|
export * from "./actions/reviews";
|
||||||
|
export * from "./actions/discount-codes";
|
||||||
|
export * from "./actions/content";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import type { CustomerRole } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autoritative Rollenpruefung fuer jede Admin-Mutation. Der Proxy
|
||||||
|
* (src/proxy.ts) blockt fremde Requests bereits auf Netzwerkebene, aber
|
||||||
|
* Server Actions sind eigene POST-Endpunkte und muessen laut Next.js-
|
||||||
|
* Sicherheitsleitfaden auch fuer sich genommen autorisieren — Render-Zeit-
|
||||||
|
* Gating (Button wird nur im Admin-UI angezeigt) ist keine Sicherheitsgrenze.
|
||||||
|
* Gibt das Admin-Konto zurueck, damit Aufrufer es ohne Zweitabfrage fuers
|
||||||
|
* Audit-Log verwenden koennen.
|
||||||
|
*/
|
||||||
|
export async function requireAdmin(): Promise<{ id: string; email: string; role: CustomerRole }> {
|
||||||
|
const admin = await getCurrentAdmin();
|
||||||
|
if (!admin) {
|
||||||
|
throw new Error("Nicht autorisiert.");
|
||||||
|
}
|
||||||
|
// Admin-Konten mit Zugriff auf Bestellungen, Rueckerstattungen und
|
||||||
|
// Kundendaten muessen Zwei-Faktor-Authentifizierung aktiviert haben — das
|
||||||
|
// Admin-Layout leitet dafuer bereits weiter, diese Pruefung faengt zusaetzlich
|
||||||
|
// eine direkt aufgerufene Server Action ab, die das Layout umgeht.
|
||||||
|
if (!admin.twoFactorEnabled) {
|
||||||
|
throw new Error("Zwei-Faktor-Authentifizierung ist für Admin-Konten erforderlich.");
|
||||||
|
}
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(path: string, message: string): never {
|
||||||
|
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
||||||
|
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
||||||
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
|
if (!customer || customer.role !== "ADMIN") return false;
|
||||||
|
const adminCount = await prisma.customer.count({ where: { role: "ADMIN" } });
|
||||||
|
return adminCount <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function str(formData: FormData, key: string): string {
|
||||||
|
return String(formData.get(key) ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function num(formData: FormData, key: string): number {
|
||||||
|
const value = Number(formData.get(key) ?? 0);
|
||||||
|
// NaN/Infinity (z.B. leeres oder manipuliertes Feld) nicht an Prisma durchreichen
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strOrNull(formData: FormData, key: string): string | null {
|
||||||
|
const value = str(formData, key);
|
||||||
|
return value === "" ? null : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"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 { requireAdmin } from "./_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
||||||
|
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
|
||||||
|
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
|
||||||
|
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
|
||||||
|
* "en::{key}" (siehe admin/inhalte/page.tsx).
|
||||||
|
*/
|
||||||
|
export async function updateSiteContentAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const group = String(formData.get("group") ?? "");
|
||||||
|
|
||||||
|
const updates: { key: string; valueDe: string; valueEn: string | null }[] = [];
|
||||||
|
|
||||||
|
for (const def of CONTENT_REGISTRY) {
|
||||||
|
if (def.localized) {
|
||||||
|
const de = formData.get(`de::${def.key}`);
|
||||||
|
const en = formData.get(`en::${def.key}`);
|
||||||
|
if (de === null && en === null) continue;
|
||||||
|
updates.push({
|
||||||
|
key: def.key,
|
||||||
|
valueDe: String(de ?? ""),
|
||||||
|
valueEn: String(en ?? ""),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const value = formData.get(def.key);
|
||||||
|
if (value === null) continue;
|
||||||
|
updates.push({ key: def.key, valueDe: String(value), valueEn: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
updates.map((u) =>
|
||||||
|
prisma.siteContent.upsert({
|
||||||
|
where: { key: u.key },
|
||||||
|
create: { key: u.key, valueDe: u.valueDe, valueEn: u.valueEn },
|
||||||
|
update: { valueDe: u.valueDe, valueEn: u.valueEn },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "site_content.update",
|
||||||
|
entityType: "SiteContent",
|
||||||
|
entityId: group || undefined,
|
||||||
|
detail: `${updates.length} Feld(er)`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect("/admin/inhalte?saved=1");
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
import { createAndSendPasswordResetEmail } from "@/lib/password-reset";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const parsed = adminCreateCustomerSchema.safeParse({
|
||||||
|
firstName: str(formData, "firstName"),
|
||||||
|
lastName: str(formData, "lastName"),
|
||||||
|
email: str(formData, "email"),
|
||||||
|
role: str(formData, "role") || "KUNDE",
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
||||||
|
// niemandem bekannten Hash. Der Nutzer vergibt sein eigenes Passwort ueber
|
||||||
|
// den Reset-Link, der sofort per E-Mail verschickt wird.
|
||||||
|
const unusablePassword = randomBytes(32).toString("hex");
|
||||||
|
const customer = await prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
firstName: parsed.data.firstName,
|
||||||
|
lastName: parsed.data.lastName,
|
||||||
|
email: parsed.data.email,
|
||||||
|
role: parsed.data.role,
|
||||||
|
passwordHash: await hashPassword(unusablePassword),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAndSendPasswordResetEmail(customer);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "customer.create",
|
||||||
|
entityType: "Customer",
|
||||||
|
entityId: customer.id,
|
||||||
|
detail: `${customer.email} (${parsed.data.role})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/benutzer");
|
||||||
|
redirect("/admin/benutzer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const id = str(formData, "id");
|
||||||
|
const parsed = adminUpdateCustomerSchema.safeParse({
|
||||||
|
firstName: str(formData, "firstName"),
|
||||||
|
lastName: str(formData, "lastName"),
|
||||||
|
email: str(formData, "email"),
|
||||||
|
phone: str(formData, "phone"),
|
||||||
|
role: str(formData, "role") || "KUNDE",
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.customer.findUnique({ where: { id } });
|
||||||
|
if (!target) 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailChanged = parsed.data.email !== target.email;
|
||||||
|
|
||||||
|
const updated = await prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
firstName: parsed.data.firstName,
|
||||||
|
lastName: parsed.data.lastName,
|
||||||
|
email: parsed.data.email,
|
||||||
|
phone: parsed.data.phone || null,
|
||||||
|
role: parsed.data.role,
|
||||||
|
// Eine geaenderte Adresse ist nicht automatisch bestaetigt — sonst
|
||||||
|
// koennte eine falsch eingetragene (oder von einem Admin-Account aus
|
||||||
|
// manipulierte) Adresse als "verifiziert" gelten, ohne dass deren
|
||||||
|
// tatsaechliche Inhaberin/Inhaber je einen Bestaetigungslink geoeffnet hat.
|
||||||
|
...(emailChanged ? { emailVerifiedAt: null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailChanged) {
|
||||||
|
await createAndSendVerificationEmail(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "customer.update",
|
||||||
|
entityType: "Customer",
|
||||||
|
entityId: id,
|
||||||
|
detail: target.role !== parsed.data.role ? `Rolle: ${target.role} → ${parsed.data.role}` : parsed.data.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/benutzer");
|
||||||
|
redirect("/admin/benutzer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const id = str(formData, "id");
|
||||||
|
if (id === admin.id) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
||||||
|
// gesperrten Kundschaft sofort ungueltig wird statt erst beim naechsten
|
||||||
|
// Login (getCurrentCustomer prueft ohnehin zusaetzlich blockedAt direkt).
|
||||||
|
const blocked = await prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { blockedAt: new Date(), sessionVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
||||||
|
revalidatePath("/admin/benutzer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const unblocked = await prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { blockedAt: null, failedLoginAttempts: 0, lockedUntil: null },
|
||||||
|
});
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "customer.unblock",
|
||||||
|
entityType: "Customer",
|
||||||
|
entityId: id,
|
||||||
|
detail: unblocked.email,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/benutzer");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const id = str(formData, "id");
|
||||||
|
if (id === admin.id) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
||||||
|
export async function sendCustomerPasswordResetAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||||
|
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||||
|
|
||||||
|
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."));
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
function parseDiscountCodeForm(formData: FormData) {
|
||||||
|
const rawMax = str(formData, "maxUses");
|
||||||
|
const rawMinQuantity = str(formData, "minQuantity");
|
||||||
|
const rawFrom = str(formData, "validFrom");
|
||||||
|
const rawUntil = str(formData, "validUntil");
|
||||||
|
return discountCodeSchema.safeParse({
|
||||||
|
code: str(formData, "code"),
|
||||||
|
type: str(formData, "type"),
|
||||||
|
value: str(formData, "value"),
|
||||||
|
minOrderCents: str(formData, "minOrderCents") || "0",
|
||||||
|
minQuantity: rawMinQuantity === "" ? undefined : rawMinQuantity,
|
||||||
|
maxUses: rawMax === "" ? undefined : rawMax,
|
||||||
|
isActive: formData.get("isActive") === "on",
|
||||||
|
validFrom: rawFrom === "" ? undefined : rawFrom,
|
||||||
|
validUntil: rawUntil === "" ? undefined : rawUntil,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const parsed = parseDiscountCodeForm(formData);
|
||||||
|
if (!parsed.success) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await prisma.discountCode.create({ data: parsed.data });
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "discount_code.create",
|
||||||
|
entityType: "DiscountCode",
|
||||||
|
entityId: code.id,
|
||||||
|
detail: code.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/gutscheine");
|
||||||
|
redirect("/admin/gutscheine");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const parsed = parseDiscountCodeForm(formData);
|
||||||
|
if (!parsed.success) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "discount_code.update",
|
||||||
|
entityType: "DiscountCode",
|
||||||
|
entityId: id,
|
||||||
|
detail: parsed.data.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/gutscheine");
|
||||||
|
redirect("/admin/gutscheine");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const deleted = await prisma.discountCode.delete({ where: { id } });
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "discount_code.delete",
|
||||||
|
entityType: "DiscountCode",
|
||||||
|
entityId: id,
|
||||||
|
detail: deleted.code,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/gutscheine");
|
||||||
|
redirect("/admin/gutscheine");
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { requireAdmin, str, num } from "./_shared";
|
||||||
|
|
||||||
|
export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const year = num(formData, "year");
|
||||||
|
const type = str(formData, "type");
|
||||||
|
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
|
||||||
|
|
||||||
|
const harvest = await prisma.harvest.create({
|
||||||
|
data: {
|
||||||
|
year,
|
||||||
|
type,
|
||||||
|
slug,
|
||||||
|
harvestDate: new Date(str(formData, "harvestDate")),
|
||||||
|
location: str(formData, "location") || "Altenmünster",
|
||||||
|
description: str(formData, "description"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "harvest.create",
|
||||||
|
entityType: "Harvest",
|
||||||
|
entityId: harvest.id,
|
||||||
|
detail: `${type} ${year}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/ernten");
|
||||||
|
redirect("/admin/ernten");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
await prisma.harvest.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
year: num(formData, "year"),
|
||||||
|
type: str(formData, "type"),
|
||||||
|
harvestDate: new Date(str(formData, "harvestDate")),
|
||||||
|
location: str(formData, "location") || "Altenmünster",
|
||||||
|
description: str(formData, "description"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
|
||||||
|
|
||||||
|
revalidatePath("/admin/ernten");
|
||||||
|
redirect("/admin/ernten");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBatchAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const code = str(formData, "code");
|
||||||
|
const batch = await prisma.batch.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
harvestId: str(formData, "harvestId"),
|
||||||
|
fillDate: new Date(str(formData, "fillDate")),
|
||||||
|
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
||||||
|
glassCount: num(formData, "glassCount"),
|
||||||
|
remainingGlasses: num(formData, "remainingGlasses"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
||||||
|
|
||||||
|
revalidatePath("/admin/chargen");
|
||||||
|
redirect("/admin/chargen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBatchAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
await prisma.batch.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
fillDate: new Date(str(formData, "fillDate")),
|
||||||
|
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
||||||
|
glassCount: num(formData, "glassCount"),
|
||||||
|
remainingGlasses: num(formData, "remainingGlasses"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
||||||
|
|
||||||
|
revalidatePath("/admin/chargen");
|
||||||
|
redirect("/admin/chargen");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { requireAdmin, str } from "./_shared";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
await prisma.businessInquiry.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "business_inquiry.status_change",
|
||||||
|
entityType: "BusinessInquiry",
|
||||||
|
entityId: id,
|
||||||
|
detail: status,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/anfragen");
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||||
|
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||||
|
import { restockOrderItems } from "@/lib/inventory";
|
||||||
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
import { requireAdmin, str, strOrNull } from "./_shared";
|
||||||
|
|
||||||
|
const ORDER_STATUSES = [
|
||||||
|
"NEU",
|
||||||
|
"BEZAHLT",
|
||||||
|
"IN_VORBEREITUNG",
|
||||||
|
"VERSANDBEREIT",
|
||||||
|
"VERSENDET",
|
||||||
|
"ABHOLBEREIT",
|
||||||
|
"ABGEHOLT",
|
||||||
|
"STORNIERT",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function updateOrderStatusAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const orderId = str(formData, "orderId");
|
||||||
|
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||||
|
if (!ORDER_STATUSES.includes(status)) return;
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const previous = await tx.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
orderNumber: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
locale: true,
|
||||||
|
items: { select: { productId: true, quantity: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!previous) return null;
|
||||||
|
|
||||||
|
await tx.order.update({ where: { id: orderId }, data: { status } });
|
||||||
|
|
||||||
|
// Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht
|
||||||
|
// (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status).
|
||||||
|
if (status === "STORNIERT" && previous.status !== "STORNIERT") {
|
||||||
|
await restockOrderItems(tx, previous.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
|
||||||
|
if (status === "VERSENDET") {
|
||||||
|
shipment = await tx.shipment.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, shippedAt: new Date() },
|
||||||
|
update: { shippedAt: new Date() },
|
||||||
|
select: { carrier: true, trackingNumber: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (status === "ABHOLBEREIT") {
|
||||||
|
await tx.pickup.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, readyAt: new Date() },
|
||||||
|
update: { readyAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (status === "ABGEHOLT") {
|
||||||
|
await tx.pickup.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, pickedUpAt: new Date() },
|
||||||
|
update: { pickedUpAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...previous, shipment };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "order.status_change",
|
||||||
|
entityType: "Order",
|
||||||
|
entityId: orderId,
|
||||||
|
detail: `${result.orderNumber}: ${result.status} → ${status}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
|
||||||
|
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
|
||||||
|
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
|
||||||
|
const locale = isLocale(result.locale) ? result.locale : defaultLocale;
|
||||||
|
if (status === "VERSENDET" && result.status !== "VERSENDET") {
|
||||||
|
const carrier = result.shipment?.carrier ?? null;
|
||||||
|
const trackingNumber = result.shipment?.trackingNumber ?? null;
|
||||||
|
const { subject, html, text } = await shipmentEmail({
|
||||||
|
locale,
|
||||||
|
orderNumber: result.orderNumber,
|
||||||
|
firstName: result.firstName,
|
||||||
|
carrier,
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||||
|
});
|
||||||
|
await sendMail({ to: result.email, subject, html, text });
|
||||||
|
}
|
||||||
|
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
||||||
|
const { subject, html, text } = await pickupReadyEmail({
|
||||||
|
locale,
|
||||||
|
orderNumber: result.orderNumber,
|
||||||
|
firstName: result.firstName,
|
||||||
|
});
|
||||||
|
await sendMail({ to: result.email, subject, html, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/bestellungen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung —
|
||||||
|
* unabhaengig vom Status setzbar, da die Nummer oft schon vor dem
|
||||||
|
* eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die
|
||||||
|
* Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung
|
||||||
|
* per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu
|
||||||
|
* stehen (die zu diesem Zeitpunkt schon verschickt war).
|
||||||
|
*/
|
||||||
|
export async function updateShipmentTrackingAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const orderId = str(formData, "orderId");
|
||||||
|
const carrier = strOrNull(formData, "carrier");
|
||||||
|
const trackingNumber = strOrNull(formData, "trackingNumber");
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
||||||
|
});
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
await prisma.shipment.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, carrier, trackingNumber },
|
||||||
|
update: { carrier, trackingNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "order.tracking_update",
|
||||||
|
entityType: "Order",
|
||||||
|
entityId: orderId,
|
||||||
|
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
||||||
|
const { subject, html, text } = await shipmentEmail({
|
||||||
|
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
firstName: order.firstName,
|
||||||
|
carrier,
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||||
|
});
|
||||||
|
await sendMail({ to: order.email, subject, html, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/bestellungen");
|
||||||
|
revalidatePath(`/admin/bestellungen/${orderId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||||
|
import { adminProductNumbersSchema } from "@/lib/validation";
|
||||||
|
import { requireAdmin, fail, str, strOrNull } from "./_shared";
|
||||||
|
|
||||||
|
export async function createProductAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||||
|
|
||||||
|
const parsedNumbers = adminProductNumbersSchema.safeParse({
|
||||||
|
intensityMild: str(formData, "intensityMild"),
|
||||||
|
intensityFloral: str(formData, "intensityFloral"),
|
||||||
|
intensityKraeftig: str(formData, "intensityKraeftig"),
|
||||||
|
weightGrams: str(formData, "weightGrams"),
|
||||||
|
priceCents: str(formData, "priceCents"),
|
||||||
|
pricePerKgCents: str(formData, "pricePerKgCents"),
|
||||||
|
quantity: str(formData, "quantity"),
|
||||||
|
});
|
||||||
|
if (!parsedNumbers.success) {
|
||||||
|
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
}
|
||||||
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
|
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
||||||
|
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||||
|
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
|
||||||
|
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
|
||||||
|
const isHoney = category === "HONIG";
|
||||||
|
|
||||||
|
const product = await prisma.product.create({
|
||||||
|
data: {
|
||||||
|
slug,
|
||||||
|
name: str(formData, "name"),
|
||||||
|
category,
|
||||||
|
harvestId: isHoney ? strOrNull(formData, "harvestId") : null,
|
||||||
|
batchId: isHoney ? strOrNull(formData, "batchId") : null,
|
||||||
|
description: str(formData, "description"),
|
||||||
|
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
|
||||||
|
intensityMild: numbers.intensityMild,
|
||||||
|
intensityFloral: numbers.intensityFloral,
|
||||||
|
intensityKraeftig: numbers.intensityKraeftig,
|
||||||
|
consistency: str(formData, "consistency") || "feincremig",
|
||||||
|
weightGrams: numbers.weightGrams,
|
||||||
|
priceCents: numbers.priceCents,
|
||||||
|
pricePerKgCents: numbers.pricePerKgCents,
|
||||||
|
isActive: formData.get("isActive") === "on",
|
||||||
|
inventory: { create: { quantity: numbers.quantity } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "product.create",
|
||||||
|
entityType: "Product",
|
||||||
|
entityId: product.id,
|
||||||
|
detail: product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/produkte");
|
||||||
|
redirect("/admin/produkte");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const parsedNumbers = adminProductNumbersSchema.safeParse({
|
||||||
|
intensityMild: str(formData, "intensityMild"),
|
||||||
|
intensityFloral: str(formData, "intensityFloral"),
|
||||||
|
intensityKraeftig: str(formData, "intensityKraeftig"),
|
||||||
|
weightGrams: str(formData, "weightGrams"),
|
||||||
|
priceCents: str(formData, "priceCents"),
|
||||||
|
pricePerKgCents: str(formData, "pricePerKgCents"),
|
||||||
|
quantity: str(formData, "quantity"),
|
||||||
|
});
|
||||||
|
if (!parsedNumbers.success) {
|
||||||
|
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
}
|
||||||
|
const numbers = parsedNumbers.data;
|
||||||
|
|
||||||
|
const previousInventory = await prisma.inventory.findUnique({
|
||||||
|
where: { productId: id },
|
||||||
|
select: { quantity: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const product = await prisma.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: str(formData, "name"),
|
||||||
|
description: str(formData, "description"),
|
||||||
|
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
|
||||||
|
intensityMild: numbers.intensityMild,
|
||||||
|
intensityFloral: numbers.intensityFloral,
|
||||||
|
intensityKraeftig: numbers.intensityKraeftig,
|
||||||
|
consistency: str(formData, "consistency") || "feincremig",
|
||||||
|
weightGrams: numbers.weightGrams,
|
||||||
|
priceCents: numbers.priceCents,
|
||||||
|
pricePerKgCents: numbers.pricePerKgCents,
|
||||||
|
isActive: formData.get("isActive") === "on",
|
||||||
|
inventory: {
|
||||||
|
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "product.update",
|
||||||
|
entityType: "Product",
|
||||||
|
entityId: id,
|
||||||
|
detail: product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
|
||||||
|
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
|
||||||
|
if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) {
|
||||||
|
await notifyStockAvailable(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/produkte");
|
||||||
|
redirect("/admin/produkte");
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { requireAdmin, str } from "./_shared";
|
||||||
|
|
||||||
|
export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const review = await prisma.review.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isApproved: true },
|
||||||
|
select: { product: { select: { slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "review.approve",
|
||||||
|
entityType: "Review",
|
||||||
|
entityId: id,
|
||||||
|
detail: review.product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/bewertungen");
|
||||||
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const review = await prisma.review.delete({
|
||||||
|
where: { id },
|
||||||
|
select: { product: { select: { slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "review.delete",
|
||||||
|
entityType: "Review",
|
||||||
|
entityId: id,
|
||||||
|
detail: review.product.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/bewertungen");
|
||||||
|
revalidatePath(`/honig/${review.product.slug}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
function parseShippingTierForm(formData: FormData) {
|
||||||
|
const rawMax = str(formData, "maxQuantity");
|
||||||
|
return shippingTierSchema.safeParse({
|
||||||
|
minQuantity: str(formData, "minQuantity"),
|
||||||
|
maxQuantity: rawMax === "" ? undefined : rawMax,
|
||||||
|
priceCents: str(formData, "priceCents"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createShippingTierAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const parsed = parseShippingTierForm(formData);
|
||||||
|
if (!parsed.success) {
|
||||||
|
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = await prisma.shippingTier.create({
|
||||||
|
data: {
|
||||||
|
minQuantity: parsed.data.minQuantity,
|
||||||
|
maxQuantity: parsed.data.maxQuantity ?? null,
|
||||||
|
priceCents: parsed.data.priceCents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "shipping_tier.create",
|
||||||
|
entityType: "ShippingTier",
|
||||||
|
entityId: tier.id,
|
||||||
|
detail: `ab ${tier.minQuantity} Stück: ${tier.priceCents} Cent`,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/versand");
|
||||||
|
revalidatePath("/warenkorb");
|
||||||
|
revalidatePath("/versand-zahlung");
|
||||||
|
redirect("/admin/versand");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const parsed = parseShippingTierForm(formData);
|
||||||
|
if (!parsed.success) {
|
||||||
|
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.shippingTier.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
minQuantity: parsed.data.minQuantity,
|
||||||
|
maxQuantity: parsed.data.maxQuantity ?? null,
|
||||||
|
priceCents: parsed.data.priceCents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, { action: "shipping_tier.update", entityType: "ShippingTier", entityId: id });
|
||||||
|
|
||||||
|
revalidatePath("/admin/versand");
|
||||||
|
revalidatePath("/warenkorb");
|
||||||
|
revalidatePath("/versand-zahlung");
|
||||||
|
redirect("/admin/versand");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
await prisma.shippingTier.delete({ where: { id } });
|
||||||
|
|
||||||
|
await logAdminAction(admin, { action: "shipping_tier.delete", entityType: "ShippingTier", entityId: id });
|
||||||
|
|
||||||
|
revalidatePath("/admin/versand");
|
||||||
|
revalidatePath("/warenkorb");
|
||||||
|
revalidatePath("/versand-zahlung");
|
||||||
|
redirect("/admin/versand");
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { updateBusinessInquiryStatusAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
NEU: t.adminInquiriesPage.statusNew,
|
||||||
|
IN_BEARBEITUNG: t.adminInquiriesPage.statusInProgress,
|
||||||
|
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
||||||
|
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
||||||
|
};
|
||||||
|
const { q } = await props.searchParams;
|
||||||
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
|
||||||
|
const inquiries = await prisma.businessInquiry.findMany({
|
||||||
|
where: query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ companyName: insensitiveContains(query) },
|
||||||
|
{ contactPerson: insensitiveContains(query) },
|
||||||
|
{ email: insensitiveContains(query) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.title}</h1>
|
||||||
|
|
||||||
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder={t.adminInquiriesPage.searchPlaceholder}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminInquiriesPage.filterButton}
|
||||||
|
</button>
|
||||||
|
{query ? (
|
||||||
|
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
|
||||||
|
{t.adminInquiriesPage.resetFilter}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{inquiries.map((inquiry) => (
|
||||||
|
<div key={inquiry.id} className="bg-background border border-ink/10 p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-forest">{inquiry.companyName}</p>
|
||||||
|
<p className="text-sm text-ink/70">
|
||||||
|
{inquiry.contactPerson} · {inquiry.email}
|
||||||
|
{inquiry.phone ? ` · ${inquiry.phone}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="id" value={inquiry.id} />
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={inquiry.status}
|
||||||
|
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="text-xs text-forest underline">
|
||||||
|
{t.adminInquiriesPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<dl className="mt-4 grid sm:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.packageLabel}</dt>
|
||||||
|
<dd>{inquiry.package}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.quantityLabel}</dt>
|
||||||
|
<dd>{t.adminInquiriesPage.unitsSuffix(inquiry.desiredQuantity)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.deliveryDateLabel}</dt>
|
||||||
|
<dd>{inquiry.desiredDeliveryDate ? formatDate(inquiry.desiredDeliveryDate) : "—"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{inquiry.message ? (
|
||||||
|
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{inquiry.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{inquiries.length === 0 ? (
|
||||||
|
<p className="text-ink/65">
|
||||||
|
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
const RECENT_LIMIT = 200;
|
||||||
|
|
||||||
|
export default async function AdminAuditLogPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const entries = await prisma.auditLog.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: RECENT_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1>
|
||||||
|
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminAuditLogPage.colTimestamp}</th>
|
||||||
|
<th className="p-4">{t.adminAuditLogPage.colAdmin}</th>
|
||||||
|
<th className="p-4">{t.adminAuditLogPage.colAction}</th>
|
||||||
|
<th className="p-4">{t.adminAuditLogPage.colObject}</th>
|
||||||
|
<th className="p-4">{t.adminAuditLogPage.colDetails}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
||||||
|
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
|
||||||
|
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
|
||||||
|
<td className="p-4 whitespace-nowrap text-ink/70">
|
||||||
|
{entry.entityType}
|
||||||
|
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 max-w-sm">{entry.detail ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
updateCustomerAction,
|
||||||
|
blockCustomerAction,
|
||||||
|
unblockCustomerAction,
|
||||||
|
deleteCustomerAction,
|
||||||
|
sendCustomerPasswordResetAction,
|
||||||
|
} from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
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(),
|
||||||
|
prisma.customer.findUnique({ where: { id } }),
|
||||||
|
]);
|
||||||
|
if (!customer) notFound();
|
||||||
|
|
||||||
|
const isSelf = customer.id === admin?.id;
|
||||||
|
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} />
|
||||||
|
<h1 className="text-2xl text-forest mb-2">
|
||||||
|
{customer.firstName} {customer.lastName}
|
||||||
|
</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} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required />
|
||||||
|
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required />
|
||||||
|
</div>
|
||||||
|
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required />
|
||||||
|
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} />
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
defaultValue={customer.role}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="KUNDE">{t.adminUserEditPage.roleCustomer}</option>
|
||||||
|
<option value="ADMIN">{t.adminUserEditPage.roleAdmin}</option>
|
||||||
|
</select>
|
||||||
|
{isSelf ? (
|
||||||
|
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminUserEditPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 bg-background border border-ink/10 p-6 space-y-4">
|
||||||
|
<h2 className="text-sm text-ink/70">{t.adminUserEditPage.securityHeading}</h2>
|
||||||
|
<dl className="text-sm space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-ink/65">{t.adminUserEditPage.twoFactorLabel}</dt>
|
||||||
|
<dd>{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-ink/65">{t.adminUserEditPage.statusLabel}</dt>
|
||||||
|
<dd>
|
||||||
|
{customer.blockedAt ? (
|
||||||
|
<span className="text-red-700">
|
||||||
|
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
|
||||||
|
</span>
|
||||||
|
) : isTemporarilyLocked ? (
|
||||||
|
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-forest">{t.adminUserEditPage.statusActive}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<form action={sendCustomerPasswordResetAction}>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||||
|
{t.adminUserEditPage.sendPasswordReset}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!isSelf ? (
|
||||||
|
<div className="flex items-center gap-4 pt-2 border-t border-ink/10">
|
||||||
|
{customer.blockedAt ? (
|
||||||
|
<form action={unblockCustomerAction}>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="text-forest underline text-sm">
|
||||||
|
{t.adminUserEditPage.unblock}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form action={blockCustomerAction}>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="text-ink/70 underline text-sm">
|
||||||
|
{t.adminUserEditPage.block}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteCustomerAction}
|
||||||
|
confirmMessage={t.adminUserEditPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="text-red-700 underline text-sm">
|
||||||
|
{t.adminUserEditPage.deleteAccount}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
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">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { error } = await props.searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} />
|
||||||
|
<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">
|
||||||
|
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required />
|
||||||
|
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required />
|
||||||
|
</div>
|
||||||
|
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required />
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span>
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
defaultValue="KUNDE"
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="KUNDE">{t.adminUserNewPage.roleCustomer}</option>
|
||||||
|
<option value="ADMIN">{t.adminUserNewPage.roleAdmin}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminUserNewPage.createAndSend}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
required={required}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
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 query = typeof q === "string" ? q.trim() : "";
|
||||||
|
|
||||||
|
const [admin, customers] = await Promise.all([
|
||||||
|
getCurrentAdmin(),
|
||||||
|
prisma.customer.findMany({
|
||||||
|
where: query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ firstName: insensitiveContains(query) },
|
||||||
|
{ lastName: insensitiveContains(query) },
|
||||||
|
{ email: insensitiveContains(query) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
|
||||||
|
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||||
|
{t.adminUsersPage.newUser}
|
||||||
|
</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">
|
||||||
|
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder={t.adminUsersPage.searchPlaceholder}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminUsersPage.filterButton}
|
||||||
|
</button>
|
||||||
|
{query ? (
|
||||||
|
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
|
||||||
|
{t.adminUsersPage.resetFilter}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminUsersPage.colName}</th>
|
||||||
|
<th className="p-4">{t.adminUsersPage.colEmail}</th>
|
||||||
|
<th className="p-4">{t.adminUsersPage.colRole}</th>
|
||||||
|
<th className="p-4">{t.adminUsersPage.colStatus}</th>
|
||||||
|
<th className="p-4">{t.adminUsersPage.col2fa}</th>
|
||||||
|
<th className="p-4">{t.adminUsersPage.colCreated}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{customers.map((customer) => {
|
||||||
|
const isSelf = customer.id === admin?.id;
|
||||||
|
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest">
|
||||||
|
{customer.firstName} {customer.lastName}
|
||||||
|
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{customer.email}</td>
|
||||||
|
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{customer.blockedAt ? (
|
||||||
|
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span>
|
||||||
|
) : isTemporarilyLocked ? (
|
||||||
|
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-forest">{t.adminUsersPage.statusActive}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
|
||||||
|
<td className="p-4">{formatDate(customer.createdAt)}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline">
|
||||||
|
{t.adminUsersPage.edit}
|
||||||
|
</Link>
|
||||||
|
{!isSelf ? (
|
||||||
|
customer.blockedAt ? (
|
||||||
|
<form action={unblockCustomerAction}>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="text-forest underline">
|
||||||
|
{t.adminUsersPage.unblock}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form action={blockCustomerAction}>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="text-ink/70 underline">
|
||||||
|
{t.adminUsersPage.block}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{!isSelf ? (
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteCustomerAction}
|
||||||
|
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={customer.id} />
|
||||||
|
<button type="submit" className="text-red-700 underline">
|
||||||
|
{t.adminUsersPage.delete}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{customers.length === 0 ? (
|
||||||
|
<p className="p-6 text-ink/65">
|
||||||
|
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
|
import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const statusLabels = t.orderStatusBadge;
|
||||||
|
const { id } = await props.params;
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
|
||||||
|
payment: true,
|
||||||
|
shipment: true,
|
||||||
|
pickup: true,
|
||||||
|
invoice: true,
|
||||||
|
customer: { select: { email: true, firstName: true, lastName: true } },
|
||||||
|
discountCode: { select: { code: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
|
||||||
|
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<form action={updateOrderStatusAction} className="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={order.status}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminOrderDetailPage.saveStatus}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.giftMessage ? (
|
||||||
|
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
||||||
|
<p className="whitespace-pre-wrap text-ink">{order.giftMessage}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.customerHeading}</h2>
|
||||||
|
<p>{order.firstName} {order.lastName}</p>
|
||||||
|
<p className="text-ink/70">{order.email}</p>
|
||||||
|
{order.phone ? <p className="text-ink/70">{order.phone}</p> : null}
|
||||||
|
{order.customer ? (
|
||||||
|
<p className="mt-2 text-xs text-honey-dark">{t.adminOrderDetailPage.registeredAccount}</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-ink/65">{t.adminOrderDetailPage.guestOrder}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">
|
||||||
|
{order.fulfillmentMethod === "VERSAND"
|
||||||
|
? t.adminOrderDetailPage.shippingAddressHeading
|
||||||
|
: t.adminOrderDetailPage.pickupHeading}
|
||||||
|
</h2>
|
||||||
|
{order.fulfillmentMethod === "VERSAND" ? (
|
||||||
|
<>
|
||||||
|
<p>{order.street}</p>
|
||||||
|
<p>{order.zip} {order.city}</p>
|
||||||
|
<p className="mt-2 text-xs text-ink/65">
|
||||||
|
{order.shipment?.shippedAt
|
||||||
|
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||||
|
: t.adminOrderDetailPage.notShippedYet}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ink/65">
|
||||||
|
{order.pickup?.pickedUpAt
|
||||||
|
? t.adminOrderDetailPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||||
|
: order.pickup?.readyAt
|
||||||
|
? t.adminOrderDetailPage.readySince(formatDate(order.pickup.readyAt))
|
||||||
|
: t.adminOrderDetailPage.notPickedUpYet}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.fulfillmentMethod === "VERSAND" ? (
|
||||||
|
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
|
||||||
|
<form action={updateShipmentTrackingAction} className="flex flex-wrap items-end gap-4">
|
||||||
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminOrderDetailPage.carrierLabel}</span>
|
||||||
|
<input
|
||||||
|
name="carrier"
|
||||||
|
defaultValue={order.shipment?.carrier ?? ""}
|
||||||
|
placeholder={t.adminOrderDetailPage.carrierPlaceholder}
|
||||||
|
className="mt-1 w-48 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminOrderDetailPage.trackingNumberLabel}</span>
|
||||||
|
<input
|
||||||
|
name="trackingNumber"
|
||||||
|
defaultValue={order.shipment?.trackingNumber ?? ""}
|
||||||
|
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminOrderDetailPage.saveTracking}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.itemsHeading}</h2>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t.adminOrderDetailPage.colProduct}</th>
|
||||||
|
<th className="pb-2">{t.adminOrderDetailPage.colBatch}</th>
|
||||||
|
<th className="pb-2 text-right">{t.adminOrderDetailPage.colQuantity}</th>
|
||||||
|
<th className="pb-2 text-right">{t.adminOrderDetailPage.colTotal}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{order.items.map((item) => (
|
||||||
|
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="py-2">
|
||||||
|
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline">
|
||||||
|
{item.productName}
|
||||||
|
</Link>{" "}
|
||||||
|
<span className="text-ink/65">({item.weightGrams} g)</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||||
|
<td className="py-2 text-right">{item.quantity}</td>
|
||||||
|
<td className="py-2 text-right">{formatPrice(item.totalCents, order.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<div className="w-56 space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-ink/70">{t.adminOrderDetailPage.subtotalLabel}</span>
|
||||||
|
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-ink/70">{t.adminOrderDetailPage.shippingLabel}</span>
|
||||||
|
<span>
|
||||||
|
{order.shippingCents === 0
|
||||||
|
? t.adminOrderDetailPage.freeShipping
|
||||||
|
: formatPrice(order.shippingCents, order.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{order.discountCents > 0 ? (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-ink/70">
|
||||||
|
{t.adminOrderDetailPage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
|
||||||
|
</span>
|
||||||
|
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-between text-forest pt-1 border-t border-ink/10">
|
||||||
|
<span>{t.adminOrderDetailPage.totalLabel}</span>
|
||||||
|
<span>{formatPrice(order.totalCents, order.currency)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.paymentHeading}</h2>
|
||||||
|
{order.payment ? (
|
||||||
|
<>
|
||||||
|
<p>{order.payment.status}</p>
|
||||||
|
<p className="text-ink/70">{formatPrice(order.payment.amountCents, order.payment.currency)}</p>
|
||||||
|
{order.payment.paidAt ? (
|
||||||
|
<p className="text-xs text-ink/65 mt-1">
|
||||||
|
{t.adminOrderDetailPage.paidOn(formatDate(order.payment.paidAt))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-ink/65">{t.adminOrderDetailPage.noPaymentYet}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.invoiceHeading}</h2>
|
||||||
|
{order.invoice ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>{order.invoice.invoiceNumber}</span>
|
||||||
|
<a
|
||||||
|
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
|
||||||
|
className="text-forest underline text-sm"
|
||||||
|
>
|
||||||
|
{t.adminOrderDetailPage.downloadPdf}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
|
import { OrderStatusBadge } from "@/components/account-ui";
|
||||||
|
import { updateOrderStatusAction } from "../actions";
|
||||||
|
import type { OrderStatus } from "@/generated/prisma/client";
|
||||||
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const statusLabels = t.orderStatusBadge;
|
||||||
|
const { q, status } = await props.searchParams;
|
||||||
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||||
|
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
...(statusFilter ? { status: statusFilter } : {}),
|
||||||
|
...(query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ orderNumber: insensitiveContains(query) },
|
||||||
|
{ email: insensitiveContains(query) },
|
||||||
|
{ lastName: insensitiveContains(query) },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
|
||||||
|
|
||||||
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder={t.adminOrdersPage.searchPlaceholder}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={statusFilter ?? ""}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t.adminOrdersPage.statusAll}</option>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminOrdersPage.filterButton}
|
||||||
|
</button>
|
||||||
|
{query || statusFilter ? (
|
||||||
|
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline">
|
||||||
|
{t.adminOrdersPage.resetFilter}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colNumber}</th>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colCustomer}</th>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colDate}</th>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colItems}</th>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
|
||||||
|
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest">
|
||||||
|
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
||||||
|
{order.orderNumber}
|
||||||
|
</Link>
|
||||||
|
{order.giftMessage ? (
|
||||||
|
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}>
|
||||||
|
🎁
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{order.firstName} {order.lastName}
|
||||||
|
<br />
|
||||||
|
<span className="text-ink/65 text-xs">{order.email}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{formatDate(order.createdAt)}</td>
|
||||||
|
<td className="p-4">{order.items.length}</td>
|
||||||
|
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||||
|
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
|
||||||
|
<input type="hidden" name="orderId" value={order.id} />
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={order.status}
|
||||||
|
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="text-xs text-forest underline">
|
||||||
|
{t.adminOrdersPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="p-6 text-ink/65">
|
||||||
|
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
|
import { approveReviewAction, deleteReviewAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { q } = await props.searchParams;
|
||||||
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
|
||||||
|
const reviews = await prisma.review.findMany({
|
||||||
|
where: query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ product: { name: insensitiveContains(query) } },
|
||||||
|
{ customer: { firstName: insensitiveContains(query) } },
|
||||||
|
{ customer: { lastName: insensitiveContains(query) } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
include: {
|
||||||
|
product: { select: { name: true, slug: true } },
|
||||||
|
customer: { select: { firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminReviewsPage.title}</h1>
|
||||||
|
|
||||||
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder={t.adminReviewsPage.searchPlaceholder}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminReviewsPage.filterButton}
|
||||||
|
</button>
|
||||||
|
{query ? (
|
||||||
|
<Link href="/admin/bewertungen" className="text-sm text-ink/65 underline">
|
||||||
|
{t.adminReviewsPage.resetFilter}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
|
||||||
|
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
|
||||||
|
<th className="p-4">{t.adminReviewsPage.colRating}</th>
|
||||||
|
<th className="p-4">{t.adminReviewsPage.colComment}</th>
|
||||||
|
<th className="p-4">{t.adminReviewsPage.colDate}</th>
|
||||||
|
<th className="p-4">{t.adminReviewsPage.colStatus}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{reviews.map((review) => (
|
||||||
|
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4">
|
||||||
|
<Link href={`/honig/${review.product.slug}`} className="text-forest underline">
|
||||||
|
{review.product.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{review.customer.firstName} {review.customer.lastName}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-honey-dark whitespace-nowrap">
|
||||||
|
{"★".repeat(review.rating)}
|
||||||
|
{"☆".repeat(5 - review.rating)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
||||||
|
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{review.isApproved ? (
|
||||||
|
<span className="text-forest">{t.adminReviewsPage.statusPublished}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-honey-dark">{t.adminReviewsPage.statusPending}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
{!review.isApproved ? (
|
||||||
|
<form action={approveReviewAction}>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<button type="submit" className="text-forest underline">
|
||||||
|
{t.adminReviewsPage.approve}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteReviewAction}
|
||||||
|
confirmMessage={t.adminReviewsPage.deleteConfirm}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={review.id} />
|
||||||
|
<button type="submit" className="text-red-700 underline">
|
||||||
|
{t.adminReviewsPage.delete}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{reviews.length === 0 ? (
|
||||||
|
<p className="p-6 text-ink/65">
|
||||||
|
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { updateBatchAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { id } = await props.params;
|
||||||
|
const batch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
|
||||||
|
if (!batch) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<BackLink href="/admin/chargen" label={t.adminBatchEditPage.backToBatches} />
|
||||||
|
<h1 className="text-2xl text-forest mb-2">{t.adminBatchEditPage.title}</h1>
|
||||||
|
<p className="text-sm text-ink/65 mb-8">
|
||||||
|
{batch.code} · {batch.harvest.type} {batch.harvest.year}
|
||||||
|
</p>
|
||||||
|
<form action={updateBatchAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
|
<input type="hidden" name="id" value={batch.id} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchEditPage.fillDateLabel}</span>
|
||||||
|
<input
|
||||||
|
name="fillDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={batch.fillDate.toISOString().slice(0, 10)}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchEditPage.bestBeforeDateLabel}</span>
|
||||||
|
<input
|
||||||
|
name="bestBeforeDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchEditPage.glassCountLabel}</span>
|
||||||
|
<input
|
||||||
|
name="glassCount"
|
||||||
|
type="number"
|
||||||
|
defaultValue={batch.glassCount}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchEditPage.remainingGlassesLabel}</span>
|
||||||
|
<input
|
||||||
|
name="remainingGlasses"
|
||||||
|
type="number"
|
||||||
|
defaultValue={batch.remainingGlasses}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminBatchEditPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { createBatchAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminBatchesPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const [batches, harvests] = await Promise.all([
|
||||||
|
prisma.batch.findMany({
|
||||||
|
orderBy: { fillDate: "desc" },
|
||||||
|
include: { harvest: true },
|
||||||
|
}),
|
||||||
|
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminBatchesPage.colBatch}</th>
|
||||||
|
<th className="p-4">{t.adminBatchesPage.colHarvest}</th>
|
||||||
|
<th className="p-4">{t.adminBatchesPage.colFillDate}</th>
|
||||||
|
<th className="p-4">{t.adminBatchesPage.colBestBefore}</th>
|
||||||
|
<th className="p-4">{t.adminBatchesPage.colTotalGlasses}</th>
|
||||||
|
<th className="p-4">{t.adminBatchesPage.colRemaining}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{batches.map((batch) => (
|
||||||
|
<tr key={batch.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest">{batch.code}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{batch.harvest.type} {batch.harvest.year}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{formatDate(batch.fillDate)}</td>
|
||||||
|
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td>
|
||||||
|
<td className="p-4">{batch.glassCount}</td>
|
||||||
|
<td className="p-4">{batch.remainingGlasses}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline">
|
||||||
|
{t.adminBatchesPage.edit}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{batches.length === 0 ? <p className="p-6 text-ink/65">{t.adminBatchesPage.noBatchesYet}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||||
|
<h2 className="text-lg text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||||
|
<form action={createBatchAction} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
|
||||||
|
<input
|
||||||
|
name="code"
|
||||||
|
required
|
||||||
|
placeholder={t.adminBatchesPage.codePlaceholder}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchesPage.harvestLabel}</span>
|
||||||
|
<select name="harvestId" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||||
|
{harvests.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.type} {h.year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span>
|
||||||
|
<input name="fillDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchesPage.bestBeforeDateLabel}</span>
|
||||||
|
<input name="bestBeforeDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span>
|
||||||
|
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span>
|
||||||
|
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminBatchesPage.create}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { updateHarvestAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { id } = await props.params;
|
||||||
|
const harvest = await prisma.harvest.findUnique({ where: { id } });
|
||||||
|
if (!harvest) notFound();
|
||||||
|
|
||||||
|
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
|
||||||
|
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||||
|
<input type="hidden" name="id" value={harvest.id} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminHarvestEditPage.typeLabel}</span>
|
||||||
|
<input
|
||||||
|
name="type"
|
||||||
|
defaultValue={harvest.type}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminHarvestEditPage.yearLabel}</span>
|
||||||
|
<input
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
defaultValue={harvest.year}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminHarvestEditPage.harvestDateLabel}</span>
|
||||||
|
<input
|
||||||
|
name="harvestDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={harvestDateValue}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminHarvestEditPage.locationLabel}</span>
|
||||||
|
<input
|
||||||
|
name="location"
|
||||||
|
defaultValue={harvest.location}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminHarvestEditPage.descriptionLabel}</span>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
defaultValue={harvest.description}
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminHarvestEditPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { createHarvestAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminHarvestsPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const harvests = await prisma.harvest.findMany({
|
||||||
|
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||||
|
include: { _count: { select: { batches: true, products: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminHarvestsPage.colType}</th>
|
||||||
|
<th className="p-4">{t.adminHarvestsPage.colYear}</th>
|
||||||
|
<th className="p-4">{t.adminHarvestsPage.colHarvestDate}</th>
|
||||||
|
<th className="p-4">{t.adminHarvestsPage.colLocation}</th>
|
||||||
|
<th className="p-4">{t.adminHarvestsPage.colBatches}</th>
|
||||||
|
<th className="p-4">{t.adminHarvestsPage.colProducts}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{harvests.map((harvest) => (
|
||||||
|
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest">{harvest.type}</td>
|
||||||
|
<td className="p-4">{harvest.year}</td>
|
||||||
|
<td className="p-4">{formatDate(harvest.harvestDate)}</td>
|
||||||
|
<td className="p-4">{harvest.location}</td>
|
||||||
|
<td className="p-4">{harvest._count.batches}</td>
|
||||||
|
<td className="p-4">{harvest._count.products}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline">
|
||||||
|
{t.adminHarvestsPage.edit}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||||
|
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||||
|
<form action={createHarvestAction} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||||
|
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||||
|
</div>
|
||||||
|
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||||
|
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||||
|
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminHarvestsPage.create}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
|
export default function AdminError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-20 text-center">
|
||||||
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
||||||
|
<h1 className="text-2xl text-forest">{t.errorPage.title}</h1>
|
||||||
|
<p className="mt-3 text-ink/70">{t.errorPage.description}</p>
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-4">
|
||||||
|
<Button type="button" onClick={() => reset()}>
|
||||||
|
{t.errorPage.retry}
|
||||||
|
</Button>
|
||||||
|
<Link href="/admin" className="text-sm text-forest underline">
|
||||||
|
{t.adminVouchersPage.backToDashboard}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { updateDiscountCodeAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
function toDateInputValue(date: Date | null): string {
|
||||||
|
if (!date) return "";
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<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} />
|
||||||
|
<Field name="code" label={t.adminVoucherEditPage.codeLabel} defaultValue={code.code} required />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminVoucherEditPage.typeLabel}</span>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
defaultValue={code.type}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="PERCENT">{t.adminVoucherEditPage.typePercent}</option>
|
||||||
|
<option value="FIXED">{t.adminVoucherEditPage.typeFixed}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Field name="value" label={t.adminVoucherEditPage.valueLabel} type="number" defaultValue={String(code.value)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
name="minOrderCents"
|
||||||
|
label={t.adminVoucherEditPage.minOrderLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(code.minOrderCents)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="minQuantity"
|
||||||
|
label={t.adminVoucherEditPage.minQuantityLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="maxUses"
|
||||||
|
label={t.adminVoucherEditPage.maxUsesLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
name="validFrom"
|
||||||
|
label={t.adminVoucherEditPage.validFromLabel}
|
||||||
|
type="date"
|
||||||
|
defaultValue={toDateInputValue(code.validFrom)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="validUntil"
|
||||||
|
label={t.adminVoucherEditPage.validUntilLabel}
|
||||||
|
type="date"
|
||||||
|
defaultValue={toDateInputValue(code.validUntil)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked={code.isActive} />
|
||||||
|
{t.adminVoucherEditPage.activeLabel}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-xs text-ink/65">{t.adminVoucherEditPage.redeemedSoFar(code.usedCount, code.maxUses)}</p>
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminVoucherEditPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
min={0}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
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">) {
|
||||||
|
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 />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminVoucherNewPage.typeLabel}</span>
|
||||||
|
<select name="type" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||||
|
<option value="PERCENT">{t.adminVoucherNewPage.typePercent}</option>
|
||||||
|
<option value="FIXED">{t.adminVoucherNewPage.typeFixed}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Field name="value" label={t.adminVoucherNewPage.valueLabel} type="number" placeholder="10" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field name="minOrderCents" label={t.adminVoucherNewPage.minOrderLabel} type="number" placeholder="0" />
|
||||||
|
<Field name="minQuantity" label={t.adminVoucherNewPage.minQuantityLabel} type="number" placeholder="3" />
|
||||||
|
<Field name="maxUses" label={t.adminVoucherNewPage.maxUsesLabel} type="number" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field name="validFrom" label={t.adminVoucherNewPage.validFromLabel} type="date" />
|
||||||
|
<Field name="validUntil" label={t.adminVoucherNewPage.validUntilLabel} type="date" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked />
|
||||||
|
{t.adminVoucherNewPage.activeLabel}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminVoucherNewPage.create}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
min={0}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
|
import { deleteDiscountCodeAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { error } = await props.searchParams;
|
||||||
|
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-2xl text-forest">{t.adminVouchersPage.title}</h1>
|
||||||
|
<Link href="/admin/gutscheine/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||||
|
{t.adminVouchersPage.newVoucher}
|
||||||
|
</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">
|
||||||
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colCode}</th>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colDiscount}</th>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colMinOrder}</th>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colMinQuantity}</th>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colUsage}</th>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colValidity}</th>
|
||||||
|
<th className="p-4">{t.adminVouchersPage.colStatus}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{codes.map((code) => (
|
||||||
|
<tr key={code.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest font-mono">{code.code}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
|
||||||
|
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{code.usedCount}
|
||||||
|
{code.maxUses !== null ? ` / ${code.maxUses}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 whitespace-nowrap">
|
||||||
|
{code.validFrom ? formatDate(code.validFrom) : t.adminVouchersPage.immediately} –{" "}
|
||||||
|
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{code.isActive ? (
|
||||||
|
<span className="text-forest">{t.adminVouchersPage.statusActive}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink/65">{t.adminVouchersPage.statusInactive}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline">
|
||||||
|
{t.adminVouchersPage.edit}
|
||||||
|
</Link>
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteDiscountCodeAction}
|
||||||
|
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={code.id} />
|
||||||
|
<button type="submit" className="text-red-700 underline">
|
||||||
|
{t.adminVouchersPage.delete}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{codes.length === 0 ? <p className="p-6 text-ink/65">{t.adminVouchersPage.noVouchersYet}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||||
|
import { updateSiteContentAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||||
|
text: 1,
|
||||||
|
textarea: 3,
|
||||||
|
"list-simple": 4,
|
||||||
|
"list-titled": 5,
|
||||||
|
"list-numbered": 6,
|
||||||
|
"richtext-sections": 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
||||||
|
const rows = await prisma.siteContent.findMany();
|
||||||
|
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
|
const { saved } = await props.searchParams;
|
||||||
|
const current = await loadCurrentValues();
|
||||||
|
|
||||||
|
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||||
|
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||||
|
{ key: "home", heading: t.adminContentPage.groupHome },
|
||||||
|
{ key: "legal", heading: t.adminContentPage.groupLegal },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
|
||||||
|
<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}
|
||||||
|
|
||||||
|
{groups.map((group) => {
|
||||||
|
const fields = CONTENT_REGISTRY.filter((f) => f.group === group.key);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
key={group.key}
|
||||||
|
action={updateSiteContentAction}
|
||||||
|
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="group" value={group.key} />
|
||||||
|
<h2 className="text-lg text-forest">{group.heading}</h2>
|
||||||
|
|
||||||
|
{fields.map((def) => {
|
||||||
|
const row = current.get(def.key);
|
||||||
|
const rows = ROWS_BY_TYPE[def.type];
|
||||||
|
const label = locale === "de" ? def.labelDe : def.labelEn;
|
||||||
|
|
||||||
|
if (!def.localized) {
|
||||||
|
return (
|
||||||
|
<label key={def.key} className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
{def.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
name={def.key}
|
||||||
|
rows={rows}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={def.key}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTextarea = def.type !== "text";
|
||||||
|
return (
|
||||||
|
<div key={def.key} className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
|
||||||
|
<p className="text-sm text-ink/70 mb-2">{label}</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="text-ink/50">{t.adminContentPage.deLabel}</span>
|
||||||
|
{isTextarea ? (
|
||||||
|
<textarea
|
||||||
|
name={`de::${def.key}`}
|
||||||
|
rows={rows}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`de::${def.key}`}
|
||||||
|
defaultValue={row?.valueDe ?? ""}
|
||||||
|
placeholder={def.getDefault("de")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="text-ink/50">{t.adminContentPage.enLabel}</span>
|
||||||
|
{isTextarea ? (
|
||||||
|
<textarea
|
||||||
|
name={`en::${def.key}`}
|
||||||
|
rows={rows}
|
||||||
|
defaultValue={row?.valueEn ?? ""}
|
||||||
|
placeholder={def.getDefault("en")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={`en::${def.key}`}
|
||||||
|
defaultValue={row?.valueEn ?? ""}
|
||||||
|
placeholder={def.getDefault("en")}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminContentPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentAdmin } from "@/lib/auth";
|
||||||
|
import { logoutAction } from "@/app/konto/actions";
|
||||||
|
import { AdminShell } from "@/components/admin-shell";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
||||||
|
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Einzelne Admin-Unterseiten (bestellungen, produkte, ...) haben bewusst
|
||||||
|
// keine eigene Metadata — der Bereich ist ueber robots.ts von der Indexierung
|
||||||
|
// ausgeschlossen. Dieses Template sorgt trotzdem fuer einen sinnvollen
|
||||||
|
// Browser-Tab-Titel statt des generischen Startseiten-Titels ueberall im
|
||||||
|
// Admin-Bereich, ohne jede Unterseite einzeln pflegen zu muessen.
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return {
|
||||||
|
title: { default: t.account.navAdmin.title, template: `%s | ${t.account.navAdmin.title}` },
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||||
|
// Der Proxy (src/proxy.ts) blockt fremde Requests bereits vor dem Rendern;
|
||||||
|
// diese zweite, gegen die Datenbank autoritative Pruefung faengt u.a. den
|
||||||
|
// Fall ab, dass die ADMIN-Rolle eines eingeloggten Kontos nach dem Login
|
||||||
|
// 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."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
||||||
|
// Bestellungen, Rueckerstattungen und Kundendaten — ohne 2FA waere ein
|
||||||
|
// 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.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminShell
|
||||||
|
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdminShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Generisches Suspense-Skeleton fuer den gesamten Admin-Bereich (alle
|
||||||
|
* Unterseiten sind "force-dynamic" und laden bei jeder Navigation neu
|
||||||
|
* gegen die DB) — orientiert an der haeufigsten Admin-Ansicht (Tabelle mit
|
||||||
|
* Filterzeile), statt pro Unterseite ein eigenes Skeleton zu pflegen.
|
||||||
|
*/
|
||||||
|
export default function AdminLoading() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-4 w-32 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="mt-6 h-8 w-56 bg-ink/10 rounded-sm" />
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="h-9 w-32 border border-ink/10 bg-ink/5" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 border border-ink/10">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className="p-4 border-b border-ink/10 last:border-b-0 flex gap-6">
|
||||||
|
<div className="h-4 w-24 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="h-4 w-40 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="h-4 w-20 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="h-4 w-16 bg-ink/10 rounded-sm" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
const DASHBOARD_ICON_PROPS = {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
function CoinIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...DASHBOARD_ICON_PROPS}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v10M9 9.5a2 2 0 0 1 2-1.5h1.5a2 2 0 0 1 0 4H11a2 2 0 0 0 0 4h1.5a2 2 0 0 0 2-1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrdersIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...DASHBOARD_ICON_PROPS}>
|
||||||
|
<path d="M4 8l8-4 8 4-8 4-8-4z" />
|
||||||
|
<path d="M4 8v8l8 4 8-4V8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JarIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...DASHBOARD_ICON_PROPS}>
|
||||||
|
<path d="M8 3h8M9 3v3.5L6.5 9A3 3 0 0 0 5.5 11.3V19a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-7.7A3 3 0 0 0 17.5 9L15 6.5V3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PeopleIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...DASHBOARD_ICON_PROPS}>
|
||||||
|
<circle cx="9" cy="8" r="3" />
|
||||||
|
<path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||||
|
<path d="M16 4.5a3 3 0 0 1 0 6" />
|
||||||
|
<path d="M18.5 14.3c2 .6 3.5 2.7 3.5 5.7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAT_KEYS = [
|
||||||
|
"revenueToday",
|
||||||
|
"revenueMonth",
|
||||||
|
"openOrders",
|
||||||
|
"inventory",
|
||||||
|
"soldGlasses",
|
||||||
|
"avgCart",
|
||||||
|
"repeatCustomers",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const statIcons: Record<(typeof STAT_KEYS)[number], () => ReturnType<typeof CoinIcon>> = {
|
||||||
|
revenueToday: CoinIcon,
|
||||||
|
revenueMonth: CoinIcon,
|
||||||
|
openOrders: OrdersIcon,
|
||||||
|
inventory: JarIcon,
|
||||||
|
soldGlasses: JarIcon,
|
||||||
|
avgCart: CoinIcon,
|
||||||
|
repeatCustomers: PeopleIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
function startOfDay(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfMonth(): Date {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVENUE_TREND_DAYS = 14;
|
||||||
|
|
||||||
|
function daysAgo(n: number): Date {
|
||||||
|
const d = startOfDay();
|
||||||
|
d.setDate(d.getDate() - n);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminDashboardPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const [
|
||||||
|
revenueToday,
|
||||||
|
revenueMonth,
|
||||||
|
openOrders,
|
||||||
|
inventorySum,
|
||||||
|
soldGlasses,
|
||||||
|
orderStats,
|
||||||
|
soldItems,
|
||||||
|
recentOrders,
|
||||||
|
repeatCustomerGroups,
|
||||||
|
topDiscountCodes,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.order.aggregate({
|
||||||
|
_sum: { totalCents: true },
|
||||||
|
where: { createdAt: { gte: startOfDay() }, status: { not: "STORNIERT" } },
|
||||||
|
}),
|
||||||
|
prisma.order.aggregate({
|
||||||
|
_sum: { totalCents: true },
|
||||||
|
where: { createdAt: { gte: startOfMonth() }, status: { not: "STORNIERT" } },
|
||||||
|
}),
|
||||||
|
prisma.order.count({
|
||||||
|
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
||||||
|
}),
|
||||||
|
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
||||||
|
prisma.orderItem.aggregate({ _sum: { quantity: true } }),
|
||||||
|
prisma.order.aggregate({
|
||||||
|
_avg: { totalCents: true },
|
||||||
|
_count: true,
|
||||||
|
where: { status: { not: "STORNIERT" } },
|
||||||
|
}),
|
||||||
|
// Fuer Top-Produkte/Umsatz nach Kategorie: kein Prisma-groupBy ueber
|
||||||
|
// Relationen moeglich, deshalb Rohdaten laden und im Code aggregieren
|
||||||
|
// (bei der Groessenordnung dieses Shops unproblematisch).
|
||||||
|
prisma.orderItem.findMany({
|
||||||
|
where: { order: { status: { not: "STORNIERT" } } },
|
||||||
|
select: {
|
||||||
|
productId: true,
|
||||||
|
productName: true,
|
||||||
|
quantity: true,
|
||||||
|
totalCents: true,
|
||||||
|
product: { select: { category: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.order.findMany({
|
||||||
|
where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } },
|
||||||
|
select: { createdAt: true, totalCents: true },
|
||||||
|
}),
|
||||||
|
// Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung.
|
||||||
|
// Kein Prisma-groupBy mit HAVING count > 1 in einer Abfrage moeglich,
|
||||||
|
// deshalb Rohdaten laden und im Code gruppieren.
|
||||||
|
prisma.order.findMany({
|
||||||
|
where: { customerId: { not: null }, status: { not: "STORNIERT" } },
|
||||||
|
select: { customerId: true },
|
||||||
|
}),
|
||||||
|
prisma.discountCode.findMany({
|
||||||
|
where: { usedCount: { gt: 0 } },
|
||||||
|
orderBy: { usedCount: "desc" },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ordersPerCustomer = new Map<string, number>();
|
||||||
|
for (const order of repeatCustomerGroups) {
|
||||||
|
if (!order.customerId) continue;
|
||||||
|
ordersPerCustomer.set(order.customerId, (ordersPerCustomer.get(order.customerId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length;
|
||||||
|
|
||||||
|
const statLabels: Record<(typeof STAT_KEYS)[number], string> = {
|
||||||
|
revenueToday: t.adminDashboardPage.statRevenueToday,
|
||||||
|
revenueMonth: t.adminDashboardPage.statRevenueMonth,
|
||||||
|
openOrders: t.adminDashboardPage.statOpenOrders,
|
||||||
|
inventory: t.adminDashboardPage.statInventory,
|
||||||
|
soldGlasses: t.adminDashboardPage.statSoldGlasses,
|
||||||
|
avgCart: t.adminDashboardPage.statAvgCart,
|
||||||
|
repeatCustomers: t.adminDashboardPage.statRepeatCustomers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats: { key: (typeof STAT_KEYS)[number]; value: string }[] = [
|
||||||
|
{ key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) },
|
||||||
|
{ key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) },
|
||||||
|
{ key: "openOrders", value: String(openOrders) },
|
||||||
|
{ key: "inventory", value: String(inventorySum._sum.quantity ?? 0) },
|
||||||
|
{ key: "soldGlasses", value: String(soldGlasses._sum.quantity ?? 0) },
|
||||||
|
{
|
||||||
|
key: "avgCart",
|
||||||
|
value: formatPrice(Math.round(orderStats._avg.totalCents ?? 0)),
|
||||||
|
},
|
||||||
|
{ key: "repeatCustomers", value: String(repeatCustomerCount) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const productTotals = new Map<string, { name: string; quantity: number; revenueCents: number }>();
|
||||||
|
const categoryTotals = new Map<string, { quantity: number; revenueCents: number }>();
|
||||||
|
|
||||||
|
for (const item of soldItems) {
|
||||||
|
const product = productTotals.get(item.productId) ?? {
|
||||||
|
name: item.productName,
|
||||||
|
quantity: 0,
|
||||||
|
revenueCents: 0,
|
||||||
|
};
|
||||||
|
product.quantity += item.quantity;
|
||||||
|
product.revenueCents += item.totalCents;
|
||||||
|
productTotals.set(item.productId, product);
|
||||||
|
|
||||||
|
const category = item.product?.category ?? "HONIG";
|
||||||
|
const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 };
|
||||||
|
categoryTotal.quantity += item.quantity;
|
||||||
|
categoryTotal.revenueCents += item.totalCents;
|
||||||
|
categoryTotals.set(category, categoryTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topProducts = [...productTotals.values()].sort((a, b) => b.revenueCents - a.revenueCents).slice(0, 5);
|
||||||
|
const revenueByCategory = [...categoryTotals.entries()].sort((a, b) => b[1].revenueCents - a[1].revenueCents);
|
||||||
|
const maxCategoryRevenue = Math.max(1, ...revenueByCategory.map(([, v]) => v.revenueCents));
|
||||||
|
|
||||||
|
const revenueByDay = new Map<string, number>();
|
||||||
|
for (let i = REVENUE_TREND_DAYS - 1; i >= 0; i--) {
|
||||||
|
revenueByDay.set(daysAgo(i).toISOString().slice(0, 10), 0);
|
||||||
|
}
|
||||||
|
for (const order of recentOrders) {
|
||||||
|
const key = order.createdAt.toISOString().slice(0, 10);
|
||||||
|
revenueByDay.set(key, (revenueByDay.get(key) ?? 0) + order.totalCents);
|
||||||
|
}
|
||||||
|
const revenueTrend = [...revenueByDay.entries()];
|
||||||
|
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = statIcons[stat.key];
|
||||||
|
const primary = stat.key === "revenueToday";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stat.key}
|
||||||
|
className={`border p-6 flex items-start gap-4 ${
|
||||||
|
primary ? "border-honey/40 bg-honey/5" : "bg-background border-ink/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 border ${
|
||||||
|
primary ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-ink/65 tracking-wide-caps">{statLabels[stat.key]}</p>
|
||||||
|
<p className="mt-1 text-2xl text-forest">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||||
|
{topProducts.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{topProducts.map((product, i) => (
|
||||||
|
<li key={product.name} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-ink/70">
|
||||||
|
{i + 1}. {product.name}{" "}
|
||||||
|
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
||||||
|
{t.adminDashboardPage.revenueByCategoryHeading}
|
||||||
|
</h2>
|
||||||
|
{revenueByCategory.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{revenueByCategory.map(([category, totals]) => (
|
||||||
|
<li key={category}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-ink/70">
|
||||||
|
{t.common.categories[category as keyof typeof t.common.categories] ?? category}
|
||||||
|
</span>
|
||||||
|
<span className="text-forest">{formatPrice(totals.revenueCents)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-ink/10">
|
||||||
|
<div
|
||||||
|
className="h-full bg-honey"
|
||||||
|
style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
||||||
|
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-end gap-1 h-24">
|
||||||
|
{revenueTrend.map(([day, revenueCents]) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="flex-1 h-full flex flex-col justify-end"
|
||||||
|
title={`${day}: ${formatPrice(revenueCents)}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-honey w-full"
|
||||||
|
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-between text-[10px] text-ink/65">
|
||||||
|
<span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span>
|
||||||
|
<span>{t.adminDashboardPage.todayLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||||
|
{topDiscountCodes.length === 0 ? (
|
||||||
|
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{topDiscountCodes.map((code) => (
|
||||||
|
<li key={code.id} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-ink/70">
|
||||||
|
{code.code}{" "}
|
||||||
|
<span className="text-ink/65">
|
||||||
|
({code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-forest whitespace-nowrap">
|
||||||
|
{t.adminDashboardPage.usedCountLabel(code.usedCount, code.maxUses)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-ink/65">
|
||||||
|
<Link href="/admin/bestellungen" className="underline">
|
||||||
|
{t.adminDashboardPage.viewAllOrders}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { asStringArray } from "@/lib/json-array";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { updateProductAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
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 rawProduct = await prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { harvest: true, batch: true, inventory: true },
|
||||||
|
});
|
||||||
|
if (!rawProduct) notFound();
|
||||||
|
const product = { ...rawProduct, tasteTags: asStringArray(rawProduct.tasteTags) };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<BackLink href="/admin/produkte" label={t.adminProductEditPage.backToProducts} />
|
||||||
|
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1>
|
||||||
|
<p className="text-sm text-ink/65 mb-8">
|
||||||
|
{t.common.categories[product.category]}
|
||||||
|
{product.harvest && product.batch
|
||||||
|
? t.adminProductEditPage.harvestBatchSuffix(
|
||||||
|
product.harvest.type,
|
||||||
|
product.harvest.year,
|
||||||
|
product.batch.code
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
<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 />
|
||||||
|
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
defaultValue={product.description}
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
name="tasteTags"
|
||||||
|
label={t.adminProductEditPage.tasteTagsLabel}
|
||||||
|
defaultValue={product.tasteTags.join(", ")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{product.category === "HONIG" ? (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
name="consistency"
|
||||||
|
label={t.adminProductEditPage.consistencyLabel}
|
||||||
|
defaultValue={product.consistency}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Field
|
||||||
|
name="intensityMild"
|
||||||
|
label={t.adminProductEditPage.intensityMildLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.intensityMild)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="intensityFloral"
|
||||||
|
label={t.adminProductEditPage.intensityFloralLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.intensityFloral)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="intensityKraeftig"
|
||||||
|
label={t.adminProductEditPage.intensityKraeftigLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.intensityKraeftig)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
name="weightGrams"
|
||||||
|
label={t.adminProductEditPage.weightLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.weightGrams)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="quantity"
|
||||||
|
label={t.adminProductEditPage.stockLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.inventory?.quantity ?? 0)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
name="priceCents"
|
||||||
|
label={t.adminProductEditPage.priceLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.priceCents)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="pricePerKgCents"
|
||||||
|
label={t.adminProductEditPage.pricePerKgLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(product.pricePerKgCents)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked={product.isActive} />
|
||||||
|
{t.adminProductEditPage.visibleInShop}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminProductEditPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { createProductAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminProductNewPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const [harvests, batches] = await Promise.all([
|
||||||
|
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||||
|
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<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} />
|
||||||
|
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminProductNewPage.categoryLabel}</span>
|
||||||
|
<select name="category" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||||
|
<option value="HONIG">{t.common.categories.HONIG}</option>
|
||||||
|
<option value="KERZEN">{t.common.categories.KERZEN}</option>
|
||||||
|
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-xs text-ink/65">{t.adminProductNewPage.categoryHint}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminProductNewPage.harvestLabel}</span>
|
||||||
|
<select name="harvestId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||||
|
<option value="">{t.adminProductNewPage.noneOption}</option>
|
||||||
|
{harvests.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.type} {h.year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminProductNewPage.batchLabel}</span>
|
||||||
|
<select name="batchId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||||
|
<option value="">{t.adminProductNewPage.noneOption}</option>
|
||||||
|
{batches.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{t.adminProductNewPage.batchOption(b.code, b.harvest.type, b.harvest.year)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.adminProductNewPage.descriptionLabel}</span>
|
||||||
|
<textarea name="description" required rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
name="tasteTags"
|
||||||
|
label={t.adminProductNewPage.tasteTagsLabel}
|
||||||
|
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="consistency"
|
||||||
|
label={t.adminProductNewPage.consistencyLabel}
|
||||||
|
placeholder={t.adminProductNewPage.consistencyPlaceholder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Field
|
||||||
|
name="intensityMild"
|
||||||
|
label={t.adminProductNewPage.intensityMildLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue="3"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="intensityFloral"
|
||||||
|
label={t.adminProductNewPage.intensityFloralLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue="3"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="intensityKraeftig"
|
||||||
|
label={t.adminProductNewPage.intensityKraeftigLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field name="weightGrams" label={t.adminProductNewPage.weightLabel} type="number" required defaultValue="500" />
|
||||||
|
<Field name="quantity" label={t.adminProductNewPage.stockLabel} type="number" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
name="priceCents"
|
||||||
|
label={t.adminProductNewPage.priceLabel}
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder={t.adminProductNewPage.pricePlaceholder}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="pricePerKgCents"
|
||||||
|
label={t.adminProductNewPage.pricePerKgLabel}
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder={t.adminProductNewPage.pricePerKgPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked />
|
||||||
|
{t.adminProductNewPage.visibleInShop}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminProductNewPage.create}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminProductsPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
include: { harvest: true, batch: true, inventory: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminProductsPage.backToDashboard} />
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-2xl text-forest">{t.adminProductsPage.title}</h1>
|
||||||
|
<Link href="/admin/produkte/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||||
|
{t.adminProductsPage.newProduct}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colProduct}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colCategory}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colHarvest}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colBatch}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colWeight}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colPrice}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colStock}</th>
|
||||||
|
<th className="p-4">{t.adminProductsPage.colStatus}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{products.map((product) => (
|
||||||
|
<tr key={product.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest">{product.name}</td>
|
||||||
|
<td className="p-4">{t.common.categories[product.category]}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{product.batch?.code ?? "—"}</td>
|
||||||
|
<td className="p-4">{product.weightGrams} g</td>
|
||||||
|
<td className="p-4">{formatPrice(product.priceCents, product.currency)}</td>
|
||||||
|
<td className="p-4">{product.inventory?.quantity ?? 0}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
|
||||||
|
{t.adminProductsPage.edit}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/app/admin/actions";
|
||||||
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
|
import { logAdminAction } from "@/lib/audit-log";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates";
|
||||||
|
import { restockOrderItems } from "@/lib/inventory";
|
||||||
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const returnRequest = await prisma.returnRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
|
||||||
|
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
|
||||||
|
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return;
|
||||||
|
|
||||||
|
const updated = await prisma.returnRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: status as "ANGENOMMEN" | "ABGELEHNT" },
|
||||||
|
include: { order: { select: { orderNumber: true, email: true, firstName: true, locale: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "return_request.status_change",
|
||||||
|
entityType: "ReturnRequest",
|
||||||
|
entityId: id,
|
||||||
|
detail: `${updated.order.orderNumber}: ${status}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locale = isLocale(updated.order.locale) ? updated.order.locale : defaultLocale;
|
||||||
|
const { subject, html, text } =
|
||||||
|
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 });
|
||||||
|
|
||||||
|
revalidatePath("/admin/retouren");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const id = str(formData, "id");
|
||||||
|
|
||||||
|
const returnRequest = await prisma.returnRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
order: {
|
||||||
|
include: { items: true, payment: { select: { stripePaymentIntentId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Nur eine angenommene Retoure kann erstattet werden — verhindert
|
||||||
|
// Doppel-Erstattung, falls dieselbe Aktion mehrfach ausgeloest wird.
|
||||||
|
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
|
||||||
|
|
||||||
|
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
|
||||||
|
if (!paymentIntentId) {
|
||||||
|
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
await stripe.refunds.create({ payment_intent: paymentIntentId });
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
|
||||||
|
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
|
||||||
|
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
|
||||||
|
// zur Stornierung im Checkout-Webhook.
|
||||||
|
await restockOrderItems(tx, returnRequest.order.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "return_request.refund",
|
||||||
|
entityType: "ReturnRequest",
|
||||||
|
entityId: id,
|
||||||
|
detail: returnRequest.order.orderNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { subject, html, text } = await returnRefundedEmail({
|
||||||
|
locale: isLocale(returnRequest.order.locale) ? returnRequest.order.locale : defaultLocale,
|
||||||
|
orderNumber: returnRequest.order.orderNumber,
|
||||||
|
firstName: returnRequest.order.firstName,
|
||||||
|
});
|
||||||
|
await sendMail({ to: returnRequest.order.email, subject, html, text });
|
||||||
|
|
||||||
|
revalidatePath("/admin/retouren");
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { formatDate, formatPrice } from "@/lib/format";
|
||||||
|
import { insensitiveContains } from "@/lib/prisma-search";
|
||||||
|
import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
ANGEFRAGT: t.adminReturnsPage.statusRequested,
|
||||||
|
ANGENOMMEN: t.adminReturnsPage.statusAccepted,
|
||||||
|
ABGELEHNT: t.adminReturnsPage.statusRejected,
|
||||||
|
ERSTATTET: t.adminReturnsPage.statusRefunded,
|
||||||
|
};
|
||||||
|
const { q } = await props.searchParams;
|
||||||
|
const query = typeof q === "string" ? q.trim() : "";
|
||||||
|
|
||||||
|
const returnRequests = await prisma.returnRequest.findMany({
|
||||||
|
where: query
|
||||||
|
? {
|
||||||
|
order: {
|
||||||
|
OR: [
|
||||||
|
{ orderNumber: insensitiveContains(query) },
|
||||||
|
{ firstName: insensitiveContains(query) },
|
||||||
|
{ lastName: insensitiveContains(query) },
|
||||||
|
{ email: insensitiveContains(query) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
include: {
|
||||||
|
order: { include: { items: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
|
||||||
|
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1>
|
||||||
|
|
||||||
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder={t.adminReturnsPage.searchPlaceholder}
|
||||||
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||||
|
{t.adminReturnsPage.filterButton}
|
||||||
|
</button>
|
||||||
|
{query ? (
|
||||||
|
<Link href="/admin/retouren" className="text-sm text-ink/65 underline">
|
||||||
|
{t.adminReturnsPage.resetFilter}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{returnRequests.map((request) => (
|
||||||
|
<div key={request.id} className="bg-background border border-ink/10 p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-forest">{request.order.orderNumber}</p>
|
||||||
|
<p className="text-sm text-ink/70">
|
||||||
|
{request.order.firstName} {request.order.lastName} · {request.order.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||||
|
{request.order.items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{item.quantity} × {item.productName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-forest">{formatPrice(request.order.totalCents, request.order.currency)}</p>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p>
|
||||||
|
|
||||||
|
{request.status === "ANGEFRAGT" ? (
|
||||||
|
<div className="mt-4 flex gap-4">
|
||||||
|
<form action={updateReturnRequestStatusAction}>
|
||||||
|
<input type="hidden" name="id" value={request.id} />
|
||||||
|
<input type="hidden" name="status" value="ANGENOMMEN" />
|
||||||
|
<button type="submit" className="text-xs text-forest underline">
|
||||||
|
{t.adminReturnsPage.accept}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action={updateReturnRequestStatusAction}>
|
||||||
|
<input type="hidden" name="id" value={request.id} />
|
||||||
|
<input type="hidden" name="status" value="ABGELEHNT" />
|
||||||
|
<button type="submit" className="text-xs text-red-700 underline">
|
||||||
|
{t.adminReturnsPage.reject}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{request.status === "ANGENOMMEN" ? (
|
||||||
|
<form action={refundReturnRequestAction} className="mt-4">
|
||||||
|
<input type="hidden" name="id" value={request.id} />
|
||||||
|
<button type="submit" className="text-xs text-forest underline">
|
||||||
|
{t.adminReturnsPage.refundNow}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{returnRequests.length === 0 ? (
|
||||||
|
<p className="text-ink/65">
|
||||||
|
{query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { updateShippingTierAction } from "../../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
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();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<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} />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
name="minQuantity"
|
||||||
|
label={t.adminShippingEditPage.minQuantityLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(tier.minQuantity)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
name="maxQuantity"
|
||||||
|
label={t.adminShippingEditPage.maxQuantityLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={tier.maxQuantity !== null ? String(tier.maxQuantity) : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
name="priceCents"
|
||||||
|
label={t.adminShippingEditPage.priceLabel}
|
||||||
|
type="number"
|
||||||
|
defaultValue={String(tier.priceCents)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminShippingEditPage.save}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
required={required}
|
||||||
|
min={0}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
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">) {
|
||||||
|
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">
|
||||||
|
<Field name="minQuantity" label={t.adminShippingNewPage.minQuantityLabel} type="number" required placeholder="0" />
|
||||||
|
<Field name="maxQuantity" label={t.adminShippingNewPage.maxQuantityLabel} type="number" placeholder="3" />
|
||||||
|
</div>
|
||||||
|
<Field name="priceCents" label={t.adminShippingNewPage.priceLabel} type="number" required placeholder="700" />
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.adminShippingNewPage.create}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
min={0}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { deleteShippingTierAction } from "../actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { error } = await props.searchParams;
|
||||||
|
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackLink href="/admin" label={t.adminShippingPage.backToDashboard} />
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h1 className="text-2xl text-forest">{t.adminShippingPage.title}</h1>
|
||||||
|
<Link href="/admin/versand/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||||
|
{t.adminShippingPage.newTier}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||||
|
<tr>
|
||||||
|
<th className="p-4">{t.adminShippingPage.colQuantity}</th>
|
||||||
|
<th className="p-4">{t.adminShippingPage.colPrice}</th>
|
||||||
|
<th className="p-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-ink/10">
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<tr key={tier.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||||
|
<td className="p-4 text-forest">
|
||||||
|
{tier.maxQuantity === null
|
||||||
|
? t.adminShippingPage.quantityFrom(tier.minQuantity)
|
||||||
|
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{formatPrice(tier.priceCents)}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
<Link href={`/admin/versand/${tier.id}`} className="text-forest underline">
|
||||||
|
{t.adminShippingPage.edit}
|
||||||
|
</Link>
|
||||||
|
<ConfirmForm
|
||||||
|
action={deleteShippingTierAction}
|
||||||
|
confirmMessage={t.adminShippingPage.deleteConfirm}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={tier.id} />
|
||||||
|
<button type="submit" className="text-red-700 underline">
|
||||||
|
{t.adminShippingPage.delete}
|
||||||
|
</button>
|
||||||
|
</ConfirmForm>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{tiers.length === 0 ? <p className="p-6 text-ink/65">{t.adminShippingPage.noTiersYet}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal-page";
|
||||||
|
import { getContentValue, parseSections } from "@/lib/content";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "AGB", alternates: { canonical: "/agb" } };
|
||||||
|
|
||||||
|
export default async function AgbPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
|
const sections = parseSections(await getContentValue("legal.agb", locale));
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title={t.agbPage.title}
|
||||||
|
noticeText={t.legalPage.reviewNotice}
|
||||||
|
sections={sections}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { businessInquirySchema } from "@/lib/validation";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`business-inquiry:${ip}`, 5, 60 * 60 * 1000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Zu viele Anfragen. Bitte versuche es später erneut." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = businessInquirySchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnstileToken = typeof body?.turnstileToken === "string" ? body.turnstileToken : null;
|
||||||
|
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||||
|
return NextResponse.json({ error: "Die Sicherheitsprüfung ist fehlgeschlagen." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { desiredDeliveryDate, ...rest } = parsed.data;
|
||||||
|
|
||||||
|
await prisma.businessInquiry.create({
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
desiredDeliveryDate: desiredDeliveryDate ? new Date(desiredDeliveryDate) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
|
import { checkoutSchema } from "@/lib/validation";
|
||||||
|
import { getProductsByIds } from "@/lib/data";
|
||||||
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { getShippingCostCents } from "@/lib/shipping";
|
||||||
|
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||||
|
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
|
class OutOfStockError extends Error {}
|
||||||
|
|
||||||
|
function generateOrderNumber(): string {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und
|
||||||
|
// praktisch kollisionsfrei (das @unique-Constraint faengt den Rest ab).
|
||||||
|
const random = randomBytes(4).toString("hex").toUpperCase();
|
||||||
|
return `WH-${year}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Zu viele Bestellversuche. Bitte versuche es später erneut." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = checkoutSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = parsed.data;
|
||||||
|
|
||||||
|
if (input.fulfillmentMethod === "VERSAND") {
|
||||||
|
if (!input.street || !input.zip || !input.city) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Bitte gib eine vollständige Lieferadresse an." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = input.items.map((item) => item.productId);
|
||||||
|
const products = await getProductsByIds(productIds);
|
||||||
|
|
||||||
|
if (products.length !== productIds.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderItemsData = input.items.map((cartItem) => {
|
||||||
|
const product = products.find((p) => p.id === cartItem.productId)!;
|
||||||
|
return {
|
||||||
|
productId: product.id,
|
||||||
|
batchId: product.batchId,
|
||||||
|
productName: product.name,
|
||||||
|
unitPriceCents: product.priceCents,
|
||||||
|
quantity: cartItem.quantity,
|
||||||
|
weightGrams: product.weightGrams,
|
||||||
|
totalCents: product.priceCents * cartItem.quantity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
|
||||||
|
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
|
||||||
|
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
|
||||||
|
// einer Menge zusammengefasst, nicht separat berechnet) — kein
|
||||||
|
// wertbasierter Freibetrag mehr.
|
||||||
|
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
const shippingCents =
|
||||||
|
input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity);
|
||||||
|
|
||||||
|
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||||
|
// haelt die Rabattlogik einfach nachvollziehbar.
|
||||||
|
let discountCents = 0;
|
||||||
|
let discountCode: { id: string; code: string } | null = null;
|
||||||
|
if (input.discountCode) {
|
||||||
|
const now = new Date();
|
||||||
|
const code = await prisma.discountCode.findUnique({
|
||||||
|
where: { code: input.discountCode.trim().toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// usedCount allein reicht bei einem Nutzungslimit nicht: er wird erst bei
|
||||||
|
// bestaetigter Zahlung erhoeht (s.u.), zaehlt also nicht bereits laufende,
|
||||||
|
// noch unbezahlte Checkouts mit demselben Code mit. Ohne diese Pruefung
|
||||||
|
// koennten mehrere parallele Checkout-Versuche (z.B. mehrere Tabs) einen
|
||||||
|
// maxUses:1-Code alle gleichzeitig als gueltig durchgehen und am Ende
|
||||||
|
// mehrfach tatsaechlich bezahlt einloesen.
|
||||||
|
if (code.maxUses !== null) {
|
||||||
|
const pendingUses = await prisma.order.count({
|
||||||
|
where: { discountCodeId: code.id, status: "NEU" },
|
||||||
|
});
|
||||||
|
if (code.usedCount + pendingUses >= code.maxUses) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||||
|
discountCode = code;
|
||||||
|
} else {
|
||||||
|
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
||||||
|
// gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden
|
||||||
|
// waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig,
|
||||||
|
// daher keine pendingUses-Pruefung wie beim manuellen Code oben.
|
||||||
|
await ensureBulkDiscountCode();
|
||||||
|
const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
||||||
|
const now = new Date();
|
||||||
|
const code = candidates
|
||||||
|
.filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity))
|
||||||
|
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
||||||
|
if (code) {
|
||||||
|
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||||
|
discountCode = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// usedCount wird bewusst erst im Webhook bei bestaetigter Zahlung erhoeht,
|
||||||
|
// nicht hier — sonst wuerde ein abgebrochener Stripe-Checkout einen
|
||||||
|
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
|
||||||
|
|
||||||
|
const totalCents = subtotalCents - discountCents + shippingCents;
|
||||||
|
const customer = await getCurrentCustomer();
|
||||||
|
|
||||||
|
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
|
||||||
|
// nur fuer registrierte, eingeloggte Kund:innen.
|
||||||
|
if (customer && !customer.emailVerifiedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Bitte bestätige zuerst deine E-Mail-Adresse (Link auf deiner Kontoseite erneut anfordern), bevor du mit deinem Konto bestellst.",
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
let order;
|
||||||
|
try {
|
||||||
|
// Bestellanlage und Bestandsreservierung atomar: das bedingte
|
||||||
|
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
||||||
|
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
||||||
|
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
||||||
|
order = await prisma.$transaction(async (tx) => {
|
||||||
|
for (const item of orderItemsData) {
|
||||||
|
const reserved = await tx.inventory.updateMany({
|
||||||
|
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||||
|
data: { quantity: { decrement: item.quantity } },
|
||||||
|
});
|
||||||
|
if (reserved.count === 0) {
|
||||||
|
throw new OutOfStockError(item.productName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.order.create({
|
||||||
|
data: {
|
||||||
|
orderNumber: generateOrderNumber(),
|
||||||
|
customerId: customer?.id,
|
||||||
|
locale,
|
||||||
|
fulfillmentMethod: input.fulfillmentMethod,
|
||||||
|
email: input.email,
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
phone: input.phone,
|
||||||
|
street: input.street ?? "",
|
||||||
|
zip: input.zip ?? "",
|
||||||
|
city: input.city ?? "",
|
||||||
|
country: input.country,
|
||||||
|
subtotalCents,
|
||||||
|
shippingCents,
|
||||||
|
discountCents,
|
||||||
|
discountCodeId: discountCode?.id,
|
||||||
|
totalCents,
|
||||||
|
giftMessage: input.giftMessage || null,
|
||||||
|
items: {
|
||||||
|
create: orderItemsData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof OutOfStockError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `„${error.message}“ ist leider nicht mehr in der gewünschten Menge verfügbar. Bitte passe deinen Warenkorb an.`,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 —
|
||||||
|
// ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
|
||||||
|
// eigener Checkout-Seite waere hochgradig phishing-tauglich.
|
||||||
|
const origin = getBaseUrl();
|
||||||
|
|
||||||
|
// Stripe erlaubt keine negativen Preise in line_items — ein Rabatt wird
|
||||||
|
// stattdessen als einmaliger Coupon auf die Session angewendet, damit
|
||||||
|
// Produktpositionen weiterhin ihre echten Einzelpreise zeigen.
|
||||||
|
let discounts: { coupon: string }[] | undefined;
|
||||||
|
if (discountCents > 0) {
|
||||||
|
const coupon = await stripe.coupons.create({
|
||||||
|
amount_off: discountCents,
|
||||||
|
currency: "eur",
|
||||||
|
duration: "once",
|
||||||
|
name: input.discountCode?.trim().toUpperCase() ?? discountCode?.code,
|
||||||
|
});
|
||||||
|
discounts = [{ coupon: coupon.id }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "payment",
|
||||||
|
customer_email: input.email,
|
||||||
|
line_items: [
|
||||||
|
...orderItemsData.map((item) => ({
|
||||||
|
quantity: item.quantity,
|
||||||
|
price_data: {
|
||||||
|
currency: "eur",
|
||||||
|
unit_amount: item.unitPriceCents,
|
||||||
|
product_data: { name: `${item.productName} (${item.weightGrams} g)` },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...(shippingCents > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price_data: {
|
||||||
|
currency: "eur",
|
||||||
|
unit_amount: shippingCents,
|
||||||
|
product_data: { name: "Versand" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
...(discounts ? { discounts } : {}),
|
||||||
|
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
|
||||||
|
cancel_url: `${origin}/checkout/cancel`,
|
||||||
|
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { stripeCheckoutSessionId: session.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: session.url });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
|
||||||
|
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
|
||||||
|
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
|
||||||
|
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
|
||||||
|
// und dieser Endpunkt ist ohne Authentifizierung erreichbar.
|
||||||
|
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||||
|
console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.",
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { getStripeClient } from "@/lib/stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
||||||
|
import { restockOrderItems } from "@/lib/inventory";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
|
||||||
|
* /api/checkout/webhook mit den Events "checkout.session.completed",
|
||||||
|
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed"
|
||||||
|
* und "checkout.session.expired" angelegt und STRIPE_WEBHOOK_SECRET gesetzt
|
||||||
|
* werden. Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
|
||||||
|
* Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die
|
||||||
|
* Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht
|
||||||
|
* (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass
|
||||||
|
* eine Kundschaft den Checkout einfach verlaesst, ohne zu bezahlen — ohne
|
||||||
|
* diesen Handler bliebe der beim Checkout reservierte Lagerbestand fuer
|
||||||
|
* diese Bestellung dauerhaft blockiert.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "STRIPE_WEBHOOK_SECRET ist nicht konfiguriert." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = request.headers.get("stripe-signature");
|
||||||
|
const rawBody = await request.text();
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
event = stripe.webhooks.constructEvent(rawBody, signature ?? "", webhookSecret);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Signaturprüfung fehlgeschlagen";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "checkout.session.completed") {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
// Bei Zahlungsmethoden mit verzoegerter Bestaetigung (z.B.
|
||||||
|
// SEPA-Lastschrift) ist payment_status hier noch "unpaid" — die
|
||||||
|
// eigentliche Bestaetigung kommt dann als eigenes
|
||||||
|
// checkout.session.async_payment_succeeded-Ereignis (siehe unten).
|
||||||
|
// Ohne diese Pruefung wuerden wir die Bestellung faelschlich als
|
||||||
|
// bezahlt markieren, obwohl das Geld noch gar nicht eingezogen ist.
|
||||||
|
if (session.payment_status !== "unpaid") {
|
||||||
|
await fulfillOrder(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "checkout.session.async_payment_succeeded") {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
await fulfillOrder(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "checkout.session.async_payment_failed") {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
await failOrder(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "checkout.session.expired") {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
await failOrder(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||||
|
const orderId = session.metadata?.orderId;
|
||||||
|
if (!orderId) return;
|
||||||
|
|
||||||
|
// Transaktional + Status-Guard (Muster wie failOrder unten): verhindert,
|
||||||
|
// dass zwei parallel zugestellte Webhook-Kopien denselben Gutschein
|
||||||
|
// doppelt zaehlen, und verhindert, dass eine im Admin-Bereich bereits
|
||||||
|
// stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete
|
||||||
|
// Zahlungsbestaetigung faelschlich wieder auf "BEZAHLT" gesetzt wird.
|
||||||
|
const order = await prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.order.updateMany({
|
||||||
|
where: { id: orderId, status: "NEU" },
|
||||||
|
data: { status: "BEZAHLT" },
|
||||||
|
});
|
||||||
|
if (result.count === 0) return null;
|
||||||
|
|
||||||
|
const updated = await tx.order.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated.discountCodeId) {
|
||||||
|
await tx.discountCode.update({
|
||||||
|
where: { id: updated.discountCodeId },
|
||||||
|
data: { usedCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bereits verarbeitet (Webhook-Replay) oder Bestellung war nicht mehr
|
||||||
|
// im Status "NEU" (z.B. schon storniert) — nichts weiter zu tun.
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
await prisma.payment.upsert({
|
||||||
|
where: { orderId: order.id },
|
||||||
|
create: {
|
||||||
|
orderId: order.id,
|
||||||
|
stripePaymentIntentId:
|
||||||
|
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
||||||
|
status: "SUCCEEDED",
|
||||||
|
amountCents: session.amount_total ?? order.totalCents,
|
||||||
|
paidAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
status: "SUCCEEDED",
|
||||||
|
paidAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.invoice.upsert({
|
||||||
|
where: { orderId: order.id },
|
||||||
|
create: {
|
||||||
|
orderId: order.id,
|
||||||
|
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
||||||
|
totalCents: order.totalCents,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// E-Mail-Fehler duerfen die Zahlungsbestaetigung selbst nicht scheitern
|
||||||
|
// lassen — Stripe wiederholt Webhooks bei Fehlerantworten, ein
|
||||||
|
// Mailserver-Ausfall soll das nicht auf die Bestellabwicklung durchschlagen.
|
||||||
|
const { subject, html, text } = await orderConfirmationEmail({
|
||||||
|
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
firstName: order.firstName,
|
||||||
|
fulfillmentMethod: order.fulfillmentMethod,
|
||||||
|
items: order.items.map((item) => ({
|
||||||
|
productName: item.productName,
|
||||||
|
quantity: item.quantity,
|
||||||
|
weightGrams: item.weightGrams,
|
||||||
|
totalCents: item.totalCents,
|
||||||
|
})),
|
||||||
|
subtotalCents: order.subtotalCents,
|
||||||
|
shippingCents: order.shippingCents,
|
||||||
|
discountCents: order.discountCents,
|
||||||
|
totalCents: order.totalCents,
|
||||||
|
street: order.street,
|
||||||
|
zip: order.zip,
|
||||||
|
city: order.city,
|
||||||
|
giftMessage: order.giftMessage,
|
||||||
|
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||||
|
});
|
||||||
|
await sendMail({ to: order.email, subject, html, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||||
|
const orderId = session.metadata?.orderId;
|
||||||
|
if (!orderId) return;
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const previous = await tx.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
orderNumber: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
locale: true,
|
||||||
|
items: { select: { productId: true, quantity: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Nur von "NEU" aus stornieren: verhindert doppelte Bestandsrueckbuchung,
|
||||||
|
// falls Stripe denselben Webhook erneut zustellt, und laesst bereits
|
||||||
|
// bezahlte/stornierte Bestellungen unangetastet.
|
||||||
|
if (!previous || previous.status !== "NEU") return null;
|
||||||
|
|
||||||
|
await tx.order.update({ where: { id: orderId }, data: { status: "STORNIERT" } });
|
||||||
|
|
||||||
|
// Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie
|
||||||
|
// bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts).
|
||||||
|
await restockOrderItems(tx, previous.items);
|
||||||
|
|
||||||
|
await tx.payment.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
create: { orderId, status: "FAILED", amountCents: session.amount_total ?? 0 },
|
||||||
|
update: { status: "FAILED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return previous;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const { subject, html, text } = await paymentFailedEmail({
|
||||||
|
locale: isLocale(result.locale) ? result.locale : defaultLocale,
|
||||||
|
orderNumber: result.orderNumber,
|
||||||
|
firstName: result.firstName,
|
||||||
|
});
|
||||||
|
await sendMail({ to: result.email, subject, html, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
||||||
|
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const customer = await getCurrentCustomer();
|
||||||
|
if (!customer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`data-export:${ip}:${customer.id}`, EXPORT_ATTEMPTS_PER_WINDOW, EXPORT_WINDOW_MS)) {
|
||||||
|
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [addresses, orders, reviews, wishlistItems, newsletter] = await Promise.all([
|
||||||
|
prisma.address.findMany({ where: { customerId: customer.id } }),
|
||||||
|
prisma.order.findMany({
|
||||||
|
where: { customerId: customer.id },
|
||||||
|
include: { items: true, invoice: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.review.findMany({
|
||||||
|
where: { customerId: customer.id },
|
||||||
|
include: { product: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.wishlistItem.findMany({
|
||||||
|
where: { customerId: customer.id },
|
||||||
|
include: { product: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
account: {
|
||||||
|
email: customer.email,
|
||||||
|
firstName: customer.firstName,
|
||||||
|
lastName: customer.lastName,
|
||||||
|
phone: customer.phone,
|
||||||
|
createdAt: customer.createdAt,
|
||||||
|
twoFactorEnabled: customer.twoFactorEnabled,
|
||||||
|
},
|
||||||
|
addresses: addresses.map((a) => ({
|
||||||
|
label: a.label,
|
||||||
|
firstName: a.firstName,
|
||||||
|
lastName: a.lastName,
|
||||||
|
street: a.street,
|
||||||
|
zip: a.zip,
|
||||||
|
city: a.city,
|
||||||
|
country: a.country,
|
||||||
|
isDefault: a.isDefault,
|
||||||
|
})),
|
||||||
|
orders: orders.map((o) => ({
|
||||||
|
orderNumber: o.orderNumber,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
status: o.status,
|
||||||
|
totalCents: o.totalCents,
|
||||||
|
currency: o.currency,
|
||||||
|
fulfillmentMethod: o.fulfillmentMethod,
|
||||||
|
invoiceNumber: o.invoice?.invoiceNumber ?? null,
|
||||||
|
items: o.items.map((item) => ({
|
||||||
|
productName: item.productName,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPriceCents: item.unitPriceCents,
|
||||||
|
totalCents: item.totalCents,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
reviews: reviews.map((r) => ({
|
||||||
|
product: r.product.name,
|
||||||
|
rating: r.rating,
|
||||||
|
comment: r.comment,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
isApproved: r.isApproved,
|
||||||
|
})),
|
||||||
|
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
||||||
|
newsletter: newsletter
|
||||||
|
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
||||||
|
: { subscribed: false, confirmed: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Disposition": `attachment; filename="wabenhain-meine-daten-${customer.id.slice(-8)}.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { hashVerificationToken } from "@/lib/email-verification";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
|
||||||
|
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)}`);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationToken = await prisma.emailVerificationToken.findUnique({
|
||||||
|
where: { tokenHash: hashVerificationToken(token) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
verificationToken && verificationToken.usedAt === null && verificationToken.expiresAt > new Date();
|
||||||
|
if (!isValid) {
|
||||||
|
return fail("Dieser Bestätigungslink ist ungültig oder abgelaufen. Bitte fordere über /konto einen neuen an.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.customer.update({
|
||||||
|
where: { id: verificationToken.customerId },
|
||||||
|
data: { emailVerifiedAt: new Date() },
|
||||||
|
}),
|
||||||
|
prisma.emailVerificationToken.update({
|
||||||
|
where: { id: verificationToken.id },
|
||||||
|
data: { usedAt: new Date() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||||
|
if (!subscriber) {
|
||||||
|
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.newsletterSubscriber.update({
|
||||||
|
where: { id: subscriber.id },
|
||||||
|
data: { confirmed: true, unsubscribedAt: null },
|
||||||
|
});
|
||||||
|
await ensureNewsletterDiscountCode();
|
||||||
|
|
||||||
|
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=ok`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { newsletterSchema } from "@/lib/validation";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { newsletterConfirmEmail } from "@/lib/email-templates";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`newsletter:${ip}`, 5, 60 * 60 * 1000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Zu viele Anfragen. Bitte versuche es später erneut." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = newsletterSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Ungültige E-Mail-Adresse." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnstileToken = typeof body?.turnstileToken === "string" ? body.turnstileToken : null;
|
||||||
|
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||||
|
return NextResponse.json({ error: "Die Sicherheitsprüfung ist fehlgeschlagen." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.newsletterSubscriber.findUnique({ where: { email: parsed.data.email } });
|
||||||
|
|
||||||
|
if (existing?.confirmed) {
|
||||||
|
// Bereits bestaetigter Abonnent meldet sich erneut an — keine neue Mail
|
||||||
|
// noetig, aber auch keine Fehlermeldung (verraet sonst, dass die
|
||||||
|
// E-Mail-Adresse bereits existiert).
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomBytes(24).toString("hex");
|
||||||
|
const subscriber = await prisma.newsletterSubscriber.upsert({
|
||||||
|
where: { email: parsed.data.email },
|
||||||
|
create: { email: parsed.data.email, confirmationToken: token },
|
||||||
|
update: { confirmationToken: token, unsubscribedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmUrl = `${getBaseUrl()}/api/newsletter/confirm?token=${subscriber.confirmationToken}`;
|
||||||
|
const { subject, html, text } = await newsletterConfirmEmail(await getLocale(), confirmUrl);
|
||||||
|
await sendMail({ to: parsed.data.email, subject, html, text });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getBaseUrl } from "@/lib/url";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const token = new URL(request.url).searchParams.get("token");
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||||
|
if (!subscriber) {
|
||||||
|
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.newsletterSubscriber.update({
|
||||||
|
where: { id: subscriber.id },
|
||||||
|
data: { unsubscribedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=unsubscribed`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getActiveProducts } from "@/lib/data";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const SUGGESTION_LIMIT = 5;
|
||||||
|
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
|
||||||
|
const SUGGEST_WINDOW_MS = 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert bis zu 5 passende Produkte fuer die Live-Suchvorschlaege im
|
||||||
|
* Header — dieselbe Suchlogik wie die Shop-Uebersicht (getActiveProducts),
|
||||||
|
* nur ohne vollen Seiten-Reload. Rein oeffentliche, bereits im Shop
|
||||||
|
* sichtbare Produktdaten, keine Authentifizierung noetig, aber wie jeder
|
||||||
|
* andere oeffentliche Endpunkt hier rate-limitiert (grosszuegig, da bei
|
||||||
|
* jedem Tastenanschlag aufgerufen).
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (!rateLimit(`search-suggest:${ip}`, SUGGEST_ATTEMPTS_PER_WINDOW, SUGGEST_WINDOW_MS)) {
|
||||||
|
return NextResponse.json({ suggestions: [] }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = new URL(request.url).searchParams.get("q")?.trim() ?? "";
|
||||||
|
if (query.length < 2) {
|
||||||
|
return NextResponse.json({ suggestions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await getActiveProducts({ query, onlyAvailable: true });
|
||||||
|
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
|
||||||
|
slug: product.slug,
|
||||||
|
name: product.name,
|
||||||
|
price: formatPrice(product.priceCents, product.currency),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ suggestions });
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
|
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||||
|
import { getOrderForTracking } from "@/lib/account-data";
|
||||||
|
import { formatPrice, formatDate } from "@/lib/format";
|
||||||
|
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||||
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Bestellung verfolgen", robots: { index: false } };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const TRACK_ATTEMPTS_PER_WINDOW = 10;
|
||||||
|
const TRACK_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { order: orderNumber, email } = await props.searchParams;
|
||||||
|
const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email;
|
||||||
|
|
||||||
|
let order = null;
|
||||||
|
let rateLimited = false;
|
||||||
|
if (hasQuery) {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
if (rateLimit(`track-order:${ip}`, TRACK_ATTEMPTS_PER_WINDOW, TRACK_WINDOW_MS)) {
|
||||||
|
order = await getOrderForTracking(orderNumber, email);
|
||||||
|
} else {
|
||||||
|
rateLimited = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-20">
|
||||||
|
<Container className="max-w-xl">
|
||||||
|
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
|
||||||
|
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
||||||
|
|
||||||
|
<form method="GET" className="mt-8 space-y-4">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
||||||
|
<input
|
||||||
|
name="order"
|
||||||
|
type="text"
|
||||||
|
defaultValue={typeof orderNumber === "string" ? orderNumber : ""}
|
||||||
|
placeholder={t.trackOrderPage.orderNumberPlaceholder}
|
||||||
|
required
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{t.account.emailLabel}</span>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
defaultValue={typeof email === "string" ? email : ""}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{rateLimited ? (
|
||||||
|
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
|
{t.trackOrderPage.rateLimitError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasQuery && !order && !rateLimited ? (
|
||||||
|
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
|
{t.trackOrderPage.notFoundError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{order ? (
|
||||||
|
<div className="mt-10 border border-ink/10 p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-forest">{order.orderNumber}</p>
|
||||||
|
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<OrderStatusBadge
|
||||||
|
status={order.status}
|
||||||
|
label={t.orderStatusBadge[order.status as keyof typeof t.orderStatusBadge] ?? order.status}
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs text-ink/65">
|
||||||
|
{order.fulfillmentMethod === "VERSAND"
|
||||||
|
? order.shipment?.shippedAt
|
||||||
|
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||||
|
: t.ordersPage.shippingPending
|
||||||
|
: order.pickup?.pickedUpAt
|
||||||
|
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||||
|
: order.pickup?.readyAt
|
||||||
|
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||||
|
: t.ordersPage.pickupPending}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||||
|
{order.items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{item.quantity} × {item.productName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<OrderStatusTimeline
|
||||||
|
status={order.status}
|
||||||
|
fulfillmentMethod={order.fulfillmentMethod}
|
||||||
|
labels={{
|
||||||
|
ordered: t.orderStatusTimeline.ordered,
|
||||||
|
paid: t.orderStatusTimeline.paid,
|
||||||
|
preparing: t.orderStatusTimeline.preparing,
|
||||||
|
readyToShip: t.orderStatusTimeline.readyToShip,
|
||||||
|
shipped: t.orderStatusTimeline.shipped,
|
||||||
|
readyForPickup: t.orderStatusTimeline.readyForPickup,
|
||||||
|
pickedUp: t.orderStatusTimeline.pickedUp,
|
||||||
|
cancelled: t.orderStatusTimeline.cancelled,
|
||||||
|
ariaLabel: t.orderStatusTimeline.ariaLabel,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.shipment?.carrier && order.shipment.trackingNumber ? (
|
||||||
|
<p className="mt-3 text-xs text-ink/65">
|
||||||
|
{t.shipmentInfo.carrierLabel}: {order.shipment.carrier} · {t.shipmentInfo.trackingNumberLabel}:{" "}
|
||||||
|
{order.shipment.trackingNumber}
|
||||||
|
{(() => {
|
||||||
|
const url = getCarrierTrackingUrl(order.shipment.carrier, order.shipment.trackingNumber);
|
||||||
|
return url ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
·{" "}
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||||
|
{t.shipmentInfo.trackPackage}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-4">
|
||||||
|
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||||
|
{order.invoice ? (
|
||||||
|
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||||
|
{t.trackOrderPage.viewInvoice}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Container } from "@/components/ui";
|
||||||
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
|
import { ShieldIcon } from "@/components/account-ui";
|
||||||
|
import { ProductTeaserLink } from "@/components/product-card";
|
||||||
|
import { getBatchByCode } from "@/lib/data";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
// Siehe Begruendung in app/honig/[slug]/page.tsx.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
props: PageProps<"/charge/[code]">
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const { code } = await props.params;
|
||||||
|
return { title: `Charge ${code}`, alternates: { canonical: `/charge/${code}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargen-Seite, die ueber den QR-Code auf jedem Wabenhain-Glas erreichbar
|
||||||
|
* ist (wabenhain.com/charge/{code}). Zeigt Herkunft, Erntezeitraum und
|
||||||
|
* Abfuelldatum der konkreten Charge, aus der das Glas stammt — der Moment,
|
||||||
|
* in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft.
|
||||||
|
*/
|
||||||
|
export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { code } = await props.params;
|
||||||
|
const batch = await getBatchByCode(code);
|
||||||
|
if (!batch) notFound();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate) },
|
||||||
|
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate) },
|
||||||
|
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-20">
|
||||||
|
<Container className="max-w-3xl">
|
||||||
|
<div className="flex items-center gap-2 text-honey-dark">
|
||||||
|
<ShieldIcon />
|
||||||
|
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||||
|
{batch.harvest.type} {batch.harvest.year}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||||
|
<PlaceholderImage tone="honey" className="aspect-square" label={`${batch.harvest.type} ${batch.harvest.year}`} />
|
||||||
|
<PlaceholderImage tone="forest" className="aspect-square" label={batch.harvest.location} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-14">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={step.label} className="relative">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-honey" />
|
||||||
|
{i < steps.length - 1 ? (
|
||||||
|
<span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs tracking-wide-caps text-ink/60">{step.label}</p>
|
||||||
|
<p className="mt-1 text-forest">{step.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 border-t border-ink/10 pt-8">
|
||||||
|
<p className="text-sm tracking-wide-caps text-ink/60 mb-2">{t.chargePage.regionLabel}</p>
|
||||||
|
<p className="text-ink/80">{batch.harvest.location}</p>
|
||||||
|
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrustBadges className="mt-10 pt-8 border-t border-ink/10" />
|
||||||
|
|
||||||
|
{batch.products.length > 0 ? (
|
||||||
|
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||||
|
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
{batch.products.map((product) => (
|
||||||
|
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Bestellung abgebrochen" };
|
||||||
|
|
||||||
|
export default async function CheckoutCancelPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return (
|
||||||
|
<div className="py-24">
|
||||||
|
<Container className="max-w-xl text-center">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow={t.checkoutCancelPage.eyebrow}
|
||||||
|
title={t.checkoutCancelPage.title}
|
||||||
|
align="center"
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
<p className="mt-6 text-ink/70">
|
||||||
|
{t.checkoutCancelPage.text}
|
||||||
|
</p>
|
||||||
|
<LinkButton href="/warenkorb" className="mt-10">
|
||||||
|
{t.checkoutCancelPage.backToCart}
|
||||||
|
</LinkButton>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { CheckoutForm } from "./checkout-form";
|
||||||
|
import { useCartStore, type CartItem } from "@/store/cart-store";
|
||||||
|
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||||
|
import type { ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
|
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
|
||||||
|
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||||
|
|
||||||
|
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||||
|
productId: "p1",
|
||||||
|
slug: "fruehtracht-2026",
|
||||||
|
name: "Frühtracht 2026",
|
||||||
|
weightGrams: 500,
|
||||||
|
priceCents: 990,
|
||||||
|
image: null,
|
||||||
|
quantity: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderCheckout(items: CartItem[]) {
|
||||||
|
useCartStore.setState({ items, isOpen: false });
|
||||||
|
return render(
|
||||||
|
<I18nProvider locale="de">
|
||||||
|
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useCartStore.setState({ items: [], isOpen: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CheckoutForm", () => {
|
||||||
|
test("shows the empty-cart message instead of the form when the cart is empty", () => {
|
||||||
|
renderCheckout([]);
|
||||||
|
expect(screen.queryByRole("button", { name: /kostenpflichtig bestellen/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("charges shipping by default and drops it when switching to pickup", async () => {
|
||||||
|
const userEventInstance = userEvent.setup();
|
||||||
|
renderCheckout([item()]);
|
||||||
|
|
||||||
|
expect(screen.getByText("7,00 €")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEventInstance.click(screen.getByRole("button", { name: /abholung/i }));
|
||||||
|
|
||||||
|
expect(screen.queryByText("7,00 €")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("kostenlos")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
|
import { TrustBadges } from "@/components/trust-badges";
|
||||||
|
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
|
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
|
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
||||||
|
|
||||||
|
export interface CheckoutInitialValues {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
street: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckoutForm({
|
||||||
|
initialValues,
|
||||||
|
tiers,
|
||||||
|
bulkDiscountTiers,
|
||||||
|
}: {
|
||||||
|
initialValues?: CheckoutInitialValues;
|
||||||
|
tiers: ShippingTierLike[];
|
||||||
|
bulkDiscountTiers: BulkDiscountTierLike[];
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const items = useCartStore((s) => s.items);
|
||||||
|
const subtotal = cartTotalCents(items);
|
||||||
|
const totalQuantity = cartItemCount(items);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isGift, setIsGift] = useState(false);
|
||||||
|
|
||||||
|
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
||||||
|
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||||
|
const shipping =
|
||||||
|
fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
||||||
|
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
||||||
|
const bulkDiscountCents = activeBulkDiscount
|
||||||
|
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
||||||
|
: 0;
|
||||||
|
const total = subtotal - bulkDiscountCents + shipping;
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const payload = {
|
||||||
|
email: String(formData.get("email") ?? ""),
|
||||||
|
firstName: String(formData.get("firstName") ?? ""),
|
||||||
|
lastName: String(formData.get("lastName") ?? ""),
|
||||||
|
phone: String(formData.get("phone") ?? "") || undefined,
|
||||||
|
fulfillmentMethod: fulfillment,
|
||||||
|
street: String(formData.get("street") ?? "") || undefined,
|
||||||
|
zip: String(formData.get("zip") ?? "") || undefined,
|
||||||
|
city: String(formData.get("city") ?? "") || undefined,
|
||||||
|
country: "DE",
|
||||||
|
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
||||||
|
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
||||||
|
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (data.orderNumber) {
|
||||||
|
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(data.error ?? t.checkoutPage.genericError);
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch {
|
||||||
|
setError(t.checkoutPage.genericError);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-20">
|
||||||
|
<Container>
|
||||||
|
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.emptyCart} />
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<Field name="firstName" label={t.checkoutPage.firstName} defaultValue={initialValues?.firstName} required />
|
||||||
|
<Field name="lastName" label={t.checkoutPage.lastName} defaultValue={initialValues?.lastName} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<Field name="email" label={t.checkoutPage.email} type="email" defaultValue={initialValues?.email} required />
|
||||||
|
<Field
|
||||||
|
name="phone"
|
||||||
|
label={t.checkoutPage.phoneOptional}
|
||||||
|
defaultValue={initialValues?.phone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={option}
|
||||||
|
onClick={() => setFulfillment(option)}
|
||||||
|
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
|
||||||
|
fulfillment === option
|
||||||
|
? "border-forest bg-forest text-cream"
|
||||||
|
: "border-ink/20 text-ink/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{fulfillment === "VERSAND" ? (
|
||||||
|
<p className="mt-2 text-xs text-ink/60">
|
||||||
|
{t.checkoutPage.deliveryTimeNote}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fulfillment === "VERSAND" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
name="street"
|
||||||
|
label={t.checkoutPage.streetLabel}
|
||||||
|
defaultValue={initialValues?.street}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<Field name="zip" label={t.checkoutPage.zipLabel} defaultValue={initialValues?.zip} required />
|
||||||
|
<Field name="city" label={t.checkoutPage.cityLabel} defaultValue={initialValues?.city} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field name="discountCode" label={t.checkoutPage.discountCodeLabel} placeholder={t.checkoutPage.discountCodePlaceholder} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isGift}
|
||||||
|
onChange={(event) => setIsGift(event.target.checked)}
|
||||||
|
className="h-4 w-4 border-ink/20 accent-forest"
|
||||||
|
/>
|
||||||
|
{t.checkoutPage.isGiftLabel}
|
||||||
|
</label>
|
||||||
|
{isGift ? (
|
||||||
|
<label className="block text-sm mt-3">
|
||||||
|
<span className="text-ink/70">{t.checkoutPage.giftMessageLabel}</span>
|
||||||
|
<textarea
|
||||||
|
name="giftMessage"
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
placeholder={t.checkoutPage.giftMessagePlaceholder}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest resize-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-red-700">{error}</p> : null}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||||
|
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-ink/65">
|
||||||
|
{t.checkoutPage.paymentMethodsNote}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside 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) => (
|
||||||
|
<li key={item.productId} className="flex justify-between">
|
||||||
|
<span>
|
||||||
|
{item.quantity} × {item.name}
|
||||||
|
</span>
|
||||||
|
<span>{formatPrice(item.priceCents * item.quantity)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between text-ink/70">
|
||||||
|
<span>{t.cart.subtotal}</span>
|
||||||
|
<span>{formatPrice(subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-ink/70">
|
||||||
|
<span>
|
||||||
|
{fulfillment === "VERSAND"
|
||||||
|
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||||||
|
: t.checkoutPage.shippingPickup}
|
||||||
|
</span>
|
||||||
|
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
|
||||||
|
</div>
|
||||||
|
{activeBulkDiscount ? (
|
||||||
|
<div className="flex justify-between text-forest">
|
||||||
|
<span>
|
||||||
|
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||||
|
</span>
|
||||||
|
<span>-{formatPrice(bulkDiscountCents)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
||||||
|
<span>{t.cartPage.total}</span>
|
||||||
|
<span>{formatPrice(total)}</span>
|
||||||
|
</div>
|
||||||
|
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||||
|
</aside>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
defaultValue,
|
||||||
|
placeholder,
|
||||||
|
required = false,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-ink/70">{label}</span>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Container } from "@/components/ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next.js-Suspense-Skeleton fuer /checkout: die Seite ist "force-dynamic"
|
||||||
|
* und laedt Versandstaffeln sowie (bei angemeldeten Kund:innen) gespeicherte
|
||||||
|
* Adressen serverseitig, bevor CheckoutForm ueberhaupt rendern kann.
|
||||||
|
* Orientiert sich am Formular-plus-Zusammenfassung-Layout der Seite.
|
||||||
|
*/
|
||||||
|
export default function CheckoutLoading() {
|
||||||
|
return (
|
||||||
|
<div className="py-16 animate-pulse">
|
||||||
|
<Container className="grid lg:grid-cols-[1fr_360px] gap-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-9 w-40 bg-ink/10 rounded-sm mb-4" />
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<div key={i} className="h-12 w-full bg-ink/10" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-80 bg-ink/5 border border-ink/10" />
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
|
import { getAddressesForCustomer } from "@/lib/account-data";
|
||||||
|
import { getShippingTiers } from "@/lib/shipping";
|
||||||
|
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
|
||||||
|
|
||||||
|
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
|
||||||
|
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
|
||||||
|
// Rendering pro Request statt statischem Build entkoppelt zudem den
|
||||||
|
// Docker-Build-Schritt von einer zur Build-Zeit erreichbaren Datenbank.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return { title: t.checkoutPage.eyebrow };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CheckoutPage() {
|
||||||
|
const [tiers, bulkDiscountTiers] = await Promise.all([getShippingTiers(), getBulkDiscountTiers()]);
|
||||||
|
const tierValues = tiers.map((t) => ({
|
||||||
|
minQuantity: t.minQuantity,
|
||||||
|
maxQuantity: t.maxQuantity,
|
||||||
|
priceCents: t.priceCents,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const customer = await getCurrentCustomer();
|
||||||
|
if (!customer) return <CheckoutForm tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
|
||||||
|
|
||||||
|
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
||||||
|
// die zuletzt angelegte — die erste Adresse in der Liste ist also immer die
|
||||||
|
// sinnvollste Vorbelegung fuer den Checkout.
|
||||||
|
const addresses = await getAddressesForCustomer(customer.id);
|
||||||
|
const defaultAddress = addresses[0];
|
||||||
|
|
||||||
|
const initialValues: CheckoutInitialValues = {
|
||||||
|
firstName: customer.firstName,
|
||||||
|
lastName: customer.lastName,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone ?? "",
|
||||||
|
street: defaultAddress?.street ?? "",
|
||||||
|
zip: defaultAddress?.zip ?? "",
|
||||||
|
city: defaultAddress?.city ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return <CheckoutForm initialValues={initialValues} tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return { title: t.checkoutSuccessPage.successEyebrow };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CheckoutSuccessLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { use } from "react";
|
||||||
|
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||||
|
import { useCartStore } from "@/store/cart-store";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clear();
|
||||||
|
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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}
|
||||||
|
align="center"
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
{order ? (
|
||||||
|
<p className="mt-4 text-ink/70">
|
||||||
|
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{order}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-6 text-ink/70">
|
||||||
|
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText}
|
||||||
|
</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">
|
||||||
|
{t.checkoutSuccessPage.trackOrder}
|
||||||
|
</LinkButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal-page";
|
||||||
|
import { CookieSettingsButton } from "@/components/cookie-consent";
|
||||||
|
import { Container } from "@/components/ui";
|
||||||
|
import { getContentValue, parseSections } from "@/lib/content";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Cookie-Richtlinie", alternates: { canonical: "/cookie-richtlinie" } };
|
||||||
|
|
||||||
|
export default async function CookieRichtliniePage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
|
const sections = parseSections(await getContentValue("legal.cookieRichtlinie", locale));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LegalPage
|
||||||
|
title={t.cookieRichtliniePage.title}
|
||||||
|
noticeText={t.legalPage.reviewNotice}
|
||||||
|
sections={sections}
|
||||||
|
/>
|
||||||
|
<Container className="max-w-3xl pb-20 -mt-10">
|
||||||
|
<CookieSettingsButton />
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal-page";
|
||||||
|
import { getContentValue, parseSections } from "@/lib/content";
|
||||||
|
import { getCompanyInfo } from "@/lib/company-info";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Datenschutz", alternates: { canonical: "/datenschutz" } };
|
||||||
|
|
||||||
|
export default async function DatenschutzPage() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = getDictionary(locale);
|
||||||
|
const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.datenschutz", locale)]);
|
||||||
|
const [verantwortlicherSection, ...restSections] = parseSections(raw);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title={t.datenschutzPage.title}
|
||||||
|
noticeText={t.legalPage.reviewNotice}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
heading: verantwortlicherSection.heading,
|
||||||
|
paragraphs: [
|
||||||
|
`${company.name}, ${company.address.street}, ${company.address.zip} ${company.address.city}, ${company.email}`,
|
||||||
|
...verantwortlicherSection.paragraphs,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...restSections,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Container } from "@/components/ui";
|
||||||
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { ProductTeaserLink } from "@/components/product-card";
|
||||||
|
import { getHarvestByYearType } from "@/lib/data";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
// Siehe Begruendung in app/honig/[slug]/page.tsx: dynamisches Rendering
|
||||||
|
// entkoppelt den Build vom DB-Zugriff und haelt Chargen-/Bestandsdaten aktuell.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(
|
||||||
|
props: PageProps<"/ernten/[year]/[type]">
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const { year, type } = await props.params;
|
||||||
|
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
||||||
|
if (!harvest) return {};
|
||||||
|
return {
|
||||||
|
title: `${harvest.type} ${harvest.year}`,
|
||||||
|
description: harvest.description,
|
||||||
|
alternates: { canonical: `/ernten/${year}/${type}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HarvestDetailPage(
|
||||||
|
props: PageProps<"/ernten/[year]/[type]">
|
||||||
|
) {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const { year, type } = await props.params;
|
||||||
|
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
|
||||||
|
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
|
||||||
|
// automatisches Decoding zu verlassen.
|
||||||
|
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
||||||
|
if (!harvest) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16">
|
||||||
|
<Container>
|
||||||
|
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||||
|
</Container>
|
||||||
|
<Container className="grid md:grid-cols-2 gap-14">
|
||||||
|
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={harvest.location} />
|
||||||
|
<div>
|
||||||
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||||
|
{harvest.year} · {formatDate(harvest.harvestDate)}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-4xl text-forest">{harvest.type}</h1>
|
||||||
|
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||||
|
|
||||||
|
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.batchesHeading}</h2>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{harvest.batches.map((batch) => (
|
||||||
|
<Link
|
||||||
|
key={batch.id}
|
||||||
|
href={`/charge/${batch.code}`}
|
||||||
|
className="flex items-center justify-between gap-4 border border-ink/10 px-4 py-3 text-sm transition-colors hover:border-forest"
|
||||||
|
>
|
||||||
|
<span className="text-forest">{batch.code}</span>
|
||||||
|
<span className="text-ink/65">
|
||||||
|
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
{harvest.products.map((product) => (
|
||||||
|
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
import { getHarvests } from "@/lib/data";
|
||||||
|
import { formatMonthYear } from "@/lib/format";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Unsere Ernten",
|
||||||
|
description: "Alle bisherigen Wabenhain-Ernten im Überblick — jede mit eigenem Charakter.",
|
||||||
|
alternates: { canonical: "/ernten" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Siehe Begruendung in app/page.tsx.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ErntenPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
const harvests = await getHarvests();
|
||||||
|
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
|
||||||
|
acc[h.year] = [...(acc[h.year] ?? []), h];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const years = Object.keys(byYear).map(Number).sort((a, b) => b - a);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-20">
|
||||||
|
<Container>
|
||||||
|
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
|
||||||
|
|
||||||
|
<div className="mt-16 space-y-16">
|
||||||
|
{years.map((year) => (
|
||||||
|
<div key={year}>
|
||||||
|
<h2 className="text-2xl text-forest mb-6">{year}</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{byYear[year].map((harvest) => (
|
||||||
|
<Link
|
||||||
|
key={harvest.id}
|
||||||
|
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<PlaceholderImage
|
||||||
|
tone="forest"
|
||||||
|
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||||
|
label={formatMonthYear(harvest.harvestDate)}
|
||||||
|
/>
|
||||||
|
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
|
||||||
|
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{years.length === 0 ? (
|
||||||
|
<p className="text-ink/70">{t.erntenPage.emptyText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Container, Button, LinkButton } from "@/components/ui";
|
||||||
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-32">
|
||||||
|
<Container className="max-w-xl text-center">
|
||||||
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
||||||
|
<h1 className="text-3xl md:text-4xl text-forest">{t.errorPage.title}</h1>
|
||||||
|
<p className="mt-4 text-ink/70 leading-relaxed">{t.errorPage.description}</p>
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-4">
|
||||||
|
<Button type="button" onClick={() => reset()}>
|
||||||
|
{t.errorPage.retry}
|
||||||
|
</Button>
|
||||||
|
<LinkButton href="/" variant="secondary">
|
||||||
|
{t.errorPage.backHome}
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body style={{ margin: 0, fontFamily: "Arial, Helvetica, sans-serif", background: "#FAF8F3", color: "#242424" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center", maxWidth: "28rem" }}>
|
||||||
|
<p style={{ fontSize: "0.75rem", letterSpacing: "0.1em", color: "#B88A44", marginBottom: "0.75rem" }}>
|
||||||
|
FEHLER
|
||||||
|
</p>
|
||||||
|
<h1 style={{ fontSize: "1.75rem", color: "#24352B", margin: 0 }}>Etwas ist schiefgelaufen.</h1>
|
||||||
|
<p style={{ marginTop: "1rem", color: "#555", lineHeight: 1.6 }}>
|
||||||
|
Bitte lade die Seite neu oder versuche es später erneut.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => reset()}
|
||||||
|
style={{
|
||||||
|
marginTop: "2rem",
|
||||||
|
padding: "0.75rem 1.75rem",
|
||||||
|
background: "#24352B",
|
||||||
|
color: "#F4EFE5",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ERNEUT VERSUCHEN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
+51
-14
@@ -1,26 +1,63 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--color-forest: #24352b;
|
||||||
--foreground: #171717;
|
--color-cream: #f4efe5;
|
||||||
|
--color-honey: #b88a44;
|
||||||
|
/* WCAG-AA-taugliche Textvarianten (4.5:1): #b88a44 selbst besteht auf
|
||||||
|
keinem der beiden Hintergruende zuverlaessig. -dark fuer Text auf
|
||||||
|
hellem Grund (Hintergrund/Cream), -light fuer Text auf bg-forest. */
|
||||||
|
--color-honey-dark: #8a6531;
|
||||||
|
--color-honey-light: #c9a468;
|
||||||
|
--color-background: #faf8f3;
|
||||||
|
--color-ink: #242424;
|
||||||
|
--color-sage: #788774;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-forest: var(--color-forest);
|
||||||
--color-foreground: var(--foreground);
|
--color-cream: var(--color-cream);
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-honey: var(--color-honey);
|
||||||
--font-mono: var(--font-geist-mono);
|
--color-honey-dark: var(--color-honey-dark);
|
||||||
|
--color-honey-light: var(--color-honey-light);
|
||||||
|
--color-background: var(--color-background);
|
||||||
|
--color-ink: var(--color-ink);
|
||||||
|
--color-sage: var(--color-sage);
|
||||||
|
|
||||||
|
--font-serif: var(--font-cormorant), Georgia, serif;
|
||||||
|
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
html {
|
||||||
:root {
|
scroll-behavior: smooth;
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--color-background);
|
||||||
color: var(--foreground);
|
color: var(--color-ink);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-wide-caps {
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-honey);
|
||||||
|
color: var(--color-cream);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Container, SectionHeading } from "@/components/ui";
|
||||||
|
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return {
|
||||||
|
title: t.herkunftPage.eyebrow,
|
||||||
|
description: t.herkunftPage.subtitle,
|
||||||
|
alternates: { canonical: "/herkunft" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HerkunftPage() {
|
||||||
|
const t = getDictionary(await getLocale());
|
||||||
|
return (
|
||||||
|
<div className="py-20">
|
||||||
|
<PlaceholderImage
|
||||||
|
tone="forest"
|
||||||
|
className="h-[50vh] min-h-[360px] w-full mb-20"
|
||||||
|
label={t.herkunftPage.eyebrow}
|
||||||
|
/>
|
||||||
|
<Container>
|
||||||
|
<SectionHeading
|
||||||
|
level="h1"
|
||||||
|
eyebrow={t.herkunftPage.eyebrow}
|
||||||
|
title={t.herkunftPage.title}
|
||||||
|
subtitle={t.herkunftPage.subtitle}
|
||||||
|
/>
|
||||||
|
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
||||||
|
{t.herkunftPage.aspects.map((a) => (
|
||||||
|
<div key={a.title}>
|
||||||
|
<h3 className="text-lg text-forest">{a.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-16 text-xs text-ink/65 max-w-md">
|
||||||
|
{t.herkunftPage.privacyNote}
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Container } from "@/components/ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next.js-Suspense-Skeleton fuer /honig/[slug]: die Seite ist "force-dynamic"
|
||||||
|
* (Bestand/Charge/Bewertungen aendern sich laufend) und laedt mehrere
|
||||||
|
* DB-Abfragen parallel, ohne bisher waehrend der Navigation Feedback zu
|
||||||
|
* zeigen. Orientiert sich am tatsaechlichen Zwei-Spalten-Layout der Seite.
|
||||||
|
*/
|
||||||
|
export default function ProductDetailLoading() {
|
||||||
|
return (
|
||||||
|
<div className="py-16 animate-pulse">
|
||||||
|
<Container className="grid md:grid-cols-2 gap-12">
|
||||||
|
<div className="aspect-square w-full bg-ink/10" />
|
||||||
|
<div>
|
||||||
|
<div className="h-3 w-24 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="mt-3 h-9 w-3/4 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="mt-3 h-4 w-1/2 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="mt-6 h-8 w-32 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<div className="h-4 w-full bg-ink/10 rounded-sm" />
|
||||||
|
<div className="h-4 w-5/6 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="h-4 w-2/3 bg-ink/10 rounded-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 h-12 w-full bg-ink/10" />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container className="mt-20">
|
||||||
|
<div className="h-3 w-32 bg-ink/10 rounded-sm mb-8" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="aspect-[4/5] w-full bg-ink/10" />
|
||||||
|
<div className="mt-4 h-5 w-2/3 bg-ink/10 rounded-sm" />
|
||||||
|
<div className="mt-2 h-4 w-1/2 bg-ink/10 rounded-sm" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { getProductBySlug } from "@/lib/data";
|
||||||
|
import { formatPrice } from "@/lib/format";
|
||||||
|
|
||||||
|
export const alt = "Wabenhain Produkt";
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produktspezifisches Social-Preview-Bild statt des generischen
|
||||||
|
* Seiten-Fallbacks (app/opengraph-image.tsx) — wer einen Honig-Link teilt,
|
||||||
|
* soll den tatsaechlichen Produktnamen und Preis in der Vorschau sehen statt
|
||||||
|
* nur des Wabenhain-Markenbilds.
|
||||||
|
*/
|
||||||
|
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const product = await getProductBySlug(slug);
|
||||||
|
|
||||||
|
const title = product?.name ?? "Wabenhain";
|
||||||
|
const tags = product?.tasteTags.slice(0, 3).join(" · ") ?? "";
|
||||||
|
const price = product ? formatPrice(product.priceCents, product.currency) : "";
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "linear-gradient(135deg, #1a271f 0%, #24352b 55%, #3a4d3f 100%)",
|
||||||
|
padding: "80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 20,
|
||||||
|
letterSpacing: 6,
|
||||||
|
color: "#b88a44",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WABENHAIN
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 76,
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: "#f4efe5",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{tags ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 26,
|
||||||
|
color: "#f4efe5",
|
||||||
|
opacity: 0.7,
|
||||||
|
marginTop: 28,
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tags}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{price ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 34,
|
||||||
|
color: "#b88a44",
|
||||||
|
marginTop: 36,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{price}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{ ...size }
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user