feat: implement server-side pricing calculation and add site settings management

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LagoESP
2026-04-29 20:39:21 +02:00
parent b0bea0bef1
commit 3298efe54b
11 changed files with 288 additions and 44 deletions
+29 -10
View File
@@ -27,12 +27,15 @@ Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on a
### 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.
- `supabase/migrations/08-backend-pricing-and-security.sql``calculate_price` RPC (public, read-only pricing), refactored `create_lead` RPC (accepts no price params, computes server-side), unique partial indexes on `lead_attachments` (max 1 id_document + 1 income_proof per lead), hardened storage RLS (anon INSERT-only on customer-documents, admin SELECT+INSERT only).
- `supabase/migrations/09-site-settings.sql``site_settings` key-value table + RLS (public SELECT, admin full CRUD) + seed row `hero_image_url = '/images/ferrari-main-car.png'`.
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.
- If it's ongoing / idempotent → new file `NN-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint in `docker-compose.yml`.
- Current numbering: `01`, `02`, `08`, `09`. Use the next available number for new migrations.
---
@@ -40,13 +43,15 @@ Both are run by the `post-init` service, which gates on `auth.users` and `storag
| 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 |
| `anon` | Anon JWT (shipped to browser) | `select` on active `vehicles`; `insert` on `leads` only; `select` on `site_settings`; `insert` on `customer-documents` bucket; execute `calculate_price` and `create_lead` RPCs |
| `authenticated` | JWT from `signInWithPassword` | Full CRUD on vehicles/leads/customers/sales_orders/site_settings, `execute` RPCs, storage uploads |
| `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.
- Anon **cannot** read/delete from `customer-documents` bucket — only INSERT (upload). Admin can SELECT + INSERT but not DELETE (prevents evidence destruction).
- 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.
- `create_lead` computes prices server-side via `calculate_price` — no price params accepted from client (prevents price-tampering).
---
@@ -93,6 +98,10 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
- The `web` service uses `nginx:1.27-alpine` directly (no `build:`). Static files and `nginx.conf` are bind-mounted. Updating the frontend is `git pull` + `docker compose up -d --force-recreate 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.
- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, **Einstellungen**. The Einstellungen (Settings) tab manages site-wide configuration (currently: hero image upload).
- `app.js` calls `calculate_price` RPC for sidebar pricing display — **no client-side price computation**.
- `app.js` calls `create_lead` RPC for form submission — prices are computed server-side and stored on the lead.
- `app.js` at boot loads `hero_image_url` from `site_settings` and applies it via CSS variable `--hero-bg` on `.hero`. The `styles.css` fallback is `url('images/ferrari-main-car.png')`.
- `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.
---
@@ -111,6 +120,9 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
| `config.js` not generated, website uses fallback URL | Unraid doesn't preserve execute bits — entrypoint script skipped. Fixed: `config.js` is now written by an inline `entrypoint:` command in docker-compose, no file needed. |
| Website loads but API calls fail ("Failed to fetch") | `config.js` cached by NPM or browser with old `localhost` URL. The `?v=timestamp` cache-buster on the `<script>` tag prevents this. Hard-refresh or check NPM "Cache Assets" toggle. |
| Git pull fails with "Your local changes would be overwritten" | `.env` was modified on the NAS. Use `git checkout -- .env && git pull`, then re-apply the two URL lines with `sed`. |
| `calculate_price` returns PGRST202 "Could not find the function" | Migration `08-backend-pricing-and-security.sql` not applied. Check `post-init` logs or run it manually. |
| Hero image not changing after admin upload | Browser/CDN caching the old image. The URL includes a unique path (`site/hero.ext`) with upsert — hard-refresh the public page. |
| `create_lead` fails with "function does not exist" | Same as calculate_price — migration 08 not applied. |
---
@@ -119,22 +131,29 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
```bash
docker compose ps # all services up (post-init exited 0)
curl http://localhost:55521/rest/v1/vehicles?select=brand -H "apikey: $ANON" # 6 demo cars
curl -X POST http://localhost:55521/rest/v1/rpc/calculate_price \
-H "apikey: $ANON" -H "Content-Type: application/json" \
-d '{"p_vehicle_id":"<uuid>","p_date_from":"2025-01-06","p_date_to":"2025-01-10"}' # pricing JSON
curl http://localhost:55521/rest/v1/site_settings?select=value&key=eq.hero_image_url \
-H "apikey: $ANON" # returns hero image URL
```
In the browser:
1. Open http://\<host\>:55580, submit the booking form.
2. Open http://\<host\>:55580/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://\<host\>:55530 and confirm the `customers.lead_id` FK matches.
1. Open http://\<host\>:55580, verify the hero image loads (dynamic from settings).
2. Submit the booking form — sidebar shows server-computed pricing.
3. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
4. Rotate the password when prompted.
5. The lead you submitted appears in "Aktive Leads" — in real time, with pricing columns.
6. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
7. Go to **Einstellungen** tab. Upload a new hero image. Verify it appears on the public site after refresh.
8. Open Supabase Studio at http://\<host\>:55530 and confirm `site_settings`, `leads` pricing columns, `customers.lead_id` FK.
---
## 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.
- No client-side price calculation — all pricing is server-side via `calculate_price` RPC.
+134 -25
View File
@@ -58,14 +58,17 @@ No auth header except the anon `apikey`. Kong strips the key and forwards with t
### 2.2 Public visitor submits booking
```
browser → supabase.from('leads').insert({...})
→ :55521/rest/v1/leads (POST, apikey=anon)
→ PostgREST → postgres
RLS "leads_anon_insert": insert allowed, select denied
← 201 (no body returned to anon)
browser → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
:55521/rest/v1/rpc/create_lead (POST, apikey=anon)
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price() → computes pricing
→ INSERT INTO leads (with pricing columns)
← lead uuid
```
The frontend never reads leads back. If the INSERT fails (validation, RLS), the UI shows a localized failure string.
No price parameters are accepted from the client — all pricing is computed server-side. This prevents price-tampering. The frontend never reads leads back. If the RPC fails (validation, RLS), the UI shows a localized failure string.
### 2.3 Admin login
@@ -114,6 +117,53 @@ admin.js → UPDATE vehicles SET photo_url = <public url>
Bucket is public-read, write-authenticated, MIME-restricted to `image/*`, 50 MB cap.
### 2.6 Server-side price calculation
```
app.js → supabase.rpc('calculate_price', {p_vehicle_id, p_date_from, p_date_to})
→ :55521/rest/v1/rpc/calculate_price (POST, apikey=anon)
→ PostgREST → postgres.public.calculate_price(uuid, date, date)
← jsonb {daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur,
deposit_eur, total_days, weekday_count, weekend_day_count}
```
Pricing logic lives **only** in the database — the frontend never computes prices. `create_lead` also calls `calculate_price` internally to store an immutable pricing snapshot on the lead row.
### 2.7 Lead submission (server-computed prices)
```
app.js → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price → stores pricing on lead row
← lead uuid
```
No price parameters are accepted from the client. This prevents price-tampering attacks.
### 2.8 Site settings & configurable hero image
```
app.js → supabase.from('site_settings').select('value').eq('key','hero_image_url').single()
→ :55521/rest/v1/site_settings?key=eq.hero_image_url&select=value
→ RLS: anon SELECT allowed
← { value: '/images/ferrari-main-car.png' }
→ app.js sets CSS variable --hero-bg on .hero element
```
Admin upload flow:
```
admin.js → supabase.storage.from('vehicle-photos').upload('site/hero.ext', file, {upsert:true})
→ storage API writes to bucket
← public URL
admin.js → supabase.from('site_settings').upsert({key:'hero_image_url', value: publicUrl})
→ PostgREST → UPDATE site_settings
← 200
```
The CSS uses `var(--hero-bg, url('images/ferrari-main-car.png'))` so the default hero image is shown until JavaScript loads and overrides it.
---
## 3. Database layout
@@ -124,13 +174,26 @@ postgres (database = "postgres")
├── 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
├── vehicles (fleet)
├── leads (form submissions, server-computed pricing)
├── lead_attachments (id_document, income_proof per lead)
├── customers (spawned from qualified leads)
├── customer_attachments (admin-uploaded docs for customers)
├── sales_orders (rental orders linked to customer+lead)
── sales_order_attachments
├── site_settings (key-value config, e.g. hero_image_url)
├── tg_touch_updated_at() trigger fn
├── calculate_price() RPC (public, read-only pricing)
├── create_lead() RPC (server-side price computation)
├── qualify_lead() RPC
├── disqualify_lead() RPC
├── reopen_lead() RPC
├── sales_order_toggle_kaution() RPC
├── sales_order_toggle_rental() RPC
├── sales_order_toggle_complete() RPC
├── customer_update_private_notes() RPC
├── sales_order_update_private_notes() RPC
└── sales_order_upload_attachment() RPC
```
### 3.1 `public.vehicles`
@@ -148,10 +211,12 @@ postgres (database = "postgres")
- `vehicle_id → vehicles(id) ON DELETE SET NULL`
- `vehicle_label text` — denormalized at submit; survives vehicle deletion
- `date_from`, `date_to`, `message`
- Pricing (server-computed, immutable): `daily_subtotal`, `weekend_subtotal`, `subtotal_eur`, `vat_eur`, `total_eur`, `deposit_eur`, `total_days`, `weekday_count`, `weekend_day_count`
- `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'`
- `ip_address inet`, `ip_country text` — geolocation info from submission
- `qualified_at`, `qualified_by → auth.users(id)`
### 3.3 `public.customers`
@@ -159,25 +224,65 @@ postgres (database = "postgres")
- `lead_id → leads(id) ON DELETE RESTRICT` + `unique index` (exactly one customer per lead)
- `name`, `email`, `phone`
- `first_contacted_at`
- `notes`
- `notes`, `private_notes`
- `status check in ('active','inactive')`
- `created_by → auth.users(id)`
### 3.4 RLS matrix
### 3.4 `public.lead_attachments`
- `id uuid pk`
- `lead_id → leads(id) ON DELETE CASCADE`
- `bucket`, `file_path`, `file_name`, `mime_type`
- `kind check in ('id_document', 'income_proof', 'other')`
- Unique partial index: max 1 `id_document` + 1 `income_proof` per lead
| Table | anon | authenticated |
| ---------- | -------- | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| customers | denied | full CRUD |
### 3.5 `public.sales_orders`
- `id uuid pk`
- `customer_id → customers(id)`, `lead_id → leads(id)`
- `order_number`, `private_notes`
- `kaution_paid`, `rental_paid`, `rental_complete` (booleans + timestamps)
- Pricing snapshot: `daily_subtotal`, `weekend_subtotal`, `subtotal_eur`, `vat_eur`, `total_eur`, `deposit_eur`
- `total_days`, `weekday_count`, `weekend_day_count`, `date_from`, `date_to`, `vehicle_label`
### 3.5 RPCs
### 3.6 `public.site_settings`
- `key text pk` — setting identifier (e.g. `hero_image_url`)
- `value text` — setting value (e.g. a public URL or path)
- `updated_at timestamptz`
- RLS: public SELECT for all, authenticated-only INSERT/UPDATE/DELETE
### 3.7 RLS matrix
| Table | anon | authenticated |
| ------------------ | ------------------ | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| lead_attachments | INSERT only | SELECT + INSERT |
| customers | denied | full CRUD |
| sales_orders | denied | full CRUD |
| site_settings | SELECT only | full CRUD |
### 3.8 Storage buckets
| Bucket | Public read | anon writes | authenticated writes | Notes |
| ------------------- | ----------- | ----------- | -------------------- | ----- |
| `vehicle-photos` | yes | no | INSERT (MIME `image/*`, 50 MB) | Fleet photos + site hero image |
| `customer-documents`| no | INSERT only | SELECT + INSERT | ID docs, income proofs |
Anon users may only `INSERT` into `customer-documents` (no SELECT/UPDATE/DELETE). Admin can SELECT + INSERT but not DELETE (prevents accidental evidence destruction).
### 3.9 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. |
| `calculate_price(uuid, date, date)` | anon | Read-only pricing calculator. Returns jsonb: `{daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count}`. Uses vehicle's `daily_price_eur`, applies 20% weekend surcharge, 19% VAT, 3× daily deposit. |
| `create_lead(...)` | anon | Creates a lead with server-computed pricing (calls `calculate_price` internally). Accepts: name, email, phone, vehicle_id, vehicle_label, date_from, date_to, message, source, ip_address, ip_country. No price params from client. |
| `qualify_lead(uuid, text)` | authenticated | Locks lead row, flips to `qualified`+inactive, inserts matching customer + sales_order with pricing snapshot. 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. |
| `sales_order_toggle_kaution(uuid)` | authenticated | Toggles kaution_paid boolean + timestamp on a sales order. |
| `sales_order_toggle_rental(uuid)` | authenticated | Toggles rental_paid boolean + timestamp. |
| `sales_order_toggle_complete(uuid)` | authenticated | Toggles rental_complete boolean + timestamp. |
| `customer_update_private_notes(uuid, text)` | authenticated | Updates private_notes field on a customer. |
| `sales_order_update_private_notes(uuid, text)` | authenticated | Updates private_notes field on a sales order. |
All three are `SECURITY INVOKER`, so `auth.uid()` is the live admin and RLS applies.
@@ -214,9 +319,9 @@ Host port mapping: `55521:8000` (8000 blocked by Docker Desktop's wslrelay on Wi
frontend/
├── nginx.conf (serves /, gzip, cache headers)
├── index.html app.js (public site, anon key, persistSession:false)
├── admin.html admin.js (admin CRM, persistSession:true)
├── admin.html admin.js (admin CRM + settings, persistSession:true)
├── i18n.js (DE/EN)
├── styles.css (copper/charcoal palette)
├── styles.css (copper/charcoal palette, CSS-variable hero image)
├── impressum.html
└── datenschutz.html
```
@@ -227,7 +332,9 @@ frontend/
- The `web` service uses `nginx:1.27-alpine` directly with bind-mounted files (no `build:` step). This is Portainer-compatible: updating the frontend is `git pull` + `docker compose up -d --force-recreate web`.
- `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.
- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, **Einstellungen** (site settings: hero image upload).
- Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
- Public site hero image is loaded dynamically from `site_settings` table at boot and applied via CSS variable `--hero-bg`. Fallback default is baked into `styles.css`.
---
@@ -334,4 +441,6 @@ For real deployments:
- **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.
- **Server-side pricing** — `create_lead` computes prices internally via `calculate_price`. No price parameters are accepted from the client, preventing price-tampering.
- **Document security** — `customer-documents` bucket allows anon INSERT only (upload), no SELECT/DELETE. Admin can SELECT + INSERT but not DELETE. Unique partial indexes enforce max 1 `id_document` + 1 `income_proof` per lead.
- **Storage bucket** (`vehicle-photos`) is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.
+13 -8
View File
@@ -41,7 +41,7 @@ cd /mnt/user/appdata/mc-cars
docker compose up -d
```
First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`). Give it 3060 s to settle.
First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`, `08-backend-pricing-and-security.sql`, `09-site-settings.sql`). Give it 3060 s to settle.
### Stop / reset
@@ -74,9 +74,12 @@ The admin is seeded with `must_change_password = true` in `raw_user_meta_data`.
## 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.leads` — booking form submissions with server-computed pricing. `anon` may `INSERT` only (via `create_lead` RPC); `authenticated` has full CRUD. Status: `new | qualified | disqualified`.
- `public.lead_attachments` — ID documents and income proofs per lead. Max 1 of each enforced by unique partial index.
- `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.
- `public.sales_orders` — rental orders created during qualification, contain pricing snapshot.
- `public.site_settings` — key-value settings table (e.g. `hero_image_url`). Publicly readable, admin-writable.
- RPCs: `calculate_price(uuid, date, date)` (public pricing), `create_lead(...)` (server-side submission), `qualify_lead(uuid, text)`, `disqualify_lead(uuid, text)`, `reopen_lead(uuid)` — transactional, `SECURITY INVOKER`, `authenticated` only (except calculate_price and create_lead which are anon-accessible).
- Realtime: `supabase_realtime` publication broadcasts inserts/updates on leads, customers, vehicles.
## Environment: two variables per deployment
@@ -166,16 +169,18 @@ MC Cars/
│ ├── 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
── 02-leads.sql # leads, customers, RPCs, realtime publication
│ ├── 08-backend-pricing-and-security.sql # calculate_price RPC, refactored create_lead, document security
│ └── 09-site-settings.sql # site_settings table + hero_image_url seed
├── frontend/
│ ├── 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
│ ├── admin.html # auth-gated CRM + settings panel
│ ├── app.js # dynamic hero image, server-side pricing sidebar
│ ├── admin.js # realtime + qualify/disqualify + password change + settings
│ ├── config.js # generated at container start (git-ignored)
│ ├── i18n.js
│ ├── styles.css
│ ├── styles.css # CSS-variable hero image with fallback
│ ├── impressum.html
│ └── datenschutz.html
├── .gitattributes # enforces LF on .sh files
+1
View File
@@ -23,6 +23,7 @@ services:
- ./supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro
- ./supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
kong:
volumes:
+2
View File
@@ -216,6 +216,7 @@ services:
- /mnt/user/appdata/mc-cars/supabase/migrations/06-admin-pricing-documents.sql:/sql/06-admin-pricing-documents.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/07-sales-orders.sql:/sql/07-sales-orders.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
entrypoint: ["sh","-c"]
command:
- |
@@ -240,6 +241,7 @@ services:
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/06-admin-pricing-documents.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/07-sales-orders.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql
echo "post-init done."
restart: "no"
networks: [mccars]
+17
View File
@@ -76,6 +76,7 @@
<button class="tab" data-tab="customers" role="tab"><span data-i18n="adminCustomers">Kunden</span> <span id="customersBadge" class="tab-badge">0</span></button>
<button class="tab" data-tab="orders" role="tab"><span data-i18n="adminTabOrderHistory">Bestellungen</span> <span id="ordersBadge" class="tab-badge">0</span></button>
<button class="tab" data-tab="vehicles" role="tab" data-i18n="adminVehicles">Fahrzeuge</button>
<button class="tab" data-tab="settings" role="tab" data-i18n="adminSettings">Einstellungen</button>
</div>
<!-- LEADS -->
@@ -238,6 +239,22 @@
</div>
</div>
</div>
<!-- SETTINGS -->
<div class="tab-panel" id="tab-settings" style="display:none;">
<div class="panel" style="max-width:600px;">
<h2 data-i18n="adminSettings">Einstellungen</h2>
<div class="admin-form">
<label>
<span data-i18n="adminHeroImage">Hauptbild (Hero-Bereich)</span>
<div class="admin-photo-preview" id="heroPreview" style="aspect-ratio:21/9;"></div>
<input type="file" id="heroImageInput" accept="image/jpeg,image/png,image/webp" />
</label>
<p class="muted" style="font-size:0.82rem;" data-i18n="adminHeroImageHint">JPG/PNG/WebP. Wird als Hintergrundbild im Hero-Bereich der Website angezeigt.</p>
<p class="form-feedback" id="heroFeedback"></p>
</div>
</div>
</div>
</section>
<!-- Order detail dialog -->
+45
View File
@@ -177,6 +177,7 @@ const tabPanels = {
customers: document.querySelector("#tab-customers"),
orders: document.querySelector("#tab-orders"),
vehicles: document.querySelector("#tab-vehicles"),
settings: document.querySelector("#tab-settings"),
};
let activeTab = "leads";
@@ -206,6 +207,7 @@ function renderActiveTab() {
if (activeTab === "customers") renderCustomers();
if (activeTab === "orders") renderOrders();
if (activeTab === "vehicles") renderVehicles();
if (activeTab === "settings") renderSettings();
}
// Sub-tabs (active/inactive leads)
@@ -1100,4 +1102,47 @@ leadDialog.addEventListener("close", onDialogClose);
customerDialog.addEventListener("close", onDialogClose);
orderDialog.addEventListener("close", onDialogClose);
// =========================================================================
// SETTINGS
// =========================================================================
const heroPreview = document.querySelector("#heroPreview");
const heroImageInput = document.querySelector("#heroImageInput");
const heroFeedback = document.querySelector("#heroFeedback");
async function renderSettings() {
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
const url = data?.value || "/images/ferrari-main-car.png";
heroPreview.style.backgroundImage = `url('${url}')`;
}
heroImageInput.addEventListener("change", async () => {
const file = heroImageInput.files?.[0];
if (!file) return;
heroFeedback.className = "form-feedback";
heroFeedback.textContent = "Uploading...";
try {
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const path = `site/hero.${ext}`;
const { error: upErr } = await supabase.storage
.from("vehicle-photos")
.upload(path, file, { contentType: file.type, upsert: true });
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
const publicUrl = pub.publicUrl;
// Save to site_settings
const { error: dbErr } = await supabase
.from("site_settings")
.upsert({ key: "hero_image_url", value: publicUrl, updated_at: new Date().toISOString() }, { onConflict: "key" });
if (dbErr) throw dbErr;
heroPreview.style.backgroundImage = `url('${publicUrl}')`;
heroFeedback.className = "form-feedback";
heroFeedback.textContent = "Gespeichert.";
} catch (err) {
heroFeedback.className = "form-feedback error";
heroFeedback.textContent = err.message || String(err);
}
});
bootstrap();
+8
View File
@@ -522,3 +522,11 @@ langToggle.textContent = getLang() === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
loadVehicles();
// Load hero image from site_settings
(async () => {
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
if (data && data.value) {
document.querySelector(".hero").style.setProperty("--hero-bg", `url('${data.value}')`);
}
})();
+6
View File
@@ -130,6 +130,9 @@ export const translations = {
adminLeads: "Leads",
adminCustomers: "Kunden",
adminVehicles: "Fahrzeuge",
adminSettings: "Einstellungen",
adminHeroImage: "Hauptbild (Hero-Bereich)",
adminHeroImageHint: "JPG/PNG/WebP. Wird als Hintergrundbild im Hero-Bereich der Website angezeigt.",
adminNewVehicle: "Neues Fahrzeug",
adminAllVehicles: "Alle Fahrzeuge",
adminPhotoUpload: "Foto hochladen (JPG/PNG/WebP, max 50 MB)",
@@ -358,6 +361,9 @@ export const translations = {
adminLeads: "Leads",
adminCustomers: "Customers",
adminVehicles: "Vehicles",
adminSettings: "Settings",
adminHeroImage: "Main Photo (Hero Section)",
adminHeroImageHint: "JPG/PNG/WebP. Displayed as the background image in the hero section of the website.",
adminNewVehicle: "New vehicle",
adminAllVehicles: "All vehicles",
adminPhotoUpload: "Upload photo (JPG/PNG/WebP, max 50 MB)",
+1 -1
View File
@@ -246,7 +246,7 @@ section { padding: 5rem 0; }
inset: 0;
background:
linear-gradient(180deg, rgba(11,12,16,0.6) 0%, rgba(11,12,16,0.95) 100%),
url('images/ferrari-main-car.png') center / cover no-repeat;
var(--hero-bg, url('images/ferrari-main-car.png')) center / cover no-repeat;
z-index: -1;
}
+32
View File
@@ -0,0 +1,32 @@
-- 09-site-settings.sql
-- Site-wide settings (key-value). Used for configurable assets like hero image.
create table if not exists public.site_settings (
key text primary key,
value text not null default '',
updated_at timestamptz not null default now()
);
alter table public.site_settings enable row level security;
-- Anyone can read settings (public site needs hero image URL)
drop policy if exists "settings_public_read" on public.site_settings;
create policy "settings_public_read"
on public.site_settings for select
using (true);
-- Only authenticated admins can modify
drop policy if exists "settings_admin_all" on public.site_settings;
create policy "settings_admin_all"
on public.site_settings for all to authenticated
using (true) with check (true);
grant select on public.site_settings to anon, authenticated, service_role;
grant all on public.site_settings to authenticated, service_role;
-- Seed default hero image
insert into public.site_settings (key, value)
values ('hero_image_url', '/images/ferrari-main-car.png')
on conflict (key) do nothing;
notify pgrst, 'reload schema';