
Booking System

A three-language marketing site and booking system for an İzmir-based VIP transfer company. Two separate root layouts in one Next.js application: a public site whose paths change with the language, and an admin panel that sits entirely outside the locale segment.
Transfer requests arrived by phone and messaging; the record of the request, the price quoted and what happened afterwards did not live in the same place. What gets lost in a business like this is not the order but its history: when a customer says "you quoted me this price", there is no single source for where and when that price was recorded. The second issue is language: part of the audience comes from Germany and wants to see an address in their own language in search results — translating the page is not enough, the address itself has to be in that language. The third is a scope question: should a transfer site have payment built in? Payment also means refunds, invoices and disputes.
Two parties. The visitor: mostly on a phone, mostly looking at a flight time; enters a route and a time, expects a quote, and does not want to register. The manager: wants to see incoming requests during the day, enter a price and advance the status — not at a desk, but on a phone. That is why a new request arrives as a notification on the device rather than as an email.
Single developer. Data model and migrations, three-language route design, booking flow and status machine, admin panel, notification and email layer, deployment.
One Next.js 16 application, two root layouts. The site lives under `(site)/[locale]`: the language is the first segment of the URL, the paths change per language through next-intl's pathname map, and English sits at the root without a prefix. The admin panel lives under `(admin)/admin`, entirely outside the locale segment — single language, single shell. The write path is Server Action → Zod → Drizzle. When a booking is created, a code is generated on the server and the record and its first status transition are written in the same request; if the code collides, the caller retries with a new one. Status is a six-value enum (new, quoted, paid, confirmed, cancelled, completed) and every transition is written to a separate history table. The price sits inside the record as an integer alongside its currency; payment does not run inside the application but through a payment link attached to the record. The notification and email layer sits outside the write path: neither throws, both return how many were sent.
The admin panel uses the same data model, the same validation schemas and the same query layer as the site. Taken into a separate project, those three would have had to be either duplicated or moved into a shared package; both are unnecessary weight for a team that is not even two people. Two root layouts keep the panel from carrying the site shell, the locale segment or the marketing scripts at all.
Trade-offThe panel and the site share a deployment: a change to the site redeploys the panel and vice versa. Bundling needs care too — heavy panel components can leak into the site side, and what prevents that is a habit rather than a mechanism.
The language of the address shown in search results is a signal that affects the click decision. Service, fleet and legal pages have separate paths in all three languages; the internal key stays single while the exposed address changes. English sits at the root with no prefix, so no redirect layer is created for the default language.
Trade-offEvery new page needs three path definitions, and if one is forgotten the error does not appear at build time but as a 404 in that language. Changing a published path later also creates redirect debt.
The code is read out on the phone and typed into messages. Ambiguous characters were removed from the alphabet, the date part is generated from the local day on which the record was opened, and the code carries an unguessable random suffix. So it is both easy to share aloud and not sequential like a running number.
Trade-offRandomness leaves a theoretical chance of collision; the uniqueness constraint catches it, but the caller has to carry a retry path. The code also hides the record count, so "how many requests came in today" cannot be read from it and needs a separate query.
The booking record carries a price and a currency; payment runs through a link attached to the record. Bringing payment in is not just an integration — it means a refund flow, invoicing, disputes and retention obligations. At this volume that weight does not pay for itself.
Trade-offPayment state lives outside the system: a record being "paid" depends on the manager advancing the status by hand. There is no automatic reconciliation, so a record that has been paid but not marked is possible.
Environment variables are validated with Zod; if a required one is missing the production build fails, and if an optional one is missing the corresponding feature turns off. Notification delivery returns zero and moves on when the keys are absent, and the email layer never throws under any circumstance. As long as a booking can be written, the application stays up.
Trade-offQuiet deactivation means a feature turned off by mistake may go unnoticed: if the notification key is entered wrongly the system looks healthy, only the notification never arrives. There is no health check catching this, only a log line.
Drizzle keeps enums and migrations close to SQL, so the database counterpart of the status machine stays readable — the definition of the six states sits plainly in a migration file. Neon is on the same serverless model as the deployment; at this scale, running a separate database server does not pay for itself. next-intl solves path localisation at the routing layer, meaning the translation file and the URL structure are fed from the same place. Web Push was chosen over email because of delay: in this business, the time it takes for a new request to reach the manager turns directly into response time. GSAP and smoothed scrolling exist only on the site side; the panel carries none of those packages.
The admin panel is protected by a signed session cookie and passwords are stored with bcrypt. Because the panel routes sit outside the locale segment, no localisation path from the site side lands on the panel. The booking form sits behind Turnstile, verified on the server. Fields carrying personal data are kept only on the record and never enter the notification body — the notification that lands on the device carries only the code and a link. Dead push subscriptions are deleted from the table when the provider replies that they are gone, so the subscription table does not fill up with unused endpoints.
There is no stored, date-stamped Lighthouse output for this project, so no page-speed score is written here. Structurally, the site pages are statically generated and the only dynamic path is the booking write; the admin panel is fully dynamic. The motion layer loads only on the site side and never enters the panel bundle. Because notification and email delivery run outside the write path, the form's response time does not depend on a provider slowing down.
There are no automated tests in this repository, and the gap is felt most in the status machine: in a six-state transition graph, which transition is legitimate lives today only in the interface and in my head, not enforced in code. In Ustura, moving the appointment-overlap rule into a database constraint made it hold no matter which path a write came through; here I would reduce the permitted transitions to a table or a constraint and then test them as a pure function. The second point is payment: I stand behind leaving it out of scope, but marking "paid" by hand leaves a reconciliation gap. Today I would at least record the moment of marking and the user who marked it in the history table — with a record the argument ends, without one everyone remembers it differently.
Tell me what you want to build; I'll tell you up front how long it takes and where to start.