5c6d99d0a369b185d8172c901b193f4bff8d3b73
194
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c95c713717 |
feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche
- 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]> |
||
|
|
15ddceaa39 |
feat(ui): Fehlerseite mit gerissener Wabe
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
e6455b05e6 |
feat(ui): Admin-Seitenleiste mit Wabenmarkierung statt Seitenbalken
Aktive Seite mit Honig-Wabe statt farbigem border-left, feines Wabenraster in der Seitenleiste (liegt per isolate/-z-10 unter dem Menue). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> |
||
|
|
578ec767b2 |
feat(ui): Checkout mit Schrittanzeige und Lieferart-Karten
- 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]> |
||
|
|
d4bb6ec1cf |
feat(ui): Adressen, Retoure, Suche und Cookie-Hinweis
- 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]> |
||
|
|
baebf78ee4 |
feat(ui): Sendungsverfolgung im Wabenstil
- 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]> |
||
|
|
ee854bd77b |
feat(ui): Formular-Karten, Backup-Codes als Kacheln, Gutschein zum Kopieren
- 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]> |
||
|
|
76be1292ed |
feat(ui): Markenseiten Herkunft, Imkerei, Unternehmen, Kontakt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
- 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]> |
||
|
|
dfda94ddfc |
feat(ui): Leseerlebnis fuer Wissen und rechtliche Seiten
- 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]> |
||
|
|
e75392c75b |
feat(ui): Warenkorb-Seite mit Fortschritt zum Mengenrabatt
- 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]> |
||
|
|
467984d027 |
feat(ui): Ernten, Chargen-Nachweis und aktive Navigation
- 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]> |
||
|
|
1c5282e84f |
fix: fehlenden STRIPE_PUBLISHABLE_KEY im Server-Log melden
Bisher fiel der fehlende Schluessel nur in der Kasse auf ("Zahlung gerade
nicht verfuegbar"), nie im Log des Pods.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
||
|
|
064ca3ff6d |
feat(ui): Kopfzeile, Fusszeile und Details mit mehr Leben
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m11s
- 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]> |
||
|
|
4ee5bafe20 |
feat: Stripe Embedded Checkout statt Weiterleitung zu checkout.stripe.com
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]> |
||
|
|
6ac7cc5d1f |
feat(ui): Kundenkonto im Wabenstil
- 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]> |
||
|
|
3de17ca070 |
feat(ui): Startseite mutiger gestaltet
- 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]> |
||
|
|
3cb639c154 |
feat(ui): Fundament und Einkaufserlebnis visuell aufgewertet
- 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]> |
||
|
|
839bbff595 |
feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)
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]> |
||
|
|
fb9c2353c6 |
feat: Archiv-Tab fuer Bestellungen, Retouren und Produkte im Admin
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m24s
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]> |
||
|
|
01eaca39dc |
feat: DHL-Sendungsverlauf direkt auf der Tracking-Seite anzeigen
/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]> |
||
|
|
2659db64bd |
docs: Design fuer kategoriebasierte Versandstaffeln und Hoechstbestellmenge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m14s
Haelt die freigegebene Loesung fest: Versandstaffeln haengen kuenftig an der Produktkategorie statt an einer einzigen globalen Mengenstaffel, weil ein Honigglas deutlich mehr Versandkosten verursacht als eine Kerze. Bei gemischten Warenkoerben gilt die teuerste Kategorie, nicht die Summe. Ausserdem wird die Hoechstbestellmenge je Position im Adminportal pflegbar (globaler Standardwert plus optionale Uebersteuerung je Produkt) statt als widerspruechliche Konstanten im Stepper und im Checkout-Schema zu stehen. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
c35d61bfd6 |
feat: Bestellungen ohne Geschaeftsvorfall lassen sich im Admin loeschen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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]> |
||
|
|
256e823c4f |
fix: Erntepost-Eingabefeld war auf 34 px zusammengedrueckt
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m13s
Regression aus dem vorigen Commit (Turnstile per Prop statt process.env): sobald das CAPTCHA auf der Newsletter-Anmeldung tatsaechlich rendert, sprengt es die Eingabezeile. Das Formular war "flex flex-col sm:flex-row gap-3 max-w-lg" ohne flex-wrap, und das Widget lag als dritter Eintrag in derselben Zeile. Ab 640 px teilten sich damit Eingabefeld, Knopf und ein rund 300 px breites CAPTCHA eine Zeile von 512 px. Auf wabenhain.com bei 1440 px nachgemessen: Eingabefeld 34x71, Knopf 174x71, CAPTCHA 300x71. Solange kein Sitekey ankam, fiel das nicht auf — das Widget erschien nie, die Zeile hatte nur zwei Eintraege. Der Fehler lag also schon laenger im Markup und wurde erst sichtbar, als das CAPTCHA wie vorgesehen erschien. Jetzt bilden Eingabefeld und Knopf eine eigene Zeile, das CAPTCHA steht darunter. Die Firmenanfrage ist nicht betroffen, sie stapelt ueber space-y-5. Dazu zwei Einstellungen am Widget selbst, die von Anfang an gefehlt haben: - language: Turnstile folgte der BROWSER-Sprache statt der Seitensprache. Auf der deutschen Seite stand englisch "Verify you are human". - theme: "light": Turnstile folgte dem Farbschema des Betriebssystems. Wer es auf "dunkel" stehen hat, bekam einen schwarzen Kasten mitten in einen Abschnitt, den diese Seite ausschliesslich in Creme und Waldgruen zeichnet. Die Seite hat keinen Dunkelmodus, "auto" ist hier also immer falsch. Beides betrifft alle Formulare, nicht nur den Newsletter. tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
b9f92b5c0d |
feat: Rechtstexte nennen alle Empfaenger, denen tatsaechlich Daten zugehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m11s
Die Datenschutzerklaerung beschrieb Hosting, Bestellung, Konto, Newsletter, Kontaktformular und Google-Rezensionen — drei Empfaenger fehlten, obwohl Daten an sie gehen. Abgeglichen wurde gegen die Content-Security-Policy in next.config.ts: sie listet abschliessend, wohin der Browser ueberhaupt Verbindungen aufbauen darf, und ist damit ein belastbares Inventar. Neu aufgenommen: - Cloudflare Turnstile als eigener Abschnitt 8. Das Skript laedt auf jeder Seite mit Formular aus dem Browser des Besuchers nach; IP-Adresse und Geraetemerkmale gehen dabei an Cloudflare in den USA. Bisher stand davon nirgends etwas — weder in der Datenschutzerklaerung noch unter "Externe Inhalte" in der Cookie-Richtlinie, wo Stripe bereits korrekt beschrieben ist. Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO, Drittlandtransfer ueber EU-US Data Privacy Framework. - DHL in Abschnitt 4: Name und Lieferanschrift, bei Packstationen zusaetzlich die Standortnummer, und bei der Packstationssuche im Bestellvorgang der eingegebene Ort. - Microsoft Entra ID in Abschnitt 5. Die Anmeldung damit ist kundenseitig auf /konto verlinkt, tauchte aber in keinem Rechtstext auf. Die bisherigen Abschnitte 8 bis 10 ruecken auf 9 bis 11. Ausserdem zwei Korrekturen am Bestand: - Der Verweis auf die EU-Plattform zur Online-Streitbeilegung entfaellt in Impressum UND AGB. Die ODR-Verordnung ist aufgehoben, die Plattform eingestellt; ein Link auf einen abgeschalteten Dienst ist schlechter als keiner. Die Erklaerung nach § 36 Abs. 1 Nr. 1 VSBG bleibt und ist jetzt ausdruecklich als solche benannt. - Die Cookie-Richtlinie zitierte das TTDSG. Das Gesetz heisst seit Mai 2024 TDDDG. Unveraendert bleibt der Cookie-Banner: er bietet eine Auswahl, die derzeit nichts schaltet, weil keine einwilligungspflichtigen Cookies gesetzt werden — das ist Absicht, damit spaeter hinzukommende Cookies den Mechanismus schon vorfinden. Alle Texte auch auf Englisch. Kein Ersatz fuer eine anwaltliche Pruefung. tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
3393de2286 |
fix: Hero-Text war ueber hellem Bildmaterial nicht lesbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
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]>
|
||
|
|
368985c0cf |
fix: Newsletter und Firmenanfrage waeren mit aktivem CAPTCHA unabsendbar gewesen
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]> |
||
|
|
5870c60c50 |
feat: Ruecksendekosten sind geregelt und ausgewiesen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m29s
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]> |
||
|
|
cc267ad8d4 |
fix: ohne JavaScript blieb der gesamte Seiteninhalt unter dem Hero unsichtbar
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]> |
||
|
|
c5e4978fad |
fix: Produkt ohne Geschmacksnoten bekam "Waldhonig — . 500 g" als Beschreibung
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m5s
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]> |
||
|
|
7dd42e35bc |
perf: Produktbilder werden als AVIF ausgeliefert
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m43s
next/image lieferte bisher nur WebP (die Next.js-Vorgabe). Am Hero-Bild der
Startseite (wabenhain.com, 1920x1280) nachgemessen, mit genau den
Einstellungen, die Next.js selbst verwendet — WebP q75 gegen AVIF q47
(image-optimizer.js skaliert die Qualitaet mit 50/80) bei effort 3:
Breite WebP AVIF
640 21,2 kB 12,0 kB -43 %
828 30,2 kB 17,4 kB -42 %
1080 44,4 kB 26,5 kB -40 %
1920 105,0 kB 65,7 kB -37 %
Auf einer Shop-Uebersicht mit einem Dutzend Kacheln ist das der groesste
einzelne Posten der Seite. Browser ohne AVIF-Unterstuetzung bekommen
unveraendert WebP, ganz alte das Original; die Auswahl trifft Next.js anhand
des Accept-Headers.
Die Kodierdauer spricht nicht dagegen: bei effort 3 lag AVIF in derselben
Messung durchgehend gleichauf oder schneller als WebP (1920 px: 60 ms gegen
122 ms). minimumCacheTTL steigt trotzdem von den voreingestellten vier Wochen
auf ein Jahr — nicht wegen der Kodierdauer, sondern weil hochgeladene Bilder
unveraenderlich sind: lib/uploads.ts legt jede Datei unter einem eigenen
Namen ab, ein ausgetauschtes Produktbild bekommt also eine neue URL. Ein
kurzes TTL wirft hier nur brauchbare Ergebnisse weg.
Dazu zwei ausdrueckliche Festschreibungen, die das bisherige Verhalten
dokumentieren statt es zu aendern:
- qualities: [75] entspricht der Vorgabe von Next.js 16, die andere Werte
bereits ablehnt (gegen die laufende Seite geprueft: ?q=42 antwortet mit
400). Ausgeschrieben, damit eine spaetere Erweiterung der Liste eine
bewusste Entscheidung ist und nicht unbemerkt Kodier-Last je Wert oeffnet.
- dangerouslyAllowSVG: false entspricht ebenfalls der Vorgabe. Ausgeschrieben,
weil ein SVG ein Dokument ist, das Skripte enthalten kann, und hier
same-origin ausgeliefert wuerde.
tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. next build: gruen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
73c070f11e |
feat: Produktseiten liefern Google Versandkosten, Ruecknahme und Verkaeufer
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]> |
||
|
|
a8990a872a |
fix: Warenkorb, Kasse, Konto und Rechnungen fielen nicht aus dem Suchindex
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]> |
||
|
|
bffcce127d |
fix: "Passwort vergessen" schob sich ueber das CAPTCHA
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m41s
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]>
|
||
|
|
7f9ed7262f |
refactor: Liefergebiet bekommt einen eigenen Abschnitt "Versand"
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]> |
||
|
|
1bdf4df04a |
feat: Gebiete mit Zollpflicht vom Versand ausgenommen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m7s
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]>
|
||
|
|
6d7a77c8dd |
feat: Liefergebiet im Admin-Bereich umschaltbar (Deutschland / EU)
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]>
|
||
|
|
29f59adc6b |
feat: Kommando zum Nachschlagen der Google-Place-ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m37s
Die Place-ID steht nicht in der Google Cloud Console — dort gibt es nur den API-Schluessel. Sie gehoert zum Eintrag bei Google Maps und muss ueber die Places API nachgeschlagen werden. pnpm places:find "Wabenhain Altenmünster" gibt Name, Anschrift und GOOGLE_PLACE_ID je Treffer aus. Das Kommando ist zugleich die schnellste Probe auf die Einrichtung: kommt eine ID heraus, sind "Places API (New)", Abrechnung und Schluessel richtig gesetzt und die Startseite kann die Rezensionen abrufen. Kommt eine Ablehnung, nennt sie die drei tatsaechlich moeglichen Ursachen (API nicht aktiviert, keine Abrechnung, OAuth-Client statt API-Schluessel bzw. Referrer-Beschraenkung) — statt wie Google pauschal auf den Schluessel zu zeigen. Die Feldmaske fordert nur id, displayName und formattedAddress an: die API rechnet nach angeforderten Feldern ab. tsc: 0 Fehler. eslint: 0 Warnungen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
6639da130e |
fix: Google-Rezensionen auf "Places API (New)" umgestellt
Symptom: dauerhaft REQUEST_DENIED mit "The provided API key is invalid",
obwohl der Schluessel keinerlei Einschraenkung traegt.
Die Meldung fuehrt in die Irre. Der Code rief den alten Endpunkt
maps.googleapis.com/maps/api/place/details/json auf, und Google laesst
die dazugehoerige alte "Places API" in seit Maerz 2025 angelegten
Cloud-Projekten gar nicht mehr aktivieren. Der Fehler betrifft also nicht
den Schluessel, sondern die API — wer der Meldung folgt, sucht
zwangslaeufig an der falschen Stelle. Das README hatte diesen Fall als
"Legacy-Hinweis" bereits vorhergesagt.
Jetzt places.googleapis.com/v1 mit X-Goog-Api-Key im Header (statt des
Schluessels in der Adresszeile, wo er in fremden Zugriffsprotokollen
landet) und einer engen X-Goog-FieldMask — die API rechnet nach
angeforderten Feldern ab.
Die Fehlermeldung im Log nennt jetzt die drei Dinge, an denen es
tatsaechlich liegen kann: API nicht aktiviert, keine Abrechnung am
Projekt, oder ein OAuth-Client statt eines API-Schluessels hinterlegt.
Zwei Verhaltensaenderungen, beide unvermeidbar:
- Die neue API kennt kein reviews_sort=newest; sie liefert bis zu fuenf
Rezensionen in Googles eigener Reihenfolge.
- Rezensionen ohne Namen werden uebersprungen (zuvor nur die ohne
Text): ohne Namen fehlt die von den Nutzungsbedingungen verlangte
Zuordnung zur Quelle.
README und .env.example nennen jetzt alle vier noetigen Schritte und
weisen ausdruecklich darauf hin, dass eine Referrer-Beschraenkung den
Abruf blockiert — er laeuft serverseitig.
Dazu, mit Blick auf mehrere Instanzen: DATABASE_POOL_SIZE ist jetzt
einstellbar (Vorgabe 20) und in der ConfigMap dokumentiert. Der Wert gilt
je Instanz und multipliziert sich mit "replicas" — drei Instanzen belegen
60 der voreingestellt 100 PostgreSQL-Verbindungen.
tsc: 0 Fehler. vitest: 336/336 gruen. eslint: 0 Warnungen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
9b10a9f0c5 |
perf: Checkout haelt gleichzeitigem Andrang stand
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]>
|
||
|
|
cbc81caa1a |
fix: Turnstile-Widget erschien nur beim ersten Formular je Seitenaufbau
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m59s
Symptom: Das CAPTCHA fehlte gelegentlich und war erst nach einem
Neuladen der Seite da.
Ursache ist kein Wettlauf, sondern ein deterministischer Ausfall.
next/script fuehrt onLoad fuer eine bereits geladene Quelle NICHT erneut
aus — der Schluessel ist dabei die URL (id || src) und damit fuer alle
Widgets derselbe. In next/dist/client/script.js:
const cacheKey = id || src;
// Script has already loaded
if (cacheKey && LoadCache.has(cacheKey)) {
return;
}
Das Widget wartete aber genau auf dieses onLoad, bevor es
turnstile.render() aufrief. Also bekam nur das ERSTE Widget nach einem
echten Seitenaufbau eines; jedes weitere blieb leer:
- die Kontoseite zeigt Anmeldung UND Registrierung untereinander, also
zwei Widgets — eines davon war immer tot;
- jedes Formular, das ueber einen Link im Seitenverlauf erreicht wurde
statt ueber F5.
Weil die Newsletter-Anmeldung im Footer steht, laedt das Skript auf
praktisch jeder Seite. Damit fuehrte fast jeder Weg durch die Seite in
den kaputten Zustand — und ein Neuladen half, weil das betroffene Widget
dann wieder als erstes an der Reihe war. Das erklaert das "manchmal".
Folgen waren groesser, als es aussieht: ohne Widget kein Token, ohne
Token weist der Server das Formular ab. Anmeldung, Registrierung,
Passwort-Reset, Zwei-Faktor-Eingabe und Checkout waren blockiert, ohne
dass irgendwo eine Fehlermeldung stand.
Das Widget laedt das Skript jetzt selbst, ueber ein von allen Instanzen
geteiltes Versprechen, und haengt an keinem Ereignis mehr, das man
verpassen kann: es zaehlt allein, ob window.turnstile vorhanden ist.
Dazu drei Dinge, die vorher fehlten:
- "render=explicit", damit Turnstile nicht zusaetzlich selbst nach
Containern sucht;
- turnstile.remove() beim Verlassen, statt das Widget stehen zu lassen;
- ein SICHTBARER Hinweis mit "Erneut versuchen", wenn das Skript nicht
laedt (Netzaussetzer, Inhaltsblocker). Vorher blieb dort eine leere
Flaeche, und das Formular liess sich ohne erkennbaren Grund nicht
abschicken.
Der Callback liegt in einem Ref: eine bei jedem Render neu erzeugte
Funktion haette das Widget sonst abgebaut und mit ihm ein bereits
geloestes CAPTCHA.
Fuenf Regressionstests (src/components/turnstile-widget.test.tsx), allen
voran "baut sich auch dann auf, wenn das Skript schon geladen ist". Per
Mutationstest geprueft, dass sie ohne den Fix rot werden.
tsc: 0 Fehler. vitest: 333/333 gruen (zuvor 328). eslint: 0 Warnungen.
"next build" laeuft durch.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
e5cae9b49d |
perf: Firmendaten und Social-Links je Request nur noch einmal laden
getCompanyInfo() wurde bei JEDEM Seitenaufruf mehrfach ausgefuehrt: das Root-Layout braucht es zweimal (einmal in generateMetadata, einmal fuer das LocalBusiness-Schema im Rumpf), dazu kommt fast jede Seite mit einem eigenen Aufruf. Jeder davon war eine eigene Datenbankabfrage ueber dieselben zehn Schluessel — dreimal dasselbe Ergebnis pro gerenderter Seite, auf jeder Seite des Shops. Beide Funktionen sind jetzt in React-cache() gehuellt. Die Deduplizierung gilt je Request und ist KEIN serverweiter Zwischenspeicher: eine Aenderung im Admin-Bereich wirkt unveraendert sofort, es gibt nichts zu invalidieren. Beide Funktionen nehmen keine Argumente, damit greift die Deduplizierung zuverlaessig — cache() unterscheidet Aufrufe anhand ihrer Argumente. Geprueft: "next build" laeuft durch, tsc 0 Fehler, vitest 328/328 gruen, eslint 0 Warnungen. Nicht geaendert, aber geprueft: die Admin-Listen ohne "take" (Chargen, Ernten, Gutscheine, Inhalte, Produkte, Versandstaffeln) betreffen ausnahmslos kleine, langsam wachsende Tabellen — eine Begrenzung waere dort Vorratsarbeit. Die Listen auf den wachsenden Tabellen (Bestellungen, Bewertungen, Audit-Log, Retouren, Anfragen, Benutzer) sind bereits begrenzt. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
2bbb38c31a |
fix: SSO umging die Admin-2FA; perf: Dashboard und fehlende Indizes
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]>
|
||
|
|
3e81896194 |
fix: Session-Pruefung akzeptierte Token anderer Zwecke
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]>
|
||
|
|
5170e477bc |
fix: leere Telefonnummer wird nirgends mehr angezeigt
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]>
|
||
|
|
dbf146c19f |
test: Geldpfade absichern und Go-Live-Pruefung ehrlich machen
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]> |
||
|
|
1fa8dc9ce2 |
fix: Bestell- und Abrechnungsprozess vor Go-Live abdichten
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]>
|
||
|
|
dda0a66c6c |
fix: ein abgebrochener Checkout hinterlaesst keine Bestellung mehr
Bisher blieb nach einem Abbruch eine Bestellung im Status "STORNIERT" stehen. Im Admin-Bereich sah damit jeder weggeklickte Warenkorb aus wie ein echter, gescheiterter Verkauf — bei einem Shop, in dem die meisten Besuche nicht im Kauf enden, verdecken diese Einträge irgendwann die tatsächlichen Vorgänge. Ein nie bezahlter Checkout wird deshalb gelöscht statt storniert. Es gab kein Geschäft, keine Zahlung, keine Bestätigung und keine Rechnung, also auch nichts aufzubewahren; die Positionen gehen mit (onDelete: Cascade). Betroffen sind der Abbruch-Knopf, die abgelaufene Stripe-Sitzung und der Aufräumlauf. Eine Ausnahme bleibt bewusst bestehen: checkout.session.async_payment_failed. Dort wurde tatsächlich eine Zahlung versucht und ist gescheitert (SEPA-Lastschrift, teils Klarna) — die Kundschaft hat den Checkout abgeschlossen und rechnet mit einer Lieferung. Dieser Fall bleibt als "STORNIERT" nachvollziehbar und wird weiterhin per E-Mail beantwortet. Beim bloßen Abbruch entfällt die Mail: eine Nachricht über einen Vorgang, den man selbst abgebrochen hat, verwirrt mehr als sie hilft. releaseUnpaidOrder unterscheidet die beiden Fälle jetzt über einen Parameter. Beim Löschen ist deleteMany mit der Statusbedingung der atomare Zugriff — es entfernt entweder genau diese eine noch unbezahlte Bestellung oder nichts. Hat inzwischen eine eingehende Zahlung den Status auf "BEZAHLT" gesetzt, fängt count === 0 das ab: eine bezahlte Bestellung darf dieser Weg unter keinen Umständen entfernen. Die Positionen werden vor dem Löschen gelesen, sonst wäre der Bestand danach nicht mehr zurückzubuchen. Die Reservierungsdauer bleibt bei 32 Minuten. Kürzer geht nicht: Stripe lässt als Untergrenze 30 Minuten zu (die zwei Minuten sind Puffer gegen auseinanderlaufende Uhren). Eine kürzere Reservierung ließe sich nur bauen, indem die Sitzung vorzeitig per API geschlossen wird — das hieße, einer Kundschaft, die gerade eine 3-D-Secure-Abfrage beantwortet, die Zahlung unter den Händen wegzunehmen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
a3f91b83fc |
fix: abgebrochener Checkout gibt die reservierte Ware sofort wieder frei
Eine Bestellung entsteht vor der Zahlung und reserviert dabei sofort
Lagerbestand — sonst könnte zwischen Kaufentscheidung und Zahlung jemand
anders dasselbe Glas kaufen. Bricht die Kundschaft bei Stripe ab, passierte
danach: nichts. Die Seite /checkout/cancel war reiner Text, die Bestellung
blieb auf "NEU", und der Bestand kam erst zurück, wenn Stripe die Sitzung für
abgelaufen erklärte — nach Voreinstellung 24 Stunden später.
Das ist nicht nur unschön, es ist ausnutzbar. Zehn Checkouts pro Stunde und
IP, bis zu zwanzig Stück je Position, fünfzig Positionen: damit lässt sich der
Shop leerräumen, ohne einen Cent zu zahlen, und das Lager bleibt einen Tag
lang blockiert.
Drei Schichten dagegen, jede für sich wirksam:
1. Die Stripe-Sitzung läuft nach 32 statt 24 Stunden ab (expires_at). Stripe
lässt als Untergrenze 30 Minuten zu; die zwei Minuten Aufschlag sind Puffer
gegen auseinanderlaufende Uhren, sonst kann die Erstellung als "weniger als
30 Minuten" abgewiesen werden.
2. Der Abbruch-Knopf bucht sofort zurück. Die cancel_url trägt dazu einen
eigenen Token (Order.cancelToken, 32 Byte), nicht die Bestellnummer: die
steht in jeder Bestätigungsmail und auf jeder Rechnung, mit ihr ließe sich
also die offene Zahlung einer fremden Person abbrechen. Der Token wird
nach Gebrauch aus der Adresszeile entfernt.
{CHECKOUT_SESSION_ID} wäre der naheliegende Weg gewesen, ist aber nur für
success_url und return_url dokumentiert, nicht für cancel_url — darauf
baut hier nichts.
3. Ein CronJob prüft alle 30 Minuten nach, was liegengeblieben ist. Er greift
genau dann, wenn Schicht 2 nicht greifen kann: checkout.session.expired
ist im Dashboard nicht abonniert, der Endpunkt war nicht erreichbar, die
Zustellversuche sind abgelaufen. Ohne ihn bliebe der Bestand dauerhaft
blockiert, und zwar unsichtbar — eine solche Bestellung sieht im Admin
aus wie jede andere offene.
Gefragt wird an jeder Stelle zuerst Stripe, nie die Uhr allein. Ist die
Sitzung dort "complete", war der Aufruf kein Abbruch, sondern ein
Zurück-Klick nach erfolgreicher Zahlung — dann bleibt alles unangetastet.
Umgekehrt vorzugehen hieße, eine bezahlte Bestellung zu stornieren und ihre
Ware ein zweites Mal zu verkaufen.
Findet der Aufräumlauf eine Bestellung, die bei Stripe bezahlt ist, hier aber
noch auf "NEU" steht, rührt er sie nicht an: das nachträglich selbst zu
erledigen hieße, Rechnung, Bestätigungsmail und Gutscheinzählung zu
überspringen. Er meldet den Fall und endet mit Exit-Code 1, damit er als
fehlgeschlagener Job auffällt statt in einer Logzeile zu verschwinden.
Das Stornieren samt Rückbuchung liegt jetzt einmal in lib/order-release.ts
statt verteilt. Der Statuswechsel ist ein einzelnes bedingtes UPDATE ("nur
wenn noch NEU"), damit Abbruch-Klick und Webhook im selben Moment den Bestand
nicht doppelt gutschreiben.
Weiter in diesem Zug:
- Betragsabgleich im Webhook: weicht session.amount_total von der eigenen
Summe ab, hat etwas außerhalb dieses Codes mitgerechnet (am ehesten eine im
Dashboard eingeschaltete automatische Steuer). Die Bestellung bleibt
bezahlt — das Geld ist geflossen —, aber die Abweichung wird laut geloggt.
Sonst wird bei zu wenig eingezogenem Geld stillschweigend mit Verlust
versendet, und bei zu viel weicht die Rechnung vom belasteten Betrag ab.
- preflight prüft, ob der Webhook-Endpunkt alle vier Ereignisse abonniert
hat. Ein eingeschränkter Schlüssel darf die Liste womöglich nicht lesen;
dann meldet die Prüfung "ungeprüft" statt "in Ordnung".
- Packstation-Bestellungen waren vollständig kaputt: die Muster für
Packstation- und Postnummer lauteten /^d{1,10}$/ und /^d{6,10}$/. Ohne
Backslash trifft das nicht "Ziffer", sondern den Buchstaben d — jede echte
Nummer wurde abgewiesen, gültig wären nur Eingaben wie "dddddd" gewesen.
Der Fehler war beim Lesen kaum zu sehen, weil die Fehlermeldung daneben das
Richtige sagte. Drei Tests halten das jetzt fest.
- Das Laufzeit-Image bekommt src/lib vollständig statt zweier einzelner
Dateien: der CronJob zieht über order-release.ts weitere Module nach, und
einzeln aufgezählt fällt so etwas erst im Cluster mit "Cannot find module"
auf.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
e525db84ef |
feat: fortlaufende Rechnungsnummer statt Zufallszahl
§ 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Rechnungsnummer. Bisher
stand dort "RE-2026-<8 Hex>": eindeutig ja, fortlaufend nein. Bei einer
Betriebsprüfung ließe sich damit nicht zeigen, dass keine Rechnung fehlt —
aus einer Lücke in einer Zufallsfolge lässt sich nichts ablesen, aus einer
Lücke in 0001, 0002, 0004 sehr wohl. Das war der letzte offene Rechtspunkt
vor dem Live-Gang, und der einzige, der sich ohne Absprache schließen ließ.
Die Nummer heißt jetzt RE-<Jahr>-<laufende Nummer>, also RE-2026-0001. Die
Zählung beginnt jedes Jahr neu bei 0001; das ist zulässig, solange das Jahr
Teil der Nummer ist.
Die laufende Nummer kommt aus der neuen Tabelle invoice_counters, eine Zeile
je Jahr. Der naheliegende Weg "höchste vorhandene Nummer + 1" wäre falsch:
zwei gleichzeitig eintreffende Stripe-Webhooks läsen denselben Höchstwert und
vergäben dieselbe Nummer. Das upsert mit { increment: 1 } sperrt die Zeile,
solange es zählt, und serialisiert damit die Vergabe.
Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Rechnung existiert. Stripe stellt denselben Webhook erneut zu, wenn die
Bestätigung ausbleibt — ohne diese Prüfung verbrauchte jeder Wiederholungs-
versuch eine Nummer und risse genau die Lücke, die hier vermieden werden
soll.
Vier Stellen halten die Nummern gleich lang und damit sortierbar; ab der
zehntausendsten Rechnung eines Jahres wächst die Nummer mit, statt
abgeschnitten zu werden — eine abgeschnittene Nummer wäre nicht mehr
eindeutig.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
7fe1a01550 |
fix: Google-Rezensionen wurden nie geladen — falscher fields-Parameter
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m9s
Die Anfrage an die Places API setzte "fields=review". Richtig ist "reviews" (Plural). Google antwortet auf den falschen Wert mit INVALID_REQUEST, die Funktion gab daraufhin null zurueck, und die Startseite fiel auf die statischen Beispiel-Stimmen zurueck. Nach aussen sah das voellig normal aus — eine Bewertungen-Sektion mit Zitaten und Sternen war ja da. Nur waren es nie die echten. Genau deshalb blieb der Tippfehler unbemerkt. Damit das nicht wieder passieren kann, meldet die Anbindung jetzt, WARUM sie nichts liefert. Vorher fuehrten alle Fehlerfaelle stumm zum selben Bild: [google-reviews] Places API meldet "REQUEST_DENIED": ... REQUEST_DENIED (Schluessel ungueltig oder Places API im Projekt nicht aktiviert), NOT_FOUND (Place-ID stimmt nicht) und OVER_QUERY_LIMIT (Kontingent erschoepft oder keine Abrechnung hinterlegt) sind damit von aussen unterscheidbar. Weitere Korrekturen: - Zeitlimit von fuenf Sekunden. Ohne das haengt die Startseite an einer nicht antwortenden fremden API — dieselbe Absicherung wie beim Passwort-Leak-Abgleich. - Rezensionen verlinken auf ihre Quelle bei Google. Die Nutzungsbedingungen der Places API verlangen die Zuordnung; bisher stand ein fremdes Zitat unbelegt auf der Seite. author_url und profile_photo_url wurden aus der Antwort schlicht verworfen. - Rezensionen ohne Text werden uebersprungen. Reine Sternebewertungen sind in der Antwort enthalten und waeren als Zitat eine leere Sprechblase; "text" ist laut Google optional, war hier aber als Pflichtfeld typisiert. README: Einrichtung, Bedeutung der Statuscodes und der Hinweis, dass Google den genutzten place/details-Endpunkt als Legacy fuehrt — bleibt REQUEST_DENIED trotz gueltigem Schluessel bestehen, ist die Umstellung auf die Places API (New) faellig. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |