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
Stand: 2026-08-03
Stand: 2026-08-04
## 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.
@@ -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.
- 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.
- Im Bereich `Events` gibt es jetzt zunächst drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung.
- `Ort` blendet Datum aus und erlaubt stattdessen Öffnungszeiten/Hinweise.
- `Sonstige Veranstaltung` unterstützt Uhrzeit und erste Wiederholungsmodelle wie täglich, wöchentlich oder bestimmte Wochentage.
- Preisangaben für Orte und sonstige Veranstaltungen werden als strukturierte Einträge für Kinder- und Erwachsenenpreise gespeichert.
- Im Bereich `Events` gibt es jetzt drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung.
- Eigene Events unterstützen Kategorie, optionales Bild, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl und Sichtbarkeit.
- `Ort` nutzt die gleiche Adresslogik wie das Profil, blendet den Event-Termin aus und erlaubt stattdessen flexible Öffnungs-/Zeitfenster, optionale Kontaktangaben und flexible Preisregeln.
- `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
- eigener Front-Controller in `public/index.php`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Projektstruktur
Stand: 2026-08-03
Stand: 2026-08-04
## Projektordner
- `config/`: Konfigurationen, inklusive `prod/` und `staging/`
@@ -19,7 +19,8 @@ Stand: 2026-08-03
- System-Einstellungen: `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.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
Kanonische interne Dokumentation:

View File

@@ -1,6 +1,6 @@
# 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.
@@ -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
- neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt
- 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
- Primärbegriff im Produkt: `Events`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Project Context
Updated: 2026-08-03
Updated: 2026-08-04
## 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.
@@ -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.
- 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.
- `Place` hides date/time and allows opening hours or availability notes instead.
- `Other Event` supports time and first recurrence models such as daily, weekly, or selected weekdays.
- Pricing for places and other events is stored in structured child/adult price rows.
- Own events support category, optional image, a clear `with children` yes/no field, optional capacity, and visibility.
- `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.
- `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
- custom front controller in `public/index.php`

View File

@@ -1,6 +1,6 @@
# Papa-Kind-Treff Project Structure
Updated: 2026-08-03
Updated: 2026-08-04
## Main Directories
- `config/`: configuration, including `prod/` and `staging/`
@@ -19,7 +19,8 @@ Updated: 2026-08-03
- system settings: `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.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
- canonical internal documentation lives in `Internal/de/` and `Internal/en/`

View File

@@ -1,6 +1,6 @@
# 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.
@@ -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
- 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`
- 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
- Primary product term: `Events`

View File

@@ -1,6 +1,6 @@
# 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.
@@ -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`
- 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
- 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
- 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'])) {
$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');
$submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort speichern' : 'Veranstaltung speichern') : 'Eintrag anlegen');
$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'] ?? '');
$lngValue = $editingEvent ? (string)($editEvent['lng'] ?? '') : (string)($editListing['lng'] ?? '');
$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'] ?? '') : '';
$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'] ?? '') : '';
$recurrenceUntilValue = '';
if ($editingListing && !empty($editListing['recurrence_until'])) {
@@ -57,41 +63,14 @@ if (preg_match('/BYDAY=([A-Z,]+)/', $recurrenceRuleValue, $matches)) {
}
}
}
$priceModeValue = 'none';
$freeChildUntilAge = '';
$priceChildAmount = '';
$priceChildAgeFrom = '';
$priceChildAgeTo = '';
$priceAdultAmount = '';
$priceAdultAgeFrom = '';
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];
}
}
}
}
$priceRows = $editingListing && !empty($editListing['prices']) && is_array($editListing['prices']) ? $editListing['prices'] : [[
'label' => '',
'audience' => 'general',
'price_type' => 'fixed',
'amount' => '',
'amount_secondary' => '',
'note' => '',
]];
$editingChild = isset($editChild) && $editChild !== null;
$childAction = $editingChild ? 'child_update' : 'child_add';
$childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen';
@@ -908,7 +887,7 @@ if (!empty($canManageSystemSettings)) {
<h3><?= htmlspecialchars($eventTitle, ENT_QUOTES) ?></h3>
<button class="btn ghost" type="button" data-modal-close>✕</button>
</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="entry_kind" id="entryKind" value="<?= htmlspecialchars($currentEntryKind, ENT_QUOTES) ?>">
<?php if ($editingEvent && $editEvent): ?>
@@ -942,36 +921,17 @@ if (!empty($canManageSystemSettings)) {
</p>
</div>
<?php endif; ?>
<div class="form-grid" id="titleDescriptionRow">
<div class="stack gap-6">
<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) ?>">
</div>
</div>
<div class="stack gap-6">
<label class="label" for="evDesc">Beschreibung (voll)</label>
<textarea id="evDesc" name="description" class="textarea" rows="3" placeholder="Was soll passieren, was mitbringen?" required><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea>
<p class="muted small" style="margin:0;">Die Kurzbeschreibung wird intern automatisch aus deinem Text erzeugt.</p>
<label class="label" for="evDesc">Beschreibung</label>
<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 automatisch aus dem Text erzeugt.</p>
</div>
<div class="stack gap-6" id="placeLookupWrap">
<label class="label" for="existingPlaceLookup">Vorhandenen Ort suchen (optional)</label>
<div class="flex gap-8" style="flex-wrap:wrap;">
<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>
</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="form-grid" id="eventDateRow">
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="evDate">Datum & Uhrzeit</label>
<input id="evDate" name="starts_at" class="input" type="datetime-local" required value="<?= htmlspecialchars($startVal, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6" id="eventLocationLabelWrap">
<label class="label" for "evLoc">Ort/Label</label>
<input id="evLoc" name="location_label" class="input" placeholder="Park / Café" value="<?= htmlspecialchars($locationLabelValue, ENT_QUOTES) ?>">
</div>
</div>
<div class="stack gap-6" id="eventCategoryWrap">
<label class="label" for="listingCategorySlug">Kategorie</label>
<select id="listingCategorySlug" name="category_slug" class="select">
<option value="">Bitte auswählen</option>
@@ -981,11 +941,48 @@ if (!empty($canManageSystemSettings)) {
</option>
<?php endforeach; ?>
</select>
<p class="muted small" style="margin:0;">Für Orte und sonstige Veranstaltungen, damit Spielplatz, Café, Restaurant, Zoo oder Zirkus sauber unterscheidbar bleiben.</p>
</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 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 class="stack gap-6" id="placeLookupWrap">
<label class="label" for="existingPlaceLookup">Vorhandenen Ort suchen (optional)</label>
<div class="flex gap-8" style="flex-wrap:wrap;">
<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>
</div>
</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 class="form-grid" id="eventDateRow">
<div class="stack gap-6">
<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) ?>">
</div>
<div class="stack gap-6" id="eventLocationLabelWrap">
<label class="label" for="evLoc">Ort-Label</label>
<input id="evLoc" name="location_label" class="input" placeholder="z. B. Café Sonnenseite" value="<?= htmlspecialchars($locationLabelValue, ENT_QUOTES) ?>">
</div>
</div>
<div class="form-grid" id="eventMetaRow">
<div class="stack gap-6" id="eventChildrenWrap">
<label class="label" for="withChildren">Mit Kindern</label>
<select id="withChildren" name="with_children" class="select">
<option value="yes" <?= $withChildrenValue === 'yes' ? 'selected' : '' ?>>Ja</option>
<option value="no" <?= $withChildrenValue === 'no' ? 'selected' : '' ?>>Nein</option>
</select>
</div>
<div class="stack gap-6" id="eventCapacityWrap">
<label class="label" for="maxParticipants">Platzzahl (optional)</label>
<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="eventRecurrenceWrap">
<label class="label" for="recurrenceMode">Wiederholung</label>
@@ -1005,7 +1002,7 @@ if (!empty($canManageSystemSettings)) {
<?php endforeach; ?>
</div>
<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) ?>">
</div>
</div>
@@ -1032,12 +1029,42 @@ if (!empty($canManageSystemSettings)) {
</div>
<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="btnEventBrowserLocation">Browser-Adresse übernehmen</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>
</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="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">
<button class="btn ghost" type="button" id="btnMap">Auf Karte suchen</button>
<div id="mapWrapper" class="map-wrapper" hidden>
@@ -1062,48 +1089,45 @@ if (!empty($canManageSystemSettings)) {
</div>
</div>
<div class="stack gap-10" id="eventPricingWrap">
<label class="label" for="priceMode">Eintritt / Kosten</label>
<select id="priceMode" name="price_mode" class="select">
<option value="none" <?= $priceModeValue === 'none' ? 'selected' : '' ?>>Keine Preisangabe</option>
<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>
<div class="flex between center-y">
<label class="label">Eintritt / Kosten</label>
<button class="btn ghost" type="button" id="btnAddPriceRow">Preisregel hinzufügen</button>
</div>
<div id="priceRows" class="stack gap-8">
<?php foreach ($priceRows as $priceRow): ?>
<div class="stack gap-8 price-row card">
<div class="form-grid">
<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 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 class="stack gap-12" id="paidPricingWrap">
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="priceChildAmount">Preis pro Kind</label>
<input id="priceChildAmount" name="price_child_amount" class="input" inputmode="decimal" placeholder="z. B. 12,50" value="<?= htmlspecialchars($priceChildAmount, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="priceAdultAmount">Preis pro Erwachsener</label>
<input id="priceAdultAmount" name="price_adult_amount" class="input" inputmode="decimal" placeholder="z. B. 18,00" value="<?= htmlspecialchars($priceAdultAmount, ENT_QUOTES) ?>">
</div>
</div>
<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) ?>">
<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>
<?php endforeach; ?>
</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) ?>">
<label class="label" for="specialConditionsNote">Sonderkonditionen</label>
<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>
</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>
<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">
<button class="btn ghost" type="button" data-modal-close>Abbrechen</button>
<button class="btn" type="submit"><?= htmlspecialchars($submitLabel, ENT_QUOTES) ?></button>
@@ -1140,6 +1164,7 @@ if (!empty($canManageSystemSettings)) {
const btnMapSearch = document.getElementById('btnMapSearch');
const btnAddrToMap = document.getElementById('btnAddrToMap');
const btnEventAddressSearch = document.getElementById('btnEventAddressSearch');
const btnEventBrowserLocation = document.getElementById('btnEventBrowserLocation');
const eventAddressStatus = document.getElementById('eventAddressStatus');
const mapSearch = document.getElementById('mapSearch');
const latInput = document.getElementById('evLat');
@@ -1149,19 +1174,21 @@ if (!empty($canManageSystemSettings)) {
const entryKindButtons = document.querySelectorAll('[data-entry-kind-choice]');
const eventDateRow = document.getElementById('eventDateRow');
const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap');
const eventCategoryWrap = document.getElementById('eventCategoryWrap');
const eventKidsWrap = document.getElementById('eventKidsWrap');
const eventChildrenWrap = document.getElementById('eventChildrenWrap');
const eventCapacityWrap = document.getElementById('eventCapacityWrap');
const eventPricingWrap = document.getElementById('eventPricingWrap');
const placeContactWrap = document.getElementById('placeContactWrap');
const placeLookupWrap = document.getElementById('placeLookupWrap');
const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap');
const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap');
const eventDateInput = document.getElementById('evDate');
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 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 btnUseExistingPlace = document.getElementById('btnUseExistingPlace');
const streetInput = document.getElementById('evStreet');
@@ -1225,23 +1252,17 @@ if (!empty($canManageSystemSettings)) {
if (eventDateRow) eventDateRow.hidden = kind === 'place';
if (eventDateInput) eventDateInput.required = kind !== 'place';
if (eventLocationLabelWrap) eventLocationLabelWrap.hidden = kind !== 'event';
if (eventCategoryWrap) eventCategoryWrap.hidden = kind === 'event';
if (categorySelect) categorySelect.required = kind !== 'event';
if (eventKidsWrap) eventKidsWrap.hidden = kind !== 'event';
if (categorySelect) categorySelect.required = true;
if (eventChildrenWrap) eventChildrenWrap.hidden = kind === 'place';
if (eventCapacityWrap) eventCapacityWrap.hidden = kind !== 'event';
if (eventPricingWrap) eventPricingWrap.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';
syncPriceModeVisibility();
syncRecurrenceVisibility();
}
function syncPriceModeVisibility() {
const mode = priceModeSelect?.value || 'none';
if (freeChildRuleWrap) freeChildRuleWrap.hidden = mode !== 'free_child_rule';
if (paidPricingWrap) paidPricingWrap.hidden = mode !== 'paid';
}
function syncRecurrenceVisibility() {
const mode = recurrenceModeSelect?.value || 'single';
if (recurrenceWeekdaysWrap) recurrenceWeekdaysWrap.hidden = mode !== 'custom_weekdays';
@@ -1252,13 +1273,63 @@ if (!empty($canManageSystemSettings)) {
syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event');
});
});
priceModeSelect?.addEventListener('change', syncPriceModeVisibility);
recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility);
syncEventEntryKind(entryKindInput?.value || 'event');
syncPriceModeVisibility();
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) {
if (!place) return;
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(){
const setProfileStatus = (message = '') => {
if (!profileAddressStatus) return;

View File

@@ -9,7 +9,7 @@ $clientCookie = $config->cookiePrefix() . 'client';
<main class="container section legal-page">
<div class="content-card content-card--narrow page-copy">
<h1>Datenschutz &amp; Cookies</h1>
<p class="muted small">Stand: 31. Juli 2026</p>
<p class="muted small">Stand: 4. August 2026</p>
<p>
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,
insbesondere E-Mail-Adresse, Passwort-Hash, Verifikationsstatus sowie die von dir gepflegten Profilangaben.
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>
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.
</p>
<p>
Zusätzlich kannst du im Mitgliederbereich deine Profiladresse über die gleichen externen Adressdienste
lokalisieren lassen, die auch für Event-Adressen genutzt werden. Dabei kannst du entweder nach passenden
Adress-Treffern suchen oder die vom Browser ermittelte Position in eine Profiladresse übernehmen.
Zusätzlich kannst du im Mitgliederbereich deine Profiladresse sowie Adressen für eigene Events, Orte und
Veranstaltungen über die gleichen externen Adressdienste lokalisieren lassen. Dabei kannst du entweder nach
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
an den jeweiligen Geocoding-Dienst übermittelt.
</p>

View File

@@ -79,6 +79,8 @@ CREATE TABLE events (
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
category_slug VARCHAR(120) NULL,
image_path VARCHAR(255) NULL,
location_label VARCHAR(180) NULL,
street VARCHAR(180) NULL,
zip CHAR(5) NULL,
@@ -149,6 +151,7 @@ CREATE TABLE listing_places (
phone VARCHAR(60) 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',
external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL,
@@ -173,6 +176,8 @@ CREATE TABLE listings (
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
special_conditions_note TEXT NULL,
visibility ENUM('public','members') NOT NULL DEFAULT 'public',
status ENUM('draft','published','cancelled','archived') NOT NULL DEFAULT 'draft',
supports_registration TINYINT(1) NOT NULL DEFAULT 0,

View File

@@ -189,6 +189,9 @@ final class AccountPages
if ($listingCatalog) {
$listingCatalog->ensureSchema();
}
if ($pdo) {
self::ensureLegacyEventSchema($pdo);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
@@ -378,6 +381,8 @@ final class AccountPages
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
}
$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'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
@@ -404,6 +409,7 @@ final class AccountPages
$payload['lat'] = $resolvedAddress['lat'];
$payload['lng'] = $resolvedAddress['lng'];
}
$payload['image_path'] = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingListing) ? (string)($existingListing['image_path'] ?? '') : '');
$payload['teaser'] = self::generateTeaser(
trim((string)($payload['description'] ?? '')),
trim((string)($payload['title'] ?? ''))
@@ -415,27 +421,59 @@ final class AccountPages
);
$info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.';
} 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'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$needsGeocode = ($lat === null || $lng === null || $region === '');
if ($needsGeocode) {
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region);
if ($lat === null) { $lat = $geoLat; }
if ($lng === null) { $lng = $geoLng; }
if ($region === '' && $geoRegion) { $region = $geoRegion; }
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
if ($hasAddress) {
$resolvedAddress = null;
if ($lat !== null && $lng !== null) {
$resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
}
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') {
$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([
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']),
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
@@ -444,7 +482,8 @@ final class AccountPages
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'capacity' => $capacity,
'allow' => $allowKids,
'vis' => $_POST['visibility'] ?? 'public',
'status' => 'published',
]);
@@ -458,13 +497,15 @@ final class AccountPages
}
} else {
$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([
'id' => $eventId,
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']),
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
@@ -473,7 +514,8 @@ final class AccountPages
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'capacity' => $capacity,
'allow' => $allowKids,
'vis' => $_POST['visibility'] ?? 'public',
]);
$info = 'Event aktualisiert.';
@@ -481,7 +523,7 @@ final class AccountPages
}
} elseif ($action === 'event_delete') {
$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]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
@@ -490,6 +532,7 @@ final class AccountPages
if ((int)$ev['participant_count'] > 0) {
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]);
$info = 'Event gelöscht.';
} elseif ($action === 'event_cancel') {
@@ -510,6 +553,10 @@ final class AccountPages
if (!$listingCatalog) {
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);
$info = 'Eintrag gelöscht.';
} elseif ($action === 'community_admin_apply') {
@@ -638,7 +685,7 @@ final class AccountPages
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : [];
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
$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
FROM events e
WHERE e.created_by = :id AND e.starts_at >= NOW()
@@ -648,7 +695,7 @@ final class AccountPages
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$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
FROM events e
WHERE e.created_by = :id AND e.starts_at < NOW()
@@ -845,6 +892,87 @@ final class AccountPages
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
{
$url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([

View File

@@ -39,6 +39,8 @@ final class ListingCatalog
website_url VARCHAR(255) NULL,
phone VARCHAR(60) 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",
external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL,
@@ -62,6 +64,8 @@ final class ListingCatalog
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
image_path VARCHAR(255) NULL,
special_conditions_note TEXT NULL,
visibility ENUM("public","members") NOT NULL DEFAULT "public",
status ENUM("draft","published","cancelled","archived") NOT NULL DEFAULT "draft",
supports_registration TINYINT(1) NOT NULL DEFAULT 0,
@@ -145,6 +149,9 @@ final class ListingCatalog
$columnStatements = [
'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) {
[$table, $column] = explode('.', $key, 2);
@@ -214,7 +221,7 @@ final class ListingCatalog
{
$this->ensureSchema();
$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,
lo.starts_at, lo.ends_at, lo.occurrence_type,
lc.title AS category_title, lc.slug AS category_slug
@@ -241,9 +248,9 @@ final class ListingCatalog
{
$this->ensureSchema();
$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.recurrence_rule, lo.recurrence_until,
lo.recurrence_rule, lo.recurrence_until, lo.capacity_total,
lc.slug AS category_slug
FROM listings l
LEFT JOIN listing_places lp ON lp.id = l.primary_place_id
@@ -262,6 +269,7 @@ final class ListingCatalog
return null;
}
$row['prices'] = $this->listPrices((int)$row['id']);
$row['opening_hours'] = $this->decodeJsonRows((string)($row['opening_hours_json'] ?? ''));
return $row;
}
@@ -292,22 +300,28 @@ final class ListingCatalog
$teaser = trim((string)($data['teaser'] ?? ''));
$description = trim((string)($data['description'] ?? ''));
$openingHoursNote = trim((string)($data['opening_hours_note'] ?? ''));
$openingHoursRows = $this->normalizeOpeningHoursRows($data);
$visibility = (string)($data['visibility'] ?? 'public');
$placeKind = trim((string)($data['category_slug'] ?? ''));
$customCategoryTitle = trim((string)($data['category_custom_title'] ?? ''));
$street = trim((string)($data['street'] ?? ''));
$zip = trim((string)($data['zip'] ?? ''));
$city = trim((string)($data['city'] ?? ''));
$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;
$lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null;
$startsAt = trim((string)($data['starts_at'] ?? ''));
$recurrenceMode = (string)($data['recurrence_mode'] ?? 'single');
$recurrenceUntil = trim((string)($data['recurrence_until'] ?? ''));
$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 === '') {
throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.');
if ($title === '' || $description === '') {
throw new \RuntimeException('Bitte fülle Titel und Beschreibung aus.');
}
if (!in_array($visibility, ['public', 'members'], true)) {
$visibility = 'public';
@@ -315,16 +329,13 @@ final class ListingCatalog
if ($entryType === 'editorial_event' && $startsAt === '') {
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();
try {
$categoryId = null;
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;
}
[$placeKind, $categoryId] = $this->resolveCategory($placeKind, $customCategoryTitle);
if ($listingId !== null && $listingId > 0) {
$existing = $this->getDashboardEntry($userId, $listingId);
@@ -335,7 +346,9 @@ final class ListingCatalog
if ($placeId > 0) {
$placeStmt = $this->pdo->prepare(
'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'
);
$placeStmt->execute([
@@ -347,33 +360,43 @@ final class ListingCatalog
'region' => $region !== '' ? $region : null,
'lat' => $lat,
'lng' => $lng,
'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null,
'phone' => $phone !== '' ? $phone : null,
'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
'id' => $placeId,
]);
}
$listingStmt = $this->pdo->prepare(
'UPDATE listings
SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description, visibility = :visibility,
supports_registration = 0, supports_capacity = 0, supports_pricing = 0, is_recurring = 0, status = "published", updated_at = NOW()
SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description,
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'
);
$listingStmt->execute([
'listingType' => $entryType,
'title' => $title,
'teaser' => $teaser,
'teaser' => $teaser !== '' ? $teaser : $title,
'description' => $description,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null,
'visibility' => $visibility,
'supportsCapacity' => $capacityTotal !== null ? 1 : 0,
'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0,
'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0,
'id' => $listingId,
'uid' => $userId,
]);
$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 {
$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)
VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, :openingHoursNote, "manual", "published", NOW(), NOW())'
'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, :websiteUrl, :phone, :placeKind, :openingHoursNote, :openingHoursJson, "manual", "published", NOW(), NOW())'
);
$placeStmt->execute([
'uid' => $userId,
@@ -385,27 +408,35 @@ final class ListingCatalog
'region' => $region !== '' ? $region : null,
'lat' => $lat,
'lng' => $lng,
'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null,
'phone' => $phone !== '' ? $phone : null,
'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
]);
$placeId = (int)$this->pdo->lastInsertId();
$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)
VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :visibility, "published", 0, 0, 0, 0, NOW(), NOW())'
'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, :imagePath, :specialConditionsNote, :visibility, "published", 0, :supportsCapacity, :supportsPricing, :isRecurring, NOW(), NOW())'
);
$listingStmt->execute([
'uid' => $userId,
'listingType' => $entryType,
'placeId' => $placeId,
'title' => $title,
'teaser' => $teaser,
'teaser' => $teaser !== '' ? $teaser : $title,
'description' => $description,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null,
'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();
$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]);
@@ -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;
if ($entryType === 'editorial_event') {
[$occurrenceType, $recurrenceRule] = $this->buildRecurrence($recurrenceMode, $weekdayValues);
$occStmt = $this->pdo->prepare(
'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, status, created_at, updated_at)
VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, "scheduled", NOW(), NOW())'
'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, :capacityTotal, "scheduled", NOW(), NOW())'
);
$occStmt->execute([
'listingId' => $listingId,
@@ -442,12 +473,14 @@ final class ListingCatalog
'startsAt' => $startsAt !== '' ? $startsAt : null,
'recurrenceRule' => $recurrenceRule,
'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null,
'capacityTotal' => $capacityTotal,
]);
$occurrenceId = (int)$this->pdo->lastInsertId();
}
$this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
if ($priceMode === 'none') {
$priceRows = $this->normalizePriceRows($data);
if ($priceRows === []) {
return;
}
@@ -456,52 +489,16 @@ final class ListingCatalog
VALUES (:listingId, :occurrenceId, :label, :audience, :priceType, :amount, :amountSecondary, "EUR", :note, NOW(), NOW())'
);
if ($priceMode === 'free_child_rule') {
$freeUntil = trim((string)($data['free_child_until_age'] ?? ''));
foreach ($priceRows as $row) {
$insertPrice->execute([
'listingId' => $listingId,
'occurrenceId' => $occurrenceId,
'label' => 'Eintritt frei',
'audience' => 'child',
'priceType' => 'free',
'amount' => null,
'amountSecondary' => null,
'note' => $freeUntil !== '' ? 'Kinder bis ' . $freeUntil . ' Jahre frei' : 'Kinder frei',
]);
}
$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',
'label' => $row['label'],
'audience' => $row['audience'],
'priceType' => $row['price_type'],
'amount' => $row['amount'],
'amountSecondary' => $row['amount_secondary'],
'note' => $row['note'],
]);
}
}
@@ -539,6 +536,126 @@ final class ListingCatalog
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
{
$this->ensureSchema();