import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL || "http://localhost:54321"; const SUPA_KEY = window.MCCARS_CONFIG?.SUPABASE_ANON_KEY || ""; // Only the public anon key lives here. Write access is gated by RLS policies // that require an `authenticated` JWT obtained via signInWithPassword. const supabase = createClient(SUPA_URL, SUPA_KEY, { auth: { persistSession: true, storageKey: "mccars.auth" }, }); // ----- DOM ----- const loginView = document.querySelector("#loginView"); const adminView = document.querySelector("#adminView"); const rotateView = document.querySelector("#rotateView"); const loginForm = document.querySelector("#loginForm"); const loginError = document.querySelector("#loginError"); const rotateForm = document.querySelector("#rotateForm"); const rotateError = document.querySelector("#rotateError"); const logoutBtn = document.querySelector("#logoutBtn"); const changePwBtn = document.querySelector("#changePwBtn"); const adminWho = document.querySelector("#adminWho"); const leadsTableBody = document.querySelector("#leadsTable tbody"); const leadsEmpty = document.querySelector("#leadsEmpty"); const leadsBadge = document.querySelector("#leadsBadge"); const customersTableBody = document.querySelector("#customersTable tbody"); const customersEmpty = document.querySelector("#customersEmpty"); const customersBadge = document.querySelector("#customersBadge"); const leadDialog = document.querySelector("#leadDialog"); const leadDialogTitle = document.querySelector("#leadDialogTitle"); const leadDialogBody = document.querySelector("#leadDialogBody"); const leadDialogClose = document.querySelector("#leadDialogClose"); const vehicleForm = document.querySelector("#vehicleForm"); const formFeedback = document.querySelector("#formFeedback"); const formTitle = document.querySelector("#formTitle"); const saveBtn = document.querySelector("#saveBtn"); const resetBtn = document.querySelector("#resetBtn"); const photoInput = document.querySelector("#photoInput"); const photoPreview = document.querySelector("#photoPreview"); const tableBody = document.querySelector("#adminTable tbody"); // ----- State ----- const state = { user: null, loginPassword: null, // captured at signInWithPassword to block same-value rotation leadView: "active", // "active" | "inactive" leads: [], customers: [], vehicles: [], vehicleMap: new Map(), currentPhotoPath: null, realtimeChannel: null, forcedRotation: false, }; // ========================================================================= // AUTH FLOW // ========================================================================= async function bootstrap() { const { data } = await supabase.auth.getSession(); if (data?.session) { await onAuthenticated(data.session.user); } else { show("login"); } } loginForm.addEventListener("submit", async (e) => { e.preventDefault(); loginError.textContent = ""; const fd = new FormData(loginForm); const pw = fd.get("password"); const { data, error } = await supabase.auth.signInWithPassword({ email: fd.get("email"), password: pw, }); if (error) { loginError.textContent = error.message; return; } state.loginPassword = pw; await onAuthenticated(data.user); }); logoutBtn.addEventListener("click", async () => { if (state.realtimeChannel) await supabase.removeChannel(state.realtimeChannel); await supabase.auth.signOut(); location.reload(); }); changePwBtn.addEventListener("click", () => { state.forcedRotation = false; show("rotate"); }); rotateForm.addEventListener("submit", async (e) => { e.preventDefault(); rotateError.textContent = ""; const fd = new FormData(rotateForm); const pw1 = fd.get("pw1"); const pw2 = fd.get("pw2"); if (pw1 !== pw2) { rotateError.textContent = "Passwoerter stimmen nicht ueberein."; return; } if (pw1.length < 10) { rotateError.textContent = "Mindestens 10 Zeichen."; return; } if (state.loginPassword && pw1 === state.loginPassword) { rotateError.textContent = "Neues Passwort muss sich vom alten unterscheiden."; return; } const { error } = await supabase.auth.updateUser({ password: pw1, data: { must_change_password: false }, }); if (error) { rotateError.textContent = error.message; return; } state.loginPassword = pw1; rotateForm.reset(); if (state.forcedRotation) { state.forcedRotation = false; await enterAdmin(); } else { show("admin"); } }); async function onAuthenticated(user) { state.user = user; adminWho.textContent = user.email; // Force rotation path (first-login bootstrap) const meta = user.user_metadata || {}; if (meta.must_change_password) { state.forcedRotation = true; show("rotate"); return; } await enterAdmin(); } async function enterAdmin() { show("admin"); await Promise.all([loadVehicles(), loadLeads(), loadCustomers()]); renderActiveTab(); attachRealtime(); } function show(which) { loginView.style.display = which === "login" ? "block" : "none"; rotateView.style.display = which === "rotate" ? "block" : "none"; adminView.style.display = which === "admin" ? "block" : "none"; } // ========================================================================= // TABS // ========================================================================= const tabButtons = document.querySelectorAll(".admin-tabs .tab"); const tabPanels = { leads: document.querySelector("#tab-leads"), customers: document.querySelector("#tab-customers"), vehicles: document.querySelector("#tab-vehicles"), }; let activeTab = "leads"; tabButtons.forEach(btn => btn.addEventListener("click", () => { activeTab = btn.dataset.tab; tabButtons.forEach(b => b.classList.toggle("active", b === btn)); for (const [k, el] of Object.entries(tabPanels)) el.style.display = (k === activeTab) ? "block" : "none"; renderActiveTab(); })); function renderActiveTab() { if (activeTab === "leads") renderLeads(); if (activeTab === "customers") renderCustomers(); if (activeTab === "vehicles") renderVehicles(); } // Sub-tabs (active/inactive leads) document.querySelectorAll(".sub-tab").forEach(btn => btn.addEventListener("click", () => { state.leadView = btn.dataset.lview; document.querySelectorAll(".sub-tab").forEach(b => b.classList.toggle("active", b === btn)); renderLeads(); })); // ========================================================================= // VEHICLES (existing CRUD preserved) // ========================================================================= async function loadVehicles() { const { data, error } = await supabase .from("vehicles") .select("*") .order("sort_order", { ascending: true }); if (error) { console.error(error); return; } state.vehicles = data || []; state.vehicleMap = new Map(state.vehicles.map(v => [v.id, v])); } function renderVehicles() { tableBody.innerHTML = ""; for (const v of state.vehicles) { const tr = document.createElement("tr"); tr.innerHTML = `
${esc(v.brand)}
${esc(v.model)} € ${v.daily_price_eur} ${v.is_active ? "✅" : "—"} `; tableBody.appendChild(tr); } tableBody.querySelectorAll("[data-edit]").forEach(b => b.addEventListener("click", () => loadForEdit(b.dataset.edit))); tableBody.querySelectorAll("[data-del]").forEach(b => b.addEventListener("click", () => deleteVehicle(b.dataset.del))); } function loadForEdit(id) { const v = state.vehicleMap.get(id); if (!v) return; formTitle.textContent = `Fahrzeug bearbeiten · ${v.brand} ${v.model}`; vehicleForm.id.value = v.id; vehicleForm.brand.value = v.brand; vehicleForm.model.value = v.model; vehicleForm.power_hp.value = v.power_hp; vehicleForm.top_speed_kmh.value = v.top_speed_kmh; vehicleForm.acceleration.value = v.acceleration; vehicleForm.seats.value = v.seats; vehicleForm.daily_price_eur.value = v.daily_price_eur; vehicleForm.sort_order.value = v.sort_order; vehicleForm.location.value = v.location; vehicleForm.description_de.value = v.description_de; vehicleForm.description_en.value = v.description_en; vehicleForm.photo_url.value = v.photo_url; vehicleForm.is_active.checked = v.is_active; state.currentPhotoPath = v.photo_path || null; updatePreview(v.photo_url); window.scrollTo({ top: 0, behavior: "smooth" }); } resetBtn.addEventListener("click", () => { vehicleForm.reset(); vehicleForm.id.value = ""; vehicleForm.is_active.checked = true; vehicleForm.sort_order.value = 100; vehicleForm.location.value = "Steiermark (TBD)"; vehicleForm.seats.value = 2; state.currentPhotoPath = null; updatePreview(""); formTitle.textContent = "Neues Fahrzeug"; formFeedback.textContent = ""; }); vehicleForm.addEventListener("submit", async (e) => { e.preventDefault(); saveBtn.disabled = true; formFeedback.className = "form-feedback"; formFeedback.textContent = "Saving..."; try { const fd = new FormData(vehicleForm); const payload = { brand: fd.get("brand"), model: fd.get("model"), power_hp: +fd.get("power_hp") || 0, top_speed_kmh: +fd.get("top_speed_kmh") || 0, acceleration: fd.get("acceleration") || "", seats: +fd.get("seats") || 2, daily_price_eur: +fd.get("daily_price_eur") || 0, sort_order: +fd.get("sort_order") || 100, location: fd.get("location") || "Steiermark (TBD)", description_de: fd.get("description_de") || "", description_en: fd.get("description_en") || "", photo_url: fd.get("photo_url") || "", photo_path: state.currentPhotoPath, is_active: !!fd.get("is_active"), }; const id = fd.get("id"); const { error } = id ? await supabase.from("vehicles").update(payload).eq("id", id) : await supabase.from("vehicles").insert(payload); if (error) throw error; formFeedback.textContent = "Gespeichert."; await loadVehicles(); renderVehicles(); if (!id) resetBtn.click(); } catch (err) { formFeedback.className = "form-feedback error"; formFeedback.textContent = err.message || String(err); } finally { saveBtn.disabled = false; } }); async function deleteVehicle(id) { const v = state.vehicleMap.get(id); if (!v) return; if (!confirm(`Delete ${v.brand} ${v.model}?`)) return; if (v.photo_path) await supabase.storage.from("vehicle-photos").remove([v.photo_path]); const { error } = await supabase.from("vehicles").delete().eq("id", id); if (error) { alert(error.message); return; } await loadVehicles(); renderVehicles(); } // Photo upload photoInput.addEventListener("change", async () => { const file = photoInput.files?.[0]; if (!file) return; formFeedback.className = "form-feedback"; formFeedback.textContent = "Uploading photo..."; try { const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); const path = `${crypto.randomUUID()}.${ext}`; const { error: upErr } = await supabase.storage .from("vehicle-photos") .upload(path, file, { contentType: file.type, upsert: false }); if (upErr) throw upErr; const { data: pub } = supabase.storage.from("vehicle-photos").getPublicUrl(path); state.currentPhotoPath = path; vehicleForm.photo_url.value = pub.publicUrl; updatePreview(pub.publicUrl); formFeedback.textContent = "Upload ok."; } catch (err) { formFeedback.className = "form-feedback error"; formFeedback.textContent = err.message || String(err); } }); function updatePreview(url) { photoPreview.style.backgroundImage = url ? `url('${url}')` : ""; } // ========================================================================= // LEADS // ========================================================================= async function loadLeads() { const { data, error } = await supabase .from("leads") .select("*") .order("created_at", { ascending: false }); if (error) { console.error(error); return; } state.leads = data || []; updateBadges(); } function renderLeads() { const wantActive = state.leadView === "active"; const rows = state.leads.filter(l => l.is_active === wantActive); leadsEmpty.style.display = rows.length ? "none" : "block"; leadsTableBody.innerHTML = ""; for (const l of rows) { const tr = document.createElement("tr"); tr.innerHTML = ` ${fmtDate(l.created_at)} ${esc(l.name)}
${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""} ${esc(l.vehicle_label || "—")} ${esc(l.date_from || "—")} → ${esc(l.date_to || "—")} ${esc(l.status)} ${wantActive ? ` ` : ` `} `; leadsTableBody.appendChild(tr); } leadsTableBody.querySelectorAll("[data-open]").forEach(b => b.addEventListener("click", () => openLead(b.dataset.open))); leadsTableBody.querySelectorAll("[data-qual]").forEach(b => b.addEventListener("click", () => qualifyLead(b.dataset.qual))); leadsTableBody.querySelectorAll("[data-disq]").forEach(b => b.addEventListener("click", () => disqualifyLead(b.dataset.disq))); leadsTableBody.querySelectorAll("[data-reopen]").forEach(b => b.addEventListener("click", () => reopenLead(b.dataset.reopen))); } function openLead(id) { const l = state.leads.find(x => x.id === id); if (!l) return; leadDialogTitle.textContent = `${l.name} · ${l.status}`; leadDialogBody.innerHTML = `
Eingang
${fmtDate(l.created_at)}
E-Mail
${esc(l.email)}
Telefon
${esc(l.phone || "—")}
Fahrzeug
${esc(l.vehicle_label || "—")}
Zeitraum
${esc(l.date_from || "—")} → ${esc(l.date_to || "—")}
Nachricht
${esc(l.message || "—")}
Status
${esc(l.status)}
Notiz
${l.is_active ? ` ` : ``}
`; leadDialog.showModal(); const note = () => document.querySelector("#leadNote").value; document.querySelector("#dlgQual")?.addEventListener("click", () => qualifyLead(l.id, note())); document.querySelector("#dlgDisq")?.addEventListener("click", () => disqualifyLead(l.id, note())); document.querySelector("#dlgReopen")?.addEventListener("click", () => reopenLead(l.id)); } leadDialogClose.addEventListener("click", () => leadDialog.close()); async function qualifyLead(id, notes = "") { const { error } = await supabase.rpc("qualify_lead", { p_lead_id: id, p_notes: notes }); if (error) { alert(error.message); return; } leadDialog.open && leadDialog.close(); // Realtime will refresh; still trigger a quick reload for responsiveness. await Promise.all([loadLeads(), loadCustomers()]); renderActiveTab(); } async function disqualifyLead(id, notes = "") { const { error } = await supabase.rpc("disqualify_lead", { p_lead_id: id, p_notes: notes }); if (error) { alert(error.message); return; } leadDialog.open && leadDialog.close(); await loadLeads(); renderLeads(); updateBadges(); } async function reopenLead(id) { if (!confirm("Lead wieder in 'Aktive' verschieben und Kunde ggf. entfernen?")) return; const { error } = await supabase.rpc("reopen_lead", { p_lead_id: id }); if (error) { alert(error.message); return; } leadDialog.open && leadDialog.close(); await Promise.all([loadLeads(), loadCustomers()]); renderActiveTab(); } // ========================================================================= // CUSTOMERS // ========================================================================= async function loadCustomers() { const { data, error } = await supabase .from("customers") .select("*") .order("created_at", { ascending: false }); if (error) { console.error(error); return; } state.customers = data || []; updateBadges(); } function renderCustomers() { customersEmpty.style.display = state.customers.length ? "none" : "block"; customersTableBody.innerHTML = ""; for (const c of state.customers) { const tr = document.createElement("tr"); tr.innerHTML = ` ${fmtDate(c.first_contacted_at)} ${esc(c.name)}
${esc(c.email)} ${esc(c.phone || "—")} ${esc(c.lead_id?.slice(0, 8) || "—")} ${esc(c.status)} `; customersTableBody.appendChild(tr); } customersTableBody.querySelectorAll("[data-toggle]").forEach(b => b.addEventListener("click", async () => { const id = b.dataset.toggle; const next = b.dataset.status === "active" ? "inactive" : "active"; const { error } = await supabase.from("customers").update({ status: next }).eq("id", id); if (error) { alert(error.message); return; } await loadCustomers(); renderCustomers(); })); } function updateBadges() { const active = state.leads.filter(l => l.is_active).length; leadsBadge.textContent = String(active); customersBadge.textContent = String(state.customers.length); } // ========================================================================= // REALTIME // ========================================================================= function attachRealtime() { if (state.realtimeChannel) return; state.realtimeChannel = supabase .channel("mccars-admin") .on("postgres_changes", { event: "*", schema: "public", table: "leads" }, async () => { await loadLeads(); if (activeTab === "leads") renderLeads(); }) .on("postgres_changes", { event: "*", schema: "public", table: "customers" }, async () => { await loadCustomers(); if (activeTab === "customers") renderCustomers(); }) .on("postgres_changes", { event: "*", schema: "public", table: "vehicles" }, async () => { await loadVehicles(); if (activeTab === "vehicles") renderVehicles(); }) .subscribe(); } // ========================================================================= // HELPERS // ========================================================================= function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); } function attr(s) { return esc(s); } function fmtDate(iso) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleString("de-AT", { dateStyle: "short", timeStyle: "short" }); } bootstrap();