Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
10 KiB
Architektur- und Designentscheidungen
Referenzseite
https://www.freedomfilmmaker.de/filmcamp war erreichbar. Ausgewertet wurde eine automatisch erzeugte Zusammenfassung des ausgelieferten HTML, keine vollständige visuelle Analyse. Laut dieser Zusammenfassung ist die Reihenfolge: Hero mit Termin, Ort, Preis und CTA, dann Galerie, Tagesablauf, Unterkunftsgalerie, Platzoptionen mit Preisen, Team-Profile, Abschluss-CTA und Footer mit Rechtlichem. Video-Testimonials waren dort nicht erkennbar. Übernommen wurden nur Prinzipien: Fakten schon im Hero, Unterkunft über Bilder statt Text, Team mit Gesicht, früher und wiederholter Buchungseinstieg, eine persistente Buchungsaktion auf Mobilgeräten. Texte, Marken und Medien wurden nicht übernommen.
Gestaltung
- Idee: Die Onepage ist als Modemagazin angelegt. Der Hero ist ein Cover, der Masthead „ARTISTA“ überlappt das Leitbild. Schlagzeile und Buchung stehen direkt unter dem Masthead und bleiben im ersten Bildschirm; Termin, Ort und Preis folgen als Cover-Line-Leiste unter dem Bild. Das Cover ist das einzige laute Element, der Rest bleibt ruhig.
- Farbe: Putz-Weiß
#F2ECE3, Papier#FAF7F1, Anthrazit#23211F, Stein#676058, Linie#D8CEC0. Der Akzent ist Pompeji-Rot#8E3B2Bstatt des üblichen Terrakotta. Kontraste gegen den Grund: 14,6 : 1, 5,3 : 1 und 6,7 : 1. - Schrift: Bodoni Moda (Didone) für Display, Hanken Grotesk für Text. Beide werden über
next/fontselbst ausgeliefert, es gibt keine Verbindung zu Google. - Layout: asymmetrisches 12-Spalten-Raster, linksbündig, keine Kartenraster und keine Versal-Labels über Überschriften. Überschrift und Einleitung stehen untereinander (kein „Split-Header“). Listen werden über Abstand gegliedert, nicht über eine Linie unter jeder Zeile. Nummerierung gibt es nur beim beispielhaften Ablauf, weil der eine echte Abfolge ist.
- Formen: Bedienelemente (Buttons) sind Pillen, Bild- und Tafelflächen haben rechte Winkel. Textlinks sind fein unterstrichen (
.link-quiet), der wichtigste Link eines Abschnitts trägt eine rote Unterstreichung (.link-strong). - Rezensionen: echte Google-Rezensionen (Places API) als Zitatstrecke: ein führendes Zitat in Bodoni, weitere in zwei Spalten, daneben Durchschnitt und Anzahl. Initialen statt Profilbildern (keine Verbindung zu Google-Servern im Browser), Links nur zu Google-Hosts. Ohne Daten entfällt der Abschnitt; es gibt keine Ersatzstimmen.
- Bewegung:
- eine orchestrierte Hero-Eröffnung in reinem CSS
- Clip-Reveal nur auf Bildern
- Nav-Indikator
- Dialog-Übergänge und mobile Leiste über Motion (
LazyMotion) prefers-reduced-motionwird überall respektiert, es gibt keine Scroll-Übernahme.
- Platzhalter: Fehlende Bilder erscheinen als typografische Tafeln: ruhiger Palettenton, feine Papierstruktur, Passepartout und ein angeschnittener Bodoni-Buchstabe aus „ARTISTA“ (im Cover die Jahreszahl). Die früheren Verläufe mit Bogen- und Silhouettenformen wurden ersetzt, weil sie wie generische Füllbilder wirkten; die Tafeln sind eindeutig kein Foto und damit als Platzhalter erkennbar. In Entwicklung und Staging tragen sie zusätzlich das Label „Platzhalter: …“. Leere Video-Slots werden in Produktion ausgeblendet.
- Hell-Modus: Die Seite hat bewusst nur ein helles Druckbild (Magazin); die dunklen Flächen (Leistungen, Schluss) sind gesetzte Kontrastseiten, kein Dunkelmodus.
Stack und Laufzeit
- Next.js 16 App Router mit Server Components. Clientseitig sind nur Header, Buchungsdialog (lazy geladen), mobile Leiste, Galerie, Video-Slots und Statusaktionen.
- Die Ausgabe ist
standaloneund läuft als Docker-Container unter Nicht-Root. Es gibt keine Vercel-Abhängigkeit. - Der Zustand liegt vollständig in PostgreSQL: Buchungen, Reservierungen, Gutscheine, Rate Limits, E-Mail-Ausgang und Stripe-Events. Die App ist dadurch zustandslos und kann mehrere Replikate haben.
- Die Content-Security-Policy arbeitet mit Nonce und
strict-dynamic(src/proxy.ts). Stripe Checkout ist gehostet, deshalb braucht die Seite kein Stripe.js.
ADR-1: Stripe Checkout statt eingebetteter Zahlung
Der gehostete Checkout bildet den Ablauf vollständig ab: Betrag, Ablaufzeit, das Speichern der Zahlungsmethode für Folgeraten und dynamische Zahlungsarten. Kartendaten berühren die Anwendung nie, und die CSP bleibt streng. Eine eingebettete Oberfläche hätte keinen Mehrwert gebracht, der den zusätzlichen Integrations- und Sicherheitsaufwand rechtfertigt.
ADR-2: Preisbindung
Regel: Maßgeblich ist der Zeitpunkt des Buchungsstarts. Der dabei serverseitig berechnete Preis gilt, wenn die Zahlung innerhalb der Reservierungszeit abgeschlossen wird (30 Minuten, konfigurierbar, Minimum laut Stripe). Danach verfällt die Reservierung, ein neuer Versuch wird zum dann gültigen Preis berechnet.
Erzwingung:
expires_atder Checkout-Session entsprichtpriceLockedUntil, danach ist die Session bei Stripe nicht mehr bezahlbar.- Der Platz bleibt 10 Minuten länger reserviert als die Session läuft, damit eine kurz vor Ablauf abgeschlossene Zahlung ihren Platz sicher behält.
- Beim Jahreswechsel gilt: Wer am 31.12. um 23:50 Uhr startet, zahlt Early Bird, sofern die Zahlung bis zum angezeigten Ablauf gelingt. Eine Verlängerung gibt es nicht.
- Kommuniziert wird die Regel im Dialog, in der FAQ und im Stripe-Checkout-Text.
ADR-3: Endliche Ratenpläne
Gewählt: Die erste Rate läuft über Stripe Checkout mit setup_future_usage=off_session. Die Folgeraten sind einzelne Off-Session-PaymentIntents, die ein idempotenter Kubernetes-CronJob am Fälligkeitstag um 9:00 Uhr Berliner Zeit auslöst.
Verworfen: Subscription Schedules. Sie sind laut Stripe-Doku das Standardmuster für Ratenzahlung, rechnen aber in festen Intervallen (z. B. monatlich). Die geforderte Regel „letzte Rate spätestens 30 Tage vor Anreise“ und eine vom Buchungstag abhängige Mitte lassen sich damit nicht sauber abbilden. Außerdem wäre es formal ein Abonnement.
Gründe für den gewählten Weg:
- Die Raten sind exakte, unveränderliche Datenbankzeilen (per DB-Trigger geschützt). Beträge wie 800 + 700 + 700 € sind direkt abbildbar.
- Endlichkeit ist dreifach gesichert: Es gibt maximal drei Ratenzeilen, jeder Einzug prüft
paid + Rate ≤ Gesamt, und nach der letzten Rate wird die Zahlungsmethode bei Stripe getrennt. - Zustimmung: Vor der Zahlung wird ausdrücklich dem Einzug zugestimmt, mit Zeitstempel
installmentMandateAt. - Verlangt die Bank eine Authentifizierung, geht die Rate auf
REQUIRES_ACTION, und die Kundin bekommt per E-Mail einen Link und zahlt über eine eigene Checkout-Session. - Bei Fehlschlag gibt es bis zu drei Versuche im Abstand von drei Tagen, danach „Prüfung nötig“ im Admin.
- Idempotenzschlüssel
offsession:<rate>:<versuch>verhindern Doppelabbuchungen, auch nach Netzwerkfehlern oder Worker-Abbrüchen. - Keine Gleichsetzung mit Klarna: Es ist die eigene Ratenvereinbarung des Veranstalters. Im Livemodus ist sie erst nach ausdrücklicher Freigabe verfügbar.
Aufteilung: Ist der Betrag ohne Centrest teilbar, sind alle Raten gleich. Sonst werden die Folgeraten auf volle 50 € abgerundet, der Rest fällt in Rate 1. Das reproduziert alle zwölf Vorgabewerte (Tests).
Termine: Die letzte Rate ist am Stichtag fällig (Anreise minus 30 Tage). Bei drei Raten liegt die zweite in der Mitte zwischen Buchungstag und Stichtag. Der Mindestabstand beträgt 14 Tage, sonst ist die Variante serverseitig deaktiviert. Diese Terminregel ist ein Vorschlag und braucht vor dem Live-Verkauf eine Freigabe.
ADR-4: Kapazität und Gutscheine ohne Überbuchung
- Jede platzbelegende Transaktion sperrt zuerst die Camp-Zeile (
SELECT … FOR UPDATE), dann gegebenenfalls die Gutscheinzeile. Durch die feste Reihenfolge entstehen keine Deadlocks. Zwei Personen bekommen nie denselben letzten Platz oder dieselbe letzte Einlösung (Tests mit 8 bzw. 7 parallelen Buchungen). - Belegt sind CONVERTED- und HELD-Reservierungen (verzögerte Zahlung wie SEPA) sowie ACTIVE-Reservierungen, die noch nicht abgelaufen sind. Ein abgelaufener Checkout gibt Platz und Gutschein automatisch frei.
- Test- und Livebuchungen werden getrennt gezählt (
livemode). - Öffentlich sind keine Platzzahl, kein Restplatz-Zähler und keine Liste gültiger Codes sichtbar. Abgelehnte Codes bekommen immer dieselbe neutrale Meldung.
ADR-5: Webhooks und Zustände
- Die Signatur wird gegen den unveränderten Body geprüft (
request.text()). Die Event-ID wird inStripeEventgespeichert, wodurch jedes Event genau einmal verarbeitet wird. - Buchungsstatus und Zahlungsstatus sind getrennt. Die Übergänge sind monoton und laufen unter Buchungssperre, so dass ein spätes „expired“ keine bezahlte Buchung aufhebt.
- Ist nach einer verspäteten Zahlung der Platz inzwischen vergeben, wird nicht überbucht. Die Buchung geht auf „Prüfung nötig“, und Admin und Kundin werden informiert.
- Die Erfolgsseite zeigt nur den Datenbankstand. Bezahlt markieren darf nur der Webhook oder der Worker-Abgleich, der Stripe selbst abfragt.
- E-Mails laufen über einen transaktionalen Postausgang: gleiche Transaktion wie die Statusänderung, Versand danach, Wiederholung durch den Worker.
ADR-6: Verkaufsfreigabe
src/lib/sales-gate.ts wird von jedem Checkout-Endpunkt aufgerufen.
- Livemodus (
sk_live_): Verkauf nur mit laufender echter Preisphase, eingeschaltetem Live-Verkauf und allen elf Bestätigungen. - Testmodus: Checkout ist ohne Freigaben möglich, die Preisphase lässt sich simulieren.
- Schutz vor Fehlkonfiguration: Ein Live-Schlüssel außerhalb von
APP_ENV=productionbricht mit einem Fehler ab.
ADR-7: Adminbereich
Better Auth übernimmt E-Mail und Passwort, Registrierung ist ausgeschaltet. Konten entstehen nur über die Kommandozeile, TOTP-2FA ist Pflicht. Rate Limits der Anmeldung liegen in PostgreSQL, Passwörter werden gegen Datenlecks geprüft. Jede Seite, jede Server-Action und der CSV-Export prüfen Sitzung und 2FA selbst. Der Proxy leitet nur vorab um. Alle Buchungs-, Zahlungs- und Konfigurationsaktionen landen im AuditLog.