Compare commits
23 Commits
9de88a5459
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 597d47f824 | |||
| 44dbf6b93c | |||
| 387d2ba2ab | |||
| 3ec79e1923 | |||
| f46ba8cadc | |||
| e34d56e36a | |||
| e24bc743e2 | |||
| 32580781c8 | |||
| d5a219bd50 | |||
| d6713e25f9 | |||
| b21b3937b2 | |||
| f440f88725 | |||
| 408a59bd5c | |||
| 652131a285 | |||
| e986121240 | |||
| bd906dbe15 | |||
| 05de6cc9a4 | |||
| fae2c0120e | |||
| 54d9cdcdc9 | |||
| db4001aaa5 | |||
| b4258edb91 | |||
| aca60696ae | |||
| 926950bd62 |
@@ -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,11 @@ 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
|
||||||
|
|
||||||
kong:
|
kong:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -41,5 +46,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
|
||||||
|
|||||||
+36
-3
@@ -218,6 +218,11 @@ 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
|
||||||
entrypoint: ["sh","-c"]
|
entrypoint: ["sh","-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
@@ -244,6 +249,11 @@ 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
|
||||||
echo "post-init done."
|
echo "post-init done."
|
||||||
restart: "no"
|
restart: "no"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
@@ -387,8 +397,10 @@ services:
|
|||||||
N8N_HOST: 0.0.0.0
|
N8N_HOST: 0.0.0.0
|
||||||
N8N_PORT: 5678
|
N8N_PORT: 5678
|
||||||
N8N_PROTOCOL: http
|
N8N_PROTOCOL: http
|
||||||
WEBHOOK_URL: http://localhost:55590/
|
WEBHOOK_URL: ${WEBHOOK_DOMAIN:-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 +419,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
|
||||||
|
|||||||
+12
-8
@@ -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>
|
||||||
@@ -192,13 +195,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 +281,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 +308,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>
|
||||||
|
|||||||
+339
-70
@@ -70,6 +70,55 @@ const state = {
|
|||||||
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 +311,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;
|
||||||
@@ -283,10 +332,10 @@ 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("");
|
updatePreview("");
|
||||||
formTitle.textContent = "Neues Fahrzeug";
|
formTitle.textContent = "Neues Fahrzeug";
|
||||||
@@ -309,9 +358,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") || "",
|
||||||
@@ -396,6 +445,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 +454,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 +603,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 +619,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 +738,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 +748,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 +764,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 +1159,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 +1169,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 +1203,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 +1327,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 +1383,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
|
||||||
|
|||||||
+59
-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 || "";
|
||||||
@@ -156,10 +156,12 @@ 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 photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
||||||
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">
|
||||||
|
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" />
|
||||||
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vehicle-body">
|
<div class="vehicle-body">
|
||||||
@@ -197,23 +199,24 @@ function renderGrid() {
|
|||||||
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 photoUrl = 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)}" />
|
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
|
||||||
<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>
|
||||||
@@ -395,28 +398,45 @@ 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 photoUrl = optimizedVehiclePhotoUrl(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 +552,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 +568,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}')`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
+32
-4
@@ -89,6 +89,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",
|
||||||
@@ -176,7 +177,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 +223,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,9 +234,20 @@ 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",
|
||||||
@@ -323,7 +337,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",
|
||||||
@@ -411,7 +426,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 +472,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,6 +486,17 @@ 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.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
+17
-47
@@ -5,12 +5,16 @@
|
|||||||
<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 · Sportwagenvermietung 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 Sportwagen- und Luxusvermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||||
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
|
<link rel="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, Ferraris mieten Graz, Porsche mieten Österreich" />
|
||||||
@@ -38,8 +42,6 @@
|
|||||||
<meta name="twitter:description" content="Fahren Sie Premium-Sportwagen in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<meta name="twitter:description" content="Fahren Sie Premium-Sportwagen in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||||
<meta name="twitter:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
|
<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">
|
||||||
{
|
{
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
"alternateName": "MC Cars",
|
"alternateName": "MC Cars",
|
||||||
"description": "Premium Sportwagen- und Luxusvermietung in der Steiermark",
|
"description": "Premium Sportwagen- und Luxusvermietung 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",
|
||||||
@@ -74,7 +76,7 @@
|
|||||||
"@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 Sportwagen- und Luxusvermietung in Steiermark, Österreich",
|
||||||
"foundingDate": "2024",
|
"foundingDate": "2024",
|
||||||
"contactPoint": {
|
"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>
|
||||||
@@ -177,37 +178,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 +220,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,7 +368,7 @@
|
|||||||
<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 Österreich. Standort: Steiermark (TBD).</p>
|
||||||
@@ -407,8 +377,7 @@
|
|||||||
<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>
|
||||||
@@ -444,6 +413,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>
|
||||||
|
|||||||
+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: /
|
|
||||||
|
|||||||
+56
-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);
|
||||||
}
|
}
|
||||||
@@ -555,12 +559,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 +592,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 +904,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 +960,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 +970,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; }
|
||||||
|
|
||||||
@@ -1071,6 +1102,9 @@ input:checked + .toggle-slider:before {
|
|||||||
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); }
|
.pill-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 +1114,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 +1123,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
|
||||||
|
|||||||
@@ -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,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# MC Cars — Local Testing Protocol
|
||||||
|
|
||||||
|
> This document records the exact steps taken to verify the MC Cars stack is operational.
|
||||||
|
> Run these steps after every stack spin-up to confirm baseline functionality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine with Compose v2
|
||||||
|
- Clean data directory: `rm -rf data/db data/storage data/n8n && mkdir -p data/{db,storage,n8n}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Spin Up the Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait ~30 seconds for migrations to complete, then verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all 14 services running (db healthy, kong healthy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verify API Responds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:55580/index.html | head -5
|
||||||
|
```
|
||||||
|
Expected: HTML response with `<!DOCTYPE html>`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "apikey: $ANON_KEY" "http://localhost:55521/rest/v1/vehicles?select=brand,model"
|
||||||
|
```
|
||||||
|
Expected: `[{"brand":"Ferrari","model":"296 GTB"}]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Playwright End-to-End Tests
|
||||||
|
|
||||||
|
### 3.1 Public Website — Verify Access & Ferrari
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost:55580/index.html`
|
||||||
|
2. Verify page loads with title "MC Cars · Sportwagenvermietung Steiermark"
|
||||||
|
3. Verify hero section is visible
|
||||||
|
4. Verify "1" car count in stats section
|
||||||
|
5. Verify Ferrari 296 GTB card is visible with:
|
||||||
|
- Image: "Ferrari 296 GTB"
|
||||||
|
- Specs: 830 PS, 330 km/h, 2.9s
|
||||||
|
- Price: € 850 / pro Tag
|
||||||
|
- Buttons: "Details" and "Buchen"
|
||||||
|
|
||||||
|
### 3.2 Playwright — Make a Reservation
|
||||||
|
|
||||||
|
1. Scroll to/click "Buchen" section
|
||||||
|
2. Select "Ferrari 296 GTB" from vehicle dropdown
|
||||||
|
3. Click "Individuell" (custom dates) button
|
||||||
|
4. Set Start date: `2026-06-20`
|
||||||
|
5. Set End date: `2026-06-22`
|
||||||
|
6. Click "Weiter" (Weiter button, ref=e146)
|
||||||
|
7. Verify step 2 ("Kontaktdaten") is shown
|
||||||
|
8. Verify pricing sidebar shows:
|
||||||
|
- "Ferrari 296 GTB · 2 Tage"
|
||||||
|
- "Wochenendtage (2 × € 1100)" → "€ 2.200"
|
||||||
|
- "MwSt. (20%)" → "€ 440"
|
||||||
|
- "Gesamtbetrag" → "€ 2.640"
|
||||||
|
- "Kaution" → "€ 5.000"
|
||||||
|
9. Fill Name: `Jose Lago`
|
||||||
|
10. Fill Email: `jose@lago.dev`
|
||||||
|
11. Fill Phone: `+43 660 1234567`
|
||||||
|
12. Click "Weiter" to go to step 3
|
||||||
|
13. Click "Anfrage absenden" (submit button)
|
||||||
|
14. Verify toast notification: "Danke! Wir melden uns in Kürze per E-Mail."
|
||||||
|
15. Verify form reset (vehicle dropdown back to "Fahrzeug wählen")
|
||||||
|
|
||||||
|
### 3.3 Admin Portal — Verify Lead Appears
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost:55581/admin.html`
|
||||||
|
2. Verify login page loads with title "Admin · MC Cars"
|
||||||
|
3. Fill email: `admin@mccars.local`
|
||||||
|
4. Fill password: `mc-cars-admin`
|
||||||
|
5. Click "Anmelden"
|
||||||
|
6. Verify password rotation screen ("Passwort setzen") appears
|
||||||
|
7. Set new password: `NewMcCars2026!` (twice)
|
||||||
|
8. Click "Speichern"
|
||||||
|
9. Verify admin dashboard loads with hash `#leads`
|
||||||
|
10. Verify tabs: "Leads 1", "Kunden 0", "Bestellungen 0", "Fahrzeuge", "Einstellungen"
|
||||||
|
11. Verify the lead appears in "Aktive Leads" table with:
|
||||||
|
- **Eingang:** 17.05.26, 13:34 (current date/time)
|
||||||
|
- **Name/E-Mail:** Jose Lago · jose@lago.dev
|
||||||
|
- **Fahrzeug:** Ferrari 296 GTB
|
||||||
|
- **Zeitraum:** 2026-06-20 → 2026-06-22
|
||||||
|
- **Gesamtbetrag:** € 2.640
|
||||||
|
- **Status:** new
|
||||||
|
- **Actions:** Details, Qualifizieren, Ablehnen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Spin Down & Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.local.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
Clean data directory for next test run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf data/db/* 2>/dev/null
|
||||||
|
# Note: data/db/ directory may need sudo to fully remove (owned by Docker bind mount UID)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Results Summary
|
||||||
|
|
||||||
|
| Check | Expected |
|
||||||
|
|-------|----------|
|
||||||
|
| All services running | Yes (14 services) |
|
||||||
|
| Public website loads | Yes, 200 OK |
|
||||||
|
| API returns vehicles | Yes, Ferrari 296 GTB |
|
||||||
|
| Booking form works | Yes, 3-step wizard |
|
||||||
|
| Server-side pricing | Yes, € 2.640 for weekend |
|
||||||
|
| Booking submission | Yes, success toast shown |
|
||||||
|
| Admin login | Yes, password rotation enforced |
|
||||||
|
| Lead visible in admin | Yes, all fields correct |
|
||||||
Reference in New Issue
Block a user