diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index 2a1281e..fdb8767 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -23,6 +23,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - Profiladresse kann per Suche oder Browser-Standort übernommen werden. - Im Bereich `Events` gibt es jetzt drei Eintragstypen: eigenes Event, Ort und sonstige Veranstaltung. - Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb zuerst nur Pflichtangaben. +- Kategorien werden im Mitgliederbereich nicht mehr per Select gewählt, sondern per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt. - Eigene Events unterstützen Kategorie, optionales Bild, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl und Sichtbarkeit. - `Ort` nutzt die gleiche Adresslogik wie das Profil, blendet den Event-Termin aus und erlaubt stattdessen flexible Öffnungs-/Zeitfenster, optionale Kontaktangaben und flexible Preisregeln. - `Sonstige Veranstaltung` nutzt ebenfalls die Profil-Adresslogik, unterstützt Wiederholungen, ein Pflichtfeld `Gültig bis`, flexible Preisregeln, Bild-Upload und die Angabe `Mit Kindern`. diff --git a/Internal/de/PROJECT_STRUCTURE.md b/Internal/de/PROJECT_STRUCTURE.md index c5c2816..6a2bc99 100644 --- a/Internal/de/PROJECT_STRUCTURE.md +++ b/Internal/de/PROJECT_STRUCTURE.md @@ -21,6 +21,7 @@ Stand: 2026-08-04 - UI für Eintragstyp-Auswahl im Mitgliederbereich: `partials/landing/account/dashboard.php` - Preis-, Wiederholungs-, Bild- und Ortseingabe für neue Listing-Typen: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php` - Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `schema.sql` um Kategorie- und Bildfelder +- Kategorien-Prüfung und Zusammenführung für Seiten-Admins liegen ebenfalls in `partials/landing/account/dashboard.php` mit Logik in `src/App/ListingCatalog.php` ## Dokumentationsregel Kanonische interne Dokumentation: diff --git a/Internal/de/README.md b/Internal/de/README.md index b0d9ae4..fc3f9e7 100644 --- a/Internal/de/README.md +++ b/Internal/de/README.md @@ -26,6 +26,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale - Eigene Events haben jetzt Kategorie, optionales Bild, klare Kinderangabe, optionale Platzzahl und serverseitig validierte Adressdaten - Orte und sonstige Veranstaltungen haben jetzt eine angepasste Eingabemaske mit Ortssuche, Browser-Adressübernahme, serverseitiger Adressvalidierung, flexiblen Preisregeln, flexiblen Öffnungs-/Zeitfenstern und Bild-Upload - Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen +- Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden ## Wording-Regel - Primärbegriff im Produkt: `Events` diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index ec8f7fb..2e6475f 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -23,6 +23,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events - Profile address can be completed via address search or browser-based location import. - The `Events` area now distinguishes between own events, places, and other time-limited event entries. - The `Events` input flow should stay as simple as possible and therefore shows required fields first. +- Categories in the member area are no longer chosen via select only, but via a search input with existing suggestions and automatic creation when needed. - Own events support category, optional image, a clear `with children` yes/no field, optional capacity, and visibility. - `Place` uses the same address flow as the profile, hides the event date, and instead supports flexible opening/time slots, optional contact data, and flexible pricing rules. - `Other Event` also uses the profile-style address flow, supports recurrence, a required `valid until` field, flexible pricing rules, image upload, and a `with children` flag. diff --git a/Internal/en/README.md b/Internal/en/README.md index 8ade69b..b8cfa01 100644 --- a/Internal/en/README.md +++ b/Internal/en/README.md @@ -26,6 +26,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app - own events now include category, optional image, explicit child suitability, optional capacity, and server-side validated address data - places and other events now use adaptive forms with place lookup, browser address pickup, server-side address validation, flexible pricing rules, flexible opening/time slots, and image upload - the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections +- categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area ## Core Wording Rule - Primary product term: `Events` diff --git a/README.md b/README.md index 9358f3c..495df06 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf: - Für Orte und sonstige Veranstaltungen gibt es jetzt typabhängige Masken mit Ortssuche, Browser-Adressübernahme, Adressprüfung, flexiblen Preisregeln, flexiblen Öffnungs-/Zeitfenstern und Bild-Upload - Eigene Events unterstützen jetzt zusätzlich Kategorie, Bild-Upload, klare Kinderangabe und optionale Platzzahl - Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen +- Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar ## Datenschutz und Sicherheit - sensible Profilfelder werden app-seitig verschlüsselt gespeichert diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index b4c94d0..dbb95a5 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -13,9 +13,9 @@ $currentEntryKind = $editingListing ? (string)($editListing['listing_type'] ?? ' $actionEvent = $editing ? 'event_update' : 'event_add'; $startVal = ''; if ($editingEvent && !empty($editEvent['starts_at'])) { - $startVal = date('Y-m-d\TH:i', strtotime((string)$editEvent['starts_at'])); + $startVal = date('Y-m-d', strtotime((string)$editEvent['starts_at'])); } elseif ($editingListing && !empty($editListing['starts_at'])) { - $startVal = date('Y-m-d\TH:i', strtotime((string)$editListing['starts_at'])); + $startVal = date('Y-m-d', strtotime((string)$editListing['starts_at'])); } $withChildrenValue = $editingEvent ? (((int)($editEvent['allow_kids'] ?? 1)) === 1 ? 'yes' : 'no') : (($editingListing && $currentEntryKind === 'editorial_event' && !empty($editListing['supports_registration'])) ? 'yes' : 'yes'); $eventTitle = $editingEvent ? 'Event bearbeiten' : ($editingListing ? ($currentEntryKind === 'place' ? 'Ort bearbeiten' : 'Veranstaltung bearbeiten') : 'Neuen Eintrag anlegen'); @@ -23,7 +23,6 @@ $submitLabel = $editingEvent ? 'Event speichern' : ($editingListing ? ($currentE $titleValue = $editingEvent ? (string)($editEvent['title'] ?? '') : (string)($editListing['title'] ?? ''); $teaserValue = $editingEvent ? (string)($editEvent['teaser_public'] ?? '') : (string)($editListing['teaser_public'] ?? ''); $descriptionValue = $editingEvent ? (string)($editEvent['description'] ?? '') : (string)($editListing['description'] ?? ''); -$locationLabelValue = $editingEvent ? (string)($editEvent['location_label'] ?? '') : ''; $streetValue = $editingEvent ? (string)($editEvent['street'] ?? '') : (string)($editListing['street'] ?? ''); $zipValue = $editingEvent ? (string)($editEvent['zip'] ?? '') : (string)($editListing['zip'] ?? ''); $cityValue = $editingEvent ? (string)($editEvent['city'] ?? '') : (string)($editListing['city'] ?? ''); @@ -42,35 +41,45 @@ $openingHoursRows = $editingListing && !empty($editListing['opening_hours']) && $recurrenceRuleValue = $editingListing ? (string)($editListing['recurrence_rule'] ?? '') : ''; $recurrenceUntilValue = ''; if ($editingListing && !empty($editListing['recurrence_until'])) { - $recurrenceUntilValue = date('Y-m-d\TH:i', strtotime((string)$editListing['recurrence_until'])); + $recurrenceUntilValue = date('Y-m-d', strtotime((string)$editListing['recurrence_until'])); } $recurrenceModeValue = 'single'; if (str_contains($recurrenceRuleValue, 'FREQ=DAILY')) { $recurrenceModeValue = 'daily'; -} elseif (str_contains($recurrenceRuleValue, 'BYDAY=MO,TU,WE,TH,FR')) { - $recurrenceModeValue = 'weekdays'; -} elseif (str_contains($recurrenceRuleValue, 'BYDAY=')) { - $recurrenceModeValue = 'custom_weekdays'; } elseif (str_contains($recurrenceRuleValue, 'FREQ=WEEKLY')) { $recurrenceModeValue = 'weekly'; } -$selectedWeekdays = []; -if (preg_match('/BYDAY=([A-Z,]+)/', $recurrenceRuleValue, $matches)) { - $map = ['MO' => 'mo', 'TU' => 'di', 'WE' => 'mi', 'TH' => 'do', 'FR' => 'fr', 'SA' => 'sa', 'SU' => 'so']; - foreach (explode(',', $matches[1]) as $day) { - if (isset($map[$day])) { - $selectedWeekdays[] = $map[$day]; +$categoryTitleMap = []; +foreach (($listingCategories ?? []) as $categoryRow) { + $categoryTitleMap[(string)($categoryRow['slug'] ?? '')] = (string)($categoryRow['title'] ?? ''); +} +$categoryInputValue = $categoryTitleMap[$categorySlugValue] ?? $categorySlugValue; +$priceRows = []; +if ($editingListing && !empty($editListing['prices']) && is_array($editListing['prices'])) { + foreach ($editListing['prices'] as $priceRow) { + $ageFrom = ''; + $ageTo = ''; + $noteValue = (string)($priceRow['note'] ?? ''); + if (preg_match('/ab\s+(\d+)/u', $noteValue, $match)) { + $ageFrom = $match[1]; } + if (preg_match('/bis\s+(\d+)/u', $noteValue, $match)) { + $ageTo = $match[1]; + } + $priceRows[] = [ + 'amount' => (string)($priceRow['amount'] ?? ''), + 'age_from' => $ageFrom, + 'age_to' => $ageTo, + ]; } } -$priceRows = $editingListing && !empty($editListing['prices']) && is_array($editListing['prices']) ? $editListing['prices'] : [[ - 'label' => '', - 'audience' => 'general', - 'price_type' => 'fixed', - 'amount' => '', - 'amount_secondary' => '', - 'note' => '', -]]; +if ($priceRows === []) { + $priceRows = [[ + 'amount' => '', + 'age_from' => '', + 'age_to' => '', + ]]; +} $editingChild = isset($editChild) && $editChild !== null; $childAction = $editingChild ? 'child_update' : 'child_add'; $childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen'; @@ -509,6 +518,44 @@ if (!empty($canManageSystemSettings)) {
  • Fehlende Tabellen:
  • + +
    + Kategorien prüfen und zusammenführen +

    Neue Kategorien werden automatisch angelegt. Hier kannst du sie prüfen und bei Bedarf mit einer bestehenden Kategorie zusammenführen.

    + +
    + +
    +
    +
    + + + Neu + +
    + Slug: · Einträge: · Orte: · Eigene Events: +
    +
    +
    +
    + + + + +
    +
    + +
    + +

    Aktuell gibt es keine Kategorien zur Prüfung.

    + +
    @@ -921,9 +968,6 @@ if (!empty($canManageSystemSettings)) {

    -
    -

    Trage zuerst nur die wichtigsten Angaben ein. Alles Weitere ist optional und unten einklappbar.

    -
    @@ -933,44 +977,22 @@ if (!empty($canManageSystemSettings)) {

    Die Kurzbeschreibung wird automatisch aus dem Text erzeugt.

    -
    -
    - - -
    -
    - - -
    +
    + + + + + + + + +
    -
    - Vorhandenen Ort übernehmen -
    - -
    - - -
    -
    -
    - - + +
    -
    - - -
    -
    -
    +
    +
    +
    + + +
    @@ -989,21 +1017,7 @@ if (!empty($canManageSystemSettings)) { - - -
    - 'Mo', 'di' => 'Di', 'mi' => 'Mi', 'do' => 'Do', 'fr' => 'Fr', 'sa' => 'Sa', 'so' => 'So'] as $weekdayValue => $weekdayLabel): ?> - - -
    -
    - - -
    @@ -1028,7 +1042,7 @@ if (!empty($canManageSystemSettings)) {
    - +
    @@ -1037,8 +1051,7 @@ if (!empty($canManageSystemSettings)) { Adresse auf Karte wählen
    - - Hält Karte und Adresse synchron. +
    @@ -1122,29 +1135,12 @@ if (!empty($canManageSystemSettings)) {
    - - + +
    -
    -
    - - -
    -
    - - -
    @@ -1188,7 +1184,7 @@ if (!empty($canManageSystemSettings)) { const mapWrapper = document.getElementById('mapWrapper'); const mapContainer = document.getElementById('mapContainer'); const btnMapSearch = document.getElementById('btnMapSearch'); - const btnAddrToMap = document.getElementById('btnAddrToMap'); + const btnEventMapCurrentLocation = document.getElementById('btnEventMapCurrentLocation'); const btnEventAddressSearch = document.getElementById('btnEventAddressSearch'); const btnEventBrowserLocation = document.getElementById('btnEventBrowserLocation'); const eventAddressStatus = document.getElementById('eventAddressStatus'); @@ -1199,24 +1195,24 @@ if (!empty($canManageSystemSettings)) { const entryKindHint = document.getElementById('entryKindHint'); const entryKindButtons = document.querySelectorAll('[data-entry-kind-choice]'); const eventDateRow = document.getElementById('eventDateRow'); - const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap'); + const eventDateLabel = document.getElementById('eventDateLabel'); const eventChildrenWrap = document.getElementById('eventChildrenWrap'); const eventCapacityWrap = document.getElementById('eventCapacityWrap'); + const eventValidUntilWrap = document.getElementById('eventValidUntilWrap'); const eventPricingWrap = document.getElementById('eventPricingWrap'); + const visibilityDetails = document.getElementById('visibilityDetails'); const placeContactWrap = document.getElementById('placeContactWrap'); - const placeLookupWrap = document.getElementById('placeLookupWrap'); const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap'); const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap'); const eventDateInput = document.getElementById('evDate'); - const categorySelect = document.getElementById('listingCategorySlug'); + const categoryInput = document.getElementById('categoryInput'); + const categorySlugInput = document.getElementById('listingCategorySlug'); + const categoryInputStatus = document.getElementById('categoryInputStatus'); const recurrenceModeSelect = document.getElementById('recurrenceMode'); - const recurrenceWeekdaysWrap = document.getElementById('recurrenceWeekdaysWrap'); const openingHoursRows = document.getElementById('openingHoursRows'); const priceRows = document.getElementById('priceRows'); const btnAddOpeningRow = document.getElementById('btnAddOpeningRow'); const btnAddPriceRow = document.getElementById('btnAddPriceRow'); - const existingPlaceLookup = document.getElementById('existingPlaceLookup'); - const btnUseExistingPlace = document.getElementById('btnUseExistingPlace'); const streetInput = document.getElementById('evStreet'); const zipInput = document.getElementById('evZip'); const cityInput = document.getElementById('evCity'); @@ -1236,7 +1232,8 @@ if (!empty($canManageSystemSettings)) { const profileAddressResults = document.getElementById('profileAddressResults'); const profileAddressResultsModal = document.getElementById('modalProfileAddressResults'); let map, marker; - const existingPlaces = ; + const existingCategories = ; + let lastAutoGeocodeQuery = ''; function normalizeGeoResult(result) { const address = result.address || {}; @@ -1286,21 +1283,18 @@ if (!empty($canManageSystemSettings)) { setSectionState(eventDateRow, kind !== 'place'); if (eventDateInput) eventDateInput.required = kind !== 'place'; - setSectionState(eventLocationLabelWrap, kind === 'event'); - if (categorySelect) categorySelect.required = true; + if (categoryInput) categoryInput.required = true; setSectionState(eventChildrenWrap, kind === 'event' || kind === 'editorial_event'); setSectionState(eventCapacityWrap, kind === 'event'); + setSectionState(eventValidUntilWrap, kind === 'editorial_event'); setSectionState(eventPricingWrap, kind === 'place' || kind === 'editorial_event'); - setSectionState(placeLookupWrap, kind === 'place' || kind === 'editorial_event'); setSectionState(placeContactWrap, kind === 'place' || kind === 'editorial_event'); setSectionState(placeOpeningHoursWrap, kind === 'place' || kind === 'editorial_event'); setSectionState(eventRecurrenceWrap, kind === 'editorial_event'); - syncRecurrenceVisibility(); - } - - function syncRecurrenceVisibility() { - const mode = recurrenceModeSelect?.value || 'single'; - if (recurrenceWeekdaysWrap) recurrenceWeekdaysWrap.hidden = mode !== 'custom_weekdays'; + setSectionState(visibilityDetails, kind === 'event'); + if (eventDateLabel) { + eventDateLabel.textContent = kind === 'editorial_event' ? 'Gültig von' : 'Datum'; + } } entryKindButtons.forEach((button) => { @@ -1308,10 +1302,7 @@ if (!empty($canManageSystemSettings)) { syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event'); }); }); - recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility); - syncEventEntryKind(entryKindInput?.value || 'event'); - syncRecurrenceVisibility(); function createOpeningRow() { const row = document.createElement('div'); @@ -1329,35 +1320,13 @@ if (!empty($canManageSystemSettings)) { const row = document.createElement('div'); row.className = 'stack gap-8 price-row card'; row.innerHTML = ` -
    - - -
    -
    - - -
    -
    - - -
    +
    + +
    `; priceRows?.appendChild(row); } @@ -1365,29 +1334,30 @@ if (!empty($canManageSystemSettings)) { btnAddOpeningRow?.addEventListener('click', createOpeningRow); btnAddPriceRow?.addEventListener('click', createPriceRow); - function applyExistingPlace(place) { - if (!place) return; - if (document.getElementById('evTitle') && !document.getElementById('evTitle').value.trim()) { - document.getElementById('evTitle').value = place.title || ''; - } - if (streetInput) streetInput.value = place.street || ''; - if (zipInput) zipInput.value = place.zip || ''; - if (cityInput) cityInput.value = place.city || ''; - if (regionInput) regionInput.value = place.region || ''; - if (categorySelect && place.place_kind) categorySelect.value = place.place_kind; - } - - btnUseExistingPlace?.addEventListener('click', () => { - const query = (existingPlaceLookup?.value || '').trim().toLowerCase(); - if (!query) return; - const match = existingPlaces.find((place) => String(place.title || '').trim().toLowerCase() === query); - if (match) { - applyExistingPlace(match); - if (eventAddressStatus) eventAddressStatus.textContent = 'Vorhandener Ort übernommen.'; + function syncCategoryInputState() { + const value = (categoryInput?.value || '').trim().toLowerCase(); + if (!categorySlugInput || !categoryInputStatus) return; + if (!value) { + categorySlugInput.value = ''; + categoryInputStatus.textContent = ''; return; } - if (eventAddressStatus) eventAddressStatus.textContent = 'Kein vorhandener Ort exakt gefunden. Du kannst unten einen neuen Ort anlegen.'; - }); + const match = existingCategories.find((category) => { + const title = String(category.title || '').trim().toLowerCase(); + const slug = String(category.slug || '').trim().toLowerCase(); + return value === title || value === slug; + }); + if (match) { + categorySlugInput.value = String(match.slug || ''); + categoryInputStatus.textContent = 'Bestehende Kategorie wird verwendet.'; + return; + } + categorySlugInput.value = ''; + categoryInputStatus.textContent = 'Wird neu angelegt.'; + } + + categoryInput?.addEventListener('input', syncCategoryInputState); + syncCategoryInputState(); function ensureLeaflet(callback) { if (!window.PKTConsent || !window.PKTConsent.has('external_services')) { @@ -1464,6 +1434,10 @@ if (!empty($canManageSystemSettings)) { if (Array.isArray(data) && data[0]) { const lat = parseFloat(data[0].lat); const lng = parseFloat(data[0].lon); + if (!Number.isNaN(lat) && !Number.isNaN(lng)) { + if (latInput) latInput.value = lat.toFixed(7); + if (lngInput) lngInput.value = lng.toFixed(7); + } ensureLeaflet(() => { mapWrapper.hidden = false; initMap(); @@ -1559,17 +1533,6 @@ if (!empty($canManageSystemSettings)) { geocodeAndPlace(q); }); - btnAddrToMap?.addEventListener('click', () => { - const parts = [ - streetInput?.value || '', - zipInput?.value || '', - cityInput?.value || '', - regionInput?.value || '', - ].map(v => v.trim()).filter(Boolean); - if (!parts.length) return; - geocodeAndPlace(parts.join(', ')); - }); - btnEventAddressSearch?.addEventListener('click', () => { const parts = [ streetInput?.value || '', @@ -1593,6 +1556,14 @@ if (!empty($canManageSystemSettings)) { if (latInput) latInput.value = Number.isNaN(normalized.lat) ? '' : normalized.lat.toFixed(7); if (lngInput) lngInput.value = Number.isNaN(normalized.lng) ? '' : normalized.lng.toFixed(7); if (eventAddressStatus) eventAddressStatus.textContent = `Gewählte Adresse: ${normalized.label || [normalized.street, `${normalized.zip} ${normalized.city}`.trim(), normalized.region].filter(Boolean).join(', ')}`; + if (!mapWrapper.hidden) { + ensureLeaflet(() => { + initMap(); + if (!Number.isNaN(normalized.lat) && !Number.isNaN(normalized.lng)) { + setMarker({ lat: normalized.lat, lng: normalized.lng }); + } + }); + } }); }); @@ -1617,6 +1588,13 @@ if (!empty($canManageSystemSettings)) { if (latInput) latInput.value = Number.isNaN(normalized.lat) ? '' : normalized.lat.toFixed(7); if (lngInput) lngInput.value = Number.isNaN(normalized.lng) ? '' : normalized.lng.toFixed(7); if (eventAddressStatus) eventAddressStatus.textContent = `Browser-Adresse übernommen: ${normalized.label || [normalized.street, `${normalized.zip} ${normalized.city}`.trim(), normalized.region].filter(Boolean).join(', ')}`; + ensureLeaflet(() => { + mapWrapper.hidden = false; + initMap(); + if (!Number.isNaN(normalized.lat) && !Number.isNaN(normalized.lng)) { + setMarker({ lat: normalized.lat, lng: normalized.lng }); + } + }); }); }, () => { if (eventAddressStatus) eventAddressStatus.textContent = ''; @@ -1624,6 +1602,43 @@ if (!empty($canManageSystemSettings)) { }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }); }); + btnEventMapCurrentLocation?.addEventListener('click', () => { + btnEventBrowserLocation?.click(); + }); + + function syncMapFromAddressFields() { + const parts = [ + streetInput?.value || '', + zipInput?.value || '', + cityInput?.value || '', + regionInput?.value || '', + ].map((value) => value.trim()).filter(Boolean); + if (!parts.length) return; + const query = parts.join(', '); + if (query === lastAutoGeocodeQuery) return; + lastAutoGeocodeQuery = query; + geocodeAddressCandidates(query, (results) => { + const first = results?.[0]; + if (!first) return; + const normalized = normalizeGeoResult(first); + if (latInput) latInput.value = Number.isNaN(normalized.lat) ? '' : normalized.lat.toFixed(7); + if (lngInput) lngInput.value = Number.isNaN(normalized.lng) ? '' : normalized.lng.toFixed(7); + if (!mapWrapper.hidden) { + ensureLeaflet(() => { + initMap(); + if (!Number.isNaN(normalized.lat) && !Number.isNaN(normalized.lng)) { + setMarker({ lat: normalized.lat, lng: normalized.lng }); + } + }); + } + }); + } + + [streetInput, zipInput, cityInput, regionInput].forEach((field) => { + field?.addEventListener('change', syncMapFromAddressFields); + field?.addEventListener('blur', syncMapFromAddressFields); + }); + (function(){ const setProfileStatus = (message = '') => { if (!profileAddressStatus) return; diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 338cc9c..30d0003 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -309,6 +309,15 @@ final class AccountPages 'place_data_provider' => $placeDataProvider, ], $userId); $info = 'System-Einstellungen gespeichert.'; + } elseif ($action === 'category_merge') { + if (!$canManageSystemSettings || !$listingCatalog) { + throw new \RuntimeException('Keine Berechtigung für die Kategorien-Verwaltung.'); + } + $listingCatalog->mergeCategories( + (string)($_POST['source_category_slug'] ?? ''), + (string)($_POST['target_category_slug'] ?? '') + ); + $info = 'Kategorie zusammengeführt.'; } elseif ($action === 'child_add' || $action === 'child_update') { $crypto = self::requireCrypto($crypto, 'Kinder'); $childId = (int)($_POST['child_id'] ?? 0); @@ -459,12 +468,15 @@ final class AccountPages $imagePath = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingEvent) ? (string)($existingEvent['image_path'] ?? '') : ''); $allowKids = ((string)($_POST['with_children'] ?? 'yes')) === 'yes' ? 1 : 0; $capacity = isset($_POST['max_participants']) && $_POST['max_participants'] !== '' ? (int)$_POST['max_participants'] : null; - $categorySlug = trim((string)($_POST['category_slug'] ?? '')); - $customCategoryTitle = trim((string)($_POST['category_custom_title'] ?? '')); - if ($customCategoryTitle !== '') { - $categorySlug = self::slugifyValue($customCategoryTitle); + $categoryInput = trim((string)($_POST['category_input'] ?? $_POST['category_slug'] ?? '')); + $categorySlug = ''; + if ($categoryInput !== '' && $listingCatalog) { + $category = $listingCatalog->ensureCategoryForInput($categoryInput, 'event'); + $categorySlug = (string)($category['slug'] ?? ''); } + $eventStartValue = self::normalizeDateForStorage((string)($_POST['starts_at'] ?? ''), true); + if ($action === 'event_add') { $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, category_slug, image_path, location_label, street, zip, city, region, lat, lng, starts_at, max_participants, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :categorySlug, :imagePath, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :capacity, :allow, :vis, :status, NOW(), NOW())'); $stmt?->execute([ @@ -474,14 +486,14 @@ final class AccountPages 'descr' => trim((string)$_POST['description']), 'categorySlug' => $categorySlug !== '' ? $categorySlug : null, 'imagePath' => $imagePath !== '' ? $imagePath : null, - 'loc' => trim((string)$_POST['location_label']), + 'loc' => null, 'street' => $street ?: null, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, - 'start' => $_POST['starts_at'] ?? null, + 'start' => $eventStartValue, 'capacity' => $capacity, 'allow' => $allowKids, 'vis' => $_POST['visibility'] ?? 'public', @@ -506,14 +518,14 @@ final class AccountPages 'descr' => trim((string)$_POST['description']), 'categorySlug' => $categorySlug !== '' ? $categorySlug : null, 'imagePath' => $imagePath !== '' ? $imagePath : null, - 'loc' => trim((string)$_POST['location_label']), + 'loc' => null, 'street' => $street ?: null, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, - 'start' => $_POST['starts_at'] ?? null, + 'start' => $eventStartValue, 'capacity' => $capacity, 'allow' => $allowKids, 'vis' => $_POST['visibility'] ?? 'public', @@ -682,7 +694,8 @@ final class AccountPages $editEvent = null; $otherListings = []; $editListing = null; - $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : []; + $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['general', 'place', 'food', 'event', 'family']) : []; + $categoryReviewItems = $listingCatalog && $canManageSystemSettings ? $listingCatalog->listCategoryReviewItems() : []; $placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.description, e.category_slug, e.image_path, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.max_participants, e.allow_kids, e.visibility, e.status, e.lat, e.lng, @@ -750,6 +763,7 @@ final class AccountPages 'otherListings', 'editListing', 'listingCategories', + 'categoryReviewItems', 'placeSuggestions', 'communityPoints', 'communityLevel', @@ -973,6 +987,20 @@ final class AccountPages return trim($value, '-'); } + private static function normalizeDateForStorage(string $value, bool $endOfDay = false): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { + return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'); + } + + return $value; + } + private static function reverseGeocodeAddress(float $lat, float $lng): ?array { $url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([ diff --git a/src/App/ListingCatalog.php b/src/App/ListingCatalog.php index bd10c79..9d88e8c 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -217,6 +217,136 @@ final class ListingCatalog return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } + public function ensureCategoryForInput(string $input, string $group = 'general'): ?array + { + $this->ensureSchema(); + $input = trim($input); + if ($input === '') { + return null; + } + + $slug = $this->slugify($input); + $stmt = $this->pdo->prepare( + 'SELECT id, slug, title, category_group, sort_order + FROM listing_categories + WHERE slug = :slug OR LOWER(title) = LOWER(:title) + ORDER BY sort_order ASC, id ASC + LIMIT 1' + ); + $stmt->execute([ + 'slug' => $slug, + 'title' => $input, + ]); + $existing = $stmt->fetch(\PDO::FETCH_ASSOC); + if ($existing) { + $existing['is_new'] = false; + return $existing; + } + + $insert = $this->pdo->prepare( + 'INSERT INTO listing_categories (slug, title, category_group, sort_order) + VALUES (:slug, :title, :groupName, 999)' + ); + $insert->execute([ + 'slug' => $slug, + 'title' => $input, + 'groupName' => in_array($group, ['general', 'event', 'place', 'food', 'family', 'partner'], true) ? $group : 'general', + ]); + + return [ + 'id' => (int)$this->pdo->lastInsertId(), + 'slug' => $slug, + 'title' => $input, + 'category_group' => $group, + 'sort_order' => 999, + 'is_new' => true, + ]; + } + + public function listCategoryReviewItems(): array + { + $this->ensureSchema(); + $stmt = $this->pdo->query( + 'SELECT c.id, c.slug, c.title, c.category_group, c.sort_order, + (SELECT COUNT(*) FROM listing_category_map m WHERE m.category_id = c.id) AS listing_count, + (SELECT COUNT(*) FROM listing_places p WHERE p.place_kind = c.slug) AS place_count + FROM listing_categories c + ORDER BY + CASE WHEN c.sort_order >= 900 THEN 0 ELSE 1 END, + c.sort_order ASC, + c.title ASC' + ); + $rows = $stmt ? ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []) : []; + + $eventStmt = $this->pdo->query('SELECT category_slug, COUNT(*) AS total FROM events WHERE category_slug IS NOT NULL AND category_slug <> "" GROUP BY category_slug'); + $eventCounts = []; + foreach (($eventStmt ? $eventStmt->fetchAll(\PDO::FETCH_ASSOC) : []) as $row) { + $eventCounts[(string)$row['category_slug']] = (int)$row['total']; + } + + foreach ($rows as &$row) { + $row['event_count'] = $eventCounts[(string)$row['slug']] ?? 0; + $row['needs_review'] = ((int)($row['sort_order'] ?? 0)) >= 900; + } + unset($row); + + return $rows; + } + + public function mergeCategories(string $sourceSlug, string $targetSlug): void + { + $this->ensureSchema(); + $sourceSlug = trim($sourceSlug); + $targetSlug = trim($targetSlug); + if ($sourceSlug === '' || $targetSlug === '' || $sourceSlug === $targetSlug) { + throw new \RuntimeException('Bitte zwei unterschiedliche Kategorien auswählen.'); + } + + $lookup = $this->pdo->prepare('SELECT id, slug FROM listing_categories WHERE slug = :slug LIMIT 1'); + $lookup->execute(['slug' => $sourceSlug]); + $source = $lookup->fetch(\PDO::FETCH_ASSOC); + $lookup->execute(['slug' => $targetSlug]); + $target = $lookup->fetch(\PDO::FETCH_ASSOC); + if (!$source || !$target) { + throw new \RuntimeException('Kategorie nicht gefunden.'); + } + + $sourceId = (int)$source['id']; + $targetId = (int)$target['id']; + + $this->pdo->beginTransaction(); + try { + $mapRows = $this->pdo->prepare('SELECT listing_id FROM listing_category_map WHERE category_id = :categoryId'); + $mapRows->execute(['categoryId' => $sourceId]); + $listingIds = $mapRows->fetchAll(\PDO::FETCH_COLUMN) ?: []; + $insertMap = $this->pdo->prepare( + 'INSERT IGNORE INTO listing_category_map (listing_id, category_id) VALUES (:listingId, :categoryId)' + ); + foreach ($listingIds as $listingId) { + $insertMap->execute([ + 'listingId' => (int)$listingId, + 'categoryId' => $targetId, + ]); + } + $this->pdo->prepare('DELETE FROM listing_category_map WHERE category_id = :categoryId')->execute(['categoryId' => $sourceId]); + $this->pdo->prepare('UPDATE listing_places SET place_kind = :target WHERE place_kind = :source')->execute([ + 'target' => $targetSlug, + 'source' => $sourceSlug, + ]); + $this->pdo->prepare('UPDATE events SET category_slug = :target WHERE category_slug = :source')->execute([ + 'target' => $targetSlug, + 'source' => $sourceSlug, + ]); + $this->pdo->prepare('DELETE FROM listing_categories WHERE id = :id')->execute(['id' => $sourceId]); + $this->pdo->commit(); + } catch (\Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + } + public function listDashboardEntries(int $userId): array { $this->ensureSchema(); @@ -302,8 +432,8 @@ final class ListingCatalog $openingHoursNote = trim((string)($data['opening_hours_note'] ?? '')); $openingHoursRows = $this->normalizeOpeningHoursRows($data); $visibility = (string)($data['visibility'] ?? 'public'); - $placeKind = trim((string)($data['category_slug'] ?? '')); - $customCategoryTitle = trim((string)($data['category_custom_title'] ?? '')); + $categoryInput = trim((string)($data['category_input'] ?? $data['category_slug'] ?? '')); + $placeKind = $categoryInput; $street = trim((string)($data['street'] ?? '')); $zip = trim((string)($data['zip'] ?? '')); $city = trim((string)($data['city'] ?? '')); @@ -332,10 +462,14 @@ final class ListingCatalog if ($entryType === 'editorial_event' && $recurrenceUntil === '') { throw new \RuntimeException('Bitte gib für Veranstaltungen ein Gültig-bis-Datum an.'); } + $startsAt = $this->normalizeDateForStorage($startsAt, false); + $recurrenceUntil = $this->normalizeDateForStorage($recurrenceUntil, true); $this->pdo->beginTransaction(); try { - [$placeKind, $categoryId] = $this->resolveCategory($placeKind, $customCategoryTitle); + $category = $this->ensureCategoryForInput($categoryInput, $entryType === 'place' ? 'place' : 'event'); + $placeKind = (string)($category['slug'] ?? ''); + $categoryId = isset($category['id']) ? (int)$category['id'] : null; if ($listingId !== null && $listingId > 0) { $existing = $this->getDashboardEntry($userId, $listingId); @@ -536,31 +670,6 @@ final class ListingCatalog return ['single', null]; } - private function resolveCategory(string $slug, string $customTitle): array - { - $slug = trim($slug); - $customTitle = trim($customTitle); - if ($customTitle !== '') { - $slug = $this->slugify($customTitle); - $stmt = $this->pdo->prepare( - 'INSERT INTO listing_categories (slug, title, category_group, sort_order) - VALUES (:slug, :title, "general", 999) - ON DUPLICATE KEY UPDATE title = VALUES(title), updated_at = CURRENT_TIMESTAMP' - ); - $stmt->execute([ - 'slug' => $slug, - 'title' => $customTitle, - ]); - } - if ($slug === '') { - return ['', null]; - } - $stmt = $this->pdo->prepare('SELECT id FROM listing_categories WHERE slug = :slug LIMIT 1'); - $stmt->execute(['slug' => $slug]); - $categoryId = $stmt->fetchColumn(); - return [$slug, $categoryId !== false ? (int)$categoryId : null]; - } - private function normalizeOpeningHoursRows(array $data): array { $labels = (array)($data['opening_day_label'] ?? []); @@ -586,32 +695,19 @@ final class ListingCatalog private function normalizePriceRows(array $data): array { - $labels = (array)($data['price_label'] ?? []); - $audiences = (array)($data['price_audience'] ?? []); - $types = (array)($data['price_type'] ?? []); $amounts = (array)($data['price_amount'] ?? []); - $secondaryAmounts = (array)($data['price_amount_secondary'] ?? []); $ageFroms = (array)($data['price_age_from'] ?? []); $ageTos = (array)($data['price_age_to'] ?? []); - $notes = (array)($data['price_note'] ?? []); - $count = max(count($labels), count($audiences), count($types), count($amounts), count($secondaryAmounts), count($ageFroms), count($ageTos), count($notes)); + $count = max(count($amounts), count($ageFroms), count($ageTos)); $rows = []; for ($index = 0; $index < $count; $index++) { - $label = trim((string)($labels[$index] ?? '')); - $audience = trim((string)($audiences[$index] ?? 'general')); - $priceType = trim((string)($types[$index] ?? 'fixed')); $amount = trim((string)($amounts[$index] ?? '')); - $secondary = trim((string)($secondaryAmounts[$index] ?? '')); $ageFrom = trim((string)($ageFroms[$index] ?? '')); $ageTo = trim((string)($ageTos[$index] ?? '')); - $note = trim((string)($notes[$index] ?? '')); - if ($label === '' && $amount === '' && $secondary === '' && $note === '' && $ageFrom === '' && $ageTo === '') { + if ($amount === '' && $ageFrom === '' && $ageTo === '') { continue; } $noteParts = []; - if ($note !== '') { - $noteParts[] = $note; - } if ($ageFrom !== '' || $ageTo !== '') { $ageText = []; if ($ageFrom !== '') { @@ -623,11 +719,11 @@ final class ListingCatalog $noteParts[] = implode(', ', $ageText); } $rows[] = [ - 'label' => $label !== '' ? $label : 'Preis', - 'audience' => in_array($audience, ['general', 'adult', 'child', 'family', 'group'], true) ? $audience : 'general', - 'price_type' => in_array($priceType, ['free', 'fixed', 'from', 'up_to', 'range', 'request'], true) ? $priceType : 'fixed', + 'label' => 'Preis', + 'audience' => 'general', + 'price_type' => $amount === '' ? 'request' : 'fixed', 'amount' => $amount !== '' ? (float)str_replace(',', '.', $amount) : null, - 'amount_secondary' => $secondary !== '' ? (float)str_replace(',', '.', $secondary) : null, + 'amount_secondary' => null, 'note' => $noteParts !== [] ? implode(' | ', $noteParts) : null, ]; } @@ -648,6 +744,18 @@ final class ListingCatalog return is_array($decoded) ? $decoded : []; } + private function normalizeDateForStorage(string $value, bool $endOfDay): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) { + return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00'); + } + return $value; + } + private function slugify(string $value): string { $value = mb_strtolower(trim($value));