This commit is contained in:
@@ -82,6 +82,8 @@ CREATE TABLE events (
|
||||
category_slug VARCHAR(120) NULL,
|
||||
image_path VARCHAR(255) NULL,
|
||||
location_label VARCHAR(180) NULL,
|
||||
location_source_type ENUM('custom','place','editorial_event') NOT NULL DEFAULT 'custom',
|
||||
location_source_listing_id BIGINT UNSIGNED NULL,
|
||||
street VARCHAR(180) NULL,
|
||||
zip CHAR(5) NULL,
|
||||
city VARCHAR(120) NULL,
|
||||
|
||||
@@ -24,7 +24,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
- Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt.
|
||||
- Der Bereich `Events` bietet zusätzlich einen manuellen ICS-Export und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen.
|
||||
- Der Bereich `Events` ist im UI in `Meine Events`, `Kalendersynchronisation` und `Abgelaufene Events` getrennt, damit aktive Events, Kalender-Themen und Vergangenes sauber getrennt bleiben.
|
||||
- In `Meine Events` sitzt der Button `Neues Event anlegen` direkt in der Box `Eigene Events`; die bisherige Teilnahme-Box heißt jetzt `Angemeldete Events` und enthält zusätzlich einen direkten Link zur `Event Suche`.
|
||||
- In `Meine Events` sitzt der Button `Neues Event anlegen` jetzt direkt in der oberen Tab-Zeile; die bisherige Teilnahme-Box heißt `Angemeldete Events` und enthält zusätzlich einen direkten Link zur `Event Suche`.
|
||||
- Orte und Veranstaltungen sind vorerst in einen separaten Mitgliederbereichspunkt `Orte & Veranstaltungen` verschoben.
|
||||
- In `Orte & Veranstaltungen` können Nutzer dauerhafte Orte und zeitlich begrenzte Veranstaltungen neu anlegen.
|
||||
- Neue Orte und Veranstaltungen bleiben bis zur Freigabe im Status `wartet auf Freigabe` und werden erst danach systemweit veröffentlicht.
|
||||
@@ -32,8 +32,12 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
||||
- Vorschläge für neue Orte und Veranstaltungen dürfen schon vor der Freigabe als Ortsvorschlag für Events genutzt werden, solange sie noch offen geprüft werden.
|
||||
- Dubletten bei Orten und Veranstaltungen sollen serverseitig mindestens über Namen und Adresse abgefangen werden.
|
||||
- Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder.
|
||||
- Eigene Events nutzen jetzt als Pflichtfelder nur noch Titel, Datum und `Mit Kindern`; die Beschreibung ist optional.
|
||||
- Für die Event-Location gibt es jetzt die Auswahl `Ort aus Datenbank`, `Veranstaltung aus Datenbank` oder `Benutzerdefiniert`.
|
||||
- Bei `Ort` oder `Veranstaltung` werden Adresse und Kategorie direkt aus dem gewählten Datenbank-Eintrag übernommen.
|
||||
- Bei `Veranstaltung aus Datenbank` muss das Event-Datum innerhalb des hinterlegten Gültigkeitszeitraums dieser Veranstaltungs-Quelle liegen.
|
||||
- Kategorien werden im Mitgliederbereich 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 direkte Karten-/Adress-Synchronisierung.
|
||||
- Eigene Events unterstützen optionalen Beschreibungstext, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl, optionales Bild sowie eine Location aus Datenbank oder benutzerdefinierte Adresse.
|
||||
|
||||
## Technischer Rahmen
|
||||
- eigener Front-Controller in `public/index.php`
|
||||
|
||||
@@ -17,6 +17,7 @@ Stand: 2026-08-10
|
||||
- Avatar-Verwaltung: `src/App/Avatar/AvatarManager.php`
|
||||
- aktive Styles: `src/App/Avatar/Lorelei.php`, `src/App/Avatar/Croodles.php`
|
||||
- Profil-/Kinder-/Event-/Ausflugsziel-Logik im Mitgliederbereich: `partials/landing/account/dashboard.php`
|
||||
- Event-Quellen aus Datenbank plus Speicherung der Event-Location-Herkunft: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql`
|
||||
- Consent- und rechtliche Texte: `public/page/datenschutz.php`
|
||||
- System-Einstellungen: `src/App/SystemSettings.php`
|
||||
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
|
||||
|
||||
@@ -23,7 +23,7 @@ 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` ist wieder auf echte eigene Events und Event-Teilnahmen fokussiert
|
||||
- Eigene Events haben jetzt Kategorie, optionales Bild, klare Kinderangabe, optionale Platzzahl und eine direktere Karten-/Adress-Synchronisierung
|
||||
- Eigene Events haben jetzt optionalen Beschreibungstext, klare Kinderangabe, optionale Platzzahl, optionales Bild sowie eine Location aus Datenbank oder benutzerdefinierte Adresse
|
||||
- Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Orte & Veranstaltungen` verschoben
|
||||
- Neue Orte und Veranstaltungen müssen vor Veröffentlichung erst durch einen Admin freigegeben werden; Änderungs- und Löschwünsche laufen ebenfalls nur als begründete Anfrage
|
||||
- Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen
|
||||
|
||||
@@ -24,7 +24,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
- The `Events` area is limited again to real own events and event participations.
|
||||
- The `Events` area now also provides a manual ICS export and a personal subscribable calendar feed for all own events and event participations.
|
||||
- The `Events` area is now separated in the UI into `My Events`, `Calendar Sync`, and `Expired Events` so that active events, calendar tasks, and past items stay clearly separated.
|
||||
- Inside `My Events`, the `Create New Event` button now lives directly in the `Own Events` box; the previous participation box is now labeled `Registered Events` and also includes a direct link to `Event Search`.
|
||||
- Inside `My Events`, the `Create New Event` button now lives directly in the upper tab row; the previous participation box is now labeled `Registered Events` and also includes a direct link to `Event Search`.
|
||||
- Places and event-like entries were moved for now into a separate member-area section `Places & Events`.
|
||||
- Inside `Places & Events`, users can create permanent places and time-limited event-style entries.
|
||||
- New places and event-style entries stay in a pending state until an admin approves them for publication.
|
||||
@@ -32,8 +32,12 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
|
||||
- Pending new places and event-style entries can already be used as location suggestions for events while they are still under review.
|
||||
- Duplicate submissions for places and event-style entries should be blocked server-side at least by name and address.
|
||||
- The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events.
|
||||
- Own events now only require title, date, and the `with children` choice; the description is optional.
|
||||
- Event locations now support the modes `place from database`, `event from database`, or `custom`.
|
||||
- When a database-backed place or event is selected, the address and category are taken directly from that source entry.
|
||||
- When `event from database` is selected, the event date must fall inside that source event's valid date window.
|
||||
- Categories in the member area are handled 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 direct map/address synchronization.
|
||||
- Own events support an optional description, a clear `with children` yes/no field, optional capacity, an optional image, and either a database-backed location or a custom address.
|
||||
|
||||
## Technical Frame
|
||||
- custom front controller in `public/index.php`
|
||||
|
||||
@@ -17,6 +17,7 @@ Updated: 2026-08-10
|
||||
- avatar management: `src/App/Avatar/AvatarManager.php`
|
||||
- active avatar styles: `src/App/Avatar/Lorelei.php`, `src/App/Avatar/Croodles.php`
|
||||
- profile/children/event/outing logic inside member area: `partials/landing/account/dashboard.php`
|
||||
- database-backed event location sources plus persisted event location origin: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql`
|
||||
- consent and legal texts: `public/page/datenschutz.php`
|
||||
- system settings: `src/App/SystemSettings.php`
|
||||
- new listing/place base: `src/App/ListingCatalog.php`
|
||||
|
||||
@@ -23,7 +23,7 @@ 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` is now limited again to real own events and event participations
|
||||
- own events now include category, optional image, explicit child suitability, optional capacity, and more direct map/address synchronization
|
||||
- own events now support an optional description, explicit child suitability, optional capacity, an optional image, and either a database-backed location or a custom address
|
||||
- places and other event-style entries were moved for now into a separate member-area section `Places & Events`
|
||||
- new places and event-style entries must be approved by an admin before publication; change and deletion wishes also run as reasoned requests
|
||||
- the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections
|
||||
|
||||
@@ -42,7 +42,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
|
||||
- Im Mitgliederbereich ist `Events` jetzt wieder auf echte eigene Events und Event-Teilnahmen beschränkt
|
||||
- Orte und sonstige Veranstaltungen laufen jetzt separat unter `Orte & Veranstaltungen`
|
||||
- Neue Orte und Veranstaltungen müssen vor Veröffentlichung erst durch einen Admin freigegeben werden; Änderungs- und Löschwünsche laufen ebenfalls als begründete Anfrage
|
||||
- Eigene Events unterstützen aktuell Kategorie, Bild-Upload, klare Kinderangabe, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung
|
||||
- Eigene Events unterstützen aktuell optionalen Beschreibungstext, klare Kinderangabe, optionale Platzzahl, Bild-Upload sowie eine Location aus Datenbank oder benutzerdefinierte Adresse
|
||||
- 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
|
||||
- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
|
||||
|
||||
@@ -54,6 +54,31 @@ foreach (($listingCategories ?? []) as $categoryRow) {
|
||||
$categoryTitleMap[(string)($categoryRow['slug'] ?? '')] = (string)($categoryRow['title'] ?? '');
|
||||
}
|
||||
$categoryInputValue = $categoryTitleMap[$categorySlugValue] ?? $categorySlugValue;
|
||||
$eventLocationModeValue = $editingEvent ? (string)($editEvent['location_source_type'] ?? 'custom') : 'custom';
|
||||
$eventLocationSourceIdValue = $editingEvent && !empty($editEvent['location_source_listing_id']) ? (int)$editEvent['location_source_listing_id'] : 0;
|
||||
$eventLocationSourceInputValue = '';
|
||||
foreach (($eventLocationOptions ?? []) as $locationOption) {
|
||||
if ((int)($locationOption['id'] ?? 0) !== $eventLocationSourceIdValue) {
|
||||
continue;
|
||||
}
|
||||
$optionTypeLabel = (string)($locationOption['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung';
|
||||
$optionAddressLabel = trim(implode(', ', array_filter([
|
||||
(string)($locationOption['street'] ?? ''),
|
||||
trim((string)($locationOption['zip'] ?? '') . ' ' . (string)($locationOption['city'] ?? '')),
|
||||
])));
|
||||
$optionDateLabel = !empty($locationOption['starts_at']) ? date('d.m.Y', strtotime((string)$locationOption['starts_at'])) : '';
|
||||
$eventLocationSourceInputValue = trim(implode(' · ', array_filter([
|
||||
$optionTypeLabel,
|
||||
(string)($locationOption['title'] ?? ''),
|
||||
$optionDateLabel,
|
||||
$optionAddressLabel,
|
||||
])));
|
||||
if ($eventLocationModeValue !== 'custom' && !empty($locationOption['category_title'])) {
|
||||
$categoryInputValue = (string)$locationOption['category_title'];
|
||||
$categorySlugValue = (string)($locationOption['category_slug'] ?? $categorySlugValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
$priceRows = [];
|
||||
if ($editingListing && !empty($editListing['prices']) && is_array($editListing['prices'])) {
|
||||
foreach ($editListing['prices'] as $priceRow) {
|
||||
@@ -1100,6 +1125,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
<?php if ($editingListing && $editListing): ?>
|
||||
<input type="hidden" name="listing_id" value="<?= (int)$editListing['id'] ?>">
|
||||
<?php endif; ?>
|
||||
<input type="hidden" name="event_location_source_id" id="eventLocationSourceId" value="<?= $eventLocationSourceIdValue > 0 ? (int)$eventLocationSourceIdValue : '' ?>">
|
||||
<datalist id="existingPlacesList">
|
||||
<?php foreach ($placeSuggestions as $placeSuggestion): ?>
|
||||
<option value="<?= htmlspecialchars((string)$placeSuggestion['title'], ENT_QUOTES) ?>" data-place='<?= htmlspecialchars(json_encode($placeSuggestion, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>'>
|
||||
@@ -1107,6 +1133,25 @@ if (!empty($canManageSystemSettings)) {
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</datalist>
|
||||
<datalist id="eventLocationOptionList">
|
||||
<?php foreach (($eventLocationOptions ?? []) as $locationOption): ?>
|
||||
<?php
|
||||
$optionTypeLabel = (string)($locationOption['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung';
|
||||
$optionAddressLabel = trim(implode(', ', array_filter([
|
||||
(string)($locationOption['street'] ?? ''),
|
||||
trim((string)($locationOption['zip'] ?? '') . ' ' . (string)($locationOption['city'] ?? '')),
|
||||
])));
|
||||
$optionDateLabel = !empty($locationOption['starts_at']) ? date('d.m.Y', strtotime((string)$locationOption['starts_at'])) : '';
|
||||
$optionLabel = trim(implode(' · ', array_filter([
|
||||
$optionTypeLabel,
|
||||
(string)($locationOption['title'] ?? ''),
|
||||
$optionDateLabel,
|
||||
$optionAddressLabel,
|
||||
])));
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($optionLabel, ENT_QUOTES) ?>" data-location='<?= htmlspecialchars(json_encode($locationOption, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>'></option>
|
||||
<?php endforeach; ?>
|
||||
</datalist>
|
||||
<?php if (!$editing && $showEventTypeChoice): ?>
|
||||
<div class="stack gap-8">
|
||||
<label class="label">Was möchtest du anlegen?</label>
|
||||
@@ -1130,8 +1175,19 @@ if (!empty($canManageSystemSettings)) {
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<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>
|
||||
<textarea id="evDesc" name="description" class="textarea" rows="4" placeholder="Was erwartet Familien vor Ort?"><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea>
|
||||
</div>
|
||||
<div class="stack gap-6" id="eventLocationModeWrap">
|
||||
<label class="label" for="eventLocationMode">Location</label>
|
||||
<select id="eventLocationMode" name="event_location_mode" class="select">
|
||||
<option value="place" <?= $eventLocationModeValue === 'place' ? 'selected' : '' ?>>Ort aus Datenbank</option>
|
||||
<option value="editorial_event" <?= $eventLocationModeValue === 'editorial_event' ? 'selected' : '' ?>>Veranstaltung aus Datenbank</option>
|
||||
<option value="custom" <?= $eventLocationModeValue === 'custom' ? 'selected' : '' ?>>Benutzerdefiniert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stack gap-6" id="eventLocationSourceWrap">
|
||||
<label class="label" for="eventLocationSourceInput" id="eventLocationSourceLabel">Ort aus Datenbank</label>
|
||||
<input id="eventLocationSourceInput" class="input" list="eventLocationOptionList" placeholder="Eintrag aus der Datenbank auswählen" value="<?= htmlspecialchars($eventLocationSourceInputValue, ENT_QUOTES) ?>">
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="categoryInput">Kategorie</label>
|
||||
@@ -1184,7 +1240,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
<?php elseif (($section ?? '') === 'places'): ?>
|
||||
<p class="muted small" style="margin:0;">Neue Orte und Veranstaltungen werden erst nach Prüfung durch einen Admin freigegeben.</p>
|
||||
<?php endif; ?>
|
||||
<div class="form-grid">
|
||||
<div class="form-grid" id="eventAddressFieldsWrap">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="evZip">PLZ</label>
|
||||
<input id="evZip" name="zip" class="input" maxlength="5" value="<?= htmlspecialchars($zipValue, ENT_QUOTES) ?>">
|
||||
@@ -1194,18 +1250,17 @@ if (!empty($canManageSystemSettings)) {
|
||||
<input id="evCity" name="city" class="input" value="<?= htmlspecialchars($cityValue, ENT_QUOTES) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-grid" id="eventAddressStreetWrap">
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="evStreet">Straße / Adresse</label>
|
||||
<input id="evStreet" name="street" class="input" placeholder="z. B. Musterstraße 12" value="<?= htmlspecialchars($streetValue, ENT_QUOTES) ?>">
|
||||
<p class="muted small">Wird zur Karten-/Umkreissuche genutzt.</p>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="evRegion">Region/Bezirk</label>
|
||||
<input id="evRegion" name="region" class="input" value="<?= htmlspecialchars($regionValue, ENT_QUOTES) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-8" style="flex-wrap:wrap; align-items:center;">
|
||||
<div class="flex gap-8" style="flex-wrap:wrap; align-items:center;" id="eventAddressActionsWrap">
|
||||
<button class="btn ghost" type="button" id="btnEventAddressSearch">Adresse suchen</button>
|
||||
<button class="btn ghost" type="button" id="btnEventBrowserLocation">📍 Aktuellen Standort</button>
|
||||
</div>
|
||||
@@ -1355,6 +1410,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
const btnEventBrowserLocation = document.getElementById('btnEventBrowserLocation');
|
||||
const eventAddressStatus = document.getElementById('eventAddressStatus');
|
||||
const mapSearch = document.getElementById('mapSearch');
|
||||
const mapDetails = document.getElementById('mapDetails');
|
||||
const latInput = document.getElementById('evLat');
|
||||
const lngInput = document.getElementById('evLng');
|
||||
const entryKindInput = document.getElementById('entryKind');
|
||||
@@ -1364,6 +1420,15 @@ if (!empty($canManageSystemSettings)) {
|
||||
const eventDateLabel = document.getElementById('eventDateLabel');
|
||||
const eventChildrenWrap = document.getElementById('eventChildrenWrap');
|
||||
const eventCapacityWrap = document.getElementById('eventCapacityWrap');
|
||||
const eventLocationModeWrap = document.getElementById('eventLocationModeWrap');
|
||||
const eventLocationModeSelect = document.getElementById('eventLocationMode');
|
||||
const eventLocationSourceWrap = document.getElementById('eventLocationSourceWrap');
|
||||
const eventLocationSourceInput = document.getElementById('eventLocationSourceInput');
|
||||
const eventLocationSourceIdInput = document.getElementById('eventLocationSourceId');
|
||||
const eventLocationSourceLabel = document.getElementById('eventLocationSourceLabel');
|
||||
const eventAddressFieldsWrap = document.getElementById('eventAddressFieldsWrap');
|
||||
const eventAddressStreetWrap = document.getElementById('eventAddressStreetWrap');
|
||||
const eventAddressActionsWrap = document.getElementById('eventAddressActionsWrap');
|
||||
const eventValidUntilWrap = document.getElementById('eventValidUntilWrap');
|
||||
const eventPricingWrap = document.getElementById('eventPricingWrap');
|
||||
const visibilityDetails = document.getElementById('visibilityDetails');
|
||||
@@ -1371,6 +1436,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap');
|
||||
const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap');
|
||||
const eventDateInput = document.getElementById('evDate');
|
||||
const eventDescriptionInput = document.getElementById('evDesc');
|
||||
const categoryInput = document.getElementById('categoryInput');
|
||||
const categorySlugInput = document.getElementById('listingCategorySlug');
|
||||
const categoryInputStatus = document.getElementById('categoryInputStatus');
|
||||
@@ -1429,6 +1495,77 @@ if (!empty($canManageSystemSettings)) {
|
||||
});
|
||||
}
|
||||
|
||||
function findEventLocationOptionByLabel(label) {
|
||||
const normalized = (label || '').trim();
|
||||
if (!normalized) return null;
|
||||
const option = document.querySelector(`#eventLocationOptionList option[value="${CSS.escape(normalized)}"]`);
|
||||
if (!option) return null;
|
||||
try {
|
||||
return JSON.parse(option.getAttribute('data-location') || 'null');
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyEventLocationOption(option) {
|
||||
if (!option) return;
|
||||
if (eventLocationSourceIdInput) eventLocationSourceIdInput.value = String(option.id || '');
|
||||
if (streetInput) streetInput.value = String(option.street || '');
|
||||
if (zipInput) zipInput.value = String(option.zip || '');
|
||||
if (cityInput) cityInput.value = String(option.city || '');
|
||||
if (regionInput) regionInput.value = String(option.region || '');
|
||||
if (latInput) latInput.value = option.lat !== null && option.lat !== '' ? Number(option.lat).toFixed(7) : '';
|
||||
if (lngInput) lngInput.value = option.lng !== null && option.lng !== '' ? Number(option.lng).toFixed(7) : '';
|
||||
if (categoryInput) categoryInput.value = String(option.category_title || '');
|
||||
if (categorySlugInput) categorySlugInput.value = String(option.category_slug || '');
|
||||
if (categoryInputStatus) categoryInputStatus.textContent = '';
|
||||
if (eventAddressStatus) eventAddressStatus.textContent = '';
|
||||
}
|
||||
|
||||
function clearEventLocationSelection() {
|
||||
if (eventLocationSourceIdInput) eventLocationSourceIdInput.value = '';
|
||||
if (eventLocationSourceInput) eventLocationSourceInput.value = '';
|
||||
if (categoryInputStatus) categoryInputStatus.textContent = '';
|
||||
}
|
||||
|
||||
function syncEventLocationMode() {
|
||||
const kind = entryKindInput?.value || 'event';
|
||||
const mode = eventLocationModeSelect?.value || 'custom';
|
||||
const isOwnEvent = kind === 'event';
|
||||
const usesDatabaseLocation = isOwnEvent && mode !== 'custom';
|
||||
|
||||
setSectionState(eventLocationModeWrap, isOwnEvent);
|
||||
setSectionState(eventLocationSourceWrap, usesDatabaseLocation);
|
||||
setSectionState(eventAddressFieldsWrap, !usesDatabaseLocation);
|
||||
setSectionState(eventAddressStreetWrap, !usesDatabaseLocation);
|
||||
setSectionState(eventAddressActionsWrap, !usesDatabaseLocation);
|
||||
setSectionState(mapDetails, !usesDatabaseLocation);
|
||||
|
||||
if (eventLocationSourceLabel) {
|
||||
eventLocationSourceLabel.textContent = mode === 'editorial_event' ? 'Veranstaltung aus Datenbank' : 'Ort aus Datenbank';
|
||||
}
|
||||
|
||||
if (categoryInput) {
|
||||
categoryInput.readOnly = usesDatabaseLocation;
|
||||
if (usesDatabaseLocation) {
|
||||
categoryInput.removeAttribute('list');
|
||||
} else {
|
||||
categoryInput.setAttribute('list', 'listingCategoryList');
|
||||
}
|
||||
}
|
||||
|
||||
if (usesDatabaseLocation) {
|
||||
const option = findEventLocationOptionByLabel(eventLocationSourceInput?.value || '');
|
||||
if (option && String(option.listing_type || '') === mode) {
|
||||
applyEventLocationOption(option);
|
||||
} else {
|
||||
clearEventLocationSelection();
|
||||
}
|
||||
} else if (isOwnEvent) {
|
||||
if (eventLocationSourceIdInput) eventLocationSourceIdInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function syncEventEntryKind(nextKind) {
|
||||
const kind = nextKind || entryKindInput?.value || 'event';
|
||||
if (entryKindInput) entryKindInput.value = kind;
|
||||
@@ -1451,7 +1588,8 @@ if (!empty($canManageSystemSettings)) {
|
||||
|
||||
setSectionState(eventDateRow, kind !== 'place');
|
||||
if (eventDateInput) eventDateInput.required = kind !== 'place';
|
||||
if (categoryInput) categoryInput.required = true;
|
||||
if (eventDescriptionInput) eventDescriptionInput.required = kind !== 'event';
|
||||
if (categoryInput) categoryInput.required = kind !== 'event';
|
||||
setSectionState(eventChildrenWrap, kind === 'event' || kind === 'editorial_event');
|
||||
setSectionState(eventCapacityWrap, kind === 'event');
|
||||
setSectionState(eventValidUntilWrap, kind === 'editorial_event');
|
||||
@@ -1463,6 +1601,7 @@ if (!empty($canManageSystemSettings)) {
|
||||
if (eventDateLabel) {
|
||||
eventDateLabel.textContent = kind === 'editorial_event' ? 'Gültig von' : 'Datum';
|
||||
}
|
||||
syncEventLocationMode();
|
||||
}
|
||||
|
||||
entryKindButtons.forEach((button) => {
|
||||
@@ -1470,6 +1609,19 @@ if (!empty($canManageSystemSettings)) {
|
||||
syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event');
|
||||
});
|
||||
});
|
||||
eventLocationModeSelect?.addEventListener('change', () => {
|
||||
clearEventLocationSelection();
|
||||
syncEventLocationMode();
|
||||
});
|
||||
eventLocationSourceInput?.addEventListener('change', () => {
|
||||
const mode = eventLocationModeSelect?.value || 'custom';
|
||||
const option = findEventLocationOptionByLabel(eventLocationSourceInput.value || '');
|
||||
if (!option || String(option.listing_type || '') !== mode) {
|
||||
clearEventLocationSelection();
|
||||
return;
|
||||
}
|
||||
applyEventLocationOption(option);
|
||||
});
|
||||
syncEventEntryKind(entryKindInput?.value || 'event');
|
||||
|
||||
function createOpeningRow() {
|
||||
@@ -1503,8 +1655,12 @@ if (!empty($canManageSystemSettings)) {
|
||||
btnAddPriceRow?.addEventListener('click', createPriceRow);
|
||||
|
||||
function syncCategoryInputState() {
|
||||
const value = (categoryInput?.value || '').trim().toLowerCase();
|
||||
if (!categorySlugInput || !categoryInputStatus) return;
|
||||
if ((entryKindInput?.value || 'event') === 'event' && (eventLocationModeSelect?.value || 'custom') !== 'custom') {
|
||||
categoryInputStatus.textContent = '';
|
||||
return;
|
||||
}
|
||||
const value = (categoryInput?.value || '').trim().toLowerCase();
|
||||
if (!value) {
|
||||
categorySlugInput.value = '';
|
||||
categoryInputStatus.textContent = '';
|
||||
|
||||
@@ -454,14 +454,51 @@ final class AccountPages
|
||||
$existingEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
}
|
||||
$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;
|
||||
$eventStartValue = self::normalizeDateForStorage((string)($_POST['starts_at'] ?? ''), true);
|
||||
$sourceMode = (string)($_POST['event_location_mode'] ?? 'custom');
|
||||
$sourceListingId = isset($_POST['event_location_source_id']) && $_POST['event_location_source_id'] !== '' ? (int)$_POST['event_location_source_id'] : null;
|
||||
$locationLabel = 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;
|
||||
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
|
||||
if ($hasAddress) {
|
||||
$categoryInput = trim((string)($_POST['category_input'] ?? $_POST['category_slug'] ?? ''));
|
||||
$categorySlug = '';
|
||||
$sourceListing = null;
|
||||
if (in_array($sourceMode, ['place', 'editorial_event'], true)) {
|
||||
if (!$listingCatalog || $sourceListingId === null || $sourceListingId <= 0) {
|
||||
throw new \RuntimeException('Bitte wähle einen gültigen Ort oder eine gültige Veranstaltung aus der Datenbank.');
|
||||
}
|
||||
$sourceListing = $listingCatalog->getEventLocationSource($sourceListingId);
|
||||
if (!$sourceListing || (string)($sourceListing['listing_type'] ?? '') !== $sourceMode) {
|
||||
throw new \RuntimeException('Die gewählte Location ist nicht verfügbar.');
|
||||
}
|
||||
if ($sourceMode === 'editorial_event') {
|
||||
$eventDate = substr($eventStartValue, 0, 10);
|
||||
$sourceStart = !empty($sourceListing['starts_at']) ? substr((string)$sourceListing['starts_at'], 0, 10) : '';
|
||||
$sourceUntil = !empty($sourceListing['recurrence_until']) ? substr((string)$sourceListing['recurrence_until'], 0, 10) : '';
|
||||
if ($eventDate === '' || $sourceStart === '' || $sourceUntil === '' || $eventDate < $sourceStart || $eventDate > $sourceUntil) {
|
||||
throw new \RuntimeException('Die gewählte Veranstaltungs-Location passt zeitlich nicht zum Event-Datum.');
|
||||
}
|
||||
}
|
||||
$street = trim((string)($sourceListing['street'] ?? ''));
|
||||
$zip = trim((string)($sourceListing['zip'] ?? ''));
|
||||
$city = trim((string)($sourceListing['city'] ?? ''));
|
||||
$region = trim((string)($sourceListing['region'] ?? ''));
|
||||
$lat = isset($sourceListing['lat']) && $sourceListing['lat'] !== '' ? (float)$sourceListing['lat'] : null;
|
||||
$lng = isset($sourceListing['lng']) && $sourceListing['lng'] !== '' ? (float)$sourceListing['lng'] : null;
|
||||
$locationLabel = trim((string)($sourceListing['title'] ?? '')) ?: null;
|
||||
$categorySlug = trim((string)($sourceListing['category_slug'] ?? ''));
|
||||
} else {
|
||||
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
|
||||
if (!$hasAddress) {
|
||||
throw new \RuntimeException('Bitte gib für eine benutzerdefinierte Location eine Adresse an.');
|
||||
}
|
||||
$resolvedAddress = null;
|
||||
if ($lat !== null && $lng !== null) {
|
||||
$resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
|
||||
@@ -479,21 +516,14 @@ final class AccountPages
|
||||
$region = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region;
|
||||
$lat = $resolvedAddress['lat'];
|
||||
$lng = $resolvedAddress['lng'];
|
||||
if ($categoryInput !== '' && $listingCatalog) {
|
||||
$category = $listingCatalog->ensureCategoryForInput($categoryInput, 'event');
|
||||
$categorySlug = (string)($category['slug'] ?? '');
|
||||
}
|
||||
}
|
||||
$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;
|
||||
$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 = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, category_slug, image_path, location_label, location_source_type, location_source_listing_id, 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, :sourceType, :sourceListingId, :street, :zip, :city, :region, :lat, :lng, :start, :capacity, :allow, :vis, :status, NOW(), NOW())');
|
||||
$stmt?->execute([
|
||||
'uid' => $userId,
|
||||
'title' => trim((string)$_POST['title']),
|
||||
@@ -501,7 +531,9 @@ final class AccountPages
|
||||
'descr' => trim((string)$_POST['description']),
|
||||
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
|
||||
'imagePath' => $imagePath !== '' ? $imagePath : null,
|
||||
'loc' => null,
|
||||
'loc' => $locationLabel,
|
||||
'sourceType' => $sourceMode,
|
||||
'sourceListingId' => $sourceMode === 'custom' ? null : $sourceListingId,
|
||||
'street' => $street ?: null,
|
||||
'zip' => $zip,
|
||||
'city' => $city,
|
||||
@@ -524,7 +556,7 @@ final class AccountPages
|
||||
}
|
||||
} else {
|
||||
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||
$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 = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, category_slug=:categorySlug, image_path=:imagePath, location_label=:loc, location_source_type=:sourceType, location_source_listing_id=:sourceListingId, 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,
|
||||
@@ -533,7 +565,9 @@ final class AccountPages
|
||||
'descr' => trim((string)$_POST['description']),
|
||||
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
|
||||
'imagePath' => $imagePath !== '' ? $imagePath : null,
|
||||
'loc' => null,
|
||||
'loc' => $locationLabel,
|
||||
'sourceType' => $sourceMode,
|
||||
'sourceListingId' => $sourceMode === 'custom' ? null : $sourceListingId,
|
||||
'street' => $street ?: null,
|
||||
'zip' => $zip,
|
||||
'city' => $city,
|
||||
@@ -712,8 +746,9 @@ final class AccountPages
|
||||
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['general', 'place', 'food', 'event', 'family']) : [];
|
||||
$categoryReviewItems = $listingCatalog && $canManageSystemSettings ? $listingCatalog->listCategoryReviewItems() : [];
|
||||
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
|
||||
$eventLocationOptions = $listingCatalog ? $listingCatalog->listEventLocationOptions(120) : [];
|
||||
$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,
|
||||
'SELECT e.id, e.title, e.teaser_public, e.description, e.category_slug, e.image_path, e.location_label, e.location_source_type, e.location_source_listing_id, 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()
|
||||
@@ -832,6 +867,7 @@ final class AccountPages
|
||||
'listingCategories',
|
||||
'categoryReviewItems',
|
||||
'placeSuggestions',
|
||||
'eventLocationOptions',
|
||||
'communityPoints',
|
||||
'communityLevel',
|
||||
'communityRoles',
|
||||
@@ -980,6 +1016,8 @@ final class AccountPages
|
||||
$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',
|
||||
'location_source_type' => 'ALTER TABLE events ADD COLUMN location_source_type ENUM("custom","place","editorial_event") NOT NULL DEFAULT "custom" AFTER location_label',
|
||||
'location_source_listing_id' => 'ALTER TABLE events ADD COLUMN location_source_listing_id BIGINT UNSIGNED NULL AFTER location_source_type',
|
||||
];
|
||||
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');
|
||||
|
||||
@@ -493,6 +493,81 @@ final class ListingCatalog
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function listEventLocationOptions(int $limit = 80): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT l.id,
|
||||
l.listing_type,
|
||||
l.title,
|
||||
l.status,
|
||||
lp.street,
|
||||
lp.zip,
|
||||
lp.city,
|
||||
lp.region,
|
||||
lp.lat,
|
||||
lp.lng,
|
||||
lp.place_kind,
|
||||
lo.starts_at,
|
||||
lo.recurrence_until,
|
||||
lc.slug AS category_slug,
|
||||
lc.title AS category_title
|
||||
FROM listings l
|
||||
LEFT JOIN listing_places lp ON lp.id = l.primary_place_id
|
||||
LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id
|
||||
LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id
|
||||
LEFT JOIN listing_categories lc ON lc.id = lcm.category_id
|
||||
WHERE l.listing_type IN ("place","editorial_event")
|
||||
AND l.status IN ("draft","published")
|
||||
ORDER BY
|
||||
CASE l.listing_type
|
||||
WHEN "place" THEN 1
|
||||
WHEN "editorial_event" THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
l.title ASC,
|
||||
COALESCE(lo.starts_at, l.created_at) ASC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':limit', max(1, min(250, $limit)), \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function getEventLocationSource(int $listingId): ?array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$stmt = $this->pdo->prepare(
|
||||
'SELECT l.id,
|
||||
l.listing_type,
|
||||
l.title,
|
||||
l.status,
|
||||
lp.street,
|
||||
lp.zip,
|
||||
lp.city,
|
||||
lp.region,
|
||||
lp.lat,
|
||||
lp.lng,
|
||||
lp.place_kind,
|
||||
lo.starts_at,
|
||||
lo.recurrence_until,
|
||||
lc.slug AS category_slug,
|
||||
lc.title AS category_title
|
||||
FROM listings l
|
||||
LEFT JOIN listing_places lp ON lp.id = l.primary_place_id
|
||||
LEFT JOIN listing_occurrences lo ON lo.listing_id = l.id
|
||||
LEFT JOIN listing_category_map lcm ON lcm.listing_id = l.id
|
||||
LEFT JOIN listing_categories lc ON lc.id = lcm.category_id
|
||||
WHERE l.id = :id
|
||||
AND l.listing_type IN ("place","editorial_event")
|
||||
AND l.status IN ("draft","published")
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute(['id' => $listingId]);
|
||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function listUserOpenModerationRequestMap(int $userId): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
|
||||
Reference in New Issue
Block a user