feat: Add Supabase configuration and migrations for MC Cars application
- Create Kong declarative configuration for routing and authentication. - Implement initialization script to set up the database. - Add SQL migration for initializing roles, schemas, and seeding vehicle data. - Create leads and customers tables with appropriate policies and functions for CRM. - Seed admin user and configure storage bucket with RLS policies.
This commit is contained in:
+246
@@ -0,0 +1,246 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
||||
import { translations, REVIEWS, getLang, setLang, t, applyI18n } from "./i18n.js";
|
||||
|
||||
const SUPA_URL = window.MCCARS_CONFIG?.SUPABASE_URL || "http://localhost:54321";
|
||||
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 bookingCar = document.querySelector("#bookingCar");
|
||||
const bookingForm = document.querySelector("#bookingForm");
|
||||
const bookingFeedback = document.querySelector("#bookingFeedback");
|
||||
const langToggle = document.querySelector(".lang-toggle");
|
||||
const menuToggle = document.querySelector(".menu-toggle");
|
||||
const mainNav = document.querySelector(".main-nav");
|
||||
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");
|
||||
|
||||
document.querySelector("#year").textContent = new Date().getFullYear();
|
||||
|
||||
// ---------------- 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("");
|
||||
|
||||
bookingCar.innerHTML = 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 card = document.createElement("article");
|
||||
card.className = "vehicle-card";
|
||||
card.innerHTML = `
|
||||
<div class="vehicle-photo" style="background-image:url('${escapeAttr(v.photo_url)}');">
|
||||
<span class="badge">${escapeHtml(v.brand)}</span>
|
||||
</div>
|
||||
<div class="vehicle-body">
|
||||
<p class="model-brand">${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}">${t("details")}</button>
|
||||
<button class="btn small" data-book="${v.id}">${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", () => {
|
||||
bookingCar.value = b.dataset.book;
|
||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openDetails(id) {
|
||||
const v = state.vehicles.find(x => x.id === id);
|
||||
if (!v) return;
|
||||
const lang = getLang();
|
||||
const desc = lang === "en" ? v.description_en : v.description_de;
|
||||
|
||||
dialogTitle.textContent = `${v.brand} ${v.model}`;
|
||||
dialogBody.innerHTML = `
|
||||
<img src="${escapeAttr(v.photo_url)}" 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 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();
|
||||
bookingCar.value = v.id;
|
||||
document.querySelector("#buchen").scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Reviews ----------------
|
||||
function renderReviews() {
|
||||
const list = REVIEWS[getLang()];
|
||||
state.reviewIdx = state.reviewIdx % list.length;
|
||||
const r = list[state.reviewIdx];
|
||||
reviewStrip.innerHTML = `
|
||||
<p class="review-quote">"${escapeHtml(r.quote)}"</p>
|
||||
<p class="review-author">${escapeHtml(r.author)}</p>
|
||||
`;
|
||||
reviewDots.innerHTML = list.map((_, i) =>
|
||||
`<button class="${i === state.reviewIdx ? 'active' : ''}" data-rev="${i}"></button>`
|
||||
).join("");
|
||||
reviewDots.querySelectorAll("button").forEach(b => {
|
||||
b.addEventListener("click", () => { state.reviewIdx = +b.dataset.rev; renderReviews(); });
|
||||
});
|
||||
}
|
||||
setInterval(() => { state.reviewIdx++; renderReviews(); }, 6000);
|
||||
|
||||
// ---------------- Booking -> LEADS ----------------
|
||||
bookingForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(bookingForm);
|
||||
const data = Object.fromEntries(fd.entries());
|
||||
|
||||
if (!data.from || !data.to || new Date(data.to) <= new Date(data.from)) {
|
||||
bookingFeedback.textContent = t("invalidDates");
|
||||
bookingFeedback.className = "form-feedback error";
|
||||
return;
|
||||
}
|
||||
|
||||
const vehicle = state.vehicles.find(v => v.id === data.vehicle);
|
||||
const payload = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
phone: data.phone || "",
|
||||
vehicle_id: data.vehicle || null,
|
||||
vehicle_label: vehicle ? `${vehicle.brand} ${vehicle.model}` : "",
|
||||
date_from: data.from || null,
|
||||
date_to: data.to || null,
|
||||
message: data.message || "",
|
||||
source: "website",
|
||||
};
|
||||
|
||||
bookingFeedback.className = "form-feedback";
|
||||
bookingFeedback.textContent = "...";
|
||||
|
||||
const { error } = await supabase.from("leads").insert(payload);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
bookingFeedback.className = "form-feedback error";
|
||||
bookingFeedback.textContent = t("bookingFailed");
|
||||
return;
|
||||
}
|
||||
|
||||
bookingFeedback.textContent = t("bookingSuccess");
|
||||
bookingForm.reset();
|
||||
});
|
||||
|
||||
// ---------------- 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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
})[c]);
|
||||
}
|
||||
function escapeAttr(s) { return escapeHtml(s); }
|
||||
|
||||
// ---------------- Boot ----------------
|
||||
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
|
||||
applyI18n();
|
||||
renderReviews();
|
||||
loadVehicles();
|
||||
Reference in New Issue
Block a user