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:
@@ -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
|
||||
@@ -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>
|
||||
@@ -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 => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[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
@@ -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();
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user