Landing page and booking API for elcorix, a laser hair-removal studio in Kempten (Allgäu), Germany.
The front end is built to the elcorix Figma file
(figma.com/design/L3SYAnqXeHiD81Ny4cdVmF/elcorix).
| Layer | Technology |
|---|---|
| Frontend | React 19, React Router 8, TypeScript, Vite, Tailwind CSS v4 |
| Backend | Node.js, Express, TypeScript |
| Database | PostgreSQL (pg) |
| Booking | Altegio embedded booking widget (calendar) |
| Unit tests | Vitest, React Testing Library, Supertest |
| E2E tests | Playwright |
.
├── client/ # React + TS + Tailwind SPA
│ └── src/
│ ├── pages/ # Home, Prices, Gallery, Booking, Contact, Privacy,
│ │ # Imprint, Terms (AGB), Mission (Leitbild), 404
│ ├── sections/ # The one-page landing sections from the Figma
│ ├── components/ # Header, Footer, AltegioWidget, ConsultationForm,
│ │ # PriceTables, MapEmbed, …
│ ├── seo/ # route table, shared title/canonical rules, the
│ │ # build-time <head> and usePageMeta
│ └── test/ # Vitest unit tests
├── server/ # Express + TS API
│ └── src/
│ ├── routes/ # /api/contact, /api/bookings
│ ├── db/ # pg pool, schema.sql, migrate script
│ ├── mailer.ts # optional SMTP notifications
│ ├── telegram.ts # optional Telegram bot notifications
│ ├── retention.ts # daily GDPR data-retention cleanup
│ └── test/ # Vitest + Supertest unit tests
├── e2e/ # Playwright end-to-end tests (dev server, API mocked)
├── e2e-docker/ # Playwright smoke tests against the composed stack
└── playwright.config.ts
Prerequisites: Node.js ≥ 22.22.2 (React Router 8 and jsdom need it), PostgreSQL ≥ 14.
npm install
# configure environment
cp .env.example .env # set DATABASE_URL and ALTEGIO_COMPANY_ID
# create the database and apply schema
createdb kosmetic # the database name is unchanged
npm run db:migrate
# run frontend (http://localhost:5173) and API (http://localhost:3001)
npm run devThe Vite dev server proxies /api/* to the Express server.
The whole stack (Postgres, API, nginx-served client) is containerized:
cp .env.example .env # set ALTEGIO_COMPANY_ID, POSTGRES_PASSWORD, WEB_PORT
docker compose up -d --build
open http://localhost:8080TLS in production: add the Caddy overlay — it terminates HTTPS with
automatic Let's Encrypt certificates, redirects HTTP→HTTPS and www→apex,
and sets HSTS (set SITE_DOMAIN and ACME_EMAIL in .env):
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --buildBackups: the db-backup service writes a nightly pg_dump into
./backups/ and keeps BACKUP_KEEP_DAYS (default 14) days of dumps
(docker/db-backup.sh). A dump only gets its .dump name after
pg_restore --list has read it; a failed night logs FAILED and retries
hourly, and never rotates older dumps away. Check it with
docker compose logs db-backup | grep -E 'OK|FAILED'. The opt-in offsite-backup service (--profile offsite) copies them,
encrypted with restic, to a Hetzner Storage Box. Setup, firewall and SSH
hardening: SECURITY.md → Server hardening.
Publishing images: .github/workflows/deploy.yml builds and pushes
both images to GHCR on every push to main.
- client — multi-stage build (client/Dockerfile):
Vite production build (the Altegio company id is inlined via the
VITE_ALTEGIO_COMPANY_IDbuild arg), served by nginx with the SPA fallback, gzip, immutable asset caching and the security headers from SECURITY.md (CSP,frame-ancestors 'none', …) already in place (docker/nginx.conf). - server — multi-stage build (server/Dockerfile),
production dependencies only, runs as the non-root
nodeuser, applies the idempotent schema migration on start. Port 3001 is not published: nginx proxies/api/*same-origin, andTRUST_PROXY_HOPS=1makes rate limiting see the real client IP. - db —
postgres:16-alpinewith a named volume (db-data) and a healthcheck; the server waits for it to be healthy.
Both Dockerfiles use the repo root as build context because the npm
workspaces share one package-lock.json. Data persists across
docker compose down; use docker compose down -v to also drop the
database volume.
The client is fully translated with react-i18next into English (en), German (de), Ukrainian (uk) and Russian (ru).
Every page lives under a language segment and carries that language's own
slug — /de/preise, /en/prices, /uk/ціни, /ru/цены — so each
translation has an address that reads in its own language, can be linked,
shared, cached and indexed, and declares the others as hreflang alternates.
Two files own this: client/src/i18n/routing.ts maps URL ↔ language
(React-, i18next- and DOM-free), and client/src/seo/routes.ts gives each
route its slug per language, which client/src/seo/routePaths.ts translates
in both directions for the router, the links, the meta tags, the prerender
and the sitemap.
- The URL decides the language.
LanguageLayoutinApp.tsxcallschangeLanguagefor whichever segment the router matched, so a shared/uk/ціниlink opens in Ukrainian whatever the visitor picked before. - Encoded or decoded. Routes and files use the real slug (
/uk/ціни) — React Router decodes the pathname before matching, and so does nginx beforetry_files. Everything that goes out into the world — links, canonicals, hreflang, the sitemap — is percent-encoded. - The bare root is a page, not a redirect.
/renders the German home page (it is prerendered and hydrated, so it has to) andRootEntrymoves the visitor to their own language after mount:localStorage(i18nextLng) → browser language → German. An unprefixed deep path (/prices) is 301'd by nginx; a locale the site does not have (/fr/prices) is redirected in the app, path, query and hash intact. - Every URL the site ever served still works.
client/src/seo/redirects.tsgeneratesdist/_redirects.map— the old English slugs, the unprefixed paths and/privacy— and nginx answers them with a 301. - Links go through
LocalizedLink, notLink: it takes the canonical path (to="/prices") and renders the current language's slug. A bare<Link to="/prices">would drop the segment and bounce the visitor through a redirect. Anchor links useuseAnchorHref(), which returns#priceson the home page and/de#priceselsewhere. <html lang>follows the active language; the choice is still persisted tolocalStorage, but only to pick the target for the next unprefixed visit.- Missing keys fall back to English.
- Translations live in
client/src/i18n/locales/<lng>/common.json; the i18n instance is initialized inclient/src/i18n/index.ts(imported first inmain.tsx). Translation keys are type-checked:client/src/i18n/i18next.d.tsaugments i18next with the English resource shape, so a typo in at()key is a compile error. - The header's
LanguageSwitcher(accessible listbox dropdown, keyboard navigable) lists languages by name only — no flags: a language is not a country.
Adding a key: add it to en/common.json first (it is the type source and
the fallback), then mirror it in de, uk and ru. A unit test
(src/test/translations.test.ts) fails if the key sets ever diverge.
Adding a language: create client/src/i18n/locales/<lng>/common.json with
the full key set, register it in resources in client/src/i18n/index.ts and
in supportedLanguages in client/src/i18n/routing.ts (that one list gives it
a URL segment, a route tree, a prerendered shell per route, hreflang alternates
and sitemap entries), give every route in client/src/seo/routes.ts a slug in
it (the types will not compile until you do), add its og:locale to
client/src/seo/meta.ts, add an
entry (label + icon component) to LanguageSwitcher, and extend the
completeness test. If the language has a server-side auto-reply, add it to
SUPPORTED_LANGS and the confirmation map in server/src/routes/contact.ts
too — that list is separate from the client's on purpose, so an untranslated
auto-reply falls back to German rather than shipping half-translated.
Business data that must not be translated (name, address, phone, e-mail,
Instagram) stays in client/src/config.ts.
The booking page embeds the Altegio-hosted booking flow (services, staff,
calendar, confirmation) at https://n<companyId>.alteg.io.
- In Altegio: Settings → Online booking → copy your booking link / company id.
- Set
ALTEGIO_COMPANY_IDin.env(server) andVITE_ALTEGIO_COMPANY_IDfor the client build.
The API also exposes GET /api/bookings/link (canonical booking URL) and
POST /api/bookings (logs booking requests to Postgres for staff follow-up).
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
Liveness + DB readiness (503 degraded if DB down) |
| POST | /api/contact |
Store a contact-form message (+ e-mail notify) |
| GET | /api/bookings/link |
Altegio booking URL for the company |
| POST | /api/bookings |
Store a call-back/booking request (+ e-mail/Telegram) |
Both POST endpoints carry a hidden honeypot field (website): submissions
that fill it get a fake success response and are stored nowhere.
Set SMTP_HOST, MAIL_FROM and MAIL_TO (plus SMTP_USER/SMTP_PASS if
the relay needs auth — see .env.example) and the API will e-mail staff on
every contact message and booking request, and send the customer a localized
confirmation of receipt. Without SMTP config everything is still stored in
Postgres; only the notifications are skipped (a warning is logged in
production).
Consultation requests can also land in a Telegram chat, so staff see them on their phone without waiting for e-mail. It runs alongside the SMTP notification — both, either or neither can be configured. The bot only ever sends: no webhook, no polling process, no bot command to secure.
Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID (see .env.example) and
restart the API. A malformed or half-finished configuration disables the
channel with a warning in the log rather than failing silently.
TELEGRAM.md has the full walkthrough: creating the bot with @BotFather, finding the chat id, wiring it into dev and Docker, verifying it, a troubleshooting table and the security/GDPR notes.
A daily job deletes stored contact messages and booking requests older than
RETENTION_MONTHS (default 12) — matching the promise in the privacy
policy. See server/src/retention.ts.
npm test # unit tests (client + server, DB is mocked)
npm run test:e2e # Playwright e2e (starts the Vite dev server itself)
npm run test:e2e:docker # smoke tests against the composed Docker stack
# (run `docker compose up -d --build` first)
npx playwright install chromium # one-time browser download for e2e
npm run typecheck # TypeScript across both workspacesnpm run build # client → client/dist, server → server/dist
npm run start -w server- Cookie consent banner (every language) on first visit; “Accept
all” and “Only necessary” have equal prominence, the decision is stored in
localStorage(cookie-consent, with timestamp as the consent record) and can be changed any time via “Cookie settings” in the footer. - The Altegio embed is consent-gated (two-click pattern): the iframe — which sets third-party cookies — only loads after opt-in, either via the banner or the placeholder on the booking page. A no-cookie fallback link (new tab) and a cookie-free call-back request form are always available.
- The OpenStreetMap embed on the contact page is click-to-load: nothing third-party loads until the visitor explicitly asks for the map.
- Contact form requires a privacy-policy checkbox before submitting.
- Stored requests are auto-deleted after
RETENTION_MONTHS(default 12). - Legal pages:
/datenschutz(privacy policy, GDPR Art. 13 information) and/imprint(German Impressum, §5 DDG), linked from the footer. Replace theownerandvatIdplaceholders inclient/src/config.tsand have the privacy text reviewed before going live. - The site itself sets no tracking cookies;
localStorageholds only the language preference and the consent decision (both functional, exempt from consent).
See SECURITY.md for the full posture: helmet headers,
origin-restricted CORS, rate limiting, body/field limits, Altegio company-id
sanitization, CI vulnerability gates (npm audit on every push) and the
production deployment checklist (CSP, HTTPS, proxy settings). Payments stay
on Altegio's hosted pages — card data never touches this codebase.
npm run build -w client is three steps, and the last two exist for
crawlers: the client build, an SSR build of src/entry-server.tsx, and
scripts/prerender.mjs, which renders every page into its shell. What nginx
serves is a complete HTML document per URL — head and body — not a mount
point.
- The head —
client/vite/seoPrerender.tsinjects a marked block intoindex.html(title, description, canonical, hreflang, Open Graph, and theschema.org/BeautySalonJSON-LD) and writes one shell per language per route intodist/:dist/de/preise/index.html,dist/uk/ціни/index.html, … 56 in total, each in its own language with<html lang>to match. Without it, everything that does not run JavaScript saw the home page's head whatever URL it asked for. - The body —
scripts/prerender.mjsrenders each of those 58 documents (56 shells,index.htmland404.html) withrenderToStringand drops the markup into<div id="root">.main.tsxthen hydrates rather than re-rendering, which is whyRevealalways starts unrevealed andConsentProviderreadslocalStoragein an effect: the browser's first render has to match the prerendered markup exactly, or React throws it away.e2e-docker/hydration.spec.tsis what proves it still does. - Unknown URLs get a real 404. Every served URL has a file, so
try_files … =404pluserror_page 404 /404.htmlanswers anything else withdist/404.html: the same app document,noindex, no canonical. It used to fall back toindex.htmlwith a 200, which made every typo an indexable copy of the home page. - The unprefixed
dist/index.htmlthat nginx serves for/carries the German home page and canonicalises to/de. - Runtime —
client/src/seo/usePageMeta.tsrewrites those same tags for the route and language the router landed on. The title, canonical and alternate rules are shared with the build step (client/src/seo/meta.ts) so the two cannot disagree. - hreflang — every page declares every language plus
x-default(German), in the shells, at runtime, and in the sitemap. This is the point of the language segments: Google crawls withAccept-Language: en-US, so while they all shared one URL the German site was being indexed in its English rendering, with no alternate URL to point at. client/src/seo/routes.tsis the route table the router, the prerender, the sitemap and the tests all read.sitemap.xmlis generated, not committed:client/src/seo/sitemap.tsbuilds it from the route table × the language list (56 URLs with their alternates, each at its own slug), the prerender plugin writes it intodist/and serves it from the dev server, andsrc/test/sitemap.test.tsasserts the output.robots.txt, SVG favicon and apple-touch-icon inclient/public/— keep the origin there in sync withsiteUrlinclient/src/business.ts.
Self-hosted Umami v3 on the same server — open source,
no cookies, no consent banner, no data leaving the host. It is opt-in: the
compose services sit behind the analytics profile and the client only
contains the tracker when it is built with a website id.
How it fits together:
- Database: its own
umamirole and database inside the existing Postgres (umami-db-init, idempotent). That role cannot connect to thekosmeticdatabase with the customer requests.db-backupdumps it next to the site's database. - Dashboard:
https://stats.elcorix.de(also.comand.eu; Caddy,docker/Caddyfile), and on the host at127.0.0.1:3002for SSH tunnels. - Tracker: loaded first-party from
/u/p.js, posting to/u/api/send. nginx passes exactly those two URLs to Umami, so there is no CSP change and fewer ad-blocker losses.client/src/analytics.tsdoes not load it at all for browsers that send Global Privacy Control or Do Not Track. - Privacy: no IP addresses are stored, and the visitor hash rotates daily
(
SALT_ROTATION=day).umami-retentiondeletes raw data afterUMAMI_RETENTION_MONTHS(14). The privacy policy's section 11 says exactly this and appears only in builds with the tracker ("feature": "analytics"inlegal.json). Change the code and the policy together.
Setup on the server:
- DNS: an
A(andAAAA) recordstats→ the server in each of the three zones (elcorix.de, elcorix.com, elcorix.eu). .env:COMPOSE_PROFILES=analytics(add,offsiteif you use it),UMAMI_DB_PASSWORD,UMAMI_APP_SECRETandUMAMI_2FA_KEY, each fromopenssl rand -hex 32.docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d- Open
https://stats.elcorix.deand log in asadmin/umami. Change that password right away (the dashboard is public) and turn on two-factor login under Profile. - Settings → Websites → Add: name
elcorix, domainelcorix.de. Copy its website id into.envasUMAMI_WEBSITE_ID. - Rebuild the client so the id is baked in:
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d --build client
What gets tracked:
| Event | When | Data |
|---|---|---|
| pageview | every route change (automatic) | path, title, referrer, UTM/click-ids |
consultation-request |
consultation form accepted by the API | preferredDate: yes/no |
contact-message |
contact form accepted by the API | — |
phone-click, email-click, whatsapp-click, instagram-click |
tap on any such link, site-wide | placement: header, footer or the section id |
booking-consent |
Altegio calendar opted into (feature flag off today) | — |
Nothing a visitor types is ever sent. A new link to one of those channels
is picked up automatically (one delegated listener). A new event name goes
into the AnalyticsEvent union.
For marketing attribution, tag every link you place outside the site, e.g.
the Instagram bio https://elcorix.de/de?utm_source=instagram&utm_medium=social&utm_campaign=bio,
and the Google Business profile …?utm_source=google&utm_medium=maps&utm_campaign=gbp.
Umami's UTM, Attribution, Funnel (/ → /de/prices →
consultation-request) and Goals reports then work out of the box.
Business name, address, phone, e-mail, Instagram, WhatsApp, coordinates and
opening hours are configured in client/src/config.ts — currently
placeholders, replace with real values. The prices in
client/src/pricing.ts and the testimonials in the translation files are
placeholders too.