rebuild events
All checks were successful
Deploy / deploy (push) Successful in 59s

This commit is contained in:
2026-08-04 20:59:40 +02:00
parent 1a58ec91de
commit 9cfa26a031
11 changed files with 489 additions and 50 deletions

View File

@@ -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. - 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. - 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. - 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 ## Technischer Rahmen
- eigener Front-Controller in `public/index.php` - eigener Front-Controller in `public/index.php`

View File

@@ -19,6 +19,7 @@ Stand: 2026-08-03
- System-Einstellungen: `src/App/SystemSettings.php` - System-Einstellungen: `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php` - neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
- UI für Eintragstyp-Auswahl im Mitgliederbereich: `partials/landing/account/dashboard.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 ## Dokumentationsregel
Kanonische interne Dokumentation: Kanonische interne Dokumentation:

View File

@@ -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 - System-Sektion für Seiten-Admins mit Wartungs- und Diensteschaltern
- neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt - neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt
- Mitgliederbereich `Events` unterstützt jetzt drei Anlagetypen: `Eigenes Event`, `Ort`, `Sonstige Veranstaltung` - 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 ## Wording-Regel
- Primärbegriff im Produkt: `Events` - Primärbegriff im Produkt: `Events`

View File

@@ -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. - 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. - 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` 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 ## Technical Frame
- custom front controller in `public/index.php` - custom front controller in `public/index.php`

View File

@@ -19,6 +19,7 @@ Updated: 2026-08-03
- system settings: `src/App/SystemSettings.php` - system settings: `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.php` - new listing/place base: `src/App/ListingCatalog.php`
- entry type selector UI in member area: `partials/landing/account/dashboard.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 ## Documentation Rule
- canonical internal documentation lives in `Internal/de/` and `Internal/en/` - canonical internal documentation lives in `Internal/de/` and `Internal/en/`

View File

@@ -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 - system section for site admins with maintenance and service toggles
- new data foundation prepared for a broader event, place, and listing system - 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` - 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 ## Core Wording Rule
- Primary product term: `Events` - Primary product term: `Events`

View File

@@ -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` - 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 - 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 - 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 ## Datenschutz und Sicherheit
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert - sensible Profilfelder werden app-seitig verschlüsselt gespeichert

View File

@@ -32,6 +32,66 @@ $latValue = $editingEvent ? (string)($editEvent['lat'] ?? '') : (string)($editLi
$lngValue = $editingEvent ? (string)($editEvent['lng'] ?? '') : (string)($editListing['lng'] ?? ''); $lngValue = $editingEvent ? (string)($editEvent['lng'] ?? '') : (string)($editListing['lng'] ?? '');
$visibilityValue = $editingEvent ? (string)($editEvent['visibility'] ?? 'public') : (string)($editListing['visibility'] ?? 'public'); $visibilityValue = $editingEvent ? (string)($editEvent['visibility'] ?? 'public') : (string)($editListing['visibility'] ?? 'public');
$categorySlugValue = $editingListing ? (string)($editListing['category_slug'] ?? '') : ''; $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; $editingChild = isset($editChild) && $editChild !== null;
$childAction = $editingChild ? 'child_update' : 'child_add'; $childAction = $editingChild ? 'child_update' : 'child_add';
$childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen'; $childModalTitle = $editingChild ? 'Kind bearbeiten' : 'Kind hinzufügen';
@@ -857,6 +917,13 @@ if (!empty($canManageSystemSettings)) {
<?php if ($editingListing && $editListing): ?> <?php if ($editingListing && $editListing): ?>
<input type="hidden" name="listing_id" value="<?= (int)$editListing['id'] ?>"> <input type="hidden" name="listing_id" value="<?= (int)$editListing['id'] ?>">
<?php endif; ?> <?php endif; ?>
<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) ?>'>
<?= htmlspecialchars(trim(implode(', ', array_filter([(string)($placeSuggestion['street'] ?? ''), trim((string)($placeSuggestion['zip'] ?? '') . ' ' . (string)($placeSuggestion['city'] ?? '')), (string)($placeSuggestion['region'] ?? '')]))), ENT_QUOTES) ?>
</option>
<?php endforeach; ?>
</datalist>
<?php if (!$editing): ?> <?php if (!$editing): ?>
<div class="stack gap-8"> <div class="stack gap-8">
<label class="label">Was möchtest du anlegen?</label> <label class="label">Was möchtest du anlegen?</label>
@@ -875,19 +942,24 @@ if (!empty($canManageSystemSettings)) {
</p> </p>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="form-grid"> <div class="form-grid" id="titleDescriptionRow">
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="evTitle">Titel</label> <label class="label" for="evTitle">Titel</label>
<input id="evTitle" name="title" class="input" placeholder="z. B. Väter-Kaffee im Park" required value="<?= htmlspecialchars($titleValue, ENT_QUOTES) ?>"> <input id="evTitle" name="title" class="input" placeholder="z. B. Väter-Kaffee im Park" required value="<?= htmlspecialchars($titleValue, ENT_QUOTES) ?>">
</div> </div>
<div class="stack gap-6">
<label class="label" for="evTeaser">Kurzbeschreibung</label>
<input id="evTeaser" name="teaser" class="input" placeholder="Kurztext für Gäste" required value="<?= htmlspecialchars($teaserValue, ENT_QUOTES) ?>">
</div>
</div> </div>
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="evDesc">Beschreibung (voll)</label> <label class="label" for="evDesc">Beschreibung (voll)</label>
<textarea id="evDesc" name="description" class="textarea" rows="3" placeholder="Was soll passieren, was mitbringen?" required><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea> <textarea id="evDesc" name="description" class="textarea" rows="3" placeholder="Was soll passieren, was mitbringen?" required><?= htmlspecialchars($descriptionValue, ENT_QUOTES) ?></textarea>
<p class="muted small" style="margin:0;">Die Kurzbeschreibung wird intern automatisch aus deinem Text erzeugt.</p>
</div>
<div class="stack gap-6" id="placeLookupWrap">
<label class="label" for="existingPlaceLookup">Vorhandenen Ort suchen (optional)</label>
<div class="flex gap-8" style="flex-wrap:wrap;">
<input id="existingPlaceLookup" class="input" list="existingPlacesList" placeholder="z. B. Tierpark, Café, Spielplatz" style="flex:1;">
<button class="btn ghost" type="button" id="btnUseExistingPlace">Ort übernehmen</button>
</div>
<p class="muted small" style="margin:0;">Wenn ein passender Ort schon existiert, kannst du ihn übernehmen. Falls nichts passt, legst du unten einfach einen neuen Ort an.</p>
</div> </div>
<div class="form-grid" id="eventDateRow"> <div class="form-grid" id="eventDateRow">
<div class="stack gap-6"> <div class="stack gap-6">
@@ -911,6 +983,32 @@ if (!empty($canManageSystemSettings)) {
</select> </select>
<p class="muted small" style="margin:0;">Für Orte und sonstige Veranstaltungen, damit Spielplatz, Café, Restaurant, Zoo oder Zirkus sauber unterscheidbar bleiben.</p> <p class="muted small" style="margin:0;">Für Orte und sonstige Veranstaltungen, damit Spielplatz, Café, Restaurant, Zoo oder Zirkus sauber unterscheidbar bleiben.</p>
</div> </div>
<div class="stack gap-6" id="placeOpeningHoursWrap">
<label class="label" for="openingHoursNote">Öffnungszeiten / Hinweise</label>
<textarea id="openingHoursNote" name="opening_hours_note" class="textarea" rows="2" placeholder="z. B. täglich 10:00 bis 18:00 Uhr"><?= htmlspecialchars($openingHoursValue, ENT_QUOTES) ?></textarea>
</div>
<div class="stack gap-6" id="eventRecurrenceWrap">
<label class="label" for="recurrenceMode">Wiederholung</label>
<select id="recurrenceMode" name="recurrence_mode" class="select">
<option value="single" <?= $recurrenceModeValue === 'single' ? 'selected' : '' ?>>Einmalig</option>
<option value="daily" <?= $recurrenceModeValue === 'daily' ? 'selected' : '' ?>>Täglich</option>
<option value="weekly" <?= $recurrenceModeValue === 'weekly' ? 'selected' : '' ?>>Wöchentlich</option>
<option value="weekdays" <?= $recurrenceModeValue === 'weekdays' ? 'selected' : '' ?>>Montag bis Freitag</option>
<option value="custom_weekdays" <?= $recurrenceModeValue === 'custom_weekdays' ? 'selected' : '' ?>>Bestimmte Wochentage</option>
</select>
<div class="chips" id="recurrenceWeekdaysWrap" style="flex-wrap:wrap;">
<?php foreach (['mo' => 'Mo', 'di' => 'Di', 'mi' => 'Mi', 'do' => 'Do', 'fr' => 'Fr', 'sa' => 'Sa', 'so' => 'So'] as $weekdayValue => $weekdayLabel): ?>
<label class="chip" style="cursor:pointer;">
<input type="checkbox" name="recurrence_weekdays[]" value="<?= htmlspecialchars($weekdayValue, ENT_QUOTES) ?>" <?= in_array($weekdayValue, $selectedWeekdays, true) ? 'checked' : '' ?> style="margin-right:6px;">
<?= htmlspecialchars($weekdayLabel, ENT_QUOTES) ?>
</label>
<?php endforeach; ?>
</div>
<div class="stack gap-6">
<label class="label" for="recurrenceUntil">Wiederholen bis</label>
<input id="recurrenceUntil" name="recurrence_until" class="input" type="datetime-local" value="<?= htmlspecialchars($recurrenceUntilValue, ENT_QUOTES) ?>">
</div>
</div>
<div class="form-grid"> <div class="form-grid">
<div class="stack gap-6"> <div class="stack gap-6">
<label class="label" for="evZip">PLZ</label> <label class="label" for="evZip">PLZ</label>
@@ -933,9 +1031,11 @@ if (!empty($canManageSystemSettings)) {
</div> </div>
</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;">
<button class="btn ghost" type="button" id="btnEventAddressSearch">Adresse suchen</button>
<button class="btn ghost" type="button" id="btnAddrToMap">Adresse auf Karte setzen</button> <button class="btn ghost" type="button" id="btnAddrToMap">Adresse auf Karte setzen</button>
<span class="muted small">Hält Karte und Adresse synchron.</span> <span class="muted small">Hält Karte und Adresse synchron.</span>
</div> </div>
<div id="eventAddressStatus" class="muted small" style="margin:0;"></div>
<input type="hidden" id="evLat" name="lat" value="<?= htmlspecialchars($latValue, ENT_QUOTES) ?>"> <input type="hidden" id="evLat" name="lat" value="<?= htmlspecialchars($latValue, ENT_QUOTES) ?>">
<input type="hidden" id="evLng" name="lng" value="<?= htmlspecialchars($lngValue, ENT_QUOTES) ?>"> <input type="hidden" id="evLng" name="lng" value="<?= htmlspecialchars($lngValue, ENT_QUOTES) ?>">
<div class="stack gap-6"> <div class="stack gap-6">
@@ -961,6 +1061,46 @@ if (!empty($canManageSystemSettings)) {
</select> </select>
</div> </div>
</div> </div>
<div class="stack gap-10" id="eventPricingWrap">
<label class="label" for="priceMode">Eintritt / Kosten</label>
<select id="priceMode" name="price_mode" class="select">
<option value="none" <?= $priceModeValue === 'none' ? 'selected' : '' ?>>Keine Preisangabe</option>
<option value="free_child_rule" <?= $priceModeValue === 'free_child_rule' ? 'selected' : '' ?>>Frei für Kinder bis zu einem Alter</option>
<option value="paid" <?= $priceModeValue === 'paid' ? 'selected' : '' ?>>Bezahlter Eintritt</option>
</select>
<div class="form-grid" id="freeChildRuleWrap">
<div class="stack gap-6">
<label class="label" for="freeChildUntilAge">Kinder frei bis Alter</label>
<input id="freeChildUntilAge" name="free_child_until_age" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($freeChildUntilAge, ENT_QUOTES) ?>">
</div>
</div>
<div class="stack gap-12" id="paidPricingWrap">
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="priceChildAmount">Preis pro Kind</label>
<input id="priceChildAmount" name="price_child_amount" class="input" inputmode="decimal" placeholder="z. B. 12,50" value="<?= htmlspecialchars($priceChildAmount, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="priceAdultAmount">Preis pro Erwachsener</label>
<input id="priceAdultAmount" name="price_adult_amount" class="input" inputmode="decimal" placeholder="z. B. 18,00" value="<?= htmlspecialchars($priceAdultAmount, ENT_QUOTES) ?>">
</div>
</div>
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="priceChildAgeFrom">Kind ab Alter</label>
<input id="priceChildAgeFrom" name="price_child_age_from" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($priceChildAgeFrom, ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="priceChildAgeTo">Kind bis Alter</label>
<input id="priceChildAgeTo" name="price_child_age_to" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($priceChildAgeTo, ENT_QUOTES) ?>">
</div>
</div>
<div class="stack gap-6">
<label class="label" for="priceAdultAgeFrom">Erwachsener ab Alter</label>
<input id="priceAdultAgeFrom" name="price_adult_age_from" class="input" type="number" min="0" max="25" value="<?= htmlspecialchars($priceAdultAgeFrom, ENT_QUOTES) ?>">
</div>
</div>
</div>
<label class="label" id="eventKidsWrap" style="display:flex; align-items:center; gap:8px;"> <label class="label" id="eventKidsWrap" style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" name="allow_kids" <?= $allowNoKidsChecked ? 'checked' : '' ?>> Event ohne Kinder <input type="checkbox" name="allow_kids" <?= $allowNoKidsChecked ? 'checked' : '' ?>> Event ohne Kinder
</label> </label>
@@ -999,6 +1139,8 @@ if (!empty($canManageSystemSettings)) {
const mapContainer = document.getElementById('mapContainer'); const mapContainer = document.getElementById('mapContainer');
const btnMapSearch = document.getElementById('btnMapSearch'); const btnMapSearch = document.getElementById('btnMapSearch');
const btnAddrToMap = document.getElementById('btnAddrToMap'); const btnAddrToMap = document.getElementById('btnAddrToMap');
const btnEventAddressSearch = document.getElementById('btnEventAddressSearch');
const eventAddressStatus = document.getElementById('eventAddressStatus');
const mapSearch = document.getElementById('mapSearch'); const mapSearch = document.getElementById('mapSearch');
const latInput = document.getElementById('evLat'); const latInput = document.getElementById('evLat');
const lngInput = document.getElementById('evLng'); const lngInput = document.getElementById('evLng');
@@ -1009,8 +1151,19 @@ if (!empty($canManageSystemSettings)) {
const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap'); const eventLocationLabelWrap = document.getElementById('eventLocationLabelWrap');
const eventCategoryWrap = document.getElementById('eventCategoryWrap'); const eventCategoryWrap = document.getElementById('eventCategoryWrap');
const eventKidsWrap = document.getElementById('eventKidsWrap'); 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 eventDateInput = document.getElementById('evDate');
const categorySelect = document.getElementById('listingCategorySlug'); 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 streetInput = document.getElementById('evStreet');
const zipInput = document.getElementById('evZip'); const zipInput = document.getElementById('evZip');
const cityInput = document.getElementById('evCity'); const cityInput = document.getElementById('evCity');
@@ -1030,6 +1183,24 @@ if (!empty($canManageSystemSettings)) {
const profileAddressResults = document.getElementById('profileAddressResults'); const profileAddressResults = document.getElementById('profileAddressResults');
const profileAddressResultsModal = document.getElementById('modalProfileAddressResults'); const profileAddressResultsModal = document.getElementById('modalProfileAddressResults');
let map, marker; let map, marker;
const existingPlaces = <?= json_encode($placeSuggestions ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
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) { function syncEventEntryKind(nextKind) {
const kind = nextKind || entryKindInput?.value || 'event'; const kind = nextKind || entryKindInput?.value || 'event';
@@ -1037,7 +1208,7 @@ if (!empty($canManageSystemSettings)) {
entryKindButtons.forEach((button) => { entryKindButtons.forEach((button) => {
const active = button.getAttribute('data-entry-kind-choice') === kind; 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'); button.setAttribute('aria-pressed', active ? 'true' : 'false');
}); });
@@ -1057,6 +1228,23 @@ if (!empty($canManageSystemSettings)) {
if (eventCategoryWrap) eventCategoryWrap.hidden = kind === 'event'; if (eventCategoryWrap) eventCategoryWrap.hidden = kind === 'event';
if (categorySelect) categorySelect.required = kind !== 'event'; if (categorySelect) categorySelect.required = kind !== 'event';
if (eventKidsWrap) eventKidsWrap.hidden = 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) => { entryKindButtons.forEach((button) => {
@@ -1064,8 +1252,36 @@ if (!empty($canManageSystemSettings)) {
syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event'); syncEventEntryKind(button.getAttribute('data-entry-kind-choice') || 'event');
}); });
}); });
priceModeSelect?.addEventListener('change', syncPriceModeVisibility);
recurrenceModeSelect?.addEventListener('change', syncRecurrenceVisibility);
syncEventEntryKind(entryKindInput?.value || 'event'); 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) { function ensureLeaflet(callback) {
if (!window.PKTConsent || !window.PKTConsent.has('external_services')) { if (!window.PKTConsent || !window.PKTConsent.has('external_services')) {
@@ -1248,6 +1464,32 @@ if (!empty($canManageSystemSettings)) {
geocodeAndPlace(parts.join(', ')); 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(){ (function(){
const setProfileStatus = (message = '') => { const setProfileStatus = (message = '') => {
if (!profileAddressStatus) return; if (!profileAddressStatus) return;
@@ -1272,20 +1514,7 @@ if (!empty($canManageSystemSettings)) {
}; };
const normalizeResult = (result) => { const normalizeResult = (result) => {
const address = result.address || {}; return normalizeGeoResult(result);
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),
};
}; };
const applyProfileAddress = (result) => { const applyProfileAddress = (result) => {

View File

@@ -148,6 +148,7 @@ CREATE TABLE listing_places (
website_url VARCHAR(255) NULL, website_url VARCHAR(255) NULL,
phone VARCHAR(60) NULL, phone VARCHAR(60) NULL,
place_kind VARCHAR(80) NULL, place_kind VARCHAR(80) NULL,
opening_hours_note TEXT NULL,
provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual', provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual',
external_place_id VARCHAR(190) NULL, external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL, google_place_id VARCHAR(190) NULL,

View File

@@ -377,9 +377,40 @@ final class AccountPages
if (!$listingCatalog) { if (!$listingCatalog) {
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); 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( $listingCatalog->saveDashboardEntry(
$userId, $userId,
$_POST, $payload,
$action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null
); );
$info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.'; $info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.';
@@ -403,7 +434,7 @@ final class AccountPages
$stmt?->execute([ $stmt?->execute([
'uid' => $userId, 'uid' => $userId,
'title' => trim((string)$_POST['title']), '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']), 'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']), 'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null, 'street' => $street ?: null,
@@ -432,7 +463,7 @@ final class AccountPages
'id' => $eventId, 'id' => $eventId,
'uid' => $userId, 'uid' => $userId,
'title' => trim((string)$_POST['title']), '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']), 'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']), 'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null, 'street' => $street ?: null,
@@ -605,6 +636,7 @@ final class AccountPages
$otherListings = []; $otherListings = [];
$editListing = null; $editListing = null;
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : []; $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : [];
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
$stmt = $pdo?->prepare( $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 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 (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
@@ -671,6 +703,7 @@ final class AccountPages
'otherListings', 'otherListings',
'editListing', 'editListing',
'listingCategories', 'listingCategories',
'placeSuggestions',
'communityPoints', 'communityPoints',
'communityLevel', 'communityLevel',
'communityRoles', 'communityRoles',
@@ -702,6 +735,22 @@ final class AccountPages
return $birthDateValue->diff($today)->y; 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 private static function requireCrypto(?Crypto $crypto, string $context): Crypto
{ {
if ($crypto instanceof Crypto) { if ($crypto instanceof Crypto) {

View File

@@ -6,6 +6,7 @@ namespace App;
final class ListingCatalog final class ListingCatalog
{ {
private array $tableCache = []; private array $tableCache = [];
private array $columnCache = [];
public function __construct(private \PDO $pdo) public function __construct(private \PDO $pdo)
{ {
@@ -142,6 +143,17 @@ final class ListingCatalog
$this->pdo->exec($sql); $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( $seed = $this->pdo->prepare(
'INSERT INTO listing_categories (slug, title, category_group, sort_order) 'INSERT INTO listing_categories (slug, title, category_group, sort_order)
VALUES (:slug, :title, :groupName, :sortOrder) VALUES (:slug, :title, :groupName, :sortOrder)
@@ -229,8 +241,9 @@ final class ListingCatalog
{ {
$this->ensureSchema(); $this->ensureSchema();
$stmt = $this->pdo->prepare( $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.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type,
lo.recurrence_rule, lo.recurrence_until,
lc.slug AS category_slug lc.slug AS category_slug
FROM listings l FROM listings l
LEFT JOIN listing_places lp ON lp.id = l.primary_place_id LEFT JOIN listing_places lp ON lp.id = l.primary_place_id
@@ -245,7 +258,26 @@ final class ListingCatalog
'uid' => $userId, 'uid' => $userId,
]); ]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC); $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 public function saveDashboardEntry(int $userId, array $data, ?int $listingId = null): int
@@ -259,6 +291,7 @@ final class ListingCatalog
$title = trim((string)($data['title'] ?? '')); $title = trim((string)($data['title'] ?? ''));
$teaser = trim((string)($data['teaser'] ?? '')); $teaser = trim((string)($data['teaser'] ?? ''));
$description = trim((string)($data['description'] ?? '')); $description = trim((string)($data['description'] ?? ''));
$openingHoursNote = trim((string)($data['opening_hours_note'] ?? ''));
$visibility = (string)($data['visibility'] ?? 'public'); $visibility = (string)($data['visibility'] ?? 'public');
$placeKind = trim((string)($data['category_slug'] ?? '')); $placeKind = trim((string)($data['category_slug'] ?? ''));
$street = trim((string)($data['street'] ?? '')); $street = trim((string)($data['street'] ?? ''));
@@ -268,6 +301,10 @@ final class ListingCatalog
$lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null; $lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null;
$lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null; $lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null;
$startsAt = trim((string)($data['starts_at'] ?? '')); $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 === '') { if ($title === '' || $teaser === '' || $description === '') {
throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.'); throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.');
@@ -298,7 +335,7 @@ final class ListingCatalog
if ($placeId > 0) { if ($placeId > 0) {
$placeStmt = $this->pdo->prepare( $placeStmt = $this->pdo->prepare(
'UPDATE listing_places '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' WHERE id = :id'
); );
$placeStmt->execute([ $placeStmt->execute([
@@ -311,6 +348,7 @@ final class ListingCatalog
'lat' => $lat, 'lat' => $lat,
'lng' => $lng, 'lng' => $lng,
'placeKind' => $placeKind !== '' ? $placeKind : null, 'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
'id' => $placeId, 'id' => $placeId,
]); ]);
} }
@@ -331,21 +369,11 @@ final class ListingCatalog
]); ]);
$this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]);
if ($entryType === 'editorial_event') { $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data);
$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,
]);
}
} else { } else {
$placeStmt = $this->pdo->prepare( $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) '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, "manual", "published", NOW(), NOW())' VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, :openingHoursNote, "manual", "published", NOW(), NOW())'
); );
$placeStmt->execute([ $placeStmt->execute([
'uid' => $userId, 'uid' => $userId,
@@ -358,6 +386,7 @@ final class ListingCatalog
'lat' => $lat, 'lat' => $lat,
'lng' => $lng, 'lng' => $lng,
'placeKind' => $placeKind !== '' ? $placeKind : null, 'placeKind' => $placeKind !== '' ? $placeKind : null,
'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null,
]); ]);
$placeId = (int)$this->pdo->lastInsertId(); $placeId = (int)$this->pdo->lastInsertId();
@@ -376,17 +405,7 @@ final class ListingCatalog
]); ]);
$listingId = (int)$this->pdo->lastInsertId(); $listingId = (int)$this->pdo->lastInsertId();
if ($entryType === 'editorial_event') { $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data);
$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->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); $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 public function deleteDashboardEntry(int $userId, int $listingId): void
{ {
$this->ensureSchema(); $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 private function defaultCategories(): array
{ {
return [ return [