Compare commits
20 Commits
e24bc743e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc902768a1 | |||
| 94eac68da4 | |||
| 557528d85a | |||
| 2f90534877 | |||
| 28db852453 | |||
| 331d0557b0 | |||
| 287629878b | |||
| cec51d6c19 | |||
| 9bc08d994c | |||
| 8be7d5aad2 | |||
| e1f6bd56b0 | |||
| e4bdd85518 | |||
| b4c6a47ce8 | |||
| 597d47f824 | |||
| 44dbf6b93c | |||
| 75b338988d | |||
| 387d2ba2ab | |||
| 3ec79e1923 | |||
| f46ba8cadc | |||
| e34d56e36a |
@@ -66,6 +66,7 @@ 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_ID=AWozEaiOSymMj7JF
|
||||||
N8N_POSTGRES_CREDENTIAL_NAME=Postgres account
|
N8N_POSTGRES_CREDENTIAL_NAME=Postgres account
|
||||||
N8N_SMTP_CREDENTIAL_ID=nRMemi1sz2C0N4Vu
|
N8N_SMTP_CREDENTIAL_ID=nRMemi1sz2C0N4Vu
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ docker-compose.override.yml
|
|||||||
frontend/config.js
|
frontend/config.js
|
||||||
|
|
||||||
.playwright-mcp
|
.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/
|
||||||
@@ -121,31 +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 (example):
|
### 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For mc-cars.at:
|
This updates `.env` and outputs the configuration. Then restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual setup (legacy sed method)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sed -i 's|SITE_URL=.*|SITE_URL=https://www.mc-cars.at|' .env
|
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|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=https://www.mc-cars.at|' .env
|
||||||
docker compose up -d --force-recreate web
|
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/`:
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# MC Cars Deployment Configuration Setup
|
||||||
|
# Usage: ./deploy-setup.sh https://www.mc-cars.at
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: ./deploy-setup.sh <domain>"
|
||||||
|
echo "Example: ./deploy-setup.sh https://www.mc-cars.at"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOMAIN="$1"
|
||||||
|
|
||||||
|
echo "🚀 Configuring MC Cars for: $DOMAIN"
|
||||||
|
|
||||||
|
# Update environment variables
|
||||||
|
sed -i "s|SITE_URL=.*|SITE_URL=$DOMAIN|" .env
|
||||||
|
sed -i "s|SUPABASE_PUBLIC_URL=.*|SUPABASE_PUBLIC_URL=$DOMAIN|" .env
|
||||||
|
sed -i "s|N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send|" .env
|
||||||
|
|
||||||
|
echo "✅ Updated .env:"
|
||||||
|
echo " SITE_URL=$DOMAIN"
|
||||||
|
echo " SUPABASE_PUBLIC_URL=$DOMAIN"
|
||||||
|
echo " N8N_WEBHOOK_URL=$DOMAIN/webhook/manual-email-send"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📋 Next steps:"
|
||||||
|
echo " 1. Verify .env looks correct: grep -E 'SITE_URL|SUPABASE_PUBLIC_URL|N8N_WEBHOOK_URL' .env"
|
||||||
|
echo " 2. Restart services: docker-compose down && docker-compose up -d --build"
|
||||||
|
echo " 3. Test webhook: curl '$DOMAIN/webhook/manual-email-send' -d 'sales_order_id=test'"
|
||||||
@@ -25,6 +25,13 @@ services:
|
|||||||
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
- ./supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||||
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
- ./supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||||
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
- ./supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
||||||
|
- ./supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
|
||||||
|
- ./supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
|
||||||
|
- ./supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
|
||||||
|
- ./supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
|
||||||
|
- ./supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
|
||||||
|
- ./supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro
|
||||||
|
- ./supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro
|
||||||
|
|
||||||
kong:
|
kong:
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+18
-2
@@ -218,6 +218,13 @@ services:
|
|||||||
- /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
- /mnt/user/appdata/mc-cars/supabase/migrations/08-backend-pricing-and-security.sql:/sql/08-backend-pricing-and-security.sql:ro
|
||||||
- /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
- /mnt/user/appdata/mc-cars/supabase/migrations/09-site-settings.sql:/sql/09-site-settings.sql:ro
|
||||||
- /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
- /mnt/user/appdata/mc-cars/supabase/migrations/10-mietvertrag-workflow.sql:/sql/10-mietvertrag-workflow.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/11-consolidate-km-rental.sql:/sql/11-consolidate-km-rental.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/12-email-sent-and-more.sql:/sql/12-email-sent-and-more.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/13-rental-type-daily-and-email-guard.sql:/sql/13-rental-type-daily-and-email-guard.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro
|
||||||
|
- /mnt/user/appdata/mc-cars/supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro
|
||||||
entrypoint: ["sh","-c"]
|
entrypoint: ["sh","-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
@@ -244,6 +251,13 @@ services:
|
|||||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/08-backend-pricing-and-security.sql
|
||||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/09-site-settings.sql
|
||||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/10-mietvertrag-workflow.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/11-consolidate-km-rental.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/12-email-sent-and-more.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/13-rental-type-daily-and-email-guard.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/14-email-requested-trigger.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/15-individuell-vat-subtotal-fix.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/16-rental-type-weekend-gap-fix.sql
|
||||||
|
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/17-vehicle-photos.sql
|
||||||
echo "post-init done."
|
echo "post-init done."
|
||||||
restart: "no"
|
restart: "no"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
@@ -339,10 +353,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
||||||
- /mnt/user/appdata/mc-cars/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- /mnt/user/appdata/mc-cars/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\",N8N_WEBHOOK_URL:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" \"$$N8N_WEBHOOK_URL\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||||
ports:
|
ports:
|
||||||
- "55580:80"
|
- "55580:80"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
@@ -360,10 +375,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
- /mnt/user/appdata/mc-cars/frontend:/usr/share/nginx/html
|
||||||
- /mnt/user/appdata/mc-cars/frontend/nginx-admin.conf:/etc/nginx/conf.d/default.conf:ro
|
- /mnt/user/appdata/mc-cars/frontend/nginx-admin.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
entrypoint: ["/bin/sh", "-c", "printf 'window.MCCARS_CONFIG={SUPABASE_URL:\"%s\",SUPABASE_ANON_KEY:\"%s\",N8N_WEBHOOK_URL:\"%s\"};\\n' \"$$SUPABASE_URL\" \"$$SUPABASE_ANON_KEY\" \"$$N8N_WEBHOOK_URL\" > /usr/share/nginx/html/config.js && exec nginx -g 'daemon off;'"]
|
||||||
ports:
|
ports:
|
||||||
- "55581:80"
|
- "55581:80"
|
||||||
networks: [mccars]
|
networks: [mccars]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+18
-9
@@ -101,6 +101,7 @@
|
|||||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||||
|
<th data-i18n="adminRentalType">Miettyp</th>
|
||||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||||
<th data-i18n="adminStatus">Status</th>
|
<th data-i18n="adminStatus">Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -146,10 +147,12 @@
|
|||||||
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
<th data-i18n="adminNameEmail">Name / E-Mail</th>
|
||||||
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
<th data-i18n="adminVehicleTab">Fahrzeug</th>
|
||||||
<th data-i18n="adminPeriod">Zeitraum</th>
|
<th data-i18n="adminPeriod">Zeitraum</th>
|
||||||
|
<th data-i18n="adminRentalType">Miettyp</th>
|
||||||
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
<th data-i18n="adminTotalPrice">Gesamtbetrag</th>
|
||||||
<th>Kaution</th>
|
<th>Kaution</th>
|
||||||
<th>Miete</th>
|
<th>Miete</th>
|
||||||
<th data-i18n="adminStatus">Status</th>
|
<th data-i18n="adminStatus">Status</th>
|
||||||
|
<th data-i18n="adminEmailSent">Email</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -167,13 +170,18 @@
|
|||||||
<form class="admin-form" id="vehicleForm">
|
<form class="admin-form" id="vehicleForm">
|
||||||
<input type="hidden" name="vid" />
|
<input type="hidden" name="vid" />
|
||||||
|
|
||||||
<div class="admin-photo-preview" id="photoPreview"></div>
|
<div class="admin-photo-upload-zone" id="photoUploadZone">
|
||||||
<label>
|
<div class="admin-photo-upload-content">
|
||||||
<span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
|
<span class="admin-photo-upload-icon">📷</span>
|
||||||
<input type="file" id="photoInput" accept="image/*" />
|
<span>Fotos hochladen (JPG/PNG/WebP, max 50 MB)</span>
|
||||||
</label>
|
<span class="muted" style="font-size:0.85rem;">Klicken oder Dateien hierher ziehen · Mehrfachauswahl möglich</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="photoInput" accept="image/*" multiple />
|
||||||
|
</div>
|
||||||
<input type="hidden" name="photo_url" />
|
<input type="hidden" name="photo_url" />
|
||||||
|
|
||||||
|
<div class="admin-photo-gallery" id="extraPhotoGallery"></div>
|
||||||
|
|
||||||
<div class="row2">
|
<div class="row2">
|
||||||
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
|
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
|
||||||
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
|
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
|
||||||
@@ -192,13 +200,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row3">
|
<div class="row3">
|
||||||
<label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label>
|
<label><span>Inkl. km/Tag</span><input type="number" name="included_km_per_day" min="0" value="150" /></label>
|
||||||
|
<label><span data-i18n="adminPricePerKm">Preis extra km (€)</span><input type="number" name="price_per_km_eur" step="0.01" min="0" value="1.50" /></label>
|
||||||
<label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label>
|
<label><span data-i18n="adminKaution">Kaution (€)</span><input type="number" name="kaution_eur" min="1" value="5000" required /></label>
|
||||||
<label><span data-i18n="adminMaxKmWeekend">Max. km/Wochenendtag</span><input type="number" name="max_km_weekend" min="0" placeholder="wie km/Tag" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row2">
|
<div class="row2">
|
||||||
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
|
<label><span data-i18n="adminSortOrder">Ordnung</span><input type="number" name="sort_order" value="100" /></label>
|
||||||
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
|
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,6 +286,7 @@
|
|||||||
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
<h3 id="orderDialogTitle" style="margin:0;">Bestellung</h3>
|
||||||
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
<button class="dialog-close" id="orderDialogClose" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dialog-tabs" id="orderDialogTabs" role="tablist"></div>
|
||||||
<div class="dialog-body" id="orderDialogBody"></div>
|
<div class="dialog-body" id="orderDialogBody"></div>
|
||||||
<div class="dialog-footer" id="orderDialogFooter"></div>
|
<div class="dialog-footer" id="orderDialogFooter"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -304,6 +313,6 @@
|
|||||||
<div class="dialog-footer" id="customerDialogFooter"></div>
|
<div class="dialog-footer" id="customerDialogFooter"></div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script type="module" src="admin.js"></script>
|
<script type="module" src="admin.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+541
-36
@@ -52,7 +52,8 @@ const formTitle = document.querySelector("#formTitle");
|
|||||||
const saveBtn = document.querySelector("#saveBtn");
|
const saveBtn = document.querySelector("#saveBtn");
|
||||||
const resetBtn = document.querySelector("#resetBtn");
|
const resetBtn = document.querySelector("#resetBtn");
|
||||||
const photoInput = document.querySelector("#photoInput");
|
const photoInput = document.querySelector("#photoInput");
|
||||||
const photoPreview = document.querySelector("#photoPreview");
|
const photoUploadZone = document.querySelector("#photoUploadZone");
|
||||||
|
const extraPhotoGallery = document.querySelector("#extraPhotoGallery");
|
||||||
const tableBody = document.querySelector("#adminTable tbody");
|
const tableBody = document.querySelector("#adminTable tbody");
|
||||||
|
|
||||||
// ----- State -----
|
// ----- State -----
|
||||||
@@ -66,10 +67,60 @@ const state = {
|
|||||||
vehicles: [],
|
vehicles: [],
|
||||||
vehicleMap: new Map(),
|
vehicleMap: new Map(),
|
||||||
currentPhotoPath: null,
|
currentPhotoPath: null,
|
||||||
|
vehiclePhotos: [],
|
||||||
realtimeChannel: null,
|
realtimeChannel: null,
|
||||||
forcedRotation: false,
|
forcedRotation: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function notify(message, duration = 3000) {
|
||||||
|
if (typeof window.showToast === "function") {
|
||||||
|
window.showToast(message, duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showAdminPopup(message, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAdminPopup(message, duration = 3000) {
|
||||||
|
const host = document.querySelector("dialog[open]") || document.body;
|
||||||
|
let popup = host.querySelector("[data-admin-notify-popup]");
|
||||||
|
if (!popup) {
|
||||||
|
popup = document.createElement("div");
|
||||||
|
popup.setAttribute("data-admin-notify-popup", "1");
|
||||||
|
popup.setAttribute("role", "status");
|
||||||
|
popup.setAttribute("aria-live", "polite");
|
||||||
|
popup.style.position = host.tagName === "DIALOG" ? "absolute" : "fixed";
|
||||||
|
popup.style.left = "50%";
|
||||||
|
popup.style.top = "50%";
|
||||||
|
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
|
||||||
|
popup.style.minWidth = "320px";
|
||||||
|
popup.style.maxWidth = "min(92vw, 560px)";
|
||||||
|
popup.style.padding = "1rem 1.2rem";
|
||||||
|
popup.style.borderRadius = "12px";
|
||||||
|
popup.style.border = "1px solid var(--line)";
|
||||||
|
popup.style.background = "var(--bg-card)";
|
||||||
|
popup.style.color = "var(--text)";
|
||||||
|
popup.style.boxShadow = "0 16px 40px rgba(0,0,0,0.35)";
|
||||||
|
popup.style.textAlign = "center";
|
||||||
|
popup.style.fontSize = "1rem";
|
||||||
|
popup.style.fontWeight = "600";
|
||||||
|
popup.style.opacity = "0";
|
||||||
|
popup.style.zIndex = "3000";
|
||||||
|
popup.style.pointerEvents = "none";
|
||||||
|
popup.style.transition = "opacity 0.18s ease, transform 0.18s ease";
|
||||||
|
host.appendChild(popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
popup.textContent = message;
|
||||||
|
popup.style.opacity = "1";
|
||||||
|
popup.style.transform = "translate(-50%, -50%) scale(1)";
|
||||||
|
|
||||||
|
clearTimeout(popup._hideTimer);
|
||||||
|
popup._hideTimer = setTimeout(() => {
|
||||||
|
popup.style.opacity = "0";
|
||||||
|
popup.style.transform = "translate(-50%, -50%) scale(0.96)";
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// AUTH FLOW
|
// AUTH FLOW
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -262,9 +313,9 @@ function loadForEdit(id) {
|
|||||||
vehicleForm.seats.value = v.seats;
|
vehicleForm.seats.value = v.seats;
|
||||||
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
||||||
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
|
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
|
||||||
vehicleForm.max_daily_km.value = v.max_daily_km || 150;
|
vehicleForm.included_km_per_day.value = v.included_km_per_day || 150;
|
||||||
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
|
vehicleForm.kaution_eur.value = v.kaution_eur || 5000;
|
||||||
vehicleForm.max_km_weekend.value = v.max_km_weekend || '';
|
vehicleForm.price_per_km_eur.value = v.price_per_km_eur || 1.50;
|
||||||
vehicleForm.sort_order.value = v.sort_order;
|
vehicleForm.sort_order.value = v.sort_order;
|
||||||
vehicleForm.location.value = v.location;
|
vehicleForm.location.value = v.location;
|
||||||
vehicleForm.description_de.value = v.description_de;
|
vehicleForm.description_de.value = v.description_de;
|
||||||
@@ -272,7 +323,7 @@ function loadForEdit(id) {
|
|||||||
vehicleForm.photo_url.value = v.photo_url;
|
vehicleForm.photo_url.value = v.photo_url;
|
||||||
vehicleForm.is_active.checked = v.is_active;
|
vehicleForm.is_active.checked = v.is_active;
|
||||||
state.currentPhotoPath = v.photo_path || null;
|
state.currentPhotoPath = v.photo_path || null;
|
||||||
updatePreview(v.photo_url);
|
loadVehiclePhotos(v.id);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +334,12 @@ resetBtn.addEventListener("click", () => {
|
|||||||
vehicleForm.sort_order.value = 100;
|
vehicleForm.sort_order.value = 100;
|
||||||
vehicleForm.location.value = "Steiermark (TBD)";
|
vehicleForm.location.value = "Steiermark (TBD)";
|
||||||
vehicleForm.seats.value = 2;
|
vehicleForm.seats.value = 2;
|
||||||
vehicleForm.max_daily_km.value = 150;
|
vehicleForm.included_km_per_day.value = 150;
|
||||||
vehicleForm.weekend_price_eur.value = 0;
|
vehicleForm.weekend_price_eur.value = 0;
|
||||||
vehicleForm.kaution_eur.value = 5000;
|
vehicleForm.kaution_eur.value = 5000;
|
||||||
vehicleForm.max_km_weekend.value = '';
|
vehicleForm.price_per_km_eur.value = 1.50;
|
||||||
state.currentPhotoPath = null;
|
state.currentPhotoPath = null;
|
||||||
updatePreview("");
|
state.vehiclePhotos = [];
|
||||||
formTitle.textContent = "Neues Fahrzeug";
|
formTitle.textContent = "Neues Fahrzeug";
|
||||||
formFeedback.textContent = "";
|
formFeedback.textContent = "";
|
||||||
});
|
});
|
||||||
@@ -309,9 +360,9 @@ vehicleForm.addEventListener("submit", async (e) => {
|
|||||||
seats: +fd.get("seats") || 2,
|
seats: +fd.get("seats") || 2,
|
||||||
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
||||||
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
|
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
|
||||||
max_daily_km: +fd.get("max_daily_km") || 150,
|
included_km_per_day: +fd.get("included_km_per_day") || 150,
|
||||||
kaution_eur: +fd.get("kaution_eur") || 5000,
|
kaution_eur: +fd.get("kaution_eur") || 5000,
|
||||||
max_km_weekend: fd.get("max_km_weekend") ? +fd.get("max_km_weekend") : null,
|
price_per_km_eur: parseFloat(fd.get("price_per_km_eur")) || 1.50,
|
||||||
sort_order: +fd.get("sort_order") || 100,
|
sort_order: +fd.get("sort_order") || 100,
|
||||||
location: fd.get("location") || "Steiermark (TBD)",
|
location: fd.get("location") || "Steiermark (TBD)",
|
||||||
description_de: fd.get("description_de") || "",
|
description_de: fd.get("description_de") || "",
|
||||||
@@ -341,41 +392,275 @@ async function deleteVehicle(id) {
|
|||||||
const v = state.vehicleMap.get(id);
|
const v = state.vehicleMap.get(id);
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (!confirm(`Delete ${v.brand} ${v.model}?`)) return;
|
if (!confirm(`Delete ${v.brand} ${v.model}?`)) return;
|
||||||
|
// Delete old main photo
|
||||||
if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]);
|
if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]);
|
||||||
|
// Delete gallery photos from storage
|
||||||
|
const { data: photos } = await supabase.from("vehicle_photos").select("photo_path").eq("vehicle_id", id);
|
||||||
|
if (photos?.length) {
|
||||||
|
await supabase.storage.from("vehicle-photos").remove(photos.map(p => p.photo_path));
|
||||||
|
}
|
||||||
const { error } = await supabase.from("vehicles").delete().eq("id", id);
|
const { error } = await supabase.from("vehicles").delete().eq("id", id);
|
||||||
if (error) { alert(error.message); return; }
|
if (error) { alert(error.message); return; }
|
||||||
await loadVehicles();
|
await loadVehicles();
|
||||||
renderVehicles();
|
renderVehicles();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photo upload
|
// ----- Unified Photo Upload + Gallery -----
|
||||||
photoInput.addEventListener("change", async () => {
|
|
||||||
const file = photoInput.files?.[0];
|
async function loadVehiclePhotos(vehicleId) {
|
||||||
if (!file) return;
|
if (!vehicleId) {
|
||||||
formFeedback.className = "form-feedback";
|
state.vehiclePhotos = [];
|
||||||
formFeedback.textContent = "Uploading photo...";
|
renderExtraPhotoGallery();
|
||||||
try {
|
return;
|
||||||
// Delete old photo if exists
|
|
||||||
if (state.currentPhotoPath) {
|
|
||||||
await supabase.storage.from("vehicle-photos").remove([state.currentPhotoPath]);
|
|
||||||
}
|
}
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("vehicle_photos")
|
||||||
|
.select("*")
|
||||||
|
.eq("vehicle_id", vehicleId)
|
||||||
|
.order("display_order", { ascending: true });
|
||||||
|
if (error) { console.error("Failed to load vehicle photos:", error); return; }
|
||||||
|
state.vehiclePhotos = data || [];
|
||||||
|
renderExtraPhotoGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExtraPhotoGallery() {
|
||||||
|
if (!extraPhotoGallery) return;
|
||||||
|
extraPhotoGallery.innerHTML = "";
|
||||||
|
const photos = state.vehiclePhotos;
|
||||||
|
if (!photos.length) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < photos.length; i++) {
|
||||||
|
const ph = photos[i];
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "admin-photo-card";
|
||||||
|
card.draggable = true;
|
||||||
|
card.dataset.photoId = ph.id;
|
||||||
|
card.dataset.photoIdx = i;
|
||||||
|
card.innerHTML = `
|
||||||
|
<img src="${attr(ph.photo_url)}" alt="Foto ${i + 1}" />
|
||||||
|
<div class="admin-photo-card-arrows">
|
||||||
|
<button type="button" class="admin-photo-arrow" data-move-dir="-1" ${i === 0 ? 'disabled style="opacity:0.3;pointer-events:none;"' : ''} aria-label="Nach links verschieben">‹</button>
|
||||||
|
<button type="button" class="admin-photo-arrow" data-move-dir="1" ${i === photos.length - 1 ? 'disabled style="opacity:0.3;pointer-events:none;"' : ''} aria-label="Nach rechts verschieben">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-photo-card-actions">
|
||||||
|
${!ph.is_primary ? `<button type="button" class="admin-photo-set-primary" data-photo-id="${ph.id}" aria-label="Als Hauptfoto setzen">★</button>` : ''}
|
||||||
|
<button type="button" class="admin-photo-delete" data-photo-id="${ph.id}" aria-label="Foto löschen">×</button>
|
||||||
|
</div>
|
||||||
|
${ph.is_primary ? '<span class="admin-photo-badge">Hauptfoto</span>' : ''}
|
||||||
|
<span class="admin-photo-drag-handle" aria-hidden="true">⠿</span>
|
||||||
|
`;
|
||||||
|
extraPhotoGallery.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
extraPhotoGallery.querySelectorAll(".admin-photo-delete").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
await deleteVehiclePhoto(btn.dataset.photoId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
extraPhotoGallery.querySelectorAll(".admin-photo-set-primary").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
await setPrimaryPhoto(btn.dataset.photoId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
extraPhotoGallery.querySelectorAll(".admin-photo-arrow").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
const card = btn.closest(".admin-photo-card");
|
||||||
|
const idx = +card.dataset.photoIdx;
|
||||||
|
const dir = +btn.dataset.moveDir;
|
||||||
|
await reorderPhoto(idx, dir);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
extraPhotoGallery.querySelectorAll(".admin-photo-card").forEach(card => {
|
||||||
|
card.addEventListener("dragstart", handleDragStart);
|
||||||
|
card.addEventListener("dragover", handleDragOver);
|
||||||
|
card.addEventListener("dragenter", handleDragEnter);
|
||||||
|
card.addEventListener("dragleave", handleDragLeave);
|
||||||
|
card.addEventListener("drop", handleDrop);
|
||||||
|
card.addEventListener("dragend", handleDragEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let draggedPhotoIdx = null;
|
||||||
|
|
||||||
|
function handleDragStart(e) {
|
||||||
|
draggedPhotoIdx = +this.dataset.photoIdx;
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
e.dataTransfer.setData("text/plain", this.dataset.photoId);
|
||||||
|
this.style.opacity = "0.4";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (+this.dataset.photoIdx !== draggedPhotoIdx) {
|
||||||
|
this.classList.add("admin-photo-card-drag-over");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
this.classList.remove("admin-photo-card-drag-over");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.classList.remove("admin-photo-card-drag-over");
|
||||||
|
const targetIdx = +this.dataset.photoIdx;
|
||||||
|
if (draggedPhotoIdx !== null && draggedPhotoIdx !== targetIdx) {
|
||||||
|
const dir = targetIdx > draggedPhotoIdx ? 1 : -1;
|
||||||
|
reorderPhoto(draggedPhotoIdx, dir, targetIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
this.style.opacity = "1";
|
||||||
|
draggedPhotoIdx = null;
|
||||||
|
extraPhotoGallery?.querySelectorAll(".admin-photo-card").forEach(c => c.classList.remove("admin-photo-card-drag-over"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reorderPhoto(fromIdx, dir, targetIdx) {
|
||||||
|
const photos = state.vehiclePhotos;
|
||||||
|
if (photos.length < 2) return;
|
||||||
|
|
||||||
|
let toIdx;
|
||||||
|
if (targetIdx !== undefined) {
|
||||||
|
toIdx = targetIdx;
|
||||||
|
} else {
|
||||||
|
toIdx = fromIdx + dir;
|
||||||
|
if (toIdx < 0 || toIdx >= photos.length) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap in local array
|
||||||
|
[photos[fromIdx], photos[toIdx]] = [photos[toIdx], photos[fromIdx]];
|
||||||
|
|
||||||
|
// Build order payload
|
||||||
|
const orderPayload = photos.map((p, i) => ({ id: p.id, order: i }));
|
||||||
|
|
||||||
|
const vid = vehicleForm.vid?.value;
|
||||||
|
if (vid) {
|
||||||
|
try {
|
||||||
|
await supabase.rpc("reorder_vehicle_photos", {
|
||||||
|
p_vehicle_id: vid,
|
||||||
|
p_photo_orders: orderPayload,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Reorder failed:", err);
|
||||||
|
[photos[fromIdx], photos[toIdx]] = [photos[fromIdx], photos[toIdx]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderExtraPhotoGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVehiclePhoto(photoId) {
|
||||||
|
const ph = state.vehiclePhotos.find(p => p.id === photoId);
|
||||||
|
if (!ph) return;
|
||||||
|
try {
|
||||||
|
if (ph.photo_path) {
|
||||||
|
await supabase.storage.from("vehicle-photos").remove([ph.photo_path]);
|
||||||
|
}
|
||||||
|
const { error } = await supabase.from("vehicle_photos").delete().eq("id", photoId);
|
||||||
|
if (error) throw error;
|
||||||
|
state.vehiclePhotos = state.vehiclePhotos.filter(p => p.id !== photoId);
|
||||||
|
|
||||||
|
// If deleted was primary, promote next photo
|
||||||
|
if (ph.is_primary && state.vehiclePhotos.length) {
|
||||||
|
const newPrimary = state.vehiclePhotos[0];
|
||||||
|
const vid = vehicleForm.vid?.value;
|
||||||
|
if (vid) {
|
||||||
|
await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: newPrimary.id });
|
||||||
|
vehicleForm.photo_url.value = newPrimary.photo_url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderExtraPhotoGallery();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete photo:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPrimaryPhoto(photoId) {
|
||||||
|
const vid = vehicleForm.vid?.value;
|
||||||
|
if (!vid) return;
|
||||||
|
try {
|
||||||
|
const ph = state.vehiclePhotos.find(p => p.id === photoId);
|
||||||
|
await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: photoId });
|
||||||
|
vehicleForm.photo_url.value = ph?.photo_url || "";
|
||||||
|
await loadVehiclePhotos(vid);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to set primary photo:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified photo upload handler
|
||||||
|
photoInput.addEventListener("change", async () => {
|
||||||
|
const files = photoInput.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
const vid = vehicleForm.vid?.value;
|
||||||
|
if (!vid) {
|
||||||
|
formFeedback.className = "form-feedback error";
|
||||||
|
formFeedback.textContent = "Bitte zuerst das Fahrzeug speichern, dann Fotos hinzufügen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formFeedback.className = "form-feedback";
|
||||||
|
formFeedback.textContent = `Uploading ${files.length} photo(s)...`;
|
||||||
|
let uploaded = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
const ext = (file.name.split(".").pop() || "jpg").toLowerCase();
|
||||||
const path = `${crypto.randomUUID()}.${ext}`;
|
const path = `${vid}/${crypto.randomUUID()}.${ext}`;
|
||||||
const { error: upErr } = await supabase.storage
|
const { error: upErr } = await supabase.storage
|
||||||
.from("vehicle-photos")
|
.from("vehicle-photos")
|
||||||
.upload(path, file, { contentType: file.type, upsert: true });
|
.upload(path, file, { contentType: file.type, upsert: true });
|
||||||
if (upErr) throw upErr;
|
if (upErr) throw upErr;
|
||||||
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
|
const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path);
|
||||||
state.currentPhotoPath = path;
|
const isFirst = state.vehiclePhotos.length === 0;
|
||||||
|
const maxOrder = state.vehiclePhotos.reduce((m, p) => Math.max(m, p.display_order), -1);
|
||||||
|
await supabase.from("vehicle_photos").insert({
|
||||||
|
vehicle_id: vid,
|
||||||
|
photo_url: pub.publicUrl,
|
||||||
|
photo_path: path,
|
||||||
|
display_order: maxOrder + 1,
|
||||||
|
is_primary: isFirst,
|
||||||
|
});
|
||||||
|
if (isFirst) {
|
||||||
vehicleForm.photo_url.value = pub.publicUrl;
|
vehicleForm.photo_url.value = pub.publicUrl;
|
||||||
updatePreview(pub.publicUrl);
|
}
|
||||||
formFeedback.textContent = "Upload ok.";
|
uploaded++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formFeedback.className = "form-feedback error";
|
console.error("Upload failed:", err);
|
||||||
formFeedback.textContent = err.message || String(err);
|
}
|
||||||
|
}
|
||||||
|
await loadVehiclePhotos(vid);
|
||||||
|
formFeedback.textContent = `${uploaded} Foto(s) hochgeladen.`;
|
||||||
|
photoInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag-and-drop on upload zone
|
||||||
|
if (photoUploadZone) {
|
||||||
|
photoUploadZone.addEventListener("dragover", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
photoUploadZone.classList.add("drag-active");
|
||||||
|
});
|
||||||
|
photoUploadZone.addEventListener("dragleave", () => {
|
||||||
|
photoUploadZone.classList.remove("drag-active");
|
||||||
|
});
|
||||||
|
photoUploadZone.addEventListener("drop", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
photoUploadZone.classList.remove("drag-active");
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length) {
|
||||||
|
photoInput.files = files;
|
||||||
|
photoInput.dispatchEvent(new Event("change"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; }
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// LEADS
|
// LEADS
|
||||||
@@ -396,6 +681,7 @@ function renderLeads() {
|
|||||||
leadsEmpty.style.display = rows.length ? "none" : "block";
|
leadsEmpty.style.display = rows.length ? "none" : "block";
|
||||||
leadsTableBody.innerHTML = "";
|
leadsTableBody.innerHTML = "";
|
||||||
for (const l of rows) {
|
for (const l of rows) {
|
||||||
|
const rental = rentalTypeMeta(l.rental_type);
|
||||||
const total = l.total_eur || 0;
|
const total = l.total_eur || 0;
|
||||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
@@ -404,6 +690,7 @@ function renderLeads() {
|
|||||||
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
|
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
|
||||||
<td>${esc(l.vehicle_label || "—")}</td>
|
<td>${esc(l.vehicle_label || "—")}</td>
|
||||||
<td>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</td>
|
<td>${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}</td>
|
||||||
|
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
|
||||||
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
||||||
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
|
||||||
<td style="white-space:nowrap;">
|
<td style="white-space:nowrap;">
|
||||||
@@ -552,6 +839,7 @@ async function renderLeadTab(tab, l) {
|
|||||||
});
|
});
|
||||||
leadDialogBody.appendChild(saveNoteBtn);
|
leadDialogBody.appendChild(saveNoteBtn);
|
||||||
} else if (tab === "pricing") {
|
} else if (tab === "pricing") {
|
||||||
|
const rental = rentalTypeMeta(l.rental_type);
|
||||||
const daily = l.daily_subtotal || 0;
|
const daily = l.daily_subtotal || 0;
|
||||||
const weekend = l.weekend_subtotal || 0;
|
const weekend = l.weekend_subtotal || 0;
|
||||||
const sub = l.subtotal_eur || 0;
|
const sub = l.subtotal_eur || 0;
|
||||||
@@ -567,8 +855,9 @@ async function renderLeadTab(tab, l) {
|
|||||||
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||||
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
||||||
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
|
||||||
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${((l.weekday_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150) + (l.weekend_day_count || 0) * (state.vehicleMap.get(l.vehicle_id)?.max_km_weekend || state.vehicleMap.get(l.vehicle_id)?.max_daily_km || 150))} km</span></div>
|
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${(l.total_days || 0) * (state.vehicleMap.get(l.vehicle_id)?.included_km_per_day || 150)} km</span></div>
|
||||||
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
|
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${l.total_days || 0}</span></div>
|
||||||
|
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (tab === "documents") {
|
} else if (tab === "documents") {
|
||||||
const docs = await loadLeadAttachments(l.id);
|
const docs = await loadLeadAttachments(l.id);
|
||||||
@@ -685,6 +974,7 @@ function renderOrders() {
|
|||||||
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
|
ordersEmpty.style.display = state.salesOrders.length ? "none" : "block";
|
||||||
ordersTableBody.innerHTML = "";
|
ordersTableBody.innerHTML = "";
|
||||||
for (const o of state.salesOrders) {
|
for (const o of state.salesOrders) {
|
||||||
|
const rental = rentalTypeMeta(o.rental_type);
|
||||||
const total = o.total_eur || 0;
|
const total = o.total_eur || 0;
|
||||||
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
const totalStr = total > 0 ? "€ " + total.toLocaleString("de-DE") : "—";
|
||||||
const cust = state.customers.find(c => c.id === o.customer_id);
|
const cust = state.customers.find(c => c.id === o.customer_id);
|
||||||
@@ -694,10 +984,12 @@ function renderOrders() {
|
|||||||
<td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td>
|
<td>${cust ? `<strong>${esc(cust.name)}</strong><br><span class="muted">${esc(cust.email)}</span>` : `<span class="muted">${esc(o.customer_id?.slice(0, 8) || "—")}</span>`}</td>
|
||||||
<td>${esc(o.vehicle_label || "—")}</td>
|
<td>${esc(o.vehicle_label || "—")}</td>
|
||||||
<td>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</td>
|
<td>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</td>
|
||||||
|
<td style="white-space:nowrap;"><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></td>
|
||||||
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
<td style="font-weight:600;color:var(--accent-strong);">${totalStr}</td>
|
||||||
<td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td>
|
<td><span class="pill pill-${o.kaution_paid ? "active" : "new"}">${o.kaution_paid ? "✓" : "—"}</span></td>
|
||||||
<td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td>
|
<td><span class="pill pill-${o.rental_paid ? "active" : "new"}">${o.rental_paid ? "✓" : "—"}</span></td>
|
||||||
<td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td>
|
<td><span class="pill pill-${o.rental_complete ? "qualified" : "new"}">${o.rental_complete ? t("adminCompleteDone") : t("adminCompletePending")}</span></td>
|
||||||
|
<td style="white-space:nowrap;"><span class="pill pill-${o.email_sent === 1 ? 'active' : o.email_sent === 2 ? 'disqualified' : 'new'}">${o.email_sent === 0 ? '—' : o.email_sent === 1 ? '✓' : '✗'}</span></td>
|
||||||
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
|
<td style="white-space:nowrap;"><button class="btn small ghost" data-open-order="${o.id}">${t("adminDetails")}</button></td>`;
|
||||||
ordersTableBody.appendChild(tr);
|
ordersTableBody.appendChild(tr);
|
||||||
}
|
}
|
||||||
@@ -708,20 +1000,98 @@ 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
|
// Load attachments for this order
|
||||||
const { data: attachments } = await supabase
|
const { data: attachments } = await supabase
|
||||||
.from("sales_order_attachments")
|
.from("sales_order_attachments")
|
||||||
@@ -735,8 +1105,8 @@ async function openOrder(id) {
|
|||||||
<dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd>
|
<dt>${lang === "de" ? "Kunde" : "Customer"}</dt><dd>${cust ? `<a href="#" class="link-lead" data-goto-cust="${cust.id}">${esc(cust.name)} (${esc(cust.email)})</a>` : esc(o.customer_id?.slice(0, 8) || "—")}</dd>
|
||||||
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
|
<dt>${lang === "de" ? "Fahrzeug" : "Vehicle"}</dt><dd>${esc(o.vehicle_label || "—")}</dd>
|
||||||
<dt>${lang === "de" ? "Zeitraum" : "Period"}</dt><dd>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</dd>
|
<dt>${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>${lang === "de" ? t("adminRentalType") : t("Rental type")}</dt><dd><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></dd>
|
||||||
<dt>${t("adminDepositLabel")}</dt><dd>€ ${deposit.toLocaleString("de-DE")}</dd>
|
<dt>${t("adminEmailSent")}</dt><dd><span class="pill pill-${emailSentPillClass}">${emailSentText}</span></dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin:1rem 0;">
|
<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.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
|
||||||
@@ -748,7 +1118,8 @@ async function openOrder(id) {
|
|||||||
<div style="margin-top:0.8rem;">
|
<div style="margin-top:0.8rem;">
|
||||||
<label class="muted" style="display:block;margin-bottom:0.3rem;">${t("adminPrivateNotes")}</label>
|
<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>
|
<textarea id="orderNote" rows="4" style="width:100%;resize:vertical;">${esc(o.private_notes || "")}</textarea>
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:0.4rem;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.4rem;">
|
||||||
|
<div>${emailSent !== 1 ? `<button class="btn small" type="button" data-manual-email-send data-order-id="${o.id}" style="background-color:var(--accent-strong);color:#fff;">${t("sendEmailButton")}</button>` : ''}</div>
|
||||||
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
|
<button class="btn small" id="orderNoteSave">${t("adminSaveNotes")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -774,22 +1145,114 @@ async function openOrder(id) {
|
|||||||
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dirty form tracking
|
||||||
|
let noteIsDirty = false;
|
||||||
|
const orderNoteEl = document.querySelector("#orderNote");
|
||||||
|
const originalNoteValue = o.private_notes || "";
|
||||||
|
const saveBtn = document.querySelector("#orderNoteSave");
|
||||||
|
if (orderNoteEl && saveBtn) {
|
||||||
|
saveBtn.classList.add("ghost");
|
||||||
|
orderNoteEl.addEventListener("input", () => {
|
||||||
|
noteIsDirty = orderNoteEl.value !== originalNoteValue;
|
||||||
|
if (noteIsDirty) {
|
||||||
|
saveBtn.classList.remove("ghost");
|
||||||
|
saveBtn.style.backgroundColor = "var(--accent-strong)";
|
||||||
|
saveBtn.style.color = "#fff";
|
||||||
|
saveBtn.textContent = t("adminSaveNotes") + " (unsaved)";
|
||||||
|
} else {
|
||||||
|
saveBtn.classList.add("ghost");
|
||||||
|
saveBtn.style.backgroundColor = "";
|
||||||
|
saveBtn.style.color = "";
|
||||||
|
saveBtn.textContent = t("adminSaveNotes");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Save notes
|
// Save notes
|
||||||
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
|
document.querySelector("#orderNoteSave")?.addEventListener("click", async () => {
|
||||||
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
|
const ok = await saveSalesOrderPrivateNotes(o.id, document.querySelector("#orderNote").value);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
noteIsDirty = false;
|
||||||
document.querySelector("#orderNoteSave").textContent = "✓";
|
document.querySelector("#orderNoteSave").textContent = "✓";
|
||||||
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
|
setTimeout(() => { document.querySelector("#orderNoteSave").textContent = t("adminSaveNotes"); }, 1500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} else if (tab === "pricing") {
|
||||||
|
let daily = o.daily_subtotal || 0;
|
||||||
|
let weekend = o.weekend_subtotal || 0;
|
||||||
|
let sub = o.subtotal_eur || 0;
|
||||||
|
let vat = o.vat_eur || 0;
|
||||||
|
const days = o.total_days || 0;
|
||||||
|
const inclVatLabel = lang === "de" ? t("adminInclVat") : t("adminInclVatEn");
|
||||||
|
|
||||||
|
// For individuell: derive pricing from total (which includes VAT)
|
||||||
|
if (isIndividuell && total > 0) {
|
||||||
|
sub = Math.round(total / 1.2 * 100) / 100;
|
||||||
|
vat = Math.round((total - sub) * 100) / 100;
|
||||||
|
daily = sub; // all days counted as weekdays
|
||||||
|
weekend = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdayCount = isIndividuell ? days : (o.weekday_count || 0);
|
||||||
|
const weekendCount = isIndividuell ? 0 : (o.weekend_day_count || 0);
|
||||||
|
const perDay = daily && weekdayCount ? Math.round(daily / weekdayCount) : 0;
|
||||||
|
const perWeekend = weekend && weekendCount ? Math.round(weekend / weekendCount) : 0;
|
||||||
|
|
||||||
|
orderDialogBody.innerHTML = `
|
||||||
|
<div class="pricing-card">
|
||||||
|
<div class="price-row"><span>${lang === "de" ? t("adminWeekdays") : t("adminWeekdaysEn")} (${weekdayCount} × € ${perDay || "—"})</span><span>€ ${daily.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="price-row"><span>${lang === "de" ? t("adminWeekendRateLabel") : t("adminWeekendRateLabelEn")} (${weekendCount} × € ${perWeekend || "—"})</span><span>€ ${weekend.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="price-row divider"><span>${lang === "de" ? t("adminSubtotalLabel") : t("adminSubtotalLabelEn")}</span><span>€ ${sub.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="price-row muted"><span>${lang === "de" ? t("adminVatLabel") : t("adminVatLabelEn")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
|
||||||
|
<div class="price-row total"><span>${lang === "de" ? t("adminTotalLabel") : t("adminTotalLabelEn")}</span>
|
||||||
|
<span>${isIndividuell && !isEmailLocked
|
||||||
|
? `<input type="number" id="orderTotalInput" step="1" min="0" value="${total}" style="font-weight:600;width:120px;" />`
|
||||||
|
: '€ ' + total.toLocaleString("de-DE")
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
${!isEmailLocked ? `<div class="price-row muted" id="inclVatHint"><span></span><span style="font-size:0.78rem;">${inclVatLabel}</span></div>` : ''}
|
||||||
|
<div class="price-row muted" style="margin-top:0.8rem;"><span>${lang === "de" ? t("adminDepositLabel") : t("adminDepositLabelEn")}</span>
|
||||||
|
<span>${isEmailLocked || !isIndividuell
|
||||||
|
? '€ ' + deposit.toLocaleString("de-DE")
|
||||||
|
: `<input type="number" id="orderDepositInput" step="1" min="0" value="${deposit}" style="width:120px;" />`
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<div class="price-row muted"><span>${lang === "de" ? t("adminIncludedKmLabel") : t("adminIncludedKmLabelEn")}</span><span>${days * (state.vehicleMap.get(o.vehicle_id)?.included_km_per_day || 150)} km</span></div>
|
||||||
|
<div class="price-row muted"><span>${lang === "de" ? t("adminTotalDaysLabel") : t("adminTotalDaysLabelEn")}</span><span>${days}</span></div>
|
||||||
|
<div class="price-row muted"><span>${lang === "de" ? t("adminRentalType") : t("Rental type")}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
|
||||||
|
</div>
|
||||||
|
${isIndividuell && !isEmailLocked ? `<div style="display:flex;justify-content:flex-end;margin-top:0.8rem;"><button class="btn small" id="orderPricingSave">${t("adminSave")}</button></div>` : ''}`;
|
||||||
|
|
||||||
|
// Single save for both total + deposit
|
||||||
|
document.querySelector("#orderPricingSave")?.addEventListener("click", async () => {
|
||||||
|
const btn = document.querySelector("#orderPricingSave");
|
||||||
|
btn.disabled = true;
|
||||||
|
const totalInput = document.querySelector("#orderTotalInput");
|
||||||
|
const depositInput = document.querySelector("#orderDepositInput");
|
||||||
|
const errors = [];
|
||||||
|
if (totalInput) {
|
||||||
|
const { error } = await supabase.rpc("sales_order_set_total", { p_so_id: o.id, p_total_eur: +totalInput.value });
|
||||||
|
if (error) errors.push(error.message);
|
||||||
|
}
|
||||||
|
if (depositInput) {
|
||||||
|
const { error } = await supabase.rpc("sales_order_set_deposit", { p_so_id: o.id, p_deposit_eur: +depositInput.value });
|
||||||
|
if (error) errors.push(error.message);
|
||||||
|
}
|
||||||
|
if (errors.length) { alert(errors.join("\n")); btn.disabled = false; return; }
|
||||||
|
await loadSalesOrders();
|
||||||
|
const fresh = state.salesOrders.find(x => x.id === id);
|
||||||
|
if (fresh) await renderOrderTab("pricing", fresh, id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
orderDialogFooter.innerHTML = "";
|
orderDialogFooter.innerHTML = "";
|
||||||
orderDialog.showModal();
|
|
||||||
orderDialogClose.addEventListener("click", () => orderDialog.close(), { once: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSalesOrderState(orderId, action) {
|
async function toggleSalesOrderState(orderId, action) {
|
||||||
@@ -932,6 +1395,7 @@ async function renderCustomerTab(tab, c) {
|
|||||||
let html = "";
|
let html = "";
|
||||||
if (orders.length) {
|
if (orders.length) {
|
||||||
for (const o of orders) {
|
for (const o of orders) {
|
||||||
|
const rental = rentalTypeMeta(o.rental_type);
|
||||||
const total = o.total_eur || 0;
|
const total = o.total_eur || 0;
|
||||||
html += `
|
html += `
|
||||||
<div class="pricing-card" style="margin-bottom:0.9rem;">
|
<div class="pricing-card" style="margin-bottom:0.9rem;">
|
||||||
@@ -941,6 +1405,7 @@ async function renderCustomerTab(tab, c) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div>
|
<div class="price-row"><span>${lang === "de" ? "Fahrzeug" : "Vehicle"}</span><span>${esc(o.vehicle_label || "—")}</span></div>
|
||||||
<div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</span></div>
|
<div class="price-row"><span>${lang === "de" ? "Zeitraum" : "Period"}</span><span>${esc(o.date_from || "—")} → ${esc(o.date_to || "—")}</span></div>
|
||||||
|
<div class="price-row"><span>${lang === "de" ? "Miettyp" : "Rental type"}</span><span><span class="pill pill-${esc(rental.type)}">${esc(rental.label)}</span></span></div>
|
||||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;">
|
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.7rem;">
|
||||||
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
|
<button class="btn small ${o.kaution_paid ? "ghost" : ""}" data-so-toggle="kaution" data-so-id="${o.id}">${o.kaution_paid ? t("adminKautionPaid") : t("adminKautionPending")}</button>
|
||||||
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
|
<button class="btn small ${o.rental_paid ? "ghost" : ""}" data-so-toggle="rental" data-so-id="${o.id}">${o.rental_paid ? t("adminRentalPaid") : t("adminRentalPending")}</button>
|
||||||
@@ -974,8 +1439,32 @@ async function renderCustomerTab(tab, c) {
|
|||||||
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
|
const noteEl = customerDialogBody.querySelector(`[data-so-note="${btn.dataset.soSaveNote}"]`);
|
||||||
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
|
const ok = await saveSalesOrderPrivateNotes(btn.dataset.soSaveNote, noteEl?.value || "");
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
btn.classList.remove("ghost");
|
||||||
|
btn.style.backgroundColor = "";
|
||||||
|
btn.style.color = "";
|
||||||
btn.textContent = "✓";
|
btn.textContent = "✓";
|
||||||
setTimeout(() => { btn.textContent = t("adminSaveNotes"); }, 1500);
|
setTimeout(() => {
|
||||||
|
btn.textContent = t("adminSaveNotes");
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
customerDialogBody.querySelectorAll("[data-so-note]").forEach((noteEl) => {
|
||||||
|
const btn = customerDialogBody.querySelector(`[data-so-save-note="${noteEl.dataset.soNote}"]`);
|
||||||
|
const originalValue = noteEl.value;
|
||||||
|
noteEl.addEventListener("input", () => {
|
||||||
|
const isDirty = noteEl.value !== originalValue;
|
||||||
|
if (isDirty) {
|
||||||
|
btn.classList.remove("ghost");
|
||||||
|
btn.style.backgroundColor = "var(--accent-strong)";
|
||||||
|
btn.style.color = "#fff";
|
||||||
|
btn.textContent = "Speichern (unsaved)";
|
||||||
|
} else {
|
||||||
|
btn.classList.add("ghost");
|
||||||
|
btn.style.backgroundColor = "";
|
||||||
|
btn.style.color = "";
|
||||||
|
btn.textContent = t("adminSaveNotes");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1074,6 +1563,22 @@ function attachRealtime() {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); }
|
||||||
function attr(s) { return esc(s); }
|
function attr(s) { return esc(s); }
|
||||||
|
function normalizeRentalType(rawType) {
|
||||||
|
const value = String(rawType ?? "").trim().toLowerCase();
|
||||||
|
if (!value) return "weekend";
|
||||||
|
if (value === "individual" || value === "custom") return "individuell";
|
||||||
|
if (value === "day" || value === "daily" || value === "1 tag" || value === "1_tag" || value === "single_day") return "single_day";
|
||||||
|
if (value === "weekend") return "weekend";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function rentalTypeMeta(rawType) {
|
||||||
|
const type = normalizeRentalType(rawType);
|
||||||
|
const lang = getLang();
|
||||||
|
if (type === "single_day") return { type, label: lang === "de" ? "1 Tag" : "1 Day" };
|
||||||
|
if (type === "individuell") return { type, label: lang === "de" ? "individuell" : "individual" };
|
||||||
|
if (type === "weekend") return { type, label: "weekend" };
|
||||||
|
return { type: "weekend", label: type };
|
||||||
|
}
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
|
|||||||
+9
-12
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>AGB · MC Cars</title>
|
<title>AGB · MC Cars</title>
|
||||||
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||||
@@ -51,13 +51,12 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
Die AGB definieren die rechtlichen Bedingungen für die Vermietung von Fahrzeugen durch MC Cars.
|
Die AGB definieren die rechtlichen Bedingungen für die Vermietung von Fahrzeugen durch MC Cars.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Bitte wenden Sie sich an hello@mccars.at für weitere Informationen.
|
Bitte wenden Sie sich an hello@mc-cars.at für weitere Informationen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,16 +87,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<div class="logo" style="margin-bottom:0.8rem;">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
|
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,8 +109,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+128
-12
@@ -1,5 +1,5 @@
|
|||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||||
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js";
|
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js?v=3";
|
||||||
|
|
||||||
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
|
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
|
||||||
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
|
||||||
@@ -16,6 +16,7 @@ const state = {
|
|||||||
sort: "sort_order",
|
sort: "sort_order",
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
reviewIdx: 0,
|
reviewIdx: 0,
|
||||||
|
vehiclePhotosMap: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------- Elements ----------------
|
// ---------------- Elements ----------------
|
||||||
@@ -123,6 +124,33 @@ async function loadVehicles() {
|
|||||||
|
|
||||||
state.vehicles = data || [];
|
state.vehicles = data || [];
|
||||||
statCarsCount.textContent = state.vehicles.length;
|
statCarsCount.textContent = state.vehicles.length;
|
||||||
|
statCarsLabel.dataset.i18n = state.vehicles.length > 1 ? 'statCars' : 'statCar';
|
||||||
|
applyI18n();
|
||||||
|
|
||||||
|
// Load vehicle photos
|
||||||
|
if (state.vehicles.length > 0) {
|
||||||
|
const ids = state.vehicles.map(v => v.id);
|
||||||
|
const { data: photos } = await supabase
|
||||||
|
.from("vehicle_photos")
|
||||||
|
.select("*")
|
||||||
|
.in("vehicle_id", ids)
|
||||||
|
.order("display_order", { ascending: true });
|
||||||
|
state.vehiclePhotosMap = new Map();
|
||||||
|
if (photos) {
|
||||||
|
for (const ph of photos) {
|
||||||
|
if (!state.vehiclePhotosMap.has(ph.vehicle_id)) {
|
||||||
|
state.vehiclePhotosMap.set(ph.vehicle_id, []);
|
||||||
|
}
|
||||||
|
state.vehiclePhotosMap.get(ph.vehicle_id).push(ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also include legacy main photo if no gallery photos exist
|
||||||
|
for (const v of state.vehicles) {
|
||||||
|
if (!state.vehiclePhotosMap.has(v.id) && v.photo_url) {
|
||||||
|
state.vehiclePhotosMap.set(v.id, [{ photo_url: v.photo_url }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
|
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
|
||||||
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
|
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
|
||||||
@@ -156,12 +184,16 @@ function renderGrid() {
|
|||||||
emptyState.style.display = state.filtered.length ? "none" : "block";
|
emptyState.style.display = state.filtered.length ? "none" : "block";
|
||||||
|
|
||||||
for (const v of state.filtered) {
|
for (const v of state.filtered) {
|
||||||
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
const photos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||||
|
const primaryPhoto = photos.find(p => p.is_primary) || photos[0];
|
||||||
|
const photoUrl = optimizedVehiclePhotoUrl(primaryPhoto?.photo_url || v.photo_url);
|
||||||
|
const photoCount = photos.length;
|
||||||
const card = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
card.className = "vehicle-card";
|
card.className = "vehicle-card";
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="vehicle-photo">
|
<div class="vehicle-photo" data-photos='${escapeAttr(JSON.stringify(photos.map(p => optimizedVehiclePhotoUrl(p.photo_url))))}' data-current="0">
|
||||||
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" />
|
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" class="vehicle-photo-img" />
|
||||||
|
${photoCount > 1 ? `<div class="vehicle-photo-nav"><button class="vehicle-photo-prev" aria-label="Vorheriges Foto">‹</button><button class="vehicle-photo-next" aria-label="Nächstes Foto">›</button></div><div class="vehicle-photo-dots">${photos.map((_, i) => `<span class="${i === 0 ? 'active' : ''}"></span>`).join('')}</div>` : ''}
|
||||||
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vehicle-body">
|
<div class="vehicle-body">
|
||||||
@@ -194,18 +226,54 @@ function renderGrid() {
|
|||||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Photo carousel nav
|
||||||
|
grid.querySelectorAll(".vehicle-photo-prev").forEach(btn => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const container = btn.closest(".vehicle-photo");
|
||||||
|
const urls = JSON.parse(container.dataset.photos);
|
||||||
|
let idx = +container.dataset.current;
|
||||||
|
idx = (idx - 1 + urls.length) % urls.length;
|
||||||
|
container.dataset.current = idx;
|
||||||
|
container.querySelector(".vehicle-photo-img").src = urls[idx];
|
||||||
|
updatePhotoDots(container, idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
grid.querySelectorAll(".vehicle-photo-next").forEach(btn => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const container = btn.closest(".vehicle-photo");
|
||||||
|
const urls = JSON.parse(container.dataset.photos);
|
||||||
|
let idx = +container.dataset.current;
|
||||||
|
idx = (idx + 1) % urls.length;
|
||||||
|
container.dataset.current = idx;
|
||||||
|
container.querySelector(".vehicle-photo-img").src = urls[idx];
|
||||||
|
updatePhotoDots(container, idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePhotoDots(container, idx) {
|
||||||
|
container.querySelectorAll(".vehicle-photo-dots span").forEach((dot, i) => {
|
||||||
|
dot.classList.toggle("active", i === idx);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetails(id) {
|
function openDetails(id) {
|
||||||
const v = state.vehicles.find(x => x.id === id);
|
const v = state.vehicles.find(x => x.id === id);
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
const photos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||||
|
const photoUrls = photos.length ? photos.map(p => optimizedVehiclePhotoUrl(p.photo_url)) : [optimizedVehiclePhotoUrl(v.photo_url)];
|
||||||
const lang = getLang();
|
const lang = getLang();
|
||||||
const desc = lang === "en" ? v.description_en : v.description_de;
|
const desc = lang === "en" ? v.description_en : v.description_de;
|
||||||
|
|
||||||
dialogTitle.textContent = `${v.brand} ${v.model}`;
|
dialogTitle.textContent = `${v.brand} ${v.model}`;
|
||||||
dialogBody.innerHTML = `
|
dialogBody.innerHTML = `
|
||||||
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
|
<div class="dialog-gallery" data-gallery-urls='${escapeAttr(JSON.stringify(photoUrls))}' data-gallery-idx="0">
|
||||||
|
<img src="${escapeAttr(photoUrls[0])}" alt="${escapeAttr(v.brand + ' ' + v.model)}" class="dialog-gallery-main" />
|
||||||
|
${photoUrls.length > 1 ? `<div class="dialog-gallery-nav"><button class="dialog-gallery-prev" aria-label="Vorheriges Foto">‹</button><button class="dialog-gallery-next" aria-label="Nächstes Foto">›</button></div><div class="dialog-gallery-thumbs">${photoUrls.map((u, i) => `<button class="${i === 0 ? 'active' : ''}" data-gidx="${i}"><img src="${escapeAttr(u)}" loading="lazy" /></button>`).join('')}</div>` : ''}
|
||||||
|
</div>
|
||||||
<p>${escapeHtml(desc || "")}</p>
|
<p>${escapeHtml(desc || "")}</p>
|
||||||
<div class="spec-row" style="margin:1rem 0;">
|
<div class="spec-row" style="margin:1rem 0;">
|
||||||
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
|
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
|
||||||
@@ -215,7 +283,7 @@ function openDetails(id) {
|
|||||||
<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>
|
||||||
@@ -232,6 +300,37 @@ function openDetails(id) {
|
|||||||
bpfCar.dispatchEvent(new Event("change"));
|
bpfCar.dispatchEvent(new Event("change"));
|
||||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dialog gallery nav
|
||||||
|
const gallery = dialogBody.querySelector(".dialog-gallery");
|
||||||
|
const galleryPrev = dialogBody.querySelector(".dialog-gallery-prev");
|
||||||
|
const galleryNext = dialogBody.querySelector(".dialog-gallery-next");
|
||||||
|
if (galleryPrev) {
|
||||||
|
galleryPrev.addEventListener("click", () => {
|
||||||
|
let idx = +gallery.dataset.galleryIdx;
|
||||||
|
idx = (idx - 1 + photoUrls.length) % photoUrls.length;
|
||||||
|
gallery.dataset.galleryIdx = idx;
|
||||||
|
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
|
||||||
|
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (galleryNext) {
|
||||||
|
galleryNext.addEventListener("click", () => {
|
||||||
|
let idx = +gallery.dataset.galleryIdx;
|
||||||
|
idx = (idx + 1) % photoUrls.length;
|
||||||
|
gallery.dataset.galleryIdx = idx;
|
||||||
|
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
|
||||||
|
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
gallery?.querySelectorAll(".dialog-gallery-thumbs button").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const idx = +btn.dataset.gidx;
|
||||||
|
gallery.dataset.galleryIdx = idx;
|
||||||
|
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
|
||||||
|
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- Reviews ----------------
|
// ---------------- Reviews ----------------
|
||||||
@@ -398,11 +497,26 @@ async function updateSidebar() {
|
|||||||
const vat = price.vat_eur;
|
const vat = price.vat_eur;
|
||||||
const total = price.total_eur;
|
const total = price.total_eur;
|
||||||
const deposit = price.deposit_eur;
|
const deposit = price.deposit_eur;
|
||||||
const kmPerWeekday = price.max_daily_km;
|
const includedKmPerDay = price.included_km_per_day || 150;
|
||||||
const kmPerWeekendDay = price.max_km_weekend;
|
const includedKm = totalDays * includedKmPerDay;
|
||||||
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
|
const sidebarPhotos = state.vehiclePhotosMap?.get(v.id) || [];
|
||||||
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
|
const photoUrl = optimizedVehiclePhotoUrl((sidebarPhotos.find(p => p.is_primary) || sidebarPhotos[0] || v)?.photo_url || v.photo_url);
|
||||||
|
|
||||||
|
if (totalDays > 2) {
|
||||||
|
// Individuell mode: show info banner instead of pricing
|
||||||
|
bpfSidebarPlaceholder.style.display = "none";
|
||||||
|
bpfSidebarContent.style.display = "block";
|
||||||
|
bpfSidebarContent.innerHTML = `
|
||||||
|
<h4>${t("bpfPriceOverview")}</h4>
|
||||||
|
<div class="bpf-info-banner">
|
||||||
|
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
|
||||||
|
<p>${t("bpfIndividuellDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
|
||||||
|
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
|
||||||
|
<p class="bpf-car-specs">${v.power_hp} ${t("hp")} • ${v.top_speed_kmh} ${t("kmh")} • ${escapeHtml(v.acceleration)}</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
bpfSidebarPlaceholder.style.display = "none";
|
bpfSidebarPlaceholder.style.display = "none";
|
||||||
bpfSidebarContent.style.display = "block";
|
bpfSidebarContent.style.display = "block";
|
||||||
bpfSidebarContent.innerHTML = `
|
bpfSidebarContent.innerHTML = `
|
||||||
@@ -415,13 +529,15 @@ async function updateSidebar() {
|
|||||||
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
|
<div class="bpf-price-row 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" 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("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
|
||||||
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
|
<div class="bpf-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>
|
<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-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>
|
<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);
|
||||||
bpfTo.addEventListener("change", updateSidebar);
|
bpfTo.addEventListener("change", updateSidebar);
|
||||||
|
|||||||
+98
-16
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Datenschutz · MC Cars (GmbH)</title>
|
<title>Datenschutz · MC Cars (GmbH)</title>
|
||||||
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
|
||||||
@@ -48,13 +48,12 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -64,11 +63,95 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main style="padding: 3rem 0;">
|
<main style="padding: 3rem 0;">
|
||||||
<div class="shell">
|
<div class="shell" style="max-width: 80ch;">
|
||||||
<h1>Datenschutz</h1>
|
<h1>Datenschutzerklärung</h1>
|
||||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
|
||||||
<p>Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden über ein selbstgehostetes Supabase verwaltet.</p>
|
<div style="max-width: 70ch; line-height: 1.8; color: var(--text);">
|
||||||
<p>Ansprechpartner: hello@mccars.at</p>
|
<p>Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Wir verarbeiten Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, DSG 2018). In diesen Datenschutzinformationen informieren wir Sie über die wichtigsten Aspekte der Datenverarbeitung im Rahmen unserer Website.</p>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Verantwortlicher für die Datenverarbeitung</h2>
|
||||||
|
<p><strong>MC Cars GmbH</strong><br/> Gaisfeld 1/2, 8564 Krottendorf-Gaisfeld<br/> E-Mail: hello@mc-cars.at</p>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Daten, die wir verarbeiten</h2>
|
||||||
|
|
||||||
|
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Server-Logfiles</h3>
|
||||||
|
<p>Beim Besuch unserer Website werden automatisch Informationen in Server-Logfiles gespeichert, die Ihr Browser an uns übermittelt. Dies sind:</p>
|
||||||
|
<ul style="padding-left: 1.5rem;">
|
||||||
|
<li>Browsertyp und Browserversion</li>
|
||||||
|
<li>Verwendetes Betriebssystem</li>
|
||||||
|
<li>Referrer URL (die zuvor besuchte Seite)</li>
|
||||||
|
<li>Hostname des zugreifenden Rechners</li>
|
||||||
|
<li>Uhrzeit der Serveranfrage</li>
|
||||||
|
<li>IP-Adresse</li>
|
||||||
|
</ul>
|
||||||
|
<p>Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Buchungsanfragen</h3>
|
||||||
|
<p>Wenn Sie unser Buchungsformular nutzen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen gespeichert. Dies umfasst:</p>
|
||||||
|
<ul style="padding-left: 1.5rem;">
|
||||||
|
<li>Name</li>
|
||||||
|
<li>E-Mail-Adresse</li>
|
||||||
|
<li>Telefonnummer</li>
|
||||||
|
<li>Gewähltes Fahrzeug und Mietzeitraum</li>
|
||||||
|
<li>Nachricht / Anmerkungen</li>
|
||||||
|
</ul>
|
||||||
|
<p>Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Identitätsdokumente</h3>
|
||||||
|
<p>Zur Bearbeitung von Buchungsanfragen laden wir Identitätsdokumente (Ausweis, Führerschein) sowie optionale Einkommensnachweise hoch. Diese Dokumente dienen ausschließlich der Identitätsverifizierung und Bonitätsprüfung. Sie werden vertraulich behandelt und nicht an Dritte weitergegeben.</p>
|
||||||
|
|
||||||
|
<h3 style="font-size: 1.05rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.3rem;">Cookies und lokale Speicherung</h3>
|
||||||
|
<p>Unsere Website verwendet lokale Speicherung (localStorage) für die Auswahl der Spracheinstellung. Diese Daten werden ausschließlich auf Ihrem Endgerät gespeichert und nicht an uns übermittelt.</p>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Zweck der Datenverarbeitung</h2>
|
||||||
|
<p>Die Verarbeitung Ihrer personenbezogenen Daten erfolgt zu folgenden Zwecken:</p>
|
||||||
|
<ul style="padding-left: 1.5rem;">
|
||||||
|
<li>Zur Bereitstellung, Optimierung und Weiterentwicklung unserer Website</li>
|
||||||
|
<li>Zur Bearbeitung Ihrer Buchungsanfragen</li>
|
||||||
|
<li>Zur Identitätsprüfung und Bonitätsprüfung</li>
|
||||||
|
<li>Zur Gewährleistung der Sicherheit und Funktionsfähigkeit unserer Website</li>
|
||||||
|
<li>Zur Erfüllung gesetzlicher Verpflichtungen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Rechtsgrundlage der Verarbeitung</h2>
|
||||||
|
<p>Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf folgenden Rechtsgrundlagen:</p>
|
||||||
|
<ul style="padding-left: 1.5rem;">
|
||||||
|
<li><strong>Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen (Art. 6 Abs. 1 lit. b DSGVO)</strong> – bei der Bearbeitung Ihrer Buchungsanfragen und der Verarbeitung Ihrer Identitätsdokumente</li>
|
||||||
|
<li><strong>Erfüllung einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO)</strong> – z.B. aufgrund gesetzlicher Aufbewahrungsfristen</li>
|
||||||
|
<li><strong>Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)</strong> – zur Gewährleistung der Sicherheit, der Funktionsfähigkeit und der Optimierung unserer Website</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Datenhosting</h2>
|
||||||
|
<p>Unsere Website und Datenbank laufen auf einer selbstgehosteten Infrastruktur. Alle personenbezogenen Daten werden auf unseren eigenen Servern verarbeitet und gespeichert. Es erfolgt keine Weitergabe an Cloud-Dienstanbieter oder Drittunternehmen.</p>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Übermittlung Ihrer Daten</h2>
|
||||||
|
<p>Eine Übermittlung Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn:</p>
|
||||||
|
<ul style="padding-left: 1.5rem;">
|
||||||
|
<li>Dies ist zur Erfüllung unserer vertraglichen Pflichten erforderlich</li>
|
||||||
|
<li>Wir sind gesetzlich dazu verpflichtet</li>
|
||||||
|
<li>Sie haben ausdrücklich eingewilligt</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Speicherdauer</h2>
|
||||||
|
<p>Wir speichern Ihre personenbezogenen Daten nur so lange, wie es für die Erreichung der oben genannten Zwecke erforderlich ist oder wie es die gesetzlichen Aufbewahrungspflichten vorsehen. Identitätsdokumente werden nach Abschluss der Buchung und Erfüllung der gesetzlichen Aufbewahrungsfristen gelöscht.</p>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Ihre Rechte</h2>
|
||||||
|
<p>Sie haben hinsichtlich Ihrer bei uns gespeicherten personenbezogenen Daten folgende Rechte:</p>
|
||||||
|
<ul style="padding-left: 1.5rem;">
|
||||||
|
<li><strong>Recht auf Auskunft (Art. 15 DSGVO):</strong> Sie können Auskunft darüber verlangen, ob und welche personenbezogenen Daten von Ihnen verarbeitet werden.</li>
|
||||||
|
<li><strong>Recht auf Berichtigung (Art. 16 DSGVO):</strong> Sie können die Berichtigung unrichtiger oder die Vervollständigung unvollständiger Daten verlangen.</li>
|
||||||
|
<li><strong>Recht auf Löschung (Art. 17 DSGVO):</strong> Sie können die Löschung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.</li>
|
||||||
|
<li><strong>Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO):</strong> Sie können die Einschränkung der Verarbeitung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.</li>
|
||||||
|
<li><strong>Recht auf Datenübertragbarkeit (Art. 20 DSGVO):</strong> Sie haben das Recht, Ihre bereitgestellten Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten.</li>
|
||||||
|
<li><strong>Recht auf Widerspruch (Art. 21 DSGVO):</strong> Sie können gegen die Verarbeitung Ihrer Daten Widerspruch einlegen.</li>
|
||||||
|
<li><strong>Recht auf Beschwerde (Art. 77 DSGVO):</strong> Sie können sich bei der zuständigen Aufsichtsbehörde beschweren.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Kontaktdaten der Aufsichtsbehörde</h2>
|
||||||
|
<p><strong>Österreichische Datenschutzbehörde</strong><br/> Barichgasse 40-42, 1030 Wien, Österreich<br/> Telefon: +43 1 52 152-0<br/> E-Mail: <a href="mailto:dsb@dsb.gv.at" style="color: var(--accent-strong);">dsb@dsb.gv.at</a></p>
|
||||||
|
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; color: var(--text);">Änderungen dieser Datenschutzerklärung</h2>
|
||||||
|
<p>Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen oder bei Änderungen unserer Dienste anzupassen. Die jeweils aktuelle Version ist auf unserer Website abrufbar.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -78,16 +161,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<div class="logo" style="margin-bottom:0.8rem;">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
|
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,8 +183,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,6 +194,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+41
-37
@@ -2,21 +2,18 @@
|
|||||||
export const translations = {
|
export const translations = {
|
||||||
de: {
|
de: {
|
||||||
navCars: "Fahrzeuge",
|
navCars: "Fahrzeuge",
|
||||||
navWhy: "Warum wir",
|
|
||||||
navReviews: "Stimmen",
|
navReviews: "Stimmen",
|
||||||
navBook: "Buchen",
|
navBook: "Buchen",
|
||||||
bookNow: "Jetzt buchen",
|
bookNow: "Jetzt buchen",
|
||||||
viewFleet: "Flotte ansehen",
|
|
||||||
|
|
||||||
heroEyebrow: "MC Cars · Sportwagenvermietung",
|
heroEyebrow: "MC Cars · Sportwagenvermietung",
|
||||||
heroTitle: "Fahren auf höchstem Niveau.",
|
heroTitle: "Fahren auf höchstem Niveau.",
|
||||||
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.",
|
heroLead: "Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.",
|
||||||
|
|
||||||
statDeposit: "Faire Kaution",
|
statDeposit: "Faire Kaution",
|
||||||
statSupport: "Support",
|
|
||||||
statCars: "Fahrzeuge",
|
statCars: "Fahrzeuge",
|
||||||
|
statCar: "Fahrzeug",
|
||||||
|
|
||||||
fleetEyebrow: "Unsere Flotte",
|
|
||||||
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
|
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
|
||||||
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
|
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
|
||||||
filterBrand: "Marke",
|
filterBrand: "Marke",
|
||||||
@@ -36,15 +33,6 @@ export const translations = {
|
|||||||
from: "ab",
|
from: "ab",
|
||||||
noMatches: "Keine Fahrzeuge gefunden.",
|
noMatches: "Keine Fahrzeuge gefunden.",
|
||||||
|
|
||||||
whyEyebrow: "Warum MC Cars",
|
|
||||||
whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspaß.",
|
|
||||||
whyInsurance: "Versicherungsschutz",
|
|
||||||
whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.",
|
|
||||||
whyFleet: "Premium Flotte",
|
|
||||||
whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.",
|
|
||||||
whyDeposit: "Faire Kaution",
|
|
||||||
whyDepositText: "Zwei Kautionsarten: Bar oder PayPal-Kaution. Bei PayPal senden wir einen Deposit-Link. Bar wird aktuell persönlich bei der Fahrzeugübergabe abgewickelt.",
|
|
||||||
|
|
||||||
reviewsEyebrow: "Kundenmeinungen",
|
reviewsEyebrow: "Kundenmeinungen",
|
||||||
reviewsTitle: "Erlebnisse, die bleiben.",
|
reviewsTitle: "Erlebnisse, die bleiben.",
|
||||||
review: "Kundenmeinung",
|
review: "Kundenmeinung",
|
||||||
@@ -89,6 +77,7 @@ export const translations = {
|
|||||||
bpfWeekendRate: "Wochenendmiete",
|
bpfWeekendRate: "Wochenendmiete",
|
||||||
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
||||||
bpfMaxKm: "Max. km/Tag",
|
bpfMaxKm: "Max. km/Tag",
|
||||||
|
bpfInclKmPerDay: "Inkl. km/Tag",
|
||||||
bpfExtraKm: "Extra km",
|
bpfExtraKm: "Extra km",
|
||||||
bpfPriceOverview: "Preisübersicht",
|
bpfPriceOverview: "Preisübersicht",
|
||||||
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
|
bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
|
||||||
@@ -112,7 +101,7 @@ export const translations = {
|
|||||||
perWeekend: "Wochenende",
|
perWeekend: "Wochenende",
|
||||||
weekendDef: "Sa 9:00 – So 20:00",
|
weekendDef: "Sa 9:00 – So 20:00",
|
||||||
|
|
||||||
footerTagline: "Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).",
|
footerTagline: "Sportwagenvermietung in der Steiermark, Österreich.",
|
||||||
footerLegal: "Rechtliches",
|
footerLegal: "Rechtliches",
|
||||||
footerContact: "Kontakt",
|
footerContact: "Kontakt",
|
||||||
footerNav: "Navigation",
|
footerNav: "Navigation",
|
||||||
@@ -176,7 +165,7 @@ export const translations = {
|
|||||||
adminVehicleTab: "Fahrzeug",
|
adminVehicleTab: "Fahrzeug",
|
||||||
adminPeriod: "Zeitraum",
|
adminPeriod: "Zeitraum",
|
||||||
adminKaution: "Kaution (€)",
|
adminKaution: "Kaution (€)",
|
||||||
adminMaxKmWeekend: "Max. km/Wochenendtag",
|
adminMaxKmWeekend: "Inkl. km/Wochenende",
|
||||||
adminTotalPrice: "Gesamtbetrag",
|
adminTotalPrice: "Gesamtbetrag",
|
||||||
adminLifetimeValueCol: "Gesamtwert",
|
adminLifetimeValueCol: "Gesamtwert",
|
||||||
adminTabGeneral: "Allgemein",
|
adminTabGeneral: "Allgemein",
|
||||||
@@ -222,6 +211,8 @@ export const translations = {
|
|||||||
adminVatLabelEn: "VAT (20%)",
|
adminVatLabelEn: "VAT (20%)",
|
||||||
adminTotalLabel: "Gesamtbetrag",
|
adminTotalLabel: "Gesamtbetrag",
|
||||||
adminTotalLabelEn: "Total",
|
adminTotalLabelEn: "Total",
|
||||||
|
adminInclVat: "inkl. MwSt.",
|
||||||
|
adminInclVatEn: "incl. VAT",
|
||||||
adminDepositLabel: "Kaution",
|
adminDepositLabel: "Kaution",
|
||||||
adminDepositLabelEn: "Deposit",
|
adminDepositLabelEn: "Deposit",
|
||||||
adminIncludedKmLabel: "Inkl. km",
|
adminIncludedKmLabel: "Inkl. km",
|
||||||
@@ -234,24 +225,32 @@ export const translations = {
|
|||||||
adminNoteEn: "Note",
|
adminNoteEn: "Note",
|
||||||
adminSave: "Speichern",
|
adminSave: "Speichern",
|
||||||
adminSaveEn: "Save",
|
adminSaveEn: "Save",
|
||||||
|
adminPricePerKm: "Preis extra km (€)",
|
||||||
|
adminRentalType: "Miettyp",
|
||||||
|
rentalTypeWeekend: "Wochenende",
|
||||||
|
rentalTypeIndividuell: "Individuell",
|
||||||
|
adminSortOrder: "Ordnung",
|
||||||
|
adminEmailSent: "E-Mail gesendet",
|
||||||
|
sendEmailButton: "E-Mail senden",
|
||||||
|
emailSentToast: "E-Mail wird erstellt und in Kürze gesendet...",
|
||||||
|
emailAlreadySent: "Bereits gesendet",
|
||||||
|
bpfIndividuellTitle: "Individuelle Mietdauer",
|
||||||
|
bpfIndividuellDesc: "Bei Mietdauer über 2 Tagen erstellen wir ein persönliches Angebot. Wir prüfen Verfügbarkeit und melden uns in Kürze per E-Mail bei Ihnen.",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
navCars: "Fleet",
|
navCars: "Fleet",
|
||||||
navWhy: "Why us",
|
|
||||||
navReviews: "Reviews",
|
navReviews: "Reviews",
|
||||||
navBook: "Book",
|
navBook: "Book",
|
||||||
bookNow: "Book now",
|
bookNow: "Book now",
|
||||||
viewFleet: "View fleet",
|
|
||||||
|
|
||||||
heroEyebrow: "MC Cars · Sports car rental",
|
heroEyebrow: "MC Cars · Sports car rental",
|
||||||
heroTitle: "Drive at the highest level.",
|
heroTitle: "Drive at the highest level.",
|
||||||
heroLead: "Premium sports and luxury cars in Styria. Fair deposit, full transparency, ready to launch.",
|
heroLead: "The Ferrari in Styria. Fair deposit, full transparency, ready to launch.",
|
||||||
|
|
||||||
statDeposit: "Fair Deposit",
|
statDeposit: "Fair Deposit",
|
||||||
statSupport: "Support",
|
|
||||||
statCars: "Vehicles",
|
statCars: "Vehicles",
|
||||||
|
statCar: "Vehicle",
|
||||||
|
|
||||||
fleetEyebrow: "Our Fleet",
|
|
||||||
fleetTitle: "Hand-picked. Maintained. Ready.",
|
fleetTitle: "Hand-picked. Maintained. Ready.",
|
||||||
fleetSub: "Filter by brand or price. Click for details or book directly.",
|
fleetSub: "Filter by brand or price. Click for details or book directly.",
|
||||||
filterBrand: "Brand",
|
filterBrand: "Brand",
|
||||||
@@ -271,15 +270,6 @@ export const translations = {
|
|||||||
from: "from",
|
from: "from",
|
||||||
noMatches: "No vehicles match the filters.",
|
noMatches: "No vehicles match the filters.",
|
||||||
|
|
||||||
whyEyebrow: "Why MC Cars",
|
|
||||||
whyTitle: "No compromises between safety and driving joy.",
|
|
||||||
whyInsurance: "Insurance",
|
|
||||||
whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.",
|
|
||||||
whyFleet: "Premium fleet",
|
|
||||||
whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.",
|
|
||||||
whyDeposit: "Fair Deposit",
|
|
||||||
whyDepositText: "Two deposit options: cash or PayPal deposit. For PayPal, we send a deposit link. Cash is currently handled in person at pickup.",
|
|
||||||
|
|
||||||
reviewsEyebrow: "Testimonials",
|
reviewsEyebrow: "Testimonials",
|
||||||
reviewsTitle: "Experiences that last.",
|
reviewsTitle: "Experiences that last.",
|
||||||
review: "Review",
|
review: "Review",
|
||||||
@@ -324,6 +314,7 @@ export const translations = {
|
|||||||
bpfWeekendRate: "Weekend rate",
|
bpfWeekendRate: "Weekend rate",
|
||||||
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
||||||
bpfMaxKm: "Max. km/day",
|
bpfMaxKm: "Max. km/day",
|
||||||
|
bpfInclKmPerDay: "Included km/day",
|
||||||
bpfExtraKm: "Extra km",
|
bpfExtraKm: "Extra km",
|
||||||
bpfPriceOverview: "Price overview",
|
bpfPriceOverview: "Price overview",
|
||||||
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
||||||
@@ -347,7 +338,7 @@ export const translations = {
|
|||||||
perWeekend: "Weekend",
|
perWeekend: "Weekend",
|
||||||
weekendDef: "Sat 9 AM – Sun 8 PM",
|
weekendDef: "Sat 9 AM – Sun 8 PM",
|
||||||
|
|
||||||
footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
|
footerTagline: "Sports car rental in Styria, Austria.",
|
||||||
footerLegal: "Legal",
|
footerLegal: "Legal",
|
||||||
footerContact: "Contact",
|
footerContact: "Contact",
|
||||||
footerNav: "Navigation",
|
footerNav: "Navigation",
|
||||||
@@ -411,7 +402,7 @@ export const translations = {
|
|||||||
adminVehicleTab: "Vehicle",
|
adminVehicleTab: "Vehicle",
|
||||||
adminPeriod: "Period",
|
adminPeriod: "Period",
|
||||||
adminKaution: "Deposit (€)",
|
adminKaution: "Deposit (€)",
|
||||||
adminMaxKmWeekend: "Max. km/weekend day",
|
adminMaxKmWeekend: "Included km/weekend",
|
||||||
adminTotalPrice: "Total",
|
adminTotalPrice: "Total",
|
||||||
adminLifetimeValueCol: "Lifetime",
|
adminLifetimeValueCol: "Lifetime",
|
||||||
adminTabGeneral: "General",
|
adminTabGeneral: "General",
|
||||||
@@ -457,6 +448,8 @@ export const translations = {
|
|||||||
adminVatLabelEn: "MwSt. (20%)",
|
adminVatLabelEn: "MwSt. (20%)",
|
||||||
adminTotalLabel: "Total",
|
adminTotalLabel: "Total",
|
||||||
adminTotalLabelEn: "Gesamtbetrag",
|
adminTotalLabelEn: "Gesamtbetrag",
|
||||||
|
adminInclVat: "incl. VAT",
|
||||||
|
adminInclVatEn: "inkl. MwSt.",
|
||||||
adminDepositLabel: "Deposit",
|
adminDepositLabel: "Deposit",
|
||||||
adminDepositLabelEn: "Kaution",
|
adminDepositLabelEn: "Kaution",
|
||||||
adminIncludedKmLabel: "Included km",
|
adminIncludedKmLabel: "Included km",
|
||||||
@@ -469,15 +462,26 @@ export const translations = {
|
|||||||
adminNoteEn: "Notiz",
|
adminNoteEn: "Notiz",
|
||||||
adminSave: "Save",
|
adminSave: "Save",
|
||||||
adminSaveEn: "Speichern",
|
adminSaveEn: "Speichern",
|
||||||
|
adminPricePerKm: "Extra km price (€)",
|
||||||
|
adminRentalType: "Rental type",
|
||||||
|
rentalTypeWeekend: "Weekend",
|
||||||
|
rentalTypeIndividuell: "Custom",
|
||||||
|
adminSortOrder: "Order",
|
||||||
|
adminEmailSent: "Email sent",
|
||||||
|
sendEmailButton: "Send Email",
|
||||||
|
emailSentToast: "Email is being prepared and will be sent shortly...",
|
||||||
|
emailAlreadySent: "Already sent",
|
||||||
|
bpfIndividuellTitle: "Custom Rental Duration",
|
||||||
|
bpfIndividuellDesc: "For rental periods over 2 days, we'll create a personalized quote. We'll check availability and get back to you via email shortly.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const REVIEWS = [
|
export const REVIEWS = [
|
||||||
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
|
{ quote: "Die Buchung war klar und schnell. Der Ferrari war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
|
||||||
{ quote: "Exzellenter Service und makellos vorbereitete Fahrzeuge. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
|
{ quote: "Exzellenter Service und ein makellos vorbereiteter Ferrari. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
|
||||||
{ quote: "Hervorragende Buchungsabwicklung und tadelloses Fahrzeugzustand. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
|
{ quote: "Hervorragende Buchungsabwicklung und tadelloser Zustand des Ferrari. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
|
||||||
{ quote: "Professionelles Team und untadelige Aufmerksamkeit zum Detail. Sehr empfohlen.", author: "David M.", lang: "de" },
|
{ quote: "Professionelles Team und erstklassiger Ferrari. Absolut empfehlenswert.", author: "David M.", lang: "de" },
|
||||||
{ quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
|
{ quote: "Booking was clear and fast. The Ferrari arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getLang() {
|
export function getLang() {
|
||||||
|
|||||||
+24
-16
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Impressum · MC Cars (GmbH)</title>
|
<title>Impressum · MC Cars (GmbH)</title>
|
||||||
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
|
|
||||||
@@ -48,13 +48,12 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -67,11 +66,21 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<h1>Impressum</h1>
|
<h1>Impressum</h1>
|
||||||
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
|
||||||
<p><strong>MC Cars (GmbH)</strong></p>
|
<p><strong>MC Cars GmbH</strong></p>
|
||||||
<p>Standort: Steiermark (TBD)</p>
|
<p>Gaisfeld 1/2<br/>8564 Krottendorf-Gaisfeld</p>
|
||||||
<p>E-Mail: hello@mccars.at</p>
|
<p>FN 675751 b · Landesgericht für Zivilrechtssachen Graz</p>
|
||||||
<p>Telefon: +43 316 880000</p>
|
<p>Geschäftsführer: Christian Leski, Marco Schober</p>
|
||||||
<p>Firmenbuch und UID werden nachgereicht.</p>
|
<p>E-Mail: hello@mc-cars.at</p>
|
||||||
|
<p>UID-Nr. wird in Kürze nachgereicht.</p>
|
||||||
|
</div>
|
||||||
|
<div style="max-width: 65ch; line-height: 1.7; color: var(--text); margin-top: 2.5rem;">
|
||||||
|
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: var(--text);">Datenschutzerklärung (Kurzfassung)</h2>
|
||||||
|
<p>Der Schutz Ihrer persönlichen Daten ist uns wichtig. Wir behandeln Ihre Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften, insbesondere der DSGVO und dem österreichischen Datenschutzgesetz.</p>
|
||||||
|
<p><strong>Welche Daten wir erfassen:</strong> Wir erheben nur die Daten, die für die Nutzung unserer Website und unserer Dienste unbedingt erforderlich sind. Dazu können Zugriffsdaten (Datum, Uhrzeit, besuchte Seiten), technische Daten (Browsertyp, Betriebssystem) und – falls relevant – von Ihnen aktiv eingegebene Daten (z.B. bei Kontakt- und Buchungsformularen) gehören.</p>
|
||||||
|
<p><strong>Wie wir Ihre Daten verwenden:</strong> Ihre Daten verwenden wir ausschließlich, um Ihnen unsere Website und die damit verbundenen Funktionen bereitzustellen, Buchungsanfragen zu bearbeiten und die Sicherheit unserer Systeme zu gewährleisten.</p>
|
||||||
|
<p><strong>Weitergabe an Dritte:</strong> Eine Weitergabe Ihrer persönlichen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn, dies ist gesetzlich vorgeschrieben oder für die Erbringung unserer Dienste unerlässlich.</p>
|
||||||
|
<p><strong>Ihre Rechte:</strong> Sie haben jederzeit das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Widerspruch gegen die Verarbeitung Ihrer personenbezogenen Daten sowie das Recht auf Datenübertragbarkeit.</p>
|
||||||
|
<p>Weitere Informationen finden Sie in unserer <a href="/datenschutz" style="color: var(--accent-strong);">vollständigen Datenschutzerklärung</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -81,16 +90,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<div class="logo" style="margin-bottom:0.8rem;">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
|
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,8 +112,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+17
-53
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>MC Cars · Sportwagenvermietung Steiermark</title>
|
<title>MC Cars · Ferrari-Vermietung Steiermark</title>
|
||||||
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<meta name="description" content="MC Cars · Premium Ferrari-Vermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||||
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
<link rel="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="preload" as="image" href="/images/ferrari-main-car-mobile.jpg" fetchpriority="high" />
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<link rel="stylesheet" href="styles.css?v=2" />
|
<link rel="stylesheet" href="styles.css?v=2" />
|
||||||
<!-- SEO & Social Meta Tags -->
|
<!-- SEO & Social Meta Tags -->
|
||||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||||
<meta name="keywords" content="Sportwagenvermietung Steiermark, Luxusauto mieten, Sportwagenverleih, Ferraris mieten Graz, Porsche mieten Österreich" />
|
<meta name="keywords" content="Sportwagenvermietung Steiermark, Luxusauto mieten, Sportwagenverleih, Ferrari mieten Graz" />
|
||||||
<meta name="theme-color" content="#1a1a1a" />
|
<meta name="theme-color" content="#1a1a1a" />
|
||||||
<meta name="language" content="German" />
|
<meta name="language" content="German" />
|
||||||
<link rel="canonical" href="https://demo.lago.dev/" />
|
<link rel="canonical" href="https://demo.lago.dev/" />
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
|
|
||||||
<!-- Open Graph Tags -->
|
<!-- Open Graph Tags -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="MC Cars – Premium Sportwagen & Luxusvermietung" />
|
<meta property="og:title" content="MC Cars – Premium Ferrari-Vermietung Steiermark" />
|
||||||
<meta property="og:description" content="Fahren Sie Premium-Sportwagen und Luxusklasse-Fahrzeuge in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<meta property="og:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||||
<meta property="og:url" content="https://demo.lago.dev/" />
|
<meta property="og:url" content="https://demo.lago.dev/" />
|
||||||
<meta property="og:site_name" content="MC Cars" />
|
<meta property="og:site_name" content="MC Cars" />
|
||||||
<meta property="og:locale" content="de_AT" />
|
<meta property="og:locale" content="de_AT" />
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
|
|
||||||
<!-- Twitter Card Tags -->
|
<!-- Twitter Card Tags -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="MC Cars – Premium Sportwagen & Luxusvermietung" />
|
<meta name="twitter:title" content="MC Cars – Premium Ferrari-Vermietung Steiermark" />
|
||||||
<meta name="twitter:description" content="Fahren Sie Premium-Sportwagen in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
<meta name="twitter:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||||
<meta name="twitter:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
|
<meta name="twitter:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
|
||||||
|
|
||||||
<!-- Structured Data (JSON-LD) -->
|
<!-- Structured Data (JSON-LD) -->
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"@id": "https://demo.lago.dev/#organization",
|
"@id": "https://demo.lago.dev/#organization",
|
||||||
"name": "MC Cars GmbH",
|
"name": "MC Cars GmbH",
|
||||||
"alternateName": "MC Cars",
|
"alternateName": "MC Cars",
|
||||||
"description": "Premium Sportwagen- und Luxusvermietung in der Steiermark",
|
"description": "Premium Ferrari-Vermietung in der Steiermark",
|
||||||
"url": "https://demo.lago.dev",
|
"url": "https://demo.lago.dev",
|
||||||
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
|
"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",
|
||||||
@@ -63,10 +63,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"priceRange": "€€€",
|
"priceRange": "€€€",
|
||||||
"serviceType": "Sportwagenvermietung",
|
"serviceType": "Ferrari-Vermietung",
|
||||||
"sameAs": [
|
"sameAs": [
|
||||||
"https://www.facebook.com/mccars",
|
"https://www.facebook.com/mc-cars",
|
||||||
"https://www.instagram.com/mccars"
|
"https://www.instagram.com/mc-cars"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"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.svg",
|
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
|
||||||
"description": "Premium Sportwagen- und Luxusvermietung in Steiermark, Österreich",
|
"description": "Premium Ferrari-Vermietung in Steiermark, Österreich",
|
||||||
"foundingDate": "2024",
|
"foundingDate": "2024",
|
||||||
"contactPoint": {
|
"contactPoint": {
|
||||||
"@type": "ContactPoint",
|
"@type": "ContactPoint",
|
||||||
@@ -113,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>
|
||||||
@@ -128,16 +127,15 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
|
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
|
||||||
<h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
|
<h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
|
||||||
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
|
<p class="lead" data-i18n="heroLead">Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.</p>
|
||||||
|
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
<a class="btn ghost" href="#fahrzeuge" data-i18n="viewFleet">Flotte ansehen</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div><strong data-i18n="statDeposit">Faire Kaution</strong><span>Fair Deposit</span></div>
|
<div><strong data-i18n="statDeposit">Faire Kaution</strong><span>Fair Deposit</span></div>
|
||||||
<div><strong id="statCarsCount">–</strong><span data-i18n="statCars">Fahrzeuge</span></div>
|
<div><strong id="statCarsCount">–</strong><span id="statCarsLabel" data-i18n="statCar">Fahrzeug</span></div>
|
||||||
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
|
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +146,6 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
|
|
||||||
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
|
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
|
||||||
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.</p>
|
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,37 +176,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Why -->
|
|
||||||
<section id="warum" style="background:var(--bg-elev);">
|
|
||||||
<a href="/impressum" data-i18n="imprint">Impressum</a>
|
|
||||||
<a href="/agb" data-i18n="terms">AGB</a>
|
|
||||||
<a href="/mietbedingungen" data-i18n="rentalTerms">Mietbedingungen</a>
|
|
||||||
<a href="/datenschutz" data-i18n="privacy">Datenschutz</a>
|
|
||||||
<p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p>
|
|
||||||
<h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspaß.</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="why-grid">
|
|
||||||
<article class="why-card">
|
|
||||||
<div class="icon">🛡</div>
|
|
||||||
<h3 data-i18n="whyInsurance">Versicherungsschutz</h3>
|
|
||||||
<p data-i18n="whyInsuranceText">Vollkasko mit klarem Selbstbehalt.</p>
|
|
||||||
</article>
|
|
||||||
<article class="why-card">
|
|
||||||
<div class="icon">★</div>
|
|
||||||
<h3 data-i18n="whyFleet">Premium Flotte</h3>
|
|
||||||
<p data-i18n="whyFleetText">Handverlesene Performance-Modelle.</p>
|
|
||||||
</article>
|
|
||||||
<article class="why-card">
|
|
||||||
<div class="icon">€</div>
|
|
||||||
<h3 data-i18n="whyDeposit">Faire Kaution</h3>
|
|
||||||
<p data-i18n="whyDepositText">Kein Überziehen. Transparente, faire Kaution ohne unnötige Belastung.</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Toast Notification -->
|
<!-- Toast Notification -->
|
||||||
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
<!-- Reviews -->
|
<!-- Reviews -->
|
||||||
@@ -403,13 +369,12 @@
|
|||||||
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" style="width:2rem;height:2rem;" />
|
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" style="width:2rem;height:2rem;" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
|
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
<a href="#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -423,8 +388,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,6 +411,6 @@
|
|||||||
<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 src="config.js"></script>
|
<script src="config.js"></script>
|
||||||
<script type="module" src="app.js"></script>
|
<script type="module" src="app.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Mietbedingungen · MC Cars</title>
|
<title>Mietbedingungen · MC Cars</title>
|
||||||
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
|
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||||
@@ -51,13 +51,12 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
<a class="logo" href="/" aria-label="MC Cars Startseite">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="menu-toggle" aria-label="Menü">☰</button>
|
<button class="menu-toggle" aria-label="Menü">☰</button>
|
||||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
Die Mietbedingungen regeln die Nutzung der Mietfahrzeuge, Zahlungsbedingungen, Haftung und Versicherung.
|
Die Mietbedingungen regeln die Nutzung der Mietfahrzeuge, Zahlungsbedingungen, Haftung und Versicherung.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Bitte wenden Sie sich an hello@mccars.at für weitere Informationen.
|
Bitte wenden Sie sich an hello@mc-cars.at für weitere Informationen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,16 +87,15 @@
|
|||||||
<div class="footer-grid">
|
<div class="footer-grid">
|
||||||
<div>
|
<div>
|
||||||
<div class="logo" style="margin-bottom:0.8rem;">
|
<div class="logo" style="margin-bottom:0.8rem;">
|
||||||
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
|
||||||
<span>MC Cars</span>
|
<span>MC Cars</span>
|
||||||
</div>
|
</div>
|
||||||
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
|
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerNav">Navigation</h4>
|
<h4 data-i18n="footerNav">Navigation</h4>
|
||||||
<a href="/" data-i18n="navCars">Fahrzeuge</a>
|
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
|
||||||
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
|
|
||||||
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
<a href="/#buchen" data-i18n="navBook">Buchen</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,8 +109,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 data-i18n="footerContact">Kontakt</h4>
|
<h4 data-i18n="footerContact">Kontakt</h4>
|
||||||
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
|
<a href="mailto:hello@mc-cars.at">hello@mc-cars.at</a>
|
||||||
<a href="tel:+43316880000">+43 316 880000</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -20,10 +20,9 @@ server {
|
|||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
# CSS/JS: 1-week cache; config.js is excluded by its exact-match rule above.
|
# CSS/JS: no cache to prevent stale content during development
|
||||||
location ~* \.(?:css|js)$ {
|
location ~* \.(?:css|js)$ {
|
||||||
expires 7d;
|
add_header Cache-Control "no-store";
|
||||||
add_header Cache-Control "public, max-age=604800";
|
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+320
-4
@@ -420,6 +420,146 @@ select:focus, input:focus, textarea:focus {
|
|||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo carousel nav */
|
||||||
|
.vehicle-photo-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vehicle-photo:hover .vehicle-photo-nav {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.vehicle-photo-prev,
|
||||||
|
.vehicle-photo-next {
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.vehicle-photo-prev:hover,
|
||||||
|
.vehicle-photo-next:hover {
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
.vehicle-photo-dots {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.vehicle-photo-dots span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.4);
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.vehicle-photo-dots span.active {
|
||||||
|
background: #fff;
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog gallery */
|
||||||
|
.dialog-gallery {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/10;
|
||||||
|
background: #0e1015;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
.dialog-gallery-main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.dialog-gallery-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.dialog-gallery-prev,
|
||||||
|
.dialog-gallery-next {
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.dialog-gallery-prev:hover,
|
||||||
|
.dialog-gallery-next:hover {
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
.dialog-gallery-thumbs {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dialog-gallery-thumbs button {
|
||||||
|
width: 56px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s ease, border-color 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
.dialog-gallery-thumbs button.active {
|
||||||
|
border-color: #fff;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.dialog-gallery-thumbs button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.dialog-gallery-thumbs button img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.vehicle-body {
|
.vehicle-body {
|
||||||
padding: 1.4rem;
|
padding: 1.4rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -904,7 +1044,7 @@ dialog::backdrop { background: rgba(0,0,0,0.6); }
|
|||||||
|
|
||||||
/* ---------------- Admin ---------------- */
|
/* ---------------- Admin ---------------- */
|
||||||
.admin-page {
|
.admin-page {
|
||||||
max-width: 1100px;
|
max-width: 1280px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
@@ -960,6 +1100,7 @@ table.admin-table th, table.admin-table td {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.75rem 0.6rem;
|
padding: 0.75rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: top;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.5rem; }
|
table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 0.5rem; }
|
||||||
@@ -969,6 +1110,9 @@ table.admin-table tbody tr:hover {
|
|||||||
transform: translateX(4px);
|
transform: translateX(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin table actions column: prevent button wrap */
|
||||||
|
table.admin-table td:last-child { white-space: nowrap; }
|
||||||
|
|
||||||
.link-lead { text-decoration: none; cursor: pointer; }
|
.link-lead { text-decoration: none; cursor: pointer; }
|
||||||
.link-lead:hover code { color: var(--accent-strong); text-decoration: underline; }
|
.link-lead:hover code { color: var(--accent-strong); text-decoration: underline; }
|
||||||
|
|
||||||
@@ -991,6 +1135,172 @@ table.admin-table tbody tr:hover {
|
|||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Unified Photo Upload Zone ---- */
|
||||||
|
.admin-photo-upload-zone {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 2px dashed var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.admin-photo-upload-zone:hover {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.admin-photo-upload-zone.drag-active {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
}
|
||||||
|
.admin-photo-upload-zone input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-photo-upload-content {
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.admin-photo-upload-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Photo Gallery ---- */
|
||||||
|
.admin-photo-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-photo-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 16/10;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.admin-photo-card:hover {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
.admin-photo-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.admin-photo-card-drag-over {
|
||||||
|
border-color: #f59e0b !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.admin-photo-card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.admin-photo-card-arrows {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.admin-photo-arrow {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.admin-photo-arrow:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
.admin-photo-card-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.admin-photo-set-primary {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(245, 158, 11, 0.85);
|
||||||
|
color: #000;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.admin-photo-set-primary:hover {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
.admin-photo-delete {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(239, 68, 68, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.admin-photo-delete:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
.admin-photo-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 6px;
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.admin-photo-drag-handle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
right: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Forms / Toggle Switch ---------------- */
|
/* ---------------- Forms / Toggle Switch ---------------- */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1098,6 +1408,9 @@ input:checked + .toggle-slider:before {
|
|||||||
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); }
|
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border: 1px solid rgba(180, 90, 90, 0.3); }
|
||||||
.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border: 1px solid rgba(90, 180, 120, 0.3); }
|
.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border: 1px solid rgba(90, 180, 120, 0.3); }
|
||||||
.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); border: 1px solid transparent; }
|
.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); border: 1px solid transparent; }
|
||||||
|
.pill-single_day { background: rgba(74, 144, 226, 0.16); color: #8abfff; border: 1px solid rgba(74, 144, 226, 0.35); }
|
||||||
|
.pill-weekend { background: rgba(200, 150, 80, 0.15); color: #e4b676; border: 1px solid rgba(200, 150, 80, 0.3); }
|
||||||
|
.pill-individuell { background: rgba(204, 116, 58, 0.16); color: #ffb487; border: 1px solid rgba(204, 116, 58, 0.38); }
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
@@ -1107,7 +1420,8 @@ input:checked + .toggle-slider:before {
|
|||||||
|
|
||||||
/* Dialog */
|
/* Dialog */
|
||||||
dialog#leadDialog,
|
dialog#leadDialog,
|
||||||
dialog#customerDialog {
|
dialog#customerDialog,
|
||||||
|
dialog#orderDialog {
|
||||||
border: 1px solid var(--line); border-radius: var(--radius);
|
border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
background: var(--bg-card); color: var(--text);
|
background: var(--bg-card); color: var(--text);
|
||||||
padding: 0; max-width: 640px; width: 94%;
|
padding: 0; max-width: 640px; width: 94%;
|
||||||
@@ -1115,11 +1429,13 @@ dialog#customerDialog {
|
|||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
}
|
}
|
||||||
dialog#leadDialog[open],
|
dialog#leadDialog[open],
|
||||||
dialog#customerDialog[open] {
|
dialog#customerDialog[open],
|
||||||
|
dialog#orderDialog[open] {
|
||||||
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
animation: fadeInScale 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
}
|
}
|
||||||
dialog#leadDialog::backdrop,
|
dialog#leadDialog::backdrop,
|
||||||
dialog#customerDialog::backdrop {
|
dialog#customerDialog::backdrop,
|
||||||
|
dialog#orderDialog::backdrop {
|
||||||
background: rgba(0,0,0,0.7);
|
background: rgba(0,0,0,0.7);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
animation: fadeIn 0.3s ease forwards;
|
animation: fadeIn 0.3s ease forwards;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ set -eu
|
|||||||
|
|
||||||
WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}"
|
WORKFLOW_TEMPLATE="${N8N_WORKFLOW_TEMPLATE:-/opt/mc-cars/workflows/01-qualification-payment-email.json}"
|
||||||
WORKFLOW_RENDERED="/tmp/01-qualification-payment-email.rendered.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"
|
CREDENTIALS_FILE="/tmp/mc-cars-credentials.json"
|
||||||
|
|
||||||
required_var() {
|
required_var() {
|
||||||
@@ -74,10 +76,28 @@ sed \
|
|||||||
echo "[n8n-bootstrap] Importing credentials"
|
echo "[n8n-bootstrap] Importing credentials"
|
||||||
n8n import:credentials --input="$CREDENTIALS_FILE"
|
n8n import:credentials --input="$CREDENTIALS_FILE"
|
||||||
|
|
||||||
echo "[n8n-bootstrap] Importing workflow"
|
echo "[n8n-bootstrap] Importing workflow 01"
|
||||||
n8n import:workflow --input="$WORKFLOW_RENDERED"
|
n8n import:workflow --input="$WORKFLOW_RENDERED"
|
||||||
|
|
||||||
echo "[n8n-bootstrap] Activating workflow ${N8N_PAYMENT_WORKFLOW_ID}"
|
# Process and import workflow 03 - Manual Email Send
|
||||||
n8n update:workflow --id="${N8N_PAYMENT_WORKFLOW_ID}" --active=true
|
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"
|
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
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.APP_URL || 'http://localhost:55580',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -120,3 +120,16 @@ services:
|
|||||||
hide_groups_header: true
|
hide_groups_header: true
|
||||||
allow:
|
allow:
|
||||||
- admin
|
- admin
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# n8n Webhooks (internal workflow triggers)
|
||||||
|
########################################
|
||||||
|
- name: n8n-webhooks
|
||||||
|
url: http://n8n:5678/
|
||||||
|
routes:
|
||||||
|
- name: n8n-webhooks-all
|
||||||
|
strip_path: false
|
||||||
|
paths:
|
||||||
|
- /webhook/
|
||||||
|
plugins:
|
||||||
|
- name: cors
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
-- 11-consolidate-km-rental.sql
|
||||||
|
-- Consolidate km/rental model: new included_km_per_day, rental_type,
|
||||||
|
-- rewrite calculate_price / create_lead / qualify_lead / notify_lead_qualified,
|
||||||
|
-- add sales_order_set_total RPC.
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- A. Vehicles table changes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
alter table public.vehicles add column if not exists included_km_per_day integer not null default 150;
|
||||||
|
|
||||||
|
update public.vehicles set included_km_per_day = coalesce(max_daily_km, 150) where included_km_per_day = 150;
|
||||||
|
|
||||||
|
update public.vehicles set included_km_per_day = 200 where brand = 'Ferrari' and model = '296 GTB';
|
||||||
|
|
||||||
|
alter table public.vehicles add column if not exists price_per_km_eur numeric(10,2) not null default 1.50;
|
||||||
|
|
||||||
|
alter table public.vehicles drop column if exists max_daily_km;
|
||||||
|
alter table public.vehicles drop column if exists max_km_weekend;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- B. Leads table changes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
alter table public.leads add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
|
||||||
|
update public.leads set rental_type = 'weekend' where rental_type is null;
|
||||||
|
create index if not exists leads_rental_type_idx on public.leads (rental_type);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- C. Sales orders table changes
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
alter table public.sales_orders add column if not exists rental_type text not null default 'weekend' check (rental_type in ('weekend','individuell'));
|
||||||
|
update public.sales_orders set rental_type = 'weekend' where rental_type is null;
|
||||||
|
create index if not exists sales_orders_rental_type_idx on public.sales_orders (rental_type);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- D. Rewrite calculate_price() RPC
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
drop function if exists public.calculate_price(uuid, date, date);
|
||||||
|
|
||||||
|
create or replace function public.calculate_price(
|
||||||
|
p_vehicle_id uuid,
|
||||||
|
p_date_from date,
|
||||||
|
p_date_to date
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_vehicle record;
|
||||||
|
v_total_days integer;
|
||||||
|
v_weekend_days integer;
|
||||||
|
v_weekdays integer;
|
||||||
|
v_daily_subtotal integer;
|
||||||
|
v_weekend_subtotal integer;
|
||||||
|
v_subtotal_eur integer;
|
||||||
|
v_vat_eur integer;
|
||||||
|
v_total_eur integer;
|
||||||
|
v_deposit_eur integer;
|
||||||
|
v_included_km_per_day integer;
|
||||||
|
v_price_per_km numeric(10,2);
|
||||||
|
v_total_included_km integer;
|
||||||
|
v_extra_km integer;
|
||||||
|
v_extra_km_eur numeric(10,2);
|
||||||
|
v_cur date;
|
||||||
|
v_dow integer;
|
||||||
|
begin
|
||||||
|
if p_vehicle_id is null or p_date_from is null or p_date_to is null then
|
||||||
|
raise exception 'vehicle_id, date_from and date_to are required';
|
||||||
|
end if;
|
||||||
|
if p_date_to <= p_date_from then
|
||||||
|
raise exception 'date_to must be after date_from';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select daily_price_eur, weekend_price_eur, kaution_eur, included_km_per_day, price_per_km_eur
|
||||||
|
into v_vehicle
|
||||||
|
from public.vehicles
|
||||||
|
where id = p_vehicle_id;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Vehicle not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_total_days := (p_date_to - p_date_from);
|
||||||
|
v_weekend_days := 0;
|
||||||
|
v_cur := p_date_from;
|
||||||
|
while v_cur < p_date_to loop
|
||||||
|
v_dow := extract(isodow from v_cur); -- 6=Sat, 7=Sun
|
||||||
|
if v_dow in (6, 7) then
|
||||||
|
v_weekend_days := v_weekend_days + 1;
|
||||||
|
end if;
|
||||||
|
v_cur := v_cur + 1;
|
||||||
|
end loop;
|
||||||
|
v_weekdays := v_total_days - v_weekend_days;
|
||||||
|
|
||||||
|
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||||
|
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||||
|
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||||
|
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||||
|
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||||
|
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||||
|
|
||||||
|
v_included_km_per_day := coalesce(v_vehicle.included_km_per_day, 150);
|
||||||
|
v_total_included_km := v_total_days * v_included_km_per_day;
|
||||||
|
v_price_per_km := coalesce(v_vehicle.price_per_km_eur, 1.50);
|
||||||
|
v_extra_km := greatest(0, 0); -- extra km is determined by caller (frontend) based on expected usage
|
||||||
|
v_extra_km_eur := v_extra_km * v_price_per_km;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'total_days', v_total_days,
|
||||||
|
'weekday_count', v_weekdays,
|
||||||
|
'weekend_day_count', v_weekend_days,
|
||||||
|
'daily_subtotal', v_daily_subtotal,
|
||||||
|
'weekend_subtotal', v_weekend_subtotal,
|
||||||
|
'subtotal_eur', v_subtotal_eur,
|
||||||
|
'vat_eur', v_vat_eur,
|
||||||
|
'total_eur', v_total_eur,
|
||||||
|
'deposit_eur', v_deposit_eur,
|
||||||
|
'daily_price_eur', v_vehicle.daily_price_eur,
|
||||||
|
'weekend_price_eur', (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end),
|
||||||
|
'included_km_per_day', v_included_km_per_day,
|
||||||
|
'total_included_km', v_total_included_km,
|
||||||
|
'price_per_km_eur', v_price_per_km,
|
||||||
|
'extra_km', v_extra_km,
|
||||||
|
'extra_km_eur', v_extra_km_eur
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.calculate_price(uuid, date, date) to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- E. Rewrite create_lead() RPC
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
drop function if exists public.create_lead(
|
||||||
|
text, text, text, uuid, text, date, date, text, text
|
||||||
|
);
|
||||||
|
drop function if exists public.create_lead(
|
||||||
|
text, text, text, uuid, text, date, date, text, text,
|
||||||
|
integer, integer, integer, integer, integer, integer, integer, integer, integer,
|
||||||
|
text, text
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.create_lead(
|
||||||
|
p_name text,
|
||||||
|
p_email text,
|
||||||
|
p_phone text default '',
|
||||||
|
p_vehicle_id uuid default null,
|
||||||
|
p_vehicle_label text default '',
|
||||||
|
p_date_from date default null,
|
||||||
|
p_date_to date default null,
|
||||||
|
p_message text default '',
|
||||||
|
p_source text default 'website',
|
||||||
|
p_ip_address text default '',
|
||||||
|
p_ip_country text default ''
|
||||||
|
)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_lead_id uuid;
|
||||||
|
v_vehicle record;
|
||||||
|
v_total_days integer := 0;
|
||||||
|
v_weekend_days integer := 0;
|
||||||
|
v_weekdays integer := 0;
|
||||||
|
v_daily_subtotal integer := 0;
|
||||||
|
v_weekend_subtotal integer := 0;
|
||||||
|
v_subtotal_eur integer := 0;
|
||||||
|
v_vat_eur integer := 0;
|
||||||
|
v_total_eur integer := 0;
|
||||||
|
v_deposit_eur integer := 0;
|
||||||
|
v_rental_type text := 'weekend';
|
||||||
|
v_cur date;
|
||||||
|
v_dow integer;
|
||||||
|
begin
|
||||||
|
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||||
|
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||||
|
into v_vehicle
|
||||||
|
from public.vehicles
|
||||||
|
where id = p_vehicle_id;
|
||||||
|
|
||||||
|
if found then
|
||||||
|
v_total_days := (p_date_to - p_date_from);
|
||||||
|
|
||||||
|
-- Auto-detect rental type: 2 days or less = weekend, more = individuell
|
||||||
|
v_rental_type := 'weekend';
|
||||||
|
if v_total_days > 2 then
|
||||||
|
v_rental_type := 'individuell';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- For individuell, set all pricing to 0
|
||||||
|
if v_rental_type = 'individuell' then
|
||||||
|
v_daily_subtotal := 0;
|
||||||
|
v_weekend_subtotal := 0;
|
||||||
|
v_subtotal_eur := 0;
|
||||||
|
v_vat_eur := 0;
|
||||||
|
v_total_eur := 0;
|
||||||
|
v_deposit_eur := 0;
|
||||||
|
else
|
||||||
|
v_cur := p_date_from;
|
||||||
|
while v_cur < p_date_to loop
|
||||||
|
v_dow := extract(isodow from v_cur);
|
||||||
|
if v_dow in (6, 7) then
|
||||||
|
v_weekend_days := v_weekend_days + 1;
|
||||||
|
end if;
|
||||||
|
v_cur := v_cur + 1;
|
||||||
|
end loop;
|
||||||
|
v_weekdays := v_total_days - v_weekend_days;
|
||||||
|
|
||||||
|
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||||
|
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||||
|
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||||
|
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||||
|
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||||
|
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.leads (
|
||||||
|
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||||
|
message, source,
|
||||||
|
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||||
|
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
|
||||||
|
rental_type
|
||||||
|
) values (
|
||||||
|
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||||
|
p_message, p_source,
|
||||||
|
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||||
|
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
|
||||||
|
v_rental_type
|
||||||
|
)
|
||||||
|
returning id into v_lead_id;
|
||||||
|
return v_lead_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.create_lead(
|
||||||
|
text, text, text, uuid, text, date, date, text, text, text, text
|
||||||
|
) to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- F. Rewrite qualify_lead() RPC
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
|
||||||
|
returns public.customers
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_lead public.leads;
|
||||||
|
v_customer public.customers;
|
||||||
|
v_sales_order public.sales_orders;
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_order_num text;
|
||||||
|
v_year integer;
|
||||||
|
v_count integer;
|
||||||
|
begin
|
||||||
|
select * into v_lead from public.leads where id = p_lead_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'lead % not found', p_lead_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_lead.status = 'qualified' then
|
||||||
|
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
|
||||||
|
return v_customer;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.leads
|
||||||
|
set status = 'qualified',
|
||||||
|
is_active = false,
|
||||||
|
qualified_at = now(),
|
||||||
|
qualified_by = v_user,
|
||||||
|
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
|
||||||
|
where id = v_lead.id;
|
||||||
|
|
||||||
|
insert into public.customers (lead_id, name, email, phone, notes, created_by)
|
||||||
|
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
|
||||||
|
on conflict ((lower(email))) do update
|
||||||
|
set name = excluded.name,
|
||||||
|
phone = excluded.phone,
|
||||||
|
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||||
|
updated_at = now()
|
||||||
|
returning * into v_customer;
|
||||||
|
|
||||||
|
v_year := extract(year from now())::integer;
|
||||||
|
select coalesce(count(*), 0) + 1 into v_count
|
||||||
|
from public.sales_orders
|
||||||
|
where extract(year from created_at)::integer = v_year;
|
||||||
|
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||||
|
|
||||||
|
insert into public.sales_orders (
|
||||||
|
customer_id, lead_id, order_number, private_notes,
|
||||||
|
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||||
|
total_days, weekday_count, weekend_day_count,
|
||||||
|
date_from, date_to, vehicle_label, rental_type
|
||||||
|
) values (
|
||||||
|
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||||
|
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||||
|
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||||
|
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||||
|
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||||
|
coalesce(v_lead.weekend_day_count, 0),
|
||||||
|
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type
|
||||||
|
) returning * into v_sales_order;
|
||||||
|
|
||||||
|
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||||
|
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||||
|
from public.lead_attachments la
|
||||||
|
where la.lead_id = v_lead.id
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.customer_attachments ca
|
||||||
|
where ca.customer_id = v_customer.id
|
||||||
|
and ca.file_path = la.file_path
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||||
|
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||||
|
from public.lead_attachments la
|
||||||
|
where la.lead_id = v_lead.id;
|
||||||
|
|
||||||
|
return v_customer;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- G. Rewrite notify_lead_qualified() trigger function
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.notify_lead_qualified()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
-- Skip notification for 'individuell' rental type
|
||||||
|
if NEW.rental_type = 'individuell' then
|
||||||
|
return NEW;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform pg_notify('lead_qualified', json_build_object(
|
||||||
|
'sales_order_id', NEW.id,
|
||||||
|
'customer_id', NEW.customer_id,
|
||||||
|
'lead_id', NEW.lead_id,
|
||||||
|
'order_number', NEW.order_number,
|
||||||
|
'total_eur', NEW.total_eur,
|
||||||
|
'deposit_eur', NEW.deposit_eur,
|
||||||
|
'date_from', NEW.date_from,
|
||||||
|
'date_to', NEW.date_to,
|
||||||
|
'vehicle_label', NEW.vehicle_label,
|
||||||
|
'rental_type', NEW.rental_type
|
||||||
|
)::text);
|
||||||
|
return NEW;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- H. New RPC: sales_order_set_total
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_so public.sales_orders;
|
||||||
|
begin
|
||||||
|
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'sales order % not found', p_so_id;
|
||||||
|
end if;
|
||||||
|
if v_so.rental_type != 'individuell' then
|
||||||
|
raise exception 'can only set total for individuell orders';
|
||||||
|
end if;
|
||||||
|
update public.sales_orders
|
||||||
|
set total_eur = p_total_eur, updated_at = now()
|
||||||
|
where id = p_so_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- I. Final schema reload
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
-- 12-email-sent-and-more.sql
|
||||||
|
-- Add email_sent column to sales_orders, update notify_lead_qualified() to include
|
||||||
|
-- rental_type and email_sent, update qualify_lead() to set email_sent=0,
|
||||||
|
-- add sales_order_update_email_sent and sales_order_get_email_details RPCs.
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- A. Add email_sent to sales_orders
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
alter table public.sales_orders add column if not exists email_sent integer not null default 0;
|
||||||
|
create index if not exists sales_orders_email_sent_idx on public.sales_orders (email_sent);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- B. Update notify_lead_qualified() trigger function
|
||||||
|
-- (defined in 10-mietvertrag-workflow.sql, overridden by 11-consolidate-km-rental.sql)
|
||||||
|
-- Since migration 12 runs after 11, this is the final version.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.notify_lead_qualified()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
-- Skip notification for 'individuell' rental type
|
||||||
|
if NEW.rental_type = 'individuell' then
|
||||||
|
return NEW;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform pg_notify('lead_qualified', json_build_object(
|
||||||
|
'sales_order_id', NEW.id,
|
||||||
|
'customer_id', NEW.customer_id,
|
||||||
|
'lead_id', NEW.lead_id,
|
||||||
|
'order_number', NEW.order_number,
|
||||||
|
'total_eur', NEW.total_eur,
|
||||||
|
'deposit_eur', NEW.deposit_eur,
|
||||||
|
'date_from', NEW.date_from,
|
||||||
|
'date_to', NEW.date_to,
|
||||||
|
'vehicle_label', NEW.vehicle_label,
|
||||||
|
'rental_type', NEW.rental_type,
|
||||||
|
'email_sent', NEW.email_sent
|
||||||
|
)::text);
|
||||||
|
return NEW;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- C. Update qualify_lead() RPC
|
||||||
|
-- Add email_sent = 0 to the sales_orders insert.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
|
||||||
|
returns public.customers
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_lead public.leads;
|
||||||
|
v_customer public.customers;
|
||||||
|
v_sales_order public.sales_orders;
|
||||||
|
v_user uuid := auth.uid();
|
||||||
|
v_order_num text;
|
||||||
|
v_year integer;
|
||||||
|
v_count integer;
|
||||||
|
begin
|
||||||
|
select * into v_lead from public.leads where id = p_lead_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'lead % not found', p_lead_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_lead.status = 'qualified' then
|
||||||
|
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
|
||||||
|
return v_customer;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.leads
|
||||||
|
set status = 'qualified',
|
||||||
|
is_active = false,
|
||||||
|
qualified_at = now(),
|
||||||
|
qualified_by = v_user,
|
||||||
|
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
|
||||||
|
where id = v_lead.id;
|
||||||
|
|
||||||
|
insert into public.customers (lead_id, name, email, phone, notes, created_by)
|
||||||
|
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
|
||||||
|
on conflict ((lower(email))) do update
|
||||||
|
set name = excluded.name,
|
||||||
|
phone = excluded.phone,
|
||||||
|
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||||
|
updated_at = now()
|
||||||
|
returning * into v_customer;
|
||||||
|
|
||||||
|
v_year := extract(year from now())::integer;
|
||||||
|
select coalesce(count(*), 0) + 1 into v_count
|
||||||
|
from public.sales_orders
|
||||||
|
where extract(year from created_at)::integer = v_year;
|
||||||
|
v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0');
|
||||||
|
|
||||||
|
insert into public.sales_orders (
|
||||||
|
customer_id, lead_id, order_number, private_notes,
|
||||||
|
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||||
|
total_days, weekday_count, weekend_day_count,
|
||||||
|
date_from, date_to, vehicle_label, rental_type, email_sent
|
||||||
|
) values (
|
||||||
|
v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''),
|
||||||
|
coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0),
|
||||||
|
coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0),
|
||||||
|
coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0),
|
||||||
|
coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0),
|
||||||
|
coalesce(v_lead.weekend_day_count, 0),
|
||||||
|
v_lead.date_from, v_lead.date_to, v_lead.vehicle_label, v_lead.rental_type, 0
|
||||||
|
) returning * into v_sales_order;
|
||||||
|
|
||||||
|
insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||||
|
select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||||
|
from public.lead_attachments la
|
||||||
|
where la.lead_id = v_lead.id
|
||||||
|
and not exists (
|
||||||
|
select 1 from public.customer_attachments ca
|
||||||
|
where ca.customer_id = v_customer.id
|
||||||
|
and ca.file_path = la.file_path
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||||
|
select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||||
|
from public.lead_attachments la
|
||||||
|
where la.lead_id = v_lead.id;
|
||||||
|
|
||||||
|
return v_customer;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- D. New RPC: sales_order_update_email_sent
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.sales_order_update_email_sent(p_so_id uuid, p_status integer)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_so public.sales_orders;
|
||||||
|
begin
|
||||||
|
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'sales order % not found', p_so_id;
|
||||||
|
end if;
|
||||||
|
if p_status not in (0, 1, 2) then
|
||||||
|
raise exception 'invalid email_sent status: %', p_status;
|
||||||
|
end if;
|
||||||
|
update public.sales_orders
|
||||||
|
set email_sent = p_status, updated_at = now()
|
||||||
|
where id = p_so_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.sales_order_update_email_sent(uuid, integer) to authenticated;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- E. New RPC: sales_order_get_email_details
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.sales_order_get_email_details(p_so_id uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_result jsonb;
|
||||||
|
begin
|
||||||
|
select jsonb_build_object(
|
||||||
|
'order_number', so.order_number,
|
||||||
|
'total_eur', so.total_eur,
|
||||||
|
'deposit_eur', so.deposit_eur,
|
||||||
|
'date_from', so.date_from,
|
||||||
|
'date_to', so.date_to,
|
||||||
|
'vehicle_label', so.vehicle_label,
|
||||||
|
'customer_name', c.name,
|
||||||
|
'customer_email', c.email,
|
||||||
|
'customer_phone', c.phone,
|
||||||
|
'daily_subtotal', so.daily_subtotal,
|
||||||
|
'weekend_subtotal', so.weekend_subtotal,
|
||||||
|
'subtotal_eur', so.subtotal_eur,
|
||||||
|
'vat_eur', so.vat_eur,
|
||||||
|
'total_days', so.total_days,
|
||||||
|
'weekday_count', so.weekday_count,
|
||||||
|
'weekend_day_count', so.weekend_day_count
|
||||||
|
) into v_result
|
||||||
|
from public.sales_orders so
|
||||||
|
join public.customers c on c.id = so.customer_id
|
||||||
|
where so.id = p_so_id;
|
||||||
|
|
||||||
|
if v_result is null then
|
||||||
|
raise exception 'sales order % not found', p_so_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_result;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.sales_order_get_email_details(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- F. Final schema reload
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
-- 13-rental-type-daily-and-email-guard.sql
|
||||||
|
-- Introduce explicit 'single_day' rental_type, normalize legacy values,
|
||||||
|
-- and harden auto-email guard for individuell rentals.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- A. Normalize and expand rental_type checks
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
alter table public.leads drop constraint if exists leads_rental_type_check;
|
||||||
|
alter table public.sales_orders drop constraint if exists sales_orders_rental_type_check;
|
||||||
|
|
||||||
|
update public.leads
|
||||||
|
set rental_type = lower(trim(coalesce(rental_type, '')));
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = lower(trim(coalesce(rental_type, '')));
|
||||||
|
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'individuell'
|
||||||
|
where rental_type in ('individual', 'custom');
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'individuell'
|
||||||
|
where rental_type in ('individual', 'custom');
|
||||||
|
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'single_day'
|
||||||
|
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'single_day'
|
||||||
|
where rental_type in ('day', 'daily', '1 tag', '1_tag', 'single_day');
|
||||||
|
|
||||||
|
-- Existing one-day bookings should be single_day.
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'single_day'
|
||||||
|
where rental_type = 'weekend'
|
||||||
|
and total_days = 1;
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'single_day'
|
||||||
|
where rental_type = 'weekend'
|
||||||
|
and total_days = 1;
|
||||||
|
|
||||||
|
-- Two-day non-Saturday starts are effectively single_day rentals, not weekend packages.
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'single_day'
|
||||||
|
where rental_type = 'weekend'
|
||||||
|
and total_days = 2
|
||||||
|
and date_from is not null
|
||||||
|
and extract(isodow from date_from) <> 6;
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'single_day'
|
||||||
|
where rental_type = 'weekend'
|
||||||
|
and total_days = 2
|
||||||
|
and date_from is not null
|
||||||
|
and extract(isodow from date_from) <> 6;
|
||||||
|
|
||||||
|
-- Fallback for any unexpected value.
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'weekend'
|
||||||
|
where rental_type not in ('single_day', 'weekend', 'individuell');
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'weekend'
|
||||||
|
where rental_type not in ('single_day', 'weekend', 'individuell');
|
||||||
|
|
||||||
|
alter table public.leads
|
||||||
|
alter column rental_type set default 'weekend';
|
||||||
|
|
||||||
|
alter table public.sales_orders
|
||||||
|
alter column rental_type set default 'weekend';
|
||||||
|
|
||||||
|
alter table public.leads
|
||||||
|
add constraint leads_rental_type_check
|
||||||
|
check (rental_type in ('single_day', 'weekend', 'individuell'));
|
||||||
|
|
||||||
|
alter table public.sales_orders
|
||||||
|
add constraint sales_orders_rental_type_check
|
||||||
|
check (rental_type in ('single_day', 'weekend', 'individuell'));
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- B. Harden notify_lead_qualified() against malformed rental_type values
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.notify_lead_qualified()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_rental_type text := coalesce(lower(trim(NEW.rental_type)), 'weekend');
|
||||||
|
begin
|
||||||
|
-- Never auto-email individuell orders (including legacy synonyms).
|
||||||
|
if v_rental_type in ('individuell', 'individual', 'custom') then
|
||||||
|
return NEW;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform pg_notify('lead_qualified', json_build_object(
|
||||||
|
'sales_order_id', NEW.id,
|
||||||
|
'customer_id', NEW.customer_id,
|
||||||
|
'lead_id', NEW.lead_id,
|
||||||
|
'order_number', NEW.order_number,
|
||||||
|
'total_eur', NEW.total_eur,
|
||||||
|
'deposit_eur', NEW.deposit_eur,
|
||||||
|
'date_from', NEW.date_from,
|
||||||
|
'date_to', NEW.date_to,
|
||||||
|
'vehicle_label', NEW.vehicle_label,
|
||||||
|
'rental_type', v_rental_type,
|
||||||
|
'email_sent', NEW.email_sent
|
||||||
|
)::text);
|
||||||
|
|
||||||
|
return NEW;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- C. Update create_lead() classification logic to include daily
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.create_lead(
|
||||||
|
p_name text,
|
||||||
|
p_email text,
|
||||||
|
p_phone text default '',
|
||||||
|
p_vehicle_id uuid default null,
|
||||||
|
p_vehicle_label text default '',
|
||||||
|
p_date_from date default null,
|
||||||
|
p_date_to date default null,
|
||||||
|
p_message text default '',
|
||||||
|
p_source text default 'website',
|
||||||
|
p_ip_address text default '',
|
||||||
|
p_ip_country text default ''
|
||||||
|
)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_lead_id uuid;
|
||||||
|
v_vehicle record;
|
||||||
|
v_total_days integer := 0;
|
||||||
|
v_weekend_days integer := 0;
|
||||||
|
v_weekdays integer := 0;
|
||||||
|
v_daily_subtotal integer := 0;
|
||||||
|
v_weekend_subtotal integer := 0;
|
||||||
|
v_subtotal_eur integer := 0;
|
||||||
|
v_vat_eur integer := 0;
|
||||||
|
v_total_eur integer := 0;
|
||||||
|
v_deposit_eur integer := 0;
|
||||||
|
v_rental_type text := 'weekend';
|
||||||
|
v_cur date;
|
||||||
|
v_dow integer;
|
||||||
|
begin
|
||||||
|
if p_vehicle_id is not null and p_date_from is not null and p_date_to is not null and p_date_to > p_date_from then
|
||||||
|
select daily_price_eur, weekend_price_eur, kaution_eur
|
||||||
|
into v_vehicle
|
||||||
|
from public.vehicles
|
||||||
|
where id = p_vehicle_id;
|
||||||
|
|
||||||
|
if found then
|
||||||
|
v_total_days := (p_date_to - p_date_from);
|
||||||
|
|
||||||
|
-- Classification:
|
||||||
|
-- 1 day => single_day
|
||||||
|
-- 2 days starting Saturday => weekend package
|
||||||
|
-- 2 days otherwise => single_day
|
||||||
|
-- > 2 days => individuell (manual processing)
|
||||||
|
if v_total_days > 2 then
|
||||||
|
v_rental_type := 'individuell';
|
||||||
|
elsif v_total_days = 1 then
|
||||||
|
v_rental_type := 'single_day';
|
||||||
|
elsif v_total_days = 2 and extract(isodow from p_date_from) = 6 then
|
||||||
|
v_rental_type := 'weekend';
|
||||||
|
elsif v_total_days = 2 then
|
||||||
|
v_rental_type := 'single_day';
|
||||||
|
else
|
||||||
|
v_rental_type := 'weekend';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_rental_type = 'individuell' then
|
||||||
|
v_daily_subtotal := 0;
|
||||||
|
v_weekend_subtotal := 0;
|
||||||
|
v_subtotal_eur := 0;
|
||||||
|
v_vat_eur := 0;
|
||||||
|
v_total_eur := 0;
|
||||||
|
v_deposit_eur := 0;
|
||||||
|
else
|
||||||
|
v_cur := p_date_from;
|
||||||
|
while v_cur < p_date_to loop
|
||||||
|
v_dow := extract(isodow from v_cur);
|
||||||
|
if v_dow in (6, 7) then
|
||||||
|
v_weekend_days := v_weekend_days + 1;
|
||||||
|
end if;
|
||||||
|
v_cur := v_cur + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
v_weekdays := v_total_days - v_weekend_days;
|
||||||
|
v_daily_subtotal := v_weekdays * v_vehicle.daily_price_eur;
|
||||||
|
v_weekend_subtotal := v_weekend_days * (case when v_vehicle.weekend_price_eur > 0 then v_vehicle.weekend_price_eur else v_vehicle.daily_price_eur end);
|
||||||
|
v_subtotal_eur := v_daily_subtotal + v_weekend_subtotal;
|
||||||
|
v_vat_eur := round(v_subtotal_eur * 0.20);
|
||||||
|
v_total_eur := v_subtotal_eur + v_vat_eur;
|
||||||
|
v_deposit_eur := coalesce(nullif(v_vehicle.kaution_eur, 0), 5000);
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.leads (
|
||||||
|
name, email, phone, vehicle_id, vehicle_label, date_from, date_to,
|
||||||
|
message, source,
|
||||||
|
daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur,
|
||||||
|
total_days, weekday_count, weekend_day_count, ip_address, ip_country,
|
||||||
|
rental_type
|
||||||
|
) values (
|
||||||
|
p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to,
|
||||||
|
p_message, p_source,
|
||||||
|
v_daily_subtotal, v_weekend_subtotal, v_subtotal_eur, v_vat_eur, v_total_eur, v_deposit_eur,
|
||||||
|
v_total_days, v_weekdays, v_weekend_days, p_ip_address, p_ip_country,
|
||||||
|
v_rental_type
|
||||||
|
)
|
||||||
|
returning id into v_lead_id;
|
||||||
|
|
||||||
|
return v_lead_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.create_lead(
|
||||||
|
text, text, text, uuid, text, date, date, text, text, text, text
|
||||||
|
) to anon, authenticated, service_role;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- 14-sales-order-set-deposit.sql
|
||||||
|
-- Adds sales_order_set_deposit RPC for updating deposit from admin pricing tab.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- A. RPC: sales_order_set_deposit
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
create or replace function public.sales_order_set_deposit(p_so_id uuid, p_deposit_eur integer)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
update public.sales_orders
|
||||||
|
set deposit_eur = p_deposit_eur, updated_at = now()
|
||||||
|
where id = p_so_id;
|
||||||
|
if not found then
|
||||||
|
raise exception 'sales order % not found', p_so_id;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.sales_order_set_deposit(uuid, integer) to authenticated;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- B. Schema reload
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- Ensure individuell orders persist net/vat components when total is manually set
|
||||||
|
-- and backfill existing records where these fields are still zero.
|
||||||
|
|
||||||
|
create or replace function public.sales_order_set_total(p_so_id uuid, p_total_eur integer)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security invoker
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_so public.sales_orders;
|
||||||
|
v_subtotal_eur integer := 0;
|
||||||
|
v_vat_eur integer := 0;
|
||||||
|
begin
|
||||||
|
select * into v_so from public.sales_orders where id = p_so_id for update;
|
||||||
|
if not found then
|
||||||
|
raise exception 'sales order % not found', p_so_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_so.rental_type != 'individuell' then
|
||||||
|
raise exception 'can only set total for individuell orders';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if coalesce(p_total_eur, 0) < 0 then
|
||||||
|
raise exception 'total must be >= 0';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_total_eur > 0 then
|
||||||
|
v_subtotal_eur := round(p_total_eur / 1.2);
|
||||||
|
v_vat_eur := p_total_eur - v_subtotal_eur;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set total_eur = p_total_eur,
|
||||||
|
subtotal_eur = v_subtotal_eur,
|
||||||
|
vat_eur = v_vat_eur,
|
||||||
|
daily_subtotal = v_subtotal_eur,
|
||||||
|
weekend_subtotal = 0,
|
||||||
|
weekday_count = coalesce(total_days, 0),
|
||||||
|
weekend_day_count = 0,
|
||||||
|
updated_at = now()
|
||||||
|
where id = p_so_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.sales_order_set_total(uuid, integer) to authenticated;
|
||||||
|
|
||||||
|
-- Backfill already existing individuell orders with missing net/vat split.
|
||||||
|
update public.sales_orders
|
||||||
|
set subtotal_eur = round(total_eur / 1.2),
|
||||||
|
vat_eur = total_eur - round(total_eur / 1.2),
|
||||||
|
daily_subtotal = round(total_eur / 1.2),
|
||||||
|
weekend_subtotal = 0,
|
||||||
|
weekday_count = coalesce(total_days, 0),
|
||||||
|
weekend_day_count = 0,
|
||||||
|
updated_at = now()
|
||||||
|
where rental_type = 'individuell'
|
||||||
|
and coalesce(total_eur, 0) > 0
|
||||||
|
and coalesce(subtotal_eur, 0) = 0
|
||||||
|
and coalesce(vat_eur, 0) = 0;
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- 16-rental-type-weekend-gap-fix.sql
|
||||||
|
-- Fix misclassified long rentals that were stored as 'weekend'.
|
||||||
|
-- Business rule: only true weekend package may remain 'weekend'; long spans are 'individuell'.
|
||||||
|
|
||||||
|
-- Leads: any weekend booking longer than 2 days must be individuell.
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'individuell'
|
||||||
|
where coalesce(lower(trim(rental_type)), 'weekend') = 'weekend'
|
||||||
|
and coalesce(total_days, 0) > 2;
|
||||||
|
|
||||||
|
-- Sales orders: same correction.
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'individuell'
|
||||||
|
where coalesce(lower(trim(rental_type)), 'weekend') = 'weekend'
|
||||||
|
and coalesce(total_days, 0) > 2;
|
||||||
|
|
||||||
|
-- If old rows have unknown/legacy values and >2 days, normalize to individuell as well.
|
||||||
|
update public.leads
|
||||||
|
set rental_type = 'individuell'
|
||||||
|
where coalesce(total_days, 0) > 2
|
||||||
|
and coalesce(lower(trim(rental_type)), '') not in ('individuell', 'weekend', 'single_day');
|
||||||
|
|
||||||
|
update public.sales_orders
|
||||||
|
set rental_type = 'individuell'
|
||||||
|
where coalesce(total_days, 0) > 2
|
||||||
|
and coalesce(lower(trim(rental_type)), '') not in ('individuell', 'weekend', 'single_day');
|
||||||
|
|
||||||
|
notify pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- 17-vehicle-photos.sql
|
||||||
|
-- Idempotent migration: add vehicle_photos table for multiple photos per vehicle.
|
||||||
|
-- Each vehicle can have multiple photos with ordering support.
|
||||||
|
|
||||||
|
-- Create vehicle_photos table
|
||||||
|
create table if not exists public.vehicle_photos (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
vehicle_id uuid not null references public.vehicles(id) on delete cascade,
|
||||||
|
photo_url text not null default '',
|
||||||
|
photo_path text not null,
|
||||||
|
display_order integer not null default 0,
|
||||||
|
is_primary boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists vehicle_photos_vehicle_id_idx
|
||||||
|
on public.vehicle_photos(vehicle_id, display_order);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
alter table public.vehicle_photos enable row level security;
|
||||||
|
|
||||||
|
-- Drop existing policies to ensure idempotency
|
||||||
|
drop policy if exists "vehicle_photos_public_read" on public.vehicle_photos;
|
||||||
|
drop policy if exists "vehicle_photos_admin_read" on public.vehicle_photos;
|
||||||
|
drop policy if exists "vehicle_photos_admin_insert" on public.vehicle_photos;
|
||||||
|
drop policy if exists "vehicle_photos_admin_delete" on public.vehicle_photos;
|
||||||
|
drop policy if exists "vehicle_photos_admin_update" on public.vehicle_photos;
|
||||||
|
|
||||||
|
-- Public can read all photos
|
||||||
|
create policy "vehicle_photos_public_read"
|
||||||
|
on public.vehicle_photos for select
|
||||||
|
to anon using (true);
|
||||||
|
|
||||||
|
-- Authenticated (admin) full access
|
||||||
|
create policy "vehicle_photos_admin_read"
|
||||||
|
on public.vehicle_photos for select
|
||||||
|
to authenticated using (true);
|
||||||
|
|
||||||
|
create policy "vehicle_photos_admin_insert"
|
||||||
|
on public.vehicle_photos for insert
|
||||||
|
to authenticated with check (true);
|
||||||
|
|
||||||
|
create policy "vehicle_photos_admin_update"
|
||||||
|
on public.vehicle_photos for update
|
||||||
|
to authenticated using (true) with check (true);
|
||||||
|
|
||||||
|
create policy "vehicle_photos_admin_delete"
|
||||||
|
on public.vehicle_photos for delete
|
||||||
|
to authenticated using (true);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
grant select on public.vehicle_photos to anon, authenticated;
|
||||||
|
grant insert, update, delete on public.vehicle_photos to authenticated;
|
||||||
|
grant all on public.vehicle_photos to service_role;
|
||||||
|
|
||||||
|
-- Migrate existing vehicle photo_url/photo_path to vehicle_photos table
|
||||||
|
-- This ensures existing vehicles get their photo into the new table
|
||||||
|
insert into public.vehicle_photos (vehicle_id, photo_url, photo_path, display_order, is_primary)
|
||||||
|
select id, photo_url, coalesce(photo_path, 'legacy'), 0, true
|
||||||
|
from public.vehicles
|
||||||
|
where photo_url != '' and photo_path is not null
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
-- RPC: set primary photo for a vehicle (unsets others)
|
||||||
|
create or replace function public.set_primary_vehicle_photo(
|
||||||
|
p_vehicle_id uuid,
|
||||||
|
p_photo_id uuid
|
||||||
|
) returns void
|
||||||
|
language plpgsql security invoker as $$
|
||||||
|
begin
|
||||||
|
update public.vehicle_photos set is_primary = false where vehicle_id = p_vehicle_id;
|
||||||
|
update public.vehicle_photos set is_primary = true where id = p_photo_id and vehicle_id = p_vehicle_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- RPC: re-order photos for a vehicle
|
||||||
|
create or replace function public.reorder_vehicle_photos(
|
||||||
|
p_vehicle_id uuid,
|
||||||
|
p_photo_orders jsonb -- [{id: uuid, order: int}, ...]
|
||||||
|
) returns void
|
||||||
|
language plpgsql security invoker as $$
|
||||||
|
declare
|
||||||
|
rec jsonb;
|
||||||
|
begin
|
||||||
|
for rec in select * from jsonb_array_elements(p_photo_orders) loop
|
||||||
|
update public.vehicle_photos
|
||||||
|
set display_order = (rec->>'order')::int
|
||||||
|
where id = (rec->>'id')::uuid and vehicle_id = p_vehicle_id;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Booking Flow End-to-End', () => {
|
||||||
|
const ADMIN_URL = 'http://localhost:55581';
|
||||||
|
const ADMIN_EMAIL = 'admin@mccars.local';
|
||||||
|
const ADMIN_PASSWORD = 'mc-cars-admin';
|
||||||
|
|
||||||
|
// Generate unique test data per run to avoid conflicts
|
||||||
|
const ts = Date.now();
|
||||||
|
const testEmails = [
|
||||||
|
`test-day-${ts}@playwright.test`,
|
||||||
|
`test-weekend-${ts}@playwright.test`,
|
||||||
|
`test-custom-${ts}@playwright.test`,
|
||||||
|
];
|
||||||
|
const testNames = [
|
||||||
|
'Test Testerson Day',
|
||||||
|
'Test Testerson Weekend',
|
||||||
|
'Test Testerson Custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: fill out the booking form for a given mietdauer type.
|
||||||
|
* Returns nothing - the form submission is handled by the page's JS.
|
||||||
|
*/
|
||||||
|
async function submitBooking(page, type, index) {
|
||||||
|
// Scroll to booking section
|
||||||
|
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Step 1: Select vehicle
|
||||||
|
const carSelect = page.locator('#bpfCar');
|
||||||
|
await expect(carSelect).toBeVisible({ timeout: 10000 });
|
||||||
|
// Select first available vehicle option (skip the placeholder)
|
||||||
|
const options = await carSelect.locator('option').all();
|
||||||
|
expect(options.length).toBeGreaterThan(1);
|
||||||
|
const firstVehicle = await options[1].innerText();
|
||||||
|
await carSelect.selectOption({ label: firstVehicle });
|
||||||
|
|
||||||
|
// Step 2: Select mietdauer type
|
||||||
|
const presetBtn = page.locator(`.bpf-preset[data-preset="${type}"]`);
|
||||||
|
await expect(presetBtn).toBeVisible();
|
||||||
|
await presetBtn.click();
|
||||||
|
|
||||||
|
// Step 3: Pick date(s) based on type
|
||||||
|
if (type === 'day') {
|
||||||
|
// Pick a date 7 days from now
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + 7);
|
||||||
|
const dateStr = futureDate.toISOString().split('T')[0];
|
||||||
|
const dateInput = page.locator('#bpfDayDate');
|
||||||
|
await dateInput.fill(dateStr);
|
||||||
|
} else if (type === 'weekend') {
|
||||||
|
// Pick next Saturday
|
||||||
|
const nextSaturday = new Date();
|
||||||
|
const daysUntilSaturday = (6 - nextSaturday.getDay() + 7) % 7 || 7;
|
||||||
|
nextSaturday.setDate(nextSaturday.getDate() + daysUntilSaturday);
|
||||||
|
const dateStr = nextSaturday.toISOString().split('T')[0];
|
||||||
|
const dateInput = page.locator('#bpfWeekendDate');
|
||||||
|
await dateInput.fill(dateStr);
|
||||||
|
} else if (type === 'custom') {
|
||||||
|
// Pick start date 14 days from now, end date 17 days from now (4 days = individuell)
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(startDate.getDate() + 14);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setDate(endDate.getDate() + 3);
|
||||||
|
const fromStr = startDate.toISOString().split('T')[0];
|
||||||
|
const toStr = endDate.toISOString().split('T')[0];
|
||||||
|
await page.locator('#bpfFrom').fill(fromStr);
|
||||||
|
await page.locator('#bpfTo').fill(toStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click Weiter to go to step 2
|
||||||
|
await page.locator('#bpfNext1').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Step 2: Fill contact info
|
||||||
|
await expect(page.locator('#bpfName')).toBeVisible();
|
||||||
|
await page.locator('#bpfName').fill(testNames[index]);
|
||||||
|
await page.locator('#bpfEmail').fill(testEmails[index]);
|
||||||
|
await page.locator('#bpfPhone').fill('+43 660 1234567');
|
||||||
|
await page.locator('#bpfMessage').fill(`Test booking via playwright - ${type}`);
|
||||||
|
|
||||||
|
// Click Weiter to go to step 3
|
||||||
|
await page.locator('#bpfNext2').click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Step 3: Submit (skip file uploads - they are optional)
|
||||||
|
await expect(page.locator('#bpfSubmit')).toBeVisible();
|
||||||
|
await page.locator('#bpfSubmit').click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(page.locator('#toast.show')).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Complete booking flow: 1 Tag, Wochenende, Individuell → 3 leads in admin → disqualify all', async ({ page, context }) => {
|
||||||
|
// ========================================
|
||||||
|
// PART 1: Submit 3 bookings on main site
|
||||||
|
// ========================================
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Booking 1: 1 Tag
|
||||||
|
await submitBooking(page, 'day', 0);
|
||||||
|
|
||||||
|
// Booking 2: Wochenende
|
||||||
|
await submitBooking(page, 'weekend', 1);
|
||||||
|
|
||||||
|
// Booking 3: Individuell
|
||||||
|
await submitBooking(page, 'custom', 2);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PART 2: Verify 3 leads in admin panel
|
||||||
|
// ========================================
|
||||||
|
const adminCtx = await test.info().project.use.baseBrowserType?.newContext() ?? context;
|
||||||
|
const adminPage = await adminCtx.newPage();
|
||||||
|
adminPage.setDefaultTimeout(30000);
|
||||||
|
|
||||||
|
await adminPage.goto(ADMIN_URL);
|
||||||
|
await adminPage.waitForLoadState('domcontentloaded');
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Login
|
||||||
|
const loginForm = adminPage.locator('#loginForm');
|
||||||
|
await expect(loginForm).toBeVisible({ timeout: 10000 });
|
||||||
|
await adminPage.locator('#loginForm [name="email"]').fill(ADMIN_EMAIL);
|
||||||
|
await adminPage.locator('#loginForm [name="password"]').fill(ADMIN_PASSWORD);
|
||||||
|
await adminPage.locator('#loginForm [type="submit"]').click();
|
||||||
|
|
||||||
|
// Wait a moment for login to process
|
||||||
|
await adminPage.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check for login error
|
||||||
|
const loginError = adminPage.locator('#loginError');
|
||||||
|
if (await loginError.isVisible()) {
|
||||||
|
const errorMsg = await loginError.textContent();
|
||||||
|
throw new Error(`Login failed: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if password rotation is required (first login)
|
||||||
|
const rotateView = adminPage.locator('#rotateView');
|
||||||
|
if (await rotateView.isVisible({ timeout: 2000 })) {
|
||||||
|
// Set a new password (must be different from bootstrap)
|
||||||
|
const newPw = 'Playwright-Test-PW-2026!';
|
||||||
|
await adminPage.locator('#rotateForm [name="pw1"]').fill(newPw);
|
||||||
|
await adminPage.locator('#rotateForm [name="pw2"]').fill(newPw);
|
||||||
|
await adminPage.locator('#rotateForm [type="submit"]').click();
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for admin view to load
|
||||||
|
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Ensure leads tab is active (it's the default)
|
||||||
|
const leadsTab = adminPage.locator('[data-tab="leads"]');
|
||||||
|
const leadsTabClass = await leadsTab.getAttribute('class');
|
||||||
|
if (!leadsTabClass?.includes('active')) {
|
||||||
|
await leadsTab.click();
|
||||||
|
await adminPage.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for our test leads to appear by checking for their emails in the table
|
||||||
|
// We wait for at least one of our test emails to appear, then verify all 3
|
||||||
|
await adminPage.waitForFunction(
|
||||||
|
([emails]) => {
|
||||||
|
const rows = document.querySelectorAll('#leadsTable tbody tr');
|
||||||
|
let found = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const text = row.textContent;
|
||||||
|
for (const email of emails) {
|
||||||
|
if (text.includes(email)) {
|
||||||
|
found++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found >= 3;
|
||||||
|
},
|
||||||
|
testEmails,
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await adminPage.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Find our test leads by email pattern
|
||||||
|
const allRows = adminPage.locator('#leadsTable tbody tr');
|
||||||
|
const totalRows = await allRows.count();
|
||||||
|
const testRowIndices = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalRows; i++) {
|
||||||
|
const row = allRows.nth(i);
|
||||||
|
const rowText = await row.textContent();
|
||||||
|
if (testEmails.some(email => rowText.includes(email))) {
|
||||||
|
testRowIndices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(testRowIndices.length).toBe(3);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PART 3: Disqualify all 3 test leads
|
||||||
|
// ========================================
|
||||||
|
// Disqualify each lead one at a time, re-finding it after each disqualification
|
||||||
|
// since the table re-renders and indices shift.
|
||||||
|
for (const email of testEmails) {
|
||||||
|
// Find the row for this email
|
||||||
|
const rows = adminPage.locator('#leadsTable tbody tr');
|
||||||
|
const count = await rows.count();
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const rowText = await rows.nth(i).textContent();
|
||||||
|
if (rowText.includes(email)) {
|
||||||
|
// Click disqualify button
|
||||||
|
const disqBtn = rows.nth(i).locator('[data-disq]');
|
||||||
|
if (await disqBtn.isVisible()) {
|
||||||
|
await disqBtn.click();
|
||||||
|
await adminPage.waitForTimeout(1500);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(found).toBe(true, `Lead with email ${email} not found or could not be disqualified`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for disqualifications to process
|
||||||
|
await adminPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Refresh page to ensure fresh data after disqualifications
|
||||||
|
await adminPage.reload();
|
||||||
|
await expect(adminPage.locator('#adminView')).toBeVisible({ timeout: 15000 });
|
||||||
|
await adminPage.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Verify our test leads are now disqualified (no longer in active view)
|
||||||
|
const remainingRows = adminPage.locator('#leadsTable tbody tr');
|
||||||
|
const remainingCount = await remainingRows.count();
|
||||||
|
let foundTestLead = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < remainingCount; i++) {
|
||||||
|
const rowText = await remainingRows.nth(i).textContent();
|
||||||
|
if (testEmails.some(email => rowText.includes(email))) {
|
||||||
|
foundTestLead = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(foundTestLead).toBe(false);
|
||||||
|
|
||||||
|
await adminPage.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Legal Pages - Warum wir removed', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Impressum page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/impressum.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AGB page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/agb.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Datenschutz page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/datenschutz.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mietbedingungen page - Warum wir nav link removed', async ({ page }) => {
|
||||||
|
await page.goto('/mietbedingungen.html');
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('All legal pages - other nav links present', async ({ page }) => {
|
||||||
|
await page.goto('/impressum.html');
|
||||||
|
const nav = page.getByLabel('Hauptnavigation');
|
||||||
|
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('MC Cars - Customer Changes Verification', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Page loads successfully', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/MC Cars/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - Flotte ansehen button removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Flotte ansehen')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('View fleet')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - 24/7 Support stat removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('24/7')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - Faire Kaution stat still visible', async ({ page }) => {
|
||||||
|
const kautionStat = page.getByText('Faire Kaution', { exact: true });
|
||||||
|
await expect(kautionStat).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero section - Fahrzeuge stat still visible', async ({ page }) => {
|
||||||
|
const vehiclesSection = page.locator('.hero-stats');
|
||||||
|
await expect(vehiclesSection).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fleet section - Unsere Flotte eyebrow removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Unsere Flotte')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('Our Fleet')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fleet section - Title still visible', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Handverlesen. Gepflegt. Startklar.')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigation - Warum wir link removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Warum wir')).not.toBeVisible();
|
||||||
|
await expect(page.getByText('Why us')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigation - Other links still present', async ({ page }) => {
|
||||||
|
const nav = page.getByLabel('Hauptnavigation');
|
||||||
|
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Stimmen' })).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
|
||||||
|
await expect(nav.getByRole('link', { name: 'Jetzt buchen' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reviews - Ferrari references in reviews', async ({ page }) => {
|
||||||
|
await page.locator('#stimmen').scrollIntoViewIfNeeded();
|
||||||
|
const reviewsSection = page.locator('#stimmen');
|
||||||
|
await expect(reviewsSection).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Reviews - GT3 references removed', async ({ page }) => {
|
||||||
|
await expect(page.getByText('GT3')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Footer - correct content', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Rechtliches')).toBeVisible();
|
||||||
|
await expect(page.getByText('Impressum')).toBeVisible();
|
||||||
|
await expect(page.getByText('Datenschutz')).toBeVisible();
|
||||||
|
await expect(page.getByText('hello@mc-cars.at')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Footer - Steiermark reference updated', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Made in Steiermark')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Language toggle works', async ({ page }) => {
|
||||||
|
const langToggle = page.locator('.lang-toggle');
|
||||||
|
await expect(langToggle).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to English
|
||||||
|
await langToggle.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(langToggle).toHaveText('DE');
|
||||||
|
await expect(page.getByText('Drive at the highest level.')).toBeVisible();
|
||||||
|
|
||||||
|
// Switch back to German
|
||||||
|
await langToggle.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(langToggle).toHaveText('EN');
|
||||||
|
await expect(page.getByRole('heading', { name: /Niveau/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fleet section - vehicle cards visible', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const vehicleCards = page.locator('.vehicle-card');
|
||||||
|
await expect(vehicleCards.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Booking section visible', async ({ page }) => {
|
||||||
|
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Jetzt buchen' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEO title updated', async ({ page }) => {
|
||||||
|
const title = await page.title();
|
||||||
|
expect(title).toContain('Ferrari');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Photo Gallery Feature', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vehicle cards render correctly', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const cards = page.locator('.vehicle-card');
|
||||||
|
await expect(cards.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vehicle card has photo', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const firstPhoto = page.locator('.vehicle-card').first().locator('.vehicle-photo img');
|
||||||
|
await expect(firstPhoto).toBeVisible();
|
||||||
|
const src = await firstPhoto.getAttribute('src');
|
||||||
|
expect(src).not.toBeNull();
|
||||||
|
expect(src).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Vehicle details dialog opens', async ({ page }) => {
|
||||||
|
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
|
||||||
|
const detailsBtn = page.locator('[data-details]').first();
|
||||||
|
if (await detailsBtn.isVisible()) {
|
||||||
|
await detailsBtn.click();
|
||||||
|
const dialog = page.locator('#carDialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Booking wizard - vehicle selector works', async ({ page }) => {
|
||||||
|
await page.locator('#buchen').scrollIntoViewIfNeeded();
|
||||||
|
const carSelect = page.locator('#bpfCar');
|
||||||
|
await expect(carSelect).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user