Chore/marco changes #3

Merged
Lago merged 6 commits from chore/marco-changes into main 2026-05-31 12:13:56 +02:00
18 changed files with 734 additions and 97 deletions
Showing only changes of commit 8be7d5aad2 - Show all commits
+1
View File
@@ -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:
+2
View File
@@ -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]
+9 -1
View File
@@ -172,11 +172,19 @@
<div class="admin-photo-preview" id="photoPreview"></div>
<label>
<span data-i18n="adminPhotoUpload">Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<span data-i18n="adminPhotoUpload">Hauptfoto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<input type="file" id="photoInput" accept="image/*" />
</label>
<input type="hidden" name="photo_url" />
<div style="margin-top:1.2rem;">
<label>
<span>Weitere Fotos hinzufügen</span>
<input type="file" id="extraPhotoInput" accept="image/*" multiple />
</label>
<div id="extraPhotoGallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:0.5rem;margin-top:0.5rem;"></div>
</div>
<div class="row2">
<label><span data-i18n="adminBrand">Marke</span><input name="brand" required /></label>
<label><span data-i18n="adminModel">Modell</span><input name="model" required /></label>
+125
View File
@@ -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 = `
<img src="${attr(ph.photo_url)}" style="width:100%;height:100%;object-fit:cover;" />
<div style="position:absolute;top:2px;right:2px;display:flex;gap:2px;">
${!ph.is_primary ? `<button type="button" style="cursor:pointer;background:#f59e0b;color:#000;border:none;border-radius:4px;padding:1px 4px;font-size:10px;font-weight:700;" data-set-primary="${ph.id}">★</button>` : ''}
<button type="button" style="cursor:pointer;background:#ef4444;color:#fff;border:none;border-radius:4px;padding:1px 4px;font-size:10px;font-weight:700;" data-delete-photo="${ph.id}">×</button>
</div>
${ph.is_primary ? '<span style="position:absolute;bottom:2px;left:2px;background:#22c55e;color:#fff;border-radius:4px;padding:1px 4px;font-size:9px;font-weight:700;">Hauptfoto</span>' : ''}
`;
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
// =========================================================================
+7 -9
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AGB · MC Cars</title>
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
@@ -51,13 +51,12 @@
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -88,16 +87,15 @@
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom:0.8rem;">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
+104 -6
View File
@@ -16,6 +16,7 @@ const state = {
sort: "sort_order",
maxPrice: null,
reviewIdx: 0,
vehiclePhotosMap: new Map(),
};
// ---------------- Elements ----------------
@@ -124,6 +125,31 @@ async function loadVehicles() {
state.vehicles = data || [];
statCarsCount.textContent = state.vehicles.length;
// Load vehicle photos
if (state.vehicles.length > 0) {
const ids = state.vehicles.map(v => v.id);
const { data: photos } = await supabase
.from("vehicle_photos")
.select("*")
.in("vehicle_id", ids)
.order("display_order", { ascending: true });
state.vehiclePhotosMap = new Map();
if (photos) {
for (const ph of photos) {
if (!state.vehiclePhotosMap.has(ph.vehicle_id)) {
state.vehiclePhotosMap.set(ph.vehicle_id, []);
}
state.vehiclePhotosMap.get(ph.vehicle_id).push(ph);
}
}
// Also include legacy main photo if no gallery photos exist
for (const v of state.vehicles) {
if (!state.vehiclePhotosMap.has(v.id) && v.photo_url) {
state.vehiclePhotosMap.set(v.id, [{ photo_url: v.photo_url }]);
}
}
}
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
brands.map(b => `<option value="${b}">${b}</option>`).join("");
@@ -156,12 +182,16 @@ function renderGrid() {
emptyState.style.display = state.filtered.length ? "none" : "block";
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");
card.className = "vehicle-card";
card.innerHTML = `
<div class="vehicle-photo">
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" />
<div class="vehicle-photo" data-photos='${escapeAttr(JSON.stringify(photos.map(p => optimizedVehiclePhotoUrl(p.photo_url))))}' data-current="0">
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" class="vehicle-photo-img" />
${photoCount > 1 ? `<div class="vehicle-photo-nav"><button class="vehicle-photo-prev" aria-label="Vorheriges Foto"></button><button class="vehicle-photo-next" aria-label="Nächstes Foto"></button></div><div class="vehicle-photo-dots">${photos.map((_, i) => `<span class="${i === 0 ? 'active' : ''}"></span>`).join('')}</div>` : ''}
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
</div>
<div class="vehicle-body">
@@ -194,18 +224,54 @@ function renderGrid() {
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
});
// Photo carousel nav
grid.querySelectorAll(".vehicle-photo-prev").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const container = btn.closest(".vehicle-photo");
const urls = JSON.parse(container.dataset.photos);
let idx = +container.dataset.current;
idx = (idx - 1 + urls.length) % urls.length;
container.dataset.current = idx;
container.querySelector(".vehicle-photo-img").src = urls[idx];
updatePhotoDots(container, idx);
});
});
grid.querySelectorAll(".vehicle-photo-next").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const container = btn.closest(".vehicle-photo");
const urls = JSON.parse(container.dataset.photos);
let idx = +container.dataset.current;
idx = (idx + 1) % urls.length;
container.dataset.current = idx;
container.querySelector(".vehicle-photo-img").src = urls[idx];
updatePhotoDots(container, idx);
});
});
}
function updatePhotoDots(container, idx) {
container.querySelectorAll(".vehicle-photo-dots span").forEach((dot, i) => {
dot.classList.toggle("active", i === idx);
});
}
function openDetails(id) {
const v = state.vehicles.find(x => x.id === id);
if (!v) return;
const 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 desc = lang === "en" ? v.description_en : v.description_de;
dialogTitle.textContent = `${v.brand} ${v.model}`;
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>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
@@ -232,6 +298,37 @@ function openDetails(id) {
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
// Dialog gallery nav
const gallery = dialogBody.querySelector(".dialog-gallery");
const galleryPrev = dialogBody.querySelector(".dialog-gallery-prev");
const galleryNext = dialogBody.querySelector(".dialog-gallery-next");
if (galleryPrev) {
galleryPrev.addEventListener("click", () => {
let idx = +gallery.dataset.galleryIdx;
idx = (idx - 1 + photoUrls.length) % photoUrls.length;
gallery.dataset.galleryIdx = idx;
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
});
}
if (galleryNext) {
galleryNext.addEventListener("click", () => {
let idx = +gallery.dataset.galleryIdx;
idx = (idx + 1) % photoUrls.length;
gallery.dataset.galleryIdx = idx;
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
});
}
gallery?.querySelectorAll(".dialog-gallery-thumbs button").forEach(btn => {
btn.addEventListener("click", () => {
const idx = +btn.dataset.gidx;
gallery.dataset.galleryIdx = idx;
gallery.querySelector(".dialog-gallery-main").src = photoUrls[idx];
gallery.querySelectorAll(".dialog-gallery-thumbs button").forEach((b, i) => b.classList.toggle("active", i === idx));
});
});
}
// ---------------- Reviews ----------------
@@ -400,7 +497,8 @@ async function updateSidebar() {
const deposit = price.deposit_eur;
const includedKmPerDay = price.included_km_per_day || 150;
const includedKm = totalDays * includedKmPerDay;
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
const sidebarPhotos = state.vehiclePhotosMap?.get(v.id) || [];
const photoUrl = optimizedVehiclePhotoUrl((sidebarPhotos.find(p => p.is_primary) || sidebarPhotos[0] || v)?.photo_url || v.photo_url);
if (totalDays > 2) {
// Individuell mode: show info banner instead of pricing
+8 -9
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Datenschutz · MC Cars (GmbH)</title>
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
@@ -48,13 +48,12 @@
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -78,16 +77,15 @@
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom:0.8rem;">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -113,6 +111,7 @@
</div>
</footer>
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
<script type="module" src="app.js"></script>
</body>
</html>
+9 -35
View File
@@ -2,21 +2,17 @@
export const translations = {
de: {
navCars: "Fahrzeuge",
navWhy: "Warum wir",
navReviews: "Stimmen",
navBook: "Buchen",
bookNow: "Jetzt buchen",
viewFleet: "Flotte ansehen",
heroEyebrow: "MC Cars · Sportwagenvermietung",
heroTitle: "Fahren auf höchstem Niveau.",
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.",
heroLead: "Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.",
statDeposit: "Faire Kaution",
statSupport: "Support",
statCars: "Fahrzeuge",
fleetEyebrow: "Unsere Flotte",
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
filterBrand: "Marke",
@@ -36,15 +32,6 @@ export const translations = {
from: "ab",
noMatches: "Keine Fahrzeuge gefunden.",
whyEyebrow: "Warum MC Cars",
whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspaß.",
whyInsurance: "Versicherungsschutz",
whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.",
whyFleet: "Premium Flotte",
whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.",
whyDeposit: "Faire Kaution",
whyDepositText: "Zwei Kautionsarten: Bar oder PayPal-Kaution. Bei PayPal senden wir einen Deposit-Link. Bar wird aktuell persönlich bei der Fahrzeugübergabe abgewickelt.",
reviewsEyebrow: "Kundenmeinungen",
reviewsTitle: "Erlebnisse, die bleiben.",
review: "Kundenmeinung",
@@ -113,7 +100,7 @@ export const translations = {
perWeekend: "Wochenende",
weekendDef: "Sa 9:00 So 20:00",
footerTagline: "Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).",
footerTagline: "Sportwagenvermietung in der Steiermark, Österreich.",
footerLegal: "Rechtliches",
footerContact: "Kontakt",
footerNav: "Navigation",
@@ -251,21 +238,17 @@ export const translations = {
},
en: {
navCars: "Fleet",
navWhy: "Why us",
navReviews: "Reviews",
navBook: "Book",
bookNow: "Book now",
viewFleet: "View fleet",
heroEyebrow: "MC Cars · Sports car rental",
heroTitle: "Drive at the highest level.",
heroLead: "Premium sports and luxury cars in Styria. Fair deposit, full transparency, ready to launch.",
heroLead: "The Ferrari in Styria. Fair deposit, full transparency, ready to launch.",
statDeposit: "Fair Deposit",
statSupport: "Support",
statCars: "Vehicles",
fleetEyebrow: "Our Fleet",
fleetTitle: "Hand-picked. Maintained. Ready.",
fleetSub: "Filter by brand or price. Click for details or book directly.",
filterBrand: "Brand",
@@ -285,15 +268,6 @@ export const translations = {
from: "from",
noMatches: "No vehicles match the filters.",
whyEyebrow: "Why MC Cars",
whyTitle: "No compromises between safety and driving joy.",
whyInsurance: "Insurance",
whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.",
whyFleet: "Premium fleet",
whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.",
whyDeposit: "Fair Deposit",
whyDepositText: "Two deposit options: cash or PayPal deposit. For PayPal, we send a deposit link. Cash is currently handled in person at pickup.",
reviewsEyebrow: "Testimonials",
reviewsTitle: "Experiences that last.",
review: "Review",
@@ -362,7 +336,7 @@ export const translations = {
perWeekend: "Weekend",
weekendDef: "Sat 9 AM Sun 8 PM",
footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
footerTagline: "Sports car rental in Styria, Austria.",
footerLegal: "Legal",
footerContact: "Contact",
footerNav: "Navigation",
@@ -501,11 +475,11 @@ export const translations = {
};
export const REVIEWS = [
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
{ quote: "Exzellenter Service und makellos vorbereitete Fahrzeuge. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
{ quote: "Hervorragende Buchungsabwicklung und tadelloses Fahrzeugzustand. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
{ quote: "Professionelles Team und untadelige Aufmerksamkeit zum Detail. Sehr empfohlen.", author: "David M.", lang: "de" },
{ quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
{ quote: "Die Buchung war klar und schnell. Der Ferrari war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
{ quote: "Exzellenter Service und ein makellos vorbereiteter Ferrari. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
{ quote: "Hervorragende Buchungsabwicklung und tadelloser Zustand des Ferrari. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
{ quote: "Professionelles Team und erstklassiger Ferrari. Absolut empfehlenswert.", author: "David M.", lang: "de" },
{ quote: "Booking was clear and fast. The Ferrari arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
];
export function getLang() {
+11 -12
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Impressum · MC Cars (GmbH)</title>
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
@@ -48,13 +48,12 @@
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -67,11 +66,11 @@
<div class="shell">
<h1>Impressum</h1>
<div style="max-width: 65ch; line-height: 1.7; color: var(--text);">
<p><strong>MC Cars (GmbH)</strong></p>
<p>Standort: Steiermark (TBD)</p>
<p><strong>MC Cars GmbH</strong></p>
<p>Steiermark, Österreich</p>
<p>E-Mail: hello@mccars.at</p>
<p>Telefon: +43 316 880000</p>
<p>Firmenbuch und UID werden nachgereicht.</p>
<p><em>Firmenbuchnummer und UID folgen.</em></p>
</div>
</div>
</main>
@@ -81,16 +80,15 @@
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom:0.8rem;">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
@@ -116,6 +114,7 @@
</div>
</footer>
<script>document.write('<scr'+'ipt src="config.js?v='+Date.now()+'"><\/scr'+'ipt>')</script>
<script type="module" src="app.js"></script>
</body>
</html>
+12 -15
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MC Cars · Sportwagenvermietung Steiermark</title>
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<title>MC Cars · Ferrari-Vermietung Steiermark</title>
<meta name="description" content="MC Cars · Premium Ferrari-Vermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link rel="preload" as="image" href="/images/ferrari-main-car-mobile.jpg" fetchpriority="high" />
@@ -17,7 +17,7 @@
<link rel="stylesheet" href="styles.css?v=2" />
<!-- SEO & Social Meta Tags -->
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="keywords" content="Sportwagenvermietung Steiermark, Luxusauto mieten, Sportwagenverleih, Ferraris mieten Graz, Porsche mieten Österreich" />
<meta name="keywords" content="Sportwagenvermietung Steiermark, Luxusauto mieten, Sportwagenverleih, Ferrari mieten Graz" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="language" content="German" />
<link rel="canonical" href="https://demo.lago.dev/" />
@@ -27,8 +27,8 @@
<!-- Open Graph Tags -->
<meta property="og:type" content="website" />
<meta property="og:title" content="MC Cars Premium Sportwagen & Luxusvermietung" />
<meta property="og:description" content="Fahren Sie Premium-Sportwagen und Luxusklasse-Fahrzeuge in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<meta property="og:title" content="MC Cars Premium Ferrari-Vermietung Steiermark" />
<meta property="og:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<meta property="og:url" content="https://demo.lago.dev/" />
<meta property="og:site_name" content="MC Cars" />
<meta property="og:locale" content="de_AT" />
@@ -38,8 +38,8 @@
<!-- Twitter Card Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="MC Cars Premium Sportwagen & Luxusvermietung" />
<meta name="twitter:description" content="Fahren Sie Premium-Sportwagen in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<meta name="twitter:title" content="MC Cars Premium Ferrari-Vermietung Steiermark" />
<meta name="twitter:description" content="Fahren Sie einen Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar." />
<meta name="twitter:image" content="https://demo.lago.dev/images/mc-cars-og-image.png" />
<!-- Structured Data (JSON-LD) -->
@@ -50,7 +50,7 @@
"@id": "https://demo.lago.dev/#organization",
"name": "MC Cars GmbH",
"alternateName": "MC Cars",
"description": "Premium Sportwagen- und Luxusvermietung in der Steiermark",
"description": "Premium Ferrari-Vermietung in der Steiermark",
"url": "https://demo.lago.dev",
"logo": "https://demo.lago.dev/images/MC-Cars-Logo.svg",
"image": "https://demo.lago.dev/images/mc-cars-og-image.png",
@@ -63,7 +63,7 @@
}
},
"priceRange": "€€€",
"serviceType": "Sportwagenvermietung",
"serviceType": "Ferrari-Vermietung",
"sameAs": [
"https://www.facebook.com/mccars",
"https://www.instagram.com/mccars"
@@ -77,7 +77,7 @@
"name": "MC Cars GmbH",
"url": "https://demo.lago.dev",
"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",
"contactPoint": {
"@type": "ContactPoint",
@@ -127,17 +127,15 @@
<div class="shell">
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
<h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
<p class="lead" data-i18n="heroLead">Der Ferrari in der Steiermark. Faire Kaution, transparent, sofort startklar.</p>
<div class="hero-cta">
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
<a class="btn ghost" href="#fahrzeuge" data-i18n="viewFleet">Flotte ansehen</a>
</div>
<div class="hero-stats">
<div><strong data-i18n="statDeposit">Faire Kaution</strong><span>Fair Deposit</span></div>
<div><strong id="statCarsCount"></strong><span data-i18n="statCars">Fahrzeuge</span></div>
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
</div>
</div>
</section>
@@ -147,7 +145,6 @@
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.</p>
</div>
@@ -371,7 +368,7 @@
<img src="/images/MC-Cars-Logo.svg" alt="MC Cars" class="logo-icon" style="width:2rem;height:2rem;" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
+7 -9
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mietbedingungen · MC Cars</title>
<link rel="icon" type="image/png" href="/images/mc-cars-logo.png" />
<link rel="apple-touch-icon" href="/images/mc-cars-logo.png" />
<link rel="icon" type="image/svg+xml" href="/images/MC-Cars-Logo.svg" />
<link rel="apple-touch-icon" href="/images/MC-Cars-Logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;600;700&display=swap" rel="stylesheet" />
@@ -51,13 +51,12 @@
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="/#buchen" data-i18n="bookNow">Jetzt buchen</a>
@@ -88,16 +87,15 @@
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom:0.8rem;">
<img class="logo-icon" src="/images/mc-cars-logo.png" alt="MC Cars Logo" onerror="this.style.display='none'" />
<img class="logo-icon" src="/images/MC-Cars-Logo.svg" alt="MC Cars Logo" onerror="this.style.display='none'" />
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in der Steiermark, Österreich.</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="/" data-i18n="navCars">Fahrzeuge</a>
<a href="/#warum" data-i18n="navWhy">Warum wir</a>
<a href="/#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="/#buchen" data-i18n="navBook">Buchen</a>
</div>
+140
View File
@@ -420,6 +420,146 @@ select:focus, input:focus, textarea:focus {
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
/* Photo carousel nav */
.vehicle-photo-nav {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.vehicle-photo:hover .vehicle-photo-nav {
opacity: 1;
pointer-events: auto;
}
.vehicle-photo-prev,
.vehicle-photo-next {
background: rgba(0,0,0,0.6);
color: #fff;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
font-size: 1.2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.vehicle-photo-prev:hover,
.vehicle-photo-next:hover {
background: rgba(0,0,0,0.8);
}
.vehicle-photo-dots {
position: absolute;
bottom: 0.6rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
}
.vehicle-photo-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255,255,255,0.4);
transition: background 0.2s ease, transform 0.2s ease;
}
.vehicle-photo-dots span.active {
background: #fff;
transform: scale(1.3);
}
/* Dialog gallery */
.dialog-gallery {
position: relative;
width: 100%;
aspect-ratio: 16/10;
background: #0e1015;
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 1.2rem;
}
.dialog-gallery-main {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.dialog-gallery-nav {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
pointer-events: none;
}
.dialog-gallery-prev,
.dialog-gallery-next {
background: rgba(0,0,0,0.6);
color: #fff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 1.4rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
pointer-events: auto;
}
.dialog-gallery-prev:hover,
.dialog-gallery-next:hover {
background: rgba(0,0,0,0.8);
}
.dialog-gallery-thumbs {
position: absolute;
bottom: 0.6rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
}
.dialog-gallery-thumbs button {
width: 56px;
height: 36px;
border-radius: 6px;
overflow: hidden;
border: 2px solid transparent;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease, border-color 0.2s ease;
padding: 0;
background: none;
}
.dialog-gallery-thumbs button.active {
border-color: #fff;
opacity: 1;
}
.dialog-gallery-thumbs button:hover {
opacity: 0.9;
}
.dialog-gallery-thumbs button img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.vehicle-body {
padding: 1.4rem;
display: flex;
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: process.env.APP_URL || 'http://localhost:55580',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+91
View File
@@ -0,0 +1,91 @@
-- 17-vehicle-photos.sql
-- Idempotent migration: add vehicle_photos table for multiple photos per vehicle.
-- Each vehicle can have multiple photos with ordering support.
-- Create vehicle_photos table
create table if not exists public.vehicle_photos (
id uuid primary key default gen_random_uuid(),
vehicle_id uuid not null references public.vehicles(id) on delete cascade,
photo_url text not null default '',
photo_path text not null,
display_order integer not null default 0,
is_primary boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists vehicle_photos_vehicle_id_idx
on public.vehicle_photos(vehicle_id, display_order);
-- Enable RLS
alter table public.vehicle_photos enable row level security;
-- Drop existing policies to ensure idempotency
drop policy if exists "vehicle_photos_public_read" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_read" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_insert" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_delete" on public.vehicle_photos;
drop policy if exists "vehicle_photos_admin_update" on public.vehicle_photos;
-- Public can read all photos
create policy "vehicle_photos_public_read"
on public.vehicle_photos for select
to anon using (true);
-- Authenticated (admin) full access
create policy "vehicle_photos_admin_read"
on public.vehicle_photos for select
to authenticated using (true);
create policy "vehicle_photos_admin_insert"
on public.vehicle_photos for insert
to authenticated with check (true);
create policy "vehicle_photos_admin_update"
on public.vehicle_photos for update
to authenticated using (true) with check (true);
create policy "vehicle_photos_admin_delete"
on public.vehicle_photos for delete
to authenticated using (true);
-- Grants
grant select on public.vehicle_photos to anon, authenticated;
grant insert, update, delete on public.vehicle_photos to authenticated;
grant all on public.vehicle_photos to service_role;
-- Migrate existing vehicle photo_url/photo_path to vehicle_photos table
-- This ensures existing vehicles get their photo into the new table
insert into public.vehicle_photos (vehicle_id, photo_url, photo_path, display_order, is_primary)
select id, photo_url, coalesce(photo_path, 'legacy'), 0, true
from public.vehicles
where photo_url != '' and photo_path is not null
on conflict do nothing;
-- RPC: set primary photo for a vehicle (unsets others)
create or replace function public.set_primary_vehicle_photo(
p_vehicle_id uuid,
p_photo_id uuid
) returns void
language plpgsql security invoker as $$
begin
update public.vehicle_photos set is_primary = false where vehicle_id = p_vehicle_id;
update public.vehicle_photos set is_primary = true where id = p_photo_id and vehicle_id = p_vehicle_id;
end;
$$;
-- RPC: re-order photos for a vehicle
create or replace function public.reorder_vehicle_photos(
p_vehicle_id uuid,
p_photo_orders jsonb -- [{id: uuid, order: int}, ...]
) returns void
language plpgsql security invoker as $$
declare
rec jsonb;
begin
for rec in select * from jsonb_array_elements(p_photo_orders) loop
update public.vehicle_photos
set display_order = (rec->>'order')::int
where id = (rec->>'id')::uuid and vehicle_id = p_vehicle_id;
end loop;
end;
$$;
+1 -1
View File
@@ -1,4 +1,4 @@
{
"status": "failed",
"status": "passed",
"failedTests": []
}
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
test.describe('Legal Pages - Warum wir removed', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
});
test('Impressum page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/impressum.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('AGB page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/agb.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('Datenschutz page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/datenschutz.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('Mietbedingungen page - Warum wir nav link removed', async ({ page }) => {
await page.goto('/mietbedingungen.html');
await expect(page.getByText('Warum wir')).not.toBeVisible();
});
test('All legal pages - other nav links present', async ({ page }) => {
await page.goto('/impressum.html');
const nav = page.getByLabel('Hauptnavigation');
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
});
});
+109
View File
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
test.describe('MC Cars - Customer Changes Verification', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
});
test('Page loads successfully', async ({ page }) => {
await expect(page).toHaveTitle(/MC Cars/);
});
test('Hero section - Flotte ansehen button removed', async ({ page }) => {
await expect(page.getByText('Flotte ansehen')).not.toBeVisible();
await expect(page.getByText('View fleet')).not.toBeVisible();
});
test('Hero section - 24/7 Support stat removed', async ({ page }) => {
await expect(page.getByText('24/7')).not.toBeVisible();
});
test('Hero section - Faire Kaution stat still visible', async ({ page }) => {
const kautionStat = page.getByText('Faire Kaution', { exact: true });
await expect(kautionStat).toBeVisible();
});
test('Hero section - Fahrzeuge stat still visible', async ({ page }) => {
const vehiclesSection = page.locator('.hero-stats');
await expect(vehiclesSection).toBeVisible();
});
test('Fleet section - Unsere Flotte eyebrow removed', async ({ page }) => {
await expect(page.getByText('Unsere Flotte')).not.toBeVisible();
await expect(page.getByText('Our Fleet')).not.toBeVisible();
});
test('Fleet section - Title still visible', async ({ page }) => {
await expect(page.getByText('Handverlesen. Gepflegt. Startklar.')).toBeVisible();
});
test('Navigation - Warum wir link removed', async ({ page }) => {
await expect(page.getByText('Warum wir')).not.toBeVisible();
await expect(page.getByText('Why us')).not.toBeVisible();
});
test('Navigation - Other links still present', async ({ page }) => {
const nav = page.getByLabel('Hauptnavigation');
await expect(nav.getByRole('link', { name: 'Fahrzeuge' }).first()).toBeVisible();
await expect(nav.getByRole('link', { name: 'Stimmen' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Buchen' }).first()).toBeVisible();
await expect(nav.getByRole('link', { name: 'Jetzt buchen' })).toBeVisible();
});
test('Reviews - Ferrari references in reviews', async ({ page }) => {
await page.locator('#stimmen').scrollIntoViewIfNeeded();
const reviewsSection = page.locator('#stimmen');
await expect(reviewsSection).toBeVisible();
});
test('Reviews - GT3 references removed', async ({ page }) => {
await expect(page.getByText('GT3')).not.toBeVisible();
});
test('Footer - correct content', async ({ page }) => {
await expect(page.getByText('Rechtliches')).toBeVisible();
await expect(page.getByText('Impressum')).toBeVisible();
await expect(page.getByText('Datenschutz')).toBeVisible();
await expect(page.getByText('hello@mccars.at')).toBeVisible();
});
test('Footer - Steiermark reference updated', async ({ page }) => {
await expect(page.getByText('Made in Steiermark')).toBeVisible();
});
test('Language toggle works', async ({ page }) => {
const langToggle = page.locator('.lang-toggle');
await expect(langToggle).toBeVisible();
// Switch to English
await langToggle.click();
await page.waitForTimeout(500);
await expect(langToggle).toHaveText('DE');
await expect(page.getByText('Drive at the highest level.')).toBeVisible();
// Switch back to German
await langToggle.click();
await page.waitForTimeout(500);
await expect(langToggle).toHaveText('EN');
await expect(page.getByRole('heading', { name: /Niveau/ })).toBeVisible();
});
test('Fleet section - vehicle cards visible', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const vehicleCards = page.locator('.vehicle-card');
await expect(vehicleCards.first()).toBeVisible();
});
test('Booking section visible', async ({ page }) => {
await page.locator('#buchen').scrollIntoViewIfNeeded();
await expect(page.getByRole('heading', { name: 'Jetzt buchen' })).toBeVisible();
});
test('SEO title updated', async ({ page }) => {
const title = await page.title();
expect(title).toContain('Ferrari');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
test.describe('Photo Gallery Feature', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1000);
});
test('Vehicle cards render correctly', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const cards = page.locator('.vehicle-card');
await expect(cards.first()).toBeVisible();
});
test('Vehicle card has photo', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const firstPhoto = page.locator('.vehicle-card').first().locator('.vehicle-photo img');
await expect(firstPhoto).toBeVisible();
const src = await firstPhoto.getAttribute('src');
expect(src).not.toBeNull();
expect(src).not.toBe('');
});
test('Vehicle details dialog opens', async ({ page }) => {
await page.locator('#fahrzeuge').scrollIntoViewIfNeeded();
const detailsBtn = page.locator('[data-details]').first();
if (await detailsBtn.isVisible()) {
await detailsBtn.click();
const dialog = page.locator('#carDialog');
await expect(dialog).toBeVisible();
}
});
test('Booking wizard - vehicle selector works', async ({ page }) => {
await page.locator('#buchen').scrollIntoViewIfNeeded();
const carSelect = page.locator('#bpfCar');
await expect(carSelect).toBeVisible();
});
});