feat: enhance booking flow with BPF wizard, weekend pricing, and document uploads

- Updated translations for German and English to reflect changes in deposit terminology.
- Modified index.html to implement a new booking flow with a step-by-step wizard for vehicle selection, contact details, and ID verification.
- Added CSS styles for the new booking flow interface, including responsive design and improved input styles.
- Created new database policies and tables for handling customer and lead attachments during the booking process.
- Introduced weekend pricing and daily KM limits for vehicles in the database schema.
- Implemented email-based customer upsert functionality to streamline lead qualification and attachment transfer.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LagoESP
2026-04-29 15:01:25 +02:00
parent d17fe0651e
commit d960e37aa8
10 changed files with 880 additions and 86 deletions
+281 -29
View File
@@ -24,9 +24,6 @@ const emptyState = document.querySelector("#emptyState");
const brandFilter = document.querySelector("#brandFilter");
const sortFilter = document.querySelector("#sortFilter");
const priceFilter = document.querySelector("#priceFilter");
const bookingCar = document.querySelector("#bookingCar");
const bookingForm = document.querySelector("#bookingForm");
const bookingFeedback = document.querySelector("#bookingFeedback");
const langToggle = document.querySelector(".lang-toggle");
const menuToggle = document.querySelector(".menu-toggle");
const mainNav = document.querySelector(".main-nav");
@@ -37,6 +34,63 @@ 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 bpfSidebar = document.querySelector("#bpfSidebar");
const bpfSidebarContent = document.querySelector("#bpfSidebarContent");
const bpfSidebarPlaceholder = document.querySelector(".bpf-sidebar-placeholder");
let bpfDurationMode = "custom"; // "day" | "weekend" | "custom"
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();
@@ -61,9 +115,9 @@ async function loadVehicles() {
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
brands.map(b => `<option value="${b}">${b}</option>`).join("");
bookingCar.innerHTML = state.vehicles
.map(v => `<option value="${v.id}">${v.brand} ${v.model}</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();
}
@@ -120,7 +174,8 @@ function renderGrid() {
});
grid.querySelectorAll("[data-book]").forEach(b => {
b.addEventListener("click", () => {
bookingCar.value = b.dataset.book;
bpfCar.value = b.dataset.book;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
});
@@ -149,7 +204,8 @@ function openDetails(id) {
dialog.showModal();
document.querySelector("#dialogBook").addEventListener("click", () => {
dialog.close();
bookingCar.value = v.id;
bpfCar.value = v.id;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
}
@@ -172,35 +228,188 @@ function renderReviews() {
}
setInterval(() => { state.reviewIdx++; renderReviews(); }, 6000);
// ---------------- Booking -> LEADS ----------------
bookingForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(bookingForm);
const data = Object.fromEntries(fd.entries());
// ---------------- BPF WIZARD ----------------
const bpfStepPanels = [
document.querySelector("#bpfStep1"),
document.querySelector("#bpfStep2"),
document.querySelector("#bpfStep3"),
];
const bpfStepButtons = document.querySelectorAll(".bpf-step");
let bpfCurrentStep = 1;
if (!data.from || !data.to || new Date(data.to) <= new Date(data.from)) {
bookingFeedback.textContent = t("invalidDates");
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));
const vehicle = state.vehicles.find(v => v.id === data.vehicle);
const payload = {
name: data.name,
email: data.email,
phone: data.phone || "",
vehicle_id: data.vehicle || null,
vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
date_from: data.from || null,
date_to: data.to || null,
message: data.message || "",
source: "website",
};
// 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 = Math.round(v.daily_price_eur * 2.5);
const includedKm = (v.max_daily_km || 150) * totalDays;
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 () => {
bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = "...";
const { error } = await supabase.from("leads").insert(payload);
const vehicle = state.vehicles.find(v => v.id === bpfCar.value);
const payload = {
name: bpfName.value,
email: bpfEmail.value,
phone: bpfPhone.value || "",
vehicle_id: bpfCar.value || null,
vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
date_from: bpfFrom.value || null,
date_to: bpfTo.value || null,
message: bpfMessage.value || "",
source: "website",
};
// Create lead
const { data: lead, error } = await supabase.from("leads").insert(payload).select("id").single();
if (error) {
console.error(error);
bookingFeedback.className = "form-feedback error";
@@ -208,10 +417,53 @@ bookingForm.addEventListener("submit", async (e) => {
return;
}
// Upload files
const uploads = [];
if (bpfFileId.files[0]) {
uploads.push(uploadDoc(lead.id, bpfFileId.files[0], "id_document"));
}
if (bpfFileIncome.files[0]) {
uploads.push(uploadDoc(lead.id, bpfFileIncome.files[0], "income_proof"));
}
await Promise.all(uploads);
bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = t("bookingSuccess");
bookingForm.reset();
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();
});
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, upsert: true });
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(); });