Files
Lago e34d56e36a feat: Add manual email sending workflow and related database changes
- Implemented a new n8n workflow for manual email sending, including webhook trigger, order data fetching, email building, and sending.
- Added logic to format email content with customer and order details.
- Introduced new columns in the sales_orders table to track email sending status.
- Updated database functions to handle new rental types and email status.
- Created new RPCs for updating email status and retrieving email details for sales orders.
2026-05-17 18:04:36 +02:00

577 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js?v=3";
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL ?? "";
const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || "";
export const supabase = createClient(SUPA_URL, SUPA_KEY, {
auth: { persistSession: false, storageKey: "mccars.public" },
});
// ---------------- State ----------------
const state = {
vehicles: [],
filtered: [],
brand: "all",
sort: "sort_order",
maxPrice: null,
reviewIdx: 0,
};
// ---------------- Elements ----------------
const grid = document.querySelector("#vehicleGrid");
const emptyState = document.querySelector("#emptyState");
const brandFilter = document.querySelector("#brandFilter");
const sortFilter = document.querySelector("#sortFilter");
const priceFilter = document.querySelector("#priceFilter");
const langToggle = document.querySelector(".lang-toggle");
const menuToggle = document.querySelector(".menu-toggle");
const mainNav = document.querySelector(".main-nav");
const dialog = document.querySelector("#carDialog");
const dialogTitle = document.querySelector("#dialogTitle");
const dialogBody = document.querySelector("#dialogBody");
const dialogClose = document.querySelector("#dialogClose");
const reviewStrip = document.querySelector("#reviewStrip");
const reviewDots = document.querySelector("#reviewDots");
const statCarsCount = document.querySelector("#statCarsCount");
const bookingFeedback = document.querySelector("#bookingFeedback");
// BPF elements
const bpfCar = document.querySelector("#bpfCar");
const bpfFrom = document.querySelector("#bpfFrom");
const bpfTo = document.querySelector("#bpfTo");
const bpfDayDate = document.querySelector("#bpfDayDate");
const bpfWeekendDate = document.querySelector("#bpfWeekendDate");
const bpfName = document.querySelector("#bpfName");
const bpfEmail = document.querySelector("#bpfEmail");
const bpfPhone = document.querySelector("#bpfPhone");
const bpfMessage = document.querySelector("#bpfMessage");
const bpfFileId = document.querySelector("#bpfFileId");
const bpfFileIncome = document.querySelector("#bpfFileIncome");
const bpfSubmitBtn = document.querySelector("#bpfSubmit");
const bpfSidebar = document.querySelector("#bpfSidebar");
const bpfSidebarContent = document.querySelector("#bpfSidebarContent");
const bpfSidebarPlaceholder = document.querySelector(".bpf-sidebar-placeholder");
let bpfDurationMode = ""; // "day" | "weekend" | "custom" | ""
let bpfSubmitting = false;
function formatYmdLocal(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function parseYmdLocal(ymd) {
const [y, m, d] = (ymd || "").split("-").map(Number);
if (!y || !m || !d) return null;
return new Date(y, m - 1, d);
}
function addDaysYmd(ymd, days) {
const d = parseYmdLocal(ymd);
if (!d) return null;
d.setDate(d.getDate() + days);
return formatYmdLocal(d);
}
function isSaturdayYmd(ymd) {
const d = parseYmdLocal(ymd);
return !!d && d.getDay() === 6;
}
function nextSaturdayYmd(ymd) {
const d = parseYmdLocal(ymd);
if (!d) return null;
const delta = (6 - d.getDay() + 7) % 7;
d.setDate(d.getDate() + delta);
return formatYmdLocal(d);
}
// Set min dates
const today = formatYmdLocal(new Date());
[bpfFrom, bpfTo, bpfDayDate, bpfWeekendDate].forEach(el => {
if (el) el.min = today;
});
document.querySelector("#year").textContent = new Date().getFullYear();
// ----------------Toast Notification ----------------
function showToast(message, duration = 3000) {
const toast = document.querySelector("#toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove("show");
}, duration);
}
// ---------------- Vehicles ----------------
async function loadVehicles() {
const { data, error } = await supabase
.from("vehicles")
.select("*")
.eq("is_active", true)
.order("sort_order", { ascending: true });
if (error) {
console.error("Failed to load vehicles", error);
grid.innerHTML = `<p style="color:var(--danger);">Unable to load vehicles: ${error.message}</p>`;
return;
}
state.vehicles = data || [];
statCarsCount.textContent = state.vehicles.length;
const brands = [...new Set(state.vehicles.map(v => v.brand))].sort();
brandFilter.innerHTML = `<option value="all">${t("all")}</option>` +
brands.map(b => `<option value="${b}">${b}</option>`).join("");
// Populate BPF car selector
bpfCar.innerHTML = `<option value="">${t("bpfSelectVehicle")}</option>` +
state.vehicles.map(v => `<option value="${v.id}">${v.brand} ${v.model}</option>`).join("");
applyFilters();
}
function applyFilters() {
let rows = [...state.vehicles];
if (state.brand !== "all") rows = rows.filter(v => v.brand === state.brand);
if (state.maxPrice) rows = rows.filter(v => v.daily_price_eur <= state.maxPrice);
switch (state.sort) {
case "priceAsc": rows.sort((a, b) => a.daily_price_eur - b.daily_price_eur); break;
case "priceDesc": rows.sort((a, b) => b.daily_price_eur - a.daily_price_eur); break;
case "powerDesc": rows.sort((a, b) => b.power_hp - a.power_hp); break;
default: rows.sort((a, b) => a.sort_order - b.sort_order);
}
state.filtered = rows;
renderGrid();
}
function renderGrid() {
grid.innerHTML = "";
emptyState.style.display = state.filtered.length ? "none" : "block";
for (const v of state.filtered) {
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
const card = document.createElement("article");
card.className = "vehicle-card";
card.innerHTML = `
<div class="vehicle-photo">
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand)} ${escapeAttr(v.model)}" loading="lazy" decoding="async" />
<span class="badge" aria-hidden="true">${escapeHtml(v.brand)}</span>
</div>
<div class="vehicle-body">
<p class="model-brand" aria-hidden="true">${escapeHtml(v.brand)}</p>
<h3>${escapeHtml(v.model)}</h3>
<div class="spec-row">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div class="vehicle-footer">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<div style="display:flex;gap:0.4rem;">
<button class="btn ghost small" data-details="${v.id}" aria-label="${t("details")} ${escapeAttr(v.brand)} ${escapeAttr(v.model)}">${t("details")}</button>
<button class="btn small" data-book="${v.id}" aria-label="${t("book")} ${escapeAttr(v.brand)} ${escapeAttr(v.model)}">${t("book")}</button>
</div>
</div>
</div>
`;
grid.appendChild(card);
}
grid.querySelectorAll("[data-details]").forEach(b => {
b.addEventListener("click", () => openDetails(b.dataset.details));
});
grid.querySelectorAll("[data-book]").forEach(b => {
b.addEventListener("click", () => {
bpfCar.value = b.dataset.book;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
});
}
function openDetails(id) {
const v = state.vehicles.find(x => x.id === id);
if (!v) return;
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
const lang = getLang();
const desc = lang === "en" ? v.description_en : v.description_de;
dialogTitle.textContent = `${v.brand} ${v.model}`;
dialogBody.innerHTML = `
<img src="${escapeAttr(photoUrl)}" alt="${escapeAttr(v.brand + ' ' + v.model)}" />
<p>${escapeHtml(desc || "")}</p>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.power_hp}</strong><span>${t("hp")}</span></div>
<div><strong>${v.top_speed_kmh}</strong><span>${t("kmh")}</span></div>
<div><strong>${escapeHtml(v.acceleration)}</strong><span>${t("accel")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;">
<div><strong>${v.seats}</strong><span>${t("seats")}</span></div>
<div><strong>€ ${v.weekend_price_eur || v.daily_price_eur}</strong><span>${t("bpfWeekendRate")}</span></div>
<div><strong>${v.included_km_per_day || 150}</strong><span>${t("bpfInclKmPerDay")}</span></div>
</div>
<div class="spec-row" style="margin:1rem 0;grid-template-columns:1fr;">
<div><strong>€ ${(v.kaution_eur || 5000).toLocaleString("de-DE")}</strong><span>${t("bpfDeposit")}</span></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:1rem;">
<div class="vehicle-price">€ ${v.daily_price_eur}<span> / ${t("perDay")}</span></div>
<button class="btn" id="dialogBook">${t("bookNow")}</button>
</div>
`;
dialog.showModal();
document.querySelector("#dialogBook").addEventListener("click", () => {
dialog.close();
bpfCar.value = v.id;
bpfCar.dispatchEvent(new Event("change"));
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
});
}
// ---------------- Reviews ----------------
function renderReviews() {
state.reviewIdx = state.reviewIdx % REVIEWS.length;
const r = REVIEWS[state.reviewIdx];
reviewStrip.innerHTML = `
<p class="review-quote">"${escapeHtml(r.quote)}"</p>
<p class="review-author">${escapeHtml(r.author)}</p>
`;
reviewDots.innerHTML = REVIEWS.map((_, i) =>
`<button class="${i === state.reviewIdx ? 'active' : ''}" data-rev="${i}" aria-label="${t("review")} ${i + 1}"></button>`
).join("");
reviewDots.querySelectorAll("button").forEach(b => {
b.addEventListener("click", () => { state.reviewIdx = +b.dataset.rev; renderReviews(); });
});
}
setInterval(() => { state.reviewIdx++; renderReviews(); }, 5000);
// ---------------- BPF WIZARD ----------------
const bpfStepPanels = [
document.querySelector("#bpfStep1"),
document.querySelector("#bpfStep2"),
document.querySelector("#bpfStep3"),
];
const bpfStepButtons = document.querySelectorAll(".bpf-step");
let bpfCurrentStep = 1;
function showBpfStep(n) {
bpfCurrentStep = n;
bpfStepPanels.forEach((p, i) => p.style.display = (i === n - 1) ? "block" : "none");
bpfStepButtons.forEach((b, i) => {
b.classList.toggle("active", i === n - 1);
b.classList.toggle("done", i < n - 1);
});
}
document.querySelector("#bpfNext1").addEventListener("click", () => {
if (!bpfCar.value) { bpfCar.focus(); return; }
// Resolve effective from/to based on duration mode
const { from, to } = getBpfDates();
if (!from || !to || new Date(to) <= new Date(from)) {
bookingFeedback.textContent = bpfDurationMode === "weekend" ? t("weekendSaturdayOnly") : t("invalidDates");
bookingFeedback.className = "form-feedback error";
return;
}
// Sync hidden from/to for downstream use
bpfFrom.value = from;
bpfTo.value = to;
bookingFeedback.textContent = "";
showBpfStep(2);
});
document.querySelector("#bpfBack2").addEventListener("click", () => showBpfStep(1));
document.querySelector("#bpfNext2").addEventListener("click", () => {
if (!bpfName.value || !bpfEmail.value) { bpfEmail.focus(); return; }
showBpfStep(3);
});
document.querySelector("#bpfBack3").addEventListener("click", () => showBpfStep(2));
// File upload display
bpfFileId.addEventListener("change", () => {
document.querySelector("#bpfFileIdName").textContent = bpfFileId.files[0]?.name || "";
});
bpfFileIncome.addEventListener("change", () => {
document.querySelector("#bpfFileIncomeName").textContent = bpfFileIncome.files[0]?.name || "";
});
// ---------------- Duration Presets ----------------
function setDurationMode(mode) {
bpfDurationMode = mode || "";
document.querySelectorAll(".bpf-preset").forEach(b => b.classList.toggle("active", b.dataset.preset === mode));
document.querySelector("#bpfDateDay").style.display = mode === "day" ? "block" : "none";
document.querySelector("#bpfDateWeekend").style.display = mode === "weekend" ? "block" : "none";
document.querySelector("#bpfDateCustom").style.display = mode === "custom" ? "block" : "none";
updateSidebar();
}
// Fresh page load: no duration selected, so no date inputs are visible.
setDurationMode("");
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;
}
async 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;
// Fetch price from backend RPC
const { data: price, error } = await supabase.rpc("calculate_price", {
p_vehicle_id: v.id,
p_date_from: from,
p_date_to: to,
});
if (error || !price) { console.error("calculate_price error:", error, "data:", price); return; }
const totalDays = price.total_days;
const weekdays = price.weekday_count;
const weekendDays = price.weekend_day_count;
const weekdayCost = price.daily_subtotal;
const weekendCost = price.weekend_subtotal;
const subtotal = price.subtotal_eur;
const vat = price.vat_eur;
const total = price.total_eur;
const deposit = price.deposit_eur;
const includedKmPerDay = price.included_km_per_day || 150;
const includedKm = totalDays * includedKmPerDay;
const photoUrl = optimizedVehiclePhotoUrl(v.photo_url);
if (totalDays > 2) {
// Individuell mode: show info banner instead of pricing
bpfSidebarPlaceholder.style.display = "none";
bpfSidebarContent.style.display = "block";
bpfSidebarContent.innerHTML = `
<h4>${t("bpfPriceOverview")}</h4>
<div class="bpf-info-banner">
<p><strong>${t("bpfIndividuellTitle")}</strong></p>
<p>${t("bpfIndividuellDesc")}</p>
</div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></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>
`;
} else {
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} ×${price.daily_price_eur})</span><span>€ ${weekdayCost.toLocaleString("de-DE")}</span></div>` : ""}
${weekendDays > 0 ? `<div class="bpf-price-row"><span>${t("bpfWeekendDays")} (${weekendDays} ×${price.weekend_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>€ ${(price.price_per_km_eur || 1.50).toFixed(2).replace('.', ',')}${t("bpfPerKm")}</span></div>
<div class="bpf-car-preview" style="background-image:url('${escapeAttr(photoUrl)}');"></div>
<p class="bpf-car-name">${escapeHtml(v.brand)} ${escapeHtml(v.model)}</p>
<p class="bpf-car-specs">${v.power_hp} ${t("hp")}${v.top_speed_kmh} ${t("kmh")}${escapeHtml(v.acceleration)}</p>
`;
}
}
bpfCar.addEventListener("change", updateSidebar);
bpfFrom.addEventListener("change", updateSidebar);
bpfTo.addEventListener("change", updateSidebar);
// Submit BPF
document.querySelector("#bpfSubmit").addEventListener("click", async () => {
if (bpfSubmitting) return;
bpfSubmitting = true;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = true;
bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = "...";
const vehicle = state.vehicles.find(v => v.id === bpfCar.value);
const { from, to } = getBpfDates();
const payload = {
p_name: bpfName.value,
p_email: bpfEmail.value,
p_phone: bpfPhone.value || "",
p_vehicle_id: bpfCar.value || null,
p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
p_date_from: from || null,
p_date_to: to || null,
p_message: bpfMessage.value || "",
p_source: "website",
};
// Create lead via RPC (returns inserted id without anon SELECT privileges)
const { data: leadId, error } = await supabase.rpc("create_lead", payload);
if (error) {
console.error(error);
bookingFeedback.className = "form-feedback error";
bookingFeedback.textContent = t("bookingFailed");
bpfSubmitting = false;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
return;
}
// Upload files
const uploads = [];
if (bpfFileId.files[0]) {
uploads.push(uploadDoc(leadId, bpfFileId.files[0], "id_document"));
}
if (bpfFileIncome.files[0]) {
uploads.push(uploadDoc(leadId, bpfFileIncome.files[0], "income_proof"));
}
await Promise.all(uploads);
bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = "";
showToast(t("bookingSuccess"), 4000);
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("");
updateSidebar();
bpfSubmitting = false;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
});
async function uploadDoc(leadId, file, kind) {
try {
const ext = (file.name.split(".").pop() || "bin").toLowerCase();
const path = `${leadId}/${kind}.${ext}`;
const { error: upErr } = await supabase.storage
.from("customer-documents")
.upload(path, file, { contentType: file.type });
if (upErr) { console.error("Upload failed:", upErr); return; }
await supabase.from("lead_attachments").insert({
lead_id: leadId,
bucket: "customer-documents",
file_path: path,
file_name: file.name,
mime_type: file.type,
kind: kind,
});
} catch (e) { console.error("Doc upload error:", e); }
}
// ---------------- Events ----------------
brandFilter.addEventListener("change", e => { state.brand = e.target.value; applyFilters(); });
sortFilter.addEventListener("change", e => { state.sort = e.target.value; applyFilters(); });
priceFilter.addEventListener("input", e => { state.maxPrice = e.target.value ? +e.target.value : null; applyFilters(); });
dialogClose.addEventListener("click", () => dialog.close());
menuToggle.addEventListener("click", () => mainNav.classList.toggle("open"));
mainNav.addEventListener("click", e => { if (e.target.tagName === "A") mainNav.classList.remove("open"); });
langToggle.addEventListener("click", () => {
const next = getLang() === "de" ? "en" : "de";
setLang(next);
langToggle.textContent = next === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
applyFilters();
});
// ---------------- Helpers ----------------
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function escapeAttr(s) { return escapeHtml(s); }
function optimizedVehiclePhotoUrl(url) {
const raw = String(url ?? "");
if (!raw) return raw;
return raw.replace("/images/ferrari-main-car.png", "/images/ferrari-main-car-mobile.jpg");
}
// ---------------- Boot ----------------
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
loadVehicles();
// Load hero image from site_settings
(async () => {
const { data } = await supabase.from("site_settings").select("value").eq("key", "hero_image_url").single();
if (data && data.value) {
const heroUrl = data.value.includes("/images/ferrari-main-car.png")
? "/images/ferrari-main-car-mobile.jpg"
: data.value;
document.querySelector(".hero").style.setProperty("--hero-bg", `url('${heroUrl}')`);
}
})();