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:
@@ -17,6 +17,7 @@ services:
|
||||
volumes:
|
||||
- ./supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro
|
||||
- ./supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro
|
||||
- ./supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
|
||||
|
||||
kong:
|
||||
volumes:
|
||||
|
||||
@@ -209,6 +209,7 @@ services:
|
||||
volumes:
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/02-leads.sql:/sql/02-leads.sql:ro
|
||||
- /mnt/user/appdata/mc-cars/supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
|
||||
entrypoint: ["sh","-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -227,6 +228,7 @@ services:
|
||||
-v admin_password="$$ADMIN_PASSWORD" \
|
||||
-f /sql/post-boot.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/02-leads.sql
|
||||
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/03-booking-flow.sql
|
||||
echo "post-init done."
|
||||
restart: "no"
|
||||
networks: [mccars]
|
||||
|
||||
+6
-5
@@ -158,13 +158,14 @@
|
||||
<div class="row3">
|
||||
<label><span data-i18n="adminSeats">Sitze</span><input type="number" name="seats" min="1" value="2" /></label>
|
||||
<label><span data-i18n="adminPrice">Preis / Tag (€)</span><input type="number" name="daily_price_eur" min="0" required /></label>
|
||||
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
|
||||
<label><span>Wochenendpreis (€)</span><input type="number" name="weekend_price_eur" min="0" /></label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span data-i18n="adminLocation">Standort</span>
|
||||
<input name="location" value="Steiermark (TBD)" />
|
||||
</label>
|
||||
<div class="row3">
|
||||
<label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label>
|
||||
<label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
|
||||
<label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span data-i18n="adminDescDe">Beschreibung (Deutsch)</span>
|
||||
|
||||
@@ -228,6 +228,8 @@ function loadForEdit(id) {
|
||||
vehicleForm.acceleration.value = v.acceleration;
|
||||
vehicleForm.seats.value = v.seats;
|
||||
vehicleForm.daily_price_eur.value = v.daily_price_eur;
|
||||
vehicleForm.weekend_price_eur.value = v.weekend_price_eur || 0;
|
||||
vehicleForm.max_daily_km.value = v.max_daily_km || 150;
|
||||
vehicleForm.sort_order.value = v.sort_order;
|
||||
vehicleForm.location.value = v.location;
|
||||
vehicleForm.description_de.value = v.description_de;
|
||||
@@ -246,6 +248,8 @@ resetBtn.addEventListener("click", () => {
|
||||
vehicleForm.sort_order.value = 100;
|
||||
vehicleForm.location.value = "Steiermark (TBD)";
|
||||
vehicleForm.seats.value = 2;
|
||||
vehicleForm.max_daily_km.value = 150;
|
||||
vehicleForm.weekend_price_eur.value = 0;
|
||||
state.currentPhotoPath = null;
|
||||
updatePreview("");
|
||||
formTitle.textContent = "Neues Fahrzeug";
|
||||
@@ -267,6 +271,8 @@ vehicleForm.addEventListener("submit", async (e) => {
|
||||
acceleration: fd.get("acceleration") || "",
|
||||
seats: +fd.get("seats") || 2,
|
||||
daily_price_eur: +fd.get("daily_price_eur") || 0,
|
||||
weekend_price_eur: +fd.get("weekend_price_eur") || 0,
|
||||
max_daily_km: +fd.get("max_daily_km") || 150,
|
||||
sort_order: +fd.get("sort_order") || 100,
|
||||
location: fd.get("location") || "Steiermark (TBD)",
|
||||
description_de: fd.get("description_de") || "",
|
||||
|
||||
+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(); });
|
||||
|
||||
+104
-8
@@ -10,9 +10,9 @@ export const translations = {
|
||||
|
||||
heroEyebrow: "MC Cars · Sportwagenvermietung",
|
||||
heroTitle: "Fahren auf hoechstem Niveau.",
|
||||
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.",
|
||||
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.",
|
||||
|
||||
statDeposit: "Kaution",
|
||||
statDeposit: "Faire Kaution",
|
||||
statSupport: "Support",
|
||||
statCars: "Fahrzeuge",
|
||||
|
||||
@@ -42,8 +42,8 @@ export const translations = {
|
||||
whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.",
|
||||
whyFleet: "Premium Flotte",
|
||||
whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.",
|
||||
whyDeposit: "Kautionsfrei",
|
||||
whyDepositText: "Sie zahlen nur die Miete. Kein Kapital blockiert, kein unnoetiger Aufwand.",
|
||||
whyDeposit: "Faire Kaution",
|
||||
whyDepositText: "Kein Ueberziehen. Transparente, faire Kaution ohne unnoetige Belastung.",
|
||||
|
||||
reviewsEyebrow: "Kundenmeinungen",
|
||||
reviewsTitle: "Erlebnisse, die bleiben.",
|
||||
@@ -61,9 +61,57 @@ export const translations = {
|
||||
messagePlaceholder: "Wuensche, Uhrzeit, Anlass...",
|
||||
sendRequest: "Anfrage senden",
|
||||
invalidDates: "Bitte ein gueltiges Datum waehlen (Bis > Von).",
|
||||
weekendSaturdayOnly: "Im Wochenendmodus bitte einen Samstag waehlen.",
|
||||
bookingSuccess: "Danke! Wir melden uns in Kuerze per E-Mail.",
|
||||
bookingFailed: "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.",
|
||||
|
||||
// BPF Wizard
|
||||
bpfTitle: "Jetzt buchen",
|
||||
bpfSubtitle: "Waehle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.",
|
||||
stepVehicleTime: "Fahrzeug & Zeitraum",
|
||||
stepContact: "Kontaktdaten",
|
||||
stepVerification: "ID-Verifizierung",
|
||||
bpfRentalDuration: "Mietdauer",
|
||||
bpfVehicle: "Fahrzeug",
|
||||
bpfSelectVehicle: "Fahrzeug waehlen",
|
||||
bpfDuration: "Mietdauer",
|
||||
bpfPresetDay: "1 Tag",
|
||||
bpfPresetWeekend: "Wochenende",
|
||||
bpfPresetCustom: "Individuell",
|
||||
bpfPickDate: "Datum waehlen",
|
||||
bpfPickWeekend: "Wochenende waehlen (Samstag)",
|
||||
bpfStartDate: "Startdatum",
|
||||
bpfEndDate: "Enddatum",
|
||||
bpfSelectDate: "Datum waehlen",
|
||||
bpfNext: "Weiter",
|
||||
bpfBack: "Zurueck",
|
||||
bpfDailyRate: "Tagesmiete",
|
||||
bpfWeekendRate: "Wochenendmiete",
|
||||
bpfWeekendDef: "Wochenende: Samstag 9:00 – Sonntag 20:00",
|
||||
bpfMaxKm: "Max. km/Tag",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Preisuebersicht",
|
||||
bpfSelectForPrice: "Waehle Fahrzeug und Datum fuer eine Preisuebersicht",
|
||||
bpfSubtotal: "Zwischensumme",
|
||||
bpfVat: "MwSt. (20%)",
|
||||
bpfTotal: "Gesamtbetrag",
|
||||
bpfDeposit: "Kaution",
|
||||
bpfIncludedKm: "Inkludierte Kilometer",
|
||||
bpfIdUpload: "Ausweis / Fuehrerschein",
|
||||
bpfIncomeUpload: "Lohnzettel / Gehaltsnachweis",
|
||||
bpfUploadHint: "PDF, JPG, PNG (max. 10 MB)",
|
||||
bpfClickUpload: "Klicken zum Hochladen",
|
||||
bpfIdNotice: "Ihre Dokumente werden vertraulich behandelt und dienen ausschliesslich der Identitaetsverifizierung.",
|
||||
bpfSubmit: "Anfrage absenden",
|
||||
bpfPerKm: "/km",
|
||||
bpfDays: "Tage",
|
||||
bpfWeekendDays: "Wochenendtage",
|
||||
bpfWeekdays: "Wochentage",
|
||||
|
||||
// Weekend pricing on cards
|
||||
perWeekend: "Wochenende",
|
||||
weekendDef: "Sa 9:00 – So 20:00",
|
||||
|
||||
footerTagline: "Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).",
|
||||
footerLegal: "Rechtliches",
|
||||
footerContact: "Kontakt",
|
||||
@@ -132,9 +180,9 @@ export const translations = {
|
||||
|
||||
heroEyebrow: "MC Cars · Sports car rental",
|
||||
heroTitle: "Drive at the highest level.",
|
||||
heroLead: "Premium sports and luxury cars in Styria. No deposit, full transparency, ready to launch.",
|
||||
heroLead: "Premium sports and luxury cars in Styria. Fair deposit, full transparency, ready to launch.",
|
||||
|
||||
statDeposit: "Deposit",
|
||||
statDeposit: "Fair Deposit",
|
||||
statSupport: "Support",
|
||||
statCars: "Vehicles",
|
||||
|
||||
@@ -164,8 +212,8 @@ export const translations = {
|
||||
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: "No deposit",
|
||||
whyDepositText: "You only pay rent. No blocked capital, no unnecessary overhead.",
|
||||
whyDeposit: "Fair Deposit",
|
||||
whyDepositText: "No overcharge. A transparent, fair deposit with no unnecessary burden.",
|
||||
|
||||
reviewsEyebrow: "Testimonials",
|
||||
reviewsTitle: "Experiences that last.",
|
||||
@@ -183,9 +231,57 @@ export const translations = {
|
||||
messagePlaceholder: "Wishes, timing, occasion...",
|
||||
sendRequest: "Send request",
|
||||
invalidDates: "Please pick valid dates (To > From).",
|
||||
weekendSaturdayOnly: "In weekend mode, please select a Saturday.",
|
||||
bookingSuccess: "Thank you! We'll get back to you shortly.",
|
||||
bookingFailed: "Request could not be sent. Please try again.",
|
||||
|
||||
// BPF Wizard
|
||||
bpfTitle: "Book now",
|
||||
bpfSubtitle: "Choose your dream car, the time period, and configure your booking.",
|
||||
stepVehicleTime: "Vehicle & Period",
|
||||
stepContact: "Contact details",
|
||||
stepVerification: "ID Verification",
|
||||
bpfRentalDuration: "Rental duration",
|
||||
bpfVehicle: "Vehicle",
|
||||
bpfSelectVehicle: "Select vehicle",
|
||||
bpfDuration: "Rental duration",
|
||||
bpfPresetDay: "1 Day",
|
||||
bpfPresetWeekend: "Weekend",
|
||||
bpfPresetCustom: "Custom",
|
||||
bpfPickDate: "Pick date",
|
||||
bpfPickWeekend: "Pick weekend (Saturday)",
|
||||
bpfStartDate: "Start date",
|
||||
bpfEndDate: "End date",
|
||||
bpfSelectDate: "Select date",
|
||||
bpfNext: "Next",
|
||||
bpfBack: "Back",
|
||||
bpfDailyRate: "Daily rate",
|
||||
bpfWeekendRate: "Weekend rate",
|
||||
bpfWeekendDef: "Weekend: Saturday 9 AM – Sunday 8 PM",
|
||||
bpfMaxKm: "Max. km/day",
|
||||
bpfExtraKm: "Extra km",
|
||||
bpfPriceOverview: "Price overview",
|
||||
bpfSelectForPrice: "Select vehicle and date for a price overview",
|
||||
bpfSubtotal: "Subtotal",
|
||||
bpfVat: "VAT (20%)",
|
||||
bpfTotal: "Total",
|
||||
bpfDeposit: "Deposit",
|
||||
bpfIncludedKm: "Included kilometers",
|
||||
bpfIdUpload: "ID / Driving license",
|
||||
bpfIncomeUpload: "Pay slip / Income proof",
|
||||
bpfUploadHint: "PDF, JPG, PNG (max. 10 MB)",
|
||||
bpfClickUpload: "Click to upload",
|
||||
bpfIdNotice: "Your documents are treated confidentially and are used exclusively for identity verification.",
|
||||
bpfSubmit: "Submit request",
|
||||
bpfPerKm: "/km",
|
||||
bpfDays: "Days",
|
||||
bpfWeekendDays: "Weekend days",
|
||||
bpfWeekdays: "Weekdays",
|
||||
|
||||
// Weekend pricing on cards
|
||||
perWeekend: "Weekend",
|
||||
weekendDef: "Sat 9 AM – Sun 8 PM",
|
||||
|
||||
footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
|
||||
footerLegal: "Legal",
|
||||
footerContact: "Contact",
|
||||
|
||||
+160
-41
@@ -4,7 +4,7 @@
|
||||
<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. Kautionsfrei, transparent, sofort startklar." />
|
||||
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Faire Kaution, transparent, sofort startklar." />
|
||||
<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" />
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<div><strong>0 €</strong><span data-i18n="statDeposit">Kaution</span></div>
|
||||
<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>
|
||||
@@ -112,8 +112,8 @@
|
||||
</article>
|
||||
<article class="why-card">
|
||||
<div class="icon">€</div>
|
||||
<h3 data-i18n="whyDeposit">Kautionsfrei</h3>
|
||||
<p data-i18n="whyDepositText">Sie zahlen nur die Miete.</p>
|
||||
<h3 data-i18n="whyDeposit">Faire Kaution</h3>
|
||||
<p data-i18n="whyDepositText">Kein Ueberziehen. Transparente, faire Kaution ohne unnoetige Belastung.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,49 +134,168 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Booking -->
|
||||
<!-- Booking BPF -->
|
||||
<section id="buchen" style="background:var(--bg-elev);">
|
||||
<div class="shell">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="bookingEyebrow">Jetzt buchen</p>
|
||||
<h2 data-i18n="bookingTitle">Traumwagen unverbindlich anfragen.</h2>
|
||||
<div class="bpf-header">
|
||||
<h2 data-i18n="bpfTitle">Jetzt buchen</h2>
|
||||
<p class="sub" data-i18n="bpfSubtitle">Waehle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.</p>
|
||||
</div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="bpf-steps" id="bpfSteps">
|
||||
<button class="bpf-step active" data-step="1"><span class="bpf-step-num">1</span> <span data-i18n="stepVehicleTime">Fahrzeug & Zeitraum</span></button>
|
||||
<span class="bpf-step-arrow">›</span>
|
||||
<button class="bpf-step" data-step="2"><span class="bpf-step-num">2</span> <span data-i18n="stepContact">Kontaktdaten</span></button>
|
||||
<span class="bpf-step-arrow">›</span>
|
||||
<button class="bpf-step" data-step="3"><span class="bpf-step-num">3</span> <span data-i18n="stepVerification">ID-Verifizierung</span></button>
|
||||
</div>
|
||||
|
||||
<div class="bpf-layout">
|
||||
<!-- Main form area -->
|
||||
<div class="bpf-main">
|
||||
<!-- Step 1: Vehicle & Time -->
|
||||
<div class="bpf-panel" id="bpfStep1">
|
||||
<h3 class="bpf-panel-title">🚗 <span data-i18n="stepVehicleTime">Fahrzeug & Zeitraum</span></h3>
|
||||
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfVehicle">Fahrzeug</label>
|
||||
<select id="bpfCar">
|
||||
<option value="" data-i18n="bpfSelectVehicle">Fahrzeug waehlen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfDuration">Mietdauer</label>
|
||||
<div class="bpf-duration-presets" id="bpfDurationPresets">
|
||||
<button type="button" class="bpf-preset" data-preset="day">
|
||||
<span class="bpf-preset-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><path d="M12 14h.01"></path><path d="M12 16h.01"></path></svg>
|
||||
</span>
|
||||
<span data-i18n="bpfPresetDay">1 Tag</span>
|
||||
</button>
|
||||
<button type="button" class="bpf-preset" data-preset="weekend">
|
||||
<span class="bpf-preset-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><path d="M14 14h4v4h-4z"></path></svg>
|
||||
</span>
|
||||
<span data-i18n="bpfPresetWeekend">Wochenende</span>
|
||||
</button>
|
||||
<button type="button" class="bpf-preset active" data-preset="custom">
|
||||
<span class="bpf-preset-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><path d="M8 14h8"></path><path d="M8 18h8"></path></svg>
|
||||
</span>
|
||||
<span data-i18n="bpfPresetCustom">Individuell</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="booking-form" id="bookingForm" novalidate>
|
||||
<label>
|
||||
<span data-i18n="fieldName">Name</span>
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="fieldEmail">E-Mail</span>
|
||||
<input type="email" name="email" required />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="fieldPhone">Telefon</span>
|
||||
<input type="tel" name="phone" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="fieldCar">Fahrzeug</span>
|
||||
<select name="vehicle" id="bookingCar"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="fieldFrom">Von</span>
|
||||
<input type="date" name="from" required />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="fieldTo">Bis</span>
|
||||
<input type="date" name="to" required />
|
||||
</label>
|
||||
<label class="full">
|
||||
<span data-i18n="fieldMessage">Nachricht</span>
|
||||
<textarea name="message" rows="4" data-i18n-placeholder="messagePlaceholder" placeholder="Wuensche, Uhrzeit, Anlass..."></textarea>
|
||||
</label>
|
||||
<div class="full" style="display:flex;justify-content:flex-end;">
|
||||
<button type="submit" class="btn" data-i18n="sendRequest">Anfrage senden</button>
|
||||
<!-- Day mode: single date picker -->
|
||||
<div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
|
||||
<label data-i18n="bpfPickDate">Datum waehlen</label>
|
||||
<input type="date" id="bpfDayDate" />
|
||||
</div>
|
||||
|
||||
<!-- Weekend mode: pick the Saturday -->
|
||||
<div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display:none;">
|
||||
<label data-i18n="bpfPickWeekend">Wochenende waehlen (Samstag)</label>
|
||||
<input type="date" id="bpfWeekendDate" />
|
||||
<p class="bpf-weekend-def" data-i18n="bpfWeekendDef">Wochenende: Samstag 9:00 – Sonntag 20:00</p>
|
||||
</div>
|
||||
|
||||
<!-- Custom mode: from/to date pickers -->
|
||||
<div class="bpf-date-custom" id="bpfDateCustom">
|
||||
<div class="bpf-field-row">
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfStartDate">Startdatum</label>
|
||||
<input type="date" id="bpfFrom" />
|
||||
</div>
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfEndDate">Enddatum</label>
|
||||
<input type="date" id="bpfTo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bpf-nav">
|
||||
<div></div>
|
||||
<button class="btn" type="button" id="bpfNext1" data-i18n="bpfNext">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Contact -->
|
||||
<div class="bpf-panel" id="bpfStep2" style="display:none;">
|
||||
<h3 class="bpf-panel-title">📋 <span data-i18n="stepContact">Kontaktdaten</span></h3>
|
||||
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="fieldName">Name</label>
|
||||
<input type="text" id="bpfName" required />
|
||||
</div>
|
||||
<div class="bpf-field-row">
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="fieldEmail">E-Mail</label>
|
||||
<input type="email" id="bpfEmail" required />
|
||||
</div>
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="fieldPhone">Telefon</label>
|
||||
<input type="tel" id="bpfPhone" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="fieldMessage">Nachricht</label>
|
||||
<textarea id="bpfMessage" rows="3" data-i18n-placeholder="messagePlaceholder" placeholder="Wuensche, Uhrzeit, Anlass..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="bpf-nav">
|
||||
<button class="btn ghost" type="button" id="bpfBack2" data-i18n="bpfBack">Zurueck</button>
|
||||
<button class="btn" type="button" id="bpfNext2" data-i18n="bpfNext">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: ID Verification -->
|
||||
<div class="bpf-panel" id="bpfStep3" style="display:none;">
|
||||
<h3 class="bpf-panel-title">🔐 <span data-i18n="stepVerification">ID-Verifizierung</span></h3>
|
||||
<p class="muted" style="margin-bottom:1.5rem;">Bitte laden Sie einen gueltigen Ausweis sowie einen aktuellen Lohnzettel / Gehaltsnachweis hoch.</p>
|
||||
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfIdUpload">Ausweis / Fuehrerschein *</label>
|
||||
<div class="bpf-upload-box" id="uploadId">
|
||||
<span class="bpf-upload-icon">⬆</span>
|
||||
<span data-i18n="bpfClickUpload">Klicken zum Hochladen</span>
|
||||
<span class="muted" data-i18n="bpfUploadHint">PDF, JPG, PNG (max. 10 MB)</span>
|
||||
<input type="file" accept="image/*,.pdf" class="bpf-file-input" id="bpfFileId" />
|
||||
</div>
|
||||
<p class="bpf-file-name" id="bpfFileIdName"></p>
|
||||
</div>
|
||||
|
||||
<div class="bpf-field">
|
||||
<label data-i18n="bpfIncomeUpload">Lohnzettel / Gehaltsnachweis *</label>
|
||||
<div class="bpf-upload-box" id="uploadIncome">
|
||||
<span class="bpf-upload-icon">⬆</span>
|
||||
<span data-i18n="bpfClickUpload">Klicken zum Hochladen</span>
|
||||
<span class="muted" data-i18n="bpfUploadHint">PDF, JPG, PNG (max. 10 MB)</span>
|
||||
<input type="file" accept="image/*,.pdf" class="bpf-file-input" id="bpfFileIncome" />
|
||||
</div>
|
||||
<p class="bpf-file-name" id="bpfFileIncomeName"></p>
|
||||
</div>
|
||||
|
||||
<div class="bpf-notice">
|
||||
<span>ℹ</span>
|
||||
<p data-i18n="bpfIdNotice">Ihre Dokumente werden vertraulich behandelt und dienen ausschliesslich der Identitaetsverifizierung.</p>
|
||||
</div>
|
||||
|
||||
<div class="bpf-nav">
|
||||
<button class="btn ghost" type="button" id="bpfBack3" data-i18n="bpfBack">Zurueck</button>
|
||||
<button class="btn" type="button" id="bpfSubmit" data-i18n="bpfSubmit">Anfrage absenden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price sidebar -->
|
||||
<aside class="bpf-sidebar" id="bpfSidebar">
|
||||
<p class="bpf-sidebar-placeholder" data-i18n="bpfSelectForPrice">Waehle Fahrzeug und Datum fuer eine Preisuebersicht</p>
|
||||
<div class="bpf-sidebar-content" id="bpfSidebarContent" style="display:none;"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p id="bookingFeedback" class="form-feedback" role="status"></p>
|
||||
</div>
|
||||
|
||||
+134
-2
@@ -329,6 +329,24 @@ select, input, textarea {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input[type="date"] {
|
||||
color-scheme: dark;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
select:focus, input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
@@ -562,7 +580,7 @@ select:focus, input:focus, textarea:focus {
|
||||
.booking-form label { display: grid; gap: 0.5rem; font-size: 0.88rem; color: var(--text); font-weight: 500; transition: color 0.2s; }
|
||||
.booking-form label:focus-within { color: var(--accent-strong); }
|
||||
|
||||
select, input, textarea {
|
||||
.bpf-panel select, .bpf-panel input, .bpf-panel textarea {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--bg-elev);
|
||||
@@ -573,7 +591,7 @@ select, input, textarea {
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
select:focus, input:focus, textarea:focus {
|
||||
.bpf-panel select:focus, .bpf-panel input:focus, .bpf-panel textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(196, 138, 66, 0.2);
|
||||
@@ -587,6 +605,120 @@ select:focus, input:focus, textarea:focus {
|
||||
}
|
||||
.form-feedback.error { color: var(--danger); }
|
||||
|
||||
/* ---------------- Booking BPF Wizard ---------------- */
|
||||
.bpf-header { text-align: center; margin-bottom: 2.5rem; }
|
||||
.bpf-header h2 { font-size: clamp(2rem, 4vw, 3rem); margin-bottom: 0.5rem; }
|
||||
.bpf-header .sub { color: var(--muted); font-size: 1rem; max-width: 50ch; margin: 0 auto; }
|
||||
|
||||
.bpf-steps {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 0.5rem; margin-bottom: 2.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.bpf-step {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 0.6rem 1.2rem;
|
||||
color: var(--muted); font-size: 0.88rem; font-weight: 500;
|
||||
cursor: default; font-family: inherit; transition: all 0.3s ease;
|
||||
}
|
||||
.bpf-step.active {
|
||||
background: var(--accent); color: #0b0c10; border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bpf-step.done { border-color: var(--ok); color: var(--ok); }
|
||||
.bpf-step-num {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 1.5rem; height: 1.5rem; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.1); font-size: 0.75rem; font-weight: 700;
|
||||
}
|
||||
.bpf-step.active .bpf-step-num { background: rgba(0,0,0,0.2); color: #0b0c10; }
|
||||
.bpf-step-arrow { color: var(--muted); font-size: 1.2rem; }
|
||||
|
||||
.bpf-layout {
|
||||
display: grid; grid-template-columns: 1fr 340px; gap: 2rem; align-items: start;
|
||||
}
|
||||
|
||||
.bpf-main { min-width: 0; }
|
||||
|
||||
.bpf-panel {
|
||||
background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 2rem;
|
||||
animation: slideUpFade 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
.bpf-panel-title { font-size: 1.1rem; font-family: "Inter", sans-serif; margin-bottom: 1.5rem; font-weight: 600; }
|
||||
|
||||
.bpf-field { margin-bottom: 1.2rem; }
|
||||
.bpf-field label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.4rem; color: var(--text); }
|
||||
.bpf-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
|
||||
.bpf-weekend-def {
|
||||
font-size: 0.8rem; color: var(--accent-strong); margin: 0.5rem 0 1.5rem;
|
||||
padding: 0.5rem 0.8rem; background: rgba(196, 138, 66, 0.08);
|
||||
border-radius: 8px; border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Duration presets */
|
||||
.bpf-duration-presets {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem; margin-top: 0.4rem;
|
||||
}
|
||||
.bpf-preset {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 0.4rem; padding: 1rem 0.8rem;
|
||||
background: var(--bg-elev); border: 1px solid var(--line); border-radius: 12px;
|
||||
color: var(--text); font-family: inherit; font-size: 0.88rem; font-weight: 500;
|
||||
cursor: pointer; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.bpf-preset:hover { border-color: var(--accent); background: rgba(196, 138, 66, 0.06); }
|
||||
.bpf-preset.active {
|
||||
background: rgba(180, 50, 50, 0.15); border-color: var(--danger);
|
||||
color: var(--text); box-shadow: 0 0 0 1px var(--danger);
|
||||
}
|
||||
.bpf-preset-icon { font-size: 1.3rem; }
|
||||
|
||||
.bpf-nav { display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; }
|
||||
|
||||
/* Upload boxes */
|
||||
.bpf-upload-box {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 0.4rem; padding: 2rem;
|
||||
border: 1px dashed var(--line); border-radius: 10px;
|
||||
cursor: pointer; position: relative; transition: border-color 0.2s ease, background 0.2s ease;
|
||||
min-height: 120px;
|
||||
}
|
||||
.bpf-upload-box:hover { border-color: var(--accent); background: rgba(196,138,66,0.04); }
|
||||
.bpf-upload-icon { font-size: 1.5rem; }
|
||||
.bpf-file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||
.bpf-file-name { font-size: 0.82rem; color: var(--ok); margin-top: 0.3rem; min-height: 1.2rem; }
|
||||
|
||||
.bpf-notice {
|
||||
display: flex; align-items: flex-start; gap: 0.8rem;
|
||||
padding: 1rem; background: var(--bg-elev);
|
||||
border: 1px solid var(--line); border-radius: 10px; margin: 1.5rem 0;
|
||||
}
|
||||
.bpf-notice span { font-size: 1.2rem; flex-shrink: 0; }
|
||||
.bpf-notice p { margin: 0; font-size: 0.85rem; color: var(--muted); line-height: 1.5; }
|
||||
|
||||
/* Sidebar */
|
||||
.bpf-sidebar {
|
||||
background: var(--bg-card); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 1.8rem;
|
||||
position: sticky; top: 6rem;
|
||||
}
|
||||
.bpf-sidebar-placeholder { color: var(--muted); text-align: center; font-size: 0.9rem; }
|
||||
.bpf-sidebar-content h4 { font-family: "Inter", sans-serif; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; }
|
||||
.bpf-sidebar-content .bpf-price-row { display: flex; justify-content: space-between; padding: 0.5rem 0; font-size: 0.9rem; }
|
||||
.bpf-sidebar-content .bpf-price-row.total { font-weight: 700; font-size: 1.1rem; color: var(--accent-strong); border-top: 1px solid var(--line); padding-top: 0.8rem; margin-top: 0.5rem; }
|
||||
.bpf-sidebar-content .bpf-price-row .muted { color: var(--muted); }
|
||||
.bpf-sidebar-content .bpf-car-preview { width: 100%; aspect-ratio: 16/10; border-radius: 10px; background: var(--bg-elev) center/cover no-repeat; margin-top: 1.2rem; }
|
||||
.bpf-sidebar-content .bpf-car-name { font-weight: 700; margin-top: 0.6rem; text-transform: uppercase; font-size: 0.85rem; letter-spacing: 0.05em; }
|
||||
.bpf-sidebar-content .bpf-car-specs { font-size: 0.82rem; color: var(--muted); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.bpf-layout { grid-template-columns: 1fr; }
|
||||
.bpf-sidebar { position: static; }
|
||||
.bpf-field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---------------- Footer ---------------- */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
-- =============================================================================
|
||||
-- MC Cars - Booking flow enhancements: weekend pricing, daily KM limits,
|
||||
-- lead attachments, customer attachments, email-based customer upsert.
|
||||
-- Idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. Add weekend_price_eur and max_daily_km to vehicles
|
||||
-- -----------------------------------------------------------------------------
|
||||
alter table public.vehicles add column if not exists weekend_price_eur integer not null default 0;
|
||||
alter table public.vehicles add column if not exists max_daily_km integer not null default 0;
|
||||
|
||||
-- Backfill existing vehicles with sensible defaults (weekend = daily * 1.2)
|
||||
update public.vehicles
|
||||
set weekend_price_eur = ceil(daily_price_eur * 1.2),
|
||||
max_daily_km = 150
|
||||
where weekend_price_eur = 0;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. Lead attachments: documents uploaded during booking flow
|
||||
-- -----------------------------------------------------------------------------
|
||||
create table if not exists public.lead_attachments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
lead_id uuid not null references public.leads(id) on delete cascade,
|
||||
bucket text not null default 'customer-documents',
|
||||
file_path text not null,
|
||||
file_name text not null default '',
|
||||
mime_type text not null default 'application/octet-stream',
|
||||
kind text not null default 'other'
|
||||
check (kind in ('id_document', 'income_proof', 'other')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists lead_attachments_lead_idx on public.lead_attachments (lead_id);
|
||||
|
||||
alter table public.lead_attachments enable row level security;
|
||||
|
||||
drop policy if exists "lead_attach_anon_insert" on public.lead_attachments;
|
||||
drop policy if exists "lead_attach_admin_all" on public.lead_attachments;
|
||||
|
||||
-- Anon can insert (they upload during booking)
|
||||
create policy "lead_attach_anon_insert"
|
||||
on public.lead_attachments for insert to anon
|
||||
with check (true);
|
||||
|
||||
-- Authenticated admins can do everything
|
||||
create policy "lead_attach_admin_all"
|
||||
on public.lead_attachments for all to authenticated
|
||||
using (true) with check (true);
|
||||
|
||||
grant insert on public.lead_attachments to anon;
|
||||
grant all on public.lead_attachments to authenticated;
|
||||
grant all on public.lead_attachments to service_role;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. Customer attachments: transferred from leads on qualification
|
||||
-- -----------------------------------------------------------------------------
|
||||
create table if not exists public.customer_attachments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
customer_id uuid not null references public.customers(id) on delete cascade,
|
||||
lead_id uuid references public.leads(id) on delete set null,
|
||||
bucket text not null default 'customer-documents',
|
||||
file_path text not null,
|
||||
file_name text not null default '',
|
||||
mime_type text not null default 'application/octet-stream',
|
||||
kind text not null default 'other'
|
||||
check (kind in ('id_document', 'income_proof', 'other')),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists customer_attachments_cust_idx on public.customer_attachments (customer_id);
|
||||
|
||||
alter table public.customer_attachments enable row level security;
|
||||
|
||||
drop policy if exists "cust_attach_admin_all" on public.customer_attachments;
|
||||
|
||||
create policy "cust_attach_admin_all"
|
||||
on public.customer_attachments for all to authenticated
|
||||
using (true) with check (true);
|
||||
|
||||
grant all on public.customer_attachments to authenticated;
|
||||
grant all on public.customer_attachments to service_role;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. Allow multiple leads per customer: drop UNIQUE on lead_id, add email index
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Drop the unique index on customers.lead_id so multiple leads can map to one customer
|
||||
drop index if exists customers_lead_unique;
|
||||
|
||||
-- Create unique index on email for upsert
|
||||
create unique index if not exists customers_email_unique on public.customers (lower(email));
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. Replace qualify_lead() with email-based upsert + attachment transfer
|
||||
-- -----------------------------------------------------------------------------
|
||||
create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '')
|
||||
returns public.customers
|
||||
language plpgsql
|
||||
security invoker
|
||||
as $$
|
||||
declare
|
||||
v_lead public.leads;
|
||||
v_customer public.customers;
|
||||
v_user uuid := auth.uid();
|
||||
begin
|
||||
-- Lock the lead row
|
||||
select * into v_lead from public.leads where id = p_lead_id for update;
|
||||
if not found then
|
||||
raise exception 'lead % not found', p_lead_id;
|
||||
end if;
|
||||
|
||||
-- If already qualified, just return the associated customer
|
||||
if v_lead.status = 'qualified' then
|
||||
select * into v_customer from public.customers where lead_id = v_lead.id limit 1;
|
||||
if not found then
|
||||
select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1;
|
||||
end if;
|
||||
return v_customer;
|
||||
end if;
|
||||
|
||||
-- Mark lead as qualified
|
||||
update public.leads
|
||||
set status = 'qualified',
|
||||
is_active = false,
|
||||
qualified_at = now(),
|
||||
qualified_by = v_user,
|
||||
admin_notes = coalesce(nullif(p_notes, ''), admin_notes)
|
||||
where id = v_lead.id;
|
||||
|
||||
-- Upsert customer by email
|
||||
insert into public.customers (lead_id, name, email, phone, notes, created_by)
|
||||
values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user)
|
||||
on conflict ((lower(email))) do update
|
||||
set name = excluded.name,
|
||||
phone = excluded.phone,
|
||||
notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end,
|
||||
updated_at = now()
|
||||
returning * into v_customer;
|
||||
|
||||
-- Transfer lead attachments to customer
|
||||
insert into public.customer_attachments (customer_id, lead_id, bucket, file_path, file_name, mime_type, kind, created_at)
|
||||
select v_customer.id, la.lead_id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at
|
||||
from public.lead_attachments la
|
||||
where la.lead_id = v_lead.id
|
||||
and not exists (
|
||||
select 1 from public.customer_attachments ca
|
||||
where ca.customer_id = v_customer.id
|
||||
and ca.file_path = la.file_path
|
||||
);
|
||||
|
||||
return v_customer;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.qualify_lead(uuid, text) from public;
|
||||
grant execute on function public.qualify_lead(uuid, text) to authenticated;
|
||||
@@ -83,3 +83,32 @@ create policy "vehicle_photos_admin_update"
|
||||
create policy "vehicle_photos_admin_delete"
|
||||
on storage.objects for delete to authenticated
|
||||
using (bucket_id = 'vehicle-photos');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Private bucket for booking documents (ID, payslip)
|
||||
-- -----------------------------------------------------------------------------
|
||||
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
values ('customer-documents','customer-documents', false, 10485760,
|
||||
array['image/jpeg','image/png','application/pdf'])
|
||||
on conflict (id) do update
|
||||
set public = excluded.public,
|
||||
file_size_limit = excluded.file_size_limit,
|
||||
allowed_mime_types = excluded.allowed_mime_types;
|
||||
|
||||
drop policy if exists "custdocs_anon_upload" on storage.objects;
|
||||
drop policy if exists "custdocs_admin_read" on storage.objects;
|
||||
drop policy if exists "custdocs_admin_delete" on storage.objects;
|
||||
|
||||
-- Anon can upload during booking flow
|
||||
create policy "custdocs_anon_upload"
|
||||
on storage.objects for insert to anon
|
||||
with check (bucket_id = 'customer-documents');
|
||||
|
||||
-- Only authenticated admins can read/delete
|
||||
create policy "custdocs_admin_read"
|
||||
on storage.objects for select to authenticated
|
||||
using (bucket_id = 'customer-documents');
|
||||
|
||||
create policy "custdocs_admin_delete"
|
||||
on storage.objects for delete to authenticated
|
||||
using (bucket_id = 'customer-documents');
|
||||
|
||||
Reference in New Issue
Block a user