Compare commits
46 Commits
30e296f61b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc902768a1 | |||
| 94eac68da4 | |||
| 557528d85a | |||
| 2f90534877 | |||
| 28db852453 | |||
| 331d0557b0 | |||
| 287629878b | |||
| cec51d6c19 | |||
| 9bc08d994c | |||
| 8be7d5aad2 | |||
| e1f6bd56b0 | |||
| e4bdd85518 | |||
| b4c6a47ce8 | |||
| 597d47f824 | |||
| 44dbf6b93c | |||
| 75b338988d | |||
| 387d2ba2ab | |||
| 3ec79e1923 | |||
| f46ba8cadc | |||
| e34d56e36a | |||
| e24bc743e2 | |||
| 32580781c8 | |||
| d5a219bd50 | |||
| d6713e25f9 | |||
| b21b3937b2 | |||
| f440f88725 | |||
| 408a59bd5c | |||
| 652131a285 | |||
| e986121240 | |||
| bd906dbe15 | |||
| 05de6cc9a4 | |||
| fae2c0120e | |||
| 54d9cdcdc9 | |||
| db4001aaa5 | |||
| b4258edb91 | |||
| aca60696ae | |||
| 926950bd62 | |||
| 9de88a5459 | |||
| 8f5ea34e8b | |||
| 6a70581ef2 | |||
| 3fb0369367 | |||
| dbb4c27535 | |||
| 3a902e7138 | |||
| 3298efe54b | |||
| b0bea0bef1 | |||
| 4c1931cdf4 |
@@ -36,11 +36,21 @@ 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
|
||||
# ---- SMTP / IMAP (MC Cars mailbox) ----
|
||||
SMTP_HOST=heracles.mxrouting.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=office@mc-cars.at
|
||||
SMTP_PASS=fhXTcjWMRpSLYYzXJsN8
|
||||
|
||||
IMAP_HOST=heracles.mxrouting.net
|
||||
IMAP_PORT=993
|
||||
IMAP_USER=office@mc-cars.at
|
||||
IMAP_PASS=fhXTcjWMRpSLYYzXJsN8
|
||||
|
||||
POP3_HOST=heracles.mxrouting.net
|
||||
POP3_PORT=995
|
||||
POP3_USER=office@mc-cars.at
|
||||
POP3_PASS=fhXTcjWMRpSLYYzXJsN8
|
||||
|
||||
# ---- Admin BOOTSTRAP credentials (seeded on first DB init) ----
|
||||
# The user is flagged must_change_password=true. The REAL working password
|
||||
@@ -51,3 +61,19 @@ ADMIN_PASSWORD=mc-cars-admin
|
||||
# ---- Storage ----
|
||||
STORAGE_BACKEND=file
|
||||
FILE_SIZE_LIMIT=52428800
|
||||
|
||||
# ---- n8n ----
|
||||
N8N_ENCRYPTION_KEY=mc-cars-n8n-encryption-key-change-me
|
||||
N8N_USER_EMAIL=admin@mccars.local
|
||||
N8N_USER_PASSWORD=McCars-N8n-Admin1
|
||||
N8N_WEBHOOK_URL=http://localhost:55521/webhook/manual-email-send
|
||||
N8N_POSTGRES_CREDENTIAL_ID=AWozEaiOSymMj7JF
|
||||
N8N_POSTGRES_CREDENTIAL_NAME=Postgres account
|
||||
N8N_SMTP_CREDENTIAL_ID=nRMemi1sz2C0N4Vu
|
||||
N8N_SMTP_CREDENTIAL_NAME=SMTP account
|
||||
N8N_SMTP_HOST=heracles.mxrouting.net
|
||||
N8N_SMTP_USER=office@mc-cars.at
|
||||
N8N_SMTP_PASS=fhXTcjWMRpSLYYzXJsN8
|
||||
N8N_PAYPAL_KAUTION_LINK=https://www.google.at
|
||||
N8N_PAYPAL_MIETE_LINK=https://www.google.at
|
||||
N8N_PAYMENT_WORKFLOW_ID=rI1gUpcRXSikxWhh
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
# Bind-mounted runtime state (keep folder, ignore contents)
|
||||
/data/db/
|
||||
/data/storage/
|
||||
/data/n8n/
|
||||
/data/**/.cache/
|
||||
/data/**/crash.journal
|
||||
/data/**/n8nEventLog.log
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
@@ -16,3 +20,6 @@ docker-compose.override.yml
|
||||
|
||||
# Generated at container start by 99-config.sh — never commit
|
||||
frontend/config.js
|
||||
|
||||
.playwright-mcp
|
||||
node_modules/
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Production Deployment - n8n Webhook Routing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure your production environment has:
|
||||
- `docker-compose.yml` and `docker-compose.local.yml` updated with new n8n webhook routing
|
||||
- `supabase/kong.yml` updated with n8n webhook service
|
||||
- `frontend/admin.js` updated with new sendOrderEmailDirect function
|
||||
- Production domain configured (e.g., `your-domain.com`)
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Update Production Config
|
||||
|
||||
Edit `frontend/config.js` and replace `localhost:55521` with your production domain:
|
||||
|
||||
```javascript
|
||||
window.MCCARS_CONFIG={
|
||||
SUPABASE_URL:"https://your-domain.com",
|
||||
SUPABASE_ANON_KEY:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
N8N_WEBHOOK_URL:"https://your-domain.com/webhook/manual-email-send"
|
||||
};
|
||||
```
|
||||
|
||||
Replace:
|
||||
- `your-domain.com` with your actual production domain
|
||||
- Keep the same ANON_KEY value
|
||||
|
||||
### 2. Optional: Configure WEBHOOK_DOMAIN
|
||||
|
||||
If you want n8n to know its public webhook URL (for n8n UI display), set environment variable:
|
||||
|
||||
```bash
|
||||
export WEBHOOK_DOMAIN=https://your-domain.com
|
||||
```
|
||||
|
||||
This tells n8n that webhooks are accessible at `https://your-domain.com/webhook/*` from the internet.
|
||||
|
||||
### 3. Deploy Updated Files
|
||||
|
||||
Push these files to production:
|
||||
- `supabase/kong.yml` (updated with n8n webhook service)
|
||||
- `docker-compose.yml` (updated WEBHOOK_URL variable syntax)
|
||||
- `frontend/config.js` (updated with production domain)
|
||||
- `frontend/admin.js` (updated sendOrderEmailDirect function)
|
||||
|
||||
### 4. Restart Stack on Production Server
|
||||
|
||||
```bash
|
||||
# On production host
|
||||
cd /mnt/user/appdata/mc-cars # or your deployment path
|
||||
|
||||
# Pull latest code
|
||||
git pull origin dev # or your deployment branch
|
||||
|
||||
# Restart with new config
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
|
||||
# Verify services are healthy
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 5. Verify Webhook Routing
|
||||
|
||||
Test webhook from production domain:
|
||||
|
||||
```bash
|
||||
curl 'https://your-domain.com/webhook/manual-email-send' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-d 'sales_order_id=YOUR_ORDER_ID'
|
||||
```
|
||||
|
||||
Expected response: 200 OK with n8n workflow result
|
||||
|
||||
## Network Setup
|
||||
|
||||
Kong must be accessible from the internet:
|
||||
- **Port 55521** exposed via reverse proxy (nginx/Apache) or firewall rule
|
||||
- Domain DNS points to production server
|
||||
- SSL certificate configured (recommended to use Kong's 8443 port with cert)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to fetch" on send email button
|
||||
|
||||
1. Check Kong is routing webhook:
|
||||
```bash
|
||||
docker-compose exec kong curl -v http://n8n:5678/webhook/manual-email-send
|
||||
```
|
||||
|
||||
2. Verify Kong config loaded:
|
||||
```bash
|
||||
docker-compose logs kong | grep "n8n-webhooks"
|
||||
```
|
||||
|
||||
3. Check n8n workflow is active:
|
||||
```bash
|
||||
docker-compose logs n8n | grep "webhook"
|
||||
```
|
||||
|
||||
### CORS errors
|
||||
|
||||
Ensure Kong's CORS plugin is enabled for `/webhook/` routes (should be in kong.yml):
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
- name: cors
|
||||
```
|
||||
|
||||
### Webhook not triggering from browser
|
||||
|
||||
Verify in browser DevTools:
|
||||
1. Network tab shows POST to `/webhook/manual-email-send`
|
||||
2. Response status is 200 (not 404 or 500)
|
||||
3. Check n8n logs for workflow execution
|
||||
|
||||
## Rollback
|
||||
|
||||
If issues occur:
|
||||
|
||||
```bash
|
||||
# Rollback config.js to localhost for debugging
|
||||
git checkout frontend/config.js
|
||||
docker-compose up -d
|
||||
|
||||
# Then fix and redeploy
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] Kong routing `/webhook/*` to n8n ✓
|
||||
- [ ] Frontend config.js has production domain ✓
|
||||
- [ ] Admin portal can reach Kong on correct port ✓
|
||||
- [ ] Webhook accepts POST requests ✓
|
||||
- [ ] n8n workflow triggers and sends email ✓
|
||||
- [ ] Email appears in order record ✓
|
||||
@@ -0,0 +1,152 @@
|
||||
# n8n Webhook Routing Configuration
|
||||
|
||||
## Overview
|
||||
|
||||
n8n is intentionally kept internal to the Docker network and **not exposed to the internet**. To allow the browser to trigger n8n workflows via webhooks, Kong (the API gateway) proxies webhook requests to the internal n8n service.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser Kong (Port 55521) n8n (Port 5678, internal)
|
||||
| | |
|
||||
| POST /webhook/* | |
|
||||
|----------------------> | (no strip_path) |
|
||||
| | POST /webhook/* |
|
||||
| |--------------------------> |
|
||||
| | Webhook triggers |
|
||||
| | workflow |
|
||||
|<----- Response --------|<---------------------------|
|
||||
```
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
### 1. Kong Configuration (`supabase/kong.yml`)
|
||||
|
||||
Added a new service to route webhook traffic to internal n8n:
|
||||
|
||||
```yaml
|
||||
- name: n8n-webhooks
|
||||
url: http://n8n:5678/
|
||||
routes:
|
||||
- name: n8n-webhooks-all
|
||||
strip_path: false
|
||||
paths:
|
||||
- /webhook/
|
||||
plugins:
|
||||
- name: cors
|
||||
```
|
||||
|
||||
- `strip_path: false` ensures the full `/webhook/...` path is forwarded to n8n
|
||||
- CORS plugin allows browser cross-origin requests (all origins for internal workflow triggers)
|
||||
|
||||
### 2. Docker Compose (`docker-compose.yml`)
|
||||
|
||||
**Kong service:**
|
||||
- Added `n8n` to the `depends_on` list (waits for n8n to start before Kong)
|
||||
|
||||
**n8n service:**
|
||||
- Updated `WEBHOOK_URL` environment variable to use `${WEBHOOK_DOMAIN:http://localhost:55590}/`
|
||||
- This allows production deployments to override the default localhost URL
|
||||
|
||||
### 3. Frontend Configuration (`frontend/config.js`)
|
||||
|
||||
Updated the webhook URL configuration:
|
||||
|
||||
```javascript
|
||||
N8N_WEBHOOK_URL: "/webhook/manual-email-send"
|
||||
```
|
||||
|
||||
This is a **same-origin request** path that works for both:
|
||||
- **Local:** `http://localhost:55521/webhook/manual-email-send` (Kong on port 55521)
|
||||
- **Production:** `https://your-domain.com/webhook/manual-email-send`
|
||||
|
||||
### 4. Admin UI (`frontend/admin.js`)
|
||||
|
||||
Updated `sendOrderEmailDirect()` function to use the configured webhook URL directly:
|
||||
|
||||
```javascript
|
||||
const n8nUrl = window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "/webhook/manual-email-send";
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### For Production Deployment:
|
||||
|
||||
1. **Update Kong configuration** by deploying the modified `supabase/kong.yml`
|
||||
- Kong will automatically reload the config and start proxying `/webhook/*` requests
|
||||
|
||||
2. **Set environment variables** (in your `.env` file):
|
||||
```bash
|
||||
# Optional: Override n8n webhook domain (defaults to localhost)
|
||||
WEBHOOK_DOMAIN=https://your-domain.com
|
||||
```
|
||||
|
||||
If not set, n8n will use the default `http://localhost:55590/` (only works internally)
|
||||
|
||||
3. **Deploy the updated code**:
|
||||
- `frontend/config.js` with the new webhook URL
|
||||
- `frontend/admin.js` with the updated sendOrderEmailDirect function
|
||||
- `docker-compose.yml` with Kong n8n dependency
|
||||
- `supabase/kong.yml` with the new n8n service
|
||||
|
||||
4. **Restart the stack**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### For Local Development:
|
||||
|
||||
No special configuration needed:
|
||||
- Kong is already on port 55521
|
||||
- Browser requests to `/webhook/manual-email-send` will be proxied to internal n8n
|
||||
- Works the same as production (same-origin requests)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Browser action**: User clicks "Email senden" button in order dialog
|
||||
2. **Browser request**: JavaScript POSTs to `/webhook/manual-email-send` (same origin)
|
||||
3. **Kong routing**: Kong receives request, forwards to `http://n8n:5678/webhook/manual-email-send`
|
||||
4. **n8n webhook**: n8n webhook listener triggers the manual-email-send workflow
|
||||
5. **Workflow execution**: n8n fetches order data, builds email, sends via SMTP
|
||||
6. **Response**: Workflow returns success/error response to browser
|
||||
|
||||
## Security
|
||||
|
||||
- **Network isolation**: n8n remains internal, not exposed to internet
|
||||
- **No authentication required**: Webhook path is open (can be restricted later if needed)
|
||||
- **CORS enabled**: Allows browser requests to Kong
|
||||
- **Kong isolation**: Kong is the only service exposed; internal services hidden behind it
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to fetch" error in browser
|
||||
|
||||
1. Check Kong is routing properly:
|
||||
```bash
|
||||
# Test from inside docker network
|
||||
docker-compose exec kong curl -v http://n8n:5678/webhook/manual-email-send
|
||||
```
|
||||
|
||||
2. Verify Kong config loaded:
|
||||
```bash
|
||||
docker-compose logs kong | grep "n8n-webhooks"
|
||||
```
|
||||
|
||||
3. Check n8n is running:
|
||||
```bash
|
||||
docker-compose logs n8n
|
||||
```
|
||||
|
||||
### n8n workflow not triggering
|
||||
|
||||
1. Verify webhook path in n8n workflow (should be exactly `/webhook/manual-email-send`)
|
||||
2. Check n8n logs for webhook errors:
|
||||
```bash
|
||||
docker-compose logs n8n | grep webhook
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Kong configuration format: https://docs.konghq.com/deck/latest/
|
||||
- n8n webhooks: https://docs.n8n.io/nodes/n8n-nodes-base.Webhook/
|
||||
- Docker networking: https://docs.docker.com/engine/reference/commandline/network_connect/
|
||||
@@ -16,15 +16,52 @@ Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management
|
||||
| `post-init` | `postgres:15-alpine` | Idempotent bootstrap: seed admin + migrations |
|
||||
| `kong` | `kong:2.8.1` | Single API gateway at `:55521` |
|
||||
| `studio` | `supabase/studio` | Supabase dashboard (`:55530`) |
|
||||
| `web` | `nginx:1.27-alpine` | Public site + admin panel (`:55580`) |
|
||||
| `web` | `nginx:1.27-alpine` | Public website (`:55580`) |
|
||||
| `web-admin` | `nginx:1.27-alpine` | Admin web entrypoint (`:55581`) |
|
||||
| `n8n` | `n8nio/n8n:latest` | Automation UI/API (`:55590`) |
|
||||
| `gotenberg` | `gotenberg/gotenberg:8` | DOCX/PDF conversion (internal only) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker Engine with Compose v2 (or Portainer with Stacks)
|
||||
- Free ports: `55521`, `55530`, `55532`, `55543`, `55580`
|
||||
- Free ports: `55521`, `55530`, `55532`, `55543`, `55580`, `55581`, `55590`
|
||||
|
||||
## Run
|
||||
|
||||
### Local Dev (Windows/macOS/Linux)
|
||||
|
||||
Use the local override so bind mounts point to this repository (for example `./data/db`, `./data/storage`, `./data/n8n`).
|
||||
|
||||
Start local stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
Stop local stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||
```
|
||||
|
||||
Delete local mounts and recreate from scratch:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v --remove-orphans
|
||||
rm -rf ./data/db ./data/storage ./data/n8n
|
||||
mkdir -p ./data/db ./data/storage ./data/n8n
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
PowerShell equivalent for cleanup:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v --remove-orphans
|
||||
Remove-Item .\data\db,.\data\storage,.\data\n8n -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Path .\data\db,.\data\storage,.\data\n8n -Force | Out-Null
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
### Via Portainer (recommended)
|
||||
|
||||
1. Clone the repo onto the host: `git clone <repo> /mnt/user/appdata/mc-cars`
|
||||
@@ -41,7 +78,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 30–60 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 30–60 s to settle.
|
||||
|
||||
### Stop / reset
|
||||
|
||||
@@ -55,9 +92,11 @@ rm -rf /mnt/user/appdata/mc-cars/data/db # FULL DB wipe (re-runs fir
|
||||
| Purpose | URL |
|
||||
| ------------------------------- | --------------------------------- |
|
||||
| Public website | http://\<host\>:55580 |
|
||||
| Admin panel | http://\<host\>:55580/admin.html |
|
||||
| Admin web (dedicated nginx) | http://\<host\>:55581 |
|
||||
| Admin page | http://\<host\>:55581/admin.html |
|
||||
| Supabase Studio | http://\<host\>:55530 |
|
||||
| API gateway (Kong) | http://\<host\>:55521 |
|
||||
| n8n | http://\<host\>:55590 |
|
||||
| Postgres | `<host>:55532` |
|
||||
|
||||
> Admin access is deliberately **not** linked from the public site. Bookmark it.
|
||||
@@ -74,29 +113,59 @@ 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
|
||||
## Environment: three variables per deployment
|
||||
|
||||
Only two lines in `.env` need changing between environments:
|
||||
Three variables in `.env` need changing between environments:
|
||||
|
||||
| Variable | Local dev | Production |
|
||||
|---|---|---|
|
||||
| `SITE_URL` | `http://localhost:55580` | `https://your.domain.com` |
|
||||
| `SUPABASE_PUBLIC_URL` | `http://localhost:55521` | `https://your.domain.com` |
|
||||
| `N8N_WEBHOOK_URL` | `http://localhost:55521/webhook/manual-email-send` | `https://your.domain.com/webhook/manual-email-send` |
|
||||
|
||||
All other GoTrue URLs (`API_EXTERNAL_URL`, `GOTRUE_SITE_URL`, `GOTRUE_URI_ALLOW_LIST`) are derived automatically in `docker-compose.yml`.
|
||||
|
||||
On the NAS:
|
||||
### Quick setup with deploy-setup.sh
|
||||
|
||||
Use the included deployment script to update all environment variables at once:
|
||||
|
||||
```bash
|
||||
sed -i 's|SITE_URL=.*|SITE_URL=https://your.domain.com|' .env
|
||||
sed -i 's|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://your.domain.com|' .env
|
||||
docker compose up -d --force-recreate web
|
||||
./deploy-setup.sh https://www.mc-cars.at
|
||||
```
|
||||
|
||||
This updates `.env` and outputs the configuration. Then restart:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Manual setup (legacy sed method)
|
||||
|
||||
```bash
|
||||
sed -i 's|SITE_URL=.*|SITE_URL=https://www.mc-cars.at|' .env
|
||||
sed -i 's|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://www.mc-cars.at|' .env
|
||||
sed -i 's|N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=https://www.mc-cars.at/webhook/manual-email-send|' .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### How n8n webhooks work
|
||||
|
||||
- n8n runs internally (not exposed to the internet)
|
||||
- Kong API gateway proxies `/webhook/*` traffic to internal n8n
|
||||
- Browser requests to `https://your.domain.com/webhook/manual-email-send` route through Kong → n8n
|
||||
- Frontend config is generated at container startup from `N8N_WEBHOOK_URL` environment variable
|
||||
|
||||
See [N8N_WEBHOOK_ROUTING.md](N8N_WEBHOOK_ROUTING.md) for full architecture details.
|
||||
|
||||
## Deployment & portability
|
||||
|
||||
Runtime state under `/mnt/user/appdata/mc-cars/data/`:
|
||||
@@ -166,16 +235,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
|
||||
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
# MC Cars SEO & Positioning Guide
|
||||
**For demo.lago.dev Domain**
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
MC Cars is a premium sports car rental service in Styria (Steiermark), Austria. The SEO strategy focuses on:
|
||||
|
||||
- **Geographic Targeting**: Steiermark/Austria region with German market focus
|
||||
- **Keyword Focus**: Sports car rentals, luxury car rentals, premium vehicles
|
||||
- **Target Audience**: High-net-worth individuals looking for premium driving experiences
|
||||
- **Languages**: German (DE) primary, English (EN) secondary
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical SEO Implementation
|
||||
|
||||
### 2.1 Implemented Features
|
||||
|
||||
✅ **Canonical Tags**
|
||||
- Each page has a canonical tag pointing to `https://demo.lago.dev/[page]`
|
||||
- Prevents duplicate content issues
|
||||
|
||||
✅ **hreflang Tags**
|
||||
- Bilingual support with proper hreflang declarations
|
||||
- German (de) and English (en) language variants
|
||||
- x-default fallback to German homepage
|
||||
|
||||
✅ **Meta Tags**
|
||||
- **Title Tags**: 50-60 characters, includes brand name and location
|
||||
- **Meta Descriptions**: 155-160 characters, compelling and action-oriented
|
||||
- **Keywords**: Relevant German keywords for sports car rental
|
||||
- **Robots**: Proper directives (index/follow for public pages, noindex for admin)
|
||||
- **Language**: `lang="de"` attribute on HTML element
|
||||
- **Theme Color**: Brand color for mobile browsers
|
||||
|
||||
✅ **Open Graph (OG) Tags**
|
||||
- `og:type`: website/article as appropriate
|
||||
- `og:title`, `og:description`, `og:url`, `og:image`
|
||||
- `og:locale`: de_AT for Austrian German
|
||||
- Enables rich sharing on Facebook, LinkedIn, etc.
|
||||
|
||||
✅ **Twitter Card Tags**
|
||||
- `twitter:card`: summary_large_image
|
||||
- Enables rich previews on Twitter/X
|
||||
- Uses same images as OG tags
|
||||
|
||||
✅ **JSON-LD Structured Data**
|
||||
- LocalBusiness schema for MC Cars
|
||||
- Organization schema for company info
|
||||
- BreadcrumbList for each page
|
||||
- Service area (Steiermark, Austria)
|
||||
- ContactPoint information
|
||||
|
||||
✅ **Robots.txt**
|
||||
- Location: `/robots.txt`
|
||||
- Allows public pages, disallows admin panel
|
||||
- Blocks known bad bots (Ahrefs, Semrush, etc.)
|
||||
- Sitemap declaration
|
||||
|
||||
✅ **XML Sitemap**
|
||||
- Location: `/sitemap.xml`
|
||||
- All public pages listed with priorities
|
||||
- Change frequency and last modified dates
|
||||
- hreflang annotations for language variants
|
||||
- Homepage priority: 1.0 (highest)
|
||||
- Legal pages: 0.6-0.7
|
||||
|
||||
### 2.2 Performance Optimizations
|
||||
|
||||
✅ **Gzip Compression**
|
||||
- Enabled for: text/css, text/javascript, application/json
|
||||
- Minimum 1KB threshold
|
||||
- Compression level 5 (balanced speed/ratio)
|
||||
|
||||
✅ **Caching Strategy**
|
||||
- **HTML Pages**: No-store (always fresh)
|
||||
- **config.js**: No-store (runtime configuration)
|
||||
- **Images/Fonts**: 7 days cache
|
||||
- **CSS/JS**: No-cache (browser validates)
|
||||
- **Robots.txt/Sitemap**: 1 week cache
|
||||
|
||||
✅ **Security Headers**
|
||||
- X-Frame-Options: SAMEORIGIN (clickjacking protection)
|
||||
- X-Content-Type-Options: nosniff (MIME type sniffing)
|
||||
- X-XSS-Protection: 1; mode=block (XSS protection)
|
||||
- Referrer-Policy: strict-origin-when-cross-origin
|
||||
- Content-Security-Policy: Balanced for dynamic content
|
||||
- Permissions-Policy: Restrict unnecessary APIs
|
||||
|
||||
✅ **HTTP Keep-Alive**
|
||||
- Enabled for persistent connections
|
||||
- Improves performance for multiple requests
|
||||
|
||||
---
|
||||
|
||||
## 3. On-Page SEO Best Practices
|
||||
|
||||
### 3.1 Current Implementation
|
||||
|
||||
✅ **Title Tags**
|
||||
- Homepage: "MC Cars · Sportwagenvermietung Steiermark" (54 chars)
|
||||
- Legal pages: Descriptive with brand name
|
||||
|
||||
✅ **Meta Descriptions**
|
||||
- All pages have unique descriptions
|
||||
- Include primary keywords naturally
|
||||
- Action-oriented language ("Premium", "Transparent", "Immediate")
|
||||
|
||||
✅ **Heading Hierarchy**
|
||||
- H1: Main page title
|
||||
- H2: Section headings (Fleet, Why Us, Reviews, Booking)
|
||||
- Proper semantic structure
|
||||
|
||||
✅ **Content Structure**
|
||||
- Clear section organization
|
||||
- Call-to-action (CTA) buttons prominent
|
||||
- User engagement elements (filters, reviews)
|
||||
|
||||
✅ **Accessibility (ARIA)**
|
||||
- `aria-label` attributes on buttons and links
|
||||
- `aria-live` regions for dynamic content
|
||||
- Semantic nav elements
|
||||
|
||||
### 3.2 Recommendations for Content Improvement
|
||||
|
||||
📌 **Add ALT Text to Images**
|
||||
- Vehicle images: "[Brand] [Model] [Year] Sports Car"
|
||||
- Logo: "MC Cars logo"
|
||||
- Screenshots: Descriptive purpose
|
||||
|
||||
📌 **Optimize Image Files**
|
||||
- Use WebP format with JPEG fallback
|
||||
- Compress images to <100KB each
|
||||
- Use responsive images (srcset)
|
||||
|
||||
📌 **Create More Landing Pages**
|
||||
- Per-vehicle-model pages (Porsche, Ferrari, Lamborghini, etc.)
|
||||
- Per-region pages if expanding (Graz, Salzburg, Wien)
|
||||
- Guide pages ("How to book", "Insurance explained", etc.)
|
||||
|
||||
📌 **Add FAQ Schema**
|
||||
- Common questions about rentals
|
||||
- Booking process
|
||||
- Insurance and deposits
|
||||
- Helps with Rich Snippets in SERPs
|
||||
|
||||
---
|
||||
|
||||
## 4. Keyword Strategy
|
||||
|
||||
### 4.1 Target Keywords (German)
|
||||
|
||||
| Keyword | Search Vol | Competition | Focus |
|
||||
|---------|-----------|------------|-------|
|
||||
| Sportwagenvermietung Steiermark | Medium | Low | Primary |
|
||||
| Luxusauto mieten Graz | Medium | Low | Primary |
|
||||
| Porsche mieten Österreich | High | Medium | Secondary |
|
||||
| Ferrari Vermietung | Low | High | Tertiary |
|
||||
| Sportwagenverleih | High | High | Secondary |
|
||||
| Kaution Autovermietung | Medium | Medium | Secondary |
|
||||
| Weekend Sportwagen | Low | Low | Tertiary |
|
||||
| Premium Fahrzeug Verleih | Medium | Low | Primary |
|
||||
|
||||
### 4.2 Keyword Placement
|
||||
|
||||
- **Title Tags**: Include primary keyword
|
||||
- **Meta Descriptions**: Natural inclusion (1-2x)
|
||||
- **H1/H2**: Primary keyword in main heading
|
||||
- **First 100 words**: Include target keyword naturally
|
||||
- **Body Content**: 1-2% keyword density (natural)
|
||||
- **Internal Links**: Keyword-rich anchor text
|
||||
|
||||
---
|
||||
|
||||
## 5. Link Building Strategy
|
||||
|
||||
### 5.1 Internal Linking
|
||||
|
||||
✅ **Current Structure**
|
||||
- Navigation links to all main sections
|
||||
- Footer links to legal pages
|
||||
- Home link in header
|
||||
|
||||
📌 **Recommendations**
|
||||
- Add contextual links between related content
|
||||
- Create "Related Articles" section if blog added
|
||||
- Link from legal pages back to homepage
|
||||
|
||||
### 5.2 External Linking (Backlinks)
|
||||
|
||||
📌 **High-Value Link Opportunities**
|
||||
- Local Steiermark business directories
|
||||
- Austrian tourism websites
|
||||
- Premium car enthusiast forums
|
||||
- Regional automotive publications
|
||||
- Local event sponsorships (car shows, races)
|
||||
- University alumni networks (target demographic)
|
||||
|
||||
📌 **Link Building Tactics**
|
||||
- Guest posts on automotive blogs
|
||||
- Local chamber of commerce listing
|
||||
- Sponsorship of local events
|
||||
- PR mentions in local media
|
||||
- Partnerships with luxury brands
|
||||
|
||||
---
|
||||
|
||||
## 6. Technical Configuration for demo.lago.dev
|
||||
|
||||
### 6.1 DNS Configuration
|
||||
|
||||
```
|
||||
demo.lago.dev A record → [server IP]
|
||||
demo.lago.dev AAAA record → [IPv6 if available]
|
||||
```
|
||||
|
||||
### 6.2 SSL/TLS
|
||||
|
||||
✅ HTTPS enabled (required by Google)
|
||||
- Ensure valid SSL certificate for demo.lago.dev
|
||||
- Auto-redirect HTTP → HTTPS
|
||||
|
||||
### 6.3 Server Configuration
|
||||
|
||||
✅ Nginx optimizations applied:
|
||||
- Gzip compression enabled
|
||||
- Security headers configured
|
||||
- Proper cache directives
|
||||
- robots.txt served at root
|
||||
- sitemap.xml served at root
|
||||
|
||||
### 6.4 Google Search Console Setup
|
||||
|
||||
📌 **Required Steps**
|
||||
1. Add property: https://demo.lago.dev
|
||||
2. Verify ownership (DNS/HTML tag/GSC file)
|
||||
3. Submit sitemap.xml
|
||||
4. Monitor coverage, clicks, impressions
|
||||
5. Check Core Web Vitals
|
||||
6. Review security issues
|
||||
|
||||
### 6.5 Bing Webmaster Tools
|
||||
|
||||
📌 **Setup**
|
||||
1. Add property
|
||||
2. Verify ownership
|
||||
3. Submit sitemap.xml
|
||||
4. Monitor crawl stats
|
||||
|
||||
---
|
||||
|
||||
## 7. Content Strategy for Growth
|
||||
|
||||
### 7.1 Blog/Resources (Future)
|
||||
|
||||
📌 **Content Ideas**
|
||||
- "Guide to Renting a Sports Car in Austria"
|
||||
- "Porsche 911 vs Ferrari F8 Tributo: Comparison"
|
||||
- "Insurance Explained: Full Coverage for Luxury Rentals"
|
||||
- "Top 5 Scenic Drives in Steiermark"
|
||||
- "How to Prepare for Your Sports Car Experience"
|
||||
- "Deposit Types Explained: Cash vs PayPal"
|
||||
|
||||
### 7.2 Video Content
|
||||
|
||||
📌 **YouTube Strategy**
|
||||
- Vehicle showcase videos (4K, cinematic)
|
||||
- Customer testimonials
|
||||
- "How to Book" tutorial
|
||||
- Road trip videos from Steiermark
|
||||
- Racing track experience highlights
|
||||
|
||||
### 7.3 Social Media Signals
|
||||
|
||||
📌 **Platforms to Optimize**
|
||||
- Instagram: Vehicle photos, customer experiences
|
||||
- Facebook: Local community engagement
|
||||
- LinkedIn: B2B partnerships, corporate rentals
|
||||
- TikTok: Trendy content for younger audience (short clips)
|
||||
|
||||
---
|
||||
|
||||
## 8. Local SEO Optimization
|
||||
|
||||
### 8.1 Local Business Schema
|
||||
|
||||
✅ **Implemented**
|
||||
- LocalBusiness with service area (Steiermark)
|
||||
- Geo coordinates included
|
||||
- Service area bounding box
|
||||
|
||||
### 8.2 Google My Business
|
||||
|
||||
📌 **Setup Recommendations**
|
||||
- Create GMB profile for MC Cars GmbH
|
||||
- Add address (if available for demo)
|
||||
- Add phone and email
|
||||
- Add business photos
|
||||
- Respond to reviews quickly
|
||||
- Post regular updates
|
||||
|
||||
### 8.3 Local Directories
|
||||
|
||||
📌 **List Presence**
|
||||
- Austrian business registries
|
||||
- Steiermark tourism boards
|
||||
- Local automotive directories
|
||||
- Premium rental marketplaces
|
||||
|
||||
---
|
||||
|
||||
## 9. Mobile SEO
|
||||
|
||||
### 9.1 Current Implementation
|
||||
|
||||
✅ Responsive design with viewport meta tag
|
||||
✅ Touch-friendly buttons and navigation
|
||||
✅ Mobile-optimized forms
|
||||
|
||||
### 9.2 Mobile Best Practices
|
||||
|
||||
📌 **Core Web Vitals Targets**
|
||||
- Largest Contentful Paint (LCP): < 2.5s
|
||||
- First Input Delay (FID): < 100ms
|
||||
- Cumulative Layout Shift (CLS): < 0.1
|
||||
|
||||
📌 **Mobile Optimization**
|
||||
- Test with Google PageSpeed Insights
|
||||
- Optimize image loading
|
||||
- Minimize CSS/JS blocking
|
||||
- Use lazy loading for images below fold
|
||||
|
||||
---
|
||||
|
||||
## 10. Monitoring & Analytics
|
||||
|
||||
### 10.1 Tools Setup
|
||||
|
||||
📌 **Essential Tools**
|
||||
- Google Analytics 4 (GA4)
|
||||
- Google Search Console (GSC)
|
||||
- Bing Webmaster Tools
|
||||
- Lighthouse (Chrome DevTools)
|
||||
|
||||
### 10.2 KPIs to Track
|
||||
|
||||
| Metric | Target | Frequency |
|
||||
|--------|--------|-----------|
|
||||
| Organic traffic | +50% quarterly | Weekly |
|
||||
| Keyword rankings | Top 5 for primary KWs | Bi-weekly |
|
||||
| Click-through rate (CTR) | 3-5% | Weekly |
|
||||
| Average session duration | >2 minutes | Weekly |
|
||||
| Conversion rate | 2-5% | Daily |
|
||||
| Bounce rate | <40% | Weekly |
|
||||
| Core Web Vitals | All "Good" | Bi-weekly |
|
||||
|
||||
### 10.3 Monthly Review Checklist
|
||||
|
||||
- [ ] Review GSC - new keywords, impressions, CTR
|
||||
- [ ] Check rankings for target keywords
|
||||
- [ ] Analyze traffic sources and user behavior
|
||||
- [ ] Review bounce rate by page
|
||||
- [ ] Check for crawl errors or coverage issues
|
||||
- [ ] Update sitemap if new pages added
|
||||
- [ ] Monitor backlinks (free tools: Ahrefs free, Moz free)
|
||||
- [ ] Test mobile performance (PageSpeed Insights)
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick Implementation Checklist
|
||||
|
||||
### ✅ Completed
|
||||
- [x] Meta tags (title, description, robots)
|
||||
- [x] Open Graph tags
|
||||
- [x] Twitter Card tags
|
||||
- [x] hreflang tags for bilingual
|
||||
- [x] JSON-LD structured data
|
||||
- [x] robots.txt with sitemap reference
|
||||
- [x] sitemap.xml
|
||||
- [x] Nginx security headers
|
||||
- [x] Gzip compression
|
||||
- [x] Proper cache directives
|
||||
|
||||
### 📌 Next Steps (Priority Order)
|
||||
1. **Immediate**
|
||||
- [ ] Submit sitemap to Google Search Console
|
||||
- [ ] Add domain to GSC
|
||||
- [ ] Set up Google Analytics 4
|
||||
- [ ] Verify SSL certificate is valid
|
||||
|
||||
2. **This Week**
|
||||
- [ ] Create Google My Business profile
|
||||
- [ ] Verify in Bing Webmaster Tools
|
||||
- [ ] Test mobile experience with Google PageSpeed Insights
|
||||
- [ ] Fix any Core Web Vitals issues
|
||||
|
||||
3. **This Month**
|
||||
- [ ] Add ALT text to all images
|
||||
- [ ] Create FAQ page with structured data
|
||||
- [ ] Build 3-5 blog posts targeting long-tail keywords
|
||||
- [ ] Set up Google Ads (optional for quick wins)
|
||||
|
||||
4. **This Quarter**
|
||||
- [ ] Guest post on 3-5 relevant sites
|
||||
- [ ] Get listed in 10+ Austrian business directories
|
||||
- [ ] Create YouTube channel with 5+ videos
|
||||
- [ ] Analyze backlink profile, identify opportunities
|
||||
|
||||
---
|
||||
|
||||
## 12. Advanced SEO Tactics
|
||||
|
||||
### 12.1 Schema Markup Enhancements
|
||||
|
||||
📌 **Future Additions**
|
||||
- Product schema for each vehicle
|
||||
- Review schema for customer testimonials
|
||||
- AggregateRating schema
|
||||
- Event schema for special promotions
|
||||
- Offer schema for rental pricing
|
||||
|
||||
### 12.2 International SEO (English Version)
|
||||
|
||||
📌 **For /en/ Pages**
|
||||
- Duplicate all pages with English translations
|
||||
- Separate hreflang annotations
|
||||
- English-language keywords
|
||||
- English meta descriptions
|
||||
|
||||
### 12.3 Rich Snippets Optimization
|
||||
|
||||
📌 **Eligible Snippets**
|
||||
- FAQ results (with FAQ schema)
|
||||
- Review/Rating rich snippets
|
||||
- Product rich snippets
|
||||
- Local business rich snippets
|
||||
|
||||
---
|
||||
|
||||
## 13. Troubleshooting Guide
|
||||
|
||||
### Issue: Pages not indexed
|
||||
**Solution**: Check GSC for manual actions, verify robots.txt not blocking, submit URL directly in GSC
|
||||
|
||||
### Issue: Low click-through rate
|
||||
**Solution**: Improve title/meta description, add rich snippets, improve query matching
|
||||
|
||||
### Issue: High bounce rate
|
||||
**Solution**: Improve page load speed, better content-query match, improve UX/CTA placement
|
||||
|
||||
### Issue: Slow rankings
|
||||
**Solution**: Build quality backlinks, improve content depth, increase content freshness
|
||||
|
||||
---
|
||||
|
||||
## 14. References & Resources
|
||||
|
||||
- [Google Search Central](https://developers.google.com/search)
|
||||
- [Google E-E-A-T Guidelines](https://developers.google.com/search/docs/appearance/eeat)
|
||||
- [Schema.org Documentation](https://schema.org)
|
||||
- [Web Vitals Guide](https://web.dev/vitals/)
|
||||
- [SEMrush Keyword Tool](https://www.semrush.com/)
|
||||
- [German SEO Community](https://www.seo-united.de/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: May 9, 2026
|
||||
**Domain**: demo.lago.dev
|
||||
**Status**: ✅ SEO Implementation Complete - Monitoring Phase Ready
|
||||
@@ -0,0 +1,243 @@
|
||||
# MC Cars SEO & Positioning - Implementation Summary
|
||||
|
||||
**Domain**: demo.lago.dev
|
||||
**Completed**: May 9, 2026
|
||||
**Status**: ✅ Ready for Production
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Strategic Positioning
|
||||
|
||||
**Market Focus**: Premium sports car rental in Steiermark (Austria)
|
||||
**Target Keywords**: Sportwagenvermietung, Luxusauto mieten, Premium Fahrzeugverleih
|
||||
**Languages**: German (Primary), English (Secondary)
|
||||
**Target Audience**: High-net-worth individuals (25-65 years old)
|
||||
|
||||
---
|
||||
|
||||
## 📋 What Was Implemented
|
||||
|
||||
### 1. Enhanced HTML Meta Tags ✅
|
||||
All pages updated with:
|
||||
- **Canonical tags** - Prevent duplicate content issues
|
||||
- **Open Graph tags** - Rich previews on Facebook, LinkedIn, WhatsApp
|
||||
- **Twitter Card tags** - Rich previews on Twitter/X
|
||||
- **hreflang tags** - Proper bilingual language declaration
|
||||
- **JSON-LD structured data**:
|
||||
- LocalBusiness (with Steiermark service area)
|
||||
- Organization schema
|
||||
- BreadcrumbList for navigation
|
||||
|
||||
**Pages Updated**:
|
||||
- index.html (Homepage) - **Priority 1.0**
|
||||
- agb.html (Terms)
|
||||
- datenschutz.html (Privacy)
|
||||
- impressum.html (Imprint)
|
||||
- mietbedingungen.html (Rental Terms)
|
||||
- admin.html (noindex applied - won't be indexed)
|
||||
|
||||
### 2. Critical SEO Files Created ✅
|
||||
|
||||
**robots.txt**
|
||||
- Path: `/robots.txt`
|
||||
- Allows all public content
|
||||
- Blocks admin panel and scrapers
|
||||
- Includes sitemap reference
|
||||
- Specific rules for Google, Bing
|
||||
|
||||
**sitemap.xml**
|
||||
- Path: `/sitemap.xml`
|
||||
- All public pages listed
|
||||
- Priority values set (1.0 for homepage, 0.6-0.7 for others)
|
||||
- Change frequency indicated
|
||||
- hreflang annotations for language variants
|
||||
|
||||
### 3. Web Server Optimization ✅
|
||||
|
||||
**Nginx Configuration Enhanced** with:
|
||||
|
||||
**Performance**:
|
||||
- ✅ Gzip compression (5x-10x smaller files)
|
||||
- ✅ Browser caching (7 days for images/fonts)
|
||||
- ✅ HTTP Keep-Alive enabled
|
||||
- ✅ Proper cache headers
|
||||
|
||||
**Security Headers**:
|
||||
- ✅ X-Frame-Options: SAMEORIGIN (clickjacking protection)
|
||||
- ✅ X-Content-Type-Options: nosniff (MIME sniffing prevention)
|
||||
- ✅ Content-Security-Policy (XSS protection)
|
||||
- ✅ Referrer-Policy (privacy-preserving)
|
||||
- ✅ Permissions-Policy (restrict unnecessary APIs)
|
||||
|
||||
**Caching Strategy**:
|
||||
- HTML/config.js: No-store (always fresh)
|
||||
- Images/Fonts: 7 days
|
||||
- CSS/JS: No-cache (validate)
|
||||
- robots.txt/sitemap: 1 week
|
||||
|
||||
### 4. Keyword Optimization ✅
|
||||
|
||||
**Primary Keywords**:
|
||||
- Sportwagenvermietung Steiermark (main search term)
|
||||
- Luxusauto mieten (high intent)
|
||||
- Premium Fahrzeugverleih (brand differentiator)
|
||||
|
||||
**Secondary Keywords**:
|
||||
- Porsche mieten Österreich
|
||||
- Sportwagenverleih Graz
|
||||
- Kaution Autovermietung transparent
|
||||
|
||||
Keywords integrated naturally in:
|
||||
- Title tags
|
||||
- Meta descriptions
|
||||
- H1/H2 headings
|
||||
- First 100 words of content
|
||||
|
||||
### 5. Technical SEO Infrastructure ✅
|
||||
|
||||
| Feature | Status | Impact |
|
||||
|---------|--------|--------|
|
||||
| Canonical URLs | ✅ | Duplicate content prevention |
|
||||
| hreflang Tags | ✅ | Bilingual SEO |
|
||||
| XML Sitemap | ✅ | Faster indexing |
|
||||
| robots.txt | ✅ | Crawl efficiency |
|
||||
| Structured Data | ✅ | Rich snippets potential |
|
||||
| HTTPS | ✅ | Ranking boost + security |
|
||||
| Mobile Responsive | ✅ | Mobile-first indexing |
|
||||
| Gzip Compression | ✅ | Faster load times |
|
||||
| Security Headers | ✅ | User trust + compliance |
|
||||
|
||||
---
|
||||
|
||||
## 📊 SEO Benefits by Implementation
|
||||
|
||||
### Immediate (1-4 weeks)
|
||||
1. ✅ Faster site indexing via sitemap submission
|
||||
2. ✅ Cleaner crawl with robots.txt
|
||||
3. ✅ Reduced server bandwidth (gzip compression)
|
||||
4. ✅ Improved security posture
|
||||
5. ✅ Better social media sharing
|
||||
|
||||
### Short-term (1-3 months)
|
||||
1. ✅ Indexed pages appear in Google search results
|
||||
2. ✅ Rich snippets begin showing in SERPs
|
||||
3. ✅ Improved click-through rates from better titles/descriptions
|
||||
4. ✅ Better mobile experience rankings
|
||||
5. ✅ Faster page load = ranking boost
|
||||
|
||||
### Long-term (3-12 months)
|
||||
1. ✅ Established domain authority
|
||||
2. ✅ Top 3-5 rankings for primary keywords (with backlinks)
|
||||
3. ✅ Increased organic traffic (30-50% growth potential)
|
||||
4. ✅ Better conversion rate from qualified traffic
|
||||
5. ✅ Local market dominance
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps (Priority Order)
|
||||
|
||||
### Week 1 - Critical
|
||||
- [ ] Submit sitemap to Google Search Console
|
||||
- [ ] Add property to GSC (demo.lago.dev)
|
||||
- [ ] Verify domain ownership
|
||||
- [ ] Set up Google Analytics 4 (GA4)
|
||||
- [ ] Check HTTPS/SSL certificate validity
|
||||
|
||||
### Week 2-4 - Important
|
||||
- [ ] Submit sitemap to Bing Webmaster Tools
|
||||
- [ ] Create Google My Business profile
|
||||
- [ ] Run PageSpeed Insights test
|
||||
- [ ] Fix any Core Web Vitals issues
|
||||
- [ ] Add ALT text to images
|
||||
|
||||
### Month 2 - Strategic
|
||||
- [ ] Build 3-5 blog posts (long-tail keywords)
|
||||
- [ ] Create FAQ page with schema
|
||||
- [ ] Submit to 5+ business directories
|
||||
- [ ] Reach out for guest post opportunities
|
||||
- [ ] Create YouTube channel (vehicle videos)
|
||||
|
||||
### Month 3+ - Growth
|
||||
- [ ] Build high-quality backlinks
|
||||
- [ ] Guest posts on automotive blogs
|
||||
- [ ] Local sponsorships and PR
|
||||
- [ ] Monitor rankings and adjust strategy
|
||||
- [ ] Consider Google Ads for quick wins
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Modified/Created
|
||||
|
||||
### Created:
|
||||
- `frontend/robots.txt` - Search engine crawl directives
|
||||
- `frontend/sitemap.xml` - Complete site structure
|
||||
- `SEO-GUIDE.md` - Comprehensive SEO documentation
|
||||
|
||||
### Modified:
|
||||
- `frontend/index.html` - Enhanced with 20+ meta tags + JSON-LD
|
||||
- `frontend/agb.html` - Added SEO meta tags
|
||||
- `frontend/datenschutz.html` - Added SEO meta tags
|
||||
- `frontend/impressum.html` - Added SEO meta tags
|
||||
- `frontend/mietbedingungen.html` - Added SEO meta tags
|
||||
- `frontend/admin.html` - Added noindex directive
|
||||
- `frontend/nginx.conf` - Security headers, gzip, cache config
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Verification Checklist
|
||||
|
||||
Before going live, verify:
|
||||
|
||||
- [ ] **robots.txt**: Accessible at demo.lago.dev/robots.txt
|
||||
- [ ] **sitemap.xml**: Valid XML at demo.lago.dev/sitemap.xml
|
||||
- [ ] **HTTP Headers**: Check with curl/browser dev tools
|
||||
```bash
|
||||
curl -I https://demo.lago.dev/
|
||||
# Should show Cache-Control, Security headers
|
||||
```
|
||||
- [ ] **Mobile Test**: Test on actual mobile devices
|
||||
- [ ] **Title/Meta**: Check each page has unique title and description
|
||||
- [ ] **Canonical Tags**: Verify canonical URLs are correct
|
||||
- [ ] **SSL**: Ensure HTTPS works and redirects HTTP
|
||||
|
||||
---
|
||||
|
||||
## 💡 Key Positioning Messages
|
||||
|
||||
1. **Premium Quality**: "Handverlesene Premium-Sportwagen"
|
||||
2. **Transparency**: "Faire Kaution, transparent, sofort startklar"
|
||||
3. **Accessibility**: "24/7 Support, Vollkasko, kein Überziehen"
|
||||
4. **Local Expertise**: "Die beste Sportwagenvermietung in der Steiermark"
|
||||
|
||||
These messages should be reinforced across:
|
||||
- Homepage hero section
|
||||
- Social media posts
|
||||
- Blog content
|
||||
- Google My Business profile
|
||||
- Local directory listings
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Maintenance
|
||||
|
||||
For ongoing SEO success:
|
||||
|
||||
1. **Weekly**: Monitor GSC for issues, check rankings
|
||||
2. **Monthly**: Analyze GA4 data, review traffic sources
|
||||
3. **Quarterly**: Audit technical SEO, update content
|
||||
4. **Annually**: Comprehensive SEO audit, strategy review
|
||||
|
||||
---
|
||||
|
||||
## 📚 Resources for Team
|
||||
|
||||
- **SEO Guide**: See [SEO-GUIDE.md](./SEO-GUIDE.md) in this repository
|
||||
- **Google Search Central**: https://developers.google.com/search
|
||||
- **Google Analytics Academy**: https://analytics.google.com/analytics/academy/
|
||||
- **Schema.org**: https://schema.org/LocalBusiness
|
||||
|
||||
---
|
||||
|
||||
**Implementation completed successfully!**
|
||||
**Status**: ✅ Ready to submit to search engines
|
||||
**Estimated Traffic Growth**: 30-50% in first 6 months (with backlinks)
|
||||
@@ -0,0 +1,107 @@
|
||||
# MC Cars SEO - Quick Reference Card
|
||||
|
||||
## 🎯 Domain
|
||||
**demo.lago.dev**
|
||||
|
||||
## 📊 Key Metrics to Track
|
||||
- Organic traffic growth
|
||||
- Keyword rankings (target: top 5)
|
||||
- Click-through rate (target: 3-5%)
|
||||
- Core Web Vitals (all "Good")
|
||||
|
||||
## 🔧 Technical Checklist
|
||||
|
||||
### ✅ Implemented
|
||||
```
|
||||
✓ Canonical tags on all pages
|
||||
✓ Open Graph tags (Facebook sharing)
|
||||
✓ Twitter Card tags (Twitter sharing)
|
||||
✓ hreflang bilingual tags (de/en)
|
||||
✓ JSON-LD structured data (LocalBusiness, Organization, BreadcrumbList)
|
||||
✓ robots.txt with sitemap reference
|
||||
✓ sitemap.xml (all public pages)
|
||||
✓ Gzip compression enabled
|
||||
✓ Security headers configured
|
||||
✓ Proper cache directives
|
||||
```
|
||||
|
||||
## 📁 Key Files
|
||||
|
||||
| File | Purpose | Path |
|
||||
|------|---------|------|
|
||||
| robots.txt | Search engine crawl rules | `/frontend/robots.txt` |
|
||||
| sitemap.xml | Site structure for search engines | `/frontend/sitemap.xml` |
|
||||
| SEO-GUIDE.md | Comprehensive SEO documentation | `/SEO-GUIDE.md` |
|
||||
| nginx.conf | Web server optimization | `/frontend/nginx.conf` |
|
||||
|
||||
## 🎯 Target Keywords
|
||||
|
||||
**Primary**:
|
||||
- Sportwagenvermietung Steiermark
|
||||
- Luxusauto mieten
|
||||
- Premium Fahrzeugverleih
|
||||
|
||||
**Secondary**:
|
||||
- Porsche mieten Österreich
|
||||
- Sportwagenverleih Graz
|
||||
- Kaution Autovermietung
|
||||
|
||||
## 📋 First Week To-Do
|
||||
|
||||
1. [ ] Submit sitemap to Google Search Console
|
||||
2. [ ] Add demo.lago.dev to GSC
|
||||
3. [ ] Set up Google Analytics 4
|
||||
4. [ ] Verify SSL certificate
|
||||
5. [ ] Test mobile experience
|
||||
|
||||
## 📈 Expected Growth (6 months)
|
||||
|
||||
| Metric | Current | Target |
|
||||
|--------|---------|--------|
|
||||
| Indexed Pages | 6 | 20+ |
|
||||
| Ranked Keywords | 0 | 10+ |
|
||||
| Organic Traffic | 0 | 200-500 visitors/month |
|
||||
| Conversion Rate | - | 2-5% |
|
||||
|
||||
## 🔗 Important URLs
|
||||
|
||||
```
|
||||
Homepage: https://demo.lago.dev/
|
||||
Robots file: https://demo.lago.dev/robots.txt
|
||||
Sitemap: https://demo.lago.dev/sitemap.xml
|
||||
Admin panel: https://demo.lago.dev/admin.html (noindex)
|
||||
Privacy policy: https://demo.lago.dev/datenschutz.html
|
||||
Terms: https://demo.lago.dev/agb.html
|
||||
Imprint: https://demo.lago.dev/impressum.html
|
||||
Rental terms: https://demo.lago.dev/mietbedingungen.html
|
||||
```
|
||||
|
||||
## 🛠️ Configuration Changes
|
||||
|
||||
### Nginx Performance
|
||||
- Gzip: Enabled (5-10x compression)
|
||||
- Cache: Images 7 days, CSS/JS no-cache
|
||||
- Headers: Security headers + performance headers
|
||||
|
||||
### Search Crawlers Blocked
|
||||
- Ahrefs Bot
|
||||
- Semrush Bot
|
||||
- Low-quality scrapers
|
||||
|
||||
## 💡 Quick Tips
|
||||
|
||||
1. **Update Content Regularly**: Fresh content = better rankings
|
||||
2. **Mobile First**: 60%+ traffic will be mobile
|
||||
3. **Link Building**: Quality > Quantity (focus on 5-10 high-authority links)
|
||||
4. **User Experience**: Page speed impacts ranking (use PageSpeed Insights)
|
||||
5. **Social Signals**: Share on Instagram, Facebook for visibility boost
|
||||
|
||||
## 📞 Support
|
||||
|
||||
- **Technical Issues**: Check nginx.conf, server logs
|
||||
- **Ranking Issues**: Review keyword relevance, backlinks
|
||||
- **Indexing Issues**: Check GSC for manual actions, check robots.txt
|
||||
|
||||
---
|
||||
|
||||
**Ready for submission to search engines!**
|
||||
@@ -1 +0,0 @@
|
||||
# Bind-mounted service data lives here (db, storage, n8n). Keep tree, ignore contents.
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# MC Cars Deployment Configuration Setup
|
||||
# Usage: ./deploy-setup.sh https://www.mc-cars.at
|
||||
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: ./deploy-setup.sh <domain>"
|
||||
echo "Example: ./deploy-setup.sh https://www.mc-cars.at"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOMAIN="$1"
|
||||
|
||||
echo "🚀 Configuring MC Cars for: $DOMAIN"
|
||||
|
||||
# Update environment variables
|
||||
sed -i "s|SITE_URL=.*|SITE_URL=$DOMAIN|" .env
|
||||
sed -i "s|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=$DOMAIN|" .env
|
||||
sed -i "s|N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send|" .env
|
||||
|
||||
echo "✅ Updated .env:"
|
||||
echo " SITE_URL=$DOMAIN"
|
||||
echo " SUPABASE_PUBLIC_URL=$DOMAIN"
|
||||
echo " N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send"
|
||||
|
||||
echo ""
|
||||
echo "📋 Next steps:"
|
||||
echo " 1. Verify .env looks correct: grep -E 'SITE_URL|SUPABASE_PUBLIC_URL|N8N_WEBHOOK_URL' .env"
|
||||
echo " 2. Restart services: docker-compose down && docker-compose up -d --build"
|
||||
echo " 3. Test webhook: curl '$DOMAIN/webhook/manual-email-send' -d 'sales_order_id=test'"
|
||||
@@ -21,6 +21,17 @@ services:
|
||||
- ./supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
|
||||
- ./supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro
|
||||
- ./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
|
||||
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
||||
- ./supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
|
||||
- ./supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
|
||||
- ./supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
|
||||
- ./supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
|
||||
- ./supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
|
||||
- ./supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro
|
||||
- ./supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro
|
||||
|
||||
kong:
|
||||
volumes:
|
||||
@@ -30,3 +41,16 @@ services:
|
||||
volumes:
|
||||
- ./frontend:/usr/share/nginx/html
|
||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
web-admin:
|
||||
volumes:
|
||||
- ./frontend:/usr/share/nginx/html
|
||||
- ./frontend/nginx-admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
n8n:
|
||||
environment:
|
||||
N8N_SECURE_COOKIE: "false"
|
||||
volumes:
|
||||
- ./data/n8n:/home/node/.n8n
|
||||
- ./n8n/workflows:/opt/mc-cars/workflows:ro
|
||||
- ./n8n/bootstrap:/opt/mc-cars/bootstrap:ro
|
||||
|
||||
+132
-1
@@ -214,6 +214,17 @@ services:
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro
|
||||
- /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
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro
|
||||
entrypoint: ["sh","-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -236,6 +247,17 @@ services:
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/04-kaution-weekend-km.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/05-create-lead-rpc.sql
|
||||
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
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/11-consolidate-km-rental.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/12-email-sent-and-more.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/13-rental-type-daily-and-email-guard.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/14-email-requested-trigger.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/15-individuell-vat-subtotal-fix.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/16-rental-type-weekend-gap-fix.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/17-vehicle-photos.sql
|
||||
echo "post-init done."
|
||||
restart: "no"
|
||||
networks: [mccars]
|
||||
@@ -331,11 +353,120 @@ services:
|
||||
environment:
|
||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
|
||||
volumes:
|
||||
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
||||
- /mnt/user/appdata/mc-cars/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\",N8N_WEBHOOK_URL:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" \"$$N8N_WEBHOOK_URL\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||
ports:
|
||||
- "55580:80"
|
||||
networks: [mccars]
|
||||
logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Admin panel (separate nginx instance on its own port)
|
||||
# -------------------------------------------------------------------------
|
||||
web-admin:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: mccars-web-admin
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- kong
|
||||
environment:
|
||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
|
||||
volumes:
|
||||
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
||||
- /mnt/user/appdata/mc-cars/frontend/nginx-admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\",N8N_WEBHOOK_URL:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" \"$$N8N_WEBHOOK_URL\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||
ports:
|
||||
- "55581:80"
|
||||
networks: [mccars]
|
||||
logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# n8n - workflow automation (connects to Supabase via Postgres + REST)
|
||||
# UI: http://localhost:55590
|
||||
# -------------------------------------------------------------------------
|
||||
n8n:
|
||||
image: n8nio/n8n:latest
|
||||
container_name: mccars-n8n
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
kong:
|
||||
condition: service_started
|
||||
environment:
|
||||
# Core
|
||||
N8N_HOST: 0.0.0.0
|
||||
N8N_PORT: 5678
|
||||
N8N_PROTOCOL: http
|
||||
WEBHOOK_URL: http://localhost:55590/
|
||||
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
|
||||
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "false"
|
||||
N8N_SECURE_COOKIE: "false"
|
||||
|
||||
# Database (n8n stores its own data in the same Postgres)
|
||||
DB_TYPE: postgresdb
|
||||
DB_POSTGRESDB_HOST: db
|
||||
DB_POSTGRESDB_PORT: 5432
|
||||
DB_POSTGRESDB_DATABASE: ${POSTGRES_DB}
|
||||
DB_POSTGRESDB_USER: postgres
|
||||
DB_POSTGRESDB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
DB_POSTGRESDB_SCHEMA: n8n
|
||||
|
||||
# Auto-create owner account on first boot
|
||||
N8N_DEFAULT_USER_EMAIL: ${N8N_USER_EMAIL}
|
||||
N8N_DEFAULT_USER_PASSWORD: ${N8N_USER_PASSWORD}
|
||||
|
||||
# Timezone
|
||||
GENERIC_TIMEZONE: Europe/Vienna
|
||||
TZ: Europe/Vienna
|
||||
|
||||
# Workflow/credential bootstrap (re-import on every start)
|
||||
N8N_POSTGRES_CREDENTIAL_ID: ${N8N_POSTGRES_CREDENTIAL_ID}
|
||||
N8N_POSTGRES_CREDENTIAL_NAME: ${N8N_POSTGRES_CREDENTIAL_NAME}
|
||||
N8N_SMTP_CREDENTIAL_ID: ${N8N_SMTP_CREDENTIAL_ID}
|
||||
N8N_SMTP_CREDENTIAL_NAME: ${N8N_SMTP_CREDENTIAL_NAME}
|
||||
N8N_SMTP_HOST: ${N8N_SMTP_HOST}
|
||||
N8N_SMTP_USER: ${N8N_SMTP_USER}
|
||||
N8N_SMTP_PASS: ${N8N_SMTP_PASS}
|
||||
N8N_PAYPAL_KAUTION_LINK: ${N8N_PAYPAL_KAUTION_LINK}
|
||||
N8N_PAYPAL_MIETE_LINK: ${N8N_PAYPAL_MIETE_LINK}
|
||||
N8N_PAYMENT_WORKFLOW_ID: ${N8N_PAYMENT_WORKFLOW_ID}
|
||||
N8N_WORKFLOW_TEMPLATE: /opt/mc-cars/workflows/01-qualification-payment-email.json
|
||||
volumes:
|
||||
- /mnt/user/appdata/mc-cars/data/n8n:/home/node/.n8n
|
||||
- /mnt/user/appdata/mc-cars/n8n/workflows:/opt/mc-cars/workflows:ro
|
||||
- /mnt/user/appdata/mc-cars/n8n/bootstrap:/opt/mc-cars/bootstrap:ro
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
mkdir -p /home/node/.n8n
|
||||
chown -R 1000:1000 /home/node/.n8n
|
||||
chmod 700 /home/node/.n8n
|
||||
exec su node -s /bin/sh -c '/bin/sh /opt/mc-cars/bootstrap/bootstrap-n8n.sh && exec n8n start'
|
||||
ports:
|
||||
- "55590:5678"
|
||||
networks: [mccars]
|
||||
logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Gotenberg - headless document converter (DOCX → PDF)
|
||||
# Used by n8n to generate Mietvertrag PDFs from DOCX templates.
|
||||
# -------------------------------------------------------------------------
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
container_name: mccars-gotenberg
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "gotenberg"
|
||||
- "--api-port=3000"
|
||||
- "--api-timeout=60s"
|
||||
- "--libreoffice-restart-after=10"
|
||||
networks: [mccars]
|
||||
logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
|
||||
|
||||
@@ -3,7 +3,8 @@ set -eu
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.MCCARS_CONFIG = {
|
||||
SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",
|
||||
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}"
|
||||
SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}",
|
||||
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-http://localhost:55590}"
|
||||
};
|
||||
EOF
|
||||
exec nginx -g "daemon off;"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# (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 \
|
||||
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 N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-http://localhost:55521/webhook/manual-email-send}"\n};\nEOF\nexec nginx -g "daemon off;"\n' > /docker-entrypoint.d/99-config.sh \
|
||||
&& chmod +x /docker-entrypoint.d/99-config.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
+89
-13
@@ -4,15 +4,20 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Admin · MC Cars</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||
<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>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="description" content="MC Cars Admin Panel - Not for public access" />
|
||||
</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>
|
||||
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" />
|
||||
<span>MC Cars Admin</span>
|
||||
</div>
|
||||
<form id="loginForm" class="admin-form">
|
||||
@@ -27,7 +32,7 @@
|
||||
<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.
|
||||
Nur für Administratoren. Selbstregistrierung ist deaktiviert.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
@@ -35,7 +40,7 @@
|
||||
<!-- 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>
|
||||
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" />
|
||||
<span>Passwort setzen</span>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.9rem;text-align:center;max-width:38ch;margin:0 auto 1rem;">
|
||||
@@ -61,7 +66,7 @@
|
||||
<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" data-i18n="adminNavWebsite">Website</a>
|
||||
<a id="websiteLink" href="/index.html" target="_blank" class="btn ghost small" data-i18n="adminNavWebsite">Website</a>
|
||||
<button class="lang-toggle" type="button" aria-label="Sprache wechseln" style="margin-left:auto;">EN</button>
|
||||
|
||||
<span id="adminWho" style="color:var(--muted);font-size:0.85rem;margin-left:1rem;"></span>
|
||||
@@ -74,7 +79,9 @@
|
||||
<div class="admin-tabs" role="tablist">
|
||||
<button class="tab active" data-tab="leads" role="tab"><span data-i18n="adminLeads">Leads</span> <span id="leadsBadge" class="tab-badge">0</span></button>
|
||||
<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 -->
|
||||
@@ -94,6 +101,7 @@
|
||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||
<th data-i18n="adminRentalType">Miettyp</th>
|
||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||
<th data-i18n="adminStatus">Status</th>
|
||||
<th></th>
|
||||
@@ -128,6 +136,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SALES ORDERS -->
|
||||
<div class="tab-panel" id="tab-orders" style="display:none;">
|
||||
<div class="panel">
|
||||
<h2 data-i18n="adminTabOrderHistory">Bestellungen</h2>
|
||||
<table class="admin-table" id="ordersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr.</th>
|
||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||
<th data-i18n="adminRentalType">Miettyp</th>
|
||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||
<th>Kaution</th>
|
||||
<th>Miete</th>
|
||||
<th data-i18n="adminStatus">Status</th>
|
||||
<th data-i18n="adminEmailSent">Email</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<p id="ordersEmpty" class="muted" style="display:none;text-align:center;padding:2rem 0;">Keine Bestellungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VEHICLES -->
|
||||
<div class="tab-panel" id="tab-vehicles" style="display:none;">
|
||||
<div class="admin-grid">
|
||||
@@ -136,13 +170,18 @@
|
||||
<form class="admin-form" id="vehicleForm">
|
||||
<input type="hidden" name="vid" />
|
||||
|
||||
<div class="admin-photo-preview" id="photoPreview"></div>
|
||||
<label>
|
||||
<span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
|
||||
<input type="file" id="photoInput" accept="image/*" />
|
||||
</label>
|
||||
<div class="admin-photo-upload-zone" id="photoUploadZone">
|
||||
<div class="admin-photo-upload-content">
|
||||
<span class="admin-photo-upload-icon">📷</span>
|
||||
<span>Fotos hochladen (JPG/PNG/WebP, max 50 MB)</span>
|
||||
<span class="muted" style="font-size:0.85rem;">Klicken oder Dateien hierher ziehen · Mehrfachauswahl möglich</span>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" multiple />
|
||||
</div>
|
||||
<input type="hidden" name="photo_url" />
|
||||
|
||||
<div class="admin-photo-gallery" id="extraPhotoGallery"></div>
|
||||
|
||||
<div class="row2">
|
||||
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
|
||||
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
|
||||
@@ -161,13 +200,13 @@
|
||||
</div>
|
||||
|
||||
<div class="row3">
|
||||
<label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label>
|
||||
<label><span>Inkl. km/Tag</span><input type="number" name="included_km_per_day" min="0" value="150" /></label>
|
||||
<label><span data-i18n="adminPricePerKm">Preis extra km (€)</span><input type="number" name="price_per_km_eur" step="0.01" min="0" value="1.50" /></label>
|
||||
<label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label>
|
||||
<label><span data-i18n="adminMaxKmWeekend">Max. km/Wochenendtag</span><input type="number" name="max_km_weekend" min="0" placeholder="wie km/Tag" /></label>
|
||||
</div>
|
||||
|
||||
<div class="row2">
|
||||
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
|
||||
<label><span data-i18n="adminSortOrder">Ordnung</span><input type="number" name="sort_order" value="100" /></label>
|
||||
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
|
||||
</div>
|
||||
|
||||
@@ -213,8 +252,45 @@
|
||||
</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>
|
||||
<hr style="margin:2rem 0;border-color:var(--border);" />
|
||||
<div class="admin-form">
|
||||
<label>
|
||||
<span data-i18n="adminMietvertragTemplate">Mietvertrag-Vorlage (DOCX)</span>
|
||||
<div id="mietvertragStatus" class="muted" style="font-size:0.9rem;margin:0.5rem 0;"></div>
|
||||
<input type="file" id="mietvertragInput" accept="application/vnd.openxmlformats-officedocument.wordprocessingml.document,.docx" />
|
||||
</label>
|
||||
<p class="muted" style="font-size:0.82rem;" data-i18n="adminMietvertragHint">DOCX-Vorlage mit Platzhaltern. Wird bei Qualifizierung automatisch ausgefüllt und als PDF per E-Mail versendet.</p>
|
||||
<p class="form-feedback" id="mietvertragFeedback"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Order detail dialog -->
|
||||
<dialog id="orderDialog">
|
||||
<div class="dialog-head">
|
||||
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
||||
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dialog-tabs" id="orderDialogTabs" role="tablist"></div>
|
||||
<div class="dialog-body" id="orderDialogBody"></div>
|
||||
<div class="dialog-footer" id="orderDialogFooter"></div>
|
||||
</dialog>
|
||||
|
||||
<!-- Lead detail / qualify dialog (tabbed) -->
|
||||
<dialog id="leadDialog">
|
||||
<div class="dialog-head">
|
||||
@@ -237,6 +313,6 @@
|
||||
<div class="dialog-footer" id="customerDialogFooter"></div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="admin.js"></script>
|
||||
<script type="module" src="admin.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+918
-124
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AGB · MC Cars</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||
<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>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||
|
||||
<meta name="description" content="Allgemeine Geschäftsbedingungen (AGB) von MC Cars - Sportwagenvermietung in Steiermark" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://demo.lago.dev/agb.html" />
|
||||
<link rel="alternate" hreflang="de" href="https://demo.lago.dev/agb.html" />
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="AGB – MC Cars" />
|
||||
<meta property="og:description" content="Allgemeine Geschäftsbedingungen von MC Cars" />
|
||||
<meta property="og:url" content="https://demo.lago.dev/agb.html" />
|
||||
<meta property="og:site_name" content="MC Cars" />
|
||||
<meta property="og:locale" content="de_AT" />
|
||||
|
||||
<!-- JSON-LD Breadcrumb -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Startseite",
|
||||
"item": "https://demo.lago.dev/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "AGB",
|
||||
"item": "https://demo.lago.dev/agb.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="shell">
|
||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</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 style="padding: 3rem 0;">
|
||||
<div class="shell">
|
||||
<h1>Allgemeine Geschäftsbedingungen (AGB)</h1>
|
||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||
<p style="color: var(--muted); font-style: italic;">
|
||||
Diese Seite wird in Kürze mit den vollständigen AGB aktualisiert.
|
||||
</p>
|
||||
<p>
|
||||
Die AGB definieren die rechtlichen Bedingungen für die Vermietung von Fahrzeugen durch MC Cars.
|
||||
</p>
|
||||
<p>
|
||||
Bitte wenden Sie sich an hello@mc-cars.at für weitere Informationen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer" id="kontakt">
|
||||
<div class="shell">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<div class="logo" style="margin-bottom:0.8rem;">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
||||
<a href="/agb" data-i18n="terms">AGB</a>
|
||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</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>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+205
-70
@@ -1,5 +1,5 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js";
|
||||
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js?v=3";
|
||||
|
||||
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
|
||||
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
||||
@@ -16,6 +16,7 @@ const state = {
|
||||
sort: "sort_order",
|
||||
maxPrice: null,
|
||||
reviewIdx: 0,
|
||||
vehiclePhotosMap: new Map(),
|
||||
};
|
||||
|
||||
// ---------------- Elements ----------------
|
||||
@@ -52,7 +53,7 @@ const bpfSubmitBtn = document.querySelector("#bpfSubmit");
|
||||
const bpfSidebar = document.querySelector("#bpfSidebar");
|
||||
const bpfSidebarContent = document.querySelector("#bpfSidebarContent");
|
||||
const bpfSidebarPlaceholder = document.querySelector(".bpf-sidebar-placeholder");
|
||||
let bpfDurationMode = "custom"; // "day" | "weekend" | "custom"
|
||||
let bpfDurationMode = ""; // "day" | "weekend" | "custom" | ""
|
||||
let bpfSubmitting = false;
|
||||
|
||||
function formatYmdLocal(d) {
|
||||
@@ -96,6 +97,17 @@ const today = formatYmdLocal(new Date());
|
||||
|
||||
document.querySelector("#year").textContent = new Date().getFullYear();
|
||||
|
||||
// ----------------Toast Notification ----------------
|
||||
function showToast(message, duration = 3000) {
|
||||
const toast = document.querySelector("#toast");
|
||||
if (!toast) return;
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ---------------- Vehicles ----------------
|
||||
async function loadVehicles() {
|
||||
const { data, error } = await supabase
|
||||
@@ -112,6 +124,33 @@ async function loadVehicles() {
|
||||
|
||||
state.vehicles = data || [];
|
||||
statCarsCount.textContent = state.vehicles.length;
|
||||
statCarsLabel.dataset.i18n = state.vehicles.length > 1 ? 'statCars' : 'statCar';
|
||||
applyI18n();
|
||||
|
||||
// Load vehicle photos
|
||||
if (state.vehicles.length > 0) {
|
||||
const ids = state.vehicles.map(v => v.id);
|
||||
const { data: photos } = await supabase
|
||||
.from("vehicle_photos")
|
||||
.select("*")
|
||||
.in("vehicle_id", ids)
|
||||
.order("display_order", { ascending: true });
|
||||
state.vehiclePhotosMap = new Map();
|
||||
if (photos) {
|
||||
for (const ph of photos) {
|
||||
if (!state.vehiclePhotosMap.has(ph.vehicle_id)) {
|
||||
state.vehiclePhotosMap.set(ph.vehicle_id, []);
|
||||
}
|
||||
state.vehiclePhotosMap.get(ph.vehicle_id).push(ph);
|
||||
}
|
||||
}
|
||||
// Also include legacy main photo if no gallery photos exist
|
||||
for (const v of state.vehicles) {
|
||||
if (!state.vehiclePhotosMap.has(v.id) && v.photo_url) {
|
||||
state.vehiclePhotosMap.set(v.id, [{ photo_url: v.photo_url }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
|
||||
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
|
||||
@@ -145,10 +184,16 @@ function renderGrid() {
|
||||
emptyState.style.display = state.filtered.length ? "none" : "block";
|
||||
|
||||
for (const v of state.filtered) {
|
||||
const photos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||
const primaryPhoto = photos.find(p => p.is_primary) || photos[0];
|
||||
const photoUrl = optimizedVehiclePhotoUrl(primaryPhoto?.photo_url || v.photo_url);
|
||||
const photoCount = photos.length;
|
||||
const card = document.createElement("article");
|
||||
card.className = "vehicle-card";
|
||||
card.innerHTML = `
|
||||
<div class="vehicle-photo" role="img" aria-label="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" style="background-image:url('${escapeAttr(v.photo_url)}');">
|
||||
<div class="vehicle-photo" data-photos='${escapeAttr(JSON.stringify(photos.map(p => optimizedVehiclePhotoUrl(p.photo_url))))}' data-current="0">
|
||||
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" class="vehicle-photo-img" />
|
||||
${photoCount > 1 ? `<div class="vehicle-photo-nav"><button class="vehicle-photo-prev" aria-label="Vorheriges Foto">‹</button><button class="vehicle-photo-next" aria-label="Nächstes Foto">›</button></div><div class="vehicle-photo-dots">${photos.map((_, i) => `<span class="${i === 0 ? 'active' : ''}"></span>`).join('')}</div>` : ''}
|
||||
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
||||
</div>
|
||||
<div class="vehicle-body">
|
||||
@@ -181,28 +226,65 @@ function renderGrid() {
|
||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
// Photo carousel nav
|
||||
grid.querySelectorAll(".vehicle-photo-prev").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const container = btn.closest(".vehicle-photo");
|
||||
const urls = JSON.parse(container.dataset.photos);
|
||||
let idx = +container.dataset.current;
|
||||
idx = (idx - 1 + urls.length) % urls.length;
|
||||
container.dataset.current = idx;
|
||||
container.querySelector(".vehicle-photo-img").src = urls[idx];
|
||||
updatePhotoDots(container, idx);
|
||||
});
|
||||
});
|
||||
grid.querySelectorAll(".vehicle-photo-next").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const container = btn.closest(".vehicle-photo");
|
||||
const urls = JSON.parse(container.dataset.photos);
|
||||
let idx = +container.dataset.current;
|
||||
idx = (idx + 1) % urls.length;
|
||||
container.dataset.current = idx;
|
||||
container.querySelector(".vehicle-photo-img").src = urls[idx];
|
||||
updatePhotoDots(container, idx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updatePhotoDots(container, idx) {
|
||||
container.querySelectorAll(".vehicle-photo-dots span").forEach((dot, i) => {
|
||||
dot.classList.toggle("active", i === idx);
|
||||
});
|
||||
}
|
||||
|
||||
function openDetails(id) {
|
||||
const v = state.vehicles.find(x => x.id === id);
|
||||
if (!v) return;
|
||||
const photos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||
const photoUrls = photos.length ? photos.map(p => optimizedVehiclePhotoUrl(p.photo_url)) : [optimizedVehiclePhotoUrl(v.photo_url)];
|
||||
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)}" />
|
||||
<div class="dialog-gallery" data-gallery-urls='${escapeAttr(JSON.stringify(photoUrls))}' data-gallery-idx="0">
|
||||
<img src="${escapeAttr(photoUrls[0])}" alt="${escapeAttr(v.brand + ' ' + v.model)}" class="dialog-gallery-main" />
|
||||
${photoUrls.length > 1 ? `<div class="dialog-gallery-nav"><button class="dialog-gallery-prev" aria-label="Vorheriges Foto">‹</button><button class="dialog-gallery-next" aria-label="Nächstes Foto">›</button></div><div class="dialog-gallery-thumbs">${photoUrls.map((u, i) => `<button class="${i === 0 ? 'active' : ''}" data-gidx="${i}"><img src="${escapeAttr(u)}" loading="lazy" /></button>`).join('')}</div>` : ''}
|
||||
</div>
|
||||
<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 class="spec-row" style="margin:1rem 0;">
|
||||
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
|
||||
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
|
||||
<div><strong>${v.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div>
|
||||
</div>
|
||||
<div class="spec-row" style="margin:1rem 0;">
|
||||
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
|
||||
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
|
||||
<div><strong>${v.included_km_per_day || 150}</strong><span>${t("bpfInclKmPerDay")}</span></div>
|
||||
</div>
|
||||
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
|
||||
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
|
||||
</div>
|
||||
@@ -218,6 +300,37 @@ function openDetails(id) {
|
||||
bpfCar.dispatchEvent(new Event("change"));
|
||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
|
||||
// Dialog gallery nav
|
||||
const gallery = dialogBody.querySelector(".dialog-gallery");
|
||||
const galleryPrev = dialogBody.querySelector(".dialog-gallery-prev");
|
||||
const galleryNext = dialogBody.querySelector(".dialog-gallery-next");
|
||||
if (galleryPrev) {
|
||||
galleryPrev.addEventListener("click", () => {
|
||||
let idx = +gallery.dataset.galleryIdx;
|
||||
idx = (idx - 1 + photoUrls.length) % photoUrls.length;
|
||||
gallery.dataset.galleryIdx = idx;
|
||||
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
|
||||
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
|
||||
});
|
||||
}
|
||||
if (galleryNext) {
|
||||
galleryNext.addEventListener("click", () => {
|
||||
let idx = +gallery.dataset.galleryIdx;
|
||||
idx = (idx + 1) % photoUrls.length;
|
||||
gallery.dataset.galleryIdx = idx;
|
||||
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
|
||||
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
|
||||
});
|
||||
}
|
||||
gallery?.querySelectorAll(".dialog-gallery-thumbs button").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const idx = +btn.dataset.gidx;
|
||||
gallery.dataset.galleryIdx = idx;
|
||||
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
|
||||
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Reviews ----------------
|
||||
@@ -287,7 +400,7 @@ bpfFileIncome.addEventListener("change", () => {
|
||||
|
||||
// ---------------- Duration Presets ----------------
|
||||
function setDurationMode(mode) {
|
||||
bpfDurationMode = mode;
|
||||
bpfDurationMode = mode || "";
|
||||
document.querySelectorAll(".bpf-preset").forEach(b => b.classList.toggle("active", b.dataset.preset === mode));
|
||||
document.querySelector("#bpfDateDay").style.display = mode === "day" ? "block" : "none";
|
||||
document.querySelector("#bpfDateWeekend").style.display = mode === "weekend" ? "block" : "none";
|
||||
@@ -295,6 +408,9 @@ function setDurationMode(mode) {
|
||||
updateSidebar();
|
||||
}
|
||||
|
||||
// Fresh page load: no duration selected, so no date inputs are visible.
|
||||
setDurationMode("");
|
||||
|
||||
document.querySelectorAll(".bpf-preset").forEach(btn => {
|
||||
btn.addEventListener("click", () => setDurationMode(btn.dataset.preset));
|
||||
});
|
||||
@@ -351,7 +467,7 @@ function calcWeekendDays(from, to) {
|
||||
return count;
|
||||
}
|
||||
|
||||
function updateSidebar() {
|
||||
async function updateSidebar() {
|
||||
const v = state.vehicles.find(x => x.id === bpfCar.value);
|
||||
const { from, to } = getBpfDates();
|
||||
if (!v || !from || !to) {
|
||||
@@ -364,38 +480,63 @@ function updateSidebar() {
|
||||
if (!fromD || !toD) return;
|
||||
if (toD <= fromD) return;
|
||||
|
||||
const totalDays = Math.ceil((toD - fromD) / (1000 * 60 * 60 * 24));
|
||||
const weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to);
|
||||
const weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays);
|
||||
// Fetch price from backend RPC
|
||||
const { data: price, error } = await supabase.rpc("calculate_price", {
|
||||
p_vehicle_id: v.id,
|
||||
p_date_from: from,
|
||||
p_date_to: to,
|
||||
});
|
||||
if (error || !price) { console.error("calculate_price error:", error, "data:", price); return; }
|
||||
|
||||
const weekdayCost = weekdays * v.daily_price_eur;
|
||||
const weekendCost = weekendDays * (v.weekend_price_eur || v.daily_price_eur);
|
||||
const subtotal = weekdayCost + weekendCost;
|
||||
const vat = Math.round(subtotal * 0.20);
|
||||
const total = subtotal + vat;
|
||||
const deposit = v.kaution_eur || 5000;
|
||||
const kmPerWeekendDay = v.max_km_weekend || v.max_daily_km || 150;
|
||||
const kmPerWeekday = v.max_daily_km || 150;
|
||||
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
|
||||
const totalDays = price.total_days;
|
||||
const weekdays = price.weekday_count;
|
||||
const weekendDays = price.weekend_day_count;
|
||||
const weekdayCost = price.daily_subtotal;
|
||||
const weekendCost = price.weekend_subtotal;
|
||||
const subtotal = price.subtotal_eur;
|
||||
const vat = price.vat_eur;
|
||||
const total = price.total_eur;
|
||||
const deposit = price.deposit_eur;
|
||||
const includedKmPerDay = price.included_km_per_day || 150;
|
||||
const includedKm = totalDays * includedKmPerDay;
|
||||
const sidebarPhotos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||
const photoUrl = optimizedVehiclePhotoUrl((sidebarPhotos.find(p => p.is_primary) || sidebarPhotos[0] || v)?.photo_url || v.photo_url);
|
||||
|
||||
bpfSidebarPlaceholder.style.display = "none";
|
||||
bpfSidebarContent.style.display = "block";
|
||||
bpfSidebarContent.innerHTML = `
|
||||
<h4>${t("bpfPriceOverview")}</h4>
|
||||
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
|
||||
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} × € ${v.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
|
||||
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} × € ${v.weekend_price_eur || v.daily_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
|
||||
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
|
||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(v.photo_url)}');"></div>
|
||||
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||
`;
|
||||
}
|
||||
if (totalDays > 2) {
|
||||
// Individuell mode: show info banner instead of pricing
|
||||
bpfSidebarPlaceholder.style.display = "none";
|
||||
bpfSidebarContent.style.display = "block";
|
||||
bpfSidebarContent.innerHTML = `
|
||||
<h4>${t("bpfPriceOverview")}</h4>
|
||||
<div class="bpf-info-banner">
|
||||
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
|
||||
<p>${t("bpfIndividuellDesc")}</p>
|
||||
</div>
|
||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||
`;
|
||||
} else {
|
||||
bpfSidebarPlaceholder.style.display = "none";
|
||||
bpfSidebarContent.style.display = "block";
|
||||
bpfSidebarContent.innerHTML = `
|
||||
<h4>${t("bpfPriceOverview")}</h4>
|
||||
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
|
||||
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} × € ${price.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
|
||||
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} × € ${price.weekend_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
|
||||
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
|
||||
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
|
||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bpfCar.addEventListener("change", updateSidebar);
|
||||
bpfFrom.addEventListener("change", updateSidebar);
|
||||
@@ -411,40 +552,16 @@ document.querySelector("#bpfSubmit").addEventListener("click", async () => {
|
||||
|
||||
const vehicle = state.vehicles.find(v => v.id === bpfCar.value);
|
||||
const { from, to } = getBpfDates();
|
||||
const vFrom = parseYmdLocal(from);
|
||||
const vTo = parseYmdLocal(to);
|
||||
let weekdayCost = 0, weekendCost = 0, subtotal = 0, vat = 0, total = 0, deposit = 0;
|
||||
let totalDays = 0, weekdays = 0, weekendDays = 0;
|
||||
if (vehicle && vFrom && vTo && vTo > vFrom) {
|
||||
totalDays = Math.ceil((vTo - vFrom) / (1000 * 60 * 60 * 24));
|
||||
weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to);
|
||||
weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays);
|
||||
weekdayCost = weekdays * vehicle.daily_price_eur;
|
||||
weekendCost = weekendDays * (vehicle.weekend_price_eur || vehicle.daily_price_eur);
|
||||
subtotal = weekdayCost + weekendCost;
|
||||
vat = Math.round(subtotal * 0.20);
|
||||
total = subtotal + vat;
|
||||
deposit = vehicle.kaution_eur || 5000;
|
||||
}
|
||||
const payload = {
|
||||
p_name: bpfName.value,
|
||||
p_email: bpfEmail.value,
|
||||
p_phone: bpfPhone.value || "",
|
||||
p_vehicle_id: bpfCar.value || null,
|
||||
p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
|
||||
p_date_from: bpfFrom.value || null,
|
||||
p_date_to: bpfTo.value || null,
|
||||
p_date_from: from || null,
|
||||
p_date_to: to || null,
|
||||
p_message: bpfMessage.value || "",
|
||||
p_source: "website",
|
||||
p_daily_subtotal: weekdayCost,
|
||||
p_weekend_subtotal: weekendCost,
|
||||
p_subtotal_eur: subtotal,
|
||||
p_vat_eur: vat,
|
||||
p_total_eur: total,
|
||||
p_deposit_eur: deposit,
|
||||
p_total_days: totalDays,
|
||||
p_weekday_count: weekdays,
|
||||
p_weekend_day_count: weekendDays,
|
||||
};
|
||||
|
||||
// Create lead via RPC (returns inserted id without anon SELECT privileges)
|
||||
@@ -469,7 +586,8 @@ document.querySelector("#bpfSubmit").addEventListener("click", async () => {
|
||||
await Promise.all(uploads);
|
||||
|
||||
bookingFeedback.className = "form-feedback";
|
||||
bookingFeedback.textContent = t("bookingSuccess");
|
||||
bookingFeedback.textContent = "";
|
||||
showToast(t("bookingSuccess"), 4000);
|
||||
showBpfStep(1);
|
||||
bpfCar.value = "";
|
||||
bpfFrom.value = "";
|
||||
@@ -482,7 +600,7 @@ document.querySelector("#bpfSubmit").addEventListener("click", async () => {
|
||||
bpfMessage.value = "";
|
||||
document.querySelector("#bpfFileIdName").textContent = "";
|
||||
document.querySelector("#bpfFileIncomeName").textContent = "";
|
||||
setDurationMode("custom");
|
||||
setDurationMode("");
|
||||
updateSidebar();
|
||||
bpfSubmitting = false;
|
||||
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
|
||||
@@ -494,7 +612,7 @@ async function uploadDoc(leadId, file, kind) {
|
||||
const path = `${leadId}/${kind}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from("customer-documents")
|
||||
.upload(path, file, { contentType: file.type, upsert: true });
|
||||
.upload(path, file, { contentType: file.type });
|
||||
if (upErr) { console.error("Upload failed:", upErr); return; }
|
||||
await supabase.from("lead_attachments").insert({
|
||||
lead_id: leadId,
|
||||
@@ -534,8 +652,25 @@ function escapeHtml(s) {
|
||||
}
|
||||
function escapeAttr(s) { return escapeHtml(s); }
|
||||
|
||||
function optimizedVehiclePhotoUrl(url) {
|
||||
const raw = String(url ?? "");
|
||||
if (!raw) return raw;
|
||||
return raw.replace("/images/ferrari-main-car.png", "/images/ferrari-main-car-mobile.jpg");
|
||||
}
|
||||
|
||||
// ---------------- Boot ----------------
|
||||
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) {
|
||||
const heroUrl = data.value.includes("/images/ferrari-main-car.png")
|
||||
? "/images/ferrari-main-car-mobile.jpg"
|
||||
: data.value;
|
||||
document.querySelector(".hero").style.setProperty("--hero-bg", `url('${heroUrl}')`);
|
||||
}
|
||||
})();
|
||||
|
||||
+187
-6
@@ -4,16 +4,197 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Datenschutz · MC Cars (GmbH)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||
<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" />
|
||||
|
||||
<meta name="description" content="Datenschutzrichtlinie von MC Cars - Wie wir Ihre persönlichen Daten schützen" />
|
||||
<meta name="robots" content="index, follow, nosnippet" />
|
||||
<link rel="canonical" href="https://demo.lago.dev/datenschutz.html" />
|
||||
<link rel="alternate" hreflang="de" href="https://demo.lago.dev/datenschutz.html" />
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Datenschutz – MC Cars" />
|
||||
<meta property="og:description" content="Datenschutzrichtlinie von MC Cars" />
|
||||
<meta property="og:url" content="https://demo.lago.dev/datenschutz.html" />
|
||||
<meta property="og:site_name" content="MC Cars" />
|
||||
<meta property="og:locale" content="de_AT" />
|
||||
|
||||
<!-- JSON-LD Breadcrumb -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Startseite",
|
||||
"item": "https://demo.lago.dev/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Datenschutz",
|
||||
"item": "https://demo.lago.dev/datenschutz.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</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 über 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>
|
||||
<header class="site-header">
|
||||
<div class="shell">
|
||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</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 style="padding: 3rem 0;">
|
||||
<div class="shell" style="max-width: 80ch;">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
|
||||
<div style="max-width: 70ch; line-height: 1.8; color: var(--text);">
|
||||
<p>Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Wir verarbeiten Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, DSG 2018). In diesen Datenschutzinformationen informieren wir Sie über die wichtigsten Aspekte der Datenverarbeitung im Rahmen unserer Website.</p>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Verantwortlicher für die Datenverarbeitung</h2>
|
||||
<p><strong>MC Cars GmbH</strong><br/> Gaisfeld 1/2, 8564 Krottendorf-Gaisfeld<br/> E-Mail: hello@mc-cars.at</p>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Daten, die wir verarbeiten</h2>
|
||||
|
||||
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Server-Logfiles</h3>
|
||||
<p>Beim Besuch unserer Website werden automatisch Informationen in Server-Logfiles gespeichert, die Ihr Browser an uns übermittelt. Dies sind:</p>
|
||||
<ul style="padding-left: 1.5rem;">
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>Verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL (die zuvor besuchte Seite)</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse</li>
|
||||
</ul>
|
||||
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.</p>
|
||||
|
||||
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Buchungsanfragen</h3>
|
||||
<p>Wenn Sie unser Buchungsformular nutzen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen gespeichert. Dies umfasst:</p>
|
||||
<ul style="padding-left: 1.5rem;">
|
||||
<li>Name</li>
|
||||
<li>E-Mail-Adresse</li>
|
||||
<li>Telefonnummer</li>
|
||||
<li>Gewähltes Fahrzeug und Mietzeitraum</li>
|
||||
<li>Nachricht / Anmerkungen</li>
|
||||
</ul>
|
||||
<p>Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.</p>
|
||||
|
||||
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Identitätsdokumente</h3>
|
||||
<p>Zur Bearbeitung von Buchungsanfragen laden wir Identitätsdokumente (Ausweis, Führerschein) sowie optionale Einkommensnachweise hoch. Diese Dokumente dienen ausschließlich der Identitätsverifizierung und Bonitätsprüfung. Sie werden vertraulich behandelt und nicht an Dritte weitergegeben.</p>
|
||||
|
||||
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Cookies und lokale Speicherung</h3>
|
||||
<p>Unsere Website verwendet lokale Speicherung (localStorage) für die Auswahl der Spracheinstellung. Diese Daten werden ausschließlich auf Ihrem Endgerät gespeichert und nicht an uns übermittelt.</p>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Zweck der Datenverarbeitung</h2>
|
||||
<p>Die Verarbeitung Ihrer personenbezogenen Daten erfolgt zu folgenden Zwecken:</p>
|
||||
<ul style="padding-left: 1.5rem;">
|
||||
<li>Zur Bereitstellung, Optimierung und Weiterentwicklung unserer Website</li>
|
||||
<li>Zur Bearbeitung Ihrer Buchungsanfragen</li>
|
||||
<li>Zur Identitätsprüfung und Bonitätsprüfung</li>
|
||||
<li>Zur Gewährleistung der Sicherheit und Funktionsfähigkeit unserer Website</li>
|
||||
<li>Zur Erfüllung gesetzlicher Verpflichtungen</li>
|
||||
</ul>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Rechtsgrundlage der Verarbeitung</h2>
|
||||
<p>Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf folgenden Rechtsgrundlagen:</p>
|
||||
<ul style="padding-left: 1.5rem;">
|
||||
<li><strong>Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen (Art. 6 Abs. 1 lit. b DSGVO)</strong> – bei der Bearbeitung Ihrer Buchungsanfragen und der Verarbeitung Ihrer Identitätsdokumente</li>
|
||||
<li><strong>Erfüllung einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO)</strong> – z.B. aufgrund gesetzlicher Aufbewahrungsfristen</li>
|
||||
<li><strong>Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)</strong> – zur Gewährleistung der Sicherheit, der Funktionsfähigkeit und der Optimierung unserer Website</li>
|
||||
</ul>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Datenhosting</h2>
|
||||
<p>Unsere Website und Datenbank laufen auf einer selbstgehosteten Infrastruktur. Alle personenbezogenen Daten werden auf unseren eigenen Servern verarbeitet und gespeichert. Es erfolgt keine Weitergabe an Cloud-Dienstanbieter oder Drittunternehmen.</p>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Übermittlung Ihrer Daten</h2>
|
||||
<p>Eine Übermittlung Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn:</p>
|
||||
<ul style="padding-left: 1.5rem;">
|
||||
<li>Dies ist zur Erfüllung unserer vertraglichen Pflichten erforderlich</li>
|
||||
<li>Wir sind gesetzlich dazu verpflichtet</li>
|
||||
<li>Sie haben ausdrücklich eingewilligt</li>
|
||||
</ul>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Speicherdauer</h2>
|
||||
<p>Wir speichern Ihre personenbezogenen Daten nur so lange, wie es für die Erreichung der oben genannten Zwecke erforderlich ist oder wie es die gesetzlichen Aufbewahrungspflichten vorsehen. Identitätsdokumente werden nach Abschluss der Buchung und Erfüllung der gesetzlichen Aufbewahrungsfristen gelöscht.</p>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Ihre Rechte</h2>
|
||||
<p>Sie haben hinsichtlich Ihrer bei uns gespeicherten personenbezogenen Daten folgende Rechte:</p>
|
||||
<ul style="padding-left: 1.5rem;">
|
||||
<li><strong>Recht auf Auskunft (Art. 15 DSGVO):</strong> Sie können Auskunft darüber verlangen, ob und welche personenbezogenen Daten von Ihnen verarbeitet werden.</li>
|
||||
<li><strong>Recht auf Berichtigung (Art. 16 DSGVO):</strong> Sie können die Berichtigung unrichtiger oder die Vervollständigung unvollständiger Daten verlangen.</li>
|
||||
<li><strong>Recht auf Löschung (Art. 17 DSGVO):</strong> Sie können die Löschung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.</li>
|
||||
<li><strong>Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO):</strong> Sie können die Einschränkung der Verarbeitung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.</li>
|
||||
<li><strong>Recht auf Datenübertragbarkeit (Art. 20 DSGVO):</strong> Sie haben das Recht, Ihre bereitgestellten Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten.</li>
|
||||
<li><strong>Recht auf Widerspruch (Art. 21 DSGVO):</strong> Sie können gegen die Verarbeitung Ihrer Daten Widerspruch einlegen.</li>
|
||||
<li><strong>Recht auf Beschwerde (Art. 77 DSGVO):</strong> Sie können sich bei der zuständigen Aufsichtsbehörde beschweren.</li>
|
||||
</ul>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Kontaktdaten der Aufsichtsbehörde</h2>
|
||||
<p><strong>Österreichische Datenschutzbehörde</strong><br/> Barichgasse 40-42, 1030 Wien, Österreich<br/> Telefon: +43 1 52 152-0<br/> E-Mail: <a href="mailto:dsb@dsb.gv.at" style="color: var(--accent-strong);">dsb@dsb.gv.at</a></p>
|
||||
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Änderungen dieser Datenschutzerklärung</h2>
|
||||
<p>Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen oder bei Änderungen unserer Dienste anzupassen. Die jeweils aktuelle Version ist auf unserer Website abrufbar.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer" id="kontakt">
|
||||
<div class="shell">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<div class="logo" style="margin-bottom:0.8rem;">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
||||
<a href="/agb" data-i18n="terms">AGB</a>
|
||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</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>
|
||||
|
||||
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+88
-44
@@ -2,21 +2,18 @@
|
||||
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 höchstem Niveau.",
|
||||
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.",
|
||||
heroLead: "Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.",
|
||||
|
||||
statDeposit: "Faire Kaution",
|
||||
statSupport: "Support",
|
||||
statCars: "Fahrzeuge",
|
||||
statCar: "Fahrzeug",
|
||||
|
||||
fleetEyebrow: "Unsere Flotte",
|
||||
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
|
||||
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
|
||||
filterBrand: "Marke",
|
||||
@@ -36,15 +33,6 @@ export const translations = {
|
||||
from: "ab",
|
||||
noMatches: "Keine Fahrzeuge gefunden.",
|
||||
|
||||
whyEyebrow: "Warum MC Cars",
|
||||
whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspaß.",
|
||||
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: "Faire Kaution",
|
||||
whyDepositText: "Zwei Kautionsarten: Bar oder PayPal-Kaution. Bei PayPal senden wir einen Deposit-Link. Bar wird aktuell persönlich bei der Fahrzeugübergabe abgewickelt.",
|
||||
|
||||
reviewsEyebrow: "Kundenmeinungen",
|
||||
reviewsTitle: "Erlebnisse, die bleiben.",
|
||||
review: "Kundenmeinung",
|
||||
@@ -89,6 +77,7 @@ export const translations = {
|
||||
bpfWeekendRate: "Wochenendmiete",
|
||||
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
||||
bpfMaxKm: "Max. km/Tag",
|
||||
bpfInclKmPerDay: "Inkl. km/Tag",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Preisübersicht",
|
||||
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
|
||||
@@ -112,13 +101,14 @@ export const translations = {
|
||||
perWeekend: "Wochenende",
|
||||
weekendDef: "Sa 9:00 – So 20:00",
|
||||
|
||||
footerTagline: "Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).",
|
||||
footerTagline: "Sportwagenvermietung in der Steiermark, Österreich.",
|
||||
footerLegal: "Rechtliches",
|
||||
footerContact: "Kontakt",
|
||||
footerNav: "Navigation",
|
||||
imprint: "Impressum",
|
||||
privacy: "Datenschutz",
|
||||
footerTerms: "Mietbedingungen",
|
||||
terms: "AGB",
|
||||
rentalTerms: "Mietbedingungen",
|
||||
copyright: "Alle Rechte vorbehalten.",
|
||||
|
||||
close: "Schließen",
|
||||
@@ -130,6 +120,12 @@ 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.",
|
||||
adminMietvertragTemplate: "Mietvertrag-Vorlage (DOCX)",
|
||||
adminMietvertragHint: "DOCX-Vorlage mit Platzhaltern. Wird bei Qualifizierung automatisch ausgefüllt und als PDF per E-Mail versendet.",
|
||||
adminMietvertragEmpty: "Keine Vorlage hochgeladen.",
|
||||
adminNewVehicle: "Neues Fahrzeug",
|
||||
adminAllVehicles: "Alle Fahrzeuge",
|
||||
adminPhotoUpload: "Foto hochladen (JPG/PNG/WebP, max 50 MB)",
|
||||
@@ -169,7 +165,7 @@ export const translations = {
|
||||
adminVehicleTab: "Fahrzeug",
|
||||
adminPeriod: "Zeitraum",
|
||||
adminKaution: "Kaution (€)",
|
||||
adminMaxKmWeekend: "Max. km/Wochenendtag",
|
||||
adminMaxKmWeekend: "Inkl. km/Wochenende",
|
||||
adminTotalPrice: "Gesamtbetrag",
|
||||
adminLifetimeValueCol: "Gesamtwert",
|
||||
adminTabGeneral: "Allgemein",
|
||||
@@ -180,10 +176,23 @@ export const translations = {
|
||||
adminTabDocumentsEn: "Documents",
|
||||
adminTabNotes: "Notiz",
|
||||
adminTabNotesEn: "Notes",
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminTabOrderHistory: "Bestellungen",
|
||||
adminTabOrderHistoryEn: "Sales Orders",
|
||||
adminPrivateNotes: "Private Notizen",
|
||||
adminPrivateNotesEn: "Private Notes",
|
||||
adminSaveNotes: "Notizen speichern",
|
||||
adminSaveNotesEn: "Save notes",
|
||||
adminNoOrders: "Keine Buchungen gefunden.",
|
||||
adminNoOrdersEn: "No bookings found.",
|
||||
adminKautionPending: "Kaution ausstehend",
|
||||
adminKautionPaid: "Kaution ✓",
|
||||
adminRentalPending: "Miete ausstehend",
|
||||
adminRentalPaid: "Miete ✓",
|
||||
adminCompletePending: "Abgeschlossen offen",
|
||||
adminCompleteDone: "Abgeschlossen ✓",
|
||||
adminLifetimeValue: "Gesamtwert aller Buchungen",
|
||||
adminLifetimeValueEn: "Lifetime value",
|
||||
adminDownload: "Download",
|
||||
adminDownload: "Herunterladen",
|
||||
adminNoDocuments: "Keine Dokumente hochgeladen",
|
||||
adminNoDocumentsEn: "No documents uploaded",
|
||||
adminIdDoc: "Ausweis / Führerschein",
|
||||
@@ -202,6 +211,8 @@ export const translations = {
|
||||
adminVatLabelEn: "VAT (20%)",
|
||||
adminTotalLabel: "Gesamtbetrag",
|
||||
adminTotalLabelEn: "Total",
|
||||
adminInclVat: "inkl. MwSt.",
|
||||
adminInclVatEn: "incl. VAT",
|
||||
adminDepositLabel: "Kaution",
|
||||
adminDepositLabelEn: "Deposit",
|
||||
adminIncludedKmLabel: "Inkl. km",
|
||||
@@ -211,27 +222,35 @@ export const translations = {
|
||||
adminFirstContacted: "Erster Kontakt",
|
||||
adminFirstContactedEn: "First contacted",
|
||||
adminNote: "Notiz",
|
||||
adminNoteEn: "Note",
|
||||
adminNoteEn: "Note",
|
||||
adminSave: "Speichern",
|
||||
adminSaveEn: "Save",
|
||||
adminPricePerKm: "Preis extra km (€)",
|
||||
adminRentalType: "Miettyp",
|
||||
rentalTypeWeekend: "Wochenende",
|
||||
rentalTypeIndividuell: "Individuell",
|
||||
adminSortOrder: "Ordnung",
|
||||
adminEmailSent: "E-Mail gesendet",
|
||||
sendEmailButton: "E-Mail senden",
|
||||
emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...",
|
||||
emailAlreadySent: "Bereits gesendet",
|
||||
bpfIndividuellTitle: "Individuelle Mietdauer",
|
||||
bpfIndividuellDesc: "Bei Mietdauer über 2 Tagen erstellen wir ein persönliches Angebot. Wir prüfen Verfügbarkeit und melden uns in Kürze per E-Mail bei Ihnen.",
|
||||
},
|
||||
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. Fair deposit, full transparency, ready to launch.",
|
||||
heroLead: "The Ferrari in Styria. Fair deposit, full transparency, ready to launch.",
|
||||
|
||||
statDeposit: "Fair Deposit",
|
||||
statSupport: "Support",
|
||||
statCars: "Vehicles",
|
||||
statCar: "Vehicle",
|
||||
|
||||
fleetEyebrow: "Our Fleet",
|
||||
fleetTitle: "Hand-picked. Maintained. Ready.",
|
||||
fleetSub: "Filter by brand or price. Click for details or book directly.",
|
||||
filterBrand: "Brand",
|
||||
@@ -251,15 +270,6 @@ export const translations = {
|
||||
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: "Fair Deposit",
|
||||
whyDepositText: "Two deposit options: cash or PayPal deposit. For PayPal, we send a deposit link. Cash is currently handled in person at pickup.",
|
||||
|
||||
reviewsEyebrow: "Testimonials",
|
||||
reviewsTitle: "Experiences that last.",
|
||||
review: "Review",
|
||||
@@ -303,7 +313,8 @@ export const translations = {
|
||||
bpfDailyRate: "Daily rate",
|
||||
bpfWeekendRate: "Weekend rate",
|
||||
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
||||
bpfMaxKm: "Max. km/day",
|
||||
bpfMaxKm: "Max. km/day",
|
||||
bpfInclKmPerDay: "Included km/day",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Price overview",
|
||||
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
||||
@@ -327,13 +338,14 @@ export const translations = {
|
||||
perWeekend: "Weekend",
|
||||
weekendDef: "Sat 9 AM – Sun 8 PM",
|
||||
|
||||
footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
|
||||
footerTagline: "Sports car rental in Styria, Austria.",
|
||||
footerLegal: "Legal",
|
||||
footerContact: "Contact",
|
||||
footerNav: "Navigation",
|
||||
imprint: "Imprint",
|
||||
privacy: "Privacy",
|
||||
footerTerms: "Rental conditions",
|
||||
terms: "Terms",
|
||||
rentalTerms: "Rental Terms",
|
||||
copyright: "All rights reserved.",
|
||||
|
||||
close: "Close",
|
||||
@@ -345,6 +357,12 @@ 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.",
|
||||
adminMietvertragTemplate: "Rental Contract Template (DOCX)",
|
||||
adminMietvertragHint: "DOCX template with placeholders. Automatically filled and sent as PDF via email upon qualification.",
|
||||
adminMietvertragEmpty: "No template uploaded.",
|
||||
adminNewVehicle: "New vehicle",
|
||||
adminAllVehicles: "All vehicles",
|
||||
adminPhotoUpload: "Upload photo (JPG/PNG/WebP, max 50 MB)",
|
||||
@@ -384,7 +402,7 @@ export const translations = {
|
||||
adminVehicleTab: "Vehicle",
|
||||
adminPeriod: "Period",
|
||||
adminKaution: "Deposit (€)",
|
||||
adminMaxKmWeekend: "Max. km/weekend day",
|
||||
adminMaxKmWeekend: "Included km/weekend",
|
||||
adminTotalPrice: "Total",
|
||||
adminLifetimeValueCol: "Lifetime",
|
||||
adminTabGeneral: "General",
|
||||
@@ -395,7 +413,20 @@ export const translations = {
|
||||
adminTabDocumentsEn: "Dokumente",
|
||||
adminTabNotes: "Notes",
|
||||
adminTabNotesEn: "Notiz",
|
||||
adminTabOrderHistory: "Order History",
|
||||
adminTabOrderHistory: "Sales Orders",
|
||||
adminTabOrderHistoryEn: "Bestellungen",
|
||||
adminPrivateNotes: "Private Notes",
|
||||
adminPrivateNotesEn: "Private Notizen",
|
||||
adminSaveNotes: "Save notes",
|
||||
adminSaveNotesEn: "Notizen speichern",
|
||||
adminNoOrders: "No bookings found.",
|
||||
adminNoOrdersEn: "Keine Buchungen gefunden.",
|
||||
adminKautionPending: "Deposit pending",
|
||||
adminKautionPaid: "Deposit ✓",
|
||||
adminRentalPending: "Rental pending",
|
||||
adminRentalPaid: "Rental ✓",
|
||||
adminCompletePending: "Complete open",
|
||||
adminCompleteDone: "Complete ✓",
|
||||
adminLifetimeValue: "Lifetime value",
|
||||
adminLifetimeValueEn: "Gesamtwert aller Buchungen",
|
||||
adminDownload: "Download",
|
||||
@@ -417,6 +448,8 @@ export const translations = {
|
||||
adminVatLabelEn: "MwSt. (20%)",
|
||||
adminTotalLabel: "Total",
|
||||
adminTotalLabelEn: "Gesamtbetrag",
|
||||
adminInclVat: "incl. VAT",
|
||||
adminInclVatEn: "inkl. MwSt.",
|
||||
adminDepositLabel: "Deposit",
|
||||
adminDepositLabelEn: "Kaution",
|
||||
adminIncludedKmLabel: "Included km",
|
||||
@@ -429,15 +462,26 @@ export const translations = {
|
||||
adminNoteEn: "Notiz",
|
||||
adminSave: "Save",
|
||||
adminSaveEn: "Speichern",
|
||||
adminPricePerKm: "Extra km price (€)",
|
||||
adminRentalType: "Rental type",
|
||||
rentalTypeWeekend: "Weekend",
|
||||
rentalTypeIndividuell: "Custom",
|
||||
adminSortOrder: "Order",
|
||||
adminEmailSent: "Email sent",
|
||||
sendEmailButton: "Send Email",
|
||||
emailSentToast: "Email is being prepared and will be sent shortly...",
|
||||
emailAlreadySent: "Already sent",
|
||||
bpfIndividuellTitle: "Custom Rental Duration",
|
||||
bpfIndividuellDesc: "For rental periods over 2 days, we'll create a personalized quote. We'll check availability and get back to you via email shortly.",
|
||||
},
|
||||
};
|
||||
|
||||
export const REVIEWS = [
|
||||
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
|
||||
{ quote: "Exzellenter Service und makellos vorbereitete Fahrzeuge. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
|
||||
{ quote: "Hervorragende Buchungsabwicklung und tadelloses Fahrzeugzustand. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
|
||||
{ quote: "Professionelles Team und untadelige Aufmerksamkeit zum Detail. Sehr empfohlen.", author: "David M.", lang: "de" },
|
||||
{ quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
|
||||
{ quote: "Die Buchung war klar und schnell. Der Ferrari war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
|
||||
{ quote: "Exzellenter Service und ein makellos vorbereiteter Ferrari. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
|
||||
{ quote: "Hervorragende Buchungsabwicklung und tadelloser Zustand des Ferrari. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
|
||||
{ quote: "Professionelles Team und erstklassiger Ferrari. Absolut empfehlenswert.", author: "David M.", lang: "de" },
|
||||
{ quote: "Booking was clear and fast. The Ferrari arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
|
||||
];
|
||||
|
||||
export function getLang() {
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 1254 1254" enable-background="new 0 0 1254 1254" xml:space="preserve">
|
||||
<path fill="#181b23" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M701.000000,1255.000000
|
||||
C467.333344,1255.000000 234.166672,1255.000000 1.000000,1255.000000
|
||||
C1.000000,837.000000 1.000000,419.000000 1.000000,1.000000
|
||||
C419.000000,1.000000 837.000000,1.000000 1255.000000,1.000000
|
||||
C1255.000000,419.000000 1255.000000,837.000000 1255.000000,1255.000000
|
||||
C1070.500000,1255.000000 886.000000,1255.000000 701.000000,1255.000000
|
||||
M396.781219,313.799316
|
||||
C397.171356,314.222809 397.561462,314.646301 398.001038,315.017700
|
||||
C398.001038,315.017700 397.934113,315.043488 398.151062,315.750244
|
||||
C399.478943,316.373016 400.886017,317.648407 402.120117,317.498596
|
||||
C406.139893,317.010681 410.096466,316.002075 414.733521,315.276184
|
||||
C415.157410,315.168335 415.581268,315.060455 416.745514,315.054932
|
||||
C418.189423,314.770813 419.633331,314.486694 421.813538,314.319031
|
||||
C422.542450,314.193451 423.271332,314.067871 424.874542,314.048035
|
||||
C427.925720,313.724304 430.976898,313.400574 434.521881,313.184052
|
||||
C434.682587,313.133514 434.843292,313.083008 435.708618,313.187958
|
||||
C446.342194,312.921204 456.976135,312.412842 467.609253,312.430511
|
||||
C508.679047,312.498688 549.750366,313.024567 590.817383,312.771454
|
||||
C608.231873,312.664124 625.637146,311.053101 643.707214,310.244781
|
||||
C644.141846,310.155365 644.576538,310.065948 645.653992,310.116730
|
||||
C648.449707,309.782227 651.245483,309.447723 654.701111,309.229950
|
||||
C655.133850,309.135101 655.566650,309.040253 656.741760,309.052643
|
||||
C658.184998,308.764557 659.628174,308.476440 661.559326,308.241028
|
||||
C661.559326,308.241028 662.009216,308.044861 662.636475,308.179169
|
||||
C664.764282,307.808258 666.892090,307.437378 669.528076,307.128723
|
||||
C669.686584,307.065399 669.845032,307.002106 670.859375,307.031677
|
||||
C674.244629,306.371277 677.629883,305.710907 681.522522,305.121155
|
||||
C681.681946,305.060120 681.841431,304.999054 682.844971,305.006561
|
||||
C686.902588,304.023102 690.960205,303.039612 695.527466,302.095306
|
||||
C695.683716,302.027435 695.840027,301.959564 696.640991,301.979736
|
||||
C697.446350,301.702332 698.251709,301.424927 699.552368,301.165436
|
||||
C699.552368,301.165436 699.999451,300.951569 700.759521,300.993317
|
||||
C702.861633,300.388641 704.963745,299.783997 707.561462,299.212860
|
||||
C707.561462,299.212860 708.006836,298.992889 708.780640,299.021851
|
||||
C712.833008,297.947479 716.885376,296.873108 720.937744,295.798706
|
||||
C720.865356,295.500671 720.792908,295.202606 720.720520,294.904541
|
||||
C716.495972,295.156158 712.271484,295.407745 707.153503,295.550201
|
||||
C704.763428,295.718048 702.373352,295.885895 699.307434,295.870880
|
||||
C691.827026,296.147675 684.347717,296.456543 676.866028,296.694214
|
||||
C663.900513,297.106110 650.933472,297.473816 637.052124,297.673737
|
||||
C631.725525,297.710205 626.398865,297.746674 620.286987,297.536438
|
||||
C608.196716,297.367432 596.106445,297.198456 583.108887,296.825806
|
||||
C579.101318,296.798584 575.093689,296.771393 570.335876,296.430695
|
||||
C564.563171,296.302856 558.790405,296.175018 552.284851,295.845795
|
||||
C550.530273,295.807404 548.775757,295.769043 546.125244,295.484924
|
||||
C541.229675,295.210266 536.334167,294.935608 531.438599,294.660950
|
||||
C531.433716,294.287903 531.428833,293.914825 531.423950,293.541748
|
||||
C532.621826,292.739075 533.819641,291.936401 535.753296,291.029877
|
||||
C548.082947,285.569672 560.423279,280.133575 572.740295,274.645020
|
||||
C619.553894,253.784515 668.285034,242.128235 719.822815,242.017166
|
||||
C774.537720,241.899246 827.307434,252.427643 879.229797,268.542175
|
||||
C881.159363,269.141052 883.002747,270.017517 884.883545,270.763519
|
||||
C884.563477,271.430573 884.508972,271.681091 884.430054,271.689056
|
||||
C876.813049,272.455048 869.187500,273.140594 861.576477,273.961182
|
||||
C806.742737,279.872833 759.507080,301.690308 720.826050,341.338104
|
||||
C719.755005,342.435852 719.080383,343.920410 718.093445,345.327209
|
||||
C718.093445,345.327209 717.972961,345.058960 717.972961,345.058960
|
||||
C717.972961,345.058960 718.084839,345.315521 718.650269,344.896301
|
||||
C735.223145,333.885864 752.783081,324.744263 771.302368,317.503693
|
||||
C831.078369,294.132935 892.834778,289.612976 956.081787,297.510864
|
||||
C979.010681,300.374115 1002.125366,302.170929 1025.211304,303.216339
|
||||
C1042.583984,304.003052 1059.904541,301.704041 1075.887085,294.038757
|
||||
C1082.150146,291.035004 1087.853149,286.863739 1093.811279,283.224213
|
||||
C1093.559448,282.781799 1093.307617,282.339355 1093.055786,281.896942
|
||||
C1091.600708,282.143341 1090.154053,282.517822 1088.689087,282.616180
|
||||
C1075.105347,283.528564 1061.482544,285.491821 1047.939575,285.049744
|
||||
C1017.028503,284.040741 987.651123,274.883209 958.250061,266.153778
|
||||
C901.083801,249.180603 843.656006,233.270599 784.319458,225.623398
|
||||
C746.606262,220.762939 708.936340,220.984604 671.272217,226.568558
|
||||
C607.108459,236.081253 549.352417,262.637085 492.225098,292.998047
|
||||
C488.066254,292.794434 483.907227,292.414398 479.748596,292.418854
|
||||
C464.788544,292.434906 449.828644,292.580688 434.029724,292.587646
|
||||
C432.350281,292.722931 430.670837,292.858246 428.161041,292.845856
|
||||
C425.741302,293.148315 423.321564,293.450775 420.170654,293.643799
|
||||
C419.445709,293.765839 418.720734,293.887878 417.375122,293.833588
|
||||
C415.225281,294.158722 413.075470,294.483856 410.273438,294.703339
|
||||
C409.847961,294.801819 409.422485,294.900269 408.331085,294.872192
|
||||
C402.205994,295.872437 396.080933,296.872681 389.453491,297.820312
|
||||
C389.300934,297.891479 389.148346,297.962677 388.311951,297.963135
|
||||
C387.769958,298.605743 386.640411,299.595123 386.781708,299.834503
|
||||
C387.673859,301.346039 388.847778,302.691284 389.989868,304.026306
|
||||
C389.989868,304.026306 389.907837,304.061707 390.092712,304.676270
|
||||
C390.663269,305.303772 391.233795,305.931274 391.920441,307.024323
|
||||
C392.236633,307.367615 392.552826,307.710907 393.022980,308.653168
|
||||
C393.683838,309.432037 394.344666,310.210907 394.998779,311.000000
|
||||
C394.998779,311.000000 395.008911,310.995544 395.137665,311.571503
|
||||
C395.645935,312.157867 396.154205,312.744232 396.681732,313.248444
|
||||
C396.681732,313.248444 396.597687,313.241608 396.781219,313.799316
|
||||
M899.661255,831.203491
|
||||
C908.254639,833.730408 911.388550,837.512146 912.155518,846.361877
|
||||
C912.298889,848.016418 912.347168,849.683716 912.348938,851.345215
|
||||
C912.385498,885.620789 912.416748,919.896423 912.420654,954.171997
|
||||
C912.420959,956.996643 912.353821,959.834412 912.083313,962.643433
|
||||
C911.380554,969.939453 908.202637,975.377869 900.457458,976.891174
|
||||
C896.860291,977.593994 896.383850,979.606018 897.455200,982.831421
|
||||
C916.869507,982.831421 936.268188,982.831421 955.957520,982.831421
|
||||
C955.957520,981.068054 955.957520,979.512329 955.957520,978.035461
|
||||
C943.790100,974.055786 943.201233,973.545105 940.747864,964.027405
|
||||
C940.699707,946.664612 940.651550,929.301880 941.566040,911.765930
|
||||
C953.093933,909.562439 959.488159,915.274353 964.879822,924.964233
|
||||
C973.048279,939.644592 982.464905,953.635925 991.488892,967.832458
|
||||
C996.323059,975.437500 1003.011658,980.818787 1011.994629,982.646057
|
||||
C1022.533875,984.789978 1033.125488,984.504272 1044.823486,982.450378
|
||||
C1043.779175,980.521118 1043.281616,978.100159 1042.161377,977.765869
|
||||
C1031.492432,974.581238 1024.974609,966.588135 1019.008789,958.076355
|
||||
C1007.823547,942.118103 996.874023,925.994629 985.849121,909.924377
|
||||
C985.158569,908.917725 984.664795,907.776245 984.127380,906.785706
|
||||
C988.278015,905.212891 992.126099,904.042664 995.723022,902.341370
|
||||
C1010.548279,895.329285 1018.324402,883.373596 1018.604919,867.123840
|
||||
C1018.880737,851.153076 1011.481079,839.381470 996.972046,832.387451
|
||||
C984.955994,826.595154 972.178833,825.056641 958.081360,825.816528
|
||||
C951.381653,825.864868 944.681885,825.913208 937.075623,825.751404
|
||||
C932.720642,825.823792 928.365662,825.896179 923.083557,825.810547
|
||||
C916.049316,825.859253 909.015076,825.907898 901.251709,825.757141
|
||||
C900.503906,825.821350 899.756165,825.885620 898.185303,825.844971
|
||||
C896.539246,827.840454 895.281250,829.774719 899.661255,831.203491
|
||||
M342.377563,612.500000
|
||||
C342.377563,546.821960 342.377563,481.143921 342.377563,415.465851
|
||||
C342.958832,415.269623 343.540070,415.073395 344.121338,414.877167
|
||||
C411.630127,508.205872 479.138947,601.534607 546.863403,695.161438
|
||||
C548.020203,693.651001 549.029724,692.368408 550.002014,691.058228
|
||||
C584.351501,644.771912 618.488220,598.325684 653.120667,552.252075
|
||||
C674.754944,523.470703 697.542725,495.647705 723.774902,470.764221
|
||||
C770.021179,426.895569 823.415344,399.275452 888.272766,397.703644
|
||||
C928.537354,396.727844 964.895752,407.770935 995.619995,434.805878
|
||||
C1002.730652,441.062622 1009.992676,447.147247 1017.396973,453.492737
|
||||
C1034.874756,437.186096 1054.087769,419.260529 1073.177368,401.450104
|
||||
C1072.942627,400.737000 1072.920776,400.364136 1072.729004,400.131012
|
||||
C1047.276855,369.189423 1016.585510,345.808441 977.770569,334.012177
|
||||
C943.889099,323.715210 909.323120,320.099457 874.044983,322.741333
|
||||
C830.967285,325.967285 790.045532,336.734283 751.852661,357.264099
|
||||
C711.883240,378.748932 677.993958,407.840149 647.461365,441.020386
|
||||
C618.858459,472.103699 593.989014,506.159760 569.500000,540.492188
|
||||
C562.484070,550.328247 555.453918,560.154175 548.182922,570.330872
|
||||
C546.682434,568.357788 545.551270,566.952881 544.507812,565.485596
|
||||
C530.022766,545.117676 515.623901,524.688110 501.060333,504.376556
|
||||
C451.746399,435.599335 402.357910,366.875580 353.086639,298.067871
|
||||
C351.029236,295.194672 348.936646,294.044098 345.348358,294.060974
|
||||
C314.849731,294.204407 284.350067,294.127106 253.850723,294.116089
|
||||
C252.080444,294.115448 250.310181,294.115997 248.418060,294.115997
|
||||
C248.418060,444.198608 248.418060,593.610046 248.418060,743.261475
|
||||
C279.754730,743.261475 310.820251,743.261475 342.379639,743.261475
|
||||
C342.379639,699.779724 342.379639,656.639954 342.377563,612.500000
|
||||
M767.521057,704.973877
|
||||
C858.763550,772.466553 1002.016541,759.777527 1070.519043,664.034180
|
||||
C1052.421997,647.694031 1034.303955,631.334961 1016.167542,614.959229
|
||||
C970.622925,674.647339 887.092346,688.326477 826.294128,650.167725
|
||||
C799.310364,633.231873 779.692322,609.730286 769.489075,579.463135
|
||||
C755.117004,536.829590 762.565247,496.493652 784.840210,458.068756
|
||||
C783.091125,458.780029 781.617310,459.681000 780.207947,460.673584
|
||||
C743.732910,486.362610 713.644348,518.504333 686.533691,553.622314
|
||||
C685.342346,555.165527 684.516846,557.647095 684.758789,559.532593
|
||||
C686.153748,570.405701 686.810425,581.543274 689.754944,592.018433
|
||||
C702.723816,638.154053 728.986938,675.427490 767.521057,704.973877
|
||||
M240.043091,882.462097
|
||||
C231.663834,901.314758 223.284592,920.167480 214.590317,939.728943
|
||||
C212.759430,935.807373 211.323883,932.810791 209.953308,929.784851
|
||||
C194.710678,896.132019 179.506958,862.461426 164.141251,828.864929
|
||||
C163.500443,827.463867 161.384949,825.863586 159.935028,825.842712
|
||||
C146.945099,825.655151 133.950317,825.798279 120.957123,825.870789
|
||||
C120.688065,825.872314 120.420792,826.192505 119.913864,826.516907
|
||||
C119.973740,827.826477 120.039406,829.262817 120.100716,830.603882
|
||||
C133.238220,834.237915 135.955963,837.707581 134.991364,850.879333
|
||||
C133.738647,867.985535 132.188660,885.071472 130.613647,902.152039
|
||||
C128.824738,921.551941 127.017845,940.952698 124.911133,960.319641
|
||||
C124.019203,968.519043 120.197647,974.907959 111.409042,976.915833
|
||||
C107.852699,977.728394 108.255356,980.038635 108.902344,982.731079
|
||||
C124.439468,982.731079 139.842697,982.731079 155.116333,982.731079
|
||||
C156.811462,978.687744 154.946045,977.575378 151.544220,976.828613
|
||||
C143.097702,974.974426 139.366440,970.589966 138.594238,961.995789
|
||||
C138.356949,959.354980 138.420029,956.659668 138.624588,954.010864
|
||||
C140.740677,926.609070 142.896790,899.210327 145.094650,871.815002
|
||||
C145.346909,868.670593 145.920700,865.552002 146.513458,861.182739
|
||||
C147.693909,863.463379 148.267746,864.460632 148.741486,865.503357
|
||||
C166.022629,903.541443 183.332962,941.566406 200.488159,979.661255
|
||||
C201.743317,982.448425 203.278671,983.133362 206.097534,983.210938
|
||||
C208.970184,983.290039 210.107437,982.111877 211.163452,979.658203
|
||||
C223.996704,949.839294 236.926147,920.061707 249.866196,890.288818
|
||||
C254.004623,880.767029 258.247681,871.290710 263.030640,860.463013
|
||||
C263.521057,862.735840 263.764343,863.480652 263.833954,864.241211
|
||||
C266.627228,894.762329 269.274414,925.297546 272.241516,955.801636
|
||||
C273.645294,970.233887 270.144226,975.136658 258.330750,978.229797
|
||||
C258.330750,979.628052 258.330750,981.059692 258.330750,982.774841
|
||||
C278.626587,982.774841 298.706604,982.774841 319.045044,982.774841
|
||||
C319.045044,980.990906 319.045044,979.439514 319.045044,977.546448
|
||||
C307.608154,976.237854 303.706635,968.652954 302.708405,958.534302
|
||||
C301.040771,941.630432 299.197998,924.743713 297.540649,907.838867
|
||||
C295.656006,888.615662 293.569672,869.403381 292.199158,850.141663
|
||||
C291.366211,838.434998 294.769104,834.433167 305.932343,830.965210
|
||||
C306.362823,830.831482 306.727966,830.487488 307.120667,830.243225
|
||||
C307.120667,828.833069 307.120667,827.539917 307.120667,825.713806
|
||||
C294.333740,825.713806 281.713135,825.837219 269.098022,825.629333
|
||||
C265.832306,825.575500 264.457031,826.887878 263.254242,829.656189
|
||||
C255.693024,847.058838 247.976471,864.393921 240.043091,882.462097
|
||||
M752.725037,926.095276
|
||||
C747.955322,937.458862 743.343811,948.892395 738.361511,960.161987
|
||||
C734.917969,967.950684 730.248596,974.777710 721.265503,977.139893
|
||||
C717.893982,978.026367 717.950073,980.210571 719.115662,982.872314
|
||||
C734.391174,982.872314 749.510132,982.872314 764.929199,982.872314
|
||||
C764.929199,981.104614 764.929199,979.552429 764.929199,978.093872
|
||||
C750.868347,973.479126 749.241211,970.191650 754.325134,956.485779
|
||||
C756.988159,949.306335 759.733521,942.145935 762.822205,935.144836
|
||||
C763.489502,933.632202 765.774597,931.888611 767.341187,931.862732
|
||||
C783.994934,931.586853 800.654419,931.588562 817.311218,931.688599
|
||||
C818.620361,931.696411 820.645081,932.695801 821.113647,933.768677
|
||||
C825.371765,943.519958 829.602356,953.298340 833.337463,963.257690
|
||||
C835.823853,969.887573 833.281921,974.602417 826.447388,976.767334
|
||||
C823.035461,977.848083 821.768250,979.374268 823.218506,982.820190
|
||||
C843.397034,982.820190 863.494934,982.820190 883.800537,982.820190
|
||||
C883.800537,981.120361 883.800537,979.680908 883.800537,977.883240
|
||||
C873.755371,976.109192 868.798645,968.913696 865.012085,960.308838
|
||||
C845.381104,915.699036 825.586548,871.160767 806.124512,826.477661
|
||||
C804.434143,822.596741 802.057678,823.122253 799.142822,822.839478
|
||||
C796.060181,822.540283 794.831299,823.909180 793.738159,826.576355
|
||||
C780.228333,859.539673 766.602478,892.455444 752.725037,926.095276
|
||||
M1119.461914,971.209045
|
||||
C1098.797729,980.869446 1073.666748,969.569031 1066.172729,947.202209
|
||||
C1064.919189,943.461060 1063.903809,939.640137 1062.705566,935.611206
|
||||
C1060.411133,935.963684 1058.504517,936.256592 1057.000000,936.487732
|
||||
C1057.000000,948.935730 1056.916870,960.892395 1057.135986,972.843506
|
||||
C1057.156616,973.968018 1058.873413,975.515259 1060.152222,976.093750
|
||||
C1075.699585,983.127014 1091.884399,987.246033 1109.105469,986.033508
|
||||
C1125.354492,984.889404 1139.900513,979.687256 1150.322632,966.548035
|
||||
C1167.572388,944.801025 1161.346313,914.167542 1136.631348,899.357971
|
||||
C1127.793091,894.061951 1118.190552,890.056335 1109.196533,884.999878
|
||||
C1102.851807,881.432983 1096.142578,877.914795 1091.000122,872.927185
|
||||
C1078.015869,860.333740 1083.995972,839.108215 1101.475952,834.644775
|
||||
C1109.724121,832.538635 1118.009277,833.036560 1125.834717,836.952271
|
||||
C1136.273926,842.176086 1141.064331,851.541321 1143.825439,862.141907
|
||||
C1144.991333,866.618286 1147.184082,867.167847 1151.080688,865.670410
|
||||
C1151.080688,859.555176 1151.159790,853.559021 1151.056763,847.566040
|
||||
C1150.965942,842.283264 1152.591187,835.498535 1149.949097,832.129822
|
||||
C1147.081421,828.473633 1140.257446,827.634033 1134.985840,826.201965
|
||||
C1116.642456,821.218689 1098.529053,820.576172 1081.014038,829.683533
|
||||
C1056.068848,842.654419 1050.181274,876.186279 1070.459595,895.583984
|
||||
C1078.354980,903.136353 1088.671631,908.237915 1098.141357,914.037781
|
||||
C1105.513550,918.552856 1113.746704,921.759766 1120.781006,926.708435
|
||||
C1136.957275,938.088684 1136.393433,959.731079 1119.461914,971.209045
|
||||
M410.241119,972.547424
|
||||
C390.010254,966.709839 377.104034,953.318665 370.803955,933.542297
|
||||
C364.793274,914.674377 364.440918,895.586609 369.720398,876.417664
|
||||
C377.518402,848.104187 396.613190,833.567810 425.904724,833.794617
|
||||
C447.896790,833.964844 463.754089,845.443665 470.377106,866.394104
|
||||
C471.682861,870.524658 473.350433,872.402283 478.019867,870.456177
|
||||
C477.456665,858.759216 476.956299,846.980957 476.205383,835.218750
|
||||
C476.140411,834.200623 474.593964,832.822998 473.444275,832.394775
|
||||
C445.710236,822.064392 417.425232,818.360352 388.855621,828.097534
|
||||
C357.568085,838.761108 338.275635,860.711121 333.961670,893.668030
|
||||
C329.663330,926.505554 340.614594,954.110657 369.422424,971.696716
|
||||
C403.228607,992.333984 438.628601,989.872742 473.857178,974.409729
|
||||
C474.850677,973.973572 475.911041,972.607849 476.079132,971.539978
|
||||
C477.809326,960.549927 479.361694,949.531860 481.020905,938.127930
|
||||
C478.976471,937.638977 477.134216,937.198364 475.160278,936.726257
|
||||
C474.016022,939.454102 473.041382,941.737061 472.098083,944.032898
|
||||
C465.199829,960.822510 453.006500,971.306580 434.860199,973.406677
|
||||
C427.050293,974.310547 418.989166,973.043457 410.241119,972.547424
|
||||
M597.621521,971.878357
|
||||
C631.630188,991.818359 666.190979,989.852722 701.049133,974.400024
|
||||
C702.088135,973.939514 703.313782,972.696960 703.482300,971.661133
|
||||
C705.294312,960.522217 706.911133,949.351562 708.551392,938.389343
|
||||
C704.149902,935.638611 702.045776,936.802002 700.562378,941.245056
|
||||
C697.776672,949.588501 692.900085,956.670044 686.465759,962.748718
|
||||
C668.544312,979.679260 624.789490,981.437256 605.977844,947.884399
|
||||
C591.294006,921.693787 590.954163,893.914246 602.233765,866.489502
|
||||
C613.684814,838.647949 637.191223,829.613220 665.056030,835.189758
|
||||
C681.373169,838.455200 692.208862,848.626648 697.505615,864.514221
|
||||
C698.230774,866.689453 699.002075,868.849304 699.811340,871.187683
|
||||
C701.806091,871.083801 703.560730,870.992432 705.618835,870.885193
|
||||
C705.096985,858.863403 704.655090,847.244019 703.985474,835.637817
|
||||
C703.923462,834.563110 702.619812,833.023499 701.547424,832.621887
|
||||
C673.181946,821.998047 644.379456,817.938538 615.220764,828.981201
|
||||
C584.257874,840.707092 565.942810,863.315796 562.330505,896.230896
|
||||
C558.868347,927.777710 570.462036,953.307617 597.621521,971.878357
|
||||
M773.138794,743.546509
|
||||
C772.200073,742.866699 771.289856,742.142761 770.318359,741.513611
|
||||
C737.921082,720.529480 711.533325,693.640808 691.080872,660.914062
|
||||
C689.984192,659.159241 688.834045,657.437805 687.169312,654.868103
|
||||
C687.169312,685.306335 687.169312,714.642639 687.169312,744.122742
|
||||
C716.004272,744.122742 744.455444,744.122742 773.138794,743.546509
|
||||
M1066.790894,333.627319
|
||||
C1077.286499,341.597107 1080.680542,353.318207 1083.160522,366.093536
|
||||
C1090.884521,350.208221 1087.062866,320.920654 1081.299438,313.952759
|
||||
C1068.203125,317.041840 1055.669800,319.998108 1044.897705,322.538940
|
||||
C1050.960815,325.563965 1058.593994,329.372223 1066.790894,333.627319
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M342.378601,613.000061
|
||||
C342.379639,656.639954 342.379639,699.779724 342.379639,743.261475
|
||||
C310.820251,743.261475 279.754730,743.261475 248.418060,743.261475
|
||||
C248.418060,593.610046 248.418060,444.198608 248.418060,294.115997
|
||||
C250.310181,294.115997 252.080444,294.115448 253.850723,294.116089
|
||||
C284.350067,294.127106 314.849731,294.204407 345.348358,294.060974
|
||||
C348.936646,294.044098 351.029236,295.194672 353.086639,298.067871
|
||||
C402.357910,366.875580 451.746399,435.599335 501.060333,504.376556
|
||||
C515.623901,524.688110 530.022766,545.117676 544.507812,565.485596
|
||||
C545.551270,566.952881 546.682434,568.357788 548.182922,570.330872
|
||||
C555.453918,560.154175 562.484070,550.328247 569.500000,540.492188
|
||||
C593.989014,506.159760 618.858459,472.103699 647.461365,441.020386
|
||||
C677.993958,407.840149 711.883240,378.748932 751.852661,357.264099
|
||||
C790.045532,336.734283 830.967285,325.967285 874.044983,322.741333
|
||||
C909.323120,320.099457 943.889099,323.715210 977.770569,334.012177
|
||||
C1016.585510,345.808441 1047.276855,369.189423 1072.729004,400.131012
|
||||
C1072.920776,400.364136 1072.942627,400.737000 1073.177368,401.450104
|
||||
C1054.087769,419.260529 1034.874756,437.186096 1017.396973,453.492737
|
||||
C1009.992676,447.147247 1002.730652,441.062622 995.619995,434.805878
|
||||
C964.895752,407.770935 928.537354,396.727844 888.272766,397.703644
|
||||
C823.415344,399.275452 770.021179,426.895569 723.774902,470.764221
|
||||
C697.542725,495.647705 674.754944,523.470703 653.120667,552.252075
|
||||
C618.488220,598.325684 584.351501,644.771912 550.002014,691.058228
|
||||
C549.029724,692.368408 548.020203,693.651001 546.863403,695.161438
|
||||
C479.138947,601.534607 411.630127,508.205872 344.121338,414.877167
|
||||
C343.540070,415.073395 342.958832,415.269623 342.377563,415.465851
|
||||
C342.377563,481.143921 342.377563,546.821960 342.378601,613.000061
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M767.231995,704.764160
|
||||
C728.986938,675.427490 702.723816,638.154053 689.754944,592.018433
|
||||
C686.810425,581.543274 686.153748,570.405701 684.758789,559.532593
|
||||
C684.516846,557.647095 685.342346,555.165527 686.533691,553.622314
|
||||
C713.644348,518.504333 743.732910,486.362610 780.207947,460.673584
|
||||
C781.617310,459.681000 783.091125,458.780029 784.840210,458.068756
|
||||
C762.565247,496.493652 755.117004,536.829590 769.489075,579.463135
|
||||
C779.692322,609.730286 799.310364,633.231873 826.294128,650.167725
|
||||
C887.092346,688.326477 970.622925,674.647339 1016.167542,614.959229
|
||||
C1034.303955,631.334961 1052.421997,647.694031 1070.519043,664.034180
|
||||
C1002.016541,759.777527 858.763550,772.466553 767.231995,704.764160
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M408.997009,294.998718
|
||||
C409.422485,294.900269 409.847961,294.801819 410.902557,294.985901
|
||||
C413.686371,294.848938 415.841095,294.429413 417.995789,294.009888
|
||||
C418.720734,293.887878 419.445709,293.765839 420.848450,293.940430
|
||||
C424.014618,293.822540 426.503021,293.408051 428.991394,292.993530
|
||||
C430.670837,292.858246 432.350281,292.722931 434.785583,292.897644
|
||||
C437.495605,293.462982 439.444977,293.892731 441.404602,293.945770
|
||||
C455.533234,294.328400 469.662933,294.756714 483.794708,294.868927
|
||||
C486.863708,294.893280 489.942383,293.697327 493.016632,293.063416
|
||||
C549.352417,262.637085 607.108459,236.081253 671.272217,226.568558
|
||||
C708.936340,220.984604 746.606262,220.762939 784.319458,225.623398
|
||||
C843.656006,233.270599 901.083801,249.180603 958.250061,266.153778
|
||||
C987.651123,274.883209 1017.028503,284.040741 1047.939575,285.049744
|
||||
C1061.482544,285.491821 1075.105347,283.528564 1088.689087,282.616180
|
||||
C1090.154053,282.517822 1091.600708,282.143341 1093.055786,281.896942
|
||||
C1093.307617,282.339355 1093.559448,282.781799 1093.811279,283.224213
|
||||
C1087.853149,286.863739 1082.150146,291.035004 1075.887085,294.038757
|
||||
C1059.904541,301.704041 1042.583984,304.003052 1025.211304,303.216339
|
||||
C1002.125366,302.170929 979.010681,300.374115 956.081787,297.510864
|
||||
C892.834778,289.612976 831.078369,294.132935 771.302368,317.503693
|
||||
C752.783081,324.744263 735.223145,333.885864 718.389038,345.064514
|
||||
C718.127869,345.232758 718.220886,345.224609 718.220825,345.224609
|
||||
C719.080383,343.920410 719.755005,342.435852 720.826050,341.338104
|
||||
C759.507080,301.690308 806.742737,279.872833 861.576477,273.961182
|
||||
C869.187500,273.140594 876.813049,272.455048 884.430054,271.689056
|
||||
C884.508972,271.681091 884.563477,271.430573 884.883545,270.763519
|
||||
C883.002747,270.017517 881.159363,269.141052 879.229797,268.542175
|
||||
C827.307434,252.427643 774.537720,241.899246 719.822815,242.017166
|
||||
C668.285034,242.128235 619.553894,253.784515 572.740295,274.645020
|
||||
C560.423279,280.133575 548.082947,285.569672 535.117798,290.870880
|
||||
C532.487488,291.727478 530.492737,292.743103 528.497925,293.758728
|
||||
C529.131165,294.745911 529.779785,296.596466 530.395142,296.585480
|
||||
C535.940491,296.486267 541.479980,296.060577 547.021179,295.730652
|
||||
C548.775757,295.769043 550.530273,295.807404 552.829468,296.285553
|
||||
C559.278076,296.731598 565.182129,296.737885 571.086121,296.744171
|
||||
C575.093689,296.771393 579.101318,296.798584 583.804810,297.227142
|
||||
C585.870178,298.072968 587.235168,298.885956 588.609802,298.902527
|
||||
C598.033081,299.016205 607.458923,299.011414 616.882568,298.910858
|
||||
C618.282959,298.895905 619.675903,298.176605 621.072266,297.783142
|
||||
C626.398865,297.746674 631.725525,297.710205 637.644531,298.075562
|
||||
C640.780396,298.641632 643.326904,298.984131 645.866943,298.941223
|
||||
C662.335876,298.663361 678.804749,298.351013 695.269043,297.887939
|
||||
C696.856445,297.843262 698.412659,296.691559 699.983276,296.053741
|
||||
C702.373352,295.885895 704.763428,295.718048 707.599915,296.021637
|
||||
C708.033142,297.326355 708.019958,298.159607 708.006836,298.992889
|
||||
C708.006836,298.992889 707.561462,299.212860 707.044373,299.010559
|
||||
C704.351318,299.522675 702.175415,300.237122 699.999451,300.951569
|
||||
C699.999451,300.951569 699.552368,301.165436 699.083008,300.968079
|
||||
C697.741272,301.144348 696.868835,301.518005 695.996399,301.891693
|
||||
C695.840027,301.959564 695.683716,302.027435 695.022827,301.886169
|
||||
C690.345764,302.763977 686.173401,303.850952 682.000977,304.937958
|
||||
C681.841431,304.999054 681.681946,305.060120 680.990479,304.885864
|
||||
C676.973450,305.413300 673.488464,306.176025 670.003540,306.938782
|
||||
C669.845032,307.002106 669.686584,307.065399 668.963623,306.887512
|
||||
C666.269226,307.112488 664.139221,307.578674 662.009216,308.044861
|
||||
C662.009216,308.044861 661.559326,308.241028 661.042786,307.996399
|
||||
C659.017273,308.149658 657.508362,308.547516 655.999390,308.945374
|
||||
C655.566650,309.040253 655.133850,309.135101 654.052795,308.951477
|
||||
C650.606689,309.107513 647.808960,309.542023 645.011169,309.976532
|
||||
C644.576538,310.065948 644.141846,310.155365 643.031616,309.953644
|
||||
C636.750488,309.778473 631.144897,309.891510 625.539490,310.010986
|
||||
C609.627075,310.350128 593.714172,310.984406 577.802368,310.958618
|
||||
C558.000244,310.926514 538.199829,310.198822 518.396851,310.076691
|
||||
C509.263031,310.020355 500.126709,310.896118 490.988037,310.983154
|
||||
C474.214844,311.142975 457.438538,310.968201 440.665222,311.121002
|
||||
C438.772491,311.138275 436.890778,312.366089 435.003967,313.032532
|
||||
C434.843292,313.083008 434.682587,313.133514 433.940979,312.909851
|
||||
C430.240143,313.071228 427.120209,313.506775 424.000244,313.942322
|
||||
C423.271332,314.067871 422.542450,314.193451 421.174408,314.038879
|
||||
C419.025238,314.156708 417.515198,314.554657 416.005157,314.952606
|
||||
C415.581268,315.060455 415.157410,315.168335 414.114563,315.007416
|
||||
C409.690247,313.744415 405.890686,310.544586 402.115387,315.150635
|
||||
C401.572815,315.812592 399.369080,315.112976 397.934113,315.043488
|
||||
C397.934113,315.043488 398.001038,315.017700 397.904816,314.754425
|
||||
C397.404968,314.074646 397.001312,313.658112 396.597687,313.241608
|
||||
C396.597687,313.241608 396.681732,313.248444 396.658508,312.974640
|
||||
C396.093201,312.132385 395.551056,311.563965 395.008911,310.995544
|
||||
C395.008911,310.995544 394.998779,311.000000 394.972046,310.659790
|
||||
C394.253235,309.564453 393.561127,308.809326 392.869019,308.054199
|
||||
C392.552826,307.710907 392.236633,307.367615 391.820557,306.451050
|
||||
C391.116425,305.272400 390.512146,304.667053 389.907837,304.061707
|
||||
C389.907837,304.061707 389.989868,304.026306 389.948242,303.673950
|
||||
C389.603027,301.559052 389.299408,299.796478 388.995789,298.033905
|
||||
C389.148346,297.962677 389.300934,297.891479 390.026886,298.070557
|
||||
C396.732513,297.213440 402.864777,296.106079 408.997009,294.998718
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M240.177200,882.106689
|
||||
C247.976471,864.393921 255.693024,847.058838 263.254242,829.656189
|
||||
C264.457031,826.887878 265.832306,825.575500 269.098022,825.629333
|
||||
C281.713135,825.837219 294.333740,825.713806 307.120667,825.713806
|
||||
C307.120667,827.539917 307.120667,828.833069 307.120667,830.243225
|
||||
C306.727966,830.487488 306.362823,830.831482 305.932343,830.965210
|
||||
C294.769104,834.433167 291.366211,838.434998 292.199158,850.141663
|
||||
C293.569672,869.403381 295.656006,888.615662 297.540649,907.838867
|
||||
C299.197998,924.743713 301.040771,941.630432 302.708405,958.534302
|
||||
C303.706635,968.652954 307.608154,976.237854 319.045044,977.546448
|
||||
C319.045044,979.439514 319.045044,980.990906 319.045044,982.774841
|
||||
C298.706604,982.774841 278.626587,982.774841 258.330750,982.774841
|
||||
C258.330750,981.059692 258.330750,979.628052 258.330750,978.229797
|
||||
C270.144226,975.136658 273.645294,970.233887 272.241516,955.801636
|
||||
C269.274414,925.297546 266.627228,894.762329 263.833954,864.241211
|
||||
C263.764343,863.480652 263.521057,862.735840 263.030640,860.463013
|
||||
C258.247681,871.290710 254.004623,880.767029 249.866196,890.288818
|
||||
C236.926147,920.061707 223.996704,949.839294 211.163452,979.658203
|
||||
C210.107437,982.111877 208.970184,983.290039 206.097534,983.210938
|
||||
C203.278671,983.133362 201.743317,982.448425 200.488159,979.661255
|
||||
C183.332962,941.566406 166.022629,903.541443 148.741486,865.503357
|
||||
C148.267746,864.460632 147.693909,863.463379 146.513458,861.182739
|
||||
C145.920700,865.552002 145.346909,868.670593 145.094650,871.815002
|
||||
C142.896790,899.210327 140.740677,926.609070 138.624588,954.010864
|
||||
C138.420029,956.659668 138.356949,959.354980 138.594238,961.995789
|
||||
C139.366440,970.589966 143.097702,974.974426 151.544220,976.828613
|
||||
C154.946045,977.575378 156.811462,978.687744 155.116333,982.731079
|
||||
C139.842697,982.731079 124.439468,982.731079 108.902344,982.731079
|
||||
C108.255356,980.038635 107.852699,977.728394 111.409042,976.915833
|
||||
C120.197647,974.907959 124.019203,968.519043 124.911133,960.319641
|
||||
C127.017845,940.952698 128.824738,921.551941 130.613647,902.152039
|
||||
C132.188660,885.071472 133.738647,867.985535 134.991364,850.879333
|
||||
C135.955963,837.707581 133.238220,834.237915 120.100716,830.603882
|
||||
C120.039406,829.262817 119.973740,827.826477 119.913864,826.516907
|
||||
C120.420792,826.192505 120.688065,825.872314 120.957123,825.870789
|
||||
C133.950317,825.798279 146.945099,825.655151 159.935028,825.842712
|
||||
C161.384949,825.863586 163.500443,827.463867 164.141251,828.864929
|
||||
C179.506958,862.461426 194.710678,896.132019 209.953308,929.784851
|
||||
C211.323883,932.810791 212.759430,935.807373 214.590317,939.728943
|
||||
C223.284592,920.167480 231.663834,901.314758 240.177200,882.106689
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M940.729797,964.960083
|
||||
C943.201233,973.545105 943.790100,974.055786 955.957520,978.035461
|
||||
C955.957520,979.512329 955.957520,981.068054 955.957520,982.831421
|
||||
C936.268188,982.831421 916.869507,982.831421 897.455200,982.831421
|
||||
C896.383850,979.606018 896.860291,977.593994 900.457458,976.891174
|
||||
C908.202637,975.377869 911.380554,969.939453 912.083313,962.643433
|
||||
C912.353821,959.834412 912.420959,956.996643 912.420654,954.171997
|
||||
C912.416748,919.896423 912.385498,885.620789 912.348938,851.345215
|
||||
C912.347168,849.683716 912.298889,848.016418 912.155518,846.361877
|
||||
C911.388550,837.512146 908.254639,833.730408 899.327271,830.650452
|
||||
C898.998291,828.714905 899.003296,827.332336 899.008301,825.949829
|
||||
C899.756165,825.885620 900.503906,825.821350 901.960510,826.090393
|
||||
C909.783081,826.271973 916.896912,826.120239 924.010681,825.968506
|
||||
C928.365662,825.896179 932.720642,825.823792 937.874146,826.088135
|
||||
C945.450867,826.275879 952.229187,826.126770 959.007446,825.977722
|
||||
C972.178833,825.056641 984.955994,826.595154 996.972046,832.387451
|
||||
C1011.481079,839.381470 1018.880737,851.153076 1018.604919,867.123840
|
||||
C1018.324402,883.373596 1010.548279,895.329285 995.723022,902.341370
|
||||
C992.126099,904.042664 988.278015,905.212891 984.127380,906.785706
|
||||
C984.664795,907.776245 985.158569,908.917725 985.849121,909.924377
|
||||
C996.874023,925.994629 1007.823547,942.118103 1019.008789,958.076355
|
||||
C1024.974609,966.588135 1031.492432,974.581238 1042.161377,977.765869
|
||||
C1043.281616,978.100159 1043.779175,980.521118 1044.823486,982.450378
|
||||
C1033.125488,984.504272 1022.533875,984.789978 1011.994629,982.646057
|
||||
C1003.011658,980.818787 996.323059,975.437500 991.488892,967.832458
|
||||
C982.464905,953.635925 973.048279,939.644592 964.879822,924.964233
|
||||
C959.488159,915.274353 953.093933,909.562439 940.800171,912.097412
|
||||
C939.688904,913.496460 939.047241,914.562805 939.042847,915.631714
|
||||
C938.980408,930.797241 938.962952,945.963440 939.081909,961.128296
|
||||
C939.091980,962.409790 940.155640,963.683044 940.729797,964.960083
|
||||
M941.633545,902.196960
|
||||
C945.962891,902.198486 950.294861,902.298706 954.621155,902.184753
|
||||
C974.393188,901.663879 985.523438,891.988342 987.349426,872.436340
|
||||
C989.189636,852.732117 980.520813,836.094604 955.656189,835.838257
|
||||
C950.663635,835.786743 945.669373,835.906006 940.091187,836.425354
|
||||
C939.726990,837.641235 939.049744,838.855957 939.045349,840.073242
|
||||
C938.975586,859.280090 938.956177,878.487427 939.061584,897.693970
|
||||
C939.069580,899.150696 940.104980,900.601746 941.633545,902.196960
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M752.865784,925.739380
|
||||
C766.602478,892.455444 780.228333,859.539673 793.738159,826.576355
|
||||
C794.831299,823.909180 796.060181,822.540283 799.142822,822.839478
|
||||
C802.057678,823.122253 804.434143,822.596741 806.124512,826.477661
|
||||
C825.586548,871.160767 845.381104,915.699036 865.012085,960.308838
|
||||
C868.798645,968.913696 873.755371,976.109192 883.800537,977.883240
|
||||
C883.800537,979.680908 883.800537,981.120361 883.800537,982.820190
|
||||
C863.494934,982.820190 843.397034,982.820190 823.218506,982.820190
|
||||
C821.768250,979.374268 823.035461,977.848083 826.447388,976.767334
|
||||
C833.281921,974.602417 835.823853,969.887573 833.337463,963.257690
|
||||
C829.602356,953.298340 825.371765,943.519958 821.113647,933.768677
|
||||
C820.645081,932.695801 818.620361,931.696411 817.311218,931.688599
|
||||
C800.654419,931.588562 783.994934,931.586853 767.341187,931.862732
|
||||
C765.774597,931.888611 763.489502,933.632202 762.822205,935.144836
|
||||
C759.733521,942.145935 756.988159,949.306335 754.325134,956.485779
|
||||
C749.241211,970.191650 750.868347,973.479126 764.929199,978.093872
|
||||
C764.929199,979.552429 764.929199,981.104614 764.929199,982.872314
|
||||
C749.510132,982.872314 734.391174,982.872314 719.115662,982.872314
|
||||
C717.950073,980.210571 717.893982,978.026367 721.265503,977.139893
|
||||
C730.248596,974.777710 734.917969,967.950684 738.361511,960.161987
|
||||
C743.343811,948.892395 747.955322,937.458862 752.865784,925.739380
|
||||
M809.037659,903.434570
|
||||
C803.494446,890.223938 797.951233,877.013367 791.896851,862.584412
|
||||
C783.823975,882.648804 776.338501,901.253174 768.777405,920.045471
|
||||
C784.698364,920.045471 799.996643,920.045471 815.807434,920.045471
|
||||
C813.469604,914.304993 811.400269,909.223877 809.037659,903.434570
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1119.793213,971.032227
|
||||
C1136.393433,959.731079 1136.957275,938.088684 1120.781006,926.708435
|
||||
C1113.746704,921.759766 1105.513550,918.552856 1098.141357,914.037781
|
||||
C1088.671631,908.237915 1078.354980,903.136353 1070.459595,895.583984
|
||||
C1050.181274,876.186279 1056.068848,842.654419 1081.014038,829.683533
|
||||
C1098.529053,820.576172 1116.642456,821.218689 1134.985840,826.201965
|
||||
C1140.257446,827.634033 1147.081421,828.473633 1149.949097,832.129822
|
||||
C1152.591187,835.498535 1150.965942,842.283264 1151.056763,847.566040
|
||||
C1151.159790,853.559021 1151.080688,859.555176 1151.080688,865.670410
|
||||
C1147.184082,867.167847 1144.991333,866.618286 1143.825439,862.141907
|
||||
C1141.064331,851.541321 1136.273926,842.176086 1125.834717,836.952271
|
||||
C1118.009277,833.036560 1109.724121,832.538635 1101.475952,834.644775
|
||||
C1083.995972,839.108215 1078.015869,860.333740 1091.000122,872.927185
|
||||
C1096.142578,877.914795 1102.851807,881.432983 1109.196533,884.999878
|
||||
C1118.190552,890.056335 1127.793091,894.061951 1136.631348,899.357971
|
||||
C1161.346313,914.167542 1167.572388,944.801025 1150.322632,966.548035
|
||||
C1139.900513,979.687256 1125.354492,984.889404 1109.105469,986.033508
|
||||
C1091.884399,987.246033 1075.699585,983.127014 1060.152222,976.093750
|
||||
C1058.873413,975.515259 1057.156616,973.968018 1057.135986,972.843506
|
||||
C1056.916870,960.892395 1057.000000,948.935730 1057.000000,936.487732
|
||||
C1058.504517,936.256592 1060.411133,935.963684 1062.705566,935.611206
|
||||
C1063.903809,939.640137 1064.919189,943.461060 1066.172729,947.202209
|
||||
C1073.666748,969.569031 1098.797729,980.869446 1119.793213,971.032227
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M410.641632,972.655029
|
||||
C418.989166,973.043457 427.050293,974.310547 434.860199,973.406677
|
||||
C453.006500,971.306580 465.199829,960.822510 472.098083,944.032898
|
||||
C473.041382,941.737061 474.016022,939.454102 475.160278,936.726257
|
||||
C477.134216,937.198364 478.976471,937.638977 481.020905,938.127930
|
||||
C479.361694,949.531860 477.809326,960.549927 476.079132,971.539978
|
||||
C475.911041,972.607849 474.850677,973.973572 473.857178,974.409729
|
||||
C438.628601,989.872742 403.228607,992.333984 369.422424,971.696716
|
||||
C340.614594,954.110657 329.663330,926.505554 333.961670,893.668030
|
||||
C338.275635,860.711121 357.568085,838.761108 388.855621,828.097534
|
||||
C417.425232,818.360352 445.710236,822.064392 473.444275,832.394775
|
||||
C474.593964,832.822998 476.140411,834.200623 476.205383,835.218750
|
||||
C476.956299,846.980957 477.456665,858.759216 478.019867,870.456177
|
||||
C473.350433,872.402283 471.682861,870.524658 470.377106,866.394104
|
||||
C463.754089,845.443665 447.896790,833.964844 425.904724,833.794617
|
||||
C396.613190,833.567810 377.518402,848.104187 369.720398,876.417664
|
||||
C364.440918,895.586609 364.793274,914.674377 370.803955,933.542297
|
||||
C377.104034,953.318665 390.010254,966.709839 410.641632,972.655029
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M597.319336,971.679199
|
||||
C570.462036,953.307617 558.868347,927.777710 562.330505,896.230896
|
||||
C565.942810,863.315796 584.257874,840.707092 615.220764,828.981201
|
||||
C644.379456,817.938538 673.181946,821.998047 701.547424,832.621887
|
||||
C702.619812,833.023499 703.923462,834.563110 703.985474,835.637817
|
||||
C704.655090,847.244019 705.096985,858.863403 705.618835,870.885193
|
||||
C703.560730,870.992432 701.806091,871.083801 699.811340,871.187683
|
||||
C699.002075,868.849304 698.230774,866.689453 697.505615,864.514221
|
||||
C692.208862,848.626648 681.373169,838.455200 665.056030,835.189758
|
||||
C637.191223,829.613220 613.684814,838.647949 602.233765,866.489502
|
||||
C590.954163,893.914246 591.294006,921.693787 605.977844,947.884399
|
||||
C624.789490,981.437256 668.544312,979.679260 686.465759,962.748718
|
||||
C692.900085,956.670044 697.776672,949.588501 700.562378,941.245056
|
||||
C702.045776,936.802002 704.149902,935.638611 708.551392,938.389343
|
||||
C706.911133,949.351562 705.294312,960.522217 703.482300,971.661133
|
||||
C703.313782,972.696960 702.088135,973.939514 701.049133,974.400024
|
||||
C666.190979,989.852722 631.630188,991.818359 597.319336,971.679199
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M773.022705,743.834595
|
||||
C744.455444,744.122742 716.004272,744.122742 687.169312,744.122742
|
||||
C687.169312,714.642639 687.169312,685.306335 687.169312,654.868103
|
||||
C688.834045,657.437805 689.984192,659.159241 691.080872,660.914062
|
||||
C711.533325,693.640808 737.921082,720.529480 770.318359,741.513611
|
||||
C771.289856,742.142761 772.200073,742.866699 773.022705,743.834595
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1066.509033,333.403931
|
||||
C1058.593994,329.372223 1050.960815,325.563965 1044.897705,322.538940
|
||||
C1055.669800,319.998108 1068.203125,317.041840 1081.299438,313.952759
|
||||
C1087.062866,320.920654 1090.884521,350.208221 1083.160522,366.093536
|
||||
C1080.680542,353.318207 1077.286499,341.597107 1066.509033,333.403931
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M435.356293,313.110229
|
||||
C436.890778,312.366089 438.772491,311.138275 440.665222,311.121002
|
||||
C457.438538,310.968201 474.214844,311.142975 490.988037,310.983154
|
||||
C500.126709,310.896118 509.263031,310.020355 518.396851,310.076691
|
||||
C538.199829,310.198822 558.000244,310.926514 577.802368,310.958618
|
||||
C593.714172,310.984406 609.627075,310.350128 625.539490,310.010986
|
||||
C631.144897,309.891510 636.750488,309.778473 642.701294,309.893982
|
||||
C625.637146,311.053101 608.231873,312.664124 590.817383,312.771454
|
||||
C549.750366,313.024567 508.679047,312.498688 467.609253,312.430511
|
||||
C456.976135,312.412842 446.342194,312.921204 435.356293,313.110229
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M699.645386,295.962311
|
||||
C698.412659,296.691559 696.856445,297.843262 695.269043,297.887939
|
||||
C678.804749,298.351013 662.335876,298.663361 645.866943,298.941223
|
||||
C643.326904,298.984131 640.780396,298.641632 638.101990,298.168732
|
||||
C650.933472,297.473816 663.900513,297.106110 676.866028,296.694214
|
||||
C684.347717,296.456543 691.827026,296.147675 699.645386,295.962311
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M492.620850,293.030731
|
||||
C489.942383,293.697327 486.863708,294.893280 483.794708,294.868927
|
||||
C469.662933,294.756714 455.533234,294.328400 441.404602,293.945770
|
||||
C439.444977,293.892731 437.495605,293.462982 435.205078,292.942535
|
||||
C449.828644,292.580688 464.788544,292.434906 479.748596,292.418854
|
||||
C483.907227,292.414398 488.066254,292.794434 492.620850,293.030731
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M940.738831,964.493774
|
||||
C940.155640,963.683044 939.091980,962.409790 939.081909,961.128296
|
||||
C938.962952,945.963440 938.980408,930.797241 939.042847,915.631714
|
||||
C939.047241,914.562805 939.688904,913.496460 940.318848,912.184021
|
||||
C940.651550,929.301880 940.699707,946.664612 940.738831,964.493774
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M620.679626,297.659790
|
||||
C619.675903,298.176605 618.282959,298.895905 616.882568,298.910858
|
||||
C607.458923,299.011414 598.033081,299.016205 588.609802,298.902527
|
||||
C587.235168,298.885956 585.870178,298.072968 584.258423,297.328979
|
||||
C596.106445,297.198456 608.196716,297.367432 620.679626,297.659790
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M546.573242,295.607788
|
||||
C541.479980,296.060577 535.940491,296.486267 530.395142,296.585480
|
||||
C529.779785,296.596466 529.131165,294.745911 528.497925,293.758728
|
||||
C530.492737,292.743103 532.487488,291.727478 534.749878,290.922791
|
||||
C533.819641,291.936401 532.621826,292.739075 531.423950,293.541748
|
||||
C531.428833,293.914825 531.433716,294.287903 531.438599,294.660950
|
||||
C536.334167,294.935608 541.229675,295.210266 546.573242,295.607788
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M398.042603,315.396851
|
||||
C399.369080,315.112976 401.572815,315.812592 402.115387,315.150635
|
||||
C405.890686,310.544586 409.690247,313.744415 413.786469,314.966248
|
||||
C410.096466,316.002075 406.139893,317.010681 402.120117,317.498596
|
||||
C400.886017,317.648407 399.478943,316.373016 398.042603,315.396851
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M408.664062,294.935425
|
||||
C402.864777,296.106079 396.732513,297.213440 390.278076,298.096863
|
||||
C396.080933,296.872681 402.205994,295.872437 408.664062,294.935425
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M570.710999,296.587433
|
||||
C565.182129,296.737885 559.278076,296.731598 553.195923,296.386230
|
||||
C558.790405,296.175018 564.563171,296.302856 570.710999,296.587433
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M708.393738,299.007385
|
||||
C708.019958,298.159607 708.033142,297.326355 708.046631,296.076233
|
||||
C712.271484,295.407745 716.495972,295.156158 720.720520,294.904541
|
||||
C720.792908,295.202606 720.865356,295.500671 720.937744,295.798706
|
||||
C716.885376,296.873108 712.833008,297.947479 708.393738,299.007385
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M682.422974,304.972260
|
||||
C686.173401,303.850952 690.345764,302.763977 694.768005,301.866577
|
||||
C690.960205,303.039612 686.902588,304.023102 682.422974,304.972260
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M670.431458,306.985229
|
||||
C673.488464,306.176025 676.973450,305.413300 680.736755,304.850525
|
||||
C677.629883,305.710907 674.244629,306.371277 670.431458,306.985229
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M923.547119,825.889526
|
||||
C916.896912,826.120239 909.783081,826.271973 902.325073,826.190125
|
||||
C909.015076,825.907898 916.049316,825.859253 923.547119,825.889526
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M958.544434,825.897095
|
||||
C952.229187,826.126770 945.450867,826.275879 938.327393,826.193237
|
||||
C944.681885,825.913208 951.381653,825.864868 958.544434,825.897095
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M424.437378,313.995178
|
||||
C427.120209,313.506775 430.240143,313.071228 433.694092,312.856262
|
||||
C430.976898,313.400574 427.925720,313.724304 424.437378,313.995178
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M645.332581,310.046631
|
||||
C647.808960,309.542023 650.606689,309.107513 653.722839,308.893127
|
||||
C651.245483,309.447723 648.449707,309.782227 645.332581,310.046631
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M417.685455,293.921753
|
||||
C415.841095,294.429413 413.686371,294.848938 411.228638,295.038696
|
||||
C413.075470,294.483856 415.225281,294.158722 417.685455,293.921753
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M428.576233,292.919678
|
||||
C426.503021,293.408051 424.014618,293.822540 421.214050,293.995117
|
||||
C423.321564,293.450775 425.741302,293.148315 428.576233,292.919678
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M662.322876,308.112000
|
||||
C664.139221,307.578674 666.269226,307.112488 668.709595,306.856384
|
||||
C666.892090,307.437378 664.764282,307.808258 662.322876,308.112000
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M700.379517,300.972443
|
||||
C702.175415,300.237122 704.351318,299.522675 706.796570,298.993774
|
||||
C704.963745,299.783997 702.861633,300.388641 700.379517,300.972443
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M388.653870,297.998535
|
||||
C389.299408,299.796478 389.603027,301.559052 389.917664,303.706543
|
||||
C388.847778,302.691284 387.673859,301.346039 386.781708,299.834503
|
||||
C386.640411,299.595123 387.769958,298.605743 388.653870,297.998535
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M898.596802,825.897400
|
||||
C899.003296,827.332336 898.998291,828.714905 898.990051,830.511719
|
||||
C895.281250,829.774719 896.539246,827.840454 898.596802,825.897400
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M416.375336,315.003784
|
||||
C417.515198,314.554657 419.025238,314.156708 420.806274,313.980682
|
||||
C419.633331,314.486694 418.189423,314.770813 416.375336,315.003784
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M656.370605,308.998993
|
||||
C657.508362,308.547516 659.017273,308.149658 660.798828,307.970093
|
||||
C659.628174,308.476440 658.184998,308.764557 656.370605,308.998993
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M696.318726,301.935730
|
||||
C696.868835,301.518005 697.741272,301.144348 698.835388,300.959106
|
||||
C698.251709,301.424927 697.446350,301.702332 696.318726,301.935730
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M392.945984,308.353699
|
||||
C393.561127,308.809326 394.253235,309.564453 394.975433,310.654694
|
||||
C394.344666,310.210907 393.683838,309.432037 392.945984,308.353699
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M395.073303,311.283508
|
||||
C395.551056,311.563965 396.093201,312.132385 396.648926,313.015686
|
||||
C396.154205,312.744232 395.645935,312.157867 395.073303,311.283508
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M390.000275,304.368988
|
||||
C390.512146,304.667053 391.116425,305.272400 391.762512,306.218262
|
||||
C391.233795,305.931274 390.663269,305.303772 390.000275,304.368988
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M396.689453,313.520447
|
||||
C397.001312,313.658112 397.404968,314.074646 397.880066,314.780457
|
||||
C397.561462,314.646301 397.171356,314.222809 396.689453,313.520447
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M718.106323,345.274139
|
||||
C718.084839,345.315521 717.972961,345.058960 717.972961,345.058960
|
||||
C717.972961,345.058960 718.093445,345.327209 718.157166,345.275909
|
||||
C718.220886,345.224609 718.127869,345.232758 718.106323,345.274139
|
||||
z"/>
|
||||
<path fill="#181b23" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M940.675903,835.946533
|
||||
C945.669373,835.906006 950.663635,835.786743 955.656189,835.838257
|
||||
C980.520813,836.094604 989.189636,852.732117 987.349426,872.436340
|
||||
C985.523438,891.988342 974.393188,901.663879 954.621155,902.184753
|
||||
C950.294861,902.298706 945.962891,902.198486 941.131226,901.654419
|
||||
C940.644592,879.390076 940.660278,857.668274 940.675903,835.946533
|
||||
z"/>
|
||||
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M940.383545,836.185913
|
||||
C940.660278,857.668274 940.644592,879.390076 940.645874,901.583618
|
||||
C940.104980,900.601746 939.069580,899.150696 939.061584,897.693970
|
||||
C938.956177,878.487427 938.975586,859.280090 939.045349,840.073242
|
||||
C939.049744,838.855957 939.726990,837.641235 940.383545,836.185913
|
||||
z"/>
|
||||
<path fill="#181b23" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M809.184326,903.788635
|
||||
C811.400269,909.223877 813.469604,914.304993 815.807434,920.045471
|
||||
C799.996643,920.045471 784.698364,920.045471 768.777405,920.045471
|
||||
C776.338501,901.253174 783.823975,882.648804 791.896851,862.584412
|
||||
C797.951233,877.013367 803.494446,890.223938 809.184326,903.788635
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
+116
-9
@@ -4,19 +4,126 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Impressum · MC Cars (GmbH)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||
<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" />
|
||||
|
||||
<meta name="description" content="Impressum - Kontaktdaten und Informationen von MC Cars GmbH" />
|
||||
<meta name="robots" content="index, follow, nosnippet" />
|
||||
<link rel="canonical" href="https://demo.lago.dev/impressum.html" />
|
||||
<link rel="alternate" hreflang="de" href="https://demo.lago.dev/impressum.html" />
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Impressum – MC Cars" />
|
||||
<meta property="og:description" content="Impressum von MC Cars GmbH" />
|
||||
<meta property="og:url" content="https://demo.lago.dev/impressum.html" />
|
||||
<meta property="og:site_name" content="MC Cars" />
|
||||
<meta property="og:locale" content="de_AT" />
|
||||
|
||||
<!-- JSON-LD Breadcrumb -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Startseite",
|
||||
"item": "https://demo.lago.dev/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Impressum",
|
||||
"item": "https://demo.lago.dev/impressum.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</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>
|
||||
<header class="site-header">
|
||||
<div class="shell">
|
||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</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 style="padding: 3rem 0;">
|
||||
<div class="shell">
|
||||
<h1>Impressum</h1>
|
||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||
<p><strong>MC Cars GmbH</strong></p>
|
||||
<p>Gaisfeld 1/2<br/>8564 Krottendorf-Gaisfeld</p>
|
||||
<p>FN 675751 b · Landesgericht für Zivilrechtssachen Graz</p>
|
||||
<p>Geschäftsführer: Christian Leski, Marco Schober</p>
|
||||
<p>E-Mail: hello@mc-cars.at</p>
|
||||
<p>UID-Nr. wird in Kürze nachgereicht.</p>
|
||||
</div>
|
||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text); margin-top: 2.5rem;">
|
||||
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: var(--text);">Datenschutzerklärung (Kurzfassung)</h2>
|
||||
<p>Der Schutz Ihrer persönlichen Daten ist uns wichtig. Wir behandeln Ihre Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften, insbesondere der DSGVO und dem österreichischen Datenschutzgesetz.</p>
|
||||
<p><strong>Welche Daten wir erfassen:</strong> Wir erheben nur die Daten, die für die Nutzung unserer Website und unserer Dienste unbedingt erforderlich sind. Dazu können Zugriffsdaten (Datum, Uhrzeit, besuchte Seiten), technische Daten (Browsertyp, Betriebssystem) und – falls relevant – von Ihnen aktiv eingegebene Daten (z.B. bei Kontakt- und Buchungsformularen) gehören.</p>
|
||||
<p><strong>Wie wir Ihre Daten verwenden:</strong> Ihre Daten verwenden wir ausschließlich, um Ihnen unsere Website und die damit verbundenen Funktionen bereitzustellen, Buchungsanfragen zu bearbeiten und die Sicherheit unserer Systeme zu gewährleisten.</p>
|
||||
<p><strong>Weitergabe an Dritte:</strong> Eine Weitergabe Ihrer persönlichen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn, dies ist gesetzlich vorgeschrieben oder für die Erbringung unserer Dienste unerlässlich.</p>
|
||||
<p><strong>Ihre Rechte:</strong> Sie haben jederzeit das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Widerspruch gegen die Verarbeitung Ihrer personenbezogenen Daten sowie das Recht auf Datenübertragbarkeit.</p>
|
||||
<p>Weitere Informationen finden Sie in unserer <a href="/datenschutz" style="color: var(--accent-strong);">vollständigen Datenschutzerklärung</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer" id="kontakt">
|
||||
<div class="shell">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<div class="logo" style="margin-bottom:0.8rem;">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
||||
<a href="/agb" data-i18n="terms">AGB</a>
|
||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</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>
|
||||
|
||||
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+118
-57
@@ -3,19 +3,109 @@
|
||||
<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. Faire Kaution, transparent, sofort startklar." />
|
||||
<title>MC Cars · Ferrari-Vermietung Steiermark</title>
|
||||
<meta name="description" content="MC Cars · Premium Ferrari-Vermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="preload" as="image" href="/images/ferrari-main-car-mobile.jpg" fetchpriority="high" />
|
||||
<link rel="preconnect" href="https://esm.sh" />
|
||||
<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>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||
<!-- Fonts loaded async: display=optional means they never block render -->
|
||||
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=optional" as="style" onload="this.onload=null;this.rel='stylesheet'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=optional" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="styles.css?v=2" />
|
||||
<!-- SEO & Social Meta Tags -->
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||
<meta name="keywords" content="Sportwagenvermietung Steiermark, Luxusauto mieten, Sportwagenverleih, Ferrari mieten Graz" />
|
||||
<meta name="theme-color" content="#1a1a1a" />
|
||||
<meta name="language" content="German" />
|
||||
<link rel="canonical" href="https://demo.lago.dev/" />
|
||||
<link rel="alternate" hreflang="en" href="https://demo.lago.dev/en/" />
|
||||
<link rel="alternate" hreflang="de" href="https://demo.lago.dev/" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://demo.lago.dev/" />
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="MC Cars – Premium Ferrari-Vermietung Steiermark" />
|
||||
<meta property="og:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||
<meta property="og:url" content="https://demo.lago.dev/" />
|
||||
<meta property="og:site_name" content="MC Cars" />
|
||||
<meta property="og:locale" content="de_AT" />
|
||||
<meta property="og:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
|
||||
<!-- Twitter Card Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="MC Cars – Premium Ferrari-Vermietung Steiermark" />
|
||||
<meta name="twitter:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||
<meta name="twitter:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
|
||||
|
||||
<!-- Structured Data (JSON-LD) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"@id": "https://demo.lago.dev/#organization",
|
||||
"name": "MC Cars GmbH",
|
||||
"alternateName": "MC Cars",
|
||||
"description": "Premium Ferrari-Vermietung in der Steiermark",
|
||||
"url": "https://demo.lago.dev",
|
||||
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
|
||||
"image": "https://demo.lago.dev/images/mc-cars-og-image.png",
|
||||
"areaServed": {
|
||||
"@type": "Place",
|
||||
"name": "Steiermark, Österreich",
|
||||
"geo": {
|
||||
"@type": "GeoShape",
|
||||
"box": "47.2 13.0 48.5 16.0"
|
||||
}
|
||||
},
|
||||
"priceRange": "€€€",
|
||||
"serviceType": "Ferrari-Vermietung",
|
||||
"sameAs": [
|
||||
"https://www.facebook.com/mc-cars",
|
||||
"https://www.instagram.com/mc-cars"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "MC Cars GmbH",
|
||||
"url": "https://demo.lago.dev",
|
||||
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
|
||||
"description": "Premium Ferrari-Vermietung in Steiermark, Österreich",
|
||||
"foundingDate": "2024",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "Customer Support",
|
||||
"availableLanguage": ["de", "en"]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Startseite",
|
||||
"item": "https://demo.lago.dev/"
|
||||
}
|
||||
]
|
||||
}
|
||||
</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>
|
||||
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" />
|
||||
<span>MC Cars</span>
|
||||
</a>
|
||||
|
||||
@@ -23,7 +113,6 @@
|
||||
|
||||
<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>
|
||||
@@ -38,16 +127,15 @@
|
||||
<div class="shell">
|
||||
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
|
||||
<h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
|
||||
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
|
||||
<p class="lead" data-i18n="heroLead">Der Ferrari in der Steiermark. Faire Kaution, 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 data-i18n="statDeposit">Faire Kaution</strong><span>Fair Deposit</span></div>
|
||||
<div><strong id="statCarsCount">–</strong><span data-i18n="statCars">Fahrzeuge</span></div>
|
||||
<div><strong id="statCarsCount">–</strong><span id="statCarsLabel" data-i18n="statCar">Fahrzeug</span></div>
|
||||
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +146,6 @@
|
||||
<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 für Details oder buchen Sie direkt.</p>
|
||||
</div>
|
||||
@@ -89,36 +176,8 @@
|
||||
</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 Fahrspaß.</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">Faire Kaution</h3>
|
||||
<p data-i18n="whyDepositText">Kein Überziehen. Transparente, faire Kaution ohne unnötige Belastung.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<!-- Reviews -->
|
||||
<section id="stimmen">
|
||||
<div class="shell">
|
||||
@@ -159,8 +218,8 @@
|
||||
<h3 class="bpf-panel-title">🚗 <span data-i18n="stepVehicleTime">Fahrzeug & Zeitraum</span></h3>
|
||||
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfVehicle">Fahrzeug</label>
|
||||
<select id="bpfCar">
|
||||
<label for="bpfCar" id="bpfCarLabel" data-i18n="bpfVehicle">Fahrzeug</label>
|
||||
<select id="bpfCar" aria-labelledby="bpfCarLabel">
|
||||
<option value="" data-i18n="bpfSelectVehicle">Fahrzeug wählen</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -180,7 +239,7 @@
|
||||
</span>
|
||||
<span data-i18n="bpfPresetWeekend">Wochenende</span>
|
||||
</button>
|
||||
<button type="button" class="bpf-preset active" data-preset="custom">
|
||||
<button type="button" class="bpf-preset" data-preset="custom">
|
||||
<span class="bpf-preset-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><path d="M8 14h8"></path><path d="M8 18h8"></path></svg>
|
||||
</span>
|
||||
@@ -190,20 +249,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Day mode: single date picker -->
|
||||
<div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
|
||||
<div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
|
||||
<label data-i18n="bpfPickDate">Datum wählen</label>
|
||||
<input type="date" id="bpfDayDate" />
|
||||
</div>
|
||||
|
||||
<!-- Weekend mode: pick the Saturday -->
|
||||
<div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display:none;">
|
||||
<div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display: none;">
|
||||
<label data-i18n="bpfPickWeekend">Wochenende wählen (Samstag)</label>
|
||||
<input type="date" id="bpfWeekendDate" />
|
||||
<p class="bpf-weekend-def" data-i18n="bpfWeekendDef">Wochenende: Samstag 9:00 – Sonntag 20:00</p>
|
||||
</div>
|
||||
|
||||
<!-- Custom mode: from/to date pickers -->
|
||||
<div class="bpf-date-custom" id="bpfDateCustom">
|
||||
<div class="bpf-date-custom" id="bpfDateCustom" style="display:none;">
|
||||
<div class="bpf-field-row">
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfStartDate">Startdatum</label>
|
||||
@@ -307,30 +366,29 @@
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<div class="logo" style="margin-bottom:0.8rem;">
|
||||
<span class="logo-mark">MC</span>
|
||||
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" style="width:2rem;height:2rem;" />
|
||||
<span>MC Cars</span>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
|
||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</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>
|
||||
<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>
|
||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
||||
<a href="/agb" data-i18n="terms">AGB</a>
|
||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</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>
|
||||
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,6 +408,9 @@
|
||||
<div class="dialog-body" id="dialogBody"></div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script type="module" src="app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mietbedingungen · MC Cars</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||
<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>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||
|
||||
<meta name="description" content="Mietbedingungen von MC Cars - Sportwagenvermietung in Steiermark" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://demo.lago.dev/mietbedingungen.html" />
|
||||
<link rel="alternate" hreflang="de" href="https://demo.lago.dev/mietbedingungen.html" />
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Mietbedingungen – MC Cars" />
|
||||
<meta property="og:description" content="Mietbedingungen von MC Cars Sportwagenvermietung" />
|
||||
<meta property="og:url" content="https://demo.lago.dev/mietbedingungen.html" />
|
||||
<meta property="og:site_name" content="MC Cars" />
|
||||
<meta property="og:locale" content="de_AT" />
|
||||
|
||||
<!-- JSON-LD Breadcrumb -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "Startseite",
|
||||
"item": "https://demo.lago.dev/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Mietbedingungen",
|
||||
"item": "https://demo.lago.dev/mietbedingungen.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="shell">
|
||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</a>
|
||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</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 style="padding: 3rem 0;">
|
||||
<div class="shell">
|
||||
<h1>Mietbedingungen</h1>
|
||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||
<p style="color: var(--muted); font-style: italic;">
|
||||
Diese Seite wird in Kürze mit den vollständigen Mietbedingungen aktualisiert.
|
||||
</p>
|
||||
<p>
|
||||
Die Mietbedingungen regeln die Nutzung der Mietfahrzeuge, Zahlungsbedingungen, Haftung und Versicherung.
|
||||
</p>
|
||||
<p>
|
||||
Bitte wenden Sie sich an hello@mc-cars.at für weitere Informationen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer" id="kontakt">
|
||||
<div class="shell">
|
||||
<div class="footer-grid">
|
||||
<div>
|
||||
<div class="logo" style="margin-bottom:0.8rem;">
|
||||
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||
<span>MC Cars</span>
|
||||
</div>
|
||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
||||
<a href="/agb" data-i18n="terms">AGB</a>
|
||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</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>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index admin.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/ /admin.html;
|
||||
}
|
||||
|
||||
# Static assets: images/fonts can be cached, JS/CSS must revalidate.
|
||||
location ~* \.(?:jpg|jpeg|png|webp|svg|ico|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location ~* \.(?:css|js)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
+43
-2
@@ -10,7 +10,8 @@ server {
|
||||
location ~* \.html$ { add_header Cache-Control "no-store"; try_files $uri =404; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Try files with or without .html, then fallback to index
|
||||
try_files $uri $uri.html $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static assets: images/fonts can be cached, JS/CSS must revalidate.
|
||||
@@ -19,8 +20,48 @@ server {
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
# CSS/JS: no cache to prevent stale content during development
|
||||
location ~* \.(?:css|js)$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cache-Control "no-store";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SEO files - cache for 1 week
|
||||
location = /robots.txt {
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location = /sitemap.xml {
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
add_header Content-Type "application/xml";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Enable gzip compression for text-based content
|
||||
gzip on;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/xml application/rss+xml application/javascript application/json;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 5;
|
||||
gzip_vary on;
|
||||
gzip_disable "msie6";
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Content Security Policy - permissive for dynamic content
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://esm.sh https://fonts.googleapis.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self';" always;
|
||||
|
||||
# Permissions policy (formerly Feature Policy)
|
||||
add_header Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" always;
|
||||
|
||||
# Performance: Enable HTTP Keep-Alive
|
||||
keepalive_timeout 65;
|
||||
keepalive_requests 100;
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /index.html;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Disallow: /admin.html
|
||||
Disallow: /admin/
|
||||
Disallow: /config.js
|
||||
|
||||
Sitemap: https://www.mc-cars.at/sitemap.xml
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml"
|
||||
xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
|
||||
xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
|
||||
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
|
||||
<!-- Main homepage - highest priority -->
|
||||
<url>
|
||||
<loc>https://demo.lago.dev/</loc>
|
||||
<lastmod>2026-05-09</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://demo.lago.dev/"/>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://demo.lago.dev/en/"/>
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://demo.lago.dev/"/>
|
||||
</url>
|
||||
|
||||
<!-- Legal pages -->
|
||||
<url>
|
||||
<loc>https://demo.lago.dev/agb.html</loc>
|
||||
<lastmod>2026-05-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://demo.lago.dev/datenschutz.html</loc>
|
||||
<lastmod>2026-05-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://demo.lago.dev/impressum.html</loc>
|
||||
<lastmod>2026-05-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://demo.lago.dev/mietbedingungen.html</loc>
|
||||
<lastmod>2026-05-09</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
|
||||
</urlset>
|
||||
+400
-12
@@ -97,6 +97,17 @@ section { padding: 5rem 0; }
|
||||
}
|
||||
.logo:hover { opacity: 0.85; }
|
||||
|
||||
.logo-icon {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.logo:hover .logo-icon {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -246,7 +257,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-mobile.jpg')) center / cover no-repeat;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@@ -378,10 +389,17 @@ select:focus, input:focus, textarea:focus {
|
||||
.vehicle-photo {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #0e1015 center / cover no-repeat;
|
||||
background: #0e1015;
|
||||
overflow: hidden;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.vehicle-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.vehicle-card:hover .vehicle-photo {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
@@ -402,6 +420,146 @@ select:focus, input:focus, textarea:focus {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* Photo carousel nav */
|
||||
.vehicle-photo-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vehicle-photo:hover .vehicle-photo-nav {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.vehicle-photo-prev,
|
||||
.vehicle-photo-next {
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.vehicle-photo-prev:hover,
|
||||
.vehicle-photo-next:hover {
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
.vehicle-photo-dots {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.vehicle-photo-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.4);
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.vehicle-photo-dots span.active {
|
||||
background: #fff;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
/* Dialog gallery */
|
||||
.dialog-gallery {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10;
|
||||
background: #0e1015;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.dialog-gallery-main {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.dialog-gallery-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dialog-gallery-prev,
|
||||
.dialog-gallery-next {
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.dialog-gallery-prev:hover,
|
||||
.dialog-gallery-next:hover {
|
||||
background: rgba(0,0,0,0.8);
|
||||
}
|
||||
.dialog-gallery-thumbs {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.dialog-gallery-thumbs button {
|
||||
width: 56px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease, border-color 0.2s ease;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.dialog-gallery-thumbs button.active {
|
||||
border-color: #fff;
|
||||
opacity: 1;
|
||||
}
|
||||
.dialog-gallery-thumbs button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.dialog-gallery-thumbs button img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vehicle-body {
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
@@ -541,12 +699,32 @@ select:focus, input:focus, textarea:focus {
|
||||
}
|
||||
|
||||
.review-dots button {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
border: none; background: var(--line); cursor: pointer;
|
||||
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s ease, width 0.3s ease;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.review-dots button::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--line);
|
||||
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s ease, width 0.3s ease, border-radius 0.3s ease;
|
||||
}
|
||||
|
||||
.review-dots button:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.review-dots button:hover::before {
|
||||
background: rgba(196, 138, 66, 0.5);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
@@ -554,12 +732,15 @@ select:focus, input:focus, textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
.review-dots button.active {
|
||||
background: var(--accent);
|
||||
width: 32px;
|
||||
border-radius: 6px;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.review-dots button.active::before {
|
||||
background: var(--accent);
|
||||
width: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ---------------- Booking ---------------- */
|
||||
.booking-form {
|
||||
display: grid;
|
||||
@@ -760,6 +941,10 @@ select:focus, input:focus, textarea:focus {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.footer-grid > div > a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 1.8rem;
|
||||
@@ -772,6 +957,30 @@ select:focus, input:focus, textarea:focus {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ----------------Toast Notification --------------- */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(200px);
|
||||
background: var(--accent);
|
||||
color: var(--bg-base);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
z-index: 9999;
|
||||
transition: transform 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ---------------- Dialog ---------------- */
|
||||
dialog {
|
||||
width: min(700px, 92vw);
|
||||
@@ -835,7 +1044,7 @@ dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||
|
||||
/* ---------------- Admin ---------------- */
|
||||
.admin-page {
|
||||
max-width: 1100px;
|
||||
max-width: 1280px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
@@ -891,6 +1100,7 @@ table.admin-table th, table.admin-table td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.5rem; }
|
||||
@@ -900,6 +1110,12 @@ table.admin-table tbody tr:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Admin table actions column: prevent button wrap */
|
||||
table.admin-table td:last-child { white-space: nowrap; }
|
||||
|
||||
.link-lead { text-decoration: none; cursor: pointer; }
|
||||
.link-lead:hover code { color: var(--accent-strong); text-decoration: underline; }
|
||||
|
||||
.admin-form { display: grid; gap: 1rem; }
|
||||
.admin-form label { display: grid; gap: 0.3rem; font-size: 0.85rem; color: var(--muted); transition: color 0.2s; }
|
||||
.admin-form label:focus-within { color: var(--accent-strong); }
|
||||
@@ -919,6 +1135,172 @@ table.admin-table tbody tr:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* ---- Unified Photo Upload Zone ---- */
|
||||
.admin-photo-upload-zone {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
background: var(--bg-elev);
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
.admin-photo-upload-zone:hover {
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
.admin-photo-upload-zone.drag-active {
|
||||
border-color: var(--accent-strong);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
.admin-photo-upload-zone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-photo-upload-content {
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.admin-photo-upload-icon {
|
||||
font-size: 2rem;
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* ---- Photo Gallery ---- */
|
||||
.admin-photo-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-photo-card {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16/10;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
|
||||
cursor: grab;
|
||||
}
|
||||
.admin-photo-card:hover {
|
||||
border-color: var(--accent-strong);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.admin-photo-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.admin-photo-card-drag-over {
|
||||
border-color: #f59e0b !important;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.admin-photo-card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.admin-photo-card-arrows {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
z-index: 2;
|
||||
}
|
||||
.admin-photo-arrow {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.admin-photo-arrow:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.admin-photo-card-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
z-index: 2;
|
||||
}
|
||||
.admin-photo-set-primary {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(245, 158, 11, 0.85);
|
||||
color: #000;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.admin-photo-set-primary:hover {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.admin-photo-delete {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.admin-photo-delete:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
.admin-photo-badge {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
z-index: 2;
|
||||
}
|
||||
.admin-photo-drag-handle {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 1.1rem;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------------- Forms / Toggle Switch ---------------- */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
@@ -1026,6 +1408,9 @@ input:checked + .toggle-slider:before {
|
||||
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); }
|
||||
.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border: 1px solid rgba(90, 180, 120, 0.3); }
|
||||
.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); border: 1px solid transparent; }
|
||||
.pill-single_day { background: rgba(74, 144, 226, 0.16); color: #8abfff; border: 1px solid rgba(74, 144, 226, 0.35); }
|
||||
.pill-weekend { background: rgba(200, 150, 80, 0.15); color: #e4b676; border: 1px solid rgba(200, 150, 80, 0.3); }
|
||||
.pill-individuell { background: rgba(204, 116, 58, 0.16); color: #ffb487; border: 1px solid rgba(204, 116, 58, 0.38); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
@@ -1035,7 +1420,8 @@ input:checked + .toggle-slider:before {
|
||||
|
||||
/* Dialog */
|
||||
dialog#leadDialog,
|
||||
dialog#customerDialog {
|
||||
dialog#customerDialog,
|
||||
dialog#orderDialog {
|
||||
border: 1px solid var(--line); border-radius: var(--radius);
|
||||
background: var(--bg-card); color: var(--text);
|
||||
padding: 0; max-width: 640px; width: 94%;
|
||||
@@ -1043,11 +1429,13 @@ dialog#customerDialog {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
dialog#leadDialog[open],
|
||||
dialog#customerDialog[open] {
|
||||
dialog#customerDialog[open],
|
||||
dialog#orderDialog[open] {
|
||||
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
dialog#leadDialog::backdrop,
|
||||
dialog#customerDialog::backdrop {
|
||||
dialog#customerDialog::backdrop,
|
||||
dialog#orderDialog::backdrop {
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}"
|
||||
WORKFLOW_RENDERED="/tmp/01-qualification-payment-email.rendered.json"
|
||||
WORKFLOW03_TEMPLATE="/opt/mc-cars/workflows/03-manual-email-send.json"
|
||||
WORKFLOW03_RENDERED="/tmp/03-manual-email-send.rendered.json"
|
||||
CREDENTIALS_FILE="/tmp/mc-cars-credentials.json"
|
||||
|
||||
required_var() {
|
||||
var_name="$1"
|
||||
eval "var_value=\${$var_name:-}"
|
||||
if [ -z "$var_value" ]; then
|
||||
echo "[n8n-bootstrap] Missing required env var: $var_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
escape_sed() {
|
||||
printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'
|
||||
}
|
||||
|
||||
required_var N8N_POSTGRES_CREDENTIAL_ID
|
||||
required_var N8N_POSTGRES_CREDENTIAL_NAME
|
||||
required_var N8N_SMTP_CREDENTIAL_ID
|
||||
required_var N8N_SMTP_CREDENTIAL_NAME
|
||||
required_var N8N_SMTP_HOST
|
||||
required_var N8N_SMTP_USER
|
||||
required_var N8N_SMTP_PASS
|
||||
required_var N8N_PAYPAL_KAUTION_LINK
|
||||
required_var N8N_PAYPAL_MIETE_LINK
|
||||
required_var DB_POSTGRESDB_PASSWORD
|
||||
required_var N8N_PAYMENT_WORKFLOW_ID
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<EOF
|
||||
[
|
||||
{
|
||||
"id": "${N8N_POSTGRES_CREDENTIAL_ID}",
|
||||
"name": "${N8N_POSTGRES_CREDENTIAL_NAME}",
|
||||
"type": "postgres",
|
||||
"data": {
|
||||
"host": "db",
|
||||
"password": "${DB_POSTGRESDB_PASSWORD}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "${N8N_SMTP_CREDENTIAL_ID}",
|
||||
"name": "${N8N_SMTP_CREDENTIAL_NAME}",
|
||||
"type": "smtp",
|
||||
"data": {
|
||||
"host": "${N8N_SMTP_HOST}",
|
||||
"user": "${N8N_SMTP_USER}",
|
||||
"password": "${N8N_SMTP_PASS}"
|
||||
}
|
||||
}
|
||||
]
|
||||
EOF
|
||||
|
||||
if [ ! -f "$WORKFLOW_TEMPLATE" ]; then
|
||||
echo "[n8n-bootstrap] Workflow template not found: $WORKFLOW_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
POSTGRES_ID_ESCAPED="$(escape_sed "$N8N_POSTGRES_CREDENTIAL_ID")"
|
||||
SMTP_ID_ESCAPED="$(escape_sed "$N8N_SMTP_CREDENTIAL_ID")"
|
||||
KAUTION_LINK_ESCAPED="$(escape_sed "$N8N_PAYPAL_KAUTION_LINK")"
|
||||
MIETE_LINK_ESCAPED="$(escape_sed "$N8N_PAYPAL_MIETE_LINK")"
|
||||
|
||||
sed \
|
||||
-e "s/__POSTGRES_CREDENTIAL_ID__/${POSTGRES_ID_ESCAPED}/g" \
|
||||
-e "s/__SMTP_CREDENTIAL_ID__/${SMTP_ID_ESCAPED}/g" \
|
||||
-e "s|__PAYPAL_KAUTION_LINK__|${KAUTION_LINK_ESCAPED}|g" \
|
||||
-e "s|__PAYPAL_MIETE_LINK__|${MIETE_LINK_ESCAPED}|g" \
|
||||
"$WORKFLOW_TEMPLATE" > "$WORKFLOW_RENDERED"
|
||||
|
||||
echo "[n8n-bootstrap] Importing credentials"
|
||||
n8n import:credentials --input="$CREDENTIALS_FILE"
|
||||
|
||||
echo "[n8n-bootstrap] Importing workflow 01"
|
||||
n8n import:workflow --input="$WORKFLOW_RENDERED"
|
||||
|
||||
# Process and import workflow 03 - Manual Email Send
|
||||
if [ -f "$WORKFLOW03_TEMPLATE" ]; then
|
||||
sed \
|
||||
-e "s/__POSTGRES_CREDENTIAL_ID__/${POSTGRES_ID_ESCAPED}/g" \
|
||||
-e "s/__SMTP_CREDENTIAL_ID__/${SMTP_ID_ESCAPED}/g" \
|
||||
-e "s|__PAYPAL_KAUTION_LINK__|${KAUTION_LINK_ESCAPED}|g" \
|
||||
-e "s|__PAYPAL_MIETE_LINK__|${MIETE_LINK_ESCAPED}|g" \
|
||||
"$WORKFLOW03_TEMPLATE" > "$WORKFLOW03_RENDERED"
|
||||
|
||||
echo "[n8n-bootstrap] Importing workflow 03 (Manual Email Send)"
|
||||
n8n import:workflow --input="$WORKFLOW03_RENDERED"
|
||||
fi
|
||||
|
||||
# Publish all imported workflows so they appear in the UI
|
||||
echo "[n8n-bootstrap] Publishing all workflows"
|
||||
WF_IDS=$(n8n list:workflow 2>/dev/null | cut -d'|' -f1 || true)
|
||||
for wfid in $WF_IDS; do
|
||||
echo "[n8n-bootstrap] Publishing workflow $wfid"
|
||||
n8n publish:workflow --id="$wfid" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "[n8n-bootstrap] Bootstrap complete"
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"name": "Lead Qualified → Mietvertrag PDF",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"triggerOnNotify": true,
|
||||
"channel": "lead_qualified",
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "pg-trigger-mv",
|
||||
"name": "Postgres Trigger",
|
||||
"type": "n8n-nodes-base.postgresTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [250, 300],
|
||||
"credentials": {
|
||||
"postgres": {
|
||||
"id": "1",
|
||||
"name": "MC Cars Postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "SELECT value FROM public.site_settings WHERE key = 'mietvertrag_template_path'",
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "check-template",
|
||||
"name": "Check Template Exists",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.5,
|
||||
"position": [470, 300],
|
||||
"credentials": {
|
||||
"postgres": {
|
||||
"id": "1",
|
||||
"name": "MC Cars Postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "strict"
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cond1",
|
||||
"leftValue": "={{ $json.value }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "if-template-exists",
|
||||
"name": "Template Exists?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [670, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "executeQuery",
|
||||
"query": "SELECT c.name, c.email, c.phone,\n so.order_number, so.total_eur, so.deposit_eur,\n so.date_from, so.date_to, so.vehicle_label,\n so.daily_subtotal, so.weekend_subtotal,\n so.subtotal_eur, so.vat_eur,\n so.total_days, so.weekday_count, so.weekend_day_count,\n to_char(so.date_from, 'DD.MM.YYYY') as date_from_de,\n to_char(so.date_to, 'DD.MM.YYYY') as date_to_de,\n to_char(now(), 'DD.MM.YYYY') as today_de\nFROM public.customers c\nJOIN public.sales_orders so ON so.customer_id = c.id\nWHERE so.id = '{{ $('Postgres Trigger').item.json.sales_order_id }}'::uuid",
|
||||
"additionalFields": {}
|
||||
},
|
||||
"id": "fetch-full-data",
|
||||
"name": "Fetch Full Order+Customer",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.5,
|
||||
"position": [890, 200],
|
||||
"credentials": {
|
||||
"postgres": {
|
||||
"id": "1",
|
||||
"name": "MC Cars Postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "=http://kong:8000/storage/v1/object/document-templates/{{ $('Check Template Exists').item.json.value }}",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "apikey",
|
||||
"value": "={{ $env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' }}"
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"value": "=Bearer {{ $env.SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "download-template",
|
||||
"name": "Download DOCX Template",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1110, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Fill DOCX template placeholders using simple text replacement.\n// The DOCX is a ZIP containing XML. We replace {{placeholder}} markers\n// in the document.xml with actual values from the order data.\n\nconst JSZip = require('jszip');\n\nconst binaryData = await this.helpers.getBinaryDataBuffer(0, 'data');\nconst orderData = $('Fetch Full Order+Customer').first().json;\n\n// Placeholders map\nconst placeholders = {\n '{{KUNDE_NAME}}': orderData.name || '',\n '{{KUNDE_EMAIL}}': orderData.email || '',\n '{{KUNDE_TELEFON}}': orderData.phone || '',\n '{{BESTELLNUMMER}}': orderData.order_number || '',\n '{{FAHRZEUG}}': orderData.vehicle_label || '',\n '{{DATUM_VON}}': orderData.date_from_de || '',\n '{{DATUM_BIS}}': orderData.date_to_de || '',\n '{{TAGE_GESAMT}}': String(orderData.total_days || 0),\n '{{WOCHENTAGE}}': String(orderData.weekday_count || 0),\n '{{WOCHENENDTAGE}}': String(orderData.weekend_day_count || 0),\n '{{NETTO}}': String(orderData.subtotal_eur || 0),\n '{{MWST}}': String(orderData.vat_eur || 0),\n '{{GESAMT}}': String(orderData.total_eur || 0),\n '{{KAUTION}}': String(orderData.deposit_eur || 0),\n '{{TAGESSATZ}}': String(orderData.daily_subtotal || 0),\n '{{WOCHENENDZUSCHLAG}}': String(orderData.weekend_subtotal || 0),\n '{{DATUM_HEUTE}}': orderData.today_de || '',\n};\n\nconst zip = await JSZip.loadAsync(binaryData);\n\n// Process all XML files in the docx\nconst xmlFiles = Object.keys(zip.files).filter(f => f.endsWith('.xml'));\n\nfor (const xmlFile of xmlFiles) {\n let content = await zip.file(xmlFile).async('string');\n for (const [placeholder, value] of Object.entries(placeholders)) {\n // Handle split placeholders in XML (Word splits text into runs)\n // Simple approach: replace in the raw XML\n const escaped = placeholder.replace(/[{}]/g, c => `\\\\${c}`);\n content = content.split(placeholder).join(value);\n }\n zip.file(xmlFile, content);\n}\n\nconst filledDocx = await zip.generateAsync({ type: 'nodebuffer' });\n\nreturn [{\n json: { filename: `Mietvertrag_${orderData.order_number}.docx` },\n binary: {\n data: await this.helpers.prepareBinaryData(filledDocx, `Mietvertrag_${orderData.order_number}.docx`, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')\n }\n}];"
|
||||
},
|
||||
"id": "fill-template",
|
||||
"name": "Fill DOCX Template",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1330, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "http://gotenberg:3000/forms/libreoffice/convert",
|
||||
"method": "POST",
|
||||
"sendBody": true,
|
||||
"contentType": "multipart-form-data",
|
||||
"bodyParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"parameterType": "formBinaryData",
|
||||
"name": "files",
|
||||
"inputDataFieldName": "data"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"responseFormat": "file"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "convert-to-pdf",
|
||||
"name": "Convert to PDF (Gotenberg)",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1550, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "// Rename the binary output to have the correct PDF filename\nconst orderData = $('Fetch Full Order+Customer').first().json;\nconst binaryData = await this.helpers.getBinaryDataBuffer(0, 'data');\n\nreturn [{\n json: { filename: `Mietvertrag_${orderData.order_number}.pdf`, email: orderData.email, name: orderData.name, order_number: orderData.order_number },\n binary: {\n data: await this.helpers.prepareBinaryData(binaryData, `Mietvertrag_${orderData.order_number}.pdf`, 'application/pdf')\n }\n}];"
|
||||
},
|
||||
"id": "prepare-pdf",
|
||||
"name": "Prepare PDF Attachment",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1770, 200]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"fromEmail": "info@mc-cars.at",
|
||||
"toEmail": "={{ $json.email }}",
|
||||
"subject": "MC Cars – Ihr Mietvertrag {{ $json.order_number }}",
|
||||
"emailType": "html",
|
||||
"html": "<div style=\"font-family:Arial,sans-serif;max-width:600px;margin:0 auto;\">\n <div style=\"background:#1a1a1a;padding:20px;text-align:center;\">\n <h1 style=\"color:#c87941;margin:0;\">MC Cars</h1>\n </div>\n <div style=\"padding:30px;background:#f9f9f9;\">\n <p>Sehr geehrte/r <strong>{{ $json.name }}</strong>,</p>\n <p>anbei finden Sie Ihren Mietvertrag für die Bestellung <strong>{{ $json.order_number }}</strong>.</p>\n <p>Bitte prüfen Sie die Angaben und bringen Sie den unterschriebenen Vertrag zur Fahrzeugübergabe mit.</p>\n <hr style=\"border:none;border-top:1px solid #ddd;margin:25px 0;\" />\n <p>Bei Fragen stehen wir Ihnen jederzeit zur Verfügung.</p>\n <p>Mit freundlichen Grüßen,<br/><strong>MC Cars GmbH</strong><br/>info@mc-cars.at<br/>mc-cars.at</p>\n </div>\n</div>",
|
||||
"options": {
|
||||
"attachments": "data"
|
||||
}
|
||||
},
|
||||
"id": "send-mietvertrag",
|
||||
"name": "Send Mietvertrag Email",
|
||||
"type": "n8n-nodes-base.emailSend",
|
||||
"typeVersion": 2.1,
|
||||
"position": [1990, 200],
|
||||
"credentials": {
|
||||
"smtp": {
|
||||
"id": "2",
|
||||
"name": "MC Cars SMTP"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Postgres Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Check Template Exists",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Check Template Exists": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Template Exists?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Template Exists?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fetch Full Order+Customer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[]
|
||||
]
|
||||
},
|
||||
"Fetch Full Order+Customer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Download DOCX Template",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Download DOCX Template": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Fill DOCX Template",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fill DOCX Template": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Convert to PDF (Gotenberg)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Convert to PDF (Gotenberg)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prepare PDF Attachment",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prepare PDF Attachment": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send Mietvertrag Email",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"staticData": null,
|
||||
"tags": [
|
||||
{
|
||||
"name": "mc-cars"
|
||||
}
|
||||
],
|
||||
"triggerCount": 1
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
||||
# n8n Workflows – MC Cars
|
||||
|
||||
This folder contains exportable n8n workflow definitions for the MC Cars qualification automation.
|
||||
|
||||
## Workflows
|
||||
|
||||
### 01 – Qualification Payment Email
|
||||
**Trigger:** Postgres `NOTIFY` on channel `lead_qualified` (fires when `qualify_lead()` creates a sales order).
|
||||
|
||||
**Flow:**
|
||||
1. Receives notification with `sales_order_id`, `customer_id`, etc.
|
||||
2. Fetches full order + customer data from Postgres.
|
||||
3. Sends HTML email to customer with:
|
||||
- Booking summary (vehicle, dates, pricing)
|
||||
- Kaution bank transfer instructions
|
||||
- Payment link for the rental amount
|
||||
|
||||
### 02 – Mietvertrag PDF Email
|
||||
**Trigger:** Same `lead_qualified` Postgres notification.
|
||||
|
||||
**Flow:**
|
||||
1. Checks if `mietvertrag_template_path` is set in `site_settings`.
|
||||
2. If no template → workflow stops (no error).
|
||||
3. If template exists:
|
||||
- Fetches customer + sales order data
|
||||
- Downloads DOCX template from `document-templates` storage bucket
|
||||
- Fills placeholders using JSZip (in a Code node)
|
||||
- Converts filled DOCX to PDF via Gotenberg
|
||||
- Sends PDF as email attachment to customer
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Configure `.env`
|
||||
The stack now bootstraps n8n credentials/workflow automatically on every `docker compose up`.
|
||||
|
||||
Required env variables:
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `N8N_POSTGRES_CREDENTIAL_ID`
|
||||
- `N8N_POSTGRES_CREDENTIAL_NAME`
|
||||
- `N8N_SMTP_CREDENTIAL_ID`
|
||||
- `N8N_SMTP_CREDENTIAL_NAME`
|
||||
- `N8N_SMTP_HOST`
|
||||
- `N8N_SMTP_USER`
|
||||
- `N8N_SMTP_PASS`
|
||||
- `N8N_PAYPAL_KAUTION_LINK`
|
||||
- `N8N_PAYPAL_MIETE_LINK`
|
||||
- `N8N_PAYMENT_WORKFLOW_ID`
|
||||
|
||||
### 2. Mailbox reference (for future incoming-email workflows)
|
||||
- **IMAP host:** `heracles.mxrouting.net` (port `993`, SSL/TLS)
|
||||
- **POP3 host:** `heracles.mxrouting.net` (port `995`, SSL/TLS)
|
||||
- **Username:** `office@mc-cars.at`
|
||||
- **Password:** same mailbox password as SMTP
|
||||
|
||||
### 3. Import behavior
|
||||
On startup, n8n runs `/opt/mc-cars/bootstrap/bootstrap-n8n.sh` which:
|
||||
1. Creates/updates Postgres and SMTP credentials from `.env`
|
||||
2. Renders `01-qualification-payment-email.json` placeholders
|
||||
3. Imports the workflow so nodes are always linked to the expected credential IDs
|
||||
4. Activates the payment workflow automatically (`n8n update:workflow --active=true`)
|
||||
|
||||
### 4. Upload Mietvertrag template (optional)
|
||||
1. Open Admin panel → **Einstellungen** tab
|
||||
2. Upload a DOCX file in the "Mietvertrag-Vorlage" section
|
||||
3. The template should contain these placeholders:
|
||||
|
||||
| Placeholder | Replaced with |
|
||||
|---|---|
|
||||
| `{{KUNDE_NAME}}` | Customer full name |
|
||||
| `{{KUNDE_EMAIL}}` | Customer email |
|
||||
| `{{KUNDE_TELEFON}}` | Customer phone |
|
||||
| `{{BESTELLNUMMER}}` | Sales order number (e.g. SO-2026-0001) |
|
||||
| `{{FAHRZEUG}}` | Vehicle label (e.g. "Ferrari 488 GTB") |
|
||||
| `{{DATUM_VON}}` | Rental start date (DD.MM.YYYY) |
|
||||
| `{{DATUM_BIS}}` | Rental end date (DD.MM.YYYY) |
|
||||
| `{{TAGE_GESAMT}}` | Total rental days |
|
||||
| `{{WOCHENTAGE}}` | Number of weekdays |
|
||||
| `{{WOCHENENDTAGE}}` | Number of weekend days |
|
||||
| `{{TAGESSATZ}}` | Weekday daily subtotal |
|
||||
| `{{WOCHENENDZUSCHLAG}}` | Weekend surcharge subtotal |
|
||||
| `{{NETTO}}` | Net amount (excl. VAT) |
|
||||
| `{{MWST}}` | VAT amount (19%) |
|
||||
| `{{GESAMT}}` | Total amount (incl. VAT) |
|
||||
| `{{KAUTION}}` | Deposit amount |
|
||||
| `{{DATUM_HEUTE}}` | Today's date (DD.MM.YYYY) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Gotenberg** (docker service `gotenberg`) — converts DOCX → PDF via LibreOffice
|
||||
- **n8n** needs the `jszip` npm package (pre-installed in n8n Docker image)
|
||||
|
||||
## Domain
|
||||
|
||||
All email links and sender addresses use `mc-cars.at`.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.APP_URL || 'http://localhost:55580',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -120,3 +120,16 @@ services:
|
||||
hide_groups_header: true
|
||||
allow:
|
||||
- admin
|
||||
|
||||
########################################
|
||||
# n8n Webhooks (internal workflow triggers)
|
||||
########################################
|
||||
- name: n8n-webhooks
|
||||
url: http://n8n:5678/
|
||||
routes:
|
||||
- name: n8n-webhooks-all
|
||||
strip_path: false
|
||||
paths:
|
||||
- /webhook/
|
||||
plugins:
|
||||
- name: cors
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
-- 07-sales-orders.sql
|
||||
-- Sales Orders, customer private notes, lead IP tracking, attachment relations
|
||||
|
||||
alter table public.leads add column if not exists ip_address text default '';
|
||||
alter table public.leads add column if not exists ip_country text default '';
|
||||
|
||||
create table if not exists public.sales_orders (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
customer_id uuid not null references public.customers(id) on delete cascade,
|
||||
lead_id uuid not null references public.leads(id) on delete restrict,
|
||||
order_number text not null default '',
|
||||
private_notes text not null default '',
|
||||
kaution_paid boolean not null default false,
|
||||
rental_paid boolean not null default false,
|
||||
rental_complete boolean not null default false,
|
||||
kaution_paid_at timestamptz,
|
||||
rental_paid_at timestamptz,
|
||||
rental_complete_at timestamptz,
|
||||
daily_subtotal integer not null default 0,
|
||||
weekend_subtotal integer not null default 0,
|
||||
subtotal_eur integer not null default 0,
|
||||
vat_eur integer not null default 0,
|
||||
total_eur integer not null default 0,
|
||||
deposit_eur integer not null default 0,
|
||||
total_days integer not null default 0,
|
||||
weekday_count integer not null default 0,
|
||||
weekend_day_count integer not null default 0,
|
||||
date_from date,
|
||||
date_to date,
|
||||
vehicle_label text not null default '',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists sales_orders_customer_idx on public.sales_orders (customer_id);
|
||||
create index if not exists sales_orders_lead_idx on public.sales_orders (lead_id);
|
||||
create index if not exists sales_orders_order_number on public.sales_orders (order_number);
|
||||
|
||||
drop trigger if exists sales_orders_touch on public.sales_orders;
|
||||
create trigger sales_orders_touch
|
||||
before update on public.sales_orders
|
||||
for each row execute function public.tg_touch_updated_at();
|
||||
|
||||
alter table public.sales_orders enable row level security;
|
||||
|
||||
drop policy if exists "sales_orders_admin_select" on public.sales_orders;
|
||||
drop policy if exists "sales_orders_admin_insert" on public.sales_orders;
|
||||
drop policy if exists "sales_orders_admin_update" on public.sales_orders;
|
||||
|
||||
create policy "sales_orders_admin_select"
|
||||
on public.sales_orders for select to authenticated using (true);
|
||||
create policy "sales_orders_admin_insert"
|
||||
on public.sales_orders for insert to authenticated with check (true);
|
||||
create policy "sales_orders_admin_update"
|
||||
on public.sales_orders for update to authenticated using (true) with check (true);
|
||||
|
||||
grant select, insert, update on public.sales_orders to authenticated;
|
||||
grant all on public.sales_orders to service_role;
|
||||
|
||||
create table if not exists public.sales_order_attachments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
sales_order_id uuid not null references public.sales_orders(id) on delete cascade,
|
||||
bucket text not null default 'customer-documents',
|
||||
file_path text not null,
|
||||
file_name text not null default '',
|
||||
mime_type text not null default 'application/octet-stream',
|
||||
kind text not null default 'other' check (kind in ('id_document', 'income_proof', 'other')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists sales_order_attachments_so_idx on public.sales_order_attachments (sales_order_id);
|
||||
|
||||
alter table public.sales_order_attachments enable row level security;
|
||||
|
||||
drop policy if exists "so_attach_admin_all" on public.sales_order_attachments;
|
||||
create policy "so_attach_admin_all"
|
||||
on public.sales_order_attachments for all to authenticated
|
||||
using (true) with check (true);
|
||||
|
||||
grant all on public.sales_order_attachments to authenticated;
|
||||
grant all on public.sales_order_attachments to service_role;
|
||||
|
||||
alter table public.customers add column if not exists private_notes text not null default '';
|
||||
create unique index if not exists customers_email_lower_unique on public.customers ((lower(email)));
|
||||
|
||||
alter table public.customer_attachments add column if not exists sales_order_id uuid references public.sales_orders(id) on delete set null;
|
||||
create index if not exists customer_attachments_so_idx on public.customer_attachments (sales_order_id);
|
||||
|
||||
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_sales_order public.sales_orders;
|
||||
v_user uuid := auth.uid();
|
||||
v_order_num text;
|
||||
v_year integer;
|
||||
v_count integer;
|
||||
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 lower(email) = lower(v_lead.email) limit 1;
|
||||
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)
|
||||
on conflict ((lower(email))) do update
|
||||
set name = excluded.name,
|
||||
phone = excluded.phone,
|
||||
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||
updated_at = now()
|
||||
returning * into v_customer;
|
||||
|
||||
v_year := extract(year from now())::integer;
|
||||
select coalesce(count(*), 0) + 1 into v_count
|
||||
from public.sales_orders
|
||||
where extract(year from created_at)::integer = v_year;
|
||||
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||
|
||||
insert into public.sales_orders (
|
||||
customer_id, lead_id, order_number, private_notes,
|
||||
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
|
||||
) values (
|
||||
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||
coalesce(v_lead.weekend_day_count, 0),
|
||||
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label
|
||||
) returning * into v_sales_order;
|
||||
|
||||
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id
|
||||
and not exists (
|
||||
select 1 from public.customer_attachments ca
|
||||
where ca.customer_id = v_customer.id
|
||||
and ca.file_path = la.file_path
|
||||
);
|
||||
|
||||
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id;
|
||||
|
||||
return v_customer;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.sales_order_toggle_kaution(p_so_id uuid)
|
||||
returns public.sales_orders
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
if v_so.kaution_paid then
|
||||
update public.sales_orders
|
||||
set kaution_paid = false, kaution_paid_at = null, updated_at = now()
|
||||
where id = p_so_id
|
||||
returning * into v_so;
|
||||
else
|
||||
if v_so.rental_complete then
|
||||
raise exception 'cannot set kaution paid after rental is complete';
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set kaution_paid = true, kaution_paid_at = now(), updated_at = now()
|
||||
where id = p_so_id
|
||||
returning * into v_so;
|
||||
end if;
|
||||
return v_so;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.sales_order_toggle_rental(p_so_id uuid)
|
||||
returns public.sales_orders
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
if v_so.rental_paid then
|
||||
update public.sales_orders
|
||||
set rental_paid = false, rental_paid_at = null, updated_at = now()
|
||||
where id = p_so_id
|
||||
returning * into v_so;
|
||||
else
|
||||
if v_so.kaution_paid = false or v_so.rental_complete then
|
||||
raise exception 'can only set rental paid after kaution is paid and before rental is complete';
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set rental_paid = true, rental_paid_at = now(), updated_at = now()
|
||||
where id = p_so_id
|
||||
returning * into v_so;
|
||||
end if;
|
||||
return v_so;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.sales_order_toggle_complete(p_so_id uuid)
|
||||
returns public.sales_orders
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
if v_so.rental_complete then
|
||||
update public.sales_orders
|
||||
set rental_complete = false, rental_complete_at = null, updated_at = now()
|
||||
where id = p_so_id
|
||||
returning * into v_so;
|
||||
else
|
||||
if v_so.kaution_paid = false or v_so.rental_paid = false then
|
||||
raise exception 'can only set rental complete after kaution and rental are paid';
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set rental_complete = true, rental_complete_at = now(), updated_at = now()
|
||||
where id = p_so_id
|
||||
returning * into v_so;
|
||||
end if;
|
||||
return v_so;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.customer_update_private_notes(p_customer_id uuid, p_notes text)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
begin
|
||||
update public.customers
|
||||
set private_notes = coalesce(p_notes, ''), updated_at = now()
|
||||
where id = p_customer_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.sales_order_update_private_notes(p_so_id uuid, p_notes text)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
begin
|
||||
update public.sales_orders
|
||||
set private_notes = coalesce(p_notes, ''), updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.sales_order_upload_attachment(
|
||||
p_sales_order_id uuid,
|
||||
p_file_path text,
|
||||
p_file_name text,
|
||||
p_mime_type text,
|
||||
p_kind text default 'other'
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind)
|
||||
values (p_sales_order_id, 'customer-documents', p_file_path, p_file_name, p_mime_type, p_kind)
|
||||
returning id into v_id;
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_daily_subtotal integer default 0,
|
||||
p_weekend_subtotal integer default 0,
|
||||
p_subtotal_eur integer default 0,
|
||||
p_vat_eur integer default 0,
|
||||
p_total_eur integer default 0,
|
||||
p_deposit_eur integer default 0,
|
||||
p_total_days integer default 0,
|
||||
p_weekday_count integer default 0,
|
||||
p_weekend_day_count integer default 0,
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
begin
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
p_daily_subtotal, p_weekend_subtotal, p_subtotal_eur, p_vat_eur, p_total_eur, p_deposit_eur,
|
||||
p_total_days, p_weekday_count, p_weekend_day_count, p_ip_address, p_ip_country
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text
|
||||
);
|
||||
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer
|
||||
);
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_daily_subtotal integer default 0,
|
||||
p_weekend_subtotal integer default 0,
|
||||
p_subtotal_eur integer default 0,
|
||||
p_vat_eur integer default 0,
|
||||
p_total_eur integer default 0,
|
||||
p_deposit_eur integer default 0,
|
||||
p_total_days integer default 0,
|
||||
p_weekday_count integer default 0,
|
||||
p_weekend_day_count integer default 0,
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
begin
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
p_daily_subtotal, p_weekend_subtotal, p_subtotal_eur, p_vat_eur, p_total_eur, p_deposit_eur,
|
||||
p_total_days, p_weekday_count, p_weekend_day_count, p_ip_address, p_ip_country
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_toggle_kaution(uuid) to authenticated;
|
||||
grant execute on function public.sales_order_toggle_rental(uuid) to authenticated;
|
||||
grant execute on function public.sales_order_toggle_complete(uuid) to authenticated;
|
||||
grant execute on function public.customer_update_private_notes(uuid, text) to authenticated;
|
||||
grant execute on function public.sales_order_update_private_notes(uuid, text) to authenticated;
|
||||
grant execute on function public.sales_order_upload_attachment(uuid, text, text, text, text) to authenticated;
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer,
|
||||
text, text
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,205 @@
|
||||
-- 08-backend-pricing-and-security.sql
|
||||
-- 1. Server-side price calculation RPC (read-only, callable by anon for display)
|
||||
-- 2. Refactored create_lead RPC that computes prices internally (no price params from frontend)
|
||||
-- 3. Unique constraint on lead_attachments to enforce max 1 id_document + 1 income_proof per lead
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. calculate_price RPC
|
||||
-- =============================================================================
|
||||
create or replace function public.calculate_price(
|
||||
p_vehicle_id uuid,
|
||||
p_date_from date,
|
||||
p_date_to date
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_vehicle record;
|
||||
v_total_days integer;
|
||||
v_weekend_days integer;
|
||||
v_weekdays integer;
|
||||
v_daily_subtotal integer;
|
||||
v_weekend_subtotal integer;
|
||||
v_subtotal_eur integer;
|
||||
v_vat_eur integer;
|
||||
v_total_eur integer;
|
||||
v_deposit_eur integer;
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is null or p_date_from is null or p_date_to is null then
|
||||
raise exception 'vehicle_id, date_from and date_to are required';
|
||||
end if;
|
||||
|
||||
if p_date_to <= p_date_from then
|
||||
raise exception 'date_to must be after date_from';
|
||||
end if;
|
||||
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur, max_daily_km, max_km_weekend
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Vehicle not found';
|
||||
end if;
|
||||
|
||||
-- Count days
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
v_weekend_days := 0;
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
|
||||
-- Calculate prices
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
|
||||
return jsonb_build_object(
|
||||
'total_days', v_total_days,
|
||||
'weekday_count', v_weekdays,
|
||||
'weekend_day_count', v_weekend_days,
|
||||
'daily_subtotal', v_daily_subtotal,
|
||||
'weekend_subtotal', v_weekend_subtotal,
|
||||
'subtotal_eur', v_subtotal_eur,
|
||||
'vat_eur', v_vat_eur,
|
||||
'total_eur', v_total_eur,
|
||||
'deposit_eur', v_deposit_eur,
|
||||
'daily_price_eur', v_vehicle.daily_price_eur,
|
||||
'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end),
|
||||
'max_daily_km', coalesce(v_vehicle.max_daily_km, 150),
|
||||
'max_km_weekend', coalesce(v_vehicle.max_km_weekend, v_vehicle.max_daily_km, 150)
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Refactored create_lead – computes prices server-side, no price params
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop old overloaded signatures
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text
|
||||
);
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer
|
||||
);
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer,
|
||||
text, text
|
||||
);
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
v_vehicle record;
|
||||
v_total_days integer := 0;
|
||||
v_weekend_days integer := 0;
|
||||
v_weekdays integer := 0;
|
||||
v_daily_subtotal integer := 0;
|
||||
v_weekend_subtotal integer := 0;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
v_total_eur integer := 0;
|
||||
v_deposit_eur integer := 0;
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
-- Compute prices server-side if vehicle and dates are provided
|
||||
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if found then
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur);
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text, text, text
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Enforce max 1 id_document + 1 income_proof per lead
|
||||
-- =============================================================================
|
||||
|
||||
-- Unique partial index: only one 'id_document' per lead
|
||||
drop index if exists lead_attachments_unique_id_document;
|
||||
create unique index lead_attachments_unique_id_document
|
||||
on public.lead_attachments (lead_id)
|
||||
where kind = 'id_document';
|
||||
|
||||
-- Unique partial index: only one 'income_proof' per lead
|
||||
drop index if exists lead_attachments_unique_income_proof;
|
||||
create unique index lead_attachments_unique_income_proof
|
||||
on public.lead_attachments (lead_id)
|
||||
where kind = 'income_proof';
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 10-mietvertrag-workflow.sql
|
||||
-- Document templates bucket, mietvertrag setting, and qualification webhook trigger.
|
||||
|
||||
-- 1. Storage bucket for admin-uploaded document templates (private, admin-only)
|
||||
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
values (
|
||||
'document-templates',
|
||||
'document-templates',
|
||||
false,
|
||||
20971520,
|
||||
array['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- RLS: only authenticated (admin) can read/write document-templates
|
||||
drop policy if exists "templates_admin_select" on storage.objects;
|
||||
create policy "templates_admin_select"
|
||||
on storage.objects for select to authenticated
|
||||
using (bucket_id = 'document-templates');
|
||||
|
||||
drop policy if exists "templates_admin_insert" on storage.objects;
|
||||
create policy "templates_admin_insert"
|
||||
on storage.objects for insert to authenticated
|
||||
with check (bucket_id = 'document-templates');
|
||||
|
||||
drop policy if exists "templates_admin_update" on storage.objects;
|
||||
create policy "templates_admin_update"
|
||||
on storage.objects for update to authenticated
|
||||
using (bucket_id = 'document-templates');
|
||||
|
||||
-- 2. Site setting for Mietvertrag template (empty on startup)
|
||||
insert into public.site_settings (key, value)
|
||||
values ('mietvertrag_template_path', '')
|
||||
on conflict (key) do nothing;
|
||||
|
||||
-- 3. Function to notify n8n when a lead is qualified (new sales order created)
|
||||
-- n8n listens on the 'lead_qualified' channel via Postgres trigger.
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
begin
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label
|
||||
)::text);
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Trigger on sales_orders insert (fires when qualify_lead creates the order)
|
||||
drop trigger if exists trg_notify_lead_qualified on public.sales_orders;
|
||||
create trigger trg_notify_lead_qualified
|
||||
after insert on public.sales_orders
|
||||
for each row
|
||||
execute function public.notify_lead_qualified();
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,396 @@
|
||||
-- 11-consolidate-km-rental.sql
|
||||
-- Consolidate km/rental model: new included_km_per_day, rental_type,
|
||||
-- rewrite calculate_price / create_lead / qualify_lead / notify_lead_qualified,
|
||||
-- add sales_order_set_total RPC.
|
||||
-- Idempotent.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. Vehicles table changes
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.vehicles add column if not exists included_km_per_day integer not null default 150;
|
||||
|
||||
update public.vehicles set included_km_per_day = coalesce(max_daily_km, 150) where included_km_per_day = 150;
|
||||
|
||||
update public.vehicles set included_km_per_day = 200 where brand = 'Ferrari' and model = '296 GTB';
|
||||
|
||||
alter table public.vehicles add column if not exists price_per_km_eur numeric(10,2) not null default 1.50;
|
||||
|
||||
alter table public.vehicles drop column if exists max_daily_km;
|
||||
alter table public.vehicles drop column if exists max_km_weekend;
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Leads table changes
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.leads add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
|
||||
update public.leads set rental_type = 'weekend' where rental_type is null;
|
||||
create index if not exists leads_rental_type_idx on public.leads (rental_type);
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Sales orders table changes
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.sales_orders add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
|
||||
update public.sales_orders set rental_type = 'weekend' where rental_type is null;
|
||||
create index if not exists sales_orders_rental_type_idx on public.sales_orders (rental_type);
|
||||
|
||||
-- =============================================================================
|
||||
-- D. Rewrite calculate_price() RPC
|
||||
-- =============================================================================
|
||||
|
||||
drop function if exists public.calculate_price(uuid, date, date);
|
||||
|
||||
create or replace function public.calculate_price(
|
||||
p_vehicle_id uuid,
|
||||
p_date_from date,
|
||||
p_date_to date
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_vehicle record;
|
||||
v_total_days integer;
|
||||
v_weekend_days integer;
|
||||
v_weekdays integer;
|
||||
v_daily_subtotal integer;
|
||||
v_weekend_subtotal integer;
|
||||
v_subtotal_eur integer;
|
||||
v_vat_eur integer;
|
||||
v_total_eur integer;
|
||||
v_deposit_eur integer;
|
||||
v_included_km_per_day integer;
|
||||
v_price_per_km numeric(10,2);
|
||||
v_total_included_km integer;
|
||||
v_extra_km integer;
|
||||
v_extra_km_eur numeric(10,2);
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is null or p_date_from is null or p_date_to is null then
|
||||
raise exception 'vehicle_id, date_from and date_to are required';
|
||||
end if;
|
||||
if p_date_to <= p_date_from then
|
||||
raise exception 'date_to must be after date_from';
|
||||
end if;
|
||||
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur, included_km_per_day, price_per_km_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Vehicle not found';
|
||||
end if;
|
||||
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
v_weekend_days := 0;
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
|
||||
v_included_km_per_day := coalesce(v_vehicle.included_km_per_day, 150);
|
||||
v_total_included_km := v_total_days * v_included_km_per_day;
|
||||
v_price_per_km := coalesce(v_vehicle.price_per_km_eur, 1.50);
|
||||
v_extra_km := greatest(0, 0); -- extra km is determined by caller (frontend) based on expected usage
|
||||
v_extra_km_eur := v_extra_km * v_price_per_km;
|
||||
|
||||
return jsonb_build_object(
|
||||
'total_days', v_total_days,
|
||||
'weekday_count', v_weekdays,
|
||||
'weekend_day_count', v_weekend_days,
|
||||
'daily_subtotal', v_daily_subtotal,
|
||||
'weekend_subtotal', v_weekend_subtotal,
|
||||
'subtotal_eur', v_subtotal_eur,
|
||||
'vat_eur', v_vat_eur,
|
||||
'total_eur', v_total_eur,
|
||||
'deposit_eur', v_deposit_eur,
|
||||
'daily_price_eur', v_vehicle.daily_price_eur,
|
||||
'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end),
|
||||
'included_km_per_day', v_included_km_per_day,
|
||||
'total_included_km', v_total_included_km,
|
||||
'price_per_km_eur', v_price_per_km,
|
||||
'extra_km', v_extra_km,
|
||||
'extra_km_eur', v_extra_km_eur
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- E. Rewrite create_lead() RPC
|
||||
-- =============================================================================
|
||||
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text
|
||||
);
|
||||
drop function if exists public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text,
|
||||
integer, integer, integer, integer, integer, integer, integer, integer, integer,
|
||||
text, text
|
||||
);
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
v_vehicle record;
|
||||
v_total_days integer := 0;
|
||||
v_weekend_days integer := 0;
|
||||
v_weekdays integer := 0;
|
||||
v_daily_subtotal integer := 0;
|
||||
v_weekend_subtotal integer := 0;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
v_total_eur integer := 0;
|
||||
v_deposit_eur integer := 0;
|
||||
v_rental_type text := 'weekend';
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if found then
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
|
||||
-- Auto-detect rental type: 2 days or less = weekend, more = individuell
|
||||
v_rental_type := 'weekend';
|
||||
if v_total_days > 2 then
|
||||
v_rental_type := 'individuell';
|
||||
end if;
|
||||
|
||||
-- For individuell, set all pricing to 0
|
||||
if v_rental_type = 'individuell' then
|
||||
v_daily_subtotal := 0;
|
||||
v_weekend_subtotal := 0;
|
||||
v_subtotal_eur := 0;
|
||||
v_vat_eur := 0;
|
||||
v_total_eur := 0;
|
||||
v_deposit_eur := 0;
|
||||
else
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur);
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
end if;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
|
||||
rental_type
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
|
||||
v_rental_type
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text, text, text
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
-- =============================================================================
|
||||
-- F. Rewrite qualify_lead() RPC
|
||||
-- =============================================================================
|
||||
|
||||
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_sales_order public.sales_orders;
|
||||
v_user uuid := auth.uid();
|
||||
v_order_num text;
|
||||
v_year integer;
|
||||
v_count integer;
|
||||
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 lower(email) = lower(v_lead.email) limit 1;
|
||||
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)
|
||||
on conflict ((lower(email))) do update
|
||||
set name = excluded.name,
|
||||
phone = excluded.phone,
|
||||
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||
updated_at = now()
|
||||
returning * into v_customer;
|
||||
|
||||
v_year := extract(year from now())::integer;
|
||||
select coalesce(count(*), 0) + 1 into v_count
|
||||
from public.sales_orders
|
||||
where extract(year from created_at)::integer = v_year;
|
||||
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||
|
||||
insert into public.sales_orders (
|
||||
customer_id, lead_id, order_number, private_notes,
|
||||
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, rental_type
|
||||
) values (
|
||||
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||
coalesce(v_lead.weekend_day_count, 0),
|
||||
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type
|
||||
) returning * into v_sales_order;
|
||||
|
||||
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id
|
||||
and not exists (
|
||||
select 1 from public.customer_attachments ca
|
||||
where ca.customer_id = v_customer.id
|
||||
and ca.file_path = la.file_path
|
||||
);
|
||||
|
||||
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id;
|
||||
|
||||
return v_customer;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- G. Rewrite notify_lead_qualified() trigger function
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
begin
|
||||
-- Skip notification for 'individuell' rental type
|
||||
if NEW.rental_type = 'individuell' then
|
||||
return NEW;
|
||||
end if;
|
||||
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label,
|
||||
'rental_type', NEW.rental_type
|
||||
)::text);
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- H. New RPC: sales_order_set_total
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
if v_so.rental_type != 'individuell' then
|
||||
raise exception 'can only set total for individuell orders';
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set total_eur = p_total_eur, updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- I. Final schema reload
|
||||
-- =============================================================================
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,209 @@
|
||||
-- 12-email-sent-and-more.sql
|
||||
-- Add email_sent column to sales_orders, update notify_lead_qualified() to include
|
||||
-- rental_type and email_sent, update qualify_lead() to set email_sent=0,
|
||||
-- add sales_order_update_email_sent and sales_order_get_email_details RPCs.
|
||||
-- Idempotent.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. Add email_sent to sales_orders
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.sales_orders add column if not exists email_sent integer not null default 0;
|
||||
create index if not exists sales_orders_email_sent_idx on public.sales_orders (email_sent);
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Update notify_lead_qualified() trigger function
|
||||
-- (defined in 10-mietvertrag-workflow.sql, overridden by 11-consolidate-km-rental.sql)
|
||||
-- Since migration 12 runs after 11, this is the final version.
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
begin
|
||||
-- Skip notification for 'individuell' rental type
|
||||
if NEW.rental_type = 'individuell' then
|
||||
return NEW;
|
||||
end if;
|
||||
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label,
|
||||
'rental_type', NEW.rental_type,
|
||||
'email_sent', NEW.email_sent
|
||||
)::text);
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Update qualify_lead() RPC
|
||||
-- Add email_sent = 0 to the sales_orders insert.
|
||||
-- =============================================================================
|
||||
|
||||
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_sales_order public.sales_orders;
|
||||
v_user uuid := auth.uid();
|
||||
v_order_num text;
|
||||
v_year integer;
|
||||
v_count integer;
|
||||
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 lower(email) = lower(v_lead.email) limit 1;
|
||||
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)
|
||||
on conflict ((lower(email))) do update
|
||||
set name = excluded.name,
|
||||
phone = excluded.phone,
|
||||
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||
updated_at = now()
|
||||
returning * into v_customer;
|
||||
|
||||
v_year := extract(year from now())::integer;
|
||||
select coalesce(count(*), 0) + 1 into v_count
|
||||
from public.sales_orders
|
||||
where extract(year from created_at)::integer = v_year;
|
||||
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||
|
||||
insert into public.sales_orders (
|
||||
customer_id, lead_id, order_number, private_notes,
|
||||
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, rental_type, email_sent
|
||||
) values (
|
||||
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||
coalesce(v_lead.weekend_day_count, 0),
|
||||
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type, 0
|
||||
) returning * into v_sales_order;
|
||||
|
||||
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id
|
||||
and not exists (
|
||||
select 1 from public.customer_attachments ca
|
||||
where ca.customer_id = v_customer.id
|
||||
and ca.file_path = la.file_path
|
||||
);
|
||||
|
||||
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id;
|
||||
|
||||
return v_customer;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- D. New RPC: sales_order_update_email_sent
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_update_email_sent(p_so_id uuid, p_status integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
if p_status not in (0, 1, 2) then
|
||||
raise exception 'invalid email_sent status: %', p_status;
|
||||
end if;
|
||||
update public.sales_orders
|
||||
set email_sent = p_status, updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_update_email_sent(uuid, integer) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- E. New RPC: sales_order_get_email_details
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_get_email_details(p_so_id uuid)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_result jsonb;
|
||||
begin
|
||||
select jsonb_build_object(
|
||||
'order_number', so.order_number,
|
||||
'total_eur', so.total_eur,
|
||||
'deposit_eur', so.deposit_eur,
|
||||
'date_from', so.date_from,
|
||||
'date_to', so.date_to,
|
||||
'vehicle_label', so.vehicle_label,
|
||||
'customer_name', c.name,
|
||||
'customer_email', c.email,
|
||||
'customer_phone', c.phone,
|
||||
'daily_subtotal', so.daily_subtotal,
|
||||
'weekend_subtotal', so.weekend_subtotal,
|
||||
'subtotal_eur', so.subtotal_eur,
|
||||
'vat_eur', so.vat_eur,
|
||||
'total_days', so.total_days,
|
||||
'weekday_count', so.weekday_count,
|
||||
'weekend_day_count', so.weekend_day_count
|
||||
) into v_result
|
||||
from public.sales_orders so
|
||||
join public.customers c on c.id = so.customer_id
|
||||
where so.id = p_so_id;
|
||||
|
||||
if v_result is null then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_get_email_details(uuid) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- F. Final schema reload
|
||||
-- =============================================================================
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,232 @@
|
||||
-- 13-rental-type-daily-and-email-guard.sql
|
||||
-- Introduce explicit 'single_day' rental_type, normalize legacy values,
|
||||
-- and harden auto-email guard for individuell rentals.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. Normalize and expand rental_type checks
|
||||
-- =============================================================================
|
||||
|
||||
alter table public.leads drop constraint if exists leads_rental_type_check;
|
||||
alter table public.sales_orders drop constraint if exists sales_orders_rental_type_check;
|
||||
|
||||
update public.leads
|
||||
set rental_type = lower(trim(coalesce(rental_type, '')));
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = lower(trim(coalesce(rental_type, '')));
|
||||
|
||||
update public.leads
|
||||
set rental_type = 'individuell'
|
||||
where rental_type in ('individual', 'custom');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'individuell'
|
||||
where rental_type in ('individual', 'custom');
|
||||
|
||||
update public.leads
|
||||
set rental_type = 'single_day'
|
||||
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'single_day'
|
||||
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
|
||||
|
||||
-- Existing one-day bookings should be single_day.
|
||||
update public.leads
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 1;
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 1;
|
||||
|
||||
-- Two-day non-Saturday starts are effectively single_day rentals, not weekend packages.
|
||||
update public.leads
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 2
|
||||
and date_from is not null
|
||||
and extract(isodow from date_from) <> 6;
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'single_day'
|
||||
where rental_type = 'weekend'
|
||||
and total_days = 2
|
||||
and date_from is not null
|
||||
and extract(isodow from date_from) <> 6;
|
||||
|
||||
-- Fallback for any unexpected value.
|
||||
update public.leads
|
||||
set rental_type = 'weekend'
|
||||
where rental_type not in ('single_day', 'weekend', 'individuell');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'weekend'
|
||||
where rental_type not in ('single_day', 'weekend', 'individuell');
|
||||
|
||||
alter table public.leads
|
||||
alter column rental_type set default 'weekend';
|
||||
|
||||
alter table public.sales_orders
|
||||
alter column rental_type set default 'weekend';
|
||||
|
||||
alter table public.leads
|
||||
add constraint leads_rental_type_check
|
||||
check (rental_type in ('single_day', 'weekend', 'individuell'));
|
||||
|
||||
alter table public.sales_orders
|
||||
add constraint sales_orders_rental_type_check
|
||||
check (rental_type in ('single_day', 'weekend', 'individuell'));
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Harden notify_lead_qualified() against malformed rental_type values
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.notify_lead_qualified()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_rental_type text := coalesce(lower(trim(NEW.rental_type)), 'weekend');
|
||||
begin
|
||||
-- Never auto-email individuell orders (including legacy synonyms).
|
||||
if v_rental_type in ('individuell', 'individual', 'custom') then
|
||||
return NEW;
|
||||
end if;
|
||||
|
||||
perform pg_notify('lead_qualified', json_build_object(
|
||||
'sales_order_id', NEW.id,
|
||||
'customer_id', NEW.customer_id,
|
||||
'lead_id', NEW.lead_id,
|
||||
'order_number', NEW.order_number,
|
||||
'total_eur', NEW.total_eur,
|
||||
'deposit_eur', NEW.deposit_eur,
|
||||
'date_from', NEW.date_from,
|
||||
'date_to', NEW.date_to,
|
||||
'vehicle_label', NEW.vehicle_label,
|
||||
'rental_type', v_rental_type,
|
||||
'email_sent', NEW.email_sent
|
||||
)::text);
|
||||
|
||||
return NEW;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- C. Update create_lead() classification logic to include daily
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.create_lead(
|
||||
p_name text,
|
||||
p_email text,
|
||||
p_phone text default '',
|
||||
p_vehicle_id uuid default null,
|
||||
p_vehicle_label text default '',
|
||||
p_date_from date default null,
|
||||
p_date_to date default null,
|
||||
p_message text default '',
|
||||
p_source text default 'website',
|
||||
p_ip_address text default '',
|
||||
p_ip_country text default ''
|
||||
)
|
||||
returns uuid
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_lead_id uuid;
|
||||
v_vehicle record;
|
||||
v_total_days integer := 0;
|
||||
v_weekend_days integer := 0;
|
||||
v_weekdays integer := 0;
|
||||
v_daily_subtotal integer := 0;
|
||||
v_weekend_subtotal integer := 0;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
v_total_eur integer := 0;
|
||||
v_deposit_eur integer := 0;
|
||||
v_rental_type text := 'weekend';
|
||||
v_cur date;
|
||||
v_dow integer;
|
||||
begin
|
||||
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||
into v_vehicle
|
||||
from public.vehicles
|
||||
where id = p_vehicle_id;
|
||||
|
||||
if found then
|
||||
v_total_days := (p_date_to - p_date_from);
|
||||
|
||||
-- Classification:
|
||||
-- 1 day => single_day
|
||||
-- 2 days starting Saturday => weekend package
|
||||
-- 2 days otherwise => single_day
|
||||
-- > 2 days => individuell (manual processing)
|
||||
if v_total_days > 2 then
|
||||
v_rental_type := 'individuell';
|
||||
elsif v_total_days = 1 then
|
||||
v_rental_type := 'single_day';
|
||||
elsif v_total_days = 2 and extract(isodow from p_date_from) = 6 then
|
||||
v_rental_type := 'weekend';
|
||||
elsif v_total_days = 2 then
|
||||
v_rental_type := 'single_day';
|
||||
else
|
||||
v_rental_type := 'weekend';
|
||||
end if;
|
||||
|
||||
if v_rental_type = 'individuell' then
|
||||
v_daily_subtotal := 0;
|
||||
v_weekend_subtotal := 0;
|
||||
v_subtotal_eur := 0;
|
||||
v_vat_eur := 0;
|
||||
v_total_eur := 0;
|
||||
v_deposit_eur := 0;
|
||||
else
|
||||
v_cur := p_date_from;
|
||||
while v_cur < p_date_to loop
|
||||
v_dow := extract(isodow from v_cur);
|
||||
if v_dow in (6, 7) then
|
||||
v_weekend_days := v_weekend_days + 1;
|
||||
end if;
|
||||
v_cur := v_cur + 1;
|
||||
end loop;
|
||||
|
||||
v_weekdays := v_total_days - v_weekend_days;
|
||||
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||
end if;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
insert into public.leads (
|
||||
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||
message, source,
|
||||
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
|
||||
rental_type
|
||||
) values (
|
||||
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||
p_message, p_source,
|
||||
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
|
||||
v_rental_type
|
||||
)
|
||||
returning id into v_lead_id;
|
||||
|
||||
return v_lead_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.create_lead(
|
||||
text, text, text, uuid, text, date, date, text, text, text, text
|
||||
) to anon, authenticated, service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 14-sales-order-set-deposit.sql
|
||||
-- Adds sales_order_set_deposit RPC for updating deposit from admin pricing tab.
|
||||
|
||||
-- =============================================================================
|
||||
-- A. RPC: sales_order_set_deposit
|
||||
-- =============================================================================
|
||||
|
||||
create or replace function public.sales_order_set_deposit(p_so_id uuid, p_deposit_eur integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
begin
|
||||
update public.sales_orders
|
||||
set deposit_eur = p_deposit_eur, updated_at = now()
|
||||
where id = p_so_id;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_set_deposit(uuid, integer) to authenticated;
|
||||
|
||||
-- =============================================================================
|
||||
-- B. Schema reload
|
||||
-- =============================================================================
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Ensure individuell orders persist net/vat components when total is manually set
|
||||
-- and backfill existing records where these fields are still zero.
|
||||
|
||||
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_so public.sales_orders;
|
||||
v_subtotal_eur integer := 0;
|
||||
v_vat_eur integer := 0;
|
||||
begin
|
||||
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||
if not found then
|
||||
raise exception 'sales order % not found', p_so_id;
|
||||
end if;
|
||||
|
||||
if v_so.rental_type != 'individuell' then
|
||||
raise exception 'can only set total for individuell orders';
|
||||
end if;
|
||||
|
||||
if coalesce(p_total_eur, 0) < 0 then
|
||||
raise exception 'total must be >= 0';
|
||||
end if;
|
||||
|
||||
if p_total_eur > 0 then
|
||||
v_subtotal_eur := round(p_total_eur / 1.2);
|
||||
v_vat_eur := p_total_eur - v_subtotal_eur;
|
||||
end if;
|
||||
|
||||
update public.sales_orders
|
||||
set total_eur = p_total_eur,
|
||||
subtotal_eur = v_subtotal_eur,
|
||||
vat_eur = v_vat_eur,
|
||||
daily_subtotal = v_subtotal_eur,
|
||||
weekend_subtotal = 0,
|
||||
weekday_count = coalesce(total_days, 0),
|
||||
weekend_day_count = 0,
|
||||
updated_at = now()
|
||||
where id = p_so_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
|
||||
|
||||
-- Backfill already existing individuell orders with missing net/vat split.
|
||||
update public.sales_orders
|
||||
set subtotal_eur = round(total_eur / 1.2),
|
||||
vat_eur = total_eur - round(total_eur / 1.2),
|
||||
daily_subtotal = round(total_eur / 1.2),
|
||||
weekend_subtotal = 0,
|
||||
weekday_count = coalesce(total_days, 0),
|
||||
weekend_day_count = 0,
|
||||
updated_at = now()
|
||||
where rental_type = 'individuell'
|
||||
and coalesce(total_eur, 0) > 0
|
||||
and coalesce(subtotal_eur, 0) = 0
|
||||
and coalesce(vat_eur, 0) = 0;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 16-rental-type-weekend-gap-fix.sql
|
||||
-- Fix misclassified long rentals that were stored as 'weekend'.
|
||||
-- Business rule: only true weekend package may remain 'weekend'; long spans are 'individuell'.
|
||||
|
||||
-- Leads: any weekend booking longer than 2 days must be individuell.
|
||||
update public.leads
|
||||
set rental_type = 'individuell'
|
||||
where coalesce(lower(trim(rental_type)), 'weekend') = 'weekend'
|
||||
and coalesce(total_days, 0) > 2;
|
||||
|
||||
-- Sales orders: same correction.
|
||||
update public.sales_orders
|
||||
set rental_type = 'individuell'
|
||||
where coalesce(lower(trim(rental_type)), 'weekend') = 'weekend'
|
||||
and coalesce(total_days, 0) > 2;
|
||||
|
||||
-- If old rows have unknown/legacy values and >2 days, normalize to individuell as well.
|
||||
update public.leads
|
||||
set rental_type = 'individuell'
|
||||
where coalesce(total_days, 0) > 2
|
||||
and coalesce(lower(trim(rental_type)), '') not in ('individuell', 'weekend', 'single_day');
|
||||
|
||||
update public.sales_orders
|
||||
set rental_type = 'individuell'
|
||||
where coalesce(total_days, 0) > 2
|
||||
and coalesce(lower(trim(rental_type)), '') not in ('individuell', 'weekend', 'single_day');
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -0,0 +1,91 @@
|
||||
-- 17-vehicle-photos.sql
|
||||
-- Idempotent migration: add vehicle_photos table for multiple photos per vehicle.
|
||||
-- Each vehicle can have multiple photos with ordering support.
|
||||
|
||||
-- Create vehicle_photos table
|
||||
create table if not exists public.vehicle_photos (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
vehicle_id uuid not null references public.vehicles(id) on delete cascade,
|
||||
photo_url text not null default '',
|
||||
photo_path text not null,
|
||||
display_order integer not null default 0,
|
||||
is_primary boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists vehicle_photos_vehicle_id_idx
|
||||
on public.vehicle_photos(vehicle_id, display_order);
|
||||
|
||||
-- Enable RLS
|
||||
alter table public.vehicle_photos enable row level security;
|
||||
|
||||
-- Drop existing policies to ensure idempotency
|
||||
drop policy if exists "vehicle_photos_public_read" on public.vehicle_photos;
|
||||
drop policy if exists "vehicle_photos_admin_read" on public.vehicle_photos;
|
||||
drop policy if exists "vehicle_photos_admin_insert" on public.vehicle_photos;
|
||||
drop policy if exists "vehicle_photos_admin_delete" on public.vehicle_photos;
|
||||
drop policy if exists "vehicle_photos_admin_update" on public.vehicle_photos;
|
||||
|
||||
-- Public can read all photos
|
||||
create policy "vehicle_photos_public_read"
|
||||
on public.vehicle_photos for select
|
||||
to anon using (true);
|
||||
|
||||
-- Authenticated (admin) full access
|
||||
create policy "vehicle_photos_admin_read"
|
||||
on public.vehicle_photos for select
|
||||
to authenticated using (true);
|
||||
|
||||
create policy "vehicle_photos_admin_insert"
|
||||
on public.vehicle_photos for insert
|
||||
to authenticated with check (true);
|
||||
|
||||
create policy "vehicle_photos_admin_update"
|
||||
on public.vehicle_photos for update
|
||||
to authenticated using (true) with check (true);
|
||||
|
||||
create policy "vehicle_photos_admin_delete"
|
||||
on public.vehicle_photos for delete
|
||||
to authenticated using (true);
|
||||
|
||||
-- Grants
|
||||
grant select on public.vehicle_photos to anon, authenticated;
|
||||
grant insert, update, delete on public.vehicle_photos to authenticated;
|
||||
grant all on public.vehicle_photos to service_role;
|
||||
|
||||
-- Migrate existing vehicle photo_url/photo_path to vehicle_photos table
|
||||
-- This ensures existing vehicles get their photo into the new table
|
||||
insert into public.vehicle_photos (vehicle_id, photo_url, photo_path, display_order, is_primary)
|
||||
select id, photo_url, coalesce(photo_path, 'legacy'), 0, true
|
||||
from public.vehicles
|
||||
where photo_url != '' and photo_path is not null
|
||||
on conflict do nothing;
|
||||
|
||||
-- RPC: set primary photo for a vehicle (unsets others)
|
||||
create or replace function public.set_primary_vehicle_photo(
|
||||
p_vehicle_id uuid,
|
||||
p_photo_id uuid
|
||||
) returns void
|
||||
language plpgsql security invoker as $$
|
||||
begin
|
||||
update public.vehicle_photos set is_primary = false where vehicle_id = p_vehicle_id;
|
||||
update public.vehicle_photos set is_primary = true where id = p_photo_id and vehicle_id = p_vehicle_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- RPC: re-order photos for a vehicle
|
||||
create or replace function public.reorder_vehicle_photos(
|
||||
p_vehicle_id uuid,
|
||||
p_photo_orders jsonb -- [{id: uuid, order: int}, ...]
|
||||
) returns void
|
||||
language plpgsql security invoker as $$
|
||||
declare
|
||||
rec jsonb;
|
||||
begin
|
||||
for rec in select * from jsonb_array_elements(p_photo_orders) loop
|
||||
update public.vehicle_photos
|
||||
set display_order = (rec->>'order')::int
|
||||
where id = (rec->>'id')::uuid and vehicle_id = p_vehicle_id;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
@@ -65,7 +65,7 @@ grant anon, authenticated, service_role to supabase_storage_admin;
|
||||
|
||||
grant select on storage.buckets to anon, authenticated;
|
||||
grant all on storage.buckets to service_role;
|
||||
grant select on storage.objects to anon;
|
||||
grant insert on storage.objects to anon;
|
||||
grant select, insert, update, delete on storage.objects to authenticated;
|
||||
grant all on storage.objects to service_role;
|
||||
|
||||
@@ -101,19 +101,26 @@ on conflict (id) do update
|
||||
allowed_mime_types = excluded.allowed_mime_types;
|
||||
|
||||
drop policy if exists "custdocs_anon_upload" on storage.objects;
|
||||
drop policy if exists "custdocs_anon_select" on storage.objects;
|
||||
drop policy if exists "custdocs_anon_update" on storage.objects;
|
||||
drop policy if exists "custdocs_anon_upsert_update" on storage.objects;
|
||||
drop policy if exists "custdocs_public_upload" on storage.objects;
|
||||
drop policy if exists "custdocs_public_upsert_update" on storage.objects;
|
||||
drop policy if exists "custdocs_admin_read" on storage.objects;
|
||||
drop policy if exists "custdocs_admin_delete" on storage.objects;
|
||||
drop policy if exists "custdocs_admin_insert" on storage.objects;
|
||||
|
||||
-- Anon can upload during booking flow
|
||||
-- Anon can only INSERT (upload) during booking flow — no SELECT/UPDATE/DELETE
|
||||
create policy "custdocs_anon_upload"
|
||||
on storage.objects for insert to anon
|
||||
with check (bucket_id = 'customer-documents');
|
||||
|
||||
-- Only authenticated admins can read/delete
|
||||
-- Authenticated admins can read (view documents)
|
||||
create policy "custdocs_admin_read"
|
||||
on storage.objects for select to authenticated
|
||||
using (bucket_id = 'customer-documents');
|
||||
|
||||
create policy "custdocs_admin_delete"
|
||||
on storage.objects for delete to authenticated
|
||||
using (bucket_id = 'customer-documents');
|
||||
-- Authenticated admins can upload new documents
|
||||
create policy "custdocs_admin_insert"
|
||||
on storage.objects for insert to authenticated
|
||||
with check (bucket_id = 'customer-documents');
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# MC Cars — Local Testing Protocol
|
||||
|
||||
> This document records the exact steps taken to verify the MC Cars stack is operational.
|
||||
> Run these steps after every stack spin-up to confirm baseline functionality.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine with Compose v2
|
||||
- Clean data directory: `rm -rf data/db data/storage data/n8n && mkdir -p data/{db,storage,n8n}`
|
||||
|
||||
---
|
||||
|
||||
## 1. Spin Up the Stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~30 seconds for migrations to complete, then verify:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml ps
|
||||
```
|
||||
|
||||
Expected: all 14 services running (db healthy, kong healthy).
|
||||
|
||||
---
|
||||
|
||||
## 2. Verify API Responds
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:55580/index.html | head -5
|
||||
```
|
||||
Expected: HTML response with `<!DOCTYPE html>`
|
||||
|
||||
```bash
|
||||
curl -s -H "apikey: $ANON_KEY" "http://localhost:55521/rest/v1/vehicles?select=brand,model"
|
||||
```
|
||||
Expected: `[{"brand":"Ferrari","model":"296 GTB"}]`
|
||||
|
||||
---
|
||||
|
||||
## 3. Playwright End-to-End Tests
|
||||
|
||||
### 3.1 Public Website — Verify Access & Ferrari
|
||||
|
||||
1. Navigate to `http://localhost:55580/index.html`
|
||||
2. Verify page loads with title "MC Cars · Sportwagenvermietung Steiermark"
|
||||
3. Verify hero section is visible
|
||||
4. Verify "1" car count in stats section
|
||||
5. Verify Ferrari 296 GTB card is visible with:
|
||||
- Image: "Ferrari 296 GTB"
|
||||
- Specs: 830 PS, 330 km/h, 2.9s
|
||||
- Price: € 850 / pro Tag
|
||||
- Buttons: "Details" and "Buchen"
|
||||
|
||||
### 3.2 Playwright — Make a Reservation
|
||||
|
||||
1. Scroll to/click "Buchen" section
|
||||
2. Select "Ferrari 296 GTB" from vehicle dropdown
|
||||
3. Click "Individuell" (custom dates) button
|
||||
4. Set Start date: `2026-06-20`
|
||||
5. Set End date: `2026-06-22`
|
||||
6. Click "Weiter" (Weiter button, ref=e146)
|
||||
7. Verify step 2 ("Kontaktdaten") is shown
|
||||
8. Verify pricing sidebar shows:
|
||||
- "Ferrari 296 GTB · 2 Tage"
|
||||
- "Wochenendtage (2 × € 1100)" → "€ 2.200"
|
||||
- "MwSt. (20%)" → "€ 440"
|
||||
- "Gesamtbetrag" → "€ 2.640"
|
||||
- "Kaution" → "€ 5.000"
|
||||
9. Fill Name: `Jose Lago`
|
||||
10. Fill Email: `jose@lago.dev`
|
||||
11. Fill Phone: `+43 660 1234567`
|
||||
12. Click "Weiter" to go to step 3
|
||||
13. Click "Anfrage absenden" (submit button)
|
||||
14. Verify toast notification: "Danke! Wir melden uns in Kürze per E-Mail."
|
||||
15. Verify form reset (vehicle dropdown back to "Fahrzeug wählen")
|
||||
|
||||
### 3.3 Admin Portal — Verify Lead Appears
|
||||
|
||||
1. Navigate to `http://localhost:55581/admin.html`
|
||||
2. Verify login page loads with title "Admin · MC Cars"
|
||||
3. Fill email: `admin@mccars.local`
|
||||
4. Fill password: `mc-cars-admin`
|
||||
5. Click "Anmelden"
|
||||
6. Verify password rotation screen ("Passwort setzen") appears
|
||||
7. Set new password: `NewMcCars2026!` (twice)
|
||||
8. Click "Speichern"
|
||||
9. Verify admin dashboard loads with hash `#leads`
|
||||
10. Verify tabs: "Leads 1", "Kunden 0", "Bestellungen 0", "Fahrzeuge", "Einstellungen"
|
||||
11. Verify the lead appears in "Aktive Leads" table with:
|
||||
- **Eingang:** 17.05.26, 13:34 (current date/time)
|
||||
- **Name/E-Mail:** Jose Lago · jose@lago.dev
|
||||
- **Fahrzeug:** Ferrari 296 GTB
|
||||
- **Zeitraum:** 2026-06-20 → 2026-06-22
|
||||
- **Gesamtbetrag:** € 2.640
|
||||
- **Status:** new
|
||||
- **Actions:** Details, Qualifizieren, Ablehnen
|
||||
|
||||
---
|
||||
|
||||
## 4. Spin Down & Cleanup
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||
```
|
||||
|
||||
Clean data directory for next test run:
|
||||
|
||||
```bash
|
||||
rm -rf data/db/* 2>/dev/null
|
||||
# Note: data/db/ directory may need sudo to fully remove (owned by Docker bind mount UID)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Results Summary
|
||||
|
||||
| Check | Expected |
|
||||
|-------|----------|
|
||||
| All services running | Yes (14 services) |
|
||||
| Public website loads | Yes, 200 OK |
|
||||
| API returns vehicles | Yes, Ferrari 296 GTB |
|
||||
| Booking form works | Yes, 3-step wizard |
|
||||
| Server-side pricing | Yes, € 2.640 for weekend |
|
||||
| Booking submission | Yes, success toast shown |
|
||||
| Admin login | Yes, password rotation enforced |
|
||||
| Lead visible in admin | Yes, all fields correct |
|
||||
@@ -0,0 +1,255 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Booking Flow End-to-End', () => {
|
||||
const ADMIN_URL = 'http://localhost:55581';
|
||||
const ADMIN_EMAIL = 'admin@mccars.local';
|
||||
const ADMIN_PASSWORD = 'mc-cars-admin';
|
||||
|
||||
// Generate unique test data per run to avoid conflicts
|
||||
const ts = Date.now();
|
||||
const testEmails = [
|
||||
`test-day-${ts}@playwright.test`,
|
||||
`test-weekend-${ts}@playwright.test`,
|
||||
`test-custom-${ts}@playwright.test`,
|
||||
];
|
||||
const testNames = [
|
||||
'Test Testerson Day',
|
||||
'Test Testerson Weekend',
|
||||
'Test Testerson Custom',
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper: fill out the booking form for a given mietdauer type.
|
||||
* Returns nothing - the form submission is handled by the page's JS.
|
||||
*/
|
||||
async function submitBooking(page, type, index) {
|
||||
// Scroll to booking section
|
||||
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 1: Select vehicle
|
||||
const carSelect = page.locator('#bpfCar');
|
||||
await expect(carSelect).toBeVisible({ timeout: 10000 });
|
||||
// Select first available vehicle option (skip the placeholder)
|
||||
const options = await carSelect.locator('option').all();
|
||||
expect(options.length).toBeGreaterThan(1);
|
||||
const firstVehicle = await options[1].innerText();
|
||||
await carSelect.selectOption({ label: firstVehicle });
|
||||
|
||||
// Step 2: Select mietdauer type
|
||||
const presetBtn = page.locator(`.bpf-preset[data-preset="${type}"]`);
|
||||
await expect(presetBtn).toBeVisible();
|
||||
await presetBtn.click();
|
||||
|
||||
// Step 3: Pick date(s) based on type
|
||||
if (type === 'day') {
|
||||
// Pick a date 7 days from now
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 7);
|
||||
const dateStr = futureDate.toISOString().split('T')[0];
|
||||
const dateInput = page.locator('#bpfDayDate');
|
||||
await dateInput.fill(dateStr);
|
||||
} else if (type === 'weekend') {
|
||||
// Pick next Saturday
|
||||
const nextSaturday = new Date();
|
||||
const daysUntilSaturday = (6 - nextSaturday.getDay() + 7) % 7 || 7;
|
||||
nextSaturday.setDate(nextSaturday.getDate() + daysUntilSaturday);
|
||||
const dateStr = nextSaturday.toISOString().split('T')[0];
|
||||
const dateInput = page.locator('#bpfWeekendDate');
|
||||
await dateInput.fill(dateStr);
|
||||
} else if (type === 'custom') {
|
||||
// Pick start date 14 days from now, end date 17 days from now (4 days = individuell)
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() + 14);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + 3);
|
||||
const fromStr = startDate.toISOString().split('T')[0];
|
||||
const toStr = endDate.toISOString().split('T')[0];
|
||||
await page.locator('#bpfFrom').fill(fromStr);
|
||||
await page.locator('#bpfTo').fill(toStr);
|
||||
}
|
||||
|
||||
// Click Weiter to go to step 2
|
||||
await page.locator('#bpfNext1').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Step 2: Fill contact info
|
||||
await expect(page.locator('#bpfName')).toBeVisible();
|
||||
await page.locator('#bpfName').fill(testNames[index]);
|
||||
await page.locator('#bpfEmail').fill(testEmails[index]);
|
||||
await page.locator('#bpfPhone').fill('+43 660 1234567');
|
||||
await page.locator('#bpfMessage').fill(`Test booking via playwright - ${type}`);
|
||||
|
||||
// Click Weiter to go to step 3
|
||||
await page.locator('#bpfNext2').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Step 3: Submit (skip file uploads - they are optional)
|
||||
await expect(page.locator('#bpfSubmit')).toBeVisible();
|
||||
await page.locator('#bpfSubmit').click();
|
||||
|
||||
// Wait for success toast
|
||||
await expect(page.locator('#toast.show')).toBeVisible({ timeout: 10000 });
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
test('Complete booking flow: 1 Tag, Wochenende, Individuell → 3 leads in admin → disqualify all', async ({ page, context }) => {
|
||||
// ========================================
|
||||
// PART 1: Submit 3 bookings on main site
|
||||
// ========================================
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Booking 1: 1 Tag
|
||||
await submitBooking(page, 'day', 0);
|
||||
|
||||
// Booking 2: Wochenende
|
||||
await submitBooking(page, 'weekend', 1);
|
||||
|
||||
// Booking 3: Individuell
|
||||
await submitBooking(page, 'custom', 2);
|
||||
|
||||
// ========================================
|
||||
// PART 2: Verify 3 leads in admin panel
|
||||
// ========================================
|
||||
const adminCtx = await test.info().project.use.baseBrowserType?.newContext() ?? context;
|
||||
const adminPage = await adminCtx.newPage();
|
||||
adminPage.setDefaultTimeout(30000);
|
||||
|
||||
await adminPage.goto(ADMIN_URL);
|
||||
await adminPage.waitForLoadState('domcontentloaded');
|
||||
await adminPage.waitForTimeout(2000);
|
||||
|
||||
// Login
|
||||
const loginForm = adminPage.locator('#loginForm');
|
||||
await expect(loginForm).toBeVisible({ timeout: 10000 });
|
||||
await adminPage.locator('#loginForm [name="email"]').fill(ADMIN_EMAIL);
|
||||
await adminPage.locator('#loginForm [name="password"]').fill(ADMIN_PASSWORD);
|
||||
await adminPage.locator('#loginForm [type="submit"]').click();
|
||||
|
||||
// Wait a moment for login to process
|
||||
await adminPage.waitForTimeout(3000);
|
||||
|
||||
// Check for login error
|
||||
const loginError = adminPage.locator('#loginError');
|
||||
if (await loginError.isVisible()) {
|
||||
const errorMsg = await loginError.textContent();
|
||||
throw new Error(`Login failed: ${errorMsg}`);
|
||||
}
|
||||
|
||||
// Check if password rotation is required (first login)
|
||||
const rotateView = adminPage.locator('#rotateView');
|
||||
if (await rotateView.isVisible({ timeout: 2000 })) {
|
||||
// Set a new password (must be different from bootstrap)
|
||||
const newPw = 'Playwright-Test-PW-2026!';
|
||||
await adminPage.locator('#rotateForm [name="pw1"]').fill(newPw);
|
||||
await adminPage.locator('#rotateForm [name="pw2"]').fill(newPw);
|
||||
await adminPage.locator('#rotateForm [type="submit"]').click();
|
||||
await adminPage.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Wait for admin view to load
|
||||
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
|
||||
await adminPage.waitForTimeout(2000);
|
||||
|
||||
// Ensure leads tab is active (it's the default)
|
||||
const leadsTab = adminPage.locator('[data-tab="leads"]');
|
||||
const leadsTabClass = await leadsTab.getAttribute('class');
|
||||
if (!leadsTabClass?.includes('active')) {
|
||||
await leadsTab.click();
|
||||
await adminPage.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Wait for our test leads to appear by checking for their emails in the table
|
||||
// We wait for at least one of our test emails to appear, then verify all 3
|
||||
await adminPage.waitForFunction(
|
||||
([emails]) => {
|
||||
const rows = document.querySelectorAll('#leadsTable tbody tr');
|
||||
let found = 0;
|
||||
for (const row of rows) {
|
||||
const text = row.textContent;
|
||||
for (const email of emails) {
|
||||
if (text.includes(email)) {
|
||||
found++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found >= 3;
|
||||
},
|
||||
testEmails,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
await adminPage.waitForTimeout(1000);
|
||||
|
||||
// Find our test leads by email pattern
|
||||
const allRows = adminPage.locator('#leadsTable tbody tr');
|
||||
const totalRows = await allRows.count();
|
||||
const testRowIndices = [];
|
||||
|
||||
for (let i = 0; i < totalRows; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const rowText = await row.textContent();
|
||||
if (testEmails.some(email => rowText.includes(email))) {
|
||||
testRowIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
expect(testRowIndices.length).toBe(3);
|
||||
|
||||
// ========================================
|
||||
// PART 3: Disqualify all 3 test leads
|
||||
// ========================================
|
||||
// Disqualify each lead one at a time, re-finding it after each disqualification
|
||||
// since the table re-renders and indices shift.
|
||||
for (const email of testEmails) {
|
||||
// Find the row for this email
|
||||
const rows = adminPage.locator('#leadsTable tbody tr');
|
||||
const count = await rows.count();
|
||||
let found = false;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const rowText = await rows.nth(i).textContent();
|
||||
if (rowText.includes(email)) {
|
||||
// Click disqualify button
|
||||
const disqBtn = rows.nth(i).locator('[data-disq]');
|
||||
if (await disqBtn.isVisible()) {
|
||||
await disqBtn.click();
|
||||
await adminPage.waitForTimeout(1500);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(found).toBe(true, `Lead with email ${email} not found or could not be disqualified`);
|
||||
}
|
||||
|
||||
// Wait for disqualifications to process
|
||||
await adminPage.waitForTimeout(2000);
|
||||
|
||||
// Refresh page to ensure fresh data after disqualifications
|
||||
await adminPage.reload();
|
||||
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
|
||||
await adminPage.waitForTimeout(3000);
|
||||
|
||||
// Verify our test leads are now disqualified (no longer in active view)
|
||||
const remainingRows = adminPage.locator('#leadsTable tbody tr');
|
||||
const remainingCount = await remainingRows.count();
|
||||
let foundTestLead = false;
|
||||
|
||||
for (let i = 0; i < remainingCount; i++) {
|
||||
const rowText = await remainingRows.nth(i).textContent();
|
||||
if (testEmails.some(email => rowText.includes(email))) {
|
||||
foundTestLead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundTestLead).toBe(false);
|
||||
|
||||
await adminPage.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Legal Pages - Warum wir removed', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
|
||||
test('Impressum page - Warum wir nav link removed', async ({ page }) => {
|
||||
await page.goto('/impressum.html');
|
||||
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('AGB page - Warum wir nav link removed', async ({ page }) => {
|
||||
await page.goto('/agb.html');
|
||||
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Datenschutz page - Warum wir nav link removed', async ({ page }) => {
|
||||
await page.goto('/datenschutz.html');
|
||||
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Mietbedingungen page - Warum wir nav link removed', async ({ page }) => {
|
||||
await page.goto('/mietbedingungen.html');
|
||||
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('All legal pages - other nav links present', async ({ page }) => {
|
||||
await page.goto('/impressum.html');
|
||||
const nav = page.getByLabel('Hauptnavigation');
|
||||
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('MC Cars - Customer Changes Verification', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('Page loads successfully', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/MC Cars/);
|
||||
});
|
||||
|
||||
test('Hero section - Flotte ansehen button removed', async ({ page }) => {
|
||||
await expect(page.getByText('Flotte ansehen')).not.toBeVisible();
|
||||
await expect(page.getByText('View fleet')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Hero section - 24/7 Support stat removed', async ({ page }) => {
|
||||
await expect(page.getByText('24/7')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Hero section - Faire Kaution stat still visible', async ({ page }) => {
|
||||
const kautionStat = page.getByText('Faire Kaution', { exact: true });
|
||||
await expect(kautionStat).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hero section - Fahrzeuge stat still visible', async ({ page }) => {
|
||||
const vehiclesSection = page.locator('.hero-stats');
|
||||
await expect(vehiclesSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('Fleet section - Unsere Flotte eyebrow removed', async ({ page }) => {
|
||||
await expect(page.getByText('Unsere Flotte')).not.toBeVisible();
|
||||
await expect(page.getByText('Our Fleet')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Fleet section - Title still visible', async ({ page }) => {
|
||||
await expect(page.getByText('Handverlesen. Gepflegt. Startklar.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Navigation - Warum wir link removed', async ({ page }) => {
|
||||
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||
await expect(page.getByText('Why us')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Navigation - Other links still present', async ({ page }) => {
|
||||
const nav = page.getByLabel('Hauptnavigation');
|
||||
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Stimmen' })).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Jetzt buchen' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Reviews - Ferrari references in reviews', async ({ page }) => {
|
||||
await page.locator('#stimmen').scrollIntoViewIfNeeded();
|
||||
const reviewsSection = page.locator('#stimmen');
|
||||
await expect(reviewsSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('Reviews - GT3 references removed', async ({ page }) => {
|
||||
await expect(page.getByText('GT3')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Footer - correct content', async ({ page }) => {
|
||||
await expect(page.getByText('Rechtliches')).toBeVisible();
|
||||
await expect(page.getByText('Impressum')).toBeVisible();
|
||||
await expect(page.getByText('Datenschutz')).toBeVisible();
|
||||
await expect(page.getByText('hello@mc-cars.at')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Footer - Steiermark reference updated', async ({ page }) => {
|
||||
await expect(page.getByText('Made in Steiermark')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Language toggle works', async ({ page }) => {
|
||||
const langToggle = page.locator('.lang-toggle');
|
||||
await expect(langToggle).toBeVisible();
|
||||
|
||||
// Switch to English
|
||||
await langToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(langToggle).toHaveText('DE');
|
||||
await expect(page.getByText('Drive at the highest level.')).toBeVisible();
|
||||
|
||||
// Switch back to German
|
||||
await langToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(langToggle).toHaveText('EN');
|
||||
await expect(page.getByRole('heading', { name: /Niveau/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Fleet section - vehicle cards visible', async ({ page }) => {
|
||||
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||
const vehicleCards = page.locator('.vehicle-card');
|
||||
await expect(vehicleCards.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Booking section visible', async ({ page }) => {
|
||||
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||
await expect(page.getByRole('heading', { name: 'Jetzt buchen' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('SEO title updated', async ({ page }) => {
|
||||
const title = await page.title();
|
||||
expect(title).toContain('Ferrari');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Photo Gallery Feature', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test('Vehicle cards render correctly', async ({ page }) => {
|
||||
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||
const cards = page.locator('.vehicle-card');
|
||||
await expect(cards.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('Vehicle card has photo', async ({ page }) => {
|
||||
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||
const firstPhoto = page.locator('.vehicle-card').first().locator('.vehicle-photo img');
|
||||
await expect(firstPhoto).toBeVisible();
|
||||
const src = await firstPhoto.getAttribute('src');
|
||||
expect(src).not.toBeNull();
|
||||
expect(src).not.toBe('');
|
||||
});
|
||||
|
||||
test('Vehicle details dialog opens', async ({ page }) => {
|
||||
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||
const detailsBtn = page.locator('[data-details]').first();
|
||||
if (await detailsBtn.isVisible()) {
|
||||
await detailsBtn.click();
|
||||
const dialog = page.locator('#carDialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Booking wizard - vehicle selector works', async ({ page }) => {
|
||||
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||
const carSelect = page.locator('#bpfCar');
|
||||
await expect(carSelect).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user