feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s

This commit is contained in:
Nick Szaunig committed 2026-09-03 13:51:50 +02:00
1 parent 0753a3bc8a
commit 4d3100e9ba
28 files changed
+789 -108

No files matched your search

+77
View File
@@ -80,3 +80,80 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM="Wabenhain <[email protected]>" SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Ernten-Synchronisierung (optional, admin/ernten -> "ApiCore-
# Synchronisierung"). Ohne diese drei Werte zeigt der Sync-Button einen
# Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein separates
# Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein API-Key-
# Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines dedizierten,
# MFA-freien Service-Kontos mit ausschliesslich den Berechtigungen
# "harvests:read" (und optional "apiaries:read") eintragen, nicht die eines
# echten Admin-Zugangs. Details siehe src/lib/apicore-client.ts.
APICORE_API_URL=
APICORE_SYNC_EMAIL=
APICORE_SYNC_PASSWORD=
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
# der Button dort nur den Hinweis "DHL ist noch nicht konfiguriert".
#
# So kommt ihr an die Werte:
# 1. Account anlegen auf https://developer.dhl.com und eine App registrieren,
# die die API "Parcel DE Shipping" (Post & Parcel Germany) einbindet.
# Die App liefert DHL_API_KEY (Consumer Key) und DHL_API_SECRET
# (Consumer Secret). Fuer den Sandbox-Test reichen diese beiden Werte
# plus die DHL-Testzugangsdaten unten.
# 2. Fuer den PRODUKTIV-Betrieb zusaetzlich einen DHL-Geschaeftskundenvertrag
# ("DHL Paket" Versandvertrag) abschliessen — Ansprechpartner ist der
# DHL-Vertrieb bzw. https://www.dhl.de/de/geschaeftskunden/paket.html.
# Darueber bekommt ihr:
# - Zugangsdaten zum Geschaeftskundenportal (GKP) -> DHL_GKP_USERNAME/
# DHL_GKP_PASSWORD (dieselben Zugangsdaten, mit denen ihr euch auf
# dhl.de/geschaeftskundenportal einloggt).
# - Die EKP (10-stellige Kundennummer) plus Verfahren/Teilnahme fuer
# das gebuchte Produkt (Standard-Inlandspaket V01PAK) -> zusammen
# ergibt das die 14-stellige DHL_ACCOUNT_NUMBER (billingNumber).
# 3. DHL_ENVIRONMENT auf "sandbox" lassen, bis alles funktioniert, dann auf
# "production" umstellen (unterschiedliche Basis-URLs, siehe lib/dhl.ts).
#
# DHL-Sandbox-Testzugangsdaten (Stand der Recherche, koennen sich aendern —
# im Zweifel im DHL-Developer-Portal unter der App nachschauen):
# DHL_GKP_USERNAME=user-valid
# DHL_GKP_PASSWORD=SandboxPasswort2023!
DHL_API_KEY=
DHL_API_SECRET=
DHL_GKP_USERNAME=
DHL_GKP_PASSWORD=
# 14-stellig: EKP (10-stellig) + Verfahren (2-stellig) + Teilnahme (2-stellig)
DHL_ACCOUNT_NUMBER=
DHL_ENVIRONMENT=sandbox
# Microsoft Entra ID SSO ("Mit Microsoft anmelden", siehe
# src/app/api/auth/entra/). Ohne diese vier Werte zeigt der Button auf
# /konto einen Hinweis, dass SSO noch nicht konfiguriert ist, statt
# abzustuerzen.
#
# So richtet ihr es ein (Owner-Aufgabe im Azure-Portal, https://entra.microsoft.com):
# 1. Entra ID -> App registrations -> "New registration".
# - Name frei waehlbar, z.B. "Wabenhain Kundenlogin".
# - "Supported account types": i.d.R. "Accounts in this organizational
# directory only" (Single Tenant) fuer eine reine Firmen-SSO-Anbindung.
# - Redirect URI: Plattform "Web", URL = <eure Domain>/api/auth/entra/callback
# (lokal: http://localhost:3000/api/auth/entra/callback).
# 2. Nach dem Erstellen: "Application (client) ID" -> ENTRA_CLIENT_ID,
# "Directory (tenant) ID" -> ENTRA_TENANT_ID (beide auf der Overview-Seite
# der App-Registrierung sichtbar).
# 3. "Certificates & secrets" -> "New client secret" erzeugen -> der
# angezeigte WERT (nicht die Secret-ID!) -> ENTRA_CLIENT_SECRET. Nur
# einmal sichtbar, bei Verlust ein neues Secret erzeugen.
# 4. ENTRA_REDIRECT_URI = exakt dieselbe URL wie die in Schritt 1 hinterlegte
# Redirect-URI (muss zeichengenau uebereinstimmen).
# 5. "Enterprise applications" in Entra ID zeigt die App danach automatisch
# als Unternehmensapplikation (wird beim App-Registration-Schritt
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
# (z.B. nur bestimmte Gruppen) einschraenken.
ENTRA_TENANT_ID=
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/entra/callback
+2
View File
@@ -21,3 +21,5 @@ data:
GOOGLE_PLACES_API_KEY: "" GOOGLE_PLACES_API_KEY: ""
GOOGLE_PLACE_ID: "" GOOGLE_PLACE_ID: ""
NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production".
DHL_ENVIRONMENT: "sandbox"
+20
View File
@@ -46,3 +46,23 @@ stringData:
SMTP_USER: "" SMTP_USER: ""
SMTP_PASSWORD: "" SMTP_PASSWORD: ""
TURNSTILE_SECRET_KEY: "" TURNSTILE_SECRET_KEY: ""
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
# .env.example fuer die vollstaendige Einrichtungsanleitung. Leer lassen,
# bis vorhanden — der Button zeigt dann "noch nicht konfiguriert" statt
# zu crashen.
DHL_API_KEY: ""
DHL_API_SECRET: ""
DHL_GKP_USERNAME: ""
DHL_GKP_PASSWORD: ""
DHL_ACCOUNT_NUMBER: ""
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. ENTRA_REDIRECT_URI muss auf die echte, oeffentliche
# Domain zeigen (z.B. https://shop.example.com/api/auth/entra/callback).
ENTRA_TENANT_ID: ""
ENTRA_CLIENT_ID: ""
ENTRA_CLIENT_SECRET: ""
ENTRA_REDIRECT_URI: ""
# ApiCore-Ernten-Sync (Admin -> Ernten -> Sync), siehe .env.example.
APICORE_API_URL: ""
APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: ""
+4 -39
View File
@@ -6,6 +6,7 @@ settings:
overrides: overrides:
nanoid: '>=3.3.18' nanoid: '>=3.3.18'
mariadb: '>=3.4.6'
importers: importers:
@@ -27,7 +28,7 @@ importers:
specifier: ^6.2.8 specifier: ^6.2.8
version: 6.2.8 version: 6.2.8
mariadb: mariadb:
specifier: ^3.5.3 specifier: '>=3.4.6'
version: 3.5.3 version: 3.5.3
motion: motion:
specifier: ^13.0.0 specifier: ^13.0.0
@@ -1231,9 +1232,6 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/[email protected]':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
@@ -2071,6 +2069,7 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true hasBin: true
peerDependencies: peerDependencies:
jiti: '*' jiti: '*'
@@ -2322,10 +2321,6 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2727,9 +2722,6 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -2748,10 +2740,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==}
engines: {node: '>= 14'}
[email protected]: [email protected]:
resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==} resolution: {integrity: sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==}
engines: {node: '>= 20.0.0'} engines: {node: '>= 20.0.0'}
@@ -3491,9 +3479,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
[email protected]:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
[email protected]: [email protected]:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
@@ -4249,7 +4234,7 @@ snapshots:
'@prisma/[email protected]': '@prisma/[email protected]':
dependencies: dependencies:
'@prisma/driver-adapter-utils': 7.9.1 '@prisma/driver-adapter-utils': 7.9.1
mariadb: 3.4.5 mariadb: 3.5.3
'@prisma/[email protected]': '@prisma/[email protected]':
dependencies: dependencies:
@@ -4633,10 +4618,6 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/[email protected]':
dependencies:
undici-types: 7.18.2
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
@@ -5941,10 +5922,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
[email protected]:
dependencies:
safer-buffer: 2.1.2
[email protected]: [email protected]:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -6302,8 +6279,6 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -6318,14 +6293,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
[email protected]:
dependencies:
'@types/geojson': 7946.0.16
'@types/node': 24.13.3
denque: 2.1.0
iconv-lite: 0.6.3
lru-cache: 10.4.3
[email protected]: [email protected]:
dependencies: dependencies:
'@types/geojson': 7946.0.16 '@types/geojson': 7946.0.16
@@ -7137,8 +7104,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
+7
View File
@@ -9,3 +9,10 @@ overrides:
# GHSA-2v37-7h3g-55p8: DoS bei size=0 in aelteren nanoid-Versionen, ueber # GHSA-2v37-7h3g-55p8: DoS bei size=0 in aelteren nanoid-Versionen, ueber
# postcss/vite transitiv eingezogen (next, @tailwindcss/postcss, vitest). # postcss/vite transitiv eingezogen (next, @tailwindcss/postcss, vitest).
nanoid: '>=3.3.18' nanoid: '>=3.3.18'
# GHSA-cqhc-2h57-wpxf: mariadb < 3.4.6 leakt das Klartext-Passwort an einen
# MitM trotz ssl:true. @prisma/[email protected] (genutzt in
# lib/db-adapter.ts fuer DATABASE_PROVIDER=mysql) bringt intern eine
# eigene, veraltete [email protected] mit, die vom direkten mariadb-Eintrag in
# den dependencies (bereits auf ^3.5.3) NICHT ueberschrieben wird — pnpm
# haelt dafuer sonst zwei getrennte Kopien vor.
mariadb: '>=3.4.6'
+6
View File
@@ -144,6 +144,12 @@ model Harvest {
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den /// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
/// Normalisierungs-Helfer beim Lesen. /// Normalisierungs-Helfer beim Lesen.
images Json @default("[]") images Json @default("[]")
/// Steuert Sichtbarkeit auf den oeffentlichen Shop-/Ernten-Seiten.
/// Default true, damit alle VOR dieser Spalte angelegten Ernten sofort
/// sichtbar bleiben. Neu aus ApiCore synchronisierte Ernten werden explizit
/// mit false angelegt (siehe admin/actions/apicore-sync.ts) — vorbefuellt,
/// aber erst nach Admin-Review ueber "Veroeffentlichen" oeffentlich sichtbar.
isPublished Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+2
View File
@@ -12,3 +12,5 @@ export * from "./actions/shipping";
export * from "./actions/reviews"; export * from "./actions/reviews";
export * from "./actions/discount-codes"; export * from "./actions/discount-codes";
export * from "./actions/content"; export * from "./actions/content";
export * from "./actions/apicore-sync";
export * from "./actions/dhl";
+39
View File
@@ -11,6 +11,11 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
const year = num(formData, "year"); const year = num(formData, "year");
const type = str(formData, "type"); const type = str(formData, "type");
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`; const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
// Einfuehrung von isPublished).
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
const harvest = await prisma.harvest.create({ const harvest = await prisma.harvest.create({
data: { data: {
@@ -20,6 +25,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")), harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
isPublished,
}, },
}); });
@@ -31,12 +37,14 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
}); });
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten"); redirect("/admin/ernten");
} }
export async function updateHarvestAction(formData: FormData): Promise<void> { export async function updateHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const id = str(formData, "id"); const id = str(formData, "id");
const isPublished = formData.get("isPublished") === "on";
await prisma.harvest.update({ await prisma.harvest.update({
where: { id }, where: { id },
@@ -46,12 +54,43 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")), harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
isPublished,
}, },
}); });
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id }); await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
revalidatePath("/admin/ernten"); revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten");
}
/**
* Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von
* updateHarvestAction — so kann der Status per Knopfdruck direkt aus der
* Ernten-Liste umgeschaltet werden, ohne durch das komplette Bearbeiten-
* Formular zu muessen (insbesondere fuer den "veroeffentlichen"-Bestaetigungs-
* schritt bei aus ApiCore synchronisierten Entwuerfen, siehe apicore-sync.ts).
*/
export async function toggleHarvestPublishedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const isPublished = formData.get("isPublished") === "on";
const harvest = await prisma.harvest.update({
where: { id },
data: { isPublished },
});
await logAdminAction(admin, {
action: isPublished ? "harvest.publish" : "harvest.unpublish",
entityType: "Harvest",
entityId: id,
detail: `${harvest.type} ${harvest.year}`,
});
revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten"); redirect("/admin/ernten");
} }
+31 -1
View File
@@ -3,14 +3,16 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions"; import { updateOrderStatusAction, updateShipmentTrackingAction, createDhlLabelAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge; const statusLabels = t.orderStatusBadge;
const { id } = await props.params; const { id } = await props.params;
const { error, message } = await props.searchParams;
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { include: {
@@ -24,11 +26,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
}, },
}); });
if (!order) notFound(); if (!order) notFound();
const dhlConfigured = isDhlConfigured();
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} /> <BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<div className="flex flex-wrap items-start justify-between gap-4 mb-8"> <div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div> <div>
<h1 className="text-2xl text-forest">{order.orderNumber}</h1> <h1 className="text-2xl text-forest">{order.orderNumber}</h1>
@@ -101,6 +111,26 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
</div> </div>
</div> </div>
{order.fulfillmentMethod === "VERSAND" && order.payment?.status === "SUCCEEDED" ? (
<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.dhlHeading}</h2>
{order.shipment?.trackingNumber ? (
<p className="text-sm text-ink/70">
{t.adminOrderDetailPage.dhlAlreadyCreated(order.shipment.trackingNumber)}
</p>
) : dhlConfigured ? (
<form action={createDhlLabelAction}>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" className="bg-honey-dark text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.dhlCreateButton}
</button>
</form>
) : (
<p className="text-sm text-ink/65">{t.adminOrderDetailPage.dhlNotConfigured}</p>
)}
</div>
) : null}
{order.fulfillmentMethod === "VERSAND" ? ( {order.fulfillmentMethod === "VERSAND" ? (
<div className="bg-background border border-ink/10 p-6 mb-8"> <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> <h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
+20 -1
View File
@@ -1,7 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { updateHarvestAction } from "../../actions"; import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -17,6 +17,20 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
<div className="max-w-xl"> <div className="max-w-xl">
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} /> <BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
{harvest.isPublished ? null : (
<div className="mb-6 bg-honey/10 border border-honey/30 px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
<p className="text-sm text-honey-dark">{t.adminHarvestEditPage.draftNotice}</p>
<form action={toggleHarvestPublishedAction}>
<input type="hidden" name="id" value={harvest.id} />
<input type="hidden" name="isPublished" value="on" />
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm whitespace-nowrap">
{t.adminHarvestEditPage.publishNow}
</button>
</form>
</div>
)}
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6"> <form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -68,6 +82,11 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label> </label>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
{t.adminHarvestEditPage.isPublishedLabel}
</label>
<p className="text-xs text-ink/50">{t.adminHarvestEditPage.isPublishedHint}</p>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestEditPage.save} {t.adminHarvestEditPage.save}
</button> </button>
+52 -4
View File
@@ -2,12 +2,13 @@ import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { createHarvestAction } from "../actions"; import { createHarvestAction, toggleHarvestPublishedAction, syncHarvestsFromApiCoreAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage() { export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const { synced, updated, skipped, failed, syncError } = await props.searchParams;
const harvests = await prisma.harvest.findMany({ const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }], orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } }, include: { _count: { select: { batches: true, products: true } } },
@@ -17,6 +18,18 @@ export default async function AdminHarvestsPage() {
<div> <div>
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
{synced !== undefined ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))}
</p>
) : null}
{syncError !== undefined ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
{t.adminHarvestsPage.syncError} ({syncError})
</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
@@ -27,6 +40,7 @@ export default async function AdminHarvestsPage() {
<th className="p-4">{t.adminHarvestsPage.colLocation}</th> <th className="p-4">{t.adminHarvestsPage.colLocation}</th>
<th className="p-4">{t.adminHarvestsPage.colBatches}</th> <th className="p-4">{t.adminHarvestsPage.colBatches}</th>
<th className="p-4">{t.adminHarvestsPage.colProducts}</th> <th className="p-4">{t.adminHarvestsPage.colProducts}</th>
<th className="p-4">{t.adminHarvestsPage.colStatus}</th>
<th className="p-4" /> <th className="p-4" />
</tr> </tr>
</thead> </thead>
@@ -40,9 +54,27 @@ export default async function AdminHarvestsPage() {
<td className="p-4">{harvest._count.batches}</td> <td className="p-4">{harvest._count.batches}</td>
<td className="p-4">{harvest._count.products}</td> <td className="p-4">{harvest._count.products}</td>
<td className="p-4"> <td className="p-4">
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline"> <span
className={
harvest.isPublished
? "text-forest bg-forest/10 px-2 py-1 text-xs"
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs"
}
>
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
</span>
</td>
<td className="p-4 whitespace-nowrap">
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4">
{t.adminHarvestsPage.edit} {t.adminHarvestsPage.edit}
</Link> </Link>
<form action={toggleHarvestPublishedAction} className="inline">
<input type="hidden" name="id" value={harvest.id} />
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
<button type="submit" className="text-forest underline">
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
</button>
</form>
</td> </td>
</tr> </tr>
))} ))}
@@ -51,7 +83,8 @@ export default async function AdminHarvestsPage() {
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null} {harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
</div> </div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl"> <div className="mt-10 grid md:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2> <h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
<form action={createHarvestAction} className="space-y-4"> <form action={createHarvestAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@@ -61,11 +94,26 @@ export default async function AdminHarvestsPage() {
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required /> <Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" /> <Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required /> <TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked />
{t.adminHarvestsPage.isPublishedLabel}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestsPage.create} {t.adminHarvestsPage.create}
</button> </button>
</form> </form>
</div> </div>
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.syncHeading}</h2>
<p className="text-sm text-ink/65 mb-4">{t.adminHarvestsPage.syncIntro}</p>
<form action={syncHarvestsFromApiCoreAction}>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestsPage.syncButton}
</button>
</form>
</div>
</div>
</div> </div>
); );
} }
+1
View File
@@ -28,6 +28,7 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [ const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
{ key: "company", heading: t.adminContentPage.groupCompany }, { key: "company", heading: t.adminContentPage.groupCompany },
{ key: "home", heading: t.adminContentPage.groupHome }, { key: "home", heading: t.adminContentPage.groupHome },
{ key: "social", heading: t.adminContentPage.groupSocial },
{ key: "legal", heading: t.adminContentPage.groupLegal }, { key: "legal", heading: t.adminContentPage.groupLegal },
]; ];
+6
View File
@@ -3,8 +3,14 @@ import { prisma } from "@/lib/prisma";
import { businessInquirySchema } from "@/lib/validation"; import { businessInquirySchema } from "@/lib/validation";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { verifyTurnstileToken } from "@/lib/turnstile"; import { verifyTurnstileToken } from "@/lib/turnstile";
import { isTrustedOrigin } from "@/lib/csrf";
export async function POST(request: Request) { export async function POST(request: Request) {
// Route-Handler haben keinen eingebauten CSRF-Schutz — siehe lib/csrf.ts.
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`business-inquiry:${ip}`, 5, 60 * 60 * 1000)) { if (!rateLimit(`business-inquiry:${ip}`, 5, 60 * 60 * 1000)) {
return NextResponse.json( return NextResponse.json(
+9
View File
@@ -6,6 +6,7 @@ import { checkoutSchema } from "@/lib/validation";
import { getProductsByIds } from "@/lib/data"; import { getProductsByIds } from "@/lib/data";
import { getCurrentCustomer } from "@/lib/auth"; import { getCurrentCustomer } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { getShippingCostCents } from "@/lib/shipping"; import { getShippingCostCents } from "@/lib/shipping";
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc"; import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
import { ensureBulkDiscountCode } from "@/lib/bulk-discount"; import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
@@ -23,6 +24,14 @@ function generateOrderNumber(): string {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
// Route-Handler haben (anders als Server Actions) keinen eingebauten
// CSRF-Schutz — siehe lib/csrf.ts. Ohne diese Pruefung koennte eine fremde
// Seite per gefaelschtem Cross-Site-POST Bestellungen unter dem Konto
// einer eingeloggten Kundschaft anlegen und Lagerbestand reservieren.
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) { if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) {
return NextResponse.json( return NextResponse.json(
+6
View File
@@ -7,9 +7,15 @@ import { sendMail } from "@/lib/mail";
import { newsletterConfirmEmail } from "@/lib/email-templates"; import { newsletterConfirmEmail } from "@/lib/email-templates";
import { getBaseUrl } from "@/lib/url"; import { getBaseUrl } from "@/lib/url";
import { verifyTurnstileToken } from "@/lib/turnstile"; import { verifyTurnstileToken } from "@/lib/turnstile";
import { isTrustedOrigin } from "@/lib/csrf";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
export async function POST(request: Request) { export async function POST(request: Request) {
// Route-Handler haben keinen eingebauten CSRF-Schutz — siehe lib/csrf.ts.
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const ip = await getClientIp(); const ip = await getClientIp();
if (!rateLimit(`newsletter:${ip}`, 5, 60 * 60 * 1000)) { if (!rateLimit(`newsletter:${ip}`, 5, 60 * 60 * 1000)) {
return NextResponse.json( return NextResponse.json(
+6 -1
View File
@@ -11,7 +11,8 @@ export default async function ImpressumPage() {
const locale = await getLocale(); const locale = await getLocale();
const t = getDictionary(locale); const t = getDictionary(locale);
const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.impressum", locale)]); const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.impressum", locale)]);
const [angabenSection, ustSection, streitSection] = parseSections(raw); const [angabenSection, ustSection, redaktionSection, haftungInhalteSection, haftungLinksSection, urheberrechtSection, streitSection] =
parseSections(raw);
return ( return (
<LegalPage <LegalPage
@@ -30,6 +31,10 @@ export default async function ImpressumPage() {
paragraphs: [`${t.impressumPage.phoneLabel}: ${company.phone}`, `${t.impressumPage.emailLabel}: ${company.email}`], paragraphs: [`${t.impressumPage.phoneLabel}: ${company.phone}`, `${t.impressumPage.emailLabel}: ${company.email}`],
}, },
ustSection, ustSection,
redaktionSection,
haftungInhalteSection,
haftungLinksSection,
urheberrechtSection,
streitSection, streitSection,
]} ]}
/> />
+11 -5
View File
@@ -2,7 +2,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { createSession, getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth"; import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
import { rateLimit, getClientIp } from "@/lib/rate-limit"; import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { sendMail } from "@/lib/mail"; import { sendMail } from "@/lib/mail";
import { securityAlertEmail } from "@/lib/email-templates"; import { securityAlertEmail } from "@/lib/email-templates";
@@ -50,16 +50,22 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht.")); redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
} }
/** Widerruft alle bestehenden Sessions (auch andere Geraete) und meldet nur das aktuelle Tab neu an. */ /**
* Widerruft alle bestehenden Sessions — auch das aktuell genutzte Geraet.
* Bewusst KEIN Re-Login des aktuellen Tabs mehr (fruehere Variante): "alle
* Geraete abmelden" muss auch die eigene, gerade aktive Sitzung beenden,
* sonst waere ein gestohlenes/kompromittiertes aktuelles Session-Cookie von
* dieser Aktion gar nicht betroffen.
*/
export async function logoutAllDevicesAction(): Promise<void> { export async function logoutAllDevicesAction(): Promise<void> {
const customer = await getCurrentCustomer(); const customer = await getCurrentCustomer();
if (!customer) fail("/konto", "Bitte melde dich an."); if (!customer) fail("/konto", "Bitte melde dich an.");
const updated = await prisma.customer.update({ await prisma.customer.update({
where: { id: customer.id }, where: { id: customer.id },
data: { sessionVersion: { increment: 1 } }, data: { sessionVersion: { increment: 1 } },
}); });
await createSession(updated.id, updated.role, updated.sessionVersion); await destroySession();
redirect("/konto?message=" + encodeURIComponent("Alle anderen Geräte wurden abgemeldet.")); redirect("/konto?message=" + encodeURIComponent("Alle Geräte wurden abgemeldet. Bitte melde dich erneut an."));
} }
+29
View File
@@ -80,6 +80,35 @@ export default async function KontoPage(props: PageProps<"/konto">) {
<Button type="submit">{t.account.register}</Button> <Button type="submit">{t.account.register}</Button>
</form> </form>
</div> </div>
<div className="mt-10 flex items-center gap-4 text-xs text-ink/50">
<span className="h-px flex-1 bg-ink/10" />
{t.account.ssoDivider}
<span className="h-px flex-1 bg-ink/10" />
</div>
<div className="mt-6">
{/*
Bewusst ein einfaches <a> statt LinkButton/next/link: der Klick
muss eine ECHTE Browser-Top-Level-Navigation ausloesen, die dem
302-Redirect dieser Route bis zu login.microsoftonline.com
folgt. Next.js' <Link> navigiert clientseitig per fetch() —
dieser Cross-Origin-Redirect-Sprung wuerde dann von der
connect-src-CSP in proxy.ts blockiert (die nur "self" u.ae.
erlaubt), waehrend eine echte Top-Level-Navigation von CSP
connect-src gar nicht erst betroffen ist.
Bewusst immer sichtbar, auch ohne konfiguriertes SSO (siehe
.env.example, ENTRA_*) — der Link fuehrt in dem Fall zu einer
klaren Fehlermeldung auf dieser Seite statt eines defekten
Buttons (src/app/api/auth/entra/login/route.ts prueft die
Konfiguration server-seitig, bevor irgendetwas passiert).
*/}
<a
href="/api/auth/entra/login"
className="inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream"
>
{t.account.ssoButton}
</a>
</div>
</Container> </Container>
</div> </div>
); );
+3 -2
View File
@@ -5,6 +5,7 @@ import { Header, Footer, CartDrawer } from "@/components/site-chrome";
import { CookieConsentBanner } from "@/components/cookie-consent"; import { CookieConsentBanner } from "@/components/cookie-consent";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getSocialLinks } from "@/lib/social-links";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { I18nProvider } from "@/i18n/i18n-provider"; import { I18nProvider } from "@/i18n/i18n-provider";
@@ -41,7 +42,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const locale = await getLocale(); const locale = await getLocale();
const dict = getDictionary(locale); const dict = getDictionary(locale);
const company = await getCompanyInfo(); const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]);
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt // LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
// auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche // auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche
@@ -105,7 +106,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
<main id="main-content" className="flex-1"> <main id="main-content" className="flex-1">
{children} {children}
</main> </main>
<Footer companyInfo={company} /> <Footer companyInfo={company} socialLinks={socialLinks} />
<CartDrawer /> <CartDrawer />
<CookieConsentBanner /> <CookieConsentBanner />
</I18nProvider> </I18nProvider>
+99 -8
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect, useRef, type RefObject } from "react"; import { useState, useEffect, useRef, type RefObject, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store"; import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
@@ -512,12 +512,88 @@ export function CartDrawer() {
); );
} }
export function Footer({ // Erstes vollstaendiges Geschaeftsjahr — Startpunkt der im Footer
companyInfo, // angezeigten Copyright-Spanne "2023–<aktuelles Jahr>". Das Endjahr wird
// bewusst NICHT gespeichert, sondern bei jedem Render live aus
// new Date().getFullYear() berechnet, damit es nie per Hand nachgepflegt
// werden muss.
const FOUNDING_YEAR = 2023;
const SOCIAL_ICON_PROPS = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.5 } as const;
function TikTokIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<path d="M14 4v10.5a3.5 3.5 0 1 1-3.5-3.5" />
<path d="M14 4c.3 1.8 1.6 3.6 4 4" />
</svg>
);
}
function YouTubeIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<rect x="2" y="5" width="20" height="14" rx="4" />
<path d="M10 9l6 3-6 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function InstagramIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
);
}
interface SocialLinks {
tiktok: string;
youtube: string;
instagram: string;
}
/**
* Ein Icon+Name-Link wird nur gerendert, wenn im Admin-Bereich (Inhalte ->
* Social Media) tatsaechlich eine URL hinterlegt ist — nie ein toter/
* Platzhalter-Link (siehe getSocialLinks()/content-registry.ts).
*/
function SocialLink({
href,
name,
icon,
}: { }: {
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } }; href: string;
/** Plattformname (Eigenname, z.B. "TikTok") — unlokalisiert, dient als sichtbarer Linktext. */
name: string;
icon: ReactNode;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={t.footer.socialAriaLabel(name)}
className="flex items-center gap-1.5 text-cream/70 hover:text-cream transition-colors"
>
{icon}
<span className="text-xs">{name}</span>
</a>
);
}
export function Footer({
companyInfo,
socialLinks,
}: {
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
socialLinks: SocialLinks;
}) {
const { t } = useI18n();
const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = { const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = {
aboutUs: t.footer.aboutUs, aboutUs: t.footer.aboutUs,
@@ -527,8 +603,6 @@ export function Footer({
}; };
const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = { const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = {
shop: t.nav.shop, shop: t.nav.shop,
shipping: t.footer.shipping,
pickup: t.footer.pickup,
businessCustomers: t.footer.businessCustomers, businessCustomers: t.footer.businessCustomers,
trackOrder: t.footer.trackOrder, trackOrder: t.footer.trackOrder,
}; };
@@ -565,8 +639,25 @@ export function Footer({
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} /> <FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
<FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} /> <FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} />
</div> </div>
<div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row justify-between gap-4 text-xs text-cream/70"> <div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row md:items-center justify-between gap-4 text-xs text-cream/70">
<span>© {new Date().getFullYear()} Wabenhain</span> <div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<span>
© {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
</span>
{hasAnySocialLink ? (
<div className="flex items-center gap-4">
{socialLinks.tiktok ? (
<SocialLink href={socialLinks.tiktok} name="TikTok" icon={<TikTokIcon />} />
) : null}
{socialLinks.youtube ? (
<SocialLink href={socialLinks.youtube} name="YouTube" icon={<YouTubeIcon />} />
) : null}
{socialLinks.instagram ? (
<SocialLink href={socialLinks.instagram} name="Instagram" icon={<InstagramIcon />} />
) : null}
</div>
) : null}
</div>
<span>{t.footer.bottomLine}</span> <span>{t.footer.bottomLine}</span>
</div> </div>
</Container> </Container>
+120 -20
View File
@@ -32,12 +32,11 @@ export const de: Dictionary = {
columnShopping: "Einkaufen", columnShopping: "Einkaufen",
columnLegal: "Rechtliches", columnLegal: "Rechtliches",
aboutUs: "Über uns", aboutUs: "Über uns",
shipping: "Versand",
pickup: "Abholung",
businessCustomers: "Geschäftskunden", businessCustomers: "Geschäftskunden",
trackOrder: "Bestellung verfolgen", trackOrder: "Bestellung verfolgen",
tagline: "Honig aus den Westlichen Wäldern.", tagline: "Honig aus den Westlichen Wäldern.",
bottomLine: "Geerntet · abgefüllt · versendet aus eigener Imkerei in Altenmünster", bottomLine: "Geerntet · abgefüllt · versendet aus eigener Imkerei in Altenmünster",
socialAriaLabel: (platform) => `Wabenhain bei ${platform}`,
}, },
legalTitles: { legalTitles: {
impressum: "Impressum", impressum: "Impressum",
@@ -168,11 +167,41 @@ export const de: Dictionary = {
sections: [ sections: [
{ {
heading: "Angaben gemäß § 5 TMG", heading: "Angaben gemäß § 5 TMG",
paragraphs: ["[PLATZHALTER] Inhaber: Vor- und Nachname"], paragraphs: [
"[PLATZHALTER — Name der Inhaberin/des Inhabers bzw. der Geschäftsführung eintragen]",
"[PLATZHALTER — Rechtsform eintragen, z.B. Einzelunternehmen/e.K./GmbH. Sofern im Handelsregister eingetragen: Registergericht und Handelsregisternummer ergänzen.]",
],
}, },
{ {
heading: "Umsatzsteuer-ID", heading: "Umsatzsteuer-ID",
paragraphs: ["[PLATZHALTER] Umsatzsteuer-Identifikationsnummer gemäß §27a Umsatzsteuergesetz."], paragraphs: [
"[PLATZHALTER] Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz.",
"Sofern stattdessen die Kleinunternehmerregelung nach § 19 UStG angewendet wird, entfällt eine Umsatzsteuer-ID — dies ist an dieser Stelle entsprechend zu vermerken (z.B. \"Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.\").",
],
},
{
heading: "Redaktionell verantwortlich",
paragraphs: [
"Verantwortlich für den redaktionellen Inhalt gemäß § 18 Abs. 2 MStV (u.a. die Beiträge im Bereich \"Honigwissen\"): [Name wie oben].",
],
},
{
heading: "Haftung für Inhalte",
paragraphs: [
"Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden entsprechender Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.",
],
},
{
heading: "Haftung für Links",
paragraphs: [
"Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zu diesem Zeitpunkt nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.",
],
},
{
heading: "Urheberrecht",
paragraphs: [
"Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.",
],
}, },
{ {
heading: "Streitschlichtung", heading: "Streitschlichtung",
@@ -187,53 +216,61 @@ export const de: Dictionary = {
sections: [ sections: [
{ {
heading: "1. Verantwortlicher", heading: "1. Verantwortlicher",
paragraphs: ["[PLATZHALTER] Datenschutzbeauftragte/r (falls gesetzlich erforderlich): Name, Kontaktdaten."], paragraphs: [
"Sofern gesetzlich erforderlich (z.B. bei umfangreicher Verarbeitung besonderer Datenkategorien oder mehr als 20 Personen, die ständig mit der automatisierten Verarbeitung personenbezogener Daten befasst sind, § 38 BDSG), benennen wir hier eine/n Datenschutzbeauftragte/n: [PLATZHALTER — Name und Kontaktdaten eintragen, falls zutreffend].",
],
}, },
{ {
heading: "2. Server-Logfiles beim Besuch der Website", heading: "2. Hosting",
paragraphs: [
"Unsere Website wird bei einem externen Hosting-Anbieter gehostet: [PLATZHALTER — Name und Anschrift des Hosting-Anbieters eintragen]. Mit diesem Anbieter besteht ein Auftragsverarbeitungsvertrag gemäß Art. 28 DSGVO. Der Hosting-Anbieter verarbeitet in unserem Auftrag insbesondere die in den Server-Logfiles (siehe Punkt 3) anfallenden Daten sowie sämtliche über diese Website eingegebenen Daten.",
],
},
{
heading: "3. Server-Logfiles beim Besuch der Website",
paragraphs: [ paragraphs: [
"Beim Aufruf unserer Website erhebt unser Hosting-Anbieter automatisch technische Zugriffsdaten (z.B. IP-Adresse, Datum und Uhrzeit des Zugriffs, aufgerufene Seite, Browsertyp, Referrer-URL) in sogenannten Server-Logfiles. Diese Verarbeitung ist zur Bereitstellung der Website technisch erforderlich (Art. 6 Abs. 1 lit. f DSGVO) und wird nach kurzer Zeit automatisch gelöscht.", "Beim Aufruf unserer Website erhebt unser Hosting-Anbieter automatisch technische Zugriffsdaten (z.B. IP-Adresse, Datum und Uhrzeit des Zugriffs, aufgerufene Seite, Browsertyp, Referrer-URL) in sogenannten Server-Logfiles. Diese Verarbeitung ist zur Bereitstellung der Website technisch erforderlich (Art. 6 Abs. 1 lit. f DSGVO) und wird nach kurzer Zeit automatisch gelöscht.",
], ],
}, },
{ {
heading: "3. Bestellung und Vertragsabwicklung", heading: "4. Bestellung und Vertragsabwicklung",
paragraphs: [ paragraphs: [
"Bei einer Bestellung erheben wir die für die Vertrags-, Zahlungs- und Versandabwicklung notwendigen Daten (Name, Adresse, E-Mail, ggf. Telefonnummer sowie die bestellten Artikel). Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).", "Bei einer Bestellung erheben wir die für die Vertrags-, Zahlungs- und Versandabwicklung notwendigen Daten (Name, Adresse, E-Mail, ggf. Telefonnummer sowie die bestellten Artikel). Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).",
"Zahlungen werden über unseren Zahlungsdienstleister Stripe Payments Europe, Ltd. abgewickelt. Dabei werden die für die Zahlungsabwicklung notwendigen Daten an Stripe übermittelt. Es gelten zusätzlich die Datenschutzhinweise von Stripe (stripe.com/de/privacy).", "Zahlungen werden über unseren Zahlungsdienstleister Stripe Payments Europe, Ltd. abgewickelt. Dabei werden die für die Zahlungsabwicklung notwendigen Daten an Stripe übermittelt. Es gelten zusätzlich die Datenschutzhinweise von Stripe (stripe.com/de/privacy). Soweit dabei Daten an mit Stripe verbundene Unternehmen außerhalb der EU/des EWR (insbesondere Stripe, Inc. in den USA) übermittelt werden, erfolgt dies auf Grundlage von EU-Standardvertragsklauseln gemäß Art. 46 DSGVO.",
"Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.", "Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.",
], ],
}, },
{ {
heading: "4. Kundenkonto", heading: "5. Kundenkonto",
paragraphs: [ paragraphs: [
"Legst du ein Kundenkonto an, speichern wir die von dir angegebenen Daten (Name, E-Mail, Passwort verschlüsselt als Hash) sowie optional hinterlegte Lieferadressen, um dir Bestellhistorie, Rechnungseinsicht und eine schnellere Kasse zu ermöglichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Du kannst dein Konto und die zugehörigen Daten jederzeit über eine formlose Anfrage an uns löschen lassen.", "Legst du ein Kundenkonto an, speichern wir die von dir angegebenen Daten (Name, E-Mail, Passwort verschlüsselt als Hash) sowie optional hinterlegte Lieferadressen, um dir Bestellhistorie, Rechnungseinsicht und eine schnellere Kasse zu ermöglichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Du kannst dein Konto und die zugehörigen Daten jederzeit über eine formlose Anfrage an uns löschen lassen.",
"Zur Anmeldung wird ein technisch notwendiges Cookie gesetzt, siehe unsere Cookie-Richtlinie.", "Zur Anmeldung wird ein technisch notwendiges Cookie gesetzt, siehe unsere Cookie-Richtlinie.",
], ],
}, },
{ {
heading: "5. Newsletter (Erntepost)", heading: "6. Newsletter (Erntepost)",
paragraphs: [ paragraphs: [
"Meldest du dich zu unserer Erntepost an, verwenden wir deine E-Mail-Adresse ausschließlich zum Versand von Informationen über neue Ernten. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Eine Abmeldung ist jederzeit über den Link in jeder E-Mail oder im Kundenkonto möglich.", "Meldest du dich zu unserer Erntepost an, verwenden wir deine E-Mail-Adresse ausschließlich zum Versand von Informationen über neue Ernten. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Eine Abmeldung ist jederzeit über den Link in jeder E-Mail oder im Kundenkonto möglich.",
], ],
}, },
{ {
heading: "6. Kontakt- und Firmenkundenformular", heading: "7. Kontakt- und Firmenkundenformular",
paragraphs: [ paragraphs: [
"Nimmst du über ein Formular Kontakt zu uns auf (z.B. Firmenkunden-Anfrage), verarbeiten wir die von dir mitgeteilten Daten zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b bzw. f DSGVO.", "Nimmst du über ein Formular Kontakt zu uns auf (z.B. Firmenkunden-Anfrage), verarbeiten wir die von dir mitgeteilten Daten zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b bzw. f DSGVO.",
], ],
}, },
{ {
heading: "7. Google-Rezensionen", heading: "8. Google-Rezensionen",
paragraphs: [ paragraphs: [
"Auf unserer Startseite zeigen wir Bewertungen von unserem Google-Unternehmensprofil an. Die Rezensionstexte werden dafür serverseitig über die Google Places API abgerufen; dein Browser baut dabei keine eigene Verbindung zu Google auf. Anbieter: Google Ireland Limited. Es gelten die Datenschutzhinweise von Google (policies.google.com/privacy).", "Auf unserer Startseite zeigen wir Bewertungen von unserem Google-Unternehmensprofil an. Die Rezensionstexte werden dafür serverseitig über die Google Places API abgerufen; dein Browser baut dabei keine eigene Verbindung zu Google auf. Anbieter: Google Ireland Limited, wobei eine Übermittlung an die Google LLC (USA) nicht ausgeschlossen werden kann. Es gelten die Datenschutzhinweise von Google (policies.google.com/privacy).",
], ],
}, },
{ {
heading: "8. Cookies", heading: "9. Cookies",
paragraphs: ["Details zu den auf dieser Website eingesetzten Cookies findest du in unserer Cookie-Richtlinie."], paragraphs: ["Details zu den auf dieser Website eingesetzten Cookies findest du in unserer Cookie-Richtlinie."],
}, },
{ {
heading: "9. Deine Rechte", heading: "10. Deine Rechte",
paragraphs: [ paragraphs: [
"Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) sowie Widerspruch gegen die Verarbeitung (Art. 21 DSGVO) deiner personenbezogenen Daten.", "Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) sowie Widerspruch gegen die Verarbeitung (Art. 21 DSGVO) deiner personenbezogenen Daten.",
"Zudem steht dir ein Beschwerderecht bei einer Datenschutz-Aufsichtsbehörde zu, z.B. dem Bayerischen Landesamt für Datenschutzaufsicht.", "Zudem steht dir ein Beschwerderecht bei einer Datenschutz-Aufsichtsbehörde zu, z.B. dem Bayerischen Landesamt für Datenschutzaufsicht.",
@@ -246,22 +283,61 @@ export const de: Dictionary = {
sections: [ sections: [
{ {
heading: "1. Geltungsbereich", heading: "1. Geltungsbereich",
paragraphs: ["Diese AGB gelten für alle Bestellungen über wabenhain.com zwischen Wabenhain und ihren Kund:innen."], paragraphs: [
"Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Bestellungen über wabenhain.com zwischen Wabenhain und ihren Kund:innen, sowohl Verbraucher:innen im Sinne des § 13 BGB als auch Unternehmer:innen im Sinne des § 14 BGB.",
],
}, },
{ {
heading: "2. Vertragsschluss", heading: "2. Vertragsschluss",
paragraphs: [ paragraphs: [
"Die Darstellung der Produkte im Shop stellt kein bindendes Angebot dar. Mit Abschluss der Bestellung gibst du ein verbindliches Angebot zum Kauf ab, das wir durch eine Bestätigung annehmen.", "Die Darstellung der Produkte im Shop stellt kein bindendes Angebot dar, sondern eine unverbindliche Aufforderung, eine Bestellung aufzugeben. Mit Abschluss der Bestellung gibst du ein verbindliches Angebot zum Kauf ab, das wir durch eine Bestätigung per E-Mail oder durch Lieferung der Ware annehmen.",
], ],
}, },
{ {
heading: "3. Preise und Versandkosten", heading: "3. Preise und Versandkosten",
paragraphs: ["Alle Preise verstehen sich inklusive der gesetzlichen Umsatzsteuer. Versandkosten werden im Checkout transparent ausgewiesen."], paragraphs: ["Alle Preise verstehen sich inklusive der gesetzlichen Umsatzsteuer. Versandkosten werden im Checkout transparent ausgewiesen, siehe auch unsere Seite \"Versand und Zahlung\"."],
}, },
{ {
heading: "4. Lieferung und Abholung", heading: "4. Zahlungsarten",
paragraphs: [
"Wir bieten PayPal, Kreditkarte, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich. Details siehe unsere Seite \"Versand und Zahlung\" sowie unsere Datenschutzerklärung zu Stripe.",
"Die Kaufpreiszahlung ist grundsätzlich sofort mit Bestellung fällig (Vorkasse über den gewählten Zahlungsdienst), sofern nichts anderes vereinbart wurde.",
],
},
{
heading: "5. Lieferung und Abholung",
paragraphs: ["Wir liefern innerhalb Deutschlands. Alternativ kann die Ware nach Absprache direkt bei uns in Altenmünster abgeholt werden."], paragraphs: ["Wir liefern innerhalb Deutschlands. Alternativ kann die Ware nach Absprache direkt bei uns in Altenmünster abgeholt werden."],
}, },
{
heading: "6. Widerrufsrecht",
paragraphs: [
"Verbraucher:innen steht grundsätzlich ein gesetzliches Widerrufsrecht zu. Einzelheiten, insbesondere zu Ausnahmen bei schnell verderblichen Waren wie Honig, entnimmst du unserer separaten Widerrufsbelehrung.",
],
},
{
heading: "7. Gewährleistung",
paragraphs: [
"Es gelten die gesetzlichen Gewährleistungsrechte (§§ 434 ff. BGB). Bei Verbraucher:innen beträgt die Verjährungsfrist für Mängelansprüche zwei Jahre ab Ablieferung der Ware.",
"Bei Lebensmitteln wie Honig weist das aufgedruckte Mindesthaltbarkeitsdatum (MHD) darauf hin, bis wann das Produkt bei sachgerechter Lagerung mindestens seine spezifischen Eigenschaften behält — es handelt sich nicht um ein Verfallsdatum und begründet für sich genommen keinen Mangel.",
],
},
{
heading: "8. Eigentumsvorbehalt",
paragraphs: ["Die gelieferte Ware bleibt bis zur vollständigen Bezahlung unser Eigentum."],
},
{
heading: "9. Streitschlichtung",
paragraphs: [
"Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr. Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.",
],
},
{
heading: "10. Schlussbestimmungen",
paragraphs: [
"Es gilt das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Bei Verbraucher:innen gilt dies nur, soweit hierdurch der durch zwingende Bestimmungen des Rechts des gewöhnlichen Aufenthaltslandes gewährte Schutz nicht entzogen wird.",
"Sollte eine Bestimmung dieser AGB unwirksam sein oder werden, bleibt die Wirksamkeit der übrigen Bestimmungen hiervon unberührt.",
],
},
], ],
}, },
widerrufPage: { widerrufPage: {
@@ -790,6 +866,11 @@ export const de: Dictionary = {
colLocation: "Standort", colLocation: "Standort",
colBatches: "Chargen", colBatches: "Chargen",
colProducts: "Produkte", colProducts: "Produkte",
colStatus: "Status",
statusPublished: "Veröffentlicht",
statusDraft: "Entwurf",
publish: "Veröffentlichen",
unpublish: "Verbergen",
edit: "Bearbeiten", edit: "Bearbeiten",
noHarvestsYet: "Noch keine Ernten angelegt.", noHarvestsYet: "Noch keine Ernten angelegt.",
newHarvestHeading: "Neue Ernte anlegen", newHarvestHeading: "Neue Ernte anlegen",
@@ -798,7 +879,15 @@ export const de: Dictionary = {
harvestDateLabel: "Erntedatum", harvestDateLabel: "Erntedatum",
locationLabel: "Standort", locationLabel: "Standort",
descriptionLabel: "Beschreibung", descriptionLabel: "Beschreibung",
isPublishedLabel: "Sofort veröffentlichen",
create: "Anlegen", create: "Anlegen",
syncHeading: "ApiCore-Synchronisierung",
syncIntro:
"Importiert Ernten aus ApiCore als Entwürfe (nicht öffentlich sichtbar). Bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert — nur neue oder noch unveröffentlichte Entwürfe werden aktualisiert.",
syncButton: "Jetzt synchronisieren",
syncResult: (created, updated, skipped, failed) =>
`Synchronisierung abgeschlossen: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
}, },
adminHarvestEditPage: { adminHarvestEditPage: {
backToHarvests: "Zurück zu Ernten", backToHarvests: "Zurück zu Ernten",
@@ -808,6 +897,10 @@ export const de: Dictionary = {
harvestDateLabel: "Erntedatum", harvestDateLabel: "Erntedatum",
locationLabel: "Standort", locationLabel: "Standort",
descriptionLabel: "Beschreibung", descriptionLabel: "Beschreibung",
isPublishedLabel: "Öffentlich sichtbar",
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.",
draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.",
publishNow: "Jetzt veröffentlichen",
save: "Speichern", save: "Speichern",
}, },
adminProductsPage: { adminProductsPage: {
@@ -945,6 +1038,10 @@ export const de: Dictionary = {
carrierPlaceholder: "z.B. DHL, DPD, Hermes", carrierPlaceholder: "z.B. DHL, DPD, Hermes",
trackingNumberLabel: "Sendungsnummer", trackingNumberLabel: "Sendungsnummer",
saveTracking: "Speichern", saveTracking: "Speichern",
dhlHeading: "DHL-Versandmarke",
dhlCreateButton: "Versandmarke erstellen",
dhlAlreadyCreated: (trackingNumber) => `DHL-Versandmarke bereits erstellt — Sendungsnummer ${trackingNumber}`,
dhlNotConfigured: "DHL ist noch nicht konfiguriert, siehe .env (DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, DHL_GKP_PASSWORD, DHL_ACCOUNT_NUMBER).",
}, },
adminOrdersPage: { adminOrdersPage: {
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
@@ -998,6 +1095,7 @@ export const de: Dictionary = {
groupCompany: "Firmendaten", groupCompany: "Firmendaten",
groupHome: "Homepage", groupHome: "Homepage",
groupLegal: "Rechtstexte", groupLegal: "Rechtstexte",
groupSocial: "Social Media",
deLabel: "Deutsch", deLabel: "Deutsch",
enLabel: "Englisch", enLabel: "Englisch",
save: "Speichern", save: "Speichern",
@@ -1071,6 +1169,8 @@ export const de: Dictionary = {
firstNameLabel: "Vorname", firstNameLabel: "Vorname",
lastNameLabel: "Nachname", lastNameLabel: "Nachname",
forgotPassword: "Passwort vergessen?", forgotPassword: "Passwort vergessen?",
ssoDivider: "oder",
ssoButton: "Mit Microsoft anmelden",
greeting: (name) => `Hallo, ${name}`, greeting: (name) => `Hallo, ${name}`,
verifyEmailPrompt: "Bitte bestätige deine E-Mail-Adresse — ohne Bestätigung kannst du mit diesem Konto nicht bestellen.", verifyEmailPrompt: "Bitte bestätige deine E-Mail-Adresse — ohne Bestätigung kannst du mit diesem Konto nicht bestellen.",
resendVerification: "Link erneut senden", resendVerification: "Link erneut senden",
@@ -1090,7 +1190,7 @@ export const de: Dictionary = {
subscribe: "Anmelden", subscribe: "Anmelden",
unsubscribe: "Abmelden", unsubscribe: "Abmelden",
logoutAllDevicesHeading: "Alle Geräte abmelden", logoutAllDevicesHeading: "Alle Geräte abmelden",
logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen auf anderen Geräten.", logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen, auch dieses Gerät. Du musst dich danach neu anmelden.",
logout: "Abmelden", logout: "Abmelden",
privacy: { privacy: {
heading: "Datenschutz", heading: "Datenschutz",
+121 -21
View File
@@ -32,12 +32,11 @@ export const en: Dictionary = {
columnShopping: "Shopping", columnShopping: "Shopping",
columnLegal: "Legal", columnLegal: "Legal",
aboutUs: "About us", aboutUs: "About us",
shipping: "Shipping",
pickup: "Pickup",
businessCustomers: "Business customers", businessCustomers: "Business customers",
trackOrder: "Track order", trackOrder: "Track order",
tagline: "Honey from the Western Woods.", tagline: "Honey from the Western Woods.",
bottomLine: "Harvested · bottled · shipped from our own apiary in Altenmünster", bottomLine: "Harvested · bottled · shipped from our own apiary in Altenmünster",
socialAriaLabel: (platform) => `Wabenhain on ${platform}`,
}, },
legalTitles: { legalTitles: {
impressum: "Legal Notice", impressum: "Legal Notice",
@@ -167,12 +166,42 @@ export const en: Dictionary = {
emailLabel: "Email", emailLabel: "Email",
sections: [ sections: [
{ {
heading: "Information pursuant to § 5 TMG", heading: "Information pursuant to § 5 TMG (German Telemedia Act)",
paragraphs: ["[PLACEHOLDER] Owner: First and last name"], paragraphs: [
"[PLACEHOLDER — enter the name of the owner / managing director]",
"[PLACEHOLDER — enter the legal form, e.g. sole proprietorship/GmbH. If registered in the commercial register: add the register court and registration number.]",
],
}, },
{ {
heading: "VAT ID", heading: "VAT ID",
paragraphs: ["[PLACEHOLDER] VAT identification number pursuant to §27a of the German VAT Act (UStG)."], paragraphs: [
"[PLACEHOLDER] VAT identification number pursuant to § 27a of the German VAT Act (UStG).",
"If the small business regulation under § 19 UStG applies instead, no VAT ID is required — this should be noted here instead (e.g. \"No VAT is charged pursuant to § 19 UStG.\").",
],
},
{
heading: "Editorially Responsible",
paragraphs: [
"Responsible for editorial content pursuant to § 18(2) MStV (German Interstate Media Treaty), including the articles in our \"Honey Knowledge\" section: [name as above].",
],
},
{
heading: "Liability for Content",
paragraphs: [
"As a service provider, we are responsible for our own content on these pages under the general laws pursuant to § 7(1) TMG. However, pursuant to §§ 8 to 10 TMG, we as a service provider are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate unlawful activity. Obligations to remove or block the use of information under general law remain unaffected. However, liability in this regard is only possible from the point in time at which we become aware of a specific infringement. Upon becoming aware of any such infringements, we will remove the relevant content immediately.",
],
},
{
heading: "Liability for Links",
paragraphs: [
"Our site contains links to external third-party websites over whose content we have no influence. We therefore cannot accept any liability for this third-party content. The respective provider or operator of the linked pages is always responsible for their content. The linked pages were checked for possible legal violations at the time of linking. No unlawful content was recognizable at that time. However, permanent monitoring of the content of linked pages is not reasonable without concrete evidence of a legal violation. We will remove such links immediately upon becoming aware of any legal violations.",
],
},
{
heading: "Copyright",
paragraphs: [
"The content and works created by the site operators on these pages are subject to German copyright law. Reproduction, editing, distribution, and any kind of use outside the limits of copyright law require the written consent of the respective author or creator. Downloads and copies of this site are only permitted for private, non-commercial use.",
],
}, },
{ {
heading: "Dispute Resolution", heading: "Dispute Resolution",
@@ -187,53 +216,61 @@ export const en: Dictionary = {
sections: [ sections: [
{ {
heading: "1. Data Controller", heading: "1. Data Controller",
paragraphs: ["[PLACEHOLDER] Data protection officer (if legally required): name, contact details."], paragraphs: [
"If legally required (e.g. for extensive processing of special categories of data, or where more than 20 people are constantly involved in the automated processing of personal data, § 38 BDSG), we name a data protection officer here: [PLACEHOLDER — name and contact details, if applicable].",
],
}, },
{ {
heading: "2. Server Log Files When Visiting the Website", heading: "2. Hosting",
paragraphs: [
"Our website is hosted by an external hosting provider: [PLACEHOLDER — enter the hosting provider's name and address]. We have a data processing agreement with this provider pursuant to Art. 28 GDPR. On our behalf, the hosting provider processes in particular the data collected in the server log files (see item 3) as well as all data entered via this website.",
],
},
{
heading: "3. Server Log Files When Visiting the Website",
paragraphs: [ paragraphs: [
"When you access our website, our hosting provider automatically collects technical access data (e.g. IP address, date and time of access, page accessed, browser type, referrer URL) in what are known as server log files. This processing is technically necessary to provide the website (Art. 6(1)(f) GDPR) and is deleted automatically after a short time.", "When you access our website, our hosting provider automatically collects technical access data (e.g. IP address, date and time of access, page accessed, browser type, referrer URL) in what are known as server log files. This processing is technically necessary to provide the website (Art. 6(1)(f) GDPR) and is deleted automatically after a short time.",
], ],
}, },
{ {
heading: "3. Order and Contract Processing", heading: "4. Order and Contract Processing",
paragraphs: [ paragraphs: [
"When you place an order, we collect the data necessary for contract, payment, and shipping processing (name, address, email, phone number if provided, and the items ordered). The legal basis is Art. 6(1)(b) GDPR (contract performance).", "When you place an order, we collect the data necessary for contract, payment, and shipping processing (name, address, email, phone number if provided, and the items ordered). The legal basis is Art. 6(1)(b) GDPR (contract performance).",
"Payments are processed by our payment provider, Stripe Payments Europe, Ltd. The data necessary for payment processing is transmitted to Stripe for this purpose. Stripe's privacy policy also applies (stripe.com/privacy).", "Payments are processed by our payment provider, Stripe Payments Europe, Ltd. The data necessary for payment processing is transmitted to Stripe for this purpose. Stripe's privacy policy also applies (stripe.com/privacy). Where this involves transferring data to Stripe-affiliated companies outside the EU/EEA (in particular Stripe, Inc. in the USA), this is done on the basis of EU Standard Contractual Clauses pursuant to Art. 46 GDPR.",
"Order and invoice data is retained for the legally required period due to commercial and tax record-keeping obligations (§ 257 HGB, § 147 AO of German law).", "Order and invoice data is retained for the legally required period due to commercial and tax record-keeping obligations (§ 257 HGB, § 147 AO of German law).",
], ],
}, },
{ {
heading: "4. Customer Account", heading: "5. Customer Account",
paragraphs: [ paragraphs: [
"If you create a customer account, we store the data you provide (name, email, password encrypted as a hash) as well as any delivery addresses you optionally save, to give you access to your order history, invoices, and a faster checkout. The legal basis is Art. 6(1)(b) GDPR. You can have your account and associated data deleted at any time via an informal request to us.", "If you create a customer account, we store the data you provide (name, email, password encrypted as a hash) as well as any delivery addresses you optionally save, to give you access to your order history, invoices, and a faster checkout. The legal basis is Art. 6(1)(b) GDPR. You can have your account and associated data deleted at any time via an informal request to us.",
"A technically necessary cookie is set for login — see our Cookie Policy.", "A technically necessary cookie is set for login — see our Cookie Policy.",
], ],
}, },
{ {
heading: "5. Newsletter (Harvest Mail)", heading: "6. Newsletter (Harvest Mail)",
paragraphs: [ paragraphs: [
"If you sign up for our harvest mail, we use your email address exclusively to send information about new harvests. The legal basis is your consent (Art. 6(1)(a) GDPR). You can unsubscribe at any time via the link in every email or through your customer account.", "If you sign up for our harvest mail, we use your email address exclusively to send information about new harvests. The legal basis is your consent (Art. 6(1)(a) GDPR). You can unsubscribe at any time via the link in every email or through your customer account.",
], ],
}, },
{ {
heading: "6. Contact and Business Customer Form", heading: "7. Contact and Business Customer Form",
paragraphs: [ paragraphs: [
"If you contact us via a form (e.g. a business customer inquiry), we process the data you provide to handle your request. The legal basis is Art. 6(1)(b) or (f) GDPR.", "If you contact us via a form (e.g. a business customer inquiry), we process the data you provide to handle your request. The legal basis is Art. 6(1)(b) or (f) GDPR.",
], ],
}, },
{ {
heading: "7. Google Reviews", heading: "8. Google Reviews",
paragraphs: [ paragraphs: [
"On our homepage, we display reviews from our Google Business Profile. The review text is retrieved server-side via the Google Places API for this purpose; your browser does not establish its own connection to Google. Provider: Google Ireland Limited. Google's privacy policy applies (policies.google.com/privacy).", "On our homepage, we display reviews from our Google Business Profile. The review text is retrieved server-side via the Google Places API for this purpose; your browser does not establish its own connection to Google. Provider: Google Ireland Limited, though a transfer to Google LLC (USA) cannot be ruled out. Google's privacy policy applies (policies.google.com/privacy).",
], ],
}, },
{ {
heading: "8. Cookies", heading: "9. Cookies",
paragraphs: ["Details on the cookies used on this website can be found in our Cookie Policy."], paragraphs: ["Details on the cookies used on this website can be found in our Cookie Policy."],
}, },
{ {
heading: "9. Your Rights", heading: "10. Your Rights",
paragraphs: [ paragraphs: [
"You have the right to access (Art. 15 GDPR), rectification (Art. 16 GDPR), erasure (Art. 17 GDPR), restriction of processing (Art. 18 GDPR), data portability (Art. 20 GDPR), and objection to processing (Art. 21 GDPR) of your personal data.", "You have the right to access (Art. 15 GDPR), rectification (Art. 16 GDPR), erasure (Art. 17 GDPR), restriction of processing (Art. 18 GDPR), data portability (Art. 20 GDPR), and objection to processing (Art. 21 GDPR) of your personal data.",
"You also have the right to lodge a complaint with a data protection supervisory authority, e.g. the Bavarian State Office for Data Protection Supervision.", "You also have the right to lodge a complaint with a data protection supervisory authority, e.g. the Bavarian State Office for Data Protection Supervision.",
@@ -246,22 +283,61 @@ export const en: Dictionary = {
sections: [ sections: [
{ {
heading: "1. Scope", heading: "1. Scope",
paragraphs: ["These Terms & Conditions apply to all orders placed via wabenhain.com between Wabenhain and its customers."], paragraphs: [
"These Terms & Conditions apply to all orders placed via wabenhain.com between Wabenhain and its customers, both consumers within the meaning of § 13 BGB and businesses within the meaning of § 14 BGB.",
],
}, },
{ {
heading: "2. Formation of Contract", heading: "2. Formation of Contract",
paragraphs: [ paragraphs: [
"The presentation of products in the shop does not constitute a binding offer. By completing an order, you submit a binding offer to purchase, which we accept by confirming it.", "The presentation of products in the shop does not constitute a binding offer, but rather a non-binding invitation for you to place an order. By completing an order, you submit a binding offer to purchase, which we accept by confirming it via email or by delivering the goods.",
], ],
}, },
{ {
heading: "3. Prices and Shipping Costs", heading: "3. Prices and Shipping Costs",
paragraphs: ["All prices include statutory VAT. Shipping costs are shown transparently at checkout."], paragraphs: ["All prices include statutory VAT. Shipping costs are shown transparently at checkout — see also our \"Shipping & Payment\" page."],
}, },
{ {
heading: "4. Delivery and Pickup", heading: "4. Payment Methods",
paragraphs: [
"We offer PayPal, credit card, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request. See our \"Shipping & Payment\" page and our Privacy Policy for details on Stripe.",
"The purchase price is generally due immediately upon ordering (prepayment via the chosen payment method), unless otherwise agreed.",
],
},
{
heading: "5. Delivery and Pickup",
paragraphs: ["We deliver within Germany. Alternatively, goods can be picked up directly from us in Altenmünster by arrangement."], paragraphs: ["We deliver within Germany. Alternatively, goods can be picked up directly from us in Altenmünster by arrangement."],
}, },
{
heading: "6. Right of Withdrawal",
paragraphs: [
"Consumers generally have a statutory right of withdrawal. For details, in particular exceptions for perishable goods such as honey, please see our separate Right of Withdrawal page.",
],
},
{
heading: "7. Warranty",
paragraphs: [
"Statutory warranty rights apply (§§ 434 et seq. BGB). For consumers, the limitation period for defect claims is two years from delivery of the goods.",
"For food products such as honey, the printed best-before date (BBD) indicates the date until which the product retains its specific properties when stored properly — it is not an expiry date and does not, by itself, constitute a defect.",
],
},
{
heading: "8. Retention of Title",
paragraphs: ["The delivered goods remain our property until paid for in full."],
},
{
heading: "9. Dispute Resolution",
paragraphs: [
"The European Commission provides a platform for online dispute resolution (ODR): https://ec.europa.eu/consumers/odr. We are not obligated, and not willing, to participate in dispute resolution proceedings before a consumer arbitration board.",
],
},
{
heading: "10. Final Provisions",
paragraphs: [
"The law of the Federal Republic of Germany applies, excluding the UN Convention on Contracts for the International Sale of Goods. For consumers, this only applies to the extent that it does not deprive them of the protection granted by mandatory provisions of the law of their country of habitual residence.",
"Should any provision of these Terms & Conditions be or become invalid, the validity of the remaining provisions shall remain unaffected.",
],
},
], ],
}, },
widerrufPage: { widerrufPage: {
@@ -790,6 +866,11 @@ export const en: Dictionary = {
colLocation: "Location", colLocation: "Location",
colBatches: "Batches", colBatches: "Batches",
colProducts: "Products", colProducts: "Products",
colStatus: "Status",
statusPublished: "Published",
statusDraft: "Draft",
publish: "Publish",
unpublish: "Unpublish",
edit: "Edit", edit: "Edit",
noHarvestsYet: "No harvests created yet.", noHarvestsYet: "No harvests created yet.",
newHarvestHeading: "Create new harvest", newHarvestHeading: "Create new harvest",
@@ -798,7 +879,15 @@ export const en: Dictionary = {
harvestDateLabel: "Harvest date", harvestDateLabel: "Harvest date",
locationLabel: "Location", locationLabel: "Location",
descriptionLabel: "Description", descriptionLabel: "Description",
isPublishedLabel: "Publish immediately",
create: "Create", create: "Create",
syncHeading: "ApiCore sync",
syncIntro:
"Imports harvests from ApiCore as drafts (not publicly visible). Previously synced harvests that are already published are left untouched — only new or still-unpublished drafts are updated.",
syncButton: "Sync now",
syncResult: (created, updated, skipped, failed) =>
`Sync complete: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
syncError: "Sync with ApiCore failed.",
}, },
adminHarvestEditPage: { adminHarvestEditPage: {
backToHarvests: "Back to harvests", backToHarvests: "Back to harvests",
@@ -808,6 +897,10 @@ export const en: Dictionary = {
harvestDateLabel: "Harvest date", harvestDateLabel: "Harvest date",
locationLabel: "Location", locationLabel: "Location",
descriptionLabel: "Description", descriptionLabel: "Description",
isPublishedLabel: "Publicly visible",
isPublishedHint: "Controls whether this harvest shows up on the website (shop, /ernten).",
draftNotice: "This harvest is currently only visible in the admin area (e.g. imported from ApiCore). Please review the details, then publish it.",
publishNow: "Publish now",
save: "Save", save: "Save",
}, },
adminProductsPage: { adminProductsPage: {
@@ -945,6 +1038,10 @@ export const en: Dictionary = {
carrierPlaceholder: "e.g. DHL, DPD, Hermes", carrierPlaceholder: "e.g. DHL, DPD, Hermes",
trackingNumberLabel: "Tracking number", trackingNumberLabel: "Tracking number",
saveTracking: "Save", saveTracking: "Save",
dhlHeading: "DHL shipping label",
dhlCreateButton: "Create shipping label",
dhlAlreadyCreated: (trackingNumber) => `DHL shipping label already created — tracking number ${trackingNumber}`,
dhlNotConfigured: "DHL isn't configured yet, see .env (DHL_API_KEY, DHL_API_SECRET, DHL_GKP_USERNAME, DHL_GKP_PASSWORD, DHL_ACCOUNT_NUMBER).",
}, },
adminOrdersPage: { adminOrdersPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
@@ -998,6 +1095,7 @@ export const en: Dictionary = {
groupCompany: "Company details", groupCompany: "Company details",
groupHome: "Homepage", groupHome: "Homepage",
groupLegal: "Legal pages", groupLegal: "Legal pages",
groupSocial: "Social media",
deLabel: "German", deLabel: "German",
enLabel: "English", enLabel: "English",
save: "Save", save: "Save",
@@ -1071,6 +1169,8 @@ export const en: Dictionary = {
firstNameLabel: "First name", firstNameLabel: "First name",
lastNameLabel: "Last name", lastNameLabel: "Last name",
forgotPassword: "Forgot your password?", forgotPassword: "Forgot your password?",
ssoDivider: "or",
ssoButton: "Sign in with Microsoft",
greeting: (name) => `Hello, ${name}`, greeting: (name) => `Hello, ${name}`,
verifyEmailPrompt: "Please confirm your email address — you can't place orders with this account until you do.", verifyEmailPrompt: "Please confirm your email address — you can't place orders with this account until you do.",
resendVerification: "Resend link", resendVerification: "Resend link",
@@ -1090,7 +1190,7 @@ export const en: Dictionary = {
subscribe: "Subscribe", subscribe: "Subscribe",
unsubscribe: "Unsubscribe", unsubscribe: "Unsubscribe",
logoutAllDevicesHeading: "Log out all devices", logoutAllDevicesHeading: "Log out all devices",
logoutAllDevicesDescription: "Ends all active sessions on other devices.", logoutAllDevicesDescription: "Ends all active sessions, including this device. You'll need to sign in again.",
logout: "Log out", logout: "Log out",
privacy: { privacy: {
heading: "Privacy", heading: "Privacy",
+25 -2
View File
@@ -69,12 +69,12 @@ export interface Dictionary {
columnShopping: string; columnShopping: string;
columnLegal: string; columnLegal: string;
aboutUs: string; aboutUs: string;
shipping: string;
pickup: string;
businessCustomers: string; businessCustomers: string;
trackOrder: string; trackOrder: string;
tagline: string; tagline: string;
bottomLine: string; bottomLine: string;
/** aria-label fuer die Social-Media-Icons im Footer, z.B. "Wabenhain bei TikTok". */
socialAriaLabel: (platform: string) => string;
}; };
legalTitles: { legalTitles: {
impressum: string; impressum: string;
@@ -568,6 +568,11 @@ export interface Dictionary {
colLocation: string; colLocation: string;
colBatches: string; colBatches: string;
colProducts: string; colProducts: string;
colStatus: string;
statusPublished: string;
statusDraft: string;
publish: string;
unpublish: string;
edit: string; edit: string;
noHarvestsYet: string; noHarvestsYet: string;
newHarvestHeading: string; newHarvestHeading: string;
@@ -576,7 +581,14 @@ export interface Dictionary {
harvestDateLabel: string; harvestDateLabel: string;
locationLabel: string; locationLabel: string;
descriptionLabel: string; descriptionLabel: string;
isPublishedLabel: string;
create: string; create: string;
syncHeading: string;
syncIntro: string;
syncButton: string;
/** created, updated, skipped, failed */
syncResult: (created: number, updated: number, skipped: number, failed: number) => string;
syncError: string;
}; };
adminHarvestEditPage: { adminHarvestEditPage: {
backToHarvests: string; backToHarvests: string;
@@ -586,6 +598,10 @@ export interface Dictionary {
harvestDateLabel: string; harvestDateLabel: string;
locationLabel: string; locationLabel: string;
descriptionLabel: string; descriptionLabel: string;
isPublishedLabel: string;
isPublishedHint: string;
draftNotice: string;
publishNow: string;
save: string; save: string;
}; };
adminProductsPage: { adminProductsPage: {
@@ -722,6 +738,10 @@ export interface Dictionary {
carrierPlaceholder: string; carrierPlaceholder: string;
trackingNumberLabel: string; trackingNumberLabel: string;
saveTracking: string; saveTracking: string;
dhlHeading: string;
dhlCreateButton: string;
dhlAlreadyCreated: (trackingNumber: string) => string;
dhlNotConfigured: string;
}; };
adminOrdersPage: { adminOrdersPage: {
backToDashboard: string; backToDashboard: string;
@@ -774,6 +794,7 @@ export interface Dictionary {
groupCompany: string; groupCompany: string;
groupHome: string; groupHome: string;
groupLegal: string; groupLegal: string;
groupSocial: string;
deLabel: string; deLabel: string;
enLabel: string; enLabel: string;
save: string; save: string;
@@ -845,6 +866,8 @@ export interface Dictionary {
firstNameLabel: string; firstNameLabel: string;
lastNameLabel: string; lastNameLabel: string;
forgotPassword: string; forgotPassword: string;
ssoDivider: string;
ssoButton: string;
greeting: (name: string) => string; greeting: (name: string) => string;
verifyEmailPrompt: string; verifyEmailPrompt: string;
resendVerification: string; resendVerification: string;
+32
View File
@@ -8,11 +8,13 @@ import {
TWO_FACTOR_CHALLENGE_COOKIE, TWO_FACTOR_CHALLENGE_COOKIE,
TWO_FACTOR_SETUP_COOKIE, TWO_FACTOR_SETUP_COOKIE,
TWO_FACTOR_BACKUP_CODES_COOKIE, TWO_FACTOR_BACKUP_CODES_COOKIE,
ENTRA_OAUTH_COOKIE,
signSessionToken, signSessionToken,
verifySessionToken, verifySessionToken,
verifyTwoFactorChallenge, verifyTwoFactorChallenge,
verifyTwoFactorSetup, verifyTwoFactorSetup,
verifyTwoFactorBackupCodesDisplay, verifyTwoFactorBackupCodesDisplay,
verifyEntraOAuthState,
type SessionRole, type SessionRole,
} from "@/lib/session"; } from "@/lib/session";
import type { Customer } from "@/generated/prisma/client"; import type { Customer } from "@/generated/prisma/client";
@@ -183,3 +185,33 @@ export async function clearTwoFactorBackupCodesDisplayCookie(): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete(TWO_FACTOR_BACKUP_CODES_COOKIE); cookieStore.delete(TWO_FACTOR_BACKUP_CODES_COOKIE);
} }
/** Setzt das kurzlebige Cookie mit PKCE-code_verifier + CSRF-state fuer den laufenden Entra-ID-SSO-Versuch. */
export async function setEntraOAuthCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(ENTRA_OAUTH_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
// "lax" reicht hier: der Redirect zurueck von login.microsoftonline.com
// ist eine Top-Level-GET-Navigation, die "lax" bei Cross-Site-Redirects
// ausdruecklich mitsendet (anders als bei einem POST/Formular-Cross-Site-
// Request, wo "lax" das Cookie zurueckhaelt).
sameSite: "lax",
path: "/",
maxAge: 10 * 60,
});
}
/** Liest state + code_verifier des laufenden Entra-ID-SSO-Versuchs, ohne das Cookie zu loeschen. */
export async function getEntraOAuthCookie(): Promise<{ state: string; codeVerifier: string } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(ENTRA_OAUTH_COOKIE)?.value;
if (!token) return null;
return verifyEntraOAuthState(token);
}
/** Einmal verwendet (erfolgreich oder nicht) wird das Cookie sofort geloescht — kein Replay eines alten code_verifier/state-Paars. */
export async function clearEntraOAuthCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ENTRA_OAUTH_COOKIE);
}
+7 -1
View File
@@ -13,7 +13,7 @@ export type ContentFieldType =
export interface ContentFieldDef { export interface ContentFieldDef {
key: string; key: string;
group: "company" | "home" | "legal"; group: "company" | "home" | "legal" | "social";
labelDe: string; labelDe: string;
labelEn: string; labelEn: string;
type: ContentFieldType; type: ContentFieldType;
@@ -67,6 +67,12 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) }, { key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph }, { key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
// angezeigt, siehe components/site-chrome.tsx Footer()) ---
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.youtube", group: "social", labelDe: "YouTube-Kanal (URL)", labelEn: "YouTube channel (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
// --- Rechtstexte (lokalisiert) --- // --- Rechtstexte (lokalisiert) ---
{ key: "legal.impressum", group: "legal", labelDe: "Impressum", labelEn: "Legal notice", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).impressumPage.sections) }, { key: "legal.impressum", group: "legal", labelDe: "Impressum", labelEn: "Legal notice", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).impressumPage.sections) },
{ key: "legal.datenschutz", group: "legal", labelDe: "Datenschutz", labelEn: "Privacy policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).datenschutzPage.sections) }, { key: "legal.datenschutz", group: "legal", labelDe: "Datenschutz", labelEn: "Privacy policy", type: "richtext-sections", localized: true, getDefault: (l) => joinSections(getDictionary(l).datenschutzPage.sections) },
+10 -1
View File
@@ -105,21 +105,30 @@ export async function getProductsByIds(
return products.map(normalizeProduct); return products.map(normalizeProduct);
} }
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> { export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> {
return prisma.harvest.findMany({ return prisma.harvest.findMany({
where: { isPublished: true },
include: { batches: true }, include: { batches: true },
orderBy: [{ year: "desc" }, { harvestDate: "desc" }], orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
}); });
} }
/**
* Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — gibt null
* zurueck, wenn die Ernte nicht existiert ODER noch nicht veroeffentlicht
* ist, damit unveroeffentlichte (z.B. frisch aus ApiCore synchronisierte)
* Ernten ueber ihre Shop-URL nicht erratbar sind.
*/
export async function getHarvestByYearType( export async function getHarvestByYearType(
year: number, year: number,
type: string type: string
): Promise<(Harvest & { batches: Batch[]; products: Product[] }) | null> { ): Promise<(Harvest & { batches: Batch[]; products: Product[] }) | null> {
return prisma.harvest.findUnique({ const harvest = await prisma.harvest.findUnique({
where: { year_type: { year, type } }, where: { year_type: { year, type } },
include: { batches: true, products: true }, include: { batches: true, products: true },
}); });
return harvest && harvest.isPublished ? harvest : null;
} }
export async function getBatchByCode( export async function getBatchByCode(
+41
View File
@@ -174,3 +174,44 @@ export async function verifyTwoFactorBackupCodesDisplay(token: string): Promise<
return null; return null;
} }
} }
// --- Entra-ID-SSO ("Mit Microsoft anmelden"): kurzlebiger Zwischenschritt
// zwischen Redirect zu Microsoft und Rueckkehr am Callback. Haelt den
// PKCE-code_verifier (darf nie an den Client/die URL, sonst waere PKCE
// wirkungslos) und einen zufaelligen state-Wert zur CSRF-Absicherung —
// genau wie beim 2FA-Challenge-Cookie oben ein eigener, separat benannter
// Token-Typ, nicht wiederverwendbar fuer irgendeinen anderen Zweck. ---
export const ENTRA_OAUTH_COOKIE = "wabenhain_entra_oauth";
const ENTRA_OAUTH_TTL_SECONDS = 10 * 60; // 10 Minuten, Microsoft-Login + Redirect zurueck muss darin passen
interface EntraOAuthStatePayload {
state: string;
codeVerifier: string;
}
export async function signEntraOAuthState(state: string, codeVerifier: string): Promise<string> {
return new SignJWT({ type: "entra-oauth-state", state, codeVerifier })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setIssuedAt()
.setExpirationTime(`${ENTRA_OAUTH_TTL_SECONDS}s`)
.sign(getSecretKey());
}
export async function verifyEntraOAuthState(token: string): Promise<EntraOAuthStatePayload | null> {
try {
const { payload } = await jwtVerify(token, getSecretKey(), {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
algorithms: ["HS256"],
});
if (payload.type !== "entra-oauth-state" || typeof payload.state !== "string" || typeof payload.codeVerifier !== "string") {
return null;
}
return { state: payload.state, codeVerifier: payload.codeVerifier };
} catch {
return null;
}
}
+3 -2
View File
@@ -49,10 +49,11 @@ export const footerNav = {
{ href: "/ernten", key: "harvests" }, { href: "/ernten", key: "harvests" },
{ href: "/kontakt", key: "contact" }, { href: "/kontakt", key: "contact" },
], ],
// "Versand"/"Abholung" bewusst entfernt: beide verlinkten auf dieselbe
// Seite wie "Rechtliches" -> "Versand und Zahlung" (unten,
// /versand-zahlung) — die eine Verlinkung dort reicht.
einkaufen: [ einkaufen: [
{ href: "/shop", key: "shop" }, { href: "/shop", key: "shop" },
{ href: "/versand-zahlung", key: "shipping" },
{ href: "/versand-zahlung", key: "pickup" },
{ href: "/unternehmen", key: "businessCustomers" }, { href: "/unternehmen", key: "businessCustomers" },
{ href: "/bestellung-verfolgen", key: "trackOrder" }, { href: "/bestellung-verfolgen", key: "trackOrder" },
], ],