Skip to content
MikeGoldenPublic

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

elcorix

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).

Stack

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

Structure

.
├── 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

Getting started

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 dev

The Vite dev server proxies /api/* to the Express server.

Run with Docker

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:8080

TLS 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 --build

Backups: 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_ID build 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 node user, applies the idempotent schema migration on start. Port 3001 is not published: nginx proxies /api/* same-origin, and TRUST_PROXY_HOPS=1 makes rate limiting see the real client IP.
  • db — postgres:16-alpine with 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.

Internationalization

The client is fully translated with react-i18next into English (en), German (de), Ukrainian (uk) and Russian (ru).

Language URLs

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. LanguageLayout in App.tsx calls changeLanguage for 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 before try_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) and RootEntry moves 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.ts generates dist/_redirects.map — the old English slugs, the unprefixed paths and /privacy — and nginx answers them with a 301.
  • Links go through LocalizedLink, not Link: 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 use useAnchorHref(), which returns #prices on the home page and /de#prices elsewhere.
  • <html lang> follows the active language; the choice is still persisted to localStorage, 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 in client/src/i18n/index.ts (imported first in main.tsx). Translation keys are type-checked: client/src/i18n/i18next.d.ts augments i18next with the English resource shape, so a typo in a t() 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.

Altegio integration

The booking page embeds the Altegio-hosted booking flow (services, staff, calendar, confirmation) at https://n<companyId>.alteg.io.

  1. In Altegio: Settings → Online booking → copy your booking link / company id.
  2. Set ALTEGIO_COMPANY_ID in .env (server) and VITE_ALTEGIO_COMPANY_ID for 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).

API

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.

E-mail notifications

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).

Telegram notifications

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.

Data retention (GDPR)

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.

Testing

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 workspaces

Build

npm run build         # client → client/dist, server → server/dist
npm run start -w server

GDPR / privacy

  • 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 the owner and vatId placeholders in client/src/config.ts and have the privacy text reviewed before going live.
  • The site itself sets no tracking cookies; localStorage holds only the language preference and the consent decision (both functional, exempt from consent).

Security

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.

SEO

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.ts injects a marked block into index.html (title, description, canonical, hreflang, Open Graph, and the schema.org/BeautySalon JSON-LD) and writes one shell per language per route into dist/: 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.mjs renders each of those 58 documents (56 shells, index.html and 404.html) with renderToString and drops the markup into <div id="root">. main.tsx then hydrates rather than re-rendering, which is why Reveal always starts unrevealed and ConsentProvider reads localStorage in an effect: the browser's first render has to match the prerendered markup exactly, or React throws it away. e2e-docker/hydration.spec.ts is what proves it still does.
  • Unknown URLs get a real 404. Every served URL has a file, so try_files … =404 plus error_page 404 /404.html answers anything else with dist/404.html: the same app document, noindex, no canonical. It used to fall back to index.html with a 200, which made every typo an indexable copy of the home page.
  • The unprefixed dist/index.html that nginx serves for / carries the German home page and canonicalises to /de.
  • Runtime — client/src/seo/usePageMeta.ts rewrites 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 with Accept-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.ts is the route table the router, the prerender, the sitemap and the tests all read.
  • sitemap.xml is generated, not committed: client/src/seo/sitemap.ts builds it from the route table × the language list (56 URLs with their alternates, each at its own slug), the prerender plugin writes it into dist/ and serves it from the dev server, and src/test/sitemap.test.ts asserts the output.
  • robots.txt, SVG favicon and apple-touch-icon in client/public/ — keep the origin there in sync with siteUrl in client/src/business.ts.

Analytics (Umami)

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 umami role and database inside the existing Postgres (umami-db-init, idempotent). That role cannot connect to the kosmetic database with the customer requests. db-backup dumps it next to the site's database.
  • Dashboard: https://stats.elcorix.de (also .com and .eu; Caddy, docker/Caddyfile), and on the host at 127.0.0.1:3002 for 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.ts does 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-retention deletes raw data after UMAMI_RETENTION_MONTHS (14). The privacy policy's section 11 says exactly this and appears only in builds with the tracker ("feature": "analytics" in legal.json). Change the code and the policy together.

Setup on the server:

  1. DNS: an A (and AAAA) record stats → the server in each of the three zones (elcorix.de, elcorix.com, elcorix.eu).
  2. .env: COMPOSE_PROFILES=analytics (add ,offsite if you use it), UMAMI_DB_PASSWORD, UMAMI_APP_SECRET and UMAMI_2FA_KEY, each from openssl rand -hex 32.
  3. docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
  4. Open https://stats.elcorix.de and log in as admin / umami. Change that password right away (the dashboard is public) and turn on two-factor login under Profile.
  5. Settings → Websites → Add: name elcorix, domain elcorix.de. Copy its website id into .env as UMAMI_WEBSITE_ID.
  6. 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.

Contact details

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.

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages