
Buchungssystem

Dreisprachige Website und Buchungssystem für ein VIP-Transferunternehmen mit Sitz in İzmir. Zwei getrennte Root-Layouts in einer Next.js-Anwendung: eine öffentliche Website, deren Pfade sich mit der Sprache ändern, und ein Verwaltungspanel, das vollständig außerhalb des Sprachsegments liegt.
Transferanfragen kamen per Telefon und Nachricht; die Aufzeichnung der Anfrage, der genannte Preis und der weitere Verlauf lagen nicht an derselben Stelle. Was in einem solchen Betrieb verloren geht, ist nicht der Auftrag, sondern seine Geschichte: Sagt die Kundschaft "Sie haben mir diesen Preis genannt", gibt es keine einzige Quelle dafür, wo und wann dieser Preis festgehalten wurde. Der zweite Punkt ist die Sprache: Ein Teil der Zielgruppe kommt aus Deutschland und möchte in den Suchergebnissen eine Adresse in der eigenen Sprache sehen — die Seite zu übersetzen genügt nicht, die Adresse selbst muss in dieser Sprache stehen. Der dritte ist eine Umfangsfrage: Gehört Zahlung in eine Transfer-Website? Zahlung heißt zugleich Rückerstattung, Rechnung und Streitfall.
Zwei Parteien. Die Besucher: meist am Telefon, meist mit Blick auf eine Flugzeit; sie geben Route und Uhrzeit ein, erwarten ein Angebot und wollen sich nicht registrieren. Die Leitung: möchte eingehende Anfragen im Tagesverlauf sehen, einen Preis eintragen und den Status weiterschalten — nicht am Schreibtisch, sondern am Telefon. Deshalb kommt eine neue Anfrage als Benachrichtigung auf dem Gerät und nicht als E-Mail.
Einzelner Entwickler. Datenmodell und Migrationen, dreisprachiges Routendesign, Buchungsablauf und Statusmaschine, Verwaltungspanel, Benachrichtigungs- und E-Mail-Schicht, Bereitstellung.
Eine Next.js-16-Anwendung, zwei Root-Layouts. Die Website liegt unter `(site)/[locale]`: Die Sprache ist das erste Segment der URL, die Pfade ändern sich je Sprache über die Pathname-Zuordnung von next-intl, und Englisch liegt ohne Präfix an der Wurzel. Das Verwaltungspanel liegt unter `(admin)/admin`, vollständig außerhalb des Sprachsegments — einsprachig, eine Hülle. Der Schreibpfad ist Server Action → Zod → Drizzle. Beim Anlegen einer Buchung wird serverseitig ein Code erzeugt; Datensatz und erster Statusübergang werden in derselben Anfrage geschrieben, und bei einer Kollision versucht es die aufrufende Seite mit einem neuen Code erneut. Der Status ist ein Enum mit sechs Werten (neu, angeboten, bezahlt, bestätigt, storniert, abgeschlossen), und jeder Übergang wird in eine eigene Verlaufstabelle geschrieben. Der Preis liegt als ganze Zahl mit Währung im Datensatz; die Zahlung läuft nicht in der Anwendung, sondern über einen dem Datensatz beigefügten Zahlungslink. Benachrichtigungs- und E-Mail-Schicht liegen außerhalb des Schreibpfads: Beide werfen keine Fehler, beide geben die Zahl der Zustellungen zurück.
Das Verwaltungspanel nutzt dasselbe Datenmodell, dieselben Validierungsschemata und dieselbe Query-Schicht wie die Website. In einem eigenen Projekt hätten diese drei entweder dupliziert oder in ein gemeinsames Paket verlagert werden müssen; beides ist für ein Team von unter zwei Personen unnötige Last. Zwei Root-Layouts sorgen dafür, dass das Panel weder die Website-Hülle noch das Sprachsegment noch die Marketing-Skripte mitträgt.
KompromissPanel und Website teilen sich eine Bereitstellung: Eine Änderung an der Website veröffentlicht auch das Panel neu und umgekehrt. Auch beim Bundling ist Sorgfalt nötig — schwere Panel-Komponenten können auf die Website-Seite durchsickern, und was das verhindert, ist eine Gewohnheit und kein Mechanismus.
Die Sprache der in Suchergebnissen sichtbaren Adresse ist ein Signal, das die Klickentscheidung beeinflusst. Leistungs-, Flotten- und Rechtsseiten haben in allen drei Sprachen eigene Pfade; der interne Schlüssel bleibt einer, die nach außen sichtbare Adresse ändert sich. Englisch liegt ohne Präfix an der Wurzel, sodass für die Standardsprache keine Weiterleitungsschicht entsteht.
KompromissJede neue Seite braucht drei Pfaddefinitionen, und wird eine davon vergessen, zeigt sich der Fehler nicht beim Build, sondern als 404 in dieser Sprache. Einen veröffentlichten Pfad später zu ändern, erzeugt außerdem Weiterleitungsschulden.
Der Code wird am Telefon vorgelesen und in Nachrichten getippt. Verwechselbare Zeichen wurden aus dem Alphabet entfernt, der Datumsteil entsteht aus dem lokalen Tag der Anlage, und der Code trägt ein nicht erratbares zufälliges Suffix. So lässt er sich mündlich weitergeben und ist zugleich nicht fortlaufend wie eine Nummer.
KompromissZufälligkeit lässt eine theoretische Kollisionschance; die Eindeutigkeits-Constraint fängt sie ab, doch die aufrufende Seite muss einen Wiederholungspfad mitführen. Der Code verbirgt zudem die Zahl der Datensätze, sodass sich "wie viele Anfragen kamen heute" nicht aus ihm ablesen lässt und eine eigene Abfrage braucht.
Der Buchungsdatensatz trägt Preis und Währung; die Zahlung läuft über einen beigefügten Link. Zahlung hereinzuholen ist nicht nur eine Integration — es heißt Rückerstattungsablauf, Rechnungsstellung, Streitfälle und Aufbewahrungspflichten. Bei diesem Volumen zahlt sich diese Last nicht aus.
KompromissDer Zahlungszustand lebt außerhalb des Systems: Dass ein Datensatz "bezahlt" ist, hängt daran, dass die Leitung den Status von Hand weiterschaltet. Einen automatischen Abgleich gibt es nicht, also ist ein bezahlter, aber nicht markierter Datensatz möglich.
Umgebungsvariablen werden mit Zod geprüft; fehlt eine erforderliche, scheitert der Produktions-Build, fehlt eine optionale, schaltet sich die zugehörige Funktion ab. Der Benachrichtigungsversand gibt ohne Schlüssel null zurück und läuft weiter, die E-Mail-Schicht wirft unter keinen Umständen einen Fehler. Solange eine Buchung geschrieben werden kann, bleibt die Anwendung nutzbar.
KompromissStille Abschaltung heißt: Eine versehentlich deaktivierte Funktion bleibt womöglich unbemerkt. Ist der Benachrichtigungsschlüssel falsch eingetragen, wirkt das System gesund, nur die Benachrichtigung kommt nie an. Ein Health-Check, der das erfasst, existiert nicht — nur ein Log-Eintrag.
Drizzle hält Enums und Migrationen nahe an SQL, sodass die Entsprechung der Statusmaschine in der Datenbank lesbar bleibt — die Definition der sechs Zustände steht offen in einer Migrationsdatei. Neon läuft im selben serverlosen Modell wie die Bereitstellung; in dieser Größenordnung lohnt sich der Betrieb eines eigenen Datenbankservers nicht. next-intl löst die Pfadlokalisierung in der Routing-Schicht, sodass Übersetzungsdatei und URL-Struktur aus derselben Quelle gespeist werden. Web Push wurde gegenüber E-Mail wegen der Verzögerung gewählt: Die Zeit, bis eine neue Anfrage die Leitung erreicht, wird in diesem Geschäft direkt zur Antwortzeit. GSAP und geglättetes Scrollen gibt es nur auf der Website-Seite; das Panel trägt keines dieser Pakete.
Das Verwaltungspanel ist durch ein signiertes Sitzungscookie geschützt, Passwörter liegen mit bcrypt gehasht vor. Da die Panel-Routen außerhalb des Sprachsegments liegen, landet kein Lokalisierungspfad der Website im Panel. Das Buchungsformular liegt hinter Turnstile, geprüft wird serverseitig. Felder mit personenbezogenen Daten bleiben ausschließlich im Datensatz und gelangen nie in den Benachrichtigungstext — die Benachrichtigung auf dem Gerät trägt nur den Code und einen Link. Tote Push-Abonnements werden gelöscht, sobald der Anbieter meldet, dass es sie nicht mehr gibt, damit die Abonnementtabelle nicht mit unbenutzten Endpunkten volläuft.
Für dieses Projekt liegt keine gespeicherte, mit Datum versehene Lighthouse-Ausgabe vor, deshalb steht hier kein Seitengeschwindigkeits-Score. Strukturell werden die Website-Seiten statisch erzeugt, und der einzige dynamische Pfad ist das Schreiben einer Buchung; das Verwaltungspanel ist vollständig dynamisch. Die Bewegungsschicht wird nur auf der Website-Seite geladen und gelangt nie in das Panel-Bundle. Da Benachrichtigung und E-Mail außerhalb des Schreibpfads laufen, hängt die Antwortzeit des Formulars nicht davon ab, ob ein Anbieter langsamer wird.
In diesem Repository gibt es keine automatischen Tests, und die Lücke macht sich in der Statusmaschine am stärksten bemerkbar: In einem Übergangsgraphen mit sechs Zuständen steht heute nur in der Oberfläche und in meinem Kopf, welcher Übergang zulässig ist — im Code erzwungen wird es nicht. Bei Ustura hielt die Regel gegen Terminüberschneidungen, sobald sie in eine Datenbank-Constraint gewandert war, unabhängig davon, über welchen Pfad geschrieben wurde; hier würde ich die erlaubten Übergänge ebenfalls auf eine Tabelle oder eine Constraint herunterbrechen und sie dann als reine Funktion testen. Der zweite Punkt ist die Zahlung: Ich stehe zu der Entscheidung, sie außen vor zu lassen, doch das manuelle Markieren von "bezahlt" lässt eine Abgleichlücke. Heute würde ich zumindest den Zeitpunkt des Markierens und die markierende Person in die Verlaufstabelle schreiben — mit einem Eintrag endet die Diskussion, ohne ihn erinnert sich jeder anders.
Sagen Sie mir, was Sie bauen wollen; ich sage Ihnen vorab, wie lange es dauert und wo man anfängt.