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:
@@ -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
@@ -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/
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# MC Cars – Dockerized Supabase CRM
|
||||||
|
|
||||||
|
Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management admin panel. Everything lives under this folder. Copying the folder to another machine and running `docker compose up -d` reproduces the stack bit-for-bit — all runtime state is under `./data/` bind mounts, no named volumes.
|
||||||
|
|
||||||
|
## What's inside
|
||||||
|
|
||||||
|
| Service | Image | Purpose |
|
||||||
|
| ------------- | ------------------------------------ | ----------------------------------------- |
|
||||||
|
| `db` | `postgres:15-alpine` | Postgres with `wal_level=logical` |
|
||||||
|
| `auth` | `supabase/gotrue:v2.158.1` | Email+password auth (signup disabled) |
|
||||||
|
| `rest` | `postgrest/postgrest:v12.2.0` | Auto-generated REST API |
|
||||||
|
| `storage` | `supabase/storage-api:v1.11.13` | S3-like bucket API (backed by `./data/storage`) |
|
||||||
|
| `imgproxy` | `darthsim/imgproxy:v3.8.0` | On-the-fly image transforms |
|
||||||
|
| `realtime` | `supabase/realtime:v2.30.23` | Live `postgres_changes` subscriptions |
|
||||||
|
| `meta` | `supabase/postgres-meta:v0.84.2` | Schema introspection for Studio |
|
||||||
|
| `post-init` | `postgres:15-alpine` | Idempotent bootstrap: seed admin + migrations |
|
||||||
|
| `kong` | `kong:2.8.1` | Single API gateway at `:54321` |
|
||||||
|
| `studio` | `supabase/studio` | Supabase dashboard (`:3000`) |
|
||||||
|
| `web` | local `nginx:alpine` build | Public site + admin panel (`:8080`) |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker Desktop / Docker Engine with Compose v2
|
||||||
|
- Free ports: `3000`, `5432`, `8080`, `54321`, `54443`
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd 'c:\Coding\MC Cars GmbH'
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`). Give it 30–60 s to settle.
|
||||||
|
|
||||||
|
### Stop / reset
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose down # stop, keep data
|
||||||
|
docker compose down -v # stop + delete named volumes (there are none anymore)
|
||||||
|
Remove-Item -Recurse -Force .\data # FULL wipe (needed to re-run first-boot migrations)
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
| Purpose | URL |
|
||||||
|
| ------------------------------- | -------------------------------- |
|
||||||
|
| Public website | http://localhost:8080 |
|
||||||
|
| Admin panel | http://localhost:8080/admin.html |
|
||||||
|
| Supabase Studio | http://localhost:3000 |
|
||||||
|
| API gateway (Kong) | http://localhost:54321 |
|
||||||
|
| Postgres | `localhost:5432` |
|
||||||
|
|
||||||
|
> Admin access is deliberately **not** linked from the public site. Bookmark it.
|
||||||
|
|
||||||
|
## Credentials (dev defaults – rotate before any deployment)
|
||||||
|
|
||||||
|
| Account | Value |
|
||||||
|
| -------------------- | ---------------------------------------- |
|
||||||
|
| Admin bootstrap user | `admin@mccars.local` / `mc-cars-admin` |
|
||||||
|
| Postgres superuser | `postgres` / see `POSTGRES_PASSWORD` |
|
||||||
|
|
||||||
|
The admin is seeded with `must_change_password = true` in `raw_user_meta_data`. On first login the UI **forces** a rotation and refuses to reuse the bootstrap password. The real working password never equals `.env`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
- `public.vehicles` — fleet, public-readable where `is_active`.
|
||||||
|
- `public.leads` — booking form submissions. `anon` may `INSERT` only; `authenticated` has full CRUD. Status: `new | qualified | disqualified`.
|
||||||
|
- `public.customers` — created **only** by qualifying a lead. Hard FK `lead_id` preserves the audit link to the originating lead.
|
||||||
|
- RPCs: `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only.
|
||||||
|
- Realtime: `supabase_realtime` publication broadcasts inserts/updates on leads, customers, vehicles.
|
||||||
|
|
||||||
|
## Portability
|
||||||
|
|
||||||
|
Runtime state under `./data/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── db/ # Postgres cluster (bind mount)
|
||||||
|
└── storage/ # vehicle-photos bucket content
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else (config, migrations, frontend) is in the repo. Zip the folder, scp it, `docker compose up -d` — you have the same stack.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
MC Cars/
|
||||||
|
├── docker-compose.yml # full stack, bind-mount portability
|
||||||
|
├── .env # dev secrets / tunables
|
||||||
|
├── data/ # runtime state (git-ignored)
|
||||||
|
├── supabase/
|
||||||
|
│ ├── kong.yml # gateway routes (/auth, /rest, /realtime, /storage, /pg)
|
||||||
|
│ └── migrations/
|
||||||
|
│ ├── 00-run-init.sh # creates supabase service roles
|
||||||
|
│ ├── 01-init.sql # vehicles + bucket + seed cars
|
||||||
|
│ ├── post-boot.sql # admin user (must_change_password) + bucket row
|
||||||
|
│ └── 02-leads.sql # leads, customers, RPCs, realtime publication
|
||||||
|
├── frontend/
|
||||||
|
│ ├── Dockerfile # nginx + runtime anon-key injection
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ ├── index.html # public DE/EN site, booking form -> leads
|
||||||
|
│ ├── admin.html # auth-gated CRM
|
||||||
|
│ ├── app.js
|
||||||
|
│ ├── admin.js # realtime + qualify/disqualify + password change
|
||||||
|
│ ├── config.js # anon-only runtime config
|
||||||
|
│ ├── i18n.js
|
||||||
|
│ ├── styles.css
|
||||||
|
│ ├── impressum.html
|
||||||
|
│ └── datenschutz.html
|
||||||
|
├── AGENT.md # findings, conventions, traps
|
||||||
|
└── ARQUITECTURE.md # architecture deep-dive
|
||||||
|
```
|
||||||
|
|
||||||
|
See [AGENT.md](AGENT.md) and [ARQUITECTURE.md](ARQUITECTURE.md) for everything deeper.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Bind-mounted service data lives here (db, storage, n8n). Keep tree, ignore contents.
|
||||||
@@ -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" } }
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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 => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[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
@@ -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 => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
})[c]);
|
||||||
|
}
|
||||||
|
function escapeAttr(s) { return escapeHtml(s); }
|
||||||
|
|
||||||
|
// ---------------- Boot ----------------
|
||||||
|
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
|
||||||
|
applyI18n();
|
||||||
|
renderReviews();
|
||||||
|
loadVehicles();
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user