-
- 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 = = json_encode($placeSuggestions ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
+ const existingCategories = = json_encode($listingCategories ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
+ 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));