From 8be7d5aad26c3212d3f7044d90012716ea761c3d Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Sun, 31 May 2026 09:53:23 +0200 Subject: [PATCH 1/6] feat: implement Marco's customer changes - Remove 'Flotte ansehen' button from hero section - Remove '24/7 Support' stat from hero section - Remove 'Unsere Flotte' eyebrow from fleet section - Remove ALL 'Warum wir' / 'Why us' references from nav links, i18n keys, and legal pages - Update reviews: Ferrari references only (removed GT3 mentions) - Update Impressum with correct company data (MC Cars GmbH) - Add multi-photo gallery: DB migration (17-vehicle-photos.sql), admin UI for photo management, frontend carousel on cards and dialog - Update SEO: Ferrari-focused meta tags, title, keywords, JSON-LD - Clean up dead i18n keys (viewFleet, statSupport, fleetEyebrow, navWhy, why* keys) - Fix legal page issues: add config.js script, fix logo references to SVG - Add Playwright E2E tests (26/26 passing) - Update footer tagline across all pages --- docker-compose.local.yml | 1 + docker-compose.yml | 2 + frontend/admin.html | 10 +- frontend/admin.js | 125 +++++++++++++++++++ frontend/agb.html | 16 ++- frontend/app.js | 110 ++++++++++++++++- frontend/datenschutz.html | 17 ++- frontend/i18n.js | 44 ++----- frontend/impressum.html | 23 ++-- frontend/index.html | 27 ++--- frontend/mietbedingungen.html | 16 ++- frontend/styles.css | 140 ++++++++++++++++++++++ playwright.config.js | 21 ++++ supabase/migrations/17-vehicle-photos.sql | 91 ++++++++++++++ test-results/.last-run.json | 2 +- tests/legal-pages.spec.js | 36 ++++++ tests/marco-changes.spec.js | 109 +++++++++++++++++ tests/photo-gallery.spec.js | 41 +++++++ 18 files changed, 734 insertions(+), 97 deletions(-) create mode 100644 playwright.config.js create mode 100644 supabase/migrations/17-vehicle-photos.sql create mode 100644 tests/legal-pages.spec.js create mode 100644 tests/marco-changes.spec.js create mode 100644 tests/photo-gallery.spec.js diff --git a/docker-compose.local.yml b/docker-compose.local.yml index af5b4e3..5725c15 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -31,6 +31,7 @@ services: - ./supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro - ./supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro - ./supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro + - ./supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro kong: volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 60eaa29..a806b35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -224,6 +224,7 @@ services: - /mnt/user/appdata/mc-cars/supabase/migrations/14-email-requested-trigger.sql:/sql/14-email-requested-trigger.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/15-individuell-vat-subtotal-fix.sql:/sql/15-individuell-vat-subtotal-fix.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/16-rental-type-weekend-gap-fix.sql:/sql/16-rental-type-weekend-gap-fix.sql:ro + - /mnt/user/appdata/mc-cars/supabase/migrations/17-vehicle-photos.sql:/sql/17-vehicle-photos.sql:ro entrypoint: ["sh","-c"] command: - | @@ -256,6 +257,7 @@ services: psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/14-email-requested-trigger.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/15-individuell-vat-subtotal-fix.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/16-rental-type-weekend-gap-fix.sql + psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/17-vehicle-photos.sql echo "post-init done." restart: "no" networks: [mccars] diff --git a/frontend/admin.html b/frontend/admin.html index b569349..d688560 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -172,11 +172,19 @@
+
+ +
+
+
diff --git a/frontend/admin.js b/frontend/admin.js index 5cfb203..bdd997c 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -53,6 +53,8 @@ const saveBtn = document.querySelector("#saveBtn"); const resetBtn = document.querySelector("#resetBtn"); const photoInput = document.querySelector("#photoInput"); const photoPreview = document.querySelector("#photoPreview"); +const extraPhotoInput = document.querySelector("#extraPhotoInput"); +const extraPhotoGallery = document.querySelector("#extraPhotoGallery"); const tableBody = document.querySelector("#adminTable tbody"); // ----- State ----- @@ -66,6 +68,7 @@ const state = { vehicles: [], vehicleMap: new Map(), currentPhotoPath: null, + vehiclePhotos: [], realtimeChannel: null, forcedRotation: false, }; @@ -322,6 +325,7 @@ function loadForEdit(id) { vehicleForm.is_active.checked = v.is_active; state.currentPhotoPath = v.photo_path || null; updatePreview(v.photo_url); + loadVehiclePhotos(v.id); window.scrollTo({ top: 0, behavior: "smooth" }); } @@ -337,6 +341,7 @@ resetBtn.addEventListener("click", () => { vehicleForm.kaution_eur.value = 5000; vehicleForm.price_per_km_eur.value = 1.50; state.currentPhotoPath = null; + state.vehiclePhotos = []; updatePreview(""); formTitle.textContent = "Neues Fahrzeug"; formFeedback.textContent = ""; @@ -390,7 +395,13 @@ async function deleteVehicle(id) { const v = state.vehicleMap.get(id); if (!v) return; if (!confirm(`Delete ${v.brand} ${v.model}?`)) return; + // Delete old main photo if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]); + // Delete gallery photos from storage + const { data: photos } = await supabase.from("vehicle_photos").select("photo_path").eq("vehicle_id", id); + if (photos?.length) { + await supabase.storage.from("vehicle-photos").remove(photos.map(p => p.photo_path)); + } const { error } = await supabase.from("vehicles").delete().eq("id", id); if (error) { alert(error.message); return; } await loadVehicles(); @@ -426,6 +437,120 @@ photoInput.addEventListener("change", async () => { }); function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; } +// ----- Vehicle Photo Gallery ----- +async function loadVehiclePhotos(vehicleId) { + if (!vehicleId) { + state.vehiclePhotos = []; + renderExtraPhotoGallery(); + return; + } + const { data, error } = await supabase + .from("vehicle_photos") + .select("*") + .eq("vehicle_id", vehicleId) + .order("display_order", { ascending: true }); + if (error) { console.error("Failed to load vehicle photos:", error); return; } + state.vehiclePhotos = data || []; + renderExtraPhotoGallery(); +} + +function renderExtraPhotoGallery() { + if (!extraPhotoGallery) return; + extraPhotoGallery.innerHTML = ""; + for (const ph of state.vehiclePhotos) { + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;border-radius:8px;overflow:hidden;aspect-ratio:16/10;background:#1a1a1a;"; + wrapper.innerHTML = ` + +
+ ${!ph.is_primary ? `` : ''} + +
+ ${ph.is_primary ? 'Hauptfoto' : ''} + `; + extraPhotoGallery.appendChild(wrapper); + } + + // Event listeners + extraPhotoGallery.querySelectorAll("[data-delete-photo]").forEach(btn => { + btn.addEventListener("click", async () => { + const phId = btn.dataset.deletePhoto; + await deleteVehiclePhoto(phId); + }); + }); + extraPhotoGallery.querySelectorAll("[data-set-primary]").forEach(btn => { + btn.addEventListener("click", async () => { + const phId = btn.dataset.setPrimary; + await setPrimaryPhoto(phId); + }); + }); +} + +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); + renderExtraPhotoGallery(); + } catch (err) { + console.error("Failed to delete photo:", err); + } +} + +async function setPrimaryPhoto(photoId) { + const vid = vehicleForm.vid?.value; + if (!vid) return; + try { + await supabase.rpc("set_primary_vehicle_photo", { p_vehicle_id: vid, p_photo_id: photoId }); + await loadVehiclePhotos(vid); + } catch (err) { + console.error("Failed to set primary photo:", err); + } +} + +// Extra photos upload +extraPhotoInput?.addEventListener("change", async () => { + const files = extraPhotoInput.files; + if (!files.length) return; + const vid = vehicleForm.vid?.value; + if (!vid) { + formFeedback.className = "form-feedback error"; + formFeedback.textContent = "Bitte zuerst Fahrzeug speichern, dann Fotos hinzufügen."; + return; + } + formFeedback.className = "form-feedback"; + formFeedback.textContent = "Uploading photos..."; + for (const file of files) { + try { + const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); + const path = `${vid}/${crypto.randomUUID()}.${ext}`; + const { error: upErr } = await supabase.storage + .from("vehicle-photos") + .upload(path, file, { contentType: file.type, upsert: true }); + if (upErr) throw upErr; + const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path); + const 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: state.vehiclePhotos.length === 0, + }); + } catch (err) { + console.error("Upload failed:", err); + } + } + await loadVehiclePhotos(vid); + formFeedback.textContent = `${files.length} Foto(s) hochgeladen.`; + extraPhotoInput.value = ""; +}); + // ========================================================================= // LEADS // ========================================================================= diff --git a/frontend/agb.html b/frontend/agb.html index fd2fcd2..5f5b1b8 100644 --- a/frontend/agb.html +++ b/frontend/agb.html @@ -4,8 +4,8 @@ AGB · MC Cars - - + + @@ -51,13 +51,12 @@ -
-
+
+

Impressum

MC Cars GmbH

Gaisfeld 1/2
8564 Krottendorf-Gaisfeld

FN 675751 b · Landesgericht für Zivilrechtssachen Graz

Geschäftsführer: Christian Leski, Marco Schober

+

E-Mail: hello@mc-cars.at
Telefon: +43 316 880000

UID-Nr. wird in Kürze nachgereicht.

-

E-Mail: hello@mc-cars.at

-

Telefon: +43 316 880000

-
+
+

Datenschutzerklärung (Kurzfassung)

+

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.

+

Welche Daten wir erfassen: 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.

+

Wie wir Ihre Daten verwenden: 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.

+

Weitergabe an Dritte: 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.

+

Ihre Rechte: 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.

+

Weitere Informationen finden Sie in unserer vollständigen Datenschutzerklärung.

+
From 331d0557b0c1d14e56164e59216149ac3767b083 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Sun, 31 May 2026 10:57:15 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20complete=20Datenschutzerkl=C3=A4run?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full rewrite from stub to legally compliant privacy policy - Covers: Server-Logfiles, Buchungsanfragen, Identitätsdokumente, Cookies - Legal basis: Art. 6 DSGVO (lit. b, c, f) - Self-hosted infrastructure — no third-party data sharing - User rights: Art. 15-20, 77 DSGVO (Auskunft, Berichtigung, Löschung, etc.) - Aufsichtsbehörde: Österreichische Datenschutzbehörde - No analytics, no Google services, no cloud providers --- frontend/agb.html | 1 - frontend/datenschutz.html | 95 ++++++++++++++++++++++++++++++++--- frontend/impressum.html | 3 +- frontend/index.html | 1 - frontend/mietbedingungen.html | 1 - 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/frontend/agb.html b/frontend/agb.html index 75f32c0..7631d47 100644 --- a/frontend/agb.html +++ b/frontend/agb.html @@ -110,7 +110,6 @@

Kontakt

hello@mc-cars.at - +43 316 880000
diff --git a/frontend/datenschutz.html b/frontend/datenschutz.html index d9f9dc0..2f1daf6 100644 --- a/frontend/datenschutz.html +++ b/frontend/datenschutz.html @@ -63,11 +63,95 @@
-
-

Datenschutz

-
-

Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden über ein selbstgehostetes Supabase verwaltet.

-

Ansprechpartner: hello@mc-cars.at

+
+

Datenschutzerklärung

+ +
+

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.

+ +

Verantwortlicher für die Datenverarbeitung

+

MC Cars GmbH
Gaisfeld 1/2, 8564 Krottendorf-Gaisfeld
E-Mail: hello@mc-cars.at

+ +

Daten, die wir verarbeiten

+ +

Server-Logfiles

+

Beim Besuch unserer Website werden automatisch Informationen in Server-Logfiles gespeichert, die Ihr Browser an uns übermittelt. Dies sind:

+
    +
  • Browsertyp und Browserversion
  • +
  • Verwendetes Betriebssystem
  • +
  • Referrer URL (die zuvor besuchte Seite)
  • +
  • Hostname des zugreifenden Rechners
  • +
  • Uhrzeit der Serveranfrage
  • +
  • IP-Adresse
  • +
+

Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.

+ +

Buchungsanfragen

+

Wenn Sie unser Buchungsformular nutzen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen gespeichert. Dies umfasst:

+
    +
  • Name
  • +
  • E-Mail-Adresse
  • +
  • Telefonnummer
  • +
  • Gewähltes Fahrzeug und Mietzeitraum
  • +
  • Nachricht / Anmerkungen
  • +
+

Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.

+ +

Identitätsdokumente

+

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.

+ +

Cookies und lokale Speicherung

+

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.

+ +

Zweck der Datenverarbeitung

+

Die Verarbeitung Ihrer personenbezogenen Daten erfolgt zu folgenden Zwecken:

+
    +
  • Zur Bereitstellung, Optimierung und Weiterentwicklung unserer Website
  • +
  • Zur Bearbeitung Ihrer Buchungsanfragen
  • +
  • Zur Identitätsprüfung und Bonitätsprüfung
  • +
  • Zur Gewährleistung der Sicherheit und Funktionsfähigkeit unserer Website
  • +
  • Zur Erfüllung gesetzlicher Verpflichtungen
  • +
+ +

Rechtsgrundlage der Verarbeitung

+

Die Verarbeitung Ihrer personenbezogenen Daten erfolgt auf folgenden Rechtsgrundlagen:

+
    +
  • Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen (Art. 6 Abs. 1 lit. b DSGVO) – bei der Bearbeitung Ihrer Buchungsanfragen und der Verarbeitung Ihrer Identitätsdokumente
  • +
  • Erfüllung einer rechtlichen Verpflichtung (Art. 6 Abs. 1 lit. c DSGVO) – z.B. aufgrund gesetzlicher Aufbewahrungsfristen
  • +
  • Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO) – zur Gewährleistung der Sicherheit, der Funktionsfähigkeit und der Optimierung unserer Website
  • +
+ +

Datenhosting

+

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.

+ +

Übermittlung Ihrer Daten

+

Eine Übermittlung Ihrer personenbezogenen Daten an Dritte erfolgt grundsätzlich nicht, es sei denn:

+
    +
  • Dies ist zur Erfüllung unserer vertraglichen Pflichten erforderlich
  • +
  • Wir sind gesetzlich dazu verpflichtet
  • +
  • Sie haben ausdrücklich eingewilligt
  • +
+ +

Speicherdauer

+

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.

+ +

Ihre Rechte

+

Sie haben hinsichtlich Ihrer bei uns gespeicherten personenbezogenen Daten folgende Rechte:

+
    +
  • Recht auf Auskunft (Art. 15 DSGVO): Sie können Auskunft darüber verlangen, ob und welche personenbezogenen Daten von Ihnen verarbeitet werden.
  • +
  • Recht auf Berichtigung (Art. 16 DSGVO): Sie können die Berichtigung unrichtiger oder die Vervollständigung unvollständiger Daten verlangen.
  • +
  • Recht auf Löschung (Art. 17 DSGVO): Sie können die Löschung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.
  • +
  • Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO): Sie können die Einschränkung der Verarbeitung Ihrer Daten verlangen, sofern die gesetzlichen Voraussetzungen dafür vorliegen.
  • +
  • Recht auf Datenübertragbarkeit (Art. 20 DSGVO): Sie haben das Recht, Ihre bereitgestellten Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten.
  • +
  • Recht auf Widerspruch (Art. 21 DSGVO): Sie können gegen die Verarbeitung Ihrer Daten Widerspruch einlegen.
  • +
  • Recht auf Beschwerde (Art. 77 DSGVO): Sie können sich bei der zuständigen Aufsichtsbehörde beschweren.
  • +
+ +

Kontaktdaten der Aufsichtsbehörde

+

Österreichische Datenschutzbehörde
Barichgasse 40-42, 1030 Wien, Österreich
Telefon: +43 1 52 152-0
E-Mail: dsb@dsb.gv.at

+ +

Änderungen dieser Datenschutzerklärung

+

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.

@@ -100,7 +184,6 @@

Kontakt

hello@mc-cars.at - +43 316 880000
diff --git a/frontend/impressum.html b/frontend/impressum.html index ae82ab5..173646e 100644 --- a/frontend/impressum.html +++ b/frontend/impressum.html @@ -70,7 +70,7 @@

Gaisfeld 1/2
8564 Krottendorf-Gaisfeld

FN 675751 b · Landesgericht für Zivilrechtssachen Graz

Geschäftsführer: Christian Leski, Marco Schober

-

E-Mail: hello@mc-cars.at
Telefon: +43 316 880000

+

E-Mail: hello@mc-cars.at

UID-Nr. wird in Kürze nachgereicht.

@@ -113,7 +113,6 @@

Kontakt

hello@mc-cars.at - +43 316 880000
diff --git a/frontend/index.html b/frontend/index.html index 87a2177..928df50 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -388,7 +388,6 @@

Kontakt

hello@mc-cars.at - +43 316 880000
diff --git a/frontend/mietbedingungen.html b/frontend/mietbedingungen.html index 92849fd..550c0d2 100644 --- a/frontend/mietbedingungen.html +++ b/frontend/mietbedingungen.html @@ -110,7 +110,6 @@

Kontakt

hello@mc-cars.at - +43 316 880000
From 28db85245364d78f3a6b630eba7addcbfd408904 Mon Sep 17 00:00:00 2001 From: Jose Lago Date: Sun, 31 May 2026 11:29:59 +0200 Subject: [PATCH 6/6] test: add end-to-end booking flow with admin lead disqualification --- tests/booking-flow.spec.js | 255 +++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 tests/booking-flow.spec.js diff --git a/tests/booking-flow.spec.js b/tests/booking-flow.spec.js new file mode 100644 index 0000000..7bc4f37 --- /dev/null +++ b/tests/booking-flow.spec.js @@ -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(); + }); +});