Compare commits
37 Commits
9de88a5459
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc902768a1 | |||
| 94eac68da4 | |||
| 557528d85a | |||
| 2f90534877 | |||
| 28db852453 | |||
| 331d0557b0 | |||
| 287629878b | |||
| cec51d6c19 | |||
| 9bc08d994c | |||
| 8be7d5aad2 | |||
| e1f6bd56b0 | |||
| e4bdd85518 | |||
| b4c6a47ce8 | |||
| 597d47f824 | |||
| 44dbf6b93c | |||
| 75b338988d | |||
| 387d2ba2ab | |||
| 3ec79e1923 | |||
| f46ba8cadc | |||
| e34d56e36a | |||
| e24bc743e2 | |||
| 32580781c8 | |||
| d5a219bd50 | |||
| d6713e25f9 | |||
| b21b3937b2 | |||
| f440f88725 | |||
| 408a59bd5c | |||
| 652131a285 | |||
| e986121240 | |||
| bd906dbe15 | |||
| 05de6cc9a4 | |||
| fae2c0120e | |||
| 54d9cdcdc9 | |||
| db4001aaa5 | |||
| b4258edb91 | |||
| aca60696ae | |||
| 926950bd62 |
@@ -36,11 +36,21 @@ ENABLE_EMAIL_SIGNUP=true
|
|||||||
ENABLE_EMAIL_AUTOCONFIRM=true
|
ENABLE_EMAIL_AUTOCONFIRM=true
|
||||||
ENABLE_ANONYMOUS_USERS=false
|
ENABLE_ANONYMOUS_USERS=false
|
||||||
|
|
||||||
# ---- SMTP (dummy; real values needed only to send password-reset mail) ----
|
# ---- SMTP / IMAP (MC Cars mailbox) ----
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=heracles.mxrouting.net
|
||||||
SMTP_PORT=2500
|
SMTP_PORT=587
|
||||||
SMTP_USER=fake
|
SMTP_USER=office@mc-cars.at
|
||||||
SMTP_PASS=fake
|
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) ----
|
# ---- Admin BOOTSTRAP credentials (seeded on first DB init) ----
|
||||||
# The user is flagged must_change_password=true. The REAL working password
|
# 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_ENCRYPTION_KEY=mc-cars-n8n-encryption-key-change-me
|
||||||
N8N_USER_EMAIL=admin@mccars.local
|
N8N_USER_EMAIL=admin@mccars.local
|
||||||
N8N_USER_PASSWORD=McCars-N8n-Admin1
|
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
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Generated at container start by 99-config.sh — never commit
|
# Generated at container start by 99-config.sh — never commit
|
||||||
frontend/config.js
|
frontend/config.js
|
||||||
|
|
||||||
|
.playwright-mcp
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Production Deployment - n8n Webhook Routing
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Ensure your production environment has:
|
||||||
|
- `docker-compose.yml` and `docker-compose.local.yml` updated with new n8n webhook routing
|
||||||
|
- `supabase/kong.yml` updated with n8n webhook service
|
||||||
|
- `frontend/admin.js` updated with new sendOrderEmailDirect function
|
||||||
|
- Production domain configured (e.g., `your-domain.com`)
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### 1. Update Production Config
|
||||||
|
|
||||||
|
Edit `frontend/config.js` and replace `localhost:55521` with your production domain:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.MCCARS_CONFIG={
|
||||||
|
SUPABASE_URL:"https://your-domain.com",
|
||||||
|
SUPABASE_ANON_KEY:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
N8N_WEBHOOK_URL:"https://your-domain.com/webhook/manual-email-send"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
- `your-domain.com` with your actual production domain
|
||||||
|
- Keep the same ANON_KEY value
|
||||||
|
|
||||||
|
### 2. Optional: Configure WEBHOOK_DOMAIN
|
||||||
|
|
||||||
|
If you want n8n to know its public webhook URL (for n8n UI display), set environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export WEBHOOK_DOMAIN=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells n8n that webhooks are accessible at `https://your-domain.com/webhook/*` from the internet.
|
||||||
|
|
||||||
|
### 3. Deploy Updated Files
|
||||||
|
|
||||||
|
Push these files to production:
|
||||||
|
- `supabase/kong.yml` (updated with n8n webhook service)
|
||||||
|
- `docker-compose.yml` (updated WEBHOOK_URL variable syntax)
|
||||||
|
- `frontend/config.js` (updated with production domain)
|
||||||
|
- `frontend/admin.js` (updated sendOrderEmailDirect function)
|
||||||
|
|
||||||
|
### 4. Restart Stack on Production Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On production host
|
||||||
|
cd /mnt/user/appdata/mc-cars # or your deployment path
|
||||||
|
|
||||||
|
# Pull latest code
|
||||||
|
git pull origin dev # or your deployment branch
|
||||||
|
|
||||||
|
# Restart with new config
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Verify services are healthy
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify Webhook Routing
|
||||||
|
|
||||||
|
Test webhook from production domain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://your-domain.com/webhook/manual-email-send' \
|
||||||
|
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||||
|
-d 'sales_order_id=YOUR_ORDER_ID'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response: 200 OK with n8n workflow result
|
||||||
|
|
||||||
|
## Network Setup
|
||||||
|
|
||||||
|
Kong must be accessible from the internet:
|
||||||
|
- **Port 55521** exposed via reverse proxy (nginx/Apache) or firewall rule
|
||||||
|
- Domain DNS points to production server
|
||||||
|
- SSL certificate configured (recommended to use Kong's 8443 port with cert)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Failed to fetch" on send email button
|
||||||
|
|
||||||
|
1. Check Kong is routing webhook:
|
||||||
|
```bash
|
||||||
|
docker-compose exec kong curl -v http://n8n:5678/webhook/manual-email-send
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify Kong config loaded:
|
||||||
|
```bash
|
||||||
|
docker-compose logs kong | grep "n8n-webhooks"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check n8n workflow is active:
|
||||||
|
```bash
|
||||||
|
docker-compose logs n8n | grep "webhook"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
|
||||||
|
Ensure Kong's CORS plugin is enabled for `/webhook/` routes (should be in kong.yml):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plugins:
|
||||||
|
- name: cors
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook not triggering from browser
|
||||||
|
|
||||||
|
Verify in browser DevTools:
|
||||||
|
1. Network tab shows POST to `/webhook/manual-email-send`
|
||||||
|
2. Response status is 200 (not 404 or 500)
|
||||||
|
3. Check n8n logs for workflow execution
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rollback config.js to localhost for debugging
|
||||||
|
git checkout frontend/config.js
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Then fix and redeploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] Kong routing `/webhook/*` to n8n ✓
|
||||||
|
- [ ] Frontend config.js has production domain ✓
|
||||||
|
- [ ] Admin portal can reach Kong on correct port ✓
|
||||||
|
- [ ] Webhook accepts POST requests ✓
|
||||||
|
- [ ] n8n workflow triggers and sends email ✓
|
||||||
|
- [ ] Email appears in order record ✓
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# n8n Webhook Routing Configuration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
n8n is intentionally kept internal to the Docker network and **not exposed to the internet**. To allow the browser to trigger n8n workflows via webhooks, Kong (the API gateway) proxies webhook requests to the internal n8n service.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Kong (Port 55521) n8n (Port 5678, internal)
|
||||||
|
| | |
|
||||||
|
| POST /webhook/* | |
|
||||||
|
|----------------------> | (no strip_path) |
|
||||||
|
| | POST /webhook/* |
|
||||||
|
| |--------------------------> |
|
||||||
|
| | Webhook triggers |
|
||||||
|
| | workflow |
|
||||||
|
|<----- Response --------|<---------------------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Changes
|
||||||
|
|
||||||
|
### 1. Kong Configuration (`supabase/kong.yml`)
|
||||||
|
|
||||||
|
Added a new service to route webhook traffic to internal n8n:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: n8n-webhooks
|
||||||
|
url: http://n8n:5678/
|
||||||
|
routes:
|
||||||
|
- name: n8n-webhooks-all
|
||||||
|
strip_path: false
|
||||||
|
paths:
|
||||||
|
- /webhook/
|
||||||
|
plugins:
|
||||||
|
- name: cors
|
||||||
|
```
|
||||||
|
|
||||||
|
- `strip_path: false` ensures the full `/webhook/...` path is forwarded to n8n
|
||||||
|
- CORS plugin allows browser cross-origin requests (all origins for internal workflow triggers)
|
||||||
|
|
||||||
|
### 2. Docker Compose (`docker-compose.yml`)
|
||||||
|
|
||||||
|
**Kong service:**
|
||||||
|
- Added `n8n` to the `depends_on` list (waits for n8n to start before Kong)
|
||||||
|
|
||||||
|
**n8n service:**
|
||||||
|
- Updated `WEBHOOK_URL` environment variable to use `${WEBHOOK_DOMAIN:http://localhost:55590}/`
|
||||||
|
- This allows production deployments to override the default localhost URL
|
||||||
|
|
||||||
|
### 3. Frontend Configuration (`frontend/config.js`)
|
||||||
|
|
||||||
|
Updated the webhook URL configuration:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
N8N_WEBHOOK_URL: "/webhook/manual-email-send"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a **same-origin request** path that works for both:
|
||||||
|
- **Local:** `http://localhost:55521/webhook/manual-email-send` (Kong on port 55521)
|
||||||
|
- **Production:** `https://your-domain.com/webhook/manual-email-send`
|
||||||
|
|
||||||
|
### 4. Admin UI (`frontend/admin.js`)
|
||||||
|
|
||||||
|
Updated `sendOrderEmailDirect()` function to use the configured webhook URL directly:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const n8nUrl = window.MCCARS_CONFIG?.N8N_WEBHOOK_URL || "/webhook/manual-email-send";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### For Production Deployment:
|
||||||
|
|
||||||
|
1. **Update Kong configuration** by deploying the modified `supabase/kong.yml`
|
||||||
|
- Kong will automatically reload the config and start proxying `/webhook/*` requests
|
||||||
|
|
||||||
|
2. **Set environment variables** (in your `.env` file):
|
||||||
|
```bash
|
||||||
|
# Optional: Override n8n webhook domain (defaults to localhost)
|
||||||
|
WEBHOOK_DOMAIN=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
If not set, n8n will use the default `http://localhost:55590/` (only works internally)
|
||||||
|
|
||||||
|
3. **Deploy the updated code**:
|
||||||
|
- `frontend/config.js` with the new webhook URL
|
||||||
|
- `frontend/admin.js` with the updated sendOrderEmailDirect function
|
||||||
|
- `docker-compose.yml` with Kong n8n dependency
|
||||||
|
- `supabase/kong.yml` with the new n8n service
|
||||||
|
|
||||||
|
4. **Restart the stack**:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Local Development:
|
||||||
|
|
||||||
|
No special configuration needed:
|
||||||
|
- Kong is already on port 55521
|
||||||
|
- Browser requests to `/webhook/manual-email-send` will be proxied to internal n8n
|
||||||
|
- Works the same as production (same-origin requests)
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Browser action**: User clicks "Email senden" button in order dialog
|
||||||
|
2. **Browser request**: JavaScript POSTs to `/webhook/manual-email-send` (same origin)
|
||||||
|
3. **Kong routing**: Kong receives request, forwards to `http://n8n:5678/webhook/manual-email-send`
|
||||||
|
4. **n8n webhook**: n8n webhook listener triggers the manual-email-send workflow
|
||||||
|
5. **Workflow execution**: n8n fetches order data, builds email, sends via SMTP
|
||||||
|
6. **Response**: Workflow returns success/error response to browser
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Network isolation**: n8n remains internal, not exposed to internet
|
||||||
|
- **No authentication required**: Webhook path is open (can be restricted later if needed)
|
||||||
|
- **CORS enabled**: Allows browser requests to Kong
|
||||||
|
- **Kong isolation**: Kong is the only service exposed; internal services hidden behind it
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Failed to fetch" error in browser
|
||||||
|
|
||||||
|
1. Check Kong is routing properly:
|
||||||
|
```bash
|
||||||
|
# Test from inside docker network
|
||||||
|
docker-compose exec kong curl -v http://n8n:5678/webhook/manual-email-send
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify Kong config loaded:
|
||||||
|
```bash
|
||||||
|
docker-compose logs kong | grep "n8n-webhooks"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check n8n is running:
|
||||||
|
```bash
|
||||||
|
docker-compose logs n8n
|
||||||
|
```
|
||||||
|
|
||||||
|
### n8n workflow not triggering
|
||||||
|
|
||||||
|
1. Verify webhook path in n8n workflow (should be exactly `/webhook/manual-email-send`)
|
||||||
|
2. Check n8n logs for webhook errors:
|
||||||
|
```bash
|
||||||
|
docker-compose logs n8n | grep webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Kong configuration format: https://docs.konghq.com/deck/latest/
|
||||||
|
- n8n webhooks: https://docs.n8n.io/nodes/n8n-nodes-base.Webhook/
|
||||||
|
- Docker networking: https://docs.docker.com/engine/reference/commandline/network_connect/
|
||||||
@@ -16,12 +16,15 @@ Self-hosted Supabase stack + bilingual (DE/EN) public website + lead-management
|
|||||||
| `post-init` | `postgres:15-alpine` | Idempotent bootstrap: seed admin + migrations |
|
| `post-init` | `postgres:15-alpine` | Idempotent bootstrap: seed admin + migrations |
|
||||||
| `kong` | `kong:2.8.1` | Single API gateway at `:55521` |
|
| `kong` | `kong:2.8.1` | Single API gateway at `:55521` |
|
||||||
| `studio` | `supabase/studio` | Supabase dashboard (`:55530`) |
|
| `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
|
## Requirements
|
||||||
|
|
||||||
- Docker Engine with Compose v2 (or Portainer with Stacks)
|
- 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
|
## Run
|
||||||
|
|
||||||
@@ -89,9 +92,11 @@ rm -rf /mnt/user/appdata/mc-cars/data/db # FULL DB wipe (re-runs fir
|
|||||||
| Purpose | URL |
|
| Purpose | URL |
|
||||||
| ------------------------------- | --------------------------------- |
|
| ------------------------------- | --------------------------------- |
|
||||||
| Public website | http://\<host\>:55580 |
|
| 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 |
|
| Supabase Studio | http://\<host\>:55530 |
|
||||||
| API gateway (Kong) | http://\<host\>:55521 |
|
| API gateway (Kong) | http://\<host\>:55521 |
|
||||||
|
| n8n | http://\<host\>:55590 |
|
||||||
| Postgres | `<host>:55532` |
|
| Postgres | `<host>:55532` |
|
||||||
|
|
||||||
> Admin access is deliberately **not** linked from the public site. Bookmark it.
|
> 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).
|
- 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.
|
- 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 |
|
| Variable | Local dev | Production |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SITE_URL` | `http://localhost:55580` | `https://your.domain.com` |
|
| `SITE_URL` | `http://localhost:55580` | `https://your.domain.com` |
|
||||||
| `SUPABASE_PUBLIC_URL` | `http://localhost:55521` | `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`.
|
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
|
```bash
|
||||||
sed -i 's|SITE_URL=.*|SITE_URL=https://your.domain.com|' .env
|
./deploy-setup.sh https://www.mc-cars.at
|
||||||
sed -i 's|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://your.domain.com|' .env
|
|
||||||
docker compose up -d --force-recreate web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Deployment & portability
|
||||||
|
|
||||||
Runtime state under `/mnt/user/appdata/mc-cars/data/`:
|
Runtime state under `/mnt/user/appdata/mc-cars/data/`:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Bind-mounted service data lives here (db, storage, n8n). Keep tree, ignore contents.
|
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# MC Cars Deployment Configuration Setup
|
||||||
|
# Usage: ./deploy-setup.sh https://www.mc-cars.at
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: ./deploy-setup.sh <domain>"
|
||||||
|
echo "Example: ./deploy-setup.sh https://www.mc-cars.at"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOMAIN="$1"
|
||||||
|
|
||||||
|
echo "🚀 Configuring MC Cars for: $DOMAIN"
|
||||||
|
|
||||||
|
# Update environment variables
|
||||||
|
sed -i "s|SITE_URL=.*|SITE_URL=$DOMAIN|" .env
|
||||||
|
sed -i "s|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=$DOMAIN|" .env
|
||||||
|
sed -i "s|N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send|" .env
|
||||||
|
|
||||||
|
echo "✅ Updated .env:"
|
||||||
|
echo " SITE_URL=$DOMAIN"
|
||||||
|
echo " SUPABASE_PUBLIC_URL=$DOMAIN"
|
||||||
|
echo " N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📋 Next steps:"
|
||||||
|
echo " 1. Verify .env looks correct: grep -E 'SITE_URL|SUPABASE_PUBLIC_URL|N8N_WEBHOOK_URL' .env"
|
||||||
|
echo " 2. Restart services: docker-compose down && docker-compose up -d --build"
|
||||||
|
echo " 3. Test webhook: curl '$DOMAIN/webhook/manual-email-send' -d 'sales_order_id=test'"
|
||||||
@@ -25,6 +25,13 @@ services:
|
|||||||
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||||
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||||
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.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:
|
kong:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -41,5 +48,9 @@ services:
|
|||||||
- ./frontend/nginx-admin.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./frontend/nginx-admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
|
||||||
n8n:
|
n8n:
|
||||||
|
environment:
|
||||||
|
N8N_SECURE_COOKIE: "false"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/n8n:/home/node/.n8n
|
- ./data/n8n:/home/node/.n8n
|
||||||
|
- ./n8n/workflows:/opt/mc-cars/workflows:ro
|
||||||
|
- ./n8n/bootstrap:/opt/mc-cars/bootstrap:ro
|
||||||
|
|||||||
+43
-4
@@ -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/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/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/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"]
|
entrypoint: ["sh","-c"]
|
||||||
command:
|
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/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/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/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."
|
echo "post-init done."
|
||||||
restart: "no"
|
restart: "no"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
@@ -339,10 +353,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
- /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
|
- /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:
|
ports:
|
||||||
- "55580:80"
|
- "55580:80"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
@@ -360,10 +375,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
- /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
|
- /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:
|
ports:
|
||||||
- "55581:80"
|
- "55581:80"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
@@ -389,6 +405,8 @@ services:
|
|||||||
N8N_PROTOCOL: http
|
N8N_PROTOCOL: http
|
||||||
WEBHOOK_URL: http://localhost:55590/
|
WEBHOOK_URL: http://localhost:55590/
|
||||||
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
|
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)
|
# Database (n8n stores its own data in the same Postgres)
|
||||||
DB_TYPE: postgresdb
|
DB_TYPE: postgresdb
|
||||||
@@ -407,10 +425,31 @@ services:
|
|||||||
GENERIC_TIMEZONE: Europe/Vienna
|
GENERIC_TIMEZONE: Europe/Vienna
|
||||||
TZ: Europe/Vienna
|
TZ: Europe/Vienna
|
||||||
|
|
||||||
# Allow importing workflows from filesystem
|
# Workflow/credential bootstrap (re-import on every start)
|
||||||
N8N_USER_FOLDER: /home/node/.n8n
|
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:
|
volumes:
|
||||||
- /mnt/user/appdata/mc-cars/data/n8n:/home/node/.n8n
|
- /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:
|
ports:
|
||||||
- "55590:5678"
|
- "55590:5678"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ set -eu
|
|||||||
cat > /usr/share/nginx/html/config.js <<EOF
|
cat > /usr/share/nginx/html/config.js <<EOF
|
||||||
window.MCCARS_CONFIG = {
|
window.MCCARS_CONFIG = {
|
||||||
SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",
|
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
|
EOF
|
||||||
exec nginx -g "daemon off;"
|
exec nginx -g "daemon off;"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
# (anon key only — safe for the browser).
|
# (anon key only — safe for the browser).
|
||||||
RUN rm -f /usr/share/nginx/html/Dockerfile /usr/share/nginx/html/nginx.conf
|
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
|
&& chmod +x /docker-entrypoint.d/99-config.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
+22
-13
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Admin · MC Cars</title>
|
<title>Admin · MC Cars</title>
|
||||||
<link rel="icon" type="image/png" 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.png" />
|
<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 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" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
<section id="loginView" class="admin-login" style="display:none;">
|
<section id="loginView" class="admin-login" style="display:none;">
|
||||||
<div class="logo" style="justify-content:center;margin-bottom:1.5rem;">
|
<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>
|
<span>MC Cars Admin</span>
|
||||||
</div>
|
</div>
|
||||||
<form id="loginForm" class="admin-form">
|
<form id="loginForm" class="admin-form">
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<!-- Forced password rotation (first login OR user-triggered) -->
|
<!-- Forced password rotation (first login OR user-triggered) -->
|
||||||
<section id="rotateView" class="admin-login" style="display:none;">
|
<section id="rotateView" class="admin-login" style="display:none;">
|
||||||
<div class="logo" style="justify-content:center;margin-bottom:1rem;">
|
<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>
|
<span>Passwort setzen</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--muted);font-size:0.9rem;text-align:center;max-width:38ch;margin:0 auto 1rem;">
|
<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="adminNameEmail">Name / E-Mail</th>
|
||||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||||
|
<th data-i18n="adminRentalType">Miettyp</th>
|
||||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||||
<th data-i18n="adminStatus">Status</th>
|
<th data-i18n="adminStatus">Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -146,10 +147,12 @@
|
|||||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||||
|
<th data-i18n="adminRentalType">Miettyp</th>
|
||||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||||
<th>Kaution</th>
|
<th>Kaution</th>
|
||||||
<th>Miete</th>
|
<th>Miete</th>
|
||||||
<th data-i18n="adminStatus">Status</th>
|
<th data-i18n="adminStatus">Status</th>
|
||||||
|
<th data-i18n="adminEmailSent">Email</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -167,13 +170,18 @@
|
|||||||
<form class="admin-form" id="vehicleForm">
|
<form class="admin-form" id="vehicleForm">
|
||||||
<input type="hidden" name="vid" />
|
<input type="hidden" name="vid" />
|
||||||
|
|
||||||
<div class="admin-photo-preview" id="photoPreview"></div>
|
<div class="admin-photo-upload-zone" id="photoUploadZone">
|
||||||
<label>
|
<div class="admin-photo-upload-content">
|
||||||
<span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
|
<span class="admin-photo-upload-icon">📷</span>
|
||||||
<input type="file" id="photoInput" accept="image/*" />
|
<span>Fotos hochladen (JPG/PNG/WebP, max 50 MB)</span>
|
||||||
</label>
|
<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" />
|
<input type="hidden" name="photo_url" />
|
||||||
|
|
||||||
|
<div class="admin-photo-gallery" id="extraPhotoGallery"></div>
|
||||||
|
|
||||||
<div class="row2">
|
<div class="row2">
|
||||||
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
|
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
|
||||||
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
|
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
|
||||||
@@ -192,13 +200,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row3">
|
<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="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>
|
||||||
|
|
||||||
<div class="row2">
|
<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>
|
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,6 +286,7 @@
|
|||||||
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
||||||
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dialog-tabs" id="orderDialogTabs" role="tablist"></div>
|
||||||
<div class="dialog-body" id="orderDialogBody"></div>
|
<div class="dialog-body" id="orderDialogBody"></div>
|
||||||
<div class="dialog-footer" id="orderDialogFooter"></div>
|
<div class="dialog-footer" id="orderDialogFooter"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -304,6 +313,6 @@
|
|||||||
<div class="dialog-footer" id="customerDialogFooter"></div>
|
<div class="dialog-footer" id="customerDialogFooter"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script type="module" src="admin.js"></script>
|
<script type="module" src="admin.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+604
-99
@@ -52,7 +52,8 @@ const formTitle = document.querySelector("#formTitle");
|
|||||||
const saveBtn = document.querySelector("#saveBtn");
|
const saveBtn = document.querySelector("#saveBtn");
|
||||||
const resetBtn = document.querySelector("#resetBtn");
|
const resetBtn = document.querySelector("#resetBtn");
|
||||||
const photoInput = document.querySelector("#photoInput");
|
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");
|
const tableBody = document.querySelector("#adminTable tbody");
|
||||||
|
|
||||||
// ----- State -----
|
// ----- State -----
|
||||||
@@ -66,10 +67,60 @@ const state = {
|
|||||||
vehicles: [],
|
vehicles: [],
|
||||||
vehicleMap: new Map(),
|
vehicleMap: new Map(),
|
||||||
currentPhotoPath: null,
|
currentPhotoPath: null,
|
||||||
|
vehiclePhotos: [],
|
||||||
realtimeChannel: null,
|
realtimeChannel: null,
|
||||||
forcedRotation: false,
|
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
|
// AUTH FLOW
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -262,9 +313,9 @@ function loadForEdit(id) {
|
|||||||
vehicleForm.seats.value = v.seats;
|
vehicleForm.seats.value = v.seats;
|
||||||
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
||||||
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
|
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.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.sort_order.value = v.sort_order;
|
||||||
vehicleForm.location.value = v.location;
|
vehicleForm.location.value = v.location;
|
||||||
vehicleForm.description_de.value = v.description_de;
|
vehicleForm.description_de.value = v.description_de;
|
||||||
@@ -272,7 +323,7 @@ function loadForEdit(id) {
|
|||||||
vehicleForm.photo_url.value = v.photo_url;
|
vehicleForm.photo_url.value = v.photo_url;
|
||||||
vehicleForm.is_active.checked = v.is_active;
|
vehicleForm.is_active.checked = v.is_active;
|
||||||
state.currentPhotoPath = v.photo_path || null;
|
state.currentPhotoPath = v.photo_path || null;
|
||||||
updatePreview(v.photo_url);
|
loadVehiclePhotos(v.id);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +334,12 @@ resetBtn.addEventListener("click", () => {
|
|||||||
vehicleForm.sort_order.value = 100;
|
vehicleForm.sort_order.value = 100;
|
||||||
vehicleForm.location.value = "Steiermark (TBD)";
|
vehicleForm.location.value = "Steiermark (TBD)";
|
||||||
vehicleForm.seats.value = 2;
|
vehicleForm.seats.value = 2;
|
||||||
vehicleForm.max_daily_km.value = 150;
|
vehicleForm.included_km_per_day.value = 150;
|
||||||
vehicleForm.weekend_price_eur.value = 0;
|
vehicleForm.weekend_price_eur.value = 0;
|
||||||
vehicleForm.kaution_eur.value = 5000;
|
vehicleForm.kaution_eur.value = 5000;
|
||||||
vehicleForm.max_km_weekend.value = '';
|
vehicleForm.price_per_km_eur.value = 1.50;
|
||||||
state.currentPhotoPath = null;
|
state.currentPhotoPath = null;
|
||||||
updatePreview("");
|
state.vehiclePhotos = [];
|
||||||
formTitle.textContent = "Neues Fahrzeug";
|
formTitle.textContent = "Neues Fahrzeug";
|
||||||
formFeedback.textContent = "";
|
formFeedback.textContent = "";
|
||||||
});
|
});
|
||||||
@@ -309,9 +360,9 @@ vehicleForm.addEventListener("submit", async (e) => {
|
|||||||
seats: +fd.get("seats") || 2,
|
seats: +fd.get("seats") || 2,
|
||||||
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
||||||
weekend_price_eur: +fd.get("weekend_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,
|
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,
|
sort_order: +fd.get("sort_order") || 100,
|
||||||
location: fd.get("location") || "Steiermark (TBD)",
|
location: fd.get("location") || "Steiermark (TBD)",
|
||||||
description_de: fd.get("description_de") || "",
|
description_de: fd.get("description_de") || "",
|
||||||
@@ -341,41 +392,275 @@ async function deleteVehicle(id) {
|
|||||||
const v = state.vehicleMap.get(id);
|
const v = state.vehicleMap.get(id);
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (!confirm(`Delete ${v.brand} ${v.model}?`)) 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]);
|
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);
|
const { error } = await supabase.from("vehicles").delete().eq("id", id);
|
||||||
if (error) { alert(error.message); return; }
|
if (error) { alert(error.message); return; }
|
||||||
await loadVehicles();
|
await loadVehicles();
|
||||||
renderVehicles();
|
renderVehicles();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photo upload
|
// ----- Unified Photo Upload + Gallery -----
|
||||||
photoInput.addEventListener("change", async () => {
|
|
||||||
const file = photoInput.files?.[0];
|
async function loadVehiclePhotos(vehicleId) {
|
||||||
if (!file) return;
|
if (!vehicleId) {
|
||||||
formFeedback.className = "form-feedback";
|
state.vehiclePhotos = [];
|
||||||
formFeedback.textContent = "Uploading photo...";
|
renderExtraPhotoGallery();
|
||||||
try {
|
return;
|
||||||
// Delete old photo if exists
|
|
||||||
if (state.currentPhotoPath) {
|
|
||||||
await supabase.storage.from("vehicle-photos").remove([state.currentPhotoPath]);
|
|
||||||
}
|
|
||||||
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
|
||||||
const path = `${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;
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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 = `${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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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
|
// LEADS
|
||||||
@@ -396,6 +681,7 @@ function renderLeads() {
|
|||||||
leadsEmpty.style.display = rows.length ? "none" : "block";
|
leadsEmpty.style.display = rows.length ? "none" : "block";
|
||||||
leadsTableBody.innerHTML = "";
|
leadsTableBody.innerHTML = "";
|
||||||
for (const l of rows) {
|
for (const l of rows) {
|
||||||
|
const rental = rentalTypeMeta(l.rental_type);
|
||||||
const total = l.total_eur || 0;
|
const total = l.total_eur || 0;
|
||||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||||
const tr = document.createElement("tr");
|
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><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.vehicle_label || "—")}</td>
|
||||||
<td>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</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 style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
||||||
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
||||||
<td style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
@@ -552,6 +839,7 @@ async function renderLeadTab(tab, l) {
|
|||||||
});
|
});
|
||||||
leadDialogBody.appendChild(saveNoteBtn);
|
leadDialogBody.appendChild(saveNoteBtn);
|
||||||
} else if (tab === "pricing") {
|
} else if (tab === "pricing") {
|
||||||
|
const rental = rentalTypeMeta(l.rental_type);
|
||||||
const daily = l.daily_subtotal || 0;
|
const daily = l.daily_subtotal || 0;
|
||||||
const weekend = l.weekend_subtotal || 0;
|
const weekend = l.weekend_subtotal || 0;
|
||||||
const sub = l.subtotal_eur || 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 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 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" 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("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>`;
|
</div>`;
|
||||||
} else if (tab === "documents") {
|
} else if (tab === "documents") {
|
||||||
const docs = await loadLeadAttachments(l.id);
|
const docs = await loadLeadAttachments(l.id);
|
||||||
@@ -685,6 +974,7 @@ function renderOrders() {
|
|||||||
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
|
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
|
||||||
ordersTableBody.innerHTML = "";
|
ordersTableBody.innerHTML = "";
|
||||||
for (const o of state.salesOrders) {
|
for (const o of state.salesOrders) {
|
||||||
|
const rental = rentalTypeMeta(o.rental_type);
|
||||||
const total = o.total_eur || 0;
|
const total = o.total_eur || 0;
|
||||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
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>${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.vehicle_label || "—")}</td>
|
||||||
<td>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</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 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.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_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><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>`;
|
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
|
||||||
ordersTableBody.appendChild(tr);
|
ordersTableBody.appendChild(tr);
|
||||||
}
|
}
|
||||||
@@ -708,88 +1000,259 @@ function renderOrders() {
|
|||||||
// ----- ORDER DETAIL DIALOG -----
|
// ----- ORDER DETAIL DIALOG -----
|
||||||
const orderDialog = document.querySelector("#orderDialog");
|
const orderDialog = document.querySelector("#orderDialog");
|
||||||
const orderDialogTitle = document.querySelector("#orderDialogTitle");
|
const orderDialogTitle = document.querySelector("#orderDialogTitle");
|
||||||
|
const orderDialogTabs = document.querySelector("#orderDialogTabs");
|
||||||
const orderDialogBody = document.querySelector("#orderDialogBody");
|
const orderDialogBody = document.querySelector("#orderDialogBody");
|
||||||
const orderDialogFooter = document.querySelector("#orderDialogFooter");
|
const orderDialogFooter = document.querySelector("#orderDialogFooter");
|
||||||
const orderDialogClose = document.querySelector("#orderDialogClose");
|
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) {
|
async function openOrder(id) {
|
||||||
const o = state.salesOrders.find(x => x.id === id);
|
const o = state.salesOrders.find(x => x.id === id);
|
||||||
if (!o) return;
|
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 lang = getLang();
|
||||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
const cust = state.customers.find(c => c.id === o.customer_id);
|
||||||
const total = o.total_eur || 0;
|
const total = o.total_eur || 0;
|
||||||
const deposit = o.deposit_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")
|
||||||
|
.select("*")
|
||||||
|
.eq("sales_order_id", id)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
const docs = attachments || [];
|
||||||
|
|
||||||
// Load attachments for this order
|
orderDialogBody.innerHTML = `
|
||||||
const { data: attachments } = await supabase
|
<dl class="kv">
|
||||||
.from("sales_order_attachments")
|
<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>
|
||||||
.select("*")
|
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
|
||||||
.eq("sales_order_id", id)
|
<dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</dd>
|
||||||
.order("created_at", { ascending: false });
|
<dt>${lang === "de" ? t("adminRentalType") : t("Rental type")}</dt><dd><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></dd>
|
||||||
const docs = attachments || [];
|
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></dd>
|
||||||
|
</dl>
|
||||||
orderDialogBody.innerHTML = `
|
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;">
|
||||||
<dl class="kv">
|
<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>
|
||||||
<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>
|
<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>
|
||||||
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
|
<button class="btn small ${o.rental_complete ? "ghost" : ""}" data-so-toggle="complete" data-so-id="${o.id}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</button>
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
<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>
|
|
||||||
<button class="btn small ${o.rental_complete ? "ghost" : ""}" data-so-toggle="complete" data-so-id="${o.id}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</button>
|
|
||||||
</div>
|
|
||||||
<h4 style="margin:1.2rem 0 0.6rem;font-size:0.9rem;color:var(--muted);">${t("adminTabDocuments")}</h4>
|
|
||||||
${docs.length ? renderDocList(docs) : `<p class="muted" style="text-align:center;padding:1rem 0;">${t("adminNoDocuments")}</p>`}
|
|
||||||
<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;">
|
|
||||||
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
<h4 style="margin:1.2rem 0 0.6rem;font-size:0.9rem;color:var(--muted);">${t("adminTabDocuments")}</h4>
|
||||||
|
${docs.length ? renderDocList(docs) : `<p class="muted" style="text-align:center;padding:1rem 0;">${t("adminNoDocuments")}</p>`}
|
||||||
|
<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: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>`;
|
||||||
|
|
||||||
// Customer lookup link
|
// Customer lookup link
|
||||||
orderDialogBody.querySelectorAll("[data-goto-cust]").forEach(a => {
|
orderDialogBody.querySelectorAll("[data-goto-cust]").forEach(a => {
|
||||||
a.addEventListener("click", (e) => {
|
a.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
orderDialog.close();
|
orderDialog.close();
|
||||||
openCustomer(a.dataset.gotoCust);
|
openCustomer(a.dataset.gotoCust);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Document open links
|
// Document open links
|
||||||
orderDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
|
orderDialogBody.querySelectorAll("[data-open-file]").forEach(btn => {
|
||||||
btn.addEventListener("click", async (e) => {
|
btn.addEventListener("click", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
|
await openAttachmentInNewTab(btn.dataset.openFile, btn.dataset.openBucket || "customer-documents");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle buttons
|
// Toggle buttons
|
||||||
orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => {
|
orderDialogBody.querySelectorAll("[data-so-toggle]").forEach(btn => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
await toggleSalesOrderState(btn.dataset.soId, btn.dataset.soToggle);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Save notes
|
// Dirty form tracking
|
||||||
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
|
let noteIsDirty = false;
|
||||||
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
|
const orderNoteEl = document.querySelector("#orderNote");
|
||||||
if (ok) {
|
const originalNoteValue = o.private_notes || "";
|
||||||
document.querySelector("#orderNoteSave").textContent = "✓";
|
const saveBtn = document.querySelector("#orderNoteSave");
|
||||||
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
|
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 = "";
|
orderDialogFooter.innerHTML = "";
|
||||||
orderDialog.showModal();
|
|
||||||
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSalesOrderState(orderId, action) {
|
async function toggleSalesOrderState(orderId, action) {
|
||||||
@@ -932,6 +1395,7 @@ async function renderCustomerTab(tab, c) {
|
|||||||
let html = "";
|
let html = "";
|
||||||
if (orders.length) {
|
if (orders.length) {
|
||||||
for (const o of orders) {
|
for (const o of orders) {
|
||||||
|
const rental = rentalTypeMeta(o.rental_type);
|
||||||
const total = o.total_eur || 0;
|
const total = o.total_eur || 0;
|
||||||
html += `
|
html += `
|
||||||
<div class="pricing-card" style="margin-bottom:0.9rem;">
|
<div class="pricing-card" style="margin-bottom:0.9rem;">
|
||||||
@@ -941,6 +1405,7 @@ async function renderCustomerTab(tab, c) {
|
|||||||
</div>
|
</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" ? "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" ? "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;">
|
<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.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>
|
<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 noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
|
||||||
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
|
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
btn.classList.remove("ghost");
|
||||||
|
btn.style.backgroundColor = "";
|
||||||
|
btn.style.color = "";
|
||||||
btn.textContent = "✓";
|
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 => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
||||||
function attr(s) { return esc(s); }
|
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) {
|
function fmtDate(iso) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -1114,7 +1619,7 @@ const mietvertragFeedback = document.querySelector("#mietvertragFeedback");
|
|||||||
|
|
||||||
async function renderSettings() {
|
async function renderSettings() {
|
||||||
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
|
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}')`;
|
heroPreview.style.backgroundImage = `url('${url}')`;
|
||||||
|
|
||||||
// Mietvertrag template status
|
// Mietvertrag template status
|
||||||
|
|||||||
+9
-12
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>AGB · MC Cars</title>
|
<title>AGB · MC Cars</title>
|
||||||
<link rel="icon" type="image/png" 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.png" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 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">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<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>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<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="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt 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.
|
Die AGB definieren die rechtlichen Bedingungen für die Vermietung von Fahrzeugen durch MC Cars.
|
||||||
</p>
|
</p>
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,16 +87,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<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>
|
<span>MC Cars</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,8 +109,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+159
-30
@@ -1,5 +1,5 @@
|
|||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
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_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
|
||||||
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
||||||
@@ -16,6 +16,7 @@ const state = {
|
|||||||
sort: "sort_order",
|
sort: "sort_order",
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
reviewIdx: 0,
|
reviewIdx: 0,
|
||||||
|
vehiclePhotosMap: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------- Elements ----------------
|
// ---------------- Elements ----------------
|
||||||
@@ -123,6 +124,33 @@ async function loadVehicles() {
|
|||||||
|
|
||||||
state.vehicles = data || [];
|
state.vehicles = data || [];
|
||||||
statCarsCount.textContent = state.vehicles.length;
|
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();
|
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
|
||||||
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
|
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
|
||||||
@@ -156,10 +184,16 @@ function renderGrid() {
|
|||||||
emptyState.style.display = state.filtered.length ? "none" : "block";
|
emptyState.style.display = state.filtered.length ? "none" : "block";
|
||||||
|
|
||||||
for (const v of state.filtered) {
|
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");
|
const card = document.createElement("article");
|
||||||
card.className = "vehicle-card";
|
card.className = "vehicle-card";
|
||||||
card.innerHTML = `
|
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>
|
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vehicle-body">
|
<div class="vehicle-body">
|
||||||
@@ -192,28 +226,65 @@ function renderGrid() {
|
|||||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
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) {
|
function openDetails(id) {
|
||||||
const v = state.vehicles.find(x => x.id === id);
|
const v = state.vehicles.find(x => x.id === id);
|
||||||
if (!v) return;
|
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 lang = getLang();
|
||||||
const desc = lang === "en" ? v.description_en : v.description_de;
|
const desc = lang === "en" ? v.description_en : v.description_de;
|
||||||
|
|
||||||
dialogTitle.textContent = `${v.brand} ${v.model}`;
|
dialogTitle.textContent = `${v.brand} ${v.model}`;
|
||||||
dialogBody.innerHTML = `
|
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>
|
<p>${escapeHtml(desc || "")}</p>
|
||||||
<div class="spec-row" style="margin:1rem 0;">
|
<div class="spec-row" style="margin:1rem 0;">
|
||||||
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
|
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
|
||||||
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
|
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
|
||||||
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
|
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="spec-row" style="margin:1rem 0;">
|
<div class="spec-row" style="margin:1rem 0;">
|
||||||
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
|
<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.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>
|
||||||
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
|
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
|
||||||
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
|
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,6 +300,37 @@ function openDetails(id) {
|
|||||||
bpfCar.dispatchEvent(new Event("change"));
|
bpfCar.dispatchEvent(new Event("change"));
|
||||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
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 ----------------
|
// ---------------- Reviews ----------------
|
||||||
@@ -395,28 +497,46 @@ async function updateSidebar() {
|
|||||||
const vat = price.vat_eur;
|
const vat = price.vat_eur;
|
||||||
const total = price.total_eur;
|
const total = price.total_eur;
|
||||||
const deposit = price.deposit_eur;
|
const deposit = price.deposit_eur;
|
||||||
const kmPerWeekday = price.max_daily_km;
|
const includedKmPerDay = price.included_km_per_day || 150;
|
||||||
const kmPerWeekendDay = price.max_km_weekend;
|
const includedKm = totalDays * includedKmPerDay;
|
||||||
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
|
const sidebarPhotos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||||
|
const photoUrl = optimizedVehiclePhotoUrl((sidebarPhotos.find(p => p.is_primary) || sidebarPhotos[0] || v)?.photo_url || v.photo_url);
|
||||||
|
|
||||||
bpfSidebarPlaceholder.style.display = "none";
|
if (totalDays > 2) {
|
||||||
bpfSidebarContent.style.display = "block";
|
// Individuell mode: show info banner instead of pricing
|
||||||
bpfSidebarContent.innerHTML = `
|
bpfSidebarPlaceholder.style.display = "none";
|
||||||
<h4>${t("bpfPriceOverview")}</h4>
|
bpfSidebarContent.style.display = "block";
|
||||||
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
|
bpfSidebarContent.innerHTML = `
|
||||||
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} × € ${price.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
|
<h4>${t("bpfPriceOverview")}</h4>
|
||||||
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} × € ${price.weekend_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
|
<div class="bpf-info-banner">
|
||||||
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
|
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
|
||||||
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
<p>${t("bpfIndividuellDesc")}</p>
|
||||||
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
</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-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||||
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
|
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||||
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
|
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||||
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(v.photo_url)}');"></div>
|
`;
|
||||||
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
} else {
|
||||||
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
bpfSidebarPlaceholder.style.display = "none";
|
||||||
`;
|
bpfSidebarContent.style.display = "block";
|
||||||
}
|
bpfSidebarContent.innerHTML = `
|
||||||
|
<h4>${t("bpfPriceOverview")}</h4>
|
||||||
|
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
|
||||||
|
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} × € ${price.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
|
||||||
|
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} × € ${price.weekend_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
|
||||||
|
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
|
||||||
|
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
|
||||||
|
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||||
|
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||||
|
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
bpfCar.addEventListener("change", updateSidebar);
|
bpfCar.addEventListener("change", updateSidebar);
|
||||||
bpfFrom.addEventListener("change", updateSidebar);
|
bpfFrom.addEventListener("change", updateSidebar);
|
||||||
@@ -532,6 +652,12 @@ function escapeHtml(s) {
|
|||||||
}
|
}
|
||||||
function escapeAttr(s) { return 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 ----------------
|
// ---------------- Boot ----------------
|
||||||
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
|
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
|
||||||
applyI18n();
|
applyI18n();
|
||||||
@@ -542,6 +668,9 @@ loadVehicles();
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
|
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
|
||||||
if (data && data.value) {
|
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
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Datenschutz · MC Cars (GmbH)</title>
|
<title>Datenschutz · MC Cars (GmbH)</title>
|
||||||
<link rel="icon" type="image/png" 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.png" />
|
<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 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" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
|
||||||
@@ -48,13 +48,12 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<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>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<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="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -64,11 +63,95 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main style="padding: 3rem 0;">
|
<main style="padding: 3rem 0;">
|
||||||
<div class="shell">
|
<div class="shell" style="max-width: 80ch;">
|
||||||
<h1>Datenschutz</h1>
|
<h1>Datenschutzerklärung</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>
|
<div style="max-width: 70ch; line-height: 1.8; color: var(--text);">
|
||||||
<p>Ansprechpartner: hello@mccars.at</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -78,16 +161,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<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>
|
<span>MC Cars</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,8 +183,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,6 +194,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+43
-39
@@ -2,21 +2,18 @@
|
|||||||
export const translations = {
|
export const translations = {
|
||||||
de: {
|
de: {
|
||||||
navCars: "Fahrzeuge",
|
navCars: "Fahrzeuge",
|
||||||
navWhy: "Warum wir",
|
|
||||||
navReviews: "Stimmen",
|
navReviews: "Stimmen",
|
||||||
navBook: "Buchen",
|
navBook: "Buchen",
|
||||||
bookNow: "Jetzt buchen",
|
bookNow: "Jetzt buchen",
|
||||||
viewFleet: "Flotte ansehen",
|
|
||||||
|
|
||||||
heroEyebrow: "MC Cars · Sportwagenvermietung",
|
heroEyebrow: "MC Cars · Sportwagenvermietung",
|
||||||
heroTitle: "Fahren auf höchstem Niveau.",
|
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",
|
statDeposit: "Faire Kaution",
|
||||||
statSupport: "Support",
|
|
||||||
statCars: "Fahrzeuge",
|
statCars: "Fahrzeuge",
|
||||||
|
statCar: "Fahrzeug",
|
||||||
|
|
||||||
fleetEyebrow: "Unsere Flotte",
|
|
||||||
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
|
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
|
||||||
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
|
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
|
||||||
filterBrand: "Marke",
|
filterBrand: "Marke",
|
||||||
@@ -36,15 +33,6 @@ export const translations = {
|
|||||||
from: "ab",
|
from: "ab",
|
||||||
noMatches: "Keine Fahrzeuge gefunden.",
|
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",
|
reviewsEyebrow: "Kundenmeinungen",
|
||||||
reviewsTitle: "Erlebnisse, die bleiben.",
|
reviewsTitle: "Erlebnisse, die bleiben.",
|
||||||
review: "Kundenmeinung",
|
review: "Kundenmeinung",
|
||||||
@@ -89,6 +77,7 @@ export const translations = {
|
|||||||
bpfWeekendRate: "Wochenendmiete",
|
bpfWeekendRate: "Wochenendmiete",
|
||||||
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
||||||
bpfMaxKm: "Max. km/Tag",
|
bpfMaxKm: "Max. km/Tag",
|
||||||
|
bpfInclKmPerDay: "Inkl. km/Tag",
|
||||||
bpfExtraKm: "Extra km",
|
bpfExtraKm: "Extra km",
|
||||||
bpfPriceOverview: "Preisübersicht",
|
bpfPriceOverview: "Preisübersicht",
|
||||||
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
|
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
|
||||||
@@ -112,7 +101,7 @@ export const translations = {
|
|||||||
perWeekend: "Wochenende",
|
perWeekend: "Wochenende",
|
||||||
weekendDef: "Sa 9:00 – So 20:00",
|
weekendDef: "Sa 9:00 – So 20:00",
|
||||||
|
|
||||||
footerTagline: "Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).",
|
footerTagline: "Sportwagenvermietung in der Steiermark, Österreich.",
|
||||||
footerLegal: "Rechtliches",
|
footerLegal: "Rechtliches",
|
||||||
footerContact: "Kontakt",
|
footerContact: "Kontakt",
|
||||||
footerNav: "Navigation",
|
footerNav: "Navigation",
|
||||||
@@ -176,7 +165,7 @@ export const translations = {
|
|||||||
adminVehicleTab: "Fahrzeug",
|
adminVehicleTab: "Fahrzeug",
|
||||||
adminPeriod: "Zeitraum",
|
adminPeriod: "Zeitraum",
|
||||||
adminKaution: "Kaution (€)",
|
adminKaution: "Kaution (€)",
|
||||||
adminMaxKmWeekend: "Max. km/Wochenendtag",
|
adminMaxKmWeekend: "Inkl. km/Wochenende",
|
||||||
adminTotalPrice: "Gesamtbetrag",
|
adminTotalPrice: "Gesamtbetrag",
|
||||||
adminLifetimeValueCol: "Gesamtwert",
|
adminLifetimeValueCol: "Gesamtwert",
|
||||||
adminTabGeneral: "Allgemein",
|
adminTabGeneral: "Allgemein",
|
||||||
@@ -222,6 +211,8 @@ export const translations = {
|
|||||||
adminVatLabelEn: "VAT (20%)",
|
adminVatLabelEn: "VAT (20%)",
|
||||||
adminTotalLabel: "Gesamtbetrag",
|
adminTotalLabel: "Gesamtbetrag",
|
||||||
adminTotalLabelEn: "Total",
|
adminTotalLabelEn: "Total",
|
||||||
|
adminInclVat: "inkl. MwSt.",
|
||||||
|
adminInclVatEn: "incl. VAT",
|
||||||
adminDepositLabel: "Kaution",
|
adminDepositLabel: "Kaution",
|
||||||
adminDepositLabelEn: "Deposit",
|
adminDepositLabelEn: "Deposit",
|
||||||
adminIncludedKmLabel: "Inkl. km",
|
adminIncludedKmLabel: "Inkl. km",
|
||||||
@@ -231,27 +222,35 @@ export const translations = {
|
|||||||
adminFirstContacted: "Erster Kontakt",
|
adminFirstContacted: "Erster Kontakt",
|
||||||
adminFirstContactedEn: "First contacted",
|
adminFirstContactedEn: "First contacted",
|
||||||
adminNote: "Notiz",
|
adminNote: "Notiz",
|
||||||
adminNoteEn: "Note",
|
adminNoteEn: "Note",
|
||||||
adminSave: "Speichern",
|
adminSave: "Speichern",
|
||||||
adminSaveEn: "Save",
|
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: {
|
en: {
|
||||||
navCars: "Fleet",
|
navCars: "Fleet",
|
||||||
navWhy: "Why us",
|
|
||||||
navReviews: "Reviews",
|
navReviews: "Reviews",
|
||||||
navBook: "Book",
|
navBook: "Book",
|
||||||
bookNow: "Book now",
|
bookNow: "Book now",
|
||||||
viewFleet: "View fleet",
|
|
||||||
|
|
||||||
heroEyebrow: "MC Cars · Sports car rental",
|
heroEyebrow: "MC Cars · Sports car rental",
|
||||||
heroTitle: "Drive at the highest level.",
|
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",
|
statDeposit: "Fair Deposit",
|
||||||
statSupport: "Support",
|
|
||||||
statCars: "Vehicles",
|
statCars: "Vehicles",
|
||||||
|
statCar: "Vehicle",
|
||||||
|
|
||||||
fleetEyebrow: "Our Fleet",
|
|
||||||
fleetTitle: "Hand-picked. Maintained. Ready.",
|
fleetTitle: "Hand-picked. Maintained. Ready.",
|
||||||
fleetSub: "Filter by brand or price. Click for details or book directly.",
|
fleetSub: "Filter by brand or price. Click for details or book directly.",
|
||||||
filterBrand: "Brand",
|
filterBrand: "Brand",
|
||||||
@@ -271,15 +270,6 @@ export const translations = {
|
|||||||
from: "from",
|
from: "from",
|
||||||
noMatches: "No vehicles match the filters.",
|
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",
|
reviewsEyebrow: "Testimonials",
|
||||||
reviewsTitle: "Experiences that last.",
|
reviewsTitle: "Experiences that last.",
|
||||||
review: "Review",
|
review: "Review",
|
||||||
@@ -323,7 +313,8 @@ export const translations = {
|
|||||||
bpfDailyRate: "Daily rate",
|
bpfDailyRate: "Daily rate",
|
||||||
bpfWeekendRate: "Weekend rate",
|
bpfWeekendRate: "Weekend rate",
|
||||||
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
||||||
bpfMaxKm: "Max. km/day",
|
bpfMaxKm: "Max. km/day",
|
||||||
|
bpfInclKmPerDay: "Included km/day",
|
||||||
bpfExtraKm: "Extra km",
|
bpfExtraKm: "Extra km",
|
||||||
bpfPriceOverview: "Price overview",
|
bpfPriceOverview: "Price overview",
|
||||||
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
||||||
@@ -347,7 +338,7 @@ export const translations = {
|
|||||||
perWeekend: "Weekend",
|
perWeekend: "Weekend",
|
||||||
weekendDef: "Sat 9 AM – Sun 8 PM",
|
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",
|
footerLegal: "Legal",
|
||||||
footerContact: "Contact",
|
footerContact: "Contact",
|
||||||
footerNav: "Navigation",
|
footerNav: "Navigation",
|
||||||
@@ -411,7 +402,7 @@ export const translations = {
|
|||||||
adminVehicleTab: "Vehicle",
|
adminVehicleTab: "Vehicle",
|
||||||
adminPeriod: "Period",
|
adminPeriod: "Period",
|
||||||
adminKaution: "Deposit (€)",
|
adminKaution: "Deposit (€)",
|
||||||
adminMaxKmWeekend: "Max. km/weekend day",
|
adminMaxKmWeekend: "Included km/weekend",
|
||||||
adminTotalPrice: "Total",
|
adminTotalPrice: "Total",
|
||||||
adminLifetimeValueCol: "Lifetime",
|
adminLifetimeValueCol: "Lifetime",
|
||||||
adminTabGeneral: "General",
|
adminTabGeneral: "General",
|
||||||
@@ -457,6 +448,8 @@ export const translations = {
|
|||||||
adminVatLabelEn: "MwSt. (20%)",
|
adminVatLabelEn: "MwSt. (20%)",
|
||||||
adminTotalLabel: "Total",
|
adminTotalLabel: "Total",
|
||||||
adminTotalLabelEn: "Gesamtbetrag",
|
adminTotalLabelEn: "Gesamtbetrag",
|
||||||
|
adminInclVat: "incl. VAT",
|
||||||
|
adminInclVatEn: "inkl. MwSt.",
|
||||||
adminDepositLabel: "Deposit",
|
adminDepositLabel: "Deposit",
|
||||||
adminDepositLabelEn: "Kaution",
|
adminDepositLabelEn: "Kaution",
|
||||||
adminIncludedKmLabel: "Included km",
|
adminIncludedKmLabel: "Included km",
|
||||||
@@ -469,15 +462,26 @@ export const translations = {
|
|||||||
adminNoteEn: "Notiz",
|
adminNoteEn: "Notiz",
|
||||||
adminSave: "Save",
|
adminSave: "Save",
|
||||||
adminSaveEn: "Speichern",
|
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 = [
|
export const REVIEWS = [
|
||||||
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
|
{ quote: "Die Buchung war klar und schnell. Der Ferrari 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: "Exzellenter Service und ein makellos vorbereiteter Ferrari. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
|
||||||
{ quote: "Hervorragende Buchungsabwicklung und tadelloses Fahrzeugzustand. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
|
{ quote: "Hervorragende Buchungsabwicklung und tadelloser Zustand des Ferrari. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
|
||||||
{ quote: "Professionelles Team und untadelige Aufmerksamkeit zum Detail. Sehr empfohlen.", author: "David M.", lang: "de" },
|
{ quote: "Professionelles Team und erstklassiger Ferrari. Absolut empfehlenswert.", author: "David M.", lang: "de" },
|
||||||
{ quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
|
{ quote: "Booking was clear and fast. The Ferrari arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getLang() {
|
export function getLang() {
|
||||||
|
|||||||
@@ -0,0 +1,851 @@
|
|||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
width="100%" viewBox="0 0 1254 1254" enable-background="new 0 0 1254 1254" xml:space="preserve">
|
||||||
|
<path fill="#181b23" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M701.000000,1255.000000
|
||||||
|
C467.333344,1255.000000 234.166672,1255.000000 1.000000,1255.000000
|
||||||
|
C1.000000,837.000000 1.000000,419.000000 1.000000,1.000000
|
||||||
|
C419.000000,1.000000 837.000000,1.000000 1255.000000,1.000000
|
||||||
|
C1255.000000,419.000000 1255.000000,837.000000 1255.000000,1255.000000
|
||||||
|
C1070.500000,1255.000000 886.000000,1255.000000 701.000000,1255.000000
|
||||||
|
M396.781219,313.799316
|
||||||
|
C397.171356,314.222809 397.561462,314.646301 398.001038,315.017700
|
||||||
|
C398.001038,315.017700 397.934113,315.043488 398.151062,315.750244
|
||||||
|
C399.478943,316.373016 400.886017,317.648407 402.120117,317.498596
|
||||||
|
C406.139893,317.010681 410.096466,316.002075 414.733521,315.276184
|
||||||
|
C415.157410,315.168335 415.581268,315.060455 416.745514,315.054932
|
||||||
|
C418.189423,314.770813 419.633331,314.486694 421.813538,314.319031
|
||||||
|
C422.542450,314.193451 423.271332,314.067871 424.874542,314.048035
|
||||||
|
C427.925720,313.724304 430.976898,313.400574 434.521881,313.184052
|
||||||
|
C434.682587,313.133514 434.843292,313.083008 435.708618,313.187958
|
||||||
|
C446.342194,312.921204 456.976135,312.412842 467.609253,312.430511
|
||||||
|
C508.679047,312.498688 549.750366,313.024567 590.817383,312.771454
|
||||||
|
C608.231873,312.664124 625.637146,311.053101 643.707214,310.244781
|
||||||
|
C644.141846,310.155365 644.576538,310.065948 645.653992,310.116730
|
||||||
|
C648.449707,309.782227 651.245483,309.447723 654.701111,309.229950
|
||||||
|
C655.133850,309.135101 655.566650,309.040253 656.741760,309.052643
|
||||||
|
C658.184998,308.764557 659.628174,308.476440 661.559326,308.241028
|
||||||
|
C661.559326,308.241028 662.009216,308.044861 662.636475,308.179169
|
||||||
|
C664.764282,307.808258 666.892090,307.437378 669.528076,307.128723
|
||||||
|
C669.686584,307.065399 669.845032,307.002106 670.859375,307.031677
|
||||||
|
C674.244629,306.371277 677.629883,305.710907 681.522522,305.121155
|
||||||
|
C681.681946,305.060120 681.841431,304.999054 682.844971,305.006561
|
||||||
|
C686.902588,304.023102 690.960205,303.039612 695.527466,302.095306
|
||||||
|
C695.683716,302.027435 695.840027,301.959564 696.640991,301.979736
|
||||||
|
C697.446350,301.702332 698.251709,301.424927 699.552368,301.165436
|
||||||
|
C699.552368,301.165436 699.999451,300.951569 700.759521,300.993317
|
||||||
|
C702.861633,300.388641 704.963745,299.783997 707.561462,299.212860
|
||||||
|
C707.561462,299.212860 708.006836,298.992889 708.780640,299.021851
|
||||||
|
C712.833008,297.947479 716.885376,296.873108 720.937744,295.798706
|
||||||
|
C720.865356,295.500671 720.792908,295.202606 720.720520,294.904541
|
||||||
|
C716.495972,295.156158 712.271484,295.407745 707.153503,295.550201
|
||||||
|
C704.763428,295.718048 702.373352,295.885895 699.307434,295.870880
|
||||||
|
C691.827026,296.147675 684.347717,296.456543 676.866028,296.694214
|
||||||
|
C663.900513,297.106110 650.933472,297.473816 637.052124,297.673737
|
||||||
|
C631.725525,297.710205 626.398865,297.746674 620.286987,297.536438
|
||||||
|
C608.196716,297.367432 596.106445,297.198456 583.108887,296.825806
|
||||||
|
C579.101318,296.798584 575.093689,296.771393 570.335876,296.430695
|
||||||
|
C564.563171,296.302856 558.790405,296.175018 552.284851,295.845795
|
||||||
|
C550.530273,295.807404 548.775757,295.769043 546.125244,295.484924
|
||||||
|
C541.229675,295.210266 536.334167,294.935608 531.438599,294.660950
|
||||||
|
C531.433716,294.287903 531.428833,293.914825 531.423950,293.541748
|
||||||
|
C532.621826,292.739075 533.819641,291.936401 535.753296,291.029877
|
||||||
|
C548.082947,285.569672 560.423279,280.133575 572.740295,274.645020
|
||||||
|
C619.553894,253.784515 668.285034,242.128235 719.822815,242.017166
|
||||||
|
C774.537720,241.899246 827.307434,252.427643 879.229797,268.542175
|
||||||
|
C881.159363,269.141052 883.002747,270.017517 884.883545,270.763519
|
||||||
|
C884.563477,271.430573 884.508972,271.681091 884.430054,271.689056
|
||||||
|
C876.813049,272.455048 869.187500,273.140594 861.576477,273.961182
|
||||||
|
C806.742737,279.872833 759.507080,301.690308 720.826050,341.338104
|
||||||
|
C719.755005,342.435852 719.080383,343.920410 718.093445,345.327209
|
||||||
|
C718.093445,345.327209 717.972961,345.058960 717.972961,345.058960
|
||||||
|
C717.972961,345.058960 718.084839,345.315521 718.650269,344.896301
|
||||||
|
C735.223145,333.885864 752.783081,324.744263 771.302368,317.503693
|
||||||
|
C831.078369,294.132935 892.834778,289.612976 956.081787,297.510864
|
||||||
|
C979.010681,300.374115 1002.125366,302.170929 1025.211304,303.216339
|
||||||
|
C1042.583984,304.003052 1059.904541,301.704041 1075.887085,294.038757
|
||||||
|
C1082.150146,291.035004 1087.853149,286.863739 1093.811279,283.224213
|
||||||
|
C1093.559448,282.781799 1093.307617,282.339355 1093.055786,281.896942
|
||||||
|
C1091.600708,282.143341 1090.154053,282.517822 1088.689087,282.616180
|
||||||
|
C1075.105347,283.528564 1061.482544,285.491821 1047.939575,285.049744
|
||||||
|
C1017.028503,284.040741 987.651123,274.883209 958.250061,266.153778
|
||||||
|
C901.083801,249.180603 843.656006,233.270599 784.319458,225.623398
|
||||||
|
C746.606262,220.762939 708.936340,220.984604 671.272217,226.568558
|
||||||
|
C607.108459,236.081253 549.352417,262.637085 492.225098,292.998047
|
||||||
|
C488.066254,292.794434 483.907227,292.414398 479.748596,292.418854
|
||||||
|
C464.788544,292.434906 449.828644,292.580688 434.029724,292.587646
|
||||||
|
C432.350281,292.722931 430.670837,292.858246 428.161041,292.845856
|
||||||
|
C425.741302,293.148315 423.321564,293.450775 420.170654,293.643799
|
||||||
|
C419.445709,293.765839 418.720734,293.887878 417.375122,293.833588
|
||||||
|
C415.225281,294.158722 413.075470,294.483856 410.273438,294.703339
|
||||||
|
C409.847961,294.801819 409.422485,294.900269 408.331085,294.872192
|
||||||
|
C402.205994,295.872437 396.080933,296.872681 389.453491,297.820312
|
||||||
|
C389.300934,297.891479 389.148346,297.962677 388.311951,297.963135
|
||||||
|
C387.769958,298.605743 386.640411,299.595123 386.781708,299.834503
|
||||||
|
C387.673859,301.346039 388.847778,302.691284 389.989868,304.026306
|
||||||
|
C389.989868,304.026306 389.907837,304.061707 390.092712,304.676270
|
||||||
|
C390.663269,305.303772 391.233795,305.931274 391.920441,307.024323
|
||||||
|
C392.236633,307.367615 392.552826,307.710907 393.022980,308.653168
|
||||||
|
C393.683838,309.432037 394.344666,310.210907 394.998779,311.000000
|
||||||
|
C394.998779,311.000000 395.008911,310.995544 395.137665,311.571503
|
||||||
|
C395.645935,312.157867 396.154205,312.744232 396.681732,313.248444
|
||||||
|
C396.681732,313.248444 396.597687,313.241608 396.781219,313.799316
|
||||||
|
M899.661255,831.203491
|
||||||
|
C908.254639,833.730408 911.388550,837.512146 912.155518,846.361877
|
||||||
|
C912.298889,848.016418 912.347168,849.683716 912.348938,851.345215
|
||||||
|
C912.385498,885.620789 912.416748,919.896423 912.420654,954.171997
|
||||||
|
C912.420959,956.996643 912.353821,959.834412 912.083313,962.643433
|
||||||
|
C911.380554,969.939453 908.202637,975.377869 900.457458,976.891174
|
||||||
|
C896.860291,977.593994 896.383850,979.606018 897.455200,982.831421
|
||||||
|
C916.869507,982.831421 936.268188,982.831421 955.957520,982.831421
|
||||||
|
C955.957520,981.068054 955.957520,979.512329 955.957520,978.035461
|
||||||
|
C943.790100,974.055786 943.201233,973.545105 940.747864,964.027405
|
||||||
|
C940.699707,946.664612 940.651550,929.301880 941.566040,911.765930
|
||||||
|
C953.093933,909.562439 959.488159,915.274353 964.879822,924.964233
|
||||||
|
C973.048279,939.644592 982.464905,953.635925 991.488892,967.832458
|
||||||
|
C996.323059,975.437500 1003.011658,980.818787 1011.994629,982.646057
|
||||||
|
C1022.533875,984.789978 1033.125488,984.504272 1044.823486,982.450378
|
||||||
|
C1043.779175,980.521118 1043.281616,978.100159 1042.161377,977.765869
|
||||||
|
C1031.492432,974.581238 1024.974609,966.588135 1019.008789,958.076355
|
||||||
|
C1007.823547,942.118103 996.874023,925.994629 985.849121,909.924377
|
||||||
|
C985.158569,908.917725 984.664795,907.776245 984.127380,906.785706
|
||||||
|
C988.278015,905.212891 992.126099,904.042664 995.723022,902.341370
|
||||||
|
C1010.548279,895.329285 1018.324402,883.373596 1018.604919,867.123840
|
||||||
|
C1018.880737,851.153076 1011.481079,839.381470 996.972046,832.387451
|
||||||
|
C984.955994,826.595154 972.178833,825.056641 958.081360,825.816528
|
||||||
|
C951.381653,825.864868 944.681885,825.913208 937.075623,825.751404
|
||||||
|
C932.720642,825.823792 928.365662,825.896179 923.083557,825.810547
|
||||||
|
C916.049316,825.859253 909.015076,825.907898 901.251709,825.757141
|
||||||
|
C900.503906,825.821350 899.756165,825.885620 898.185303,825.844971
|
||||||
|
C896.539246,827.840454 895.281250,829.774719 899.661255,831.203491
|
||||||
|
M342.377563,612.500000
|
||||||
|
C342.377563,546.821960 342.377563,481.143921 342.377563,415.465851
|
||||||
|
C342.958832,415.269623 343.540070,415.073395 344.121338,414.877167
|
||||||
|
C411.630127,508.205872 479.138947,601.534607 546.863403,695.161438
|
||||||
|
C548.020203,693.651001 549.029724,692.368408 550.002014,691.058228
|
||||||
|
C584.351501,644.771912 618.488220,598.325684 653.120667,552.252075
|
||||||
|
C674.754944,523.470703 697.542725,495.647705 723.774902,470.764221
|
||||||
|
C770.021179,426.895569 823.415344,399.275452 888.272766,397.703644
|
||||||
|
C928.537354,396.727844 964.895752,407.770935 995.619995,434.805878
|
||||||
|
C1002.730652,441.062622 1009.992676,447.147247 1017.396973,453.492737
|
||||||
|
C1034.874756,437.186096 1054.087769,419.260529 1073.177368,401.450104
|
||||||
|
C1072.942627,400.737000 1072.920776,400.364136 1072.729004,400.131012
|
||||||
|
C1047.276855,369.189423 1016.585510,345.808441 977.770569,334.012177
|
||||||
|
C943.889099,323.715210 909.323120,320.099457 874.044983,322.741333
|
||||||
|
C830.967285,325.967285 790.045532,336.734283 751.852661,357.264099
|
||||||
|
C711.883240,378.748932 677.993958,407.840149 647.461365,441.020386
|
||||||
|
C618.858459,472.103699 593.989014,506.159760 569.500000,540.492188
|
||||||
|
C562.484070,550.328247 555.453918,560.154175 548.182922,570.330872
|
||||||
|
C546.682434,568.357788 545.551270,566.952881 544.507812,565.485596
|
||||||
|
C530.022766,545.117676 515.623901,524.688110 501.060333,504.376556
|
||||||
|
C451.746399,435.599335 402.357910,366.875580 353.086639,298.067871
|
||||||
|
C351.029236,295.194672 348.936646,294.044098 345.348358,294.060974
|
||||||
|
C314.849731,294.204407 284.350067,294.127106 253.850723,294.116089
|
||||||
|
C252.080444,294.115448 250.310181,294.115997 248.418060,294.115997
|
||||||
|
C248.418060,444.198608 248.418060,593.610046 248.418060,743.261475
|
||||||
|
C279.754730,743.261475 310.820251,743.261475 342.379639,743.261475
|
||||||
|
C342.379639,699.779724 342.379639,656.639954 342.377563,612.500000
|
||||||
|
M767.521057,704.973877
|
||||||
|
C858.763550,772.466553 1002.016541,759.777527 1070.519043,664.034180
|
||||||
|
C1052.421997,647.694031 1034.303955,631.334961 1016.167542,614.959229
|
||||||
|
C970.622925,674.647339 887.092346,688.326477 826.294128,650.167725
|
||||||
|
C799.310364,633.231873 779.692322,609.730286 769.489075,579.463135
|
||||||
|
C755.117004,536.829590 762.565247,496.493652 784.840210,458.068756
|
||||||
|
C783.091125,458.780029 781.617310,459.681000 780.207947,460.673584
|
||||||
|
C743.732910,486.362610 713.644348,518.504333 686.533691,553.622314
|
||||||
|
C685.342346,555.165527 684.516846,557.647095 684.758789,559.532593
|
||||||
|
C686.153748,570.405701 686.810425,581.543274 689.754944,592.018433
|
||||||
|
C702.723816,638.154053 728.986938,675.427490 767.521057,704.973877
|
||||||
|
M240.043091,882.462097
|
||||||
|
C231.663834,901.314758 223.284592,920.167480 214.590317,939.728943
|
||||||
|
C212.759430,935.807373 211.323883,932.810791 209.953308,929.784851
|
||||||
|
C194.710678,896.132019 179.506958,862.461426 164.141251,828.864929
|
||||||
|
C163.500443,827.463867 161.384949,825.863586 159.935028,825.842712
|
||||||
|
C146.945099,825.655151 133.950317,825.798279 120.957123,825.870789
|
||||||
|
C120.688065,825.872314 120.420792,826.192505 119.913864,826.516907
|
||||||
|
C119.973740,827.826477 120.039406,829.262817 120.100716,830.603882
|
||||||
|
C133.238220,834.237915 135.955963,837.707581 134.991364,850.879333
|
||||||
|
C133.738647,867.985535 132.188660,885.071472 130.613647,902.152039
|
||||||
|
C128.824738,921.551941 127.017845,940.952698 124.911133,960.319641
|
||||||
|
C124.019203,968.519043 120.197647,974.907959 111.409042,976.915833
|
||||||
|
C107.852699,977.728394 108.255356,980.038635 108.902344,982.731079
|
||||||
|
C124.439468,982.731079 139.842697,982.731079 155.116333,982.731079
|
||||||
|
C156.811462,978.687744 154.946045,977.575378 151.544220,976.828613
|
||||||
|
C143.097702,974.974426 139.366440,970.589966 138.594238,961.995789
|
||||||
|
C138.356949,959.354980 138.420029,956.659668 138.624588,954.010864
|
||||||
|
C140.740677,926.609070 142.896790,899.210327 145.094650,871.815002
|
||||||
|
C145.346909,868.670593 145.920700,865.552002 146.513458,861.182739
|
||||||
|
C147.693909,863.463379 148.267746,864.460632 148.741486,865.503357
|
||||||
|
C166.022629,903.541443 183.332962,941.566406 200.488159,979.661255
|
||||||
|
C201.743317,982.448425 203.278671,983.133362 206.097534,983.210938
|
||||||
|
C208.970184,983.290039 210.107437,982.111877 211.163452,979.658203
|
||||||
|
C223.996704,949.839294 236.926147,920.061707 249.866196,890.288818
|
||||||
|
C254.004623,880.767029 258.247681,871.290710 263.030640,860.463013
|
||||||
|
C263.521057,862.735840 263.764343,863.480652 263.833954,864.241211
|
||||||
|
C266.627228,894.762329 269.274414,925.297546 272.241516,955.801636
|
||||||
|
C273.645294,970.233887 270.144226,975.136658 258.330750,978.229797
|
||||||
|
C258.330750,979.628052 258.330750,981.059692 258.330750,982.774841
|
||||||
|
C278.626587,982.774841 298.706604,982.774841 319.045044,982.774841
|
||||||
|
C319.045044,980.990906 319.045044,979.439514 319.045044,977.546448
|
||||||
|
C307.608154,976.237854 303.706635,968.652954 302.708405,958.534302
|
||||||
|
C301.040771,941.630432 299.197998,924.743713 297.540649,907.838867
|
||||||
|
C295.656006,888.615662 293.569672,869.403381 292.199158,850.141663
|
||||||
|
C291.366211,838.434998 294.769104,834.433167 305.932343,830.965210
|
||||||
|
C306.362823,830.831482 306.727966,830.487488 307.120667,830.243225
|
||||||
|
C307.120667,828.833069 307.120667,827.539917 307.120667,825.713806
|
||||||
|
C294.333740,825.713806 281.713135,825.837219 269.098022,825.629333
|
||||||
|
C265.832306,825.575500 264.457031,826.887878 263.254242,829.656189
|
||||||
|
C255.693024,847.058838 247.976471,864.393921 240.043091,882.462097
|
||||||
|
M752.725037,926.095276
|
||||||
|
C747.955322,937.458862 743.343811,948.892395 738.361511,960.161987
|
||||||
|
C734.917969,967.950684 730.248596,974.777710 721.265503,977.139893
|
||||||
|
C717.893982,978.026367 717.950073,980.210571 719.115662,982.872314
|
||||||
|
C734.391174,982.872314 749.510132,982.872314 764.929199,982.872314
|
||||||
|
C764.929199,981.104614 764.929199,979.552429 764.929199,978.093872
|
||||||
|
C750.868347,973.479126 749.241211,970.191650 754.325134,956.485779
|
||||||
|
C756.988159,949.306335 759.733521,942.145935 762.822205,935.144836
|
||||||
|
C763.489502,933.632202 765.774597,931.888611 767.341187,931.862732
|
||||||
|
C783.994934,931.586853 800.654419,931.588562 817.311218,931.688599
|
||||||
|
C818.620361,931.696411 820.645081,932.695801 821.113647,933.768677
|
||||||
|
C825.371765,943.519958 829.602356,953.298340 833.337463,963.257690
|
||||||
|
C835.823853,969.887573 833.281921,974.602417 826.447388,976.767334
|
||||||
|
C823.035461,977.848083 821.768250,979.374268 823.218506,982.820190
|
||||||
|
C843.397034,982.820190 863.494934,982.820190 883.800537,982.820190
|
||||||
|
C883.800537,981.120361 883.800537,979.680908 883.800537,977.883240
|
||||||
|
C873.755371,976.109192 868.798645,968.913696 865.012085,960.308838
|
||||||
|
C845.381104,915.699036 825.586548,871.160767 806.124512,826.477661
|
||||||
|
C804.434143,822.596741 802.057678,823.122253 799.142822,822.839478
|
||||||
|
C796.060181,822.540283 794.831299,823.909180 793.738159,826.576355
|
||||||
|
C780.228333,859.539673 766.602478,892.455444 752.725037,926.095276
|
||||||
|
M1119.461914,971.209045
|
||||||
|
C1098.797729,980.869446 1073.666748,969.569031 1066.172729,947.202209
|
||||||
|
C1064.919189,943.461060 1063.903809,939.640137 1062.705566,935.611206
|
||||||
|
C1060.411133,935.963684 1058.504517,936.256592 1057.000000,936.487732
|
||||||
|
C1057.000000,948.935730 1056.916870,960.892395 1057.135986,972.843506
|
||||||
|
C1057.156616,973.968018 1058.873413,975.515259 1060.152222,976.093750
|
||||||
|
C1075.699585,983.127014 1091.884399,987.246033 1109.105469,986.033508
|
||||||
|
C1125.354492,984.889404 1139.900513,979.687256 1150.322632,966.548035
|
||||||
|
C1167.572388,944.801025 1161.346313,914.167542 1136.631348,899.357971
|
||||||
|
C1127.793091,894.061951 1118.190552,890.056335 1109.196533,884.999878
|
||||||
|
C1102.851807,881.432983 1096.142578,877.914795 1091.000122,872.927185
|
||||||
|
C1078.015869,860.333740 1083.995972,839.108215 1101.475952,834.644775
|
||||||
|
C1109.724121,832.538635 1118.009277,833.036560 1125.834717,836.952271
|
||||||
|
C1136.273926,842.176086 1141.064331,851.541321 1143.825439,862.141907
|
||||||
|
C1144.991333,866.618286 1147.184082,867.167847 1151.080688,865.670410
|
||||||
|
C1151.080688,859.555176 1151.159790,853.559021 1151.056763,847.566040
|
||||||
|
C1150.965942,842.283264 1152.591187,835.498535 1149.949097,832.129822
|
||||||
|
C1147.081421,828.473633 1140.257446,827.634033 1134.985840,826.201965
|
||||||
|
C1116.642456,821.218689 1098.529053,820.576172 1081.014038,829.683533
|
||||||
|
C1056.068848,842.654419 1050.181274,876.186279 1070.459595,895.583984
|
||||||
|
C1078.354980,903.136353 1088.671631,908.237915 1098.141357,914.037781
|
||||||
|
C1105.513550,918.552856 1113.746704,921.759766 1120.781006,926.708435
|
||||||
|
C1136.957275,938.088684 1136.393433,959.731079 1119.461914,971.209045
|
||||||
|
M410.241119,972.547424
|
||||||
|
C390.010254,966.709839 377.104034,953.318665 370.803955,933.542297
|
||||||
|
C364.793274,914.674377 364.440918,895.586609 369.720398,876.417664
|
||||||
|
C377.518402,848.104187 396.613190,833.567810 425.904724,833.794617
|
||||||
|
C447.896790,833.964844 463.754089,845.443665 470.377106,866.394104
|
||||||
|
C471.682861,870.524658 473.350433,872.402283 478.019867,870.456177
|
||||||
|
C477.456665,858.759216 476.956299,846.980957 476.205383,835.218750
|
||||||
|
C476.140411,834.200623 474.593964,832.822998 473.444275,832.394775
|
||||||
|
C445.710236,822.064392 417.425232,818.360352 388.855621,828.097534
|
||||||
|
C357.568085,838.761108 338.275635,860.711121 333.961670,893.668030
|
||||||
|
C329.663330,926.505554 340.614594,954.110657 369.422424,971.696716
|
||||||
|
C403.228607,992.333984 438.628601,989.872742 473.857178,974.409729
|
||||||
|
C474.850677,973.973572 475.911041,972.607849 476.079132,971.539978
|
||||||
|
C477.809326,960.549927 479.361694,949.531860 481.020905,938.127930
|
||||||
|
C478.976471,937.638977 477.134216,937.198364 475.160278,936.726257
|
||||||
|
C474.016022,939.454102 473.041382,941.737061 472.098083,944.032898
|
||||||
|
C465.199829,960.822510 453.006500,971.306580 434.860199,973.406677
|
||||||
|
C427.050293,974.310547 418.989166,973.043457 410.241119,972.547424
|
||||||
|
M597.621521,971.878357
|
||||||
|
C631.630188,991.818359 666.190979,989.852722 701.049133,974.400024
|
||||||
|
C702.088135,973.939514 703.313782,972.696960 703.482300,971.661133
|
||||||
|
C705.294312,960.522217 706.911133,949.351562 708.551392,938.389343
|
||||||
|
C704.149902,935.638611 702.045776,936.802002 700.562378,941.245056
|
||||||
|
C697.776672,949.588501 692.900085,956.670044 686.465759,962.748718
|
||||||
|
C668.544312,979.679260 624.789490,981.437256 605.977844,947.884399
|
||||||
|
C591.294006,921.693787 590.954163,893.914246 602.233765,866.489502
|
||||||
|
C613.684814,838.647949 637.191223,829.613220 665.056030,835.189758
|
||||||
|
C681.373169,838.455200 692.208862,848.626648 697.505615,864.514221
|
||||||
|
C698.230774,866.689453 699.002075,868.849304 699.811340,871.187683
|
||||||
|
C701.806091,871.083801 703.560730,870.992432 705.618835,870.885193
|
||||||
|
C705.096985,858.863403 704.655090,847.244019 703.985474,835.637817
|
||||||
|
C703.923462,834.563110 702.619812,833.023499 701.547424,832.621887
|
||||||
|
C673.181946,821.998047 644.379456,817.938538 615.220764,828.981201
|
||||||
|
C584.257874,840.707092 565.942810,863.315796 562.330505,896.230896
|
||||||
|
C558.868347,927.777710 570.462036,953.307617 597.621521,971.878357
|
||||||
|
M773.138794,743.546509
|
||||||
|
C772.200073,742.866699 771.289856,742.142761 770.318359,741.513611
|
||||||
|
C737.921082,720.529480 711.533325,693.640808 691.080872,660.914062
|
||||||
|
C689.984192,659.159241 688.834045,657.437805 687.169312,654.868103
|
||||||
|
C687.169312,685.306335 687.169312,714.642639 687.169312,744.122742
|
||||||
|
C716.004272,744.122742 744.455444,744.122742 773.138794,743.546509
|
||||||
|
M1066.790894,333.627319
|
||||||
|
C1077.286499,341.597107 1080.680542,353.318207 1083.160522,366.093536
|
||||||
|
C1090.884521,350.208221 1087.062866,320.920654 1081.299438,313.952759
|
||||||
|
C1068.203125,317.041840 1055.669800,319.998108 1044.897705,322.538940
|
||||||
|
C1050.960815,325.563965 1058.593994,329.372223 1066.790894,333.627319
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M342.378601,613.000061
|
||||||
|
C342.379639,656.639954 342.379639,699.779724 342.379639,743.261475
|
||||||
|
C310.820251,743.261475 279.754730,743.261475 248.418060,743.261475
|
||||||
|
C248.418060,593.610046 248.418060,444.198608 248.418060,294.115997
|
||||||
|
C250.310181,294.115997 252.080444,294.115448 253.850723,294.116089
|
||||||
|
C284.350067,294.127106 314.849731,294.204407 345.348358,294.060974
|
||||||
|
C348.936646,294.044098 351.029236,295.194672 353.086639,298.067871
|
||||||
|
C402.357910,366.875580 451.746399,435.599335 501.060333,504.376556
|
||||||
|
C515.623901,524.688110 530.022766,545.117676 544.507812,565.485596
|
||||||
|
C545.551270,566.952881 546.682434,568.357788 548.182922,570.330872
|
||||||
|
C555.453918,560.154175 562.484070,550.328247 569.500000,540.492188
|
||||||
|
C593.989014,506.159760 618.858459,472.103699 647.461365,441.020386
|
||||||
|
C677.993958,407.840149 711.883240,378.748932 751.852661,357.264099
|
||||||
|
C790.045532,336.734283 830.967285,325.967285 874.044983,322.741333
|
||||||
|
C909.323120,320.099457 943.889099,323.715210 977.770569,334.012177
|
||||||
|
C1016.585510,345.808441 1047.276855,369.189423 1072.729004,400.131012
|
||||||
|
C1072.920776,400.364136 1072.942627,400.737000 1073.177368,401.450104
|
||||||
|
C1054.087769,419.260529 1034.874756,437.186096 1017.396973,453.492737
|
||||||
|
C1009.992676,447.147247 1002.730652,441.062622 995.619995,434.805878
|
||||||
|
C964.895752,407.770935 928.537354,396.727844 888.272766,397.703644
|
||||||
|
C823.415344,399.275452 770.021179,426.895569 723.774902,470.764221
|
||||||
|
C697.542725,495.647705 674.754944,523.470703 653.120667,552.252075
|
||||||
|
C618.488220,598.325684 584.351501,644.771912 550.002014,691.058228
|
||||||
|
C549.029724,692.368408 548.020203,693.651001 546.863403,695.161438
|
||||||
|
C479.138947,601.534607 411.630127,508.205872 344.121338,414.877167
|
||||||
|
C343.540070,415.073395 342.958832,415.269623 342.377563,415.465851
|
||||||
|
C342.377563,481.143921 342.377563,546.821960 342.378601,613.000061
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M767.231995,704.764160
|
||||||
|
C728.986938,675.427490 702.723816,638.154053 689.754944,592.018433
|
||||||
|
C686.810425,581.543274 686.153748,570.405701 684.758789,559.532593
|
||||||
|
C684.516846,557.647095 685.342346,555.165527 686.533691,553.622314
|
||||||
|
C713.644348,518.504333 743.732910,486.362610 780.207947,460.673584
|
||||||
|
C781.617310,459.681000 783.091125,458.780029 784.840210,458.068756
|
||||||
|
C762.565247,496.493652 755.117004,536.829590 769.489075,579.463135
|
||||||
|
C779.692322,609.730286 799.310364,633.231873 826.294128,650.167725
|
||||||
|
C887.092346,688.326477 970.622925,674.647339 1016.167542,614.959229
|
||||||
|
C1034.303955,631.334961 1052.421997,647.694031 1070.519043,664.034180
|
||||||
|
C1002.016541,759.777527 858.763550,772.466553 767.231995,704.764160
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M408.997009,294.998718
|
||||||
|
C409.422485,294.900269 409.847961,294.801819 410.902557,294.985901
|
||||||
|
C413.686371,294.848938 415.841095,294.429413 417.995789,294.009888
|
||||||
|
C418.720734,293.887878 419.445709,293.765839 420.848450,293.940430
|
||||||
|
C424.014618,293.822540 426.503021,293.408051 428.991394,292.993530
|
||||||
|
C430.670837,292.858246 432.350281,292.722931 434.785583,292.897644
|
||||||
|
C437.495605,293.462982 439.444977,293.892731 441.404602,293.945770
|
||||||
|
C455.533234,294.328400 469.662933,294.756714 483.794708,294.868927
|
||||||
|
C486.863708,294.893280 489.942383,293.697327 493.016632,293.063416
|
||||||
|
C549.352417,262.637085 607.108459,236.081253 671.272217,226.568558
|
||||||
|
C708.936340,220.984604 746.606262,220.762939 784.319458,225.623398
|
||||||
|
C843.656006,233.270599 901.083801,249.180603 958.250061,266.153778
|
||||||
|
C987.651123,274.883209 1017.028503,284.040741 1047.939575,285.049744
|
||||||
|
C1061.482544,285.491821 1075.105347,283.528564 1088.689087,282.616180
|
||||||
|
C1090.154053,282.517822 1091.600708,282.143341 1093.055786,281.896942
|
||||||
|
C1093.307617,282.339355 1093.559448,282.781799 1093.811279,283.224213
|
||||||
|
C1087.853149,286.863739 1082.150146,291.035004 1075.887085,294.038757
|
||||||
|
C1059.904541,301.704041 1042.583984,304.003052 1025.211304,303.216339
|
||||||
|
C1002.125366,302.170929 979.010681,300.374115 956.081787,297.510864
|
||||||
|
C892.834778,289.612976 831.078369,294.132935 771.302368,317.503693
|
||||||
|
C752.783081,324.744263 735.223145,333.885864 718.389038,345.064514
|
||||||
|
C718.127869,345.232758 718.220886,345.224609 718.220825,345.224609
|
||||||
|
C719.080383,343.920410 719.755005,342.435852 720.826050,341.338104
|
||||||
|
C759.507080,301.690308 806.742737,279.872833 861.576477,273.961182
|
||||||
|
C869.187500,273.140594 876.813049,272.455048 884.430054,271.689056
|
||||||
|
C884.508972,271.681091 884.563477,271.430573 884.883545,270.763519
|
||||||
|
C883.002747,270.017517 881.159363,269.141052 879.229797,268.542175
|
||||||
|
C827.307434,252.427643 774.537720,241.899246 719.822815,242.017166
|
||||||
|
C668.285034,242.128235 619.553894,253.784515 572.740295,274.645020
|
||||||
|
C560.423279,280.133575 548.082947,285.569672 535.117798,290.870880
|
||||||
|
C532.487488,291.727478 530.492737,292.743103 528.497925,293.758728
|
||||||
|
C529.131165,294.745911 529.779785,296.596466 530.395142,296.585480
|
||||||
|
C535.940491,296.486267 541.479980,296.060577 547.021179,295.730652
|
||||||
|
C548.775757,295.769043 550.530273,295.807404 552.829468,296.285553
|
||||||
|
C559.278076,296.731598 565.182129,296.737885 571.086121,296.744171
|
||||||
|
C575.093689,296.771393 579.101318,296.798584 583.804810,297.227142
|
||||||
|
C585.870178,298.072968 587.235168,298.885956 588.609802,298.902527
|
||||||
|
C598.033081,299.016205 607.458923,299.011414 616.882568,298.910858
|
||||||
|
C618.282959,298.895905 619.675903,298.176605 621.072266,297.783142
|
||||||
|
C626.398865,297.746674 631.725525,297.710205 637.644531,298.075562
|
||||||
|
C640.780396,298.641632 643.326904,298.984131 645.866943,298.941223
|
||||||
|
C662.335876,298.663361 678.804749,298.351013 695.269043,297.887939
|
||||||
|
C696.856445,297.843262 698.412659,296.691559 699.983276,296.053741
|
||||||
|
C702.373352,295.885895 704.763428,295.718048 707.599915,296.021637
|
||||||
|
C708.033142,297.326355 708.019958,298.159607 708.006836,298.992889
|
||||||
|
C708.006836,298.992889 707.561462,299.212860 707.044373,299.010559
|
||||||
|
C704.351318,299.522675 702.175415,300.237122 699.999451,300.951569
|
||||||
|
C699.999451,300.951569 699.552368,301.165436 699.083008,300.968079
|
||||||
|
C697.741272,301.144348 696.868835,301.518005 695.996399,301.891693
|
||||||
|
C695.840027,301.959564 695.683716,302.027435 695.022827,301.886169
|
||||||
|
C690.345764,302.763977 686.173401,303.850952 682.000977,304.937958
|
||||||
|
C681.841431,304.999054 681.681946,305.060120 680.990479,304.885864
|
||||||
|
C676.973450,305.413300 673.488464,306.176025 670.003540,306.938782
|
||||||
|
C669.845032,307.002106 669.686584,307.065399 668.963623,306.887512
|
||||||
|
C666.269226,307.112488 664.139221,307.578674 662.009216,308.044861
|
||||||
|
C662.009216,308.044861 661.559326,308.241028 661.042786,307.996399
|
||||||
|
C659.017273,308.149658 657.508362,308.547516 655.999390,308.945374
|
||||||
|
C655.566650,309.040253 655.133850,309.135101 654.052795,308.951477
|
||||||
|
C650.606689,309.107513 647.808960,309.542023 645.011169,309.976532
|
||||||
|
C644.576538,310.065948 644.141846,310.155365 643.031616,309.953644
|
||||||
|
C636.750488,309.778473 631.144897,309.891510 625.539490,310.010986
|
||||||
|
C609.627075,310.350128 593.714172,310.984406 577.802368,310.958618
|
||||||
|
C558.000244,310.926514 538.199829,310.198822 518.396851,310.076691
|
||||||
|
C509.263031,310.020355 500.126709,310.896118 490.988037,310.983154
|
||||||
|
C474.214844,311.142975 457.438538,310.968201 440.665222,311.121002
|
||||||
|
C438.772491,311.138275 436.890778,312.366089 435.003967,313.032532
|
||||||
|
C434.843292,313.083008 434.682587,313.133514 433.940979,312.909851
|
||||||
|
C430.240143,313.071228 427.120209,313.506775 424.000244,313.942322
|
||||||
|
C423.271332,314.067871 422.542450,314.193451 421.174408,314.038879
|
||||||
|
C419.025238,314.156708 417.515198,314.554657 416.005157,314.952606
|
||||||
|
C415.581268,315.060455 415.157410,315.168335 414.114563,315.007416
|
||||||
|
C409.690247,313.744415 405.890686,310.544586 402.115387,315.150635
|
||||||
|
C401.572815,315.812592 399.369080,315.112976 397.934113,315.043488
|
||||||
|
C397.934113,315.043488 398.001038,315.017700 397.904816,314.754425
|
||||||
|
C397.404968,314.074646 397.001312,313.658112 396.597687,313.241608
|
||||||
|
C396.597687,313.241608 396.681732,313.248444 396.658508,312.974640
|
||||||
|
C396.093201,312.132385 395.551056,311.563965 395.008911,310.995544
|
||||||
|
C395.008911,310.995544 394.998779,311.000000 394.972046,310.659790
|
||||||
|
C394.253235,309.564453 393.561127,308.809326 392.869019,308.054199
|
||||||
|
C392.552826,307.710907 392.236633,307.367615 391.820557,306.451050
|
||||||
|
C391.116425,305.272400 390.512146,304.667053 389.907837,304.061707
|
||||||
|
C389.907837,304.061707 389.989868,304.026306 389.948242,303.673950
|
||||||
|
C389.603027,301.559052 389.299408,299.796478 388.995789,298.033905
|
||||||
|
C389.148346,297.962677 389.300934,297.891479 390.026886,298.070557
|
||||||
|
C396.732513,297.213440 402.864777,296.106079 408.997009,294.998718
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M240.177200,882.106689
|
||||||
|
C247.976471,864.393921 255.693024,847.058838 263.254242,829.656189
|
||||||
|
C264.457031,826.887878 265.832306,825.575500 269.098022,825.629333
|
||||||
|
C281.713135,825.837219 294.333740,825.713806 307.120667,825.713806
|
||||||
|
C307.120667,827.539917 307.120667,828.833069 307.120667,830.243225
|
||||||
|
C306.727966,830.487488 306.362823,830.831482 305.932343,830.965210
|
||||||
|
C294.769104,834.433167 291.366211,838.434998 292.199158,850.141663
|
||||||
|
C293.569672,869.403381 295.656006,888.615662 297.540649,907.838867
|
||||||
|
C299.197998,924.743713 301.040771,941.630432 302.708405,958.534302
|
||||||
|
C303.706635,968.652954 307.608154,976.237854 319.045044,977.546448
|
||||||
|
C319.045044,979.439514 319.045044,980.990906 319.045044,982.774841
|
||||||
|
C298.706604,982.774841 278.626587,982.774841 258.330750,982.774841
|
||||||
|
C258.330750,981.059692 258.330750,979.628052 258.330750,978.229797
|
||||||
|
C270.144226,975.136658 273.645294,970.233887 272.241516,955.801636
|
||||||
|
C269.274414,925.297546 266.627228,894.762329 263.833954,864.241211
|
||||||
|
C263.764343,863.480652 263.521057,862.735840 263.030640,860.463013
|
||||||
|
C258.247681,871.290710 254.004623,880.767029 249.866196,890.288818
|
||||||
|
C236.926147,920.061707 223.996704,949.839294 211.163452,979.658203
|
||||||
|
C210.107437,982.111877 208.970184,983.290039 206.097534,983.210938
|
||||||
|
C203.278671,983.133362 201.743317,982.448425 200.488159,979.661255
|
||||||
|
C183.332962,941.566406 166.022629,903.541443 148.741486,865.503357
|
||||||
|
C148.267746,864.460632 147.693909,863.463379 146.513458,861.182739
|
||||||
|
C145.920700,865.552002 145.346909,868.670593 145.094650,871.815002
|
||||||
|
C142.896790,899.210327 140.740677,926.609070 138.624588,954.010864
|
||||||
|
C138.420029,956.659668 138.356949,959.354980 138.594238,961.995789
|
||||||
|
C139.366440,970.589966 143.097702,974.974426 151.544220,976.828613
|
||||||
|
C154.946045,977.575378 156.811462,978.687744 155.116333,982.731079
|
||||||
|
C139.842697,982.731079 124.439468,982.731079 108.902344,982.731079
|
||||||
|
C108.255356,980.038635 107.852699,977.728394 111.409042,976.915833
|
||||||
|
C120.197647,974.907959 124.019203,968.519043 124.911133,960.319641
|
||||||
|
C127.017845,940.952698 128.824738,921.551941 130.613647,902.152039
|
||||||
|
C132.188660,885.071472 133.738647,867.985535 134.991364,850.879333
|
||||||
|
C135.955963,837.707581 133.238220,834.237915 120.100716,830.603882
|
||||||
|
C120.039406,829.262817 119.973740,827.826477 119.913864,826.516907
|
||||||
|
C120.420792,826.192505 120.688065,825.872314 120.957123,825.870789
|
||||||
|
C133.950317,825.798279 146.945099,825.655151 159.935028,825.842712
|
||||||
|
C161.384949,825.863586 163.500443,827.463867 164.141251,828.864929
|
||||||
|
C179.506958,862.461426 194.710678,896.132019 209.953308,929.784851
|
||||||
|
C211.323883,932.810791 212.759430,935.807373 214.590317,939.728943
|
||||||
|
C223.284592,920.167480 231.663834,901.314758 240.177200,882.106689
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M940.729797,964.960083
|
||||||
|
C943.201233,973.545105 943.790100,974.055786 955.957520,978.035461
|
||||||
|
C955.957520,979.512329 955.957520,981.068054 955.957520,982.831421
|
||||||
|
C936.268188,982.831421 916.869507,982.831421 897.455200,982.831421
|
||||||
|
C896.383850,979.606018 896.860291,977.593994 900.457458,976.891174
|
||||||
|
C908.202637,975.377869 911.380554,969.939453 912.083313,962.643433
|
||||||
|
C912.353821,959.834412 912.420959,956.996643 912.420654,954.171997
|
||||||
|
C912.416748,919.896423 912.385498,885.620789 912.348938,851.345215
|
||||||
|
C912.347168,849.683716 912.298889,848.016418 912.155518,846.361877
|
||||||
|
C911.388550,837.512146 908.254639,833.730408 899.327271,830.650452
|
||||||
|
C898.998291,828.714905 899.003296,827.332336 899.008301,825.949829
|
||||||
|
C899.756165,825.885620 900.503906,825.821350 901.960510,826.090393
|
||||||
|
C909.783081,826.271973 916.896912,826.120239 924.010681,825.968506
|
||||||
|
C928.365662,825.896179 932.720642,825.823792 937.874146,826.088135
|
||||||
|
C945.450867,826.275879 952.229187,826.126770 959.007446,825.977722
|
||||||
|
C972.178833,825.056641 984.955994,826.595154 996.972046,832.387451
|
||||||
|
C1011.481079,839.381470 1018.880737,851.153076 1018.604919,867.123840
|
||||||
|
C1018.324402,883.373596 1010.548279,895.329285 995.723022,902.341370
|
||||||
|
C992.126099,904.042664 988.278015,905.212891 984.127380,906.785706
|
||||||
|
C984.664795,907.776245 985.158569,908.917725 985.849121,909.924377
|
||||||
|
C996.874023,925.994629 1007.823547,942.118103 1019.008789,958.076355
|
||||||
|
C1024.974609,966.588135 1031.492432,974.581238 1042.161377,977.765869
|
||||||
|
C1043.281616,978.100159 1043.779175,980.521118 1044.823486,982.450378
|
||||||
|
C1033.125488,984.504272 1022.533875,984.789978 1011.994629,982.646057
|
||||||
|
C1003.011658,980.818787 996.323059,975.437500 991.488892,967.832458
|
||||||
|
C982.464905,953.635925 973.048279,939.644592 964.879822,924.964233
|
||||||
|
C959.488159,915.274353 953.093933,909.562439 940.800171,912.097412
|
||||||
|
C939.688904,913.496460 939.047241,914.562805 939.042847,915.631714
|
||||||
|
C938.980408,930.797241 938.962952,945.963440 939.081909,961.128296
|
||||||
|
C939.091980,962.409790 940.155640,963.683044 940.729797,964.960083
|
||||||
|
M941.633545,902.196960
|
||||||
|
C945.962891,902.198486 950.294861,902.298706 954.621155,902.184753
|
||||||
|
C974.393188,901.663879 985.523438,891.988342 987.349426,872.436340
|
||||||
|
C989.189636,852.732117 980.520813,836.094604 955.656189,835.838257
|
||||||
|
C950.663635,835.786743 945.669373,835.906006 940.091187,836.425354
|
||||||
|
C939.726990,837.641235 939.049744,838.855957 939.045349,840.073242
|
||||||
|
C938.975586,859.280090 938.956177,878.487427 939.061584,897.693970
|
||||||
|
C939.069580,899.150696 940.104980,900.601746 941.633545,902.196960
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M752.865784,925.739380
|
||||||
|
C766.602478,892.455444 780.228333,859.539673 793.738159,826.576355
|
||||||
|
C794.831299,823.909180 796.060181,822.540283 799.142822,822.839478
|
||||||
|
C802.057678,823.122253 804.434143,822.596741 806.124512,826.477661
|
||||||
|
C825.586548,871.160767 845.381104,915.699036 865.012085,960.308838
|
||||||
|
C868.798645,968.913696 873.755371,976.109192 883.800537,977.883240
|
||||||
|
C883.800537,979.680908 883.800537,981.120361 883.800537,982.820190
|
||||||
|
C863.494934,982.820190 843.397034,982.820190 823.218506,982.820190
|
||||||
|
C821.768250,979.374268 823.035461,977.848083 826.447388,976.767334
|
||||||
|
C833.281921,974.602417 835.823853,969.887573 833.337463,963.257690
|
||||||
|
C829.602356,953.298340 825.371765,943.519958 821.113647,933.768677
|
||||||
|
C820.645081,932.695801 818.620361,931.696411 817.311218,931.688599
|
||||||
|
C800.654419,931.588562 783.994934,931.586853 767.341187,931.862732
|
||||||
|
C765.774597,931.888611 763.489502,933.632202 762.822205,935.144836
|
||||||
|
C759.733521,942.145935 756.988159,949.306335 754.325134,956.485779
|
||||||
|
C749.241211,970.191650 750.868347,973.479126 764.929199,978.093872
|
||||||
|
C764.929199,979.552429 764.929199,981.104614 764.929199,982.872314
|
||||||
|
C749.510132,982.872314 734.391174,982.872314 719.115662,982.872314
|
||||||
|
C717.950073,980.210571 717.893982,978.026367 721.265503,977.139893
|
||||||
|
C730.248596,974.777710 734.917969,967.950684 738.361511,960.161987
|
||||||
|
C743.343811,948.892395 747.955322,937.458862 752.865784,925.739380
|
||||||
|
M809.037659,903.434570
|
||||||
|
C803.494446,890.223938 797.951233,877.013367 791.896851,862.584412
|
||||||
|
C783.823975,882.648804 776.338501,901.253174 768.777405,920.045471
|
||||||
|
C784.698364,920.045471 799.996643,920.045471 815.807434,920.045471
|
||||||
|
C813.469604,914.304993 811.400269,909.223877 809.037659,903.434570
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M1119.793213,971.032227
|
||||||
|
C1136.393433,959.731079 1136.957275,938.088684 1120.781006,926.708435
|
||||||
|
C1113.746704,921.759766 1105.513550,918.552856 1098.141357,914.037781
|
||||||
|
C1088.671631,908.237915 1078.354980,903.136353 1070.459595,895.583984
|
||||||
|
C1050.181274,876.186279 1056.068848,842.654419 1081.014038,829.683533
|
||||||
|
C1098.529053,820.576172 1116.642456,821.218689 1134.985840,826.201965
|
||||||
|
C1140.257446,827.634033 1147.081421,828.473633 1149.949097,832.129822
|
||||||
|
C1152.591187,835.498535 1150.965942,842.283264 1151.056763,847.566040
|
||||||
|
C1151.159790,853.559021 1151.080688,859.555176 1151.080688,865.670410
|
||||||
|
C1147.184082,867.167847 1144.991333,866.618286 1143.825439,862.141907
|
||||||
|
C1141.064331,851.541321 1136.273926,842.176086 1125.834717,836.952271
|
||||||
|
C1118.009277,833.036560 1109.724121,832.538635 1101.475952,834.644775
|
||||||
|
C1083.995972,839.108215 1078.015869,860.333740 1091.000122,872.927185
|
||||||
|
C1096.142578,877.914795 1102.851807,881.432983 1109.196533,884.999878
|
||||||
|
C1118.190552,890.056335 1127.793091,894.061951 1136.631348,899.357971
|
||||||
|
C1161.346313,914.167542 1167.572388,944.801025 1150.322632,966.548035
|
||||||
|
C1139.900513,979.687256 1125.354492,984.889404 1109.105469,986.033508
|
||||||
|
C1091.884399,987.246033 1075.699585,983.127014 1060.152222,976.093750
|
||||||
|
C1058.873413,975.515259 1057.156616,973.968018 1057.135986,972.843506
|
||||||
|
C1056.916870,960.892395 1057.000000,948.935730 1057.000000,936.487732
|
||||||
|
C1058.504517,936.256592 1060.411133,935.963684 1062.705566,935.611206
|
||||||
|
C1063.903809,939.640137 1064.919189,943.461060 1066.172729,947.202209
|
||||||
|
C1073.666748,969.569031 1098.797729,980.869446 1119.793213,971.032227
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M410.641632,972.655029
|
||||||
|
C418.989166,973.043457 427.050293,974.310547 434.860199,973.406677
|
||||||
|
C453.006500,971.306580 465.199829,960.822510 472.098083,944.032898
|
||||||
|
C473.041382,941.737061 474.016022,939.454102 475.160278,936.726257
|
||||||
|
C477.134216,937.198364 478.976471,937.638977 481.020905,938.127930
|
||||||
|
C479.361694,949.531860 477.809326,960.549927 476.079132,971.539978
|
||||||
|
C475.911041,972.607849 474.850677,973.973572 473.857178,974.409729
|
||||||
|
C438.628601,989.872742 403.228607,992.333984 369.422424,971.696716
|
||||||
|
C340.614594,954.110657 329.663330,926.505554 333.961670,893.668030
|
||||||
|
C338.275635,860.711121 357.568085,838.761108 388.855621,828.097534
|
||||||
|
C417.425232,818.360352 445.710236,822.064392 473.444275,832.394775
|
||||||
|
C474.593964,832.822998 476.140411,834.200623 476.205383,835.218750
|
||||||
|
C476.956299,846.980957 477.456665,858.759216 478.019867,870.456177
|
||||||
|
C473.350433,872.402283 471.682861,870.524658 470.377106,866.394104
|
||||||
|
C463.754089,845.443665 447.896790,833.964844 425.904724,833.794617
|
||||||
|
C396.613190,833.567810 377.518402,848.104187 369.720398,876.417664
|
||||||
|
C364.440918,895.586609 364.793274,914.674377 370.803955,933.542297
|
||||||
|
C377.104034,953.318665 390.010254,966.709839 410.641632,972.655029
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M597.319336,971.679199
|
||||||
|
C570.462036,953.307617 558.868347,927.777710 562.330505,896.230896
|
||||||
|
C565.942810,863.315796 584.257874,840.707092 615.220764,828.981201
|
||||||
|
C644.379456,817.938538 673.181946,821.998047 701.547424,832.621887
|
||||||
|
C702.619812,833.023499 703.923462,834.563110 703.985474,835.637817
|
||||||
|
C704.655090,847.244019 705.096985,858.863403 705.618835,870.885193
|
||||||
|
C703.560730,870.992432 701.806091,871.083801 699.811340,871.187683
|
||||||
|
C699.002075,868.849304 698.230774,866.689453 697.505615,864.514221
|
||||||
|
C692.208862,848.626648 681.373169,838.455200 665.056030,835.189758
|
||||||
|
C637.191223,829.613220 613.684814,838.647949 602.233765,866.489502
|
||||||
|
C590.954163,893.914246 591.294006,921.693787 605.977844,947.884399
|
||||||
|
C624.789490,981.437256 668.544312,979.679260 686.465759,962.748718
|
||||||
|
C692.900085,956.670044 697.776672,949.588501 700.562378,941.245056
|
||||||
|
C702.045776,936.802002 704.149902,935.638611 708.551392,938.389343
|
||||||
|
C706.911133,949.351562 705.294312,960.522217 703.482300,971.661133
|
||||||
|
C703.313782,972.696960 702.088135,973.939514 701.049133,974.400024
|
||||||
|
C666.190979,989.852722 631.630188,991.818359 597.319336,971.679199
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M773.022705,743.834595
|
||||||
|
C744.455444,744.122742 716.004272,744.122742 687.169312,744.122742
|
||||||
|
C687.169312,714.642639 687.169312,685.306335 687.169312,654.868103
|
||||||
|
C688.834045,657.437805 689.984192,659.159241 691.080872,660.914062
|
||||||
|
C711.533325,693.640808 737.921082,720.529480 770.318359,741.513611
|
||||||
|
C771.289856,742.142761 772.200073,742.866699 773.022705,743.834595
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M1066.509033,333.403931
|
||||||
|
C1058.593994,329.372223 1050.960815,325.563965 1044.897705,322.538940
|
||||||
|
C1055.669800,319.998108 1068.203125,317.041840 1081.299438,313.952759
|
||||||
|
C1087.062866,320.920654 1090.884521,350.208221 1083.160522,366.093536
|
||||||
|
C1080.680542,353.318207 1077.286499,341.597107 1066.509033,333.403931
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M435.356293,313.110229
|
||||||
|
C436.890778,312.366089 438.772491,311.138275 440.665222,311.121002
|
||||||
|
C457.438538,310.968201 474.214844,311.142975 490.988037,310.983154
|
||||||
|
C500.126709,310.896118 509.263031,310.020355 518.396851,310.076691
|
||||||
|
C538.199829,310.198822 558.000244,310.926514 577.802368,310.958618
|
||||||
|
C593.714172,310.984406 609.627075,310.350128 625.539490,310.010986
|
||||||
|
C631.144897,309.891510 636.750488,309.778473 642.701294,309.893982
|
||||||
|
C625.637146,311.053101 608.231873,312.664124 590.817383,312.771454
|
||||||
|
C549.750366,313.024567 508.679047,312.498688 467.609253,312.430511
|
||||||
|
C456.976135,312.412842 446.342194,312.921204 435.356293,313.110229
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M699.645386,295.962311
|
||||||
|
C698.412659,296.691559 696.856445,297.843262 695.269043,297.887939
|
||||||
|
C678.804749,298.351013 662.335876,298.663361 645.866943,298.941223
|
||||||
|
C643.326904,298.984131 640.780396,298.641632 638.101990,298.168732
|
||||||
|
C650.933472,297.473816 663.900513,297.106110 676.866028,296.694214
|
||||||
|
C684.347717,296.456543 691.827026,296.147675 699.645386,295.962311
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M492.620850,293.030731
|
||||||
|
C489.942383,293.697327 486.863708,294.893280 483.794708,294.868927
|
||||||
|
C469.662933,294.756714 455.533234,294.328400 441.404602,293.945770
|
||||||
|
C439.444977,293.892731 437.495605,293.462982 435.205078,292.942535
|
||||||
|
C449.828644,292.580688 464.788544,292.434906 479.748596,292.418854
|
||||||
|
C483.907227,292.414398 488.066254,292.794434 492.620850,293.030731
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M940.738831,964.493774
|
||||||
|
C940.155640,963.683044 939.091980,962.409790 939.081909,961.128296
|
||||||
|
C938.962952,945.963440 938.980408,930.797241 939.042847,915.631714
|
||||||
|
C939.047241,914.562805 939.688904,913.496460 940.318848,912.184021
|
||||||
|
C940.651550,929.301880 940.699707,946.664612 940.738831,964.493774
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M620.679626,297.659790
|
||||||
|
C619.675903,298.176605 618.282959,298.895905 616.882568,298.910858
|
||||||
|
C607.458923,299.011414 598.033081,299.016205 588.609802,298.902527
|
||||||
|
C587.235168,298.885956 585.870178,298.072968 584.258423,297.328979
|
||||||
|
C596.106445,297.198456 608.196716,297.367432 620.679626,297.659790
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M546.573242,295.607788
|
||||||
|
C541.479980,296.060577 535.940491,296.486267 530.395142,296.585480
|
||||||
|
C529.779785,296.596466 529.131165,294.745911 528.497925,293.758728
|
||||||
|
C530.492737,292.743103 532.487488,291.727478 534.749878,290.922791
|
||||||
|
C533.819641,291.936401 532.621826,292.739075 531.423950,293.541748
|
||||||
|
C531.428833,293.914825 531.433716,294.287903 531.438599,294.660950
|
||||||
|
C536.334167,294.935608 541.229675,295.210266 546.573242,295.607788
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M398.042603,315.396851
|
||||||
|
C399.369080,315.112976 401.572815,315.812592 402.115387,315.150635
|
||||||
|
C405.890686,310.544586 409.690247,313.744415 413.786469,314.966248
|
||||||
|
C410.096466,316.002075 406.139893,317.010681 402.120117,317.498596
|
||||||
|
C400.886017,317.648407 399.478943,316.373016 398.042603,315.396851
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M408.664062,294.935425
|
||||||
|
C402.864777,296.106079 396.732513,297.213440 390.278076,298.096863
|
||||||
|
C396.080933,296.872681 402.205994,295.872437 408.664062,294.935425
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M570.710999,296.587433
|
||||||
|
C565.182129,296.737885 559.278076,296.731598 553.195923,296.386230
|
||||||
|
C558.790405,296.175018 564.563171,296.302856 570.710999,296.587433
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M708.393738,299.007385
|
||||||
|
C708.019958,298.159607 708.033142,297.326355 708.046631,296.076233
|
||||||
|
C712.271484,295.407745 716.495972,295.156158 720.720520,294.904541
|
||||||
|
C720.792908,295.202606 720.865356,295.500671 720.937744,295.798706
|
||||||
|
C716.885376,296.873108 712.833008,297.947479 708.393738,299.007385
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M682.422974,304.972260
|
||||||
|
C686.173401,303.850952 690.345764,302.763977 694.768005,301.866577
|
||||||
|
C690.960205,303.039612 686.902588,304.023102 682.422974,304.972260
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M670.431458,306.985229
|
||||||
|
C673.488464,306.176025 676.973450,305.413300 680.736755,304.850525
|
||||||
|
C677.629883,305.710907 674.244629,306.371277 670.431458,306.985229
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M923.547119,825.889526
|
||||||
|
C916.896912,826.120239 909.783081,826.271973 902.325073,826.190125
|
||||||
|
C909.015076,825.907898 916.049316,825.859253 923.547119,825.889526
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M958.544434,825.897095
|
||||||
|
C952.229187,826.126770 945.450867,826.275879 938.327393,826.193237
|
||||||
|
C944.681885,825.913208 951.381653,825.864868 958.544434,825.897095
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M424.437378,313.995178
|
||||||
|
C427.120209,313.506775 430.240143,313.071228 433.694092,312.856262
|
||||||
|
C430.976898,313.400574 427.925720,313.724304 424.437378,313.995178
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M645.332581,310.046631
|
||||||
|
C647.808960,309.542023 650.606689,309.107513 653.722839,308.893127
|
||||||
|
C651.245483,309.447723 648.449707,309.782227 645.332581,310.046631
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M417.685455,293.921753
|
||||||
|
C415.841095,294.429413 413.686371,294.848938 411.228638,295.038696
|
||||||
|
C413.075470,294.483856 415.225281,294.158722 417.685455,293.921753
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M428.576233,292.919678
|
||||||
|
C426.503021,293.408051 424.014618,293.822540 421.214050,293.995117
|
||||||
|
C423.321564,293.450775 425.741302,293.148315 428.576233,292.919678
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M662.322876,308.112000
|
||||||
|
C664.139221,307.578674 666.269226,307.112488 668.709595,306.856384
|
||||||
|
C666.892090,307.437378 664.764282,307.808258 662.322876,308.112000
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M700.379517,300.972443
|
||||||
|
C702.175415,300.237122 704.351318,299.522675 706.796570,298.993774
|
||||||
|
C704.963745,299.783997 702.861633,300.388641 700.379517,300.972443
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M388.653870,297.998535
|
||||||
|
C389.299408,299.796478 389.603027,301.559052 389.917664,303.706543
|
||||||
|
C388.847778,302.691284 387.673859,301.346039 386.781708,299.834503
|
||||||
|
C386.640411,299.595123 387.769958,298.605743 388.653870,297.998535
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M898.596802,825.897400
|
||||||
|
C899.003296,827.332336 898.998291,828.714905 898.990051,830.511719
|
||||||
|
C895.281250,829.774719 896.539246,827.840454 898.596802,825.897400
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M416.375336,315.003784
|
||||||
|
C417.515198,314.554657 419.025238,314.156708 420.806274,313.980682
|
||||||
|
C419.633331,314.486694 418.189423,314.770813 416.375336,315.003784
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M656.370605,308.998993
|
||||||
|
C657.508362,308.547516 659.017273,308.149658 660.798828,307.970093
|
||||||
|
C659.628174,308.476440 658.184998,308.764557 656.370605,308.998993
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M696.318726,301.935730
|
||||||
|
C696.868835,301.518005 697.741272,301.144348 698.835388,300.959106
|
||||||
|
C698.251709,301.424927 697.446350,301.702332 696.318726,301.935730
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M392.945984,308.353699
|
||||||
|
C393.561127,308.809326 394.253235,309.564453 394.975433,310.654694
|
||||||
|
C394.344666,310.210907 393.683838,309.432037 392.945984,308.353699
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M395.073303,311.283508
|
||||||
|
C395.551056,311.563965 396.093201,312.132385 396.648926,313.015686
|
||||||
|
C396.154205,312.744232 395.645935,312.157867 395.073303,311.283508
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M390.000275,304.368988
|
||||||
|
C390.512146,304.667053 391.116425,305.272400 391.762512,306.218262
|
||||||
|
C391.233795,305.931274 390.663269,305.303772 390.000275,304.368988
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M396.689453,313.520447
|
||||||
|
C397.001312,313.658112 397.404968,314.074646 397.880066,314.780457
|
||||||
|
C397.561462,314.646301 397.171356,314.222809 396.689453,313.520447
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M718.106323,345.274139
|
||||||
|
C718.084839,345.315521 717.972961,345.058960 717.972961,345.058960
|
||||||
|
C717.972961,345.058960 718.093445,345.327209 718.157166,345.275909
|
||||||
|
C718.220886,345.224609 718.127869,345.232758 718.106323,345.274139
|
||||||
|
z"/>
|
||||||
|
<path fill="#181b23" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M940.675903,835.946533
|
||||||
|
C945.669373,835.906006 950.663635,835.786743 955.656189,835.838257
|
||||||
|
C980.520813,836.094604 989.189636,852.732117 987.349426,872.436340
|
||||||
|
C985.523438,891.988342 974.393188,901.663879 954.621155,902.184753
|
||||||
|
C950.294861,902.298706 945.962891,902.198486 941.131226,901.654419
|
||||||
|
C940.644592,879.390076 940.660278,857.668274 940.675903,835.946533
|
||||||
|
z"/>
|
||||||
|
<path fill="#c48a42" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M940.383545,836.185913
|
||||||
|
C940.660278,857.668274 940.644592,879.390076 940.645874,901.583618
|
||||||
|
C940.104980,900.601746 939.069580,899.150696 939.061584,897.693970
|
||||||
|
C938.956177,878.487427 938.975586,859.280090 939.045349,840.073242
|
||||||
|
C939.049744,838.855957 939.726990,837.641235 940.383545,836.185913
|
||||||
|
z"/>
|
||||||
|
<path fill="#181b23" opacity="1.000000" stroke="none"
|
||||||
|
d="
|
||||||
|
M809.184326,903.788635
|
||||||
|
C811.400269,909.223877 813.469604,914.304993 815.807434,920.045471
|
||||||
|
C799.996643,920.045471 784.698364,920.045471 768.777405,920.045471
|
||||||
|
C776.338501,901.253174 783.823975,882.648804 791.896851,862.584412
|
||||||
|
C797.951233,877.013367 803.494446,890.223938 809.184326,903.788635
|
||||||
|
z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 MiB |
+25
-17
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Impressum · MC Cars (GmbH)</title>
|
<title>Impressum · MC Cars (GmbH)</title>
|
||||||
<link rel="icon" type="image/png" 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.png" />
|
<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 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" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
|
||||||
@@ -48,13 +48,12 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<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>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<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="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -63,15 +62,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main style="padding: 3rem 0;">
|
<main style="padding: 3rem 0;">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<h1>Impressum</h1>
|
<h1>Impressum</h1>
|
||||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||||
<p><strong>MC Cars (GmbH)</strong></p>
|
<p><strong>MC Cars GmbH</strong></p>
|
||||||
<p>Standort: Steiermark (TBD)</p>
|
<p>Gaisfeld 1/2<br/>8564 Krottendorf-Gaisfeld</p>
|
||||||
<p>E-Mail: hello@mccars.at</p>
|
<p>FN 675751 b · Landesgericht für Zivilrechtssachen Graz</p>
|
||||||
<p>Telefon: +43 316 880000</p>
|
<p>Geschäftsführer: Christian Leski, Marco Schober</p>
|
||||||
<p>Firmenbuch und UID werden nachgereicht.</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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -81,16 +90,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<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>
|
<span>MC Cars</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,8 +112,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+33
-66
@@ -3,17 +3,21 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>MC Cars · Sportwagenvermietung Steiermark</title>
|
<title>MC Cars · Ferrari-Vermietung Steiermark</title>
|
||||||
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<meta name="description" content="MC Cars · Premium Ferrari-Vermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||||
<link rel="icon" type="image/png" 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.png" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<!-- Fonts loaded async: display=optional means they never block render -->
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<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 -->
|
<!-- SEO & Social Meta Tags -->
|
||||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
<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="theme-color" content="#1a1a1a" />
|
||||||
<meta name="language" content="German" />
|
<meta name="language" content="German" />
|
||||||
<link rel="canonical" href="https://demo.lago.dev/" />
|
<link rel="canonical" href="https://demo.lago.dev/" />
|
||||||
@@ -23,8 +27,8 @@
|
|||||||
|
|
||||||
<!-- Open Graph Tags -->
|
<!-- Open Graph Tags -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="MC Cars – Premium Sportwagen & Luxusvermietung" />
|
<meta property="og:title" content="MC Cars – Premium Ferrari-Vermietung Steiermark" />
|
||||||
<meta property="og:description" content="Fahren Sie Premium-Sportwagen und Luxusklasse-Fahrzeuge in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<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:url" content="https://demo.lago.dev/" />
|
||||||
<meta property="og:site_name" content="MC Cars" />
|
<meta property="og:site_name" content="MC Cars" />
|
||||||
<meta property="og:locale" content="de_AT" />
|
<meta property="og:locale" content="de_AT" />
|
||||||
@@ -34,12 +38,10 @@
|
|||||||
|
|
||||||
<!-- Twitter Card Tags -->
|
<!-- Twitter Card Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="MC Cars – Premium Sportwagen & Luxusvermietung" />
|
<meta name="twitter:title" content="MC Cars – Premium Ferrari-Vermietung Steiermark" />
|
||||||
<meta name="twitter:description" content="Fahren Sie Premium-Sportwagen in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<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" />
|
<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) -->
|
<!-- Structured Data (JSON-LD) -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
@@ -48,9 +50,9 @@
|
|||||||
"@id": "https://demo.lago.dev/#organization",
|
"@id": "https://demo.lago.dev/#organization",
|
||||||
"name": "MC Cars GmbH",
|
"name": "MC Cars GmbH",
|
||||||
"alternateName": "MC Cars",
|
"alternateName": "MC Cars",
|
||||||
"description": "Premium Sportwagen- und Luxusvermietung in der Steiermark",
|
"description": "Premium Ferrari-Vermietung in der Steiermark",
|
||||||
"url": "https://demo.lago.dev",
|
"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",
|
"image": "https://demo.lago.dev/images/mc-cars-og-image.png",
|
||||||
"areaServed": {
|
"areaServed": {
|
||||||
"@type": "Place",
|
"@type": "Place",
|
||||||
@@ -61,10 +63,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priceRange": "€€€",
|
"priceRange": "€€€",
|
||||||
"serviceType": "Sportwagenvermietung",
|
"serviceType": "Ferrari-Vermietung",
|
||||||
"sameAs": [
|
"sameAs": [
|
||||||
"https://www.facebook.com/mccars",
|
"https://www.facebook.com/mc-cars",
|
||||||
"https://www.instagram.com/mccars"
|
"https://www.instagram.com/mc-cars"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -74,8 +76,8 @@
|
|||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "MC Cars GmbH",
|
"name": "MC Cars GmbH",
|
||||||
"url": "https://demo.lago.dev",
|
"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",
|
||||||
"description": "Premium Sportwagen- und Luxusvermietung in Steiermark, Österreich",
|
"description": "Premium Ferrari-Vermietung in Steiermark, Österreich",
|
||||||
"foundingDate": "2024",
|
"foundingDate": "2024",
|
||||||
"contactPoint": {
|
"contactPoint": {
|
||||||
"@type": "ContactPoint",
|
"@type": "ContactPoint",
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<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>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -111,7 +113,6 @@
|
|||||||
|
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
<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="#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -126,16 +127,15 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
|
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
|
||||||
<h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
|
<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">
|
<div class="hero-cta">
|
||||||
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<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>
|
||||||
|
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div><strong data-i18n="statDeposit">Faire Kaution</strong><span>Fair Deposit</span></div>
|
<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><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +146,6 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
|
|
||||||
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
|
<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>
|
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,37 +176,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Toast Notification -->
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
<!-- Reviews -->
|
<!-- Reviews -->
|
||||||
@@ -250,8 +218,8 @@
|
|||||||
<h3 class="bpf-panel-title">🚗 <span data-i18n="stepVehicleTime">Fahrzeug & Zeitraum</span></h3>
|
<h3 class="bpf-panel-title">🚗 <span data-i18n="stepVehicleTime">Fahrzeug & Zeitraum</span></h3>
|
||||||
|
|
||||||
<div class="bpf-field">
|
<div class="bpf-field">
|
||||||
<label data-i18n="bpfVehicle">Fahrzeug</label>
|
<label for="bpfCar" id="bpfCarLabel" data-i18n="bpfVehicle">Fahrzeug</label>
|
||||||
<select id="bpfCar">
|
<select id="bpfCar" aria-labelledby="bpfCarLabel">
|
||||||
<option value="" data-i18n="bpfSelectVehicle">Fahrzeug wählen</option>
|
<option value="" data-i18n="bpfSelectVehicle">Fahrzeug wählen</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,17 +366,16 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<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>
|
<span>MC Cars</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="#warum" data-i18n="navWhy">Warum wir</a>
|
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -421,8 +388,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,6 +410,7 @@
|
|||||||
|
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Mietbedingungen · MC Cars</title>
|
<title>Mietbedingungen · MC Cars</title>
|
||||||
<link rel="icon" type="image/png" 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.png" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 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">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<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>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<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="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt 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.
|
Die Mietbedingungen regeln die Nutzung der Mietfahrzeuge, Zahlungsbedingungen, Haftung und Versicherung.
|
||||||
</p>
|
</p>
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,16 +87,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<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>
|
<span>MC Cars</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,8 +109,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -20,8 +20,9 @@ server {
|
|||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
# CSS/JS: no cache to prevent stale content during development
|
||||||
location ~* \.(?:css|js)$ {
|
location ~* \.(?:css|js)$ {
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-store";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-44
@@ -1,49 +1,6 @@
|
|||||||
# MC Cars - Robots Configuration
|
|
||||||
# For demo.lago.dev domain
|
|
||||||
|
|
||||||
User-agent: *
|
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.html
|
||||||
Disallow: /admin/
|
Disallow: /admin/
|
||||||
Disallow: /config.js
|
Disallow: /config.js
|
||||||
|
|
||||||
# Prevent crawling of query strings (pagination, filters, etc.)
|
Sitemap: https://www.mc-cars.at/sitemap.xml
|
||||||
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: /
|
|
||||||
|
|||||||
+362
-19
@@ -98,17 +98,14 @@ section { padding: 5rem 0; }
|
|||||||
.logo:hover { opacity: 0.85; }
|
.logo:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
width: 2.3rem;
|
width: 2.6rem;
|
||||||
height: 2.3rem;
|
height: 2.6rem;
|
||||||
border-radius: 10px;
|
object-fit: contain;
|
||||||
object-fit: cover;
|
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover .logo-icon {
|
.logo:hover .logo-icon {
|
||||||
transform: scale(1.04);
|
transform: scale(1.04);
|
||||||
box-shadow: 0 6px 16px rgba(196, 138, 66, 0.55);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-mark {
|
.logo-mark {
|
||||||
@@ -260,7 +257,7 @@ section { padding: 5rem 0; }
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(11,12,16,0.6) 0%, rgba(11,12,16,0.95) 100%),
|
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;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,10 +389,17 @@ select:focus, input:focus, textarea:focus {
|
|||||||
.vehicle-photo {
|
.vehicle-photo {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
background: #0e1015 center / cover no-repeat;
|
background: #0e1015;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.4s ease;
|
transition: transform 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vehicle-photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.vehicle-card:hover .vehicle-photo {
|
.vehicle-card:hover .vehicle-photo {
|
||||||
transform: scale(1.02);
|
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);
|
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 {
|
.vehicle-body {
|
||||||
padding: 1.4rem;
|
padding: 1.4rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -555,12 +699,32 @@ select:focus, input:focus, textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.review-dots button {
|
.review-dots button {
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
width: 44px;
|
||||||
border: none; background: var(--line); cursor: pointer;
|
height: 44px;
|
||||||
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s ease, width 0.3s ease;
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
outline-offset: 4px;
|
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 {
|
.review-dots button:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-dots button:hover::before {
|
||||||
background: rgba(196, 138, 66, 0.5);
|
background: rgba(196, 138, 66, 0.5);
|
||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
}
|
}
|
||||||
@@ -568,12 +732,15 @@ select:focus, input:focus, textarea:focus {
|
|||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
.review-dots button.active {
|
.review-dots button.active {
|
||||||
background: var(--accent);
|
|
||||||
width: 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-dots button.active::before {
|
||||||
|
background: var(--accent);
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Booking ---------------- */
|
/* ---------------- Booking ---------------- */
|
||||||
.booking-form {
|
.booking-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -877,7 +1044,7 @@ dialog::backdrop { background: rgba(0,0,0,0.6); }
|
|||||||
|
|
||||||
/* ---------------- Admin ---------------- */
|
/* ---------------- Admin ---------------- */
|
||||||
.admin-page {
|
.admin-page {
|
||||||
max-width: 1100px;
|
max-width: 1280px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
@@ -933,6 +1100,7 @@ table.admin-table th, table.admin-table td {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.75rem 0.6rem;
|
padding: 0.75rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
transition: background-color 0.2s ease;
|
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; }
|
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);
|
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 { text-decoration: none; cursor: pointer; }
|
||||||
.link-lead:hover code { color: var(--accent-strong); text-decoration: underline; }
|
.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);
|
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 ---------------- */
|
/* ---------------- Forms / Toggle Switch ---------------- */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
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-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-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-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); }
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
@@ -1080,7 +1420,8 @@ input:checked + .toggle-slider:before {
|
|||||||
|
|
||||||
/* Dialog */
|
/* Dialog */
|
||||||
dialog#leadDialog,
|
dialog#leadDialog,
|
||||||
dialog#customerDialog {
|
dialog#customerDialog,
|
||||||
|
dialog#orderDialog {
|
||||||
border: 1px solid var(--line); border-radius: var(--radius);
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
background: var(--bg-card); color: var(--text);
|
background: var(--bg-card); color: var(--text);
|
||||||
padding: 0; max-width: 640px; width: 94%;
|
padding: 0; max-width: 640px; width: 94%;
|
||||||
@@ -1088,11 +1429,13 @@ dialog#customerDialog {
|
|||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
}
|
}
|
||||||
dialog#leadDialog[open],
|
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;
|
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
dialog#leadDialog::backdrop,
|
dialog#leadDialog::backdrop,
|
||||||
dialog#customerDialog::backdrop {
|
dialog#customerDialog::backdrop,
|
||||||
|
dialog#orderDialog::backdrop {
|
||||||
background: rgba(0,0,0,0.7);
|
background: rgba(0,0,0,0.7);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
animation: fadeIn 0.3s ease forwards;
|
animation: fadeIn 0.3s ease forwards;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}"
|
||||||
|
WORKFLOW_RENDERED="/tmp/01-qualification-payment-email.rendered.json"
|
||||||
|
WORKFLOW03_TEMPLATE="/opt/mc-cars/workflows/03-manual-email-send.json"
|
||||||
|
WORKFLOW03_RENDERED="/tmp/03-manual-email-send.rendered.json"
|
||||||
|
CREDENTIALS_FILE="/tmp/mc-cars-credentials.json"
|
||||||
|
|
||||||
|
required_var() {
|
||||||
|
var_name="$1"
|
||||||
|
eval "var_value=\${$var_name:-}"
|
||||||
|
if [ -z "$var_value" ]; then
|
||||||
|
echo "[n8n-bootstrap] Missing required env var: $var_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
escape_sed() {
|
||||||
|
printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
required_var N8N_POSTGRES_CREDENTIAL_ID
|
||||||
|
required_var N8N_POSTGRES_CREDENTIAL_NAME
|
||||||
|
required_var N8N_SMTP_CREDENTIAL_ID
|
||||||
|
required_var N8N_SMTP_CREDENTIAL_NAME
|
||||||
|
required_var N8N_SMTP_HOST
|
||||||
|
required_var N8N_SMTP_USER
|
||||||
|
required_var N8N_SMTP_PASS
|
||||||
|
required_var N8N_PAYPAL_KAUTION_LINK
|
||||||
|
required_var N8N_PAYPAL_MIETE_LINK
|
||||||
|
required_var DB_POSTGRESDB_PASSWORD
|
||||||
|
required_var N8N_PAYMENT_WORKFLOW_ID
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<EOF
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "${N8N_POSTGRES_CREDENTIAL_ID}",
|
||||||
|
"name": "${N8N_POSTGRES_CREDENTIAL_NAME}",
|
||||||
|
"type": "postgres",
|
||||||
|
"data": {
|
||||||
|
"host": "db",
|
||||||
|
"password": "${DB_POSTGRESDB_PASSWORD}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "${N8N_SMTP_CREDENTIAL_ID}",
|
||||||
|
"name": "${N8N_SMTP_CREDENTIAL_NAME}",
|
||||||
|
"type": "smtp",
|
||||||
|
"data": {
|
||||||
|
"host": "${N8N_SMTP_HOST}",
|
||||||
|
"user": "${N8N_SMTP_USER}",
|
||||||
|
"password": "${N8N_SMTP_PASS}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ ! -f "$WORKFLOW_TEMPLATE" ]; then
|
||||||
|
echo "[n8n-bootstrap] Workflow template not found: $WORKFLOW_TEMPLATE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
POSTGRES_ID_ESCAPED="$(escape_sed "$N8N_POSTGRES_CREDENTIAL_ID")"
|
||||||
|
SMTP_ID_ESCAPED="$(escape_sed "$N8N_SMTP_CREDENTIAL_ID")"
|
||||||
|
KAUTION_LINK_ESCAPED="$(escape_sed "$N8N_PAYPAL_KAUTION_LINK")"
|
||||||
|
MIETE_LINK_ESCAPED="$(escape_sed "$N8N_PAYPAL_MIETE_LINK")"
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s/__POSTGRES_CREDENTIAL_ID__/${POSTGRES_ID_ESCAPED}/g" \
|
||||||
|
-e "s/__SMTP_CREDENTIAL_ID__/${SMTP_ID_ESCAPED}/g" \
|
||||||
|
-e "s|__PAYPAL_KAUTION_LINK__|${KAUTION_LINK_ESCAPED}|g" \
|
||||||
|
-e "s|__PAYPAL_MIETE_LINK__|${MIETE_LINK_ESCAPED}|g" \
|
||||||
|
"$WORKFLOW_TEMPLATE" > "$WORKFLOW_RENDERED"
|
||||||
|
|
||||||
|
echo "[n8n-bootstrap] Importing credentials"
|
||||||
|
n8n import:credentials --input="$CREDENTIALS_FILE"
|
||||||
|
|
||||||
|
echo "[n8n-bootstrap] Importing workflow 01"
|
||||||
|
n8n import:workflow --input="$WORKFLOW_RENDERED"
|
||||||
|
|
||||||
|
# Process and import workflow 03 - Manual Email Send
|
||||||
|
if [ -f "$WORKFLOW03_TEMPLATE" ]; then
|
||||||
|
sed \
|
||||||
|
-e "s/__POSTGRES_CREDENTIAL_ID__/${POSTGRES_ID_ESCAPED}/g" \
|
||||||
|
-e "s/__SMTP_CREDENTIAL_ID__/${SMTP_ID_ESCAPED}/g" \
|
||||||
|
-e "s|__PAYPAL_KAUTION_LINK__|${KAUTION_LINK_ESCAPED}|g" \
|
||||||
|
-e "s|__PAYPAL_MIETE_LINK__|${MIETE_LINK_ESCAPED}|g" \
|
||||||
|
"$WORKFLOW03_TEMPLATE" > "$WORKFLOW03_RENDERED"
|
||||||
|
|
||||||
|
echo "[n8n-bootstrap] Importing workflow 03 (Manual Email Send)"
|
||||||
|
n8n import:workflow --input="$WORKFLOW03_RENDERED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Publish all imported workflows so they appear in the UI
|
||||||
|
echo "[n8n-bootstrap] Publishing all workflows"
|
||||||
|
WF_IDS=$(n8n list:workflow 2>/dev/null | cut -d'|' -f1 || true)
|
||||||
|
for wfid in $WF_IDS; do
|
||||||
|
echo "[n8n-bootstrap] Publishing workflow $wfid"
|
||||||
|
n8n publish:workflow --id="$wfid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[n8n-bootstrap] Bootstrap complete"
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26
-20
@@ -30,28 +30,34 @@ This folder contains exportable n8n workflow definitions for the MC Cars qualifi
|
|||||||
|
|
||||||
## Setup Instructions
|
## Setup Instructions
|
||||||
|
|
||||||
### 1. Create Postgres credential in n8n
|
### 1. Configure `.env`
|
||||||
- **Name:** `MC Cars Postgres`
|
The stack now bootstraps n8n credentials/workflow automatically on every `docker compose up`.
|
||||||
- **Host:** `db`
|
|
||||||
- **Port:** `5432`
|
|
||||||
- **Database:** `postgres`
|
|
||||||
- **User:** `postgres`
|
|
||||||
- **Password:** (value of `POSTGRES_PASSWORD` from `.env`)
|
|
||||||
|
|
||||||
### 2. Create SMTP credential in n8n
|
Required env variables:
|
||||||
- **Name:** `MC Cars SMTP`
|
- `POSTGRES_PASSWORD`
|
||||||
- **Host:** your SMTP server (e.g. `smtp.mailgun.org`, `mail.mc-cars.at`)
|
- `N8N_POSTGRES_CREDENTIAL_ID`
|
||||||
- **Port:** `587` (TLS) or `465` (SSL)
|
- `N8N_POSTGRES_CREDENTIAL_NAME`
|
||||||
- **User:** your SMTP username
|
- `N8N_SMTP_CREDENTIAL_ID`
|
||||||
- **Password:** your SMTP password
|
- `N8N_SMTP_CREDENTIAL_NAME`
|
||||||
- **From:** `info@mc-cars.at`
|
- `N8N_SMTP_HOST`
|
||||||
|
- `N8N_SMTP_USER`
|
||||||
|
- `N8N_SMTP_PASS`
|
||||||
|
- `N8N_PAYPAL_KAUTION_LINK`
|
||||||
|
- `N8N_PAYPAL_MIETE_LINK`
|
||||||
|
- `N8N_PAYMENT_WORKFLOW_ID`
|
||||||
|
|
||||||
### 3. Import workflows
|
### 2. Mailbox reference (for future incoming-email workflows)
|
||||||
1. Open n8n at http://localhost:55590
|
- **IMAP host:** `heracles.mxrouting.net` (port `993`, SSL/TLS)
|
||||||
2. Go to **Workflows** → **Import from file**
|
- **POP3 host:** `heracles.mxrouting.net` (port `995`, SSL/TLS)
|
||||||
3. Import `01-qualification-payment-email.json`
|
- **Username:** `office@mc-cars.at`
|
||||||
4. Import `02-mietvertrag-pdf-email.json`
|
- **Password:** same mailbox password as SMTP
|
||||||
5. Open each workflow → assign the credentials created above → **Activate**
|
|
||||||
|
### 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)
|
### 4. Upload Mietvertrag template (optional)
|
||||||
1. Open Admin panel → **Einstellungen** tab
|
1. Open Admin panel → **Einstellungen** tab
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.APP_URL || 'http://localhost:55580',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -120,3 +120,16 @@ services:
|
|||||||
hide_groups_header: true
|
hide_groups_header: true
|
||||||
allow:
|
allow:
|
||||||
- admin
|
- 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';
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# MC Cars — Local Testing Protocol
|
||||||
|
|
||||||
|
> This document records the exact steps taken to verify the MC Cars stack is operational.
|
||||||
|
> Run these steps after every stack spin-up to confirm baseline functionality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine with Compose v2
|
||||||
|
- Clean data directory: `rm -rf data/db data/storage data/n8n && mkdir -p data/{db,storage,n8n}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Spin Up the Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait ~30 seconds for migrations to complete, then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 14 services running (db healthy, kong healthy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verify API Responds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:55580/index.html | head -5
|
||||||
|
```
|
||||||
|
Expected: HTML response with `<!DOCTYPE html>`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "apikey: $ANON_KEY" "http://localhost:55521/rest/v1/vehicles?select=brand,model"
|
||||||
|
```
|
||||||
|
Expected: `[{"brand":"Ferrari","model":"296 GTB"}]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Playwright End-to-End Tests
|
||||||
|
|
||||||
|
### 3.1 Public Website — Verify Access & Ferrari
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost:55580/index.html`
|
||||||
|
2. Verify page loads with title "MC Cars · Sportwagenvermietung Steiermark"
|
||||||
|
3. Verify hero section is visible
|
||||||
|
4. Verify "1" car count in stats section
|
||||||
|
5. Verify Ferrari 296 GTB card is visible with:
|
||||||
|
- Image: "Ferrari 296 GTB"
|
||||||
|
- Specs: 830 PS, 330 km/h, 2.9s
|
||||||
|
- Price: € 850 / pro Tag
|
||||||
|
- Buttons: "Details" and "Buchen"
|
||||||
|
|
||||||
|
### 3.2 Playwright — Make a Reservation
|
||||||
|
|
||||||
|
1. Scroll to/click "Buchen" section
|
||||||
|
2. Select "Ferrari 296 GTB" from vehicle dropdown
|
||||||
|
3. Click "Individuell" (custom dates) button
|
||||||
|
4. Set Start date: `2026-06-20`
|
||||||
|
5. Set End date: `2026-06-22`
|
||||||
|
6. Click "Weiter" (Weiter button, ref=e146)
|
||||||
|
7. Verify step 2 ("Kontaktdaten") is shown
|
||||||
|
8. Verify pricing sidebar shows:
|
||||||
|
- "Ferrari 296 GTB · 2 Tage"
|
||||||
|
- "Wochenendtage (2 × € 1100)" → "€ 2.200"
|
||||||
|
- "MwSt. (20%)" → "€ 440"
|
||||||
|
- "Gesamtbetrag" → "€ 2.640"
|
||||||
|
- "Kaution" → "€ 5.000"
|
||||||
|
9. Fill Name: `Jose Lago`
|
||||||
|
10. Fill Email: `jose@lago.dev`
|
||||||
|
11. Fill Phone: `+43 660 1234567`
|
||||||
|
12. Click "Weiter" to go to step 3
|
||||||
|
13. Click "Anfrage absenden" (submit button)
|
||||||
|
14. Verify toast notification: "Danke! Wir melden uns in Kürze per E-Mail."
|
||||||
|
15. Verify form reset (vehicle dropdown back to "Fahrzeug wählen")
|
||||||
|
|
||||||
|
### 3.3 Admin Portal — Verify Lead Appears
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost:55581/admin.html`
|
||||||
|
2. Verify login page loads with title "Admin · MC Cars"
|
||||||
|
3. Fill email: `admin@mccars.local`
|
||||||
|
4. Fill password: `mc-cars-admin`
|
||||||
|
5. Click "Anmelden"
|
||||||
|
6. Verify password rotation screen ("Passwort setzen") appears
|
||||||
|
7. Set new password: `NewMcCars2026!` (twice)
|
||||||
|
8. Click "Speichern"
|
||||||
|
9. Verify admin dashboard loads with hash `#leads`
|
||||||
|
10. Verify tabs: "Leads 1", "Kunden 0", "Bestellungen 0", "Fahrzeuge", "Einstellungen"
|
||||||
|
11. Verify the lead appears in "Aktive Leads" table with:
|
||||||
|
- **Eingang:** 17.05.26, 13:34 (current date/time)
|
||||||
|
- **Name/E-Mail:** Jose Lago · jose@lago.dev
|
||||||
|
- **Fahrzeug:** Ferrari 296 GTB
|
||||||
|
- **Zeitraum:** 2026-06-20 → 2026-06-22
|
||||||
|
- **Gesamtbetrag:** € 2.640
|
||||||
|
- **Status:** new
|
||||||
|
- **Actions:** Details, Qualifizieren, Ablehnen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Spin Down & Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
Clean data directory for next test run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf data/db/* 2>/dev/null
|
||||||
|
# Note: data/db/ directory may need sudo to fully remove (owned by Docker bind mount UID)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Results Summary
|
||||||
|
|
||||||
|
| Check | Expected |
|
||||||
|
|-------|----------|
|
||||||
|
| All services running | Yes (14 services) |
|
||||||
|
| Public website loads | Yes, 200 OK |
|
||||||
|
| API returns vehicles | Yes, Ferrari 296 GTB |
|
||||||
|
| Booking form works | Yes, 3-step wizard |
|
||||||
|
| Server-side pricing | Yes, € 2.640 for weekend |
|
||||||
|
| Booking submission | Yes, success toast shown |
|
||||||
|
| Admin login | Yes, password rotation enforced |
|
||||||
|
| Lead visible in admin | Yes, all fields correct |
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Booking Flow End-to-End', () => {
|
||||||
|
const ADMIN_URL = 'http://localhost:55581';
|
||||||
|
const ADMIN_EMAIL = 'admin@mccars.local';
|
||||||
|
const ADMIN_PASSWORD = 'mc-cars-admin';
|
||||||
|
|
||||||
|
// Generate unique test data per run to avoid conflicts
|
||||||
|
const ts = Date.now();
|
||||||
|
const testEmails = [
|
||||||
|
`test-day-${ts}@playwright.test`,
|
||||||
|
`test-weekend-${ts}@playwright.test`,
|
||||||
|
`test-custom-${ts}@playwright.test`,
|
||||||
|
];
|
||||||
|
const testNames = [
|
||||||
|
'Test Testerson Day',
|
||||||
|
'Test Testerson Weekend',
|
||||||
|
'Test Testerson Custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: fill out the booking form for a given mietdauer type.
|
||||||
|
* Returns nothing - the form submission is handled by the page's JS.
|
||||||
|
*/
|
||||||
|
async function submitBooking(page, type, index) {
|
||||||
|
// Scroll to booking section
|
||||||
|
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Step 1: Select vehicle
|
||||||
|
const carSelect = page.locator('#bpfCar');
|
||||||
|
await expect(carSelect).toBeVisible({ timeout: 10000 });
|
||||||
|
// Select first available vehicle option (skip the placeholder)
|
||||||
|
const options = await carSelect.locator('option').all();
|
||||||
|
expect(options.length).toBeGreaterThan(1);
|
||||||
|
const firstVehicle = await options[1].innerText();
|
||||||
|
await carSelect.selectOption({ label: firstVehicle });
|
||||||
|
|
||||||
|
// Step 2: Select mietdauer type
|
||||||
|
const presetBtn = page.locator(`.bpf-preset[data-preset="${type}"]`);
|
||||||
|
await expect(presetBtn).toBeVisible();
|
||||||
|
await presetBtn.click();
|
||||||
|
|
||||||
|
// Step 3: Pick date(s) based on type
|
||||||
|
if (type === 'day') {
|
||||||
|
// Pick a date 7 days from now
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + 7);
|
||||||
|
const dateStr = futureDate.toISOString().split('T')[0];
|
||||||
|
const dateInput = page.locator('#bpfDayDate');
|
||||||
|
await dateInput.fill(dateStr);
|
||||||
|
} else if (type === 'weekend') {
|
||||||
|
// Pick next Saturday
|
||||||
|
const nextSaturday = new Date();
|
||||||
|
const daysUntilSaturday = (6 - nextSaturday.getDay() + 7) % 7 || 7;
|
||||||
|
nextSaturday.setDate(nextSaturday.getDate() + daysUntilSaturday);
|
||||||
|
const dateStr = nextSaturday.toISOString().split('T')[0];
|
||||||
|
const dateInput = page.locator('#bpfWeekendDate');
|
||||||
|
await dateInput.fill(dateStr);
|
||||||
|
} else if (type === 'custom') {
|
||||||
|
// Pick start date 14 days from now, end date 17 days from now (4 days = individuell)
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() + 14);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setDate(endDate.getDate() + 3);
|
||||||
|
const fromStr = startDate.toISOString().split('T')[0];
|
||||||
|
const toStr = endDate.toISOString().split('T')[0];
|
||||||
|
await page.locator('#bpfFrom').fill(fromStr);
|
||||||
|
await page.locator('#bpfTo').fill(toStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click Weiter to go to step 2
|
||||||
|
await page.locator('#bpfNext1').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Step 2: Fill contact info
|
||||||
|
await expect(page.locator('#bpfName')).toBeVisible();
|
||||||
|
await page.locator('#bpfName').fill(testNames[index]);
|
||||||
|
await page.locator('#bpfEmail').fill(testEmails[index]);
|
||||||
|
await page.locator('#bpfPhone').fill('+43 660 1234567');
|
||||||
|
await page.locator('#bpfMessage').fill(`Test booking via playwright - ${type}`);
|
||||||
|
|
||||||
|
// Click Weiter to go to step 3
|
||||||
|
await page.locator('#bpfNext2').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Step 3: Submit (skip file uploads - they are optional)
|
||||||
|
await expect(page.locator('#bpfSubmit')).toBeVisible();
|
||||||
|
await page.locator('#bpfSubmit').click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(page.locator('#toast.show')).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Complete booking flow: 1 Tag, Wochenende, Individuell → 3 leads in admin → disqualify all', async ({ page, context }) => {
|
||||||
|
// ========================================
|
||||||
|
// PART 1: Submit 3 bookings on main site
|
||||||
|
// ========================================
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Booking 1: 1 Tag
|
||||||
|
await submitBooking(page, 'day', 0);
|
||||||
|
|
||||||
|
// Booking 2: Wochenende
|
||||||
|
await submitBooking(page, 'weekend', 1);
|
||||||
|
|
||||||
|
// Booking 3: Individuell
|
||||||
|
await submitBooking(page, 'custom', 2);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PART 2: Verify 3 leads in admin panel
|
||||||
|
// ========================================
|
||||||
|
const adminCtx = await test.info().project.use.baseBrowserType?.newContext() ?? context;
|
||||||
|
const adminPage = await adminCtx.newPage();
|
||||||
|
adminPage.setDefaultTimeout(30000);
|
||||||
|
|
||||||
|
await adminPage.goto(ADMIN_URL);
|
||||||
|
await adminPage.waitForLoadState('domcontentloaded');
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Login
|
||||||
|
const loginForm = adminPage.locator('#loginForm');
|
||||||
|
await expect(loginForm).toBeVisible({ timeout: 10000 });
|
||||||
|
await adminPage.locator('#loginForm [name="email"]').fill(ADMIN_EMAIL);
|
||||||
|
await adminPage.locator('#loginForm [name="password"]').fill(ADMIN_PASSWORD);
|
||||||
|
await adminPage.locator('#loginForm [type="submit"]').click();
|
||||||
|
|
||||||
|
// Wait a moment for login to process
|
||||||
|
await adminPage.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check for login error
|
||||||
|
const loginError = adminPage.locator('#loginError');
|
||||||
|
if (await loginError.isVisible()) {
|
||||||
|
const errorMsg = await loginError.textContent();
|
||||||
|
throw new Error(`Login failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if password rotation is required (first login)
|
||||||
|
const rotateView = adminPage.locator('#rotateView');
|
||||||
|
if (await rotateView.isVisible({ timeout: 2000 })) {
|
||||||
|
// Set a new password (must be different from bootstrap)
|
||||||
|
const newPw = 'Playwright-Test-PW-2026!';
|
||||||
|
await adminPage.locator('#rotateForm [name="pw1"]').fill(newPw);
|
||||||
|
await adminPage.locator('#rotateForm [name="pw2"]').fill(newPw);
|
||||||
|
await adminPage.locator('#rotateForm [type="submit"]').click();
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for admin view to load
|
||||||
|
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Ensure leads tab is active (it's the default)
|
||||||
|
const leadsTab = adminPage.locator('[data-tab="leads"]');
|
||||||
|
const leadsTabClass = await leadsTab.getAttribute('class');
|
||||||
|
if (!leadsTabClass?.includes('active')) {
|
||||||
|
await leadsTab.click();
|
||||||
|
await adminPage.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for our test leads to appear by checking for their emails in the table
|
||||||
|
// We wait for at least one of our test emails to appear, then verify all 3
|
||||||
|
await adminPage.waitForFunction(
|
||||||
|
([emails]) => {
|
||||||
|
const rows = document.querySelectorAll('#leadsTable tbody tr');
|
||||||
|
let found = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const text = row.textContent;
|
||||||
|
for (const email of emails) {
|
||||||
|
if (text.includes(email)) {
|
||||||
|
found++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found >= 3;
|
||||||
|
},
|
||||||
|
testEmails,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await adminPage.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Find our test leads by email pattern
|
||||||
|
const allRows = adminPage.locator('#leadsTable tbody tr');
|
||||||
|
const totalRows = await allRows.count();
|
||||||
|
const testRowIndices = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalRows; i++) {
|
||||||
|
const row = allRows.nth(i);
|
||||||
|
const rowText = await row.textContent();
|
||||||
|
if (testEmails.some(email => rowText.includes(email))) {
|
||||||
|
testRowIndices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testRowIndices.length).toBe(3);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PART 3: Disqualify all 3 test leads
|
||||||
|
// ========================================
|
||||||
|
// Disqualify each lead one at a time, re-finding it after each disqualification
|
||||||
|
// since the table re-renders and indices shift.
|
||||||
|
for (const email of testEmails) {
|
||||||
|
// Find the row for this email
|
||||||
|
const rows = adminPage.locator('#leadsTable tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const rowText = await rows.nth(i).textContent();
|
||||||
|
if (rowText.includes(email)) {
|
||||||
|
// Click disqualify button
|
||||||
|
const disqBtn = rows.nth(i).locator('[data-disq]');
|
||||||
|
if (await disqBtn.isVisible()) {
|
||||||
|
await disqBtn.click();
|
||||||
|
await adminPage.waitForTimeout(1500);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(found).toBe(true, `Lead with email ${email} not found or could not be disqualified`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for disqualifications to process
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Refresh page to ensure fresh data after disqualifications
|
||||||
|
await adminPage.reload();
|
||||||
|
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
|
||||||
|
await adminPage.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Verify our test leads are now disqualified (no longer in active view)
|
||||||
|
const remainingRows = adminPage.locator('#leadsTable tbody tr');
|
||||||
|
const remainingCount = await remainingRows.count();
|
||||||
|
let foundTestLead = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < remainingCount; i++) {
|
||||||
|
const rowText = await remainingRows.nth(i).textContent();
|
||||||
|
if (testEmails.some(email => rowText.includes(email))) {
|
||||||
|
foundTestLead = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(foundTestLead).toBe(false);
|
||||||
|
|
||||||
|
await adminPage.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Legal Pages - Warum wir removed', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Impressum page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/impressum.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AGB page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/agb.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Datenschutz page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/datenschutz.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mietbedingungen page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/mietbedingungen.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('All legal pages - other nav links present', async ({ page }) => {
|
||||||
|
await page.goto('/impressum.html');
|
||||||
|
const nav = page.getByLabel('Hauptnavigation');
|
||||||
|
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('MC Cars - Customer Changes Verification', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Page loads successfully', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/MC Cars/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - Flotte ansehen button removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Flotte ansehen')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('View fleet')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - 24/7 Support stat removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('24/7')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - Faire Kaution stat still visible', async ({ page }) => {
|
||||||
|
const kautionStat = page.getByText('Faire Kaution', { exact: true });
|
||||||
|
await expect(kautionStat).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - Fahrzeuge stat still visible', async ({ page }) => {
|
||||||
|
const vehiclesSection = page.locator('.hero-stats');
|
||||||
|
await expect(vehiclesSection).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fleet section - Unsere Flotte eyebrow removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Unsere Flotte')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('Our Fleet')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fleet section - Title still visible', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Handverlesen. Gepflegt. Startklar.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigation - Warum wir link removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('Why us')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigation - Other links still present', async ({ page }) => {
|
||||||
|
const nav = page.getByLabel('Hauptnavigation');
|
||||||
|
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Stimmen' })).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Jetzt buchen' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reviews - Ferrari references in reviews', async ({ page }) => {
|
||||||
|
await page.locator('#stimmen').scrollIntoViewIfNeeded();
|
||||||
|
const reviewsSection = page.locator('#stimmen');
|
||||||
|
await expect(reviewsSection).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reviews - GT3 references removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('GT3')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Footer - correct content', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Rechtliches')).toBeVisible();
|
||||||
|
await expect(page.getByText('Impressum')).toBeVisible();
|
||||||
|
await expect(page.getByText('Datenschutz')).toBeVisible();
|
||||||
|
await expect(page.getByText('hello@mc-cars.at')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Footer - Steiermark reference updated', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Made in Steiermark')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Language toggle works', async ({ page }) => {
|
||||||
|
const langToggle = page.locator('.lang-toggle');
|
||||||
|
await expect(langToggle).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to English
|
||||||
|
await langToggle.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(langToggle).toHaveText('DE');
|
||||||
|
await expect(page.getByText('Drive at the highest level.')).toBeVisible();
|
||||||
|
|
||||||
|
// Switch back to German
|
||||||
|
await langToggle.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(langToggle).toHaveText('EN');
|
||||||
|
await expect(page.getByRole('heading', { name: /Niveau/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fleet section - vehicle cards visible', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const vehicleCards = page.locator('.vehicle-card');
|
||||||
|
await expect(vehicleCards.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Booking section visible', async ({ page }) => {
|
||||||
|
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Jetzt buchen' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEO title updated', async ({ page }) => {
|
||||||
|
const title = await page.title();
|
||||||
|
expect(title).toContain('Ferrari');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Photo Gallery Feature', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vehicle cards render correctly', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const cards = page.locator('.vehicle-card');
|
||||||
|
await expect(cards.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vehicle card has photo', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const firstPhoto = page.locator('.vehicle-card').first().locator('.vehicle-photo img');
|
||||||
|
await expect(firstPhoto).toBeVisible();
|
||||||
|
const src = await firstPhoto.getAttribute('src');
|
||||||
|
expect(src).not.toBeNull();
|
||||||
|
expect(src).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vehicle details dialog opens', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const detailsBtn = page.locator('[data-details]').first();
|
||||||
|
if (await detailsBtn.isVisible()) {
|
||||||
|
await detailsBtn.click();
|
||||||
|
const dialog = page.locator('#carDialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Booking wizard - vehicle selector works', async ({ page }) => {
|
||||||
|
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||||
|
const carSelect = page.locator('#bpfCar');
|
||||||
|
await expect(carSelect).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user