diff --git a/README.md b/README.md index d5a6a29..ed097ff 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Diese Anwendung stellt eine mehrseitige deutsche Vermietungsseite für eine Foto - 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 +- Live-Preisberechnung je nach Abholung oder Liefergebiet - einen Verwaltungsbereich für Anfragen, Buchungen, Kunden, Kalender, Rechnungen und Einstellungen - MySQL-Unterstützung mit Tabellenpräfix `fb_` sowie JSON-Fallback @@ -59,7 +59,10 @@ Die Datei `mysql.local.php` ist bereits in `.gitignore` ausgeschlossen. Standard - Ein Miettag entspricht immer einer Übernachtung. - Beispiel: `Montag bis Dienstag = 1 Miettag` -- Der Standardpreis beträgt `99,99 €` pro Miettag. +- Selbstabholung kostet `99,99 €` pro Miettag. +- Lieferung nach Hannover kostet `199,99 €` pro Miettag. +- Lieferung in die Region Hannover kostet `249,99 €` pro Miettag. +- Lieferung nach Hameln, Braunschweig, Hildesheim oder Celle kostet `299,99 €` pro Miettag. - Zahlungsarten: `Rechnung / Überweisung` und `PayPal` - Öffentliche Eingaben sind zunächst Buchungsanfragen und werden erst nach Bestätigung verbindlich. diff --git a/assets/app.js b/assets/app.js index dfb14ad..a712f29 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,4 +1,7 @@ const forms = document.querySelectorAll('.booking-form'); +const navToggle = document.querySelector('[data-nav-toggle]'); +const navMenu = document.querySelector('[data-nav-menu]'); +const navLinks = document.querySelectorAll('.site-nav-public a'); const formatCurrency = (cents) => new Intl.NumberFormat('de-DE', { @@ -26,17 +29,54 @@ 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 rateOutput = form.querySelector('[data-summary-rate]'); const totalOutput = form.querySelector('[data-summary-total]'); const rateInput = form.querySelector('input[name="price_per_day_cents"]'); + const deliveryModeInput = form.querySelector('[data-delivery-mode]'); + const deliveryZoneInput = form.querySelector('[data-delivery-zone]'); const defaultRate = Number(form.dataset.dayRate || rateInput?.value || 9999); + const priceRates = (() => { + try { + return JSON.parse(form.dataset.priceRates || '{}'); + } catch { + return {}; + } + })(); + + const resolveRate = () => { + if (deliveryModeInput?.value === 'self_pickup') { + return Number(priceRates.self_pickup || defaultRate); + } + + return Number(priceRates[deliveryZoneInput?.value || ''] || defaultRate); + }; + + const syncRate = () => { + if (deliveryZoneInput) { + if (deliveryModeInput?.value === 'self_pickup') { + deliveryZoneInput.value = 'self_pickup'; + } else if (deliveryZoneInput.value === 'self_pickup') { + deliveryZoneInput.value = 'hannover'; + } + } + + const rate = resolveRate(); + + if (rateInput) { + rateInput.value = String(rate); + } + + return rate; + }; const render = () => { const rentalDays = calculateRentalDays(startInput?.value, endInput?.value); - const rate = Number(rateInput?.value || defaultRate); + const rate = syncRate(); if (!rentalDays || rate < 0) { if (daysOutput) daysOutput.textContent = 'Noch nicht gewählt'; - if (totalOutput) totalOutput.textContent = formatCurrency(defaultRate); + if (rateOutput) rateOutput.textContent = formatCurrency(rate); + if (totalOutput) totalOutput.textContent = formatCurrency(rate); return; } @@ -44,6 +84,10 @@ forms.forEach((form) => { daysOutput.textContent = `${rentalDays} ${rentalDays === 1 ? 'Miettag' : 'Miettage'}`; } + if (rateOutput) { + rateOutput.textContent = formatCurrency(rate); + } + if (totalOutput) { totalOutput.textContent = formatCurrency(rentalDays * rate); } @@ -52,5 +96,29 @@ forms.forEach((form) => { startInput?.addEventListener('input', render); endInput?.addEventListener('input', render); rateInput?.addEventListener('input', render); + deliveryModeInput?.addEventListener('change', render); + deliveryZoneInput?.addEventListener('change', render); render(); }); + +if (navToggle && navMenu) { + const closeMenu = () => { + navToggle.setAttribute('aria-expanded', 'false'); + navMenu.classList.remove('is-open'); + }; + + navToggle.addEventListener('click', () => { + const isOpen = navMenu.classList.toggle('is-open'); + navToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + }); + + navLinks.forEach((link) => { + link.addEventListener('click', closeMenu); + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + closeMenu(); + } + }); +} diff --git a/assets/styles.css b/assets/styles.css index adc290a..8dd5d26 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -175,6 +175,125 @@ textarea { transform: translateY(-1px); } +.public-header-shell { + display: flex; + align-items: center; + gap: 1rem; + margin-left: auto; +} + +.public-header-controls { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.site-nav-public { + gap: 1.2rem; +} + +.site-nav-public a { + position: relative; + padding: 0.35rem 0; + border-radius: 0; + background: transparent; + color: var(--text-soft); + font-weight: 500; +} + +.site-nav-public a::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -0.35rem; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent) 0%, #d4a081 100%); + opacity: 0; + transform: scaleX(0.55); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.site-nav-public a:hover, +.site-nav-public a:focus-visible, +.site-nav-public a.is-active { + background: transparent; + color: var(--text); + transform: none; +} + +.site-nav-public a:hover::after, +.site-nav-public a:focus-visible::after, +.site-nav-public a.is-active::after { + opacity: 1; + transform: scaleX(1); +} + +.header-actions-public { + gap: 0.8rem; +} + +.menu-meta { + display: none; + gap: 0.35rem; + color: var(--text-soft); + font-size: 0.92rem; +} + +.menu-meta a { + color: var(--accent-strong); + text-decoration: none; +} + +.contact-chip { + display: inline-grid; + gap: 0.1rem; + padding: 0.75rem 1rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.74); + text-decoration: none; + line-height: 1.25; +} + +.contact-chip span { + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-soft); +} + +.contact-chip strong { + font-size: 0.95rem; + color: var(--text); +} + +.nav-toggle { + display: none; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.76); + color: var(--text); + cursor: pointer; +} + +.nav-toggle-box { + display: inline-grid; + gap: 0.24rem; +} + +.nav-toggle-box span { + display: block; + width: 1rem; + height: 2px; + border-radius: 999px; + background: currentColor; +} + .header-actions { display: flex; align-items: center; @@ -618,6 +737,136 @@ p { gap: 0.9rem; } +.calendar-legend, +.public-calendar-grid, +.calendar-weekdays, +.calendar-days, +.calendar-entry-list { + display: grid; +} + +.calendar-legend { + grid-auto-flow: column; + justify-content: start; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.public-calendar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.calendar-month-card { + padding: 1.35rem; + border-radius: var(--radius-xl); + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.78); + box-shadow: var(--shadow-card); +} + +.calendar-month-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.calendar-month-header h2 { + margin: 0; + font-size: 1.45rem; +} + +.calendar-month-header span, +.calendar-weekdays span, +.calendar-day-state { + color: var(--text-soft); + font-size: 0.88rem; +} + +.calendar-weekdays, +.calendar-days { + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.45rem; +} + +.calendar-weekdays { + margin-bottom: 0.45rem; + text-align: center; +} + +.calendar-day { + min-height: 4.6rem; + padding: 0.55rem 0.45rem; + border-radius: var(--radius-sm); + border: 1px solid var(--line); + background: rgba(247, 243, 236, 0.9); + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 0.35rem; +} + +.calendar-day-empty { + border-style: dashed; + background: rgba(255, 255, 255, 0.34); +} + +.calendar-day.is-today { + border-color: rgba(183, 106, 71, 0.42); + box-shadow: inset 0 0 0 1px rgba(183, 106, 71, 0.18); +} + +.calendar-day.is-booked { + color: var(--text); +} + +.calendar-day-requested { + background: rgba(183, 106, 71, 0.14); + border-color: rgba(183, 106, 71, 0.24); +} + +.calendar-day-reserved { + background: rgba(102, 87, 74, 0.14); + border-color: rgba(102, 87, 74, 0.22); +} + +.calendar-day-confirmed { + background: rgba(77, 105, 91, 0.14); + border-color: rgba(77, 105, 91, 0.22); +} + +.calendar-day-number { + font-weight: 700; + font-size: 1rem; +} + +.calendar-entry-list { + gap: 0.75rem; + margin-top: 1rem; +} + +.calendar-entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.9rem; + padding: 0.85rem 0.95rem; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.58); +} + +.calendar-entry strong { + display: block; +} + +.calendar-entry span { + color: var(--text-soft); + font-size: 0.92rem; +} + .availability-card, .stack-item { display: flex; @@ -1064,21 +1313,90 @@ tbody tr:last-child td { @media (max-width: 900px) { .header-inner { + gap: 0.85rem; + } + + .site-header-admin .header-inner { flex-direction: column; align-items: stretch; } - .site-nav, - .header-actions { + .site-header-admin .site-nav, + .site-header-admin .header-actions { justify-content: center; } + .public-header-shell { + position: relative; + margin-left: 0; + } + + .nav-toggle { + display: inline-flex; + } + + .public-header-controls { + position: absolute; + top: calc(100% + 0.85rem); + right: 0; + width: min(24rem, calc(100vw - 2rem)); + padding: 1rem; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: rgba(255, 251, 246, 0.98); + box-shadow: var(--shadow-soft); + display: none; + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .public-header-controls.is-open { + display: flex; + } + + .site-nav-public, + .menu-meta, + .header-actions-public { + width: 100%; + align-items: stretch; + } + + .site-nav-public { + display: flex; + flex-direction: column; + gap: 0; + } + + .menu-meta { + display: grid; + padding-top: 0.15rem; + } + + .header-actions-public { + flex-direction: column; + } + + .site-nav-public a { + padding: 0.95rem 0; + border-bottom: 1px solid var(--line); + } + + .site-nav-public a::after { + display: none; + } + + .site-nav-public a:last-child { + border-bottom: 0; + } + .faq-grid, .legal-section, .trust-grid, .form-grid, .form-grid-two, - .calendar-grid { + .calendar-grid, + .public-calendar-grid { grid-template-columns: 1fr; } @@ -1123,9 +1441,14 @@ tbody tr:last-child td { flex-direction: column; } + .topbar { + display: none; + } + .button-primary, .button-secondary, - .ghost-button { + .ghost-button, + .contact-chip { width: 100%; } @@ -1144,10 +1467,30 @@ tbody tr:last-child td { .availability-card, .stack-item, + .calendar-entry, .form-section-header, .hero-panel-top, .hero-panel-bottom { flex-direction: column; align-items: flex-start; } + + .calendar-weekdays, + .calendar-days { + gap: 0.3rem; + } + + .calendar-day { + min-height: 4rem; + padding: 0.45rem 0.35rem; + } + + .calendar-legend { + grid-auto-flow: row; + } + + .public-header-shell { + width: 100%; + justify-content: flex-end; + } } diff --git a/config.php b/config.php index b1181bd..6da5c85 100644 --- a/config.php +++ b/config.php @@ -13,7 +13,7 @@ return [ 'email' => 'hallo@fotobox-moments.local', 'phone' => '+49 170 1234567', 'website' => 'https://fotobox-moments.local', - 'service_area' => 'Musterstadt und Umgebung', + 'service_area' => 'Hannover, Region Hannover, Hameln, Braunschweig, Hildesheim und Celle', 'response_time' => 'Antwort meist innerhalb von 24 Stunden', 'pickup_window' => 'Abholung ab 17:00 Uhr', 'return_window' => 'Rückgabe bis 13:00 Uhr', @@ -42,7 +42,29 @@ return [ 'pricing' => [ 'default_day_rate_cents' => 9999, 'currency' => 'EUR', - 'label' => '99,99 € pro Miettag', + 'label' => 'Abholung ab 99,99 € pro Miettag', + 'delivery_rates' => [ + 'self_pickup' => [ + 'label' => 'Selbstabholung', + 'description' => 'Abholung in Hannover', + 'price_cents' => 9999, + ], + 'hannover' => [ + 'label' => 'Lieferung nach Hannover', + 'description' => 'Lieferung und Aufbau im Stadtgebiet Hannover', + 'price_cents' => 19999, + ], + 'region_hannover' => [ + 'label' => 'Lieferung in die Region Hannover', + 'description' => 'Lieferung und Aufbau in die Region Hannover', + 'price_cents' => 24999, + ], + 'extended_region' => [ + 'label' => 'Lieferung nach Hameln, Braunschweig, Hildesheim oder Celle', + 'description' => 'Lieferung und Aufbau in Hameln, Braunschweig, Hildesheim oder Celle', + 'price_cents' => 29999, + ], + ], ], 'admin' => [ 'username' => 'admin', diff --git a/docs/manual-test.md b/docs/manual-test.md index b5d5470..305f43b 100644 --- a/docs/manual-test.md +++ b/docs/manual-test.md @@ -2,7 +2,7 @@ 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. +3. Im Buchungsformular die Lieferart wechseln und prüfen, ob die Preisberechnung sauber umschaltet: `99,99 €` für Selbstabholung, `199,99 €` für Hannover, `249,99 €` für Region Hannover und `299,99 €` für Hameln, Braunschweig, Hildesheim oder Celle. 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. diff --git a/src/Services/BookingService.php b/src/Services/BookingService.php index 837124f..0cdbe34 100644 --- a/src/Services/BookingService.php +++ b/src/Services/BookingService.php @@ -200,6 +200,79 @@ final class BookingService return array_slice($bookings, 0, 4); } + public function getPublicAvailabilityBookings(): array + { + $today = gmdate('Y-m-d'); + + return array_values(array_filter( + $this->getBookingsByStatuses(['requested', 'reserved', 'confirmed']), + static fn(array $booking): bool => (string) $booking['end_date'] >= $today + )); + } + + public function getPublicCalendarMonths(int $monthCount = 4): array + { + $months = []; + $monthCount = max(1, $monthCount); + $today = gmdate('Y-m-d'); + $bookings = $this->getPublicAvailabilityBookings(); + $monthCursor = (new DateTimeImmutable('first day of this month'))->setTime(0, 0); + + for ($index = 0; $index < $monthCount; $index++) { + $monthStart = $monthCursor->modify('+' . $index . ' month'); + $monthEnd = $monthStart->modify('first day of next month'); + $monthKey = $monthStart->format('Y-m'); + $entries = array_values(array_filter( + $bookings, + fn(array $booking): bool => $this->bookingIntersectsRange( + $booking, + $monthStart->format('Y-m-d'), + $monthEnd->format('Y-m-d') + ) + )); + + $days = []; + for ($blank = 1; $blank < (int) $monthStart->format('N'); $blank++) { + $days[] = ['is_padding' => true]; + } + + $dayCursor = $monthStart; + while ($dayCursor < $monthEnd) { + $date = $dayCursor->format('Y-m-d'); + $dayBooking = $this->findBookingForDate($entries, $date); + + $days[] = [ + 'is_padding' => false, + 'date' => $date, + 'day' => (int) $dayCursor->format('j'), + 'is_today' => $date === $today, + 'is_booked' => $dayBooking !== null, + 'status' => $dayBooking['status'] ?? '', + 'status_label' => $dayBooking['status_label'] ?? '', + ]; + + $dayCursor = $dayCursor->modify('+1 day'); + } + + while (count($days) % 7 !== 0) { + $days[] = ['is_padding' => true]; + } + + $months[] = [ + 'label' => $this->formatMonthLabel($monthKey), + 'month_key' => $monthKey, + 'entry_count' => count($entries), + 'days' => $days, + 'entries' => array_map( + fn(array $booking): array => $this->buildPublicCalendarEntry($booking), + $entries + ), + ]; + } + + return $months; + } + public function getDashboardStats(): array { $bookings = $this->getBookings(); @@ -303,6 +376,7 @@ final class BookingService 'payment_status' => 'unpaid', 'payment_method' => 'invoice_transfer', 'delivery_mode' => 'self_pickup', + 'delivery_zone' => 'self_pickup', ]; } @@ -336,6 +410,20 @@ final class BookingService ]; } + public function getDeliveryModeOptions(): array + { + return [ + 'self_pickup' => 'Selbstabholung', + 'delivery_setup' => 'Lieferung und Aufbau', + 'on_site_support' => 'Lieferung, Aufbau und Vor-Ort-Betreuung', + ]; + } + + public function getDeliveryZoneOptions(): array + { + return $this->config['pricing']['delivery_rates'] ?? []; + } + private function normalizeBookingInput(array $input, bool $adminMode): array { $customerName = trim((string) ($input['customer_name'] ?? '')); @@ -351,11 +439,11 @@ final class BookingService $endDate = trim((string) ($input['end_date'] ?? '')); $paymentMethod = trim((string) ($input['payment_method'] ?? 'invoice_transfer')); $deliveryMode = trim((string) ($input['delivery_mode'] ?? 'self_pickup')); + $deliveryZone = trim((string) ($input['delivery_zone'] ?? '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']); $privacyAccepted = (string) ($input['privacy_accepted'] ?? '') === '1'; $termsAccepted = (string) ($input['terms_accepted'] ?? '') === '1'; @@ -408,11 +496,8 @@ final class BookingService 'paypal' => 'PayPal', ]; - $deliveryLabels = [ - 'self_pickup' => 'Selbstabholung', - 'delivery_setup' => 'Lieferung und Aufbau', - 'on_site_support' => 'Lieferung, Aufbau und Vor-Ort-Betreuung', - ]; + $deliveryLabels = $this->getDeliveryModeOptions(); + $deliveryZones = $this->getDeliveryZoneOptions(); if (!array_key_exists($paymentMethod, $paymentLabels)) { throw new RuntimeException('Die gewählte Zahlungsart ist ungültig.'); @@ -422,6 +507,18 @@ final class BookingService throw new RuntimeException('Die gewählte Lieferart ist ungültig.'); } + if (!array_key_exists($deliveryZone, $deliveryZones)) { + throw new RuntimeException('Bitte wähle ein gültiges Liefergebiet aus.'); + } + + if ($deliveryMode === 'self_pickup') { + $deliveryZone = 'self_pickup'; + } elseif ($deliveryZone === 'self_pickup') { + throw new RuntimeException('Bitte wähle für die Lieferung ein passendes Liefergebiet aus.'); + } + + $pricePerDay = $this->resolveRateForSelection($deliveryMode, $deliveryZone); + if ($pricePerDay < 0) { throw new RuntimeException('Der Tagespreis ist ungültig.'); } @@ -443,6 +540,8 @@ final class BookingService 'payment_method_label' => $paymentLabels[$paymentMethod], 'delivery_mode' => $deliveryMode, 'delivery_mode_label' => $deliveryLabels[$deliveryMode], + 'delivery_zone' => $deliveryZone, + 'delivery_zone_label' => $deliveryZones[$deliveryZone]['label'], 'notes_customer' => $notesCustomer, 'internal_notes' => $internalNotes, 'status' => $status, @@ -472,6 +571,8 @@ final class BookingService 'payment_method_label' => $payload['payment_method_label'], 'delivery_mode' => $payload['delivery_mode'], 'delivery_mode_label' => $payload['delivery_mode_label'], + 'delivery_zone' => $payload['delivery_zone'], + 'delivery_zone_label' => $payload['delivery_zone_label'], 'start_date' => $payload['start_date'], 'end_date' => $payload['end_date'], 'total_days' => $payload['total_days'], @@ -562,6 +663,46 @@ final class BookingService return $prefix . '_' . strtolower(bin2hex(random_bytes(6))); } + private function resolveRateForSelection(string $deliveryMode, string $deliveryZone): int + { + $zones = $this->getDeliveryZoneOptions(); + + if ($deliveryMode === 'self_pickup') { + return (int) ($zones['self_pickup']['price_cents'] ?? $this->config['pricing']['default_day_rate_cents']); + } + + return (int) ($zones[$deliveryZone]['price_cents'] ?? $this->config['pricing']['default_day_rate_cents']); + } + + private function bookingIntersectsRange(array $booking, string $rangeStart, string $rangeEnd): bool + { + return $booking['start_date'] < $rangeEnd && $booking['end_date'] > $rangeStart; + } + + private function findBookingForDate(array $bookings, string $date): ?array + { + foreach ($bookings as $booking) { + if ($booking['start_date'] <= $date && $booking['end_date'] > $date) { + return $booking; + } + } + + return null; + } + + private function buildPublicCalendarEntry(array $booking): array + { + $deliveryLabel = (string) ($booking['delivery_zone_label'] ?: $booking['delivery_mode_label']); + + return [ + 'date_label' => formatDate($booking['start_date']) . ' bis ' . formatDate($booking['end_date']), + 'status' => $booking['status'], + 'status_label' => $booking['status_label'], + 'delivery_label' => $deliveryLabel, + 'day_count_label' => $booking['total_days'] . ' Miettag' . ($booking['total_days'] === 1 ? '' : 'e'), + ]; + } + private function formatMonthLabel(string $monthKey): string { [$year, $month] = explode('-', $monthKey) + [null, null]; diff --git a/src/Support/functions.php b/src/Support/functions.php index 77e3635..11d095f 100644 --- a/src/Support/functions.php +++ b/src/Support/functions.php @@ -64,6 +64,7 @@ function basePath(): string '/admin/login', '/admin/logout', '/admin', + '/fotobox', '/leistungen', '/preise', '/verfuegbarkeit', @@ -185,7 +186,7 @@ function h(string $value): string function formatCurrency(int $cents): string { - return number_format($cents / 100, 2, ',', '.') . ' EUR'; + return number_format($cents / 100, 2, ',', '.') . ' €'; } function selected(string $current, string $expected): string diff --git a/src/bootstrap.php b/src/bootstrap.php index 5003382..ae8751f 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -343,10 +343,16 @@ function publicRoutes(): array 'metaDescription' => 'DSLR-Kamera, Studioblitz, Softbox, WLAN-Download und professioneller Lieferumfang für Ihre Fotobox-Miete.', 'pageKey' => 'leistungen', ], + '/fotobox' => [ + 'view' => 'pages/leistungen', + 'pageTitle' => 'Fotobox und Ausstattung', + 'metaDescription' => 'DSLR-Kamera, Studioblitz, Softbox, WLAN-Download und professioneller Lieferumfang für Ihre Fotobox-Miete.', + 'pageKey' => 'fotobox', + ], '/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.', + 'metaDescription' => 'Selbstabholung ab 99,99 € pro Miettag, Lieferung je nach Zielort. Ein Miettag entspricht einer Übernachtung.', 'pageKey' => 'preise', ], '/verfuegbarkeit' => [ @@ -404,6 +410,7 @@ function renderPublicPage(array $route, BookingService $bookingService, array $c { $company = $config['company']; $bookings = $bookingService->getHighlightedBookings(); + $availabilityBookings = $bookingService->getPublicAvailabilityBookings(); $currentView = (string) $route['view']; render($currentView, [ @@ -413,10 +420,14 @@ function renderPublicPage(array $route, BookingService $bookingService, array $c 'config' => $config, 'company' => $company, 'dayRate' => $config['pricing']['default_day_rate_cents'], + 'deliveryModeOptions' => $bookingService->getDeliveryModeOptions(), + 'deliveryZoneOptions' => $bookingService->getDeliveryZoneOptions(), 'flashSuccess' => flash('success'), 'flashError' => flash('error'), 'old' => flash('old') ?? [], 'bookings' => $bookings, + 'availabilityBookings' => $availabilityBookings, + 'availabilityCalendarMonths' => $bookingService->getPublicCalendarMonths(4), 'trustFacts' => publicTrustFacts($config), 'featureCards' => publicFeatureCards(), 'processSteps' => publicProcessSteps($config), @@ -547,6 +558,8 @@ function renderAdminCreate(BookingService $bookingService): void 'flashError' => flash('error'), 'old' => flash('admin_old') ?? [], 'defaults' => $bookingService->getAdminDefaults(), + 'deliveryModeOptions' => $bookingService->getDeliveryModeOptions(), + 'deliveryZoneOptions' => $bookingService->getDeliveryZoneOptions(), ]); } @@ -574,13 +587,14 @@ function renderAdminOrder(BookingService $bookingService): void function publicTrustFacts(array $config): array { - $company = $config['company']; + $rates = $config['pricing']['delivery_rates']; return [ - ['label' => 'Preis', 'value' => $config['pricing']['label']], - ['label' => 'Mietlogik', 'value' => '1 Miettag = 1 Übernachtung'], + ['label' => 'Abholung', 'value' => formatCurrency((int) $rates['self_pickup']['price_cents']) . ' pro Miettag'], + ['label' => 'Lieferung Hannover', 'value' => formatCurrency((int) $rates['hannover']['price_cents']) . ' pro Miettag'], + ['label' => 'Mietdauer', 'value' => '1 Miettag = 1 Übernachtung'], ['label' => 'Zahlung', 'value' => 'Rechnung, Überweisung oder PayPal'], - ['label' => 'Servicefenster', 'value' => $company['pickup_window'] . ' / ' . $company['return_window']], + ['label' => 'Bilder', 'value' => 'Digitale Bilder inklusive'], ]; } @@ -588,20 +602,20 @@ 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' => 'Spiegelreflexkamera & Studioblitz', + 'text' => 'DSLR-Kamera, Bildschirm und große Softbox sorgen für helle, hochwertige Fotos bei jedem Anlass.', ], [ - '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' => 'WLAN-Download direkt vor Ort', + 'text' => 'Ihre Gäste können Fotos direkt auf das Handy laden und teilen. Nach dem Event erhalten Sie zusätzlich alle Bilder digital.', ], [ - 'title' => 'Lieferung oder Selbstabholung', - 'text' => 'Sie entscheiden zwischen Selbstabholung, Lieferung mit Aufbau oder einem Rundum-Service mit Vor-Ort-Betreuung.', + 'title' => 'Schnell aufgebaut, leicht bedient', + 'text' => 'Die Fotobox ist in wenigen Minuten einsatzbereit und wird bei der Übergabe kurz erklärt.', ], [ - 'title' => 'Saubere Verwaltung im Hintergrund', - 'text' => 'Anfragen, Kundendaten, Rechnungen und Zahlungsstatus werden in einem Verwaltungsbereich zentral gepflegt.', + 'title' => 'Lieferung, Abholung oder Betreuung', + 'text' => 'Sie entscheiden zwischen Selbstabholung, Lieferung mit Aufbau oder Vor-Ort-Unterstützung für Ihr Event.', ], ]; } @@ -612,42 +626,43 @@ function publicProcessSteps(array $config): array return [ [ - 'title' => 'Zeitraum wählen', + 'title' => 'Wunschtermin anfragen', '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' => 'Verfügbarkeit bestätigen lassen', + 'text' => 'Wir prüfen den Termin und melden uns in der Regel innerhalb von 24 Stunden zurück.', ], [ - 'title' => 'Anfrage absenden', - 'text' => 'Wir prüfen Verfügbarkeit, erfassen Ihre Daten und bestätigen den Auftrag persönlich.', + 'title' => 'Fotobox abholen oder liefern lassen', + 'text' => 'Sie wählen Selbstabholung oder Lieferung mit Aufbau passend zu Ihrem Event.', ], [ 'title' => 'Feiern und Bilder erhalten', - 'text' => 'Die Fotobox steht rechtzeitig bereit. Die Rückgabe erfolgt bis ' . $company['return_window'] . '.', + 'text' => 'Die Fotobox steht rechtzeitig bereit und alle Bilder erhalten Sie anschließend digital.', ], ]; } function publicPricingExamples(array $config): array { - $label = $config['pricing']['label']; + $rates = $config['pricing']['delivery_rates']; 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.'], + ['title' => 'Abholung', 'text' => formatCurrency((int) $rates['self_pickup']['price_cents']) . ' pro Miettag'], + ['title' => 'Lieferung nach Hannover', 'text' => formatCurrency((int) $rates['hannover']['price_cents']) . ' pro Miettag'], + ['title' => 'Region Hannover', 'text' => formatCurrency((int) $rates['region_hannover']['price_cents']) . ' pro Miettag'], + ['title' => 'Hameln, Braunschweig, Hildesheim oder Celle', 'text' => formatCurrency((int) $rates['extended_region']['price_cents']) . ' pro Miettag'], ]; } 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.'], + ['title' => 'Hochzeiten', 'text' => 'Für emotionale Erinnerungen und eine entspannte Feier mit Ihren Gästen.'], + ['title' => 'Geburtstage', 'text' => 'Einfach zu bedienen und schnell einsatzbereit für jede Party.'], + ['title' => 'Firmenfeiern', 'text' => 'Mit klarer Abwicklung, Rechnung und professionellem Auftritt.'], + ['title' => 'Jubiläen und Vereinsfeste', 'text' => 'Ideal für Veranstaltungen mit vielen Gästen und unkompliziertem Ablauf.'], ]; } @@ -661,8 +676,12 @@ function publicFaqItems(array $config): array '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' => 'Was kostet die Fotobox je nach Zielort?', + 'answer' => 'Selbstabholung kostet ' . formatCurrency((int) $config['pricing']['delivery_rates']['self_pickup']['price_cents']) . ' pro Miettag. Lieferung nach Hannover kostet ' . formatCurrency((int) $config['pricing']['delivery_rates']['hannover']['price_cents']) . ', in die Region Hannover ' . formatCurrency((int) $config['pricing']['delivery_rates']['region_hannover']['price_cents']) . ' und nach Hameln, Braunschweig, Hildesheim oder Celle ' . formatCurrency((int) $config['pricing']['delivery_rates']['extended_region']['price_cents']) . ' pro Miettag.', + ], + [ + 'question' => 'Wann erhalte ich eine Rückmeldung?', + 'answer' => 'In der Regel erhalten Sie innerhalb von 24 Stunden eine Rückmeldung zur Verfügbarkeit und zum weiteren Ablauf.', ], [ 'question' => 'Welche Zahlungsarten sind möglich?', @@ -672,13 +691,17 @@ function publicFaqItems(array $config): array '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' => 'Wie schnell ist die Fotobox einsatzbereit?', + 'answer' => 'Nach Aufbau und Stromversorgung ist die Fotobox in wenigen Minuten startklar. Bei der Übergabe erhalten Sie zudem eine kurze Einweisung.', + ], [ '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.', + 'question' => 'Kann ich auch auf Rechnung zahlen?', + 'answer' => 'Ja. Auf Wunsch erhalten Sie eine Rechnung mit vollständigen Kundendaten. Alternativ ist auch PayPal möglich.', ], ]; } @@ -686,30 +709,33 @@ function publicFaqItems(array $config): array 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']], + ['title' => 'Im Preis enthalten', 'items' => ['Fotobox mit Stativ', 'Spiegelreflexkamera', 'Studioblitz mit Softbox', 'Alle Bilder digital']], + ['title' => 'Das macht es einfach', 'items' => ['Kurze Einweisung bei der Übergabe', 'Schneller Aufbau', 'WLAN-Download vor Ort', 'Einfache Bedienung per Knopfdruck']], + ['title' => 'Auf Wunsch zusätzlich', 'items' => ['Lieferung und Aufbau', 'Vor-Ort-Betreuung', 'Rechnung für Firmenkunden', 'Persönliche Rücksprache zum Ablauf']], ]; } function publicServiceStandards(array $config): array { $company = $config['company']; + $rates = $config['pricing']['delivery_rates']; 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', + 'Selbstabholung ab ' . formatCurrency((int) $rates['self_pickup']['price_cents']) . ' pro Miettag', + 'Lieferung nach Hannover ab ' . formatCurrency((int) $rates['hannover']['price_cents']) . ' pro Miettag', + 'WLAN-Download direkt auf das Handy Ihrer Gäste', + 'Kurze Einweisung bei der Übergabe', + $company['pickup_window'] . ' · ' . $company['return_window'], + 'Rückmeldung in der Regel innerhalb von 24 Stunden', ]; } 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', + 'Wunschtermin auswählen', + 'Leistungsart, Liefergebiet und Zahlungsart festlegen', + 'Kontaktdaten und Veranstaltungsort eintragen', + 'Anfrage unverbindlich absenden', ]; } diff --git a/views/admin/create.php b/views/admin/create.php index f5ac8f2..25763fa 100644 --- a/views/admin/create.php +++ b/views/admin/create.php @@ -1,4 +1,10 @@
+ $zone) { + $priceRates[$zoneKey] = (int) $zone['price_cents']; + } + ?>

Manuelle Buchung

@@ -14,8 +20,9 @@
-
+ +
Verwaltung @@ -67,10 +74,6 @@ Rückgabedatum - +
+

Preislogik: Abholung , Hannover , Region Hannover , Hameln/Braunschweig/Hildesheim/Celle pro Miettag.

@@ -119,6 +133,10 @@ Mietdauer Noch nicht gewählt
+
+ Preis pro Miettag + +
Gesamtpreis @@ -128,4 +146,3 @@
- diff --git a/views/admin/order.php b/views/admin/order.php index 60ece7e..48237fe 100644 --- a/views/admin/order.php +++ b/views/admin/order.php @@ -28,6 +28,7 @@
Mietzeitraum
bis
Miettage
Leistung
+
Liefergebiet
Zahlungsart
Gesamt
@@ -106,4 +107,3 @@ - diff --git a/views/home.php b/views/home.php index 8d26dec..c5c61de 100644 --- a/views/home.php +++ b/views/home.php @@ -1,15 +1,14 @@
-

Professionelle Fotobox-Vermietung

-

Fotobox mieten für Hochzeiten, Geburtstage und Firmenfeiern.

+

Fotobox-Verleih für

+

Fotobox mieten für Hochzeit, Geburtstag und Firmenfeier.

- Hochwertige Technik, klare Preislogik pro Miettag und ein Buchungsablauf, - der auch kaufmännisch sauber funktioniert. Anfrage senden, Bestätigung erhalten, - Bilder digital bekommen. + Professionelle Fotobox mit Spiegelreflexkamera, Studioblitz und digitaler Bildübergabe. + Lieferung oder Selbstabholung möglich. Schon ab pro Miettag bei Selbstabholung.

@@ -23,8 +22,8 @@
- diff --git a/views/pages/verfuegbarkeit.php b/views/pages/verfuegbarkeit.php index 07123d7..7cc1b22 100644 --- a/views/pages/verfuegbarkeit.php +++ b/views/pages/verfuegbarkeit.php @@ -1,27 +1,91 @@

Verfügbarkeit

-

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

+

Prüfen Sie, ob Ihr Wunschtermin noch frei ist.

- Die Übersicht zeigt aktuelle Belegungen aus dem Verwaltungssystem. - Für Ihren Wunschtermin senden Sie am besten direkt eine Anfrage. + Hier sehen Sie bereits belegte Termine im Monatskalender. + Ist Ihr Wunschtermin noch frei, senden Sie uns direkt Ihre unverbindliche Anfrage.

+
+
+

Buchungskalender

+

Belegte Termine auf einen Blick

+

Die Kalenderansicht zeigt angefragte, reservierte und bestätigte Zeiträume für die nächsten Monate.

+
+
+ Anfrage + Reserviert + Bestätigt +
+
+ +
+
+

+ Termine +
+ +
+ + + + +
+ + + + +
+ + +
+
+ +
+ Derzeit kein belegter Zeitraum. + Dieser Monat ist aktuell noch frei. +
+ + +
+
+ + · +
+ +
+ + +
+
+ +
+
+

Aktuelle Belegung

- +
Momentan gibt es keine festen Einträge. Ihre Anfrage kann direkt neu aufgenommen werden.
- +
- - bis + bis + ·
@@ -29,13 +93,18 @@
-

Direkt zur Anfrage

+

Jetzt unverbindlich anfragen

- Jetzt Termin anfragen +

+ Selbstabholung: · + Hannover: · + Region Hannover: · + Hameln, Braunschweig, Hildesheim oder Celle: +

+ Wunschtermin anfragen
- diff --git a/views/partials/public-booking-form.php b/views/partials/public-booking-form.php index c078d8d..312d090 100644 --- a/views/partials/public-booking-form.php +++ b/views/partials/public-booking-form.php @@ -1,5 +1,9 @@ $zone) { + $priceRates[$zoneKey] = (int) $zone['price_cents']; +} ?>
@@ -10,7 +14,7 @@ $oldData = is_array($old ?? null) ? $old : [];
-
+ @@ -35,15 +39,25 @@ $oldData = is_array($old ?? null) ? $old : [];
Schritt 2 -

Leistung und Zahlung festlegen

+

Paket und Zahlung wählen

+
+

+ Abholung kostet pro Miettag. + Lieferung nach Hannover kostet , + in die Region Hannover und + nach Hameln, Braunschweig, Hildesheim oder Celle pro Miettag. +

Schritt 3 -

Kundendaten erfassen

+

Kontaktdaten und Veranstaltungsort

Preis pro Miettag - +
Voraussichtlicher Gesamtpreis @@ -131,7 +151,7 @@ $oldData = is_array($old ?? null) ? $old : [];
- -

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

+ +

Sie erhalten schnell eine Rückmeldung zur Verfügbarkeit und zum weiteren Ablauf.