From 61517879e1e6639eb2966afc89239a0111272013 Mon Sep 17 00:00:00 2001 From: Lago Date: Fri, 17 Apr 2026 17:50:57 +0200 Subject: [PATCH] feat: Add Supabase configuration and migrations for MC Cars application - Create Kong declarative configuration for routing and authentication. - Implement initialization script to set up the database. - Add SQL migration for initializing roles, schemas, and seeding vehicle data. - Create leads and customers tables with appropriate policies and functions for CRM. - Seed admin user and configure storage bucket with RLS policies. --- .env | 51 +++ .gitignore | 13 + AGENT.md | 131 ++++++ ARQUITECTURE.md | 259 ++++++++++++ README.md | 114 +++++ data/.gitkeep | 1 + docker-compose.yml | 328 +++++++++++++++ frontend/Dockerfile | 16 + frontend/admin.html | 220 ++++++++++ frontend/admin.js | 493 ++++++++++++++++++++++ frontend/app.js | 246 +++++++++++ frontend/config.js | 8 + frontend/datenschutz.html | 19 + frontend/i18n.js | 194 +++++++++ frontend/impressum.html | 22 + frontend/index.html | 236 +++++++++++ frontend/nginx.conf | 22 + frontend/styles.css | 644 +++++++++++++++++++++++++++++ supabase/kong.yml | 122 ++++++ supabase/migrations/00-run-init.sh | 3 + supabase/migrations/01-init.sql | 198 +++++++++ supabase/migrations/02-leads.sql | 252 +++++++++++ supabase/migrations/post-boot.sql | 81 ++++ 23 files changed, 3673 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 AGENT.md create mode 100644 ARQUITECTURE.md create mode 100644 README.md create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/admin.html create mode 100644 frontend/admin.js create mode 100644 frontend/app.js create mode 100644 frontend/config.js create mode 100644 frontend/datenschutz.html create mode 100644 frontend/i18n.js create mode 100644 frontend/impressum.html create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/styles.css create mode 100644 supabase/kong.yml create mode 100644 supabase/migrations/00-run-init.sh create mode 100644 supabase/migrations/01-init.sql create mode 100644 supabase/migrations/02-leads.sql create mode 100644 supabase/migrations/post-boot.sql diff --git a/.env b/.env new file mode 100644 index 0000000..0242f89 --- /dev/null +++ b/.env @@ -0,0 +1,51 @@ +############################################################ +# MC Cars - self-hosted Supabase + web +# Dev defaults. ROTATE EVERY SECRET before any non-local deploy. +############################################################ + +# ---- Postgres ---- +POSTGRES_HOST=db +POSTGRES_DB=postgres +POSTGRES_PORT=5432 +POSTGRES_PASSWORD=mc-cars-local-postgres-password + +# ---- JWT ---- +# Well-known demo values from Supabase self-hosting guide (local-only). +# Replace with values generated from JWT_SECRET on any real deployment. +JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long +JWT_EXPIRY=3600 +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + +# ---- Studio basic-auth (behind Kong) ---- +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=mc-cars-studio + +# ---- Public URLs (as seen from the browser) ---- +SITE_URL=http://localhost:8080 +API_EXTERNAL_URL=http://localhost:54321 +SUPABASE_PUBLIC_URL=http://localhost:54321 + +# ---- GoTrue (Auth) ---- +GOTRUE_SITE_URL=http://localhost:8080 +GOTRUE_URI_ALLOW_LIST=http://localhost:8080,http://localhost:8080/admin.html +DISABLE_SIGNUP=true +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=true +ENABLE_ANONYMOUS_USERS=false + +# ---- SMTP (dummy; real values needed only to send password-reset mail) ---- +SMTP_HOST=localhost +SMTP_PORT=2500 +SMTP_USER=fake +SMTP_PASS=fake + +# ---- Admin BOOTSTRAP credentials (seeded on first DB init) ---- +# The user is flagged must_change_password=true. The REAL working password +# is set by the admin via the UI on first login and never equals this seed. +ADMIN_EMAIL=admin@mccars.local +ADMIN_PASSWORD=mc-cars-admin + +# ---- Storage ---- +STORAGE_BACKEND=file +FILE_SIZE_LIMIT=52428800 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27a223e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Environment +.env.local +*.local + +# Bind-mounted runtime state (keep folder, ignore contents) +/data/db/ +/data/storage/ + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..b31594d --- /dev/null +++ b/AGENT.md @@ -0,0 +1,131 @@ +# AGENT.md – Operational notes for working on MC Cars + +A compact field guide for anyone (human or AI) making changes to this stack. Skim this first. + +--- + +## 1. The portability contract (don't break it) + +1. **No named volumes.** All runtime state bind-mounts to `./data/`. `docker compose down -v` must never be required to migrate: copy the folder, `docker compose up -d`, done. +2. **No absolute paths** in `docker-compose.yml` or any config. +3. **Secrets live in `.env`**; the `.env` ships with dev defaults and is safe to commit until real deploy. Any non-local deployment must rotate every key (see §6). +4. Anything that resets the DB in dev is `Remove-Item -Recurse -Force .\data` — not `docker volume rm`. + +--- + +## 2. Migration lifecycle + +Migrations come in two flavours: + +### First-boot only (runs iff `./data/db` is empty) +- `supabase/migrations/00-run-init.sh` — wrapper that runs `01-init.sql` via `psql` so we can pre-create Supabase service roles (`anon`, `authenticated`, `service_role`, `authenticator`, `supabase_auth_admin`, `supabase_storage_admin`) that the official images otherwise assume already exist on their `supabase/postgres` image. We use plain `postgres:15-alpine`, so we create them ourselves. +- `supabase/migrations/01-init.sql` — vehicles table, RLS, bucket row, seed demo vehicles. + +Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on an **empty** data dir. To re-run: wipe `./data/db`. + +### Post-boot (every time, idempotent) +- `supabase/migrations/post-boot.sql` — seeds the admin `auth.users` row with `must_change_password=true`, creates the `vehicle-photos` bucket row if missing. Parameterized: `psql -v admin_email=... -v admin_password=...`. +- `supabase/migrations/02-leads.sql` — leads + customers + RPCs + `supabase_realtime` publication. Uses `create if not exists`, `create or replace`, and `do $$ ... $$` with exists-checks so every object is idempotent. + +Both are run by the `post-init` service, which gates on `auth.users` and `storage.buckets` being queryable before touching anything. + +**Adding a new migration:** +- If it's schema the app can't start without on a fresh DB → extend `01-init.sql`. +- If it's ongoing / idempotent → new file `03-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint. + +--- + +## 3. Roles, RLS, and the mental model + +| Role | How obtained | Can do | +| ----------------- | ---------------------------------- | ------------------------------------ | +| `anon` | Anon JWT (shipped to browser) | `select` on active `vehicles`; `insert` on `leads` only | +| `authenticated` | JWT from `signInWithPassword` | Full CRUD on vehicles/leads/customers, `execute` RPCs | +| `service_role` | Service JWT (never in browser) | Bypasses RLS | + +- Anon **cannot** read leads. The booking form is fire-and-forget by design. An abuser submitting with a stolen anon key sees only HTTP 201 with no row data. +- RPCs are `SECURITY INVOKER`. They run as the caller, so `auth.uid()` returns the actual admin; RLS still applies. +- `qualify_lead` is idempotent — calling it twice returns the existing customer row instead of raising. + +--- + +## 4. Realtime wiring + +- Postgres command line forces `wal_level=logical`, `max_wal_senders=10`, `max_replication_slots=10`. Without `logical`, `supabase/realtime` crash-loops. +- `supabase/realtime:v2.30.23` connects as **`DB_USER=postgres`** (not `supabase_admin`). We're on the plain `postgres:15-alpine` image; the `supabase_admin` role does not exist. +- `SECRET_KEY_BASE` must be ≥ 64 chars; the hardcoded value in `docker-compose.yml` is a dev default — rotate it on real deploys. +- Replica identity `full` is set on leads/customers/vehicles so updates broadcast the complete row, not just the PK. +- Kong route `/realtime/v1/` → `http://realtime:4000/socket` (websocket upgrade headers handled by Kong's default config). + +--- + +## 5. Kong traps + +- On Windows/WSL2, Docker Desktop's `wslrelay` intercepts `127.0.0.1:8000`. We expose Kong on host **`54321`** (container `8000`). Don't move it back to 8000. +- Kong needs `KONG_PLUGINS: bundled,request-transformer,cors,key-auth,acl,basic-auth`. Omitting `acl` breaks Studio. +- `kong.yml` has one `consumer` per role with `acl` groups; plugins `key-auth` + `acl` gate which `apikey` (anon vs service_role) can reach which route. + +--- + +## 6. Password rotation & admin bootstrap + +The `.env` `ADMIN_PASSWORD` is a one-shot **bootstrap** seed. On first login: + +1. `admin.js` reads `user.user_metadata.must_change_password`. +2. If true, the UI shows the "Passwort setzen" screen and refuses to proceed. +3. Submit calls `supabase.auth.updateUser({ password, data: { must_change_password: false } })`. +4. The frontend also rejects `newPassword === loginPassword` before sending. + +Result: the live admin password differs from `.env` from the very first session. The `.env` value is useful only for a brand-new `./data/db`. + +Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`. + +--- + +## 7. Frontend conventions + +- **Only** the anon key leaves the server. `frontend/Dockerfile` writes `config.js` at container start from `$SUPABASE_URL` / `$SUPABASE_ANON_KEY`. The service_role key is not mounted into `web`. +- `frontend/app.js` (public) creates a Supabase client with `persistSession: false` — the public site never needs a session. +- `frontend/admin.js` uses `persistSession: true, storageKey: "mccars.auth"` and a single realtime channel `mccars-admin` that subscribes to leads/customers/vehicles. +- `app.js` writes `vehicle_id` (uuid) **and** denormalized `vehicle_label` ("BMW M3") into the lead at submit time, so the admin UI renders even if the vehicle is later deleted. + +--- + +## 8. Common failure modes + +| Symptom | Cause / Fix | +| ------------------------------------------------------- | ---------------------------------------------------------------- | +| `auth` container restart loop, "role supabase_auth_admin does not exist" | `00-run-init.sh` didn't run. Wipe `./data/db`, retry. | +| `realtime` container exits, "wal_level must be logical" | `db` `command:` missing `wal_level=logical`. | +| 502 on `/realtime/v1/` through Kong | Missing `acl` plugin in `KONG_PLUGINS`, or realtime not listed in `kong.yml` consumers. | +| Admin login works but Leads tab is empty | RLS: session is `anon` instead of `authenticated`. Check that `signInWithPassword` succeeded and token is attached to subsequent requests. | +| Booking form submits but no row appears | anon lacks `INSERT` grant on `public.leads`. Check `02-leads.sql` ran (see `post-init` logs). | +| `.sh` files reject with `\r: command not found` | CRLF line endings. `dos2unix` or re-save as LF. | +| Port 8000 "connection refused" on Windows | Docker Desktop wslrelay. Use `54321`. | + +--- + +## 9. How to verify a working stack + +```powershell +docker compose ps # all services up (post-init exited 0) +curl http://localhost:54321/rest/v1/vehicles?select=brand -H "apikey: $env:ANON" # 6 demo cars +``` + +In the browser: +1. Open http://localhost:8080, submit the booking form. +2. Open http://localhost:8080/admin.html, log in with `.env` bootstrap creds. +3. Rotate the password when prompted. +4. The lead you submitted appears in "Aktive Leads" — in real time. +5. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed. +6. Open Supabase Studio at http://localhost:3000 and confirm the `customers.lead_id` FK matches. + +--- + +## 10. Things explicitly NOT in this stack + +- No n8n. Email automation is out of scope here; do it downstream. +- No Google Analytics / Google Reviews widget. +- No "Anmelden/Registrieren" on the public site. Admin access is intentionally unlinked. +- No logflare / analytics. +- No edge-functions container — RPCs live in Postgres. diff --git a/ARQUITECTURE.md b/ARQUITECTURE.md new file mode 100644 index 0000000..c5bbf26 --- /dev/null +++ b/ARQUITECTURE.md @@ -0,0 +1,259 @@ +# ARQUITECTURE.md – MC Cars stack + +A thorough walkthrough of how the MC Cars system is wired. Complements [AGENT.md](AGENT.md) (operational notes) and [README.md](README.md) (how to run). + +--- + +## 1. High-level diagram + +``` + Browser (localhost) + │ │ + :8080 (nginx) :54321 (Kong) :3000 (Studio) + │ │ │ + ┌──────┴──────┐ │ │ + │ web │ │ │ + │ (static) │ │ │ + └─────────────┘ │ │ + │ │ + ┌─────────────────────────┼─────────────────────────┤ + │ │ │ │ │ + /auth/v1 /rest/v1 /realtime/v1 /storage/v1 /pg/ + │ │ │ │ │ + ┌────┴───┐ ┌─────┴────┐ ┌────┴────┐ ┌─────┴────┐ ┌────┴───┐ + │ gotrue │ │ postgrest│ │ realtime│ │ storage │ │ meta │ + └────┬───┘ └─────┬────┘ └────┬────┘ └─────┬────┘ └────┬───┘ + │ │ │ │ │ + └──────────┬─┴────────────┴─────────────┴───────────┘ + │ + ┌────┴─────┐ ┌───────────┐ + │ postgres │ │ imgproxy │ + │ (wal: │ │ (resize) │ + │ logical) │ └───────────┘ + └──────────┘ ▲ + │ │ + ./data/db ./data/storage +``` + +Dashed lifetime services (exit 0 once done): `post-init`. + +Network: single user-defined bridge `mccars`. No host networking, no ingress controller — everything is container-to-container DNS (`db`, `auth`, `kong`, …). + +--- + +## 2. Request flows + +### 2.1 Public visitor reads cars + +``` +browser → :8080/index.html (nginx, static) +browser → :54321/rest/v1/vehicles?select=*&is_active=eq.true + → Kong → rest (PostgREST) → postgres + → RLS: "vehicles_select_active" (for anon, is_active=true only) + ← JSON +``` + +No auth header except the anon `apikey`. Kong strips the key and forwards with the service's internal Auth context. + +### 2.2 Public visitor submits booking + +``` +browser → supabase.from('leads').insert({...}) + → :54321/rest/v1/leads (POST, apikey=anon) + → PostgREST → postgres + → RLS "leads_anon_insert": insert allowed, select denied + ← 201 (no body returned to anon) +``` + +The frontend never reads leads back. If the INSERT fails (validation, RLS), the UI shows a localized failure string. + +### 2.3 Admin login + +``` +browser(admin.html) → supabase.auth.signInWithPassword + → :54321/auth/v1/token?grant_type=password + → Kong → gotrue → postgres(auth.users) + ← access_token (JWT, aud=authenticated), refresh_token + ← user.raw_user_meta_data.must_change_password (true on bootstrap) + +if must_change_password: + user blocked in "Passwort setzen" screen + supabase.auth.updateUser({password, data:{must_change_password:false}}) + → :54321/auth/v1/user (PUT with Bearer access_token) + → gotrue updates auth.users +``` + +### 2.4 Admin qualifies a lead + +``` +admin.js → supabase.rpc('qualify_lead', {p_lead_id, p_notes}) + → PostgREST → postgres.public.qualify_lead(uuid,text) + (SECURITY INVOKER, role=authenticated, auth.uid()=admin) + BEGIN + SELECT ... FOR UPDATE on leads + UPDATE leads SET status='qualified', is_active=false, ... + INSERT INTO customers (lead_id, name, email, phone, ...) + COMMIT + ← customers row + +Realtime publication fires: + postgres_changes(table=leads, event=UPDATE, ...) → admin.js channel + postgres_changes(table=customers, event=INSERT, ...) → admin.js channel + → admin UI reloads tabs, badges update +``` + +### 2.5 Photo upload + +``` +admin.js → supabase.storage.from('vehicle-photos').upload(path, file) + → :54321/storage/v1/object/vehicle-photos/ + → Kong → storage-api → ./data/storage + ← public URL served via imgproxy for on-the-fly transforms +admin.js → UPDATE vehicles SET photo_url = +``` + +Bucket is public-read, write-authenticated, MIME-restricted to `image/*`, 50 MB cap. + +--- + +## 3. Database layout + +``` +postgres (database = "postgres") +├── auth schema (GoTrue) +├── storage schema (Storage API) +├── _realtime schema (Realtime bookkeeping) +└── public schema + ├── vehicles (fleet) + ├── leads (form submissions) + ├── customers (spawned from qualified leads) + ├── tg_touch_updated_at() trigger fn + ├── qualify_lead() RPC + ├── disqualify_lead() RPC + └── reopen_lead() RPC +``` + +### 3.1 `public.vehicles` +- `id uuid pk` +- `brand`, `model`, `power_hp`, `top_speed_kmh`, `acceleration`, `seats`, `daily_price_eur` +- `sort_order`, `location` +- `description_de`, `description_en` +- `photo_url`, `photo_path` (storage object key for deletion) +- `is_active bool` — public visibility flag +- `created_at`, `updated_at` + +### 3.2 `public.leads` +- `id uuid pk` +- `name`, `email`, `phone` +- `vehicle_id → vehicles(id) ON DELETE SET NULL` +- `vehicle_label text` — denormalized at submit; survives vehicle deletion +- `date_from`, `date_to`, `message` +- `status check in ('new','qualified','disqualified')` default `new` +- `is_active bool` default `true` — filter for Active/Inactive tabs +- `admin_notes text` +- `source text` default `'website'` +- `qualified_at`, `qualified_by → auth.users(id)` + +### 3.3 `public.customers` +- `id uuid pk` +- `lead_id → leads(id) ON DELETE RESTRICT` + `unique index` (exactly one customer per lead) +- `name`, `email`, `phone` +- `first_contacted_at` +- `notes` +- `status check in ('active','inactive')` +- `created_by → auth.users(id)` + +### 3.4 RLS matrix + +| Table | anon | authenticated | +| ---------- | -------- | -------------------- | +| vehicles | SELECT where `is_active=true` | full CRUD | +| leads | INSERT only | full CRUD | +| customers | denied | full CRUD | + +### 3.5 RPCs + +| Function | Role required | Semantics | +| --------------------------------- | --------------- | ------------------------------------------------------------------------- | +| `qualify_lead(uuid, text)` | authenticated | Locks lead row, flips to `qualified`+inactive, inserts matching customer. Idempotent: returns existing customer on second call. | +| `disqualify_lead(uuid, text)` | authenticated | Marks lead `disqualified`+inactive, stores notes. | +| `reopen_lead(uuid)` | authenticated | Resets lead to `new`+active, deletes the spawned customer if any. | + +All three are `SECURITY INVOKER`, so `auth.uid()` is the live admin and RLS applies. + +--- + +## 4. Realtime + +- Postgres runs with `wal_level=logical`, `max_wal_senders=10`, `max_replication_slots=10`. +- `supabase/realtime:v2.30.23` on internal `:4000`, DB_USER=`postgres` (no `supabase_admin` on plain postgres). +- Publication `supabase_realtime` dynamically adds `leads`, `customers`, `vehicles`. +- `replica identity full` on all three — delivers the full row on updates (needed so the admin UI can show changed fields without re-fetching). +- Frontend joins a single channel (`mccars-admin`) and attaches three `postgres_changes` handlers. + +--- + +## 5. API gateway (Kong) + +Declarative config in `supabase/kong.yml`. One consumer per role: + +| Consumer | `apikey` | ACL groups | Routes accessible | +| ------------- | ------------------ | ---------- | ---------------------------------------------------------- | +| anon | `ANON_KEY` | `anon` | `/auth/v1/*`, `/rest/v1/*` (rls-gated), `/realtime/v1/*`, `/storage/v1/object/public/*` | +| service_role | `SERVICE_ROLE_KEY` | `admin` | all of the above + `/pg/*` (postgres-meta), write paths | + +Plugins pipeline: `cors` → `key-auth` → `acl` → proxy. Public routes (storage public objects) are whitelisted without `key-auth`. + +Host port mapping: `54321:8000` (8000 blocked by Docker Desktop's wslrelay on Windows). + +--- + +## 6. Frontend architecture + +``` +frontend/ +├── Dockerfile (nginx:alpine, writes config.js at boot) +├── nginx.conf (serves /, gzip, cache headers) +├── index.html app.js (public site, anon key, persistSession:false) +├── admin.html admin.js (admin CRM, persistSession:true) +├── i18n.js (DE/EN) +├── styles.css (copper/charcoal palette) +├── impressum.html +└── datenschutz.html +``` + +- `config.js` is generated at container start (`envsubst`) from `SUPABASE_URL` and `SUPABASE_ANON_KEY` only. The service_role key is never mounted into the web container. +- `admin.js` is ES modules, imports `@supabase/supabase-js` from `esm.sh` (CDN, pinned version). One Supabase client per page. +- State lives in a single `state` object. Admin re-renders the active tab on realtime events. +- Force-password-change modal is the only state that can preempt the rest of the admin UI after login. + +--- + +## 7. Portability & deployment + +**The contract:** everything under `c:\Coding\MC Cars GmbH\` (or whatever you rename it to) is the full stack. Copy it anywhere Docker runs, bring up the stack. + +What's state (under `./data/`): +- `./data/db/` — Postgres cluster +- `./data/storage/` — object bucket files + +What's code/config (committed): +- `docker-compose.yml`, `.env` (dev defaults), `supabase/`, `frontend/`, `AGENT.md`, `ARQUITECTURE.md`, `README.md` + +For real deployments: +1. Generate a new `JWT_SECRET` and matching `ANON_KEY` / `SERVICE_ROLE_KEY` (see Supabase self-hosting docs). +2. Set a strong `POSTGRES_PASSWORD`, `ADMIN_PASSWORD`. +3. Put Kong behind a TLS reverse-proxy (Caddy/Traefik/nginx) and set `SITE_URL`, `API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL` to the public https URLs. +4. Wire real SMTP for password-reset mail (`SMTP_*`). +5. Back up `./data/` on a schedule. + +--- + +## 8. Security model summary + +- **No admin keys in the browser, ever.** Only the anon key reaches the client. +- **Signup is disabled** (`GOTRUE_DISABLE_SIGNUP=true`). New admins are provisioned by inserting into `auth.users` via `post-boot.sql` or Studio. +- **Bootstrap password is forced out** on first login (`must_change_password` metadata). Frontend refuses to reuse the bootstrap value. +- **RLS is the single source of authorization.** The admin UI calling `.from('leads').select('*')` works only because the authenticated policy allows it; without a valid Bearer JWT the same query returns zero rows. +- **RPCs are `SECURITY INVOKER`** — no role-escalation surface. +- **Storage bucket** is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited. diff --git a/README.md b/README.md new file mode 100644 index 0000000..81f27d4 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# MC Cars – Dockerized Supabase CRM + +Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management admin panel. Everything lives under this folder. Copying the folder to another machine and running `docker compose up -d` reproduces the stack bit-for-bit — all runtime state is under `./data/` bind mounts, no named volumes. + +## What's inside + +| Service | Image | Purpose | +| ------------- | ------------------------------------ | ----------------------------------------- | +| `db` | `postgres:15-alpine` | Postgres with `wal_level=logical` | +| `auth` | `supabase/gotrue:v2.158.1` | Email+password auth (signup disabled) | +| `rest` | `postgrest/postgrest:v12.2.0` | Auto-generated REST API | +| `storage` | `supabase/storage-api:v1.11.13` | S3-like bucket API (backed by `./data/storage`) | +| `imgproxy` | `darthsim/imgproxy:v3.8.0` | On-the-fly image transforms | +| `realtime` | `supabase/realtime:v2.30.23` | Live `postgres_changes` subscriptions | +| `meta` | `supabase/postgres-meta:v0.84.2` | Schema introspection for Studio | +| `post-init` | `postgres:15-alpine` | Idempotent bootstrap: seed admin + migrations | +| `kong` | `kong:2.8.1` | Single API gateway at `:54321` | +| `studio` | `supabase/studio` | Supabase dashboard (`:3000`) | +| `web` | local `nginx:alpine` build | Public site + admin panel (`:8080`) | + +## Requirements + +- Docker Desktop / Docker Engine with Compose v2 +- Free ports: `3000`, `5432`, `8080`, `54321`, `54443` + +## Run + +```powershell +cd 'c:\Coding\MC Cars GmbH' +docker compose up -d +``` + +First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`). Give it 30–60 s to settle. + +### Stop / reset + +```powershell +docker compose down # stop, keep data +docker compose down -v # stop + delete named volumes (there are none anymore) +Remove-Item -Recurse -Force .\data # FULL wipe (needed to re-run first-boot migrations) +``` + +## URLs + +| Purpose | URL | +| ------------------------------- | -------------------------------- | +| Public website | http://localhost:8080 | +| Admin panel | http://localhost:8080/admin.html | +| Supabase Studio | http://localhost:3000 | +| API gateway (Kong) | http://localhost:54321 | +| Postgres | `localhost:5432` | + +> Admin access is deliberately **not** linked from the public site. Bookmark it. + +## Credentials (dev defaults – rotate before any deployment) + +| Account | Value | +| -------------------- | ---------------------------------------- | +| Admin bootstrap user | `admin@mccars.local` / `mc-cars-admin` | +| Postgres superuser | `postgres` / see `POSTGRES_PASSWORD` | + +The admin is seeded with `must_change_password = true` in `raw_user_meta_data`. On first login the UI **forces** a rotation and refuses to reuse the bootstrap password. The real working password never equals `.env`. + +## Data model + +- `public.vehicles` — fleet, public-readable where `is_active`. +- `public.leads` — booking form submissions. `anon` may `INSERT` only; `authenticated` has full CRUD. Status: `new | qualified | disqualified`. +- `public.customers` — created **only** by qualifying a lead. Hard FK `lead_id` preserves the audit link to the originating lead. +- RPCs: `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only. +- Realtime: `supabase_realtime` publication broadcasts inserts/updates on leads, customers, vehicles. + +## Portability + +Runtime state under `./data/`: + +``` +data/ +├── db/ # Postgres cluster (bind mount) +└── storage/ # vehicle-photos bucket content +``` + +Everything else (config, migrations, frontend) is in the repo. Zip the folder, scp it, `docker compose up -d` — you have the same stack. + +## Project layout + +``` +MC Cars/ +├── docker-compose.yml # full stack, bind-mount portability +├── .env # dev secrets / tunables +├── data/ # runtime state (git-ignored) +├── supabase/ +│ ├── kong.yml # gateway routes (/auth, /rest, /realtime, /storage, /pg) +│ └── migrations/ +│ ├── 00-run-init.sh # creates supabase service roles +│ ├── 01-init.sql # vehicles + bucket + seed cars +│ ├── post-boot.sql # admin user (must_change_password) + bucket row +│ └── 02-leads.sql # leads, customers, RPCs, realtime publication +├── frontend/ +│ ├── Dockerfile # nginx + runtime anon-key injection +│ ├── nginx.conf +│ ├── index.html # public DE/EN site, booking form -> leads +│ ├── admin.html # auth-gated CRM +│ ├── app.js +│ ├── admin.js # realtime + qualify/disqualify + password change +│ ├── config.js # anon-only runtime config +│ ├── i18n.js +│ ├── styles.css +│ ├── impressum.html +│ └── datenschutz.html +├── AGENT.md # findings, conventions, traps +└── ARQUITECTURE.md # architecture deep-dive +``` + +See [AGENT.md](AGENT.md) and [ARQUITECTURE.md](ARQUITECTURE.md) for everything deeper. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..f091b15 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ +# Bind-mounted service data lives here (db, storage, n8n). Keep tree, ignore contents. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ac3696 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,328 @@ +name: mc-cars + +############################################################ +# MC Cars - fully Dockerized self-hosted Supabase + web +# +# Portability contract: +# - ALL runtime state lives under ./data (bind mounts). +# - ALL config lives under this folder (supabase/, frontend/, .env). +# - Copying the "MC Cars" folder to another host and running +# docker compose up -d +# reproduces the stack bit-for-bit. No named volumes, no +# absolute paths, no external dependencies. +############################################################ + +networks: + mccars: + driver: bridge + +services: + # ------------------------------------------------------------------------- + # Postgres with logical replication enabled (needed by supabase/realtime). + # First-boot SQL creates the Supabase service roles. + # ------------------------------------------------------------------------- + db: + image: postgres:15-alpine + container_name: mccars-db + restart: unless-stopped + command: + - postgres + - -c + - wal_level=logical + - -c + - max_wal_senders=10 + - -c + - max_replication_slots=10 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"] + interval: 5s + timeout: 5s + retries: 30 + environment: + POSTGRES_USER: postgres + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./data/db:/var/lib/postgresql/data + - ./supabase/migrations/00-run-init.sh:/docker-entrypoint-initdb.d/00-run-init.sh:ro + - ./supabase/migrations/01-init.sql:/sql/01-init.sql:ro + ports: + - "5432:5432" + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + # ------------------------------------------------------------------------- + # GoTrue (Supabase Auth) + # ------------------------------------------------------------------------- + auth: + image: supabase/gotrue:v2.158.1 + container_name: mccars-auth + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?search_path=auth + DB_NAMESPACE: auth + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${GOTRUE_URI_ALLOW_LIST} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + GOTRUE_SMTP_ADMIN_EMAIL: ${ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: MC Cars + GOTRUE_PASSWORD_MIN_LENGTH: 10 + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + # ------------------------------------------------------------------------- + # PostgREST (REST API generated from Postgres schema) + # ------------------------------------------------------------------------- + rest: + image: postgrest/postgrest:v12.2.0 + container_name: mccars-rest + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + PGRST_DB_SCHEMAS: public,storage + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + # ------------------------------------------------------------------------- + # Storage API + imgproxy + # ------------------------------------------------------------------------- + storage: + image: supabase/storage-api:v1.11.13 + container_name: mccars-storage + restart: unless-stopped + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} + STORAGE_BACKEND: ${STORAGE_BACKEND} + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - ./data/storage:/var/lib/storage + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + imgproxy: + image: darthsim/imgproxy:v3.8.0 + container_name: mccars-imgproxy + restart: unless-stopped + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + volumes: + - ./data/storage:/var/lib/storage + networks: [mccars] + + # ------------------------------------------------------------------------- + # Realtime (Phoenix/Elixir) - live subscriptions for leads/customers + # ------------------------------------------------------------------------- + realtime: + image: supabase/realtime:v2.30.23 + container_name: mccars-realtime + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + DB_HOST: db + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + FLY_ALLOC_ID: fly123 + FLY_APP_NAME: realtime + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + ERL_AFLAGS: -proto_dist inet_tcp + ENABLE_TAILSCALE: "false" + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + REALTIME_IP_VERSION: IPV4 + command: > + sh -c "/app/bin/migrate && /app/bin/server" + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + # ------------------------------------------------------------------------- + # Post-init: admin user + storage bucket + leads/customers migration. + # Exits 0 once done. Re-running is idempotent. + # ------------------------------------------------------------------------- + post-init: + image: postgres:15-alpine + container_name: mccars-postinit + depends_on: + auth: + condition: service_started + storage: + condition: service_started + environment: + PGPASSWORD: ${POSTGRES_PASSWORD} + ADMIN_EMAIL: ${ADMIN_EMAIL} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + volumes: + - ./supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro + - ./supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro + entrypoint: ["sh","-c"] + command: + - | + set -e + echo "Waiting for auth.users and storage.buckets..." + for i in $$(seq 1 60); do + if psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -tAc "select 1 from auth.users limit 1" >/dev/null 2>&1 \ + && psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -tAc "select 1 from storage.buckets limit 1" >/dev/null 2>&1; then + echo "Schemas ready." + break + fi + sleep 2 + done + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 \ + -v admin_email="$$ADMIN_EMAIL" \ + -v admin_password="$$ADMIN_PASSWORD" \ + -f /sql/post-boot.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/02-leads.sql + echo "post-init done." + restart: "no" + networks: [mccars] + + # ------------------------------------------------------------------------- + # postgres-meta (Studio uses this for schema introspection) + # ------------------------------------------------------------------------- + meta: + image: supabase/postgres-meta:v0.84.2 + container_name: mccars-meta + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: postgres + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + networks: [mccars] + + # ------------------------------------------------------------------------- + # Kong - single API gateway for the browser + # ------------------------------------------------------------------------- + kong: + image: kong:2.8.1 + container_name: mccars-kong + restart: unless-stopped + depends_on: + - auth + - rest + - storage + - meta + - realtime + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: bundled,request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + volumes: + - ./supabase/kong.yml:/home/kong/kong.yml:ro + ports: + - "54321:8000/tcp" + - "54443:8443/tcp" + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } + + # ------------------------------------------------------------------------- + # Supabase Studio + # ------------------------------------------------------------------------- + studio: + image: supabase/studio:20241202-71e5240 + container_name: mccars-studio + restart: unless-stopped + depends_on: + meta: + condition: service_started + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + DEFAULT_ORGANIZATION_NAME: MC Cars + DEFAULT_PROJECT_NAME: mc-cars + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + + LOGFLARE_API_KEY: stub + LOGFLARE_URL: http://localhost:4000 + NEXT_PUBLIC_ENABLE_LOGS: "false" + ports: + - "3000:3000" + networks: [mccars] + + # ------------------------------------------------------------------------- + # Public website (nginx + static assets). Anon key injected at boot. + # ------------------------------------------------------------------------- + web: + build: + context: ./frontend + container_name: mccars-web + restart: unless-stopped + depends_on: + - kong + environment: + SUPABASE_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + ports: + - "8080:80" + networks: [mccars] + logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } } diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..26bc680 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM nginx:1.27-alpine + +# Copy static assets +COPY . /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Generate runtime config.js so the frontend picks up env vars at container start +# (anon key only — safe for the browser). +RUN rm -f /usr/share/nginx/html/Dockerfile /usr/share/nginx/html/nginx.conf + +RUN printf '#!/bin/sh\nset -eu\ncat > /usr/share/nginx/html/config.js < /docker-entrypoint.d/99-config.sh \ + && chmod +x /docker-entrypoint.d/99-config.sh + +EXPOSE 80 diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..4170b31 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,220 @@ + + + + + + Admin · MC Cars + + + + + + + + + + + + + + + + +
+

Lead

+ +
+
+
+ + + + diff --git a/frontend/admin.js b/frontend/admin.js new file mode 100644 index 0000000..a5c24cd --- /dev/null +++ b/frontend/admin.js @@ -0,0 +1,493 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; + +const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL || "http://localhost:54321"; +const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || ""; + +// Only the public anon key lives here. Write access is gated by RLS policies +// that require an `authenticated` JWT obtained via signInWithPassword. +const supabase = createClient(SUPA_URL, SUPA_KEY, { + auth: { persistSession: true, storageKey: "mccars.auth" }, +}); + +// ----- DOM ----- +const loginView = document.querySelector("#loginView"); +const adminView = document.querySelector("#adminView"); +const rotateView = document.querySelector("#rotateView"); +const loginForm = document.querySelector("#loginForm"); +const loginError = document.querySelector("#loginError"); +const rotateForm = document.querySelector("#rotateForm"); +const rotateError = document.querySelector("#rotateError"); +const logoutBtn = document.querySelector("#logoutBtn"); +const changePwBtn = document.querySelector("#changePwBtn"); +const adminWho = document.querySelector("#adminWho"); + +const leadsTableBody = document.querySelector("#leadsTable tbody"); +const leadsEmpty = document.querySelector("#leadsEmpty"); +const leadsBadge = document.querySelector("#leadsBadge"); +const customersTableBody = document.querySelector("#customersTable tbody"); +const customersEmpty = document.querySelector("#customersEmpty"); +const customersBadge = document.querySelector("#customersBadge"); +const leadDialog = document.querySelector("#leadDialog"); +const leadDialogTitle = document.querySelector("#leadDialogTitle"); +const leadDialogBody = document.querySelector("#leadDialogBody"); +const leadDialogClose = document.querySelector("#leadDialogClose"); + +const vehicleForm = document.querySelector("#vehicleForm"); +const formFeedback = document.querySelector("#formFeedback"); +const formTitle = document.querySelector("#formTitle"); +const saveBtn = document.querySelector("#saveBtn"); +const resetBtn = document.querySelector("#resetBtn"); +const photoInput = document.querySelector("#photoInput"); +const photoPreview = document.querySelector("#photoPreview"); +const tableBody = document.querySelector("#adminTable tbody"); + +// ----- State ----- +const state = { + user: null, + loginPassword: null, // captured at signInWithPassword to block same-value rotation + leadView: "active", // "active" | "inactive" + leads: [], + customers: [], + vehicles: [], + vehicleMap: new Map(), + currentPhotoPath: null, + realtimeChannel: null, + forcedRotation: false, +}; + +// ========================================================================= +// AUTH FLOW +// ========================================================================= +async function bootstrap() { + const { data } = await supabase.auth.getSession(); + if (data?.session) { + await onAuthenticated(data.session.user); + } else { + show("login"); + } +} + +loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + loginError.textContent = ""; + const fd = new FormData(loginForm); + const pw = fd.get("password"); + const { data, error } = await supabase.auth.signInWithPassword({ + email: fd.get("email"), + password: pw, + }); + if (error) { loginError.textContent = error.message; return; } + state.loginPassword = pw; + await onAuthenticated(data.user); +}); + +logoutBtn.addEventListener("click", async () => { + if (state.realtimeChannel) await supabase.removeChannel(state.realtimeChannel); + await supabase.auth.signOut(); + location.reload(); +}); + +changePwBtn.addEventListener("click", () => { + state.forcedRotation = false; + show("rotate"); +}); + +rotateForm.addEventListener("submit", async (e) => { + e.preventDefault(); + rotateError.textContent = ""; + const fd = new FormData(rotateForm); + const pw1 = fd.get("pw1"); + const pw2 = fd.get("pw2"); + if (pw1 !== pw2) { rotateError.textContent = "Passwoerter stimmen nicht ueberein."; return; } + if (pw1.length < 10) { rotateError.textContent = "Mindestens 10 Zeichen."; return; } + if (state.loginPassword && pw1 === state.loginPassword) { + rotateError.textContent = "Neues Passwort muss sich vom alten unterscheiden."; + return; + } + + const { error } = await supabase.auth.updateUser({ + password: pw1, + data: { must_change_password: false }, + }); + if (error) { rotateError.textContent = error.message; return; } + + state.loginPassword = pw1; + rotateForm.reset(); + + if (state.forcedRotation) { + state.forcedRotation = false; + await enterAdmin(); + } else { + show("admin"); + } +}); + +async function onAuthenticated(user) { + state.user = user; + adminWho.textContent = user.email; + + // Force rotation path (first-login bootstrap) + const meta = user.user_metadata || {}; + if (meta.must_change_password) { + state.forcedRotation = true; + show("rotate"); + return; + } + await enterAdmin(); +} + +async function enterAdmin() { + show("admin"); + await Promise.all([loadVehicles(), loadLeads(), loadCustomers()]); + renderActiveTab(); + attachRealtime(); +} + +function show(which) { + loginView.style.display = which === "login" ? "block" : "none"; + rotateView.style.display = which === "rotate" ? "block" : "none"; + adminView.style.display = which === "admin" ? "block" : "none"; +} + +// ========================================================================= +// TABS +// ========================================================================= +const tabButtons = document.querySelectorAll(".admin-tabs .tab"); +const tabPanels = { + leads: document.querySelector("#tab-leads"), + customers: document.querySelector("#tab-customers"), + vehicles: document.querySelector("#tab-vehicles"), +}; +let activeTab = "leads"; +tabButtons.forEach(btn => btn.addEventListener("click", () => { + activeTab = btn.dataset.tab; + tabButtons.forEach(b => b.classList.toggle("active", b === btn)); + for (const [k, el] of Object.entries(tabPanels)) el.style.display = (k === activeTab) ? "block" : "none"; + renderActiveTab(); +})); +function renderActiveTab() { + if (activeTab === "leads") renderLeads(); + if (activeTab === "customers") renderCustomers(); + if (activeTab === "vehicles") renderVehicles(); +} + +// Sub-tabs (active/inactive leads) +document.querySelectorAll(".sub-tab").forEach(btn => btn.addEventListener("click", () => { + state.leadView = btn.dataset.lview; + document.querySelectorAll(".sub-tab").forEach(b => b.classList.toggle("active", b === btn)); + renderLeads(); +})); + +// ========================================================================= +// VEHICLES (existing CRUD preserved) +// ========================================================================= +async function loadVehicles() { + const { data, error } = await supabase + .from("vehicles") + .select("*") + .order("sort_order", { ascending: true }); + if (error) { console.error(error); return; } + state.vehicles = data || []; + state.vehicleMap = new Map(state.vehicles.map(v => [v.id, v])); +} + +function renderVehicles() { + tableBody.innerHTML = ""; + for (const v of state.vehicles) { + const tr = document.createElement("tr"); + tr.innerHTML = ` +
+ ${esc(v.brand)}
${esc(v.model)} + € ${v.daily_price_eur} + ${v.is_active ? "✅" : "—"} + + + + `; + tableBody.appendChild(tr); + } + tableBody.querySelectorAll("[data-edit]").forEach(b => b.addEventListener("click", () => loadForEdit(b.dataset.edit))); + tableBody.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteVehicle(b.dataset.del))); +} + +function loadForEdit(id) { + const v = state.vehicleMap.get(id); + if (!v) return; + formTitle.textContent = `Fahrzeug bearbeiten · ${v.brand} ${v.model}`; + vehicleForm.id.value = v.id; + vehicleForm.brand.value = v.brand; + vehicleForm.model.value = v.model; + vehicleForm.power_hp.value = v.power_hp; + vehicleForm.top_speed_kmh.value = v.top_speed_kmh; + vehicleForm.acceleration.value = v.acceleration; + vehicleForm.seats.value = v.seats; + vehicleForm.daily_price_eur.value = v.daily_price_eur; + vehicleForm.sort_order.value = v.sort_order; + vehicleForm.location.value = v.location; + vehicleForm.description_de.value = v.description_de; + vehicleForm.description_en.value = v.description_en; + vehicleForm.photo_url.value = v.photo_url; + vehicleForm.is_active.checked = v.is_active; + state.currentPhotoPath = v.photo_path || null; + updatePreview(v.photo_url); + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +resetBtn.addEventListener("click", () => { + vehicleForm.reset(); + vehicleForm.id.value = ""; + vehicleForm.is_active.checked = true; + vehicleForm.sort_order.value = 100; + vehicleForm.location.value = "Steiermark (TBD)"; + vehicleForm.seats.value = 2; + state.currentPhotoPath = null; + updatePreview(""); + formTitle.textContent = "Neues Fahrzeug"; + formFeedback.textContent = ""; +}); + +vehicleForm.addEventListener("submit", async (e) => { + e.preventDefault(); + saveBtn.disabled = true; + formFeedback.className = "form-feedback"; + formFeedback.textContent = "Saving..."; + try { + const fd = new FormData(vehicleForm); + const payload = { + brand: fd.get("brand"), + model: fd.get("model"), + power_hp: +fd.get("power_hp") || 0, + top_speed_kmh: +fd.get("top_speed_kmh") || 0, + acceleration: fd.get("acceleration") || "", + seats: +fd.get("seats") || 2, + daily_price_eur: +fd.get("daily_price_eur") || 0, + sort_order: +fd.get("sort_order") || 100, + location: fd.get("location") || "Steiermark (TBD)", + description_de: fd.get("description_de") || "", + description_en: fd.get("description_en") || "", + photo_url: fd.get("photo_url") || "", + photo_path: state.currentPhotoPath, + is_active: !!fd.get("is_active"), + }; + const id = fd.get("id"); + const { error } = id + ? await supabase.from("vehicles").update(payload).eq("id", id) + : await supabase.from("vehicles").insert(payload); + if (error) throw error; + formFeedback.textContent = "Gespeichert."; + await loadVehicles(); + renderVehicles(); + if (!id) resetBtn.click(); + } catch (err) { + formFeedback.className = "form-feedback error"; + formFeedback.textContent = err.message || String(err); + } finally { + saveBtn.disabled = false; + } +}); + +async function deleteVehicle(id) { + const v = state.vehicleMap.get(id); + if (!v) return; + if (!confirm(`Delete ${v.brand} ${v.model}?`)) return; + if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]); + const { error } = await supabase.from("vehicles").delete().eq("id", id); + if (error) { alert(error.message); return; } + await loadVehicles(); + renderVehicles(); +} + +// Photo upload +photoInput.addEventListener("change", async () => { + const file = photoInput.files?.[0]; + if (!file) return; + formFeedback.className = "form-feedback"; + formFeedback.textContent = "Uploading photo..."; + try { + const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); + const path = `${crypto.randomUUID()}.${ext}`; + const { error: upErr } = await supabase.storage + .from("vehicle-photos") + .upload(path, file, { contentType: file.type, upsert: false }); + if (upErr) throw upErr; + const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path); + state.currentPhotoPath = path; + vehicleForm.photo_url.value = pub.publicUrl; + updatePreview(pub.publicUrl); + formFeedback.textContent = "Upload ok."; + } catch (err) { + formFeedback.className = "form-feedback error"; + formFeedback.textContent = err.message || String(err); + } +}); +function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; } + +// ========================================================================= +// LEADS +// ========================================================================= +async function loadLeads() { + const { data, error } = await supabase + .from("leads") + .select("*") + .order("created_at", { ascending: false }); + if (error) { console.error(error); return; } + state.leads = data || []; + updateBadges(); +} + +function renderLeads() { + const wantActive = state.leadView === "active"; + const rows = state.leads.filter(l => l.is_active === wantActive); + leadsEmpty.style.display = rows.length ? "none" : "block"; + leadsTableBody.innerHTML = ""; + for (const l of rows) { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${fmtDate(l.created_at)} + ${esc(l.name)}
${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""} + ${esc(l.vehicle_label || "—")} + ${esc(l.date_from || "—")} → ${esc(l.date_to || "—")} + ${esc(l.status)} + + + ${wantActive ? ` + + + ` : ` + + `} + `; + leadsTableBody.appendChild(tr); + } + leadsTableBody.querySelectorAll("[data-open]").forEach(b => b.addEventListener("click", () => openLead(b.dataset.open))); + leadsTableBody.querySelectorAll("[data-qual]").forEach(b => b.addEventListener("click", () => qualifyLead(b.dataset.qual))); + leadsTableBody.querySelectorAll("[data-disq]").forEach(b => b.addEventListener("click", () => disqualifyLead(b.dataset.disq))); + leadsTableBody.querySelectorAll("[data-reopen]").forEach(b => b.addEventListener("click", () => reopenLead(b.dataset.reopen))); +} + +function openLead(id) { + const l = state.leads.find(x => x.id === id); + if (!l) return; + leadDialogTitle.textContent = `${l.name} · ${l.status}`; + leadDialogBody.innerHTML = ` +
+
Eingang
${fmtDate(l.created_at)}
+
E-Mail
${esc(l.email)}
+
Telefon
${esc(l.phone || "—")}
+
Fahrzeug
${esc(l.vehicle_label || "—")}
+
Zeitraum
${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}
+
Nachricht
${esc(l.message || "—")}
+
Status
${esc(l.status)}
+
Notiz
+
+
+ ${l.is_active ? ` + + + ` : ``} +
`; + leadDialog.showModal(); + const note = () => document.querySelector("#leadNote").value; + document.querySelector("#dlgQual")?.addEventListener("click", () => qualifyLead(l.id, note())); + document.querySelector("#dlgDisq")?.addEventListener("click", () => disqualifyLead(l.id, note())); + document.querySelector("#dlgReopen")?.addEventListener("click", () => reopenLead(l.id)); +} +leadDialogClose.addEventListener("click", () => leadDialog.close()); + +async function qualifyLead(id, notes = "") { + const { error } = await supabase.rpc("qualify_lead", { p_lead_id: id, p_notes: notes }); + if (error) { alert(error.message); return; } + leadDialog.open && leadDialog.close(); + // Realtime will refresh; still trigger a quick reload for responsiveness. + await Promise.all([loadLeads(), loadCustomers()]); + renderActiveTab(); +} +async function disqualifyLead(id, notes = "") { + const { error } = await supabase.rpc("disqualify_lead", { p_lead_id: id, p_notes: notes }); + if (error) { alert(error.message); return; } + leadDialog.open && leadDialog.close(); + await loadLeads(); + renderLeads(); + updateBadges(); +} +async function reopenLead(id) { + if (!confirm("Lead wieder in 'Aktive' verschieben und Kunde ggf. entfernen?")) return; + const { error } = await supabase.rpc("reopen_lead", { p_lead_id: id }); + if (error) { alert(error.message); return; } + leadDialog.open && leadDialog.close(); + await Promise.all([loadLeads(), loadCustomers()]); + renderActiveTab(); +} + +// ========================================================================= +// CUSTOMERS +// ========================================================================= +async function loadCustomers() { + const { data, error } = await supabase + .from("customers") + .select("*") + .order("created_at", { ascending: false }); + if (error) { console.error(error); return; } + state.customers = data || []; + updateBadges(); +} + +function renderCustomers() { + customersEmpty.style.display = state.customers.length ? "none" : "block"; + customersTableBody.innerHTML = ""; + for (const c of state.customers) { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${fmtDate(c.first_contacted_at)} + ${esc(c.name)}
${esc(c.email)} + ${esc(c.phone || "—")} + ${esc(c.lead_id?.slice(0, 8) || "—")} + ${esc(c.status)} + + + `; + customersTableBody.appendChild(tr); + } + customersTableBody.querySelectorAll("[data-toggle]").forEach(b => b.addEventListener("click", async () => { + const id = b.dataset.toggle; + const next = b.dataset.status === "active" ? "inactive" : "active"; + const { error } = await supabase.from("customers").update({ status: next }).eq("id", id); + if (error) { alert(error.message); return; } + await loadCustomers(); + renderCustomers(); + })); +} + +function updateBadges() { + const active = state.leads.filter(l => l.is_active).length; + leadsBadge.textContent = String(active); + customersBadge.textContent = String(state.customers.length); +} + +// ========================================================================= +// REALTIME +// ========================================================================= +function attachRealtime() { + if (state.realtimeChannel) return; + state.realtimeChannel = supabase + .channel("mccars-admin") + .on("postgres_changes", { event: "*", schema: "public", table: "leads" }, async () => { await loadLeads(); if (activeTab === "leads") renderLeads(); }) + .on("postgres_changes", { event: "*", schema: "public", table: "customers" }, async () => { await loadCustomers(); if (activeTab === "customers") renderCustomers(); }) + .on("postgres_changes", { event: "*", schema: "public", table: "vehicles" }, async () => { await loadVehicles(); if (activeTab === "vehicles") renderVehicles(); }) + .subscribe(); +} + +// ========================================================================= +// HELPERS +// ========================================================================= +function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); } +function attr(s) { return esc(s); } +function fmtDate(iso) { + if (!iso) return "—"; + const d = new Date(iso); + return d.toLocaleString("de-AT", { dateStyle: "short", timeStyle: "short" }); +} + +bootstrap(); diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..e6dd670 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,246 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js"; + +const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL || "http://localhost:54321"; +const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || ""; + +export const supabase = createClient(SUPA_URL, SUPA_KEY, { + auth: { persistSession: false, storageKey: "mccars.public" }, +}); + +// ---------------- State ---------------- +const state = { + vehicles: [], + filtered: [], + brand: "all", + sort: "sort_order", + maxPrice: null, + reviewIdx: 0, +}; + +// ---------------- Elements ---------------- +const grid = document.querySelector("#vehicleGrid"); +const emptyState = document.querySelector("#emptyState"); +const brandFilter = document.querySelector("#brandFilter"); +const sortFilter = document.querySelector("#sortFilter"); +const priceFilter = document.querySelector("#priceFilter"); +const bookingCar = document.querySelector("#bookingCar"); +const bookingForm = document.querySelector("#bookingForm"); +const bookingFeedback = document.querySelector("#bookingFeedback"); +const langToggle = document.querySelector(".lang-toggle"); +const menuToggle = document.querySelector(".menu-toggle"); +const mainNav = document.querySelector(".main-nav"); +const dialog = document.querySelector("#carDialog"); +const dialogTitle = document.querySelector("#dialogTitle"); +const dialogBody = document.querySelector("#dialogBody"); +const dialogClose = document.querySelector("#dialogClose"); +const reviewStrip = document.querySelector("#reviewStrip"); +const reviewDots = document.querySelector("#reviewDots"); +const statCarsCount = document.querySelector("#statCarsCount"); + +document.querySelector("#year").textContent = new Date().getFullYear(); + +// ---------------- Vehicles ---------------- +async function loadVehicles() { + const { data, error } = await supabase + .from("vehicles") + .select("*") + .eq("is_active", true) + .order("sort_order", { ascending: true }); + + if (error) { + console.error("Failed to load vehicles", error); + grid.innerHTML = `

Unable to load vehicles: ${error.message}

`; + return; + } + + state.vehicles = data || []; + statCarsCount.textContent = state.vehicles.length; + + const brands = [...new Set(state.vehicles.map(v => v.brand))].sort(); + brandFilter.innerHTML = `` + + brands.map(b => ``).join(""); + + bookingCar.innerHTML = state.vehicles + .map(v => ``) + .join(""); + + applyFilters(); +} + +function applyFilters() { + let rows = [...state.vehicles]; + if (state.brand !== "all") rows = rows.filter(v => v.brand === state.brand); + if (state.maxPrice) rows = rows.filter(v => v.daily_price_eur <= state.maxPrice); + + switch (state.sort) { + case "priceAsc": rows.sort((a, b) => a.daily_price_eur - b.daily_price_eur); break; + case "priceDesc": rows.sort((a, b) => b.daily_price_eur - a.daily_price_eur); break; + case "powerDesc": rows.sort((a, b) => b.power_hp - a.power_hp); break; + default: rows.sort((a, b) => a.sort_order - b.sort_order); + } + + state.filtered = rows; + renderGrid(); +} + +function renderGrid() { + grid.innerHTML = ""; + emptyState.style.display = state.filtered.length ? "none" : "block"; + + for (const v of state.filtered) { + const card = document.createElement("article"); + card.className = "vehicle-card"; + card.innerHTML = ` +
+ ${escapeHtml(v.brand)} +
+
+

${escapeHtml(v.brand)}

+

${escapeHtml(v.model)}

+
+
${v.power_hp}${t("hp")}
+
${v.top_speed_kmh}${t("kmh")}
+
${escapeHtml(v.acceleration)}${t("accel")}
+
+ +
+ `; + grid.appendChild(card); + } + + grid.querySelectorAll("[data-details]").forEach(b => { + b.addEventListener("click", () => openDetails(b.dataset.details)); + }); + grid.querySelectorAll("[data-book]").forEach(b => { + b.addEventListener("click", () => { + bookingCar.value = b.dataset.book; + document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" }); + }); + }); +} + +function openDetails(id) { + const v = state.vehicles.find(x => x.id === id); + if (!v) return; + const lang = getLang(); + const desc = lang === "en" ? v.description_en : v.description_de; + + dialogTitle.textContent = `${v.brand} ${v.model}`; + dialogBody.innerHTML = ` + ${escapeAttr(v.brand + ' ' + v.model)} +

${escapeHtml(desc || "")}

+
+
${v.power_hp}${t("hp")}
+
${v.top_speed_kmh}${t("kmh")}
+
${escapeHtml(v.acceleration)}${t("accel")}
+
+
+
€ ${v.daily_price_eur} / ${t("perDay")}
+ +
+ `; + dialog.showModal(); + document.querySelector("#dialogBook").addEventListener("click", () => { + dialog.close(); + bookingCar.value = v.id; + document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" }); + }); +} + +// ---------------- Reviews ---------------- +function renderReviews() { + const list = REVIEWS[getLang()]; + state.reviewIdx = state.reviewIdx % list.length; + const r = list[state.reviewIdx]; + reviewStrip.innerHTML = ` +

"${escapeHtml(r.quote)}"

+

${escapeHtml(r.author)}

+ `; + reviewDots.innerHTML = list.map((_, i) => + `` + ).join(""); + reviewDots.querySelectorAll("button").forEach(b => { + b.addEventListener("click", () => { state.reviewIdx = +b.dataset.rev; renderReviews(); }); + }); +} +setInterval(() => { state.reviewIdx++; renderReviews(); }, 6000); + +// ---------------- Booking -> LEADS ---------------- +bookingForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(bookingForm); + const data = Object.fromEntries(fd.entries()); + + if (!data.from || !data.to || new Date(data.to) <= new Date(data.from)) { + bookingFeedback.textContent = t("invalidDates"); + bookingFeedback.className = "form-feedback error"; + return; + } + + const vehicle = state.vehicles.find(v => v.id === data.vehicle); + const payload = { + name: data.name, + email: data.email, + phone: data.phone || "", + vehicle_id: data.vehicle || null, + vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "", + date_from: data.from || null, + date_to: data.to || null, + message: data.message || "", + source: "website", + }; + + bookingFeedback.className = "form-feedback"; + bookingFeedback.textContent = "..."; + + const { error } = await supabase.from("leads").insert(payload); + if (error) { + console.error(error); + bookingFeedback.className = "form-feedback error"; + bookingFeedback.textContent = t("bookingFailed"); + return; + } + + bookingFeedback.textContent = t("bookingSuccess"); + bookingForm.reset(); +}); + +// ---------------- Events ---------------- +brandFilter.addEventListener("change", e => { state.brand = e.target.value; applyFilters(); }); +sortFilter.addEventListener("change", e => { state.sort = e.target.value; applyFilters(); }); +priceFilter.addEventListener("input", e => { state.maxPrice = e.target.value ? +e.target.value : null; applyFilters(); }); + +dialogClose.addEventListener("click", () => dialog.close()); + +menuToggle.addEventListener("click", () => mainNav.classList.toggle("open")); +mainNav.addEventListener("click", e => { if (e.target.tagName === "A") mainNav.classList.remove("open"); }); + +langToggle.addEventListener("click", () => { + const next = getLang() === "de" ? "en" : "de"; + setLang(next); + langToggle.textContent = next === "de" ? "EN" : "DE"; + applyI18n(); + renderReviews(); + applyFilters(); +}); + +// ---------------- Helpers ---------------- +function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, c => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + })[c]); +} +function escapeAttr(s) { return escapeHtml(s); } + +// ---------------- Boot ---------------- +langToggle.textContent = getLang() === "de" ? "EN" : "DE"; +applyI18n(); +renderReviews(); +loadVehicles(); diff --git a/frontend/config.js b/frontend/config.js new file mode 100644 index 0000000..2b2b00d --- /dev/null +++ b/frontend/config.js @@ -0,0 +1,8 @@ +// Fallback runtime config — overwritten by the nginx container entrypoint at +// boot with values from SUPABASE_URL / SUPABASE_ANON_KEY env vars. Only the +// anon key is ever exposed to the browser. The service_role key stays server- +// side (Supabase Studio / PostgREST container environment). +window.MCCARS_CONFIG = { + SUPABASE_URL: "http://localhost:54321", + SUPABASE_ANON_KEY: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0", +}; diff --git a/frontend/datenschutz.html b/frontend/datenschutz.html new file mode 100644 index 0000000..033aa15 --- /dev/null +++ b/frontend/datenschutz.html @@ -0,0 +1,19 @@ + + + + + + Datenschutz · MC Cars (GmbH) + + + + +
+

Rechtliches

+

Datenschutz

+

Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden ueber ein selbstgehostetes Supabase verwaltet.

+

Ansprechpartner: hello@mccars.at

+

← Startseite

+
+ + diff --git a/frontend/i18n.js b/frontend/i18n.js new file mode 100644 index 0000000..afa159d --- /dev/null +++ b/frontend/i18n.js @@ -0,0 +1,194 @@ +// Translations shared between public site and admin panel. +export const translations = { + de: { + navCars: "Fahrzeuge", + navWhy: "Warum wir", + navReviews: "Stimmen", + navBook: "Buchen", + bookNow: "Jetzt buchen", + viewFleet: "Flotte ansehen", + + heroEyebrow: "MC Cars · Sportwagenvermietung", + heroTitle: "Fahren auf hoechstem Niveau.", + heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.", + + statDeposit: "Kaution", + statSupport: "Support", + statCars: "Fahrzeuge", + + fleetEyebrow: "Unsere Flotte", + fleetTitle: "Handverlesen. Gepflegt. Startklar.", + fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie fuer Details oder buchen Sie direkt.", + filterBrand: "Marke", + filterSort: "Sortierung", + filterPrice: "Max. Preis / Tag", + all: "Alle", + sortPriceAsc: "Preis aufsteigend", + sortPriceDesc: "Preis absteigend", + sortPowerDesc: "Leistung absteigend", + details: "Details", + book: "Buchen", + perDay: "pro Tag", + hp: "PS", + kmh: "km/h", + accel: "0-100", + seats: "Sitze", + from: "ab", + noMatches: "Keine Fahrzeuge gefunden.", + + whyEyebrow: "Warum MC Cars", + whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspass.", + whyInsurance: "Versicherungsschutz", + whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.", + whyFleet: "Premium Flotte", + whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.", + whyDeposit: "Kautionsfrei", + whyDepositText: "Sie zahlen nur die Miete. Kein Kapital blockiert, kein unnoetiger Aufwand.", + + reviewsEyebrow: "Kundenmeinungen", + reviewsTitle: "Erlebnisse, die bleiben.", + + bookingEyebrow: "Jetzt buchen", + bookingTitle: "Traumwagen unverbindlich anfragen.", + fieldName: "Name", + fieldEmail: "E-Mail", + fieldPhone: "Telefon", + fieldCar: "Fahrzeug", + fieldFrom: "Von", + fieldTo: "Bis", + fieldMessage: "Nachricht", + messagePlaceholder: "Wuensche, Uhrzeit, Anlass...", + sendRequest: "Anfrage senden", + invalidDates: "Bitte ein gueltiges Datum waehlen (Bis > Von).", + bookingSuccess: "Danke! Wir melden uns in Kuerze per E-Mail.", + bookingFailed: "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.", + + footerTagline: "Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).", + footerLegal: "Rechtliches", + footerContact: "Kontakt", + footerNav: "Navigation", + imprint: "Impressum", + privacy: "Datenschutz", + terms: "Mietbedingungen", + copyright: "Alle Rechte vorbehalten.", + + close: "Schliessen", + editVehicle: "Fahrzeug bearbeiten", + }, + en: { + navCars: "Fleet", + navWhy: "Why us", + navReviews: "Reviews", + navBook: "Book", + bookNow: "Book now", + viewFleet: "View fleet", + + heroEyebrow: "MC Cars · Sports car rental", + heroTitle: "Drive at the highest level.", + heroLead: "Premium sports and luxury cars in Styria. No deposit, full transparency, ready to launch.", + + statDeposit: "Deposit", + statSupport: "Support", + statCars: "Vehicles", + + fleetEyebrow: "Our Fleet", + fleetTitle: "Hand-picked. Maintained. Ready.", + fleetSub: "Filter by brand or price. Click for details or book directly.", + filterBrand: "Brand", + filterSort: "Sort", + filterPrice: "Max price / day", + all: "All", + sortPriceAsc: "Price ascending", + sortPriceDesc: "Price descending", + sortPowerDesc: "Power descending", + details: "Details", + book: "Book", + perDay: "per day", + hp: "HP", + kmh: "km/h", + accel: "0-62", + seats: "Seats", + from: "from", + noMatches: "No vehicles match the filters.", + + whyEyebrow: "Why MC Cars", + whyTitle: "No compromises between safety and driving joy.", + whyInsurance: "Insurance", + whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.", + whyFleet: "Premium fleet", + whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.", + whyDeposit: "No deposit", + whyDepositText: "You only pay rent. No blocked capital, no unnecessary overhead.", + + reviewsEyebrow: "Testimonials", + reviewsTitle: "Experiences that last.", + + bookingEyebrow: "Book now", + bookingTitle: "Request your dream car without obligation.", + fieldName: "Name", + fieldEmail: "Email", + fieldPhone: "Phone", + fieldCar: "Vehicle", + fieldFrom: "From", + fieldTo: "To", + fieldMessage: "Message", + messagePlaceholder: "Wishes, timing, occasion...", + sendRequest: "Send request", + invalidDates: "Please pick valid dates (To > From).", + bookingSuccess: "Thank you! We'll get back to you shortly.", + bookingFailed: "Request could not be sent. Please try again.", + + footerTagline: "Sports car rental in Austria. Location: Styria (TBD).", + footerLegal: "Legal", + footerContact: "Contact", + footerNav: "Navigation", + imprint: "Imprint", + privacy: "Privacy", + terms: "Rental conditions", + copyright: "All rights reserved.", + + close: "Close", + editVehicle: "Edit vehicle", + }, +}; + +export const REVIEWS = { + de: [ + { quote: "Top Service und perfekt vorbereitete Fahrzeuge. Unser Wochenendtrip war ein Highlight.", author: "Laura K." }, + { quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P." }, + { quote: "Sehr professionelles Team und ehrliche Kommunikation zu allen Konditionen.", author: "Sina T." }, + ], + en: [ + { quote: "Excellent service and flawlessly prepared cars. Our weekend trip was unforgettable.", author: "Laura K." }, + { quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Martin P." }, + { quote: "Very professional team and transparent communication on all terms.", author: "Sina T." }, + ], +}; + +export function getLang() { + return localStorage.getItem("mccars.lang") || "de"; +} + +export function setLang(lang) { + localStorage.setItem("mccars.lang", lang); +} + +export function t(key) { + const lang = getLang(); + return (translations[lang] && translations[lang][key]) || key; +} + +export function applyI18n(root = document) { + const lang = getLang(); + document.documentElement.lang = lang; + + root.querySelectorAll("[data-i18n]").forEach((el) => { + const key = el.dataset.i18n; + if (translations[lang][key]) el.textContent = translations[lang][key]; + }); + + root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + const key = el.dataset.i18nPlaceholder; + if (translations[lang][key]) el.placeholder = translations[lang][key]; + }); +} diff --git a/frontend/impressum.html b/frontend/impressum.html new file mode 100644 index 0000000..eba9832 --- /dev/null +++ b/frontend/impressum.html @@ -0,0 +1,22 @@ + + + + + + Impressum · MC Cars (GmbH) + + + + +
+

Rechtliches

+

Impressum

+

MC Cars (GmbH)

+

Standort: Steiermark (TBD)

+

E-Mail: hello@mccars.at

+

Telefon: +43 316 880000

+

Firmenbuch und UID werden nachgereicht.

+

← Startseite

+
+ + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0c7b2aa --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,236 @@ + + + + + + MC Cars · Sportwagenvermietung Steiermark + + + + + + + + + + +
+ +
+
+

MC Cars · Sportwagenvermietung

+

Fahren auf hoechstem Niveau.

+

Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.

+ + + +
+
0 €Kaution
+
Fahrzeuge
+
24/7Support
+
+
+
+ + +
+
+
+
+

Unsere Flotte

+

Handverlesen. Gepflegt. Startklar.

+

Filtern Sie nach Marke und Preis. Klicken Sie fuer Details oder buchen Sie direkt.

+
+
+ +
+ + + +
+ +
+ +
+
+ + +
+
+
+
+

Warum MC Cars

+

Keine Kompromisse zwischen Sicherheit und Fahrspass.

+
+
+ +
+
+
🛡
+

Versicherungsschutz

+

Vollkasko mit klarem Selbstbehalt.

+
+
+
+

Premium Flotte

+

Handverlesene Performance-Modelle.

+
+
+
+

Kautionsfrei

+

Sie zahlen nur die Miete.

+
+
+
+
+ + +
+
+
+
+

Kundenmeinungen

+

Erlebnisse, die bleiben.

+
+
+ +
+
+
+
+ + +
+
+
+
+

Jetzt buchen

+

Traumwagen unverbindlich anfragen.

+
+
+ +
+ + + + + + + +
+ +
+
+ + +
+
+
+ + + + + +
+

+ +
+
+
+ + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8a3ae38 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Never cache config.js / html so runtime config updates take effect. + location = /config.js { add_header Cache-Control "no-store"; try_files $uri =404; } + location ~* \.html$ { add_header Cache-Control "no-store"; try_files $uri =404; } + + location / { + try_files $uri $uri/ /index.html; + } + + # Static assets can be cached aggressively. + location ~* \.(?:css|js|jpg|jpeg|png|webp|svg|ico|woff2?)$ { + expires 7d; + add_header Cache-Control "public"; + try_files $uri =404; + } +} diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..eab273f --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,644 @@ +:root { + --bg: #0b0c10; + --bg-elev: #14161c; + --bg-card: #181b23; + --line: #262a36; + --text: #f2efe8; + --muted: #9aa1ad; + --accent: #c48a42; /* burnished copper */ + --accent-strong: #e0a55b; + --danger: #e05050; + --ok: #4fd1a3; + --radius: 14px; + --shadow: 0 20px 45px rgba(0, 0, 0, 0.45); + --maxw: 1180px; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + font-family: "Inter", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent-strong); text-decoration: none; } +a:hover { color: var(--text); } + +h1, h2, h3, h4 { + font-family: "Playfair Display", "Inter", serif; + font-weight: 600; + letter-spacing: -0.01em; + margin: 0 0 0.4em; +} + +h1 { font-size: clamp(2.2rem, 4.3vw, 3.8rem); line-height: 1.05; } +h2 { font-size: clamp(1.6rem, 2.8vw, 2.4rem); } +h3 { font-size: 1.2rem; } + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.22em; + font-size: 0.72rem; + color: var(--accent-strong); + font-weight: 600; + margin: 0 0 0.6rem; +} + +.shell { + width: min(var(--maxw), 92vw); + margin: 0 auto; +} + +section { padding: 5rem 0; } + +/* ---------------- Header ---------------- */ +.site-header { + position: sticky; + top: 0; + z-index: 40; + background: rgba(11, 12, 16, 0.85); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--line); +} + +.site-header .shell { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 0; +} + +.logo { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--text); + font-family: "Playfair Display", serif; + font-weight: 600; + font-size: 1.2rem; +} + +.logo-mark { + display: grid; + place-items: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent) 0%, #8a5a22 100%); + color: #0b0c10; + font-weight: 700; + font-family: "Inter", sans-serif; + font-size: 0.85rem; +} + +.main-nav { + display: flex; + gap: 1.3rem; + align-items: center; +} + +.main-nav a { + color: var(--muted); + font-size: 0.93rem; + font-weight: 500; + transition: color 0.2s; +} + +.main-nav a:hover { color: var(--text); } + +.main-nav .btn { margin-left: 0.6rem; } + +.lang-toggle { + background: transparent; + color: var(--text); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.4rem 0.85rem; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.08em; +} + +.lang-toggle:hover { border-color: var(--accent); } + +.menu-toggle { + display: none; + background: transparent; + border: 1px solid var(--line); + color: var(--text); + border-radius: 8px; + padding: 0.4rem 0.75rem; + cursor: pointer; +} + +/* ---------------- Buttons ---------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: none; + cursor: pointer; + background: var(--accent); + color: #0b0c10; + padding: 0.8rem 1.2rem; + border-radius: 10px; + font-weight: 600; + font-size: 0.95rem; + transition: transform 0.15s ease, background 0.15s ease; + font-family: inherit; +} + +.btn:hover { background: var(--accent-strong); transform: translateY(-1px); color: #0b0c10; } + +.btn.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--line); +} +.btn.ghost:hover { border-color: var(--accent); color: var(--text); } + +.btn.small { padding: 0.55rem 0.9rem; font-size: 0.85rem; } +.btn.danger { background: var(--danger); color: #fff; } +.btn.danger:hover { background: #f06060; } + +/* ---------------- Hero ---------------- */ +.hero { + position: relative; + padding: 6rem 0 5rem; + overflow: hidden; +} + +.hero::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(11,12,16,0.6) 0%, rgba(11,12,16,0.95) 100%), + url('https://images.unsplash.com/photo-1503376780353-7e6692767b70?auto=format&fit=crop&w=1900&q=80') center / cover no-repeat; + z-index: -1; +} + +.hero .shell { max-width: 760px; } +.hero h1 { margin-bottom: 1rem; } +.hero p.lead { color: var(--muted); font-size: 1.1rem; max-width: 55ch; } + +.hero-cta { + display: flex; + gap: 0.8rem; + flex-wrap: wrap; + margin-top: 1.8rem; +} + +.hero-stats { + display: flex; + gap: 2rem; + margin-top: 2.5rem; + flex-wrap: wrap; +} + +.hero-stats div { min-width: 8rem; } +.hero-stats strong { + display: block; + font-size: 1.5rem; + font-family: "Playfair Display", serif; + color: var(--accent-strong); +} +.hero-stats span { color: var(--muted); font-size: 0.85rem; } + +/* ---------------- Section head ---------------- */ +.section-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.section-head > div { max-width: 55ch; } +.section-head p.sub { color: var(--muted); margin: 0; } + +/* ---------------- Filters ---------------- */ +.filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + margin-bottom: 1.5rem; +} + +.filters label { display: grid; gap: 0.35rem; font-size: 0.82rem; color: var(--muted); } + +select, input, textarea { + width: 100%; + padding: 0.7rem 0.85rem; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 10px; + color: var(--text); + font: inherit; + transition: border-color 0.15s; +} + +select:focus, input:focus, textarea:focus { + outline: none; + border-color: var(--accent); +} + +/* ---------------- Vehicle grid ---------------- */ +.vehicle-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.2rem; +} + +.vehicle-card { + background: var(--bg-card); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + transition: transform 0.2s, border-color 0.2s; +} + +.vehicle-card:hover { + transform: translateY(-3px); + border-color: var(--accent); +} + +.vehicle-photo { + position: relative; + aspect-ratio: 16 / 10; + background: #0e1015 center / cover no-repeat; + overflow: hidden; +} + +.vehicle-photo .badge { + position: absolute; + top: 0.7rem; + left: 0.7rem; + background: rgba(11,12,16,0.75); + backdrop-filter: blur(5px); + color: var(--accent-strong); + padding: 0.25rem 0.55rem; + border-radius: 999px; + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.vehicle-body { + padding: 1rem 1.1rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + flex: 1; +} + +.vehicle-body h3 { margin: 0; } +.vehicle-body .model-brand { color: var(--muted); font-size: 0.82rem; letter-spacing: 0.12em; text-transform: uppercase; } + +.spec-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.35rem; + padding: 0.6rem 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + font-size: 0.82rem; +} + +.spec-row div { text-align: center; } +.spec-row strong { display: block; color: var(--text); font-size: 0.95rem; } +.spec-row span { color: var(--muted); font-size: 0.72rem; } + +.vehicle-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + margin-top: auto; +} + +.vehicle-price { + font-family: "Playfair Display", serif; + font-size: 1.35rem; + color: var(--accent-strong); +} +.vehicle-price span { font-size: 0.8rem; color: var(--muted); font-family: inherit; } + +/* ---------------- Why section ---------------- */ +.why-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.why-card { + padding: 1.8rem 1.4rem; + background: var(--bg-card); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.why-card .icon { + width: 2.8rem; + height: 2.8rem; + border-radius: 10px; + background: rgba(196, 138, 66, 0.15); + color: var(--accent-strong); + display: grid; + place-items: center; + font-size: 1.3rem; + margin-bottom: 0.9rem; +} + +.why-card p { color: var(--muted); margin: 0.3rem 0 0; } + +/* ---------------- Reviews ---------------- */ +.reviews-strip { + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 2rem; + min-height: 180px; +} + +.review-quote { + font-family: "Playfair Display", serif; + font-size: 1.25rem; + line-height: 1.5; + margin: 0; + font-style: italic; +} + +.review-author { margin-top: 0.8rem; color: var(--muted); font-size: 0.88rem; } + +.review-dots { + display: flex; + gap: 0.4rem; + justify-content: center; + margin-top: 1rem; +} + +.review-dots button { + width: 9px; height: 9px; border-radius: 50%; + border: none; background: var(--line); cursor: pointer; + transition: background 0.2s, width 0.2s; +} + +.review-dots button.active { background: var(--accent); width: 24px; border-radius: 5px; } + +/* ---------------- Booking ---------------- */ +.booking-form { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.9rem; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.8rem; +} + +.booking-form .full { grid-column: 1 / -1; } +.booking-form label { display: grid; gap: 0.3rem; font-size: 0.82rem; color: var(--muted); } + +.form-feedback { + margin-top: 1rem; + min-height: 1.2rem; + color: var(--ok); +} +.form-feedback.error { color: var(--danger); } + +/* ---------------- Footer ---------------- */ +.site-footer { + border-top: 1px solid var(--line); + background: var(--bg-elev); + padding: 3rem 0 2rem; + margin-top: 3rem; +} + +.footer-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +.footer-grid h4 { + font-family: "Inter", sans-serif; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--muted); + margin-bottom: 0.7rem; +} + +.footer-grid a { + display: block; + color: var(--text); + margin-bottom: 0.4rem; + font-size: 0.92rem; +} + +.footer-bottom { + border-top: 1px solid var(--line); + padding-top: 1.2rem; + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 0.82rem; + flex-wrap: wrap; + gap: 0.6rem; +} + +/* ---------------- Dialog ---------------- */ +dialog { + width: min(700px, 92vw); + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + padding: 0; + box-shadow: var(--shadow); +} +dialog::backdrop { background: rgba(0,0,0,0.6); } + +.dialog-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.2rem; + border-bottom: 1px solid var(--line); +} + +.dialog-body { padding: 1.2rem; } + +.dialog-body img { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 10px; + margin-bottom: 1rem; +} + +.dialog-close { + background: transparent; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 8px; + cursor: pointer; + width: 2rem; height: 2rem; + display: grid; place-items: center; + font-size: 1.2rem; +} + +/* ---------------- Admin ---------------- */ +.admin-page { + max-width: 1100px; + margin: 2rem auto; + padding: 0 1rem; +} + +.admin-login { + max-width: 420px; + margin: 5rem auto; + padding: 2rem; + background: var(--bg-card); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.admin-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--line); +} + +.admin-bar h1 { margin: 0; font-size: 1.4rem; } + +.admin-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +.panel { + background: var(--bg-card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.4rem; +} + +.panel h2 { font-size: 1.1rem; font-family: "Inter", sans-serif; margin-bottom: 1rem; } + +table.admin-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +table.admin-table th, table.admin-table td { + text-align: left; + padding: 0.55rem 0.5rem; + border-bottom: 1px solid var(--line); +} +table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; } +table.admin-table tr:hover { background: rgba(255,255,255,0.02); } + +.admin-form { display: grid; gap: 0.7rem; } +.admin-form label { display: grid; gap: 0.25rem; font-size: 0.82rem; color: var(--muted); } +.admin-form .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; } +.admin-form .row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.7rem; } + +.admin-photo-preview { + width: 100%; + aspect-ratio: 16 / 9; + background: var(--bg-elev) center / cover no-repeat; + border: 1px dashed var(--line); + border-radius: 10px; + margin-bottom: 0.5rem; +} + +/* Admin tabs */ +.admin-tabs { display: flex; gap: 0.4rem; margin-bottom: 1.2rem; border-bottom: 1px solid var(--line); padding-bottom: 0.2rem; flex-wrap: wrap; } +.admin-tabs .tab { + background: transparent; border: none; color: var(--muted); + padding: 0.6rem 1rem; border-radius: 10px 10px 0 0; + font-family: "Inter", sans-serif; font-weight: 500; cursor: pointer; + display: inline-flex; align-items: center; gap: 0.4rem; + border-bottom: 2px solid transparent; +} +.admin-tabs .tab:hover { color: var(--fg); } +.admin-tabs .tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-badge { + background: var(--bg-elev); color: var(--fg); + font-size: 0.7rem; padding: 0.1rem 0.5rem; + border-radius: 999px; min-width: 1.3rem; text-align: center; +} +.admin-tabs .tab.active .tab-badge { background: var(--accent); color: #111; } + +.sub-tabs { display: inline-flex; gap: 0.3rem; background: var(--bg-elev); border: 1px solid var(--line); border-radius: 999px; padding: 0.2rem; } +.sub-tab { + background: transparent; border: none; color: var(--muted); + padding: 0.35rem 0.9rem; border-radius: 999px; cursor: pointer; + font-size: 0.82rem; font-family: "Inter", sans-serif; +} +.sub-tab.active { background: var(--accent); color: #111; font-weight: 600; } + +/* Pills */ +.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--line); } +.pill-new { background: rgba(200, 150, 80, 0.15); color: #e4b676; border-color: rgba(200, 150, 80, 0.4); } +.pill-qualified { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border-color: rgba(90, 180, 120, 0.4); } +.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border-color: rgba(180, 90, 90, 0.4); } +.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border-color: rgba(90, 180, 120, 0.4); } +.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); } + +.muted { color: var(--muted); } + +.btn.small { padding: 0.35rem 0.7rem; font-size: 0.78rem; } +.btn.danger { background: #7a2b2b; color: #fff; } +.btn.danger:hover { background: #8f3535; } + +/* Dialog */ +dialog#leadDialog { + border: 1px solid var(--line); border-radius: var(--radius); + background: var(--bg-card); color: var(--fg); + padding: 0; max-width: 560px; width: 92%; +} +dialog#leadDialog::backdrop { background: rgba(0,0,0,0.6); } +.dialog-head { + display: flex; justify-content: space-between; align-items: center; + padding: 1rem 1.2rem; border-bottom: 1px solid var(--line); +} +.dialog-close { + background: transparent; border: none; color: var(--muted); + font-size: 1.4rem; cursor: pointer; line-height: 1; +} +.dialog-body { padding: 1.2rem; } +dl.kv { display: grid; grid-template-columns: 110px 1fr; gap: 0.4rem 1rem; margin: 0; font-size: 0.88rem; } +dl.kv dt { color: var(--muted); } +dl.kv dd { margin: 0; } + +/* ---------------- Responsive ---------------- */ +@media (max-width: 900px) { + .filters, .booking-form, .admin-grid, .why-grid { grid-template-columns: 1fr; } + .footer-grid { grid-template-columns: 1fr 1fr; } + .section-head { flex-direction: column; align-items: flex-start; } +} + +@media (max-width: 700px) { + .main-nav { display: none; position: absolute; right: 1rem; top: 100%; flex-direction: column; background: var(--bg-elev); border: 1px solid var(--line); padding: 1rem; border-radius: var(--radius); } + .main-nav.open { display: flex; } + .menu-toggle { display: inline-flex; } + .footer-grid { grid-template-columns: 1fr; } + .admin-form .row2, .admin-form .row3 { grid-template-columns: 1fr; } +} diff --git a/supabase/kong.yml b/supabase/kong.yml new file mode 100644 index 0000000..dada90e --- /dev/null +++ b/supabase/kong.yml @@ -0,0 +1,122 @@ +_format_version: "2.1" +_transform: true + +### +# MC Cars - Kong declarative config +# Routes traffic coming in on the Kong proxy port to each Supabase service. +### + +consumers: + - username: anon + keyauth_credentials: + - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 + - username: service_role + keyauth_credentials: + - key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + - username: dashboard + +basicauth_credentials: + - consumer: dashboard + username: supabase + password: mc-cars-studio + +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +services: + ######################################## + # Auth (GoTrue) + ######################################## + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + + ######################################## + # REST (PostgREST) + ######################################## + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ######################################## + # Realtime (WebSocket subscriptions) + ######################################## + - name: realtime-v1 + url: http://realtime:4000/socket + protocol: http + routes: + - name: realtime-v1-all + strip_path: true + paths: + - /realtime/v1/ + protocols: + - http + - https + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ######################################## + # Storage + ######################################## + - name: storage-v1 + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + ######################################## + # postgres-meta (needed by Studio) + ######################################## + - name: meta + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin diff --git a/supabase/migrations/00-run-init.sh b/supabase/migrations/00-run-init.sh new file mode 100644 index 0000000..f08e124 --- /dev/null +++ b/supabase/migrations/00-run-init.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +psql -v ON_ERROR_STOP=1 -v pg_password="$POSTGRES_PASSWORD" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f /sql/01-init.sql diff --git a/supabase/migrations/01-init.sql b/supabase/migrations/01-init.sql new file mode 100644 index 0000000..7378b4e --- /dev/null +++ b/supabase/migrations/01-init.sql @@ -0,0 +1,198 @@ +-- ============================================================================= +-- MC Cars — Postgres bootstrap. +-- Creates the Supabase service roles that GoTrue / PostgREST / Storage expect, +-- installs required extensions, and sets up app schema + RLS + storage policies +-- + admin seed + sample fleet. +-- Runs once on first `docker compose up`. +-- ============================================================================= + +-- The Postgres connection is authenticated as role `supabase_admin` (POSTGRES_USER), +-- so we need it to be a superuser for the rest of this script to work. That is +-- handled by POSTGRES_USER=supabase_admin in compose. + +create extension if not exists pgcrypto; +create extension if not exists "uuid-ossp"; + +-- Make the password from the shell wrapper available to the DO block below. +select set_config('mccars.pg_password', :'pg_password', false); + +-- ----------------------------------------------------------------------------- +-- Supabase service roles +-- ----------------------------------------------------------------------------- +do $roles$ +declare + pw text := current_setting('mccars.pg_password', true); +begin + if pw is null or pw = '' then + raise exception 'mccars.pg_password is not set'; + end if; + + -- anon (used by PostgREST for unauthenticated requests) + if not exists (select 1 from pg_roles where rolname = 'anon') then + execute 'create role anon nologin noinherit'; + end if; + + -- authenticated (role the JWT "authenticated" maps to) + if not exists (select 1 from pg_roles where rolname = 'authenticated') then + execute 'create role authenticated nologin noinherit'; + end if; + + -- service_role (full access; never exposed to the browser) + if not exists (select 1 from pg_roles where rolname = 'service_role') then + execute 'create role service_role nologin noinherit bypassrls'; + end if; + + -- authenticator (PostgREST logs in as this and switches role per JWT) + if not exists (select 1 from pg_roles where rolname = 'authenticator') then + execute format('create role authenticator login noinherit password %L', pw); + else + execute format('alter role authenticator with login noinherit password %L', pw); + end if; + + -- supabase_auth_admin (GoTrue logs in with this, needs schema auth) + if not exists (select 1 from pg_roles where rolname = 'supabase_auth_admin') then + execute format('create role supabase_auth_admin login createrole password %L', pw); + else + execute format('alter role supabase_auth_admin with login createrole password %L', pw); + end if; + + -- supabase_storage_admin (Storage service logs in with this) + if not exists (select 1 from pg_roles where rolname = 'supabase_storage_admin') then + execute format('create role supabase_storage_admin login createrole password %L', pw); + else + execute format('alter role supabase_storage_admin with login createrole password %L', pw); + end if; + + -- Let authenticator impersonate app roles. + execute 'grant anon, authenticated, service_role to authenticator'; +end +$roles$; + +-- ----------------------------------------------------------------------------- +-- Schemas for GoTrue / Storage (they create their own objects, but own schema) +-- ----------------------------------------------------------------------------- +create schema if not exists auth authorization supabase_auth_admin; +create schema if not exists storage authorization supabase_storage_admin; +create schema if not exists _realtime authorization postgres; + +grant usage on schema auth to service_role, authenticated, anon; +grant usage on schema storage to service_role, authenticated, anon; +grant all on schema _realtime to postgres; + +-- Allow service admins to create/alter objects for their own migrations. +grant create, connect on database postgres to supabase_auth_admin, supabase_storage_admin; +grant all on schema auth to supabase_auth_admin; +grant all on schema storage to supabase_storage_admin; + +-- Storage-api's migration process expects to see public schema too. +grant usage, create on schema public to supabase_storage_admin, supabase_auth_admin; + +-- ----------------------------------------------------------------------------- +-- Application schema: public.vehicles +-- ----------------------------------------------------------------------------- +create table if not exists public.vehicles ( + id uuid primary key default gen_random_uuid(), + brand text not null, + model text not null, + power_hp integer not null default 0, + top_speed_kmh integer not null default 0, + acceleration text not null default '', + seats integer not null default 2, + daily_price_eur integer not null default 0, + location text not null default 'Steiermark (TBD)', + description_de text not null default '', + description_en text not null default '', + photo_url text not null default '', + photo_path text, + sort_order integer not null default 100, + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists vehicles_active_sort_idx + on public.vehicles (is_active, sort_order); + +create or replace function public.tg_touch_updated_at() returns trigger +language plpgsql as $$ +begin + new.updated_at := now(); + return new; +end; +$$; + +drop trigger if exists vehicles_touch on public.vehicles; +create trigger vehicles_touch + before update on public.vehicles + for each row execute function public.tg_touch_updated_at(); + +alter table public.vehicles enable row level security; + +drop policy if exists "vehicles_public_read" on public.vehicles; +drop policy if exists "vehicles_admin_read_all" on public.vehicles; +drop policy if exists "vehicles_admin_insert" on public.vehicles; +drop policy if exists "vehicles_admin_update" on public.vehicles; +drop policy if exists "vehicles_admin_delete" on public.vehicles; + +create policy "vehicles_public_read" + on public.vehicles for select + using (is_active = true); + +create policy "vehicles_admin_read_all" + on public.vehicles for select + to authenticated using (true); + +create policy "vehicles_admin_insert" + on public.vehicles for insert + to authenticated with check (true); + +create policy "vehicles_admin_update" + on public.vehicles for update + to authenticated using (true) with check (true); + +create policy "vehicles_admin_delete" + on public.vehicles for delete + to authenticated using (true); + +grant select on public.vehicles to anon, authenticated; +grant insert, update, delete on public.vehicles to authenticated; +grant all on public.vehicles to service_role; + +-- ----------------------------------------------------------------------------- +-- Seed sample fleet (admin can replace any row / photo via the UI) +-- ----------------------------------------------------------------------------- +insert into public.vehicles + (brand, model, power_hp, top_speed_kmh, acceleration, seats, + daily_price_eur, location, description_de, description_en, photo_url, sort_order) +values + ('Porsche','911 GT3', 510, 318, '3.4s', 2, 890, 'Steiermark (TBD)', + 'Puristischer Hochdrehzahl-Saugmotor und kompromissloser Motorsport-Charakter.', + 'Pure high-revving naturally aspirated engine with uncompromising motorsport character.', + 'https://images.unsplash.com/photo-1611821064430-0d40291d0f0b?auto=format&fit=crop&w=1400&q=80', + 10), + ('Lamborghini','Huracan EVO', 640, 325, '2.9s', 2, 990, 'Steiermark (TBD)', + 'V10 mit 640 PS, scharfes Design und kompromisslose Performance auf Strasse und Rennstrecke.', + 'V10 with 640 hp, sharp design and uncompromising performance on road and track.', + 'https://images.unsplash.com/photo-1544636331-e26879cd4d9b?auto=format&fit=crop&w=1400&q=80', + 20), + ('Audi','RS6 Performance', 630, 305, '3.4s', 5, 540, 'Steiermark (TBD)', + 'Alltagstauglicher Kombi mit brutaler V8-Biturbo-Power und Allradantrieb.', + 'Everyday-ready estate with brutal twin-turbo V8 power and quattro AWD.', + 'https://images.unsplash.com/photo-1606664515524-ed2f786a0bd6?auto=format&fit=crop&w=1400&q=80', + 30), + ('BMW','M4 Competition', 530, 290, '3.5s', 4, 430, 'Steiermark (TBD)', + 'Reihensechszylinder-Biturbo mit praeziser Lenkung und sportlichem Fahrwerk.', + 'Twin-turbo inline-six with precise steering and sporty chassis.', + 'https://images.unsplash.com/photo-1555215695-3004980ad54e?auto=format&fit=crop&w=1400&q=80', + 40), + ('Nissan','GT-R R35', 570, 315, '2.8s', 4, 510, 'Steiermark (TBD)', + 'Ikonischer Allrad-Supersportler mit Twin-Turbo V6 und brutalem Antritt.', + 'Iconic AWD supercar with twin-turbo V6 and ferocious launch.', + 'https://images.unsplash.com/photo-1626668893632-6f3a4466d22f?auto=format&fit=crop&w=1400&q=80', + 50), + ('Mercedes-AMG','G63', 585, 220, '4.5s', 5, 620, 'Steiermark (TBD)', + 'Legendaere G-Klasse mit V8-Biturbo-Performance und unverkennbarem Design.', + 'Legendary G-Class with V8 biturbo performance and unmistakable design.', + 'https://images.unsplash.com/photo-1606611013016-969c19ba27bb?auto=format&fit=crop&w=1400&q=80', + 60) +on conflict do nothing; diff --git a/supabase/migrations/02-leads.sql b/supabase/migrations/02-leads.sql new file mode 100644 index 0000000..da8cefa --- /dev/null +++ b/supabase/migrations/02-leads.sql @@ -0,0 +1,252 @@ +-- ============================================================================= +-- MC Cars - CRM schema: leads + customers + realtime publication. +-- Runs from post-init AFTER auth/storage schemas exist. +-- Idempotent. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- LEADS: landing-page form submissions (anon may INSERT, only authenticated +-- may SELECT/UPDATE). +-- ----------------------------------------------------------------------------- +create table if not exists public.leads ( + id uuid primary key default gen_random_uuid(), + name text not null, + email text not null, + phone text not null default '', + vehicle_id uuid references public.vehicles(id) on delete set null, + vehicle_label text not null default '', -- denormalized (brand + model) at submit time + date_from date, + date_to date, + message text not null default '', + status text not null default 'new' + check (status in ('new','qualified','disqualified')), + is_active boolean not null default true, -- false once qualified/disqualified + admin_notes text not null default '', + source text not null default 'website', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + qualified_at timestamptz, + qualified_by uuid references auth.users(id) on delete set null +); + +create index if not exists leads_active_created_idx on public.leads (is_active, created_at desc); +create index if not exists leads_status_idx on public.leads (status); +create index if not exists leads_vehicle_idx on public.leads (vehicle_id); + +drop trigger if exists leads_touch on public.leads; +create trigger leads_touch + before update on public.leads + for each row execute function public.tg_touch_updated_at(); + +alter table public.leads enable row level security; + +drop policy if exists "leads_anon_insert" on public.leads; +drop policy if exists "leads_admin_select_all" on public.leads; +drop policy if exists "leads_admin_update" on public.leads; +drop policy if exists "leads_admin_delete" on public.leads; + +-- Anonymous visitors may submit the booking form, but NOT read anything back. +create policy "leads_anon_insert" + on public.leads for insert to anon + with check (true); + +create policy "leads_admin_select_all" + on public.leads for select to authenticated + using (true); + +create policy "leads_admin_update" + on public.leads for update to authenticated + using (true) with check (true); + +create policy "leads_admin_delete" + on public.leads for delete to authenticated + using (true); + +grant insert on public.leads to anon; +grant select, insert, update, delete on public.leads to authenticated; +grant all on public.leads to service_role; + + +-- ----------------------------------------------------------------------------- +-- CUSTOMERS: created ONLY by qualifying a lead from the admin panel. +-- Keeps a hard FK back to the originating lead for audit. +-- ----------------------------------------------------------------------------- +create table if not exists public.customers ( + id uuid primary key default gen_random_uuid(), + lead_id uuid not null references public.leads(id) on delete restrict, + name text not null, + email text not null, + phone text not null default '', + first_contacted_at timestamptz not null default now(), + notes text not null default '', + status text not null default 'active' + check (status in ('active','inactive')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + created_by uuid references auth.users(id) on delete set null +); + +create unique index if not exists customers_lead_unique on public.customers (lead_id); +create index if not exists customers_email_idx on public.customers (email); + +drop trigger if exists customers_touch on public.customers; +create trigger customers_touch + before update on public.customers + for each row execute function public.tg_touch_updated_at(); + +alter table public.customers enable row level security; + +drop policy if exists "customers_admin_select" on public.customers; +drop policy if exists "customers_admin_insert" on public.customers; +drop policy if exists "customers_admin_update" on public.customers; +drop policy if exists "customers_admin_delete" on public.customers; + +create policy "customers_admin_select" + on public.customers for select to authenticated using (true); + +create policy "customers_admin_insert" + on public.customers for insert to authenticated with check (true); + +create policy "customers_admin_update" + on public.customers for update to authenticated using (true) with check (true); + +create policy "customers_admin_delete" + on public.customers for delete to authenticated using (true); + +grant select, insert, update, delete on public.customers to authenticated; +grant all on public.customers to service_role; + + +-- ----------------------------------------------------------------------------- +-- qualify_lead(lead_id, notes) +-- Transactional: marks the lead qualified+inactive and creates the matching +-- customer row. Called via PostgREST RPC by the admin UI. +-- ----------------------------------------------------------------------------- +create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '') +returns public.customers +language plpgsql +security invoker +as $$ +declare + v_lead public.leads; + v_customer public.customers; + v_user uuid := auth.uid(); +begin + select * into v_lead from public.leads where id = p_lead_id for update; + if not found then + raise exception 'lead % not found', p_lead_id; + end if; + + if v_lead.status = 'qualified' then + select * into v_customer from public.customers where lead_id = v_lead.id; + return v_customer; + end if; + + update public.leads + set status = 'qualified', + is_active = false, + qualified_at = now(), + qualified_by = v_user, + admin_notes = coalesce(nullif(p_notes, ''), admin_notes) + where id = v_lead.id; + + insert into public.customers (lead_id, name, email, phone, notes, created_by) + values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user) + returning * into v_customer; + + return v_customer; +end; +$$; + +revoke all on function public.qualify_lead(uuid, text) from public; +grant execute on function public.qualify_lead(uuid, text) to authenticated; + + +-- ----------------------------------------------------------------------------- +-- disqualify_lead(lead_id, notes) +-- ----------------------------------------------------------------------------- +create or replace function public.disqualify_lead(p_lead_id uuid, p_notes text default '') +returns public.leads +language plpgsql +security invoker +as $$ +declare + v_lead public.leads; +begin + update public.leads + set status = 'disqualified', + is_active = false, + admin_notes = coalesce(nullif(p_notes, ''), admin_notes) + where id = p_lead_id + returning * into v_lead; + + if not found then + raise exception 'lead % not found', p_lead_id; + end if; + + return v_lead; +end; +$$; + +revoke all on function public.disqualify_lead(uuid, text) from public; +grant execute on function public.disqualify_lead(uuid, text) to authenticated; + + +-- ----------------------------------------------------------------------------- +-- reopen_lead(lead_id): moves a lead back to active/new (admin correction). +-- ----------------------------------------------------------------------------- +create or replace function public.reopen_lead(p_lead_id uuid) +returns public.leads +language plpgsql +security invoker +as $$ +declare + v_lead public.leads; +begin + update public.leads + set status = 'new', + is_active = true, + qualified_at = null, + qualified_by = null + where id = p_lead_id + returning * into v_lead; + + -- If a customer was spawned from this lead, remove it. + delete from public.customers where lead_id = p_lead_id; + + return v_lead; +end; +$$; + +revoke all on function public.reopen_lead(uuid) from public; +grant execute on function public.reopen_lead(uuid) to authenticated; + + +-- ----------------------------------------------------------------------------- +-- Realtime: publish leads + customers so the admin UI sees live inserts/updates. +-- ----------------------------------------------------------------------------- +do $$ +declare t text; +begin + if not exists (select 1 from pg_publication where pubname = 'supabase_realtime') then + create publication supabase_realtime; + end if; + + foreach t in array array['leads','customers','vehicles'] loop + if not exists ( + select 1 from pg_publication_tables + where pubname='supabase_realtime' and schemaname='public' and tablename=t + ) then + execute format('alter publication supabase_realtime add table public.%I', t); + end if; + end loop; +end +$$; + +alter table public.leads replica identity full; +alter table public.customers replica identity full; +alter table public.vehicles replica identity full; + +-- Tell PostgREST to reload its schema cache after new tables/functions appear. +notify pgrst, 'reload schema'; + diff --git a/supabase/migrations/post-boot.sql b/supabase/migrations/post-boot.sql new file mode 100644 index 0000000..cec6ce4 --- /dev/null +++ b/supabase/migrations/post-boot.sql @@ -0,0 +1,81 @@ +-- Runs AFTER GoTrue and Storage auto-migrate their schemas. +-- Seeds the admin user (from psql vars :admin_email / :admin_password), +-- the vehicle-photos storage bucket, and storage RLS policies. Idempotent. +-- +-- IMPORTANT: the seeded password is a BOOTSTRAP value only. The admin UI +-- enforces a password rotation on first login via +-- auth.users.raw_user_meta_data.must_change_password=true, so the real +-- operational password is NEVER equal to the .env seed. + +-- Publish psql vars as GUCs so the DO block can read them reliably. +select set_config('mccars.admin_email', :'admin_email', false); +select set_config('mccars.admin_password', :'admin_password', false); + +do $$ +declare + v_user_id uuid; + v_email text := coalesce(nullif(current_setting('mccars.admin_email', true), ''), 'admin@mccars.local'); + v_pass text := coalesce(nullif(current_setting('mccars.admin_password', true), ''), 'mc-cars-admin'); +begin + if not exists (select 1 from auth.users where email = v_email) then + v_user_id := gen_random_uuid(); + insert into auth.users ( + id, instance_id, aud, role, email, encrypted_password, + email_confirmed_at, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at, is_super_admin, + confirmation_token, email_change, email_change_token_new, recovery_token + ) values ( + v_user_id, + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + v_email, + crypt(v_pass, gen_salt('bf')), + now(), + jsonb_build_object('provider','email','providers',jsonb_build_array('email')), + jsonb_build_object('must_change_password', true), + now(), now(), false, '', '', '', '' + ); + + insert into auth.identities ( + id, user_id, identity_data, provider, provider_id, + last_sign_in_at, created_at, updated_at + ) values ( + gen_random_uuid(), v_user_id, + jsonb_build_object('sub', v_user_id::text, 'email', v_email), + 'email', v_email, + now(), now(), now() + ); + end if; +end +$$; + +-- ----------------------------------------------------------------------------- +-- Storage bucket + RLS +-- ----------------------------------------------------------------------------- +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ('vehicle-photos','vehicle-photos', true, 52428800, + array['image/jpeg','image/png','image/webp','image/avif']) +on conflict (id) do update + set public = excluded.public, + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +drop policy if exists "vehicle_photos_public_read" on storage.objects; +drop policy if exists "vehicle_photos_admin_insert" on storage.objects; +drop policy if exists "vehicle_photos_admin_update" on storage.objects; +drop policy if exists "vehicle_photos_admin_delete" on storage.objects; + +create policy "vehicle_photos_public_read" + on storage.objects for select using (bucket_id = 'vehicle-photos'); + +create policy "vehicle_photos_admin_insert" + on storage.objects for insert to authenticated + with check (bucket_id = 'vehicle-photos'); + +create policy "vehicle_photos_admin_update" + on storage.objects for update to authenticated + using (bucket_id = 'vehicle-photos') with check (bucket_id = 'vehicle-photos'); + +create policy "vehicle_photos_admin_delete" + on storage.objects for delete to authenticated + using (bucket_id = 'vehicle-photos');