This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,16 +921,31 @@ 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 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 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="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 class="stack gap-6" id="placeLookupWrap">
|
||||
<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;">
|
||||
<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="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">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) ?>">
|
||||
</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) ?>">
|
||||
<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="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>
|
||||
<?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>
|
||||
<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="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>
|
||||
</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 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 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 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>
|
||||
<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 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) ?>">
|
||||
</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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<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>
|
||||
<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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user