From 9cfa26a031e79af46273e045fbb987ad9bf15f5e Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Tue, 4 Aug 2026 20:59:40 +0200 Subject: [PATCH] rebuild events --- Internal/de/PROJECT_CONTEXT.md | 3 + Internal/de/PROJECT_STRUCTURE.md | 1 + Internal/de/README.md | 1 + Internal/en/PROJECT_CONTEXT.md | 3 + Internal/en/PROJECT_STRUCTURE.md | 1 + Internal/en/README.md | 1 + README.md | 1 + partials/landing/account/dashboard.php | 269 +++++++++++++++++++++++-- schema.sql | 1 + src/App/AccountPages.php | 55 ++++- src/App/ListingCatalog.php | 203 ++++++++++++++++--- 11 files changed, 489 insertions(+), 50 deletions(-) diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index a62f7c0..86315ec 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -22,6 +22,9 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert. - Profiladresse kann per Suche oder Browser-Standort übernommen werden. - Im Bereich `Events` gibt es jetzt zunächst drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung. +- `Ort` blendet Datum aus und erlaubt stattdessen Öffnungszeiten/Hinweise. +- `Sonstige Veranstaltung` unterstützt Uhrzeit und erste Wiederholungsmodelle wie täglich, wöchentlich oder bestimmte Wochentage. +- Preisangaben für Orte und sonstige Veranstaltungen werden als strukturierte Einträge für Kinder- und Erwachsenenpreise gespeichert. ## Technischer Rahmen - eigener Front-Controller in `public/index.php` diff --git a/Internal/de/PROJECT_STRUCTURE.md b/Internal/de/PROJECT_STRUCTURE.md index fe3aa54..5436273 100644 --- a/Internal/de/PROJECT_STRUCTURE.md +++ b/Internal/de/PROJECT_STRUCTURE.md @@ -19,6 +19,7 @@ Stand: 2026-08-03 - System-Einstellungen: `src/App/SystemSettings.php` - neue Listing-/Ort-Basis: `src/App/ListingCatalog.php` - UI für Eintragstyp-Auswahl im Mitgliederbereich: `partials/landing/account/dashboard.php` +- Preis-, Wiederholungs- und Ortssuche für neue Listing-Typen: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php` ## Dokumentationsregel Kanonische interne Dokumentation: diff --git a/Internal/de/README.md b/Internal/de/README.md index 8bf4ec2..c4f652a 100644 --- a/Internal/de/README.md +++ b/Internal/de/README.md @@ -23,6 +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` unterstützt jetzt drei Anlagetypen: `Eigenes Event`, `Ort`, `Sonstige Veranstaltung` +- Orte und sonstige Veranstaltungen haben jetzt eine angepasste Eingabemaske mit Ortssuche, serverseitiger Adressvalidierung, Preisfeldern, Öffnungszeiten und Wiederholungen ## Wording-Regel - Primärbegriff im Produkt: `Events` diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index 2695834..5e4146c 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -22,6 +22,9 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events - Account email is encrypted application-side and addressed through a separate HMAC lookup hash. - Profile address can be completed via address search or browser-based location import. - The `Events` area now distinguishes between own events, places, and other time-limited event entries. +- `Place` hides date/time and allows opening hours or availability notes instead. +- `Other Event` supports time and first recurrence models such as daily, weekly, or selected weekdays. +- Pricing for places and other events is stored in structured child/adult price rows. ## Technical Frame - custom front controller in `public/index.php` diff --git a/Internal/en/PROJECT_STRUCTURE.md b/Internal/en/PROJECT_STRUCTURE.md index 7faeacb..d225fb1 100644 --- a/Internal/en/PROJECT_STRUCTURE.md +++ b/Internal/en/PROJECT_STRUCTURE.md @@ -19,6 +19,7 @@ Updated: 2026-08-03 - system settings: `src/App/SystemSettings.php` - new listing/place base: `src/App/ListingCatalog.php` - entry type selector UI in member area: `partials/landing/account/dashboard.php` +- pricing, recurrence, and place lookup for new listing types: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php` ## Documentation Rule - canonical internal documentation lives in `Internal/de/` and `Internal/en/` diff --git a/Internal/en/README.md b/Internal/en/README.md index 8c09c0f..7a8cad9 100644 --- a/Internal/en/README.md +++ b/Internal/en/README.md @@ -23,6 +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` now supports three creation types: `Own Event`, `Place`, and `Other Event` +- places and other events now use adaptive forms with place lookup, server-side address validation, pricing fields, opening hours, and recurrence options ## Core Wording Rule - Primary product term: `Events` diff --git a/README.md b/README.md index 823ac8a..e7cfe69 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf: - neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits` - Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter - Im Mitgliederbereich unter `Events` können Nutzer jetzt zwischen `Eigenes Event`, `Ort` und `sonstiger Veranstaltung` unterscheiden +- Für Orte und sonstige Veranstaltungen gibt es jetzt typabhängige Masken mit Ortssuche, Adressprüfung, Preislogik und Wiederholungsoptionen ## Datenschutz und Sicherheit - sensible Profilfelder werden app-seitig verschlüsselt gespeichert diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index db48044..a199515 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -32,6 +32,66 @@ $latValue = $editingEvent ? (string)($editEvent['lat'] ?? '') : (string)($editLi $lngValue = $editingEvent ? (string)($editEvent['lng'] ?? '') : (string)($editListing['lng'] ?? ''); $visibilityValue = $editingEvent ? (string)($editEvent['visibility'] ?? 'public') : (string)($editListing['visibility'] ?? 'public'); $categorySlugValue = $editingListing ? (string)($editListing['category_slug'] ?? '') : ''; +$openingHoursValue = $editingListing ? (string)($editListing['opening_hours_note'] ?? '') : ''; +$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'])); +} +$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]; + } + } +} +$priceModeValue = 'none'; +$freeChildUntilAge = ''; +$priceChildAmount = ''; +$priceChildAgeFrom = ''; +$priceChildAgeTo = ''; +$priceAdultAmount = ''; +$priceAdultAgeFrom = ''; +if ($editingListing && !empty($editListing['prices']) && is_array($editListing['prices'])) { + foreach ($editListing['prices'] as $priceRow) { + $audience = (string)($priceRow['audience'] ?? ''); + $priceType = (string)($priceRow['price_type'] ?? ''); + $note = (string)($priceRow['note'] ?? ''); + if ($priceType === 'free') { + $priceModeValue = 'free_child_rule'; + if (preg_match('/bis\s+(\d+)/u', $note, $m)) { + $freeChildUntilAge = $m[1]; + } + } elseif ($audience === 'child') { + $priceModeValue = 'paid'; + $priceChildAmount = (string)($priceRow['amount'] ?? ''); + if (preg_match('/ab\s+(\d+)/u', $note, $m)) { + $priceChildAgeFrom = $m[1]; + } + if (preg_match('/bis\s+(\d+)/u', $note, $m)) { + $priceChildAgeTo = $m[1]; + } + } elseif ($audience === 'adult') { + $priceModeValue = 'paid'; + $priceAdultAmount = (string)($priceRow['amount'] ?? ''); + if (preg_match('/ab\s+(\d+)/u', $note, $m)) { + $priceAdultAgeFrom = $m[1]; + } + } + } +} $editingChild = isset($editChild) && $editChild !== null; $childAction = $editingChild ? 'child_update' : 'child_add'; $childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen'; @@ -857,6 +917,13 @@ if (!empty($canManageSystemSettings)) { + + + + +
@@ -875,19 +942,24 @@ if (!empty($canManageSystemSettings)) {

-
+
-
- - -
+

Die Kurzbeschreibung wird intern automatisch aus deinem Text erzeugt.

+
+
+ +
+ + +
+

Wenn ein passender Ort schon existiert, kannst du ihn übernehmen. Falls nichts passt, legst du unten einfach einen neuen Ort an.

@@ -911,6 +983,32 @@ if (!empty($canManageSystemSettings)) {

Für Orte und sonstige Veranstaltungen, damit Spielplatz, Café, Restaurant, Zoo oder Zirkus sauber unterscheidbar bleiben.

+
+ + +
+
+ + +
+ 'Mo', 'di' => 'Di', 'mi' => 'Mi', 'do' => 'Do', 'fr' => 'Fr', 'sa' => 'Sa', 'so' => 'So'] as $weekdayValue => $weekdayLabel): ?> + + +
+
+ + +
+
@@ -933,9 +1031,11 @@ if (!empty($canManageSystemSettings)) {
+ Hält Karte und Adresse synchron.
+
@@ -961,6 +1061,46 @@ if (!empty($canManageSystemSettings)) {
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
@@ -999,6 +1139,8 @@ if (!empty($canManageSystemSettings)) { const mapContainer = document.getElementById('mapContainer'); const btnMapSearch = document.getElementById('btnMapSearch'); const btnAddrToMap = document.getElementById('btnAddrToMap'); + const btnEventAddressSearch = document.getElementById('btnEventAddressSearch'); + const eventAddressStatus = document.getElementById('eventAddressStatus'); const mapSearch = document.getElementById('mapSearch'); const latInput = document.getElementById('evLat'); const lngInput = document.getElementById('evLng'); @@ -1009,8 +1151,19 @@ if (!empty($canManageSystemSettings)) { const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap'); const eventCategoryWrap = document.getElementById('eventCategoryWrap'); const eventKidsWrap = document.getElementById('eventKidsWrap'); + const eventPricingWrap = document.getElementById('eventPricingWrap'); + const placeLookupWrap = document.getElementById('placeLookupWrap'); + const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap'); + const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap'); const eventDateInput = document.getElementById('evDate'); const categorySelect = document.getElementById('listingCategorySlug'); + const priceModeSelect = document.getElementById('priceMode'); + const freeChildRuleWrap = document.getElementById('freeChildRuleWrap'); + const paidPricingWrap = document.getElementById('paidPricingWrap'); + const recurrenceModeSelect = document.getElementById('recurrenceMode'); + const recurrenceWeekdaysWrap = document.getElementById('recurrenceWeekdaysWrap'); + const existingPlaceLookup = document.getElementById('existingPlaceLookup'); + const btnUseExistingPlace = document.getElementById('btnUseExistingPlace'); const streetInput = document.getElementById('evStreet'); const zipInput = document.getElementById('evZip'); const cityInput = document.getElementById('evCity'); @@ -1030,6 +1183,24 @@ if (!empty($canManageSystemSettings)) { const profileAddressResults = document.getElementById('profileAddressResults'); const profileAddressResultsModal = document.getElementById('modalProfileAddressResults'); let map, marker; + const existingPlaces = ; + + function normalizeGeoResult(result) { + const address = result.address || {}; + const street = [address.road || '', address.house_number || ''].filter(Boolean).join(' ').trim(); + const region = address.city_district || address.suburb || address.state || address.county || address.region || address.state_district || ''; + const city = address.city || address.town || address.village || ''; + + return { + label: result.display_name || '', + street, + zip: address.postcode || '', + city, + region, + lat: parseFloat(result.lat), + lng: parseFloat(result.lon), + }; + } function syncEventEntryKind(nextKind) { const kind = nextKind || entryKindInput?.value || 'event'; @@ -1037,7 +1208,7 @@ if (!empty($canManageSystemSettings)) { entryKindButtons.forEach((button) => { const active = button.getAttribute('data-entry-kind-choice') === kind; - button.classList.toggle('is-active', active); + button.className = active ? 'btn' : 'btn ghost'; button.setAttribute('aria-pressed', active ? 'true' : 'false'); }); @@ -1057,6 +1228,23 @@ if (!empty($canManageSystemSettings)) { if (eventCategoryWrap) eventCategoryWrap.hidden = kind === 'event'; if (categorySelect) categorySelect.required = kind !== 'event'; if (eventKidsWrap) eventKidsWrap.hidden = kind !== 'event'; + if (eventPricingWrap) eventPricingWrap.hidden = kind === 'event'; + if (placeLookupWrap) placeLookupWrap.hidden = kind === 'event'; + if (placeOpeningHoursWrap) placeOpeningHoursWrap.hidden = kind !== 'place'; + if (eventRecurrenceWrap) eventRecurrenceWrap.hidden = kind !== 'editorial_event'; + syncPriceModeVisibility(); + syncRecurrenceVisibility(); + } + + function syncPriceModeVisibility() { + const mode = priceModeSelect?.value || 'none'; + if (freeChildRuleWrap) freeChildRuleWrap.hidden = mode !== 'free_child_rule'; + if (paidPricingWrap) paidPricingWrap.hidden = mode !== 'paid'; + } + + function syncRecurrenceVisibility() { + const mode = recurrenceModeSelect?.value || 'single'; + if (recurrenceWeekdaysWrap) recurrenceWeekdaysWrap.hidden = mode !== 'custom_weekdays'; } entryKindButtons.forEach((button) => { @@ -1064,8 +1252,36 @@ if (!empty($canManageSystemSettings)) { syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event'); }); }); + priceModeSelect?.addEventListener('change', syncPriceModeVisibility); + recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility); syncEventEntryKind(entryKindInput?.value || 'event'); + syncPriceModeVisibility(); + syncRecurrenceVisibility(); + + function 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.'; + return; + } + if (eventAddressStatus) eventAddressStatus.textContent = 'Kein vorhandener Ort exakt gefunden. Du kannst unten einen neuen Ort anlegen.'; + }); function ensureLeaflet(callback) { if (!window.PKTConsent || !window.PKTConsent.has('external_services')) { @@ -1248,6 +1464,32 @@ if (!empty($canManageSystemSettings)) { geocodeAndPlace(parts.join(', ')); }); + btnEventAddressSearch?.addEventListener('click', () => { + const parts = [ + streetInput?.value || '', + zipInput?.value || '', + cityInput?.value || '', + regionInput?.value || '', + ].map(v => v.trim()).filter(Boolean); + if (!parts.length) { + alert('Bitte zuerst mindestens einen Teil der Adresse eingeben.'); + return; + } + if (eventAddressStatus) eventAddressStatus.textContent = 'Suche passende Adressen ...'; + geocodeAddressCandidates(parts.join(', '), (results) => { + const first = results?.[0]; + if (!first) return; + const normalized = normalizeGeoResult(first); + if (streetInput && normalized.street) streetInput.value = normalized.street; + if (zipInput && normalized.zip) zipInput.value = normalized.zip; + if (cityInput && normalized.city) cityInput.value = normalized.city; + if (regionInput) regionInput.value = normalized.region; + if (latInput) latInput.value = Number.isNaN(normalized.lat) ? '' : normalized.lat.toFixed(7); + if (lngInput) lngInput.value = Number.isNaN(normalized.lng) ? '' : normalized.lng.toFixed(7); + if (eventAddressStatus) eventAddressStatus.textContent = `Gewählte Adresse: ${normalized.label || [normalized.street, `${normalized.zip} ${normalized.city}`.trim(), normalized.region].filter(Boolean).join(', ')}`; + }); + }); + (function(){ const setProfileStatus = (message = '') => { if (!profileAddressStatus) return; @@ -1272,20 +1514,7 @@ if (!empty($canManageSystemSettings)) { }; const normalizeResult = (result) => { - const address = result.address || {}; - const street = [address.road || '', address.house_number || ''].filter(Boolean).join(' ').trim(); - const region = address.city_district || address.suburb || address.state || address.county || address.region || address.state_district || ''; - const city = address.city || address.town || address.village || ''; - - return { - label: result.display_name || '', - street, - zip: address.postcode || '', - city, - region, - lat: parseFloat(result.lat), - lng: parseFloat(result.lon), - }; + return normalizeGeoResult(result); }; const applyProfileAddress = (result) => { diff --git a/schema.sql b/schema.sql index c452126..a1f4e6c 100755 --- a/schema.sql +++ b/schema.sql @@ -148,6 +148,7 @@ CREATE TABLE listing_places ( website_url VARCHAR(255) NULL, phone VARCHAR(60) NULL, place_kind VARCHAR(80) NULL, + opening_hours_note TEXT NULL, provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual', external_place_id VARCHAR(190) NULL, google_place_id VARCHAR(190) NULL, diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 6030307..11ab5ba 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -377,9 +377,40 @@ final class AccountPages if (!$listingCatalog) { throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); } + $payload = $_POST; + $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) { + $resolvedAddress = null; + if ($lat !== null && $lng !== null) { + $resolvedAddress = self::reverseGeocodeAddress($lat, $lng); + } + if ($resolvedAddress === null) { + $searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5); + if (!$searchResults) { + throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.'); + } + $resolvedAddress = $searchResults[0]; + } + $payload['street'] = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street; + $payload['zip'] = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip; + $payload['city'] = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city; + $payload['region'] = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region; + $payload['lat'] = $resolvedAddress['lat']; + $payload['lng'] = $resolvedAddress['lng']; + } + $payload['teaser'] = self::generateTeaser( + trim((string)($payload['description'] ?? '')), + trim((string)($payload['title'] ?? '')) + ); $listingCatalog->saveDashboardEntry( $userId, - $_POST, + $payload, $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null ); $info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.'; @@ -403,7 +434,7 @@ final class AccountPages $stmt?->execute([ 'uid' => $userId, 'title' => trim((string)$_POST['title']), - 'teaser' => trim((string)$_POST['teaser']), + 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'descr' => trim((string)$_POST['description']), 'loc' => trim((string)$_POST['location_label']), 'street' => $street ?: null, @@ -432,7 +463,7 @@ final class AccountPages 'id' => $eventId, 'uid' => $userId, 'title' => trim((string)$_POST['title']), - 'teaser' => trim((string)$_POST['teaser']), + 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'descr' => trim((string)$_POST['description']), 'loc' => trim((string)$_POST['location_label']), 'street' => $street ?: null, @@ -605,6 +636,7 @@ final class AccountPages $otherListings = []; $editListing = null; $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : []; + $placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count @@ -671,6 +703,7 @@ final class AccountPages 'otherListings', 'editListing', 'listingCategories', + 'placeSuggestions', 'communityPoints', 'communityLevel', 'communityRoles', @@ -702,6 +735,22 @@ final class AccountPages return $birthDateValue->diff($today)->y; } + private static function generateTeaser(string $description, string $title = ''): string + { + $source = trim($description) !== '' ? trim($description) : trim($title); + if ($source === '') { + return ''; + } + + $collapsed = preg_replace('/\s+/u', ' ', $source) ?: $source; + $collapsed = trim($collapsed); + if (mb_strlen($collapsed) <= 140) { + return $collapsed; + } + + return rtrim(mb_substr($collapsed, 0, 137)) . '...'; + } + private static function requireCrypto(?Crypto $crypto, string $context): Crypto { if ($crypto instanceof Crypto) { diff --git a/src/App/ListingCatalog.php b/src/App/ListingCatalog.php index f805656..fa7e812 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -6,6 +6,7 @@ namespace App; final class ListingCatalog { private array $tableCache = []; + private array $columnCache = []; public function __construct(private \PDO $pdo) { @@ -142,6 +143,17 @@ final class ListingCatalog $this->pdo->exec($sql); } + $columnStatements = [ + 'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind', + ]; + foreach ($columnStatements as $key => $sql) { + [$table, $column] = explode('.', $key, 2); + if (!$this->hasColumn($table, $column)) { + $this->pdo->exec($sql); + $this->columnCache[$key] = true; + } + } + $seed = $this->pdo->prepare( 'INSERT INTO listing_categories (slug, title, category_group, sort_order) VALUES (:slug, :title, :groupName, :sortOrder) @@ -229,8 +241,9 @@ final class ListingCatalog { $this->ensureSchema(); $stmt = $this->pdo->prepare( - 'SELECT l.*, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, + 'SELECT l.*, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.opening_hours_note, lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type, + lo.recurrence_rule, lo.recurrence_until, lc.slug AS category_slug FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id @@ -245,7 +258,26 @@ final class ListingCatalog 'uid' => $userId, ]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); - return $row ?: null; + if (!$row) { + return null; + } + $row['prices'] = $this->listPrices((int)$row['id']); + return $row; + } + + public function listPlaceSuggestions(int $limit = 40): array + { + $this->ensureSchema(); + $stmt = $this->pdo->prepare( + 'SELECT id, title, street, zip, city, region, place_kind + FROM listing_places + WHERE status = "published" + ORDER BY updated_at DESC + LIMIT :limit' + ); + $stmt->bindValue(':limit', max(1, min(200, $limit)), \PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null): int @@ -259,6 +291,7 @@ final class ListingCatalog $title = trim((string)($data['title'] ?? '')); $teaser = trim((string)($data['teaser'] ?? '')); $description = trim((string)($data['description'] ?? '')); + $openingHoursNote = trim((string)($data['opening_hours_note'] ?? '')); $visibility = (string)($data['visibility'] ?? 'public'); $placeKind = trim((string)($data['category_slug'] ?? '')); $street = trim((string)($data['street'] ?? '')); @@ -268,6 +301,10 @@ final class ListingCatalog $lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null; $lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null; $startsAt = trim((string)($data['starts_at'] ?? '')); + $recurrenceMode = (string)($data['recurrence_mode'] ?? 'single'); + $recurrenceUntil = trim((string)($data['recurrence_until'] ?? '')); + $weekdayValues = array_values(array_filter(array_map('strval', (array)($data['recurrence_weekdays'] ?? [])))); + $priceMode = (string)($data['price_mode'] ?? 'none'); if ($title === '' || $teaser === '' || $description === '') { throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.'); @@ -298,7 +335,7 @@ final class ListingCatalog if ($placeId > 0) { $placeStmt = $this->pdo->prepare( 'UPDATE listing_places - SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, place_kind = :placeKind, updated_at = NOW() + SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, place_kind = :placeKind, opening_hours_note = :openingHoursNote, updated_at = NOW() WHERE id = :id' ); $placeStmt->execute([ @@ -311,6 +348,7 @@ final class ListingCatalog 'lat' => $lat, 'lng' => $lng, 'placeKind' => $placeKind !== '' ? $placeKind : null, + 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, 'id' => $placeId, ]); } @@ -331,21 +369,11 @@ final class ListingCatalog ]); $this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); - if ($entryType === 'editorial_event') { - $occStmt = $this->pdo->prepare( - 'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, status, created_at, updated_at) - VALUES (:listingId, :occurrenceType, :startsAt, "scheduled", NOW(), NOW())' - ); - $occStmt->execute([ - 'listingId' => $listingId, - 'occurrenceType' => 'single', - 'startsAt' => $startsAt !== '' ? $startsAt : null, - ]); - } + $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data); } else { $placeStmt = $this->pdo->prepare( - 'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, place_kind, provider_hint, status, created_at, updated_at) - VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, "manual", "published", NOW(), NOW())' + 'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, place_kind, opening_hours_note, provider_hint, status, created_at, updated_at) + VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, :openingHoursNote, "manual", "published", NOW(), NOW())' ); $placeStmt->execute([ 'uid' => $userId, @@ -358,6 +386,7 @@ final class ListingCatalog 'lat' => $lat, 'lng' => $lng, 'placeKind' => $placeKind !== '' ? $placeKind : null, + 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, ]); $placeId = (int)$this->pdo->lastInsertId(); @@ -376,17 +405,7 @@ final class ListingCatalog ]); $listingId = (int)$this->pdo->lastInsertId(); - if ($entryType === 'editorial_event') { - $occStmt = $this->pdo->prepare( - 'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, status, created_at, updated_at) - VALUES (:listingId, :occurrenceType, :startsAt, "scheduled", NOW(), NOW())' - ); - $occStmt->execute([ - 'listingId' => $listingId, - 'occurrenceType' => 'single', - 'startsAt' => $startsAt !== '' ? $startsAt : null, - ]); - } + $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data); } $this->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); @@ -408,6 +427,118 @@ final class ListingCatalog } } + private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, string $priceMode, array $data): void + { + $occurrenceId = null; + if ($entryType === 'editorial_event') { + [$occurrenceType, $recurrenceRule] = $this->buildRecurrence($recurrenceMode, $weekdayValues); + $occStmt = $this->pdo->prepare( + 'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, status, created_at, updated_at) + VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, "scheduled", NOW(), NOW())' + ); + $occStmt->execute([ + 'listingId' => $listingId, + 'occurrenceType' => $occurrenceType, + 'startsAt' => $startsAt !== '' ? $startsAt : null, + 'recurrenceRule' => $recurrenceRule, + 'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null, + ]); + $occurrenceId = (int)$this->pdo->lastInsertId(); + } + + $this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); + if ($priceMode === 'none') { + return; + } + + $insertPrice = $this->pdo->prepare( + 'INSERT INTO listing_prices (listing_id, occurrence_id, label, audience, price_type, amount, amount_secondary, currency, note, created_at, updated_at) + VALUES (:listingId, :occurrenceId, :label, :audience, :priceType, :amount, :amountSecondary, "EUR", :note, NOW(), NOW())' + ); + + if ($priceMode === 'free_child_rule') { + $freeUntil = trim((string)($data['free_child_until_age'] ?? '')); + $insertPrice->execute([ + 'listingId' => $listingId, + 'occurrenceId' => $occurrenceId, + 'label' => 'Eintritt frei', + 'audience' => 'child', + 'priceType' => 'free', + 'amount' => null, + 'amountSecondary' => null, + 'note' => $freeUntil !== '' ? 'Kinder bis ' . $freeUntil . ' Jahre frei' : 'Kinder frei', + ]); + } + + $childAmount = trim((string)($data['price_child_amount'] ?? '')); + if ($childAmount !== '') { + $childFrom = trim((string)($data['price_child_age_from'] ?? '')); + $childTo = trim((string)($data['price_child_age_to'] ?? '')); + $note = 'Preis pro Kind'; + if ($childFrom !== '' || $childTo !== '') { + $note .= ' (' . trim(($childFrom !== '' ? 'ab ' . $childFrom : '') . ($childTo !== '' ? ' bis ' . $childTo : '') . ' Jahre') . ')'; + } + $insertPrice->execute([ + 'listingId' => $listingId, + 'occurrenceId' => $occurrenceId, + 'label' => 'Kinder', + 'audience' => 'child', + 'priceType' => 'fixed', + 'amount' => (float)str_replace(',', '.', $childAmount), + 'amountSecondary' => null, + 'note' => $note, + ]); + } + + $adultAmount = trim((string)($data['price_adult_amount'] ?? '')); + if ($adultAmount !== '') { + $adultFrom = trim((string)($data['price_adult_age_from'] ?? '')); + $insertPrice->execute([ + 'listingId' => $listingId, + 'occurrenceId' => $occurrenceId, + 'label' => 'Erwachsene', + 'audience' => 'adult', + 'priceType' => 'fixed', + 'amount' => (float)str_replace(',', '.', $adultAmount), + 'amountSecondary' => null, + 'note' => $adultFrom !== '' ? 'Preis pro Erwachsener ab ' . $adultFrom . ' Jahre' : 'Preis pro Erwachsener', + ]); + } + } + + private function listPrices(int $listingId): array + { + $stmt = $this->pdo->prepare('SELECT * FROM listing_prices WHERE listing_id = :listingId ORDER BY id ASC'); + $stmt->execute(['listingId' => $listingId]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + private function buildRecurrence(string $recurrenceMode, array $weekdayValues): array + { + $map = ['mo' => 'MO', 'di' => 'TU', 'mi' => 'WE', 'do' => 'TH', 'fr' => 'FR', 'sa' => 'SA', 'so' => 'SU']; + if ($recurrenceMode === 'daily') { + return ['series', 'FREQ=DAILY']; + } + if ($recurrenceMode === 'weekly') { + return ['series', 'FREQ=WEEKLY']; + } + if ($recurrenceMode === 'weekdays') { + return ['series', 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR']; + } + if ($recurrenceMode === 'custom_weekdays' && $weekdayValues !== []) { + $days = []; + foreach ($weekdayValues as $value) { + if (isset($map[$value])) { + $days[] = $map[$value]; + } + } + if ($days !== []) { + return ['series', 'FREQ=WEEKLY;BYDAY=' . implode(',', $days)]; + } + } + return ['single', null]; + } + public function deleteDashboardEntry(int $userId, int $listingId): void { $this->ensureSchema(); @@ -451,6 +582,24 @@ final class ListingCatalog } } + private function hasColumn(string $table, string $column): bool + { + $key = $table . '.' . $column; + if (array_key_exists($key, $this->columnCache)) { + return $this->columnCache[$key]; + } + try { + $stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table AND column_name = :column LIMIT 1'); + $stmt->execute([ + 'table' => $table, + 'column' => $column, + ]); + return $this->columnCache[$key] = (bool)$stmt->fetchColumn(); + } catch (\Throwable) { + return $this->columnCache[$key] = false; + } + } + private function defaultCategories(): array { return [