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
+1
View File
@@ -17,6 +17,7 @@ services:
volumes: volumes:
- ./supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro - ./supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro
- ./supabase/migrations/02-leads.sql:/sql/02-leads.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: kong:
volumes: volumes:
+2
View File
@@ -209,6 +209,7 @@ services:
volumes: volumes:
- /mnt/user/appdata/mc-cars/supabase/migrations/post-boot.sql:/sql/post-boot.sql:ro - /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/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"] entrypoint: ["sh","-c"]
command: command:
- | - |
@@ -227,6 +228,7 @@ services:
-v admin_password="$$ADMIN_PASSWORD" \ -v admin_password="$$ADMIN_PASSWORD" \
-f /sql/post-boot.sql -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/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." echo "post-init done."
restart: "no" restart: "no"
networks: [mccars] networks: [mccars]
+6 -5
View File
@@ -158,13 +158,14 @@
<div class="row3"> <div class="row3">
<label><span data-i18n="adminSeats">Sitze</span><input type="number" name="seats" min="1" value="2" /></label> <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="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> </div>
<label> <div class="row3">
<span data-i18n="adminLocation">Standort</span> <label><span>Max. km/Tag</span><input type="number" name="max_daily_km" min="0" value="150" /></label>
<input name="location" value="Steiermark (TBD)" /> <label><span data-i18n="adminSort">Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
</label> <label><span data-i18n="adminLocation">Standort</span><input name="location" value="Steiermark (TBD)" /></label>
</div>
<label> <label>
<span data-i18n="adminDescDe">Beschreibung (Deutsch)</span> <span data-i18n="adminDescDe">Beschreibung (Deutsch)</span>
+6
View File
@@ -228,6 +228,8 @@ function loadForEdit(id) {
vehicleForm.acceleration.value = v.acceleration; vehicleForm.acceleration.value = v.acceleration;
vehicleForm.seats.value = v.seats; vehicleForm.seats.value = v.seats;
vehicleForm.daily_price_eur.value = v.daily_price_eur; 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.sort_order.value = v.sort_order;
vehicleForm.location.value = v.location; vehicleForm.location.value = v.location;
vehicleForm.description_de.value = v.description_de; vehicleForm.description_de.value = v.description_de;
@@ -246,6 +248,8 @@ resetBtn.addEventListener("click", () => {
vehicleForm.sort_order.value = 100; vehicleForm.sort_order.value = 100;
vehicleForm.location.value = "Steiermark (TBD)"; vehicleForm.location.value = "Steiermark (TBD)";
vehicleForm.seats.value = 2; vehicleForm.seats.value = 2;
vehicleForm.max_daily_km.value = 150;
vehicleForm.weekend_price_eur.value = 0;
state.currentPhotoPath = null; state.currentPhotoPath = null;
updatePreview(""); updatePreview("");
formTitle.textContent = "Neues Fahrzeug"; formTitle.textContent = "Neues Fahrzeug";
@@ -267,6 +271,8 @@ vehicleForm.addEventListener("submit", async (e) => {
acceleration: fd.get("acceleration") || "", acceleration: fd.get("acceleration") || "",
seats: +fd.get("seats") || 2, seats: +fd.get("seats") || 2,
daily_price_eur: +fd.get("daily_price_eur") || 0, 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, sort_order: +fd.get("sort_order") || 100,
location: fd.get("location") || "Steiermark (TBD)", location: fd.get("location") || "Steiermark (TBD)",
description_de: fd.get("description_de") || "", description_de: fd.get("description_de") || "",
+281 -29
View File
@@ -24,9 +24,6 @@ const emptyState = document.querySelector("#emptyState");
const brandFilter = document.querySelector("#brandFilter"); const brandFilter = document.querySelector("#brandFilter");
const sortFilter = document.querySelector("#sortFilter"); const sortFilter = document.querySelector("#sortFilter");
const priceFilter = document.querySelector("#priceFilter"); 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 langToggle = document.querySelector(".lang-toggle");
const menuToggle = document.querySelector(".menu-toggle"); const menuToggle = document.querySelector(".menu-toggle");
const mainNav = document.querySelector(".main-nav"); const mainNav = document.querySelector(".main-nav");
@@ -37,6 +34,63 @@ const dialogClose = document.querySelector("#dialogClose");
const reviewStrip = document.querySelector("#reviewStrip"); const reviewStrip = document.querySelector("#reviewStrip");
const reviewDots = document.querySelector("#reviewDots"); const reviewDots = document.querySelector("#reviewDots");
const statCarsCount = document.querySelector("#statCarsCount"); 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(); document.querySelector("#year").textContent = new Date().getFullYear();
@@ -61,9 +115,9 @@ async function loadVehicles() {
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` + brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
brands.map(b => `<option value="${b}">${b}</option>`).join(""); brands.map(b => `<option value="${b}">${b}</option>`).join("");
bookingCar.innerHTML = state.vehicles // Populate BPF car selector
.map(v => `<option value="${v.id}">${v.brand} ${v.model}</option>`) bpfCar.innerHTML = `<option value="">${t("bpfSelectVehicle")}</option>` +
.join(""); state.vehicles.map(v => `<option value="${v.id}">${v.brand} ${v.model}</option>`).join("");
applyFilters(); applyFilters();
} }
@@ -120,7 +174,8 @@ function renderGrid() {
}); });
grid.querySelectorAll("[data-book]").forEach(b => { grid.querySelectorAll("[data-book]").forEach(b => {
b.addEventListener("click", () => { b.addEventListener("click", () => {
bookingCar.value = b.dataset.book; bpfCar.value = b.dataset.book;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" }); document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
}); });
}); });
@@ -149,7 +204,8 @@ function openDetails(id) {
dialog.showModal(); dialog.showModal();
document.querySelector("#dialogBook").addEventListener("click", () => { document.querySelector("#dialogBook").addEventListener("click", () => {
dialog.close(); dialog.close();
bookingCar.value = v.id; bpfCar.value = v.id;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" }); document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
}); });
} }
@@ -172,35 +228,188 @@ function renderReviews() {
} }
setInterval(() => { state.reviewIdx++; renderReviews(); }, 6000); setInterval(() => { state.reviewIdx++; renderReviews(); }, 6000);
// ---------------- Booking -> LEADS ---------------- // ---------------- BPF WIZARD ----------------
bookingForm.addEventListener("submit", async (e) => { const bpfStepPanels = [
e.preventDefault(); document.querySelector("#bpfStep1"),
const fd = new FormData(bookingForm); document.querySelector("#bpfStep2"),
const data = Object.fromEntries(fd.entries()); 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)) { function showBpfStep(n) {
bookingFeedback.textContent = t("invalidDates"); 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"; bookingFeedback.className = "form-feedback error";
return; 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); // File upload display
const payload = { bpfFileId.addEventListener("change", () => {
name: data.name, document.querySelector("#bpfFileIdName").textContent = bpfFileId.files[0]?.name || "";
email: data.email, });
phone: data.phone || "", bpfFileIncome.addEventListener("change", () => {
vehicle_id: data.vehicle || null, document.querySelector("#bpfFileIncomeName").textContent = bpfFileIncome.files[0]?.name || "";
vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "", });
date_from: data.from || null,
date_to: data.to || null,
message: data.message || "",
source: "website",
};
// ---------------- 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.className = "form-feedback";
bookingFeedback.textContent = "..."; 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) { if (error) {
console.error(error); console.error(error);
bookingFeedback.className = "form-feedback error"; bookingFeedback.className = "form-feedback error";
@@ -208,10 +417,53 @@ bookingForm.addEventListener("submit", async (e) => {
return; 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"); 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 ---------------- // ---------------- Events ----------------
brandFilter.addEventListener("change", e => { state.brand = e.target.value; applyFilters(); }); brandFilter.addEventListener("change", e => { state.brand = e.target.value; applyFilters(); });
sortFilter.addEventListener("change", e => { state.sort = e.target.value; applyFilters(); }); sortFilter.addEventListener("change", e => { state.sort = e.target.value; applyFilters(); });
+104 -8
View File
@@ -10,9 +10,9 @@ export const translations = {
heroEyebrow: "MC Cars · Sportwagenvermietung", heroEyebrow: "MC Cars · Sportwagenvermietung",
heroTitle: "Fahren auf hoechstem Niveau.", 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", statSupport: "Support",
statCars: "Fahrzeuge", statCars: "Fahrzeuge",
@@ -42,8 +42,8 @@ export const translations = {
whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.", whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.",
whyFleet: "Premium Flotte", whyFleet: "Premium Flotte",
whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.", whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.",
whyDeposit: "Kautionsfrei", whyDeposit: "Faire Kaution",
whyDepositText: "Sie zahlen nur die Miete. Kein Kapital blockiert, kein unnoetiger Aufwand.", whyDepositText: "Kein Ueberziehen. Transparente, faire Kaution ohne unnoetige Belastung.",
reviewsEyebrow: "Kundenmeinungen", reviewsEyebrow: "Kundenmeinungen",
reviewsTitle: "Erlebnisse, die bleiben.", reviewsTitle: "Erlebnisse, die bleiben.",
@@ -61,9 +61,57 @@ export const translations = {
messagePlaceholder: "Wuensche, Uhrzeit, Anlass...", messagePlaceholder: "Wuensche, Uhrzeit, Anlass...",
sendRequest: "Anfrage senden", sendRequest: "Anfrage senden",
invalidDates: "Bitte ein gueltiges Datum waehlen (Bis > Von).", 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.", bookingSuccess: "Danke! Wir melden uns in Kuerze per E-Mail.",
bookingFailed: "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.", 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).", footerTagline: "Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).",
footerLegal: "Rechtliches", footerLegal: "Rechtliches",
footerContact: "Kontakt", footerContact: "Kontakt",
@@ -132,9 +180,9 @@ export const translations = {
heroEyebrow: "MC Cars · Sports car rental", heroEyebrow: "MC Cars · Sports car rental",
heroTitle: "Drive at the highest level.", 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", statSupport: "Support",
statCars: "Vehicles", statCars: "Vehicles",
@@ -164,8 +212,8 @@ export const translations = {
whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.", whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.",
whyFleet: "Premium fleet", whyFleet: "Premium fleet",
whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.", whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.",
whyDeposit: "No deposit", whyDeposit: "Fair Deposit",
whyDepositText: "You only pay rent. No blocked capital, no unnecessary overhead.", whyDepositText: "No overcharge. A transparent, fair deposit with no unnecessary burden.",
reviewsEyebrow: "Testimonials", reviewsEyebrow: "Testimonials",
reviewsTitle: "Experiences that last.", reviewsTitle: "Experiences that last.",
@@ -183,9 +231,57 @@ export const translations = {
messagePlaceholder: "Wishes, timing, occasion...", messagePlaceholder: "Wishes, timing, occasion...",
sendRequest: "Send request", sendRequest: "Send request",
invalidDates: "Please pick valid dates (To > From).", 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.", bookingSuccess: "Thank you! We'll get back to you shortly.",
bookingFailed: "Request could not be sent. Please try again.", 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).", footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
footerLegal: "Legal", footerLegal: "Legal",
footerContact: "Contact", footerContact: "Contact",
+160 -41
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MC Cars · Sportwagenvermietung Steiermark</title> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <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>
<div class="hero-stats"> <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 id="statCarsCount"></strong><span data-i18n="statCars">Fahrzeuge</span></div>
<div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div> <div><strong>24/7</strong><span data-i18n="statSupport">Support</span></div>
</div> </div>
@@ -112,8 +112,8 @@
</article> </article>
<article class="why-card"> <article class="why-card">
<div class="icon"></div> <div class="icon"></div>
<h3 data-i18n="whyDeposit">Kautionsfrei</h3> <h3 data-i18n="whyDeposit">Faire Kaution</h3>
<p data-i18n="whyDepositText">Sie zahlen nur die Miete.</p> <p data-i18n="whyDepositText">Kein Ueberziehen. Transparente, faire Kaution ohne unnoetige Belastung.</p>
</article> </article>
</div> </div>
</div> </div>
@@ -134,49 +134,168 @@
</div> </div>
</section> </section>
<!-- Booking --> <!-- Booking BPF -->
<section id="buchen" style="background:var(--bg-elev);"> <section id="buchen" style="background:var(--bg-elev);">
<div class="shell"> <div class="shell">
<div class="section-head"> <div class="bpf-header">
<div> <h2 data-i18n="bpfTitle">Jetzt buchen</h2>
<p class="eyebrow" data-i18n="bookingEyebrow">Jetzt buchen</p> <p class="sub" data-i18n="bpfSubtitle">Waehle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.</p>
<h2 data-i18n="bookingTitle">Traumwagen unverbindlich anfragen.</h2> </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>
</div> </div>
<form class="booking-form" id="bookingForm" novalidate> <!-- Day mode: single date picker -->
<label> <div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
<span data-i18n="fieldName">Name</span> <label data-i18n="bpfPickDate">Datum waehlen</label>
<input type="text" name="name" required /> <input type="date" id="bpfDayDate" />
</label> </div>
<label>
<span data-i18n="fieldEmail">E-Mail</span> <!-- Weekend mode: pick the Saturday -->
<input type="email" name="email" required /> <div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display:none;">
</label> <label data-i18n="bpfPickWeekend">Wochenende waehlen (Samstag)</label>
<label> <input type="date" id="bpfWeekendDate" />
<span data-i18n="fieldPhone">Telefon</span> <p class="bpf-weekend-def" data-i18n="bpfWeekendDef">Wochenende: Samstag 9:00 Sonntag 20:00</p>
<input type="tel" name="phone" /> </div>
</label>
<label> <!-- Custom mode: from/to date pickers -->
<span data-i18n="fieldCar">Fahrzeug</span> <div class="bpf-date-custom" id="bpfDateCustom">
<select name="vehicle" id="bookingCar"></select> <div class="bpf-field-row">
</label> <div class="bpf-field">
<label> <label data-i18n="bpfStartDate">Startdatum</label>
<span data-i18n="fieldFrom">Von</span> <input type="date" id="bpfFrom" />
<input type="date" name="from" required /> </div>
</label> <div class="bpf-field">
<label> <label data-i18n="bpfEndDate">Enddatum</label>
<span data-i18n="fieldTo">Bis</span> <input type="date" id="bpfTo" />
<input type="date" name="to" required /> </div>
</label> </div>
<label class="full"> </div>
<span data-i18n="fieldMessage">Nachricht</span>
<textarea name="message" rows="4" data-i18n-placeholder="messagePlaceholder" placeholder="Wuensche, Uhrzeit, Anlass..."></textarea> <div class="bpf-nav">
</label> <div></div>
<div class="full" style="display:flex;justify-content:flex-end;"> <button class="btn" type="button" id="bpfNext1" data-i18n="bpfNext">Weiter</button>
<button type="submit" class="btn" data-i18n="sendRequest">Anfrage senden</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> </div>
</form>
<p id="bookingFeedback" class="form-feedback" role="status"></p> <p id="bookingFeedback" class="form-feedback" role="status"></p>
</div> </div>
+134 -2
View File
@@ -329,6 +329,24 @@ select, input, textarea {
transition: border-color 0.15s; 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 { select:focus, input:focus, textarea:focus {
outline: none; outline: none;
border-color: var(--accent); 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 { 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); } .booking-form label:focus-within { color: var(--accent-strong); }
select, input, textarea { .bpf-panel select, .bpf-panel input, .bpf-panel textarea {
width: 100%; width: 100%;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
background: var(--bg-elev); 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; 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; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(196, 138, 66, 0.2); 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); } .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 ---------------- */ /* ---------------- Footer ---------------- */
.site-footer { .site-footer {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
+156
View File
@@ -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;
+29
View File
@@ -83,3 +83,32 @@ create policy "vehicle_photos_admin_update"
create policy "vehicle_photos_admin_delete" create policy "vehicle_photos_admin_delete"
on storage.objects for delete to authenticated on storage.objects for delete to authenticated
using (bucket_id = 'vehicle-photos'); 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');