feat: add RPC for public lead creation and update migrations in docker-compose files

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
LagoESP
2026-04-29 16:33:01 +02:00
parent e85b319c93
commit bc61ffa206
9 changed files with 139 additions and 76 deletions
+1
View File
@@ -19,6 +19,7 @@ services:
- ./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 - ./supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
- ./supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro - ./supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
- ./supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro
kong: kong:
volumes: volumes:
+2
View File
@@ -212,6 +212,7 @@ services:
- /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 - /mnt/user/appdata/mc-cars/supabase/migrations/03-booking-flow.sql:/sql/03-booking-flow.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro - /mnt/user/appdata/mc-cars/supabase/migrations/04-kaution-weekend-km.sql:/sql/04-kaution-weekend-km.sql:ro
- /mnt/user/appdata/mc-cars/supabase/migrations/05-create-lead-rpc.sql:/sql/05-create-lead-rpc.sql:ro
entrypoint: ["sh","-c"] entrypoint: ["sh","-c"]
command: command:
- | - |
@@ -232,6 +233,7 @@ services:
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 psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/03-booking-flow.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/04-kaution-weekend-km.sql psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/04-kaution-weekend-km.sql
psql "postgresql://postgres:$$PGPASSWORD@db:5432/postgres" -v ON_ERROR_STOP=1 -f /sql/05-create-lead-rpc.sql
echo "post-init done." echo "post-init done."
restart: "no" restart: "no"
networks: [mccars] networks: [mccars]
+2 -2
View File
@@ -65,7 +65,7 @@
<button class="lang-toggle" type="button" aria-label="Sprache wechseln" style="margin-left:auto;">EN</button> <button class="lang-toggle" type="button" aria-label="Sprache wechseln" style="margin-left:auto;">EN</button>
<span id="adminWho" style="color:var(--muted);font-size:0.85rem;margin-left:1rem;"></span> <span id="adminWho" style="color:var(--muted);font-size:0.85rem;margin-left:1rem;"></span>
<button id="changePwBtn" class="btn ghost small" data-i18n="adminChangePw">Passwort aendern</button> <button id="changePwBtn" class="btn ghost small" data-i18n="adminChangePw">Passwort ändern</button>
<button id="logoutBtn" class="btn small" data-i18n="adminLogout">Logout</button> <button id="logoutBtn" class="btn small" data-i18n="adminLogout">Logout</button>
</div> </div>
</div> </div>
@@ -108,7 +108,7 @@
<div class="tab-panel" id="tab-customers" style="display:none;"> <div class="tab-panel" id="tab-customers" style="display:none;">
<div class="panel"> <div class="panel">
<h2>Kunden</h2> <h2>Kunden</h2>
<p class="muted" style="margin-top:-0.4rem;">Entstehen automatisch, sobald ein Lead qualifiziert wird. Die Quelle bleibt als <code>lead_id</code> verknuepft.</p> <p class="muted" style="margin-top:-0.4rem;">Entstehen automatisch, sobald ein Lead qualifiziert wird. Die Quelle bleibt als <code>lead_id</code> verknüpft.</p>
<table class="admin-table" id="customersTable"> <table class="admin-table" id="customersTable">
<thead> <thead>
<tr> <tr>
+1 -1
View File
@@ -104,7 +104,7 @@ rotateForm.addEventListener("submit", async (e) => {
const fd = new FormData(rotateForm); const fd = new FormData(rotateForm);
const pw1 = fd.get("pw1"); const pw1 = fd.get("pw1");
const pw2 = fd.get("pw2"); const pw2 = fd.get("pw2");
if (pw1 !== pw2) { rotateError.textContent = "Passwoerter stimmen nicht ueberein."; return; } if (pw1 !== pw2) { rotateError.textContent = "Passwörter stimmen nicht überein."; return; }
if (pw1.length < 10) { rotateError.textContent = "Mindestens 10 Zeichen."; return; } if (pw1.length < 10) { rotateError.textContent = "Mindestens 10 Zeichen."; return; }
if (state.loginPassword && pw1 === state.loginPassword) { if (state.loginPassword && pw1 === state.loginPassword) {
rotateError.textContent = "Neues Passwort muss sich vom alten unterscheiden."; rotateError.textContent = "Neues Passwort muss sich vom alten unterscheiden.";
+26 -18
View File
@@ -48,10 +48,12 @@ const bpfPhone = document.querySelector("#bpfPhone");
const bpfMessage = document.querySelector("#bpfMessage"); const bpfMessage = document.querySelector("#bpfMessage");
const bpfFileId = document.querySelector("#bpfFileId"); const bpfFileId = document.querySelector("#bpfFileId");
const bpfFileIncome = document.querySelector("#bpfFileIncome"); const bpfFileIncome = document.querySelector("#bpfFileIncome");
const bpfSubmitBtn = document.querySelector("#bpfSubmit");
const bpfSidebar = document.querySelector("#bpfSidebar"); const bpfSidebar = document.querySelector("#bpfSidebar");
const bpfSidebarContent = document.querySelector("#bpfSidebarContent"); const bpfSidebarContent = document.querySelector("#bpfSidebarContent");
const bpfSidebarPlaceholder = document.querySelector(".bpf-sidebar-placeholder"); const bpfSidebarPlaceholder = document.querySelector(".bpf-sidebar-placeholder");
let bpfDurationMode = "custom"; // "day" | "weekend" | "custom" let bpfDurationMode = "custom"; // "day" | "weekend" | "custom"
let bpfSubmitting = false;
function formatYmdLocal(d) { function formatYmdLocal(d) {
const y = d.getFullYear(); const y = d.getFullYear();
@@ -220,21 +222,20 @@ function openDetails(id) {
// ---------------- Reviews ---------------- // ---------------- Reviews ----------------
function renderReviews() { function renderReviews() {
const list = REVIEWS[getLang()]; state.reviewIdx = state.reviewIdx % REVIEWS.length;
state.reviewIdx = state.reviewIdx % list.length; const r = REVIEWS[state.reviewIdx];
const r = list[state.reviewIdx];
reviewStrip.innerHTML = ` reviewStrip.innerHTML = `
<p class="review-quote">"${escapeHtml(r.quote)}"</p> <p class="review-quote">"${escapeHtml(r.quote)}"</p>
<p class="review-author">${escapeHtml(r.author)}</p> <p class="review-author">${escapeHtml(r.author)}</p>
`; `;
reviewDots.innerHTML = list.map((_, i) => reviewDots.innerHTML = REVIEWS.map((_, i) =>
`<button class="${i === state.reviewIdx ? 'active' : ''}" data-rev="${i}" aria-label="${t("review")} ${i + 1}"></button>` `<button class="${i === state.reviewIdx ? 'active' : ''}" data-rev="${i}" aria-label="${t("review")} ${i + 1}"></button>`
).join(""); ).join("");
reviewDots.querySelectorAll("button").forEach(b => { reviewDots.querySelectorAll("button").forEach(b => {
b.addEventListener("click", () => { state.reviewIdx = +b.dataset.rev; renderReviews(); }); b.addEventListener("click", () => { state.reviewIdx = +b.dataset.rev; renderReviews(); });
}); });
} }
setInterval(() => { state.reviewIdx++; renderReviews(); }, 6000); setInterval(() => { state.reviewIdx++; renderReviews(); }, 5000);
// ---------------- BPF WIZARD ---------------- // ---------------- BPF WIZARD ----------------
const bpfStepPanels = [ const bpfStepPanels = [
@@ -402,38 +403,43 @@ bpfTo.addEventListener("change", updateSidebar);
// Submit BPF // Submit BPF
document.querySelector("#bpfSubmit").addEventListener("click", async () => { document.querySelector("#bpfSubmit").addEventListener("click", async () => {
if (bpfSubmitting) return;
bpfSubmitting = true;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = true;
bookingFeedback.className = "form-feedback"; bookingFeedback.className = "form-feedback";
bookingFeedback.textContent = "..."; bookingFeedback.textContent = "...";
const vehicle = state.vehicles.find(v => v.id === bpfCar.value); const vehicle = state.vehicles.find(v => v.id === bpfCar.value);
const payload = { const payload = {
name: bpfName.value, p_name: bpfName.value,
email: bpfEmail.value, p_email: bpfEmail.value,
phone: bpfPhone.value || "", p_phone: bpfPhone.value || "",
vehicle_id: bpfCar.value || null, p_vehicle_id: bpfCar.value || null,
vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "", p_vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
date_from: bpfFrom.value || null, p_date_from: bpfFrom.value || null,
date_to: bpfTo.value || null, p_date_to: bpfTo.value || null,
message: bpfMessage.value || "", p_message: bpfMessage.value || "",
source: "website", p_source: "website",
}; };
// Create lead // Create lead via RPC (returns inserted id without anon SELECT privileges)
const { data: lead, error } = await supabase.from("leads").insert(payload).select("id").single(); const { data: leadId, error } = await supabase.rpc("create_lead", payload);
if (error) { if (error) {
console.error(error); console.error(error);
bookingFeedback.className = "form-feedback error"; bookingFeedback.className = "form-feedback error";
bookingFeedback.textContent = t("bookingFailed"); bookingFeedback.textContent = t("bookingFailed");
bpfSubmitting = false;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
return; return;
} }
// Upload files // Upload files
const uploads = []; const uploads = [];
if (bpfFileId.files[0]) { if (bpfFileId.files[0]) {
uploads.push(uploadDoc(lead.id, bpfFileId.files[0], "id_document")); uploads.push(uploadDoc(leadId, bpfFileId.files[0], "id_document"));
} }
if (bpfFileIncome.files[0]) { if (bpfFileIncome.files[0]) {
uploads.push(uploadDoc(lead.id, bpfFileIncome.files[0], "income_proof")); uploads.push(uploadDoc(leadId, bpfFileIncome.files[0], "income_proof"));
} }
await Promise.all(uploads); await Promise.all(uploads);
@@ -453,6 +459,8 @@ document.querySelector("#bpfSubmit").addEventListener("click", async () => {
document.querySelector("#bpfFileIncomeName").textContent = ""; document.querySelector("#bpfFileIncomeName").textContent = "";
setDurationMode("custom"); setDurationMode("custom");
updateSidebar(); updateSidebar();
bpfSubmitting = false;
if (bpfSubmitBtn) bpfSubmitBtn.disabled = false;
}); });
async function uploadDoc(leadId, file, kind) { async function uploadDoc(leadId, file, kind) {
+1 -1
View File
@@ -11,7 +11,7 @@
<main class="shell" style="padding:4rem 1rem;"> <main class="shell" style="padding:4rem 1rem;">
<p class="eyebrow">Rechtliches</p> <p class="eyebrow">Rechtliches</p>
<h1>Datenschutz</h1> <h1>Datenschutz</h1>
<p>Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden ueber ein selbstgehostetes Supabase verwaltet.</p> <p>Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden über ein selbstgehostetes Supabase verwaltet.</p>
<p>Ansprechpartner: hello@mccars.at</p> <p>Ansprechpartner: hello@mccars.at</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p> <p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
</main> </main>
+31 -36
View File
@@ -9,7 +9,7 @@ export const translations = {
viewFleet: "Flotte ansehen", viewFleet: "Flotte ansehen",
heroEyebrow: "MC Cars · Sportwagenvermietung", heroEyebrow: "MC Cars · Sportwagenvermietung",
heroTitle: "Fahren auf hoechstem Niveau.", heroTitle: "Fahren auf höchstem Niveau.",
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.", heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Faire Kaution, transparent, sofort startklar.",
statDeposit: "Faire Kaution", statDeposit: "Faire Kaution",
@@ -18,7 +18,7 @@ export const translations = {
fleetEyebrow: "Unsere Flotte", fleetEyebrow: "Unsere Flotte",
fleetTitle: "Handverlesen. Gepflegt. Startklar.", fleetTitle: "Handverlesen. Gepflegt. Startklar.",
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie fuer Details oder buchen Sie direkt.", fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.",
filterBrand: "Marke", filterBrand: "Marke",
filterSort: "Sortierung", filterSort: "Sortierung",
filterPrice: "Max. Preis / Tag", filterPrice: "Max. Preis / Tag",
@@ -37,13 +37,13 @@ export const translations = {
noMatches: "Keine Fahrzeuge gefunden.", noMatches: "Keine Fahrzeuge gefunden.",
whyEyebrow: "Warum MC Cars", whyEyebrow: "Warum MC Cars",
whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspass.", whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspaß.",
whyInsurance: "Versicherungsschutz", whyInsurance: "Versicherungsschutz",
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: "Faire Kaution", whyDeposit: "Faire Kaution",
whyDepositText: "Kein Ueberziehen. Transparente, faire Kaution ohne unnoetige Belastung.", whyDepositText: "Zwei Kautionsarten: Bar oder PayPal-Kaution. Bei PayPal senden wir einen Deposit-Link. Bar wird aktuell persönlich bei der Fahrzeugübergabe abgewickelt.",
reviewsEyebrow: "Kundenmeinungen", reviewsEyebrow: "Kundenmeinungen",
reviewsTitle: "Erlebnisse, die bleiben.", reviewsTitle: "Erlebnisse, die bleiben.",
@@ -58,50 +58,50 @@ export const translations = {
fieldFrom: "Von", fieldFrom: "Von",
fieldTo: "Bis", fieldTo: "Bis",
fieldMessage: "Nachricht", fieldMessage: "Nachricht",
messagePlaceholder: "Wuensche, Uhrzeit, Anlass...", messagePlaceholder: "Wünsche, Uhrzeit, Anlass...",
sendRequest: "Anfrage senden", sendRequest: "Anfrage senden",
invalidDates: "Bitte ein gueltiges Datum waehlen (Bis > Von).", invalidDates: "Bitte ein gültiges Datum wählen (Bis > Von).",
weekendSaturdayOnly: "Im Wochenendmodus bitte einen Samstag waehlen.", weekendSaturdayOnly: "Im Wochenendmodus bitte einen Samstag wählen.",
bookingSuccess: "Danke! Wir melden uns in Kuerze per E-Mail.", bookingSuccess: "Danke! Wir melden uns in Kürze per E-Mail.",
bookingFailed: "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.", bookingFailed: "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.",
// BPF Wizard // BPF Wizard
bpfTitle: "Jetzt buchen", bpfTitle: "Jetzt buchen",
bpfSubtitle: "Waehle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.", bpfSubtitle: "Wähle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.",
stepVehicleTime: "Fahrzeug & Zeitraum", stepVehicleTime: "Fahrzeug & Zeitraum",
stepContact: "Kontaktdaten", stepContact: "Kontaktdaten",
stepVerification: "ID-Verifizierung", stepVerification: "ID-Verifizierung",
bpfRentalDuration: "Mietdauer", bpfRentalDuration: "Mietdauer",
bpfVehicle: "Fahrzeug", bpfVehicle: "Fahrzeug",
bpfSelectVehicle: "Fahrzeug waehlen", bpfSelectVehicle: "Fahrzeug wählen",
bpfDuration: "Mietdauer", bpfDuration: "Mietdauer",
bpfPresetDay: "1 Tag", bpfPresetDay: "1 Tag",
bpfPresetWeekend: "Wochenende", bpfPresetWeekend: "Wochenende",
bpfPresetCustom: "Individuell", bpfPresetCustom: "Individuell",
bpfPickDate: "Datum waehlen", bpfPickDate: "Datum wählen",
bpfPickWeekend: "Wochenende waehlen (Samstag)", bpfPickWeekend: "Wochenende wählen (Samstag)",
bpfStartDate: "Startdatum", bpfStartDate: "Startdatum",
bpfEndDate: "Enddatum", bpfEndDate: "Enddatum",
bpfSelectDate: "Datum waehlen", bpfSelectDate: "Datum wählen",
bpfNext: "Weiter", bpfNext: "Weiter",
bpfBack: "Zurueck", bpfBack: "Zurück",
bpfDailyRate: "Tagesmiete", bpfDailyRate: "Tagesmiete",
bpfWeekendRate: "Wochenendmiete", bpfWeekendRate: "Wochenendmiete",
bpfWeekendDef: "Wochenende: Samstag 9:00 Sonntag 20:00", bpfWeekendDef: "Wochenende: Samstag 9:00 Sonntag 20:00",
bpfMaxKm: "Max. km/Tag", bpfMaxKm: "Max. km/Tag",
bpfExtraKm: "Extra km", bpfExtraKm: "Extra km",
bpfPriceOverview: "Preisuebersicht", bpfPriceOverview: "Preisübersicht",
bpfSelectForPrice: "Waehle Fahrzeug und Datum fuer eine Preisuebersicht", bpfSelectForPrice: "Wähle Fahrzeug und Datum für eine Preisübersicht",
bpfSubtotal: "Zwischensumme", bpfSubtotal: "Zwischensumme",
bpfVat: "MwSt. (20%)", bpfVat: "MwSt. (20%)",
bpfTotal: "Gesamtbetrag", bpfTotal: "Gesamtbetrag",
bpfDeposit: "Kaution", bpfDeposit: "Kaution",
bpfIncludedKm: "Inkludierte Kilometer", bpfIncludedKm: "Inkludierte Kilometer",
bpfIdUpload: "Ausweis / Fuehrerschein", bpfIdUpload: "Ausweis / Führerschein",
bpfIncomeUpload: "Lohnzettel / Gehaltsnachweis", bpfIncomeUpload: "Lohnzettel / Gehaltsnachweis (optional)",
bpfUploadHint: "PDF, JPG, PNG (max. 10 MB)", bpfUploadHint: "PDF, JPG, PNG (max. 10 MB)",
bpfClickUpload: "Klicken zum Hochladen", bpfClickUpload: "Klicken zum Hochladen",
bpfIdNotice: "Ihre Dokumente werden vertraulich behandelt und dienen ausschliesslich der Identitaetsverifizierung.", bpfIdNotice: "Ihre Dokumente werden vertraulich behandelt und dienen ausschließlich der Identitätsverifizierung.",
bpfSubmit: "Anfrage absenden", bpfSubmit: "Anfrage absenden",
bpfPerKm: "/km", bpfPerKm: "/km",
bpfDays: "Tage", bpfDays: "Tage",
@@ -112,7 +112,7 @@ export const translations = {
perWeekend: "Wochenende", perWeekend: "Wochenende",
weekendDef: "Sa 9:00 So 20:00", weekendDef: "Sa 9:00 So 20:00",
footerTagline: "Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).", footerTagline: "Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).",
footerLegal: "Rechtliches", footerLegal: "Rechtliches",
footerContact: "Kontakt", footerContact: "Kontakt",
footerNav: "Navigation", footerNav: "Navigation",
@@ -121,11 +121,11 @@ export const translations = {
footerTerms: "Mietbedingungen", footerTerms: "Mietbedingungen",
copyright: "Alle Rechte vorbehalten.", copyright: "Alle Rechte vorbehalten.",
close: "Schliessen", close: "Schließen",
editVehicle: "Fahrzeug bearbeiten", editVehicle: "Fahrzeug bearbeiten",
adminNavWebsite: "Website", adminNavWebsite: "Website",
adminChangePw: "Passwort aendern", adminChangePw: "Passwort ändern",
adminLogout: "Logout", adminLogout: "Logout",
adminLeads: "Leads", adminLeads: "Leads",
adminCustomers: "Kunden", adminCustomers: "Kunden",
@@ -214,7 +214,7 @@ export const translations = {
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: "Fair Deposit", whyDeposit: "Fair Deposit",
whyDepositText: "No overcharge. A transparent, fair deposit with no unnecessary burden.", whyDepositText: "Two deposit options: cash or PayPal deposit. For PayPal, we send a deposit link. Cash is currently handled in person at pickup.",
reviewsEyebrow: "Testimonials", reviewsEyebrow: "Testimonials",
reviewsTitle: "Experiences that last.", reviewsTitle: "Experiences that last.",
@@ -269,7 +269,7 @@ export const translations = {
bpfDeposit: "Deposit", bpfDeposit: "Deposit",
bpfIncludedKm: "Included kilometers", bpfIncludedKm: "Included kilometers",
bpfIdUpload: "ID / Driving license", bpfIdUpload: "ID / Driving license",
bpfIncomeUpload: "Pay slip / Income proof", bpfIncomeUpload: "Pay slip / Income proof (optional)",
bpfUploadHint: "PDF, JPG, PNG (max. 10 MB)", bpfUploadHint: "PDF, JPG, PNG (max. 10 MB)",
bpfClickUpload: "Click to upload", bpfClickUpload: "Click to upload",
bpfIdNotice: "Your documents are treated confidentially and are used exclusively for identity verification.", bpfIdNotice: "Your documents are treated confidentially and are used exclusively for identity verification.",
@@ -344,18 +344,13 @@ export const translations = {
}, },
}; };
export const REVIEWS = { export const REVIEWS = [
de: [ { quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P.", lang: "de" },
{ quote: "Top Service und perfekt vorbereitete Fahrzeuge. Unser Wochenendtrip war ein Highlight.", author: "Laura K." }, { quote: "Exzellenter Service und makellos vorbereitete Fahrzeuge. Unser Wochenendtrip war unvergesslich.", author: "James R.", lang: "de" },
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P." }, { quote: "Hervorragende Buchungsabwicklung und tadelloses Fahrzeugzustand. Sehr zufrieden.", author: "Thomas W.", lang: "de" },
{ quote: "Sehr professionelles Team und ehrliche Kommunikation zu allen Konditionen.", author: "Sina T." }, { quote: "Professionelles Team und untadelige Aufmerksamkeit zum Detail. Sehr empfohlen.", author: "David M.", lang: "de" },
], { quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Jonas P.", lang: "en" },
en: [ ];
{ quote: "Excellent service and flawlessly prepared cars. Our weekend trip was unforgettable.", author: "Laura K." },
{ quote: "Booking was clear and fast. The GT3 arrived in outstanding condition.", author: "Martin P." },
{ quote: "Very professional team and transparent communication on all terms.", author: "Sina T." },
],
};
export function getLang() { export function getLang() {
return localStorage.getItem("mccars.lang") || "de"; return localStorage.getItem("mccars.lang") || "de";
+18 -18
View File
@@ -19,7 +19,7 @@
<span>MC Cars</span> <span>MC Cars</span>
</a> </a>
<button class="menu-toggle" aria-label="Menue"></button> <button class="menu-toggle" aria-label="Menü"></button>
<nav class="main-nav" aria-label="Hauptnavigation"> <nav class="main-nav" aria-label="Hauptnavigation">
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a> <a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
@@ -37,7 +37,7 @@
<section class="hero" id="home"> <section class="hero" id="home">
<div class="shell"> <div class="shell">
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p> <p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
<h1 data-i18n="heroTitle">Fahren auf hoechstem Niveau.</h1> <h1 data-i18n="heroTitle">Fahren auf höchstem Niveau.</h1>
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p> <p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
<div class="hero-cta"> <div class="hero-cta">
@@ -60,7 +60,7 @@
<div> <div>
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p> <p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
<h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2> <h2 data-i18n="fleetTitle">Handverlesen. Gepflegt. Startklar.</h2>
<p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie fuer Details oder buchen Sie direkt.</p> <p class="sub" data-i18n="fleetSub">Filtern Sie nach Marke und Preis. Klicken Sie für Details oder buchen Sie direkt.</p>
</div> </div>
</div> </div>
@@ -95,7 +95,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p> <p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p>
<h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspass.</h2> <h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspaß.</h2>
</div> </div>
</div> </div>
@@ -113,7 +113,7 @@
<article class="why-card"> <article class="why-card">
<div class="icon"></div> <div class="icon"></div>
<h3 data-i18n="whyDeposit">Faire Kaution</h3> <h3 data-i18n="whyDeposit">Faire Kaution</h3>
<p data-i18n="whyDepositText">Kein Ueberziehen. Transparente, faire Kaution ohne unnoetige Belastung.</p> <p data-i18n="whyDepositText">Kein Überziehen. Transparente, faire Kaution ohne unnötige Belastung.</p>
</article> </article>
</div> </div>
</div> </div>
@@ -139,7 +139,7 @@
<div class="shell"> <div class="shell">
<div class="bpf-header"> <div class="bpf-header">
<h2 data-i18n="bpfTitle">Jetzt buchen</h2> <h2 data-i18n="bpfTitle">Jetzt buchen</h2>
<p class="sub" data-i18n="bpfSubtitle">Waehle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.</p> <p class="sub" data-i18n="bpfSubtitle">Wähle dein Wunschfahrzeug, den Zeitraum und konfiguriere deine Buchung nach Wunsch.</p>
</div> </div>
<!-- Step indicators --> <!-- Step indicators -->
@@ -161,7 +161,7 @@
<div class="bpf-field"> <div class="bpf-field">
<label data-i18n="bpfVehicle">Fahrzeug</label> <label data-i18n="bpfVehicle">Fahrzeug</label>
<select id="bpfCar"> <select id="bpfCar">
<option value="" data-i18n="bpfSelectVehicle">Fahrzeug waehlen</option> <option value="" data-i18n="bpfSelectVehicle">Fahrzeug wählen</option>
</select> </select>
</div> </div>
@@ -191,13 +191,13 @@
<!-- Day mode: single date picker --> <!-- Day mode: single date picker -->
<div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;"> <div class="bpf-field bpf-date-day" id="bpfDateDay" style="display:none;">
<label data-i18n="bpfPickDate">Datum waehlen</label> <label data-i18n="bpfPickDate">Datum wählen</label>
<input type="date" id="bpfDayDate" /> <input type="date" id="bpfDayDate" />
</div> </div>
<!-- Weekend mode: pick the Saturday --> <!-- Weekend mode: pick the Saturday -->
<div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display:none;"> <div class="bpf-field bpf-date-weekend" id="bpfDateWeekend" style="display:none;">
<label data-i18n="bpfPickWeekend">Wochenende waehlen (Samstag)</label> <label data-i18n="bpfPickWeekend">Wochenende wählen (Samstag)</label>
<input type="date" id="bpfWeekendDate" /> <input type="date" id="bpfWeekendDate" />
<p class="bpf-weekend-def" data-i18n="bpfWeekendDef">Wochenende: Samstag 9:00 Sonntag 20:00</p> <p class="bpf-weekend-def" data-i18n="bpfWeekendDef">Wochenende: Samstag 9:00 Sonntag 20:00</p>
</div> </div>
@@ -242,11 +242,11 @@
</div> </div>
<div class="bpf-field"> <div class="bpf-field">
<label data-i18n="fieldMessage">Nachricht</label> <label data-i18n="fieldMessage">Nachricht</label>
<textarea id="bpfMessage" rows="3" data-i18n-placeholder="messagePlaceholder" placeholder="Wuensche, Uhrzeit, Anlass..."></textarea> <textarea id="bpfMessage" rows="3" data-i18n-placeholder="messagePlaceholder" placeholder="Wünsche, Uhrzeit, Anlass..."></textarea>
</div> </div>
<div class="bpf-nav"> <div class="bpf-nav">
<button class="btn ghost" type="button" id="bpfBack2" data-i18n="bpfBack">Zurueck</button> <button class="btn ghost" type="button" id="bpfBack2" data-i18n="bpfBack">Zurück</button>
<button class="btn" type="button" id="bpfNext2" data-i18n="bpfNext">Weiter</button> <button class="btn" type="button" id="bpfNext2" data-i18n="bpfNext">Weiter</button>
</div> </div>
</div> </div>
@@ -254,10 +254,10 @@
<!-- Step 3: ID Verification --> <!-- Step 3: ID Verification -->
<div class="bpf-panel" id="bpfStep3" style="display:none;"> <div class="bpf-panel" id="bpfStep3" style="display:none;">
<h3 class="bpf-panel-title">🔐 <span data-i18n="stepVerification">ID-Verifizierung</span></h3> <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> <p class="muted" style="margin-bottom:1.5rem;">Bitte laden Sie einen gültigen Ausweis sowie einen aktuellen Lohnzettel / Gehaltsnachweis hoch.</p>
<div class="bpf-field"> <div class="bpf-field">
<label data-i18n="bpfIdUpload">Ausweis / Fuehrerschein *</label> <label data-i18n="bpfIdUpload">Ausweis / Führerschein *</label>
<div class="bpf-upload-box" id="uploadId"> <div class="bpf-upload-box" id="uploadId">
<span class="bpf-upload-icon"></span> <span class="bpf-upload-icon"></span>
<span data-i18n="bpfClickUpload">Klicken zum Hochladen</span> <span data-i18n="bpfClickUpload">Klicken zum Hochladen</span>
@@ -268,7 +268,7 @@
</div> </div>
<div class="bpf-field"> <div class="bpf-field">
<label data-i18n="bpfIncomeUpload">Lohnzettel / Gehaltsnachweis *</label> <label data-i18n="bpfIncomeUpload">Lohnzettel / Gehaltsnachweis (optional)</label>
<div class="bpf-upload-box" id="uploadIncome"> <div class="bpf-upload-box" id="uploadIncome">
<span class="bpf-upload-icon"></span> <span class="bpf-upload-icon"></span>
<span data-i18n="bpfClickUpload">Klicken zum Hochladen</span> <span data-i18n="bpfClickUpload">Klicken zum Hochladen</span>
@@ -280,11 +280,11 @@
<div class="bpf-notice"> <div class="bpf-notice">
<span></span> <span></span>
<p data-i18n="bpfIdNotice">Ihre Dokumente werden vertraulich behandelt und dienen ausschliesslich der Identitaetsverifizierung.</p> <p data-i18n="bpfIdNotice">Ihre Dokumente werden vertraulich behandelt und dienen ausschließlich der Identitätsverifizierung.</p>
</div> </div>
<div class="bpf-nav"> <div class="bpf-nav">
<button class="btn ghost" type="button" id="bpfBack3" data-i18n="bpfBack">Zurueck</button> <button class="btn ghost" type="button" id="bpfBack3" data-i18n="bpfBack">Zurück</button>
<button class="btn" type="button" id="bpfSubmit" data-i18n="bpfSubmit">Anfrage absenden</button> <button class="btn" type="button" id="bpfSubmit" data-i18n="bpfSubmit">Anfrage absenden</button>
</div> </div>
</div> </div>
@@ -292,7 +292,7 @@
<!-- Price sidebar --> <!-- Price sidebar -->
<aside class="bpf-sidebar" id="bpfSidebar"> <aside class="bpf-sidebar" id="bpfSidebar">
<p class="bpf-sidebar-placeholder" data-i18n="bpfSelectForPrice">Waehle Fahrzeug und Datum fuer eine Preisuebersicht</p> <p class="bpf-sidebar-placeholder" data-i18n="bpfSelectForPrice">Wähle Fahrzeug und Datum für eine Preisübersicht</p>
<div class="bpf-sidebar-content" id="bpfSidebarContent" style="display:none;"></div> <div class="bpf-sidebar-content" id="bpfSidebarContent" style="display:none;"></div>
</aside> </aside>
</div> </div>
@@ -310,7 +310,7 @@
<span class="logo-mark">MC</span> <span class="logo-mark">MC</span>
<span>MC Cars</span> <span>MC Cars</span>
</div> </div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).</p> <p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Österreich. Standort: Steiermark (TBD).</p>
</div> </div>
<div> <div>
@@ -0,0 +1,57 @@
-- =============================================================================
-- MC Cars - Public lead creation RPC
-- Allows anon/authenticated callers to create leads and receive the inserted id
-- without granting SELECT on public.leads.
-- =============================================================================
create or replace function public.create_lead(
p_name text,
p_email text,
p_phone text default '',
p_vehicle_id uuid default null,
p_vehicle_label text default '',
p_date_from date default null,
p_date_to date default null,
p_message text default '',
p_source text default 'website'
)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_id uuid;
begin
insert into public.leads (
name,
email,
phone,
vehicle_id,
vehicle_label,
date_from,
date_to,
message,
source
)
values (
p_name,
p_email,
coalesce(p_phone, ''),
p_vehicle_id,
coalesce(p_vehicle_label, ''),
p_date_from,
p_date_to,
coalesce(p_message, ''),
coalesce(p_source, 'website')
)
returning id into v_id;
return v_id;
end;
$$;
revoke all on function public.create_lead(text, text, text, uuid, text, date, date, text, text) from public;
grant execute on function public.create_lead(text, text, text, uuid, text, date, date, text, text) to anon, authenticated, service_role;
notify pgrst, 'reload schema';