Chore/marco changes #3
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,4 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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@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');
|
||||
});
|
||||
});
|
||||
@@ -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