diff --git a/README.md b/README.md
index 05debb3..d5a6a29 100644
--- a/README.md
+++ b/README.md
@@ -1,75 +1,80 @@
# Fotobox-Webseite
-Diese Anwendung stellt eine komplette Vermietungsseite fuer eine Fotobox bereit. Sie enthaelt:
+Diese Anwendung stellt eine mehrseitige deutsche Vermietungsseite für eine Fotobox bereit. Sie enthält:
-- eine neu aufgebaute Landingpage auf Basis der Inhalte von `https://ctb-it.de/fotobox/`
-- eine oeffentliche Buchungsanfrage mit zwei Terminen, Live-Preisberechnung und Zahlungsart-Auswahl
-- einen Admin-Bereich fuer Anfragen, manuelle Kundenbestellungen, Statuspflege und Rechnungs-PDFs
-- eine MySQL-faehige Datenhaltung mit JSON-Fallback
+- eine komplett neu aufgebaute öffentliche Website mit den Seiten `Leistungen`, `Preise`, `Verfügbarkeit`, `Buchen`, `Ablauf`, `FAQ`, `Kontakt`, `Impressum`, `Datenschutz` und `Mietbedingungen`
+- eine Buchungsanfrage mit Nachtlogik: `Montag bis Dienstag = 1 Miettag`
+- Live-Preisberechnung mit `99,99 €` pro Miettag
+- einen Verwaltungsbereich für Anfragen, Buchungen, Kunden, Kalender, Rechnungen und Einstellungen
+- MySQL-Unterstützung mit Tabellenpräfix `fb_` sowie JSON-Fallback
## Starten
-Die Anwendung benoetigt nur PHP 8.3 oder neuer.
+Die Anwendung benötigt PHP `8.3` oder neuer.
+
+Für den lokalen Start sollte die Website mit dem Router-Skript gestartet werden:
```bash
-php -S 127.0.0.1:8000
+php -S 127.0.0.1:8000 router.php
```
Danach ist die Seite unter `http://127.0.0.1:8000` erreichbar.
-Wenn die Anwendung unter einem Unterordner laeuft, kannst du zusaetzlich `FOTOBOX_BASE_PATH` setzen, z. B.:
+Wenn die Anwendung hinter einem Proxy oder in einem Unterordner läuft, kann zusätzlich `FOTOBOX_BASE_PATH` gesetzt werden:
```bash
-FOTOBOX_BASE_PATH=/fotobox php -S 127.0.0.1:8000
+FOTOBOX_BASE_PATH=/proxy/8000 php -S 127.0.0.1:8000 router.php
```
## Admin-Zugang
- Benutzername: `admin`
-- Passwort: Standardmaessig `fotobox-admin`
+- Passwort: standardmäßig `fotobox-admin`
-Falls du das Passwort aendern willst, setze die Umgebungsvariable `FOTOBOX_ADMIN_PASSWORD`.
+Falls du das Passwort ändern willst, setze die Umgebungsvariable `FOTOBOX_ADMIN_PASSWORD`.
## Datenhaltung
-Standardmaessig nutzt die App JSON-Dateien:
+Standardmäßig nutzt die App die Dateien:
- `storage/bookings.json`
- `storage/invoices.json`
-Sobald du `mysql.local.php` mit echten Zugangsdaten befuellst, `enabled => true` setzt und optional ein `table_prefix` definierst, schaltet die App automatisch auf MySQL um.
+Sobald `mysql.local.php` mit echten Zugangsdaten befüllt ist und `enabled => true` gesetzt wurde, schaltet die App automatisch auf MySQL um.
## MySQL vorbereiten
-Im Repository liegt als Vorlage:
+Im Repository liegen als Vorlage:
- `mysql.local.php.example`
- `docs/mysql-schema.sql`
-Lokal liegt ausserhalb des Git-Trackings:
+Lokal außerhalb des Git-Trackings liegt:
- `mysql.local.php`
-Die Datei `mysql.local.php` ist bereits in `.gitignore` ausgeschlossen und kann von dir mit echten Zugangsdaten befuellt werden.
-Standardmaessig verwendet die App das Prefix `fb_`, also z. B. `fb_bookings` und `fb_invoices`.
+Die Datei `mysql.local.php` ist bereits in `.gitignore` ausgeschlossen. Standardmäßig verwendet die App das Präfix `fb_`, also zum Beispiel `fb_bookings` und `fb_invoices`.
-## Wichtige Annahmen
+## Wichtige Regeln im System
-- Mietbeginn und Mietende werden inklusiv berechnet.
-- Standardpreis: `99,99 EUR` pro Kalendertag.
-- Zahlungsarten: `Rechnung / Ueberweisung` und `PayPal`.
-- Die PayPal-Auswahl ist im Prozess und in der Verwaltung abgebildet; fuer einen echten Live-Payment-Flow brauchst du spaeter zusaetzliche API-Zugangsdaten.
+- Ein Miettag entspricht immer einer Übernachtung.
+- Beispiel: `Montag bis Dienstag = 1 Miettag`
+- Der Standardpreis beträgt `99,99 €` pro Miettag.
+- Zahlungsarten: `Rechnung / Überweisung` und `PayPal`
+- Öffentliche Eingaben sind zunächst Buchungsanfragen und werden erst nach Bestätigung verbindlich.
## Verwaltung
-Im Admin-Bereich kannst du:
+Im Verwaltungsbereich können aktuell folgende Aufgaben erledigt werden:
-- neue Kundenbestellungen manuell anlegen
-- Anfragen bestaetigen oder stornieren
-- Zahlungsstatus pflegen
-- Rechnungen mit Kundendaten erzeugen
-- Rechnungen als PDF oeffnen
+- offene Anfragen prüfen
+- Buchungen manuell für Kunden anlegen
+- Kalender und belegte Zeiträume einsehen
+- Kundenhistorien aus Aufträgen ableiten
+- Rechnungen mit Kundendaten erzeugen und als PDF öffnen
+- Zahlungs- und Buchungsstatus pflegen
+- aktiven Speicher-Treiber und das Tabellenpräfix prüfen
## Tests
-Eine kurze Checkliste liegt in [docs/manual-test.md](docs/manual-test.md).
+Eine kurze Checkliste liegt in [docs/manual-test.md](/config/workspace/fotobox-webspite/docs/manual-test.md:1).
diff --git a/assets/app.js b/assets/app.js
index d0939cc..dfb14ad 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -6,14 +6,20 @@ const formatCurrency = (cents) =>
currency: 'EUR',
}).format(cents / 100);
-const calculateDays = (start, end) => {
+const calculateRentalDays = (start, end) => {
if (!start || !end) return null;
+
const startDate = new Date(start);
const endDate = new Date(end);
- if (Number.isNaN(startDate.valueOf()) || Number.isNaN(endDate.valueOf())) return null;
+
+ if (Number.isNaN(startDate.valueOf()) || Number.isNaN(endDate.valueOf())) {
+ return null;
+ }
+
const milliseconds = endDate.getTime() - startDate.getTime();
- const days = Math.floor(milliseconds / 86400000) + 1;
- return days > 0 ? days : null;
+ const rentalDays = Math.floor(milliseconds / 86400000);
+
+ return rentalDays > 0 ? rentalDays : null;
};
forms.forEach((form) => {
@@ -22,24 +28,24 @@ forms.forEach((form) => {
const daysOutput = form.querySelector('[data-summary-days]');
const totalOutput = form.querySelector('[data-summary-total]');
const rateInput = form.querySelector('input[name="price_per_day_cents"]');
- const defaultRate = Number(form.dataset.dayRate || 9999);
+ const defaultRate = Number(form.dataset.dayRate || rateInput?.value || 9999);
const render = () => {
- const days = calculateDays(startInput?.value, endInput?.value);
+ const rentalDays = calculateRentalDays(startInput?.value, endInput?.value);
const rate = Number(rateInput?.value || defaultRate);
- if (!days || rate < 0) {
- if (daysOutput) daysOutput.textContent = 'Noch nicht gewaehlt';
+ if (!rentalDays || rate < 0) {
+ if (daysOutput) daysOutput.textContent = 'Noch nicht gewählt';
if (totalOutput) totalOutput.textContent = formatCurrency(defaultRate);
return;
}
if (daysOutput) {
- daysOutput.textContent = `${days} ${days === 1 ? 'Tag' : 'Tage'}`;
+ daysOutput.textContent = `${rentalDays} ${rentalDays === 1 ? 'Miettag' : 'Miettage'}`;
}
if (totalOutput) {
- totalOutput.textContent = formatCurrency(days * rate);
+ totalOutput.textContent = formatCurrency(rentalDays * rate);
}
};
diff --git a/assets/styles.css b/assets/styles.css
index 8e684e4..adc290a 100644
--- a/assets/styles.css
+++ b/assets/styles.css
@@ -1,24 +1,33 @@
:root {
- --bg: #f4f0e8;
- --bg-panel: rgba(255, 252, 247, 0.84);
- --card: #fffdf8;
+ --bg: #f4efe7;
+ --bg-elevated: rgba(255, 252, 247, 0.86);
+ --bg-strong: #fffaf4;
--surface: #ffffff;
- --line: rgba(30, 24, 19, 0.1);
- --text: #171412;
- --muted: #5a5048;
- --accent: #b56a38;
- --accent-deep: #6e3413;
- --accent-soft: #edd8c9;
- --trust: #2f5a4d;
- --success: #1f6c46;
- --error: #8a2630;
- --shadow: 0 24px 60px rgba(33, 24, 17, 0.1);
- --radius-lg: 32px;
- --radius-md: 20px;
+ --surface-muted: #efe6d9;
+ --surface-admin: #f8f5ef;
+ --text: #2f261f;
+ --text-soft: #66574a;
+ --line: rgba(61, 43, 31, 0.12);
+ --line-strong: rgba(61, 43, 31, 0.2);
+ --accent: #b76a47;
+ --accent-strong: #9f5434;
+ --accent-soft: rgba(183, 106, 71, 0.14);
+ --green: #4d695b;
+ --green-soft: rgba(77, 105, 91, 0.16);
+ --shadow-soft: 0 24px 60px rgba(57, 39, 28, 0.10);
+ --shadow-card: 0 18px 36px rgba(57, 39, 28, 0.08);
+ --radius-xl: 32px;
+ --radius-lg: 24px;
+ --radius-md: 18px;
--radius-sm: 12px;
+ --content-width: 1180px;
+ --serif: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", Georgia, serif;
+ --sans: "Avenir Next", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}
-* {
+*,
+*::before,
+*::after {
box-sizing: border-box;
}
@@ -28,18 +37,20 @@ html {
body {
margin: 0;
- min-height: 100vh;
- font-family: "Trebuchet MS", "Gill Sans", sans-serif;
- color: var(--text);
background:
- radial-gradient(circle at top left, rgba(214, 171, 135, 0.2), transparent 28%),
- radial-gradient(circle at 90% 12%, rgba(59, 95, 82, 0.08), transparent 26%),
- linear-gradient(180deg, #fbf8f2 0%, var(--bg) 100%);
+ radial-gradient(circle at top left, rgba(183, 106, 71, 0.12), transparent 30%),
+ linear-gradient(180deg, #f7f2ea 0%, #f1ebe1 100%);
+ color: var(--text);
+ font-family: var(--sans);
+ line-height: 1.6;
}
a {
color: inherit;
- text-decoration: none;
+}
+
+img {
+ max-width: 100%;
}
button,
@@ -50,858 +61,1093 @@ textarea {
}
.page-shell {
- width: min(1240px, calc(100% - 32px));
+ min-height: 100vh;
+}
+
+.site-main {
+ width: min(100%, var(--content-width));
margin: 0 auto;
- padding: 24px 0 72px;
+ padding: 2.5rem 1.25rem 5rem;
+}
+
+.topbar {
+ border-bottom: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.5);
+ backdrop-filter: blur(18px);
+}
+
+.topbar-inner,
+.header-inner {
+ width: min(100%, var(--content-width));
+ margin: 0 auto;
+ padding: 0.85rem 1.25rem;
+}
+
+.topbar-inner {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem 1.2rem;
+ justify-content: space-between;
+ color: var(--text-soft);
+ font-size: 0.92rem;
}
.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ border-bottom: 1px solid var(--line);
+ backdrop-filter: blur(18px);
+}
+
+.site-header-public {
+ background: rgba(248, 243, 235, 0.85);
+}
+
+.site-header-admin {
+ background: rgba(250, 247, 242, 0.96);
+}
+
+.header-inner {
display: flex;
align-items: center;
+ gap: 1rem;
justify-content: space-between;
- gap: 24px;
- padding: 18px 22px;
- position: sticky;
- top: 18px;
- z-index: 20;
- margin-bottom: 32px;
- background: rgba(252, 248, 241, 0.88);
- border: 1px solid rgba(255, 255, 255, 0.75);
- border-radius: 999px;
- backdrop-filter: blur(16px);
- box-shadow: 0 12px 30px rgba(58, 39, 24, 0.08);
}
.brand {
display: inline-flex;
align-items: center;
- gap: 14px;
- font-family: "Baskerville", "Iowan Old Style", Georgia, serif;
-}
-
-.brand strong,
-h1,
-h2,
-h3 {
- font-family: "Baskerville", "Iowan Old Style", Georgia, serif;
- letter-spacing: -0.02em;
-}
-
-.brand small {
- display: block;
- color: var(--muted);
- font-size: 0.8rem;
- font-family: "Trebuchet MS", "Gill Sans", sans-serif;
+ gap: 0.85rem;
+ text-decoration: none;
+ min-width: 0;
}
.brand-mark {
- display: grid;
+ display: inline-grid;
place-items: center;
- width: 46px;
- height: 46px;
- border-radius: 50%;
- background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
+ width: 3rem;
+ height: 3rem;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--accent) 0%, #d9aa8b 100%);
color: white;
font-weight: 700;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
+ letter-spacing: 0.08em;
+}
+
+.brand-copy {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ min-width: 0;
+}
+
+.brand-copy strong {
+ font-size: 1rem;
+ letter-spacing: 0.02em;
+}
+
+.brand-copy small {
+ color: var(--text-soft);
+ font-size: 0.82rem;
}
.site-nav {
display: flex;
- flex-wrap: wrap;
align-items: center;
- gap: 18px;
- color: var(--muted);
+ flex-wrap: wrap;
+ gap: 0.5rem;
}
-.site-nav form {
- margin: 0;
+.site-nav a {
+ padding: 0.6rem 0.9rem;
+ border-radius: 999px;
+ text-decoration: none;
+ color: var(--text-soft);
+ transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
+}
+
+.site-nav a:hover,
+.site-nav a:focus-visible,
+.site-nav a.is-active {
+ background: var(--surface);
+ color: var(--text);
+ transform: translateY(-1px);
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
}
-.primary-link,
.button-primary,
.button-secondary,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
- gap: 10px;
+ gap: 0.5rem;
+ padding: 0.95rem 1.25rem;
border-radius: 999px;
- padding: 14px 22px;
- border: 0;
+ border: 1px solid transparent;
+ text-decoration: none;
cursor: pointer;
- transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
-.primary-link,
.button-primary {
- background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
- color: #fff;
- box-shadow: 0 18px 40px rgba(137, 63, 27, 0.24);
+ background: linear-gradient(135deg, var(--accent) 0%, #ca8f71 100%);
+ color: white;
+ box-shadow: 0 16px 34px rgba(183, 106, 71, 0.22);
+}
+
+.button-primary:hover,
+.button-primary:focus-visible,
+.button-secondary:hover,
+.button-secondary:focus-visible,
+.ghost-button:hover,
+.ghost-button:focus-visible {
+ transform: translateY(-1px);
}
.button-secondary {
- background: rgba(255, 255, 255, 0.78);
+ background: rgba(255, 255, 255, 0.72);
+ border-color: var(--line);
color: var(--text);
- border: 1px solid var(--line);
}
.ghost-button {
background: transparent;
- color: var(--muted);
+ border-color: var(--line);
+ color: var(--text-soft);
}
.button-block {
width: 100%;
}
-.primary-link:hover,
-.button-primary:hover,
-.button-secondary:hover,
-.ghost-button:hover {
- transform: translateY(-1px);
-}
-
-main {
+.hero,
+.page-hero {
display: grid;
- gap: 28px;
+ gap: 1.5rem;
}
-.hero-section,
-.trust-bar,
-.feature-strip,
-.content-grid,
-.occasion-section,
-.pricing-panel,
-.availability-section,
-.booking-section,
-.faq-section,
-.admin-section,
-.admin-login-section {
- background: var(--bg-panel);
- border: 1px solid rgba(255, 255, 255, 0.7);
- box-shadow: var(--shadow);
- border-radius: var(--radius-lg);
+.hero {
+ grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr);
+ align-items: stretch;
+ padding-top: 1rem;
}
-.hero-section {
- display: grid;
- grid-template-columns: 1.08fr 0.92fr;
- gap: 34px;
- padding: 48px;
+.hero-copy,
+.hero-panel,
+.page-hero,
+.content-card,
+.feature-card,
+.module-card,
+.occasion-card,
+.trust-card,
+.availability-card,
+.faq-card,
+.table-card,
+.admin-login-card,
+.booking-form-shell,
+.legal-card,
+.stat-card {
+ border: 1px solid var(--line);
+ background: var(--bg-elevated);
+ backdrop-filter: blur(14px);
+ box-shadow: var(--shadow-card);
+}
+
+.hero-copy,
+.hero-panel,
+.page-hero {
+ padding: 2rem;
+ border-radius: var(--radius-xl);
+}
+
+.hero-copy {
+ background:
+ linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(244, 236, 224, 0.84)),
+ var(--bg-elevated);
+}
+
+.hero-panel {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background:
+ radial-gradient(circle at top, rgba(183, 106, 71, 0.18), transparent 32%),
+ linear-gradient(180deg, rgba(245, 239, 231, 0.98), rgba(236, 228, 216, 0.92));
}
.eyebrow {
- margin: 0 0 12px;
+ margin: 0 0 0.75rem;
+ color: var(--accent-strong);
+ font-size: 0.88rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
text-transform: uppercase;
- letter-spacing: 0.18em;
- font-size: 0.78rem;
- color: var(--accent-deep);
+}
+
+h1,
+h2,
+h3 {
+ margin: 0 0 0.9rem;
+ line-height: 1.08;
+ font-family: var(--serif);
font-weight: 700;
}
-.hero-copy h1,
-.admin-login-card h1,
-.section-header h1 {
- margin: 0;
- font-size: clamp(2.8rem, 5vw, 5rem);
- line-height: 0.94;
+h1 {
+ font-size: clamp(2.4rem, 4vw, 4.7rem);
+}
+
+h2 {
+ font-size: clamp(1.8rem, 3vw, 2.8rem);
+}
+
+h3 {
+ font-size: 1.25rem;
+}
+
+p {
+ margin: 0 0 1rem;
+ color: var(--text-soft);
}
.hero-text,
-.pricing-panel p,
-.booking-copy p,
-.admin-login-card p {
- color: var(--muted);
+.page-hero p:last-child {
+ max-width: 42rem;
font-size: 1.05rem;
- line-height: 1.65;
}
-.hero-actions {
+.hero-actions,
+.section-actions {
display: flex;
+ gap: 0.9rem;
flex-wrap: wrap;
- gap: 14px;
- margin: 28px 0 26px;
+ margin-top: 1.5rem;
}
-.hero-highlight-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.hero-highlight-card,
-.feature-strip article,
-.content-block,
-.pricing-aside,
-.booking-form-card,
-.table-card,
-.admin-login-card,
-.booking-info-card,
-.availability-card,
-.trust-bar article,
-.occasion-grid article,
-.form-section {
- background: rgba(255, 255, 255, 0.76);
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
-}
-
-.hero-highlight-card {
- padding: 16px 18px;
-}
-
-.hero-highlight-card span {
- display: block;
- color: var(--trust);
- font-size: 0.76rem;
- text-transform: uppercase;
- letter-spacing: 0.12em;
- margin-bottom: 8px;
- font-weight: 700;
-}
-
-.hero-highlight-card strong {
- display: block;
- font-size: 1rem;
- line-height: 1.35;
-}
-
-.hero-card {
- display: grid;
- gap: 16px;
- padding: 22px;
- background:
- linear-gradient(145deg, rgba(255, 251, 246, 0.95), rgba(246, 233, 220, 0.9)),
- var(--card);
- border-radius: 24px;
- min-height: 520px;
- border: 1px solid rgba(104, 65, 39, 0.12);
-}
-
-.hero-card-panel {
+.hero-panel-top,
+.hero-panel-bottom {
display: flex;
justify-content: space-between;
- gap: 16px;
- padding: 16px 18px;
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.58);
- color: var(--muted);
+ gap: 1rem;
+ color: var(--text-soft);
}
-.hero-card-panel strong {
+.hero-panel-top strong,
+.hero-panel-bottom strong {
+ display: block;
color: var(--text);
}
-.hero-card-visual {
+.device-stage {
position: relative;
+ min-height: 23rem;
display: grid;
place-items: center;
overflow: hidden;
- border-radius: 24px;
- min-height: 340px;
- background:
- radial-gradient(circle at 50% 12%, rgba(255, 243, 229, 0.95), transparent 28%),
- linear-gradient(180deg, rgba(212, 189, 170, 0.42), rgba(57, 43, 35, 0.08));
}
-.device-plinth {
+.device-glow {
position: absolute;
- bottom: 32px;
- width: 220px;
- height: 24px;
+ inset: auto 15% 8% 15%;
+ height: 4rem;
border-radius: 999px;
- background: rgba(52, 38, 30, 0.12);
- filter: blur(6px);
+ background: rgba(183, 106, 71, 0.18);
+ filter: blur(22px);
}
-.camera-tower {
+.device-card {
+ position: absolute;
+ padding: 0.9rem 1rem;
+ border-radius: var(--radius-md);
+ background: rgba(255, 255, 255, 0.84);
+ border: 1px solid rgba(61, 43, 31, 0.08);
+ box-shadow: var(--shadow-card);
+}
+
+.device-card span {
+ display: block;
+ color: var(--text-soft);
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.device-card strong {
+ color: var(--text);
+}
+
+.device-card-left {
+ left: 0.75rem;
+ top: 2rem;
+}
+
+.device-card-right {
+ right: 0.75rem;
+ bottom: 3rem;
+}
+
+.device-illustration {
position: relative;
display: grid;
justify-items: center;
- z-index: 1;
+ gap: 0.75rem;
}
-.camera-head {
- position: relative;
- width: 210px;
- height: 230px;
- border-radius: 30px;
- background: linear-gradient(180deg, #25201c 0%, #12100e 100%);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 42px rgba(0, 0, 0, 0.35);
-}
-
-.camera-head::before {
- content: "";
- position: absolute;
- inset: 18px 18px auto auto;
- width: 58px;
- height: 90px;
- border-radius: 18px;
- background: linear-gradient(180deg, rgba(239, 179, 137, 0.9), rgba(255, 252, 248, 0.18));
-}
-
-.camera-head::after {
- content: "";
- position: absolute;
- inset: -20px auto auto 34px;
- width: 72px;
- height: 52px;
- border-radius: 18px 18px 8px 8px;
- background: linear-gradient(180deg, #28211e 0%, #141111 100%);
-}
-
-.camera-lens {
- position: absolute;
- inset: 54px auto auto 38px;
- width: 132px;
- height: 132px;
- border-radius: 50%;
- background:
- radial-gradient(circle at 42% 42%, rgba(95, 170, 221, 0.95) 0%, rgba(41, 86, 111, 0.9) 22%, rgba(4, 7, 10, 0.95) 58%, #050607 100%);
- border: 12px solid #4a403a;
-}
-
-.camera-flash {
- position: absolute;
- inset: 32px 28px auto auto;
- width: 26px;
- height: 26px;
- border-radius: 50%;
- background: radial-gradient(circle, rgba(255, 251, 245, 1) 0%, rgba(246, 197, 151, 0.78) 58%, rgba(255, 255, 255, 0.15) 100%);
-}
-
-.camera-stand {
- width: 18px;
- height: 108px;
- background: linear-gradient(180deg, #161311 0%, #3c342f 100%);
-}
-
-.camera-base {
- width: 160px;
- height: 14px;
+.device-head,
+.device-body,
+.device-base,
+.device-neck {
border-radius: 999px;
- background: #241d18;
+ background: linear-gradient(180deg, #352b24 0%, #1f1915 100%);
+ box-shadow: 0 16px 24px rgba(31, 25, 21, 0.18);
}
-.photo-strip {
+.device-head {
+ position: relative;
+ width: 9rem;
+ height: 5rem;
+ border-radius: 2rem;
+}
+
+.device-lens,
+.device-flash {
position: absolute;
- top: 46px;
+ top: 50%;
+ transform: translateY(-50%);
+ border-radius: 999px;
+}
+
+.device-lens {
+ left: 1.1rem;
+ width: 2.1rem;
+ height: 2.1rem;
+ background: radial-gradient(circle, #5ea2ff 0%, #0f1525 70%);
+}
+
+.device-flash {
+ right: 1.2rem;
+ width: 1rem;
+ height: 1rem;
+ background: linear-gradient(180deg, #f3d9a6 0%, #b76a47 100%);
+}
+
+.device-neck {
+ width: 1rem;
+ height: 2.5rem;
+}
+
+.device-body {
+ width: 8rem;
+ height: 11rem;
+ border-radius: 2.25rem;
+}
+
+.device-base {
+ width: 11rem;
+ height: 1.25rem;
+}
+
+.trust-grid,
+.feature-card-grid,
+.occasion-grid,
+.module-grid,
+.faq-grid,
+.stats-grid,
+.calendar-grid {
display: grid;
- gap: 10px;
- padding: 14px 10px;
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.78);
- border: 1px solid rgba(31, 21, 14, 0.08);
- color: var(--trust);
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- font-size: 0.76rem;
+ gap: 1rem;
}
-.photo-strip-left {
- left: 28px;
- transform: rotate(-8deg);
+.trust-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: 1.75rem;
}
-.photo-strip-right {
- right: 28px;
- top: 80px;
- transform: rotate(7deg);
+.trust-card,
+.feature-card,
+.module-card,
+.occasion-card,
+.faq-card,
+.stat-card,
+.legal-card {
+ padding: 1.35rem;
+ border-radius: var(--radius-lg);
}
-.trust-bar {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 14px;
- padding: 18px;
-}
-
-.trust-bar article {
- padding: 18px 20px;
-}
-
-.trust-bar span {
- display: block;
- font-size: 0.78rem;
- text-transform: uppercase;
- letter-spacing: 0.12em;
- color: var(--trust);
- margin-bottom: 10px;
- font-weight: 700;
-}
-
-.trust-bar strong {
- display: block;
- line-height: 1.35;
-}
-
-.feature-strip {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 18px;
- padding: 24px;
-}
-
-.feature-strip article,
-.content-block,
-.table-card,
-.admin-login-card {
- padding: 28px;
-}
-
-.feature-strip h2,
-.content-block h2,
-.pricing-panel h2,
-.booking-copy h2,
-.faq-section h2,
-.table-card h2,
-.section-heading h2 {
- margin: 0 0 12px;
- font-size: clamp(1.6rem, 3vw, 2.3rem);
-}
-
-.feature-strip p,
-.content-block span,
-.content-block p,
-.pricing-aside span,
+.trust-card span,
+.stat-card span,
+.summary-line span,
+.detail-list dt,
+.table-card-header span,
+.stack-item span,
.availability-card span,
-.availability-card small,
-.faq-list p,
-.table-card p,
-.detail-list dd,
-.stack-form span,
-.stack-form label,
-.booking-form span,
-.form-note,
-.occasion-grid span {
- color: var(--muted);
- line-height: 1.6;
+.pricing-example-list span,
+.contact-card span,
+.small-note {
+ color: var(--text-soft);
+ font-size: 0.92rem;
}
-.content-grid,
-.booking-section,
-.detail-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 22px;
- padding: 24px;
-}
-
-.step-list {
- display: grid;
- gap: 18px;
- margin: 24px 0 0;
- padding: 0;
- list-style: none;
-}
-
-.step-list li {
- display: grid;
- gap: 6px;
- padding: 18px 20px;
- border-radius: 18px;
- background: rgba(246, 238, 229, 0.72);
- border-left: 4px solid var(--accent);
-}
-
-.step-list strong {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.step-number {
- display: inline-grid;
- place-items: center;
- width: 30px;
- height: 30px;
- border-radius: 50%;
- background: var(--text);
- color: #fff;
- font-size: 0.85rem;
-}
-
-.check-list,
-.compact-list {
- margin: 0;
- padding-left: 20px;
- display: grid;
- gap: 10px;
-}
-
-.pricing-panel,
-.availability-section,
-.faq-section,
-.occasion-section {
- padding: 28px;
-}
-
-.pricing-panel {
- display: grid;
- grid-template-columns: 1.15fr 0.85fr;
- gap: 24px;
-}
-
-.pricing-aside {
- display: grid;
- gap: 14px;
- padding: 24px;
-}
-
-.pricing-aside strong,
-.price-summary strong,
-.stat-card strong {
- font-size: 1.45rem;
-}
-
-.section-heading {
- margin-bottom: 18px;
-}
-
-.occasion-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 16px;
-}
-
-.occasion-grid article {
- padding: 22px;
- display: grid;
- gap: 8px;
-}
-
-.availability-list,
-.faq-list,
-.stats-grid {
- display: grid;
- gap: 16px;
-}
-
-.availability-list {
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- margin-top: 20px;
-}
-
-.availability-card {
- padding: 20px;
- display: grid;
- gap: 8px;
-}
-
-.availability-card small {
- color: var(--trust);
- font-weight: 700;
-}
-
-.availability-card-empty {
- grid-column: 1 / -1;
-}
-
-.booking-section {
- align-items: start;
-}
-
-.booking-info-card {
- display: grid;
- gap: 8px;
- padding: 20px;
-}
-
-.booking-form-card {
- padding: 28px;
-}
-
-.booking-form,
-.stack-form {
- display: grid;
- gap: 18px;
-}
-
-.form-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 16px;
-}
-
-.form-grid-two {
- grid-template-columns: repeat(2, minmax(0, 1fr));
-}
-
-.form-section {
- padding: 18px;
-}
-
-.form-section-header {
- margin-bottom: 14px;
-}
-
-.form-section-header h3 {
- margin: 6px 0 0;
- font-size: 1.3rem;
-}
-
-.form-step {
- font-size: 0.78rem;
- text-transform: uppercase;
- letter-spacing: 0.12em;
- color: var(--trust);
- font-weight: 700;
-}
-
-.field-full {
- grid-column: 1 / -1;
-}
-
-label {
- display: grid;
- gap: 8px;
- font-size: 0.96rem;
-}
-
-input,
-select,
-textarea {
- width: 100%;
- border-radius: 14px;
- border: 1px solid rgba(69, 42, 26, 0.16);
- background: rgba(255, 255, 255, 0.92);
+.trust-card strong,
+.stat-card strong,
+.availability-card strong,
+.pricing-example-list strong,
+.stack-item strong {
color: var(--text);
- padding: 14px 16px;
+ display: block;
}
-textarea {
- resize: vertical;
-}
-
-.price-summary {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 16px;
- padding: 18px;
- border-radius: 18px;
- background: linear-gradient(135deg, rgba(244, 230, 217, 0.78), rgba(220, 234, 228, 0.72));
-}
-
-.price-summary-total {
- border-left: 1px solid rgba(31, 21, 14, 0.12);
- padding-left: 16px;
-}
-
-.flash {
- margin-bottom: 18px;
- padding: 14px 16px;
- border-radius: 16px;
- border: 1px solid transparent;
-}
-
-.flash-success {
- background: rgba(31, 108, 70, 0.12);
- border-color: rgba(31, 108, 70, 0.2);
- color: var(--success);
-}
-
-.flash-error {
- background: rgba(138, 38, 48, 0.1);
- border-color: rgba(138, 38, 48, 0.18);
- color: var(--error);
-}
-
-.faq-list {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-top: 18px;
-}
-
-.faq-list article {
- padding: 20px;
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.58);
- border: 1px solid var(--line);
-}
-
-.admin-login-section {
- display: grid;
- place-items: center;
- padding: 56px 24px;
-}
-
-.admin-login-card {
- width: min(520px, 100%);
-}
-
-.admin-section {
- padding: 28px;
-}
-
-.narrow-section {
- max-width: 1100px;
+.section {
+ margin-top: 1.5rem;
+ padding: 1rem 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 16px;
- margin-bottom: 22px;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+}
+
+.section-tight {
+ margin-top: 1.1rem;
+}
+
+.section-heading {
+ max-width: 50rem;
+ margin-bottom: 1.4rem;
+}
+
+.split-section,
+.detail-grid {
+ display: grid;
+ gap: 1.2rem;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.content-card,
+.table-card,
+.admin-login-card,
+.booking-form-shell {
+ padding: 1.5rem;
+ border-radius: var(--radius-xl);
+}
+
+.editorial-card {
+ background:
+ linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(237, 230, 220, 0.84));
+}
+
+.emphasis-card {
+ background:
+ linear-gradient(135deg, rgba(183, 106, 71, 0.08), rgba(77, 105, 91, 0.08)),
+ rgba(255, 252, 247, 0.86);
+}
+
+.step-list,
+.check-list {
+ display: grid;
+ gap: 1rem;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.step-list li {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 1rem;
+ align-items: start;
+}
+
+.large-step-list li {
+ padding: 1rem 0;
+ border-top: 1px solid var(--line);
+}
+
+.large-step-list li:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.step-number {
+ display: inline-grid;
+ place-items: center;
+ width: 2.4rem;
+ height: 2.4rem;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent-strong);
+ font-weight: 700;
+}
+
+.check-list li {
+ position: relative;
+ padding-left: 1.6rem;
+}
+
+.check-list li::before {
+ content: "";
+ position: absolute;
+ top: 0.55rem;
+ left: 0;
+ width: 0.65rem;
+ height: 0.65rem;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--accent) 0%, var(--green) 100%);
+}
+
+.compact-list {
+ gap: 0.75rem;
+}
+
+.availability-list,
+.pricing-example-list,
+.stack-list {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.availability-card,
+.stack-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem 1.1rem;
+ border-radius: var(--radius-md);
+}
+
+.pricing-example-list article {
+ display: grid;
+ gap: 0.25rem;
+ padding: 1rem;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.5);
+}
+
+.page-hero {
+ margin-top: 0.6rem;
+ background:
+ linear-gradient(140deg, rgba(255, 255, 255, 0.9), rgba(242, 236, 226, 0.84));
+}
+
+.booking-card {
+ padding: 1rem;
+}
+
+.booking-form-shell {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+}
+
+.booking-form {
+ display: grid;
+ gap: 1.25rem;
+}
+
+.stack-form {
+ display: grid;
+ gap: 1rem;
+}
+
+.form-section {
+ padding: 1.35rem;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.72);
+}
+
+.form-section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.45rem;
+}
+
+.form-step {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.35rem 0.7rem;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent-strong);
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.form-help,
+.form-note {
+ font-size: 0.95rem;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.form-grid-two {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.form-grid-span {
+ grid-column: 1 / -1;
+}
+
+label {
+ display: grid;
+ gap: 0.5rem;
+}
+
+label span {
+ font-size: 0.93rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ padding: 0.9rem 1rem;
+ background: white;
+ color: var(--text);
+}
+
+textarea {
+ resize: vertical;
+ min-height: 7rem;
+}
+
+input:focus,
+select:focus,
+textarea:focus,
+button:focus-visible,
+a:focus-visible {
+ outline: 2px solid rgba(183, 106, 71, 0.3);
+ outline-offset: 2px;
+}
+
+.booking-summary-card {
+ display: grid;
+ gap: 0.9rem;
+ padding: 1.2rem;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--line);
+ background: linear-gradient(145deg, rgba(77, 105, 91, 0.08), rgba(183, 106, 71, 0.06));
+}
+
+.summary-line {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.summary-line strong {
+ color: var(--text);
+ font-size: 1rem;
+}
+
+.summary-line-total strong {
+ font-size: 1.2rem;
+}
+
+.consent-stack {
+ display: grid;
+ gap: 0.8rem;
+}
+
+.checkbox-row {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.75rem;
+ align-items: start;
+ padding: 1rem 1.1rem;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.7);
+}
+
+.checkbox-row input {
+ width: 1rem;
+ height: 1rem;
+ margin-top: 0.2rem;
+}
+
+.checkbox-row span {
+ font-weight: 400;
+ color: var(--text-soft);
+}
+
+.checkbox-row a {
+ color: var(--accent-strong);
+}
+
+.contact-card {
+ display: grid;
+ gap: 0.35rem;
+ padding: 1.15rem;
+ border-radius: var(--radius-lg);
+ background: rgba(255, 255, 255, 0.78);
+ border: 1px solid var(--line);
+}
+
+.cta-band {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1.5rem 1.75rem;
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--line);
+ background: linear-gradient(145deg, rgba(255, 255, 255, 0.9), rgba(236, 228, 216, 0.86));
+ box-shadow: var(--shadow-card);
+}
+
+.faq-grid,
+.legal-section {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.legal-section {
+ display: grid;
+ gap: 1rem;
+}
+
+.legal-card a,
+.contact-card a,
+.site-footer a {
+ color: var(--accent-strong);
+ text-decoration: none;
+}
+
+.legal-card p:last-child {
+ margin-bottom: 0;
+}
+
+.flash {
+ padding: 1rem 1.1rem;
+ border-radius: var(--radius-md);
+ margin-bottom: 1rem;
+ border: 1px solid;
+}
+
+.flash-success {
+ background: rgba(77, 105, 91, 0.12);
+ border-color: rgba(77, 105, 91, 0.22);
+ color: #2d4b3d;
+}
+
+.flash-error {
+ background: rgba(183, 106, 71, 0.12);
+ border-color: rgba(183, 106, 71, 0.24);
+ color: #7a4129;
+}
+
+.site-footer {
+ margin-top: 3rem;
+ padding: 0 1.25rem 2rem;
+}
+
+.footer-grid {
+ width: min(100%, var(--content-width));
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: 1.5fr 1fr 1fr 1fr;
+ gap: 1.25rem;
+ padding: 1.8rem;
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--line);
+ background: rgba(255, 251, 246, 0.76);
+ box-shadow: var(--shadow-card);
+}
+
+.footer-grid h2,
+.footer-grid h3 {
+ font-family: var(--sans);
+ font-size: 1rem;
+ margin-bottom: 0.8rem;
+}
+
+.footer-grid a,
+.footer-grid span {
+ display: block;
+ margin-bottom: 0.35rem;
+ color: var(--text-soft);
+}
+
+.theme-admin {
+ background: linear-gradient(180deg, #f7f3ec 0%, #efe8dc 100%);
+}
+
+.admin-section,
+.admin-login-section {
+ padding-top: 0.5rem;
+}
+
+.admin-login-section {
+ display: grid;
+ place-items: center;
+ min-height: 72vh;
+}
+
+.admin-login-card {
+ width: min(100%, 32rem);
}
.stats-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
- margin-bottom: 22px;
+ margin: 1rem 0 1.25rem;
}
.stat-card {
- padding: 20px;
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.72);
- border: 1px solid var(--line);
- display: grid;
- gap: 8px;
+ border-radius: var(--radius-lg);
+ padding: 1.2rem;
}
.admin-grid {
display: grid;
- grid-template-columns: 1.3fr 1fr;
- gap: 20px;
+ grid-template-columns: 1.5fr 1fr;
+ gap: 1.2rem;
+}
+
+.table-card {
+ border-radius: var(--radius-xl);
}
.table-card-header {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 16px;
- margin-bottom: 16px;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.table-card-header a {
+ color: var(--accent-strong);
+ text-decoration: none;
}
.table-wrap {
overflow-x: auto;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line);
}
table {
width: 100%;
border-collapse: collapse;
+ min-width: 40rem;
+ background: rgba(255, 255, 255, 0.72);
+}
+
+thead {
+ background: rgba(239, 230, 217, 0.64);
}
th,
td {
- padding: 14px 12px;
- border-bottom: 1px solid rgba(61, 41, 26, 0.1);
+ padding: 0.95rem 1rem;
text-align: left;
+ border-bottom: 1px solid var(--line);
vertical-align: top;
}
-th {
- color: var(--muted);
- font-size: 0.82rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
+tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.35rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+
+.status-requested {
+ background: rgba(183, 106, 71, 0.14);
+ color: #8d4d31;
+}
+
+.status-reserved {
+ background: rgba(102, 87, 74, 0.14);
+ color: #5e5045;
+}
+
+.status-confirmed,
+.status-completed {
+ background: rgba(77, 105, 91, 0.14);
+ color: #345243;
+}
+
+.status-cancelled {
+ background: rgba(117, 49, 36, 0.12);
+ color: #7a4132;
+}
+
+.stack-item {
+ border: 1px solid var(--line);
+ background: rgba(255, 255, 255, 0.64);
+}
+
+.empty-state {
+ color: var(--text-soft);
+ margin: 0;
}
.detail-list {
display: grid;
- gap: 14px;
+ gap: 0.9rem;
margin: 0;
}
.detail-list div {
display: grid;
- gap: 4px;
+ gap: 0.2rem;
+ padding-bottom: 0.8rem;
+ border-bottom: 1px solid var(--line);
}
-.detail-list dt {
- color: var(--muted);
- font-size: 0.85rem;
+.detail-list div:last-child {
+ border-bottom: 0;
+ padding-bottom: 0;
}
.detail-list dd {
margin: 0;
+ color: var(--text);
}
-.theme-admin .site-header {
- background: rgba(250, 248, 244, 0.92);
+.narrow-section {
+ max-width: 72rem;
}
-@media (max-width: 1080px) {
- .hero-section,
- .content-grid,
- .booking-section,
- .pricing-panel,
- .admin-grid,
- .detail-grid {
+@media (max-width: 1120px) {
+ .hero,
+ .split-section,
+ .detail-grid,
+ .admin-grid {
grid-template-columns: 1fr;
}
- .trust-bar,
- .occasion-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
.stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
+
+ .footer-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
-@media (max-width: 780px) {
- .page-shell {
- width: min(100% - 24px, 1240px);
- padding-top: 16px;
- }
-
- .site-header {
- border-radius: 28px;
- padding: 18px;
- align-items: flex-start;
+@media (max-width: 900px) {
+ .header-inner {
flex-direction: column;
- position: static;
+ align-items: stretch;
}
- .feature-strip,
- .faq-list,
+ .site-nav,
+ .header-actions {
+ justify-content: center;
+ }
+
+ .faq-grid,
+ .legal-section,
+ .trust-grid,
.form-grid,
- .hero-highlight-grid,
- .trust-bar,
- .occasion-grid,
- .price-summary {
+ .form-grid-two,
+ .calendar-grid {
grid-template-columns: 1fr;
}
- .stats-grid {
- grid-template-columns: 1fr 1fr;
- }
-
- .hero-section,
- .feature-strip,
- .content-grid,
- .pricing-panel,
- .availability-section,
- .booking-section,
- .faq-section,
- .admin-section,
- .occasion-section {
- padding: 20px;
- }
-
- .hero-copy h1,
- .admin-login-card h1,
- .section-header h1 {
- font-size: 2.35rem;
- }
-
- .price-summary-total {
- border-left: 0;
- padding-left: 0;
- }
-}
-
-@media (max-width: 520px) {
- .stats-grid {
- grid-template-columns: 1fr;
- }
-
- .hero-actions,
- .section-header {
+ .cta-band {
flex-direction: column;
align-items: stretch;
}
}
+
+@media (max-width: 640px) {
+ .site-main,
+ .topbar-inner,
+ .header-inner,
+ .site-footer {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+
+ .hero-copy,
+ .hero-panel,
+ .page-hero,
+ .content-card,
+ .table-card,
+ .admin-login-card {
+ padding: 1.25rem;
+ }
+
+ .hero {
+ grid-template-columns: 1fr;
+ }
+
+ h1 {
+ font-size: 2.35rem;
+ }
+
+ h2 {
+ font-size: 1.65rem;
+ }
+
+ .hero-actions,
+ .section-actions {
+ flex-direction: column;
+ }
+
+ .button-primary,
+ .button-secondary,
+ .ghost-button {
+ width: 100%;
+ }
+
+ .stats-grid,
+ .footer-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .topbar-inner {
+ justify-content: center;
+ }
+
+ table {
+ min-width: 34rem;
+ }
+
+ .availability-card,
+ .stack-item,
+ .form-section-header,
+ .hero-panel-top,
+ .hero-panel-bottom {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/config.php b/config.php
index 59be274..b1181bd 100644
--- a/config.php
+++ b/config.php
@@ -5,15 +5,20 @@ declare(strict_types=1);
return [
'app' => [
'base_path' => getenv('FOTOBOX_BASE_PATH') ?: '',
+ 'locale' => 'de_DE',
],
'company' => [
'name' => 'Fotobox Moments',
- 'tagline' => 'Fotobox-Vermietung fuer Hochzeiten, Geburtstage und Firmenevents',
+ 'tagline' => 'Fotobox-Vermietung für Hochzeiten, Geburtstage und Firmenveranstaltungen',
'email' => 'hallo@fotobox-moments.local',
'phone' => '+49 170 1234567',
'website' => 'https://fotobox-moments.local',
+ 'service_area' => 'Musterstadt und Umgebung',
+ 'response_time' => 'Antwort meist innerhalb von 24 Stunden',
+ 'pickup_window' => 'Abholung ab 17:00 Uhr',
+ 'return_window' => 'Rückgabe bis 13:00 Uhr',
'address' => [
- 'street' => 'Musterstrasse 12',
+ 'street' => 'Musterstraße 12',
'postal_code' => '12345',
'city' => 'Musterstadt',
],
@@ -23,11 +28,21 @@ return [
'bic' => 'DEMOXXX',
'bank_name' => 'Musterbank',
],
- 'tax_notice' => 'Gemaess Paragraph 19 UStG wird keine Umsatzsteuer berechnet.',
+ 'legal' => [
+ 'owner' => 'Fotobox Moments',
+ 'representative' => 'Fotobox Moments',
+ 'vat_id' => '',
+ 'register_court' => '',
+ 'register_number' => '',
+ 'privacy_contact' => 'Datenschutzanfragen bitte an hallo@fotobox-moments.local richten.',
+ 'dispute_notice' => 'Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.',
+ ],
+ 'tax_notice' => 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
],
'pricing' => [
'default_day_rate_cents' => 9999,
'currency' => 'EUR',
+ 'label' => '99,99 € pro Miettag',
],
'admin' => [
'username' => 'admin',
diff --git a/docs/manual-test.md b/docs/manual-test.md
index 14caa20..b5d5470 100644
--- a/docs/manual-test.md
+++ b/docs/manual-test.md
@@ -1,10 +1,11 @@
# Manuelle Tests
-1. Startseite unter `/` oeffnen und pruefen, ob Hero, Buchungsformular, FAQ und Verfuegbarkeitsliste sichtbar sind.
-2. Im Buchungsformular Start- und Enddatum setzen und kontrollieren, ob Mietdauer und Gesamtpreis automatisch mit `99,99 EUR` pro Kalendertag berechnet werden.
-3. Eine Anfrage absenden und sicherstellen, dass sie in `storage/bookings.json` erscheint oder bei aktivierter MySQL-Verbindung in `fotobox_bookings`.
-4. `/admin` oeffnen, mit `admin` und dem konfigurierten Passwort anmelden und die neue Anfrage im Dashboard sehen.
-5. Im Admin-Bereich eine manuelle Bestellung fuer einen Kunden anlegen und pruefen, ob Konflikte bei bereits geblockten Zeitraeumen erkannt werden.
-6. Eine bestehende Buchung oeffnen, Status und Zahlungsstatus anpassen und speichern.
-7. Fuer eine Buchung eine Rechnung erzeugen und kontrollieren, ob eine Rechnungsnummer sowie ein PDF unter `/admin/invoice/pdf?id=...` abrufbar sind.
-8. Optional `mysql.local.php` aktivieren und nach erneutem Start pruefen, ob die Datenbanktabellen automatisch angelegt werden.
+1. Startseite unter `/` öffnen und prüfen, ob Hero, Leistungsblöcke, Verfügbarkeitsvorschau und der Link zur Buchungsanfrage sichtbar sind.
+2. Die Seite `/buchen` öffnen und kontrollieren, ob die Zusammenfassung `Montag bis Dienstag = 1 Miettag` korrekt berechnet.
+3. Im Buchungsformular einen Zeitraum wählen und prüfen, ob der Gesamtpreis mit `99,99 €` pro Miettag berechnet wird.
+4. Eine Anfrage absenden und sicherstellen, dass sie in `storage/bookings.json` erscheint oder bei aktiver MySQL-Verbindung in `fb_bookings`.
+5. `/admin/login` öffnen, mit `admin` und dem konfigurierten Passwort anmelden und das Dashboard prüfen.
+6. Im Admin-Bereich eine manuelle Buchung anlegen und kontrollieren, ob Terminüberschneidungen erkannt werden.
+7. Einen Auftrag öffnen, Status und Zahlungsstatus ändern und speichern.
+8. Für einen bestätigten Auftrag eine Rechnung erzeugen und prüfen, ob das PDF unter `/admin/invoice/pdf?id=...` geöffnet werden kann.
+9. Optional `mysql.local.php` aktivieren und nach erneutem Start prüfen, ob die Tabellen automatisch angelegt werden.
diff --git a/router.php b/router.php
new file mode 100644
index 0000000..9a71248
--- /dev/null
+++ b/router.php
@@ -0,0 +1,40 @@
+ 'text/css; charset=UTF-8',
+ 'js' => 'application/javascript; charset=UTF-8',
+ 'svg' => 'image/svg+xml',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'webp' => 'image/webp',
+ ];
+
+ header('Content-Type: ' . ($mimeTypes[$extension] ?? 'application/octet-stream'));
+ header('Content-Length: ' . (string) filesize($resolvedFile));
+ readfile($resolvedFile);
+ return true;
+}
+
+require __DIR__ . '/index.php';
diff --git a/src/Repository/JsonRepository.php b/src/Repository/JsonRepository.php
index 2612ebe..6745898 100644
--- a/src/Repository/JsonRepository.php
+++ b/src/Repository/JsonRepository.php
@@ -36,7 +36,7 @@ final class JsonRepository implements RecordRepositoryInterface
{
$handle = fopen($this->filePath, 'c+');
if ($handle === false) {
- throw new RuntimeException('Datenspeicher kann nicht geoeffnet werden.');
+ throw new RuntimeException('Datenspeicher kann nicht geöffnet werden.');
}
try {
@@ -90,7 +90,7 @@ final class JsonRepository implements RecordRepositoryInterface
$decoded = json_decode($content, true);
if (!is_array($decoded)) {
- throw new RuntimeException('Datenspeicher ist ungueltig.');
+ throw new RuntimeException('Datenspeicher ist ungültig.');
}
return $decoded;
diff --git a/src/Services/BookingService.php b/src/Services/BookingService.php
index 01e7760..837124f 100644
--- a/src/Services/BookingService.php
+++ b/src/Services/BookingService.php
@@ -55,11 +55,11 @@ final class BookingService
$paymentStatus = (string) ($input['payment_status'] ?? $booking['payment_status']);
if (!array_key_exists($status, $this->getStatusOptions())) {
- throw new RuntimeException('Der gewaehlte Status ist ungueltig.');
+ throw new RuntimeException('Der gewählte Status ist ungültig.');
}
if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
- throw new RuntimeException('Der gewaehlte Zahlungsstatus ist ungueltig.');
+ throw new RuntimeException('Der gewählte Zahlungsstatus ist ungültig.');
}
if ($this->isBlockingStatus($status)) {
@@ -88,7 +88,7 @@ final class BookingService
}
if (!in_array($booking['status'], ['reserved', 'confirmed', 'completed'], true)) {
- throw new RuntimeException('Fuer diesen Auftrag kann noch keine Rechnung erstellt werden.');
+ throw new RuntimeException('Für diesen Auftrag kann noch keine Rechnung erstellt werden.');
}
if (!empty($booking['invoice_id'])) {
@@ -104,7 +104,7 @@ final class BookingService
$lineItems = [
[
- 'label' => 'Fotobox-Miete ' . formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']),
+ 'label' => 'Fotobox-Miete ' . formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']) . ' (' . $booking['total_days'] . ' Miettag' . ($booking['total_days'] === 1 ? '' : 'e') . ')',
'quantity' => $booking['total_days'],
'unit_price_cents' => $booking['price_per_day_cents'],
'total_cents' => $booking['subtotal_cents'],
@@ -128,7 +128,7 @@ final class BookingService
'line_items' => $lineItems,
'subtotal_cents' => $booking['subtotal_cents'],
'total_cents' => $booking['subtotal_cents'],
- 'notes' => trim((string) ($input['invoice_notes'] ?? 'Vielen Dank fuer deinen Auftrag.')),
+ 'notes' => trim((string) ($input['invoice_notes'] ?? 'Vielen Dank für deinen Auftrag.')),
];
array_unshift($records, $invoice);
@@ -139,7 +139,7 @@ final class BookingService
$this->bookingRepository->transaction(function (array &$records) use ($bookingId, $invoice): void {
$index = $this->findBookingIndex($records, $bookingId);
if ($index === null) {
- throw new RuntimeException('Der Auftrag wurde beim Verknuepfen der Rechnung nicht gefunden.');
+ throw new RuntimeException('Der Auftrag wurde beim Verknüpfen der Rechnung nicht gefunden.');
}
$records[$index]['invoice_id'] = $invoice['id'];
@@ -169,6 +169,25 @@ final class BookingService
return $invoices;
}
+ public function getBookingsByStatuses(array $statuses): array
+ {
+ $bookings = array_values(array_filter(
+ $this->getBookings(),
+ static fn(array $booking): bool => in_array((string) $booking['status'], $statuses, true)
+ ));
+
+ usort($bookings, static function (array $a, array $b): int {
+ $dateComparison = strcmp($a['start_date'], $b['start_date']);
+ if ($dateComparison !== 0) {
+ return $dateComparison;
+ }
+
+ return strcmp($a['created_at'], $b['created_at']);
+ });
+
+ return $bookings;
+ }
+
public function getHighlightedBookings(): array
{
$bookings = array_values(array_filter(
@@ -217,6 +236,65 @@ final class BookingService
];
}
+ public function getCalendarGroups(): array
+ {
+ $groups = [];
+
+ foreach ($this->getBookingsByStatuses(['requested', 'reserved', 'confirmed', 'completed']) as $booking) {
+ $monthKey = substr((string) $booking['start_date'], 0, 7);
+ $label = $this->formatMonthLabel($monthKey);
+
+ $groups[$label][] = $booking;
+ }
+
+ return $groups;
+ }
+
+ public function getCustomers(): array
+ {
+ $customers = [];
+
+ foreach ($this->getBookings() as $booking) {
+ $customer = $booking['customer'];
+ $key = strtolower(trim((string) $customer['email'])) . '|' . strtolower(trim((string) $customer['phone']));
+
+ if (!isset($customers[$key])) {
+ $customers[$key] = [
+ 'name' => $customer['name'],
+ 'company' => $customer['company'],
+ 'email' => $customer['email'],
+ 'phone' => $customer['phone'],
+ 'city' => $customer['city'],
+ 'booking_count' => 0,
+ 'revenue_cents' => 0,
+ 'last_booking_date' => $booking['start_date'],
+ 'last_reference' => $booking['reference'],
+ 'last_status_label' => $booking['status_label'],
+ ];
+ }
+
+ $customers[$key]['booking_count']++;
+ $customers[$key]['revenue_cents'] += (int) $booking['subtotal_cents'];
+
+ if ($booking['start_date'] >= $customers[$key]['last_booking_date']) {
+ $customers[$key]['last_booking_date'] = $booking['start_date'];
+ $customers[$key]['last_reference'] = $booking['reference'];
+ $customers[$key]['last_status_label'] = $booking['status_label'];
+ }
+ }
+
+ usort($customers, static function (array $a, array $b): int {
+ $nameComparison = strcmp($a['name'], $b['name']);
+ if ($nameComparison !== 0) {
+ return $nameComparison;
+ }
+
+ return strcmp($a['email'], $b['email']);
+ });
+
+ return $customers;
+ }
+
public function getAdminDefaults(): array
{
return [
@@ -243,7 +321,7 @@ final class BookingService
return [
'requested' => 'Neue Anfrage',
'reserved' => 'Reserviert',
- 'confirmed' => 'Bestaetigt',
+ 'confirmed' => 'Bestätigt',
'completed' => 'Abgeschlossen',
'cancelled' => 'Storniert',
];
@@ -278,16 +356,18 @@ final class BookingService
$status = trim((string) ($input['status'] ?? ($adminMode ? 'confirmed' : 'requested')));
$paymentStatus = trim((string) ($input['payment_status'] ?? 'unpaid'));
$pricePerDay = (int) ($input['price_per_day_cents'] ?? $this->config['pricing']['default_day_rate_cents']);
+ $privacyAccepted = (string) ($input['privacy_accepted'] ?? '') === '1';
+ $termsAccepted = (string) ($input['terms_accepted'] ?? '') === '1';
foreach ([
'Name' => $customerName,
'E-Mail' => $email,
'Telefon' => $phone,
- 'Strasse' => $street,
+ 'Straße' => $street,
'PLZ' => $postalCode,
'Ort' => $city,
- 'Startdatum' => $startDate,
- 'Enddatum' => $endDate,
+ 'Abholdatum' => $startDate,
+ 'Rückgabedatum' => $endDate,
] as $label => $value) {
if ($value === '') {
throw new RuntimeException($label . ' ist ein Pflichtfeld.');
@@ -295,28 +375,36 @@ final class BookingService
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
- throw new RuntimeException('Bitte gib eine gueltige E-Mail-Adresse an.');
+ throw new RuntimeException('Bitte gib eine gültige E-Mail-Adresse an.');
+ }
+
+ if (!$adminMode && !$privacyAccepted) {
+ throw new RuntimeException('Bitte bestätige die Datenschutzerklärung.');
+ }
+
+ if (!$adminMode && !$termsAccepted) {
+ throw new RuntimeException('Bitte bestätige die Mietbedingungen.');
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
- throw new RuntimeException('Bitte waehle gueltige Mietdaten aus.');
+ throw new RuntimeException('Bitte wähle gültige Mietdaten aus.');
}
$totalDays = $this->calculateRentalDays($startDate, $endDate);
if ($totalDays < 1) {
- throw new RuntimeException('Das Mietende darf nicht vor dem Mietbeginn liegen.');
+ throw new RuntimeException('Die Rückgabe muss nach der Abholung liegen. Ein Miettag entspricht zum Beispiel Montag auf Dienstag.');
}
if (!array_key_exists($status, $this->getStatusOptions())) {
- throw new RuntimeException('Der Buchungsstatus ist ungueltig.');
+ throw new RuntimeException('Der Buchungsstatus ist ungültig.');
}
if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
- throw new RuntimeException('Der Zahlungsstatus ist ungueltig.');
+ throw new RuntimeException('Der Zahlungsstatus ist ungültig.');
}
$paymentLabels = [
- 'invoice_transfer' => 'Ueberweisung auf Rechnung',
+ 'invoice_transfer' => 'Rechnung / Überweisung',
'paypal' => 'PayPal',
];
@@ -327,15 +415,15 @@ final class BookingService
];
if (!array_key_exists($paymentMethod, $paymentLabels)) {
- throw new RuntimeException('Die gewaehlte Zahlungsart ist ungueltig.');
+ throw new RuntimeException('Die gewählte Zahlungsart ist ungültig.');
}
if (!array_key_exists($deliveryMode, $deliveryLabels)) {
- throw new RuntimeException('Die gewaehlte Lieferart ist ungueltig.');
+ throw new RuntimeException('Die gewählte Lieferart ist ungültig.');
}
if ($pricePerDay < 0) {
- throw new RuntimeException('Der Tagespreis ist ungueltig.');
+ throw new RuntimeException('Der Tagespreis ist ungültig.');
}
return [
@@ -363,6 +451,8 @@ final class BookingService
'payment_status_label' => $this->getPaymentStatusOptions()[$paymentStatus],
'price_per_day_cents' => $pricePerDay,
'subtotal_cents' => $totalDays * $pricePerDay,
+ 'privacy_accepted' => $privacyAccepted,
+ 'terms_accepted' => $termsAccepted,
];
}
@@ -392,6 +482,11 @@ final class BookingService
'invoice_id' => null,
'created_at' => $now,
'updated_at' => $now,
+ 'customer_consents' => [
+ 'privacy_accepted' => $payload['privacy_accepted'],
+ 'terms_accepted' => $payload['terms_accepted'],
+ 'recorded_at' => $now,
+ ],
'customer' => [
'name' => $payload['customer_name'],
'company' => $payload['company'],
@@ -417,9 +512,9 @@ final class BookingService
continue;
}
- if ($startDate <= $record['end_date'] && $endDate >= $record['start_date']) {
+ if ($startDate < $record['end_date'] && $endDate > $record['start_date']) {
throw new RuntimeException(
- 'Die Fotobox ist im gewaehlten Zeitraum bereits blockiert. Bitte waehle einen anderen Termin.'
+ 'Die Fotobox ist im gewählten Zeitraum bereits blockiert. Bitte wähle einen anderen Termin.'
);
}
}
@@ -430,7 +525,7 @@ final class BookingService
$start = new DateTimeImmutable($startDate);
$end = new DateTimeImmutable($endDate);
- return (int) $start->diff($end)->format('%r%a') + 1;
+ return (int) $start->diff($end)->format('%r%a');
}
private function nextInvoiceNumber(array $records): string
@@ -466,4 +561,30 @@ final class BookingService
{
return $prefix . '_' . strtolower(bin2hex(random_bytes(6)));
}
+
+ private function formatMonthLabel(string $monthKey): string
+ {
+ [$year, $month] = explode('-', $monthKey) + [null, null];
+ $monthNumber = (int) $month;
+ $monthLabels = [
+ 1 => 'Januar',
+ 2 => 'Februar',
+ 3 => 'März',
+ 4 => 'April',
+ 5 => 'Mai',
+ 6 => 'Juni',
+ 7 => 'Juli',
+ 8 => 'August',
+ 9 => 'September',
+ 10 => 'Oktober',
+ 11 => 'November',
+ 12 => 'Dezember',
+ ];
+
+ if (!isset($monthLabels[$monthNumber]) || $year === null) {
+ return $monthKey;
+ }
+
+ return $monthLabels[$monthNumber] . ' ' . $year;
+ }
}
diff --git a/src/Services/InvoicePdfService.php b/src/Services/InvoicePdfService.php
index 4ed3cd7..fbfb919 100644
--- a/src/Services/InvoicePdfService.php
+++ b/src/Services/InvoicePdfService.php
@@ -20,7 +20,7 @@ final class InvoicePdfService
'',
'Rechnung: ' . $invoice['invoice_number'],
'Rechnungsdatum: ' . formatDate($invoice['issue_date']),
- 'Faellig bis: ' . formatDate($invoice['due_date']),
+ 'Fällig bis: ' . formatDate($invoice['due_date']),
'',
'Rechnung an:',
$customer['name'],
@@ -107,21 +107,12 @@ final class InvoicePdfService
private function escapePdfText(string $value): string
{
+ $value = str_replace('€', 'EUR', $value);
+ $value = iconv('UTF-8', 'Windows-1252//TRANSLIT', $value) ?: $value;
$value = str_replace('\\', '\\\\', $value);
$value = str_replace('(', '\\(', $value);
$value = str_replace(')', '\\)', $value);
- $replacements = [
- 'ä' => 'ae',
- 'ö' => 'oe',
- 'ü' => 'ue',
- 'Ä' => 'Ae',
- 'Ö' => 'Oe',
- 'Ü' => 'Ue',
- 'ß' => 'ss',
- '€' => 'EUR',
- ];
-
- return strtr($value, $replacements);
+ return $value;
}
}
diff --git a/src/Support/functions.php b/src/Support/functions.php
index 40deca6..77e3635 100644
--- a/src/Support/functions.php
+++ b/src/Support/functions.php
@@ -19,6 +19,22 @@ function render(string $view, array $data = []): void
require dirname(__DIR__, 1) . '/../views/layout.php';
}
+function currentPath(): string
+{
+ $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+ $basePath = basePath();
+
+ if ($basePath !== '' && str_starts_with($path, $basePath)) {
+ $path = substr($path, strlen($basePath)) ?: '/';
+ }
+
+ if ($path === '') {
+ return '/';
+ }
+
+ return '/' . ltrim($path, '/');
+}
+
function basePath(): string
{
$configured = trim((string) (appConfig()['app']['base_path'] ?? ''));
@@ -39,9 +55,25 @@ function basePath(): string
'/admin/invoice/pdf',
'/admin/create',
'/admin/order',
+ '/admin/anfragen',
+ '/admin/buchungen',
+ '/admin/kalender',
+ '/admin/kunden',
+ '/admin/rechnungen',
+ '/admin/einstellungen',
'/admin/login',
'/admin/logout',
'/admin',
+ '/leistungen',
+ '/preise',
+ '/verfuegbarkeit',
+ '/buchen',
+ '/ablauf',
+ '/faq',
+ '/kontakt',
+ '/impressum',
+ '/datenschutz',
+ '/mietbedingungen',
'/book',
'/assets/styles.css',
'/assets/app.js',
@@ -91,6 +123,11 @@ function redirect(string $path): void
exit;
}
+function isCurrentPath(string $path): bool
+{
+ return currentPath() === $path;
+}
+
function flash(string $key, mixed $value = null): mixed
{
if (func_num_args() === 2) {
@@ -104,6 +141,30 @@ function flash(string $key, mixed $value = null): mixed
return $stored;
}
+function csrfToken(): string
+{
+ if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+
+ return $_SESSION['csrf_token'];
+}
+
+function csrfField(): string
+{
+ return '';
+}
+
+function verifyCsrfToken(): void
+{
+ $token = (string) ($_POST['_csrf'] ?? '');
+ $sessionToken = (string) ($_SESSION['csrf_token'] ?? '');
+
+ if ($token === '' || $sessionToken === '' || !hash_equals($sessionToken, $token)) {
+ throw new RuntimeException('Die Anfrage konnte aus Sicherheitsgründen nicht verarbeitet werden. Bitte lade die Seite neu.');
+ }
+}
+
function isAdminAuthenticated(): bool
{
return (bool) ($_SESSION['admin_authenticated'] ?? false);
@@ -141,3 +202,15 @@ function formatDate(string $date): string
return $dateTime->format('d.m.Y');
}
+
+function statusPillClass(string $status): string
+{
+ return match ($status) {
+ 'requested' => 'status-pill status-requested',
+ 'reserved' => 'status-pill status-reserved',
+ 'confirmed' => 'status-pill status-confirmed',
+ 'completed', 'paid' => 'status-pill status-completed',
+ 'cancelled', 'refunded' => 'status-pill status-cancelled',
+ default => 'status-pill',
+ };
+}
diff --git a/src/bootstrap.php b/src/bootstrap.php
index d63e540..5003382 100644
--- a/src/bootstrap.php
+++ b/src/bootstrap.php
@@ -9,20 +9,38 @@ require __DIR__ . '/Repository/MySqlJsonRepository.php';
require __DIR__ . '/Services/BookingService.php';
require __DIR__ . '/Services/InvoicePdfService.php';
-session_start();
+bootstrapSession();
+
+function bootstrapSession(): void
+{
+ if (session_status() === PHP_SESSION_ACTIVE) {
+ return;
+ }
+
+ ini_set('session.use_strict_mode', '1');
+ ini_set('session.cookie_httponly', '1');
+
+ session_set_cookie_params([
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ]);
+
+ session_start();
+}
function runApplication(): void
{
$config = require dirname(__DIR__) . '/config.php';
- setAppConfig($config);
+ [$bookingRepository, $invoiceRepository, $runtime] = resolveRepositories($config);
+ setAppConfig($config + ['runtime' => $runtime]);
- [$bookingRepository, $invoiceRepository] = resolveRepositories($config);
+ sendSecurityHeaders();
$bookingService = new BookingService($bookingRepository, $invoiceRepository, $config);
$invoicePdfService = new InvoicePdfService($config);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
- $path = requestPath();
+ $path = currentPath();
if (serveStaticAssetIfRequested($path)) {
return;
@@ -50,27 +68,26 @@ function runApplication(): void
}
if (str_starts_with($path, '/admin')) {
- handleAdminRequest($path, $method, $bookingService);
+ handleAdminRequest($path, $method, $bookingService, $runtime);
return;
}
- renderHome($bookingService, $config);
+ $route = publicRoutes()[$path] ?? null;
+ if ($route !== null) {
+ renderPublicPage($route, $bookingService, $config);
+ return;
+ }
+
+ renderNotFound($bookingService, $config);
}
-function requestPath(): string
+function sendSecurityHeaders(): void
{
- $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
- $basePath = basePath();
-
- if ($basePath !== '' && str_starts_with($path, $basePath)) {
- $path = substr($path, strlen($basePath)) ?: '/';
- }
-
- if ($path === '') {
- return '/';
- }
-
- return '/' . ltrim($path, '/');
+ header('X-Content-Type-Options: nosniff');
+ header('X-Frame-Options: SAMEORIGIN');
+ header('Referrer-Policy: strict-origin-when-cross-origin');
+ header('Permissions-Policy: camera=(), geolocation=(), microphone=()');
+ header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';");
}
function serveStaticAssetIfRequested(string $path): bool
@@ -106,12 +123,14 @@ function serveStaticAssetIfRequested(string $path): bool
function resolveRepositories(array $config): array
{
+ $defaultPrefix = (string) ($config['database']['table_prefix'] ?? '');
$databaseFile = $config['database']['credentials_file'];
+
if (file_exists($databaseFile)) {
$databaseConfig = require $databaseFile;
if (is_array($databaseConfig) && ($databaseConfig['enabled'] ?? false) === true) {
try {
- $tablePrefix = (string) ($databaseConfig['table_prefix'] ?? $config['database']['table_prefix'] ?? '');
+ $tablePrefix = (string) ($databaseConfig['table_prefix'] ?? $defaultPrefix);
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$databaseConfig['host'],
@@ -127,9 +146,29 @@ function resolveRepositories(array $config): array
return [
new MySqlJsonRepository($pdo, resolveTableName($tablePrefix, $config['database']['tables']['bookings'])),
new MySqlJsonRepository($pdo, resolveTableName($tablePrefix, $config['database']['tables']['invoices'])),
+ [
+ 'storage_driver' => 'MySQL',
+ 'table_prefix' => $tablePrefix,
+ 'database_host' => (string) $databaseConfig['host'],
+ 'database_name' => (string) $databaseConfig['database'],
+ 'database_enabled' => true,
+ ],
];
} catch (Throwable $exception) {
error_log('MySQL-Verbindung fehlgeschlagen, JSON-Fallback aktiv: ' . $exception->getMessage());
+
+ return [
+ new JsonRepository($config['storage']['bookings']),
+ new JsonRepository($config['storage']['invoices']),
+ [
+ 'storage_driver' => 'JSON-Fallback',
+ 'table_prefix' => (string) ($databaseConfig['table_prefix'] ?? $defaultPrefix),
+ 'database_host' => (string) ($databaseConfig['host'] ?? ''),
+ 'database_name' => (string) ($databaseConfig['database'] ?? ''),
+ 'database_enabled' => true,
+ 'fallback_reason' => 'MySQL-Verbindung fehlgeschlagen',
+ ],
+ ];
}
}
}
@@ -137,6 +176,13 @@ function resolveRepositories(array $config): array
return [
new JsonRepository($config['storage']['bookings']),
new JsonRepository($config['storage']['invoices']),
+ [
+ 'storage_driver' => 'JSON',
+ 'table_prefix' => $defaultPrefix,
+ 'database_host' => '',
+ 'database_name' => '',
+ 'database_enabled' => false,
+ ],
];
}
@@ -148,40 +194,66 @@ function resolveTableName(string $prefix, string $table): string
function handlePublicBooking(BookingService $bookingService): void
{
try {
+ verifyCsrfToken();
$bookingService->createPublicBooking($_POST);
- flash('success', 'Deine Anfrage wurde gespeichert. Wir melden uns zeitnah mit der Bestaetigung und allen Details.');
+ flash('success', 'Ihre Buchungsanfrage wurde gespeichert. Wir melden uns zeitnah mit der Bestätigung und allen weiteren Details.');
} catch (Throwable $exception) {
flash('error', $exception->getMessage());
flash('old', $_POST);
}
- redirect('/');
+ redirect('/buchen');
}
function handleAdminLogin(array $adminConfig): void
{
+ try {
+ verifyCsrfToken();
+ } catch (Throwable $exception) {
+ flash('error', $exception->getMessage());
+ redirect('/admin/login');
+ }
+
$username = trim((string) ($_POST['username'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if ($username === $adminConfig['username'] && hash_equals($adminConfig['password'], $password)) {
+ session_regenerate_id(true);
$_SESSION['admin_authenticated'] = true;
- flash('success', 'Admin-Bereich geoeffnet.');
- } else {
- flash('error', 'Die Admin-Zugangsdaten sind nicht korrekt.');
+ flash('success', 'Der Verwaltungsbereich wurde geöffnet.');
+ redirect('/admin');
}
- redirect('/admin');
+ flash('error', 'Die Zugangsdaten sind nicht korrekt.');
+ redirect('/admin/login');
}
function handleAdminLogout(): void
{
+ try {
+ verifyCsrfToken();
+ } catch (Throwable $exception) {
+ flash('error', $exception->getMessage());
+ redirect('/admin');
+ }
+
unset($_SESSION['admin_authenticated']);
- flash('success', 'Du wurdest aus dem Admin-Bereich abgemeldet.');
- redirect('/admin');
+ session_regenerate_id(true);
+ flash('success', 'Sie wurden aus dem Verwaltungsbereich abgemeldet.');
+ redirect('/admin/login');
}
-function handleAdminRequest(string $path, string $method, BookingService $bookingService): void
+function handleAdminRequest(string $path, string $method, BookingService $bookingService, array $runtime): void
{
+ if ($path === '/admin/login') {
+ if (isAdminAuthenticated()) {
+ redirect('/admin');
+ }
+
+ renderAdminLogin();
+ return;
+ }
+
if (!isAdminAuthenticated()) {
renderAdminLogin();
return;
@@ -189,18 +261,20 @@ function handleAdminRequest(string $path, string $method, BookingService $bookin
if ($method === 'POST' && $path === '/admin/create') {
try {
+ verifyCsrfToken();
$bookingService->createAdminBooking($_POST);
- flash('success', 'Die Bestellung wurde fuer den Kunden angelegt.');
+ flash('success', 'Die Bestellung wurde für den Kunden angelegt.');
+ redirect('/admin/buchungen');
} catch (Throwable $exception) {
flash('error', $exception->getMessage());
flash('admin_old', $_POST);
+ redirect('/admin/create');
}
-
- redirect('/admin/create');
}
if ($method === 'POST' && $path === '/admin/order/update') {
try {
+ verifyCsrfToken();
$bookingService->updateBooking((string) ($_POST['booking_id'] ?? ''), $_POST);
flash('success', 'Der Auftrag wurde aktualisiert.');
} catch (Throwable $exception) {
@@ -212,6 +286,7 @@ function handleAdminRequest(string $path, string $method, BookingService $bookin
if ($method === 'POST' && $path === '/admin/order/invoice') {
try {
+ verifyCsrfToken();
$invoiceId = $bookingService->createInvoiceForBooking((string) ($_POST['booking_id'] ?? ''), $_POST);
flash('success', 'Die Rechnung wurde erstellt.');
redirect('admin/order?id=' . urlencode((string) ($_POST['booking_id'] ?? '')) . '&invoice=' . urlencode($invoiceId));
@@ -221,17 +296,18 @@ function handleAdminRequest(string $path, string $method, BookingService $bookin
}
}
- if ($path === '/admin/create') {
- renderAdminCreate($bookingService);
- return;
- }
-
- if ($path === '/admin/order') {
- renderAdminOrder($bookingService);
- return;
- }
-
- renderAdminDashboard($bookingService);
+ match ($path) {
+ '/admin' => renderAdminDashboard($bookingService),
+ '/admin/create' => renderAdminCreate($bookingService),
+ '/admin/anfragen' => renderAdminRequests($bookingService),
+ '/admin/buchungen' => renderAdminBookings($bookingService),
+ '/admin/kalender' => renderAdminCalendar($bookingService),
+ '/admin/kunden' => renderAdminCustomers($bookingService),
+ '/admin/rechnungen' => renderAdminInvoices($bookingService),
+ '/admin/einstellungen' => renderAdminSettings($runtime),
+ '/admin/order' => renderAdminOrder($bookingService),
+ default => renderAdminDashboard($bookingService),
+ };
}
function handleInvoicePdf(BookingService $bookingService, InvoicePdfService $invoicePdfService): void
@@ -246,51 +322,227 @@ function handleInvoicePdf(BookingService $bookingService, InvoicePdfService $inv
}
$pdf = $invoicePdfService->render($invoice);
-
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $invoice['invoice_number'] . '.pdf"');
- header('Content-Length: ' . strlen($pdf));
-
+ header('Content-Length: ' . (string) strlen($pdf));
echo $pdf;
}
-function renderHome(BookingService $bookingService, array $config): void
+function publicRoutes(): array
{
- render('home', [
- 'pageTitle' => 'Fotobox mieten',
+ return [
+ '/' => [
+ 'view' => 'home',
+ 'pageTitle' => 'Fotobox mieten für Hochzeiten, Geburtstage und Firmenfeiern',
+ 'metaDescription' => 'Professionelle Fotobox-Vermietung mit klarer Preislogik, Buchungsanfrage, Lieferung oder Abholung und digitaler Bildübergabe.',
+ 'pageKey' => 'home',
+ ],
+ '/leistungen' => [
+ 'view' => 'pages/leistungen',
+ 'pageTitle' => 'Leistungen und Ausstattung',
+ 'metaDescription' => 'DSLR-Kamera, Studioblitz, Softbox, WLAN-Download und professioneller Lieferumfang für Ihre Fotobox-Miete.',
+ 'pageKey' => 'leistungen',
+ ],
+ '/preise' => [
+ 'view' => 'pages/preise',
+ 'pageTitle' => 'Preise und Mietlogik',
+ 'metaDescription' => '99,99 € pro Miettag. Ein Miettag entspricht einer Übernachtung. Klare Preise, transparente Leistungen und feste Zahlungsarten.',
+ 'pageKey' => 'preise',
+ ],
+ '/verfuegbarkeit' => [
+ 'view' => 'pages/verfuegbarkeit',
+ 'pageTitle' => 'Verfügbarkeit prüfen',
+ 'metaDescription' => 'Aktuelle Belegung, geblockte Zeiträume und der direkte Einstieg in Ihre Buchungsanfrage.',
+ 'pageKey' => 'verfuegbarkeit',
+ ],
+ '/buchen' => [
+ 'view' => 'pages/buchen',
+ 'pageTitle' => 'Buchungsanfrage stellen',
+ 'metaDescription' => 'Zeitraum wählen, Leistung festlegen und Ihre Fotobox-Anfrage in wenigen Minuten senden.',
+ 'pageKey' => 'buchen',
+ ],
+ '/ablauf' => [
+ 'view' => 'pages/ablauf',
+ 'pageTitle' => 'Ablauf Ihrer Miete',
+ 'metaDescription' => 'Von der Anfrage über Lieferung oder Abholung bis zur Rückgabe und Bildübergabe: so läuft die Fotobox-Miete ab.',
+ 'pageKey' => 'ablauf',
+ ],
+ '/faq' => [
+ 'view' => 'pages/faq',
+ 'pageTitle' => 'Häufige Fragen',
+ 'metaDescription' => 'Antworten zu Mietdauer, Verfügbarkeit, Zahlung, Rückgabe, Bildübergabe und technischer Unterstützung.',
+ 'pageKey' => 'faq',
+ ],
+ '/kontakt' => [
+ 'view' => 'pages/kontakt',
+ 'pageTitle' => 'Kontakt',
+ 'metaDescription' => 'Kontaktieren Sie Fotobox Moments für Fragen zu Verfügbarkeit, Lieferung, Rechnungen und individuellen Anforderungen.',
+ 'pageKey' => 'kontakt',
+ ],
+ '/impressum' => [
+ 'view' => 'pages/impressum',
+ 'pageTitle' => 'Impressum',
+ 'metaDescription' => 'Anbieterkennzeichnung und Kontaktangaben für die Fotobox-Vermietung.',
+ 'pageKey' => 'impressum',
+ ],
+ '/datenschutz' => [
+ 'view' => 'pages/datenschutz',
+ 'pageTitle' => 'Datenschutzerklärung',
+ 'metaDescription' => 'Informationen zur Verarbeitung personenbezogener Daten auf der Fotobox-Website und im Buchungsprozess.',
+ 'pageKey' => 'datenschutz',
+ ],
+ '/mietbedingungen' => [
+ 'view' => 'pages/mietbedingungen',
+ 'pageTitle' => 'Mietbedingungen',
+ 'metaDescription' => 'Rahmenbedingungen für Anfrage, Bestätigung, Zahlung, Rückgabe und Haftung bei der Fotobox-Miete.',
+ 'pageKey' => 'mietbedingungen',
+ ],
+ ];
+}
+
+function renderPublicPage(array $route, BookingService $bookingService, array $config): void
+{
+ $company = $config['company'];
+ $bookings = $bookingService->getHighlightedBookings();
+ $currentView = (string) $route['view'];
+
+ render($currentView, [
+ 'pageTitle' => $route['pageTitle'],
+ 'metaDescription' => $route['metaDescription'],
+ 'pageKey' => $route['pageKey'],
'config' => $config,
+ 'company' => $company,
+ 'dayRate' => $config['pricing']['default_day_rate_cents'],
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'old' => flash('old') ?? [],
- 'bookings' => $bookingService->getHighlightedBookings(),
+ 'bookings' => $bookings,
+ 'trustFacts' => publicTrustFacts($config),
+ 'featureCards' => publicFeatureCards(),
+ 'processSteps' => publicProcessSteps($config),
+ 'pricingExamples' => publicPricingExamples($config),
+ 'occasionCards' => publicOccasionCards(),
+ 'faqItems' => publicFaqItems($config),
+ 'serviceModules' => publicServiceModules(),
+ 'serviceStandards' => publicServiceStandards($config),
+ 'bookingChecklist' => publicBookingChecklist($config),
]);
}
+function renderNotFound(BookingService $bookingService, array $config): void
+{
+ http_response_code(404);
+ renderPublicPage([
+ 'view' => 'pages/not-found',
+ 'pageTitle' => 'Seite nicht gefunden',
+ 'metaDescription' => 'Die angeforderte Seite konnte nicht gefunden werden.',
+ 'pageKey' => 'not-found',
+ ], $bookingService, $config);
+}
+
function renderAdminLogin(): void
{
render('admin/login', [
- 'pageTitle' => 'Admin Login',
+ 'pageTitle' => 'Verwaltungszugang',
+ 'metaDescription' => 'Login für die interne Verwaltung der Fotobox-Anfragen und Buchungen.',
+ 'pageKey' => 'admin-login',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
+ 'defaultUsername' => appConfig()['admin']['username'] ?? 'admin',
]);
}
function renderAdminDashboard(BookingService $bookingService): void
{
render('admin/dashboard', [
- 'pageTitle' => 'Admin Dashboard',
+ 'pageTitle' => 'Dashboard',
+ 'pageKey' => 'admin-dashboard',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'stats' => $bookingService->getDashboardStats(),
+ 'bookings' => array_slice($bookingService->getBookings(), 0, 8),
+ 'requests' => array_slice($bookingService->getBookingsByStatuses(['requested', 'reserved']), 0, 6),
+ 'invoices' => array_slice($bookingService->getInvoices(), 0, 6),
+ 'upcomingBookings' => array_slice($bookingService->getBookingsByStatuses(['requested', 'reserved', 'confirmed']), 0, 6),
+ ]);
+}
+
+function renderAdminRequests(BookingService $bookingService): void
+{
+ render('admin/requests', [
+ 'pageTitle' => 'Anfragen',
+ 'pageKey' => 'admin-requests',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'requests' => $bookingService->getBookingsByStatuses(['requested', 'reserved']),
+ ]);
+}
+
+function renderAdminBookings(BookingService $bookingService): void
+{
+ render('admin/bookings', [
+ 'pageTitle' => 'Buchungen',
+ 'pageKey' => 'admin-bookings',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
'bookings' => $bookingService->getBookings(),
+ ]);
+}
+
+function renderAdminCalendar(BookingService $bookingService): void
+{
+ render('admin/calendar', [
+ 'pageTitle' => 'Kalender',
+ 'pageKey' => 'admin-calendar',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'calendarGroups' => $bookingService->getCalendarGroups(),
+ ]);
+}
+
+function renderAdminCustomers(BookingService $bookingService): void
+{
+ render('admin/customers', [
+ 'pageTitle' => 'Kunden',
+ 'pageKey' => 'admin-customers',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'customers' => $bookingService->getCustomers(),
+ ]);
+}
+
+function renderAdminInvoices(BookingService $bookingService): void
+{
+ render('admin/invoices', [
+ 'pageTitle' => 'Rechnungen',
+ 'pageKey' => 'admin-invoices',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
'invoices' => $bookingService->getInvoices(),
]);
}
+function renderAdminSettings(array $runtime): void
+{
+ $config = appConfig();
+
+ render('admin/settings', [
+ 'pageTitle' => 'Einstellungen',
+ 'pageKey' => 'admin-settings',
+ 'flashSuccess' => flash('success'),
+ 'flashError' => flash('error'),
+ 'runtime' => $runtime,
+ 'company' => $config['company'],
+ 'pricing' => $config['pricing'],
+ 'database' => $config['database'],
+ ]);
+}
+
function renderAdminCreate(BookingService $bookingService): void
{
render('admin/create', [
- 'pageTitle' => 'Kundenbestellung anlegen',
+ 'pageTitle' => 'Manuelle Buchung',
+ 'pageKey' => 'admin-create',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'old' => flash('admin_old') ?? [],
@@ -304,13 +556,13 @@ function renderAdminOrder(BookingService $bookingService): void
$booking = $bookingService->findBooking($bookingId);
if ($booking === null) {
- http_response_code(404);
- echo 'Auftrag nicht gefunden.';
- return;
+ flash('error', 'Der gewünschte Auftrag wurde nicht gefunden.');
+ redirect('/admin/buchungen');
}
render('admin/order', [
'pageTitle' => 'Auftrag ' . $booking['reference'],
+ 'pageKey' => 'admin-order',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'booking' => $booking,
@@ -319,3 +571,145 @@ function renderAdminOrder(BookingService $bookingService): void
'paymentOptions' => $bookingService->getPaymentStatusOptions(),
]);
}
+
+function publicTrustFacts(array $config): array
+{
+ $company = $config['company'];
+
+ return [
+ ['label' => 'Preis', 'value' => $config['pricing']['label']],
+ ['label' => 'Mietlogik', 'value' => '1 Miettag = 1 Übernachtung'],
+ ['label' => 'Zahlung', 'value' => 'Rechnung, Überweisung oder PayPal'],
+ ['label' => 'Servicefenster', 'value' => $company['pickup_window'] . ' / ' . $company['return_window']],
+ ];
+}
+
+function publicFeatureCards(): array
+{
+ return [
+ [
+ 'title' => 'Professionelle Bildqualität',
+ 'text' => 'DSLR-Kamera, Studioblitz und Softbox sorgen für klare, helle Fotos bei wechselnden Lichtverhältnissen.',
+ ],
+ [
+ 'title' => 'Direkter Download aufs Handy',
+ 'text' => 'Ihre Gäste können Bilder vor Ort per WLAN laden. Nach dem Event erhalten Sie zusätzlich die komplette Galerie digital.',
+ ],
+ [
+ 'title' => 'Lieferung oder Selbstabholung',
+ 'text' => 'Sie entscheiden zwischen Selbstabholung, Lieferung mit Aufbau oder einem Rundum-Service mit Vor-Ort-Betreuung.',
+ ],
+ [
+ 'title' => 'Saubere Verwaltung im Hintergrund',
+ 'text' => 'Anfragen, Kundendaten, Rechnungen und Zahlungsstatus werden in einem Verwaltungsbereich zentral gepflegt.',
+ ],
+ ];
+}
+
+function publicProcessSteps(array $config): array
+{
+ $company = $config['company'];
+
+ return [
+ [
+ 'title' => 'Zeitraum wählen',
+ 'text' => 'Sie wählen Abholtag und Rückgabetag. Montag bis Dienstag zählt als 1 Miettag.',
+ ],
+ [
+ 'title' => 'Leistung festlegen',
+ 'text' => 'Selbstabholung, Lieferung oder Betreuung vor Ort werden passend zu Ihrem Event gewählt.',
+ ],
+ [
+ 'title' => 'Anfrage absenden',
+ 'text' => 'Wir prüfen Verfügbarkeit, erfassen Ihre Daten und bestätigen den Auftrag persönlich.',
+ ],
+ [
+ 'title' => 'Feiern und Bilder erhalten',
+ 'text' => 'Die Fotobox steht rechtzeitig bereit. Die Rückgabe erfolgt bis ' . $company['return_window'] . '.',
+ ],
+ ];
+}
+
+function publicPricingExamples(array $config): array
+{
+ $label = $config['pricing']['label'];
+
+ return [
+ ['title' => 'Montag bis Dienstag', 'text' => '1 Miettag · ' . $label],
+ ['title' => 'Freitag bis Sonntag', 'text' => '2 Miettage · 199,98 €'],
+ ['title' => 'Buchungsanfrage', 'text' => 'Noch kein Sofortvertrag. Verbindlich erst nach Bestätigung.'],
+ ];
+}
+
+function publicOccasionCards(): array
+{
+ return [
+ ['title' => 'Hochzeiten', 'text' => 'Für Erinnerungen mit ruhiger Technik und hochwertigem Licht.'],
+ ['title' => 'Geburtstage', 'text' => 'Einfach zu bedienen und schnell einsatzbereit.'],
+ ['title' => 'Firmenfeiern', 'text' => 'Mit Rechnung, klarer Planung und sauberer Abwicklung.'],
+ ['title' => 'Jubiläen und Vereinsfeste', 'text' => 'Für Veranstaltungen mit vielen Gästen und wenig Zeitverlust.'],
+ ];
+}
+
+function publicFaqItems(array $config): array
+{
+ $company = $config['company'];
+
+ return [
+ [
+ 'question' => 'Wie wird ein Miettag berechnet?',
+ 'answer' => 'Ein Miettag entspricht immer einer Übernachtung. Montag bis Dienstag ist also 1 Miettag, Freitag bis Sonntag sind 2 Miettage.',
+ ],
+ [
+ 'question' => 'Ist die Online-Anfrage direkt verbindlich?',
+ 'answer' => 'Nein. Sie senden zunächst eine Buchungsanfrage. Verbindlich wird der Auftrag erst nach unserer Bestätigung.',
+ ],
+ [
+ 'question' => 'Welche Zahlungsarten sind möglich?',
+ 'answer' => 'Sie können per Rechnung / Überweisung oder per PayPal zahlen. Die gewünschte Zahlungsart wird bereits in der Anfrage abgefragt.',
+ ],
+ [
+ 'question' => 'Wie laufen Abholung und Rückgabe ab?',
+ 'answer' => 'Standardmäßig gilt ' . $company['pickup_window'] . '. Die Rückgabe erfolgt bis ' . $company['return_window'] . '. Lieferung und Aufbau sind ebenfalls möglich.',
+ ],
+ [
+ 'question' => 'Wann erhalten wir die Fotos?',
+ 'answer' => 'Die Bilder können vor Ort per WLAN geteilt werden. Zusätzlich erhalten Sie nach dem Event alle Fotos digital gesammelt.',
+ ],
+ [
+ 'question' => 'Gibt es eine Rechnung mit Kundendaten?',
+ 'answer' => 'Ja. Im Verwaltungsprozess können Rechnungen mit vollständigen Kundendaten erzeugt und als PDF bereitgestellt werden.',
+ ],
+ ];
+}
+
+function publicServiceModules(): array
+{
+ return [
+ ['title' => 'Technikpaket', 'items' => ['DSLR-Kamera', 'Studioblitz mit Softbox', 'Bedienbildschirm', 'WLAN-Fotofreigabe']],
+ ['title' => 'Eventbetrieb', 'items' => ['Schneller Aufbau', 'Intuitive Bedienung', 'Digitale Galerie', 'Saubere Rückgabeplanung']],
+ ['title' => 'Kaufmännische Abwicklung', 'items' => ['Anfrageerfassung', 'Rechnungsstellung', 'Zahlungsstatus', 'Admin-Verwaltung']],
+ ];
+}
+
+function publicServiceStandards(array $config): array
+{
+ $company = $config['company'];
+
+ return [
+ 'Klare Preisangabe mit ' . $config['pricing']['label'],
+ 'Direkt sichtbare Kontaktwege: ' . $company['phone'] . ' und ' . $company['email'],
+ 'Pflichtseiten für Impressum, Datenschutz und Mietbedingungen',
+ 'Barrierearme Formulare mit eindeutigen Beschriftungen und Fehlermeldungen',
+ ];
+}
+
+function publicBookingChecklist(array $config): array
+{
+ return [
+ 'Startdatum und Rückgabedatum bereithalten',
+ 'Lieferart auswählen: Selbstabholung, Lieferung oder Betreuung',
+ 'Rechnungsdaten und Veranstaltungsort eintragen',
+ 'Datenschutz und Mietbedingungen vor dem Absenden bestätigen',
+ ];
+}
diff --git a/views/admin/bookings.php b/views/admin/bookings.php
new file mode 100644
index 0000000..e7504da
--- /dev/null
+++ b/views/admin/bookings.php
@@ -0,0 +1,55 @@
+ Buchungen Kalender Aktuell sind keine Termine im Kalender hinterlegt.Alle Aufträge im Überblick
+ Auftragsliste
+ = h((string) count($bookings)) ?> Einträge
+
+
+
+
+
+
+
+
+ Referenz
+ Kunde
+ Zeitraum
+ Status
+ Zahlung
+ Preis
+
+
+
+
+ = h($booking['reference']) ?>
+ = h($booking['customer']['name']) ?>
+ = h(formatDate($booking['start_date'])) ?> bis = h(formatDate($booking['end_date'])) ?>
+ = h($booking['status_label']) ?>
+ = h($booking['payment_status_label']) ?>
+ = h(formatCurrency((int) $booking['subtotal_cents'])) ?>
+
+
+
+
+ Noch keine Buchungen vorhanden.
+ Zeiträume und Belegung nach Monaten
+ = h((string) $label) ?>
+ = h((string) count($entries)) ?> Termine
+
Manuelle Buchung
-