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:
+281
-29
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user