From 5d979e60bfa6bfd7e5086ebea4a46bc5b4cc99ce Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 10 Aug 2026 22:57:21 +0200 Subject: [PATCH] yasdasd --- Internal/db/schema.sql | 2 + Internal/de/PROJECT_CONTEXT.md | 8 +- Internal/de/PROJECT_STRUCTURE.md | 1 + Internal/de/README.md | 2 +- Internal/en/PROJECT_CONTEXT.md | 8 +- Internal/en/PROJECT_STRUCTURE.md | 1 + Internal/en/README.md | 2 +- README.md | 2 +- partials/landing/account/dashboard.php | 172 +++++++++++++++++++++++-- src/App/AccountPages.php | 74 ++++++++--- src/App/ListingCatalog.php | 75 +++++++++++ 11 files changed, 314 insertions(+), 33 deletions(-) diff --git a/Internal/db/schema.sql b/Internal/db/schema.sql index 606b033..008cce9 100755 --- a/Internal/db/schema.sql +++ b/Internal/db/schema.sql @@ -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, diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index 31c64c8..7df7250 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -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` diff --git a/Internal/de/PROJECT_STRUCTURE.md b/Internal/de/PROJECT_STRUCTURE.md index eeb0db3..7902057 100644 --- a/Internal/de/PROJECT_STRUCTURE.md +++ b/Internal/de/PROJECT_STRUCTURE.md @@ -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` diff --git a/Internal/de/README.md b/Internal/de/README.md index 4dfeed4..08d1fac 100644 --- a/Internal/de/README.md +++ b/Internal/de/README.md @@ -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 diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index 44d61b3..36ce8d2 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -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` diff --git a/Internal/en/PROJECT_STRUCTURE.md b/Internal/en/PROJECT_STRUCTURE.md index 97fae36..8b78bf0 100644 --- a/Internal/en/PROJECT_STRUCTURE.md +++ b/Internal/en/PROJECT_STRUCTURE.md @@ -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` diff --git a/Internal/en/README.md b/Internal/en/README.md index 1cbcb81..b70e5cc 100644 --- a/Internal/en/README.md +++ b/Internal/en/README.md @@ -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 diff --git a/README.md b/README.md index 6e1d469..0f05486 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index 166539f..af9bf4f 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -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)) { + + + + + + +
@@ -1130,8 +1175,19 @@ if (!empty($canManageSystemSettings)) {
- -

Die Kurzbeschreibung wird automatisch aus dem Text erzeugt.

+ +
+
+ + +
+
+ +
@@ -1184,7 +1240,7 @@ if (!empty($canManageSystemSettings)) {

Neue Orte und Veranstaltungen werden erst nach Prüfung durch einen Admin freigegeben.

-
+
@@ -1194,18 +1250,17 @@ if (!empty($canManageSystemSettings)) {
-
+
-

Wird zur Karten-/Umkreissuche genutzt.

-
+
@@ -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 = ''; diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 72e2732..348c5da 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -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'); diff --git a/src/App/ListingCatalog.php b/src/App/ListingCatalog.php index d5e8b87..195f4fc 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -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();