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:
Lago
2026-04-17 17:50:57 +02:00
commit 61517879e1
23 changed files with 3673 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
FROM nginx:1.27-alpine
# Copy static assets
COPY . /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Generate runtime config.js so the frontend picks up env vars at container start
# (anon key only — safe for the browser).
RUN rm -f /usr/share/nginx/html/Dockerfile /usr/share/nginx/html/nginx.conf
RUN printf '#!/bin/sh\nset -eu\ncat > /usr/share/nginx/html/config.js <<EOF\nwindow.MCCARS_CONFIG = {\n SUPABASE_URL: "${SUPABASE_URL:-http://localhost:8000}",\n SUPABASE_ANON_KEY: "${SUPABASE_ANON_KEY:-}"\n};\nEOF\nexec nginx -g "daemon off;"\n' > /docker-entrypoint.d/99-config.sh \
&& chmod +x /docker-entrypoint.d/99-config.sh
EXPOSE 80
+220
View File
@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin · MC Cars</title>
<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 rel="stylesheet" href="styles.css" />
<script src="config.js"></script>
</head>
<body>
<!-- Login -->
<section id="loginView" class="admin-login" style="display:none;">
<div class="logo" style="justify-content:center;margin-bottom:1.5rem;">
<span class="logo-mark">MC</span>
<span>MC Cars Admin</span>
</div>
<form id="loginForm" class="admin-form">
<label>
<span>E-Mail</span>
<input type="email" name="email" required autocomplete="username" />
</label>
<label>
<span>Passwort</span>
<input type="password" name="password" required autocomplete="current-password" />
</label>
<button class="btn" type="submit">Anmelden</button>
<p class="form-feedback error" id="loginError"></p>
<p style="color:var(--muted);font-size:0.82rem;text-align:center;">
Only admins. Self-registration is disabled.
</p>
</form>
</section>
<!-- Forced password rotation (first login OR user-triggered) -->
<section id="rotateView" class="admin-login" style="display:none;">
<div class="logo" style="justify-content:center;margin-bottom:1rem;">
<span class="logo-mark">MC</span>
<span>Passwort setzen</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;text-align:center;max-width:38ch;margin:0 auto 1rem;">
Das Bootstrap-Passwort muss ersetzt werden. Das neue Passwort muss sich vom
Start-Passwort unterscheiden.
</p>
<form id="rotateForm" class="admin-form">
<label>
<span>Neues Passwort (mind. 10 Zeichen)</span>
<input type="password" name="pw1" minlength="10" required autocomplete="new-password" />
</label>
<label>
<span>Wiederholen</span>
<input type="password" name="pw2" minlength="10" required autocomplete="new-password" />
</label>
<button class="btn" type="submit">Speichern</button>
<p class="form-feedback error" id="rotateError"></p>
</form>
</section>
<!-- Admin -->
<section id="adminView" class="admin-page" style="display:none;">
<div class="admin-bar">
<h1>MC Cars · Admin</h1>
<div style="display:flex;gap:0.6rem;align-items:center;flex-wrap:wrap;">
<a href="index.html" class="btn ghost small">Website</a>
<a href="http://localhost:3000" target="_blank" rel="noopener" class="btn ghost small">Supabase Studio</a>
<span id="adminWho" style="color:var(--muted);font-size:0.85rem;"></span>
<button id="changePwBtn" class="btn ghost small">Passwort aendern</button>
<button id="logoutBtn" class="btn small">Logout</button>
</div>
</div>
<!-- Tabs -->
<div class="admin-tabs" role="tablist">
<button class="tab active" data-tab="leads" role="tab">Leads <span id="leadsBadge" class="tab-badge">0</span></button>
<button class="tab" data-tab="customers" role="tab">Kunden <span id="customersBadge" class="tab-badge">0</span></button>
<button class="tab" data-tab="vehicles" role="tab">Fahrzeuge</button>
</div>
<!-- LEADS -->
<div class="tab-panel" id="tab-leads">
<div class="panel">
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;">
<h2 style="margin:0;">Leads</h2>
<div class="sub-tabs" role="tablist">
<button class="sub-tab active" data-lview="active">Aktive Leads</button>
<button class="sub-tab" data-lview="inactive">Abgeschlossen</button>
</div>
</div>
<table class="admin-table" id="leadsTable">
<thead>
<tr>
<th>Eingang</th>
<th>Name / E-Mail</th>
<th>Fahrzeug</th>
<th>Zeitraum</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<p id="leadsEmpty" class="muted" style="display:none;text-align:center;padding:2rem 0;">Keine Leads in dieser Ansicht.</p>
</div>
</div>
<!-- CUSTOMERS -->
<div class="tab-panel" id="tab-customers" style="display:none;">
<div class="panel">
<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>
<table class="admin-table" id="customersTable">
<thead>
<tr>
<th>Erster Kontakt</th>
<th>Name / E-Mail</th>
<th>Telefon</th>
<th>Quelle (Lead)</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
<p id="customersEmpty" class="muted" style="display:none;text-align:center;padding:2rem 0;">Noch keine Kunden.</p>
</div>
</div>
<!-- VEHICLES -->
<div class="tab-panel" id="tab-vehicles" style="display:none;">
<div class="admin-grid">
<div class="panel">
<h2 id="formTitle">Neues Fahrzeug</h2>
<form class="admin-form" id="vehicleForm">
<input type="hidden" name="id" />
<div class="admin-photo-preview" id="photoPreview"></div>
<label>
<span>Foto hochladen (JPG/PNG/WebP, max 50 MB)</span>
<input type="file" id="photoInput" accept="image/*" />
</label>
<label>
<span>Foto-URL (wird automatisch gesetzt nach Upload)</span>
<input type="url" name="photo_url" placeholder="https://..." />
</label>
<div class="row2">
<label><span>Marke</span><input name="brand" required /></label>
<label><span>Modell</span><input name="model" required /></label>
</div>
<div class="row3">
<label><span>PS</span><input type="number" name="power_hp" min="0" /></label>
<label><span>Top-Speed km/h</span><input type="number" name="top_speed_kmh" min="0" /></label>
<label><span>0-100</span><input name="acceleration" placeholder="3.2s" /></label>
</div>
<div class="row3">
<label><span>Sitze</span><input type="number" name="seats" min="1" value="2" /></label>
<label><span>Preis / Tag (€)</span><input type="number" name="daily_price_eur" min="0" required /></label>
<label><span>Reihenfolge</span><input type="number" name="sort_order" value="100" /></label>
</div>
<label>
<span>Standort</span>
<input name="location" value="Steiermark (TBD)" />
</label>
<label>
<span>Beschreibung (Deutsch)</span>
<textarea name="description_de" rows="3"></textarea>
</label>
<label>
<span>Description (English)</span>
<textarea name="description_en" rows="3"></textarea>
</label>
<label style="flex-direction:row;align-items:center;gap:0.5rem;">
<input type="checkbox" name="is_active" checked style="width:auto;" />
<span>Aktiv / auf Website sichtbar</span>
</label>
<div style="display:flex;gap:0.5rem;">
<button class="btn" type="submit" id="saveBtn">Speichern</button>
<button class="btn ghost" type="button" id="resetBtn">Neu</button>
</div>
<p class="form-feedback" id="formFeedback"></p>
</form>
</div>
<div class="panel">
<h2>Alle Fahrzeuge</h2>
<table class="admin-table" id="adminTable">
<thead>
<tr>
<th>Foto</th>
<th>Marke / Modell</th>
<th>€ / Tag</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Lead detail / qualify dialog -->
<dialog id="leadDialog">
<div class="dialog-head">
<h3 id="leadDialogTitle" style="margin:0;">Lead</h3>
<button class="dialog-close" id="leadDialogClose" aria-label="Close">×</button>
</div>
<div class="dialog-body" id="leadDialogBody"></div>
</dialog>
<script type="module" src="admin.js"></script>
</body>
</html>
+493
View File
@@ -0,0 +1,493 @@
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 = `
<td><div style="width:56px;height:36px;border-radius:6px;background:#0e1015 center/cover no-repeat;background-image:url('${attr(v.photo_url)}');"></div></td>
<td><strong>${esc(v.brand)}</strong><br /><span style="color:var(--muted);">${esc(v.model)}</span></td>
<td>€ ${v.daily_price_eur}</td>
<td>${v.is_active ? "✅" : "—"}</td>
<td style="white-space:nowrap;">
<button class="btn small ghost" data-edit="${v.id}">Edit</button>
<button class="btn small danger" data-del="${v.id}">Del</button>
</td>`;
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 = `
<td>${fmtDate(l.created_at)}</td>
<td><strong>${esc(l.name)}</strong><br /><span class="muted">${esc(l.email)}${l.phone ? " · " + esc(l.phone) : ""}</span></td>
<td>${esc(l.vehicle_label || "—")}</td>
<td>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</td>
<td><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></td>
<td style="white-space:nowrap;">
<button class="btn small ghost" data-open="${l.id}">Details</button>
${wantActive ? `
<button class="btn small" data-qual="${l.id}">Qualifizieren</button>
<button class="btn small danger" data-disq="${l.id}">Ablehnen</button>
` : `
<button class="btn small ghost" data-reopen="${l.id}">Wieder oeffnen</button>
`}
</td>`;
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 = `
<dl class="kv">
<dt>Eingang</dt><dd>${fmtDate(l.created_at)}</dd>
<dt>E-Mail</dt><dd><a href="mailto:${attr(l.email)}">${esc(l.email)}</a></dd>
<dt>Telefon</dt><dd>${esc(l.phone || "—")}</dd>
<dt>Fahrzeug</dt><dd>${esc(l.vehicle_label || "—")}</dd>
<dt>Zeitraum</dt><dd>${esc(l.date_from || "—")}${esc(l.date_to || "—")}</dd>
<dt>Nachricht</dt><dd style="white-space:pre-wrap;">${esc(l.message || "—")}</dd>
<dt>Status</dt><dd><span class="pill pill-${esc(l.status)}">${esc(l.status)}</span></dd>
<dt>Notiz</dt><dd><textarea id="leadNote" rows="3" style="width:100%;">${esc(l.admin_notes || "")}</textarea></dd>
</dl>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.8rem;">
${l.is_active ? `
<button class="btn danger" id="dlgDisq">Ablehnen</button>
<button class="btn" id="dlgQual">Qualifizieren</button>
` : `<button class="btn ghost" id="dlgReopen">Wieder oeffnen</button>`}
</div>`;
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 = `
<td>${fmtDate(c.first_contacted_at)}</td>
<td><strong>${esc(c.name)}</strong><br /><span class="muted">${esc(c.email)}</span></td>
<td>${esc(c.phone || "—")}</td>
<td><code class="muted">${esc(c.lead_id?.slice(0, 8) || "—")}</code></td>
<td><span class="pill pill-${esc(c.status)}">${esc(c.status)}</span></td>
<td style="white-space:nowrap;">
<button class="btn small ghost" data-toggle="${c.id}" data-status="${c.status}">
${c.status === "active" ? "Inaktiv setzen" : "Aktiv setzen"}
</button>
</td>`;
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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[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();
+246
View File
@@ -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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[c]);
}
function escapeAttr(s) { return escapeHtml(s); }
// ---------------- Boot ----------------
langToggle.textContent = getLang() === "de" ? "EN" : "DE";
applyI18n();
renderReviews();
loadVehicles();
+8
View File
@@ -0,0 +1,8 @@
// Fallback runtime config — overwritten by the nginx container entrypoint at
// boot with values from SUPABASE_URL / SUPABASE_ANON_KEY env vars. Only the
// anon key is ever exposed to the browser. The service_role key stays server-
// side (Supabase Studio / PostgREST container environment).
window.MCCARS_CONFIG = {
SUPABASE_URL: "http://localhost:54321",
SUPABASE_ANON_KEY: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0",
};
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Datenschutz · MC Cars (GmbH)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="shell" style="padding:4rem 1rem;">
<p class="eyebrow">Rechtliches</p>
<h1>Datenschutz</h1>
<p>Buchungsanfragen werden aktuell zu Demozwecken lokal im Browser gespeichert. Fahrzeugdaten werden ueber ein selbstgehostetes Supabase verwaltet.</p>
<p>Ansprechpartner: hello@mccars.at</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
</main>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
// Translations shared between public site and admin panel.
export const translations = {
de: {
navCars: "Fahrzeuge",
navWhy: "Warum wir",
navReviews: "Stimmen",
navBook: "Buchen",
bookNow: "Jetzt buchen",
viewFleet: "Flotte ansehen",
heroEyebrow: "MC Cars · Sportwagenvermietung",
heroTitle: "Fahren auf hoechstem Niveau.",
heroLead: "Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.",
statDeposit: "Kaution",
statSupport: "Support",
statCars: "Fahrzeuge",
fleetEyebrow: "Unsere Flotte",
fleetTitle: "Handverlesen. Gepflegt. Startklar.",
fleetSub: "Filtern Sie nach Marke und Preis. Klicken Sie fuer Details oder buchen Sie direkt.",
filterBrand: "Marke",
filterSort: "Sortierung",
filterPrice: "Max. Preis / Tag",
all: "Alle",
sortPriceAsc: "Preis aufsteigend",
sortPriceDesc: "Preis absteigend",
sortPowerDesc: "Leistung absteigend",
details: "Details",
book: "Buchen",
perDay: "pro Tag",
hp: "PS",
kmh: "km/h",
accel: "0-100",
seats: "Sitze",
from: "ab",
noMatches: "Keine Fahrzeuge gefunden.",
whyEyebrow: "Warum MC Cars",
whyTitle: "Keine Kompromisse zwischen Sicherheit und Fahrspass.",
whyInsurance: "Versicherungsschutz",
whyInsuranceText: "Vollkasko mit klarem Selbstbehalt. Transparente Kosten auf jedem Kilometer.",
whyFleet: "Premium Flotte",
whyFleetText: "Handverlesene Performance-Modelle, professionell gewartet und sofort startklar.",
whyDeposit: "Kautionsfrei",
whyDepositText: "Sie zahlen nur die Miete. Kein Kapital blockiert, kein unnoetiger Aufwand.",
reviewsEyebrow: "Kundenmeinungen",
reviewsTitle: "Erlebnisse, die bleiben.",
bookingEyebrow: "Jetzt buchen",
bookingTitle: "Traumwagen unverbindlich anfragen.",
fieldName: "Name",
fieldEmail: "E-Mail",
fieldPhone: "Telefon",
fieldCar: "Fahrzeug",
fieldFrom: "Von",
fieldTo: "Bis",
fieldMessage: "Nachricht",
messagePlaceholder: "Wuensche, Uhrzeit, Anlass...",
sendRequest: "Anfrage senden",
invalidDates: "Bitte ein gueltiges Datum waehlen (Bis > Von).",
bookingSuccess: "Danke! Wir melden uns in Kuerze per E-Mail.",
bookingFailed: "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.",
footerTagline: "Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).",
footerLegal: "Rechtliches",
footerContact: "Kontakt",
footerNav: "Navigation",
imprint: "Impressum",
privacy: "Datenschutz",
terms: "Mietbedingungen",
copyright: "Alle Rechte vorbehalten.",
close: "Schliessen",
editVehicle: "Fahrzeug bearbeiten",
},
en: {
navCars: "Fleet",
navWhy: "Why us",
navReviews: "Reviews",
navBook: "Book",
bookNow: "Book now",
viewFleet: "View fleet",
heroEyebrow: "MC Cars · Sports car rental",
heroTitle: "Drive at the highest level.",
heroLead: "Premium sports and luxury cars in Styria. No deposit, full transparency, ready to launch.",
statDeposit: "Deposit",
statSupport: "Support",
statCars: "Vehicles",
fleetEyebrow: "Our Fleet",
fleetTitle: "Hand-picked. Maintained. Ready.",
fleetSub: "Filter by brand or price. Click for details or book directly.",
filterBrand: "Brand",
filterSort: "Sort",
filterPrice: "Max price / day",
all: "All",
sortPriceAsc: "Price ascending",
sortPriceDesc: "Price descending",
sortPowerDesc: "Power descending",
details: "Details",
book: "Book",
perDay: "per day",
hp: "HP",
kmh: "km/h",
accel: "0-62",
seats: "Seats",
from: "from",
noMatches: "No vehicles match the filters.",
whyEyebrow: "Why MC Cars",
whyTitle: "No compromises between safety and driving joy.",
whyInsurance: "Insurance",
whyInsuranceText: "Comprehensive cover with a clear deductible. Transparent costs on every kilometer.",
whyFleet: "Premium fleet",
whyFleetText: "Hand-picked performance models, professionally maintained and ready to go.",
whyDeposit: "No deposit",
whyDepositText: "You only pay rent. No blocked capital, no unnecessary overhead.",
reviewsEyebrow: "Testimonials",
reviewsTitle: "Experiences that last.",
bookingEyebrow: "Book now",
bookingTitle: "Request your dream car without obligation.",
fieldName: "Name",
fieldEmail: "Email",
fieldPhone: "Phone",
fieldCar: "Vehicle",
fieldFrom: "From",
fieldTo: "To",
fieldMessage: "Message",
messagePlaceholder: "Wishes, timing, occasion...",
sendRequest: "Send request",
invalidDates: "Please pick valid dates (To > From).",
bookingSuccess: "Thank you! We'll get back to you shortly.",
bookingFailed: "Request could not be sent. Please try again.",
footerTagline: "Sports car rental in Austria. Location: Styria (TBD).",
footerLegal: "Legal",
footerContact: "Contact",
footerNav: "Navigation",
imprint: "Imprint",
privacy: "Privacy",
terms: "Rental conditions",
copyright: "All rights reserved.",
close: "Close",
editVehicle: "Edit vehicle",
},
};
export const REVIEWS = {
de: [
{ quote: "Top Service und perfekt vorbereitete Fahrzeuge. Unser Wochenendtrip war ein Highlight.", author: "Laura K." },
{ quote: "Die Buchung war klar und schnell. Der GT3 war in einem herausragenden Zustand.", author: "Martin P." },
{ quote: "Sehr professionelles Team und ehrliche Kommunikation zu allen Konditionen.", author: "Sina T." },
],
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() {
return localStorage.getItem("mccars.lang") || "de";
}
export function setLang(lang) {
localStorage.setItem("mccars.lang", lang);
}
export function t(key) {
const lang = getLang();
return (translations[lang] && translations[lang][key]) || key;
}
export function applyI18n(root = document) {
const lang = getLang();
document.documentElement.lang = lang;
root.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.dataset.i18n;
if (translations[lang][key]) el.textContent = translations[lang][key];
});
root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
const key = el.dataset.i18nPlaceholder;
if (translations[lang][key]) el.placeholder = translations[lang][key];
});
}
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Impressum · MC Cars (GmbH)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="shell" style="padding:4rem 1rem;">
<p class="eyebrow">Rechtliches</p>
<h1>Impressum</h1>
<p>MC Cars (GmbH)</p>
<p>Standort: Steiermark (TBD)</p>
<p>E-Mail: hello@mccars.at</p>
<p>Telefon: +43 316 880000</p>
<p>Firmenbuch und UID werden nachgereicht.</p>
<p style="margin-top:2rem;"><a class="btn small" href="index.html">← Startseite</a></p>
</main>
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MC Cars · Sportwagenvermietung Steiermark</title>
<meta name="description" content="MC Cars · Premium Sportwagen- und Luxusvermietung in der Steiermark. Kautionsfrei, transparent, sofort startklar." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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 rel="stylesheet" href="styles.css" />
<script src="config.js"></script>
</head>
<body>
<header class="site-header">
<div class="shell">
<a class="logo" href="/" aria-label="MC Cars Startseite">
<span class="logo-mark">MC</span>
<span>MC Cars</span>
</a>
<button class="menu-toggle" aria-label="Menue"></button>
<nav class="main-nav" aria-label="Hauptnavigation">
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#stimmen" data-i18n="navReviews">Stimmen</a>
<a href="#buchen" data-i18n="navBook">Buchen</a>
<a class="btn small" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
<button class="lang-toggle" type="button" aria-label="Sprache wechseln">EN</button>
</nav>
</div>
</header>
<main>
<!-- Hero -->
<section class="hero" id="home">
<div class="shell">
<p class="eyebrow" data-i18n="heroEyebrow">MC Cars · Sportwagenvermietung</p>
<h1 data-i18n="heroTitle">Fahren auf hoechstem Niveau.</h1>
<p class="lead" data-i18n="heroLead">Premium-Sportwagen und Luxusklasse in der Steiermark. Kautionsfrei, transparent, sofort startklar.</p>
<div class="hero-cta">
<a class="btn" href="#buchen" data-i18n="bookNow">Jetzt buchen</a>
<a class="btn ghost" href="#fahrzeuge" data-i18n="viewFleet">Flotte ansehen</a>
</div>
<div class="hero-stats">
<div><strong>0 €</strong><span data-i18n="statDeposit">Kaution</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>
</div>
</section>
<!-- Fleet -->
<section id="fahrzeuge">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="fleetEyebrow">Unsere Flotte</p>
<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>
</div>
</div>
<form class="filters" id="filters" onsubmit="return false;">
<label>
<span data-i18n="filterBrand">Marke</span>
<select id="brandFilter"><option value="all" data-i18n="all">Alle</option></select>
</label>
<label>
<span data-i18n="filterSort">Sortierung</span>
<select id="sortFilter">
<option value="sort_order">Empfehlung</option>
<option value="priceAsc" data-i18n="sortPriceAsc">Preis aufsteigend</option>
<option value="priceDesc" data-i18n="sortPriceDesc">Preis absteigend</option>
<option value="powerDesc" data-i18n="sortPowerDesc">Leistung absteigend</option>
</select>
</label>
<label>
<span data-i18n="filterPrice">Max. Preis / Tag</span>
<input type="number" id="priceFilter" min="0" step="50" placeholder="1000" />
</label>
</form>
<div class="vehicle-grid" id="vehicleGrid"></div>
<p id="emptyState" style="display:none;color:var(--muted);text-align:center;padding:2rem 0;" data-i18n="noMatches">Keine Fahrzeuge gefunden.</p>
</div>
</section>
<!-- Why -->
<section id="warum" style="background:var(--bg-elev);">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="whyEyebrow">Warum MC Cars</p>
<h2 data-i18n="whyTitle">Keine Kompromisse zwischen Sicherheit und Fahrspass.</h2>
</div>
</div>
<div class="why-grid">
<article class="why-card">
<div class="icon">🛡</div>
<h3 data-i18n="whyInsurance">Versicherungsschutz</h3>
<p data-i18n="whyInsuranceText">Vollkasko mit klarem Selbstbehalt.</p>
</article>
<article class="why-card">
<div class="icon"></div>
<h3 data-i18n="whyFleet">Premium Flotte</h3>
<p data-i18n="whyFleetText">Handverlesene Performance-Modelle.</p>
</article>
<article class="why-card">
<div class="icon"></div>
<h3 data-i18n="whyDeposit">Kautionsfrei</h3>
<p data-i18n="whyDepositText">Sie zahlen nur die Miete.</p>
</article>
</div>
</div>
</section>
<!-- Reviews -->
<section id="stimmen">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="reviewsEyebrow">Kundenmeinungen</p>
<h2 data-i18n="reviewsTitle">Erlebnisse, die bleiben.</h2>
</div>
</div>
<div class="reviews-strip" id="reviewStrip" aria-live="polite"></div>
<div class="review-dots" id="reviewDots"></div>
</div>
</section>
<!-- Booking -->
<section id="buchen" style="background:var(--bg-elev);">
<div class="shell">
<div class="section-head">
<div>
<p class="eyebrow" data-i18n="bookingEyebrow">Jetzt buchen</p>
<h2 data-i18n="bookingTitle">Traumwagen unverbindlich anfragen.</h2>
</div>
</div>
<form class="booking-form" id="bookingForm" novalidate>
<label>
<span data-i18n="fieldName">Name</span>
<input type="text" name="name" required />
</label>
<label>
<span data-i18n="fieldEmail">E-Mail</span>
<input type="email" name="email" required />
</label>
<label>
<span data-i18n="fieldPhone">Telefon</span>
<input type="tel" name="phone" />
</label>
<label>
<span data-i18n="fieldCar">Fahrzeug</span>
<select name="vehicle" id="bookingCar"></select>
</label>
<label>
<span data-i18n="fieldFrom">Von</span>
<input type="date" name="from" required />
</label>
<label>
<span data-i18n="fieldTo">Bis</span>
<input type="date" name="to" required />
</label>
<label class="full">
<span data-i18n="fieldMessage">Nachricht</span>
<textarea name="message" rows="4" data-i18n-placeholder="messagePlaceholder" placeholder="Wuensche, Uhrzeit, Anlass..."></textarea>
</label>
<div class="full" style="display:flex;justify-content:flex-end;">
<button type="submit" class="btn" data-i18n="sendRequest">Anfrage senden</button>
</div>
</form>
<p id="bookingFeedback" class="form-feedback" role="status"></p>
</div>
</section>
</main>
<footer class="site-footer" id="kontakt">
<div class="shell">
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom:0.8rem;">
<span class="logo-mark">MC</span>
<span>MC Cars</span>
</div>
<p style="color:var(--muted);font-size:0.9rem;max-width:40ch;" data-i18n="footerTagline">Sportwagenvermietung in Oesterreich. Standort: Steiermark (TBD).</p>
</div>
<div>
<h4 data-i18n="footerNav">Navigation</h4>
<a href="#fahrzeuge" data-i18n="navCars">Fahrzeuge</a>
<a href="#warum" data-i18n="navWhy">Warum wir</a>
<a href="#buchen" data-i18n="navBook">Buchen</a>
</div>
<div>
<h4 data-i18n="footerLegal">Rechtliches</h4>
<a href="impressum.html" data-i18n="imprint">Impressum</a>
<a href="datenschutz.html" data-i18n="privacy">Datenschutz</a>
<a href="#" data-i18n="terms">Mietbedingungen</a>
</div>
<div>
<h4 data-i18n="footerContact">Kontakt</h4>
<a href="mailto:hello@mccars.at">hello@mccars.at</a>
<a href="tel:+43316880000">+43 316 880000</a>
</div>
</div>
<div class="footer-bottom">
<span>© <span id="year"></span> MC Cars. <span data-i18n="copyright">Alle Rechte vorbehalten.</span></span>
<span>Made in Steiermark</span>
</div>
</div>
</footer>
<!-- Vehicle details dialog -->
<dialog id="carDialog">
<div class="dialog-head">
<h3 id="dialogTitle" style="margin:0;"></h3>
<button class="dialog-close" id="dialogClose" aria-label="Close">×</button>
</div>
<div class="dialog-body" id="dialogBody"></div>
</dialog>
<script type="module" src="app.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Never cache config.js / html so runtime config updates take effect.
location = /config.js { add_header Cache-Control "no-store"; try_files $uri =404; }
location ~* \.html$ { add_header Cache-Control "no-store"; try_files $uri =404; }
location / {
try_files $uri $uri/ /index.html;
}
# Static assets can be cached aggressively.
location ~* \.(?:css|js|jpg|jpeg|png|webp|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
try_files $uri =404;
}
}
+644
View File
@@ -0,0 +1,644 @@
:root {
--bg: #0b0c10;
--bg-elev: #14161c;
--bg-card: #181b23;
--line: #262a36;
--text: #f2efe8;
--muted: #9aa1ad;
--accent: #c48a42; /* burnished copper */
--accent-strong: #e0a55b;
--danger: #e05050;
--ok: #4fd1a3;
--radius: 14px;
--shadow: 0 20px 45px rgba(0, 0, 0, 0.45);
--maxw: 1180px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent-strong); text-decoration: none; }
a:hover { color: var(--text); }
h1, h2, h3, h4 {
font-family: "Playfair Display", "Inter", serif;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 0.4em;
}
h1 { font-size: clamp(2.2rem, 4.3vw, 3.8rem); line-height: 1.05; }
h2 { font-size: clamp(1.6rem, 2.8vw, 2.4rem); }
h3 { font-size: 1.2rem; }
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.22em;
font-size: 0.72rem;
color: var(--accent-strong);
font-weight: 600;
margin: 0 0 0.6rem;
}
.shell {
width: min(var(--maxw), 92vw);
margin: 0 auto;
}
section { padding: 5rem 0; }
/* ---------------- Header ---------------- */
.site-header {
position: sticky;
top: 0;
z-index: 40;
background: rgba(11, 12, 16, 0.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.site-header .shell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 0;
}
.logo {
display: flex;
align-items: center;
gap: 0.65rem;
color: var(--text);
font-family: "Playfair Display", serif;
font-weight: 600;
font-size: 1.2rem;
}
.logo-mark {
display: grid;
place-items: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent) 0%, #8a5a22 100%);
color: #0b0c10;
font-weight: 700;
font-family: "Inter", sans-serif;
font-size: 0.85rem;
}
.main-nav {
display: flex;
gap: 1.3rem;
align-items: center;
}
.main-nav a {
color: var(--muted);
font-size: 0.93rem;
font-weight: 500;
transition: color 0.2s;
}
.main-nav a:hover { color: var(--text); }
.main-nav .btn { margin-left: 0.6rem; }
.lang-toggle {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.4rem 0.85rem;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.08em;
}
.lang-toggle:hover { border-color: var(--accent); }
.menu-toggle {
display: none;
background: transparent;
border: 1px solid var(--line);
color: var(--text);
border-radius: 8px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
/* ---------------- Buttons ---------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
cursor: pointer;
background: var(--accent);
color: #0b0c10;
padding: 0.8rem 1.2rem;
border-radius: 10px;
font-weight: 600;
font-size: 0.95rem;
transition: transform 0.15s ease, background 0.15s ease;
font-family: inherit;
}
.btn:hover { background: var(--accent-strong); transform: translateY(-1px); color: #0b0c10; }
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--line);
}
.btn.ghost:hover { border-color: var(--accent); color: var(--text); }
.btn.small { padding: 0.55rem 0.9rem; font-size: 0.85rem; }
.btn.danger { background: var(--danger); color: #fff; }
.btn.danger:hover { background: #f06060; }
/* ---------------- Hero ---------------- */
.hero {
position: relative;
padding: 6rem 0 5rem;
overflow: hidden;
}
.hero::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(11,12,16,0.6) 0%, rgba(11,12,16,0.95) 100%),
url('https://images.unsplash.com/photo-1503376780353-7e6692767b70?auto=format&fit=crop&w=1900&q=80') center / cover no-repeat;
z-index: -1;
}
.hero .shell { max-width: 760px; }
.hero h1 { margin-bottom: 1rem; }
.hero p.lead { color: var(--muted); font-size: 1.1rem; max-width: 55ch; }
.hero-cta {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
margin-top: 1.8rem;
}
.hero-stats {
display: flex;
gap: 2rem;
margin-top: 2.5rem;
flex-wrap: wrap;
}
.hero-stats div { min-width: 8rem; }
.hero-stats strong {
display: block;
font-size: 1.5rem;
font-family: "Playfair Display", serif;
color: var(--accent-strong);
}
.hero-stats span { color: var(--muted); font-size: 0.85rem; }
/* ---------------- Section head ---------------- */
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.section-head > div { max-width: 55ch; }
.section-head p.sub { color: var(--muted); margin: 0; }
/* ---------------- Filters ---------------- */
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.filters label { display: grid; gap: 0.35rem; font-size: 0.82rem; color: var(--muted); }
select, input, textarea {
width: 100%;
padding: 0.7rem 0.85rem;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
font: inherit;
transition: border-color 0.15s;
}
select:focus, input:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
/* ---------------- Vehicle grid ---------------- */
.vehicle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.2rem;
}
.vehicle-card {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s, border-color 0.2s;
}
.vehicle-card:hover {
transform: translateY(-3px);
border-color: var(--accent);
}
.vehicle-photo {
position: relative;
aspect-ratio: 16 / 10;
background: #0e1015 center / cover no-repeat;
overflow: hidden;
}
.vehicle-photo .badge {
position: absolute;
top: 0.7rem;
left: 0.7rem;
background: rgba(11,12,16,0.75);
backdrop-filter: blur(5px);
color: var(--accent-strong);
padding: 0.25rem 0.55rem;
border-radius: 999px;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.vehicle-body {
padding: 1rem 1.1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
flex: 1;
}
.vehicle-body h3 { margin: 0; }
.vehicle-body .model-brand { color: var(--muted); font-size: 0.82rem; letter-spacing: 0.12em; text-transform: uppercase; }
.spec-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem;
padding: 0.6rem 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
font-size: 0.82rem;
}
.spec-row div { text-align: center; }
.spec-row strong { display: block; color: var(--text); font-size: 0.95rem; }
.spec-row span { color: var(--muted); font-size: 0.72rem; }
.vehicle-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
margin-top: auto;
}
.vehicle-price {
font-family: "Playfair Display", serif;
font-size: 1.35rem;
color: var(--accent-strong);
}
.vehicle-price span { font-size: 0.8rem; color: var(--muted); font-family: inherit; }
/* ---------------- Why section ---------------- */
.why-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.why-card {
padding: 1.8rem 1.4rem;
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.why-card .icon {
width: 2.8rem;
height: 2.8rem;
border-radius: 10px;
background: rgba(196, 138, 66, 0.15);
color: var(--accent-strong);
display: grid;
place-items: center;
font-size: 1.3rem;
margin-bottom: 0.9rem;
}
.why-card p { color: var(--muted); margin: 0.3rem 0 0; }
/* ---------------- Reviews ---------------- */
.reviews-strip {
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 2rem;
min-height: 180px;
}
.review-quote {
font-family: "Playfair Display", serif;
font-size: 1.25rem;
line-height: 1.5;
margin: 0;
font-style: italic;
}
.review-author { margin-top: 0.8rem; color: var(--muted); font-size: 0.88rem; }
.review-dots {
display: flex;
gap: 0.4rem;
justify-content: center;
margin-top: 1rem;
}
.review-dots button {
width: 9px; height: 9px; border-radius: 50%;
border: none; background: var(--line); cursor: pointer;
transition: background 0.2s, width 0.2s;
}
.review-dots button.active { background: var(--accent); width: 24px; border-radius: 5px; }
/* ---------------- Booking ---------------- */
.booking-form {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.9rem;
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.8rem;
}
.booking-form .full { grid-column: 1 / -1; }
.booking-form label { display: grid; gap: 0.3rem; font-size: 0.82rem; color: var(--muted); }
.form-feedback {
margin-top: 1rem;
min-height: 1.2rem;
color: var(--ok);
}
.form-feedback.error { color: var(--danger); }
/* ---------------- Footer ---------------- */
.site-footer {
border-top: 1px solid var(--line);
background: var(--bg-elev);
padding: 3rem 0 2rem;
margin-top: 3rem;
}
.footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.footer-grid h4 {
font-family: "Inter", sans-serif;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--muted);
margin-bottom: 0.7rem;
}
.footer-grid a {
display: block;
color: var(--text);
margin-bottom: 0.4rem;
font-size: 0.92rem;
}
.footer-bottom {
border-top: 1px solid var(--line);
padding-top: 1.2rem;
display: flex;
justify-content: space-between;
color: var(--muted);
font-size: 0.82rem;
flex-wrap: wrap;
gap: 0.6rem;
}
/* ---------------- Dialog ---------------- */
dialog {
width: min(700px, 92vw);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text);
padding: 0;
box-shadow: var(--shadow);
}
dialog::backdrop { background: rgba(0,0,0,0.6); }
.dialog-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.2rem;
border-bottom: 1px solid var(--line);
}
.dialog-body { padding: 1.2rem; }
.dialog-body img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 10px;
margin-bottom: 1rem;
}
.dialog-close {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 8px;
cursor: pointer;
width: 2rem; height: 2rem;
display: grid; place-items: center;
font-size: 1.2rem;
}
/* ---------------- Admin ---------------- */
.admin-page {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1rem;
}
.admin-login {
max-width: 420px;
margin: 5rem auto;
padding: 2rem;
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.admin-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--line);
}
.admin-bar h1 { margin: 0; font-size: 1.4rem; }
.admin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.panel {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.4rem;
}
.panel h2 { font-size: 1.1rem; font-family: "Inter", sans-serif; margin-bottom: 1rem; }
table.admin-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
table.admin-table th, table.admin-table td {
text-align: left;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
}
table.admin-table th { color: var(--muted); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; }
table.admin-table tr:hover { background: rgba(255,255,255,0.02); }
.admin-form { display: grid; gap: 0.7rem; }
.admin-form label { display: grid; gap: 0.25rem; font-size: 0.82rem; color: var(--muted); }
.admin-form .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem; }
.admin-form .row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.7rem; }
.admin-photo-preview {
width: 100%;
aspect-ratio: 16 / 9;
background: var(--bg-elev) center / cover no-repeat;
border: 1px dashed var(--line);
border-radius: 10px;
margin-bottom: 0.5rem;
}
/* Admin tabs */
.admin-tabs { display: flex; gap: 0.4rem; margin-bottom: 1.2rem; border-bottom: 1px solid var(--line); padding-bottom: 0.2rem; flex-wrap: wrap; }
.admin-tabs .tab {
background: transparent; border: none; color: var(--muted);
padding: 0.6rem 1rem; border-radius: 10px 10px 0 0;
font-family: "Inter", sans-serif; font-weight: 500; cursor: pointer;
display: inline-flex; align-items: center; gap: 0.4rem;
border-bottom: 2px solid transparent;
}
.admin-tabs .tab:hover { color: var(--fg); }
.admin-tabs .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-badge {
background: var(--bg-elev); color: var(--fg);
font-size: 0.7rem; padding: 0.1rem 0.5rem;
border-radius: 999px; min-width: 1.3rem; text-align: center;
}
.admin-tabs .tab.active .tab-badge { background: var(--accent); color: #111; }
.sub-tabs { display: inline-flex; gap: 0.3rem; background: var(--bg-elev); border: 1px solid var(--line); border-radius: 999px; padding: 0.2rem; }
.sub-tab {
background: transparent; border: none; color: var(--muted);
padding: 0.35rem 0.9rem; border-radius: 999px; cursor: pointer;
font-size: 0.82rem; font-family: "Inter", sans-serif;
}
.sub-tab.active { background: var(--accent); color: #111; font-weight: 600; }
/* Pills */
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid var(--line); }
.pill-new { background: rgba(200, 150, 80, 0.15); color: #e4b676; border-color: rgba(200, 150, 80, 0.4); }
.pill-qualified { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border-color: rgba(90, 180, 120, 0.4); }
.pill-disqualified { background: rgba(180, 90, 90, 0.15); color: #d48a8a; border-color: rgba(180, 90, 90, 0.4); }
.pill-active { background: rgba(90, 180, 120, 0.15); color: #6ecf96; border-color: rgba(90, 180, 120, 0.4); }
.pill-inactive { background: rgba(160, 160, 160, 0.12); color: var(--muted); }
.muted { color: var(--muted); }
.btn.small { padding: 0.35rem 0.7rem; font-size: 0.78rem; }
.btn.danger { background: #7a2b2b; color: #fff; }
.btn.danger:hover { background: #8f3535; }
/* Dialog */
dialog#leadDialog {
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--bg-card); color: var(--fg);
padding: 0; max-width: 560px; width: 92%;
}
dialog#leadDialog::backdrop { background: rgba(0,0,0,0.6); }
.dialog-head {
display: flex; justify-content: space-between; align-items: center;
padding: 1rem 1.2rem; border-bottom: 1px solid var(--line);
}
.dialog-close {
background: transparent; border: none; color: var(--muted);
font-size: 1.4rem; cursor: pointer; line-height: 1;
}
.dialog-body { padding: 1.2rem; }
dl.kv { display: grid; grid-template-columns: 110px 1fr; gap: 0.4rem 1rem; margin: 0; font-size: 0.88rem; }
dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; }
/* ---------------- Responsive ---------------- */
@media (max-width: 900px) {
.filters, .booking-form, .admin-grid, .why-grid { grid-template-columns: 1fr; }
.footer-grid { grid-template-columns: 1fr 1fr; }
.section-head { flex-direction: column; align-items: flex-start; }
}
@media (max-width: 700px) {
.main-nav { display: none; position: absolute; right: 1rem; top: 100%; flex-direction: column; background: var(--bg-elev); border: 1px solid var(--line); padding: 1rem; border-radius: var(--radius); }
.main-nav.open { display: flex; }
.menu-toggle { display: inline-flex; }
.footer-grid { grid-template-columns: 1fr; }
.admin-form .row2, .admin-form .row3 { grid-template-columns: 1fr; }
}