Compare commits
32 Commits
30e296f61b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 597d47f824 | |||
| 44dbf6b93c | |||
| 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.
|
||||
|
||||
+125
-16
@@ -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
|
||||
@@ -125,12 +175,25 @@ postgres (database = "postgres")
|
||||
├── _realtime schema (Realtime bookkeeping)
|
||||
└── public schema
|
||||
├── vehicles (fleet)
|
||||
├── leads (form submissions)
|
||||
├── 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
|
||||
├── 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
|
||||
|
||||
### 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.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.5 RPCs
|
||||
### 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,15 @@ 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
|
||||
|
||||
kong:
|
||||
volumes:
|
||||
@@ -30,3 +39,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
|
||||
|
||||
@@ -214,6 +214,15 @@ 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
|
||||
entrypoint: ["sh","-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -236,6 +245,15 @@ 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
|
||||
echo "post-init done."
|
||||
restart: "no"
|
||||
networks: [mccars]
|
||||
@@ -339,3 +357,110 @@ services:
|
||||
- "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}
|
||||
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\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" > /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: ${WEBHOOK_DOMAIN:-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
|
||||
|
||||
+79
-8
@@ -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">
|
||||
@@ -161,13 +195,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 +247,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 +308,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>
|
||||
|
||||
+650
-92
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
<!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/png" href="/images/mc-cars-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
||||
<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.png" 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="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||
<button class="lang-toggle" type="button" aria-label="Sprache wechseln">EN</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main 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@mccars.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.png" 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 Österreich. Standort: Steiermark (TBD).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" 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>
|
||||
</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>
|
||||
+88
-53
@@ -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 || "";
|
||||
@@ -52,7 +52,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 +96,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
|
||||
@@ -145,10 +156,12 @@ function renderGrid() {
|
||||
emptyState.style.display = state.filtered.length ? "none" : "block";
|
||||
|
||||
for (const v of state.filtered) {
|
||||
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
||||
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">
|
||||
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" />
|
||||
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
||||
</div>
|
||||
<div class="vehicle-body">
|
||||
@@ -186,12 +199,13 @@ function renderGrid() {
|
||||
function openDetails(id) {
|
||||
const v = state.vehicles.find(x => x.id === id);
|
||||
if (!v) return;
|
||||
const photoUrl = 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)}" />
|
||||
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
|
||||
<p>${escapeHtml(desc || "")}</p>
|
||||
<div class="spec-row" style="margin:1rem 0;">
|
||||
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
|
||||
@@ -201,7 +215,7 @@ function openDetails(id) {
|
||||
<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><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>
|
||||
@@ -287,7 +301,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 +309,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 +368,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 +381,62 @@ 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 photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
||||
|
||||
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} × € ${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>` : ""}
|
||||
${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>€ 1,50${t("bpfPerKm")}</span></div>
|
||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(v.photo_url)}');"></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 +452,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 +486,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 +500,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 +512,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 +552,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}')`);
|
||||
}
|
||||
})();
|
||||
|
||||
+102
-3
@@ -4,16 +4,115 @@
|
||||
<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/png" href="/images/mc-cars-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
||||
<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>
|
||||
<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.png" 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="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||
<button class="lang-toggle" type="button" aria-label="Sprache wechseln">EN</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main style="padding: 3rem 0;">
|
||||
<div class="shell">
|
||||
<h1>Datenschutz</h1>
|
||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||
<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>
|
||||
</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.png" 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 Österreich. Standort: Steiermark (TBD).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" 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>
|
||||
</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>
|
||||
|
||||
+75
-7
@@ -89,6 +89,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",
|
||||
@@ -118,7 +119,8 @@ export const translations = {
|
||||
footerNav: "Navigation",
|
||||
imprint: "Impressum",
|
||||
privacy: "Datenschutz",
|
||||
footerTerms: "Mietbedingungen",
|
||||
terms: "AGB",
|
||||
rentalTerms: "Mietbedingungen",
|
||||
copyright: "Alle Rechte vorbehalten.",
|
||||
|
||||
close: "Schließen",
|
||||
@@ -130,6 +132,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 +177,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 +188,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 +223,8 @@ export const translations = {
|
||||
adminVatLabelEn: "VAT (20%)",
|
||||
adminTotalLabel: "Gesamtbetrag",
|
||||
adminTotalLabelEn: "Total",
|
||||
adminInclVat: "inkl. MwSt.",
|
||||
adminInclVatEn: "incl. VAT",
|
||||
adminDepositLabel: "Kaution",
|
||||
adminDepositLabelEn: "Deposit",
|
||||
adminIncludedKmLabel: "Inkl. km",
|
||||
@@ -214,6 +237,17 @@ export const translations = {
|
||||
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",
|
||||
@@ -304,6 +338,7 @@ export const translations = {
|
||||
bpfWeekendRate: "Weekend rate",
|
||||
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
||||
bpfMaxKm: "Max. km/day",
|
||||
bpfInclKmPerDay: "Included km/day",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Price overview",
|
||||
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
||||
@@ -333,7 +368,8 @@ export const translations = {
|
||||
footerNav: "Navigation",
|
||||
imprint: "Imprint",
|
||||
privacy: "Privacy",
|
||||
footerTerms: "Rental conditions",
|
||||
terms: "Terms",
|
||||
rentalTerms: "Rental Terms",
|
||||
copyright: "All rights reserved.",
|
||||
|
||||
close: "Close",
|
||||
@@ -345,6 +381,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 +426,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 +437,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 +472,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,6 +486,17 @@ 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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 |
+103
-4
@@ -4,19 +4,118 @@
|
||||
<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/png" href="/images/mc-cars-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
||||
<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>
|
||||
<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.png" 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="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||
<button class="lang-toggle" type="button" aria-label="Sprache wechseln">EN</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main style="padding: 3rem 0;">
|
||||
<div class="shell">
|
||||
<h1>Impressum</h1>
|
||||
<p>MC Cars (GmbH)</p>
|
||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||
<p><strong>MC Cars (GmbH)</strong></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>
|
||||
</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.png" 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 Österreich. Standort: Steiermark (TBD).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" 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>
|
||||
</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>
|
||||
|
||||
+110
-46
@@ -5,17 +5,107 @@
|
||||
<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." />
|
||||
<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, Ferraris mieten Graz, Porsche mieten Österreich" />
|
||||
<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 Sportwagen & Luxusvermietung" />
|
||||
<meta property="og:description" content="Fahren Sie Premium-Sportwagen und Luxusklasse-Fahrzeuge 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 Sportwagen & Luxusvermietung" />
|
||||
<meta name="twitter:description" content="Fahren Sie Premium-Sportwagen 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 Sportwagen- und Luxusvermietung 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": "Sportwagenvermietung",
|
||||
"sameAs": [
|
||||
"https://www.facebook.com/mccars",
|
||||
"https://www.instagram.com/mccars"
|
||||
]
|
||||
}
|
||||
</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 Sportwagen- und Luxusvermietung 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>
|
||||
@@ -89,36 +178,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 +220,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 +241,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>
|
||||
@@ -196,14 +257,14 @@
|
||||
</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,7 +368,7 @@
|
||||
<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>
|
||||
@@ -316,15 +377,15 @@
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="impressum.html" data-i18n="imprint">Impressum</a>
|
||||
<a href="datenschutz.html" data-i18n="privacy">Datenschutz</a>
|
||||
<a href="#" data-i18n="terms">Mietbedingungen</a>
|
||||
<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>
|
||||
@@ -350,6 +411,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,128 @@
|
||||
<!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/png" href="/images/mc-cars-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
||||
<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.png" 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="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||
<button class="lang-toggle" type="button" aria-label="Sprache wechseln">EN</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main 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@mccars.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.png" 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 Österreich. Standort: Steiermark (TBD).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerNav">Navigation</h4>
|
||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="footerLegal">Rechtliches</h4>
|
||||
<a href="/impressum" 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>
|
||||
</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>
|
||||
+92
-10
@@ -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);
|
||||
}
|
||||
@@ -541,12 +559,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,10 +592,13 @@ select:focus, input:focus, textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
.review-dots button.active {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.review-dots button.active::before {
|
||||
background: var(--accent);
|
||||
width: 32px;
|
||||
border-radius: 6px;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* ---------------- Booking ---------------- */
|
||||
@@ -760,6 +801,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 +817,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 +904,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 +960,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 +970,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); }
|
||||
@@ -1026,6 +1102,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 +1114,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 +1123,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`.
|
||||
@@ -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';
|
||||
@@ -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": "failed",
|
||||
"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 |
|
||||
Reference in New Issue
Block a user