Datenschutz & Cookies
-Stand: 31. Juli 2026
+Stand: 4. August 2026
Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff @@ -56,7 +56,7 @@ $clientCookie = $config->cookiePrefix() . 'client'; Wenn du ein Konto anlegst oder den Mitgliederbereich nutzt, verarbeiten wir die dafür notwendigen Daten, insbesondere E-Mail-Adresse, Passwort-Hash, Verifikationsstatus sowie die von dir gepflegten Profilangaben. Dazu können Anzeigename, Name, Adresse, Ort, Sprachen, Kurzbeschreibung, optionale Kinderangaben, - eigene Events, Event-Teilnahmen und Community-Inhalte gehören. + eigene Events, Orte, Veranstaltungen, hochgeladene Eintragsbilder, Event-Teilnahmen und Community-Inhalte gehören.
Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der @@ -153,9 +153,9 @@ $clientCookie = $config->cookiePrefix() . 'client'; damit dir passende Events, Termine und Treffen in deiner Nähe angezeigt werden können.
- Zusätzlich kannst du im Mitgliederbereich deine Profiladresse über die gleichen externen Adressdienste - lokalisieren lassen, die auch für Event-Adressen genutzt werden. Dabei kannst du entweder nach passenden - Adress-Treffern suchen oder die vom Browser ermittelte Position in eine Profiladresse übernehmen. + Zusätzlich kannst du im Mitgliederbereich deine Profiladresse sowie Adressen für eigene Events, Orte und + Veranstaltungen über die gleichen externen Adressdienste lokalisieren lassen. Dabei kannst du entweder nach + passenden Adress-Treffern suchen oder die vom Browser ermittelte Position in eine Adresse übernehmen. In beiden Fällen werden die von dir eingegebenen oder vom Browser bereitgestellten Positionsdaten an den jeweiligen Geocoding-Dienst übermittelt.
diff --git a/schema.sql b/schema.sql index a1f4e6c..3c5a938 100755 --- a/schema.sql +++ b/schema.sql @@ -79,6 +79,8 @@ CREATE TABLE events ( title VARCHAR(200) NOT NULL, teaser_public VARCHAR(280) NOT NULL, description TEXT NOT NULL, + category_slug VARCHAR(120) NULL, + image_path VARCHAR(255) NULL, location_label VARCHAR(180) NULL, street VARCHAR(180) NULL, zip CHAR(5) NULL, @@ -149,6 +151,7 @@ CREATE TABLE listing_places ( phone VARCHAR(60) NULL, place_kind VARCHAR(80) NULL, opening_hours_note TEXT NULL, + opening_hours_json LONGTEXT NULL, provider_hint ENUM('manual','osm','google') NOT NULL DEFAULT 'manual', external_place_id VARCHAR(190) NULL, google_place_id VARCHAR(190) NULL, @@ -173,6 +176,8 @@ CREATE TABLE listings ( title VARCHAR(200) NOT NULL, teaser_public VARCHAR(280) NOT NULL, description TEXT NOT NULL, + image_path VARCHAR(255) NULL, + special_conditions_note TEXT NULL, visibility ENUM('public','members') NOT NULL DEFAULT 'public', status ENUM('draft','published','cancelled','archived') NOT NULL DEFAULT 'draft', supports_registration TINYINT(1) NOT NULL DEFAULT 0, diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 11ab5ba..338cc9c 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -189,6 +189,9 @@ final class AccountPages if ($listingCatalog) { $listingCatalog->ensureSchema(); } + if ($pdo) { + self::ensureLegacyEventSchema($pdo); + } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; @@ -378,6 +381,8 @@ final class AccountPages throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); } $payload = $_POST; + $existingListingId = $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : 0; + $existingListing = $existingListingId > 0 ? $listingCatalog->getDashboardEntry($userId, $existingListingId) : null; $street = trim((string)($_POST['street'] ?? '')); $zip = trim((string)($_POST['zip'] ?? '')); $city = trim((string)($_POST['city'] ?? '')); @@ -404,6 +409,7 @@ final class AccountPages $payload['lat'] = $resolvedAddress['lat']; $payload['lng'] = $resolvedAddress['lng']; } + $payload['image_path'] = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingListing) ? (string)($existingListing['image_path'] ?? '') : ''); $payload['teaser'] = self::generateTeaser( trim((string)($payload['description'] ?? '')), trim((string)($payload['title'] ?? '')) @@ -415,27 +421,59 @@ final class AccountPages ); $info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.'; } else { + $existingEvent = null; + if ($action === 'event_update') { + $existingEventId = (int)($_POST['event_id'] ?? 0); + if ($existingEventId > 0) { + $stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid LIMIT 1'); + $stmt?->execute(['id' => $existingEventId, 'uid' => $userId]); + $existingEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: 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; - $needsGeocode = ($lat === null || $lng === null || $region === ''); - if ($needsGeocode) { - [$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region); - if ($lat === null) { $lat = $geoLat; } - if ($lng === null) { $lng = $geoLng; } - if ($region === '' && $geoRegion) { $region = $geoRegion; } + $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]; + } + $street = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street; + $zip = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip; + $city = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city; + $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; + $categorySlug = trim((string)($_POST['category_slug'] ?? '')); + $customCategoryTitle = trim((string)($_POST['category_custom_title'] ?? '')); + if ($customCategoryTitle !== '') { + $categorySlug = self::slugifyValue($customCategoryTitle); } if ($action === 'event_add') { - $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())'); + $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([ 'uid' => $userId, 'title' => trim((string)$_POST['title']), 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'descr' => trim((string)$_POST['description']), + 'categorySlug' => $categorySlug !== '' ? $categorySlug : null, + 'imagePath' => $imagePath !== '' ? $imagePath : null, 'loc' => trim((string)$_POST['location_label']), 'street' => $street ?: null, 'zip' => $zip, @@ -444,7 +482,8 @@ final class AccountPages 'lat' => $lat, 'lng' => $lng, 'start' => $_POST['starts_at'] ?? null, - 'allow' => isset($_POST['allow_kids']) ? 0 : 1, + 'capacity' => $capacity, + 'allow' => $allowKids, 'vis' => $_POST['visibility'] ?? 'public', 'status' => 'published', ]); @@ -458,13 +497,15 @@ final class AccountPages } } else { $eventId = (int)($_POST['event_id'] ?? 0); - $stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, 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, 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, 'title' => trim((string)$_POST['title']), 'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])), 'descr' => trim((string)$_POST['description']), + 'categorySlug' => $categorySlug !== '' ? $categorySlug : null, + 'imagePath' => $imagePath !== '' ? $imagePath : null, 'loc' => trim((string)$_POST['location_label']), 'street' => $street ?: null, 'zip' => $zip, @@ -473,7 +514,8 @@ final class AccountPages 'lat' => $lat, 'lng' => $lng, 'start' => $_POST['starts_at'] ?? null, - 'allow' => isset($_POST['allow_kids']) ? 0 : 1, + 'capacity' => $capacity, + 'allow' => $allowKids, 'vis' => $_POST['visibility'] ?? 'public', ]); $info = 'Event aktualisiert.'; @@ -481,7 +523,7 @@ final class AccountPages } } elseif ($action === 'event_delete') { $eventId = (int)($_POST['event_id'] ?? 0); - $stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1'); + $stmt = $pdo?->prepare('SELECT id, created_by, status, image_path, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1'); $stmt?->execute(['id' => $eventId]); $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); if (!$ev || (int)$ev['created_by'] !== $userId) { @@ -490,6 +532,7 @@ final class AccountPages if ((int)$ev['participant_count'] > 0) { throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.'); } + self::deleteStoredImage((string)($ev['image_path'] ?? '')); $pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]); $info = 'Event gelöscht.'; } elseif ($action === 'event_cancel') { @@ -510,6 +553,10 @@ final class AccountPages if (!$listingCatalog) { throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); } + $entry = $listingCatalog->getDashboardEntry($userId, $listingId); + if (is_array($entry)) { + self::deleteStoredImage((string)($entry['image_path'] ?? '')); + } $listingCatalog->deleteDashboardEntry($userId, $listingId); $info = 'Eintrag gelöscht.'; } elseif ($action === 'community_admin_apply') { @@ -638,7 +685,7 @@ final class AccountPages $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : []; $placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : []; $stmt = $pdo?->prepare( - 'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng, + 'SELECT 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 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() @@ -648,7 +695,7 @@ final class AccountPages $eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; $stmt = $pdo?->prepare( - 'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status, + 'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status, (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() @@ -845,6 +892,87 @@ final class AccountPages return $results; } + private static function ensureLegacyEventSchema(\PDO $pdo): void + { + $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', + ]; + 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'); + $stmt->execute(['column' => $column]); + if (!$stmt->fetchColumn()) { + $pdo->exec($sql); + } + } + } + + private static function storeUploadedImage(mixed $file, string $currentPath = ''): string + { + if (!is_array($file) || (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + return $currentPath; + } + if ((int)($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + throw new \RuntimeException('Der Bild-Upload konnte nicht verarbeitet werden.'); + } + $tmpName = (string)($file['tmp_name'] ?? ''); + if ($tmpName === '' || !is_uploaded_file($tmpName)) { + throw new \RuntimeException('Die hochgeladene Datei ist ungültig.'); + } + if ((int)($file['size'] ?? 0) > 5 * 1024 * 1024) { + throw new \RuntimeException('Bitte ein Bild mit maximal 5 MB hochladen.'); + } + + $imageInfo = @getimagesize($tmpName); + if (!is_array($imageInfo) || empty($imageInfo['mime'])) { + throw new \RuntimeException('Bitte eine gültige Bilddatei hochladen.'); + } + $mime = (string)$imageInfo['mime']; + $extension = match ($mime) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + default => '', + }; + if ($extension === '') { + throw new \RuntimeException('Erlaubt sind JPG, PNG oder WebP.'); + } + + $uploadDir = dirname(__DIR__, 2) . '/public/uploads/listings'; + if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true) && !is_dir($uploadDir)) { + throw new \RuntimeException('Der Upload-Ordner konnte nicht erstellt werden.'); + } + + $filename = 'listing-' . date('Ymd-His') . '-' . bin2hex(random_bytes(6)) . '.' . $extension; + $targetPath = $uploadDir . '/' . $filename; + if (!move_uploaded_file($tmpName, $targetPath)) { + throw new \RuntimeException('Das Bild konnte nicht gespeichert werden.'); + } + + self::deleteStoredImage($currentPath); + return '/uploads/listings/' . $filename; + } + + private static function deleteStoredImage(string $path): void + { + $path = trim($path); + if ($path === '' || !str_starts_with($path, '/uploads/listings/')) { + return; + } + $absolutePath = dirname(__DIR__, 2) . '/public' . $path; + if (is_file($absolutePath)) { + @unlink($absolutePath); + } + } + + private static function slugifyValue(string $value): string + { + $value = mb_strtolower(trim($value)); + $value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); + $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: ''; + return trim($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 fa7e812..bd10c79 100644 --- a/src/App/ListingCatalog.php +++ b/src/App/ListingCatalog.php @@ -39,6 +39,8 @@ final class ListingCatalog website_url VARCHAR(255) NULL, phone VARCHAR(60) NULL, place_kind VARCHAR(80) NULL, + opening_hours_note TEXT NULL, + opening_hours_json LONGTEXT NULL, provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual", external_place_id VARCHAR(190) NULL, google_place_id VARCHAR(190) NULL, @@ -62,6 +64,8 @@ final class ListingCatalog title VARCHAR(200) NOT NULL, teaser_public VARCHAR(280) NOT NULL, description TEXT NOT NULL, + image_path VARCHAR(255) NULL, + special_conditions_note TEXT NULL, visibility ENUM("public","members") NOT NULL DEFAULT "public", status ENUM("draft","published","cancelled","archived") NOT NULL DEFAULT "draft", supports_registration TINYINT(1) NOT NULL DEFAULT 0, @@ -145,6 +149,9 @@ final class ListingCatalog $columnStatements = [ 'listing_places.opening_hours_note' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_note TEXT NULL AFTER place_kind', + 'listing_places.opening_hours_json' => 'ALTER TABLE listing_places ADD COLUMN opening_hours_json LONGTEXT NULL AFTER opening_hours_note', + 'listings.image_path' => 'ALTER TABLE listings ADD COLUMN image_path VARCHAR(255) NULL AFTER description', + 'listings.special_conditions_note' => 'ALTER TABLE listings ADD COLUMN special_conditions_note TEXT NULL AFTER image_path', ]; foreach ($columnStatements as $key => $sql) { [$table, $column] = explode('.', $key, 2); @@ -214,7 +221,7 @@ final class ListingCatalog { $this->ensureSchema(); $stmt = $this->pdo->prepare( - 'SELECT l.id, l.listing_type, l.title, l.teaser_public, l.visibility, l.status, + 'SELECT l.id, l.listing_type, l.title, l.teaser_public, l.visibility, l.status, l.image_path, lp.title AS place_title, lp.city, lp.region, lp.place_kind, lo.starts_at, lo.ends_at, lo.occurrence_type, lc.title AS category_title, lc.slug AS category_slug @@ -241,9 +248,9 @@ final class ListingCatalog { $this->ensureSchema(); $stmt = $this->pdo->prepare( - 'SELECT l.*, lp.title AS place_title, lp.description AS place_description, lp.street, lp.zip, lp.city, lp.region, lp.lat, lp.lng, lp.place_kind, lp.opening_hours_note, + '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.phone, lp.website_url, lp.opening_hours_note, lp.opening_hours_json, lo.id AS occurrence_id, lo.starts_at, lo.ends_at, lo.occurrence_type, - lo.recurrence_rule, lo.recurrence_until, + lo.recurrence_rule, lo.recurrence_until, lo.capacity_total, lc.slug AS category_slug FROM listings l LEFT JOIN listing_places lp ON lp.id = l.primary_place_id @@ -262,6 +269,7 @@ final class ListingCatalog return null; } $row['prices'] = $this->listPrices((int)$row['id']); + $row['opening_hours'] = $this->decodeJsonRows((string)($row['opening_hours_json'] ?? '')); return $row; } @@ -292,22 +300,28 @@ final class ListingCatalog $teaser = trim((string)($data['teaser'] ?? '')); $description = trim((string)($data['description'] ?? '')); $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'] ?? '')); $street = trim((string)($data['street'] ?? '')); $zip = trim((string)($data['zip'] ?? '')); $city = trim((string)($data['city'] ?? '')); $region = trim((string)($data['region'] ?? '')); + $phone = trim((string)($data['phone'] ?? '')); + $websiteUrl = trim((string)($data['website_url'] ?? '')); $lat = isset($data['lat']) && $data['lat'] !== '' ? (float)$data['lat'] : null; $lng = isset($data['lng']) && $data['lng'] !== '' ? (float)$data['lng'] : null; $startsAt = trim((string)($data['starts_at'] ?? '')); $recurrenceMode = (string)($data['recurrence_mode'] ?? 'single'); $recurrenceUntil = trim((string)($data['recurrence_until'] ?? '')); $weekdayValues = array_values(array_filter(array_map('strval', (array)($data['recurrence_weekdays'] ?? [])))); - $priceMode = (string)($data['price_mode'] ?? 'none'); + $specialConditionsNote = trim((string)($data['special_conditions_note'] ?? '')); + $imagePath = trim((string)($data['image_path'] ?? '')); + $capacityTotal = isset($data['capacity_total']) && $data['capacity_total'] !== '' ? (int)$data['capacity_total'] : null; - if ($title === '' || $teaser === '' || $description === '') { - throw new \RuntimeException('Bitte fülle Titel, Kurzbeschreibung und Beschreibung aus.'); + if ($title === '' || $description === '') { + throw new \RuntimeException('Bitte fülle Titel und Beschreibung aus.'); } if (!in_array($visibility, ['public', 'members'], true)) { $visibility = 'public'; @@ -315,16 +329,13 @@ final class ListingCatalog if ($entryType === 'editorial_event' && $startsAt === '') { throw new \RuntimeException('Bitte gib für sonstige Veranstaltungen ein Datum an.'); } + if ($entryType === 'editorial_event' && $recurrenceUntil === '') { + throw new \RuntimeException('Bitte gib für Veranstaltungen ein Gültig-bis-Datum an.'); + } $this->pdo->beginTransaction(); try { - $categoryId = null; - if ($placeKind !== '') { - $catStmt = $this->pdo->prepare('SELECT id FROM listing_categories WHERE slug = :slug LIMIT 1'); - $catStmt->execute(['slug' => $placeKind]); - $categoryId = $catStmt->fetchColumn(); - $categoryId = $categoryId !== false ? (int)$categoryId : null; - } + [$placeKind, $categoryId] = $this->resolveCategory($placeKind, $customCategoryTitle); if ($listingId !== null && $listingId > 0) { $existing = $this->getDashboardEntry($userId, $listingId); @@ -335,7 +346,9 @@ final class ListingCatalog if ($placeId > 0) { $placeStmt = $this->pdo->prepare( 'UPDATE listing_places - SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, lat = :lat, lng = :lng, place_kind = :placeKind, opening_hours_note = :openingHoursNote, updated_at = NOW() + SET title = :title, description = :description, street = :street, zip = :zip, city = :city, region = :region, + lat = :lat, lng = :lng, website_url = :websiteUrl, phone = :phone, place_kind = :placeKind, + opening_hours_note = :openingHoursNote, opening_hours_json = :openingHoursJson, updated_at = NOW() WHERE id = :id' ); $placeStmt->execute([ @@ -347,33 +360,43 @@ final class ListingCatalog 'region' => $region !== '' ? $region : null, 'lat' => $lat, 'lng' => $lng, + 'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null, + 'phone' => $phone !== '' ? $phone : null, 'placeKind' => $placeKind !== '' ? $placeKind : null, 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, + 'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, 'id' => $placeId, ]); } $listingStmt = $this->pdo->prepare( 'UPDATE listings - SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description, visibility = :visibility, - supports_registration = 0, supports_capacity = 0, supports_pricing = 0, is_recurring = 0, status = "published", updated_at = NOW() + SET listing_type = :listingType, title = :title, teaser_public = :teaser, description = :description, + image_path = :imagePath, special_conditions_note = :specialConditionsNote, visibility = :visibility, + supports_registration = 0, supports_capacity = :supportsCapacity, supports_pricing = :supportsPricing, + is_recurring = :isRecurring, status = "published", updated_at = NOW() WHERE id = :id AND created_by = :uid' ); $listingStmt->execute([ 'listingType' => $entryType, 'title' => $title, - 'teaser' => $teaser, + 'teaser' => $teaser !== '' ? $teaser : $title, 'description' => $description, + 'imagePath' => $imagePath !== '' ? $imagePath : null, + 'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null, 'visibility' => $visibility, + 'supportsCapacity' => $capacityTotal !== null ? 1 : 0, + 'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0, + 'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0, 'id' => $listingId, 'uid' => $userId, ]); $this->pdo->prepare('DELETE FROM listing_occurrences WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); - $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data); + $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data); } else { $placeStmt = $this->pdo->prepare( - 'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, place_kind, opening_hours_note, provider_hint, status, created_at, updated_at) - VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :placeKind, :openingHoursNote, "manual", "published", NOW(), NOW())' + 'INSERT INTO listing_places (created_by, source_type, title, description, street, zip, city, region, lat, lng, website_url, phone, place_kind, opening_hours_note, opening_hours_json, provider_hint, status, created_at, updated_at) + VALUES (:uid, "user", :title, :description, :street, :zip, :city, :region, :lat, :lng, :websiteUrl, :phone, :placeKind, :openingHoursNote, :openingHoursJson, "manual", "published", NOW(), NOW())' ); $placeStmt->execute([ 'uid' => $userId, @@ -385,27 +408,35 @@ final class ListingCatalog 'region' => $region !== '' ? $region : null, 'lat' => $lat, 'lng' => $lng, + 'websiteUrl' => $websiteUrl !== '' ? $websiteUrl : null, + 'phone' => $phone !== '' ? $phone : null, 'placeKind' => $placeKind !== '' ? $placeKind : null, 'openingHoursNote' => $openingHoursNote !== '' ? $openingHoursNote : null, + 'openingHoursJson' => $openingHoursRows !== [] ? json_encode($openingHoursRows, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, ]); $placeId = (int)$this->pdo->lastInsertId(); $listingStmt = $this->pdo->prepare( - 'INSERT INTO listings (created_by, owner_type, listing_type, primary_place_id, title, teaser_public, description, visibility, status, supports_registration, supports_capacity, supports_pricing, is_recurring, created_at, updated_at) - VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :visibility, "published", 0, 0, 0, 0, NOW(), NOW())' + 'INSERT INTO listings (created_by, owner_type, listing_type, primary_place_id, title, teaser_public, description, image_path, special_conditions_note, visibility, status, supports_registration, supports_capacity, supports_pricing, is_recurring, created_at, updated_at) + VALUES (:uid, "user", :listingType, :placeId, :title, :teaser, :description, :imagePath, :specialConditionsNote, :visibility, "published", 0, :supportsCapacity, :supportsPricing, :isRecurring, NOW(), NOW())' ); $listingStmt->execute([ 'uid' => $userId, 'listingType' => $entryType, 'placeId' => $placeId, 'title' => $title, - 'teaser' => $teaser, + 'teaser' => $teaser !== '' ? $teaser : $title, 'description' => $description, + 'imagePath' => $imagePath !== '' ? $imagePath : null, + 'specialConditionsNote' => $specialConditionsNote !== '' ? $specialConditionsNote : null, 'visibility' => $visibility, + 'supportsCapacity' => $capacityTotal !== null ? 1 : 0, + 'supportsPricing' => $this->hasPriceRows($data) || $specialConditionsNote !== '' ? 1 : 0, + 'isRecurring' => $entryType === 'editorial_event' && $recurrenceMode !== 'single' ? 1 : 0, ]); $listingId = (int)$this->pdo->lastInsertId(); - $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $priceMode, $data); + $this->saveOccurrenceAndPrices($listingId, $entryType, $startsAt, $recurrenceMode, $weekdayValues, $recurrenceUntil, $capacityTotal, $data); } $this->pdo->prepare('DELETE FROM listing_category_map WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); @@ -427,14 +458,14 @@ final class ListingCatalog } } - private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, string $priceMode, array $data): void + private function saveOccurrenceAndPrices(int $listingId, string $entryType, string $startsAt, string $recurrenceMode, array $weekdayValues, string $recurrenceUntil, ?int $capacityTotal, 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())' + 'INSERT INTO listing_occurrences (listing_id, occurrence_type, starts_at, recurrence_rule, recurrence_until, capacity_total, status, created_at, updated_at) + VALUES (:listingId, :occurrenceType, :startsAt, :recurrenceRule, :recurrenceUntil, :capacityTotal, "scheduled", NOW(), NOW())' ); $occStmt->execute([ 'listingId' => $listingId, @@ -442,12 +473,14 @@ final class ListingCatalog 'startsAt' => $startsAt !== '' ? $startsAt : null, 'recurrenceRule' => $recurrenceRule, 'recurrenceUntil' => $recurrenceUntil !== '' ? $recurrenceUntil : null, + 'capacityTotal' => $capacityTotal, ]); $occurrenceId = (int)$this->pdo->lastInsertId(); } $this->pdo->prepare('DELETE FROM listing_prices WHERE listing_id = :listingId')->execute(['listingId' => $listingId]); - if ($priceMode === 'none') { + $priceRows = $this->normalizePriceRows($data); + if ($priceRows === []) { return; } @@ -456,52 +489,16 @@ final class ListingCatalog 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'] ?? '')); + foreach ($priceRows as $row) { $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', + 'label' => $row['label'], + 'audience' => $row['audience'], + 'priceType' => $row['price_type'], + 'amount' => $row['amount'], + 'amountSecondary' => $row['amount_secondary'], + 'note' => $row['note'], ]); } } @@ -539,6 +536,126 @@ 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'] ?? []); + $fromValues = (array)($data['opening_time_from'] ?? []); + $toValues = (array)($data['opening_time_to'] ?? []); + $notes = (array)($data['opening_note'] ?? []); + $count = max(count($labels), count($fromValues), count($toValues), count($notes)); + $rows = []; + for ($index = 0; $index < $count; $index++) { + $row = [ + 'day_label' => trim((string)($labels[$index] ?? '')), + 'time_from' => trim((string)($fromValues[$index] ?? '')), + 'time_to' => trim((string)($toValues[$index] ?? '')), + 'note' => trim((string)($notes[$index] ?? '')), + ]; + if ($row['day_label'] === '' && $row['time_from'] === '' && $row['time_to'] === '' && $row['note'] === '') { + continue; + } + $rows[] = $row; + } + return $rows; + } + + 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)); + $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 === '') { + continue; + } + $noteParts = []; + if ($note !== '') { + $noteParts[] = $note; + } + if ($ageFrom !== '' || $ageTo !== '') { + $ageText = []; + if ($ageFrom !== '') { + $ageText[] = 'ab ' . $ageFrom . ' Jahre'; + } + if ($ageTo !== '') { + $ageText[] = 'bis ' . $ageTo . ' Jahre'; + } + $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', + 'amount' => $amount !== '' ? (float)str_replace(',', '.', $amount) : null, + 'amount_secondary' => $secondary !== '' ? (float)str_replace(',', '.', $secondary) : null, + 'note' => $noteParts !== [] ? implode(' | ', $noteParts) : null, + ]; + } + return $rows; + } + + private function hasPriceRows(array $data): bool + { + return $this->normalizePriceRows($data) !== []; + } + + private function decodeJsonRows(string $json): array + { + if ($json === '') { + return []; + } + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; + } + + private function slugify(string $value): string + { + $value = mb_strtolower(trim($value)); + $value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); + $value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: ''; + return trim($value, '-'); + } + public function deleteDashboardEntry(int $userId, int $listingId): void { $this->ensureSchema();