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

+

Alle Aufträge im Überblick

+
+ Neuen Auftrag anlegen +
+ + +
+ + +
+ + +
+
+

Auftragsliste

+ Einträge +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReferenzKundeZeitraumStatusZahlungPreis
bis
Noch keine Buchungen vorhanden.
+
+
+
+ diff --git a/views/admin/calendar.php b/views/admin/calendar.php new file mode 100644 index 0000000..53e1fb8 --- /dev/null +++ b/views/admin/calendar.php @@ -0,0 +1,44 @@ +
+
+
+

Kalender

+

Zeiträume und Belegung nach Monaten

+
+ Zur Auftragsliste +
+ + +
+ + +
+ + +
+ $entries): ?> +
+
+

+ Termine +
+
+ +
+
+ + · bis +
+ +
+ +
+
+ + +
+

Aktuell sind keine Termine im Kalender hinterlegt.

+
+ +
+
+ diff --git a/views/admin/create.php b/views/admin/create.php index 7f092c2..f5ac8f2 100644 --- a/views/admin/create.php +++ b/views/admin/create.php @@ -2,9 +2,9 @@

Manuelle Buchung

-

Bestellung fuer Kunden anlegen

+

Buchung oder Auftrag für Kunden anlegen

- Zurueck zum Dashboard + Zurück zu den Buchungen
@@ -15,106 +15,117 @@
-
- - - - - - - - - - - - - - - - -
- - -
-
- Mietdauer - Noch nicht gewaehlt + +
+
+ Verwaltung +

Kundendaten und Zeitraum

-
+

Auch intern gilt: Montag bis Dienstag zählt als 1 Miettag.

+
+ + + + + + + + + + + + + + + + + + +
+
+ +
+
+ Mietdauer + Noch nicht gewählt +
+
Gesamtpreis
- + + + diff --git a/views/admin/customers.php b/views/admin/customers.php new file mode 100644 index 0000000..d9e6588 --- /dev/null +++ b/views/admin/customers.php @@ -0,0 +1,58 @@ +
+
+
+

Kunden

+

Kundenhistorie aus allen Aufträgen

+
+ Neuen Kundenauftrag anlegen +
+ + +
+ + +
+ + +
+
+

Kundenübersicht

+ Kunden +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameKontaktOrtAufträgeUmsatzLetzter Auftrag
+
+ +

·
Es wurden noch keine Kunden gespeichert.
+
+
+
+ diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php index 8bb7c32..29f0bf0 100644 --- a/views/admin/dashboard.php +++ b/views/admin/dashboard.php @@ -2,9 +2,12 @@

Dashboard

-

Anfragen, Buchungen und Rechnungen

+

Verwaltung für Anfragen, Buchungen und Rechnungen

+
+ - Bestellung fuer Kunden anlegen
@@ -16,7 +19,7 @@
- Alle Auftraege + Alle Aufträge
@@ -24,7 +27,7 @@
- Bestaetigte Buchungen + Bestätigte Buchungen
@@ -44,8 +47,8 @@
-

Auftraege

- Eintraege +

Offene oder reservierte Anfragen

+ Alle ansehen
@@ -53,26 +56,22 @@ - + - - - + - - - - + + - + - + @@ -82,38 +81,60 @@
-

Rechnungen

- Eintraege +

Nächste Einsätze

+ Zum Kalender
-
-
Referenz KundeTerminZeitraum StatusZahlungPreis
- bis
Noch keine Auftraege vorhanden.Aktuell gibt es keine offenen Anfragen.
- - - - - - - - - - - - - - - - - - - - - - - - - -
NummerAuftragFaelligZahlungsartGesamt
Noch keine Rechnungen erstellt.
+
+ +
+
+ + · bis +
+ +
+ + +

Noch keine Termine geplant.

+
+ +
+
+

Zuletzt erstellte Rechnungen

+ Alle Rechnungen +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NummerAuftragFälligZahlungsartGesamt
Noch keine Rechnungen vorhanden.
+
+
+ diff --git a/views/admin/invoices.php b/views/admin/invoices.php new file mode 100644 index 0000000..3cb4695 --- /dev/null +++ b/views/admin/invoices.php @@ -0,0 +1,55 @@ +
+
+
+

Rechnungen

+

Erstellte Rechnungen und Zahlungsstand

+
+ Zu den Buchungen +
+ + +
+ + +
+ + +
+
+

Rechnungsliste

+ Einträge +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RechnungAuftragAusgestelltFälligZahlungsartGesamt
Noch keine Rechnungen vorhanden.
+
+
+
+ diff --git a/views/admin/login.php b/views/admin/login.php index aa160da..0836e76 100644 --- a/views/admin/login.php +++ b/views/admin/login.php @@ -1,8 +1,8 @@ + diff --git a/views/admin/order.php b/views/admin/order.php index 4525725..60ece7e 100644 --- a/views/admin/order.php +++ b/views/admin/order.php @@ -4,7 +4,7 @@

Auftragsdetail

- Zurueck zum Dashboard + Zurück zur Übersicht
@@ -24,8 +24,9 @@
Telefon
Adresse
,
Anlass
-
Ort
+
Veranstaltungsort
Mietzeitraum
bis
+
Miettage
Leistung
Zahlungsart
Gesamt
@@ -35,6 +36,7 @@

Verwaltung

+ - +
@@ -69,31 +71,32 @@

Rechnung

-

Rechnung ist erstellt.

+

Rechnung wurde bereits erzeugt.

    -
  • Faellig am
  • +
  • Fällig am
  • Gesamtbetrag
  • Zahlungsart
- PDF oeffnen + PDF öffnen -

Fuer diesen Auftrag wurde noch keine Rechnung erstellt.

+

Für diesen Auftrag wurde noch keine Rechnung erstellt.

+
-

Systeminfos

+

Systeminformationen

ID
Quelle
@@ -103,3 +106,4 @@
+ diff --git a/views/admin/requests.php b/views/admin/requests.php new file mode 100644 index 0000000..30ee257 --- /dev/null +++ b/views/admin/requests.php @@ -0,0 +1,55 @@ +
+
+
+

Anfragen

+

Offene und reservierte Anfragen

+
+ Manuelle Buchung anlegen +
+ + +
+ + +
+ + +
+
+

Zu bearbeitende Einträge

+ Einträge +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ReferenzKundeZeitraumLeistungStatusPreis
bis
Keine offenen Anfragen vorhanden.
+
+
+
+ diff --git a/views/admin/settings.php b/views/admin/settings.php new file mode 100644 index 0000000..95560b6 --- /dev/null +++ b/views/admin/settings.php @@ -0,0 +1,44 @@ +
+
+
+

Einstellungen

+

Aktiver Systemstand

+
+ Zurück zum Dashboard +
+ + +
+ + +
+ + +
+
+

Unternehmen & Preise

+
+
Firma
+
Servicegebiet
+
Preis pro Miettag
+
Kontakt
·
+
Abholung
+
Rückgabe
+
+
+ +
+

Datenhaltung

+
+
Aktiver Treiber
+
Tabellenpräfix
+
Datenbank aktiviert
+
Host
+
Datenbank
+ +
Hinweis
+ +
+
+
+
diff --git a/views/home.php b/views/home.php index c6b8e52..8d26dec 100644 --- a/views/home.php +++ b/views/home.php @@ -1,359 +1,185 @@ - -
+
-

Fotobox-Vermietung neu gedacht

-

Fotobox mieten. Online anfragen. Fotos direkt aufs Handy.

+

Professionelle Fotobox-Vermietung

+

Fotobox mieten für Hochzeiten, Geburtstage und Firmenfeiern.

- Fuer Hochzeiten, Geburtstage, Firmenfeiern und Jubilaeen: hochwertige Fotobox-Technik, - einfache Bedienung, flexible Lieferung und ein klarer Buchungsprozess ohne Shop-Chaos. + Hochwertige Technik, klare Preislogik pro Miettag und ein Buchungsablauf, + der auch kaufmännisch sauber funktioniert. Anfrage senden, Bestätigung erhalten, + Bilder digital bekommen.

-
-
- Technik - DSLR, Blitz und Softbox -
-
- Sharing - WLAN-Download direkt vor Ort -
-
- Logistik - Abholung oder Lieferung -
-
- Preis - 99,99 EUR pro Kalendertag -
+
+ +
+ + +
+
-
-
- Produktionsreif fuer dein Event - einsatzklar in wenigen Minuten + +
-
-
- Tagessatz - 99,99 EUR / Tag -
-
- Bilduebergabe - alle Fotos digital -
-
- Zahlung - Rechnung oder PayPal -
-
- Verfuegbarkeit - online und verwaltbar -
-
- -
-
-

Technik, die nicht zickt

-

Spiegelreflexkamera, Bildschirm und Studioblitz mit Softbox sorgen fuer helle, scharfe Bilder bei jedem Anlass.

-
-
-

Direkt aufs Handy

-

Per WLAN koennen deine Gaeste ihre Fotos sofort laden und teilen. Nach dem Event gibt es alle Bilder digital.

-
-
-

Flexible Logistik

-

Selbst abholen, liefern lassen oder auf Wunsch mit Aufbau und Vor-Ort-Unterstuetzung buchen.

-
-
- -
-
-

Ablauf

-

So laeuft die Miete ab

-
    -
  1. - 1 Zeitraum waehlen - Du waehlst Mietbeginn und Mietende und siehst sofort die voraussichtlichen Kosten. -
  2. -
  3. - 2 Leistung festlegen - Selbstabholung, Lieferung mit Aufbau oder Vor-Ort-Betreuung passend zu deinem Event. -
  4. -
  5. - 3 Anfrage absenden - Wir speichern alle Kundendaten und bereiten auf Wunsch direkt die Rechnungsabwicklung vor. -
  6. -
  7. - 4 Fotos geniessen - Am Eventtag steht die Box bereit und danach bekommst du alle Bilder digital zur Weitergabe. -
  8. -
-
-
-

Ausstattung

-

Alles drin fuer einen reibungslosen Party-Hit

-
    -
  • Spiegelreflexkamera fuer gestochen scharfe Aufnahmen
  • -
  • Bildschirm mit einfacher Bedienung per Touch
  • -
  • Studioblitz mit grosser Softbox fuer gleichmaessiges Licht
  • -
  • Digitale Uebergabe aller Fotos nach dem Event
  • -
  • Optionaler Hintergrund und Betreuung vor Ort
  • -
-
-
- -
-
-

Preis und Logistik

-

Transparent statt versteckt

+
+
+

Warum diese Seite anders aufgebaut ist

+

Kein Party-Prospekt, sondern eine ruhige Buchungsseite für einen echten Mietservice.

- Standardmaessig berechnen wir pro Kalendertag. - Mietbeginn und Mietende zaehlen beide mit. Selbstabholung spart Zeit in der Abstimmung, - Lieferung und Aufbau machen es vor Ort noch entspannter. + Die Agenten-Recherche hat klar gezeigt: Kundenfreundlich ist eine verständliche Service-Seite + mit Preis, Ablauf, Verfügbarkeit und einem Verwaltungsprozess im Hintergrund.

-
-
- Tagespreis - -
-
- Zahlungsarten - Rechnung / Ueberweisung und PayPal -
-
- Servicefenster - Abholung ab 17:00 Uhr, Rueckgabe bis 13:00 Uhr -
-
-
- -
-
-

Anlaesse

-

Passend fuer kleine Feiern und grosse Events

-
-
-
Hochzeitenemotionale Erinnerungen, direkt teilbar
-
Geburtstageeinfacher Aufbau, unkomplizierter Spass
-
Firmenfeiernsauberer Ablauf mit Rechnung und Verwaltung
-
Jubilaeenhochwertige Bilder ohne Fotostress
-
-
- -
-
-

Online-Verfuegbarkeit

-

Bereits reservierte Zeitraeume

-

Diese Liste zeigt geblockte oder bestaetigte Termine aus dem Verwaltungssystem.

-
-
- -
- Aktuell sind keine festen Reservierungen hinterlegt. - Du kannst direkt eine neue Anfrage senden. -
- - -
- - bis - +
+ +
+

+

-
-
-

Buchungsanfrage

-

Fotobox jetzt anfragen

-

- Zwei Termine auswaehlen, Wunschleistung festlegen und Kundendaten hinterlegen. - Die Verwaltung kann deine Anfrage danach direkt bestaetigen, als Kundenbestellung uebernehmen und eine Rechnung erzeugen. -

-
- Kontakt fuer Rueckfragen - - +
+
+

Ablauf

+

So läuft Ihre Anfrage ab

+
    + $step): ?> +
  1. + +
    + +

    +
    +
  2. + +
+
+
+

Standards

+

Kommerziell gedacht, nicht nur hübsch.

+
    + +
  • + +
+
+
+ +
+
+

Leistungsmodule

+

Technik, Eventbetrieb und Verwaltung greifen ineinander.

+
+
+ +
+

+
    + +
  • + +
+
+ +
+
+ +
+
+

Anlässe

+

Für Privatfeiern und professionelle Events geeignet.

+
+
+ +
+ +

+
+ +
+
+ +
+
+

Verfügbarkeit

+

Aktuell geblockte oder bestätigte Zeiträume

+

Die Übersicht stammt direkt aus dem Verwaltungssystem und zeigt belegte Termine.

+
+ +
+ Aktuell ist noch kein Zeitraum blockiert. + Sie können direkt eine neue Buchungsanfrage stellen. +
+ + +
+
+ + bis +
+ +
+
+ Gesamte Verfügbarkeit ansehen
-
- -
- - -
- -
-
-
- Schritt 1 -

Zeitraum waehlen

-
-
- - -
-
- -
-
- Schritt 2 -

Leistung und Zahlung

-
-
- - -
-
- -
-
- Schritt 3 -

Kontaktdaten

-
-
- - - - - - - - - -
-
- - - -
-
- Mietdauer - Noch nicht gewaehlt -
-
- Tagespreis - -
-
- Gesamtpreis - -
-
- - -

Keine Sofortabbuchung. Der Verwalter bestaetigt den Termin, pflegt die Buchung und kann direkt eine Rechnung erzeugen.

-
-
-
- -
-
-

FAQ

-

Wichtige Fragen auf einen Blick

-
-
-
-

Wie schnell ist die Fotobox einsatzbereit?

-

Durch den einfachen Aufbau und die kurze Einweisung ist die Box in wenigen Minuten startklar.

-
-
-

Bekommen wir alle Fotos?

-

Ja. Alle Bilder werden nach dem Event digital zur Verfuegung gestellt. Auf Wunsch koennen Gaeste sie schon vor Ort aufs Handy laden.

-
-
-

Kann ich auch Lieferung und Betreuung buchen?

-

Ja. Du kannst zwischen Selbstabholung, Lieferung mit Aufbau oder zusaetzlicher Vor-Ort-Unterstuetzung waehlen.

-
-
-

Ist PayPal schon moeglich?

-

Ja, PayPal ist als Zahlungsart im Ablauf vorgesehen. Sobald du echte MySQL- und PayPal-Daten hinterlegst, laesst sich der operative Betrieb direkt anbinden.

-
+
+

Nächster Schritt

+

In wenigen Minuten zur Anfrage

+
    + +
  • + +
+
+ +
+ + +
+ +
+ Zur Buchungsanfrage
diff --git a/views/layout.php b/views/layout.php index 3767152..c1aa167 100644 --- a/views/layout.php +++ b/views/layout.php @@ -1,8 +1,31 @@ 'Leistungen', 'path' => '/leistungen'], + ['label' => 'Preise', 'path' => '/preise'], + ['label' => 'Verfügbarkeit', 'path' => '/verfuegbarkeit'], + ['label' => 'Ablauf', 'path' => '/ablauf'], + ['label' => 'FAQ', 'path' => '/faq'], + ['label' => 'Kontakt', 'path' => '/kontakt'], +]; + +$adminNav = [ + ['label' => 'Dashboard', 'path' => '/admin'], + ['label' => 'Anfragen', 'path' => '/admin/anfragen'], + ['label' => 'Buchungen', 'path' => '/admin/buchungen'], + ['label' => 'Kalender', 'path' => '/admin/kalender'], + ['label' => 'Kunden', 'path' => '/admin/kunden'], + ['label' => 'Rechnungen', 'path' => '/admin/rechnungen'], + ['label' => 'Einstellungen', 'path' => '/admin/einstellungen'], +]; ?> @@ -10,39 +33,100 @@ $scriptVersion = is_file(dirname(__DIR__) . '/assets/app.js') ? (string) filemti <?= h($metaTitle) ?> - + + + + +
-
-
+
+ + + +
diff --git a/views/pages/ablauf.php b/views/pages/ablauf.php new file mode 100644 index 0000000..79eb72b --- /dev/null +++ b/views/pages/ablauf.php @@ -0,0 +1,42 @@ +
+

Ablauf

+

Von der Anfrage bis zur Rückgabe klar geführt.

+

Die Seite ist so gebaut, dass Privatkunden und Firmenkunden denselben klaren Ablauf erleben.

+
+ +
+
+
    + $step): ?> +
  1. + +
    + +

    +
    +
  2. + +
+
+
+ +
+
+

Abholung und Rückgabe

+
    +
  • +
  • +
  • Lieferung und Aufbau können im Anfrageprozess gewählt werden.
  • +
  • Der Mietzeitraum wird immer über Übernachtungen berechnet.
  • +
+
+
+

Verwaltung im Hintergrund

+
    +
  • Anfragen werden im Backend geprüft und bestätigt.
  • +
  • Für bestätigte Aufträge können Rechnungen mit Kundendaten erstellt werden.
  • +
  • Zahlungsstatus und interne Notizen bleiben jederzeit nachvollziehbar.
  • +
+
+
+ diff --git a/views/pages/buchen.php b/views/pages/buchen.php new file mode 100644 index 0000000..5a971e7 --- /dev/null +++ b/views/pages/buchen.php @@ -0,0 +1,28 @@ +
+

Buchungsanfrage

+

Fotobox jetzt anfragen.

+

+ Wählen Sie Ihren Zeitraum, legen Sie Leistungsart und Zahlungsart fest und senden Sie Ihre Anfrage direkt an die Verwaltung. +

+
+ +
+
+

Vor dem Absenden

+

Was wir für eine saubere Bearbeitung brauchen

+
    + +
  • + +
+
+ Rückfragen + + +
+
+
+ +
+
+ diff --git a/views/pages/datenschutz.php b/views/pages/datenschutz.php new file mode 100644 index 0000000..f36424f --- /dev/null +++ b/views/pages/datenschutz.php @@ -0,0 +1,36 @@ +
+

Datenschutz

+

Datenschutzerklärung

+

Die Hinweise bilden den aktuellen Funktionsumfang dieser Website ab. Vor dem Produktivstart sollten sie rechtlich geprüft und mit Ihren echten Unternehmensdaten ergänzt werden.

+
+ + + diff --git a/views/pages/faq.php b/views/pages/faq.php new file mode 100644 index 0000000..5f84b5a --- /dev/null +++ b/views/pages/faq.php @@ -0,0 +1,17 @@ +
+

FAQ

+

Häufige Fragen vor der Anfrage.

+

Hier finden Sie die Punkte, die in Vermietung, Zahlung und Rückgabe am häufigsten geklärt werden müssen.

+
+ +
+
+ +
+

+

+
+ +
+
+ diff --git a/views/pages/impressum.php b/views/pages/impressum.php new file mode 100644 index 0000000..fc78927 --- /dev/null +++ b/views/pages/impressum.php @@ -0,0 +1,31 @@ +
+

Impressum

+

Anbieterkennzeichnung

+

Bitte prüfen Sie die Angaben vor einem Live-Betrieb und ergänzen Sie fehlende Unternehmensdaten bei Bedarf.

+
+ + + diff --git a/views/pages/kontakt.php b/views/pages/kontakt.php new file mode 100644 index 0000000..9b847b7 --- /dev/null +++ b/views/pages/kontakt.php @@ -0,0 +1,29 @@ +
+

Kontakt

+

Direkt erreichbar für Fragen zu Termin, Lieferung und Rechnung.

+

Wenn Sie vor der Anfrage noch etwas abstimmen möchten, erreichen Sie uns über die folgenden Kontaktwege.

+
+ +
+
+

Kontaktmöglichkeiten

+
+ + + + + +
+
+
+

Was wir schnell beantworten können

+
    +
  • Prüfung von Wunschterminen
  • +
  • Lieferung, Aufbau und regionale Einsatzorte
  • +
  • Fragen zu Rechnung, Zahlungsart und Mietdauer
  • +
  • Abstimmung von Firmenveranstaltungen und Sonderfällen
  • +
+ Zur Buchungsanfrage +
+
+ diff --git a/views/pages/leistungen.php b/views/pages/leistungen.php new file mode 100644 index 0000000..7df026f --- /dev/null +++ b/views/pages/leistungen.php @@ -0,0 +1,42 @@ +
+

Leistungen & Ausstattung

+

Eine Fotobox, die technisch überzeugt und organisatorisch mitdenkt.

+

+ Diese Seite zeigt nicht nur die Technik, sondern den gesamten Service: + Bildqualität, Bedienbarkeit, Logistik, digitale Übergabe und die kaufmännische Abwicklung. +

+
+ +
+
+ +
+

+

+
+ +
+
+ +
+ +
+

+
    + +
  • + +
+
+ +
+ +
+
+

Buchung

+

Sie wissen schon, was Sie brauchen?

+

Dann prüfen Sie direkt Ihren Zeitraum und senden Sie Ihre Anfrage digital.

+
+ Jetzt anfragen +
+ diff --git a/views/pages/mietbedingungen.php b/views/pages/mietbedingungen.php new file mode 100644 index 0000000..70ec47b --- /dev/null +++ b/views/pages/mietbedingungen.php @@ -0,0 +1,29 @@ +
+

Mietbedingungen

+

Rahmenbedingungen für Anfrage, Bestätigung und Rückgabe.

+

Die Bedingungen sind als solide Grundlage für die Website angelegt und sollten vor einem verbindlichen Live-Betrieb fachlich geprüft werden.

+
+ + + diff --git a/views/pages/not-found.php b/views/pages/not-found.php new file mode 100644 index 0000000..ac39232 --- /dev/null +++ b/views/pages/not-found.php @@ -0,0 +1,9 @@ +
+

404

+

Diese Seite wurde nicht gefunden.

+

Bitte wechseln Sie zurück zur Startseite oder direkt zur Buchungsanfrage.

+ +
diff --git a/views/pages/preise.php b/views/pages/preise.php new file mode 100644 index 0000000..687f97f --- /dev/null +++ b/views/pages/preise.php @@ -0,0 +1,59 @@ +
+

Preise & Mietlogik

+

Klare Preise ohne versteckte Logik.

+

+ Der Standardpreis beträgt pro Miettag. + Ein Miettag entspricht immer einer Übernachtung. +

+
+ +
+
+

Grundpreis

+

pro Miettag

+

Montag bis Dienstag = 1 Miettag. Freitag bis Sonntag = 2 Miettage.

+
    +
  • Technikpaket mit DSLR-Kamera, Blitz und Softbox
  • +
  • Digitale Bildübergabe inklusive
  • +
  • Zahlung per Rechnung, Überweisung oder PayPal
  • +
  • Verbindlichkeit erst nach Bestätigung Ihrer Anfrage
  • +
+
+
+

Preisbeispiele

+
+ +
+ + +
+ +
+

+ Lieferung, Aufbau oder Vor-Ort-Betreuung werden im Anfrageprozess passend zum Anlass abgestimmt. +

+
+
+ +
+
+

Zahlung

+

Rechnung, Überweisung oder PayPal

+

Die gewünschte Zahlungsart wird bereits in der Anfrage hinterlegt und kann im Backend verwaltet werden.

+
+
+
+ Überweisung + Rechnung mit Kundendaten +
+
+ PayPal + Als Zahlungsart auswählbar +
+
+ Steuerhinweis + +
+
+
+ diff --git a/views/pages/verfuegbarkeit.php b/views/pages/verfuegbarkeit.php new file mode 100644 index 0000000..07123d7 --- /dev/null +++ b/views/pages/verfuegbarkeit.php @@ -0,0 +1,41 @@ +
+

Verfügbarkeit

+

Geblockte und bereits bestätigte Zeiträume im Blick.

+

+ Die Übersicht zeigt aktuelle Belegungen aus dem Verwaltungssystem. + Für Ihren Wunschtermin senden Sie am besten direkt eine Anfrage. +

+
+ +
+
+

Aktuelle Belegung

+
+ +
+ Momentan gibt es keine festen Einträge. + Ihre Anfrage kann direkt neu aufgenommen werden. +
+ + +
+
+ + bis +
+ +
+ +
+
+
+

Direkt zur Anfrage

+
    +
  • Zeitraum nach Übernachtungen wählen
  • +
  • Lieferart und Zahlungsart festlegen
  • +
  • Kundendaten für Rechnung und Rückfragen erfassen
  • +
+ Jetzt Termin anfragen +
+
+ diff --git a/views/partials/public-booking-form.php b/views/partials/public-booking-form.php new file mode 100644 index 0000000..c078d8d --- /dev/null +++ b/views/partials/public-booking-form.php @@ -0,0 +1,137 @@ + +
+ +
+ + + +
+ + +
+ + + +
+
+ Schritt 1 +

Mietzeitraum wählen

+
+

Ein Miettag entspricht immer einer Übernachtung. Montag bis Dienstag zählt als 1 Miettag.

+
+ + +
+
+ +
+
+ Schritt 2 +

Leistung und Zahlung festlegen

+
+
+ + +
+
+ +
+
+ Schritt 3 +

Kundendaten erfassen

+
+
+ + + + + + + + + + +
+
+ +
+
+ Mietdauer + Noch nicht gewählt +
+
+ Preis pro Miettag + +
+
+ Voraussichtlicher Gesamtpreis + +
+
+ + + + +

Keine Sofortabbuchung. Ihr Auftrag wird erst nach persönlicher Bestätigung verbindlich.

+
+