yasdasd
All checks were successful
Deploy / deploy (push) Successful in 56s

This commit is contained in:
2026-08-10 22:57:21 +02:00
parent 018496f720
commit 5d979e60bf
11 changed files with 314 additions and 33 deletions

View File

@@ -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,

View File

@@ -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`

View File

@@ -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`

View File

@@ -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

View File

@@ -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`

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -54,6 +54,31 @@ foreach (($listingCategories ?? []) as $categoryRow) {
$categoryTitleMap[(string)($categoryRow['slug'] ?? '')] = (string)($categoryRow['title'] ?? '');
}
$categoryInputValue = $categoryTitleMap[$categorySlugValue] ?? $categorySlugValue;
$eventLocationModeValue = $editingEvent ? (string)($editEvent['location_source_type'] ?? 'custom') : 'custom';
$eventLocationSourceIdValue = $editingEvent && !empty($editEvent['location_source_listing_id']) ? (int)$editEvent['location_source_listing_id'] : 0;
$eventLocationSourceInputValue = '';
foreach (($eventLocationOptions ?? []) as $locationOption) {
if ((int)($locationOption['id'] ?? 0) !== $eventLocationSourceIdValue) {
continue;
}
$optionTypeLabel = (string)($locationOption['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung';
$optionAddressLabel = trim(implode(', ', array_filter([
(string)($locationOption['street'] ?? ''),
trim((string)($locationOption['zip'] ?? '') . ' ' . (string)($locationOption['city'] ?? '')),
])));
$optionDateLabel = !empty($locationOption['starts_at']) ? date('d.m.Y', strtotime((string)$locationOption['starts_at'])) : '';
$eventLocationSourceInputValue = trim(implode(' · ', array_filter([
$optionTypeLabel,
(string)($locationOption['title'] ?? ''),
$optionDateLabel,
$optionAddressLabel,
])));
if ($eventLocationModeValue !== 'custom' && !empty($locationOption['category_title'])) {
$categoryInputValue = (string)$locationOption['category_title'];
$categorySlugValue = (string)($locationOption['category_slug'] ?? $categorySlugValue);
}
break;
}
$priceRows = [];
if ($editingListing && !empty($editListing['prices']) && is_array($editListing['prices'])) {
foreach ($editListing['prices'] as $priceRow) {
@@ -1100,6 +1125,7 @@ if (!empty($canManageSystemSettings)) {
<?php if ($editingListing && $editListing): ?>
<input type="hidden" name="listing_id" value="<?= (int)$editListing['id'] ?>">
<?php endif; ?>
<input type="hidden" name="event_location_source_id" id="eventLocationSourceId" value="<?= $eventLocationSourceIdValue > 0 ? (int)$eventLocationSourceIdValue : '' ?>">
<datalist id="existingPlacesList">
<?php foreach ($placeSuggestions as $placeSuggestion): ?>
<option value="<?= htmlspecialchars((string)$placeSuggestion['title'], ENT_QUOTES) ?>" data-place='<?= htmlspecialchars(json_encode($placeSuggestion, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>'>
@@ -1107,6 +1133,25 @@ if (!empty($canManageSystemSettings)) {
</option>
<?php endforeach; ?>
</datalist>
<datalist id="eventLocationOptionList">
<?php foreach (($eventLocationOptions ?? []) as $locationOption): ?>
<?php
$optionTypeLabel = (string)($locationOption['listing_type'] ?? '') === 'place' ? 'Ort' : 'Veranstaltung';
$optionAddressLabel = trim(implode(', ', array_filter([
(string)($locationOption['street'] ?? ''),
trim((string)($locationOption['zip'] ?? '') . ' ' . (string)($locationOption['city'] ?? '')),
])));
$optionDateLabel = !empty($locationOption['starts_at']) ? date('d.m.Y', strtotime((string)$locationOption['starts_at'])) : '';
$optionLabel = trim(implode(' · ', array_filter([
$optionTypeLabel,
(string)($locationOption['title'] ?? ''),
$optionDateLabel,
$optionAddressLabel,
])));
?>
<option value="<?= htmlspecialchars($optionLabel, ENT_QUOTES) ?>" data-location='<?= htmlspecialchars(json_encode($locationOption, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>'></option>
<?php endforeach; ?>
</datalist>
<?php if (!$editing && $showEventTypeChoice): ?>
<div class="stack gap-8">
<label class="label">Was möchtest du anlegen?</label>
@@ -1130,8 +1175,19 @@ if (!empty($canManageSystemSettings)) {
</div>
<div class="stack gap-6">
<label class="label" for="evDesc">Beschreibung</label>
<textarea id="evDesc" name="description" class="textarea" rows="4" placeholder="Was erwartet Familien vor Ort?" required><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea>
<p class="muted small" style="margin:0;">Die Kurzbeschreibung wird automatisch aus dem Text erzeugt.</p>
<textarea id="evDesc" name="description" class="textarea" rows="4" placeholder="Was erwartet Familien vor Ort?"><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea>
</div>
<div class="stack gap-6" id="eventLocationModeWrap">
<label class="label" for="eventLocationMode">Location</label>
<select id="eventLocationMode" name="event_location_mode" class="select">
<option value="place" <?= $eventLocationModeValue === 'place' ? 'selected' : '' ?>>Ort aus Datenbank</option>
<option value="editorial_event" <?= $eventLocationModeValue === 'editorial_event' ? 'selected' : '' ?>>Veranstaltung aus Datenbank</option>
<option value="custom" <?= $eventLocationModeValue === 'custom' ? 'selected' : '' ?>>Benutzerdefiniert</option>
</select>
</div>
<div class="stack gap-6" id="eventLocationSourceWrap">
<label class="label" for="eventLocationSourceInput" id="eventLocationSourceLabel">Ort aus Datenbank</label>
<input id="eventLocationSourceInput" class="input" list="eventLocationOptionList" placeholder="Eintrag aus der Datenbank auswählen" value="<?= htmlspecialchars($eventLocationSourceInputValue, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="categoryInput">Kategorie</label>
@@ -1184,7 +1240,7 @@ if (!empty($canManageSystemSettings)) {
<?php elseif (($section ?? '') === 'places'): ?>
<p class="muted small" style="margin:0;">Neue Orte und Veranstaltungen werden erst nach Prüfung durch einen Admin freigegeben.</p>
<?php endif; ?>
<div class="form-grid">
<div class="form-grid" id="eventAddressFieldsWrap">
<div class="stack gap-6">
<label class="label" for="evZip">PLZ</label>
<input id="evZip" name="zip" class="input" maxlength="5" value="<?= htmlspecialchars($zipValue, ENT_QUOTES) ?>">
@@ -1194,18 +1250,17 @@ if (!empty($canManageSystemSettings)) {
<input id="evCity" name="city" class="input" value="<?= htmlspecialchars($cityValue, ENT_QUOTES) ?>">
</div>
</div>
<div class="form-grid">
<div class="form-grid" id="eventAddressStreetWrap">
<div class="stack gap-6">
<label class="label" for="evStreet">Straße / Adresse</label>
<input id="evStreet" name="street" class="input" placeholder="z. B. Musterstraße 12" value="<?= htmlspecialchars($streetValue, ENT_QUOTES) ?>">
<p class="muted small">Wird zur Karten-/Umkreissuche genutzt.</p>
</div>
<div class="stack gap-6">
<label class="label" for="evRegion">Region/Bezirk</label>
<input id="evRegion" name="region" class="input" value="<?= htmlspecialchars($regionValue, ENT_QUOTES) ?>">
</div>
</div>
<div class="flex gap-8" style="flex-wrap:wrap; align-items:center;">
<div class="flex gap-8" style="flex-wrap:wrap; align-items:center;" id="eventAddressActionsWrap">
<button class="btn ghost" type="button" id="btnEventAddressSearch">Adresse suchen</button>
<button class="btn ghost" type="button" id="btnEventBrowserLocation">📍 Aktuellen Standort</button>
</div>
@@ -1355,6 +1410,7 @@ if (!empty($canManageSystemSettings)) {
const btnEventBrowserLocation = document.getElementById('btnEventBrowserLocation');
const eventAddressStatus = document.getElementById('eventAddressStatus');
const mapSearch = document.getElementById('mapSearch');
const mapDetails = document.getElementById('mapDetails');
const latInput = document.getElementById('evLat');
const lngInput = document.getElementById('evLng');
const entryKindInput = document.getElementById('entryKind');
@@ -1364,6 +1420,15 @@ if (!empty($canManageSystemSettings)) {
const eventDateLabel = document.getElementById('eventDateLabel');
const eventChildrenWrap = document.getElementById('eventChildrenWrap');
const eventCapacityWrap = document.getElementById('eventCapacityWrap');
const eventLocationModeWrap = document.getElementById('eventLocationModeWrap');
const eventLocationModeSelect = document.getElementById('eventLocationMode');
const eventLocationSourceWrap = document.getElementById('eventLocationSourceWrap');
const eventLocationSourceInput = document.getElementById('eventLocationSourceInput');
const eventLocationSourceIdInput = document.getElementById('eventLocationSourceId');
const eventLocationSourceLabel = document.getElementById('eventLocationSourceLabel');
const eventAddressFieldsWrap = document.getElementById('eventAddressFieldsWrap');
const eventAddressStreetWrap = document.getElementById('eventAddressStreetWrap');
const eventAddressActionsWrap = document.getElementById('eventAddressActionsWrap');
const eventValidUntilWrap = document.getElementById('eventValidUntilWrap');
const eventPricingWrap = document.getElementById('eventPricingWrap');
const visibilityDetails = document.getElementById('visibilityDetails');
@@ -1371,6 +1436,7 @@ if (!empty($canManageSystemSettings)) {
const placeOpeningHoursWrap = document.getElementById('placeOpeningHoursWrap');
const eventRecurrenceWrap = document.getElementById('eventRecurrenceWrap');
const eventDateInput = document.getElementById('evDate');
const eventDescriptionInput = document.getElementById('evDesc');
const categoryInput = document.getElementById('categoryInput');
const categorySlugInput = document.getElementById('listingCategorySlug');
const categoryInputStatus = document.getElementById('categoryInputStatus');
@@ -1429,6 +1495,77 @@ if (!empty($canManageSystemSettings)) {
});
}
function findEventLocationOptionByLabel(label) {
const normalized = (label || '').trim();
if (!normalized) return null;
const option = document.querySelector(`#eventLocationOptionList option[value="${CSS.escape(normalized)}"]`);
if (!option) return null;
try {
return JSON.parse(option.getAttribute('data-location') || 'null');
} catch (error) {
return null;
}
}
function applyEventLocationOption(option) {
if (!option) return;
if (eventLocationSourceIdInput) eventLocationSourceIdInput.value = String(option.id || '');
if (streetInput) streetInput.value = String(option.street || '');
if (zipInput) zipInput.value = String(option.zip || '');
if (cityInput) cityInput.value = String(option.city || '');
if (regionInput) regionInput.value = String(option.region || '');
if (latInput) latInput.value = option.lat !== null && option.lat !== '' ? Number(option.lat).toFixed(7) : '';
if (lngInput) lngInput.value = option.lng !== null && option.lng !== '' ? Number(option.lng).toFixed(7) : '';
if (categoryInput) categoryInput.value = String(option.category_title || '');
if (categorySlugInput) categorySlugInput.value = String(option.category_slug || '');
if (categoryInputStatus) categoryInputStatus.textContent = '';
if (eventAddressStatus) eventAddressStatus.textContent = '';
}
function clearEventLocationSelection() {
if (eventLocationSourceIdInput) eventLocationSourceIdInput.value = '';
if (eventLocationSourceInput) eventLocationSourceInput.value = '';
if (categoryInputStatus) categoryInputStatus.textContent = '';
}
function syncEventLocationMode() {
const kind = entryKindInput?.value || 'event';
const mode = eventLocationModeSelect?.value || 'custom';
const isOwnEvent = kind === 'event';
const usesDatabaseLocation = isOwnEvent && mode !== 'custom';
setSectionState(eventLocationModeWrap, isOwnEvent);
setSectionState(eventLocationSourceWrap, usesDatabaseLocation);
setSectionState(eventAddressFieldsWrap, !usesDatabaseLocation);
setSectionState(eventAddressStreetWrap, !usesDatabaseLocation);
setSectionState(eventAddressActionsWrap, !usesDatabaseLocation);
setSectionState(mapDetails, !usesDatabaseLocation);
if (eventLocationSourceLabel) {
eventLocationSourceLabel.textContent = mode === 'editorial_event' ? 'Veranstaltung aus Datenbank' : 'Ort aus Datenbank';
}
if (categoryInput) {
categoryInput.readOnly = usesDatabaseLocation;
if (usesDatabaseLocation) {
categoryInput.removeAttribute('list');
} else {
categoryInput.setAttribute('list', 'listingCategoryList');
}
}
if (usesDatabaseLocation) {
const option = findEventLocationOptionByLabel(eventLocationSourceInput?.value || '');
if (option && String(option.listing_type || '') === mode) {
applyEventLocationOption(option);
} else {
clearEventLocationSelection();
}
} else if (isOwnEvent) {
if (eventLocationSourceIdInput) eventLocationSourceIdInput.value = '';
}
}
function syncEventEntryKind(nextKind) {
const kind = nextKind || entryKindInput?.value || 'event';
if (entryKindInput) entryKindInput.value = kind;
@@ -1451,7 +1588,8 @@ if (!empty($canManageSystemSettings)) {
setSectionState(eventDateRow, kind !== 'place');
if (eventDateInput) eventDateInput.required = kind !== 'place';
if (categoryInput) categoryInput.required = true;
if (eventDescriptionInput) eventDescriptionInput.required = kind !== 'event';
if (categoryInput) categoryInput.required = kind !== 'event';
setSectionState(eventChildrenWrap, kind === 'event' || kind === 'editorial_event');
setSectionState(eventCapacityWrap, kind === 'event');
setSectionState(eventValidUntilWrap, kind === 'editorial_event');
@@ -1463,6 +1601,7 @@ if (!empty($canManageSystemSettings)) {
if (eventDateLabel) {
eventDateLabel.textContent = kind === 'editorial_event' ? 'Gültig von' : 'Datum';
}
syncEventLocationMode();
}
entryKindButtons.forEach((button) => {
@@ -1470,6 +1609,19 @@ if (!empty($canManageSystemSettings)) {
syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event');
});
});
eventLocationModeSelect?.addEventListener('change', () => {
clearEventLocationSelection();
syncEventLocationMode();
});
eventLocationSourceInput?.addEventListener('change', () => {
const mode = eventLocationModeSelect?.value || 'custom';
const option = findEventLocationOptionByLabel(eventLocationSourceInput.value || '');
if (!option || String(option.listing_type || '') !== mode) {
clearEventLocationSelection();
return;
}
applyEventLocationOption(option);
});
syncEventEntryKind(entryKindInput?.value || 'event');
function createOpeningRow() {
@@ -1503,8 +1655,12 @@ if (!empty($canManageSystemSettings)) {
btnAddPriceRow?.addEventListener('click', createPriceRow);
function syncCategoryInputState() {
const value = (categoryInput?.value || '').trim().toLowerCase();
if (!categorySlugInput || !categoryInputStatus) return;
if ((entryKindInput?.value || 'event') === 'event' && (eventLocationModeSelect?.value || 'custom') !== 'custom') {
categoryInputStatus.textContent = '';
return;
}
const value = (categoryInput?.value || '').trim().toLowerCase();
if (!value) {
categorySlugInput.value = '';
categoryInputStatus.textContent = '';

View File

@@ -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;
$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) {
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'];
}
$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');

View File

@@ -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();