This commit is contained in:
@@ -23,6 +23,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
- Profiladresse kann per Suche oder Browser-Standort übernommen werden.
|
||||
- Im Bereich `Events` gibt es jetzt drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung.
|
||||
- Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb zuerst nur Pflichtangaben.
|
||||
- Kategorien werden im Mitgliederbereich nicht mehr per Select gewählt, sondern per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt.
|
||||
- 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`.
|
||||
|
||||
@@ -21,6 +21,7 @@ Stand: 2026-08-04
|
||||
- UI für Eintragstyp-Auswahl im Mitgliederbereich: `partials/landing/account/dashboard.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
|
||||
- Kategorien-Prüfung und Zusammenführung für Seiten-Admins liegen ebenfalls in `partials/landing/account/dashboard.php` mit Logik in `src/App/ListingCatalog.php`
|
||||
|
||||
## Dokumentationsregel
|
||||
Kanonische interne Dokumentation:
|
||||
|
||||
@@ -26,6 +26,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
||||
- 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
|
||||
- Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen
|
||||
- Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden
|
||||
|
||||
## Wording-Regel
|
||||
- Primärbegriff im Produkt: `Events`
|
||||
|
||||
@@ -23,6 +23,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
- Profile address can be completed via address search or browser-based location import.
|
||||
- The `Events` area now distinguishes between own events, places, and other time-limited event entries.
|
||||
- The `Events` input flow should stay as simple as possible and therefore shows required fields first.
|
||||
- Categories in the member area are no longer chosen via select only, but via a search input with existing suggestions and automatic creation when needed.
|
||||
- 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.
|
||||
|
||||
@@ -26,6 +26,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
|
||||
- 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
|
||||
- the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections
|
||||
- categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area
|
||||
|
||||
## Core Wording Rule
|
||||
- Primary product term: `Events`
|
||||
|
||||
@@ -40,6 +40,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
|
||||
- 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
|
||||
- Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen
|
||||
- Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar
|
||||
|
||||
## Datenschutz und Sicherheit
|
||||
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert
|
||||
|
||||
@@ -13,9 +13,9 @@ $currentEntryKind = $editingListing ? (string)($editListing['listing_type'] ?? '
|
||||
$actionEvent = $editing ? 'event_update' : 'event_add';
|
||||
$startVal = '';
|
||||
if ($editingEvent && !empty($editEvent['starts_at'])) {
|
||||
$startVal = date('Y-m-d\TH:i', strtotime((string)$editEvent['starts_at']));
|
||||
$startVal = date('Y-m-d', strtotime((string)$editEvent['starts_at']));
|
||||
} elseif ($editingListing && !empty($editListing['starts_at'])) {
|
||||
$startVal = date('Y-m-d\TH:i', strtotime((string)$editListing['starts_at']));
|
||||
$startVal = date('Y-m-d', strtotime((string)$editListing['starts_at']));
|
||||
}
|
||||
$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');
|
||||
@@ -23,7 +23,6 @@ $submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? ($currentE
|
||||
$titleValue = $editingEvent ? (string)($editEvent['title'] ?? '') : (string)($editListing['title'] ?? '');
|
||||
$teaserValue = $editingEvent ? (string)($editEvent['teaser_public'] ?? '') : (string)($editListing['teaser_public'] ?? '');
|
||||
$descriptionValue = $editingEvent ? (string)($editEvent['description'] ?? '') : (string)($editListing['description'] ?? '');
|
||||
$locationLabelValue = $editingEvent ? (string)($editEvent['location_label'] ?? '') : '';
|
||||
$streetValue = $editingEvent ? (string)($editEvent['street'] ?? '') : (string)($editListing['street'] ?? '');
|
||||
$zipValue = $editingEvent ? (string)($editEvent['zip'] ?? '') : (string)($editListing['zip'] ?? '');
|
||||
$cityValue = $editingEvent ? (string)($editEvent['city'] ?? '') : (string)($editListing['city'] ?? '');
|
||||
@@ -42,35 +41,45 @@ $openingHoursRows = $editingListing && !empty($editListing['opening_hours']) &&
|
||||
$recurrenceRuleValue = $editingListing ? (string)($editListing['recurrence_rule'] ?? '') : '';
|
||||
$recurrenceUntilValue = '';
|
||||
if ($editingListing && !empty($editListing['recurrence_until'])) {
|
||||
$recurrenceUntilValue = date('Y-m-d\TH:i', strtotime((string)$editListing['recurrence_until']));
|
||||
$recurrenceUntilValue = date('Y-m-d', strtotime((string)$editListing['recurrence_until']));
|
||||
}
|
||||
$recurrenceModeValue = 'single';
|
||||
if (str_contains($recurrenceRuleValue, 'FREQ=DAILY')) {
|
||||
$recurrenceModeValue = 'daily';
|
||||
} elseif (str_contains($recurrenceRuleValue, 'BYDAY=MO,TU,WE,TH,FR')) {
|
||||
$recurrenceModeValue = 'weekdays';
|
||||
} elseif (str_contains($recurrenceRuleValue, 'BYDAY=')) {
|
||||
$recurrenceModeValue = 'custom_weekdays';
|
||||
} elseif (str_contains($recurrenceRuleValue, 'FREQ=WEEKLY')) {
|
||||
$recurrenceModeValue = 'weekly';
|
||||
}
|
||||
$selectedWeekdays = [];
|
||||
if (preg_match('/BYDAY=([A-Z,]+)/', $recurrenceRuleValue, $matches)) {
|
||||
$map = ['MO' => 'mo', 'TU' => 'di', 'WE' => 'mi', 'TH' => 'do', 'FR' => 'fr', 'SA' => 'sa', 'SU' => 'so'];
|
||||
foreach (explode(',', $matches[1]) as $day) {
|
||||
if (isset($map[$day])) {
|
||||
$selectedWeekdays[] = $map[$day];
|
||||
$categoryTitleMap = [];
|
||||
foreach (($listingCategories ?? []) as $categoryRow) {
|
||||
$categoryTitleMap[(string)($categoryRow['slug'] ?? '')] = (string)($categoryRow['title'] ?? '');
|
||||
}
|
||||
$categoryInputValue = $categoryTitleMap[$categorySlugValue] ?? $categorySlugValue;
|
||||
$priceRows = [];
|
||||
if ($editingListing && !empty($editListing['prices']) && is_array($editListing['prices'])) {
|
||||
foreach ($editListing['prices'] as $priceRow) {
|
||||
$ageFrom = '';
|
||||
$ageTo = '';
|
||||
$noteValue = (string)($priceRow['note'] ?? '');
|
||||
if (preg_match('/ab\s+(\d+)/u', $noteValue, $match)) {
|
||||
$ageFrom = $match[1];
|
||||
}
|
||||
if (preg_match('/bis\s+(\d+)/u', $noteValue, $match)) {
|
||||
$ageTo = $match[1];
|
||||
}
|
||||
$priceRows[] = [
|
||||
'amount' => (string)($priceRow['amount'] ?? ''),
|
||||
'age_from' => $ageFrom,
|
||||
'age_to' => $ageTo,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$priceRows = $editingListing && !empty($editListing['prices']) && is_array($editListing['prices']) ? $editListing['prices'] : [[
|
||||
'label' => '',
|
||||
'audience' => 'general',
|
||||
'price_type' => 'fixed',
|
||||
if ($priceRows === []) {
|
||||
$priceRows = [[
|
||||
'amount' => '',
|
||||
'amount_secondary' => '',
|
||||
'note' => '',
|
||||
'age_from' => '',
|
||||
'age_to' => '',
|
||||
]];
|
||||
}
|
||||
$editingChild = isset($editChild) && $editChild !== null;
|
||||
$childAction = $editingChild ? 'child_update' : 'child_add';
|
||||
$childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen';
|
||||
@@ -509,6 +518,44 @@ if (!empty($canManageSystemSettings)) {
|
||||
<li>Fehlende Tabellen: <?= !empty($listingCatalogStatus['missing']) ? htmlspecialchars(implode(', ', $listingCatalogStatus['missing']), ENT_QUOTES) : 'Keine' ?></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:18px;">
|
||||
<strong>Kategorien prüfen und zusammenführen</strong>
|
||||
<p class="muted small" style="margin:10px 0 14px;">Neue Kategorien werden automatisch angelegt. Hier kannst du sie prüfen und bei Bedarf mit einer bestehenden Kategorie zusammenführen.</p>
|
||||
<?php if (!empty($categoryReviewItems)): ?>
|
||||
<div class="stack gap-10">
|
||||
<?php foreach ($categoryReviewItems as $categoryItem): ?>
|
||||
<div class="card" style="padding:12px;">
|
||||
<div class="flex between center-y" style="gap:12px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<strong><?= htmlspecialchars((string)$categoryItem['title'], ENT_QUOTES) ?></strong>
|
||||
<?php if (!empty($categoryItem['needs_review'])): ?>
|
||||
<span class="badge" style="margin-left:8px;">Neu</span>
|
||||
<?php endif; ?>
|
||||
<div class="muted small" style="margin-top:6px;">
|
||||
Slug: <?= htmlspecialchars((string)$categoryItem['slug'], ENT_QUOTES) ?> · Einträge: <?= (int)($categoryItem['listing_count'] ?? 0) ?> · Orte: <?= (int)($categoryItem['place_count'] ?? 0) ?> · Eigene Events: <?= (int)($categoryItem['event_count'] ?? 0) ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="flex gap-8" style="margin-top:12px; flex-wrap:wrap;">
|
||||
<input type="hidden" name="action" value="category_merge">
|
||||
<input type="hidden" name="source_category_slug" value="<?= htmlspecialchars((string)$categoryItem['slug'], ENT_QUOTES) ?>">
|
||||
<select name="target_category_slug" class="select" style="min-width:260px;">
|
||||
<option value="">Mit Kategorie zusammenführen</option>
|
||||
<?php foreach ($listingCategories as $targetCategory): ?>
|
||||
<?php if ((string)$targetCategory['slug'] === (string)$categoryItem['slug']) { continue; } ?>
|
||||
<option value="<?= htmlspecialchars((string)$targetCategory['slug'], ENT_QUOTES) ?>"><?= htmlspecialchars((string)$targetCategory['title'], ENT_QUOTES) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button class="btn ghost" type="submit">Zusammenführen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="muted small" style="margin:12px 0 0;">Aktuell gibt es keine Kategorien zur Prüfung.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
@@ -921,9 +968,6 @@ if (!empty($canManageSystemSettings)) {
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="card" id="entryKindQuickGuide">
|
||||
<p class="muted small" style="margin:0;">Trage zuerst nur die wichtigsten Angaben ein. Alles Weitere ist optional und unten einklappbar.</p>
|
||||
</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) ?>">
|
||||
@@ -933,44 +977,22 @@ if (!empty($canManageSystemSettings)) {
|
||||
<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>
|
||||
<label class="label" for="categoryInput">Kategorie</label>
|
||||
<input id="categoryInput" name="category_input" class="input" list="listingCategoryList" placeholder="z. B. Spielplatz, Café, Zirkus" value="<?= htmlspecialchars($categoryInputValue, ENT_QUOTES) ?>">
|
||||
<input type="hidden" id="listingCategorySlug" name="category_slug" value="<?= htmlspecialchars($categorySlugValue, ENT_QUOTES) ?>">
|
||||
<datalist id="listingCategoryList">
|
||||
<?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>
|
||||
<option value="<?= htmlspecialchars((string)$category['title'], ENT_QUOTES) ?>"></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</datalist>
|
||||
<div id="categoryInputStatus" class="muted small" style="margin:0;"></div>
|
||||
</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>
|
||||
<details id="placeLookupWrap" class="card">
|
||||
<summary style="cursor:pointer; font-weight:600;">Vorhandenen Ort übernehmen</summary>
|
||||
<div class="stack gap-6" style="margin-top:12px;">
|
||||
<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>
|
||||
</details>
|
||||
<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) ?>">
|
||||
<label class="label" for="evDate" id="eventDateLabel">Datum</label>
|
||||
<input id="evDate" name="starts_at" class="input" type="date" 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">
|
||||
@@ -978,10 +1000,16 @@ if (!empty($canManageSystemSettings)) {
|
||||
<option value="no" <?= $withChildrenValue === 'no' ? 'selected' : '' ?>>Nein</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid" id="eventMetaRow">
|
||||
<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 class="stack gap-6" id="eventValidUntilWrap">
|
||||
<label class="label" for="recurrenceUntil">Gültig bis</label>
|
||||
<input id="recurrenceUntil" name="recurrence_until" class="input" type="date" value="<?= htmlspecialchars($recurrenceUntilValue, ENT_QUOTES) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack gap-6" id="eventRecurrenceWrap">
|
||||
<label class="label" for="recurrenceMode">Wiederholung</label>
|
||||
@@ -989,21 +1017,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
<option value="single" <?= $recurrenceModeValue === 'single' ? 'selected' : '' ?>>Einmalig</option>
|
||||
<option value="daily" <?= $recurrenceModeValue === 'daily' ? 'selected' : '' ?>>Täglich</option>
|
||||
<option value="weekly" <?= $recurrenceModeValue === 'weekly' ? 'selected' : '' ?>>Wöchentlich</option>
|
||||
<option value="weekdays" <?= $recurrenceModeValue === 'weekdays' ? 'selected' : '' ?>>Montag bis Freitag</option>
|
||||
<option value="custom_weekdays" <?= $recurrenceModeValue === 'custom_weekdays' ? 'selected' : '' ?>>Bestimmte Wochentage</option>
|
||||
</select>
|
||||
<div class="chips" id="recurrenceWeekdaysWrap" style="flex-wrap:wrap;">
|
||||
<?php foreach (['mo' => 'Mo', 'di' => 'Di', 'mi' => 'Mi', 'do' => 'Do', 'fr' => 'Fr', 'sa' => 'Sa', 'so' => 'So'] as $weekdayValue => $weekdayLabel): ?>
|
||||
<label class="chip" style="cursor:pointer;">
|
||||
<input type="checkbox" name="recurrence_weekdays[]" value="<?= htmlspecialchars($weekdayValue, ENT_QUOTES) ?>" <?= in_array($weekdayValue, $selectedWeekdays, true) ? 'checked' : '' ?> style="margin-right:6px;">
|
||||
<?= htmlspecialchars($weekdayLabel, ENT_QUOTES) ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<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>
|
||||
<div class="form-grid">
|
||||
<div class="stack gap-6">
|
||||
@@ -1028,7 +1042,7 @@ 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="btnEventBrowserLocation">📍 Aktuellen Standort</button>
|
||||
</div>
|
||||
<div id="eventAddressStatus" class="muted small" style="margin:0;"></div>
|
||||
<input type="hidden" id="evLat" name="lat" value="<?= htmlspecialchars($latValue, ENT_QUOTES) ?>">
|
||||
@@ -1037,8 +1051,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
<summary style="cursor:pointer; font-weight:600;">Adresse auf Karte wählen</summary>
|
||||
<div class="stack gap-6" style="margin-top:12px;">
|
||||
<div class="flex gap-8" style="flex-wrap:wrap; align-items:center;">
|
||||
<button class="btn ghost" type="button" id="btnAddrToMap">Adresse auf Karte setzen</button>
|
||||
<span class="muted small">Hält Karte und Adresse synchron.</span>
|
||||
<button class="btn ghost" type="button" id="btnEventMapCurrentLocation">📍 Aktuellen Standort auf Karte zeigen</button>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<button class="btn ghost" type="button" id="btnMap">Auf Karte suchen</button>
|
||||
@@ -1122,29 +1135,12 @@ if (!empty($canManageSystemSettings)) {
|
||||
<?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>
|
||||
<input name="price_age_from[]" class="input" type="number" min="0" max="99" placeholder="Alter von" value="<?= htmlspecialchars((string)($priceRow['age_from'] ?? ''), ENT_QUOTES) ?>">
|
||||
<input name="price_age_to[]" class="input" type="number" min="0" max="99" placeholder="Alter bis" value="<?= htmlspecialchars((string)($priceRow['age_to'] ?? ''), ENT_QUOTES) ?>">
|
||||
</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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -1188,7 +1184,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
const mapWrapper = document.getElementById('mapWrapper');
|
||||
const mapContainer = document.getElementById('mapContainer');
|
||||
const btnMapSearch = document.getElementById('btnMapSearch');
|
||||
const btnAddrToMap = document.getElementById('btnAddrToMap');
|
||||
const btnEventMapCurrentLocation = document.getElementById('btnEventMapCurrentLocation');
|
||||
const btnEventAddressSearch = document.getElementById('btnEventAddressSearch');
|
||||
const btnEventBrowserLocation = document.getElementById('btnEventBrowserLocation');
|
||||
const eventAddressStatus = document.getElementById('eventAddressStatus');
|
||||
@@ -1199,24 +1195,24 @@ if (!empty($canManageSystemSettings)) {
|
||||
const entryKindHint = document.getElementById('entryKindHint');
|
||||
const entryKindButtons = document.querySelectorAll('[data-entry-kind-choice]');
|
||||
const eventDateRow = document.getElementById('eventDateRow');
|
||||
const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap');
|
||||
const eventDateLabel = document.getElementById('eventDateLabel');
|
||||
const eventChildrenWrap = document.getElementById('eventChildrenWrap');
|
||||
const eventCapacityWrap = document.getElementById('eventCapacityWrap');
|
||||
const eventValidUntilWrap = document.getElementById('eventValidUntilWrap');
|
||||
const eventPricingWrap = document.getElementById('eventPricingWrap');
|
||||
const visibilityDetails = document.getElementById('visibilityDetails');
|
||||
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 categoryInput = document.getElementById('categoryInput');
|
||||
const categorySlugInput = document.getElementById('listingCategorySlug');
|
||||
const categoryInputStatus = document.getElementById('categoryInputStatus');
|
||||
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');
|
||||
const zipInput = document.getElementById('evZip');
|
||||
const cityInput = document.getElementById('evCity');
|
||||
@@ -1236,7 +1232,8 @@ if (!empty($canManageSystemSettings)) {
|
||||
const profileAddressResults = document.getElementById('profileAddressResults');
|
||||
const profileAddressResultsModal = document.getElementById('modalProfileAddressResults');
|
||||
let map, marker;
|
||||
const existingPlaces = <?= json_encode($placeSuggestions ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||
const existingCategories = <?= json_encode($listingCategories ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||
let lastAutoGeocodeQuery = '';
|
||||
|
||||
function normalizeGeoResult(result) {
|
||||
const address = result.address || {};
|
||||
@@ -1286,21 +1283,18 @@ if (!empty($canManageSystemSettings)) {
|
||||
|
||||
setSectionState(eventDateRow, kind !== 'place');
|
||||
if (eventDateInput) eventDateInput.required = kind !== 'place';
|
||||
setSectionState(eventLocationLabelWrap, kind === 'event');
|
||||
if (categorySelect) categorySelect.required = true;
|
||||
if (categoryInput) categoryInput.required = true;
|
||||
setSectionState(eventChildrenWrap, kind === 'event' || kind === 'editorial_event');
|
||||
setSectionState(eventCapacityWrap, kind === 'event');
|
||||
setSectionState(eventValidUntilWrap, kind === 'editorial_event');
|
||||
setSectionState(eventPricingWrap, kind === 'place' || kind === 'editorial_event');
|
||||
setSectionState(placeLookupWrap, kind === 'place' || kind === 'editorial_event');
|
||||
setSectionState(placeContactWrap, kind === 'place' || kind === 'editorial_event');
|
||||
setSectionState(placeOpeningHoursWrap, kind === 'place' || kind === 'editorial_event');
|
||||
setSectionState(eventRecurrenceWrap, kind === 'editorial_event');
|
||||
syncRecurrenceVisibility();
|
||||
setSectionState(visibilityDetails, kind === 'event');
|
||||
if (eventDateLabel) {
|
||||
eventDateLabel.textContent = kind === 'editorial_event' ? 'Gültig von' : 'Datum';
|
||||
}
|
||||
|
||||
function syncRecurrenceVisibility() {
|
||||
const mode = recurrenceModeSelect?.value || 'single';
|
||||
if (recurrenceWeekdaysWrap) recurrenceWeekdaysWrap.hidden = mode !== 'custom_weekdays';
|
||||
}
|
||||
|
||||
entryKindButtons.forEach((button) => {
|
||||
@@ -1308,10 +1302,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event');
|
||||
});
|
||||
});
|
||||
recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility);
|
||||
|
||||
syncEventEntryKind(entryKindInput?.value || 'event');
|
||||
syncRecurrenceVisibility();
|
||||
|
||||
function createOpeningRow() {
|
||||
const row = document.createElement('div');
|
||||
@@ -1329,35 +1320,13 @@ if (!empty($canManageSystemSettings)) {
|
||||
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>
|
||||
<div class="form-grid">
|
||||
<input name="price_amount[]" class="input" inputmode="decimal" placeholder="Preis">
|
||||
</div>
|
||||
`;
|
||||
priceRows?.appendChild(row);
|
||||
}
|
||||
@@ -1365,29 +1334,30 @@ if (!empty($canManageSystemSettings)) {
|
||||
btnAddOpeningRow?.addEventListener('click', createOpeningRow);
|
||||
btnAddPriceRow?.addEventListener('click', createPriceRow);
|
||||
|
||||
function applyExistingPlace(place) {
|
||||
if (!place) return;
|
||||
if (document.getElementById('evTitle') && !document.getElementById('evTitle').value.trim()) {
|
||||
document.getElementById('evTitle').value = place.title || '';
|
||||
}
|
||||
if (streetInput) streetInput.value = place.street || '';
|
||||
if (zipInput) zipInput.value = place.zip || '';
|
||||
if (cityInput) cityInput.value = place.city || '';
|
||||
if (regionInput) regionInput.value = place.region || '';
|
||||
if (categorySelect && place.place_kind) categorySelect.value = place.place_kind;
|
||||
}
|
||||
|
||||
btnUseExistingPlace?.addEventListener('click', () => {
|
||||
const query = (existingPlaceLookup?.value || '').trim().toLowerCase();
|
||||
if (!query) return;
|
||||
const match = existingPlaces.find((place) => String(place.title || '').trim().toLowerCase() === query);
|
||||
if (match) {
|
||||
applyExistingPlace(match);
|
||||
if (eventAddressStatus) eventAddressStatus.textContent = 'Vorhandener Ort übernommen.';
|
||||
function syncCategoryInputState() {
|
||||
const value = (categoryInput?.value || '').trim().toLowerCase();
|
||||
if (!categorySlugInput || !categoryInputStatus) return;
|
||||
if (!value) {
|
||||
categorySlugInput.value = '';
|
||||
categoryInputStatus.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (eventAddressStatus) eventAddressStatus.textContent = 'Kein vorhandener Ort exakt gefunden. Du kannst unten einen neuen Ort anlegen.';
|
||||
const match = existingCategories.find((category) => {
|
||||
const title = String(category.title || '').trim().toLowerCase();
|
||||
const slug = String(category.slug || '').trim().toLowerCase();
|
||||
return value === title || value === slug;
|
||||
});
|
||||
if (match) {
|
||||
categorySlugInput.value = String(match.slug || '');
|
||||
categoryInputStatus.textContent = 'Bestehende Kategorie wird verwendet.';
|
||||
return;
|
||||
}
|
||||
categorySlugInput.value = '';
|
||||
categoryInputStatus.textContent = 'Wird neu angelegt.';
|
||||
}
|
||||
|
||||
categoryInput?.addEventListener('input', syncCategoryInputState);
|
||||
syncCategoryInputState();
|
||||
|
||||
function ensureLeaflet(callback) {
|
||||
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
|
||||
@@ -1464,6 +1434,10 @@ if (!empty($canManageSystemSettings)) {
|
||||
if (Array.isArray(data) && data[0]) {
|
||||
const lat = parseFloat(data[0].lat);
|
||||
const lng = parseFloat(data[0].lon);
|
||||
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
|
||||
if (latInput) latInput.value = lat.toFixed(7);
|
||||
if (lngInput) lngInput.value = lng.toFixed(7);
|
||||
}
|
||||
ensureLeaflet(() => {
|
||||
mapWrapper.hidden = false;
|
||||
initMap();
|
||||
@@ -1559,17 +1533,6 @@ if (!empty($canManageSystemSettings)) {
|
||||
geocodeAndPlace(q);
|
||||
});
|
||||
|
||||
btnAddrToMap?.addEventListener('click', () => {
|
||||
const parts = [
|
||||
streetInput?.value || '',
|
||||
zipInput?.value || '',
|
||||
cityInput?.value || '',
|
||||
regionInput?.value || '',
|
||||
].map(v => v.trim()).filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
geocodeAndPlace(parts.join(', '));
|
||||
});
|
||||
|
||||
btnEventAddressSearch?.addEventListener('click', () => {
|
||||
const parts = [
|
||||
streetInput?.value || '',
|
||||
@@ -1593,6 +1556,14 @@ if (!empty($canManageSystemSettings)) {
|
||||
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 = `Gewählte Adresse: ${normalized.label || [normalized.street, `${normalized.zip} ${normalized.city}`.trim(), normalized.region].filter(Boolean).join(', ')}`;
|
||||
if (!mapWrapper.hidden) {
|
||||
ensureLeaflet(() => {
|
||||
initMap();
|
||||
if (!Number.isNaN(normalized.lat) && !Number.isNaN(normalized.lng)) {
|
||||
setMarker({ lat: normalized.lat, lng: normalized.lng });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1617,6 +1588,13 @@ if (!empty($canManageSystemSettings)) {
|
||||
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(', ')}`;
|
||||
ensureLeaflet(() => {
|
||||
mapWrapper.hidden = false;
|
||||
initMap();
|
||||
if (!Number.isNaN(normalized.lat) && !Number.isNaN(normalized.lng)) {
|
||||
setMarker({ lat: normalized.lat, lng: normalized.lng });
|
||||
}
|
||||
});
|
||||
});
|
||||
}, () => {
|
||||
if (eventAddressStatus) eventAddressStatus.textContent = '';
|
||||
@@ -1624,6 +1602,43 @@ if (!empty($canManageSystemSettings)) {
|
||||
}, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 });
|
||||
});
|
||||
|
||||
btnEventMapCurrentLocation?.addEventListener('click', () => {
|
||||
btnEventBrowserLocation?.click();
|
||||
});
|
||||
|
||||
function syncMapFromAddressFields() {
|
||||
const parts = [
|
||||
streetInput?.value || '',
|
||||
zipInput?.value || '',
|
||||
cityInput?.value || '',
|
||||
regionInput?.value || '',
|
||||
].map((value) => value.trim()).filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
const query = parts.join(', ');
|
||||
if (query === lastAutoGeocodeQuery) return;
|
||||
lastAutoGeocodeQuery = query;
|
||||
geocodeAddressCandidates(query, (results) => {
|
||||
const first = results?.[0];
|
||||
if (!first) return;
|
||||
const normalized = normalizeGeoResult(first);
|
||||
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 (!mapWrapper.hidden) {
|
||||
ensureLeaflet(() => {
|
||||
initMap();
|
||||
if (!Number.isNaN(normalized.lat) && !Number.isNaN(normalized.lng)) {
|
||||
setMarker({ lat: normalized.lat, lng: normalized.lng });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[streetInput, zipInput, cityInput, regionInput].forEach((field) => {
|
||||
field?.addEventListener('change', syncMapFromAddressFields);
|
||||
field?.addEventListener('blur', syncMapFromAddressFields);
|
||||
});
|
||||
|
||||
(function(){
|
||||
const setProfileStatus = (message = '') => {
|
||||
if (!profileAddressStatus) return;
|
||||
|
||||
@@ -309,6 +309,15 @@ final class AccountPages
|
||||
'place_data_provider' => $placeDataProvider,
|
||||
], $userId);
|
||||
$info = 'System-Einstellungen gespeichert.';
|
||||
} elseif ($action === 'category_merge') {
|
||||
if (!$canManageSystemSettings || !$listingCatalog) {
|
||||
throw new \RuntimeException('Keine Berechtigung für die Kategorien-Verwaltung.');
|
||||
}
|
||||
$listingCatalog->mergeCategories(
|
||||
(string)($_POST['source_category_slug'] ?? ''),
|
||||
(string)($_POST['target_category_slug'] ?? '')
|
||||
);
|
||||
$info = 'Kategorie zusammengeführt.';
|
||||
} elseif ($action === 'child_add' || $action === 'child_update') {
|
||||
$crypto = self::requireCrypto($crypto, 'Kinder');
|
||||
$childId = (int)($_POST['child_id'] ?? 0);
|
||||
@@ -459,12 +468,15 @@ final class AccountPages
|
||||
$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);
|
||||
$categoryInput = trim((string)($_POST['category_input'] ?? $_POST['category_slug'] ?? ''));
|
||||
$categorySlug = '';
|
||||
if ($categoryInput !== '' && $listingCatalog) {
|
||||
$category = $listingCatalog->ensureCategoryForInput($categoryInput, 'event');
|
||||
$categorySlug = (string)($category['slug'] ?? '');
|
||||
}
|
||||
|
||||
$eventStartValue = self::normalizeDateForStorage((string)($_POST['starts_at'] ?? ''), true);
|
||||
|
||||
if ($action === 'event_add') {
|
||||
$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([
|
||||
@@ -474,14 +486,14 @@ final class AccountPages
|
||||
'descr' => trim((string)$_POST['description']),
|
||||
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
|
||||
'imagePath' => $imagePath !== '' ? $imagePath : null,
|
||||
'loc' => trim((string)$_POST['location_label']),
|
||||
'loc' => null,
|
||||
'street' => $street ?: null,
|
||||
'zip' => $zip,
|
||||
'city' => $city,
|
||||
'region' => $region,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'start' => $_POST['starts_at'] ?? null,
|
||||
'start' => $eventStartValue,
|
||||
'capacity' => $capacity,
|
||||
'allow' => $allowKids,
|
||||
'vis' => $_POST['visibility'] ?? 'public',
|
||||
@@ -506,14 +518,14 @@ final class AccountPages
|
||||
'descr' => trim((string)$_POST['description']),
|
||||
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
|
||||
'imagePath' => $imagePath !== '' ? $imagePath : null,
|
||||
'loc' => trim((string)$_POST['location_label']),
|
||||
'loc' => null,
|
||||
'street' => $street ?: null,
|
||||
'zip' => $zip,
|
||||
'city' => $city,
|
||||
'region' => $region,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'start' => $_POST['starts_at'] ?? null,
|
||||
'start' => $eventStartValue,
|
||||
'capacity' => $capacity,
|
||||
'allow' => $allowKids,
|
||||
'vis' => $_POST['visibility'] ?? 'public',
|
||||
@@ -682,7 +694,8 @@ final class AccountPages
|
||||
$editEvent = null;
|
||||
$otherListings = [];
|
||||
$editListing = null;
|
||||
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : [];
|
||||
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['general', 'place', 'food', 'event', 'family']) : [];
|
||||
$categoryReviewItems = $listingCatalog && $canManageSystemSettings ? $listingCatalog->listCategoryReviewItems() : [];
|
||||
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
|
||||
$stmt = $pdo?->prepare(
|
||||
'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,
|
||||
@@ -750,6 +763,7 @@ final class AccountPages
|
||||
'otherListings',
|
||||
'editListing',
|
||||
'listingCategories',
|
||||
'categoryReviewItems',
|
||||
'placeSuggestions',
|
||||
'communityPoints',
|
||||
'communityLevel',
|
||||
@@ -973,6 +987,20 @@ final class AccountPages
|
||||
return trim($value, '-');
|
||||
}
|
||||
|
||||
private static function normalizeDateForStorage(string $value, bool $endOfDay = false): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||
return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function reverseGeocodeAddress(float $lat, float $lng): ?array
|
||||
{
|
||||
$url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([
|
||||
|
||||
@@ -217,6 +217,136 @@ final class ListingCatalog
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function ensureCategoryForInput(string $input, string $group = 'general'): ?array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$input = trim($input);
|
||||
if ($input === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$slug = $this->slugify($input);
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT id, slug, title, category_group, sort_order
|
||||
FROM listing_categories
|
||||
WHERE slug = :slug OR LOWER(title) = LOWER(:title)
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([
|
||||
'slug' => $slug,
|
||||
'title' => $input,
|
||||
]);
|
||||
$existing = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
if ($existing) {
|
||||
$existing['is_new'] = false;
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$insert = $this->pdo->prepare(
|
||||
'INSERT INTO listing_categories (slug, title, category_group, sort_order)
|
||||
VALUES (:slug, :title, :groupName, 999)'
|
||||
);
|
||||
$insert->execute([
|
||||
'slug' => $slug,
|
||||
'title' => $input,
|
||||
'groupName' => in_array($group, ['general', 'event', 'place', 'food', 'family', 'partner'], true) ? $group : 'general',
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int)$this->pdo->lastInsertId(),
|
||||
'slug' => $slug,
|
||||
'title' => $input,
|
||||
'category_group' => $group,
|
||||
'sort_order' => 999,
|
||||
'is_new' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function listCategoryReviewItems(): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$stmt = $this->pdo->query(
|
||||
'SELECT c.id, c.slug, c.title, c.category_group, c.sort_order,
|
||||
(SELECT COUNT(*) FROM listing_category_map m WHERE m.category_id = c.id) AS listing_count,
|
||||
(SELECT COUNT(*) FROM listing_places p WHERE p.place_kind = c.slug) AS place_count
|
||||
FROM listing_categories c
|
||||
ORDER BY
|
||||
CASE WHEN c.sort_order >= 900 THEN 0 ELSE 1 END,
|
||||
c.sort_order ASC,
|
||||
c.title ASC'
|
||||
);
|
||||
$rows = $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : [];
|
||||
|
||||
$eventStmt = $this->pdo->query('SELECT category_slug, COUNT(*) AS total FROM events WHERE category_slug IS NOT NULL AND category_slug <> "" GROUP BY category_slug');
|
||||
$eventCounts = [];
|
||||
foreach (($eventStmt ? $eventStmt->fetchAll(\PDO::FETCH_ASSOC) : []) as $row) {
|
||||
$eventCounts[(string)$row['category_slug']] = (int)$row['total'];
|
||||
}
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['event_count'] = $eventCounts[(string)$row['slug']] ?? 0;
|
||||
$row['needs_review'] = ((int)($row['sort_order'] ?? 0)) >= 900;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function mergeCategories(string $sourceSlug, string $targetSlug): void
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$sourceSlug = trim($sourceSlug);
|
||||
$targetSlug = trim($targetSlug);
|
||||
if ($sourceSlug === '' || $targetSlug === '' || $sourceSlug === $targetSlug) {
|
||||
throw new \RuntimeException('Bitte zwei unterschiedliche Kategorien auswählen.');
|
||||
}
|
||||
|
||||
$lookup = $this->pdo->prepare('SELECT id, slug FROM listing_categories WHERE slug = :slug LIMIT 1');
|
||||
$lookup->execute(['slug' => $sourceSlug]);
|
||||
$source = $lookup->fetch(\PDO::FETCH_ASSOC);
|
||||
$lookup->execute(['slug' => $targetSlug]);
|
||||
$target = $lookup->fetch(\PDO::FETCH_ASSOC);
|
||||
if (!$source || !$target) {
|
||||
throw new \RuntimeException('Kategorie nicht gefunden.');
|
||||
}
|
||||
|
||||
$sourceId = (int)$source['id'];
|
||||
$targetId = (int)$target['id'];
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$mapRows = $this->pdo->prepare('SELECT listing_id FROM listing_category_map WHERE category_id = :categoryId');
|
||||
$mapRows->execute(['categoryId' => $sourceId]);
|
||||
$listingIds = $mapRows->fetchAll(\PDO::FETCH_COLUMN) ?: [];
|
||||
$insertMap = $this->pdo->prepare(
|
||||
'INSERT IGNORE INTO listing_category_map (listing_id, category_id) VALUES (:listingId, :categoryId)'
|
||||
);
|
||||
foreach ($listingIds as $listingId) {
|
||||
$insertMap->execute([
|
||||
'listingId' => (int)$listingId,
|
||||
'categoryId' => $targetId,
|
||||
]);
|
||||
}
|
||||
$this->pdo->prepare('DELETE FROM listing_category_map WHERE category_id = :categoryId')->execute(['categoryId' => $sourceId]);
|
||||
$this->pdo->prepare('UPDATE listing_places SET place_kind = :target WHERE place_kind = :source')->execute([
|
||||
'target' => $targetSlug,
|
||||
'source' => $sourceSlug,
|
||||
]);
|
||||
$this->pdo->prepare('UPDATE events SET category_slug = :target WHERE category_slug = :source')->execute([
|
||||
'target' => $targetSlug,
|
||||
'source' => $sourceSlug,
|
||||
]);
|
||||
$this->pdo->prepare('DELETE FROM listing_categories WHERE id = :id')->execute(['id' => $sourceId]);
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function listDashboardEntries(int $userId): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
@@ -302,8 +432,8 @@ final class ListingCatalog
|
||||
$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'] ?? ''));
|
||||
$categoryInput = trim((string)($data['category_input'] ?? $data['category_slug'] ?? ''));
|
||||
$placeKind = $categoryInput;
|
||||
$street = trim((string)($data['street'] ?? ''));
|
||||
$zip = trim((string)($data['zip'] ?? ''));
|
||||
$city = trim((string)($data['city'] ?? ''));
|
||||
@@ -332,10 +462,14 @@ final class ListingCatalog
|
||||
if ($entryType === 'editorial_event' && $recurrenceUntil === '') {
|
||||
throw new \RuntimeException('Bitte gib für Veranstaltungen ein Gültig-bis-Datum an.');
|
||||
}
|
||||
$startsAt = $this->normalizeDateForStorage($startsAt, false);
|
||||
$recurrenceUntil = $this->normalizeDateForStorage($recurrenceUntil, true);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
[$placeKind, $categoryId] = $this->resolveCategory($placeKind, $customCategoryTitle);
|
||||
$category = $this->ensureCategoryForInput($categoryInput, $entryType === 'place' ? 'place' : 'event');
|
||||
$placeKind = (string)($category['slug'] ?? '');
|
||||
$categoryId = isset($category['id']) ? (int)$category['id'] : null;
|
||||
|
||||
if ($listingId !== null && $listingId > 0) {
|
||||
$existing = $this->getDashboardEntry($userId, $listingId);
|
||||
@@ -536,31 +670,6 @@ 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'] ?? []);
|
||||
@@ -586,32 +695,19 @@ final class ListingCatalog
|
||||
|
||||
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));
|
||||
$count = max(count($amounts), count($ageFroms), count($ageTos));
|
||||
$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 === '') {
|
||||
if ($amount === '' && $ageFrom === '' && $ageTo === '') {
|
||||
continue;
|
||||
}
|
||||
$noteParts = [];
|
||||
if ($note !== '') {
|
||||
$noteParts[] = $note;
|
||||
}
|
||||
if ($ageFrom !== '' || $ageTo !== '') {
|
||||
$ageText = [];
|
||||
if ($ageFrom !== '') {
|
||||
@@ -623,11 +719,11 @@ final class ListingCatalog
|
||||
$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',
|
||||
'label' => 'Preis',
|
||||
'audience' => 'general',
|
||||
'price_type' => $amount === '' ? 'request' : 'fixed',
|
||||
'amount' => $amount !== '' ? (float)str_replace(',', '.', $amount) : null,
|
||||
'amount_secondary' => $secondary !== '' ? (float)str_replace(',', '.', $secondary) : null,
|
||||
'amount_secondary' => null,
|
||||
'note' => $noteParts !== [] ? implode(' | ', $noteParts) : null,
|
||||
];
|
||||
}
|
||||
@@ -648,6 +744,18 @@ final class ListingCatalog
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function normalizeDateForStorage(string $value, bool $endOfDay): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||
return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value));
|
||||
|
||||
Reference in New Issue
Block a user