Eventupdate
All checks were successful
Deploy / deploy (push) Successful in 1m0s

This commit is contained in:
2026-08-04 22:00:43 +02:00
parent 9cfa26a031
commit ac32727dc6
12 changed files with 584 additions and 230 deletions

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Projektkontext # Papa-Kind-Treff Projektkontext
Stand: 2026-08-03 Stand: 2026-08-04
## Kurzbeschreibung ## Kurzbeschreibung
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind lokale Events, Termine und Treffen, ein Community-Forum, Mitgliederprofile mit optionalen Kinderinfos und ein Moderations-/Adminbereich. Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind lokale Events, Termine und Treffen, ein Community-Forum, Mitgliederprofile mit optionalen Kinderinfos und ein Moderations-/Adminbereich.
@@ -21,10 +21,10 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Wenn ein Geburtsdatum gesetzt ist, wird das Alter automatisch berechnet und später bei Bedarf aktualisiert. - Wenn ein Geburtsdatum gesetzt ist, wird das Alter automatisch berechnet und später bei Bedarf aktualisiert.
- Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert. - Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert.
- Profiladresse kann per Suche oder Browser-Standort übernommen werden. - Profiladresse kann per Suche oder Browser-Standort übernommen werden.
- Im Bereich `Events` gibt es jetzt zunächst drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung. - Im Bereich `Events` gibt es jetzt drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung.
- `Ort` blendet Datum aus und erlaubt stattdessen Öffnungszeiten/Hinweise. - Eigene Events unterstützen Kategorie, optionales Bild, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl und Sichtbarkeit.
- `Sonstige Veranstaltung` unterstützt Uhrzeit und erste Wiederholungsmodelle wie täglich, wöchentlich oder bestimmte Wochentage. - `Ort` nutzt die gleiche Adresslogik wie das Profil, blendet den Event-Termin aus und erlaubt stattdessen flexible Öffnungs-/Zeitfenster, optionale Kontaktangaben und flexible Preisregeln.
- Preisangaben für Orte und sonstige Veranstaltungen werden als strukturierte Einträge für Kinder- und Erwachsenenpreise gespeichert. - `Sonstige Veranstaltung` nutzt ebenfalls die Profil-Adresslogik, unterstützt Wiederholungen, ein Pflichtfeld `Gültig bis`, flexible Preisregeln, Bild-Upload und die Angabe `Mit Kindern`.
## Technischer Rahmen ## Technischer Rahmen
- eigener Front-Controller in `public/index.php` - eigener Front-Controller in `public/index.php`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Projektstruktur # Papa-Kind-Treff Projektstruktur
Stand: 2026-08-03 Stand: 2026-08-04
## Projektordner ## Projektordner
- `config/`: Konfigurationen, inklusive `prod/` und `staging/` - `config/`: Konfigurationen, inklusive `prod/` und `staging/`
@@ -19,7 +19,8 @@ Stand: 2026-08-03
- System-Einstellungen: `src/App/SystemSettings.php` - System-Einstellungen: `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php` - neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
- UI für Eintragstyp-Auswahl im Mitgliederbereich: `partials/landing/account/dashboard.php` - UI für Eintragstyp-Auswahl im Mitgliederbereich: `partials/landing/account/dashboard.php`
- Preis-, Wiederholungs- und Ortssuche für neue Listing-Typen: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php` - Preis-, Wiederholungs-, Bild- und Ortseingabe für neue Listing-Typen: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
- Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `schema.sql` um Kategorie- und Bildfelder
## Dokumentationsregel ## Dokumentationsregel
Kanonische interne Dokumentation: Kanonische interne Dokumentation:

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff # Papa-Kind-Treff
Stand: 2026-08-03 Stand: 2026-08-04
Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Events, Termine und Treffen, Community-Austausch und einen geschützten Mitgliederbereich. Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale Events, Termine und Treffen, Community-Austausch und einen geschützten Mitgliederbereich.
@@ -23,7 +23,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- System-Sektion für Seiten-Admins mit Wartungs- und Diensteschaltern - System-Sektion für Seiten-Admins mit Wartungs- und Diensteschaltern
- neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt - neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt
- Mitgliederbereich `Events` unterstützt jetzt drei Anlagetypen: `Eigenes Event`, `Ort`, `Sonstige Veranstaltung` - Mitgliederbereich `Events` unterstützt jetzt drei Anlagetypen: `Eigenes Event`, `Ort`, `Sonstige Veranstaltung`
- Orte und sonstige Veranstaltungen haben jetzt eine angepasste Eingabemaske mit Ortssuche, serverseitiger Adressvalidierung, Preisfeldern, Öffnungszeiten und Wiederholungen - Eigene Events haben jetzt Kategorie, optionales Bild, klare Kinderangabe, optionale Platzzahl und serverseitig validierte Adressdaten
- Orte und sonstige Veranstaltungen haben jetzt eine angepasste Eingabemaske mit Ortssuche, Browser-Adressübernahme, serverseitiger Adressvalidierung, flexiblen Preisregeln, flexiblen Öffnungs-/Zeitfenstern und Bild-Upload
## Wording-Regel ## Wording-Regel
- Primärbegriff im Produkt: `Events` - Primärbegriff im Produkt: `Events`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Project Context # Papa-Kind-Treff Project Context
Updated: 2026-08-03 Updated: 2026-08-04
## Summary ## Summary
Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events, appointments, and meetups, a community forum, member profiles with optional child information, and a moderation/admin area. Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events, appointments, and meetups, a community forum, member profiles with optional child information, and a moderation/admin area.
@@ -22,9 +22,9 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
- Account email is encrypted application-side and addressed through a separate HMAC lookup hash. - Account email is encrypted application-side and addressed through a separate HMAC lookup hash.
- Profile address can be completed via address search or browser-based location import. - Profile address can be completed via address search or browser-based location import.
- The `Events` area now distinguishes between own events, places, and other time-limited event entries. - The `Events` area now distinguishes between own events, places, and other time-limited event entries.
- `Place` hides date/time and allows opening hours or availability notes instead. - Own events support category, optional image, a clear `with children` yes/no field, optional capacity, and visibility.
- `Other Event` supports time and first recurrence models such as daily, weekly, or selected weekdays. - `Place` uses the same address flow as the profile, hides the event date, and instead supports flexible opening/time slots, optional contact data, and flexible pricing rules.
- Pricing for places and other events is stored in structured child/adult price rows. - `Other Event` also uses the profile-style address flow, supports recurrence, a required `valid until` field, flexible pricing rules, image upload, and a `with children` flag.
## Technical Frame ## Technical Frame
- custom front controller in `public/index.php` - custom front controller in `public/index.php`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Project Structure # Papa-Kind-Treff Project Structure
Updated: 2026-08-03 Updated: 2026-08-04
## Main Directories ## Main Directories
- `config/`: configuration, including `prod/` and `staging/` - `config/`: configuration, including `prod/` and `staging/`
@@ -19,7 +19,8 @@ Updated: 2026-08-03
- system settings: `src/App/SystemSettings.php` - system settings: `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.php` - new listing/place base: `src/App/ListingCatalog.php`
- entry type selector UI in member area: `partials/landing/account/dashboard.php` - entry type selector UI in member area: `partials/landing/account/dashboard.php`
- pricing, recurrence, and place lookup for new listing types: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php` - pricing, recurrence, image upload, and place/address input for new listing types: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
- legacy own events were extended in `src/App/AccountPages.php` and `schema.sql` with category and image fields
## Documentation Rule ## Documentation Rule
- canonical internal documentation lives in `Internal/de/` and `Internal/en/` - canonical internal documentation lives in `Internal/de/` and `Internal/en/`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff # Papa-Kind-Treff
Updated: 2026-08-03 Updated: 2026-08-04
Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, appointments, and meetups, community exchange, and a protected member area. Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, appointments, and meetups, community exchange, and a protected member area.
@@ -23,7 +23,8 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
- system section for site admins with maintenance and service toggles - system section for site admins with maintenance and service toggles
- new data foundation prepared for a broader event, place, and listing system - new data foundation prepared for a broader event, place, and listing system
- member area `Events` now supports three creation types: `Own Event`, `Place`, and `Other Event` - member area `Events` now supports three creation types: `Own Event`, `Place`, and `Other Event`
- places and other events now use adaptive forms with place lookup, server-side address validation, pricing fields, opening hours, and recurrence options - own events now include category, optional image, explicit child suitability, optional capacity, and server-side validated address data
- places and other events now use adaptive forms with place lookup, browser address pickup, server-side address validation, flexible pricing rules, flexible opening/time slots, and image upload
## Core Wording Rule ## Core Wording Rule
- Primary product term: `Events` - Primary product term: `Events`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Start-Doku # Papa-Kind-Treff Start-Doku
Stand: 2026-08-03 Stand: 2026-08-04
Diese Datei ist die zentrale Start-Doku für neue Chats und den schnellen Projekteinstieg. Diese Datei ist die zentrale Start-Doku für neue Chats und den schnellen Projekteinstieg.
@@ -37,7 +37,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
- neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits` - neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`
- Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter - Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter
- Im Mitgliederbereich unter `Events` können Nutzer jetzt zwischen `Eigenes Event`, `Ort` und `sonstiger Veranstaltung` unterscheiden - Im Mitgliederbereich unter `Events` können Nutzer jetzt zwischen `Eigenes Event`, `Ort` und `sonstiger Veranstaltung` unterscheiden
- Für Orte und sonstige Veranstaltungen gibt es jetzt typabhängige Masken mit Ortssuche, Adressprüfung, Preislogik und Wiederholungsoptionen - Für Orte und sonstige Veranstaltungen gibt es jetzt typabhängige Masken mit Ortssuche, Browser-Adressübernahme, Adressprüfung, flexiblen Preisregeln, flexiblen Öffnungs-/Zeitfenstern und Bild-Upload
- Eigene Events unterstützen jetzt zusätzlich Kategorie, Bild-Upload, klare Kinderangabe und optionale Platzzahl
## Datenschutz und Sicherheit ## Datenschutz und Sicherheit
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert - sensible Profilfelder werden app-seitig verschlüsselt gespeichert

View File

@@ -17,7 +17,7 @@ if ($editingEvent && !empty($editEvent['starts_at'])) {
} elseif ($editingListing && !empty($editListing['starts_at'])) { } elseif ($editingListing && !empty($editListing['starts_at'])) {
$startVal = date('Y-m-d\TH:i', strtotime((string)$editListing['starts_at'])); $startVal = date('Y-m-d\TH:i', strtotime((string)$editListing['starts_at']));
} }
$allowNoKidsChecked = $editingEvent ? ((int)$editEvent['allow_kids'] === 0) : false; $withChildrenValue = $editingEvent ? (((int)($editEvent['allow_kids'] ?? 1)) === 1 ? 'yes' : 'no') : (($editingListing && $currentEntryKind === 'editorial_event' && !empty($editListing['supports_registration'])) ? 'yes' : 'yes');
$eventTitle = $editingEvent ? 'Event bearbeiten' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort bearbeiten' : 'Veranstaltung bearbeiten') : 'Neuen Eintrag anlegen'); $eventTitle = $editingEvent ? 'Event bearbeiten' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort bearbeiten' : 'Veranstaltung bearbeiten') : 'Neuen Eintrag anlegen');
$submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort speichern' : 'Veranstaltung speichern') : 'Eintrag anlegen'); $submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort speichern' : 'Veranstaltung speichern') : 'Eintrag anlegen');
$titleValue = $editingEvent ? (string)($editEvent['title'] ?? '') : (string)($editListing['title'] ?? ''); $titleValue = $editingEvent ? (string)($editEvent['title'] ?? '') : (string)($editListing['title'] ?? '');
@@ -31,8 +31,14 @@ $regionValue = $editingEvent ? (string)($editEvent['region'] ?? '') : (string)($
$latValue = $editingEvent ? (string)($editEvent['lat'] ?? '') : (string)($editListing['lat'] ?? ''); $latValue = $editingEvent ? (string)($editEvent['lat'] ?? '') : (string)($editListing['lat'] ?? '');
$lngValue = $editingEvent ? (string)($editEvent['lng'] ?? '') : (string)($editListing['lng'] ?? ''); $lngValue = $editingEvent ? (string)($editEvent['lng'] ?? '') : (string)($editListing['lng'] ?? '');
$visibilityValue = $editingEvent ? (string)($editEvent['visibility'] ?? 'public') : (string)($editListing['visibility'] ?? 'public'); $visibilityValue = $editingEvent ? (string)($editEvent['visibility'] ?? 'public') : (string)($editListing['visibility'] ?? 'public');
$categorySlugValue = $editingListing ? (string)($editListing['category_slug'] ?? '') : ''; $categorySlugValue = $editingEvent ? (string)($editEvent['category_slug'] ?? '') : (string)($editListing['category_slug'] ?? '');
$imagePathValue = $editingEvent ? (string)($editEvent['image_path'] ?? '') : (string)($editListing['image_path'] ?? '');
$capacityValue = $editingEvent ? (string)($editEvent['max_participants'] ?? '') : (string)($editListing['capacity_total'] ?? '');
$phoneValue = $editingListing ? (string)($editListing['phone'] ?? '') : '';
$websiteValue = $editingListing ? (string)($editListing['website_url'] ?? '') : '';
$specialConditionsValue = $editingListing ? (string)($editListing['special_conditions_note'] ?? '') : '';
$openingHoursValue = $editingListing ? (string)($editListing['opening_hours_note'] ?? '') : ''; $openingHoursValue = $editingListing ? (string)($editListing['opening_hours_note'] ?? '') : '';
$openingHoursRows = $editingListing && !empty($editListing['opening_hours']) && is_array($editListing['opening_hours']) ? $editListing['opening_hours'] : [['day_label' => '', 'time_from' => '', 'time_to' => '', 'note' => '']];
$recurrenceRuleValue = $editingListing ? (string)($editListing['recurrence_rule'] ?? '') : ''; $recurrenceRuleValue = $editingListing ? (string)($editListing['recurrence_rule'] ?? '') : '';
$recurrenceUntilValue = ''; $recurrenceUntilValue = '';
if ($editingListing && !empty($editListing['recurrence_until'])) { if ($editingListing && !empty($editListing['recurrence_until'])) {
@@ -57,41 +63,14 @@ if (preg_match('/BYDAY=([A-Z,]+)/', $recurrenceRuleValue, $matches)) {
} }
} }
} }
$priceModeValue = 'none'; $priceRows = $editingListing && !empty($editListing['prices']) && is_array($editListing['prices']) ? $editListing['prices'] : [[
$freeChildUntilAge = ''; 'label' => '',
$priceChildAmount = ''; 'audience' => 'general',
$priceChildAgeFrom = ''; 'price_type' => 'fixed',
$priceChildAgeTo = ''; 'amount' => '',
$priceAdultAmount = ''; 'amount_secondary' => '',
$priceAdultAgeFrom = ''; 'note' => '',
if ($editingListing && !empty($editListing['prices']) && is_array($editListing['prices'])) { ]];
foreach ($editListing['prices'] as $priceRow) {
$audience = (string)($priceRow['audience'] ?? '');
$priceType = (string)($priceRow['price_type'] ?? '');
$note = (string)($priceRow['note'] ?? '');
if ($priceType === 'free') {
$priceModeValue = 'free_child_rule';
if (preg_match('/bis\s+(\d+)/u', $note, $m)) {
$freeChildUntilAge = $m[1];
}
} elseif ($audience === 'child') {
$priceModeValue = 'paid';
$priceChildAmount = (string)($priceRow['amount'] ?? '');
if (preg_match('/ab\s+(\d+)/u', $note, $m)) {
$priceChildAgeFrom = $m[1];
}
if (preg_match('/bis\s+(\d+)/u', $note, $m)) {
$priceChildAgeTo = $m[1];
}
} elseif ($audience === 'adult') {
$priceModeValue = 'paid';
$priceAdultAmount = (string)($priceRow['amount'] ?? '');
if (preg_match('/ab\s+(\d+)/u', $note, $m)) {
$priceAdultAgeFrom = $m[1];
}
}
}
}
$editingChild = isset($editChild) && $editChild !== null; $editingChild = isset($editChild) && $editChild !== null;
$childAction = $editingChild ? 'child_update' : 'child_add'; $childAction = $editingChild ? 'child_update' : 'child_add';
$childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen'; $childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen';
@@ -908,7 +887,7 @@ if (!empty($canManageSystemSettings)) {
<h3><?= htmlspecialchars($eventTitle, ENT_QUOTES) ?></h3> <h3><?= htmlspecialchars($eventTitle, ENT_QUOTES) ?></h3>
<button class="btn ghost" type="button" data-modal-close>✕</button> <button class="btn ghost" type="button" data-modal-close>✕</button>
</div> </div>
<form class="stack gap-12" style="margin-top: 10px;" method="post" action="/dashboard?section=events#events"> <form class="stack gap-12" style="margin-top: 10px;" method="post" action="/dashboard?section=events#events" enctype="multipart/form-data">
<input type="hidden" name="action" value="<?= htmlspecialchars($actionEvent, ENT_QUOTES) ?>"> <input type="hidden" name="action" value="<?= htmlspecialchars($actionEvent, ENT_QUOTES) ?>">
<input type="hidden" name="entry_kind" id="entryKind" value="<?= htmlspecialchars($currentEntryKind, ENT_QUOTES) ?>"> <input type="hidden" name="entry_kind" id="entryKind" value="<?= htmlspecialchars($currentEntryKind, ENT_QUOTES) ?>">
<?php if ($editingEvent && $editEvent): ?> <?php if ($editingEvent && $editEvent): ?>
@@ -942,16 +921,31 @@ if (!empty($canManageSystemSettings)) {
</p> </p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="form-grid" id="titleDescriptionRow"> <div class="stack gap-6">
<div class="stack gap-6"> <label class="label" for="evTitle">Titel</label>
<label class="label" for="evTitle">Titel</label> <input id="evTitle" name="title" class="input" placeholder="z. B. Väter-Kaffee im Park" required value="<?= htmlspecialchars($titleValue, ENT_QUOTES) ?>">
<input id="evTitle" name="title" class="input" placeholder="z. B. Väter-Kaffee im Park" required value="<?= htmlspecialchars($titleValue, ENT_QUOTES) ?>">
</div>
</div> </div>
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="evDesc">Beschreibung (voll)</label> <label class="label" for="evDesc">Beschreibung</label>
<textarea id="evDesc" name="description" class="textarea" rows="3" placeholder="Was soll passieren, was mitbringen?" required><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea> <textarea id="evDesc" name="description" class="textarea" rows="4" placeholder="Was erwartet Familien vor Ort?" required><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea>
<p class="muted small" style="margin:0;">Die Kurzbeschreibung wird intern automatisch aus deinem Text erzeugt.</p> <p class="muted small" style="margin:0;">Die Kurzbeschreibung wird automatisch aus dem Text erzeugt.</p>
</div>
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="listingCategorySlug">Kategorie</label>
<select id="listingCategorySlug" name="category_slug" class="select">
<option value="">Bitte auswählen</option>
<?php foreach ($listingCategories as $category): ?>
<option value="<?= htmlspecialchars((string)$category['slug'], ENT_QUOTES) ?>" <?= $categorySlugValue === (string)$category['slug'] ? 'selected' : '' ?>>
<?= htmlspecialchars((string)$category['title'], ENT_QUOTES) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="stack gap-6">
<label class="label" for="categoryCustomTitle">Oder neue Kategorie</label>
<input id="categoryCustomTitle" name="category_custom_title" class="input" placeholder="z. B. Tierpark">
</div>
</div> </div>
<div class="stack gap-6" id="placeLookupWrap"> <div class="stack gap-6" id="placeLookupWrap">
<label class="label" for="existingPlaceLookup">Vorhandenen Ort suchen (optional)</label> <label class="label" for="existingPlaceLookup">Vorhandenen Ort suchen (optional)</label>
@@ -959,33 +953,36 @@ if (!empty($canManageSystemSettings)) {
<input id="existingPlaceLookup" class="input" list="existingPlacesList" placeholder="z. B. Tierpark, Café, Spielplatz" style="flex:1;"> <input id="existingPlaceLookup" class="input" list="existingPlacesList" placeholder="z. B. Tierpark, Café, Spielplatz" style="flex:1;">
<button class="btn ghost" type="button" id="btnUseExistingPlace">Ort übernehmen</button> <button class="btn ghost" type="button" id="btnUseExistingPlace">Ort übernehmen</button>
</div> </div>
<p class="muted small" style="margin:0;">Wenn ein passender Ort schon existiert, kannst du ihn übernehmen. Falls nichts passt, legst du unten einfach einen neuen Ort an.</p> </div>
<div class="stack gap-6">
<label class="label" for="imageFile">Bild (optional)</label>
<input id="imageFile" name="image_file" class="input" type="file" accept="image/jpeg,image/png,image/webp">
<?php if ($imagePathValue !== ''): ?>
<div class="muted small">Aktuelles Bild: <a href="<?= htmlspecialchars($imagePathValue, ENT_QUOTES) ?>" target="_blank" rel="noopener">Ansehen</a></div>
<?php endif; ?>
</div> </div>
<div class="form-grid" id="eventDateRow"> <div class="form-grid" id="eventDateRow">
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="evDate">Datum & Uhrzeit</label> <label class="label" for="evDate">Start / Termin</label>
<input id="evDate" name="starts_at" class="input" type="datetime-local" required value="<?= htmlspecialchars($startVal, ENT_QUOTES) ?>"> <input id="evDate" name="starts_at" class="input" type="datetime-local" required value="<?= htmlspecialchars($startVal, ENT_QUOTES) ?>">
</div> </div>
<div class="stack gap-6" id="eventLocationLabelWrap"> <div class="stack gap-6" id="eventLocationLabelWrap">
<label class="label" for "evLoc">Ort/Label</label> <label class="label" for="evLoc">Ort-Label</label>
<input id="evLoc" name="location_label" class="input" placeholder="Park / Café" value="<?= htmlspecialchars($locationLabelValue, ENT_QUOTES) ?>"> <input id="evLoc" name="location_label" class="input" placeholder="z. B. Café Sonnenseite" value="<?= htmlspecialchars($locationLabelValue, ENT_QUOTES) ?>">
</div> </div>
</div> </div>
<div class="stack gap-6" id="eventCategoryWrap"> <div class="form-grid" id="eventMetaRow">
<label class="label" for="listingCategorySlug">Kategorie</label> <div class="stack gap-6" id="eventChildrenWrap">
<select id="listingCategorySlug" name="category_slug" class="select"> <label class="label" for="withChildren">Mit Kindern</label>
<option value="">Bitte auswählen</option> <select id="withChildren" name="with_children" class="select">
<?php foreach ($listingCategories as $category): ?> <option value="yes" <?= $withChildrenValue === 'yes' ? 'selected' : '' ?>>Ja</option>
<option value="<?= htmlspecialchars((string)$category['slug'], ENT_QUOTES) ?>" <?= $categorySlugValue === (string)$category['slug'] ? 'selected' : '' ?>> <option value="no" <?= $withChildrenValue === 'no' ? 'selected' : '' ?>>Nein</option>
<?= htmlspecialchars((string)$category['title'], ENT_QUOTES) ?> </select>
</option> </div>
<?php endforeach; ?> <div class="stack gap-6" id="eventCapacityWrap">
</select> <label class="label" for="maxParticipants">Platzzahl (optional)</label>
<p class="muted small" style="margin:0;">Für Orte und sonstige Veranstaltungen, damit Spielplatz, Café, Restaurant, Zoo oder Zirkus sauber unterscheidbar bleiben.</p> <input id="maxParticipants" name="max_participants" class="input" type="number" min="1" value="<?= htmlspecialchars($capacityValue, ENT_QUOTES) ?>" placeholder="leer = offen">
</div> </div>
<div class="stack gap-6" id="placeOpeningHoursWrap">
<label class="label" for="openingHoursNote">Öffnungszeiten / Hinweise</label>
<textarea id="openingHoursNote" name="opening_hours_note" class="textarea" rows="2" placeholder="z. B. täglich 10:00 bis 18:00 Uhr"><?= htmlspecialchars($openingHoursValue, ENT_QUOTES) ?></textarea>
</div> </div>
<div class="stack gap-6" id="eventRecurrenceWrap"> <div class="stack gap-6" id="eventRecurrenceWrap">
<label class="label" for="recurrenceMode">Wiederholung</label> <label class="label" for="recurrenceMode">Wiederholung</label>
@@ -1005,7 +1002,7 @@ if (!empty($canManageSystemSettings)) {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="recurrenceUntil">Wiederholen bis</label> <label class="label" for="recurrenceUntil">Gültig bis</label>
<input id="recurrenceUntil" name="recurrence_until" class="input" type="datetime-local" value="<?= htmlspecialchars($recurrenceUntilValue, ENT_QUOTES) ?>"> <input id="recurrenceUntil" name="recurrence_until" class="input" type="datetime-local" value="<?= htmlspecialchars($recurrenceUntilValue, ENT_QUOTES) ?>">
</div> </div>
</div> </div>
@@ -1032,12 +1029,42 @@ if (!empty($canManageSystemSettings)) {
</div> </div>
<div class="flex gap-8" style="flex-wrap:wrap; align-items:center;"> <div class="flex gap-8" style="flex-wrap:wrap; align-items:center;">
<button class="btn ghost" type="button" id="btnEventAddressSearch">Adresse suchen</button> <button class="btn ghost" type="button" id="btnEventAddressSearch">Adresse suchen</button>
<button class="btn ghost" type="button" id="btnEventBrowserLocation">Browser-Adresse übernehmen</button>
<button class="btn ghost" type="button" id="btnAddrToMap">Adresse auf Karte setzen</button> <button class="btn ghost" type="button" id="btnAddrToMap">Adresse auf Karte setzen</button>
<span class="muted small">Hält Karte und Adresse synchron.</span> <span class="muted small">Hält Karte und Adresse synchron.</span>
</div> </div>
<div id="eventAddressStatus" class="muted small" style="margin:0;"></div> <div id="eventAddressStatus" class="muted small" style="margin:0;"></div>
<input type="hidden" id="evLat" name="lat" value="<?= htmlspecialchars($latValue, ENT_QUOTES) ?>"> <input type="hidden" id="evLat" name="lat" value="<?= htmlspecialchars($latValue, ENT_QUOTES) ?>">
<input type="hidden" id="evLng" name="lng" value="<?= htmlspecialchars($lngValue, ENT_QUOTES) ?>"> <input type="hidden" id="evLng" name="lng" value="<?= htmlspecialchars($lngValue, ENT_QUOTES) ?>">
<div class="stack gap-6" id="placeContactWrap">
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="placePhone">Telefon (optional)</label>
<input id="placePhone" name="phone" class="input" value="<?= htmlspecialchars($phoneValue, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="placeWebsite">Webseite (optional)</label>
<input id="placeWebsite" name="website_url" class="input" type="url" placeholder="https://..." value="<?= htmlspecialchars($websiteValue, ENT_QUOTES) ?>">
</div>
</div>
</div>
<div class="stack gap-6" id="placeOpeningHoursWrap">
<div class="flex between center-y">
<label class="label" for="openingHoursNote">Öffnungszeiten / Zeitfenster</label>
<button class="btn ghost" type="button" id="btnAddOpeningRow">Zeit hinzufügen</button>
</div>
<div id="openingHoursRows" class="stack gap-8">
<?php foreach ($openingHoursRows as $openingRow): ?>
<div class="form-grid opening-row">
<input name="opening_day_label[]" class="input" placeholder="z. B. Montag oder täglich" value="<?= htmlspecialchars((string)($openingRow['day_label'] ?? ''), ENT_QUOTES) ?>">
<input name="opening_time_from[]" class="input" type="time" value="<?= htmlspecialchars((string)($openingRow['time_from'] ?? ''), ENT_QUOTES) ?>">
<input name="opening_time_to[]" class="input" type="time" value="<?= htmlspecialchars((string)($openingRow['time_to'] ?? ''), ENT_QUOTES) ?>">
<input name="opening_note[]" class="input" placeholder="Hinweis" value="<?= htmlspecialchars((string)($openingRow['note'] ?? ''), ENT_QUOTES) ?>">
</div>
<?php endforeach; ?>
</div>
<textarea id="openingHoursNote" name="opening_hours_note" class="textarea" rows="2" placeholder="Freitext-Hinweis zu Öffnungszeiten oder Besonderheiten"><?= htmlspecialchars($openingHoursValue, ENT_QUOTES) ?></textarea>
</div>
<div class="stack gap-6"> <div class="stack gap-6">
<button class="btn ghost" type="button" id="btnMap">Auf Karte suchen</button> <button class="btn ghost" type="button" id="btnMap">Auf Karte suchen</button>
<div id="mapWrapper" class="map-wrapper" hidden> <div id="mapWrapper" class="map-wrapper" hidden>
@@ -1062,48 +1089,45 @@ if (!empty($canManageSystemSettings)) {
</div> </div>
</div> </div>
<div class="stack gap-10" id="eventPricingWrap"> <div class="stack gap-10" id="eventPricingWrap">
<label class="label" for="priceMode">Eintritt / Kosten</label> <div class="flex between center-y">
<select id="priceMode" name="price_mode" class="select"> <label class="label">Eintritt / Kosten</label>
<option value="none" <?= $priceModeValue === 'none' ? 'selected' : '' ?>>Keine Preisangabe</option> <button class="btn ghost" type="button" id="btnAddPriceRow">Preisregel hinzufügen</button>
<option value="free_child_rule" <?= $priceModeValue === 'free_child_rule' ? 'selected' : '' ?>>Frei für Kinder bis zu einem Alter</option>
<option value="paid" <?= $priceModeValue === 'paid' ? 'selected' : '' ?>>Bezahlter Eintritt</option>
</select>
<div class="form-grid" id="freeChildRuleWrap">
<div class="stack gap-6">
<label class="label" for="freeChildUntilAge">Kinder frei bis Alter</label>
<input id="freeChildUntilAge" name="free_child_until_age" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($freeChildUntilAge, ENT_QUOTES) ?>">
</div>
</div> </div>
<div class="stack gap-12" id="paidPricingWrap"> <div id="priceRows" class="stack gap-8">
<div class="form-grid"> <?php foreach ($priceRows as $priceRow): ?>
<div class="stack gap-6"> <div class="stack gap-8 price-row card">
<label class="label" for="priceChildAmount">Preis pro Kind</label> <div class="form-grid">
<input id="priceChildAmount" name="price_child_amount" class="input" inputmode="decimal" placeholder="z. B. 12,50" value="<?= htmlspecialchars($priceChildAmount, ENT_QUOTES) ?>"> <input name="price_label[]" class="input" placeholder="z. B. Kinder 4-7" value="<?= htmlspecialchars((string)($priceRow['label'] ?? ''), ENT_QUOTES) ?>">
<select name="price_audience[]" class="select">
<?php foreach (['general' => 'Allgemein', 'child' => 'Kinder', 'adult' => 'Erwachsene', 'family' => 'Familie', 'group' => 'Gruppe'] as $audienceValue => $audienceLabel): ?>
<option value="<?= htmlspecialchars($audienceValue, ENT_QUOTES) ?>" <?= (string)($priceRow['audience'] ?? 'general') === $audienceValue ? 'selected' : '' ?>><?= htmlspecialchars($audienceLabel, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-grid">
<select name="price_type[]" class="select">
<?php foreach (['free' => 'Frei', 'fixed' => 'Fester Preis', 'from' => 'Preis ab', 'up_to' => 'Preis bis', 'range' => 'Preisspanne', 'request' => 'Auf Anfrage'] as $typeValue => $typeLabel): ?>
<option value="<?= htmlspecialchars($typeValue, ENT_QUOTES) ?>" <?= (string)($priceRow['price_type'] ?? 'fixed') === $typeValue ? 'selected' : '' ?>><?= htmlspecialchars($typeLabel, ENT_QUOTES) ?></option>
<?php endforeach; ?>
</select>
<input name="price_amount[]" class="input" inputmode="decimal" placeholder="Preis" value="<?= htmlspecialchars((string)($priceRow['amount'] ?? ''), ENT_QUOTES) ?>">
</div>
<div class="form-grid">
<input name="price_amount_secondary[]" class="input" inputmode="decimal" placeholder="Zweiter Preis bei Spanne" value="<?= htmlspecialchars((string)($priceRow['amount_secondary'] ?? ''), ENT_QUOTES) ?>">
<input name="price_note[]" class="input" placeholder="Hinweis" value="<?= htmlspecialchars((string)($priceRow['note'] ?? ''), ENT_QUOTES) ?>">
</div>
<div class="form-grid">
<input name="price_age_from[]" class="input" type="number" min="0" max="99" placeholder="Alter von" value="">
<input name="price_age_to[]" class="input" type="number" min="0" max="99" placeholder="Alter bis" value="">
</div>
</div> </div>
<div class="stack gap-6"> <?php endforeach; ?>
<label class="label" for="priceAdultAmount">Preis pro Erwachsener</label> </div>
<input id="priceAdultAmount" name="price_adult_amount" class="input" inputmode="decimal" placeholder="z. B. 18,00" value="<?= htmlspecialchars($priceAdultAmount, ENT_QUOTES) ?>"> <div class="stack gap-6">
</div> <label class="label" for="specialConditionsNote">Sonderkonditionen</label>
</div> <textarea id="specialConditionsNote" name="special_conditions_note" class="textarea" rows="2" placeholder="z. B. Sonderkonditionen für Senioren oder Menschen mit Behinderung vorhanden"><?= htmlspecialchars($specialConditionsValue, ENT_QUOTES) ?></textarea>
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="priceChildAgeFrom">Kind ab Alter</label>
<input id="priceChildAgeFrom" name="price_child_age_from" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($priceChildAgeFrom, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="priceChildAgeTo">Kind bis Alter</label>
<input id="priceChildAgeTo" name="price_child_age_to" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($priceChildAgeTo, ENT_QUOTES) ?>">
</div>
</div>
<div class="stack gap-6">
<label class="label" for="priceAdultAgeFrom">Erwachsener ab Alter</label>
<input id="priceAdultAgeFrom" name="price_adult_age_from" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($priceAdultAgeFrom, ENT_QUOTES) ?>">
</div>
</div> </div>
</div> </div>
<label class="label" id="eventKidsWrap" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="allow_kids" <?= $allowNoKidsChecked ? 'checked' : '' ?>> Event ohne Kinder
</label>
<div class="flex gap-12"> <div class="flex gap-12">
<button class="btn ghost" type="button" data-modal-close>Abbrechen</button> <button class="btn ghost" type="button" data-modal-close>Abbrechen</button>
<button class="btn" type="submit"><?= htmlspecialchars($submitLabel, ENT_QUOTES) ?></button> <button class="btn" type="submit"><?= htmlspecialchars($submitLabel, ENT_QUOTES) ?></button>
@@ -1140,6 +1164,7 @@ if (!empty($canManageSystemSettings)) {
const btnMapSearch = document.getElementById('btnMapSearch'); const btnMapSearch = document.getElementById('btnMapSearch');
const btnAddrToMap = document.getElementById('btnAddrToMap'); const btnAddrToMap = document.getElementById('btnAddrToMap');
const btnEventAddressSearch = document.getElementById('btnEventAddressSearch'); const btnEventAddressSearch = document.getElementById('btnEventAddressSearch');
const btnEventBrowserLocation = document.getElementById('btnEventBrowserLocation');
const eventAddressStatus = document.getElementById('eventAddressStatus'); const eventAddressStatus = document.getElementById('eventAddressStatus');
const mapSearch = document.getElementById('mapSearch'); const mapSearch = document.getElementById('mapSearch');
const latInput = document.getElementById('evLat'); const latInput = document.getElementById('evLat');
@@ -1149,19 +1174,21 @@ if (!empty($canManageSystemSettings)) {
const entryKindButtons = document.querySelectorAll('[data-entry-kind-choice]'); const entryKindButtons = document.querySelectorAll('[data-entry-kind-choice]');
const eventDateRow = document.getElementById('eventDateRow'); const eventDateRow = document.getElementById('eventDateRow');
const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap'); const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap');
const eventCategoryWrap = document.getElementById('eventCategoryWrap'); const eventChildrenWrap = document.getElementById('eventChildrenWrap');
const eventKidsWrap = document.getElementById('eventKidsWrap'); const eventCapacityWrap = document.getElementById('eventCapacityWrap');
const eventPricingWrap = document.getElementById('eventPricingWrap'); const eventPricingWrap = document.getElementById('eventPricingWrap');
const placeContactWrap = document.getElementById('placeContactWrap');
const placeLookupWrap = document.getElementById('placeLookupWrap'); const placeLookupWrap = document.getElementById('placeLookupWrap');
const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap'); const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap');
const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap'); const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap');
const eventDateInput = document.getElementById('evDate'); const eventDateInput = document.getElementById('evDate');
const categorySelect = document.getElementById('listingCategorySlug'); const categorySelect = document.getElementById('listingCategorySlug');
const priceModeSelect = document.getElementById('priceMode');
const freeChildRuleWrap = document.getElementById('freeChildRuleWrap');
const paidPricingWrap = document.getElementById('paidPricingWrap');
const recurrenceModeSelect = document.getElementById('recurrenceMode'); const recurrenceModeSelect = document.getElementById('recurrenceMode');
const recurrenceWeekdaysWrap = document.getElementById('recurrenceWeekdaysWrap'); const recurrenceWeekdaysWrap = document.getElementById('recurrenceWeekdaysWrap');
const openingHoursRows = document.getElementById('openingHoursRows');
const priceRows = document.getElementById('priceRows');
const btnAddOpeningRow = document.getElementById('btnAddOpeningRow');
const btnAddPriceRow = document.getElementById('btnAddPriceRow');
const existingPlaceLookup = document.getElementById('existingPlaceLookup'); const existingPlaceLookup = document.getElementById('existingPlaceLookup');
const btnUseExistingPlace = document.getElementById('btnUseExistingPlace'); const btnUseExistingPlace = document.getElementById('btnUseExistingPlace');
const streetInput = document.getElementById('evStreet'); const streetInput = document.getElementById('evStreet');
@@ -1225,23 +1252,17 @@ if (!empty($canManageSystemSettings)) {
if (eventDateRow) eventDateRow.hidden = kind === 'place'; if (eventDateRow) eventDateRow.hidden = kind === 'place';
if (eventDateInput) eventDateInput.required = kind !== 'place'; if (eventDateInput) eventDateInput.required = kind !== 'place';
if (eventLocationLabelWrap) eventLocationLabelWrap.hidden = kind !== 'event'; if (eventLocationLabelWrap) eventLocationLabelWrap.hidden = kind !== 'event';
if (eventCategoryWrap) eventCategoryWrap.hidden = kind === 'event'; if (categorySelect) categorySelect.required = true;
if (categorySelect) categorySelect.required = kind !== 'event'; if (eventChildrenWrap) eventChildrenWrap.hidden = kind === 'place';
if (eventKidsWrap) eventKidsWrap.hidden = kind !== 'event'; if (eventCapacityWrap) eventCapacityWrap.hidden = kind !== 'event';
if (eventPricingWrap) eventPricingWrap.hidden = kind === 'event'; if (eventPricingWrap) eventPricingWrap.hidden = kind === 'event';
if (placeLookupWrap) placeLookupWrap.hidden = kind === 'event'; if (placeLookupWrap) placeLookupWrap.hidden = kind === 'event';
if (placeOpeningHoursWrap) placeOpeningHoursWrap.hidden = kind !== 'place'; if (placeContactWrap) placeContactWrap.hidden = kind === 'event';
if (placeOpeningHoursWrap) placeOpeningHoursWrap.hidden = kind === 'event';
if (eventRecurrenceWrap) eventRecurrenceWrap.hidden = kind !== 'editorial_event'; if (eventRecurrenceWrap) eventRecurrenceWrap.hidden = kind !== 'editorial_event';
syncPriceModeVisibility();
syncRecurrenceVisibility(); syncRecurrenceVisibility();
} }
function syncPriceModeVisibility() {
const mode = priceModeSelect?.value || 'none';
if (freeChildRuleWrap) freeChildRuleWrap.hidden = mode !== 'free_child_rule';
if (paidPricingWrap) paidPricingWrap.hidden = mode !== 'paid';
}
function syncRecurrenceVisibility() { function syncRecurrenceVisibility() {
const mode = recurrenceModeSelect?.value || 'single'; const mode = recurrenceModeSelect?.value || 'single';
if (recurrenceWeekdaysWrap) recurrenceWeekdaysWrap.hidden = mode !== 'custom_weekdays'; if (recurrenceWeekdaysWrap) recurrenceWeekdaysWrap.hidden = mode !== 'custom_weekdays';
@@ -1252,13 +1273,63 @@ if (!empty($canManageSystemSettings)) {
syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event'); syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event');
}); });
}); });
priceModeSelect?.addEventListener('change', syncPriceModeVisibility);
recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility); recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility);
syncEventEntryKind(entryKindInput?.value || 'event'); syncEventEntryKind(entryKindInput?.value || 'event');
syncPriceModeVisibility();
syncRecurrenceVisibility(); syncRecurrenceVisibility();
function createOpeningRow() {
const row = document.createElement('div');
row.className = 'form-grid opening-row';
row.innerHTML = `
<input name="opening_day_label[]" class="input" placeholder="z. B. Montag oder täglich">
<input name="opening_time_from[]" class="input" type="time">
<input name="opening_time_to[]" class="input" type="time">
<input name="opening_note[]" class="input" placeholder="Hinweis">
`;
openingHoursRows?.appendChild(row);
}
function createPriceRow() {
const row = document.createElement('div');
row.className = 'stack gap-8 price-row card';
row.innerHTML = `
<div class="form-grid">
<input name="price_label[]" class="input" placeholder="z. B. Kinder 4-7">
<select name="price_audience[]" class="select">
<option value="general">Allgemein</option>
<option value="child">Kinder</option>
<option value="adult">Erwachsene</option>
<option value="family">Familie</option>
<option value="group">Gruppe</option>
</select>
</div>
<div class="form-grid">
<select name="price_type[]" class="select">
<option value="free">Frei</option>
<option value="fixed">Fester Preis</option>
<option value="from">Preis ab</option>
<option value="up_to">Preis bis</option>
<option value="range">Preisspanne</option>
<option value="request">Auf Anfrage</option>
</select>
<input name="price_amount[]" class="input" inputmode="decimal" placeholder="Preis">
</div>
<div class="form-grid">
<input name="price_amount_secondary[]" class="input" inputmode="decimal" placeholder="Zweiter Preis bei Spanne">
<input name="price_note[]" class="input" placeholder="Hinweis">
</div>
<div class="form-grid">
<input name="price_age_from[]" class="input" type="number" min="0" max="99" placeholder="Alter von">
<input name="price_age_to[]" class="input" type="number" min="0" max="99" placeholder="Alter bis">
</div>
`;
priceRows?.appendChild(row);
}
btnAddOpeningRow?.addEventListener('click', createOpeningRow);
btnAddPriceRow?.addEventListener('click', createPriceRow);
function applyExistingPlace(place) { function applyExistingPlace(place) {
if (!place) return; if (!place) return;
if (document.getElementById('evTitle') && !document.getElementById('evTitle').value.trim()) { if (document.getElementById('evTitle') && !document.getElementById('evTitle').value.trim()) {
@@ -1490,6 +1561,34 @@ if (!empty($canManageSystemSettings)) {
}); });
}); });
btnEventBrowserLocation?.addEventListener('click', () => {
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
alert('Für Browser-Standort und Adressübernahme bitte zuerst die externen Dienste in den Cookie-Einstellungen erlauben.');
window.PKTConsent?.openPreferences?.();
return;
}
if (!navigator.geolocation) {
alert('Dein Browser unterstützt keine Standortabfrage.');
return;
}
if (eventAddressStatus) eventAddressStatus.textContent = 'Browser-Standort wird abgerufen ...';
navigator.geolocation.getCurrentPosition((position) => {
reverseGeocodeProfileAddress(position.coords.latitude, position.coords.longitude, (data) => {
const normalized = normalizeGeoResult(data);
if (streetInput && normalized.street) streetInput.value = normalized.street;
if (zipInput && normalized.zip) zipInput.value = normalized.zip;
if (cityInput && normalized.city) cityInput.value = normalized.city;
if (regionInput) regionInput.value = normalized.region;
if (latInput) latInput.value = Number.isNaN(normalized.lat) ? '' : normalized.lat.toFixed(7);
if (lngInput) lngInput.value = Number.isNaN(normalized.lng) ? '' : normalized.lng.toFixed(7);
if (eventAddressStatus) eventAddressStatus.textContent = `Browser-Adresse übernommen: ${normalized.label || [normalized.street, `${normalized.zip} ${normalized.city}`.trim(), normalized.region].filter(Boolean).join(', ')}`;
});
}, () => {
if (eventAddressStatus) eventAddressStatus.textContent = '';
alert('Der Browser-Standort konnte nicht abgerufen werden.');
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 });
});
(function(){ (function(){
const setProfileStatus = (message = '') => { const setProfileStatus = (message = '') => {
if (!profileAddressStatus) return; if (!profileAddressStatus) return;

View File

@@ -9,7 +9,7 @@ $clientCookie = $config->cookiePrefix() . 'client';
<main class="container section legal-page"> <main class="container section legal-page">
<div class="content-card content-card--narrow page-copy"> <div class="content-card content-card--narrow page-copy">
<h1>Datenschutz &amp; Cookies</h1> <h1>Datenschutz &amp; Cookies</h1>
<p class="muted small">Stand: 31. Juli 2026</p> <p class="muted small">Stand: 4. August 2026</p>
<p> <p>
Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff
@@ -56,7 +56,7 @@ $clientCookie = $config->cookiePrefix() . 'client';
Wenn du ein Konto anlegst oder den Mitgliederbereich nutzt, verarbeiten wir die dafür notwendigen Daten, Wenn du ein Konto anlegst oder den Mitgliederbereich nutzt, verarbeiten wir die dafür notwendigen Daten,
insbesondere E-Mail-Adresse, Passwort-Hash, Verifikationsstatus sowie die von dir gepflegten Profilangaben. insbesondere E-Mail-Adresse, Passwort-Hash, Verifikationsstatus sowie die von dir gepflegten Profilangaben.
Dazu können Anzeigename, Name, Adresse, Ort, Sprachen, Kurzbeschreibung, optionale Kinderangaben, Dazu können Anzeigename, Name, Adresse, Ort, Sprachen, Kurzbeschreibung, optionale Kinderangaben,
eigene Events, Event-Teilnahmen und Community-Inhalte gehören. eigene Events, Orte, Veranstaltungen, hochgeladene Eintragsbilder, Event-Teilnahmen und Community-Inhalte gehören.
</p> </p>
<p> <p>
Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der
@@ -153,9 +153,9 @@ $clientCookie = $config->cookiePrefix() . 'client';
damit dir passende Events, Termine und Treffen in deiner Nähe angezeigt werden können. damit dir passende Events, Termine und Treffen in deiner Nähe angezeigt werden können.
</p> </p>
<p> <p>
Zusätzlich kannst du im Mitgliederbereich deine Profiladresse über die gleichen externen Adressdienste Zusätzlich kannst du im Mitgliederbereich deine Profiladresse sowie Adressen für eigene Events, Orte und
lokalisieren lassen, die auch für Event-Adressen genutzt werden. Dabei kannst du entweder nach passenden Veranstaltungen über die gleichen externen Adressdienste lokalisieren lassen. Dabei kannst du entweder nach
Adress-Treffern suchen oder die vom Browser ermittelte Position in eine Profiladresse übernehmen. passenden Adress-Treffern suchen oder die vom Browser ermittelte Position in eine Adresse übernehmen.
In beiden Fällen werden die von dir eingegebenen oder vom Browser bereitgestellten Positionsdaten In beiden Fällen werden die von dir eingegebenen oder vom Browser bereitgestellten Positionsdaten
an den jeweiligen Geocoding-Dienst übermittelt. an den jeweiligen Geocoding-Dienst übermittelt.
</p> </p>

View File

@@ -79,6 +79,8 @@ CREATE TABLE events (
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL, teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
category_slug VARCHAR(120) NULL,
image_path VARCHAR(255) NULL,
location_label VARCHAR(180) NULL, location_label VARCHAR(180) NULL,
street VARCHAR(180) NULL, street VARCHAR(180) NULL,
zip CHAR(5) NULL, zip CHAR(5) NULL,
@@ -149,6 +151,7 @@ CREATE TABLE listing_places (
phone VARCHAR(60) NULL, phone VARCHAR(60) NULL,
place_kind VARCHAR(80) NULL, place_kind VARCHAR(80) NULL,
opening_hours_note TEXT NULL, opening_hours_note TEXT NULL,
opening_hours_json LONGTEXT NULL,
provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual', provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual',
external_place_id VARCHAR(190) NULL, external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL, google_place_id VARCHAR(190) NULL,
@@ -173,6 +176,8 @@ CREATE TABLE listings (
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL, teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
special_conditions_note TEXT NULL,
visibility ENUM('public','members') NOT NULL DEFAULT 'public', visibility ENUM('public','members') NOT NULL DEFAULT 'public',
status ENUM('draft','published','cancelled','archived') NOT NULL DEFAULT 'draft', status ENUM('draft','published','cancelled','archived') NOT NULL DEFAULT 'draft',
supports_registration TINYINT(1) NOT NULL DEFAULT 0, supports_registration TINYINT(1) NOT NULL DEFAULT 0,

View File

@@ -189,6 +189,9 @@ final class AccountPages
if ($listingCatalog) { if ($listingCatalog) {
$listingCatalog->ensureSchema(); $listingCatalog->ensureSchema();
} }
if ($pdo) {
self::ensureLegacyEventSchema($pdo);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
@@ -378,6 +381,8 @@ final class AccountPages
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
} }
$payload = $_POST; $payload = $_POST;
$existingListingId = $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : 0;
$existingListing = $existingListingId > 0 ? $listingCatalog->getDashboardEntry($userId, $existingListingId) : null;
$street = trim((string)($_POST['street'] ?? '')); $street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? '')); $zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? '')); $city = trim((string)($_POST['city'] ?? ''));
@@ -404,6 +409,7 @@ final class AccountPages
$payload['lat'] = $resolvedAddress['lat']; $payload['lat'] = $resolvedAddress['lat'];
$payload['lng'] = $resolvedAddress['lng']; $payload['lng'] = $resolvedAddress['lng'];
} }
$payload['image_path'] = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingListing) ? (string)($existingListing['image_path'] ?? '') : '');
$payload['teaser'] = self::generateTeaser( $payload['teaser'] = self::generateTeaser(
trim((string)($payload['description'] ?? '')), trim((string)($payload['description'] ?? '')),
trim((string)($payload['title'] ?? '')) trim((string)($payload['title'] ?? ''))
@@ -415,27 +421,59 @@ final class AccountPages
); );
$info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.'; $info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.';
} else { } else {
$existingEvent = null;
if ($action === 'event_update') {
$existingEventId = (int)($_POST['event_id'] ?? 0);
if ($existingEventId > 0) {
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid LIMIT 1');
$stmt?->execute(['id' => $existingEventId, 'uid' => $userId]);
$existingEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
}
}
$street = trim((string)($_POST['street'] ?? '')); $street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? '')); $zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? '')); $city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? '')); $region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null; $lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null; $lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$needsGeocode = ($lat === null || $lng === null || $region === ''); $hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
if ($needsGeocode) { if ($hasAddress) {
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region); $resolvedAddress = null;
if ($lat === null) { $lat = $geoLat; } if ($lat !== null && $lng !== null) {
if ($lng === null) { $lng = $geoLng; } $resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
if ($region === '' && $geoRegion) { $region = $geoRegion; } }
if ($resolvedAddress === null) {
$searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5);
if (!$searchResults) {
throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.');
}
$resolvedAddress = $searchResults[0];
}
$street = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street;
$zip = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip;
$city = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city;
$region = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region;
$lat = $resolvedAddress['lat'];
$lng = $resolvedAddress['lng'];
}
$imagePath = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingEvent) ? (string)($existingEvent['image_path'] ?? '') : '');
$allowKids = ((string)($_POST['with_children'] ?? 'yes')) === 'yes' ? 1 : 0;
$capacity = isset($_POST['max_participants']) && $_POST['max_participants'] !== '' ? (int)$_POST['max_participants'] : null;
$categorySlug = trim((string)($_POST['category_slug'] ?? ''));
$customCategoryTitle = trim((string)($_POST['category_custom_title'] ?? ''));
if ($customCategoryTitle !== '') {
$categorySlug = self::slugifyValue($customCategoryTitle);
} }
if ($action === 'event_add') { if ($action === 'event_add') {
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())'); $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, category_slug, image_path, location_label, street, zip, city, region, lat, lng, starts_at, max_participants, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :categorySlug, :imagePath, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :capacity, :allow, :vis, :status, NOW(), NOW())');
$stmt?->execute([ $stmt?->execute([
'uid' => $userId, 'uid' => $userId,
'title' => trim((string)$_POST['title']), 'title' => trim((string)$_POST['title']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']), 'descr' => trim((string)$_POST['description']),
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'loc' => trim((string)$_POST['location_label']), 'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null, 'street' => $street ?: null,
'zip' => $zip, 'zip' => $zip,
@@ -444,7 +482,8 @@ final class AccountPages
'lat' => $lat, 'lat' => $lat,
'lng' => $lng, 'lng' => $lng,
'start' => $_POST['starts_at'] ?? null, 'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1, 'capacity' => $capacity,
'allow' => $allowKids,
'vis' => $_POST['visibility'] ?? 'public', 'vis' => $_POST['visibility'] ?? 'public',
'status' => 'published', 'status' => 'published',
]); ]);
@@ -458,13 +497,15 @@ final class AccountPages
} }
} else { } else {
$eventId = (int)($_POST['event_id'] ?? 0); $eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid'); $stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, category_slug=:categorySlug, image_path=:imagePath, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, max_participants=:capacity, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid');
$stmt?->execute([ $stmt?->execute([
'id' => $eventId, 'id' => $eventId,
'uid' => $userId, 'uid' => $userId,
'title' => trim((string)$_POST['title']), 'title' => trim((string)$_POST['title']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']), 'descr' => trim((string)$_POST['description']),
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'loc' => trim((string)$_POST['location_label']), 'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null, 'street' => $street ?: null,
'zip' => $zip, 'zip' => $zip,
@@ -473,7 +514,8 @@ final class AccountPages
'lat' => $lat, 'lat' => $lat,
'lng' => $lng, 'lng' => $lng,
'start' => $_POST['starts_at'] ?? null, 'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1, 'capacity' => $capacity,
'allow' => $allowKids,
'vis' => $_POST['visibility'] ?? 'public', 'vis' => $_POST['visibility'] ?? 'public',
]); ]);
$info = 'Event aktualisiert.'; $info = 'Event aktualisiert.';
@@ -481,7 +523,7 @@ final class AccountPages
} }
} elseif ($action === 'event_delete') { } elseif ($action === 'event_delete') {
$eventId = (int)($_POST['event_id'] ?? 0); $eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1'); $stmt = $pdo?->prepare('SELECT id, created_by, status, image_path, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]); $stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC); $ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) { if (!$ev || (int)$ev['created_by'] !== $userId) {
@@ -490,6 +532,7 @@ final class AccountPages
if ((int)$ev['participant_count'] > 0) { if ((int)$ev['participant_count'] > 0) {
throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.'); throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.');
} }
self::deleteStoredImage((string)($ev['image_path'] ?? ''));
$pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]); $pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]);
$info = 'Event gelöscht.'; $info = 'Event gelöscht.';
} elseif ($action === 'event_cancel') { } elseif ($action === 'event_cancel') {
@@ -510,6 +553,10 @@ final class AccountPages
if (!$listingCatalog) { if (!$listingCatalog) {
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
} }
$entry = $listingCatalog->getDashboardEntry($userId, $listingId);
if (is_array($entry)) {
self::deleteStoredImage((string)($entry['image_path'] ?? ''));
}
$listingCatalog->deleteDashboardEntry($userId, $listingId); $listingCatalog->deleteDashboardEntry($userId, $listingId);
$info = 'Eintrag gelöscht.'; $info = 'Eintrag gelöscht.';
} elseif ($action === 'community_admin_apply') { } elseif ($action === 'community_admin_apply') {
@@ -638,7 +685,7 @@ final class AccountPages
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : []; $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : [];
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : []; $placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
$stmt = $pdo?->prepare( $stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng, 'SELECT e.id, e.title, e.teaser_public, e.description, e.category_slug, e.image_path, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.max_participants, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e FROM events e
WHERE e.created_by = :id AND e.starts_at >= NOW() WHERE e.created_by = :id AND e.starts_at >= NOW()
@@ -648,7 +695,7 @@ final class AccountPages
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; $eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$stmt = $pdo?->prepare( $stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status, 'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e FROM events e
WHERE e.created_by = :id AND e.starts_at < NOW() WHERE e.created_by = :id AND e.starts_at < NOW()
@@ -845,6 +892,87 @@ final class AccountPages
return $results; return $results;
} }
private static function ensureLegacyEventSchema(\PDO $pdo): void
{
$columns = [
'category_slug' => 'ALTER TABLE events ADD COLUMN category_slug VARCHAR(120) NULL AFTER description',
'image_path' => 'ALTER TABLE events ADD COLUMN image_path VARCHAR(255) NULL AFTER category_slug',
];
foreach ($columns as $column => $sql) {
$stmt = $pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = "events" AND column_name = :column LIMIT 1');
$stmt->execute(['column' => $column]);
if (!$stmt->fetchColumn()) {
$pdo->exec($sql);
}
}
}
private static function storeUploadedImage(mixed $file, string $currentPath = ''): string
{
if (!is_array($file) || (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return $currentPath;
}
if ((int)($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
throw new \RuntimeException('Der Bild-Upload konnte nicht verarbeitet werden.');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new \RuntimeException('Die hochgeladene Datei ist ungültig.');
}
if ((int)($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new \RuntimeException('Bitte ein Bild mit maximal 5 MB hochladen.');
}
$imageInfo = @getimagesize($tmpName);
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
throw new \RuntimeException('Bitte eine gültige Bilddatei hochladen.');
}
$mime = (string)$imageInfo['mime'];
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => '',
};
if ($extension === '') {
throw new \RuntimeException('Erlaubt sind JPG, PNG oder WebP.');
}
$uploadDir = dirname(__DIR__, 2) . '/public/uploads/listings';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true) && !is_dir($uploadDir)) {
throw new \RuntimeException('Der Upload-Ordner konnte nicht erstellt werden.');
}
$filename = 'listing-' . date('Ymd-His') . '-' . bin2hex(random_bytes(6)) . '.' . $extension;
$targetPath = $uploadDir . '/' . $filename;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new \RuntimeException('Das Bild konnte nicht gespeichert werden.');
}
self::deleteStoredImage($currentPath);
return '/uploads/listings/' . $filename;
}
private static function deleteStoredImage(string $path): void
{
$path = trim($path);
if ($path === '' || !str_starts_with($path, '/uploads/listings/')) {
return;
}
$absolutePath = dirname(__DIR__, 2) . '/public' . $path;
if (is_file($absolutePath)) {
@unlink($absolutePath);
}
}
private static function slugifyValue(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: '';
return trim($value, '-');
}
private static function reverseGeocodeAddress(float $lat, float $lng): ?array private static function reverseGeocodeAddress(float $lat, float $lng): ?array
{ {
$url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([ $url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([

View File

@@ -39,6 +39,8 @@ final class ListingCatalog
website_url VARCHAR(255) NULL, website_url VARCHAR(255) NULL,
phone VARCHAR(60) NULL, phone VARCHAR(60) NULL,
place_kind VARCHAR(80) NULL, place_kind VARCHAR(80) NULL,
opening_hours_note TEXT NULL,
opening_hours_json LONGTEXT NULL,
provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual", provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual",
external_place_id VARCHAR(190) NULL, external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL, google_place_id VARCHAR(190) NULL,
@@ -62,6 +64,8 @@ final class ListingCatalog
title VARCHAR(200) NOT NULL, title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL, teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
special_conditions_note TEXT NULL,
visibility ENUM("public","members") NOT NULL DEFAULT "public", visibility ENUM("public","members") NOT NULL DEFAULT "public",
status ENUM("draft","published","cancelled","archived") NOT NULL DEFAULT "draft", status ENUM("draft","published","cancelled","archived") NOT NULL DEFAULT "draft",
supports_registration TINYINT(1) NOT NULL DEFAULT 0, supports_registration TINYINT(1) NOT NULL DEFAULT 0,
@@ -145,6 +149,9 @@ final class ListingCatalog
$columnStatements = [ $columnStatements = [
'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind', 'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind',
'listing_places.opening_hours_json' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_json LONGTEXT NULL AFTER opening_hours_note',
'listings.image_path' => 'ALTER TABLE listings ADD COLUMN image_path VARCHAR(255) NULL AFTER description',
'listings.special_conditions_note' => 'ALTER TABLE listings ADD COLUMN special_conditions_note TEXT NULL AFTER image_path',
]; ];
foreach ($columnStatements as $key => $sql) { foreach ($columnStatements as $key => $sql) {
[$table, $column] = explode('.', $key, 2); [$table, $column] = explode('.', $key, 2);
@@ -214,7 +221,7 @@ final class ListingCatalog
{ {
$this->ensureSchema(); $this->ensureSchema();
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT l.id, l.listing_type, l.title, l.teaser_public, l.visibility, l.status, 'SELECT l.id, l.listing_type, l.title, l.teaser_public, l.visibility, l.status, l.image_path,
lp.title AS place_title, lp.city, lp.region, lp.place_kind, lp.title AS place_title, lp.city, lp.region, lp.place_kind,
lo.starts_at, lo.ends_at, lo.occurrence_type, lo.starts_at, lo.ends_at, lo.occurrence_type,
lc.title AS category_title, lc.slug AS category_slug lc.title AS category_title, lc.slug AS category_slug
@@ -241,9 +248,9 @@ final class ListingCatalog
{ {
$this->ensureSchema(); $this->ensureSchema();
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT l.*, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.opening_hours_note, 'SELECT l.*, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json,
lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type, lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type,
lo.recurrence_rule, lo.recurrence_until, lo.recurrence_rule, lo.recurrence_until, lo.capacity_total,
lc.slug AS category_slug lc.slug AS category_slug
FROM listings l FROM listings l
LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_places lp ON lp.id = l.primary_place_id
@@ -262,6 +269,7 @@ final class ListingCatalog
return null; return null;
} }
$row['prices'] = $this->listPrices((int)$row['id']); $row['prices'] = $this->listPrices((int)$row['id']);
$row['opening_hours'] = $this->decodeJsonRows((string)($row['opening_hours_json'] ?? ''));
return $row; return $row;
} }
@@ -292,22 +300,28 @@ final class ListingCatalog
$teaser = trim((string)($data['teaser'] ?? '')); $teaser = trim((string)($data['teaser'] ?? ''));
$description = trim((string)($data['description'] ?? '')); $description = trim((string)($data['description'] ?? ''));
$openingHoursNote = trim((string)($data['opening_hours_note'] ?? '')); $openingHoursNote = trim((string)($data['opening_hours_note'] ?? ''));
$openingHoursRows = $this->normalizeOpeningHoursRows($data);
$visibility = (string)($data['visibility'] ?? 'public'); $visibility = (string)($data['visibility'] ?? 'public');
$placeKind = trim((string)($data['category_slug'] ?? '')); $placeKind = trim((string)($data['category_slug'] ?? ''));
$customCategoryTitle = trim((string)($data['category_custom_title'] ?? ''));
$street = trim((string)($data['street'] ?? '')); $street = trim((string)($data['street'] ?? ''));
$zip = trim((string)($data['zip'] ?? '')); $zip = trim((string)($data['zip'] ?? ''));
$city = trim((string)($data['city'] ?? '')); $city = trim((string)($data['city'] ?? ''));
$region = trim((string)($data['region'] ?? '')); $region = trim((string)($data['region'] ?? ''));
$phone = trim((string)($data['phone'] ?? ''));
$websiteUrl = trim((string)($data['website_url'] ?? ''));
$lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null; $lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null;
$lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null; $lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null;
$startsAt = trim((string)($data['starts_at'] ?? '')); $startsAt = trim((string)($data['starts_at'] ?? ''));
$recurrenceMode = (string)($data['recurrence_mode'] ?? 'single'); $recurrenceMode = (string)($data['recurrence_mode'] ?? 'single');
$recurrenceUntil = trim((string)($data['recurrence_until'] ?? '')); $recurrenceUntil = trim((string)($data['recurrence_until'] ?? ''));
$weekdayValues = array_values(array_filter(array_map('strval', (array)($data['recurrence_weekdays'] ?? [])))); $weekdayValues = array_values(array_filter(array_map('strval', (array)($data['recurrence_weekdays'] ?? []))));
$priceMode = (string)($data['price_mode'] ?? 'none'); $specialConditionsNote = trim((string)($data['special_conditions_note'] ?? ''));
$imagePath = trim((string)($data['image_path'] ?? ''));
$capacityTotal = isset($data['capacity_total']) && $data['capacity_total'] !== '' ? (int)$data['capacity_total'] : null;
if ($title === '' || $teaser === '' || $description === '') { if ($title === '' || $description === '') {
throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.'); throw new \RuntimeException('Bitte fülle Titel und Beschreibung aus.');
} }
if (!in_array($visibility, ['public', 'members'], true)) { if (!in_array($visibility, ['public', 'members'], true)) {
$visibility = 'public'; $visibility = 'public';
@@ -315,16 +329,13 @@ final class ListingCatalog
if ($entryType === 'editorial_event' && $startsAt === '') { if ($entryType === 'editorial_event' && $startsAt === '') {
throw new \RuntimeException('Bitte gib für sonstige Veranstaltungen ein Datum an.'); throw new \RuntimeException('Bitte gib für sonstige Veranstaltungen ein Datum an.');
} }
if ($entryType === 'editorial_event' && $recurrenceUntil === '') {
throw new \RuntimeException('Bitte gib für Veranstaltungen ein Gültig-bis-Datum an.');
}
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
try { try {
$categoryId = null; [$placeKind, $categoryId] = $this->resolveCategory($placeKind, $customCategoryTitle);
if ($placeKind !== '') {
$catStmt = $this->pdo->prepare('SELECT id FROM listing_categories WHERE slug = :slug LIMIT 1');
$catStmt->execute(['slug' => $placeKind]);
$categoryId = $catStmt->fetchColumn();
$categoryId = $categoryId !== false ? (int)$categoryId : null;
}
if ($listingId !== null && $listingId > 0) { if ($listingId !== null && $listingId > 0) {
$existing = $this->getDashboardEntry($userId, $listingId); $existing = $this->getDashboardEntry($userId, $listingId);
@@ -335,7 +346,9 @@ final class ListingCatalog
if ($placeId > 0) { if ($placeId > 0) {
$placeStmt = $this->pdo->prepare( $placeStmt = $this->pdo->prepare(
'UPDATE listing_places 'UPDATE listing_places
SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, place_kind = :placeKind, opening_hours_note = :openingHoursNote, updated_at = NOW() SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region,
lat = :lat, lng = :lng, website_url = :websiteUrl, phone = :phone, place_kind = :placeKind,
opening_hours_note = :openingHoursNote, opening_hours_json = :openingHoursJson, updated_at = NOW()
WHERE id = :id' WHERE id = :id'
); );
$placeStmt->execute([ $placeStmt->execute([
@@ -347,33 +360,43 @@ final class ListingCatalog
'region' => $region !== '' ? $region : null, 'region' => $region !== '' ? $region : null,
'lat' => $lat, 'lat' => $lat,
'lng' => $lng, 'lng' => $lng,
'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null,
'phone' => $phone !== '' ? $phone : null,
'placeKind' => $placeKind !== '' ? $placeKind : null, 'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
'id' => $placeId, 'id' => $placeId,
]); ]);
} }
$listingStmt = $this->pdo->prepare( $listingStmt = $this->pdo->prepare(
'UPDATE listings 'UPDATE listings
SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description, visibility = :visibility, SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description,
supports_registration = 0, supports_capacity = 0, supports_pricing = 0, is_recurring = 0, status = "published", updated_at = NOW() image_path = :imagePath, special_conditions_note = :specialConditionsNote, visibility = :visibility,
supports_registration = 0, supports_capacity = :supportsCapacity, supports_pricing = :supportsPricing,
is_recurring = :isRecurring, status = "published", updated_at = NOW()
WHERE id = :id AND created_by = :uid' WHERE id = :id AND created_by = :uid'
); );
$listingStmt->execute([ $listingStmt->execute([
'listingType' => $entryType, 'listingType' => $entryType,
'title' => $title, 'title' => $title,
'teaser' => $teaser, 'teaser' => $teaser !== '' ? $teaser : $title,
'description' => $description, 'description' => $description,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null,
'visibility' => $visibility, 'visibility' => $visibility,
'supportsCapacity' => $capacityTotal !== null ? 1 : 0,
'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0,
'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0,
'id' => $listingId, 'id' => $listingId,
'uid' => $userId, 'uid' => $userId,
]); ]);
$this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
$this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data); $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data);
} else { } else {
$placeStmt = $this->pdo->prepare( $placeStmt = $this->pdo->prepare(
'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, place_kind, opening_hours_note, provider_hint, status, created_at, updated_at) 'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, website_url, phone, place_kind, opening_hours_note, opening_hours_json, provider_hint, status, created_at, updated_at)
VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, :openingHoursNote, "manual", "published", NOW(), NOW())' VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :websiteUrl, :phone, :placeKind, :openingHoursNote, :openingHoursJson, "manual", "published", NOW(), NOW())'
); );
$placeStmt->execute([ $placeStmt->execute([
'uid' => $userId, 'uid' => $userId,
@@ -385,27 +408,35 @@ final class ListingCatalog
'region' => $region !== '' ? $region : null, 'region' => $region !== '' ? $region : null,
'lat' => $lat, 'lat' => $lat,
'lng' => $lng, 'lng' => $lng,
'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null,
'phone' => $phone !== '' ? $phone : null,
'placeKind' => $placeKind !== '' ? $placeKind : null, 'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
]); ]);
$placeId = (int)$this->pdo->lastInsertId(); $placeId = (int)$this->pdo->lastInsertId();
$listingStmt = $this->pdo->prepare( $listingStmt = $this->pdo->prepare(
'INSERT INTO listings (created_by, owner_type, listing_type, primary_place_id, title, teaser_public, description, visibility, status, supports_registration, supports_capacity, supports_pricing, is_recurring, created_at, updated_at) 'INSERT INTO listings (created_by, owner_type, listing_type, primary_place_id, title, teaser_public, description, image_path, special_conditions_note, visibility, status, supports_registration, supports_capacity, supports_pricing, is_recurring, created_at, updated_at)
VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :visibility, "published", 0, 0, 0, 0, NOW(), NOW())' VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :imagePath, :specialConditionsNote, :visibility, "published", 0, :supportsCapacity, :supportsPricing, :isRecurring, NOW(), NOW())'
); );
$listingStmt->execute([ $listingStmt->execute([
'uid' => $userId, 'uid' => $userId,
'listingType' => $entryType, 'listingType' => $entryType,
'placeId' => $placeId, 'placeId' => $placeId,
'title' => $title, 'title' => $title,
'teaser' => $teaser, 'teaser' => $teaser !== '' ? $teaser : $title,
'description' => $description, 'description' => $description,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null,
'visibility' => $visibility, 'visibility' => $visibility,
'supportsCapacity' => $capacityTotal !== null ? 1 : 0,
'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0,
'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0,
]); ]);
$listingId = (int)$this->pdo->lastInsertId(); $listingId = (int)$this->pdo->lastInsertId();
$this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data); $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data);
} }
$this->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $this->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
@@ -427,14 +458,14 @@ final class ListingCatalog
} }
} }
private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, string $priceMode, array $data): void private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, ?int $capacityTotal, array $data): void
{ {
$occurrenceId = null; $occurrenceId = null;
if ($entryType === 'editorial_event') { if ($entryType === 'editorial_event') {
[$occurrenceType, $recurrenceRule] = $this->buildRecurrence($recurrenceMode, $weekdayValues); [$occurrenceType, $recurrenceRule] = $this->buildRecurrence($recurrenceMode, $weekdayValues);
$occStmt = $this->pdo->prepare( $occStmt = $this->pdo->prepare(
'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, status, created_at, updated_at) 'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, capacity_total, status, created_at, updated_at)
VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, "scheduled", NOW(), NOW())' VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, :capacityTotal, "scheduled", NOW(), NOW())'
); );
$occStmt->execute([ $occStmt->execute([
'listingId' => $listingId, 'listingId' => $listingId,
@@ -442,12 +473,14 @@ final class ListingCatalog
'startsAt' => $startsAt !== '' ? $startsAt : null, 'startsAt' => $startsAt !== '' ? $startsAt : null,
'recurrenceRule' => $recurrenceRule, 'recurrenceRule' => $recurrenceRule,
'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null, 'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null,
'capacityTotal' => $capacityTotal,
]); ]);
$occurrenceId = (int)$this->pdo->lastInsertId(); $occurrenceId = (int)$this->pdo->lastInsertId();
} }
$this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
if ($priceMode === 'none') { $priceRows = $this->normalizePriceRows($data);
if ($priceRows === []) {
return; return;
} }
@@ -456,52 +489,16 @@ final class ListingCatalog
VALUES (:listingId, :occurrenceId, :label, :audience, :priceType, :amount, :amountSecondary, "EUR", :note, NOW(), NOW())' VALUES (:listingId, :occurrenceId, :label, :audience, :priceType, :amount, :amountSecondary, "EUR", :note, NOW(), NOW())'
); );
if ($priceMode === 'free_child_rule') { foreach ($priceRows as $row) {
$freeUntil = trim((string)($data['free_child_until_age'] ?? ''));
$insertPrice->execute([ $insertPrice->execute([
'listingId' => $listingId, 'listingId' => $listingId,
'occurrenceId' => $occurrenceId, 'occurrenceId' => $occurrenceId,
'label' => 'Eintritt frei', 'label' => $row['label'],
'audience' => 'child', 'audience' => $row['audience'],
'priceType' => 'free', 'priceType' => $row['price_type'],
'amount' => null, 'amount' => $row['amount'],
'amountSecondary' => null, 'amountSecondary' => $row['amount_secondary'],
'note' => $freeUntil !== '' ? 'Kinder bis ' . $freeUntil . ' Jahre frei' : 'Kinder frei', 'note' => $row['note'],
]);
}
$childAmount = trim((string)($data['price_child_amount'] ?? ''));
if ($childAmount !== '') {
$childFrom = trim((string)($data['price_child_age_from'] ?? ''));
$childTo = trim((string)($data['price_child_age_to'] ?? ''));
$note = 'Preis pro Kind';
if ($childFrom !== '' || $childTo !== '') {
$note .= ' (' . trim(($childFrom !== '' ? 'ab ' . $childFrom : '') . ($childTo !== '' ? ' bis ' . $childTo : '') . ' Jahre') . ')';
}
$insertPrice->execute([
'listingId' => $listingId,
'occurrenceId' => $occurrenceId,
'label' => 'Kinder',
'audience' => 'child',
'priceType' => 'fixed',
'amount' => (float)str_replace(',', '.', $childAmount),
'amountSecondary' => null,
'note' => $note,
]);
}
$adultAmount = trim((string)($data['price_adult_amount'] ?? ''));
if ($adultAmount !== '') {
$adultFrom = trim((string)($data['price_adult_age_from'] ?? ''));
$insertPrice->execute([
'listingId' => $listingId,
'occurrenceId' => $occurrenceId,
'label' => 'Erwachsene',
'audience' => 'adult',
'priceType' => 'fixed',
'amount' => (float)str_replace(',', '.', $adultAmount),
'amountSecondary' => null,
'note' => $adultFrom !== '' ? 'Preis pro Erwachsener ab ' . $adultFrom . ' Jahre' : 'Preis pro Erwachsener',
]); ]);
} }
} }
@@ -539,6 +536,126 @@ final class ListingCatalog
return ['single', null]; return ['single', null];
} }
private function resolveCategory(string $slug, string $customTitle): array
{
$slug = trim($slug);
$customTitle = trim($customTitle);
if ($customTitle !== '') {
$slug = $this->slugify($customTitle);
$stmt = $this->pdo->prepare(
'INSERT INTO listing_categories (slug, title, category_group, sort_order)
VALUES (:slug, :title, "general", 999)
ON DUPLICATE KEY UPDATE title = VALUES(title), updated_at = CURRENT_TIMESTAMP'
);
$stmt->execute([
'slug' => $slug,
'title' => $customTitle,
]);
}
if ($slug === '') {
return ['', null];
}
$stmt = $this->pdo->prepare('SELECT id FROM listing_categories WHERE slug = :slug LIMIT 1');
$stmt->execute(['slug' => $slug]);
$categoryId = $stmt->fetchColumn();
return [$slug, $categoryId !== false ? (int)$categoryId : null];
}
private function normalizeOpeningHoursRows(array $data): array
{
$labels = (array)($data['opening_day_label'] ?? []);
$fromValues = (array)($data['opening_time_from'] ?? []);
$toValues = (array)($data['opening_time_to'] ?? []);
$notes = (array)($data['opening_note'] ?? []);
$count = max(count($labels), count($fromValues), count($toValues), count($notes));
$rows = [];
for ($index = 0; $index < $count; $index++) {
$row = [
'day_label' => trim((string)($labels[$index] ?? '')),
'time_from' => trim((string)($fromValues[$index] ?? '')),
'time_to' => trim((string)($toValues[$index] ?? '')),
'note' => trim((string)($notes[$index] ?? '')),
];
if ($row['day_label'] === '' && $row['time_from'] === '' && $row['time_to'] === '' && $row['note'] === '') {
continue;
}
$rows[] = $row;
}
return $rows;
}
private function normalizePriceRows(array $data): array
{
$labels = (array)($data['price_label'] ?? []);
$audiences = (array)($data['price_audience'] ?? []);
$types = (array)($data['price_type'] ?? []);
$amounts = (array)($data['price_amount'] ?? []);
$secondaryAmounts = (array)($data['price_amount_secondary'] ?? []);
$ageFroms = (array)($data['price_age_from'] ?? []);
$ageTos = (array)($data['price_age_to'] ?? []);
$notes = (array)($data['price_note'] ?? []);
$count = max(count($labels), count($audiences), count($types), count($amounts), count($secondaryAmounts), count($ageFroms), count($ageTos), count($notes));
$rows = [];
for ($index = 0; $index < $count; $index++) {
$label = trim((string)($labels[$index] ?? ''));
$audience = trim((string)($audiences[$index] ?? 'general'));
$priceType = trim((string)($types[$index] ?? 'fixed'));
$amount = trim((string)($amounts[$index] ?? ''));
$secondary = trim((string)($secondaryAmounts[$index] ?? ''));
$ageFrom = trim((string)($ageFroms[$index] ?? ''));
$ageTo = trim((string)($ageTos[$index] ?? ''));
$note = trim((string)($notes[$index] ?? ''));
if ($label === '' && $amount === '' && $secondary === '' && $note === '' && $ageFrom === '' && $ageTo === '') {
continue;
}
$noteParts = [];
if ($note !== '') {
$noteParts[] = $note;
}
if ($ageFrom !== '' || $ageTo !== '') {
$ageText = [];
if ($ageFrom !== '') {
$ageText[] = 'ab ' . $ageFrom . ' Jahre';
}
if ($ageTo !== '') {
$ageText[] = 'bis ' . $ageTo . ' Jahre';
}
$noteParts[] = implode(', ', $ageText);
}
$rows[] = [
'label' => $label !== '' ? $label : 'Preis',
'audience' => in_array($audience, ['general', 'adult', 'child', 'family', 'group'], true) ? $audience : 'general',
'price_type' => in_array($priceType, ['free', 'fixed', 'from', 'up_to', 'range', 'request'], true) ? $priceType : 'fixed',
'amount' => $amount !== '' ? (float)str_replace(',', '.', $amount) : null,
'amount_secondary' => $secondary !== '' ? (float)str_replace(',', '.', $secondary) : null,
'note' => $noteParts !== [] ? implode(' | ', $noteParts) : null,
];
}
return $rows;
}
private function hasPriceRows(array $data): bool
{
return $this->normalizePriceRows($data) !== [];
}
private function decodeJsonRows(string $json): array
{
if ($json === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function slugify(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: '';
return trim($value, '-');
}
public function deleteDashboardEntry(int $userId, int $listingId): void public function deleteDashboardEntry(int $userId, int $listingId): void
{ {
$this->ensureSchema(); $this->ensureSchema();