Compare commits

..

32 Commits

Author SHA1 Message Date
Lago 597d47f824 feat: add n8n webhook URL configuration and update deployment scripts 2026-05-17 23:05:05 +02:00
Lago 44dbf6b93c feat: configure n8n webhooks and update related documentation 2026-05-17 23:01:57 +02:00
Lago 387d2ba2ab feat: replace toast notifications with a customizable popup for admin messages 2026-05-17 22:41:44 +02:00
Lago 3ec79e1923 fix: update rental type label for consistency in multiple languages 2026-05-17 22:39:00 +02:00
Lago f46ba8cadc feat(i18n): add VAT labels and email sent messages in German and English
style(admin): increase max-width of admin page and adjust table styles

fix(n8n): enhance workflow import and publishing process

fix(workflows): update SQL queries for fetching and updating sales orders

feat(migrations): normalize rental types and enhance email guard for individuell rentals

feat(migrations): add RPC for updating deposit in sales orders

fix(migrations): ensure individuell orders persist net/vat components and backfill existing records

test: update last run status to failed
2026-05-17 22:35:11 +02:00
Lago e34d56e36a feat: Add manual email sending workflow and related database changes
- Implemented a new n8n workflow for manual email sending, including webhook trigger, order data fetching, email building, and sending.
- Added logic to format email content with customer and order details.
- Introduced new columns in the sales_orders table to track email sending status.
- Updated database functions to handle new rental types and email status.
- Created new RPCs for updating email status and retrieving email details for sales orders.
2026-05-17 18:04:36 +02:00
Lago e24bc743e2 docs: add local testing protocol for MC Cars stack verification 2026-05-17 13:41:06 +02:00
Lago 32580781c8 chore: add .playwright-mcp to .gitignore to prevent committing Playwright configuration files 2026-05-17 13:39:26 +02:00
Lago d5a219bd50 chore: remove unused logo and SVG assets from frontend images 2026-05-10 12:14:20 +02:00
Lago d6713e25f9 Refactor code structure for improved readability and maintainability 2026-05-10 12:10:45 +02:00
Lago b21b3937b2 fix: set N8N_SECURE_COOKIE to false in docker-compose files for n8n service 2026-05-10 01:19:23 +02:00
Lago f440f88725 fix: update n8n service configuration for improved user permissions and workflow management 2026-05-10 01:17:12 +02:00
Lago 408a59bd5c chore: remove unused .gitkeep file from data directory 2026-05-10 01:08:14 +02:00
Lago 652131a285 fix: correct SUPABASE_PUBLIC_URL in environment setup for mc-cars.at 2026-05-10 00:56:51 +02:00
Lago e986121240 docs: update README with environment variable setup for mc-cars.at 2026-05-10 00:56:06 +02:00
Lago bd906dbe15 feat: enhance n8n workflows with dynamic credential management and email configuration 2026-05-10 00:52:35 +02:00
Lago 05de6cc9a4 fix: update n8n workflow for payment email and improve SMTP configuration details 2026-05-10 00:23:14 +02:00
Lago fae2c0120e fix: simplify robots.txt to valid standard directives 2026-05-10 00:13:36 +02:00
Lago 54d9cdcdc9 perf: async Google Fonts, extend CSS/JS cache to 1 week 2026-05-10 00:01:22 +02:00
Lago db4001aaa5 fix: optimize vehicle photo URLs and update HTML preconnect and stylesheet links 2026-05-09 23:51:40 +02:00
Lago b4258edb91 fix: update hero image URL and improve preload for styles and fonts 2026-05-09 23:48:47 +02:00
Lago aca60696ae fix: improve accessibility and styling for vehicle selection and review buttons 2026-05-09 23:45:30 +02:00
Lago 926950bd62 Add MC Cars mark SVG logo to frontend images 2026-05-09 23:42:28 +02:00
Lago 9de88a5459 fix: update nginx configuration for caching, gzip compression, security headers, and error handling 2026-05-09 23:37:22 +02:00
Lago 8f5ea34e8b chore: ignore and untrack autogenerated local n8n files 2026-05-09 23:37:02 +02:00
Lago 6a70581ef2 docs: add local dev workflow and ignore local n8n mount 2026-05-09 23:32:16 +02:00
Lago 3fb0369367 Enhance SEO and performance for MC Cars website
- Added meta tags for SEO optimization across multiple pages including descriptions, robots directives, and Open Graph tags.
- Implemented JSON-LD structured data for better search visibility and rich snippets.
- Configured nginx for improved performance with gzip compression, caching strategies, and security headers.
- Created robots.txt and sitemap.xml for better search engine indexing and crawl management.
- Developed comprehensive SEO documentation including a guide and quick reference for ongoing optimization efforts.
2026-05-09 23:12:25 +02:00
LagoESP dbb4c27535 Add favicon and apple-touch-icon to multiple HTML files; implement toast notification feature in app.js; update duration mode handling; enhance footer navigation and styling; create AGB and Mietbedingungen pages; improve Nginx configuration for HTML file handling; add logo images. 2026-05-09 23:04:29 +02:00
LagoESP 3a902e7138 feat: add Mietvertrag workflow and template management in n8n, including email notifications and document handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:42:17 +02:00
LagoESP 3298efe54b feat: implement server-side pricing calculation and add site settings management
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 20:39:21 +02:00
LagoESP b0bea0bef1 feat: add backend pricing calculation RPC and refactor create_lead function
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 20:18:07 +02:00
LagoESP 4c1931cdf4 feat: update upload functionality and permissions for document handling
- Removed the `upsert` option from the file upload in `uploadDoc` function to prevent unintended overwrites.
- Enhanced German translations in `i18n.js` for better clarity and consistency in the admin interface.
- Added new CSS styles for link interactions to improve user experience in `styles.css`.
- Updated Supabase SQL migration to grant additional permissions for anonymous users to insert and update storage objects, ensuring proper functionality during the booking flow.

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 20:09:27 +02:00
50 changed files with 6933 additions and 325 deletions
+31 -5
View File
@@ -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
+7
View File
@@ -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/
+29 -10
View File
@@ -27,12 +27,15 @@ Postgres' official entrypoint only processes `/docker-entrypoint-initdb.d/` on a
### Post-boot (every time, idempotent)
- `supabase/migrations/post-boot.sql` — seeds the admin `auth.users` row with `must_change_password=true`, creates the `vehicle-photos` bucket row if missing. Parameterized: `psql -v admin_email=... -v admin_password=...`.
- `supabase/migrations/02-leads.sql` — leads + customers + RPCs + `supabase_realtime` publication. Uses `create if not exists`, `create or replace`, and `do $$ ... $$` with exists-checks so every object is idempotent.
- `supabase/migrations/08-backend-pricing-and-security.sql``calculate_price` RPC (public, read-only pricing), refactored `create_lead` RPC (accepts no price params, computes server-side), unique partial indexes on `lead_attachments` (max 1 id_document + 1 income_proof per lead), hardened storage RLS (anon INSERT-only on customer-documents, admin SELECT+INSERT only).
- `supabase/migrations/09-site-settings.sql``site_settings` key-value table + RLS (public SELECT, admin full CRUD) + seed row `hero_image_url = '/images/ferrari-main-car.png'`.
Both are run by the `post-init` service, which gates on `auth.users` and `storage.buckets` being queryable before touching anything.
**Adding a new migration:**
- If it's schema the app can't start without on a fresh DB → extend `01-init.sql`.
- If it's ongoing / idempotent → new file `03-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint.
- If it's ongoing / idempotent → new file `NN-*.sql`, mount it in `post-init`, append one more `psql -f` line to the entrypoint in `docker-compose.yml`.
- Current numbering: `01`, `02`, `08`, `09`. Use the next available number for new migrations.
---
@@ -40,13 +43,15 @@ Both are run by the `post-init` service, which gates on `auth.users` and `storag
| Role | How obtained | Can do |
| ----------------- | ---------------------------------- | ------------------------------------ |
| `anon` | Anon JWT (shipped to browser) | `select` on active `vehicles`; `insert` on `leads` only |
| `authenticated` | JWT from `signInWithPassword` | Full CRUD on vehicles/leads/customers, `execute` RPCs |
| `anon` | Anon JWT (shipped to browser) | `select` on active `vehicles`; `insert` on `leads` only; `select` on `site_settings`; `insert` on `customer-documents` bucket; execute `calculate_price` and `create_lead` RPCs |
| `authenticated` | JWT from `signInWithPassword` | Full CRUD on vehicles/leads/customers/sales_orders/site_settings, `execute` RPCs, storage uploads |
| `service_role` | Service JWT (never in browser) | Bypasses RLS |
- Anon **cannot** read leads. The booking form is fire-and-forget by design. An abuser submitting with a stolen anon key sees only HTTP 201 with no row data.
- Anon **cannot** read/delete from `customer-documents` bucket — only INSERT (upload). Admin can SELECT + INSERT but not DELETE (prevents evidence destruction).
- RPCs are `SECURITY INVOKER`. They run as the caller, so `auth.uid()` returns the actual admin; RLS still applies.
- `qualify_lead` is idempotent — calling it twice returns the existing customer row instead of raising.
- `create_lead` computes prices server-side via `calculate_price` — no price params accepted from client (prevents price-tampering).
---
@@ -93,6 +98,10 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
- The `web` service uses `nginx:1.27-alpine` directly (no `build:`). Static files and `nginx.conf` are bind-mounted. Updating the frontend is `git pull` + `docker compose up -d --force-recreate web`.
- `frontend/app.js` (public) creates a Supabase client with `persistSession: false` — the public site never needs a session.
- `frontend/admin.js` uses `persistSession: true, storageKey: "mccars.auth"` and a single realtime channel `mccars-admin` that subscribes to leads/customers/vehicles.
- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, **Einstellungen**. The Einstellungen (Settings) tab manages site-wide configuration (currently: hero image upload).
- `app.js` calls `calculate_price` RPC for sidebar pricing display — **no client-side price computation**.
- `app.js` calls `create_lead` RPC for form submission — prices are computed server-side and stored on the lead.
- `app.js` at boot loads `hero_image_url` from `site_settings` and applies it via CSS variable `--hero-bg` on `.hero`. The `styles.css` fallback is `url('images/ferrari-main-car.png')`.
- `app.js` writes `vehicle_id` (uuid) **and** denormalized `vehicle_label` ("BMW M3") into the lead at submit time, so the admin UI renders even if the vehicle is later deleted.
---
@@ -111,6 +120,9 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
| `config.js` not generated, website uses fallback URL | Unraid doesn't preserve execute bits — entrypoint script skipped. Fixed: `config.js` is now written by an inline `entrypoint:` command in docker-compose, no file needed. |
| Website loads but API calls fail ("Failed to fetch") | `config.js` cached by NPM or browser with old `localhost` URL. The `?v=timestamp` cache-buster on the `<script>` tag prevents this. Hard-refresh or check NPM "Cache Assets" toggle. |
| Git pull fails with "Your local changes would be overwritten" | `.env` was modified on the NAS. Use `git checkout -- .env && git pull`, then re-apply the two URL lines with `sed`. |
| `calculate_price` returns PGRST202 "Could not find the function" | Migration `08-backend-pricing-and-security.sql` not applied. Check `post-init` logs or run it manually. |
| Hero image not changing after admin upload | Browser/CDN caching the old image. The URL includes a unique path (`site/hero.ext`) with upsert — hard-refresh the public page. |
| `create_lead` fails with "function does not exist" | Same as calculate_price — migration 08 not applied. |
---
@@ -119,22 +131,29 @@ Password min length is enforced server-side by `GOTRUE_PASSWORD_MIN_LENGTH=10`.
```bash
docker compose ps # all services up (post-init exited 0)
curl http://localhost:55521/rest/v1/vehicles?select=brand -H "apikey: $ANON" # 6 demo cars
curl -X POST http://localhost:55521/rest/v1/rpc/calculate_price \
-H "apikey: $ANON" -H "Content-Type: application/json" \
-d '{"p_vehicle_id":"<uuid>","p_date_from":"2025-01-06","p_date_to":"2025-01-10"}' # pricing JSON
curl http://localhost:55521/rest/v1/site_settings?select=value&key=eq.hero_image_url \
-H "apikey: $ANON" # returns hero image URL
```
In the browser:
1. Open http://\<host\>:55580, submit the booking form.
2. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
3. Rotate the password when prompted.
4. The lead you submitted appears in "Aktive Leads" — in real time.
5. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
6. Open Supabase Studio at http://\<host\>:55530 and confirm the `customers.lead_id` FK matches.
1. Open http://\<host\>:55580, verify the hero image loads (dynamic from settings).
2. Submit the booking form — sidebar shows server-computed pricing.
3. Open http://\<host\>:55580/admin.html, log in with `.env` bootstrap creds.
4. Rotate the password when prompted.
5. The lead you submitted appears in "Aktive Leads" — in real time, with pricing columns.
6. Click **Qualifizieren**. Row disappears from active, a new row appears in **Kunden** with the `lead_id` displayed.
7. Go to **Einstellungen** tab. Upload a new hero image. Verify it appears on the public site after refresh.
8. Open Supabase Studio at http://\<host\>:55530 and confirm `site_settings`, `leads` pricing columns, `customers.lead_id` FK.
---
## 10. Things explicitly NOT in this stack
- No n8n. Email automation is out of scope here; do it downstream.
- No Google Analytics / Google Reviews widget.
- No "Anmelden/Registrieren" on the public site. Admin access is intentionally unlinked.
- No logflare / analytics.
- No edge-functions container — RPCs live in Postgres.
- No client-side price calculation — all pricing is server-side via `calculate_price` RPC.
+134 -25
View File
@@ -58,14 +58,17 @@ No auth header except the anon `apikey`. Kong strips the key and forwards with t
### 2.2 Public visitor submits booking
```
browser → supabase.from('leads').insert({...})
→ :55521/rest/v1/leads (POST, apikey=anon)
→ PostgREST → postgres
RLS "leads_anon_insert": insert allowed, select denied
← 201 (no body returned to anon)
browser → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
:55521/rest/v1/rpc/create_lead (POST, apikey=anon)
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price() → computes pricing
→ INSERT INTO leads (with pricing columns)
← lead uuid
```
The frontend never reads leads back. If the INSERT fails (validation, RLS), the UI shows a localized failure string.
No price parameters are accepted from the client — all pricing is computed server-side. This prevents price-tampering. The frontend never reads leads back. If the RPC fails (validation, RLS), the UI shows a localized failure string.
### 2.3 Admin login
@@ -114,6 +117,53 @@ admin.js → UPDATE vehicles SET photo_url = <public url>
Bucket is public-read, write-authenticated, MIME-restricted to `image/*`, 50 MB cap.
### 2.6 Server-side price calculation
```
app.js → supabase.rpc('calculate_price', {p_vehicle_id, p_date_from, p_date_to})
→ :55521/rest/v1/rpc/calculate_price (POST, apikey=anon)
→ PostgREST → postgres.public.calculate_price(uuid, date, date)
← jsonb {daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur,
deposit_eur, total_days, weekday_count, weekend_day_count}
```
Pricing logic lives **only** in the database — the frontend never computes prices. `create_lead` also calls `calculate_price` internally to store an immutable pricing snapshot on the lead row.
### 2.7 Lead submission (server-computed prices)
```
app.js → supabase.rpc('create_lead', {p_name, p_email, p_phone, p_vehicle_id,
p_vehicle_label, p_date_from, p_date_to, p_message, p_source,
p_ip_address, p_ip_country})
→ PostgREST → postgres.public.create_lead(...)
→ internally calls calculate_price → stores pricing on lead row
← lead uuid
```
No price parameters are accepted from the client. This prevents price-tampering attacks.
### 2.8 Site settings & configurable hero image
```
app.js → supabase.from('site_settings').select('value').eq('key','hero_image_url').single()
→ :55521/rest/v1/site_settings?key=eq.hero_image_url&select=value
→ RLS: anon SELECT allowed
← { value: '/images/ferrari-main-car.png' }
→ app.js sets CSS variable --hero-bg on .hero element
```
Admin upload flow:
```
admin.js → supabase.storage.from('vehicle-photos').upload('site/hero.ext', file, {upsert:true})
→ storage API writes to bucket
← public URL
admin.js → supabase.from('site_settings').upsert({key:'hero_image_url', value: publicUrl})
→ PostgREST → UPDATE site_settings
← 200
```
The CSS uses `var(--hero-bg, url('images/ferrari-main-car.png'))` so the default hero image is shown until JavaScript loads and overrides it.
---
## 3. Database layout
@@ -124,13 +174,26 @@ postgres (database = "postgres")
├── storage schema (Storage API)
├── _realtime schema (Realtime bookkeeping)
└── public schema
├── vehicles (fleet)
├── leads (form submissions)
├── customers (spawned from qualified leads)
├── tg_touch_updated_at() trigger fn
├── qualify_lead() RPC
├── disqualify_lead() RPC
── reopen_lead() RPC
├── vehicles (fleet)
├── leads (form submissions, server-computed pricing)
├── lead_attachments (id_document, income_proof per lead)
├── customers (spawned from qualified leads)
├── customer_attachments (admin-uploaded docs for customers)
├── sales_orders (rental orders linked to customer+lead)
── sales_order_attachments
├── site_settings (key-value config, e.g. hero_image_url)
├── tg_touch_updated_at() trigger fn
├── calculate_price() RPC (public, read-only pricing)
├── create_lead() RPC (server-side price computation)
├── qualify_lead() RPC
├── disqualify_lead() RPC
├── reopen_lead() RPC
├── sales_order_toggle_kaution() RPC
├── sales_order_toggle_rental() RPC
├── sales_order_toggle_complete() RPC
├── customer_update_private_notes() RPC
├── sales_order_update_private_notes() RPC
└── sales_order_upload_attachment() RPC
```
### 3.1 `public.vehicles`
@@ -148,10 +211,12 @@ postgres (database = "postgres")
- `vehicle_id → vehicles(id) ON DELETE SET NULL`
- `vehicle_label text` — denormalized at submit; survives vehicle deletion
- `date_from`, `date_to`, `message`
- Pricing (server-computed, immutable): `daily_subtotal`, `weekend_subtotal`, `subtotal_eur`, `vat_eur`, `total_eur`, `deposit_eur`, `total_days`, `weekday_count`, `weekend_day_count`
- `status check in ('new','qualified','disqualified')` default `new`
- `is_active bool` default `true` — filter for Active/Inactive tabs
- `admin_notes text`
- `source text` default `'website'`
- `ip_address inet`, `ip_country text` — geolocation info from submission
- `qualified_at`, `qualified_by → auth.users(id)`
### 3.3 `public.customers`
@@ -159,25 +224,65 @@ postgres (database = "postgres")
- `lead_id → leads(id) ON DELETE RESTRICT` + `unique index` (exactly one customer per lead)
- `name`, `email`, `phone`
- `first_contacted_at`
- `notes`
- `notes`, `private_notes`
- `status check in ('active','inactive')`
- `created_by → auth.users(id)`
### 3.4 RLS matrix
### 3.4 `public.lead_attachments`
- `id uuid pk`
- `lead_id → leads(id) ON DELETE CASCADE`
- `bucket`, `file_path`, `file_name`, `mime_type`
- `kind check in ('id_document', 'income_proof', 'other')`
- Unique partial index: max 1 `id_document` + 1 `income_proof` per lead
| Table | anon | authenticated |
| ---------- | -------- | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| customers | denied | full CRUD |
### 3.5 `public.sales_orders`
- `id uuid pk`
- `customer_id → customers(id)`, `lead_id → leads(id)`
- `order_number`, `private_notes`
- `kaution_paid`, `rental_paid`, `rental_complete` (booleans + timestamps)
- Pricing snapshot: `daily_subtotal`, `weekend_subtotal`, `subtotal_eur`, `vat_eur`, `total_eur`, `deposit_eur`
- `total_days`, `weekday_count`, `weekend_day_count`, `date_from`, `date_to`, `vehicle_label`
### 3.5 RPCs
### 3.6 `public.site_settings`
- `key text pk` — setting identifier (e.g. `hero_image_url`)
- `value text` — setting value (e.g. a public URL or path)
- `updated_at timestamptz`
- RLS: public SELECT for all, authenticated-only INSERT/UPDATE/DELETE
### 3.7 RLS matrix
| Table | anon | authenticated |
| ------------------ | ------------------ | -------------------- |
| vehicles | SELECT where `is_active=true` | full CRUD |
| leads | INSERT only | full CRUD |
| lead_attachments | INSERT only | SELECT + INSERT |
| customers | denied | full CRUD |
| sales_orders | denied | full CRUD |
| site_settings | SELECT only | full CRUD |
### 3.8 Storage buckets
| Bucket | Public read | anon writes | authenticated writes | Notes |
| ------------------- | ----------- | ----------- | -------------------- | ----- |
| `vehicle-photos` | yes | no | INSERT (MIME `image/*`, 50 MB) | Fleet photos + site hero image |
| `customer-documents`| no | INSERT only | SELECT + INSERT | ID docs, income proofs |
Anon users may only `INSERT` into `customer-documents` (no SELECT/UPDATE/DELETE). Admin can SELECT + INSERT but not DELETE (prevents accidental evidence destruction).
### 3.9 RPCs
| Function | Role required | Semantics |
| --------------------------------- | --------------- | ------------------------------------------------------------------------- |
| `qualify_lead(uuid, text)` | authenticated | Locks lead row, flips to `qualified`+inactive, inserts matching customer. Idempotent: returns existing customer on second call. |
| `calculate_price(uuid, date, date)` | anon | Read-only pricing calculator. Returns jsonb: `{daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count}`. Uses vehicle's `daily_price_eur`, applies 20% weekend surcharge, 19% VAT, 3× daily deposit. |
| `create_lead(...)` | anon | Creates a lead with server-computed pricing (calls `calculate_price` internally). Accepts: name, email, phone, vehicle_id, vehicle_label, date_from, date_to, message, source, ip_address, ip_country. No price params from client. |
| `qualify_lead(uuid, text)` | authenticated | Locks lead row, flips to `qualified`+inactive, inserts matching customer + sales_order with pricing snapshot. Idempotent: returns existing customer on second call. |
| `disqualify_lead(uuid, text)` | authenticated | Marks lead `disqualified`+inactive, stores notes. |
| `reopen_lead(uuid)` | authenticated | Resets lead to `new`+active, deletes the spawned customer if any. |
| `sales_order_toggle_kaution(uuid)` | authenticated | Toggles kaution_paid boolean + timestamp on a sales order. |
| `sales_order_toggle_rental(uuid)` | authenticated | Toggles rental_paid boolean + timestamp. |
| `sales_order_toggle_complete(uuid)` | authenticated | Toggles rental_complete boolean + timestamp. |
| `customer_update_private_notes(uuid, text)` | authenticated | Updates private_notes field on a customer. |
| `sales_order_update_private_notes(uuid, text)` | authenticated | Updates private_notes field on a sales order. |
All three are `SECURITY INVOKER`, so `auth.uid()` is the live admin and RLS applies.
@@ -214,9 +319,9 @@ Host port mapping: `55521:8000` (8000 blocked by Docker Desktop's wslrelay on Wi
frontend/
├── nginx.conf (serves /, gzip, cache headers)
├── index.html app.js (public site, anon key, persistSession:false)
├── admin.html admin.js (admin CRM, persistSession:true)
├── admin.html admin.js (admin CRM + settings, persistSession:true)
├── i18n.js (DE/EN)
├── styles.css (copper/charcoal palette)
├── styles.css (copper/charcoal palette, CSS-variable hero image)
├── impressum.html
└── datenschutz.html
```
@@ -227,7 +332,9 @@ frontend/
- The `web` service uses `nginx:1.27-alpine` directly with bind-mounted files (no `build:` step). This is Portainer-compatible: updating the frontend is `git pull` + `docker compose up -d --force-recreate web`.
- `admin.js` is ES modules, imports `@supabase/supabase-js` from `esm.sh` (CDN, pinned version). One Supabase client per page.
- State lives in a single `state` object. Admin re-renders the active tab on realtime events.
- Admin tabs: Aktive Leads, Inaktive Leads, Kunden, Fahrzeuge, **Einstellungen** (site settings: hero image upload).
- Force-password-change modal is the only state that can preempt the rest of the admin UI after login.
- Public site hero image is loaded dynamically from `site_settings` table at boot and applied via CSS variable `--hero-bg`. Fallback default is baked into `styles.css`.
---
@@ -334,4 +441,6 @@ For real deployments:
- **Bootstrap password is forced out** on first login (`must_change_password` metadata). Frontend refuses to reuse the bootstrap value.
- **RLS is the single source of authorization.** The admin UI calling `.from('leads').select('*')` works only because the authenticated policy allows it; without a valid Bearer JWT the same query returns zero rows.
- **RPCs are `SECURITY INVOKER`** — no role-escalation surface.
- **Storage bucket** is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.
- **Server-side pricing** — `create_lead` computes prices internally via `calculate_price`. No price parameters are accepted from the client, preventing price-tampering.
- **Document security** — `customer-documents` bucket allows anon INSERT only (upload), no SELECT/DELETE. Admin can SELECT + INSERT but not DELETE. Unique partial indexes enforce max 1 `id_document` + 1 `income_proof` per lead.
- **Storage bucket** (`vehicle-photos`) is public-read (photos must render on the public site) but writes need authenticated, MIME-restricted, size-limited.
+137
View File
@@ -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 ✓
+152
View File
@@ -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/
+88 -17
View File
@@ -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 3060 s to settle.
First boot pulls ~1.5 GB of images and runs migrations (`01-init.sql`, `post-boot.sql`, `02-leads.sql`, `08-backend-pricing-and-security.sql`, `09-site-settings.sql`). Give it 3060 s to settle.
### Stop / reset
@@ -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
View File
@@ -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
+243
View File
@@ -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)
+107
View File
@@ -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
View File
@@ -1 +0,0 @@
# Bind-mounted service data lives here (db, storage, n8n). Keep tree, ignore contents.
+31
View File
@@ -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'"
+22
View File
@@ -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
+125
View File
@@ -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" } }
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+653 -95
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -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>
+105 -70
View File
@@ -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,23 +199,24 @@ 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>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
<div><strong>${v.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
<div><strong>${v.included_km_per_day || 150}</strong><span>${t("bpfInclKmPerDay")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
</div>
@@ -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);
bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} ×${v.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} ×${v.weekend_price_eur || v.daily_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(v.photo_url)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
}
if (totalDays > 2) {
// Individuell mode: show info banner instead of pricing
bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-info-banner">
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
<p>${t("bpfIndividuellDesc")}</p>
</div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
} else {
bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} ×${price.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} ×${price.weekend_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
}
}
bpfCar.addEventListener("change", updateSidebar);
bpfFrom.addEventListener("change", updateSidebar);
@@ -411,40 +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}')`);
}
})();
+105 -6
View File
@@ -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>
<h1>Datenschutz</h1>
<p>Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden über ein selbstgehostetes Supabase verwaltet.</p>
<p>Ansprechpartner: hello@mccars.at</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<img class="logo-icon" src="/images/mc-cars-logo.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>
</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>
+77 -9
View File
@@ -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",
@@ -211,9 +234,20 @@ export const translations = {
adminFirstContacted: "Erster Kontakt",
adminFirstContactedEn: "First contacted",
adminNote: "Notiz",
adminNoteEn: "Note",
adminNoteEn: "Note",
adminSave: "Speichern",
adminSaveEn: "Save",
adminPricePerKm: "Preis extra km (€)",
adminRentalType: "Miettyp",
rentalTypeWeekend: "Wochenende",
rentalTypeIndividuell: "Individuell",
adminSortOrder: "Ordnung",
adminEmailSent: "E-Mail gesendet",
sendEmailButton: "E-Mail senden",
emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...",
emailAlreadySent: "Bereits gesendet",
bpfIndividuellTitle: "Individuelle Mietdauer",
bpfIndividuellDesc: "Bei Mietdauer über 2 Tagen erstellen wir ein persönliches Angebot. Wir prüfen Verfügbarkeit und melden uns in Kürze per E-Mail bei Ihnen.",
},
en: {
navCars: "Fleet",
@@ -303,7 +337,8 @@ export const translations = {
bpfDailyRate: "Daily rate",
bpfWeekendRate: "Weekend rate",
bpfWeekendDef: "Weekend: Saturday 9 AM Sunday 8 PM",
bpfMaxKm: "Max. km/day",
bpfMaxKm: "Max. km/day",
bpfInclKmPerDay: "Included km/day",
bpfExtraKm: "Extra km",
bpfPriceOverview: "Price overview",
bpfSelectForPrice: "Select vehicle and date for a price overview",
@@ -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.",
},
};
+851
View File
@@ -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

+108 -9
View File
@@ -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>
<h1>Impressum</h1>
<p>MC Cars (GmbH)</p>
<p>Standort: Steiermark (TBD)</p>
<p>E-Mail: hello@mccars.at</p>
<p>Telefon: +43 316 880000</p>
<p>Firmenbuch und UID werden nachgereicht.</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<img class="logo-icon" src="/images/mc-cars-logo.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>
<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>
</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>
+112 -48
View File
@@ -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>
@@ -190,20 +251,20 @@
</div>
<!-- Day mode: single date picker -->
<div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
<div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
<label data-i18n="bpfPickDate">Datum wählen</label>
<input type="date" id="bpfDayDate" />
</div>
<!-- Weekend mode: pick the Saturday -->
<div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display:none;">
<div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display: none;">
<label data-i18n="bpfPickWeekend">Wochenende wählen (Samstag)</label>
<input type="date" id="bpfWeekendDate" />
<p class="bpf-weekend-def" data-i18n="bpfWeekendDef">Wochenende: Samstag 9:00 Sonntag 20:00</p>
</div>
<!-- Custom mode: from/to date pickers -->
<div class="bpf-date-custom" id="bpfDateCustom">
<div class="bpf-date-custom" id="bpfDateCustom" style="display:none;">
<div class="bpf-field-row">
<div class="bpf-field">
<label data-i18n="bpfStartDate">Startdatum</label>
@@ -307,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>
<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>
+128
View File
@@ -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>
+26
View File
@@ -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
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
User-agent: *
Disallow: /admin.html
Disallow: /admin/
Disallow: /config.js
Sitemap: https://www.mc-cars.at/sitemap.xml
+49
View File
@@ -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
View File
@@ -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;
+103
View File
@@ -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
+291
View File
@@ -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
+94
View File
@@ -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`.
+13
View File
@@ -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
+419
View File
@@ -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';
+32
View File
@@ -0,0 +1,32 @@
-- 09-site-settings.sql
-- Site-wide settings (key-value). Used for configurable assets like hero image.
create table if not exists public.site_settings (
key text primary key,
value text not null default '',
updated_at timestamptz not null default now()
);
alter table public.site_settings enable row level security;
-- Anyone can read settings (public site needs hero image URL)
drop policy if exists "settings_public_read" on public.site_settings;
create policy "settings_public_read"
on public.site_settings for select
using (true);
-- Only authenticated admins can modify
drop policy if exists "settings_admin_all" on public.site_settings;
create policy "settings_admin_all"
on public.site_settings for all to authenticated
using (true) with check (true);
grant select on public.site_settings to anon, authenticated, service_role;
grant all on public.site_settings to authenticated, service_role;
-- Seed default hero image
insert into public.site_settings (key, value)
values ('hero_image_url', '/images/ferrari-main-car.png')
on conflict (key) do nothing;
notify pgrst, 'reload schema';
@@ -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';
+13 -6
View File
@@ -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');
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
+131
View File
@@ -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 |