Files
mc_cars_gmbh_infraestructure/frontend/app.js
T
LagoESP 4c1931cdf4 feat: update upload functionality and permissions for document handling
- Removed the `upsert` option from the file upload in `uploadDoc` function to prevent unintended overwrites.
- Enhanced German translations in `i18n.js` for better clarity and consistency in the admin interface.
- Added new CSS styles for link interactions to improve user experience in `styles.css`.
- Updated Supabase SQL migration to grant additional permissions for anonymous users to insert and update storage objects, ensuring proper functionality during the booking flow.

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 20:09:27 +02:00

542 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js";
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
export const supabase = createClient(SUPA_URL, SUPA_KEY, {
auth: { persistSession: false, storageKey: "mccars.public" },
});
// ---------------- State ----------------
const state = {
vehicles: [],
filtered: [],
brand: "all",
sort: "sort_order",
maxPrice: null,
reviewIdx: 0,
};
// ---------------- Elements ----------------
const grid = document.querySelector("#vehicleGrid");
const emptyState = document.querySelector("#emptyState");
const brandFilter = document.querySelector("#brandFilter");
const sortFilter = document.querySelector("#sortFilter");
const priceFilter = document.querySelector("#priceFilter");
const langToggle = document.querySelector(".lang-toggle");
const menuToggle = document.querySelector(".menu-toggle");
const mainNav = document.querySelector(".main-nav");
const dialog = document.querySelector("#carDialog");
const dialogTitle = document.querySelector("#dialogTitle");
const dialogBody = document.querySelector("#dialogBody");
const dialogClose = document.querySelector("#dialogClose");
const reviewStrip = document.querySelector("#reviewStrip");
const reviewDots = document.querySelector("#reviewDots");
const statCarsCount = document.querySelector("#statCarsCount");
const bookingFeedback = document.querySelector("#bookingFeedback");
// BPF elements
const bpfCar = document.querySelector("#bpfCar");
const bpfFrom = document.querySelector("#bpfFrom");
const bpfTo = document.querySelector("#bpfTo");
const bpfDayDate = document.querySelector("#bpfDayDate");
const bpfWeekendDate = document.querySelector("#bpfWeekendDate");
const bpfName = document.querySelector("#bpfName");
const bpfEmail = document.querySelector("#bpfEmail");
const bpfPhone = document.querySelector("#bpfPhone");
const bpfMessage = document.querySelector("#bpfMessage");
const bpfFileId = document.querySelector("#bpfFileId");
const bpfFileIncome = document.querySelector("#bpfFileIncome");
const bpfSubmitBtn = document.querySelector("#bpfSubmit");
const bpfSidebar = document.querySelector("#bpfSidebar");
const bpfSidebarContent = document.querySelector("#bpfSidebarContent");
const bpfSidebarPlaceholder = document.querySelector(".bpf-sidebar-placeholder");
let bpfDurationMode = "custom"; // "day" | "weekend" | "custom"
let bpfSubmitting = false;
function formatYmdLocal(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmdLocal(ymd) {
const [y, m, d] = (ymd || "").split("-").map(Number);
if (!y || !m || !d) return null;
return new Date(y, m - 1, d);
}
function addDaysYmd(ymd, days) {
const d = parseYmdLocal(ymd);
if (!d) return null;
d.setDate(d.getDate() + days);
return formatYmdLocal(d);
}
function isSaturdayYmd(ymd) {
const d = parseYmdLocal(ymd);
return !!d && d.getDay() === 6;
}
function nextSaturdayYmd(ymd) {
const d = parseYmdLocal(ymd);
if (!d) return null;
const delta = (6 - d.getDay() + 7) % 7;
d.setDate(d.getDate() + delta);
return formatYmdLocal(d);
}
// Set min dates
const today = formatYmdLocal(new Date());
[bpfFrom, bpfTo, bpfDayDate, bpfWeekendDate].forEach(el => {
if (el) el.min = today;
});
document.querySelector("#year").textContent = new Date().getFullYear();
// ---------------- Vehicles ----------------
async function loadVehicles() {
const { data, error } = await supabase
.from("vehicles")
.select("*")
.eq("is_active", true)
.order("sort_order", { ascending: true });
if (error) {
console.error("Failed to load vehicles", error);
grid.innerHTML = `<p style="color:var(--danger);">Unable to load vehicles: ${error.message}</p>`;
return;
}
state.vehicles = data || [];
statCarsCount.textContent = state.vehicles.length;
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("");
// Populate BPF car selector
bpfCar.innerHTML = `<option value="">${t("bpfSelectVehicle")}</option>` +
state.vehicles.map(v => `<option value="${v.id}">${v.brand} ${v.model}</option>`).join("");
applyFilters();
}
function applyFilters() {
let rows = [...state.vehicles];
if (state.brand !== "all") rows = rows.filter(v => v.brand === state.brand);
if (state.maxPrice) rows = rows.filter(v => v.daily_price_eur <= state.maxPrice);
switch (state.sort) {
case "priceAsc": rows.sort((a, b) => a.daily_price_eur - b.daily_price_eur); break;
case "priceDesc": rows.sort((a, b) => b.daily_price_eur - a.daily_price_eur); break;
case "powerDesc": rows.sort((a, b) => b.power_hp - a.power_hp); break;
default: rows.sort((a, b) => a.sort_order - b.sort_order);
}
state.filtered = rows;
renderGrid();
}
function renderGrid() {
grid.innerHTML = "";
emptyState.style.display = state.filtered.length ? "none" : "block";
for (const v of state.filtered) {
const card = document.createElement("article");
card.className = "vehicle-card";
card.innerHTML = `
<div class="vehicle-photo" role="img" aria-label="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" style="background-image:url('${escapeAttr(v.photo_url)}');">
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
</div>
<div class="vehicle-body">
<p class="model-brand" aria-hidden="true">${escapeHtml(v.brand)}</p>
<h3>${escapeHtml(v.model)}</h3>
<div class="spec-row">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div class="vehicle-footer">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<div style="display:flex;gap:0.4rem;">
<button class="btn ghost small" data-details="${v.id}" aria-label="${t("details")} ${escapeAttr(v.brand)} ${escapeAttr(v.model)}">${t("details")}</button>
<button class="btn small" data-book="${v.id}" aria-label="${t("book")} ${escapeAttr(v.brand)} ${escapeAttr(v.model)}">${t("book")}</button>
</div>
</div>
</div>
`;
grid.appendChild(card);
}
grid.querySelectorAll("[data-details]").forEach(b => {
b.addEventListener("click", () => openDetails(b.dataset.details));
});
grid.querySelectorAll("[data-book]").forEach(b => {
b.addEventListener("click", () => {
bpfCar.value = b.dataset.book;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
});
}
function openDetails(id) {
const v = state.vehicles.find(x => x.id === id);
if (!v) return;
const lang = getLang();
const desc = lang === "en" ? v.description_en : v.description_de;
dialogTitle.textContent = `${v.brand} ${v.model}`;
dialogBody.innerHTML = `
<img src="${escapeAttr(v.photo_url)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
<p>${escapeHtml(desc || "")}</p>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
<div><strong>${v.max_daily_km || 150}</strong><span>${t("bpfMaxKm")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem;">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<button class="btn" id="dialogBook">${t("bookNow")}</button>
</div>
`;
dialog.showModal();
document.querySelector("#dialogBook").addEventListener("click", () => {
dialog.close();
bpfCar.value = v.id;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
}
// ---------------- Reviews ----------------
function renderReviews() {
state.reviewIdx = state.reviewIdx % REVIEWS.length;
const r = REVIEWS[state.reviewIdx];
reviewStrip.innerHTML = `
<p class="review-quote">"${escapeHtml(r.quote)}"</p>
<p class="review-author">${escapeHtml(r.author)}</p>
`;
reviewDots.innerHTML = REVIEWS.map((_, i) =>
`<button class="${i === state.reviewIdx ? 'active' : ''}" data-rev="${i}" aria-label="${t("review")} ${i + 1}"></button>`
).join("");
reviewDots.querySelectorAll("button").forEach(b => {
b.addEventListener("click", () => { state.reviewIdx = +b.dataset.rev; renderReviews(); });
});
}
setInterval(() => { state.reviewIdx++; renderReviews(); }, 5000);
// ---------------- BPF WIZARD ----------------
const bpfStepPanels = [
document.querySelector("#bpfStep1"),
document.querySelector("#bpfStep2"),
document.querySelector("#bpfStep3"),
];
const bpfStepButtons = document.querySelectorAll(".bpf-step");
let bpfCurrentStep = 1;
function showBpfStep(n) {
bpfCurrentStep = n;
bpfStepPanels.forEach((p, i) => p.style.display = (i === n - 1) ? "block" : "none");
bpfStepButtons.forEach((b, i) => {
b.classList.toggle("active", i === n - 1);
b.classList.toggle("done", i < n - 1);
});
}
document.querySelector("#bpfNext1").addEventListener("click", () => {
if (!bpfCar.value) { bpfCar.focus(); return; }
// Resolve effective from/to based on duration mode
const { from, to } = getBpfDates();
if (!from || !to || new Date(to) <= new Date(from)) {
bookingFeedback.textContent = bpfDurationMode === "weekend" ? t("weekendSaturdayOnly") : t("invalidDates");
bookingFeedback.className = "form-feedback error";
return;
}
// Sync hidden from/to for downstream use
bpfFrom.value = from;
bpfTo.value = to;
bookingFeedback.textContent = "";
showBpfStep(2);
});
document.querySelector("#bpfBack2").addEventListener("click", () => showBpfStep(1));
document.querySelector("#bpfNext2").addEventListener("click", () => {
if (!bpfName.value || !bpfEmail.value) { bpfEmail.focus(); return; }
showBpfStep(3);
});
document.querySelector("#bpfBack3").addEventListener("click", () => showBpfStep(2));
// File upload display
bpfFileId.addEventListener("change", () => {
document.querySelector("#bpfFileIdName").textContent = bpfFileId.files[0]?.name || "";
});
bpfFileIncome.addEventListener("change", () => {
document.querySelector("#bpfFileIncomeName").textContent = bpfFileIncome.files[0]?.name || "";
});
// ---------------- Duration Presets ----------------
function setDurationMode(mode) {
bpfDurationMode = mode;
document.querySelectorAll(".bpf-preset").forEach(b => b.classList.toggle("active", b.dataset.preset === mode));
document.querySelector("#bpfDateDay").style.display = mode === "day" ? "block" : "none";
document.querySelector("#bpfDateWeekend").style.display = mode === "weekend" ? "block" : "none";
document.querySelector("#bpfDateCustom").style.display = mode === "custom" ? "block" : "none";
updateSidebar();
}
document.querySelectorAll(".bpf-preset").forEach(btn => {
btn.addEventListener("click", () => setDurationMode(btn.dataset.preset));
});
// Restrict weekend picker to Saturdays only
function enforceWeekendSaturday() {
if (!bpfWeekendDate.value) return;
if (!isSaturdayYmd(bpfWeekendDate.value)) {
bpfWeekendDate.value = "";
bookingFeedback.className = "form-feedback error";
bookingFeedback.textContent = t("weekendSaturdayOnly");
bpfWeekendDate.focus();
updateSidebar();
return;
}
bookingFeedback.textContent = "";
updateSidebar();
}
bpfWeekendDate.addEventListener("input", enforceWeekendSaturday);
bpfWeekendDate.addEventListener("change", enforceWeekendSaturday);
bpfDayDate.addEventListener("change", () => {
updateSidebar();
});
function getBpfDates() {
if (bpfDurationMode === "day") {
const d = bpfDayDate.value;
if (!d) return { from: null, to: null };
return { from: d, to: addDaysYmd(d, 1) };
}
if (bpfDurationMode === "weekend") {
const sat = bpfWeekendDate.value;
if (!sat) return { from: null, to: null };
if (!isSaturdayYmd(sat)) return { from: null, to: null };
return { from: sat, to: addDaysYmd(sat, 2) };
}
// custom
return { from: bpfFrom.value, to: bpfTo.value };
}
// Pricing calculation
function calcWeekendDays(from, to) {
// Count Saturday and Sunday days in a date interval
let count = 0;
const start = parseYmdLocal(from);
const end = parseYmdLocal(to);
if (!start || !end) return 0;
const cur = new Date(start);
while (cur < end) {
const day = cur.getDay(); // 0=Sun, 6=Sat
if (day === 6 || day === 0) count++;
cur.setDate(cur.getDate() + 1);
}
return count;
}
function updateSidebar() {
const v = state.vehicles.find(x => x.id === bpfCar.value);
const { from, to } = getBpfDates();
if (!v || !from || !to) {
bpfSidebarContent.style.display = "none";
bpfSidebarPlaceholder.style.display = "block";
return;
}
const fromD = parseYmdLocal(from);
const toD = parseYmdLocal(to);
if (!fromD || !toD) return;
if (toD <= fromD) return;
const totalDays = Math.ceil((toD - fromD) / (1000 * 60 * 60 * 24));
const weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to);
const weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays);
const weekdayCost = weekdays * v.daily_price_eur;
const weekendCost = weekendDays * (v.weekend_price_eur || v.daily_price_eur);
const subtotal = weekdayCost + weekendCost;
const vat = Math.round(subtotal * 0.20);
const total = subtotal + vat;
const deposit = v.kaution_eur || 5000;
const kmPerWeekendDay = v.max_km_weekend || v.max_daily_km || 150;
const kmPerWeekday = v.max_daily_km || 150;
const includedKm = (weekdays * kmPerWeekday) + (weekendDays * kmPerWeekendDay);
bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-price-row"><span>${v.brand} ${v.model} · ${totalDays} ${t("bpfDays")}</span></div>
${weekdays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekdays")} (${weekdays} ×${v.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} ×${v.weekend_price_eur || v.daily_price_eur})</span><span>€ ${weekendCost.toLocaleString("de-DE")}</span></div>` : ""}
<div class="bpf-price-row"><span>${t("bpfSubtotal")}</span><span>€ ${subtotal.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfVat")}</span><span>€ ${vat.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row total"><span>${t("bpfTotal")}</span><span>€ ${total.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted" style="margin-top:0.8rem;"><span>${t("bpfDeposit")}</span><span>€ ${deposit.toLocaleString("de-DE")}</span></div>
<div class="bpf-price-row muted"><span>${t("bpfIncludedKm")}</span><span>${includedKm} km</span></div>
<div class="bpf-price-row muted"><span>${t("bpfExtraKm")}</span><span>€ 1,50${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(v.photo_url)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
}
bpfCar.addEventListener("change", updateSidebar);
bpfFrom.addEventListener("change", updateSidebar);
bpfTo.addEventListener("change", updateSidebar);
// Submit BPF
document.querySelector("#bpfSubmit").addEventListener("click", async () => {
if (bpfSubmitting) return;
bpfSubmitting = true;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = true;
bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = "...";
const vehicle = state.vehicles.find(v => v.id === bpfCar.value);
const { from, to } = getBpfDates();
const vFrom = parseYmdLocal(from);
const vTo = parseYmdLocal(to);
let weekdayCost = 0, weekendCost = 0, subtotal = 0, vat = 0, total = 0, deposit = 0;
let totalDays = 0, weekdays = 0, weekendDays = 0;
if (vehicle && vFrom && vTo && vTo > vFrom) {
totalDays = Math.ceil((vTo - vFrom) / (1000 * 60 * 60 * 24));
weekendDays = bpfDurationMode === "weekend" ? 2 : calcWeekendDays(from, to);
weekdays = bpfDurationMode === "weekend" ? 0 : (totalDays - weekendDays);
weekdayCost = weekdays * vehicle.daily_price_eur;
weekendCost = weekendDays * (vehicle.weekend_price_eur || vehicle.daily_price_eur);
subtotal = weekdayCost + weekendCost;
vat = Math.round(subtotal * 0.20);
total = subtotal + vat;
deposit = vehicle.kaution_eur || 5000;
}
const payload = {
p_name: bpfName.value,
p_email: bpfEmail.value,
p_phone: bpfPhone.value || "",
p_vehicle_id: bpfCar.value || null,
p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
p_date_from: bpfFrom.value || null,
p_date_to: bpfTo.value || null,
p_message: bpfMessage.value || "",
p_source: "website",
p_daily_subtotal: weekdayCost,
p_weekend_subtotal: weekendCost,
p_subtotal_eur: subtotal,
p_vat_eur: vat,
p_total_eur: total,
p_deposit_eur: deposit,
p_total_days: totalDays,
p_weekday_count: weekdays,
p_weekend_day_count: weekendDays,
};
// Create lead via RPC (returns inserted id without anon SELECT privileges)
const { data: leadId, error } = await supabase.rpc("create_lead", payload);
if (error) {
console.error(error);
bookingFeedback.className = "form-feedback error";
bookingFeedback.textContent = t("bookingFailed");
bpfSubmitting = false;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
return;
}
// Upload files
const uploads = [];
if (bpfFileId.files[0]) {
uploads.push(uploadDoc(leadId, bpfFileId.files[0], "id_document"));
}
if (bpfFileIncome.files[0]) {
uploads.push(uploadDoc(leadId, bpfFileIncome.files[0], "income_proof"));
}
await Promise.all(uploads);
bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = t("bookingSuccess");
showBpfStep(1);
bpfCar.value = "";
bpfFrom.value = "";
bpfTo.value = "";
bpfDayDate.value = "";
bpfWeekendDate.value = "";
bpfName.value = "";
bpfEmail.value = "";
bpfPhone.value = "";
bpfMessage.value = "";
document.querySelector("#bpfFileIdName").textContent = "";
document.querySelector("#bpfFileIncomeName").textContent = "";
setDurationMode("custom");
updateSidebar();
bpfSubmitting = false;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
});
async function uploadDoc(leadId, file, kind) {
try {
const ext = (file.name.split(".").pop() || "bin").toLowerCase();
const path = `${leadId}/${kind}.${ext}`;
const { error: upErr } = await supabase.storage
.from("customer-documents")
.upload(path, file, { contentType: file.type });
if (upErr) { console.error("Upload failed:", upErr); return; }
await supabase.from("lead_attachments").insert({
lead_id: leadId,
bucket: "customer-documents",
file_path: path,
file_name: file.name,
mime_type: file.type,
kind: kind,
});
} catch (e) { console.error("Doc upload error:", e); }
}
// ---------------- Events ----------------
brandFilter.addEventListener("change", e => { state.brand = e.target.value; applyFilters(); });
sortFilter.addEventListener("change", e => { state.sort = e.target.value; applyFilters(); });
priceFilter.addEventListener("input", e => { state.maxPrice = e.target.value ? +e.target.value : null; applyFilters(); });
dialogClose.addEventListener("click", () => dialog.close());
menuToggle.addEventListener("click", () => mainNav.classList.toggle("open"));
mainNav.addEventListener("click", e => { if (e.target.tagName === "A") mainNav.classList.remove("open"); });
langToggle.addEventListener("click", () => {
const next = getLang() === "de" ? "en" : "de";
setLang(next);
langToggle.textContent = next === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
applyFilters();
});
// ---------------- Helpers ----------------
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function escapeAttr(s) { return escapeHtml(s); }
// ---------------- Boot ----------------
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
loadVehicles();