1bdf4df04a02148162b4e1ebc9f5698860e9f411
37
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
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]>
|
||
|
|
92ac826baa |
feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen
Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf, nicht in den Kopf der bedienenden Person. syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen -> Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein ungeprueftes returnTo waere eine offene Weiterleitung. Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen. Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt. Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente (components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten, Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge, ausgelaufener Artikel) als Hinweis statt als Fehler. Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden Sprachdateien entfernt. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
8f0f06fd13 |
feat: Produkte loeschen, und ein sichtbarer Grund, wenn es nicht geht
Fuer Ernten und Chargen gab es das Loeschen bereits, fuer Produkte nicht.
Neue Server Action deleteProductAction samt Knopf in der Produktliste,
im selben Muster wie die beiden vorhandenen: Bestaetigungsdialog,
Audit-Log-Eintrag, Pruefung vor dem Loeschen.
Geloescht werden kann nur, was nie bestellt wurde. OrderItem verweist mit
onDelete: Restrict auf das Produkt, und das ist Absicht: Bestellpositionen
und die daraus erzeugten Rechnungen sind Belege ueber einen vergangenen
Vorgang. Verschwaende das Produkt darunter weg, verloere eine bereits
gestellte Rechnung ihren Bezug — bei zehn Jahren Aufbewahrungspflicht
(§ 147 AO) keine Option. Die Meldung sagt deshalb nicht nur "geht nicht",
sondern nennt den Ausweg: inaktiv schalten, dann verschwindet das Produkt
aus dem Shop und der Beleg bleibt.
Mit dem Produkt gehen Lagerbestand, Bewertungen, Wunschlisten-Eintraege und
Verfuegbarkeits-Benachrichtigungen (onDelete: Cascade). Hochgeladene Bilder
bleiben liegen: sie koennen anderswo eingebunden sein, und eine verwaiste
Datei ist harmloser als ein totes Bild an anderer Stelle.
Auf allen drei Seiten fehlte bisher ausserdem die Erklaerung, warum der
Loeschknopf manchmal gar nicht da ist — er verschwand wortlos, sobald etwas
darauf verwies. Jetzt steht an seiner Stelle der Grund ("bereits verkauft"
bzw. "in Verwendung").
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
625b73a8c8 |
feat: Steuersatz je Kategorie und je Produkt, Rechnung weist mehrere Saetze aus
Ein einziger Satz fuer den ganzen Shop war fachlich falsch: der Steuersatz haengt in Deutschland an der Art der Ware, nicht am Betrieb. Honig ist ein Lebensmittel und ermaessigt besteuert (Anlage 2 zum UStG), eine Bienenwachskerze nicht. Der Satz wird jetzt in drei Stufen bestimmt, die erste gewinnt: 1. Produkt — neues Feld "Umsatzsteuersatz" unter Admin -> Produkte, fuer Ausnahmen, die sich nicht aus der Kategorie ergeben. 2. Kategorie — VAT_RATE_PERCENT_HONIG / _KERZEN / _PROPOLIS. 3. VAT_RATE_PERCENT als gemeinsamer Rueckfallwert. Die Kategorie-Stufe ist der Normalfall: aendern sich die gesetzlichen Saetze, genuegt eine Aenderung an der Konfiguration statt an jedem Produkt. Leer am Produkt heisst ausdruecklich "Satz der Kategorie" und NICHT 0 — 0 bedeutet steuerfrei nach § 19 UStG. Das Formularfeld hat dafuer einen eigenen Zweig im Validierungsschema, sonst haette z.coerce.number() aus dem leeren Feld eine 0 gemacht. Damit kann eine Rechnung mehrere Saetze tragen, sobald Honig und Kerze im selben Paket liegen. Sie weist deshalb je Satz eine eigene Netto- und Steuerzeile aus. Versand und Rabatt verteilen sich im Verhaeltnis der Warenwerte: der Versand ist umsatzsteuerlich eine Nebenleistung und teilt das Schicksal der Hauptleistung — gibt es mehrere, teilt er sich anteilig auf. Die Verteilung nutzt Hare-Niemeyer, damit dabei keine Cent verschwinden oder entstehen; zugesichert ist, dass die Summe aller Zeilen exakt den Rechnungsbetrag ergibt. Der Satz wandert von der Rechnung an die BESTELLPOSITION (OrderItem.vatRatePercent) und gehoert damit zum Snapshot wie der Preis: wird ein Produkt spaeter umkategorisiert, bleiben bestehende Rechnungen unveraendert. Invoice.vatRatePercent entfaellt wieder — ein einzelner Satz je Rechnung ist bei gemischten Saetzen nicht darstellbar. Die Spalte entstand in der vorigen Migration und wurde nie in Betrieb beschrieben. VAT_RATE_PERCENT_PROPOLIS steht auf 19, weil der volle Satz im Zweifel der sicherere ist — als Lebensmittel waeren 7 richtig, als Nahrungsergaenzungs- oder Kosmetikprodukt 19. Im README als zu pruefen vermerkt. Der Manifest-Test hat die neuen Variablen zunaechst als "liest niemand" gemeldet: lib/vat.ts bekommt die Umgebung als Parameter, damit sich die Saetze ohne globalen Zustand testen lassen, und greift auf die Kategorie-Saetze ueber einen berechneten Schluessel zu — beides kann eine Regex nicht sehen. Dafuer gibt es jetzt eine begruendete Ausnahmeliste. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
7c59283ede |
feat: Umsatzsteuer ueber VAT_RATE_PERCENT konfigurierbar und auf der Rechnung ausgewiesen
Die Rechnung wies bisher gar keine Steuer aus — weder Satz und Betrag noch
den Pflichthinweis nach § 19 UStG. Der Satz kommt jetzt aus
VAT_RATE_PERCENT und steht auf PDF und HTML-Ansicht: 7 (ermaessigt, Honig
als Lebensmittel), 19 (voll) oder 0 fuer die Kleinunternehmerregelung, bei
der anstelle der Steuerzeilen der vorgeschriebene Hinweis erscheint.
DIE SHOP-PREISE SIND BRUTTOPREISE. Gegenueber Verbrauchern ist der
Gesamtpreis inklusive Umsatzsteuer anzugeben (§ 1 PAngV), und genau dieser
Betrag steht in Product.priceCents und Order.totalCents. Die Steuer wird
deshalb herausgerechnet, nicht aufgeschlagen — ein Aufschlag ergaebe einen
anderen Endpreis als den, den die Kundschaft im Warenkorb gesehen und
bezahlt hat.
Gerechnet wird auf der Gesamtsumme statt je Position, damit sich Rundungen
einzelner Zeilen nicht aufsummieren, und der Steuerbetrag ergibt sich als
Rest statt aus einer zweiten Rundung: nur so gilt immer netto + steuer ===
brutto. Eine Rechnung, die sich nicht aufaddiert, ist keine brauchbare
Rechnung. Der Versand traegt den Satz der Ware, er ist umsatzsteuerlich
eine Nebenleistung.
Der Satz wird als Invoice.vatRatePercent FESTGESCHRIEBEN statt bei jeder
Anzeige aus der Umgebung gelesen. Wechselt der Steuerstatus des Betriebs,
muessen alte Rechnungen weiterhin den damals ausgewiesenen Satz zeigen — ein
Beleg ist die Aufzeichnung eines vergangenen Vorgangs, keine Ansicht auf die
heutige Konfiguration. Bestehende Rechnungen tragen null und zeigen wie
bisher keine Steuerzeilen, weil rueckwirkend nicht feststellbar ist, welcher
Satz gegolten haette.
Ein unbrauchbarer Wert faellt auf 7% zurueck und wird laut geloggt, statt
eine bereits bezahlte Bestellung scheitern zu lassen. Damit das nicht
unbemerkt bleibt, prueft "pnpm preflight" den Wert jetzt als Blocker und
warnt zusaetzlich bei einem in Deutschland unueblichen Satz.
Der bisherige legalNote war ein Platzhalter ("Kleinunternehmerregelung /
Umsatzsteuerausweis gemaess tatsaechlichem Status ergaenzen") und ist
ersetzt, da die Angabe nun tatsaechlich erfolgt.
Bekannte Grenze, im README vermerkt: es gibt genau EINEN Satz. Honig ist
ermaessigt besteuert, Kerzen nicht — werden beide verkauft, braucht es einen
Satz je ProductCategory.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
c525735285 |
feat: ein Shop-Produkt je Jahrgang statt je ApiCore-Artikel
Der Produkt-Sync bildete den Fall gleichnamiger Jahrgaenge nicht ab. In
ApiCore ist "Waldhonig 500 g" EIN Katalogartikel, an dem jedes Jahr eine
weitere Abfuellung haengt; der Sync legte daraus genau ein Wabenhain-Produkt
an. Ab dem zweiten Jahr passierte damit nichts mehr: das Produkt existierte
schon und wurde uebersprungen, und die Charge des neuen Jahrgangs blieb
unverknuepft, weil linkProductsToBatches nur eingesprungen ist, solange
Product.batchId leer war.
In Wabenhain gehoert ein Produkt zu genau einer Charge, und das ist richtig
so: jeder Jahrgang hat eigenen Preis, eigenen Verkaufstext, eigenes
Geschmacksprofil und eigene Bewertungen. Honig ist ein Naturprodukt, die
Bewertung des Waldhonigs von 2026 sagt ueber den von 2027 nichts aus.
Der Sync laeuft deshalb jetzt ueber die ABFUELLUNGEN statt ueber den
Artikelkatalog und legt je Abfuellung einen Jahrgang an, bereits mit Charge
und Ernte verknuepft. Die nachtraegliche Verknuepfung entfaellt ersatzlos.
Wiedererkannt wird ein Jahrgang ueber die neue Spalte
Product.apicoreBottlingId (nullable, unique; manuell angelegte Produkte
tragen null). Bewusst nicht ueber den Slug: der ist die oeffentliche Adresse
und soll lesbar bleiben. Er traegt stattdessen den Jahrgang
("waldhonig-2026") und faellt bei mehrfacher Abfuellung im selben Jahr auf
die Losnummer zurueck - hochgezaehlt wird nicht, "waldhonig-2026-2" sagt
niemandem etwas.
Weitere Entscheidungen, jeweils zugunsten dessen, was tatsaechlich im Glas
ist: das Gewicht kommt aus der Glasgroesse der Abfuellung statt aus dem
Artikel, und der Lagerbestand startet bei 0 statt bei der Abfuellmenge -
wie viele Glaeser in den Webshop gehen und wie viele in Hofladen oder
Wiederverkauf, entscheidet Wabenhain.
Ein in ApiCore ausgelaufener Artikel bekommt keinen neuen Jahrgang mehr,
bereits importierte bleiben unveraendert im Verkauf: solange Glaeser im
Regal stehen, ist "nicht mehr produziert" kein Grund, den Abverkauf
abzubrechen. Fehlt die Charge eines Jahrgangs noch, meldet der Lauf das als
"wartet auf den Ernten-Sync" statt als Fehler.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
78c19f1b73 |
feat: Produkte aus ApiCore synchronisieren
Der Shop konnte Ernten und Chargen bereits aus ApiCore uebernehmen, den Produktkatalog aber nicht - jedes Glas musste im Admin ein zweites Mal von Hand angelegt werden. Neuer Knopf unter /admin/produkte zieht die Produkte und traegt anschliessend die passende Charge am Produkt nach, wo ApiCore die Abfuellung einem Produkt zugeordnet hat. Genau diese Verknuepfung fuettert die Herkunftsseite hinter dem QR-Code auf dem Glas. ApiCore fuehrt ueberhaupt keine Verkaufspreise. Importierte Produkte entstehen deshalb als deaktivierte Entwuerfe mit Preis 0, den nur ein Mensch setzen kann. Damit daraus nie eine Gratis-Bestellung wird, lehnen die Aktivierung im Admin und der Checkout ein Produkt ohne Preis jetzt ausdruecklich ab, und die Produktliste kennzeichnet solche Entwuerfe. Ein bereits vorhandenes Produkt wird uebersprungen, nie ueberschrieben: genau die drei Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach - ein Update wuerde diese Arbeit bei jedem weiteren Lauf wegwerfen. Ein in ApiCore ausgelaufenes Produkt wird nicht automatisch deaktiviert, sondern nur gemeldet, damit der Restbestand abverkauft werden kann. Nebenbei behoben: die beiden Produktformulare und die Bestelluebersicht haben den ?error=-Parameter aus fail() nie ausgegeben - abgelehnte Eingaben landeten wortlos wieder auf dem Formular. Der Login gegen ApiCore ist jetzt von den Abfragen getrennt (openApiCoreSession), damit ein Sync-Lauf mit einer einzigen Anmeldung auskommt statt mit dreien. Die Feld-Abbildung liegt neu in lib/apicore-mapping.ts und ist damit testbar, statt hinter "use server" zu verschwinden. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
c61ae92390 |
feat: nicht dauerhaftes Upload-Verzeichnis wird erkannt, bevor Daten verloren gehen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Ergaenzt den Abgleich aus |
||
|
|
c8ddcec04c |
feat: fehlende Upload-Dateien werden im Admin sofort gemeldet
Nach einem Redeploy waren alle hochgeladenen Bilder und Videos weg. Die
Ursache war bereits in
|
||
|
|
bdce9b6a0a |
feat: Impressum mit den echten Inhaberdaten ausgefuellt
Inhaber Nick Rosenloecher, Rechtsform Einzelunternehmen, Umsatzsteuer-Identifikationsnummer DE366479158 — in beiden Sprachfassungen. Der Abschnitt "Redaktionell verantwortlich" trug "[Name wie oben]" und nennt jetzt ebenfalls den Namen. Der Hinweis zur Kleinunternehmerregelung nach § 19 UStG entfaellt: er war eine Handlungsanweisung fuer den Fall OHNE Umsatzsteuer-ID. Mit einer vorhandenen ID nebeneinander stehen gelassen, haette das Impressum sich selbst widersprochen. Eingetragen in die Vorlagen unter i18n/dictionaries/ statt nur in die Datenbank: so gelten die Angaben in jeder Umgebung und ueberleben eine frisch aufgesetzte Datenbank. Ueber /admin/inhalte bleiben sie weiterhin aenderbar, ohne dass dafuer ein Deploy noetig waere. Damit meldet "pnpm preflight" die Rechtstexte erstmals als vollstaendig — der letzte Inhalts-Blocker vor dem Live-Gang ist zu. Die anwaltliche Pruefung der uebrigen Rechtstexte bleibt davon unberuehrt. Ausserdem auf "Versand und Zahlung" entfernt: "Die aktuellen Versandkosten werden dir vor Abschluss der Bestellung transparent angezeigt." Das war der Ersatztext (tierFallback), der nur erschien, solange keine Versandstaffel gepflegt ist. Ohne Staffel bleibt die Liste jetzt schlicht leer, statt einen Satz zu zeigen, der nichts aussagt. Schluessel samt Typ und Verwendung entfernt, nicht nur geleert. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
9dd71ffb9a |
feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.
Aufbau:
- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
(GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
Header, kein OAuth und kein Geschaeftskundenvertrag. Die
Packstation-Auswahl funktioniert damit auch, solange die
Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
(15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
Packstation nur bei Versand, nicht bei Abholung.
Zwei Entscheidungen, die Folgearbeit ersparen:
- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
auf eine Anschrift, die es nicht gibt.
Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.
Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.
Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
|
||
|
|
ff365c3cb1 |
feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar
Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in der allein die Reihenfolge entschied, welches Bild wohin kommt. Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom Standort pflegen wollte, bekam es auf Position 0 — also in die linke Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt, war im Formular ausserdem nirgends erkennbar. Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im Ernten-Formular: Bild 1 — linke Kachel auf der Chargenseite Bild 2 — rechte Kachel auf der Chargenseite (Standort) Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und werden in dieser Reihenfolge uebermittelt. Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt und filtert leere Werte heraus, wodurch ein leerer erster Slot verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler, der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als "" stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt weiterhin [] statt ["", ""]). Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit "??" haette dieser leere String als gueltiger Wert gegolten und das Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]). strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts. Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt offen), mit Gegenprobe, dass strArray denselben Fall weiterhin zusammenrutschen laesst. Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> |
||
|
|
96bb3d9c50 |
fix: Video-Upload als roher Body statt multipart — beseitigt die 502-Ursache
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m10s
Live beobachtet: der Upload scheitert mit HTTP 502, nicht mit 504. Das ist ein wesentlicher Unterschied. 502 bedeutet, dass der vorgeschaltete Proxy vom Backend gar keine Antwort bekam — der Prozess hat die Verbindung mitten im Request fallen gelassen. Eine Zeitueberschreitung waere 504. Die bisher verfolgte Timeout-Spur war damit falsch. Uebrig blieb als Erklaerung der Arbeitsspeicher, und dort lag noch die eigentliche Ursache: request.formData() liest den GESAMTEN Body in den Speicher, bevor eine einzige Zeile eigener Code laeuft. Der vorherige Streaming-Umbau hat nur die zusaetzlichen Kopien danach beseitigt (arrayBuffer, Buffer, readFile) — diese erste nicht. Bei einem Video bis 100 MB genuegt das, um zusammen mit dem Grundverbrauch des Servers das Speicherlimit des Pods zu sprengen; der Kernel beendet den Prozess (OOMKilled), und der Proxy meldet 502. Die Datei kommt jetzt als roher Request-Body statt als multipart/form-data. Damit laesst sich request.body direkt auf die Platte durchreichen, und der Speicherbedarf haengt nicht mehr von der Dateigroesse ab. Ein Formularfeld wurde ohnehin nicht gebraucht, es wird genau eine Datei uebertragen. Die Groessengrenze prueft jetzt ein mitzaehlender Transform-Stream, weil Content-Length nur eine Ankuendigung des Clients ist. Gemessen im Produktions-Build mit einem 95-MB-Video: Status 200 nach 5,9 Sekunden, Arbeitsspeicher des Servers von 67 MB auf in der Spitze 149 MB — also rund ein Drittel des Limits von 512Mi, mit dem der Pod bisher lief. Ausserdem berichtigt: der eigene Meldungstext behauptete bei 502 "fast immer eine Zeitueberschreitung". Das ist falsch und hat die Fehlersuche in die falsche Richtung gelenkt. 502 und 504 werden jetzt getrennt erklaert — 502 als weggebrochenes Backend (meist Speichermangel), 504 als echtes Zeitlimit. |
||
|
|
b11edd7751 |
fix: "Unexpected token '<'" bei Uploads durch die echte Ursache ersetzen
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam statt JSON eine HTML-Seite zurueck, warf der Parser Unexpected token '<', "<!DOCTYPE "... is not valid JSON und genau dieser Text landete im Formular. Damit war die eigentliche Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht, die abgebrochen hat, waren daraus ablesbar. Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON, auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung, sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung beim Transcoding abgebrochen hat. readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen, 502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird getrennt gemeldet. Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen. |
||
|
|
a0f61d328c |
fix: Uebersetzungs-Inkonsistenzen vereinheitlichen (spring harvest, intense)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m37s
"Spring flow" -> "spring harvest", passend zum Rest der englischen Texte. "robust" -> "intense" fuer kraeftig in beiden Admin-Produktformularen, passend zum bereits kundenseitig verwendeten product.tasteIntense. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
88a3e367e6 |
fix: letzten AI-Slop-Rest in Honigwissen-Artikel entfernen
"Here's why it happens" war ein Throat-Clearing-Opener, den die
deutsche Originalfassung ("Wir erklaeren, woran das liegt") gar nicht
hatte. Jetzt woertlich angeglichen.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
||
|
|
7d5c172487 |
feat: eigene Bilder pro Charge
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m21s
Chargen hatten bisher kein eigenes Bild-Feld (nur Ernten und Produkte) — neue Migration ergaenzt Batch.images (Json-Array, gleiches Muster wie Harvest.images/Product.images). Admin kann Chargenfotos jetzt unter /admin/chargen/[id] hochladen (AdminImageListField, identisch zum Ernten-Formular). Die oeffentliche Chargen-Seite (/charge/[code]) zeigt diese Fotos jetzt bevorzugt, faellt ohne eigene Chargenbilder weiterhin auf die Erntebilder zurueck. Hinweis fuer den naechsten Live-Deploy: die generierte Migration hat nebenbei eine bereits laenger bestehende Schema-Abweichung mitkorrigiert (harvests.images und products.images/tasteTags werden jetzt als NOT NULL erzwungen, wie im Schema schon laenger deklariert) - "prisma migrate deploy" schlaegt fehl, falls dort noch echte NULL-Werte stehen sollten. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
a3b11cc8e2 |
feat: Ernten und Chargen im Admin loeschbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m27s
Neue deleteHarvestAction/deleteBatchAction, jeweils mit vorheriger Referenzpruefung statt rohem Datenbankfehler: eine Ernte laesst sich nur loeschen, wenn keine Chargen/Produkte mehr darauf verweisen, eine Charge nur, wenn sie keinem Produkt zugeordnet ist und in keiner Bestellung verwendet wurde (Chargen-Code steht auf Rechnungen/QR-Codes und darf aus Nachweispflicht nie verschwinden). Loeschen-Button erscheint in den Listen nur, wenn die Loeschung tatsaechlich moeglich ist, mit Bestaetigungsdialog wie beim bestehenden Benutzer-Loeschen. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
c7286041a1 |
fix: echten Button statt rohem Datei-Input bei allen Admin-Uploads
Bild-/Video-Upload-Felder (admin/inhalte, admin/produkte, admin/ernten) zeigten den unstyled Browser-Standard-Datei-Input — kaum als Bedienelement erkennbar. Input jetzt versteckt, stattdessen ein richtiger, zum Admin-Design passender Button, der den Datei-Dialog oeffnet. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
1df62f20c8 |
fix: Klickbarkeit der Wissensdatenbank-Karten sichtbar machen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Die Artikel-Karten auf /wissen waren nur per Hover als klickbar erkennbar (Unterstreichung der Überschrift) — ohne sichtbaren Hinweis im Ruhezustand war unklar, dass man draufklicken kann. Zeigt jetzt dauerhaft ein "Weiterlesen →" je Karte. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
286387d5ec |
refactor: /admin/inhalte in Bilder/Text trennen, KI-untypische Formulierungen in Website-Texten
Content-Editor zeigt Bild-/Video-Felder jetzt gebuendelt oben und Textfelder gebuendelt darunter (je Seite ein eigenes Speichern-Formular), statt beides pro Bereich gemischt untereinander. Ausserdem alle Gedankenstrich-Konstruktionen und aehnliche KI-Textmuster aus den Website-Texten (de/en) entfernt und durch natuerlichere Formulierungen ersetzt. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
6814e9e2c8 |
feat: Hero-Hintergrundvideo im Admin, ScrollReveal + Bild-Perf auf Unterseiten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m31s
Neues admin-editierbares Hintergrundvideo fuer den Startseiten-Hero (home.hero.video, MP4/WebM) mit eigenem, magic-byte-validiertem Upload-Endpunkt (app/api/admin/upload-video) analog zur bestehenden Bild-Upload-Pipeline. Ausserdem: ScrollReveal-Animation von der Startseite auf Imkerei, Herkunft, Unternehmen, Ernten, Wissen, Kontakt, Shop sowie die Produkt-/Ernte-/Chargen-Detailseiten ausgerollt (bisher nur Startseite), priority auf bislang lazy geladene Above-the-fold-Bilder (Herkunft/Unternehmen) gesetzt, und passende sizes-Hints auf Grid-Bilder (Produktkarten, Origin/Regional/Business-Teaser, Ernten-Liste) ergaenzt statt voller Viewport-Breite. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
8d988515f3 |
feat: make /imkerei and /herkunft hero images admin-editable
Both pages are the ones most directly meant to give customers "ein gutes Bild von der Imkerei" (per the active /goal), but their hero images were still hardcoded PlaceholderImage tiles with no way to ever swap in a real photo — unlike every homepage section, which already went through this exact upload flow. Added imkerei.image / herkunft.image to the content registry (new "pages" group, since neither belongs under "Homepage") and switched both pages from PlaceholderImage to ContentImage + getContentValue, the same pattern already proven on the homepage sections. Falls back to the identical placeholder tile until a photo is uploaded, so this is a pure addition with no visual change until the owner uploads one. Verified: tsc clean, 151/151 tests pass, both pages still render correctly (curl 200 + screenshot) with the placeholder fallback. Could not verify the admin upload UI end-to-end live — the local admin account has 2FA enabled and I don't have the second factor; confidence instead comes from reusing the identical, already-verified AdminImageField/ContentImage wiring used for the four home.*.image fields, plus generic (group-agnostic) admin field loading confirmed by reading admin/inhalte/page.tsx. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
6143e5704d |
fix: Zahlungsmethoden-Text vereinheitlichen, Retry-Button in Zahlungsfehler-Mail
- Apple Pay/Google Pay geprueft (Stripe-Doku via `stripe docs`): bei der hier verwendeten vollstaendig gehosteten Checkout-Seite (kein payment_method_types, kein ui_mode: "embedded") bereits automatisch enthalten, sobald Kartenzahlung aktiv ist - keine Codeaenderung noetig, keine Domain-Registrierung noetig (die betrifft laut Stripe nur Elements/eingebettete Checkout-Seite). Klaerenden Kommentar in api/checkout/route.ts und README-Abschnitt "Stripe" ergaenzt. - versand-zahlung-Seite nannte nur 4 von 6 tatsaechlich akzeptierten Zahlungsmethoden (SEPA-Lastschrift und Klarna fehlten, obwohl sie laut README aktiv unterstuetzt werden und auf der Checkout-Seite selbst bereits korrekt gelistet sind) - Text in beiden Sprachen an die vollstaendige Liste angeglichen. - Transaktions-E-Mails durchgesehen: Layout/Branding/Track-Order-Button bei Bestellbestaetigung und Versand-Mail bereits solide, keine Aenderung noetig. Die Zahlungsfehler-Mail forderte im Fliesstext zur erneuten Bestellung auf, hatte dafuer aber keinen anklickbaren Link - jetzt ein echter Button zum Shop. Verifiziert: tsc --noEmit sauber, pnpm test 151/151 gruen, pnpm build erfolgreich. |
||
|
|
a579ebabeb |
feat: Geschenk- und Honigwissen-Hinweise auf der Produktseite, Umlaut-Suche fixen
- Produktdetailseite: dezenter Hinweis direkt bei der Kaufentscheidung, dass beim Checkout eine persoenliche Geschenknachricht hinterlegt werden kann (Feature existierte bereits, war aber nur im Checkout-Formular selbst sichtbar — zu spaet, um Mengen-/Produktwahl noch zu beeinflussen) - Produktdetailseite (Honig): kontextuelle Links zu passenden Honigwissen-Artikeln (Kristallisation immer, Fruehtracht/Sommertracht wenn aus Harvest.type erkennbar) direkt bei den Produktdetails statt einer isolierten, von der Kaufseite aus unverlinkten Wissenssektion - Suche (Shop-Uebersicht + Live-Suchvorschlaege) gleicht jetzt deutsche Umlaute/scharfes S gegen ihre ASCII-Transliteration ab (z.B. "Bluete" findet "Bluete"-Produkte, "kraeftig" findet "kräftig") — vorher lieferte eine Suche in der jeweils anderen Schreibweise keine Treffer, obwohl der Katalog durchgaengig echte Umlaute verwendet; Logik in neuer, reiner lib/search-match.ts mit eigenen Tests ausgelagert - Bewusst NICHT angefasst: Newsletter-Rabattanreiz (fehlt, aber Prozentsatz/ Mechanik ist eine Owner-Entscheidung) und Trust-Badges am Kaufabschluss (bereits vorhanden auf Produktseite/Warenkorb/Checkout) Verifikation: pnpm exec tsc --noEmit clean, pnpm test 151/151 (15 Dateien, +9 neue Tests fuer search-match), pnpm build erfolgreich. |
||
|
|
de0f6ee249 |
fix: Preis-/Datumsformat an Kunden-Locale anpassen, Produktgalerie-LCP verbessern
- format.ts: formatPrice/formatDate/formatDateTime/formatMonthYear nehmen jetzt ein optionales Locale-Argument (Default "de" fuer unveraenderte Aufrufer wie Rechnungen/Admin) und formatieren fuer "en" als en-GB statt hart codiert als de-DE -- englischsprachige Kundschaft sah bisher trotz gewaehlter Sprache "9,90 €" und "10. Mai 2026" statt "€9.90"/"10 May 2026". Alle kundenseitigen Aufrufer (Produktseite, Warenkorb, Checkout, Bestell- verfolgung, Konto-Bestellungen, Ernten-/Chargenseiten, Live-Suchvorschlaege, Bestell-/Versand-E-Mails) reichen jetzt die aktive Locale durch. - en.ts: openCart/shippingWithCount/shippingLabel pluralisieren "item(s)" jetzt korrekt (vorher z.B. "Open cart, 1 items"). - product-gallery.tsx: Haupt- und Vorschaubilder nutzen jetzt next/image statt <img> (hochgeladene Produktfotos sind immer same-origin unter /uploads/..., brauchen also keine remotePatterns-Konfiguration wie der entfernte Kommentar behauptete) -- Hauptbild mit priority/responsivem sizes, da es auf der Produktseite das wahrscheinlichste LCP-Element ist. - content-image.tsx: neue optionale priority-Prop; hero.tsx setzt sie fuer das Startseiten-Hero-Bild (ebenfalls LCP-Kandidat). - Sticky-Mobile-CTA-Leiste und useCartStock-Polling gegengeprueft: bereits korrekt (pb-28-Platzreservierung bzw. Fetch nur bei Warenkorb-Aenderung ohne Intervall) -- keine Aenderung noetig. Verifiziert: tsc --noEmit clean, pnpm test (142/142 gruen), pnpm build erfolgreich. |
||
|
|
0b2196460a |
feat: Kaufjourney-Optimierungen fuer Kund:innen (Shop, Produktseite, Warenkorb, Checkout)
Konkrete Reibungspunkte entlang der Kauf-Journey behoben: - Shop-Suche als sichtbares, editierbares Feld im Filter-Formular statt nur in der kleinen Kopfzeilen-Suche — Suche/Kategorie/Sortierung/Verfuegbarkeit lassen sich jetzt in beide Richtungen kombinieren, ohne sich gegenseitig zurueckzusetzen; plus "Suche zuruecksetzen"-Link. - Versandkosten- und Lieferzeit-Hinweis direkt auf der Produktdetailseite (bisher erst im Warenkorb/Checkout sichtbar). - Produktgalerie per Touch-Wisch bedienbar, nicht nur ueber die Vorschau-Kacheln (mobile Kaufentscheidung). - Immer sichtbare mobile Kaufleiste auf der Produktseite, damit der Warenkorb-Button auf langen Seiten (Bewertungen, verwandte Produkte) nicht wieder erst erscrollt werden muss. - Live-Lagerbestand (Ausverkauft/Nur noch N) jetzt auch im Cart-Drawer und auf der Warenkorb-Seite sichtbar, nicht nur auf der Produktkarte — neuer Endpunkt /api/shop/cart-stock + useCartStock-Hook. - Checkout- und Adressformulare: autoComplete/inputMode/type auf allen Feldern (given-name, tel, postal-code, ...) fuer Mobil-Autofill und passende Bildschirmtastaturen. - Bestellpositionen in "Meine Bestellungen" und der Bestellverfolgung verlinken jetzt zur Produktseite (samt Bewertungs-Anker) — bisher war die Bewertungsfunktion nach dem Kauf nur durch manuelles Zurueeknavigieren auffindbar. Neue Tests fuer die Lagerbestand-Hinweise im Warenkorb; tsc/vitest/build bleiben gruen (142 Tests). |
||
|
|
54919d82ed |
feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads). - lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen. - app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- + Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF). - app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine zur Laufzeit hinzugefuegten Dateien bedienen) — mit Path-Traversal-Schutz und nosniff-Header. - content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite- Grafiken, admin/inhalte hat dafuer ein Upload-Widget. - Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer Product.images/Harvest.images (beide Felder existierten im Schema, hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt Platzhalter, sobald vorhanden. - Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt. Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm build clean. Upload-/Serving-Pipeline live gegen einen laufenden Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung, Format-Ablehnung) — ein vollstaendiger authentifizierter Upload end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live durchgespielt, nur per Code-Review verifiziert. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
3de9f2906e |
fix: Platzhalter auf Rechtstexten entfernen (Review-Hinweis, DPO, Hosting)
Auf Wunsch entfernt: der bislang auf JEDER Rechtstextseite sichtbare "[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]"-Hinweis (rechtliche Prüfung ist laut Auskunft bereits erfolgt). LegalPage zeigt den Hinweis jetzt nur noch an, wenn noticeText nicht leer ist. Datenschutzbeauftragte/r-Platzhalter durch eine begründete Standard-Aussage ersetzt (§ 38 BDSG greift bei einem Betrieb dieser Größe typischerweise nicht). Hosting-Platzhalter durch eine zutreffende Aussage ersetzt: die Seite laeuft auf selbst betriebener Infrastruktur, nicht bei einem externen Anbieter — kein AV-Vertrag noetig. NICHT ersetzt (siehe Chat): Name/Rechtsform/Handelsregister/USt-IdNr im Impressum — diese Angaben kenne ich nicht und darf sie nicht erfinden, eine falsche Handelsregisternummer waere ein echtes rechtliches Risiko. Dafuer wurden die echten Werte beim Nutzer angefragt. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
d52753cfef |
feat: ApiCore-Sync auf Chargen erweitern
Der bestehende Ernten-Sync (admin/ernten) laedt jetzt zusaetzlich Chargen aus ApiCore. Fachliche Zuordnung recherchiert direkt im ApiCore-Backend-Quellcode (../ApiCore/ApiCore-Backend): Wabenhains "Charge" (model Batch) entspricht ApiCores "BottlingBatch" (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch), NICHT "HoneyBatch" selbst — die Felder passen fast 1:1 (lotNumber/code, date/fillDate, bestBeforeDate/bestBeforeDate, jarCount/glassCount). - lib/apicore-client.ts: fetchApiCoreBottlingBatches() laedt GET /bottling-batches (inkl. verknuepfter HoneyBatch) und GET /extractions (inkl. verknuepfter Ernten) und loest daraus die zugehoerige ApiCore-Ernten-ID pro Charge auf. - admin/actions/apicore-sync.ts: Chargen-Sync laeuft nach dem Ernten-Sync (damit frisch angelegte Ernten schon als harvestId verfuegbar sind), legt Chargen NUR NEU an (nie Update) — anders als Harvest hat Batch kein isPublished-Feld, ein Update wuerde insbesondere remainingGlasses (echter, durch Verkaeufe veraenderter Lagerbestand) stillschweigend zuruecksetzen. Nicht-fatal: schlaegt nur der Chargen-Teil fehl, bleiben bereits synchronisierte Ernten erhalten. - admin/ernten/page.tsx + i18n: zeigt Chargen-Sync-Ergebnis separat an. - .env.example: zwei zusaetzliche ApiCore-Berechtigungen dokumentiert (extractions:read, bottling:read). Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean. Konnte nicht gegen einen echten ApiCore-Server getestet werden (siehe bereits bestehende Einschraenkung beim Ernten-Sync) — Feldnamen/Shape direkt aus ApiCore-Backends Prisma-Schema und Service-Code entnommen. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
04cc3061cf |
perf: DB-Indizes, Footer als Server Component, Site-weite Fehlerseiten
Datenbank (prisma/migrations/20260904120000_add_performance_indexes): 7 fehlende Indizes auf Fremdschluessel-/Filter-Spalten ergaenzt (Order.customerId+createdAt, Order.status, Order.createdAt, Order.discountCodeId, OrderItem.orderId, Address.customerId, AuditLog.createdAt) — bislang volle Tabellenscans bei Bestelluebersicht, Admin-Dashboard, Checkout-Gutscheinpruefung und Audit-Log. Bundle-Groesse: Footer (TikTok/YouTube/Instagram-Icons, Copyright-Zeile) aus dem client-seitigen site-chrome.tsx in eine eigene Server Component (site-footer.tsx) ausgelagert — brauchte "use client" nur wegen useI18n(), jetzt getDictionary()-Server-Aufruf wie auf jeder Admin-Seite. Entfernt ungenutztes clientseitiges JS/Hydration aus jedem Seitenaufruf (Footer steht im Root-Layout). UX: app/not-found.tsx und app/error.tsx ergaenzt (Next.js-Konventionen fuer 404 bzw. Laufzeitfehler) — beide fehlten komplett, unbekannte Routen und Rendering-Fehler zeigten bisher Next.js' generische, unbrandete Standardseiten statt einer zur Seite passenden Nutzerfuehrung. Dictionary-Kollision dabei aufgefallen und aufgeloest: eine bereits bestehende, admin-interne errorPage-Uebersetzung (src/app/admin/error.tsx, seit dem urspruenglichen App-Grundgeruest vorhanden) blieb unangetastet, die neue site-weite Fehlerseite nutzt einen eigenen Schluessel (siteErrorPage). Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
4d3100e9ba |
feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
|
||
|
|
c7927f2883 |
feat: Wabenhain Honig-Shop Anwendung
Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests. |