Initale Einrichtung

This commit is contained in:
2026-05-04 18:46:12 +02:00
parent 8f944776a9
commit a675873437
24 changed files with 2776 additions and 1 deletions
+2
View File
@@ -25,3 +25,5 @@
/app/Config/database.php
/vendors/*
/mysql.local.php
/storage/runtime/
+68 -1
View File
@@ -1,2 +1,69 @@
# fotobox-webspite
# Fotobox-Webseite
Diese Anwendung stellt eine komplette Vermietungsseite fuer eine Fotobox bereit. Sie enthaelt:
- 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
## Starten
Die Anwendung benoetigt nur PHP 8.3 oder neuer.
```bash
php -S 127.0.0.1:8000
```
Danach ist die Seite unter `http://127.0.0.1:8000` erreichbar.
## Admin-Zugang
- Benutzername: `admin`
- Passwort: Standardmaessig `fotobox-admin`
Falls du das Passwort aendern willst, setze die Umgebungsvariable `FOTOBOX_ADMIN_PASSWORD`.
## Datenhaltung
Standardmaessig nutzt die App JSON-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.
## MySQL vorbereiten
Im Repository liegt als Vorlage:
- `mysql.local.php.example`
- `docs/mysql-schema.sql`
Lokal liegt ausserhalb des Git-Trackings:
- `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`.
## Wichtige Annahmen
- 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.
## Verwaltung
Im Admin-Bereich kannst du:
- neue Kundenbestellungen manuell anlegen
- Anfragen bestaetigen oder stornieren
- Zahlungsstatus pflegen
- Rechnungen mit Kundendaten erzeugen
- Rechnungen als PDF oeffnen
## Tests
Eine kurze Checkliste liegt in [docs/manual-test.md](docs/manual-test.md).
+50
View File
@@ -0,0 +1,50 @@
const forms = document.querySelectorAll('.booking-form');
const formatCurrency = (cents) =>
new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(cents / 100);
const calculateDays = (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;
const milliseconds = endDate.getTime() - startDate.getTime();
const days = Math.floor(milliseconds / 86400000) + 1;
return days > 0 ? days : null;
};
forms.forEach((form) => {
const startInput = form.querySelector('[data-booking-start]');
const endInput = form.querySelector('[data-booking-end]');
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 render = () => {
const days = calculateDays(startInput?.value, endInput?.value);
const rate = Number(rateInput?.value || defaultRate);
if (!days || rate < 0) {
if (daysOutput) daysOutput.textContent = 'Noch nicht gewaehlt';
if (totalOutput) totalOutput.textContent = formatCurrency(defaultRate);
return;
}
if (daysOutput) {
daysOutput.textContent = `${days} ${days === 1 ? 'Tag' : 'Tage'}`;
}
if (totalOutput) {
totalOutput.textContent = formatCurrency(days * rate);
}
};
startInput?.addEventListener('input', render);
endInput?.addEventListener('input', render);
rateInput?.addEventListener('input', render);
render();
});
+698
View File
@@ -0,0 +1,698 @@
:root {
--bg: #f4efe7;
--bg-panel: rgba(255, 255, 255, 0.72);
--card: #fdf9f2;
--surface: #ffffff;
--line: rgba(55, 35, 18, 0.12);
--text: #1f160f;
--muted: #6d5a4d;
--accent: #bd5f2d;
--accent-deep: #7a3412;
--accent-soft: #f1d1ba;
--success: #1f6c46;
--error: #8a2630;
--shadow: 0 24px 60px rgba(57, 35, 18, 0.14);
--radius-lg: 28px;
--radius-md: 18px;
--radius-sm: 12px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(203, 135, 77, 0.25), transparent 28%),
radial-gradient(circle at 85% 15%, rgba(122, 52, 18, 0.12), transparent 22%),
linear-gradient(180deg, #faf4eb 0%, var(--bg) 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}
.page-shell {
width: min(1240px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 72px;
}
.site-header {
display: flex;
align-items: center;
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.82);
border: 1px solid rgba(255, 255, 255, 0.7);
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: Georgia, "Times New Roman", serif;
}
.brand strong,
h1,
h2,
h3 {
font-family: Georgia, "Times New Roman", serif;
letter-spacing: -0.02em;
}
.brand small {
display: block;
color: var(--muted);
font-size: 0.8rem;
font-family: "Avenir Next", "Segoe UI", sans-serif;
}
.brand-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-deep) 100%);
color: white;
font-weight: 700;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.site-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px;
color: var(--muted);
}
.site-nav form {
margin: 0;
}
.primary-link,
.button-primary,
.button-secondary,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 999px;
padding: 14px 22px;
border: 0;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 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);
}
.button-secondary {
background: rgba(255, 255, 255, 0.72);
color: var(--text);
border: 1px solid var(--line);
}
.ghost-button {
background: transparent;
color: var(--muted);
}
.button-block {
width: 100%;
}
.primary-link:hover,
.button-primary:hover,
.button-secondary:hover,
.ghost-button:hover {
transform: translateY(-1px);
}
main {
display: grid;
gap: 28px;
}
.hero-section,
.feature-strip,
.content-grid,
.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-section {
display: grid;
grid-template-columns: 1.2fr 0.9fr;
gap: 28px;
padding: 42px;
}
.eyebrow {
margin: 0 0 12px;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.78rem;
color: var(--accent-deep);
font-weight: 700;
}
.hero-copy h1,
.admin-login-card h1,
.section-header h1 {
margin: 0;
font-size: clamp(2.6rem, 5vw, 4.8rem);
line-height: 0.96;
}
.hero-text,
.pricing-panel p,
.booking-copy p,
.admin-login-card p {
color: var(--muted);
font-size: 1.05rem;
line-height: 1.65;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin: 28px 0 26px;
}
.hero-points,
.check-list,
.compact-list {
margin: 0;
padding-left: 20px;
color: var(--muted);
display: grid;
gap: 10px;
}
.hero-card {
display: grid;
gap: 16px;
padding: 18px;
background:
linear-gradient(145deg, rgba(255, 244, 232, 0.92), rgba(246, 225, 209, 0.85)),
var(--card);
border-radius: 24px;
min-height: 520px;
}
.hero-card-panel {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.55);
color: var(--muted);
}
.hero-card-panel strong {
color: var(--text);
}
.hero-card-visual {
position: relative;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 24px;
min-height: 320px;
background: linear-gradient(180deg, rgba(118, 59, 31, 0.15), rgba(33, 18, 11, 0.24));
}
.camera-glow {
position: absolute;
width: 240px;
height: 240px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 228, 195, 0.9) 0%, rgba(241, 179, 119, 0.15) 58%, transparent 72%);
}
.camera-body {
position: relative;
width: 280px;
height: 200px;
border-radius: 30px;
background: linear-gradient(180deg, #2a2320 0%, #13100f 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 42px rgba(0, 0, 0, 0.35);
}
.camera-body::before {
content: "";
position: absolute;
inset: -22px 40px auto auto;
width: 90px;
height: 70px;
border-radius: 24px 24px 8px 8px;
background: linear-gradient(180deg, #28211e 0%, #141111 100%);
}
.camera-lens {
position: absolute;
inset: 34px auto auto 78px;
width: 124px;
height: 124px;
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-screen {
position: absolute;
inset: 48px 28px auto auto;
width: 56px;
height: 92px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 188, 125, 0.85), rgba(255, 240, 224, 0.22));
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.12);
}
.feature-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
padding: 24px;
}
.feature-strip article,
.content-block,
.pricing-aside,
.booking-form-card,
.table-card,
.admin-login-card,
.booking-info-card,
.availability-card {
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.feature-strip article {
padding: 24px;
}
.feature-strip h2,
.content-block h2,
.pricing-panel h2,
.booking-copy h2,
.faq-section h2,
.table-card 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,
.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 {
color: var(--muted);
line-height: 1.6;
}
.content-grid,
.booking-section,
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
padding: 24px;
}
.content-block,
.table-card,
.admin-login-card {
padding: 28px;
}
.step-list {
display: grid;
gap: 18px;
margin: 24px 0 0;
padding: 0;
list-style: none;
}
.step-list li {
display: grid;
gap: 6px;
padding: 16px 18px;
border-radius: var(--radius-sm);
background: rgba(246, 232, 221, 0.72);
}
.pricing-panel,
.availability-section,
.faq-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;
}
.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-empty {
grid-column: 1 / -1;
}
.booking-section {
align-items: start;
}
.booking-info-card {
display: grid;
gap: 8px;
padding: 20px;
}
.booking-form-card {
padding: 28px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.booking-form,
.stack-form {
display: grid;
gap: 18px;
}
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);
color: var(--text);
padding: 14px 16px;
}
textarea {
resize: vertical;
}
.price-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 18px;
border-radius: 18px;
background: rgba(245, 223, 202, 0.55);
}
.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-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.stats-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
margin-bottom: 22px;
}
.stat-card {
padding: 20px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--line);
display: grid;
gap: 8px;
}
.admin-grid {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 20px;
}
.table-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 14px 12px;
border-bottom: 1px solid rgba(61, 41, 26, 0.1);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.detail-list {
display: grid;
gap: 14px;
margin: 0;
}
.detail-list div {
display: grid;
gap: 4px;
}
.detail-list dt {
color: var(--muted);
font-size: 0.85rem;
}
.detail-list dd {
margin: 0;
}
@media (max-width: 1080px) {
.hero-section,
.content-grid,
.booking-section,
.pricing-panel,
.admin-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(3, 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;
flex-direction: column;
position: static;
}
.feature-strip,
.faq-list,
.form-grid,
.price-summary {
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 {
padding: 20px;
}
.hero-copy h1,
.admin-login-card h1,
.section-header h1 {
font-size: 2.35rem;
}
}
@media (max-width: 520px) {
.stats-grid {
grid-template-columns: 1fr;
}
.hero-actions,
.section-header {
flex-direction: column;
align-items: stretch;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
return [
'company' => [
'name' => 'Fotobox Moments',
'tagline' => 'Fotobox-Vermietung fuer Hochzeiten, Geburtstage und Firmenevents',
'email' => 'hallo@fotobox-moments.local',
'phone' => '+49 170 1234567',
'website' => 'https://fotobox-moments.local',
'address' => [
'street' => 'Musterstrasse 12',
'postal_code' => '12345',
'city' => 'Musterstadt',
],
'bank' => [
'account_holder' => 'Fotobox Moments',
'iban' => 'DE00 0000 0000 0000 0000 00',
'bic' => 'DEMOXXX',
'bank_name' => 'Musterbank',
],
'tax_notice' => 'Gemaess Paragraph 19 UStG wird keine Umsatzsteuer berechnet.',
],
'pricing' => [
'default_day_rate_cents' => 9999,
'currency' => 'EUR',
],
'admin' => [
'username' => 'admin',
'password' => getenv('FOTOBOX_ADMIN_PASSWORD') ?: 'fotobox-admin',
],
'database' => [
'credentials_file' => __DIR__ . '/mysql.local.php',
'table_prefix' => 'fb_',
'tables' => [
'bookings' => 'bookings',
'invoices' => 'invoices',
],
],
'storage' => [
'bookings' => __DIR__ . '/storage/bookings.json',
'invoices' => __DIR__ . '/storage/invoices.json',
],
];
+10
View File
@@ -0,0 +1,10 @@
# 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.
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `fb_bookings` (
`id` VARCHAR(191) NOT NULL PRIMARY KEY,
`payload` LONGTEXT NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `fb_invoices` (
`id` VARCHAR(191) NOT NULL PRIMARY KEY,
`payload` LONGTEXT NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require __DIR__ . '/src/bootstrap.php';
runApplication();
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'enabled' => false,
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'fotobox',
'username' => 'fotobox_user',
'password' => 'change-me',
'table_prefix' => 'fb_',
];
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
final class JsonRepository implements RecordRepositoryInterface
{
public function __construct(private readonly string $filePath)
{
$directory = dirname($this->filePath);
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
if (!file_exists($this->filePath)) {
file_put_contents($this->filePath, json_encode([], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
public function all(): array
{
return $this->read();
}
public function find(string $id, string $key = 'id'): ?array
{
foreach ($this->read() as $record) {
if (($record[$key] ?? null) === $id) {
return $record;
}
}
return null;
}
public function transaction(callable $callback): mixed
{
$handle = fopen($this->filePath, 'c+');
if ($handle === false) {
throw new RuntimeException('Datenspeicher kann nicht geoeffnet werden.');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('Datenspeicher kann nicht gesperrt werden.');
}
$records = $this->decodeStream($handle);
$result = $callback($records);
rewind($handle);
ftruncate($handle, 0);
fwrite($handle, json_encode($records, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
fflush($handle);
flock($handle, LOCK_UN);
return $result;
} finally {
fclose($handle);
}
}
private function read(): array
{
$handle = fopen($this->filePath, 'c+');
if ($handle === false) {
throw new RuntimeException('Datenspeicher kann nicht gelesen werden.');
}
try {
if (!flock($handle, LOCK_SH)) {
throw new RuntimeException('Datenspeicher kann nicht gesperrt werden.');
}
$records = $this->decodeStream($handle);
flock($handle, LOCK_UN);
return $records;
} finally {
fclose($handle);
}
}
private function decodeStream($handle): array
{
rewind($handle);
$content = stream_get_contents($handle);
if ($content === false || trim($content) === '') {
return [];
}
$decoded = json_decode($content, true);
if (!is_array($decoded)) {
throw new RuntimeException('Datenspeicher ist ungueltig.');
}
return $decoded;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
final class MySqlJsonRepository implements RecordRepositoryInterface
{
public function __construct(
private readonly PDO $pdo,
private readonly string $tableName,
) {
$this->ensureTable();
}
public function all(): array
{
$statement = $this->pdo->query(
sprintf('SELECT payload FROM `%s` ORDER BY created_at DESC, id DESC', $this->tableName)
);
if ($statement === false) {
throw new RuntimeException('Die MySQL-Daten konnten nicht gelesen werden.');
}
$records = [];
foreach ($statement->fetchAll(PDO::FETCH_COLUMN) as $payload) {
$decoded = json_decode((string) $payload, true);
if (is_array($decoded)) {
$records[] = $decoded;
}
}
return $records;
}
public function find(string $id, string $key = 'id'): ?array
{
foreach ($this->all() as $record) {
if (($record[$key] ?? null) === $id) {
return $record;
}
}
return null;
}
public function transaction(callable $callback): mixed
{
$this->pdo->beginTransaction();
try {
$statement = $this->pdo->query(
sprintf('SELECT id, payload FROM `%s` FOR UPDATE', $this->tableName)
);
if ($statement === false) {
throw new RuntimeException('Die MySQL-Daten konnten nicht gesperrt werden.');
}
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
$records = [];
$existingIds = [];
foreach ($rows as $row) {
$decoded = json_decode((string) $row['payload'], true);
if (is_array($decoded)) {
$records[] = $decoded;
$existingIds[] = (string) $row['id'];
}
}
$result = $callback($records);
$this->syncRecords($records, $existingIds);
$this->pdo->commit();
return $result;
} catch (Throwable $exception) {
$this->pdo->rollBack();
throw $exception;
}
}
private function ensureTable(): void
{
$sql = sprintf(
'CREATE TABLE IF NOT EXISTS `%s` (
id VARCHAR(191) NOT NULL PRIMARY KEY,
payload LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
$this->tableName
);
$this->pdo->exec($sql);
}
private function syncRecords(array $records, array $existingIds): void
{
$currentIds = [];
foreach ($records as $record) {
$id = (string) ($record['id'] ?? '');
if ($id === '') {
throw new RuntimeException('Ein Datensatz ohne ID kann nicht gespeichert werden.');
}
$currentIds[] = $id;
$statement = $this->pdo->prepare(
sprintf(
'INSERT INTO `%s` (id, payload) VALUES (:id, :payload)
ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at = CURRENT_TIMESTAMP',
$this->tableName
)
);
$statement->execute([
'id' => $id,
'payload' => json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
]);
}
$deleteIds = array_diff($existingIds, $currentIds);
if ($deleteIds === []) {
return;
}
$placeholders = implode(', ', array_fill(0, count($deleteIds), '?'));
$delete = $this->pdo->prepare(
sprintf('DELETE FROM `%s` WHERE id IN (%s)', $this->tableName, $placeholders)
);
$delete->execute(array_values($deleteIds));
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
interface RecordRepositoryInterface
{
public function all(): array;
public function find(string $id, string $key = 'id'): ?array;
public function transaction(callable $callback): mixed;
}
+469
View File
@@ -0,0 +1,469 @@
<?php
declare(strict_types=1);
final class BookingService
{
private const BLOCKING_STATUSES = ['requested', 'reserved', 'confirmed'];
public function __construct(
private readonly RecordRepositoryInterface $bookingRepository,
private readonly RecordRepositoryInterface $invoiceRepository,
private readonly array $config,
) {
}
public function createPublicBooking(array $input): array
{
$payload = $this->normalizeBookingInput($input, false);
return $this->bookingRepository->transaction(function (array &$records) use ($payload): array {
$this->assertAvailability($records, $payload['start_date'], $payload['end_date']);
$booking = $this->buildBookingRecord($payload, 'customer_form');
array_unshift($records, $booking);
return $booking;
});
}
public function createAdminBooking(array $input): array
{
$payload = $this->normalizeBookingInput($input, true);
return $this->bookingRepository->transaction(function (array &$records) use ($payload): array {
if ($this->isBlockingStatus($payload['status'])) {
$this->assertAvailability($records, $payload['start_date'], $payload['end_date']);
}
$booking = $this->buildBookingRecord($payload, 'admin_manual');
array_unshift($records, $booking);
return $booking;
});
}
public function updateBooking(string $bookingId, array $input): array
{
return $this->bookingRepository->transaction(function (array &$records) use ($bookingId, $input): array {
$index = $this->findBookingIndex($records, $bookingId);
if ($index === null) {
throw new RuntimeException('Der Auftrag wurde nicht gefunden.');
}
$booking = $records[$index];
$status = (string) ($input['status'] ?? $booking['status']);
$paymentStatus = (string) ($input['payment_status'] ?? $booking['payment_status']);
if (!array_key_exists($status, $this->getStatusOptions())) {
throw new RuntimeException('Der gewaehlte Status ist ungueltig.');
}
if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
throw new RuntimeException('Der gewaehlte Zahlungsstatus ist ungueltig.');
}
if ($this->isBlockingStatus($status)) {
$this->assertAvailability($records, $booking['start_date'], $booking['end_date'], $booking['id']);
}
$booking['status'] = $status;
$booking['status_label'] = $this->getStatusOptions()[$status];
$booking['payment_status'] = $paymentStatus;
$booking['payment_status_label'] = $this->getPaymentStatusOptions()[$paymentStatus];
$booking['internal_notes'] = trim((string) ($input['internal_notes'] ?? $booking['internal_notes']));
$booking['notes_customer'] = trim((string) ($input['notes_customer'] ?? $booking['notes_customer']));
$booking['updated_at'] = gmdate('c');
$records[$index] = $booking;
return $booking;
});
}
public function createInvoiceForBooking(string $bookingId, array $input): string
{
$booking = $this->findBooking($bookingId);
if ($booking === null) {
throw new RuntimeException('Der Auftrag wurde nicht gefunden.');
}
if (!in_array($booking['status'], ['reserved', 'confirmed', 'completed'], true)) {
throw new RuntimeException('Fuer diesen Auftrag kann noch keine Rechnung erstellt werden.');
}
if (!empty($booking['invoice_id'])) {
return $booking['invoice_id'];
}
$invoice = $this->invoiceRepository->transaction(function (array &$records) use ($booking, $input): array {
$issueDate = gmdate('Y-m-d');
$dueDate = (new DateTimeImmutable($issueDate))->modify('+14 days')->format('Y-m-d');
$invoiceNumber = $this->nextInvoiceNumber($records);
$invoiceId = $this->generateId('inv');
$company = $this->config['company'];
$lineItems = [
[
'label' => 'Fotobox-Miete ' . formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']),
'quantity' => $booking['total_days'],
'unit_price_cents' => $booking['price_per_day_cents'],
'total_cents' => $booking['subtotal_cents'],
],
];
$invoice = [
'id' => $invoiceId,
'invoice_number' => $invoiceNumber,
'booking_id' => $booking['id'],
'status' => 'issued',
'status_label' => 'Ausgestellt',
'issue_date' => $issueDate,
'due_date' => (string) ($input['due_date'] ?? $dueDate),
'created_at' => gmdate('c'),
'currency' => $this->config['pricing']['currency'],
'company' => $company,
'customer_snapshot' => $booking['customer'],
'payment_method' => $booking['payment_method'],
'payment_method_label' => $booking['payment_method_label'],
'line_items' => $lineItems,
'subtotal_cents' => $booking['subtotal_cents'],
'total_cents' => $booking['subtotal_cents'],
'notes' => trim((string) ($input['invoice_notes'] ?? 'Vielen Dank fuer deinen Auftrag.')),
];
array_unshift($records, $invoice);
return $invoice;
});
$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.');
}
$records[$index]['invoice_id'] = $invoice['id'];
if ($records[$index]['status'] === 'requested') {
$records[$index]['status'] = 'confirmed';
$records[$index]['status_label'] = $this->getStatusOptions()['confirmed'];
}
$records[$index]['updated_at'] = gmdate('c');
});
return $invoice['id'];
}
public function getBookings(): array
{
$bookings = $this->bookingRepository->all();
usort($bookings, static fn(array $a, array $b): int => strcmp($b['created_at'], $a['created_at']));
return $bookings;
}
public function getInvoices(): array
{
$invoices = $this->invoiceRepository->all();
usort($invoices, static fn(array $a, array $b): int => strcmp($b['created_at'], $a['created_at']));
return $invoices;
}
public function getHighlightedBookings(): array
{
$bookings = array_values(array_filter(
$this->getBookings(),
fn(array $booking): bool => $this->isBlockingStatus($booking['status'])
));
usort($bookings, static fn(array $a, array $b): int => strcmp($a['start_date'], $b['start_date']));
return array_slice($bookings, 0, 4);
}
public function getDashboardStats(): array
{
$bookings = $this->getBookings();
$invoices = $this->getInvoices();
$activeRevenue = 0;
$openRequests = 0;
$confirmed = 0;
foreach ($bookings as $booking) {
if (in_array($booking['status'], ['requested', 'reserved'], true)) {
$openRequests++;
}
if (in_array($booking['status'], ['confirmed', 'completed'], true)) {
$confirmed++;
$activeRevenue += $booking['subtotal_cents'];
}
}
$unpaidInvoices = 0;
foreach ($invoices as $invoice) {
if ($invoice['status'] !== 'paid') {
$unpaidInvoices++;
}
}
return [
'bookings_total' => count($bookings),
'open_requests' => $openRequests,
'confirmed_bookings' => $confirmed,
'revenue_cents' => $activeRevenue,
'invoice_total' => count($invoices),
'invoice_open' => $unpaidInvoices,
];
}
public function getAdminDefaults(): array
{
return [
'price_per_day_cents' => $this->config['pricing']['default_day_rate_cents'],
'status' => 'confirmed',
'payment_status' => 'unpaid',
'payment_method' => 'invoice_transfer',
'delivery_mode' => 'self_pickup',
];
}
public function findBooking(string $bookingId): ?array
{
return $this->bookingRepository->find($bookingId);
}
public function findInvoice(string $invoiceId): ?array
{
return $this->invoiceRepository->find($invoiceId);
}
public function getStatusOptions(): array
{
return [
'requested' => 'Neue Anfrage',
'reserved' => 'Reserviert',
'confirmed' => 'Bestaetigt',
'completed' => 'Abgeschlossen',
'cancelled' => 'Storniert',
];
}
public function getPaymentStatusOptions(): array
{
return [
'unpaid' => 'Offen',
'paid' => 'Bezahlt',
'refunded' => 'Erstattet',
];
}
private function normalizeBookingInput(array $input, bool $adminMode): array
{
$customerName = trim((string) ($input['customer_name'] ?? ''));
$email = trim((string) ($input['email'] ?? ''));
$phone = trim((string) ($input['phone'] ?? ''));
$street = trim((string) ($input['street'] ?? ''));
$postalCode = trim((string) ($input['postal_code'] ?? ''));
$city = trim((string) ($input['city'] ?? ''));
$eventType = trim((string) ($input['event_type'] ?? ''));
$eventLocation = trim((string) ($input['event_location'] ?? ''));
$company = trim((string) ($input['company'] ?? ''));
$startDate = trim((string) ($input['start_date'] ?? ''));
$endDate = trim((string) ($input['end_date'] ?? ''));
$paymentMethod = trim((string) ($input['payment_method'] ?? 'invoice_transfer'));
$deliveryMode = trim((string) ($input['delivery_mode'] ?? 'self_pickup'));
$notesCustomer = trim((string) ($input['notes_customer'] ?? ''));
$internalNotes = trim((string) ($input['internal_notes'] ?? ''));
$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']);
foreach ([
'Name' => $customerName,
'E-Mail' => $email,
'Telefon' => $phone,
'Strasse' => $street,
'PLZ' => $postalCode,
'Ort' => $city,
'Startdatum' => $startDate,
'Enddatum' => $endDate,
] as $label => $value) {
if ($value === '') {
throw new RuntimeException($label . ' ist ein Pflichtfeld.');
}
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Bitte gib eine gueltige E-Mail-Adresse an.');
}
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.');
}
$totalDays = $this->calculateRentalDays($startDate, $endDate);
if ($totalDays < 1) {
throw new RuntimeException('Das Mietende darf nicht vor dem Mietbeginn liegen.');
}
if (!array_key_exists($status, $this->getStatusOptions())) {
throw new RuntimeException('Der Buchungsstatus ist ungueltig.');
}
if (!array_key_exists($paymentStatus, $this->getPaymentStatusOptions())) {
throw new RuntimeException('Der Zahlungsstatus ist ungueltig.');
}
$paymentLabels = [
'invoice_transfer' => 'Ueberweisung auf Rechnung',
'paypal' => 'PayPal',
];
$deliveryLabels = [
'self_pickup' => 'Selbstabholung',
'delivery_setup' => 'Lieferung und Aufbau',
'on_site_support' => 'Lieferung, Aufbau und Vor-Ort-Betreuung',
];
if (!array_key_exists($paymentMethod, $paymentLabels)) {
throw new RuntimeException('Die gewaehlte Zahlungsart ist ungueltig.');
}
if (!array_key_exists($deliveryMode, $deliveryLabels)) {
throw new RuntimeException('Die gewaehlte Lieferart ist ungueltig.');
}
if ($pricePerDay < 0) {
throw new RuntimeException('Der Tagespreis ist ungueltig.');
}
return [
'customer_name' => $customerName,
'company' => $company,
'email' => $email,
'phone' => $phone,
'street' => $street,
'postal_code' => $postalCode,
'city' => $city,
'event_type' => $eventType,
'event_location' => $eventLocation,
'start_date' => $startDate,
'end_date' => $endDate,
'total_days' => $totalDays,
'payment_method' => $paymentMethod,
'payment_method_label' => $paymentLabels[$paymentMethod],
'delivery_mode' => $deliveryMode,
'delivery_mode_label' => $deliveryLabels[$deliveryMode],
'notes_customer' => $notesCustomer,
'internal_notes' => $internalNotes,
'status' => $status,
'status_label' => $this->getStatusOptions()[$status],
'payment_status' => $paymentStatus,
'payment_status_label' => $this->getPaymentStatusOptions()[$paymentStatus],
'price_per_day_cents' => $pricePerDay,
'subtotal_cents' => $totalDays * $pricePerDay,
];
}
private function buildBookingRecord(array $payload, string $source): array
{
$now = gmdate('c');
return [
'id' => $this->generateId('ord'),
'reference' => 'FB-' . gmdate('Ymd') . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'source' => $source,
'status' => $payload['status'],
'status_label' => $payload['status_label'],
'payment_status' => $payload['payment_status'],
'payment_status_label' => $payload['payment_status_label'],
'payment_method' => $payload['payment_method'],
'payment_method_label' => $payload['payment_method_label'],
'delivery_mode' => $payload['delivery_mode'],
'delivery_mode_label' => $payload['delivery_mode_label'],
'start_date' => $payload['start_date'],
'end_date' => $payload['end_date'],
'total_days' => $payload['total_days'],
'price_per_day_cents' => $payload['price_per_day_cents'],
'subtotal_cents' => $payload['subtotal_cents'],
'notes_customer' => $payload['notes_customer'],
'internal_notes' => $payload['internal_notes'],
'invoice_id' => null,
'created_at' => $now,
'updated_at' => $now,
'customer' => [
'name' => $payload['customer_name'],
'company' => $payload['company'],
'email' => $payload['email'],
'phone' => $payload['phone'],
'street' => $payload['street'],
'postal_code' => $payload['postal_code'],
'city' => $payload['city'],
'event_type' => $payload['event_type'],
'event_location' => $payload['event_location'],
],
];
}
private function assertAvailability(array $records, string $startDate, string $endDate, ?string $ignoreId = null): void
{
foreach ($records as $record) {
if ($ignoreId !== null && $record['id'] === $ignoreId) {
continue;
}
if (!$this->isBlockingStatus((string) $record['status'])) {
continue;
}
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.'
);
}
}
}
private function calculateRentalDays(string $startDate, string $endDate): int
{
$start = new DateTimeImmutable($startDate);
$end = new DateTimeImmutable($endDate);
return (int) $start->diff($end)->format('%r%a') + 1;
}
private function nextInvoiceNumber(array $records): string
{
$year = gmdate('Y');
$count = 0;
foreach ($records as $record) {
if (str_starts_with((string) ($record['invoice_number'] ?? ''), 'RE-' . $year . '-')) {
$count++;
}
}
return sprintf('RE-%s-%04d', $year, $count + 1);
}
private function findBookingIndex(array $records, string $bookingId): ?int
{
foreach ($records as $index => $record) {
if (($record['id'] ?? null) === $bookingId) {
return $index;
}
}
return null;
}
private function isBlockingStatus(string $status): bool
{
return in_array($status, self::BLOCKING_STATUSES, true);
}
private function generateId(string $prefix): string
{
return $prefix . '_' . strtolower(bin2hex(random_bytes(6)));
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
final class InvoicePdfService
{
public function __construct(private readonly array $config)
{
}
public function render(array $invoice): string
{
$customer = $invoice['customer_snapshot'];
$company = $invoice['company'];
$lines = [
$company['name'],
$company['address']['street'],
$company['address']['postal_code'] . ' ' . $company['address']['city'],
'',
'Rechnung: ' . $invoice['invoice_number'],
'Rechnungsdatum: ' . formatDate($invoice['issue_date']),
'Faellig bis: ' . formatDate($invoice['due_date']),
'',
'Rechnung an:',
$customer['name'],
$customer['company'] !== '' ? $customer['company'] : null,
$customer['street'],
$customer['postal_code'] . ' ' . $customer['city'],
'',
'Leistung:',
];
foreach ($invoice['line_items'] as $item) {
$lines[] = sprintf(
'%s | %d x %s | %s',
$item['label'],
$item['quantity'],
formatCurrency($item['unit_price_cents']),
formatCurrency($item['total_cents'])
);
}
$lines = array_values(array_filter($lines, static fn(mixed $line): bool => $line !== null));
$lines[] = '';
$lines[] = 'Zahlungsart: ' . $invoice['payment_method_label'];
$lines[] = 'Gesamtbetrag: ' . formatCurrency($invoice['total_cents']);
$lines[] = $company['tax_notice'];
$lines[] = '';
$lines[] = 'Bankverbindung:';
$lines[] = $company['bank']['account_holder'];
$lines[] = 'IBAN: ' . $company['bank']['iban'];
$lines[] = 'BIC: ' . $company['bank']['bic'];
$lines[] = $company['bank']['bank_name'];
$lines[] = '';
$lines[] = $invoice['notes'];
$operations = [];
$y = 800;
foreach ($lines as $line) {
$fontSize = str_starts_with((string) $line, 'Rechnung:') ? 16 : 11;
$operations[] = sprintf(
'BT /F1 %d Tf 1 0 0 1 50 %d Tm (%s) Tj ET',
$fontSize,
$y,
$this->escapePdfText((string) $line)
);
$y -= $fontSize === 16 ? 24 : 16;
}
$stream = implode("\n", $operations);
return $this->buildPdf($stream);
}
private function buildPdf(string $stream): string
{
$objects = [];
$objects[] = '<< /Type /Catalog /Pages 2 0 R >>';
$objects[] = '<< /Type /Pages /Kids [3 0 R] /Count 1 >>';
$objects[] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>';
$objects[] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
$objects[] = '<< /Length ' . strlen($stream) . ' >>' . "\nstream\n" . $stream . "\nendstream";
$pdf = "%PDF-1.4\n";
$offsets = [0];
foreach ($objects as $index => $object) {
$offsets[] = strlen($pdf);
$pdf .= ($index + 1) . " 0 obj\n" . $object . "\nendobj\n";
}
$xrefOffset = strlen($pdf);
$pdf .= 'xref' . "\n";
$pdf .= '0 ' . (count($objects) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
for ($i = 1; $i <= count($objects); $i++) {
$pdf .= sprintf('%010d 00000 n ', $offsets[$i]) . "\n";
}
$pdf .= 'trailer << /Size ' . (count($objects) + 1) . ' /Root 1 0 R >>' . "\n";
$pdf .= 'startxref' . "\n" . $xrefOffset . "\n%%EOF";
return $pdf;
}
private function escapePdfText(string $value): string
{
$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);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
function render(string $view, array $data = []): void
{
extract($data, EXTR_SKIP);
$viewPath = dirname(__DIR__, 1) . '/../views/' . $view . '.php';
require dirname(__DIR__, 1) . '/../views/layout.php';
}
function asset(string $path): string
{
return '/' . ltrim($path, '/');
}
function redirect(string $path): void
{
header('Location: ' . $path);
exit;
}
function flash(string $key, mixed $value = null): mixed
{
if (func_num_args() === 2) {
$_SESSION['flash'][$key] = $value;
return null;
}
$stored = $_SESSION['flash'][$key] ?? null;
unset($_SESSION['flash'][$key]);
return $stored;
}
function isAdminAuthenticated(): bool
{
return (bool) ($_SESSION['admin_authenticated'] ?? false);
}
function requireAdmin(): void
{
if (!isAdminAuthenticated()) {
renderAdminLogin();
exit;
}
}
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
function formatCurrency(int $cents): string
{
return number_format($cents / 100, 2, ',', '.') . ' EUR';
}
function selected(string $current, string $expected): string
{
return $current === $expected ? 'selected' : '';
}
function formatDate(string $date): string
{
$dateTime = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if (!$dateTime) {
return $date;
}
return $dateTime->format('d.m.Y');
}
+269
View File
@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
require __DIR__ . '/Support/functions.php';
require __DIR__ . '/Repository/RecordRepositoryInterface.php';
require __DIR__ . '/Repository/JsonRepository.php';
require __DIR__ . '/Repository/MySqlJsonRepository.php';
require __DIR__ . '/Services/BookingService.php';
require __DIR__ . '/Services/InvoicePdfService.php';
session_start();
function runApplication(): void
{
$config = require dirname(__DIR__) . '/config.php';
[$bookingRepository, $invoiceRepository] = resolveRepositories($config);
$bookingService = new BookingService($bookingRepository, $invoiceRepository, $config);
$invoicePdfService = new InvoicePdfService($config);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
if ($method === 'POST' && $path === '/book') {
handlePublicBooking($bookingService);
return;
}
if ($method === 'POST' && $path === '/admin/login') {
handleAdminLogin($config['admin']);
return;
}
if ($method === 'POST' && $path === '/admin/logout') {
handleAdminLogout();
return;
}
if ($path === '/admin/invoice/pdf') {
requireAdmin();
handleInvoicePdf($bookingService, $invoicePdfService);
return;
}
if (str_starts_with($path, '/admin')) {
handleAdminRequest($path, $method, $bookingService);
return;
}
renderHome($bookingService, $config);
}
function resolveRepositories(array $config): array
{
$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'] ?? '');
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$databaseConfig['host'],
$databaseConfig['port'] ?? 3306,
$databaseConfig['database']
);
$pdo = new PDO($dsn, $databaseConfig['username'], $databaseConfig['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return [
new MySqlJsonRepository($pdo, resolveTableName($tablePrefix, $config['database']['tables']['bookings'])),
new MySqlJsonRepository($pdo, resolveTableName($tablePrefix, $config['database']['tables']['invoices'])),
];
} catch (Throwable $exception) {
error_log('MySQL-Verbindung fehlgeschlagen, JSON-Fallback aktiv: ' . $exception->getMessage());
}
}
}
return [
new JsonRepository($config['storage']['bookings']),
new JsonRepository($config['storage']['invoices']),
];
}
function resolveTableName(string $prefix, string $table): string
{
return $prefix . $table;
}
function handlePublicBooking(BookingService $bookingService): void
{
try {
$bookingService->createPublicBooking($_POST);
flash('success', 'Deine Anfrage wurde gespeichert. Wir melden uns zeitnah mit der Bestaetigung und allen Details.');
} catch (Throwable $exception) {
flash('error', $exception->getMessage());
flash('old', $_POST);
}
redirect('/');
}
function handleAdminLogin(array $adminConfig): void
{
$username = trim((string) ($_POST['username'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if ($username === $adminConfig['username'] && hash_equals($adminConfig['password'], $password)) {
$_SESSION['admin_authenticated'] = true;
flash('success', 'Admin-Bereich geoeffnet.');
} else {
flash('error', 'Die Admin-Zugangsdaten sind nicht korrekt.');
}
redirect('/admin');
}
function handleAdminLogout(): void
{
unset($_SESSION['admin_authenticated']);
flash('success', 'Du wurdest aus dem Admin-Bereich abgemeldet.');
redirect('/admin');
}
function handleAdminRequest(string $path, string $method, BookingService $bookingService): void
{
if (!isAdminAuthenticated()) {
renderAdminLogin();
return;
}
if ($method === 'POST' && $path === '/admin/create') {
try {
$bookingService->createAdminBooking($_POST);
flash('success', 'Die Bestellung wurde fuer den Kunden angelegt.');
} catch (Throwable $exception) {
flash('error', $exception->getMessage());
flash('admin_old', $_POST);
}
redirect('/admin/create');
}
if ($method === 'POST' && $path === '/admin/order/update') {
try {
$bookingService->updateBooking((string) ($_POST['booking_id'] ?? ''), $_POST);
flash('success', 'Der Auftrag wurde aktualisiert.');
} catch (Throwable $exception) {
flash('error', $exception->getMessage());
}
redirect('/admin/order?id=' . urlencode((string) ($_POST['booking_id'] ?? '')));
}
if ($method === 'POST' && $path === '/admin/order/invoice') {
try {
$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));
} catch (Throwable $exception) {
flash('error', $exception->getMessage());
redirect('/admin/order?id=' . urlencode((string) ($_POST['booking_id'] ?? '')));
}
}
if ($path === '/admin/create') {
renderAdminCreate($bookingService);
return;
}
if ($path === '/admin/order') {
renderAdminOrder($bookingService);
return;
}
renderAdminDashboard($bookingService);
}
function handleInvoicePdf(BookingService $bookingService, InvoicePdfService $invoicePdfService): void
{
$invoiceId = (string) ($_GET['id'] ?? '');
$invoice = $bookingService->findInvoice($invoiceId);
if ($invoice === null) {
http_response_code(404);
echo 'Rechnung nicht gefunden.';
return;
}
$pdf = $invoicePdfService->render($invoice);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $invoice['invoice_number'] . '.pdf"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
}
function renderHome(BookingService $bookingService, array $config): void
{
render('home', [
'pageTitle' => 'Fotobox mieten',
'config' => $config,
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'old' => flash('old') ?? [],
'bookings' => $bookingService->getHighlightedBookings(),
]);
}
function renderAdminLogin(): void
{
render('admin/login', [
'pageTitle' => 'Admin Login',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
]);
}
function renderAdminDashboard(BookingService $bookingService): void
{
render('admin/dashboard', [
'pageTitle' => 'Admin Dashboard',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'stats' => $bookingService->getDashboardStats(),
'bookings' => $bookingService->getBookings(),
'invoices' => $bookingService->getInvoices(),
]);
}
function renderAdminCreate(BookingService $bookingService): void
{
render('admin/create', [
'pageTitle' => 'Kundenbestellung anlegen',
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'old' => flash('admin_old') ?? [],
'defaults' => $bookingService->getAdminDefaults(),
]);
}
function renderAdminOrder(BookingService $bookingService): void
{
$bookingId = (string) ($_GET['id'] ?? '');
$booking = $bookingService->findBooking($bookingId);
if ($booking === null) {
http_response_code(404);
echo 'Auftrag nicht gefunden.';
return;
}
render('admin/order', [
'pageTitle' => 'Auftrag ' . $booking['reference'],
'flashSuccess' => flash('success'),
'flashError' => flash('error'),
'booking' => $booking,
'invoice' => $booking['invoice_id'] ? $bookingService->findInvoice($booking['invoice_id']) : null,
'statusOptions' => $bookingService->getStatusOptions(),
'paymentOptions' => $bookingService->getPaymentStatusOptions(),
]);
}
+1
View File
@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+120
View File
@@ -0,0 +1,120 @@
<section class="admin-section narrow-section">
<div class="section-header">
<div>
<p class="eyebrow">Manuelle Buchung</p>
<h1>Bestellung fuer Kunden anlegen</h1>
</div>
<a class="button-secondary" href="/admin">Zurueck zum Dashboard</a>
</div>
<?php if (!empty($flashSuccess)): ?>
<div class="flash flash-success"><?= h((string) $flashSuccess) ?></div>
<?php endif; ?>
<?php if (!empty($flashError)): ?>
<div class="flash flash-error"><?= h((string) $flashError) ?></div>
<?php endif; ?>
<form method="post" action="/admin/create" class="booking-form admin-form" data-day-rate="<?= h((string) $defaults['price_per_day_cents']) ?>">
<div class="form-grid">
<label>
<span>Name</span>
<input type="text" name="customer_name" value="<?= h((string) ($old['customer_name'] ?? '')) ?>" required>
</label>
<label>
<span>Firma</span>
<input type="text" name="company" value="<?= h((string) ($old['company'] ?? '')) ?>">
</label>
<label>
<span>E-Mail</span>
<input type="email" name="email" value="<?= h((string) ($old['email'] ?? '')) ?>" required>
</label>
<label>
<span>Telefon</span>
<input type="text" name="phone" value="<?= h((string) ($old['phone'] ?? '')) ?>" required>
</label>
<label>
<span>Strasse</span>
<input type="text" name="street" value="<?= h((string) ($old['street'] ?? '')) ?>" required>
</label>
<label>
<span>PLZ</span>
<input type="text" name="postal_code" value="<?= h((string) ($old['postal_code'] ?? '')) ?>" required>
</label>
<label>
<span>Ort</span>
<input type="text" name="city" value="<?= h((string) ($old['city'] ?? '')) ?>" required>
</label>
<label>
<span>Anlass</span>
<input type="text" name="event_type" value="<?= h((string) ($old['event_type'] ?? '')) ?>">
</label>
<label>
<span>Veranstaltungsort</span>
<input type="text" name="event_location" value="<?= h((string) ($old['event_location'] ?? '')) ?>">
</label>
<label>
<span>Mietbeginn</span>
<input type="date" name="start_date" data-booking-start value="<?= h((string) ($old['start_date'] ?? '')) ?>" required>
</label>
<label>
<span>Mietende</span>
<input type="date" name="end_date" data-booking-end value="<?= h((string) ($old['end_date'] ?? '')) ?>" required>
</label>
<label>
<span>Tagespreis in Cent</span>
<input type="number" name="price_per_day_cents" min="0" value="<?= h((string) ($old['price_per_day_cents'] ?? $defaults['price_per_day_cents'])) ?>" required>
</label>
<label>
<span>Status</span>
<select name="status">
<option value="confirmed" <?= selected((string) ($old['status'] ?? $defaults['status']), 'confirmed') ?>>Bestaetigt</option>
<option value="reserved" <?= selected((string) ($old['status'] ?? ''), 'reserved') ?>>Reserviert</option>
<option value="requested" <?= selected((string) ($old['status'] ?? ''), 'requested') ?>>Neue Anfrage</option>
<option value="cancelled" <?= selected((string) ($old['status'] ?? ''), 'cancelled') ?>>Storniert</option>
</select>
</label>
<label>
<span>Zahlungsstatus</span>
<select name="payment_status">
<option value="unpaid" <?= selected((string) ($old['payment_status'] ?? $defaults['payment_status']), 'unpaid') ?>>Offen</option>
<option value="paid" <?= selected((string) ($old['payment_status'] ?? ''), 'paid') ?>>Bezahlt</option>
<option value="refunded" <?= selected((string) ($old['payment_status'] ?? ''), 'refunded') ?>>Erstattet</option>
</select>
</label>
<label>
<span>Lieferart</span>
<select name="delivery_mode">
<option value="self_pickup" <?= selected((string) ($old['delivery_mode'] ?? $defaults['delivery_mode']), 'self_pickup') ?>>Selbstabholung</option>
<option value="delivery_setup" <?= selected((string) ($old['delivery_mode'] ?? ''), 'delivery_setup') ?>>Lieferung und Aufbau</option>
<option value="on_site_support" <?= selected((string) ($old['delivery_mode'] ?? ''), 'on_site_support') ?>>Lieferung, Aufbau und Vor-Ort-Betreuung</option>
</select>
</label>
<label>
<span>Zahlungsart</span>
<select name="payment_method">
<option value="invoice_transfer" <?= selected((string) ($old['payment_method'] ?? $defaults['payment_method']), 'invoice_transfer') ?>>Rechnung / Ueberweisung</option>
<option value="paypal" <?= selected((string) ($old['payment_method'] ?? ''), 'paypal') ?>>PayPal</option>
</select>
</label>
</div>
<label>
<span>Hinweis fuer Kunden</span>
<textarea name="notes_customer" rows="4"><?= h((string) ($old['notes_customer'] ?? '')) ?></textarea>
</label>
<label>
<span>Interne Notiz</span>
<textarea name="internal_notes" rows="4"><?= h((string) ($old['internal_notes'] ?? '')) ?></textarea>
</label>
<div class="price-summary">
<div>
<span>Mietdauer</span>
<strong data-summary-days>Noch nicht gewaehlt</strong>
</div>
<div>
<span>Gesamtpreis</span>
<strong data-summary-total><?= h(formatCurrency((int) $defaults['price_per_day_cents'])) ?></strong>
</div>
</div>
<button type="submit" class="button-primary">Bestellung speichern</button>
</form>
</section>
+119
View File
@@ -0,0 +1,119 @@
<section class="admin-section">
<div class="section-header">
<div>
<p class="eyebrow">Dashboard</p>
<h1>Anfragen, Buchungen und Rechnungen</h1>
</div>
<a class="button-primary" href="/admin/create">Bestellung fuer Kunden anlegen</a>
</div>
<?php if (!empty($flashSuccess)): ?>
<div class="flash flash-success"><?= h((string) $flashSuccess) ?></div>
<?php endif; ?>
<?php if (!empty($flashError)): ?>
<div class="flash flash-error"><?= h((string) $flashError) ?></div>
<?php endif; ?>
<div class="stats-grid">
<article class="stat-card">
<span>Alle Auftraege</span>
<strong><?= h((string) $stats['bookings_total']) ?></strong>
</article>
<article class="stat-card">
<span>Offene Anfragen</span>
<strong><?= h((string) $stats['open_requests']) ?></strong>
</article>
<article class="stat-card">
<span>Bestaetigte Buchungen</span>
<strong><?= h((string) $stats['confirmed_bookings']) ?></strong>
</article>
<article class="stat-card">
<span>Planumsatz</span>
<strong><?= h(formatCurrency((int) $stats['revenue_cents'])) ?></strong>
</article>
<article class="stat-card">
<span>Rechnungen</span>
<strong><?= h((string) $stats['invoice_total']) ?></strong>
</article>
<article class="stat-card">
<span>Offene Rechnungen</span>
<strong><?= h((string) $stats['invoice_open']) ?></strong>
</article>
</div>
<div class="admin-grid">
<section class="table-card">
<div class="table-card-header">
<h2>Auftraege</h2>
<span><?= h((string) count($bookings)) ?> Eintraege</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Referenz</th>
<th>Kunde</th>
<th>Termin</th>
<th>Status</th>
<th>Zahlung</th>
<th>Preis</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $booking): ?>
<tr>
<td><a href="/admin/order?id=<?= h(urlencode($booking['id'])) ?>"><?= h($booking['reference']) ?></a></td>
<td><?= h($booking['customer']['name']) ?></td>
<td><?= h(formatDate($booking['start_date'])) ?> - <?= h(formatDate($booking['end_date'])) ?></td>
<td><?= h($booking['status_label']) ?></td>
<td><?= h($booking['payment_status_label']) ?></td>
<td><?= h(formatCurrency((int) $booking['subtotal_cents'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if ($bookings === []): ?>
<tr>
<td colspan="6">Noch keine Auftraege vorhanden.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="table-card">
<div class="table-card-header">
<h2>Rechnungen</h2>
<span><?= h((string) count($invoices)) ?> Eintraege</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Nummer</th>
<th>Auftrag</th>
<th>Faellig</th>
<th>Zahlungsart</th>
<th>Gesamt</th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $invoice): ?>
<tr>
<td><a href="/admin/invoice/pdf?id=<?= h(urlencode($invoice['id'])) ?>" target="_blank"><?= h($invoice['invoice_number']) ?></a></td>
<td><?= h($invoice['booking_id']) ?></td>
<td><?= h(formatDate($invoice['due_date'])) ?></td>
<td><?= h($invoice['payment_method_label']) ?></td>
<td><?= h(formatCurrency((int) $invoice['total_cents'])) ?></td>
</tr>
<?php endforeach; ?>
<?php if ($invoices === []): ?>
<tr>
<td colspan="5">Noch keine Rechnungen erstellt.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
</section>
+26
View File
@@ -0,0 +1,26 @@
<section class="admin-login-section">
<div class="admin-login-card">
<p class="eyebrow">Verwaltung</p>
<h1>Admin-Login</h1>
<p>Hier verwaltest du Anfragen, Kundenbestellungen und Rechnungen.</p>
<?php if (!empty($flashSuccess)): ?>
<div class="flash flash-success"><?= h((string) $flashSuccess) ?></div>
<?php endif; ?>
<?php if (!empty($flashError)): ?>
<div class="flash flash-error"><?= h((string) $flashError) ?></div>
<?php endif; ?>
<form method="post" action="/admin/login" class="stack-form">
<label>
<span>Benutzername</span>
<input type="text" name="username" value="admin" required>
</label>
<label>
<span>Passwort</span>
<input type="password" name="password" required>
</label>
<button type="submit" class="button-primary button-block">Anmelden</button>
</form>
</div>
</section>
+105
View File
@@ -0,0 +1,105 @@
<section class="admin-section narrow-section">
<div class="section-header">
<div>
<p class="eyebrow">Auftragsdetail</p>
<h1><?= h($booking['reference']) ?></h1>
</div>
<a class="button-secondary" href="/admin">Zurueck zum Dashboard</a>
</div>
<?php if (!empty($flashSuccess)): ?>
<div class="flash flash-success"><?= h((string) $flashSuccess) ?></div>
<?php endif; ?>
<?php if (!empty($flashError)): ?>
<div class="flash flash-error"><?= h((string) $flashError) ?></div>
<?php endif; ?>
<div class="detail-grid">
<article class="table-card">
<h2>Kundendaten</h2>
<dl class="detail-list">
<div><dt>Name</dt><dd><?= h($booking['customer']['name']) ?></dd></div>
<div><dt>Firma</dt><dd><?= h($booking['customer']['company'] ?: '-') ?></dd></div>
<div><dt>E-Mail</dt><dd><?= h($booking['customer']['email']) ?></dd></div>
<div><dt>Telefon</dt><dd><?= h($booking['customer']['phone']) ?></dd></div>
<div><dt>Adresse</dt><dd><?= h($booking['customer']['street']) ?>, <?= h($booking['customer']['postal_code']) ?> <?= h($booking['customer']['city']) ?></dd></div>
<div><dt>Anlass</dt><dd><?= h($booking['customer']['event_type'] ?: '-') ?></dd></div>
<div><dt>Ort</dt><dd><?= h($booking['customer']['event_location'] ?: '-') ?></dd></div>
<div><dt>Mietzeitraum</dt><dd><?= h(formatDate($booking['start_date'])) ?> bis <?= h(formatDate($booking['end_date'])) ?></dd></div>
<div><dt>Leistung</dt><dd><?= h($booking['delivery_mode_label']) ?></dd></div>
<div><dt>Zahlungsart</dt><dd><?= h($booking['payment_method_label']) ?></dd></div>
<div><dt>Gesamt</dt><dd><?= h(formatCurrency((int) $booking['subtotal_cents'])) ?></dd></div>
</dl>
</article>
<article class="table-card">
<h2>Verwaltung</h2>
<form method="post" action="/admin/order/update" class="stack-form">
<input type="hidden" name="booking_id" value="<?= h($booking['id']) ?>">
<label>
<span>Status</span>
<select name="status">
<?php foreach ($statusOptions as $value => $label): ?>
<option value="<?= h($value) ?>" <?= selected((string) $booking['status'], (string) $value) ?>><?= h($label) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Zahlungsstatus</span>
<select name="payment_status">
<?php foreach ($paymentOptions as $value => $label): ?>
<option value="<?= h($value) ?>" <?= selected((string) $booking['payment_status'], (string) $value) ?>><?= h($label) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Kundennotiz</span>
<textarea name="notes_customer" rows="4"><?= h((string) $booking['notes_customer']) ?></textarea>
</label>
<label>
<span>Interne Notiz</span>
<textarea name="internal_notes" rows="4"><?= h((string) $booking['internal_notes']) ?></textarea>
</label>
<button type="submit" class="button-primary">Aenderungen speichern</button>
</form>
</article>
</div>
<div class="detail-grid">
<article class="table-card">
<h2>Rechnung</h2>
<?php if ($invoice !== null): ?>
<p>Rechnung <strong><?= h($invoice['invoice_number']) ?></strong> ist erstellt.</p>
<ul class="check-list compact-list">
<li>Faellig am <?= h(formatDate($invoice['due_date'])) ?></li>
<li>Gesamtbetrag <?= h(formatCurrency((int) $invoice['total_cents'])) ?></li>
<li>Zahlungsart <?= h($invoice['payment_method_label']) ?></li>
</ul>
<a class="button-secondary" href="/admin/invoice/pdf?id=<?= h(urlencode($invoice['id'])) ?>" target="_blank">PDF oeffnen</a>
<?php else: ?>
<p>Fuer diesen Auftrag wurde noch keine Rechnung erstellt.</p>
<form method="post" action="/admin/order/invoice" class="stack-form">
<input type="hidden" name="booking_id" value="<?= h($booking['id']) ?>">
<label>
<span>Faelligkeitsdatum</span>
<input type="date" name="due_date">
</label>
<label>
<span>Rechnungsnotiz</span>
<textarea name="invoice_notes" rows="4">Vielen Dank fuer deinen Auftrag.</textarea>
</label>
<button type="submit" class="button-primary">Rechnung erstellen</button>
</form>
<?php endif; ?>
</article>
<article class="table-card">
<h2>Systeminfos</h2>
<dl class="detail-list">
<div><dt>ID</dt><dd><?= h($booking['id']) ?></dd></div>
<div><dt>Quelle</dt><dd><?= h($booking['source']) ?></dd></div>
<div><dt>Erstellt</dt><dd><?= h($booking['created_at']) ?></dd></div>
<div><dt>Aktualisiert</dt><dd><?= h($booking['updated_at']) ?></dd></div>
</dl>
</article>
</div>
</section>
+272
View File
@@ -0,0 +1,272 @@
<?php
$dayRate = $config['pricing']['default_day_rate_cents'];
$company = $config['company'];
?>
<section class="hero-section">
<div class="hero-copy">
<p class="eyebrow">Fotobox-Vermietung neu gedacht</p>
<h1>Die Fotobox fuer Hochzeiten, Geburtstage und Firmenevents</h1>
<p class="hero-text">
Unkompliziert mieten, schnell aufbauen, kinderleicht bedienen und alle Bilder direkt digital sichern.
Unsere Fotobox verbindet hochwertige Technik mit einem klaren Buchungsprozess ohne Shop-Chaos.
</p>
<div class="hero-actions">
<a class="button-primary" href="#buchung">Verfuegbarkeit pruefen</a>
<a class="button-secondary" href="#ablauf">So funktioniert die Miete</a>
</div>
<ul class="hero-points">
<li>Spiegelreflexkamera, Studioblitz und Softbox</li>
<li>WLAN-Download direkt aufs Handy</li>
<li>Selbstabholung oder Lieferung mit Aufbau</li>
<li>99,99 EUR pro Kalendertag</li>
</ul>
</div>
<div class="hero-card">
<div class="hero-card-panel hero-card-panel-top">
<span>Eventbereit in wenigen Minuten</span>
<strong>Abholung ab 17:00 Uhr</strong>
</div>
<div class="hero-card-visual">
<div class="camera-glow"></div>
<div class="camera-body">
<div class="camera-lens"></div>
<div class="camera-screen"></div>
</div>
</div>
<div class="hero-card-panel">
<span>Digitale Galerie inklusive</span>
<strong>Rueckgabe bis 13:00 Uhr</strong>
</div>
</div>
</section>
<section class="feature-strip" id="leistungen">
<article>
<h2>Technik, die nicht zickt</h2>
<p>Spiegelreflexkamera, Bildschirm und Studioblitz mit Softbox sorgen fuer helle, scharfe Bilder bei jedem Anlass.</p>
</article>
<article>
<h2>Direkt aufs Handy</h2>
<p>Per WLAN koennen deine Gaeste ihre Fotos sofort laden und teilen. Nach dem Event gibt es alle Bilder digital.</p>
</article>
<article>
<h2>Flexible Logistik</h2>
<p>Selbst abholen, liefern lassen oder auf Wunsch mit Aufbau und Vor-Ort-Unterstuetzung buchen.</p>
</article>
</section>
<section class="content-grid" id="ablauf">
<div class="content-block">
<p class="eyebrow">Ablauf</p>
<h2>So laeuft die Miete ab</h2>
<ol class="step-list">
<li>
<strong>1. Zeitraum waehlen</strong>
<span>Du waehlst Mietbeginn und Mietende und siehst sofort die voraussichtlichen Kosten.</span>
</li>
<li>
<strong>2. Leistung festlegen</strong>
<span>Selbstabholung, Lieferung mit Aufbau oder Vor-Ort-Betreuung passend zu deinem Event.</span>
</li>
<li>
<strong>3. Anfrage absenden</strong>
<span>Wir speichern alle Kundendaten und bereiten auf Wunsch direkt die Rechnungsabwicklung vor.</span>
</li>
<li>
<strong>4. Fotos geniessen</strong>
<span>Am Eventtag steht die Box bereit und danach bekommst du alle Bilder digital zur Weitergabe.</span>
</li>
</ol>
</div>
<div class="content-block equipment-block">
<p class="eyebrow">Ausstattung</p>
<h2>Alles drin fuer einen reibungslosen Party-Hit</h2>
<ul class="check-list">
<li>Spiegelreflexkamera fuer gestochen scharfe Aufnahmen</li>
<li>Bildschirm mit einfacher Bedienung per Touch</li>
<li>Studioblitz mit grosser Softbox fuer gleichmaessiges Licht</li>
<li>Digitale Uebergabe aller Fotos nach dem Event</li>
<li>Optionaler Hintergrund und Betreuung vor Ort</li>
</ul>
</div>
</section>
<section class="pricing-panel">
<div>
<p class="eyebrow">Preis und Logistik</p>
<h2>Transparent statt versteckt</h2>
<p>
Standardmaessig berechnen wir <strong><?= h(formatCurrency($dayRate)) ?></strong> pro Kalendertag.
Mietbeginn und Mietende zaehlen beide mit. Selbstabholung spart Zeit in der Abstimmung,
Lieferung und Aufbau machen es vor Ort noch entspannter.
</p>
</div>
<div class="pricing-aside">
<div>
<span>Tagespreis</span>
<strong><?= h(formatCurrency($dayRate)) ?></strong>
</div>
<div>
<span>Zahlungsarten</span>
<strong>Rechnung / Ueberweisung und PayPal</strong>
</div>
<div>
<span>Servicefenster</span>
<strong>Abholung ab 17:00 Uhr, Rueckgabe bis 13:00 Uhr</strong>
</div>
</div>
</section>
<section class="availability-section">
<div>
<p class="eyebrow">Online-Verfuegbarkeit</p>
<h2>Bereits reservierte Zeitraeume</h2>
<p>Diese Liste zeigt geblockte oder bestaetigte Termine aus dem Verwaltungssystem.</p>
</div>
<div class="availability-list">
<?php if ($bookings === []): ?>
<article class="availability-card availability-card-empty">
<strong>Aktuell sind keine festen Reservierungen hinterlegt.</strong>
<span>Du kannst direkt eine neue Anfrage senden.</span>
</article>
<?php endif; ?>
<?php foreach ($bookings as $booking): ?>
<article class="availability-card">
<strong><?= h($booking['reference']) ?></strong>
<span><?= h(formatDate($booking['start_date'])) ?> bis <?= h(formatDate($booking['end_date'])) ?></span>
<small><?= h($booking['status_label']) ?></small>
</article>
<?php endforeach; ?>
</div>
</section>
<section class="booking-section" id="buchung">
<div class="booking-copy">
<p class="eyebrow">Buchungsanfrage</p>
<h2>Fotobox jetzt anfragen</h2>
<p>
Zwei Termine auswaehlen, Wunschleistung festlegen und Kundendaten hinterlegen.
Die Verwaltung kann deine Anfrage danach direkt bestaetigen, als Kundenbestellung uebernehmen und eine Rechnung erzeugen.
</p>
<div class="booking-info-card">
<strong>Kontakt fuer Rueckfragen</strong>
<span><?= h($company['email']) ?></span>
<span><?= h($company['phone']) ?></span>
</div>
</div>
<div class="booking-form-card">
<?php if (!empty($flashSuccess)): ?>
<div class="flash flash-success"><?= h((string) $flashSuccess) ?></div>
<?php endif; ?>
<?php if (!empty($flashError)): ?>
<div class="flash flash-error"><?= h((string) $flashError) ?></div>
<?php endif; ?>
<form method="post" action="/book" class="booking-form" data-day-rate="<?= h((string) $dayRate) ?>">
<div class="form-grid">
<label>
<span>Name</span>
<input type="text" name="customer_name" value="<?= h((string) ($old['customer_name'] ?? '')) ?>" required>
</label>
<label>
<span>Firma</span>
<input type="text" name="company" value="<?= h((string) ($old['company'] ?? '')) ?>">
</label>
<label>
<span>E-Mail</span>
<input type="email" name="email" value="<?= h((string) ($old['email'] ?? '')) ?>" required>
</label>
<label>
<span>Telefon</span>
<input type="text" name="phone" value="<?= h((string) ($old['phone'] ?? '')) ?>" required>
</label>
<label>
<span>Strasse</span>
<input type="text" name="street" value="<?= h((string) ($old['street'] ?? '')) ?>" required>
</label>
<label>
<span>PLZ</span>
<input type="text" name="postal_code" value="<?= h((string) ($old['postal_code'] ?? '')) ?>" required>
</label>
<label>
<span>Ort</span>
<input type="text" name="city" value="<?= h((string) ($old['city'] ?? '')) ?>" required>
</label>
<label>
<span>Anlass</span>
<input type="text" name="event_type" value="<?= h((string) ($old['event_type'] ?? '')) ?>" placeholder="z. B. Hochzeit oder Sommerfest">
</label>
<label>
<span>Veranstaltungsort</span>
<input type="text" name="event_location" value="<?= h((string) ($old['event_location'] ?? '')) ?>" placeholder="Location oder Stadt">
</label>
<label>
<span>Mietbeginn</span>
<input type="date" name="start_date" data-booking-start value="<?= h((string) ($old['start_date'] ?? '')) ?>" required>
</label>
<label>
<span>Mietende</span>
<input type="date" name="end_date" data-booking-end value="<?= h((string) ($old['end_date'] ?? '')) ?>" required>
</label>
<label>
<span>Lieferart</span>
<select name="delivery_mode">
<option value="self_pickup" <?= selected((string) ($old['delivery_mode'] ?? 'self_pickup'), 'self_pickup') ?>>Selbstabholung</option>
<option value="delivery_setup" <?= selected((string) ($old['delivery_mode'] ?? ''), 'delivery_setup') ?>>Lieferung und Aufbau</option>
<option value="on_site_support" <?= selected((string) ($old['delivery_mode'] ?? ''), 'on_site_support') ?>>Lieferung, Aufbau und Vor-Ort-Betreuung</option>
</select>
</label>
<label>
<span>Zahlungsart</span>
<select name="payment_method">
<option value="invoice_transfer" <?= selected((string) ($old['payment_method'] ?? 'invoice_transfer'), 'invoice_transfer') ?>>Rechnung / Ueberweisung</option>
<option value="paypal" <?= selected((string) ($old['payment_method'] ?? ''), 'paypal') ?>>PayPal</option>
</select>
</label>
</div>
<label>
<span>Nachricht</span>
<textarea name="notes_customer" rows="4" placeholder="Sonderwuensche, Lieferdetails oder Aufbauhinweise"><?= h((string) ($old['notes_customer'] ?? '')) ?></textarea>
</label>
<div class="price-summary">
<div>
<span>Mietdauer</span>
<strong data-summary-days>Noch nicht gewaehlt</strong>
</div>
<div>
<span>Gesamtpreis</span>
<strong data-summary-total><?= h(formatCurrency($dayRate)) ?></strong>
</div>
</div>
<button type="submit" class="button-primary button-block">Anfrage absenden</button>
<p class="form-note">Mit dem Absenden wird eine verwaltbare Anfrage angelegt. Die finale Bestaetigung erfolgt durch den Verwalter.</p>
</form>
</div>
</section>
<section class="faq-section" id="faq">
<div>
<p class="eyebrow">FAQ</p>
<h2>Wichtige Fragen auf einen Blick</h2>
</div>
<div class="faq-list">
<article>
<h3>Wie schnell ist die Fotobox einsatzbereit?</h3>
<p>Durch den einfachen Aufbau und die kurze Einweisung ist die Box in wenigen Minuten startklar.</p>
</article>
<article>
<h3>Bekommen wir alle Fotos?</h3>
<p>Ja. Alle Bilder werden nach dem Event digital zur Verfuegung gestellt. Auf Wunsch koennen Gaeste sie schon vor Ort aufs Handy laden.</p>
</article>
<article>
<h3>Kann ich auch Lieferung und Betreuung buchen?</h3>
<p>Ja. Du kannst zwischen Selbstabholung, Lieferung mit Aufbau oder zusaetzlicher Vor-Ort-Unterstuetzung waehlen.</p>
</article>
<article>
<h3>Ist PayPal schon moeglich?</h3>
<p>Ja, PayPal ist als Zahlungsart im Ablauf vorgesehen. Sobald du echte MySQL- und PayPal-Daten hinterlegst, laesst sich der operative Betrieb direkt anbinden.</p>
</article>
</div>
</section>
+46
View File
@@ -0,0 +1,46 @@
<?php
$metaTitle = isset($pageTitle) ? $pageTitle . ' | Fotobox Moments' : 'Fotobox Moments';
$isAdminArea = str_contains($viewPath, '/admin/');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= h($metaTitle) ?></title>
<meta name="description" content="Fotobox mieten mit einfacher Online-Anfrage, flexibler Lieferung und kompletter Admin-Verwaltung.">
<link rel="stylesheet" href="<?= h(asset('assets/styles.css')) ?>">
<script defer src="<?= h(asset('assets/app.js')) ?>"></script>
</head>
<body class="<?= $isAdminArea ? 'theme-admin' : 'theme-public' ?>">
<div class="page-shell">
<header class="site-header">
<a class="brand" href="<?= $isAdminArea ? '/admin' : '/' ?>">
<span class="brand-mark">FM</span>
<span>
<strong>Fotobox Moments</strong>
<small>Vermietung & Verwaltung</small>
</span>
</a>
<nav class="site-nav">
<?php if ($isAdminArea): ?>
<a href="/admin">Dashboard</a>
<a href="/admin/create">Bestellung anlegen</a>
<form method="post" action="/admin/logout">
<button type="submit" class="ghost-button">Logout</button>
</form>
<?php else: ?>
<a href="#leistungen">Leistungen</a>
<a href="#ablauf">Ablauf</a>
<a href="#faq">FAQ</a>
<a class="primary-link" href="#buchung">Verfuegbarkeit pruefen</a>
<?php endif; ?>
</nav>
</header>
<main>
<?php require $viewPath; ?>
</main>
</div>
</body>
</html>