Bisher stand dort nur ein Satz und ein Knopf zum Shop. Jetzt steht der
Hinweis als Spalte links, daneben bis zu zwei aktuell verfuegbare
Produkte (Honig zuerst) - dasselbe Muster wie "Aktuelle Ernte" auf der
Startseite. Nutzt die bereits geladenen availableProducts.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Der Knopf stand unter beiden Spalten (Anmelden und Konto erstellen),
obwohl er zur Anmeldung gehoert, und erschien auch ohne eingerichtete
Entra-Konfiguration - der Klick endete dann in der Meldung "SSO ist noch
nicht konfiguriert". Jetzt steht er unter dem Anmeldeformular und nur,
wenn isEntraConfigured() zutrifft. Die serverseitige Pruefung in der
Login-Route bleibt als zweite Absicherung.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Die drei Pakete standen doppelt: als Deko-Kacheln (mit grossem
Waben-Wasserzeichen) ueber dem Formular und darin noch einmal als
Auswahlliste. Jetzt waehlt man sie direkt im Formular als Karten mit
sichtbarem Auswahl- und Fokuszustand; die Kacheln entfallen. Feldname
und Werte (package = KLASSIK/GESCHENK/INDIVIDUELL) sind unveraendert.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Imkerei, Herkunft und Ernten endeten mit dem letzten Absatz direkt vor
der Fusszeile. Neuer PageOutro: zwei naechste Wege als grosse
Serif-Links mit Kurztext (Ernten/Shop bzw. Herkunft/Shop).
- Kopfzeile: bei 1280 px lief sie 54 px ueber (horizontale Scrollleiste),
weil die nicht umbrechbare Slogan-Zeile unter dem Logo schon ab xl
erschien. Jetzt erst ab 2xl; geprueft 1024 bis 1920 px ohne Ueberlauf.
- Wissen: Inhaltsverzeichnis statt zehn gleicher Bloecke mit je einem
"Weiterlesen" - ganze Zeile ist der Link, Titel und Kurztext
nebeneinander. Ungenutzter Schluessel readMore entfernt.
- Produktseite: bei ein, zwei Empfehlungen steht die Ueberschrift als
erste Rasterspalte statt ueber einer einzelnen verlorenen Karte.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Jede zweite Ueberschrift trug eine kleine Versalien-Zeile mit Wabenpunkt
("• STIMMEN", "• AKTUELL VERFUEGBAR", "• KUNDENKONTO" ...). Auf 29 Seiten
und Abschnitten wiederholt las sich das wie eine Vorlage. SectionHeading
rendert sie nicht mehr, die Prop ist entfernt; die Ueberschrift traegt den
Abschnitt allein.
Die eyebrow-Texte, die weiterhin als Seitentitel, Bildbeschriftung oder
Breadcrumb dienen, bleiben; 18 danach ungenutzte Eintraege sind aus Typ und
beiden Woerterbuechern entfernt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sechs Route-Handler (Hinweis-Weiterleitung, E-Mail-Bestaetigung,
Newsletter bestaetigen/abmelden, Entra-Login und -Callback) bauten ihr
Weiterleitungsziel mit getBaseUrl() absolut zusammen. Ohne gesetztes
NEXT_PUBLIC_SITE_URL (lokal, Staging, Vorschau-Host) sprang man damit auf
https://wabenhain.com, und die gerade gesetzte Flash-Meldung ging auf dem
fremden Host verloren - aufgefallen, als die Admin-Pruefung nach
/api/hinweis umleitete.
Neuer Helfer lib/redirect.ts (redirectWithinSite) mit relativem
Location-Header, analog zu redirect() in Server Actions. Er lehnt alles
ausser Pfaden dieser Seite ab ("//host", "/\host", absolute URLs), damit
daraus keine offene Weiterleitung werden kann. getSiteUrl() bleibt fuer
Adressen, die die Seite verlassen (E-Mails, Stripe, Entra-redirect_uri,
Sitemap, Canonicals).
proxy.ts bleibt bei new URL("/konto", request.url) (eigener Host): der
Proxy verlangt einen absoluten Location-Header, ein relativer endet dort
in "Invalid URL" - als Kommentar festgehalten.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Neue Gruppe "Logo und Favicon" unter Admin > Inhalte mit drei Bildfeldern:
Logo (Kopfzeile), optionales Logo fuer dunkle Flaechen (Mobilmenue,
Fusszeile, Admin-Leiste; faellt sonst auf das normale Logo zurueck) und
Favicon. Leer bleibt alles wie bisher (gezeichnetes Waben-Zeichen bzw.
Standard-Icon).
- lib/brand.ts liest die Pfade und prueft sie erneut gegen das
Upload-Muster, bevor sie in ein src-Attribut gelangen.
- Statische app/icon.svg und app/favicon.ico ersetzt durch dynamische
app/icon.tsx (512 px PNG) und app/apple-icon.tsx (180 px auf Creme);
lib/brand-icon.ts rendert Upload oder Standard-Icon per sharp und liest
Dateien nur ueber resolveUploadPath. Cache-Control no-cache, damit ein
neues Favicon ohne Tage Verzoegerung erscheint.
- /favicon.ico per Rewrite auf /icon, Manifest nutzt /icon.
- Hochgeladenes Logo mit fester Hoehe und begrenzter Breite (Kopfzeile
mobil 56 px, ab sm 112 px), damit breite Wortmarken nichts sprengen.
Uploads bleiben auf Rasterbilder beschraenkt (kein SVG, XSS-Schutz).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Derselbe "Alles synchronisieren"-Knopf stand auf /admin/produkte und
/admin/ernten und sah dadurch wie zwei verschiedene Abgleiche aus. Er
sitzt jetzt einmal auf dem Dashboard, das Ergebnis des Laufs erscheint
dort oben. Die Action springt standardmaessig nach /admin zurueck; die
alten Ziele bleiben in der Whitelist erlaubt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.
Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.
Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Alle Anlege- und Bearbeiten-Formulare nutzen jetzt die Bausteine aus
components/admin-form.tsx wie das Produktformular: Rahmen mit fester
Speicherleiste, Abschnitte mit Serif-Ueberschrift (Gutschein: Code und
Rabatt / Bedingungen / Zeitraum und Status; Ernte: Angaben / Bilder /
Sichtbarkeit; Charge: Abfuellung und Bestand / Bilder), Schalter statt
Checkbox, buendige Eingabefelder in Rastern.
Benutzer bearbeiten: Sicherheitsbereich mit Status-Badges, Passwort-Reset
als Sekundaerknopf, Sperren/Loeschen in eigenem abgesetzten Bereich.
AdminField/AdminSelect koennen jetzt min und einen Hinweistext.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit
Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus,
wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und
naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich-
Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft).
Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine
(neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf
admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und
Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Die Kategorie kam bisher nur per Typ-Cast aus dem Formular; ein
manipulierter Wert endete als unbehandelte Prisma-Ausnahme. Ein eigener
Slug wurde ungeprueft zur oeffentlichen Adresse, inklusive Schraegstrichen
und Sonderzeichen. Neues adminProductIdentitySchema: Kategorie per Enum,
Slug auf a-z, 0-9 und Bindestrich (Umlaute umschrieben, max. 80 Zeichen),
leere Adresse wird mit Meldung abgelehnt.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Bestelldetail: Status-Badge neben der Nummer, Waben-Zeitleiste
(dieselbe wie im Kundenkonto) zeigt auf einen Blick den Stand
- Produkt bearbeiten: Abschnitte (Text und Bilder, Geschmack, Preis und
Bestand, Sichtbarkeit), "Im Shop sichtbar" als Schalter (gleiches Feld
isActive), Speicherleiste bleibt beim Scrollen unten erreichbar
- Kartentitel im gesamten Admin als lesbare Serifentitel statt winziger
Versal-Etiketten (zentral per .admin-content, farbige Titel bleiben)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr
- Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive
Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte
offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue
Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung)
- Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur,
tolerante Wortsuche, Ziele nur aus fester Liste
- Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl
"Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen"
mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln
- Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton
#a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen,
2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im
selben Ton, Rang der Top-Produkte als Wabe
- Einheitliche Admin-Details zentral: groessere Ueberschriften,
Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Schrittanzeige "Deine Daten -> Bezahlen" mit Waben (rein anzeigend,
gesteuert weiter ueber den bestehenden Zahlungszustand)
- Lieferart als Karten mit Symbol, Auswahl mit Waben-Haekchen, aria-pressed
- Bestelluebersicht abgehoben, Namen und Gesamtsumme in Serifenschrift
- Keine Aenderung an Bezahllogik oder Formularfeldern
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Adressen im Zweierraster, Standardadresse mit Honigrahmen und Wabenmarke,
neues Adressformular in der Formular-Karte
- Retoure: Positionen als Liste mit Waben, Formular in der Karte
- Suche: Vorschlaege gleiten gestaffelt herein, Suchbegriff hervorgehoben,
Lupe wird zum Schliessen-Symbol; das Feld legt sich ueber die Navigation
statt sie zu verschieben (vorher: waagerechter Scrollbalken)
- Cookie-Hinweis als schwebende Karte; "Nur notwendige" und "Alle
akzeptieren" gleich gewichtet (vorher war Akzeptieren Waldgruen auf
Waldgruen und kaum als Knopf erkennbar)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- DHL-Verlauf: neuestes Ereignis als Wabe in Statusfarbe (pulsiert, solange
unterwegs), aeltere als leere Waben, Eintraege erscheinen gestaffelt
- Bestellung verfolgen: Suchformular in der Formular-Karte, Ergebnis als
abgehobene Karte
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Gemeinsame FormPanel-Karte fuer Passwort- und Zwei-Faktor-Seiten
- Zwei-Faktor-Code: grosses, zentriertes Ziffernfeld
- Backup-Codes als nummerierte Kacheln statt langer Liste
- Newsletter-Bestaetigung: Willkommensrabatt als Gutschein mit
gestricheltem Rand und Kopier-Knopf (bestaetigt "Kopiert")
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Herkunft: atmendes Titelbild mit Wabenraster, Ueberschrift als Karte,
die ins Bild ragt; Aspekte als Liste mit Waben
- Imkerei: Bild mit Honigrahmen, erster Absatz als Einstieg
- Fuer Unternehmen: Pakete als Karten mit Waben-Wasserzeichen, das beim
Hover einrueckt; Anfrage mit mitlaufender Ueberschrift neben dem Formular
- Kontakt: E-Mail gross auf Wald-Karte mit Wabenraster, Abholung daneben
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Artikel: Lesefortschritt als feine Honiglinie am oberen Rand, Initiale
in Serifenschrift und Honig, groesserer Titel, angenehme Zeilenlaenge
- Wissen-Uebersicht und "Weitere Artikel" als ruhige Liste mit grossen
Serifen-Titeln, Unterstrich und gezeichnetem Pfeil statt "→"
- Rechtliche Seiten mit klarerer Ueberschriften-Hierarchie
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Weg zum Mengenrabatt als sich fuellender Balken (gleiche Schwelle wie
bisher der Texthinweis), erreichter Rabatt als Bestaetigung mit Wabe
- Positionen gleiten herein und beim Entfernen hinaus, Entfernen-Knopf
als gezeichnetes Symbol statt Textzeichen
- Artikelnamen und Preise in Serifenschrift, Gesamtsumme tickt bei jeder
Aenderung, Zusammenfassung laeuft beim Scrollen mit
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Ernten: Jahrgang als grosse Serifenzahl mit auslaufender Linie, Karten
heben sich mit Honigschimmer, Pfeil und Unterstrich
- Ernte-Detail: grosser Titel, mitlaufendes Bild, Fuellstandsbalken je
Charge (verfuegbare von abgefuellten Glaesern)
- Chargen-Seite (QR-Code auf dem Glas): Echtheitssiegel stempelt sich ein,
Zeitleiste mit Waben und sich fuellender Linie, Werte in Serifenschrift
- Produktverweise heben sich, gezeichneter Pfeil statt Textzeichen
- Fix: aktive Seite in der Kopfzeile zeigte den Unterstrich in Textfarbe
statt in Honig (unlayered CSS ueberstimmte die Utility-Klasse)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Kopfzeile: aktive Seite mit Honig-Unterstrich (auch im Handy-Menue),
einlaufende Unterstriche beim Hover, beim Scrollen Glas und Schatten;
weicht beim Runterscrollen aus und ist beim Hochscrollen sofort zurueck
(steht still, solange Warenkorb oder Menue offen sind)
- Fusszeile mit Wabenraster und einlaufenden Link-Unterstrichen
- Mengensteller: gezeichnete Plus/Minus, taktiles Druecken, tickende Zahl
- Newsletter als eigene Flaeche mit Wabenraster; Erfolg zeichnet ein
Haekchen in eine Wabe, Fehler werden als Alert angesagt
- 404: Wabe mit einer fehlenden Zelle als Bild fuer die fehlende Seite
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit
ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe
(PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin
dynamisch aus dem Dashboard.
- Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt
Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt
alle Eingaben, Turnstile wird neu geladen
- Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit
der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe
Sitzung erneut eingebettet
- Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden
Browser gebunden, die session_id in der URL allein reicht nicht
- Alte /checkout/cancel?ref= Links aendern per GET nichts mehr
- CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert
- STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit
Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Konto-Kacheln: Icon in einer Wabe, die sich beim Hover mit Honig fuellt,
einlaufender Pfeil, Serifen-Titel
- Bestell-Statusleiste: Waben statt Punkte, erledigte Abschnitte fliessen
nacheinander voll, aktueller Schritt pulsiert sanft
- Status-Badge mit pulsierendem Punkt bei laufenden Bestellungen
- Leere Zustaende mit gestrichelter Wabe und Serifen-Titel
- Formularfelder: einheitlicher Honig-Schein beim Fokus
- Groessere Abschnittstitel im Konto, Bestellsummen in Serifen-Preisschrift
- Nur CSS-Animationen (Komponenten rendern auch auf Admin-Serverseiten);
reduzierte Bewegung schaltet Puls und Fuellen ab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Hero: deutlich groessere Headline, langsam atmendes Bild, Wabenraster,
Honigtropfen als Scroll-Hinweis, Hauptaktion in Honig
- "Sechs Schritte" als Weg: eine Honiglinie fuellt sich beim Scrollen und
laesst die Waben der Schritte nacheinander aufleuchten (waagerecht ab lg,
senkrecht auf dem Handy; reduzierte Bewegung: sofort gefuellt)
- Philosophie mit Waben statt Nummern, Verantwortung als geteiltes Layout
mit mitlaufender Ueberschrift
- Bilder mit versetztem Honigrahmen, Wabenraster auf dunklen Flaechen,
grosse Anfuehrungszeichen bei den Kundenstimmen
- Knopf-Varianten "inverted"/"accent" fuer dunkle Flaechen ersetzen
!-Overrides, die den Fuell-Effekt ausgehebelt haetten
- Fix: Ueberschrift der Kundenstimmen war Waldgruen auf Waldgruen
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Fundament: feine Papierkoernung, Scrollbar/Cursor/Fokusring/Markierung in
Markenfarben, gleich breite Ziffern fuer Preise, einlaufende Unterstriche
- Knoepfe fuellen sich beim Hover von unten wie ein Glas (Honig/Waldgruen),
geben beim Klick nach
- Wabenmotiv aus dem Logo als Komponente (Raster, Markierung, Haekchen)
- In den Warenkorb: ein Honigtropfen fliegt im Bogen zum Korb, der Korb
wippt, der Zaehler springt, erst dann oeffnet die Schublade; Knopf zeigt
gezeichnetes Haekchen. Reduzierte Bewegung: Schublade oeffnet sofort.
- Produktkarten: Honigschimmer, Anheben, Serifen-Preis, gestaffeltes
Erscheinen, Pfeil-Links
- Produktseite: mitlaufendes Bild auf Wabenraster, groesserer Titel und
Preis, sich fuellende Geschmacksskala, Waben-Haekchen statt "✓"
- Shop-Filter als einheitliche Werkzeugleiste mit Schalter
- Warenkorb-Schublade: Dialog-Semantik, animierte Positionen, tickende
Summe, gestalteter leerer Zustand
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.
- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
(FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
(Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
"Deaktivieren".
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Abgeschlossene Eintraege wandern automatisch in einen Tab "Archiv", damit
die aktiven Listen nur zeigen, was noch zu tun ist:
- Bestellungen: versendet/abgeholt/storniert, keine offene Retoure,
seit einem Monat nicht bearbeitet
- Retouren: angenommen/abgelehnt/erstattet, seit einem Monat nicht bearbeitet
- Produkte: seit einem Monat Bestand 0 (nur Admin-Sicht, Shop unveraendert)
Die Automatik laeuft beim Oeffnen der jeweiligen Liste (hoechstens alle
5 Minuten) und holt Eintraege zurueck, sobald sie wieder offen sind.
Zusaetzlich "Archivieren"/"Wiederherstellen" per Hand; von Hand
archivierte Eintraege fasst die Automatik nicht an. Alle Verschiebungen
landen im Audit-Log.
Neue Felder archivedAt/archivedManually samt Index an orders,
return_requests und products (Migration 20260925010000_archive_fields).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
/bestellung-verfolgen holt den Sendungsverlauf serverseitig ueber die
DHL Shipment Tracking - Unified API und zeigt ihn im eigenen Layout an,
statt Kundschaft auf dhl.de weiterzuleiten.
- lib/dhl-tracking.ts: Abfrage mit API-Key, Zod-Validierung, Timeout,
Cache pro Sendung (unterwegs 10 min, zugestellt 12 h, Fehler 2 min)
- Abfrage erst nach erfolgreicher Pruefung von Bestellnummer + E-Mail,
damit die Seite nicht als offener Proxy zu DHL dient
- Ohne freigeschalteten Key bleibt die Seite unveraendert (Nummer + Link)
- Optional DHL_TRACKING_API_KEY, falls das Tracking in eigener DHL-App liegt
- Datenschutzerklaerung um die Statusabfrage bei DHL ergaenzt
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Stornieren gab es bereits, Loeschen nicht — abgebrochene Checkouts und
Testbestellungen blieben dauerhaft in der Liste stehen.
Loeschbar ist bewusst nur, was nie ein Geschaeftsvorfall war: keine Zahlung
erfasst, keine Rechnung erzeugt, Status NEU oder STORNIERT. Eine bezahlte
Bestellung und ihre Rechnung sind Buchungsbelege (§ 147 AO, § 257 HGB) und
muessen jahrelang vorgelegt werden koennen; ein Loeschknopf darauf wuerde
nicht aufraeumen, sondern Unterlagen vernichten. Dafuer gibt es den Storno:
er erstattet das Geld, bucht die Ware zurueck und laesst den Beleg stehen.
Die Regel steht in lib/order-delete.ts als reine Logik ohne Datenbankzugriff
— dieselbe Trennung wie bei shipping-calc.ts. Server Action und Admin-Seite
teilen sie sich, statt die Bedingung zweimal zu formulieren und irgendwann
auseinanderzulaufen, und sie laesst sich ohne DATABASE_URL testen (13 neue
Faelle, u.a. dass bei Rechnung UND Zahlung die Rechnung als Grund gemeldet
wird).
Drei Dinge, die ein blosses delete() falsch gemacht haette:
- LAGERBESTAND: bei NEU liegt die Ware reserviert und wird zurueckgebucht,
bei STORNIERT ist das beim Stornieren bereits geschehen. Dort erneut
gutzuschreiben wuerde den Bestand erhoehen, ohne dass je ein Glas zurueckkam.
- ZAHLUNGSSITZUNG: eine Bestellung im Status NEU kann eine offene
Stripe-Sitzung haben. Wird sie geloescht, ohne die Sitzung zu schliessen,
kann Minuten spaeter noch bezahlt werden — der Webhook findet dann keine
Bestellung mehr, und das Geld liegt bei Stripe, ohne dass im Shop etwas
davon zu sehen waere. Die Sitzung wird deshalb VOR dem Loeschen geschlossen;
gelingt das nicht sicher, wird gar nicht geloescht. Ist bei Stripe
zwischenzeitlich bezahlt worden, verweigert die Aktion das Loeschen.
- WETTLAUF: die Bedingungen stehen zusaetzlich in der WHERE-Klausel des
DELETE. Zwischen Pruefung und Loeschen koennte der Webhook eine Zahlung
eingetragen haben; die Pruefung sieht nur einen Schnappschuss, die
WHERE-Klausel wird atomar gegen den aktuellen Zeilenstand ausgewertet.
Der Audit-Log-Eintrag ist ausfuehrlicher als bei anderen Aktionen und traegt
Bestellnummer, Status, Betrag, E-Mail und Positionsanzahl — der Datensatz ist
danach weg, dieser Eintrag ist die einzige verbleibende Spur.
Die Oberflaeche zeigt den Knopf am Seitenende in einem abgesetzten Bereich,
damit er nicht neben dem Statusfeld liegt, mit Browser-Rueckfrage ueber
ConfirmForm. Ist die Bestellung nicht loeschbar, steht dort statt des Knopfes
der Grund und der Verweis auf den Storno. Die Bestellliste zeigt jetzt auch
Erfolgsmeldungen, nicht nur Fehler.
tsc: 0 Fehler. vitest: 372/372 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Ortszeile ueber der Ueberschrift stand in honey-light auf einem Foto mit
25 % Grundabdunklung plus einem to-tr-Verlauf, der die Textspalte bei rund
44 % Gesamtdeckung liegen liess. Gegen ein helles Motiv (Holz, Himmel)
gerechnet ergab das:
Ortszeile 1,34:1 (WCAG AA verlangt 4,5:1 bei 12 px)
Ueberschrift 2,73:1 (verlangt 3:1 bei grosser Schrift)
Fussnote 1,90:1
Lighthouse meldet davon nichts und gibt der Seite weiterhin 100 fuer
Accessibility — Kontrast von Text ueber Bildern laesst sich nicht statisch
pruefen, weil der Hintergrund vom hochgeladenen Motiv abhaengt. Genau deshalb
muss die Abdunklung den schlechtesten Fall abdecken und nicht das Bild, das
gerade hinterlegt ist.
Drei Aenderungen, alle nachgerechnet:
- Verlauf ab md waagerecht (85 % -> 70 % -> 0) statt diagonal. Die Textspalte
endet bei etwa 45 % Breite und liegt damit ganz im dunklen Teil, waehrend
die rechte Bildhaelfte unangetastet bleibt.
- Auf schmalen Schirmen senkrecht, weil der Text dort ueber die volle Breite
laeuft und rechts sonst in den durchsichtigen Teil geriete.
- Neuer Farbton honey-pale (#e4cfa4) fuer die Ortszeile; honey-light haelt
4,5:1 selbst bei 89 % Abdunklung nicht (4,14:1). Fussnote von cream/60 auf
cream/80.
Ergebnis in der Textspalte: Ortszeile 4,81:1 bis 6,97:1, Ueberschrift 6,41:1
bis 9,28:1, Fussnote 4,80:1 bis 6,59:1 — AA auf allen Breiten.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Beide Formulare lasen NEXT_PUBLIC_TURNSTILE_SITE_KEY direkt aus process.env,
und zwar im Modulrumpf einer Client Component. NEXT_PUBLIC_-Variablen werden
dort zur BUILD-Zeit ins Bundle eingebacken. Gesetzt wird der Wert hier aber
erst zur Laufzeit (k8s/01-configmap.yaml) — im Docker-Build existiert er nicht
und wird auch nicht als Build-Arg hereingereicht.
Der Wert war damit dauerhaft undefined, das Widget erschien nie. Solange
TURNSTILE_SECRET_KEY ebenfalls leer ist, faellt das nicht auf:
verifyTurnstileToken gibt ohne Secret bewusst true zurueck. Sobald das Secret
aber gesetzt wird — also genau beim Scharfschalten des Schutzes —, verlangt
der Server einen Token, den der Browser nirgends erzeugen kann. Newsletter-
Anmeldung und Firmenanfrage haetten dann jede Eingabe abgewiesen, ohne dass
irgendwo ein Fehler sichtbar wird.
Alle uebrigen Formulare (Login, Registrierung, Passwort-Reset, Zwei-Faktor,
Checkout, Verfuegbarkeits-Benachrichtigung) lesen den Wert bereits im Server
Component und reichen ihn als Prop weiter — dort wirkt die Laufzeit-Variable.
Diese beiden folgen jetzt demselben Muster.
Bewusst so und nicht ueber ein Docker-Build-Arg geloest: ein Build-Arg wuerde
den Schluessel ins Image einbacken, womit ein Schluesselwechsel einen Neubau
braeuchte und die ConfigMap fuer diese beiden Stellen wirkungslos bliebe. Ein
einziger Weg fuer alle Formulare ist weniger fehleranfaellig als zwei.
Hinweis fuer den Betrieb: der Schutz ist damit noch nicht aktiv. Dafuer
muessen NEXT_PUBLIC_TURNSTILE_SITE_KEY (ConfigMap) UND TURNSTILE_SECRET_KEY
(Secret) gesetzt sein — beide oder keiner.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Bisher sagte kein Text des Shops, wer die Kosten einer Ruecksendung traegt.
Nach § 357 Abs. 6 BGB traegt sie dann der VERKAEUFER: die Belehrung des
Kaeufers ist die Voraussetzung dafuer, sie ihm auferlegen zu duerfen. Ohne
sie war jede Retoure auf eigene Rechnung.
Die Regelung steht bewusst an zwei Stellen, nicht an einer:
- Widerrufsbelehrung: nur der amtliche Mustersatz "Du traegst die
unmittelbaren Kosten der Ruecksendung der Waren." (Anlage 1 zu Art. 246a § 1
Abs. 2 EGBGB), unveraendert uebernommen. Eine Abweichung vom Muster kostet
dessen Gesetzlichkeitsfiktion — die Belehrung waere dann angreifbar.
- Versand und Zahlung, neuer Abschnitt "Rücksendung": derselbe Grundsatz in
Alltagssprache plus die Ausnahme fuer mangelhafte Ware. Geht ein Mangel auf
die Produktion oder auf uns als Verkaeufer zurueck, tragen wir die Kosten
vollstaendig.
Die Ausnahme gehoert NICHT in die Widerrufsbelehrung: sie folgt aus der
Gewaehrleistung (§ 439 Abs. 2 BGB) und gilt unabhaengig davon, ob widerrufen
wird. Beides dort zu vermischen wuerde zwei verschiedene Rechte als eines
darstellen.
Im Product-Schema kommt damit returnFees hinzu, das bisher aus genau diesem
Grund fehlte — ReturnFeesCustomerResponsibility, der Wert fuer "Kaeufer traegt
die Ruecksendung". Er braucht keinen Betrag, was hier auch nicht ginge: die
Ruecksendung laeuft ueber den Kaeufer, nicht ueber unser Versandkonto. Der
guenstigere Sonderfall (Mangel) bleibt im Schema unabgebildet; schema.org
kennt dafuer kein Feld, und ihn generell zuzusagen waere falsch.
Beide Texte auch auf Englisch.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Hero, ScrollReveal und ProductCard starten mit opacity 0 und werden per
motion/react eingeblendet. Diese Startwerte stehen als inline-style bereits im
server-gerenderten HTML — auf wabenhain.com an 38 Elementen nachgezaehlt. Laeuft
kein JavaScript, blendet sie niemand wieder ein: der Besucher sieht das
Hero-Bild und darunter eine leere Seite. Kein Teilausfall, sondern der
komplette Inhalt.
Fuer Suchmaschinen war das unkritisch, der Text steht im HTML. Fuer Menschen
mit deaktiviertem JavaScript war es ein Totalausfall.
Die drei Bausteine tragen jetzt data-reveal, und ein noscript-Block im
Root-Layout hebt darueber die Startwerte auf. Das Attribut statt eines
Treffers auf [style*="opacity:0"] ist der Kern der Sache: Warenkorb-Schublade,
Menue und Cookie-Banner blenden ueber AnimatePresence ebenfalls mit opacity 0
ein, sollen ohne JavaScript aber verborgen BLEIBEN. Sie tragen das Attribut
nicht.
!important ist hier notwendig und nicht nachlaessig — einen inline-style
ueberschreibt sonst nichts.
Grenze der Loesung: noscript deckt "JavaScript deaktiviert" ab. Laedt das
Bundle dagegen und schlaegt erst spaeter fehl, greift es nicht; dafuer waere
ein Zeitgeber noetig, der die Animation im Normalfall stoeren wuerde.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Beschreibung der Produktseite setzt Name, Geschmacksnoten und Fuellmenge
zusammen und rechnet dabei fest damit, dass Noten gepflegt sind. Sind sie es
nicht — ein freiwilliges Admin-Feld —, blieb zwischen Gedankenstrich und
Fuellmenge ein einzelner Punkt ohne Inhalt stehen.
Auf wabenhain.com an "Waldhonig" beobachtet: description und og:description
lauteten "Waldhonig — . 500 g aus eigener Imkerei in Altenmünster." Das ist
genau der Text, den Google als Snippet anzeigt und den jede geteilte Vorschau
uebernimmt.
Derselbe Fall liess unter der Ueberschrift einen leeren Absatz stehen, der
nur Abstand zwischen Produktnamen und Bewertung erzeugte.
Beides faellt jetzt weg, wenn keine Noten gepflegt sind; mit Noten bleibt die
Ausgabe unveraendert.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Das Product-Schema der Produktseite enthielt Preis, Verfuegbarkeit und
Bewertung, aber keine der Angaben, die Google fuer ein vollstaendiges
Produkt-Suchergebnis erwartet. Praktische Folge: der Preis wurde nach einiger
Zeit als moeglicherweise veraltet eingestuft und ausgeblendet, und
Versandkosten schaetzte Google selbst — bei einem Shop, dessen Versandstaffel
ohnehin auf der Seite steht.
Neu im Offer-Block:
- shippingDetails mit der tatsaechlichen Staffel fuer ein Glas, also demselben
"ab"-Preis, der auf der Seite ausgewiesen ist. Das Liefergebiet kommt aus
der Admin-Einstellung (Deutschland oder EU), nicht aus einer festen Liste.
- hasMerchantReturnPolicy mit dem gesetzlichen Widerrufsrecht: vierzehn Tage
ab Erhalt, Rueckgabe per Versand. returnFees fehlt bewusst — wer die Kosten
der Ruecksendung traegt, sagt die Widerrufsbelehrung nicht, und eine
Annahme an dieser Stelle waere eine Zusage gegenueber Google, die der Shop
nirgends macht.
- priceValidUntil, itemCondition sowie seller als Verweis auf das
Store-Schema aus dem Root-Layout, statt Name und Anschrift ein zweites Mal
auszuschreiben.
- weight als Fuellmenge in Gramm — der Wert, ueber den Kaeufer
Lebensmittelangebote vergleichen.
Ausserdem ein eigener openGraph-Block. Ohne ihn erbte jede Produktseite den
Block aus dem Root-Layout unveraendert: ein geteilter Honig-Link zeigte in
WhatsApp, Facebook oder Slack Titel und Text der STARTSEITE, egal welches
Produkt verlinkt war. Das Feld "images" bleibt bewusst leer, damit die
gestaltete Vorschaukarte aus opengraph-image.tsx erhalten bleibt — ein
Eintrag dort haette sie durch das rohe Produktfoto ersetzt.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
robots.txt sperrte /checkout, /warenkorb, /konto und /rechnung fuer Crawler.
Das verbietet aber nur das Abrufen, nicht das Indexieren: verweist irgendwo
ein Link auf eine dieser Seiten, nimmt Google die URL trotzdem auf — als
inhaltslosen Treffer mit dem Hinweis "Fuer diese Seite sind keine
Informationen verfuegbar".
Der uebliche Ausweg, ein noindex-Meta-Tag, half hier nicht: eine per
robots.txt gesperrte Seite wird nie geladen, also sieht der Crawler das Tag
nie. Die Sperre verhinderte ausgerechnet das Mittel, das die URLs wieder aus
dem Index geholt haette. Keine dieser Seiten trug bisher ueberhaupt ein
noindex.
Jetzt greifen beide Mechanismen zusammen statt gegeneinander:
- lib/seo.ts buendelt die beiden robots-Bausteine. noIndex laesst Links
weiterhin folgen (Footer- und Produktlinks im Warenkorb sollen ihre
Linkkraft behalten), noIndexPrivate unterbindet zusaetzlich Cache-Kopie,
Bildindexierung und Textausschnitt.
- Kasse, Bestellbestaetigung und Rechnungsansicht bekommen noIndexPrivate,
der Warenkorb noIndex.
- Der gesamte Kontobereich bekommt das Tag einmal ueber ein neues
konto/layout.tsx statt in zehn Unterseiten einzeln. Metadata erbt
feldweise, ein eigener title in einer Unterseite loescht den Wert also
nicht.
- robots.txt sperrt nur noch /api/ und /admin — also das, was ein Crawler
wirklich nie abrufen soll. Alles andere darf gecrawlt werden, damit das
noindex ueberhaupt gelesen wird. /uploads bleibt bewusst offen: dort liegen
die Produktbilder, die in der Bildersuche erwuenscht sind.
Zusaetzlich eine ausdrueckliche Regel fuer GPTBot, ClaudeBot, PerplexityBot
und Google-Extended (oeffentliche Inhalte ja, interne Bereiche nein) sowie
die host-Angabe in robots.txt.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Im Anmeldeformular standen CAPTCHA, Absenden-Knopf und der
"Passwort vergessen"-Link in EINER Flex-Zeile ohne Umbruch:
<div className="flex items-center justify-between gap-4">
Das Turnstile-Widget ist ein iframe mit FESTER Breite von rund 300 px und
schrumpft nicht mit. Die Zeile steht zudem in einer der beiden Spalten
der Kontoseite, hat also nur die halbe Breite zur Verfuegung. Der Platz
reichte daher nie — die Elemente schoben sich uebereinander, und zwar
nicht nur auf schmalen Bildschirmen.
Jetzt steht das Widget auf einer eigenen Zeile, Knopf und Link darunter
in einer Zeile mit "flex-wrap". Das Registrierungsformular daneben hat es
schon immer so gemacht; die beiden sind damit endlich gleich aufgebaut.
Damit das an keiner Stelle wiederkommen kann, traegt das Widget selbst
jetzt "w-fit max-w-full overflow-x-auto". Das ist kein Schoenheitsmittel,
sondern eine Zusicherung: der iframe bleibt in jedem Fall innerhalb
seines Kastens. Ist der zu schmal, laesst sich der Inhalt seitlich
schieben, statt das Layout zu zerlegen — unabhaengig davon, wie eine
kuenftige Seite ihn einbettet.
Die uebrigen sieben Einbindungen (Registrierung, Passwort vergessen,
Passwort zuruecksetzen, Zwei-Faktor-Code, Produktseite, Firmenanfrage,
Newsletter, Checkout) habe ich geprueft: dort steht das Widget bereits
auf einer eigenen Zeile.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die Einstellung lag unter "Firmendaten" — zwischen Firmenname, Slogan und
Anschrift. Dort sucht sie niemand: eine Versandregel ist keine
Stammdatenangabe. Genau das ist beim ersten Suchen auch passiert.
Jetzt ein eigener Abschnitt "Versand" (englisch "Shipping") unter
Admin → Inhalte → Texte, direkt hinter den Firmendaten. Der
Feldtyp-Vertrag der Registry bekommt dafuer die Gruppe "shipping".
Rein organisatorisch — Schluessel, Wert und Verhalten bleiben gleich, ein
bereits gepflegter Wert wandert einfach mit.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Vorgabe war: nur dorthin liefern, wo keine Zollabwicklung noetig ist. Die
EU-Einstellung erfuellt das auf Laenderebene — die Union ist eine
Zollunion, eine Sendung von Deutschland nach Frankreich braucht keine
Zollanmeldung. Der Laendercode allein genuegt aber nicht.
Einige Gebiete gehoeren zu einem EU-Mitgliedstaat, liegen aber ausserhalb
des Zoll- und/oder Steuergebiets der Union. Dorthin ist jede Sendung eine
Ausfuhr mit Zollinhaltserklaerung, Handelsrechnung und moeglichen
Einfuhrabgaben beim Empfaenger. Zwei davon liegen in DEUTSCHLAND:
Helgoland (27498) und Büsingen am Hochrhein (78266)
Sie waren also selbst bei der Einstellung "Nur Deutschland" betroffen.
Dazu kommen Kanarische Inseln, Ceuta, Melilla, die franzoesischen
Ueberseegebiete, Livigno, Campione d'Italia, Åland und der Berg Athos.
Erkennbar sind sie nur an der Postleitzahl. findCustomsExcludedArea
prueft sie je Land, denn dieselbe Ziffernfolge bedeutet anderswo etwas
anderes: "22" ist in Finnland Åland, in Italien die Provinz Como. Die
Checkout-Route weist solche Anschriften mit einer Meldung ab, die das
Gebiet benennt und auf die Abholung verweist.
Azoren und Madeira sind bewusst NICHT ausgenommen: sie gehoeren voll zum
Zoll- und Steuergebiet, dorthin gilt der normale Binnenversand. Ein Test
haelt das fest, damit sie nicht versehentlich mitgesperrt werden.
Die Hinweistexte auf "Versand und Zahlung" nennen die Ausnahmen jetzt
ausdruecklich — in beiden Einstellungen und beiden Sprachen.
8 neue Tests in shipping-region.test.ts, 1 in der Checkout-Route
("weist Helgoland ab, obwohl es in Deutschland liegt").
tsc: 0 Fehler. vitest: 359/359 gruen (zuvor 350). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Admin-Bereich → Inhalte → Firmendaten → "Liefergebiet". Auswahl zwischen
"Nur Deutschland" und "Europäische Union". Der Wert steuert drei Stellen
gleichzeitig, damit sie nicht auseinanderlaufen koennen:
1. Checkout-Formular: bei "EU" eine Laenderauswahl mit den 27
Mitgliedstaaten, bei "Nur Deutschland" stattdessen ein Satz, der das
Gebiet benennt — bevor jemand die Adresse eintippt und erst beim
Abschicken erfaehrt, dass daraus nichts wird. Eine Auswahl mit einem
einzigen Eintrag waere eine Attrappe.
2. api/checkout/route.ts: verbindliche Pruefung. Die Auswahl im Formular
ist Bequemlichkeit, kein Schutz — der Endpunkt nimmt JSON entgegen und
laesst sich ohne Formular aufrufen. Ohne diese Pruefung entstuende eine
Bestellung mit einer Lieferadresse, fuer die weder Versandweg noch
Umsatzsteuerpflicht geklaert sind, und bezahlt waere sie dann auch
schon. Abgewiesen wird mit einer Meldung, die sagt, was stattdessen
geht (Adresse im Gebiet oder Abholung).
3. Seite "Versand und Zahlung": eigener Abschnitt "Liefergebiet" mit dem
passenden Satz. Eigener Abschnitt statt Nebensatz, weil die Frage vor
dem Warenkorb entscheidet, ob ein Kauf ueberhaupt zustande kommt.
Die Vorgabe ist "Nur Deutschland", und zwar an jeder Stelle einzeln: ein
fehlender oder unsinniger Wert, eine nicht durchgereichte Prop — im
Zweifel liefert der Shop in WENIGER Laender, nie in mehr.
"EU" heisst die 27 Mitgliedstaaten, nicht Europa im geografischen Sinn.
Schweiz, Norwegen und das Vereinigte Koenigreich sind Zollausland; dorthin
zu liefern braucht mehr als einen Haken im Admin-Bereich.
Technisch dazugekommen:
- lib/shipping-region.ts, reine Logik ohne Datenbankzugriff (wie
shipping-calc.ts), 11 Tests.
- content-registry.ts kennt jetzt den Feldtyp "select" mit "options" —
ein Freitextfeld waere hier falsch: ein Tippfehler im Liefergebiet
wuerde stillschweigend auf den Vorgabewert zurueckfallen, und niemand
saehe, dass die Umstellung nicht gegriffen hat.
- Laendernamen ueber Intl.DisplayNames statt 27 Namen je Sprache im
Woerterbuch: eine eigene Liste waere ab dem naechsten Beitritt falsch.
- 3 weitere Tests in der Checkout-Route (Abweisung, EU-Mitglied durch,
Drittland trotzdem abgewiesen).
tsc: 0 Fehler. vitest: 350/350 gruen (zuvor 336). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.
1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft
Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.
Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.
2. Zu enger Verbindungspool, zu knappe Transaktionsfristen
Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.
Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.
Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.
3. Drei Regressionstests
Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.
tsc: 0 Fehler. vitest: 336/336 gruen (zuvor 333). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
SICHERHEIT — "Mit Microsoft anmelden" uebersprang die eigene 2FA auch bei
Admin-Konten.
requireAdmin (admin/actions/_shared.ts) laesst eine Admin-Aktion nur zu,
wenn am Konto twoFactorEnabled steht. Das Flag soll zusichern, dass hinter
jeder Rueckerstattung und jedem Blick in die Kundendaten tatsaechlich ein
zweiter Faktor stand. Der SSO-Rueckweg legte aber direkt eine
vollstaendige Session an und lieferte damit ein Flag, das "geprueft"
behauptet, obwohl in diesem Anmeldevorgang nichts geprueft wurde — die
Zusicherung hing allein an einer Annahme ueber die MFA-Richtlinie des
Entra-Mandanten, die dieser Code nicht nachpruefen kann.
Ein ADMIN mit aktivierter 2FA laeuft jetzt auch per SSO durch denselben
TOTP-Schritt wie beim Passwort-Login. Ausgesperrt wird dabei niemand:
twoFactorEnabled heisst, dass der zweite Faktor eingerichtet ist. Fuer
KUNDENkonten bleibt der bequeme Weg wie dokumentiert bestehen.
PERFORMANCE — zwei unbegrenzte Abfragen auf dem Admin-Dashboard.
Die Startseite des Admin-Bereichs lud bei jedem Aufruf
1. JEDE Bestellposition, die es je gab, samt Produkt-Join — um daraus
fuenf Zeilen "Top-Produkte" zu rechnen, und
2. JEDE Bestellung, die es je gab — um daraus eine einzige Zahl
("Stammkunden") zu machen. Die Begruendung im Code lautete, Prisma
koenne kein HAVING. Es kann.
Beides erledigt jetzt die Datenbank per groupBy. Aus "waechst mit jedem
Verkauf" wird "eine Zeile je verkauftem Produkt" bzw. "eine Zeile je
Stammkundschaft". Die Kategorie haengt am Produkt und laesst sich nicht
mitgruppieren; sie kommt aus einer kurzen Nachfrage ueber die
Primaerschluessel der tatsaechlich verkauften Produkte.
PERFORMANCE — fehlende Indizes auf order_items.
PostgreSQL legt fuer Fremdschluessel-Spalten keinen Index an (anders als
MySQL/InnoDB). Auf der am schnellsten wachsenden Tabelle des Shops fehlten
dadurch zwei:
- productId: hasCustomerPurchased (lib/reviews.ts) prueft den
"verifizierten Kauf" bei JEDEM Aufruf einer Produktseite durch eine
angemeldete Kundschaft — bisher als vollstaendiger Tabellenscan.
- batchId: das Loeschen einer Charge prueft ueber onDelete: Restrict
gegen dieselbe Tabelle.
Migration 20260919010000_order_item_fk_indexes.
tsc: 0 Fehler. vitest: 328/328 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Alle Token in lib/session.ts sind mit DEMSELBEN Geheimnis signiert und
tragen denselben Aussteller und dieselbe Zielgruppe. Unterschieden werden
sie allein ueber ein "type"-Feld — und genau das prueft
verifySessionToken nicht. Es verlangte nur eine vorhandene "sub".
Damit bestand ein 2FA-Challenge-Token die Session-Pruefung und galt als
vollwertige Anmeldung. Dieses Cookie wird ausgestellt, NACHDEM das
Passwort stimmt, aber BEVOR der zweite Faktor geprueft ist: wer es im
Browser in das Session-Cookie kopiert, umgeht die Zwei-Faktor-Pruefung.
Praktisch nachgestellt:
signTwoFactorChallenge("customer_123")
-> verifySessionToken(...) ergab
{ customerId: "customer_123", role: "KUNDE", sessionVersion: 0 }
Dasselbe galt fuer den 2FA-Einrichtungstoken und den Token der
Backup-Code-Anzeige. Der Entra-OAuth-State trug keine "sub" und fiel
schon vorher durch.
Aufgehalten wurde das bisher nur zufaellig: diese Token fuehren keine
sessionVersion (also 0), und getCurrentCustomer vergleicht sie gegen die
Datenbank — wer 2FA aktiviert, steht dort bereits bei mindestens 1
(konto/actions/two-factor.ts zaehlt hoch). Das ist eine Sperre, die aus
einem voellig anderen Grund existiert und lautlos wegfaellt, sobald sich
an Zaehler oder Token etwas aendert.
verifySessionToken lehnt jetzt jeden Token mit "type"-Feld ab. Geprueft
wird auf ABWESENHEIT, nicht auf einen bestimmten Wert: das Session-Token
setzt selbst kein "type", bestehende Cookies bleiben also gueltig und
niemand wird durch das Ausrollen abgemeldet.
Dazu die erste Testabdeckung fuer die Token-Schicht ueberhaupt
(lib/session.test.ts, 12 Faelle): Rundlauf, manipulierter Token,
unbekannte Rolle wird auf KUNDE heruntergestuft, und jede Kombination von
Token-Verwechslung in beide Richtungen. Die Datei laeuft bewusst in der
node-Umgebung — jose prueft mit "instanceof Uint8Array", und jsdoms
TextEncoder liefert ein Uint8Array aus einem anderen Realm.
Nebenbei in honig/actions.ts: toggleWishlistAction nahm die productId
ungeprueft aus dem Formular. Ein erfundener Wert lief in eine
Fremdschluesselverletzung und damit in die allgemeine Fehlerseite — ein
500er als Antwort auf eine schlicht ungueltige Eingabe. Jetzt wird die
Existenz geprueft, und ein deaktiviertes Produkt kommt nicht neu auf eine
Wunschliste.
tsc: 0 Fehler. vitest: 328/328 gruen (zuvor 316). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Ohne im Admin-Bereich gepflegte Nummer griff bisher der Rueckfallwert aus
lib/site-config.ts — und der war eine Platzhalternummer
("+49 (0) 000 000000"). Sie stand damit tatsaechlich auf der Kontaktseite
und im Impressum. Eine Nummer, unter der niemand erreichbar ist, ist
schlimmer als gar keine Angabe.
Der Rueckfallwert ist jetzt leer, und beide Stellen lassen die Zeile bei
leerem Feld weg:
- kontakt/page.tsx rendert das Absatz-Element gar nicht erst.
- impressum/page.tsx nimmt die Telefonzeile aus dem Kontakt-Abschnitt.
§ 5 TMG verlangt eine Moeglichkeit zur unmittelbaren Kontaktaufnahme,
nicht zwingend ein Telefon — die E-Mail-Adresse erfuellt das.
Der DHL-Versand war bereits darauf vorbereitet: buildDhlContact setzt
"phone: address.phone || undefined" und laesst das Feld im Aufruf
entsprechend weg (lib/dhl.ts).
Die Go-Live-Pruefung warnt weiterhin bei einer EINGETRAGENEN
Platzhalternummer, aber nicht mehr bei einem leeren Feld — leer ist ab
sofort eine zulaessige Entscheidung, und der Hinweistext sagt das.
tsc: 0 Fehler. vitest: 316/316 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Die beiden Dateien, in denen Geld und Lagerbestand zusammenlaufen, hatten
bisher keinen einzigen Test. Dazu meldete die Go-Live-Pruefung gruene
Haken fuer Werte, die gar nicht gesetzt waren.
Tests fuer api/checkout/route.ts (10 Faelle)
Torwaechter (fremder Origin, Turnstile, Rate-Limit), deaktiviertes
Produkt, Preis 0, ausverkaufte Ware, Sitzungslebensdauer — und die
beiden Regressionen: scheitert die Stripe-Sitzung, wird die Bestellung
aufgeloest und OHNE orderNumber geantwortet; laesst sich die
Sitzungs-ID nicht speichern, wird die Sitzung geschlossen. Per
Mutationstest geprueft, dass der zweite Fall ohne den Fix rot wird.
Tests fuer api/checkout/webhook/route.ts (12 Faelle)
Signaturpruefung, verzoegerte Zahlung (SEPA), Auslieferung samt
Rechnung und Anhang, mehrfach zugestelltes Ereignis (Gutschein und
Rechnungsnummer duerfen sich nicht wiederholen), Zahlung auf eine
stornierte Bestellung, Betragsabweichung, abgelaufene Sitzung,
fehlgeschlagener Einzug, volle und teilweise Erstattung, Rueckbuchung.
Datenbank und Stripe sind in beiden Dateien vollstaendig ersetzt.
Go-Live-Pruefung: falsch-positive Haken beseitigt
Regeln, die einen Wert voraussetzen, bestanden bei fehlendem Wert
einfach — eine leere Zeichenkette enthaelt nun einmal kein
"example.com". Der Bericht meldete deshalb "NEXT_PUBLIC_SITE_URL ist
die echte Domain" als bestanden, direkt neben dem Blocker
"NEXT_PUBLIC_SITE_URL gesetzt". Ohne jeden Stripe-Schluessel galt
sogar der Live-Modus als bestaetigt. Das ist die gefaehrlichste
Fehlmeldung, die eine solche Pruefung machen kann: sie beruhigt ueber
genau den Punkt, der offen ist. mustWhenSet/shouldWhenSet lassen
Folgeregeln bei fehlendem Wert entfallen.
Ausserdem sagt der Bericht jetzt ausdruecklich, dass er die Umgebung
prueft, in der er LAEUFT. Lokal meldet er Blocker, die in der
ConfigMap des Clusters laengst erledigt sind; wer das ein paarmal
erlebt, liest ueber den Bericht hinweg. Das README nennt den Aufruf
im Pod.
tsc: 0 Fehler. vitest: 316/316 gruen (zuvor 294). eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fuenf Luecken zwischen Checkout, Zahlung und Stornierung geschlossen.
1. Bestellung ohne Stripe-Sitzung blieb dauerhaft haengen
Schlug stripe.checkout.sessions.create fehl, blieb die Bestellung auf
"NEU" ohne stripeCheckoutSessionId stehen. Damit griff kein einziger
Aufraeumweg: der Abbruch-Klick braucht die Sitzungs-ID, der CronJob
filterte solche Bestellungen sogar aktiv heraus, und ein Stripe-Event
kann es ohne Sitzung nicht geben. Der reservierte Lagerbestand blieb
fuer immer blockiert, und die Oberflaeche schickte die Kundschaft dabei
auf die Erfolgsseite ("wir melden uns") — obwohl es keinen Weg gab,
diese Bestellung je zu bezahlen.
Die Route loest die Bestellung jetzt selbst auf (Bestand zurueck,
Status STORNIERT mit Notiz) und antwortet mit einem ehrlichen Fehler,
der ausdruecklich sagt, dass nichts abgebucht wurde. Laesst sich die
Sitzungs-ID nicht speichern, wird die Sitzung sofort wieder
geschlossen und derselbe Weg gegangen — eine "NEU"-Bestellung ohne
Sitzungs-ID kann so nicht mehr entstehen. Der CronJob raeumt
Altbestaende trotzdem ab (Filter entfernt).
2. Kaufabbrueche standen als Bestellungen im Kundenkonto
getOrdersForCustomer lieferte auch den Status "NEU" aus. Ein gerade
abgebrochener Kauf stand damit bis zu 45 Minuten lang mit
Bestellnummer und Summe in der Liste und sah aus wie ein zustande
gekommener Kauf. "NEU" wird dort jetzt ausgeblendet; in der
Bestellverfolgung bleibt die Bestellung auffindbar, heisst aber
"Zahlung ausstehend" statt "Neu".
3. Admin-Storno einer bezahlten Bestellung erstattete kein Geld
Status wurde gesetzt, Ware zurueckgebucht, der Betrag blieb beim Shop.
Der Storno erstattet jetzt ueber die Stripe Refunds API und setzt
Payment.status auf REFUNDED.
4. Admin-Storno schloss die offene Stripe-Sitzung nicht
Die Kundschaft konnte danach noch bezahlen; der Webhook fand die
Bestellung nicht mehr auf "NEU" und brach still ab. Die Sitzung wird
jetzt geschlossen, und der Webhook protokolliert eine Zahlung auf eine
nicht mehr offene Bestellung laut, statt sie zu verschweigen.
5. Erstattungen und Rueckbuchungen kamen im Shop nicht an
charge.refunded und charge.dispute.created werden jetzt verarbeitet:
eine volle Erstattung aus dem Dashboard setzt Payment auf REFUNDED,
eine Teilerstattung wird protokolliert, eine Rueckbuchung landet als
Vermerk an der Bestellung. preflight prueft beide Abonnements mit.
Scheitert ein Stripe-Aufruf beim Storno, wird die Stornierung nicht
zurueckgenommen — der offene Punkt erscheint als Fehlermeldung im
Admin-Bereich und als order.cancel_incomplete im Audit-Log.
tsc: 0 Fehler. vitest: 294/294 gruen (ein Regressionstest fuer den
502-Fall neu). eslint: sauber.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>