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.
This commit is contained in:
Lago
2026-04-17 17:50:57 +02:00
commit 61517879e1
23 changed files with 3673 additions and 0 deletions
+51
View File
@@ -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
+13
View File
@@ -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/
+131
View File
@@ -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.
+259
View File
@@ -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/<path>
→ Kong → storage-api → ./data/storage
← public URL served via imgproxy for on-the-fly transforms
admin.js → UPDATE vehicles SET photo_url = <public 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.
+114
View File
@@ -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 3060 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.
+1
View File
@@ -0,0 +1 @@
# Bind-mounted service data lives here (db, storage, n8n). Keep tree, ignore contents.
+328
View File
@@ -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" } }
+16
View File
@@ -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 <<EOF\nwindow.MCCARS_CONFIG = {\n SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",\n SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}"\n};\nEOF\nexec nginx -g "daemon off;"\n' > /docker-entrypoint.d/99-config.sh \
&& chmod +x /docker-entrypoint.d/99-config.sh
EXPOSE 80
+220
View File
@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin · MC Cars</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<script src="config.js"></script>
</head>
<body>
<!-- Login -->
<section id="loginView" class="admin-login" style="display:none;">
<div class="logo" style="justify-content:center;margin-bottom:1.5rem;">
<span class="logo-mark">MC</span>
<span>MC Cars Admin</span>
</div>
<form id="loginForm" class="admin-form">
<label>
<span>E-Mail</span>
<input type="email" name="email" required autocomplete="username" />
</label>
<label>
<span>Passwort</span>
<input type="password" name="password" required autocomplete="current-password" />
</label>
<button class="btn" type="submit">Anmelden</button>
<p class="form-feedback error" id="loginError"></p>
<p style="color:var(--muted);font-size:0.82rem;text-align:center;">
Only admins. Self-registration is disabled.
</p>
</form>
</section>
<!-- Forced password rotation (first login OR user-triggered) -->
<section id="rotateView" class="admin-login" style="display:none;">
<div class="logo" style="justify-content:center;margin-bottom:1rem;">
<span class="logo-mark">MC</span>
<span>Passwort setzen</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;text-align:center;max-width:38ch;margin:0 auto 1rem;">
Das Bootstrap-Passwort muss ersetzt werden. Das neue Passwort muss sich vom
Start-Passwort unterscheiden.
</p>
<form id="rotateForm" class="admin-form">
<label>
<span>Neues Passwort (mind. 10 Zeichen)</span>
<input type="password" name="pw1" minlength="10" required autocomplete="new-password" />
</label>
<label>
<span>Wiederholen</span>
<input type="password" name="pw2" minlength="10" required autocomplete="new-password" />
</label>
<button class="btn" type="submit">Speichern</button>
<p class="form-feedback error" id="rotateError"></p>
</form>
</section>
<!-- Admin -->
<section id="adminView" class="admin-page" style="display:none;">
<div class="admin-bar">
<h1>MC Cars · Admin</h1>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;">
<a href="index.html" class="btn ghost small">Website</a>
<a href="http://localhost:3000" target="_blank" rel="noopener" class="btn ghost small">Supabase Studio</a>
<span id="adminWho" style="color:var(--muted);font-size:0.85rem;"></span>
<button id="changePwBtn" class="btn ghost small">Passwort aendern</button>
<button id="logoutBtn" class="btn small">Logout</button>
</div>
</div>
<!-- Tabs -->
<div class="admin-tabs" role="tablist">
<button class="tab active" data-tab="leads" role="tab">Leads <span id="leadsBadge" class="tab-badge">0</span></button>
<button class="tab" data-tab="customers" role="tab">Kunden <span id="customersBadge" class="tab-badge">0</span></button>
<button class="tab" data-tab="vehicles" role="tab">Fahrzeuge</button>
</div>
<!-- LEADS -->
<div class="tab-panel" id="tab-leads">
<div class="panel">
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;">
<h2 style="margin:0;">Leads</h2>
<div class="sub-tabs" role="tablist">
<button class="sub-tab active" data-lview="active">Aktive Leads</button>
<button class="sub-tab" data-lview="inactive">Abgeschlossen</button>
</div>
</div>
<table class="admin-table" id="leadsTable">
<thead>
<tr>
<th>Eingang</th>
<th>Name / E-Mail</th>
<th>Fahrzeug</th>
<th>Zeitraum</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<p id="leadsEmpty" class="muted" style="display:none;text-align:center;padding:2rem 0;">Keine Leads in dieser Ansicht.</p>
</div>
</div>
<!-- CUSTOMERS -->
<div class="tab-panel" id="tab-customers" style="display:none;">
<div class="panel">
<h2>Kunden</h2>
<p class="muted" style="margin-top:-0.4rem;">Entstehen automatisch, sobald ein Lead qualifiziert wird. Die Quelle bleibt als <code>lead_id</code> verknuepft.</p>
<table class="admin-table" id="customersTable">
<thead>
<tr>
<th>Erster Kontakt</th>
<th>Name / E-Mail</th>
<th>Telefon</th>
<th>Quelle (Lead)</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<p id="customersEmpty" class="muted" style="display:none;text-align:center;padding:2rem 0;">Noch keine Kunden.</p>
</div>
</div>
<!-- VEHICLES -->
<div class="tab-panel" id="tab-vehicles" style="display:none;">
<div class="admin-grid">
<div class="panel">
<h2 id="formTitle">Neues Fahrzeug</h2>
<form class="admin-form" id="vehicleForm">
<input type="hidden" name="id" />
<div class="admin-photo-preview" id="photoPreview"></div>
<label>
<span>Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<input type="file" id="photoInput" accept="image/*" />
</label>
<label>
<span>Foto-URL (wird automatisch gesetzt nach Upload)</span>
<input type="url" name="photo_url" placeholder="https://..." />
</label>
<div class="row2">
<label><span>Marke</span><input name="brand" required /></label>
<label><span>Modell</span><input name="model" required /></label>
</div>
<div class="row3">
<label><span>PS</span><input type="number" name="power_hp" min="0" /></label>
<label><span>Top-Speed km/h</span><input type="number" name="top_speed_kmh" min="0" /></label>
<label><span>0-100</span><input name="acceleration" placeholder="3.2s" /></label>
</div>
<div class="row3">
<label><span>Sitze</span><input type="number" name="seats" min="1" value="2" /></label>
<label><span>Preis / Tag (€)</span><input type="number" name="daily_price_eur" min="0" required /></label>
<label><span>Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
</div>
<label>
<span>Standort</span>
<input name="location" value="Steiermark (TBD)" />
</label>
<label>
<span>Beschreibung (Deutsch)</span>
<textarea name="description_de" rows="3"></textarea>
</label>
<label>
<span>Description (English)</span>
<textarea name="description_en" rows="3"></textarea>
</label>
<label style="flex-direction:row;align-items:center;gap:0.5rem;">
<input type="checkbox" name="is_active" checked style="width:auto;" />
<span>Aktiv / auf Website sichtbar</span>
</label>
<div style="display:flex;gap:0.5rem;">
<button class="btn" type="submit" id="saveBtn">Speichern</button>
<button class="btn ghost" type="button" id="resetBtn">Neu</button>
</div>
<p class="form-feedback" id="formFeedback"></p>
</form>
</div>
<div class="panel">
<h2>Alle Fahrzeuge</h2>
<table class="admin-table" id="adminTable">
<thead>
<tr>
<th>Foto</th>
<th>Marke / Modell</th>
<th>€ / Tag</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Lead detail / qualify dialog -->
<dialog id="leadDialog">
<div class="dialog-head">
<h3 id="leadDialogTitle" style="margin:0;">Lead</h3>
<button class="dialog-close" id="leadDialogClose" aria-label="Close">×</button>
</div>
<div class="dialog-body" id="leadDialogBody"></div>
</dialog>
<script type="module" src="admin.js"></script>
</body>
</html>
+493
View File
@@ -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 = `
<td><div style="width:56px;height:36px;border-radius:6px;background:#0e1015 center/cover no-repeat;background-image:url('${attr(v.photo_url)}');"></div></td>
<td><strong>${esc(v.brand)}</strong><br /><span style="color:var(--muted);">${esc(v.model)}</span></td>
<td>€ ${v.daily_price_eur}</td>
<td>${v.is_active ? "✅" : "—"}</td>
<td style="white-space:nowrap;">
<button class="btn small ghost" data-edit="${v.id}">Edit</button>
<button class="btn small danger" data-del="${v.id}">Del</button>
</td>`;
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 = `
<td>${fmtDate(l.created_at)}</td>
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
<td>${esc(l.vehicle_label || "—")}</td>
<td>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</td>
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
<td style="white-space:nowrap;">
<button class="btn small ghost" data-open="${l.id}">Details</button>
${wantActive ? `
<button class="btn small" data-qual="${l.id}">Qualifizieren</button>
<button class="btn small danger" data-disq="${l.id}">Ablehnen</button>
` : `
<button class="btn small ghost" data-reopen="${l.id}">Wieder oeffnen</button>
`}
</td>`;
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 = `
<dl class="kv">
<dt>Eingang</dt><dd>${fmtDate(l.created_at)}</dd>
<dt>E-Mail</dt><dd><a href="mailto:${attr(l.email)}">${esc(l.email)}</a></dd>
<dt>Telefon</dt><dd>${esc(l.phone || "—")}</dd>
<dt>Fahrzeug</dt><dd>${esc(l.vehicle_label || "—")}</dd>
<dt>Zeitraum</dt><dd>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</dd>
<dt>Nachricht</dt><dd style="white-space:pre-wrap;">${esc(l.message || "—")}</dd>
<dt>Status</dt><dd><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></dd>
<dt>Notiz</dt><dd><textarea id="leadNote" rows="3" style="width:100%;">${esc(l.admin_notes || "")}</textarea></dd>
</dl>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.8rem;">
${l.is_active ? `
<button class="btn danger" id="dlgDisq">Ablehnen</button>
<button class="btn" id="dlgQual">Qualifizieren</button>
` : `<button class="btn ghost" id="dlgReopen">Wieder oeffnen</button>`}
</div>`;
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 = `
<td>${fmtDate(c.first_contacted_at)}</td>
<td><strong>${esc(c.name)}</strong><br /><span class="muted">${esc(c.email)}</span></td>
<td>${esc(c.phone || "—")}</td>
<td><code class="muted">${esc(c.lead_id?.slice(0, 8) || "—")}</code></td>
<td><span class="pill pill-${esc(c.status)}">${esc(c.status)}</span></td>
<td style="white-space:nowrap;">
<button class="btn small ghost" data-toggle="${c.id}" data-status="${c.status}">
${c.status === "active" ? "Inaktiv setzen" : "Aktiv setzen"}
</button>
</td>`;
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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[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();
+246
View File
@@ -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 = `<p style="color:var(--danger);">Unable to load vehicles: ${error.message}</p>`;
return;
}
state.vehicles = data || [];
statCarsCount.textContent = state.vehicles.length;
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
brands.map(b => `<option value="${b}">${b}</option>`).join("");
bookingCar.innerHTML = state.vehicles
.map(v => `<option value="${v.id}">${v.brand} ${v.model}</option>`)
.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 = `
<div class="vehicle-photo" style="background-image:url('${escapeAttr(v.photo_url)}');">
<span class="badge">${escapeHtml(v.brand)}</span>
</div>
<div class="vehicle-body">
<p class="model-brand">${escapeHtml(v.brand)}</p>
<h3>${escapeHtml(v.model)}</h3>
<div class="spec-row">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div class="vehicle-footer">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<div style="display:flex;gap:0.4rem;">
<button class="btn ghost small" data-details="${v.id}">${t("details")}</button>
<button class="btn small" data-book="${v.id}">${t("book")}</button>
</div>
</div>
</div>
`;
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 = `
<img src="${escapeAttr(v.photo_url)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
<p>${escapeHtml(desc || "")}</p>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem;">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<button class="btn" id="dialogBook">${t("bookNow")}</button>
</div>
`;
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 = `
<p class="review-quote">"${escapeHtml(r.quote)}"</p>
<p class="review-author">${escapeHtml(r.author)}</p>
`;
reviewDots.innerHTML = list.map((_, i) =>
`<button class="${i === state.reviewIdx ? 'active' : ''}" data-rev="${i}"></button>`
).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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function escapeAttr(s) { return escapeHtml(s); }
// ---------------- Boot ----------------
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
loadVehicles();
+8
View File
@@ -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",
};
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Datenschutz · MC Cars (GmbH)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="shell" style="padding:4rem 1rem;">
<p class="eyebrow">Rechtliches</p>
<h1>Datenschutz</h1>
<p>Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden ueber ein selbstgehostetes Supabase verwaltet.</p>
<p>Ansprechpartner: hello@mccars.at</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
</main>
</body>
</html>
+194
View File
@@ -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];
});
}
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Impressum · MC Cars (GmbH)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="shell" style="padding:4rem 1rem;">
<p class="eyebrow">Rechtliches</p>
<h1>Impressum</h1>
<p>MC Cars (GmbH)</p>
<p>Standort: Steiermark (TBD)</p>
<p>E-Mail: hello@mccars.at</p>
<p>Telefon: +43 316 880000</p>
<p>Firmenbuch und UID werden nachgereicht.</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
</main>
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MC Cars · Sportwagenvermietung Steiermark</title>
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Kautionsfrei, transparent, sofort startklar." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<script src="config.js"></script>
</head>
<body>
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<span class="logo-mark">MC</span>
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menue"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
<button class="lang-toggle" type="button" aria-label="Sprache wechseln">EN</button>
</nav>
</div>
</header>
<main>
<!-- Hero -->
<section class="hero" id="home">
<div class="shell">
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
<h1 data-i18n="heroTitle">Fahren auf hoechstem Niveau.</h1>
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
<div class="hero-cta">
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
<a class="btn ghost" href="#fahrzeuge" data-i18n="viewFleet">Flotte ansehen</a>
</div>
<div class="hero-stats">
<div><strong>0 €</strong><span data-i18n="statDeposit">Kaution</span></div>
<div><strong id="statCarsCount"></strong><span data-i18n="statCars">Fahrzeuge</span></div>
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
</div>
</div>
</section>
<!-- Fleet -->
<section id="fahrzeuge">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie fuer Details oder buchen Sie direkt.</p>
</div>
</div>
<form class="filters" id="filters" onsubmit="return false;">
<label>
<span data-i18n="filterBrand">Marke</span>
<select id="brandFilter"><option value="all" data-i18n="all">Alle</option></select>
</label>
<label>
<span data-i18n="filterSort">Sortierung</span>
<select id="sortFilter">
<option value="sort_order">Empfehlung</option>
<option value="priceAsc" data-i18n="sortPriceAsc">Preis aufsteigend</option>
<option value="priceDesc" data-i18n="sortPriceDesc">Preis absteigend</option>
<option value="powerDesc" data-i18n="sortPowerDesc">Leistung absteigend</option>
</select>
</label>
<label>
<span data-i18n="filterPrice">Max. Preis / Tag</span>
<input type="number" id="priceFilter" min="0" step="50" placeholder="1000" />
</label>
</form>
<div class="vehicle-grid" id="vehicleGrid"></div>
<p id="emptyState" style="display:none;color:var(--muted);text-align:center;padding:2rem 0;" data-i18n="noMatches">Keine Fahrzeuge gefunden.</p>
</div>
</section>
<!-- Why -->
<section id="warum" style="background:var(--bg-elev);">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p>
<h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspass.</h2>
</div>
</div>
<div class="why-grid">
<article class="why-card">
<div class="icon">🛡</div>
<h3 data-i18n="whyInsurance">Versicherungsschutz</h3>
<p data-i18n="whyInsuranceText">Vollkasko mit klarem Selbstbehalt.</p>
</article>
<article class="why-card">
<div class="icon"></div>
<h3 data-i18n="whyFleet">Premium Flotte</h3>
<p data-i18n="whyFleetText">Handverlesene Performance-Modelle.</p>
</article>
<article class="why-card">
<div class="icon"></div>
<h3 data-i18n="whyDeposit">Kautionsfrei</h3>
<p data-i18n="whyDepositText">Sie zahlen nur die Miete.</p>
</article>
</div>
</div>
</section>
<!-- Reviews -->
<section id="stimmen">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="reviewsEyebrow">Kundenmeinungen</p>
<h2 data-i18n="reviewsTitle">Erlebnisse, die bleiben.</h2>
</div>
</div>
<div class="reviews-strip" id="reviewStrip" aria-live="polite"></div>
<div class="review-dots" id="reviewDots"></div>
</div>
</section>
<!-- Booking -->
<section id="buchen" style="background:var(--bg-elev);">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="bookingEyebrow">Jetzt buchen</p>
<h2 data-i18n="bookingTitle">Traumwagen unverbindlich anfragen.</h2>
</div>
</div>
<form class="booking-form" id="bookingForm" novalidate>
<label>
<span data-i18n="fieldName">Name</span>
<input type="text" name="name" required />
</label>
<label>
<span data-i18n="fieldEmail">E-Mail</span>
<input type="email" name="email" required />
</label>
<label>
<span data-i18n="fieldPhone">Telefon</span>
<input type="tel" name="phone" />
</label>
<label>
<span data-i18n="fieldCar">Fahrzeug</span>
<select name="vehicle" id="bookingCar"></select>
</label>
<label>
<span data-i18n="fieldFrom">Von</span>
<input type="date" name="from" required />
</label>
<label>
<span data-i18n="fieldTo">Bis</span>
<input type="date" name="to" required />
</label>
<label class="full">
<span data-i18n="fieldMessage">Nachricht</span>
<textarea name="message" rows="4" data-i18n-placeholder="messagePlaceholder" placeholder="Wuensche, Uhrzeit, Anlass..."></textarea>
</label>
<div class="full" style="display:flex;justify-content:flex-end;">
<button type="submit" class="btn" data-i18n="sendRequest">Anfrage senden</button>
</div>
</form>
<p id="bookingFeedback" class="form-feedback" role="status"></p>
</div>
</section>
</main>
<footer class="site-footer" id="kontakt">
<div class="shell">
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom:0.8rem;">
<span class="logo-mark">MC</span>
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#buchen" data-i18n="navBook">Buchen</a>
</div>
<div>
<h4 data-i18n="footerLegal">Rechtliches</h4>
<a href="impressum.html" data-i18n="imprint">Impressum</a>
<a href="datenschutz.html" data-i18n="privacy">Datenschutz</a>
<a href="#" data-i18n="terms">Mietbedingungen</a>
</div>
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
</div>
</div>
<div class="footer-bottom">
<span>© <span id="year"></span> MC Cars. <span data-i18n="copyright">Alle Rechte vorbehalten.</span></span>
<span>Made in Steiermark</span>
</div>
</div>
</footer>
<!-- Vehicle details dialog -->
<dialog id="carDialog">
<div class="dialog-head">
<h3 id="dialogTitle" style="margin:0;"></h3>
<button class="dialog-close" id="dialogClose" aria-label="Close">×</button>
</div>
<div class="dialog-body" id="dialogBody"></div>
</dialog>
<script type="module" src="app.js"></script>
</body>
</html>
+22
View File
@@ -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;
}
}
+644
View File
@@ -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; }
}
+122
View File
@@ -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
+3
View File
@@ -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
+198
View File
@@ -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;
+252
View File
@@ -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';
+81
View File
@@ -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');