Compare commits

...

37 Commits

Author SHA1 Message Date
Lago fc902768a1 fix: default to singular Fahrzeug, plural only when >1 2026-05-31 14:25:35 +02:00
Lago 94eac68da4 Merge pull request 'fix: singular Fahrzeug when only 1 car available' (#5) from hotfix/singular-fahrzeug into main 2026-05-31 14:18:34 +02:00
Lago 557528d85a fix: use singular 'Fahrzeug' when only 1 car available 2026-05-31 14:18:27 +02:00
Lago 2f90534877 Merge pull request 'Chore/marco changes' (#3) from chore/marco-changes into main
Reviewed-on: #3
2026-05-31 12:13:55 +02:00
Lago 28db852453 test: add end-to-end booking flow with admin lead disqualification 2026-05-31 11:29:59 +02:00
Lago 331d0557b0 feat: complete Datenschutzerklärung
- Full rewrite from stub to legally compliant privacy policy
- Covers: Server-Logfiles, Buchungsanfragen, Identitätsdokumente, Cookies
- Legal basis: Art. 6 DSGVO (lit. b, c, f)
- Self-hosted infrastructure — no third-party data sharing
- User rights: Art. 15-20, 77 DSGVO (Auskunft, Berichtigung, Löschung, etc.)
- Aufsichtsbehörde: Österreichische Datenschutzbehörde
- No analytics, no Google services, no cloud providers
2026-05-31 11:08:19 +02:00
Lago 287629878b feat: redesign Impressum with Datenschutz section
- Restructure Impressum to match Austrian legal standard
- Add Datenschutz Kurzfassung section (DSGVO, data collection, rights)
- Clean layout: company info → Datenschutz
- No EUID, no personal data (GDPR-safe)
2026-05-31 10:49:51 +02:00
Lago cec51d6c19 feat: update Impressum with correct company data + fix email domain
- Impressum: MC Cars GmbH, Gaisfeld 1/2, 8564 Krottendorf-Gaisfeld
- FN 675751 b, Landesgericht für Zivilrechtssachen Graz
- Geschäftsführer: Christian Leski, Marco Schober
- UID placeholder (wird nachgereicht)
- Email domain: mccars.at → mc-cars.at across all pages
- Social links: mccars → mc-cars
2026-05-31 10:43:47 +02:00
Lago 9bc08d994c feat: unify admin photo gallery with drag-and-drop + arrows
- Merge separate 'Hauptfoto' and 'Weitere Fotos' uploads into single upload zone
- Add drag-and-drop support for photo reorder
- Add ← → arrow buttons for photo reorder
- Increase thumbnail size to 200px
- Show Hauptfoto badge, ★ set primary, × delete on each thumbnail
- Auto-promote next photo when primary is deleted
- Keep vehicle.photo_url synced with primary photo
2026-05-31 10:29:07 +02:00
Lago 8be7d5aad2 feat: implement Marco's customer changes
- Remove 'Flotte ansehen' button from hero section
- Remove '24/7 Support' stat from hero section
- Remove 'Unsere Flotte' eyebrow from fleet section
- Remove ALL 'Warum wir' / 'Why us' references from nav links, i18n keys, and legal pages
- Update reviews: Ferrari references only (removed GT3 mentions)
- Update Impressum with correct company data (MC Cars GmbH)
- Add multi-photo gallery: DB migration (17-vehicle-photos.sql), admin UI for photo management, frontend carousel on cards and dialog
- Update SEO: Ferrari-focused meta tags, title, keywords, JSON-LD
- Clean up dead i18n keys (viewFleet, statSupport, fleetEyebrow, navWhy, why* keys)
- Fix legal page issues: add config.js script, fix logo references to SVG
- Add Playwright E2E tests (26/26 passing)
- Update footer tagline across all pages
2026-05-31 09:53:23 +02:00
Lago e1f6bd56b0 feat: add SQL migration to fix misclassified long rentals and update docker-compose configurations 2026-05-17 23:19:36 +02:00
Lago e4bdd85518 feat: update docker-compose to include N8N webhook URL and remove obsolete SQL migrations 2026-05-17 23:12:24 +02:00
Lago b4c6a47ce8 Merge pull request 'feat: configure n8n webhooks and update related documentation' (#2) from dev into main
Reviewed-on: #2
2026-05-17 23:05:41 +02:00
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 75b338988d Merge pull request 'feat: Add manual email sending workflow and related database changes' (#1) from dev into main
Reviewed-on: #1
2026-05-17 22:45:47 +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
46 changed files with 4873 additions and 437 deletions
+26 -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
@@ -56,3 +66,14 @@ FILE_SIZE_LIMIT=52428800
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
+3
View File
@@ -20,3 +20,6 @@ docker-compose.override.yml
# Generated at container start by 99-config.sh — never commit
frontend/config.js
.playwright-mcp
node_modules/
+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/
+41 -9
View File
@@ -16,12 +16,15 @@ 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
@@ -89,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.
@@ -116,24 +121,51 @@ The admin is seeded with `must_change_password = true` in `raw_user_meta_data`.
- 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/`:
-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'"
+11
View File
@@ -25,6 +25,13 @@ services:
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
- ./supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
- ./supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
- ./supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
- ./supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
- ./supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
- ./supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro
- ./supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro
kong:
volumes:
@@ -41,5 +48,9 @@ services:
- ./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
+43 -4
View File
@@ -218,6 +218,13 @@ services:
- /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro
entrypoint: ["sh","-c"]
command:
- |
@@ -244,6 +251,13 @@ services:
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/11-consolidate-km-rental.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/12-email-sent-and-more.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/13-rental-type-daily-and-email-guard.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/14-email-requested-trigger.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/15-individuell-vat-subtotal-fix.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/16-rental-type-weekend-gap-fix.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/17-vehicle-photos.sql
echo "post-init done."
restart: "no"
networks: [mccars]
@@ -339,10 +353,11 @@ services:
environment:
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
volumes:
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
- /mnt/user/appdata/mc-cars/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\",N8N_WEBHOOK_URL:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" \"$$N8N_WEBHOOK_URL\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
ports:
- "55580:80"
networks: [mccars]
@@ -360,10 +375,11 @@ services:
environment:
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
volumes:
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
- /mnt/user/appdata/mc-cars/frontend/nginx-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;'"]
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\",N8N_WEBHOOK_URL:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" \"$$N8N_WEBHOOK_URL\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
ports:
- "55581:80"
networks: [mccars]
@@ -389,6 +405,8 @@ services:
N8N_PROTOCOL: http
WEBHOOK_URL: http://localhost:55590/
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "false"
N8N_SECURE_COOKIE: "false"
# Database (n8n stores its own data in the same Postgres)
DB_TYPE: postgresdb
@@ -407,10 +425,31 @@ services:
GENERIC_TIMEZONE: Europe/Vienna
TZ: Europe/Vienna
# Allow importing workflows from filesystem
N8N_USER_FOLDER: /home/node/.n8n
# 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]
+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
+22 -13
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin · 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="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>
@@ -17,7 +17,7 @@
<!-- 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">
@@ -40,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;">
@@ -101,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>
@@ -146,10 +147,12 @@
<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>
@@ -167,13 +170,18 @@
<form class="admin-form" id="vehicleForm">
<input type="hidden" name="vid" />
<div class="admin-photo-preview" id="photoPreview"></div>
<label>
<span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<input type="file" id="photoInput" accept="image/*" />
</label>
<div class="admin-photo-upload-zone" id="photoUploadZone">
<div class="admin-photo-upload-content">
<span class="admin-photo-upload-icon">📷</span>
<span>Fotos hochladen (JPG/PNG/WebP, max 50 MB)</span>
<span class="muted" style="font-size:0.85rem;">Klicken oder Dateien hierher ziehen · Mehrfachauswahl möglich</span>
</div>
<input type="file" id="photoInput" accept="image/*" multiple />
</div>
<input type="hidden" name="photo_url" />
<div class="admin-photo-gallery" id="extraPhotoGallery"></div>
<div class="row2">
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
@@ -192,13 +200,13 @@
</div>
<div class="row3">
<label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label>
<label><span>Inkl. km/Tag</span><input type="number" name="included_km_per_day" min="0" value="150" /></label>
<label><span data-i18n="adminPricePerKm">Preis extra km (€)</span><input type="number" name="price_per_km_eur" step="0.01" min="0" value="1.50" /></label>
<label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label>
<label><span data-i18n="adminMaxKmWeekend">Max. km/Wochenendtag</span><input type="number" name="max_km_weekend" min="0" placeholder="wie km/Tag" /></label>
</div>
<div class="row2">
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
<label><span data-i18n="adminSortOrder">Ordnung</span><input type="number" name="sort_order" value="100" /></label>
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
</div>
@@ -278,6 +286,7 @@
<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>
@@ -304,6 +313,6 @@
<div class="dialog-footer" id="customerDialogFooter"></div>
</dialog>
<script type="module" src="admin.js"></script>
<script type="module" src="admin.js?v=3"></script>
</body>
</html>
+543 -38
View File
@@ -52,7 +52,8 @@ const formTitle = document.querySelector("#formTitle");
const saveBtn = document.querySelector("#saveBtn");
const resetBtn = document.querySelector("#resetBtn");
const photoInput = document.querySelector("#photoInput");
const photoPreview = document.querySelector("#photoPreview");
const photoUploadZone = document.querySelector("#photoUploadZone");
const extraPhotoGallery = document.querySelector("#extraPhotoGallery");
const tableBody = document.querySelector("#adminTable tbody");
// ----- State -----
@@ -66,10 +67,60 @@ const state = {
vehicles: [],
vehicleMap: new Map(),
currentPhotoPath: null,
vehiclePhotos: [],
realtimeChannel: null,
forcedRotation: false,
};
function notify(message, duration = 3000) {
if (typeof window.showToast === "function") {
window.showToast(message, duration);
return;
}
showAdminPopup(message, duration);
}
function showAdminPopup(message, duration = 3000) {
const host = document.querySelector("dialog[open]") || document.body;
let popup = host.querySelector("[data-admin-notify-popup]");
if (!popup) {
popup = document.createElement("div");
popup.setAttribute("data-admin-notify-popup", "1");
popup.setAttribute("role", "status");
popup.setAttribute("aria-live", "polite");
popup.style.position = host.tagName === "DIALOG" ? "absolute" : "fixed";
popup.style.left = "50%";
popup.style.top = "50%";
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
popup.style.minWidth = "320px";
popup.style.maxWidth = "min(92vw, 560px)";
popup.style.padding = "1rem 1.2rem";
popup.style.borderRadius = "12px";
popup.style.border = "1px solid var(--line)";
popup.style.background = "var(--bg-card)";
popup.style.color = "var(--text)";
popup.style.boxShadow = "0 16px 40px rgba(0,0,0,0.35)";
popup.style.textAlign = "center";
popup.style.fontSize = "1rem";
popup.style.fontWeight = "600";
popup.style.opacity = "0";
popup.style.zIndex = "3000";
popup.style.pointerEvents = "none";
popup.style.transition = "opacity 0.18s ease, transform 0.18s ease";
host.appendChild(popup);
}
popup.textContent = message;
popup.style.opacity = "1";
popup.style.transform = "translate(-50%, -50%) scale(1)";
clearTimeout(popup._hideTimer);
popup._hideTimer = setTimeout(() => {
popup.style.opacity = "0";
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
}, duration);
}
// =========================================================================
// AUTH FLOW
// =========================================================================
@@ -262,9 +313,9 @@ function loadForEdit(id) {
vehicleForm.seats.value = v.seats;
vehicleForm.daily_price_eur.value = v.daily_price_eur;
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
vehicleForm.max_daily_km.value = v.max_daily_km || 150;
vehicleForm.included_km_per_day.value = v.included_km_per_day || 150;
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
vehicleForm.max_km_weekend.value = v.max_km_weekend || '';
vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50;
vehicleForm.sort_order.value = v.sort_order;
vehicleForm.location.value = v.location;
vehicleForm.description_de.value = v.description_de;
@@ -272,7 +323,7 @@ function loadForEdit(id) {
vehicleForm.photo_url.value = v.photo_url;
vehicleForm.is_active.checked = v.is_active;
state.currentPhotoPath = v.photo_path || null;
updatePreview(v.photo_url);
loadVehiclePhotos(v.id);
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -283,12 +334,12 @@ resetBtn.addEventListener("click", () => {
vehicleForm.sort_order.value = 100;
vehicleForm.location.value = "Steiermark (TBD)";
vehicleForm.seats.value = 2;
vehicleForm.max_daily_km.value = 150;
vehicleForm.included_km_per_day.value = 150;
vehicleForm.weekend_price_eur.value = 0;
vehicleForm.kaution_eur.value = 5000;
vehicleForm.max_km_weekend.value = '';
vehicleForm.price_per_km_eur.value = 1.50;
state.currentPhotoPath = null;
updatePreview("");
state.vehiclePhotos = [];
formTitle.textContent = "Neues Fahrzeug";
formFeedback.textContent = "";
});
@@ -309,9 +360,9 @@ vehicleForm.addEventListener("submit", async (e) => {
seats: +fd.get("seats") || 2,
daily_price_eur: +fd.get("daily_price_eur") || 0,
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
max_daily_km: +fd.get("max_daily_km") || 150,
included_km_per_day: +fd.get("included_km_per_day") || 150,
kaution_eur: +fd.get("kaution_eur") || 5000,
max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null,
price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50,
sort_order: +fd.get("sort_order") || 100,
location: fd.get("location") || "Steiermark (TBD)",
description_de: fd.get("description_de") || "",
@@ -341,41 +392,275 @@ async function deleteVehicle(id) {
const v = state.vehicleMap.get(id);
if (!v) return;
if (!confirm(`Delete ${v.brand} ${v.model}?`)) return;
// Delete old main photo
if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]);
// Delete gallery photos from storage
const { data: photos } = await supabase.from("vehicle_photos").select("photo_path").eq("vehicle_id", id);
if (photos?.length) {
await supabase.storage.from("vehicle-photos").remove(photos.map(p => p.photo_path));
}
const { error } = await supabase.from("vehicles").delete().eq("id", id);
if (error) { alert(error.message); return; }
await loadVehicles();
renderVehicles();
}
// Photo upload
photoInput.addEventListener("change", async () => {
const file = photoInput.files?.[0];
if (!file) return;
formFeedback.className = "form-feedback";
formFeedback.textContent = "Uploading photo...";
try {
// Delete old photo if exists
if (state.currentPhotoPath) {
await supabase.storage.from("vehicle-photos").remove([state.currentPhotoPath]);
// ----- Unified Photo Upload + Gallery -----
async function loadVehiclePhotos(vehicleId) {
if (!vehicleId) {
state.vehiclePhotos = [];
renderExtraPhotoGallery();
return;
}
const { data, error } = await supabase
.from("vehicle_photos")
.select("*")
.eq("vehicle_id", vehicleId)
.order("display_order", { ascending: true });
if (error) { console.error("Failed to load vehicle photos:", error); return; }
state.vehiclePhotos = data || [];
renderExtraPhotoGallery();
}
function renderExtraPhotoGallery() {
if (!extraPhotoGallery) return;
extraPhotoGallery.innerHTML = "";
const photos = state.vehiclePhotos;
if (!photos.length) return;
for (let i = 0; i < photos.length; i++) {
const ph = photos[i];
const card = document.createElement("div");
card.className = "admin-photo-card";
card.draggable = true;
card.dataset.photoId = ph.id;
card.dataset.photoIdx = i;
card.innerHTML = `
<img src="${attr(ph.photo_url)}" alt="Foto ${i + 1}" />
<div class="admin-photo-card-arrows">
<button type="button" class="admin-photo-arrow" data-move-dir="-1" ${i === 0 ? 'disabled style="opacity:0.3;pointer-events:none;"' : ''} aria-label="Nach links verschieben"></button>
<button type="button" class="admin-photo-arrow" data-move-dir="1" ${i === photos.length - 1 ? 'disabled style="opacity:0.3;pointer-events:none;"' : ''} aria-label="Nach rechts verschieben"></button>
</div>
<div class="admin-photo-card-actions">
${!ph.is_primary ? `<button type="button" class="admin-photo-set-primary" data-photo-id="${ph.id}" aria-label="Als Hauptfoto setzen">★</button>` : ''}
<button type="button" class="admin-photo-delete" data-photo-id="${ph.id}" aria-label="Foto löschen">×</button>
</div>
${ph.is_primary ? '<span class="admin-photo-badge">Hauptfoto</span>' : ''}
<span class="admin-photo-drag-handle" aria-hidden="true">⠿</span>
`;
extraPhotoGallery.appendChild(card);
}
// Action buttons
extraPhotoGallery.querySelectorAll(".admin-photo-delete").forEach(btn => {
btn.addEventListener("click", async () => {
await deleteVehiclePhoto(btn.dataset.photoId);
});
});
extraPhotoGallery.querySelectorAll(".admin-photo-set-primary").forEach(btn => {
btn.addEventListener("click", async () => {
await setPrimaryPhoto(btn.dataset.photoId);
});
});
extraPhotoGallery.querySelectorAll(".admin-photo-arrow").forEach(btn => {
btn.addEventListener("click", async () => {
const card = btn.closest(".admin-photo-card");
const idx = +card.dataset.photoIdx;
const dir = +btn.dataset.moveDir;
await reorderPhoto(idx, dir);
});
});
// Drag and drop
extraPhotoGallery.querySelectorAll(".admin-photo-card").forEach(card => {
card.addEventListener("dragstart", handleDragStart);
card.addEventListener("dragover", handleDragOver);
card.addEventListener("dragenter", handleDragEnter);
card.addEventListener("dragleave", handleDragLeave);
card.addEventListener("drop", handleDrop);
card.addEventListener("dragend", handleDragEnd);
});
}
let draggedPhotoIdx = null;
function handleDragStart(e) {
draggedPhotoIdx = +this.dataset.photoIdx;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", this.dataset.photoId);
this.style.opacity = "0.4";
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
function handleDragEnter(e) {
e.preventDefault();
if (+this.dataset.photoIdx !== draggedPhotoIdx) {
this.classList.add("admin-photo-card-drag-over");
}
}
function handleDragLeave() {
this.classList.remove("admin-photo-card-drag-over");
}
function handleDrop(e) {
e.preventDefault();
this.classList.remove("admin-photo-card-drag-over");
const targetIdx = +this.dataset.photoIdx;
if (draggedPhotoIdx !== null && draggedPhotoIdx !== targetIdx) {
const dir = targetIdx > draggedPhotoIdx ? 1 : -1;
reorderPhoto(draggedPhotoIdx, dir, targetIdx);
}
}
function handleDragEnd() {
this.style.opacity = "1";
draggedPhotoIdx = null;
extraPhotoGallery?.querySelectorAll(".admin-photo-card").forEach(c => c.classList.remove("admin-photo-card-drag-over"));
}
async function reorderPhoto(fromIdx, dir, targetIdx) {
const photos = state.vehiclePhotos;
if (photos.length < 2) return;
let toIdx;
if (targetIdx !== undefined) {
toIdx = targetIdx;
} else {
toIdx = fromIdx + dir;
if (toIdx < 0 || toIdx >= photos.length) return;
}
// Swap in local array
[photos[fromIdx], photos[toIdx]] = [photos[toIdx], photos[fromIdx]];
// Build order payload
const orderPayload = photos.map((p, i) => ({ id: p.id, order: i }));
const vid = vehicleForm.vid?.value;
if (vid) {
try {
await supabase.rpc("reorder_vehicle_photos", {
p_vehicle_id: vid,
p_photo_orders: orderPayload,
});
} catch (err) {
console.error("Reorder failed:", err);
[photos[fromIdx], photos[toIdx]] = [photos[fromIdx], photos[toIdx]];
return;
}
}
renderExtraPhotoGallery();
}
async function deleteVehiclePhoto(photoId) {
const ph = state.vehiclePhotos.find(p => p.id === photoId);
if (!ph) return;
try {
if (ph.photo_path) {
await supabase.storage.from("vehicle-photos").remove([ph.photo_path]);
}
const { error } = await supabase.from("vehicle_photos").delete().eq("id", photoId);
if (error) throw error;
state.vehiclePhotos = state.vehiclePhotos.filter(p => p.id !== photoId);
// If deleted was primary, promote next photo
if (ph.is_primary && state.vehiclePhotos.length) {
const newPrimary = state.vehiclePhotos[0];
const vid = vehicleForm.vid?.value;
if (vid) {
await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: newPrimary.id });
vehicleForm.photo_url.value = newPrimary.photo_url;
}
}
renderExtraPhotoGallery();
} catch (err) {
console.error("Failed to delete photo:", err);
}
}
async function setPrimaryPhoto(photoId) {
const vid = vehicleForm.vid?.value;
if (!vid) return;
try {
const ph = state.vehiclePhotos.find(p => p.id === photoId);
await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: photoId });
vehicleForm.photo_url.value = ph?.photo_url || "";
await loadVehiclePhotos(vid);
} catch (err) {
console.error("Failed to set primary photo:", err);
}
}
// Unified photo upload handler
photoInput.addEventListener("change", async () => {
const files = photoInput.files;
if (!files.length) return;
const vid = vehicleForm.vid?.value;
if (!vid) {
formFeedback.className = "form-feedback error";
formFeedback.textContent = "Bitte zuerst das Fahrzeug speichern, dann Fotos hinzufügen.";
return;
}
formFeedback.className = "form-feedback";
formFeedback.textContent = `Uploading ${files.length} photo(s)...`;
let uploaded = 0;
for (const file of files) {
try {
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
const path = `${crypto.randomUUID()}.${ext}`;
const path = `${vid}/${crypto.randomUUID()}.${ext}`;
const { error: upErr } = await supabase.storage
.from("vehicle-photos")
.upload(path, file, { contentType: file.type, upsert: true });
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
state.currentPhotoPath = path;
const isFirst = state.vehiclePhotos.length === 0;
const maxOrder = state.vehiclePhotos.reduce((m, p) => Math.max(m, p.display_order), -1);
await supabase.from("vehicle_photos").insert({
vehicle_id: vid,
photo_url: pub.publicUrl,
photo_path: path,
display_order: maxOrder + 1,
is_primary: isFirst,
});
if (isFirst) {
vehicleForm.photo_url.value = pub.publicUrl;
updatePreview(pub.publicUrl);
formFeedback.textContent = "Upload ok.";
} catch (err) {
formFeedback.className = "form-feedback error";
formFeedback.textContent = err.message || String(err);
}
uploaded++;
} catch (err) {
console.error("Upload failed:", err);
}
}
await loadVehiclePhotos(vid);
formFeedback.textContent = `${uploaded} Foto(s) hochgeladen.`;
photoInput.value = "";
});
function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; }
// Drag-and-drop on upload zone
if (photoUploadZone) {
photoUploadZone.addEventListener("dragover", e => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
photoUploadZone.classList.add("drag-active");
});
photoUploadZone.addEventListener("dragleave", () => {
photoUploadZone.classList.remove("drag-active");
});
photoUploadZone.addEventListener("drop", e => {
e.preventDefault();
photoUploadZone.classList.remove("drag-active");
const files = e.dataTransfer.files;
if (files.length) {
photoInput.files = files;
photoInput.dispatchEvent(new Event("change"));
}
});
}
// =========================================================================
// LEADS
@@ -396,6 +681,7 @@ function renderLeads() {
leadsEmpty.style.display = rows.length ? "none" : "block";
leadsTableBody.innerHTML = "";
for (const l of rows) {
const rental = rentalTypeMeta(l.rental_type);
const total = l.total_eur || 0;
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
const tr = document.createElement("tr");
@@ -404,6 +690,7 @@ function renderLeads() {
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
<td>${esc(l.vehicle_label || "—")}</td>
<td>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</td>
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
<td style="white-space:nowrap;">
@@ -552,6 +839,7 @@ async function renderLeadTab(tab, l) {
});
leadDialogBody.appendChild(saveNoteBtn);
} else if (tab === "pricing") {
const rental = rentalTypeMeta(l.rental_type);
const daily = l.daily_subtotal || 0;
const weekend = l.weekend_subtotal || 0;
const sub = l.subtotal_eur || 0;
@@ -567,8 +855,9 @@ async function renderLeadTab(tab, l) {
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${((l.weekday_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150) + (l.weekend_day_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_km_weekend || state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150))} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${(l.total_days || 0) * (state.vehicleMap.get(l.vehicle_id)?.included_km_per_day || 150)} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
</div>`;
} else if (tab === "documents") {
const docs = await loadLeadAttachments(l.id);
@@ -685,6 +974,7 @@ function renderOrders() {
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
ordersTableBody.innerHTML = "";
for (const o of state.salesOrders) {
const rental = rentalTypeMeta(o.rental_type);
const total = o.total_eur || 0;
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
const cust = state.customers.find(c => c.id === o.customer_id);
@@ -694,10 +984,12 @@ function renderOrders() {
<td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td>
<td>${esc(o.vehicle_label || "—")}</td>
<td>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</td>
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
<td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td>
<td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td>
<td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td>
<td style="white-space:nowrap;"><span class="pill pill-${o.email_sent === 1 ? 'active' : o.email_sent === 2 ? 'disqualified' : 'new'}">${o.email_sent === 0 ? '—' : o.email_sent === 1 ? '✓' : '✗'}</span></td>
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
ordersTableBody.appendChild(tr);
}
@@ -708,20 +1000,98 @@ function renderOrders() {
// ----- ORDER DETAIL DIALOG -----
const orderDialog = document.querySelector("#orderDialog");
const orderDialogTitle = document.querySelector("#orderDialogTitle");
const orderDialogTabs = document.querySelector("#orderDialogTabs");
const orderDialogBody = document.querySelector("#orderDialogBody");
const orderDialogFooter = document.querySelector("#orderDialogFooter");
const orderDialogClose = document.querySelector("#orderDialogClose");
async function sendOrderEmailDirect(orderId) {
const sendBtn = orderDialogBody.querySelector("[data-manual-email-send]");
if (sendBtn) sendBtn.disabled = true;
const n8nUrl = window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "http://localhost:55521/webhook/manual-email-send";
try {
// Use urlencoded payload to avoid browser preflight/CORS issues with JSON headers.
const payload = new URLSearchParams({ sales_order_id: orderId });
const res = await fetch(n8nUrl, {
method: "POST",
body: payload,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
notify(t("emailSentToast"), 5000);
} catch (err) {
console.error("Webhook error:", err);
alert(`Email senden fehlgeschlagen: ${err?.message || err}`);
}
}
orderDialogBody.addEventListener("click", async (e) => {
const sendBtn = e.target.closest("[data-manual-email-send]");
if (!sendBtn) return;
e.preventDefault();
const orderId = sendBtn.dataset.orderId;
if (!orderId) return;
await sendOrderEmailDirect(orderId);
await loadSalesOrders();
const fresh = state.salesOrders.find(x => x.id === orderId);
if (fresh) await renderOrderTab("general", fresh, orderId);
});
const orderTabOrder = ["general", "pricing"];
const orderTabLabels = {
general: () => getLang() === "de" ? t("adminTabGeneral") : t("adminTabGeneralEn"),
pricing: () => getLang() === "de" ? t("adminTabPricing") : t("adminTabPricingEn"),
};
async function openOrder(id) {
const o = state.salesOrders.find(x => x.id === id);
if (!o) return;
const cust = state.customers.find(c => c.id === o.customer_id);
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
// Build tabs
orderDialogTabs.innerHTML = orderTabOrder.map((tab, i) =>
`<button class="order-tab${i === 0 ? " active" : ""}" data-order-tab="${tab}">${orderTabLabels[tab]()}</button>`
).join("");
// Render first tab
await renderOrderTab("general", o, id);
orderDialog.showModal();
// Tab switching
orderDialogTabs.querySelectorAll(".order-tab").forEach(btn => {
btn.addEventListener("click", () => {
orderDialogTabs.querySelectorAll(".order-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderOrderTab(btn.dataset.orderTab, o, id);
});
});
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
}
async function renderOrderTab(tab, o, id) {
const rental = rentalTypeMeta(o.rental_type);
const isIndividuell = rental.type === "individuell";
const lang = getLang();
const cust = state.customers.find(c => c.id === o.customer_id);
const total = o.total_eur || 0;
const deposit = o.deposit_eur || 0;
const emailSent = o.email_sent || 0;
const emailSentText = emailSent === 1 ? '✓' : emailSent === 2 ? '✗' : '—';
const emailSentPillClass = emailSent === 1 ? 'active' : emailSent === 2 ? 'disqualified' : 'new';
const isEmailLocked = emailSent === 1;
orderDialogTitle.textContent = `${o.order_number || o.id.slice(0, 8)} · ${cust?.name || "—"}`;
if (tab === "general") {
// Load attachments for this order
const { data: attachments } = await supabase
.from("sales_order_attachments")
@@ -735,8 +1105,8 @@ async function openOrder(id) {
<dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd>
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
<dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</dd>
<dt>${t("adminTotalLabel")}</dt><dd style="font-weight:600;">€ ${total.toLocaleString("de-DE")}</dd>
<dt>${t("adminDepositLabel")}</dt><dd>${deposit.toLocaleString("de-DE")}</dd>
<dt>${lang === "de" ? t("adminRentalType") : t("Rental type")}</dt><dd><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></dd>
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></dd>
</dl>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;">
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
@@ -748,7 +1118,8 @@ async function openOrder(id) {
<div style="margin-top:0.8rem;">
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
<textarea id="orderNote" rows="4" style="width:100%;resize:vertical;">${esc(o.private_notes || "")}</textarea>
<div style="display:flex;justify-content:flex-end;margin-top:0.4rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.4rem;">
<div>${emailSent !== 1 ? `<button class="btn small" type="button" data-manual-email-send data-order-id="${o.id}" style="background-color:var(--accent-strong);color:#fff;">${t("sendEmailButton")}</button>` : ''}</div>
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
</div>
</div>`;
@@ -774,22 +1145,114 @@ async function openOrder(id) {
orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => {
btn.addEventListener("click", async () => {
await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle);
await openOrder(id); // re-render
await loadSalesOrders();
const fresh = state.salesOrders.find(x => x.id === id);
if (fresh) await renderOrderTab("general", fresh, id);
});
});
// Dirty form tracking
let noteIsDirty = false;
const orderNoteEl = document.querySelector("#orderNote");
const originalNoteValue = o.private_notes || "";
const saveBtn = document.querySelector("#orderNoteSave");
if (orderNoteEl && saveBtn) {
saveBtn.classList.add("ghost");
orderNoteEl.addEventListener("input", () => {
noteIsDirty = orderNoteEl.value !== originalNoteValue;
if (noteIsDirty) {
saveBtn.classList.remove("ghost");
saveBtn.style.backgroundColor = "var(--accent-strong)";
saveBtn.style.color = "#fff";
saveBtn.textContent = t("adminSaveNotes") + " (unsaved)";
} else {
saveBtn.classList.add("ghost");
saveBtn.style.backgroundColor = "";
saveBtn.style.color = "";
saveBtn.textContent = t("adminSaveNotes");
}
});
}
// Save notes
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
if (ok) {
noteIsDirty = false;
document.querySelector("#orderNoteSave").textContent = "✓";
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
}
});
} else if (tab === "pricing") {
let daily = o.daily_subtotal || 0;
let weekend = o.weekend_subtotal || 0;
let sub = o.subtotal_eur || 0;
let vat = o.vat_eur || 0;
const days = o.total_days || 0;
const inclVatLabel = lang === "de" ? t("adminInclVat") : t("adminInclVatEn");
// For individuell: derive pricing from total (which includes VAT)
if (isIndividuell && total > 0) {
sub = Math.round(total / 1.2 * 100) / 100;
vat = Math.round((total - sub) * 100) / 100;
daily = sub; // all days counted as weekdays
weekend = 0;
}
const weekdayCount = isIndividuell ? days : (o.weekday_count || 0);
const weekendCount = isIndividuell ? 0 : (o.weekend_day_count || 0);
const perDay = daily && weekdayCount ? Math.round(daily / weekdayCount) : 0;
const perWeekend = weekend && weekendCount ? Math.round(weekend / weekendCount) : 0;
orderDialogBody.innerHTML = `
<div class="pricing-card">
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${weekdayCount} ×${perDay || "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${weekendCount} ×${perWeekend || "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.toLocaleString("de-DE")}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span>
<span>${isIndividuell && !isEmailLocked
? `<input type="number" id="orderTotalInput" step="1" min="0" value="${total}" style="font-weight:600;width:120px;" />`
: '€ ' + total.toLocaleString("de-DE")
}</span>
</div>
${!isEmailLocked ? `<div class="price-row muted" id="inclVatHint"><span></span><span style="font-size:0.78rem;">${inclVatLabel}</span></div>` : ''}
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span>
<span>${isEmailLocked || !isIndividuell
? '€ ' + deposit.toLocaleString("de-DE")
: `<input type="number" id="orderDepositInput" step="1" min="0" value="${deposit}" style="width:120px;" />`
}</span>
</div>
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${days * (state.vehicleMap.get(o.vehicle_id)?.included_km_per_day || 150)} km</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${days}</span></div>
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
</div>
${isIndividuell && !isEmailLocked ? `<div style="display:flex;justify-content:flex-end;margin-top:0.8rem;"><button class="btn small" id="orderPricingSave">${t("adminSave")}</button></div>` : ''}`;
// Single save for both total + deposit
document.querySelector("#orderPricingSave")?.addEventListener("click", async () => {
const btn = document.querySelector("#orderPricingSave");
btn.disabled = true;
const totalInput = document.querySelector("#orderTotalInput");
const depositInput = document.querySelector("#orderDepositInput");
const errors = [];
if (totalInput) {
const { error } = await supabase.rpc("sales_order_set_total", { p_so_id: o.id, p_total_eur: +totalInput.value });
if (error) errors.push(error.message);
}
if (depositInput) {
const { error } = await supabase.rpc("sales_order_set_deposit", { p_so_id: o.id, p_deposit_eur: +depositInput.value });
if (error) errors.push(error.message);
}
if (errors.length) { alert(errors.join("\n")); btn.disabled = false; return; }
await loadSalesOrders();
const fresh = state.salesOrders.find(x => x.id === id);
if (fresh) await renderOrderTab("pricing", fresh, id);
});
}
orderDialogFooter.innerHTML = "";
orderDialog.showModal();
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
}
async function toggleSalesOrderState(orderId, action) {
@@ -932,6 +1395,7 @@ async function renderCustomerTab(tab, c) {
let html = "";
if (orders.length) {
for (const o of orders) {
const rental = rentalTypeMeta(o.rental_type);
const total = o.total_eur || 0;
html += `
<div class="pricing-card" style="margin-bottom:0.9rem;">
@@ -941,6 +1405,7 @@ async function renderCustomerTab(tab, c) {
</div>
<div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div>
<div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")}${esc(o.date_to || "—")}</span></div>
<div class="price-row"><span>${lang === "de" ? "Miettyp" : "Rental type"}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;">
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
@@ -974,8 +1439,32 @@ async function renderCustomerTab(tab, c) {
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
if (ok) {
btn.classList.remove("ghost");
btn.style.backgroundColor = "";
btn.style.color = "";
btn.textContent = "✓";
setTimeout(() => { btn.textContent = t("adminSaveNotes"); }, 1500);
setTimeout(() => {
btn.textContent = t("adminSaveNotes");
}, 1500);
}
});
});
customerDialogBody.querySelectorAll("[data-so-note]").forEach((noteEl) => {
const btn = customerDialogBody.querySelector(`[data-so-save-note="${noteEl.dataset.soNote}"]`);
const originalValue = noteEl.value;
noteEl.addEventListener("input", () => {
const isDirty = noteEl.value !== originalValue;
if (isDirty) {
btn.classList.remove("ghost");
btn.style.backgroundColor = "var(--accent-strong)";
btn.style.color = "#fff";
btn.textContent = "Speichern (unsaved)";
} else {
btn.classList.add("ghost");
btn.style.backgroundColor = "";
btn.style.color = "";
btn.textContent = t("adminSaveNotes");
}
});
});
@@ -1074,6 +1563,22 @@ function attachRealtime() {
// =========================================================================
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]); }
function attr(s) { return esc(s); }
function normalizeRentalType(rawType) {
const value = String(rawType ?? "").trim().toLowerCase();
if (!value) return "weekend";
if (value === "individual" || value === "custom") return "individuell";
if (value === "day" || value === "daily" || value === "1 tag" || value === "1_tag" || value === "single_day") return "single_day";
if (value === "weekend") return "weekend";
return value;
}
function rentalTypeMeta(rawType) {
const type = normalizeRentalType(rawType);
const lang = getLang();
if (type === "single_day") return { type, label: lang === "de" ? "1 Tag" : "1 Day" };
if (type === "individuell") return { type, label: lang === "de" ? "individuell" : "individual" };
if (type === "weekend") return { type, label: "weekend" };
return { type: "weekend", label: type };
}
function fmtDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
@@ -1114,7 +1619,7 @@ const mietvertragFeedback = document.querySelector("#mietvertragFeedback");
async function renderSettings() {
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
const url = data?.value || "/images/ferrari-main-car.png";
const url = data?.value || "/images/ferrari-main-car-mobile.jpg";
heroPreview.style.backgroundImage = `url('${url}')`;
// Mietvertrag template status
+9 -12
View File
@@ -4,8 +4,8 @@
<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="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
@@ -51,13 +51,12 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -77,7 +76,7 @@
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.
Bitte wenden Sie sich an hello@mc-cars.at für weitere Informationen.
</p>
</div>
</div>
@@ -88,16 +87,15 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -111,8 +109,7 @@
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
</div>
</div>
+140 -11
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 || "";
@@ -16,6 +16,7 @@ const state = {
sort: "sort_order",
maxPrice: null,
reviewIdx: 0,
vehiclePhotosMap: new Map(),
};
// ---------------- Elements ----------------
@@ -123,6 +124,33 @@ async function loadVehicles() {
state.vehicles = data || [];
statCarsCount.textContent = state.vehicles.length;
statCarsLabel.dataset.i18n = state.vehicles.length > 1 ? 'statCars' : 'statCar';
applyI18n();
// Load vehicle photos
if (state.vehicles.length > 0) {
const ids = state.vehicles.map(v => v.id);
const { data: photos } = await supabase
.from("vehicle_photos")
.select("*")
.in("vehicle_id", ids)
.order("display_order", { ascending: true });
state.vehiclePhotosMap = new Map();
if (photos) {
for (const ph of photos) {
if (!state.vehiclePhotosMap.has(ph.vehicle_id)) {
state.vehiclePhotosMap.set(ph.vehicle_id, []);
}
state.vehiclePhotosMap.get(ph.vehicle_id).push(ph);
}
}
// Also include legacy main photo if no gallery photos exist
for (const v of state.vehicles) {
if (!state.vehiclePhotosMap.has(v.id) && v.photo_url) {
state.vehiclePhotosMap.set(v.id, [{ photo_url: v.photo_url }]);
}
}
}
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
@@ -156,10 +184,16 @@ function renderGrid() {
emptyState.style.display = state.filtered.length ? "none" : "block";
for (const v of state.filtered) {
const photos = state.vehiclePhotosMap?.get(v.id) || [];
const primaryPhoto = photos.find(p => p.is_primary) || photos[0];
const photoUrl = optimizedVehiclePhotoUrl(primaryPhoto?.photo_url || v.photo_url);
const photoCount = photos.length;
const card = document.createElement("article");
card.className = "vehicle-card";
card.innerHTML = `
<div class="vehicle-photo" role="img" aria-label="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" style="background-image:url('${escapeAttr(v.photo_url)}');">
<div class="vehicle-photo" data-photos='${escapeAttr(JSON.stringify(photos.map(p => optimizedVehiclePhotoUrl(p.photo_url))))}' data-current="0">
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" class="vehicle-photo-img" />
${photoCount > 1 ? `<div class="vehicle-photo-nav"><button class="vehicle-photo-prev" aria-label="Vorheriges Foto"></button><button class="vehicle-photo-next" aria-label="Nächstes Foto"></button></div><div class="vehicle-photo-dots">${photos.map((_, i) => `<span class="${i === 0 ? 'active' : ''}"></span>`).join('')}</div>` : ''}
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
</div>
<div class="vehicle-body">
@@ -192,17 +226,54 @@ function renderGrid() {
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
});
// Photo carousel nav
grid.querySelectorAll(".vehicle-photo-prev").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const container = btn.closest(".vehicle-photo");
const urls = JSON.parse(container.dataset.photos);
let idx = +container.dataset.current;
idx = (idx - 1 + urls.length) % urls.length;
container.dataset.current = idx;
container.querySelector(".vehicle-photo-img").src = urls[idx];
updatePhotoDots(container, idx);
});
});
grid.querySelectorAll(".vehicle-photo-next").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const container = btn.closest(".vehicle-photo");
const urls = JSON.parse(container.dataset.photos);
let idx = +container.dataset.current;
idx = (idx + 1) % urls.length;
container.dataset.current = idx;
container.querySelector(".vehicle-photo-img").src = urls[idx];
updatePhotoDots(container, idx);
});
});
}
function updatePhotoDots(container, idx) {
container.querySelectorAll(".vehicle-photo-dots span").forEach((dot, i) => {
dot.classList.toggle("active", i === idx);
});
}
function openDetails(id) {
const v = state.vehicles.find(x => x.id === id);
if (!v) return;
const photos = state.vehiclePhotosMap?.get(v.id) || [];
const photoUrls = photos.length ? photos.map(p => optimizedVehiclePhotoUrl(p.photo_url)) : [optimizedVehiclePhotoUrl(v.photo_url)];
const lang = getLang();
const desc = lang === "en" ? v.description_en : v.description_de;
dialogTitle.textContent = `${v.brand} ${v.model}`;
dialogBody.innerHTML = `
<img src="${escapeAttr(v.photo_url)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
<div class="dialog-gallery" data-gallery-urls='${escapeAttr(JSON.stringify(photoUrls))}' data-gallery-idx="0">
<img src="${escapeAttr(photoUrls[0])}" alt="${escapeAttr(v.brand + ' ' + v.model)}" class="dialog-gallery-main" />
${photoUrls.length > 1 ? `<div class="dialog-gallery-nav"><button class="dialog-gallery-prev" aria-label="Vorheriges Foto"></button><button class="dialog-gallery-next" aria-label="Nächstes Foto"></button></div><div class="dialog-gallery-thumbs">${photoUrls.map((u, i) => `<button class="${i === 0 ? 'active' : ''}" data-gidx="${i}"><img src="${escapeAttr(u)}" loading="lazy" /></button>`).join('')}</div>` : ''}
</div>
<p>${escapeHtml(desc || "")}</p>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
@@ -212,7 +283,7 @@ function openDetails(id) {
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
<div><strong>${v.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div>
<div><strong>${v.included_km_per_day || 150}</strong><span>${t("bpfInclKmPerDay")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
@@ -229,6 +300,37 @@ function openDetails(id) {
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
// Dialog gallery nav
const gallery = dialogBody.querySelector(".dialog-gallery");
const galleryPrev = dialogBody.querySelector(".dialog-gallery-prev");
const galleryNext = dialogBody.querySelector(".dialog-gallery-next");
if (galleryPrev) {
galleryPrev.addEventListener("click", () => {
let idx = +gallery.dataset.galleryIdx;
idx = (idx - 1 + photoUrls.length) % photoUrls.length;
gallery.dataset.galleryIdx = idx;
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
});
}
if (galleryNext) {
galleryNext.addEventListener("click", () => {
let idx = +gallery.dataset.galleryIdx;
idx = (idx + 1) % photoUrls.length;
gallery.dataset.galleryIdx = idx;
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
});
}
gallery?.querySelectorAll(".dialog-gallery-thumbs button").forEach(btn => {
btn.addEventListener("click", () => {
const idx = +btn.dataset.gidx;
gallery.dataset.galleryIdx = idx;
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
});
});
}
// ---------------- Reviews ----------------
@@ -395,10 +497,26 @@ async function updateSidebar() {
const vat = price.vat_eur;
const total = price.total_eur;
const deposit = price.deposit_eur;
const kmPerWeekday = price.max_daily_km;
const kmPerWeekendDay = price.max_km_weekend;
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
const includedKmPerDay = price.included_km_per_day || 150;
const includedKm = totalDays * includedKmPerDay;
const sidebarPhotos = state.vehiclePhotosMap?.get(v.id) || [];
const photoUrl = optimizedVehiclePhotoUrl((sidebarPhotos.find(p => p.is_primary) || sidebarPhotos[0] || v)?.photo_url || v.photo_url);
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 = `
@@ -411,12 +529,14 @@ async function updateSidebar() {
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(v.photo_url)}');"></div>
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
}
}
}
bpfCar.addEventListener("change", updateSidebar);
bpfFrom.addEventListener("change", updateSidebar);
@@ -532,6 +652,12 @@ 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();
@@ -542,6 +668,9 @@ loadVehicles();
(async () => {
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
if (data && data.value) {
document.querySelector(".hero").style.setProperty("--hero-bg", `url('${data.value}')`);
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}')`);
}
})();
+98 -16
View File
@@ -4,8 +4,8 @@
<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 rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
@@ -48,13 +48,12 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -64,11 +63,95 @@
</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 class="shell" style="max-width: 80ch;">
<h1>Datenschutzerklärung</h1>
<div style="max-width: 70ch; line-height: 1.8; color: var(--text);">
<p>Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Wir verarbeiten Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, DSG 2018). In diesen Datenschutzinformationen informieren wir Sie über die wichtigsten Aspekte der Datenverarbeitung im Rahmen unserer Website.</p>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Verantwortlicher für die Datenverarbeitung</h2>
<p><strong>MC Cars GmbH</strong><br/> Gaisfeld 1/2, 8564 Krottendorf-Gaisfeld<br/> E-Mail: hello@mc-cars.at</p>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Daten, die wir verarbeiten</h2>
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Server-Logfiles</h3>
<p>Beim Besuch unserer Website werden automatisch Informationen in Server-Logfiles gespeichert, die Ihr Browser an uns übermittelt. Dies sind:</p>
<ul style="padding-left: 1.5rem;">
<li>Browsertyp und Browserversion</li>
<li>Verwendetes Betriebssystem</li>
<li>Referrer URL (die zuvor besuchte Seite)</li>
<li>Hostname des zugreifenden Rechners</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse</li>
</ul>
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.</p>
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Buchungsanfragen</h3>
<p>Wenn Sie unser Buchungsformular nutzen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen gespeichert. Dies umfasst:</p>
<ul style="padding-left: 1.5rem;">
<li>Name</li>
<li>E-Mail-Adresse</li>
<li>Telefonnummer</li>
<li>Gewähltes Fahrzeug und Mietzeitraum</li>
<li>Nachricht / Anmerkungen</li>
</ul>
<p>Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.</p>
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Identitätsdokumente</h3>
<p>Zur Bearbeitung von Buchungsanfragen laden wir Identitätsdokumente (Ausweis, Führerschein) sowie optionale Einkommensnachweise hoch. Diese Dokumente dienen ausschließlich der Identitätsverifizierung und Bonitätsprüfung. Sie werden vertraulich behandelt und nicht an Dritte weitergegeben.</p>
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Cookies und lokale Speicherung</h3>
<p>Unsere Website verwendet lokale Speicherung (localStorage) für die Auswahl der Spracheinstellung. Diese Daten werden ausschließlich auf Ihrem Endgerät gespeichert und nicht an uns übermittelt.</p>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Zweck der Datenverarbeitung</h2>
<p>Die Verarbeitung Ihrer personenbezogenen Daten erfolgt zu folgenden Zwecken:</p>
<ul style="padding-left: 1.5rem;">
<li>Zur Bereitstellung, Optimierung und Weiterentwicklung unserer Website</li>
<li>Zur Bearbeitung Ihrer Buchungsanfragen</li>
<li>Zur Identitätsprüfung und Bonitätsprüfung</li>
<li>Zur Gewährleistung der Sicherheit und Funktionsfähigkeit unserer Website</li>
<li>Zur Erfüllung gesetzlicher Verpflichtungen</li>
</ul>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Rechtsgrundlage der Verarbeitung</h2>
<p>Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf folgenden Rechtsgrundlagen:</p>
<ul style="padding-left: 1.5rem;">
<li><strong>Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen (Art. 6 Abs. 1 lit. b DSGVO)</strong> bei der Bearbeitung Ihrer Buchungsanfragen und der Verarbeitung Ihrer Identitätsdokumente</li>
<li><strong>Erfüllung einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO)</strong> z.B. aufgrund gesetzlicher Aufbewahrungsfristen</li>
<li><strong>Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)</strong> zur Gewährleistung der Sicherheit, der Funktionsfähigkeit und der Optimierung unserer Website</li>
</ul>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Datenhosting</h2>
<p>Unsere Website und Datenbank laufen auf einer selbstgehosteten Infrastruktur. Alle personenbezogenen Daten werden auf unseren eigenen Servern verarbeitet und gespeichert. Es erfolgt keine Weitergabe an Cloud-Dienstanbieter oder Drittunternehmen.</p>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Übermittlung Ihrer Daten</h2>
<p>Eine Übermittlung Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn:</p>
<ul style="padding-left: 1.5rem;">
<li>Dies ist zur Erfüllung unserer vertraglichen Pflichten erforderlich</li>
<li>Wir sind gesetzlich dazu verpflichtet</li>
<li>Sie haben ausdrücklich eingewilligt</li>
</ul>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Speicherdauer</h2>
<p>Wir speichern Ihre personenbezogenen Daten nur so lange, wie es für die Erreichung der oben genannten Zwecke erforderlich ist oder wie es die gesetzlichen Aufbewahrungspflichten vorsehen. Identitätsdokumente werden nach Abschluss der Buchung und Erfüllung der gesetzlichen Aufbewahrungsfristen gelöscht.</p>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Ihre Rechte</h2>
<p>Sie haben hinsichtlich Ihrer bei uns gespeicherten personenbezogenen Daten folgende Rechte:</p>
<ul style="padding-left: 1.5rem;">
<li><strong>Recht auf Auskunft (Art. 15 DSGVO):</strong> Sie können Auskunft darüber verlangen, ob und welche personenbezogenen Daten von Ihnen verarbeitet werden.</li>
<li><strong>Recht auf Berichtigung (Art. 16 DSGVO):</strong> Sie können die Berichtigung unrichtiger oder die Vervollständigung unvollständiger Daten verlangen.</li>
<li><strong>Recht auf Löschung (Art. 17 DSGVO):</strong> Sie können die Löschung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.</li>
<li><strong>Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO):</strong> Sie können die Einschränkung der Verarbeitung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.</li>
<li><strong>Recht auf Datenübertragbarkeit (Art. 20 DSGVO):</strong> Sie haben das Recht, Ihre bereitgestellten Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten.</li>
<li><strong>Recht auf Widerspruch (Art. 21 DSGVO):</strong> Sie können gegen die Verarbeitung Ihrer Daten Widerspruch einlegen.</li>
<li><strong>Recht auf Beschwerde (Art. 77 DSGVO):</strong> Sie können sich bei der zuständigen Aufsichtsbehörde beschweren.</li>
</ul>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Kontaktdaten der Aufsichtsbehörde</h2>
<p><strong>Österreichische Datenschutzbehörde</strong><br/> Barichgasse 40-42, 1030 Wien, Österreich<br/> Telefon: +43 1 52 152-0<br/> E-Mail: <a href="mailto:dsb@dsb.gv.at" style="color: var(--accent-strong);">dsb@dsb.gv.at</a></p>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Änderungen dieser Datenschutzerklärung</h2>
<p>Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen oder bei Änderungen unserer Dienste anzupassen. Die jeweils aktuelle Version ist auf unserer Website abrufbar.</p>
</div>
</div>
</main>
@@ -78,16 +161,15 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -101,8 +183,7 @@
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
</div>
</div>
@@ -113,6 +194,7 @@
</div>
</footer>
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
<script type="module" src="app.js"></script>
</body>
</html>
+41 -37
View File
@@ -2,21 +2,18 @@
export const translations = {
de: {
navCars: "Fahrzeuge",
navWhy: "Warum wir",
navReviews: "Stimmen",
navBook: "Buchen",
bookNow: "Jetzt buchen",
viewFleet: "Flotte ansehen",
heroEyebrow: "MC Cars · Sportwagenvermietung",
heroTitle: "Fahren auf höchstem Niveau.",
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.",
heroLead: "Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.",
statDeposit: "Faire Kaution",
statSupport: "Support",
statCars: "Fahrzeuge",
statCar: "Fahrzeug",
fleetEyebrow: "Unsere Flotte",
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
filterBrand: "Marke",
@@ -36,15 +33,6 @@ export const translations = {
from: "ab",
noMatches: "Keine Fahrzeuge gefunden.",
whyEyebrow: "Warum MC Cars",
whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspaß.",
whyInsurance: "Versicherungsschutz",
whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.",
whyFleet: "Premium Flotte",
whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.",
whyDeposit: "Faire Kaution",
whyDepositText: "Zwei Kautionsarten: Bar oder PayPal-Kaution. Bei PayPal senden wir einen Deposit-Link. Bar wird aktuell persönlich bei der Fahrzeugübergabe abgewickelt.",
reviewsEyebrow: "Kundenmeinungen",
reviewsTitle: "Erlebnisse, die bleiben.",
review: "Kundenmeinung",
@@ -89,6 +77,7 @@ export const translations = {
bpfWeekendRate: "Wochenendmiete",
bpfWeekendDef: "Wochenende: Samstag 9:00 Sonntag 20:00",
bpfMaxKm: "Max. km/Tag",
bpfInclKmPerDay: "Inkl. km/Tag",
bpfExtraKm: "Extra km",
bpfPriceOverview: "Preisübersicht",
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
@@ -112,7 +101,7 @@ export const translations = {
perWeekend: "Wochenende",
weekendDef: "Sa 9:00 So 20:00",
footerTagline: "Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).",
footerTagline: "Sportwagenvermietung in der Steiermark, Österreich.",
footerLegal: "Rechtliches",
footerContact: "Kontakt",
footerNav: "Navigation",
@@ -176,7 +165,7 @@ export const translations = {
adminVehicleTab: "Fahrzeug",
adminPeriod: "Zeitraum",
adminKaution: "Kaution (€)",
adminMaxKmWeekend: "Max. km/Wochenendtag",
adminMaxKmWeekend: "Inkl. km/Wochenende",
adminTotalPrice: "Gesamtbetrag",
adminLifetimeValueCol: "Gesamtwert",
adminTabGeneral: "Allgemein",
@@ -222,6 +211,8 @@ export const translations = {
adminVatLabelEn: "VAT (20%)",
adminTotalLabel: "Gesamtbetrag",
adminTotalLabelEn: "Total",
adminInclVat: "inkl. MwSt.",
adminInclVatEn: "incl. VAT",
adminDepositLabel: "Kaution",
adminDepositLabelEn: "Deposit",
adminIncludedKmLabel: "Inkl. km",
@@ -234,24 +225,32 @@ export const translations = {
adminNoteEn: "Note",
adminSave: "Speichern",
adminSaveEn: "Save",
adminPricePerKm: "Preis extra km (€)",
adminRentalType: "Miettyp",
rentalTypeWeekend: "Wochenende",
rentalTypeIndividuell: "Individuell",
adminSortOrder: "Ordnung",
adminEmailSent: "E-Mail gesendet",
sendEmailButton: "E-Mail senden",
emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...",
emailAlreadySent: "Bereits gesendet",
bpfIndividuellTitle: "Individuelle Mietdauer",
bpfIndividuellDesc: "Bei Mietdauer über 2 Tagen erstellen wir ein persönliches Angebot. Wir prüfen Verfügbarkeit und melden uns in Kürze per E-Mail bei Ihnen.",
},
en: {
navCars: "Fleet",
navWhy: "Why us",
navReviews: "Reviews",
navBook: "Book",
bookNow: "Book now",
viewFleet: "View fleet",
heroEyebrow: "MC Cars · Sports car rental",
heroTitle: "Drive at the highest level.",
heroLead: "Premium sports and luxury cars in Styria. Fair deposit, full transparency, ready to launch.",
heroLead: "The Ferrari in Styria. Fair deposit, full transparency, ready to launch.",
statDeposit: "Fair Deposit",
statSupport: "Support",
statCars: "Vehicles",
statCar: "Vehicle",
fleetEyebrow: "Our Fleet",
fleetTitle: "Hand-picked. Maintained. Ready.",
fleetSub: "Filter by brand or price. Click for details or book directly.",
filterBrand: "Brand",
@@ -271,15 +270,6 @@ export const translations = {
from: "from",
noMatches: "No vehicles match the filters.",
whyEyebrow: "Why MC Cars",
whyTitle: "No compromises between safety and driving joy.",
whyInsurance: "Insurance",
whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.",
whyFleet: "Premium fleet",
whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.",
whyDeposit: "Fair Deposit",
whyDepositText: "Two deposit options: cash or PayPal deposit. For PayPal, we send a deposit link. Cash is currently handled in person at pickup.",
reviewsEyebrow: "Testimonials",
reviewsTitle: "Experiences that last.",
review: "Review",
@@ -324,6 +314,7 @@ export const translations = {
bpfWeekendRate: "Weekend rate",
bpfWeekendDef: "Weekend: Saturday 9 AM Sunday 8 PM",
bpfMaxKm: "Max. km/day",
bpfInclKmPerDay: "Included km/day",
bpfExtraKm: "Extra km",
bpfPriceOverview: "Price overview",
bpfSelectForPrice: "Select vehicle and date for a price overview",
@@ -347,7 +338,7 @@ export const translations = {
perWeekend: "Weekend",
weekendDef: "Sat 9 AM Sun 8 PM",
footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
footerTagline: "Sports car rental in Styria, Austria.",
footerLegal: "Legal",
footerContact: "Contact",
footerNav: "Navigation",
@@ -411,7 +402,7 @@ export const translations = {
adminVehicleTab: "Vehicle",
adminPeriod: "Period",
adminKaution: "Deposit (€)",
adminMaxKmWeekend: "Max. km/weekend day",
adminMaxKmWeekend: "Included km/weekend",
adminTotalPrice: "Total",
adminLifetimeValueCol: "Lifetime",
adminTabGeneral: "General",
@@ -457,6 +448,8 @@ export const translations = {
adminVatLabelEn: "MwSt. (20%)",
adminTotalLabel: "Total",
adminTotalLabelEn: "Gesamtbetrag",
adminInclVat: "incl. VAT",
adminInclVatEn: "inkl. MwSt.",
adminDepositLabel: "Deposit",
adminDepositLabelEn: "Kaution",
adminIncludedKmLabel: "Included km",
@@ -469,15 +462,26 @@ export const translations = {
adminNoteEn: "Notiz",
adminSave: "Save",
adminSaveEn: "Speichern",
adminPricePerKm: "Extra km price (€)",
adminRentalType: "Rental type",
rentalTypeWeekend: "Weekend",
rentalTypeIndividuell: "Custom",
adminSortOrder: "Order",
adminEmailSent: "Email sent",
sendEmailButton: "Send Email",
emailSentToast: "Email is being prepared and will be sent shortly...",
emailAlreadySent: "Already sent",
bpfIndividuellTitle: "Custom Rental Duration",
bpfIndividuellDesc: "For rental periods over 2 days, we'll create a personalized quote. We'll check availability and get back to you via email shortly.",
},
};
export const REVIEWS = [
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
{ quote: "Exzellenter Service und makellos vorbereitete Fahrzeuge. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
{ quote: "Hervorragende Buchungsabwicklung und tadelloses Fahrzeugzustand. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
{ quote: "Professionelles Team und untadelige Aufmerksamkeit zum Detail. Sehr empfohlen.", author: "David M.", lang: "de" },
{ quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
{ quote: "Die Buchung war klar und schnell. Der Ferrari war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
{ quote: "Exzellenter Service und ein makellos vorbereiteter Ferrari. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
{ quote: "Hervorragende Buchungsabwicklung und tadelloser Zustand des Ferrari. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
{ quote: "Professionelles Team und erstklassiger Ferrari. Absolut empfehlenswert.", author: "David M.", lang: "de" },
{ quote: "Booking was clear and fast. The Ferrari arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
];
export function getLang() {
+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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

+24 -16
View File
@@ -4,8 +4,8 @@
<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 rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
@@ -48,13 +48,12 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -67,11 +66,21 @@
<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>
<p><strong>MC Cars GmbH</strong></p>
<p>Gaisfeld 1/2<br/>8564 Krottendorf-Gaisfeld</p>
<p>FN 675751 b · Landesgericht für Zivilrechtssachen Graz</p>
<p>Geschäftsführer: Christian Leski, Marco Schober</p>
<p>E-Mail: hello@mc-cars.at</p>
<p>UID-Nr. wird in Kürze nachgereicht.</p>
</div>
<div style="max-width: 65ch; line-height: 1.7; color: var(--text); margin-top: 2.5rem;">
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: var(--text);">Datenschutzerklärung (Kurzfassung)</h2>
<p>Der Schutz Ihrer persönlichen Daten ist uns wichtig. Wir behandeln Ihre Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften, insbesondere der DSGVO und dem österreichischen Datenschutzgesetz.</p>
<p><strong>Welche Daten wir erfassen:</strong> Wir erheben nur die Daten, die für die Nutzung unserer Website und unserer Dienste unbedingt erforderlich sind. Dazu können Zugriffsdaten (Datum, Uhrzeit, besuchte Seiten), technische Daten (Browsertyp, Betriebssystem) und falls relevant von Ihnen aktiv eingegebene Daten (z.B. bei Kontakt- und Buchungsformularen) gehören.</p>
<p><strong>Wie wir Ihre Daten verwenden:</strong> Ihre Daten verwenden wir ausschließlich, um Ihnen unsere Website und die damit verbundenen Funktionen bereitzustellen, Buchungsanfragen zu bearbeiten und die Sicherheit unserer Systeme zu gewährleisten.</p>
<p><strong>Weitergabe an Dritte:</strong> Eine Weitergabe Ihrer persönlichen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn, dies ist gesetzlich vorgeschrieben oder für die Erbringung unserer Dienste unerlässlich.</p>
<p><strong>Ihre Rechte:</strong> Sie haben jederzeit das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Widerspruch gegen die Verarbeitung Ihrer personenbezogenen Daten sowie das Recht auf Datenübertragbarkeit.</p>
<p>Weitere Informationen finden Sie in unserer <a href="/datenschutz" style="color: var(--accent-strong);">vollständigen Datenschutzerklärung</a>.</p>
</div>
</div>
</main>
@@ -81,16 +90,15 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -104,8 +112,7 @@
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
</div>
</div>
@@ -116,6 +123,7 @@
</div>
</footer>
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
<script type="module" src="app.js"></script>
</body>
</html>
+32 -65
View File
@@ -3,17 +3,21 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MC Cars · Sportwagenvermietung Steiermark</title>
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
<title>MC Cars · Ferrari-Vermietung Steiermark</title>
<meta name="description" content="MC Cars · Premium Ferrari-Vermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link rel="preload" as="image" href="/images/ferrari-main-car-mobile.jpg" fetchpriority="high" />
<link rel="preconnect" href="https://esm.sh" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<!-- 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="keywords" content="Sportwagenvermietung Steiermark, Luxusauto mieten, Sportwagenverleih, Ferrari mieten Graz" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="language" content="German" />
<link rel="canonical" href="https://demo.lago.dev/" />
@@ -23,8 +27,8 @@
<!-- 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:title" content="MC Cars Premium Ferrari-Vermietung Steiermark" />
<meta property="og:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<meta property="og:url" content="https://demo.lago.dev/" />
<meta property="og:site_name" content="MC Cars" />
<meta property="og:locale" content="de_AT" />
@@ -34,12 +38,10 @@
<!-- 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:title" content="MC Cars Premium Ferrari-Vermietung Steiermark" />
<meta name="twitter:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<meta name="twitter:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
@@ -48,9 +50,9 @@
"@id": "https://demo.lago.dev/#organization",
"name": "MC Cars GmbH",
"alternateName": "MC Cars",
"description": "Premium Sportwagen- und Luxusvermietung in der Steiermark",
"description": "Premium Ferrari-Vermietung in der Steiermark",
"url": "https://demo.lago.dev",
"logo": "https://demo.lago.dev/images/mc-cars-logo.png",
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
"image": "https://demo.lago.dev/images/mc-cars-og-image.png",
"areaServed": {
"@type": "Place",
@@ -61,10 +63,10 @@
}
},
"priceRange": "€€€",
"serviceType": "Sportwagenvermietung",
"serviceType": "Ferrari-Vermietung",
"sameAs": [
"https://www.facebook.com/mccars",
"https://www.instagram.com/mccars"
"https://www.facebook.com/mc-cars",
"https://www.instagram.com/mc-cars"
]
}
</script>
@@ -74,8 +76,8 @@
"@type": "Organization",
"name": "MC Cars GmbH",
"url": "https://demo.lago.dev",
"logo": "https://demo.lago.dev/images/mc-cars-logo.png",
"description": "Premium Sportwagen- und Luxusvermietung in Steiermark, Österreich",
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
"description": "Premium Ferrari-Vermietung in Steiermark, Österreich",
"foundingDate": "2024",
"contactPoint": {
"@type": "ContactPoint",
@@ -103,7 +105,7 @@
<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'" />
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" />
<span>MC Cars</span>
</a>
@@ -111,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>
@@ -126,16 +127,15 @@
<div class="shell">
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
<h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
<p class="lead" data-i18n="heroLead">Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.</p>
<div class="hero-cta">
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
<a class="btn ghost" href="#fahrzeuge" data-i18n="viewFleet">Flotte ansehen</a>
</div>
<div class="hero-stats">
<div><strong data-i18n="statDeposit">Faire Kaution</strong><span>Fair Deposit</span></div>
<div><strong id="statCarsCount"></strong><span data-i18n="statCars">Fahrzeuge</span></div>
<div><strong id="statCarsCount"></strong><span id="statCarsLabel" data-i18n="statCar">Fahrzeug</span></div>
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
</div>
</div>
@@ -146,7 +146,6 @@
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.</p>
</div>
@@ -177,37 +176,6 @@
</div>
</section>
<!-- Why -->
<section id="warum" style="background:var(--bg-elev);">
<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>
<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 -->
@@ -250,8 +218,8 @@
<h3 class="bpf-panel-title">🚗 <span data-i18n="stepVehicleTime">Fahrzeug & Zeitraum</span></h3>
<div class="bpf-field">
<label data-i18n="bpfVehicle">Fahrzeug</label>
<select id="bpfCar">
<label for="bpfCar" id="bpfCarLabel" data-i18n="bpfVehicle">Fahrzeug</label>
<select id="bpfCar" aria-labelledby="bpfCarLabel">
<option value="" data-i18n="bpfSelectVehicle">Fahrzeug wählen</option>
</select>
</div>
@@ -398,16 +366,15 @@
<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'" />
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" style="width:2rem;height:2rem;" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -421,8 +388,7 @@
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
</div>
</div>
@@ -444,6 +410,7 @@
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script type="module" src="app.js"></script>
<script src="config.js"></script>
<script type="module" src="app.js?v=3"></script>
</body>
</html>
+9 -12
View File
@@ -4,8 +4,8 @@
<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="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
@@ -51,13 +51,12 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -77,7 +76,7 @@
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.
Bitte wenden Sie sich an hello@mc-cars.at für weitere Informationen.
</p>
</div>
</div>
@@ -88,16 +87,15 @@
<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'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -111,8 +109,7 @@
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
</div>
</div>
+2 -1
View File
@@ -20,8 +20,9 @@ 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;
}
+1 -44
View File
@@ -1,49 +1,6 @@
# MC Cars - Robots Configuration
# For demo.lago.dev domain
User-agent: *
Allow: /
Allow: /index.html
Allow: /agb.html
Allow: /datenschutz.html
Allow: /impressum.html
Allow: /mietbedingungen.html
Allow: /styles.css
Allow: /images/
# Disallow admin panel from search engines
Disallow: /admin.html
Disallow: /admin/
Disallow: /config.js
# Prevent crawling of query strings (pagination, filters, etc.)
Disallow: /*?*
# Sitemap location
Sitemap: https://demo.lago.dev/sitemap.xml
# Crawl delay for responsible crawling (optional)
Crawl-delay: 1
# Request rate: 1 request per second
Request-rate: 1/1s
# Specific rules for Google
User-agent: Googlebot
Allow: /
Disallow: /admin.html
Crawl-delay: 0
# Specific rules for Bing
User-agent: Bingbot
Allow: /
Disallow: /admin.html
Crawl-delay: 1
# Block bad bots and scrapers
User-agent: AhrefsBot
User-agent: SemrushBot
User-agent: DotBot
User-agent: MJ12bot
User-agent: AiHitBot
Disallow: /
Sitemap: https://www.mc-cars.at/sitemap.xml
+360 -17
View File
@@ -98,17 +98,14 @@ section { padding: 5rem 0; }
.logo:hover { opacity: 0.85; }
.logo-icon {
width: 2.3rem;
height: 2.3rem;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 12px rgba(196, 138, 66, 0.35);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.3s ease;
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);
box-shadow: 0 6px 16px rgba(196, 138, 66, 0.55);
}
.logo-mark {
@@ -260,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%),
var(--hero-bg, 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;
}
@@ -392,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);
}
@@ -416,6 +420,146 @@ select:focus, input:focus, textarea:focus {
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
/* Photo carousel nav */
.vehicle-photo-nav {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.vehicle-photo:hover .vehicle-photo-nav {
opacity: 1;
pointer-events: auto;
}
.vehicle-photo-prev,
.vehicle-photo-next {
background: rgba(0,0,0,0.6);
color: #fff;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.vehicle-photo-prev:hover,
.vehicle-photo-next:hover {
background: rgba(0,0,0,0.8);
}
.vehicle-photo-dots {
position: absolute;
bottom: 0.6rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
}
.vehicle-photo-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.4);
transition: background 0.2s ease, transform 0.2s ease;
}
.vehicle-photo-dots span.active {
background: #fff;
transform: scale(1.3);
}
/* Dialog gallery */
.dialog-gallery {
position: relative;
width: 100%;
aspect-ratio: 16/10;
background: #0e1015;
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 1.2rem;
}
.dialog-gallery-main {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dialog-gallery-nav {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
pointer-events: none;
}
.dialog-gallery-prev,
.dialog-gallery-next {
background: rgba(0,0,0,0.6);
color: #fff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 1.4rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
pointer-events: auto;
}
.dialog-gallery-prev:hover,
.dialog-gallery-next:hover {
background: rgba(0,0,0,0.8);
}
.dialog-gallery-thumbs {
position: absolute;
bottom: 0.6rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
}
.dialog-gallery-thumbs button {
width: 56px;
height: 36px;
border-radius: 6px;
overflow: hidden;
border: 2px solid transparent;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease, border-color 0.2s ease;
padding: 0;
background: none;
}
.dialog-gallery-thumbs button.active {
border-color: #fff;
opacity: 1;
}
.dialog-gallery-thumbs button:hover {
opacity: 0.9;
}
.dialog-gallery-thumbs button img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.vehicle-body {
padding: 1.4rem;
display: flex;
@@ -555,12 +699,32 @@ select:focus, input:focus, textarea:focus {
}
.review-dots button {
width: 10px; height: 10px; border-radius: 50%;
border: none; background: var(--line); cursor: pointer;
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s ease, width 0.3s ease;
width: 44px;
height: 44px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
outline-offset: 4px;
}
.review-dots button::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--line);
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s ease, width 0.3s ease, border-radius 0.3s ease;
}
.review-dots button:hover {
transform: scale(1.04);
}
.review-dots button:hover::before {
background: rgba(196, 138, 66, 0.5);
transform: scale(1.2);
}
@@ -568,10 +732,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 ---------------- */
@@ -877,7 +1044,7 @@ dialog::backdrop { background: rgba(0,0,0,0.6); }
/* ---------------- Admin ---------------- */
.admin-page {
max-width: 1100px;
max-width: 1280px;
margin: 2rem auto;
padding: 0 1rem;
}
@@ -933,6 +1100,7 @@ table.admin-table th, table.admin-table td {
text-align: left;
padding: 0.75rem 0.6rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
transition: background-color 0.2s ease;
}
table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.5rem; }
@@ -942,6 +1110,9 @@ 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; }
@@ -964,6 +1135,172 @@ table.admin-table tbody tr:hover {
filter: brightness(1.1);
}
/* ---- Unified Photo Upload Zone ---- */
.admin-photo-upload-zone {
width: 100%;
min-height: 120px;
border: 2px dashed var(--line);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
background: var(--bg-elev);
margin-bottom: 0.5rem;
position: relative;
}
.admin-photo-upload-zone:hover {
border-color: var(--accent-strong);
}
.admin-photo-upload-zone.drag-active {
border-color: var(--accent-strong);
background: rgba(245, 158, 11, 0.08);
}
.admin-photo-upload-zone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.admin-photo-upload-content {
text-align: center;
pointer-events: none;
}
.admin-photo-upload-icon {
font-size: 2rem;
display: block;
margin-bottom: 0.3rem;
}
/* ---- Photo Gallery ---- */
.admin-photo-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.8rem;
margin-top: 1rem;
}
.admin-photo-card {
position: relative;
border-radius: 10px;
overflow: hidden;
aspect-ratio: 16/10;
background: #1a1a1a;
border: 2px solid transparent;
transition: border-color 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
cursor: grab;
}
.admin-photo-card:hover {
border-color: var(--accent-strong);
transform: scale(1.02);
}
.admin-photo-card:active {
cursor: grabbing;
}
.admin-photo-card-drag-over {
border-color: #f59e0b !important;
transform: scale(1.05);
}
.admin-photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.admin-photo-card-arrows {
position: absolute;
top: 6px;
left: 6px;
display: flex;
gap: 3px;
z-index: 2;
}
.admin-photo-arrow {
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.admin-photo-arrow:hover {
background: rgba(0, 0, 0, 0.9);
}
.admin-photo-card-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 3px;
z-index: 2;
}
.admin-photo-set-primary {
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: rgba(245, 158, 11, 0.85);
color: #000;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.admin-photo-set-primary:hover {
background: #f59e0b;
}
.admin-photo-delete {
width: 26px;
height: 26px;
border-radius: 6px;
border: none;
background: rgba(239, 68, 68, 0.85);
color: #fff;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.admin-photo-delete:hover {
background: #ef4444;
}
.admin-photo-badge {
position: absolute;
bottom: 6px;
left: 6px;
background: #22c55e;
color: #fff;
border-radius: 5px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
z-index: 2;
}
.admin-photo-drag-handle {
position: absolute;
bottom: 6px;
right: 6px;
color: rgba(255, 255, 255, 0.5);
font-size: 1.1rem;
z-index: 2;
pointer-events: none;
}
/* ---------------- Forms / Toggle Switch ---------------- */
.toggle-switch {
position: relative;
@@ -1071,6 +1408,9 @@ input:checked + .toggle-slider:before {
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); }
.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border: 1px solid rgba(90, 180, 120, 0.3); }
.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); border: 1px solid transparent; }
.pill-single_day { background: rgba(74, 144, 226, 0.16); color: #8abfff; border: 1px solid rgba(74, 144, 226, 0.35); }
.pill-weekend { background: rgba(200, 150, 80, 0.15); color: #e4b676; border: 1px solid rgba(200, 150, 80, 0.3); }
.pill-individuell { background: rgba(204, 116, 58, 0.16); color: #ffb487; border: 1px solid rgba(204, 116, 58, 0.38); }
.muted { color: var(--muted); }
@@ -1080,7 +1420,8 @@ input:checked + .toggle-slider:before {
/* Dialog */
dialog#leadDialog,
dialog#customerDialog {
dialog#customerDialog,
dialog#orderDialog {
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--bg-card); color: var(--text);
padding: 0; max-width: 640px; width: 94%;
@@ -1088,11 +1429,13 @@ dialog#customerDialog {
transition: opacity 0.3s ease, transform 0.3s ease;
}
dialog#leadDialog[open],
dialog#customerDialog[open] {
dialog#customerDialog[open],
dialog#orderDialog[open] {
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
dialog#leadDialog::backdrop,
dialog#customerDialog::backdrop {
dialog#customerDialog::backdrop,
dialog#orderDialog::backdrop {
background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
animation: fadeIn 0.3s ease forwards;
+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
File diff suppressed because one or more lines are too long
+26 -20
View File
@@ -30,28 +30,34 @@ This folder contains exportable n8n workflow definitions for the MC Cars qualifi
## Setup Instructions
### 1. Create Postgres credential in n8n
- **Name:** `MC Cars Postgres`
- **Host:** `db`
- **Port:** `5432`
- **Database:** `postgres`
- **User:** `postgres`
- **Password:** (value of `POSTGRES_PASSWORD` from `.env`)
### 1. Configure `.env`
The stack now bootstraps n8n credentials/workflow automatically on every `docker compose up`.
### 2. Create SMTP credential in n8n
- **Name:** `MC Cars SMTP`
- **Host:** your SMTP server (e.g. `smtp.mailgun.org`, `mail.mc-cars.at`)
- **Port:** `587` (TLS) or `465` (SSL)
- **User:** your SMTP username
- **Password:** your SMTP password
- **From:** `info@mc-cars.at`
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`
### 3. Import workflows
1. Open n8n at http://localhost:55590
2. Go to **Workflows****Import from file**
3. Import `01-qualification-payment-email.json`
4. Import `02-mietvertrag-pdf-email.json`
5. Open each workflow → assign the credentials created above → **Activate**
### 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
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: process.env.APP_URL || 'http://localhost:55580',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+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
@@ -0,0 +1,396 @@
-- 11-consolidate-km-rental.sql
-- Consolidate km/rental model: new included_km_per_day, rental_type,
-- rewrite calculate_price / create_lead / qualify_lead / notify_lead_qualified,
-- add sales_order_set_total RPC.
-- Idempotent.
-- =============================================================================
-- A. Vehicles table changes
-- =============================================================================
alter table public.vehicles add column if not exists included_km_per_day integer not null default 150;
update public.vehicles set included_km_per_day = coalesce(max_daily_km, 150) where included_km_per_day = 150;
update public.vehicles set included_km_per_day = 200 where brand = 'Ferrari' and model = '296 GTB';
alter table public.vehicles add column if not exists price_per_km_eur numeric(10,2) not null default 1.50;
alter table public.vehicles drop column if exists max_daily_km;
alter table public.vehicles drop column if exists max_km_weekend;
-- =============================================================================
-- B. Leads table changes
-- =============================================================================
alter table public.leads add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
update public.leads set rental_type = 'weekend' where rental_type is null;
create index if not exists leads_rental_type_idx on public.leads (rental_type);
-- =============================================================================
-- C. Sales orders table changes
-- =============================================================================
alter table public.sales_orders add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
update public.sales_orders set rental_type = 'weekend' where rental_type is null;
create index if not exists sales_orders_rental_type_idx on public.sales_orders (rental_type);
-- =============================================================================
-- D. Rewrite calculate_price() RPC
-- =============================================================================
drop function if exists public.calculate_price(uuid, date, date);
create or replace function public.calculate_price(
p_vehicle_id uuid,
p_date_from date,
p_date_to date
)
returns jsonb
language plpgsql
stable
security definer
as $$
declare
v_vehicle record;
v_total_days integer;
v_weekend_days integer;
v_weekdays integer;
v_daily_subtotal integer;
v_weekend_subtotal integer;
v_subtotal_eur integer;
v_vat_eur integer;
v_total_eur integer;
v_deposit_eur integer;
v_included_km_per_day integer;
v_price_per_km numeric(10,2);
v_total_included_km integer;
v_extra_km integer;
v_extra_km_eur numeric(10,2);
v_cur date;
v_dow integer;
begin
if p_vehicle_id is null or p_date_from is null or p_date_to is null then
raise exception 'vehicle_id, date_from and date_to are required';
end if;
if p_date_to <= p_date_from then
raise exception 'date_to must be after date_from';
end if;
select daily_price_eur, weekend_price_eur, kaution_eur, included_km_per_day, price_per_km_eur
into v_vehicle
from public.vehicles
where id = p_vehicle_id;
if not found then
raise exception 'Vehicle not found';
end if;
v_total_days := (p_date_to - p_date_from);
v_weekend_days := 0;
v_cur := p_date_from;
while v_cur < p_date_to loop
v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun
if v_dow in (6, 7) then
v_weekend_days := v_weekend_days + 1;
end if;
v_cur := v_cur + 1;
end loop;
v_weekdays := v_total_days - v_weekend_days;
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
v_vat_eur := round(v_subtotal_eur * 0.20);
v_total_eur := v_subtotal_eur + v_vat_eur;
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
v_included_km_per_day := coalesce(v_vehicle.included_km_per_day, 150);
v_total_included_km := v_total_days * v_included_km_per_day;
v_price_per_km := coalesce(v_vehicle.price_per_km_eur, 1.50);
v_extra_km := greatest(0, 0); -- extra km is determined by caller (frontend) based on expected usage
v_extra_km_eur := v_extra_km * v_price_per_km;
return jsonb_build_object(
'total_days', v_total_days,
'weekday_count', v_weekdays,
'weekend_day_count', v_weekend_days,
'daily_subtotal', v_daily_subtotal,
'weekend_subtotal', v_weekend_subtotal,
'subtotal_eur', v_subtotal_eur,
'vat_eur', v_vat_eur,
'total_eur', v_total_eur,
'deposit_eur', v_deposit_eur,
'daily_price_eur', v_vehicle.daily_price_eur,
'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end),
'included_km_per_day', v_included_km_per_day,
'total_included_km', v_total_included_km,
'price_per_km_eur', v_price_per_km,
'extra_km', v_extra_km,
'extra_km_eur', v_extra_km_eur
);
end;
$$;
grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role;
-- =============================================================================
-- E. Rewrite create_lead() RPC
-- =============================================================================
drop function if exists public.create_lead(
text, text, text, uuid, text, date, date, text, text
);
drop function if exists public.create_lead(
text, text, text, uuid, text, date, date, text, text,
integer, integer, integer, integer, integer, integer, integer, integer, integer,
text, text
);
create or replace function public.create_lead(
p_name text,
p_email text,
p_phone text default '',
p_vehicle_id uuid default null,
p_vehicle_label text default '',
p_date_from date default null,
p_date_to date default null,
p_message text default '',
p_source text default 'website',
p_ip_address text default '',
p_ip_country text default ''
)
returns uuid
language plpgsql
security definer
as $$
declare
v_lead_id uuid;
v_vehicle record;
v_total_days integer := 0;
v_weekend_days integer := 0;
v_weekdays integer := 0;
v_daily_subtotal integer := 0;
v_weekend_subtotal integer := 0;
v_subtotal_eur integer := 0;
v_vat_eur integer := 0;
v_total_eur integer := 0;
v_deposit_eur integer := 0;
v_rental_type text := 'weekend';
v_cur date;
v_dow integer;
begin
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
select daily_price_eur, weekend_price_eur, kaution_eur
into v_vehicle
from public.vehicles
where id = p_vehicle_id;
if found then
v_total_days := (p_date_to - p_date_from);
-- Auto-detect rental type: 2 days or less = weekend, more = individuell
v_rental_type := 'weekend';
if v_total_days > 2 then
v_rental_type := 'individuell';
end if;
-- For individuell, set all pricing to 0
if v_rental_type = 'individuell' then
v_daily_subtotal := 0;
v_weekend_subtotal := 0;
v_subtotal_eur := 0;
v_vat_eur := 0;
v_total_eur := 0;
v_deposit_eur := 0;
else
v_cur := p_date_from;
while v_cur < p_date_to loop
v_dow := extract(isodow from v_cur);
if v_dow in (6, 7) then
v_weekend_days := v_weekend_days + 1;
end if;
v_cur := v_cur + 1;
end loop;
v_weekdays := v_total_days - v_weekend_days;
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
v_vat_eur := round(v_subtotal_eur * 0.20);
v_total_eur := v_subtotal_eur + v_vat_eur;
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
end if;
end if;
end if;
insert into public.leads (
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
message, source,
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
rental_type
) values (
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
p_message, p_source,
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
v_rental_type
)
returning id into v_lead_id;
return v_lead_id;
end;
$$;
grant execute on function public.create_lead(
text, text, text, uuid, text, date, date, text, text, text, text
) to anon, authenticated, service_role;
-- =============================================================================
-- F. Rewrite qualify_lead() RPC
-- =============================================================================
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
returns public.customers
language plpgsql
security invoker
as $$
declare
v_lead public.leads;
v_customer public.customers;
v_sales_order public.sales_orders;
v_user uuid := auth.uid();
v_order_num text;
v_year integer;
v_count integer;
begin
select * into v_lead from public.leads where id = p_lead_id for update;
if not found then
raise exception 'lead % not found', p_lead_id;
end if;
if v_lead.status = 'qualified' then
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
return v_customer;
end if;
update public.leads
set status = 'qualified',
is_active = false,
qualified_at = now(),
qualified_by = v_user,
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
where id = v_lead.id;
insert into public.customers (lead_id, name, email, phone, notes, created_by)
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
on conflict ((lower(email))) do update
set name = excluded.name,
phone = excluded.phone,
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
updated_at = now()
returning * into v_customer;
v_year := extract(year from now())::integer;
select coalesce(count(*), 0) + 1 into v_count
from public.sales_orders
where extract(year from created_at)::integer = v_year;
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
insert into public.sales_orders (
customer_id, lead_id, order_number, private_notes,
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
total_days, weekday_count, weekend_day_count,
date_from, date_to, vehicle_label, rental_type
) values (
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
coalesce(v_lead.weekend_day_count, 0),
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type
) returning * into v_sales_order;
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
from public.lead_attachments la
where la.lead_id = v_lead.id
and not exists (
select 1 from public.customer_attachments ca
where ca.customer_id = v_customer.id
and ca.file_path = la.file_path
);
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
from public.lead_attachments la
where la.lead_id = v_lead.id;
return v_customer;
end;
$$;
-- =============================================================================
-- G. Rewrite notify_lead_qualified() trigger function
-- =============================================================================
create or replace function public.notify_lead_qualified()
returns trigger
language plpgsql
security definer
as $$
begin
-- Skip notification for 'individuell' rental type
if NEW.rental_type = 'individuell' then
return NEW;
end if;
perform pg_notify('lead_qualified', json_build_object(
'sales_order_id', NEW.id,
'customer_id', NEW.customer_id,
'lead_id', NEW.lead_id,
'order_number', NEW.order_number,
'total_eur', NEW.total_eur,
'deposit_eur', NEW.deposit_eur,
'date_from', NEW.date_from,
'date_to', NEW.date_to,
'vehicle_label', NEW.vehicle_label,
'rental_type', NEW.rental_type
)::text);
return NEW;
end;
$$;
-- =============================================================================
-- H. New RPC: sales_order_set_total
-- =============================================================================
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
returns void
language plpgsql
security invoker
as $$
declare
v_so public.sales_orders;
begin
select * into v_so from public.sales_orders where id = p_so_id for update;
if not found then
raise exception 'sales order % not found', p_so_id;
end if;
if v_so.rental_type != 'individuell' then
raise exception 'can only set total for individuell orders';
end if;
update public.sales_orders
set total_eur = p_total_eur, updated_at = now()
where id = p_so_id;
end;
$$;
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
-- =============================================================================
-- I. Final schema reload
-- =============================================================================
notify pgrst, 'reload schema';
@@ -0,0 +1,209 @@
-- 12-email-sent-and-more.sql
-- Add email_sent column to sales_orders, update notify_lead_qualified() to include
-- rental_type and email_sent, update qualify_lead() to set email_sent=0,
-- add sales_order_update_email_sent and sales_order_get_email_details RPCs.
-- Idempotent.
-- =============================================================================
-- A. Add email_sent to sales_orders
-- =============================================================================
alter table public.sales_orders add column if not exists email_sent integer not null default 0;
create index if not exists sales_orders_email_sent_idx on public.sales_orders (email_sent);
-- =============================================================================
-- B. Update notify_lead_qualified() trigger function
-- (defined in 10-mietvertrag-workflow.sql, overridden by 11-consolidate-km-rental.sql)
-- Since migration 12 runs after 11, this is the final version.
-- =============================================================================
create or replace function public.notify_lead_qualified()
returns trigger
language plpgsql
security definer
as $$
begin
-- Skip notification for 'individuell' rental type
if NEW.rental_type = 'individuell' then
return NEW;
end if;
perform pg_notify('lead_qualified', json_build_object(
'sales_order_id', NEW.id,
'customer_id', NEW.customer_id,
'lead_id', NEW.lead_id,
'order_number', NEW.order_number,
'total_eur', NEW.total_eur,
'deposit_eur', NEW.deposit_eur,
'date_from', NEW.date_from,
'date_to', NEW.date_to,
'vehicle_label', NEW.vehicle_label,
'rental_type', NEW.rental_type,
'email_sent', NEW.email_sent
)::text);
return NEW;
end;
$$;
-- =============================================================================
-- C. Update qualify_lead() RPC
-- Add email_sent = 0 to the sales_orders insert.
-- =============================================================================
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
returns public.customers
language plpgsql
security invoker
as $$
declare
v_lead public.leads;
v_customer public.customers;
v_sales_order public.sales_orders;
v_user uuid := auth.uid();
v_order_num text;
v_year integer;
v_count integer;
begin
select * into v_lead from public.leads where id = p_lead_id for update;
if not found then
raise exception 'lead % not found', p_lead_id;
end if;
if v_lead.status = 'qualified' then
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
return v_customer;
end if;
update public.leads
set status = 'qualified',
is_active = false,
qualified_at = now(),
qualified_by = v_user,
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
where id = v_lead.id;
insert into public.customers (lead_id, name, email, phone, notes, created_by)
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
on conflict ((lower(email))) do update
set name = excluded.name,
phone = excluded.phone,
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
updated_at = now()
returning * into v_customer;
v_year := extract(year from now())::integer;
select coalesce(count(*), 0) + 1 into v_count
from public.sales_orders
where extract(year from created_at)::integer = v_year;
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
insert into public.sales_orders (
customer_id, lead_id, order_number, private_notes,
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
total_days, weekday_count, weekend_day_count,
date_from, date_to, vehicle_label, rental_type, email_sent
) values (
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
coalesce(v_lead.weekend_day_count, 0),
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type, 0
) returning * into v_sales_order;
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
from public.lead_attachments la
where la.lead_id = v_lead.id
and not exists (
select 1 from public.customer_attachments ca
where ca.customer_id = v_customer.id
and ca.file_path = la.file_path
);
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
from public.lead_attachments la
where la.lead_id = v_lead.id;
return v_customer;
end;
$$;
-- =============================================================================
-- D. New RPC: sales_order_update_email_sent
-- =============================================================================
create or replace function public.sales_order_update_email_sent(p_so_id uuid, p_status integer)
returns void
language plpgsql
security invoker
as $$
declare
v_so public.sales_orders;
begin
select * into v_so from public.sales_orders where id = p_so_id for update;
if not found then
raise exception 'sales order % not found', p_so_id;
end if;
if p_status not in (0, 1, 2) then
raise exception 'invalid email_sent status: %', p_status;
end if;
update public.sales_orders
set email_sent = p_status, updated_at = now()
where id = p_so_id;
end;
$$;
grant execute on function public.sales_order_update_email_sent(uuid, integer) to authenticated;
-- =============================================================================
-- E. New RPC: sales_order_get_email_details
-- =============================================================================
create or replace function public.sales_order_get_email_details(p_so_id uuid)
returns jsonb
language plpgsql
security definer
as $$
declare
v_result jsonb;
begin
select jsonb_build_object(
'order_number', so.order_number,
'total_eur', so.total_eur,
'deposit_eur', so.deposit_eur,
'date_from', so.date_from,
'date_to', so.date_to,
'vehicle_label', so.vehicle_label,
'customer_name', c.name,
'customer_email', c.email,
'customer_phone', c.phone,
'daily_subtotal', so.daily_subtotal,
'weekend_subtotal', so.weekend_subtotal,
'subtotal_eur', so.subtotal_eur,
'vat_eur', so.vat_eur,
'total_days', so.total_days,
'weekday_count', so.weekday_count,
'weekend_day_count', so.weekend_day_count
) into v_result
from public.sales_orders so
join public.customers c on c.id = so.customer_id
where so.id = p_so_id;
if v_result is null then
raise exception 'sales order % not found', p_so_id;
end if;
return v_result;
end;
$$;
grant execute on function public.sales_order_get_email_details(uuid) to authenticated;
-- =============================================================================
-- F. Final schema reload
-- =============================================================================
notify pgrst, 'reload schema';
@@ -0,0 +1,232 @@
-- 13-rental-type-daily-and-email-guard.sql
-- Introduce explicit 'single_day' rental_type, normalize legacy values,
-- and harden auto-email guard for individuell rentals.
-- =============================================================================
-- A. Normalize and expand rental_type checks
-- =============================================================================
alter table public.leads drop constraint if exists leads_rental_type_check;
alter table public.sales_orders drop constraint if exists sales_orders_rental_type_check;
update public.leads
set rental_type = lower(trim(coalesce(rental_type, '')));
update public.sales_orders
set rental_type = lower(trim(coalesce(rental_type, '')));
update public.leads
set rental_type = 'individuell'
where rental_type in ('individual', 'custom');
update public.sales_orders
set rental_type = 'individuell'
where rental_type in ('individual', 'custom');
update public.leads
set rental_type = 'single_day'
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
update public.sales_orders
set rental_type = 'single_day'
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
-- Existing one-day bookings should be single_day.
update public.leads
set rental_type = 'single_day'
where rental_type = 'weekend'
and total_days = 1;
update public.sales_orders
set rental_type = 'single_day'
where rental_type = 'weekend'
and total_days = 1;
-- Two-day non-Saturday starts are effectively single_day rentals, not weekend packages.
update public.leads
set rental_type = 'single_day'
where rental_type = 'weekend'
and total_days = 2
and date_from is not null
and extract(isodow from date_from) <> 6;
update public.sales_orders
set rental_type = 'single_day'
where rental_type = 'weekend'
and total_days = 2
and date_from is not null
and extract(isodow from date_from) <> 6;
-- Fallback for any unexpected value.
update public.leads
set rental_type = 'weekend'
where rental_type not in ('single_day', 'weekend', 'individuell');
update public.sales_orders
set rental_type = 'weekend'
where rental_type not in ('single_day', 'weekend', 'individuell');
alter table public.leads
alter column rental_type set default 'weekend';
alter table public.sales_orders
alter column rental_type set default 'weekend';
alter table public.leads
add constraint leads_rental_type_check
check (rental_type in ('single_day', 'weekend', 'individuell'));
alter table public.sales_orders
add constraint sales_orders_rental_type_check
check (rental_type in ('single_day', 'weekend', 'individuell'));
-- =============================================================================
-- B. Harden notify_lead_qualified() against malformed rental_type values
-- =============================================================================
create or replace function public.notify_lead_qualified()
returns trigger
language plpgsql
security definer
as $$
declare
v_rental_type text := coalesce(lower(trim(NEW.rental_type)), 'weekend');
begin
-- Never auto-email individuell orders (including legacy synonyms).
if v_rental_type in ('individuell', 'individual', 'custom') then
return NEW;
end if;
perform pg_notify('lead_qualified', json_build_object(
'sales_order_id', NEW.id,
'customer_id', NEW.customer_id,
'lead_id', NEW.lead_id,
'order_number', NEW.order_number,
'total_eur', NEW.total_eur,
'deposit_eur', NEW.deposit_eur,
'date_from', NEW.date_from,
'date_to', NEW.date_to,
'vehicle_label', NEW.vehicle_label,
'rental_type', v_rental_type,
'email_sent', NEW.email_sent
)::text);
return NEW;
end;
$$;
-- =============================================================================
-- C. Update create_lead() classification logic to include daily
-- =============================================================================
create or replace function public.create_lead(
p_name text,
p_email text,
p_phone text default '',
p_vehicle_id uuid default null,
p_vehicle_label text default '',
p_date_from date default null,
p_date_to date default null,
p_message text default '',
p_source text default 'website',
p_ip_address text default '',
p_ip_country text default ''
)
returns uuid
language plpgsql
security definer
as $$
declare
v_lead_id uuid;
v_vehicle record;
v_total_days integer := 0;
v_weekend_days integer := 0;
v_weekdays integer := 0;
v_daily_subtotal integer := 0;
v_weekend_subtotal integer := 0;
v_subtotal_eur integer := 0;
v_vat_eur integer := 0;
v_total_eur integer := 0;
v_deposit_eur integer := 0;
v_rental_type text := 'weekend';
v_cur date;
v_dow integer;
begin
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
select daily_price_eur, weekend_price_eur, kaution_eur
into v_vehicle
from public.vehicles
where id = p_vehicle_id;
if found then
v_total_days := (p_date_to - p_date_from);
-- Classification:
-- 1 day => single_day
-- 2 days starting Saturday => weekend package
-- 2 days otherwise => single_day
-- > 2 days => individuell (manual processing)
if v_total_days > 2 then
v_rental_type := 'individuell';
elsif v_total_days = 1 then
v_rental_type := 'single_day';
elsif v_total_days = 2 and extract(isodow from p_date_from) = 6 then
v_rental_type := 'weekend';
elsif v_total_days = 2 then
v_rental_type := 'single_day';
else
v_rental_type := 'weekend';
end if;
if v_rental_type = 'individuell' then
v_daily_subtotal := 0;
v_weekend_subtotal := 0;
v_subtotal_eur := 0;
v_vat_eur := 0;
v_total_eur := 0;
v_deposit_eur := 0;
else
v_cur := p_date_from;
while v_cur < p_date_to loop
v_dow := extract(isodow from v_cur);
if v_dow in (6, 7) then
v_weekend_days := v_weekend_days + 1;
end if;
v_cur := v_cur + 1;
end loop;
v_weekdays := v_total_days - v_weekend_days;
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
v_vat_eur := round(v_subtotal_eur * 0.20);
v_total_eur := v_subtotal_eur + v_vat_eur;
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
end if;
end if;
end if;
insert into public.leads (
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
message, source,
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
rental_type
) values (
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
p_message, p_source,
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
v_rental_type
)
returning id into v_lead_id;
return v_lead_id;
end;
$$;
grant execute on function public.create_lead(
text, text, text, uuid, text, date, date, text, text, text, text
) to anon, authenticated, service_role;
notify pgrst, 'reload schema';
@@ -0,0 +1,29 @@
-- 14-sales-order-set-deposit.sql
-- Adds sales_order_set_deposit RPC for updating deposit from admin pricing tab.
-- =============================================================================
-- A. RPC: sales_order_set_deposit
-- =============================================================================
create or replace function public.sales_order_set_deposit(p_so_id uuid, p_deposit_eur integer)
returns void
language plpgsql
security invoker
as $$
begin
update public.sales_orders
set deposit_eur = p_deposit_eur, updated_at = now()
where id = p_so_id;
if not found then
raise exception 'sales order % not found', p_so_id;
end if;
end;
$$;
grant execute on function public.sales_order_set_deposit(uuid, integer) to authenticated;
-- =============================================================================
-- B. Schema reload
-- =============================================================================
notify pgrst, 'reload schema';
@@ -0,0 +1,61 @@
-- Ensure individuell orders persist net/vat components when total is manually set
-- and backfill existing records where these fields are still zero.
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
returns void
language plpgsql
security invoker
as $$
declare
v_so public.sales_orders;
v_subtotal_eur integer := 0;
v_vat_eur integer := 0;
begin
select * into v_so from public.sales_orders where id = p_so_id for update;
if not found then
raise exception 'sales order % not found', p_so_id;
end if;
if v_so.rental_type != 'individuell' then
raise exception 'can only set total for individuell orders';
end if;
if coalesce(p_total_eur, 0) < 0 then
raise exception 'total must be >= 0';
end if;
if p_total_eur > 0 then
v_subtotal_eur := round(p_total_eur / 1.2);
v_vat_eur := p_total_eur - v_subtotal_eur;
end if;
update public.sales_orders
set total_eur = p_total_eur,
subtotal_eur = v_subtotal_eur,
vat_eur = v_vat_eur,
daily_subtotal = v_subtotal_eur,
weekend_subtotal = 0,
weekday_count = coalesce(total_days, 0),
weekend_day_count = 0,
updated_at = now()
where id = p_so_id;
end;
$$;
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
-- Backfill already existing individuell orders with missing net/vat split.
update public.sales_orders
set subtotal_eur = round(total_eur / 1.2),
vat_eur = total_eur - round(total_eur / 1.2),
daily_subtotal = round(total_eur / 1.2),
weekend_subtotal = 0,
weekday_count = coalesce(total_days, 0),
weekend_day_count = 0,
updated_at = now()
where rental_type = 'individuell'
and coalesce(total_eur, 0) > 0
and coalesce(subtotal_eur, 0) = 0
and coalesce(vat_eur, 0) = 0;
notify pgrst, 'reload schema';
@@ -0,0 +1,28 @@
-- 16-rental-type-weekend-gap-fix.sql
-- Fix misclassified long rentals that were stored as 'weekend'.
-- Business rule: only true weekend package may remain 'weekend'; long spans are 'individuell'.
-- Leads: any weekend booking longer than 2 days must be individuell.
update public.leads
set rental_type = 'individuell'
where coalesce(lower(trim(rental_type)), 'weekend') = 'weekend'
and coalesce(total_days, 0) > 2;
-- Sales orders: same correction.
update public.sales_orders
set rental_type = 'individuell'
where coalesce(lower(trim(rental_type)), 'weekend') = 'weekend'
and coalesce(total_days, 0) > 2;
-- If old rows have unknown/legacy values and >2 days, normalize to individuell as well.
update public.leads
set rental_type = 'individuell'
where coalesce(total_days, 0) > 2
and coalesce(lower(trim(rental_type)), '') not in ('individuell', 'weekend', 'single_day');
update public.sales_orders
set rental_type = 'individuell'
where coalesce(total_days, 0) > 2
and coalesce(lower(trim(rental_type)), '') not in ('individuell', 'weekend', 'single_day');
notify pgrst, 'reload schema';
+91
View File
@@ -0,0 +1,91 @@
-- 17-vehicle-photos.sql
-- Idempotent migration: add vehicle_photos table for multiple photos per vehicle.
-- Each vehicle can have multiple photos with ordering support.
-- Create vehicle_photos table
create table if not exists public.vehicle_photos (
id uuid primary key default gen_random_uuid(),
vehicle_id uuid not null references public.vehicles(id) on delete cascade,
photo_url text not null default '',
photo_path text not null,
display_order integer not null default 0,
is_primary boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists vehicle_photos_vehicle_id_idx
on public.vehicle_photos(vehicle_id, display_order);
-- Enable RLS
alter table public.vehicle_photos enable row level security;
-- Drop existing policies to ensure idempotency
drop policy if exists "vehicle_photos_public_read" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_read" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_insert" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_delete" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_update" on public.vehicle_photos;
-- Public can read all photos
create policy "vehicle_photos_public_read"
on public.vehicle_photos for select
to anon using (true);
-- Authenticated (admin) full access
create policy "vehicle_photos_admin_read"
on public.vehicle_photos for select
to authenticated using (true);
create policy "vehicle_photos_admin_insert"
on public.vehicle_photos for insert
to authenticated with check (true);
create policy "vehicle_photos_admin_update"
on public.vehicle_photos for update
to authenticated using (true) with check (true);
create policy "vehicle_photos_admin_delete"
on public.vehicle_photos for delete
to authenticated using (true);
-- Grants
grant select on public.vehicle_photos to anon, authenticated;
grant insert, update, delete on public.vehicle_photos to authenticated;
grant all on public.vehicle_photos to service_role;
-- Migrate existing vehicle photo_url/photo_path to vehicle_photos table
-- This ensures existing vehicles get their photo into the new table
insert into public.vehicle_photos (vehicle_id, photo_url, photo_path, display_order, is_primary)
select id, photo_url, coalesce(photo_path, 'legacy'), 0, true
from public.vehicles
where photo_url != '' and photo_path is not null
on conflict do nothing;
-- RPC: set primary photo for a vehicle (unsets others)
create or replace function public.set_primary_vehicle_photo(
p_vehicle_id uuid,
p_photo_id uuid
) returns void
language plpgsql security invoker as $$
begin
update public.vehicle_photos set is_primary = false where vehicle_id = p_vehicle_id;
update public.vehicle_photos set is_primary = true where id = p_photo_id and vehicle_id = p_vehicle_id;
end;
$$;
-- RPC: re-order photos for a vehicle
create or replace function public.reorder_vehicle_photos(
p_vehicle_id uuid,
p_photo_orders jsonb -- [{id: uuid, order: int}, ...]
) returns void
language plpgsql security invoker as $$
declare
rec jsonb;
begin
for rec in select * from jsonb_array_elements(p_photo_orders) loop
update public.vehicle_photos
set display_order = (rec->>'order')::int
where id = (rec->>'id')::uuid and vehicle_id = p_vehicle_id;
end loop;
end;
$$;
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"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 |
+255
View File
@@ -0,0 +1,255 @@
import { test, expect } from '@playwright/test';
test.describe('Booking Flow End-to-End', () => {
const ADMIN_URL = 'http://localhost:55581';
const ADMIN_EMAIL = 'admin@mccars.local';
const ADMIN_PASSWORD = 'mc-cars-admin';
// Generate unique test data per run to avoid conflicts
const ts = Date.now();
const testEmails = [
`test-day-${ts}@playwright.test`,
`test-weekend-${ts}@playwright.test`,
`test-custom-${ts}@playwright.test`,
];
const testNames = [
'Test Testerson Day',
'Test Testerson Weekend',
'Test Testerson Custom',
];
/**
* Helper: fill out the booking form for a given mietdauer type.
* Returns nothing - the form submission is handled by the page's JS.
*/
async function submitBooking(page, type, index) {
// Scroll to booking section
await page.locator('#buchen').scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
// Step 1: Select vehicle
const carSelect = page.locator('#bpfCar');
await expect(carSelect).toBeVisible({ timeout: 10000 });
// Select first available vehicle option (skip the placeholder)
const options = await carSelect.locator('option').all();
expect(options.length).toBeGreaterThan(1);
const firstVehicle = await options[1].innerText();
await carSelect.selectOption({ label: firstVehicle });
// Step 2: Select mietdauer type
const presetBtn = page.locator(`.bpf-preset[data-preset="${type}"]`);
await expect(presetBtn).toBeVisible();
await presetBtn.click();
// Step 3: Pick date(s) based on type
if (type === 'day') {
// Pick a date 7 days from now
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 7);
const dateStr = futureDate.toISOString().split('T')[0];
const dateInput = page.locator('#bpfDayDate');
await dateInput.fill(dateStr);
} else if (type === 'weekend') {
// Pick next Saturday
const nextSaturday = new Date();
const daysUntilSaturday = (6 - nextSaturday.getDay() + 7) % 7 || 7;
nextSaturday.setDate(nextSaturday.getDate() + daysUntilSaturday);
const dateStr = nextSaturday.toISOString().split('T')[0];
const dateInput = page.locator('#bpfWeekendDate');
await dateInput.fill(dateStr);
} else if (type === 'custom') {
// Pick start date 14 days from now, end date 17 days from now (4 days = individuell)
const startDate = new Date();
startDate.setDate(startDate.getDate() + 14);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 3);
const fromStr = startDate.toISOString().split('T')[0];
const toStr = endDate.toISOString().split('T')[0];
await page.locator('#bpfFrom').fill(fromStr);
await page.locator('#bpfTo').fill(toStr);
}
// Click Weiter to go to step 2
await page.locator('#bpfNext1').click();
await page.waitForTimeout(300);
// Step 2: Fill contact info
await expect(page.locator('#bpfName')).toBeVisible();
await page.locator('#bpfName').fill(testNames[index]);
await page.locator('#bpfEmail').fill(testEmails[index]);
await page.locator('#bpfPhone').fill('+43 660 1234567');
await page.locator('#bpfMessage').fill(`Test booking via playwright - ${type}`);
// Click Weiter to go to step 3
await page.locator('#bpfNext2').click();
await page.waitForTimeout(300);
// Step 3: Submit (skip file uploads - they are optional)
await expect(page.locator('#bpfSubmit')).toBeVisible();
await page.locator('#bpfSubmit').click();
// Wait for success toast
await expect(page.locator('#toast.show')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(1000);
}
test('Complete booking flow: 1 Tag, Wochenende, Individuell → 3 leads in admin → disqualify all', async ({ page, context }) => {
// ========================================
// PART 1: Submit 3 bookings on main site
// ========================================
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(2000);
// Booking 1: 1 Tag
await submitBooking(page, 'day', 0);
// Booking 2: Wochenende
await submitBooking(page, 'weekend', 1);
// Booking 3: Individuell
await submitBooking(page, 'custom', 2);
// ========================================
// PART 2: Verify 3 leads in admin panel
// ========================================
const adminCtx = await test.info().project.use.baseBrowserType?.newContext() ?? context;
const adminPage = await adminCtx.newPage();
adminPage.setDefaultTimeout(30000);
await adminPage.goto(ADMIN_URL);
await adminPage.waitForLoadState('domcontentloaded');
await adminPage.waitForTimeout(2000);
// Login
const loginForm = adminPage.locator('#loginForm');
await expect(loginForm).toBeVisible({ timeout: 10000 });
await adminPage.locator('#loginForm [name="email"]').fill(ADMIN_EMAIL);
await adminPage.locator('#loginForm [name="password"]').fill(ADMIN_PASSWORD);
await adminPage.locator('#loginForm [type="submit"]').click();
// Wait a moment for login to process
await adminPage.waitForTimeout(3000);
// Check for login error
const loginError = adminPage.locator('#loginError');
if (await loginError.isVisible()) {
const errorMsg = await loginError.textContent();
throw new Error(`Login failed: ${errorMsg}`);
}
// Check if password rotation is required (first login)
const rotateView = adminPage.locator('#rotateView');
if (await rotateView.isVisible({ timeout: 2000 })) {
// Set a new password (must be different from bootstrap)
const newPw = 'Playwright-Test-PW-2026!';
await adminPage.locator('#rotateForm [name="pw1"]').fill(newPw);
await adminPage.locator('#rotateForm [name="pw2"]').fill(newPw);
await adminPage.locator('#rotateForm [type="submit"]').click();
await adminPage.waitForTimeout(2000);
}
// Wait for admin view to load
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
await adminPage.waitForTimeout(2000);
// Ensure leads tab is active (it's the default)
const leadsTab = adminPage.locator('[data-tab="leads"]');
const leadsTabClass = await leadsTab.getAttribute('class');
if (!leadsTabClass?.includes('active')) {
await leadsTab.click();
await adminPage.waitForTimeout(1000);
}
// Wait for our test leads to appear by checking for their emails in the table
// We wait for at least one of our test emails to appear, then verify all 3
await adminPage.waitForFunction(
([emails]) => {
const rows = document.querySelectorAll('#leadsTable tbody tr');
let found = 0;
for (const row of rows) {
const text = row.textContent;
for (const email of emails) {
if (text.includes(email)) {
found++;
break;
}
}
}
return found >= 3;
},
testEmails,
{ timeout: 30000 }
);
await adminPage.waitForTimeout(1000);
// Find our test leads by email pattern
const allRows = adminPage.locator('#leadsTable tbody tr');
const totalRows = await allRows.count();
const testRowIndices = [];
for (let i = 0; i < totalRows; i++) {
const row = allRows.nth(i);
const rowText = await row.textContent();
if (testEmails.some(email => rowText.includes(email))) {
testRowIndices.push(i);
}
}
expect(testRowIndices.length).toBe(3);
// ========================================
// PART 3: Disqualify all 3 test leads
// ========================================
// Disqualify each lead one at a time, re-finding it after each disqualification
// since the table re-renders and indices shift.
for (const email of testEmails) {
// Find the row for this email
const rows = adminPage.locator('#leadsTable tbody tr');
const count = await rows.count();
let found = false;
for (let i = 0; i < count; i++) {
const rowText = await rows.nth(i).textContent();
if (rowText.includes(email)) {
// Click disqualify button
const disqBtn = rows.nth(i).locator('[data-disq]');
if (await disqBtn.isVisible()) {
await disqBtn.click();
await adminPage.waitForTimeout(1500);
found = true;
break;
}
}
}
expect(found).toBe(true, `Lead with email ${email} not found or could not be disqualified`);
}
// Wait for disqualifications to process
await adminPage.waitForTimeout(2000);
// Refresh page to ensure fresh data after disqualifications
await adminPage.reload();
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
await adminPage.waitForTimeout(3000);
// Verify our test leads are now disqualified (no longer in active view)
const remainingRows = adminPage.locator('#leadsTable tbody tr');
const remainingCount = await remainingRows.count();
let foundTestLead = false;
for (let i = 0; i < remainingCount; i++) {
const rowText = await remainingRows.nth(i).textContent();
if (testEmails.some(email => rowText.includes(email))) {
foundTestLead = true;
break;
}
}
expect(foundTestLead).toBe(false);
await adminPage.close();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
test.describe('Legal Pages - Warum wir removed', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
});
test('Impressum page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/impressum.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('AGB page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/agb.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('Datenschutz page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/datenschutz.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('Mietbedingungen page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/mietbedingungen.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('All legal pages - other nav links present', async ({ page }) => {
await page.goto('/impressum.html');
const nav = page.getByLabel('Hauptnavigation');
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
test.describe('MC Cars - Customer Changes Verification', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
});
test('Page loads successfully', async ({ page }) => {
await expect(page).toHaveTitle(/MC Cars/);
});
test('Hero section - Flotte ansehen button removed', async ({ page }) => {
await expect(page.getByText('Flotte ansehen')).not.toBeVisible();
await expect(page.getByText('View fleet')).not.toBeVisible();
});
test('Hero section - 24/7 Support stat removed', async ({ page }) => {
await expect(page.getByText('24/7')).not.toBeVisible();
});
test('Hero section - Faire Kaution stat still visible', async ({ page }) => {
const kautionStat = page.getByText('Faire Kaution', { exact: true });
await expect(kautionStat).toBeVisible();
});
test('Hero section - Fahrzeuge stat still visible', async ({ page }) => {
const vehiclesSection = page.locator('.hero-stats');
await expect(vehiclesSection).toBeVisible();
});
test('Fleet section - Unsere Flotte eyebrow removed', async ({ page }) => {
await expect(page.getByText('Unsere Flotte')).not.toBeVisible();
await expect(page.getByText('Our Fleet')).not.toBeVisible();
});
test('Fleet section - Title still visible', async ({ page }) => {
await expect(page.getByText('Handverlesen. Gepflegt. Startklar.')).toBeVisible();
});
test('Navigation - Warum wir link removed', async ({ page }) => {
await expect(page.getByText('Warum wir')).not.toBeVisible();
await expect(page.getByText('Why us')).not.toBeVisible();
});
test('Navigation - Other links still present', async ({ page }) => {
const nav = page.getByLabel('Hauptnavigation');
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
await expect(nav.getByRole('link', { name: 'Stimmen' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
await expect(nav.getByRole('link', { name: 'Jetzt buchen' })).toBeVisible();
});
test('Reviews - Ferrari references in reviews', async ({ page }) => {
await page.locator('#stimmen').scrollIntoViewIfNeeded();
const reviewsSection = page.locator('#stimmen');
await expect(reviewsSection).toBeVisible();
});
test('Reviews - GT3 references removed', async ({ page }) => {
await expect(page.getByText('GT3')).not.toBeVisible();
});
test('Footer - correct content', async ({ page }) => {
await expect(page.getByText('Rechtliches')).toBeVisible();
await expect(page.getByText('Impressum')).toBeVisible();
await expect(page.getByText('Datenschutz')).toBeVisible();
await expect(page.getByText('hello@mc-cars.at')).toBeVisible();
});
test('Footer - Steiermark reference updated', async ({ page }) => {
await expect(page.getByText('Made in Steiermark')).toBeVisible();
});
test('Language toggle works', async ({ page }) => {
const langToggle = page.locator('.lang-toggle');
await expect(langToggle).toBeVisible();
// Switch to English
await langToggle.click();
await page.waitForTimeout(500);
await expect(langToggle).toHaveText('DE');
await expect(page.getByText('Drive at the highest level.')).toBeVisible();
// Switch back to German
await langToggle.click();
await page.waitForTimeout(500);
await expect(langToggle).toHaveText('EN');
await expect(page.getByRole('heading', { name: /Niveau/ })).toBeVisible();
});
test('Fleet section - vehicle cards visible', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const vehicleCards = page.locator('.vehicle-card');
await expect(vehicleCards.first()).toBeVisible();
});
test('Booking section visible', async ({ page }) => {
await page.locator('#buchen').scrollIntoViewIfNeeded();
await expect(page.getByRole('heading', { name: 'Jetzt buchen' })).toBeVisible();
});
test('SEO title updated', async ({ page }) => {
const title = await page.title();
expect(title).toContain('Ferrari');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
test.describe('Photo Gallery Feature', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
});
test('Vehicle cards render correctly', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const cards = page.locator('.vehicle-card');
await expect(cards.first()).toBeVisible();
});
test('Vehicle card has photo', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const firstPhoto = page.locator('.vehicle-card').first().locator('.vehicle-photo img');
await expect(firstPhoto).toBeVisible();
const src = await firstPhoto.getAttribute('src');
expect(src).not.toBeNull();
expect(src).not.toBe('');
});
test('Vehicle details dialog opens', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const detailsBtn = page.locator('[data-details]').first();
if (await detailsBtn.isVisible()) {
await detailsBtn.click();
const dialog = page.locator('#carDialog');
await expect(dialog).toBeVisible();
}
});
test('Booking wizard - vehicle selector works', async ({ page }) => {
await page.locator('#buchen').scrollIntoViewIfNeeded();
const carSelect = page.locator('#bpfCar');
await expect(carSelect).toBeVisible();
});
});