hhhh
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
return [
|
return [
|
||||||
// Points per action
|
// Points per action
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ $eventsForJs = [];
|
|||||||
try {
|
try {
|
||||||
$pdo = $app->pdo();
|
$pdo = $app->pdo();
|
||||||
if ($pdo) {
|
if ($pdo) {
|
||||||
$stmt = $pdo->prepare('SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng FROM events WHERE starts_at >= NOW() AND status != "cancelled" ORDER BY starts_at ASC LIMIT 50');
|
$stmt = $pdo->prepare('SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng, created_at FROM events WHERE starts_at >= NOW() AND status != "cancelled" ORDER BY created_at DESC, starts_at ASC LIMIT 10');
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
@@ -58,63 +58,64 @@ try {
|
|||||||
<div class="chip inline">Echt lokale Gruppen</div>
|
<div class="chip inline">Echt lokale Gruppen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero__card card">
|
|
||||||
<div class="badge">Schnellsuche</div>
|
|
||||||
<h3>Events in deiner Region</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="label" for="locInput">PLZ oder Ort</label>
|
|
||||||
<input id="locInput" class="input" placeholder="z. B. 10437 oder Berlin" />
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="label" for="topicSelect">Thema</label>
|
|
||||||
<select id="topicSelect" class="select">
|
|
||||||
<option value="">Alle</option>
|
|
||||||
<option value="outdoor">Outdoor / Spielplatz</option>
|
|
||||||
<option value="kaffee">Kaffee & Austausch</option>
|
|
||||||
<option value="sport">Sport</option>
|
|
||||||
<option value="workshop">Workshop</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="label" for="ageSelect">Alter der Kinder</label>
|
|
||||||
<select id="ageSelect" class="select">
|
|
||||||
<option value="">Alle</option>
|
|
||||||
<option value="baby">0-2 Jahre</option>
|
|
||||||
<option value="kids">3-6 Jahre</option>
|
|
||||||
<option value="school">7-12 Jahre</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn block" id="btnSearch">Events anzeigen</button>
|
|
||||||
<button class="btn ghost block" id="btnGeo">Standort automatisch ermitteln</button>
|
|
||||||
<p class="muted small">Geodaten werden nur zur Anzeige verwendet.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slider {display:flex; align-items:center; gap:12px;}
|
||||||
|
.slider__viewport {overflow-x:hidden; flex:1; scroll-behavior:smooth;}
|
||||||
|
.slider__track {display:flex; gap:12px; min-height:100%;}
|
||||||
|
.slider__track .event-card-small {min-width:240px; max-width:260px;}
|
||||||
|
.slider__nav {min-width:44px;}
|
||||||
|
</style>
|
||||||
<section class="container section" id="events">
|
<section class="container section" id="events">
|
||||||
<div class="section__head">
|
<div class="section__head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Termine entdecken</p>
|
<p class="eyebrow">Termine entdecken</p>
|
||||||
<h2>Die nächsten anstehenden Events</h2>
|
<h2>Neueste Termine</h2>
|
||||||
<p class="muted">Gäste sehen nur Basisinfos. Als Mitglied siehst du vollständige Details, kannst zusagen und neue Treffen anlegen.</p>
|
<p class="muted">Die zehn neuesten veröffentlichten Events – kompakt zum Durchscrollen. Gäste sehen Basisinfos, Mitglieder erhalten alle Details.</p>
|
||||||
</div>
|
|
||||||
<div class="chips">
|
|
||||||
<button class="chip" data-filter="all">Alle</button>
|
|
||||||
<button class="chip" data-filter="outdoor">Outdoor</button>
|
|
||||||
<button class="chip" data-filter="kaffee">Kaffee</button>
|
|
||||||
<button class="chip" data-filter="sport">Sport</button>
|
|
||||||
<button class="chip" data-filter="workshop">Workshops</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="results" id="eventList"></div>
|
<div class="slider">
|
||||||
<div class="surface border rounded p-4 mt-3">
|
<button class="btn ghost slider__nav" type="button" data-slider-prev aria-label="Zurück">‹</button>
|
||||||
<div class="flex between center-y gap-12">
|
<div class="slider__viewport">
|
||||||
|
<div class="slider__track" id="eventSlider"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn ghost slider__nav" type="button" data-slider-next aria-label="Weiter">›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section alt" id="quicksearch">
|
||||||
|
<div class="container">
|
||||||
|
<p class="eyebrow">Schnellsuche</p>
|
||||||
|
<h3>Passende Events finden</h3>
|
||||||
|
<form id="quickSearchForm" class="grid grid-3" style="gap: 12px; align-items:flex-end;" action="/search" method="get">
|
||||||
|
<div class="stack gap-6">
|
||||||
|
<label class="label" for="qsQuery">Suchbegriff</label>
|
||||||
|
<input id="qsQuery" name="q" class="input" placeholder="Titel, Thema, Beschreibung">
|
||||||
|
</div>
|
||||||
|
<div class="stack gap-6">
|
||||||
|
<label class="label" for="qsLoc">Ort oder PLZ</label>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<input id="qsLoc" name="loc" class="input" placeholder="z. B. 10437 oder Berlin" style="flex:1;">
|
||||||
|
<button class="btn ghost" type="button" id="quickGeo">📍</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="lat" id="qsLat">
|
||||||
|
<input type="hidden" name="lng" id="qsLng">
|
||||||
|
</div>
|
||||||
|
<div class="stack gap-6">
|
||||||
|
<label class="label" for="qsRadius">Umkreis (km)</label>
|
||||||
|
<select id="qsRadius" name="radius" class="select">
|
||||||
|
<?php foreach ([1,2,5,10,15,25] as $r): ?>
|
||||||
|
<option value="<?= $r ?>"><?= $r ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Nur für eingeloggte Mitglieder</strong>
|
<button class="btn block" type="submit" style="width:100%;">Suchen</button>
|
||||||
<p class="muted small">Volle Beschreibung, Kontakt, Kinder-Infos und Anmeldeoptionen.</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn">Jetzt anmelden</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="muted small" style="margin-top:8px;">Optional Standort ermitteln oder Ort eingeben; Umkreis bestimmt die Treffer in der Suche.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -4,29 +4,73 @@ declare(strict_types=1);
|
|||||||
$app = app();
|
$app = app();
|
||||||
$pdo = $app->pdo();
|
$pdo = $app->pdo();
|
||||||
$q = trim((string)($_GET['q'] ?? ''));
|
$q = trim((string)($_GET['q'] ?? ''));
|
||||||
|
$loc = trim((string)($_GET['loc'] ?? ''));
|
||||||
|
$radius = isset($_GET['radius']) ? (float)$_GET['radius'] : 5.0;
|
||||||
|
$radius = ($radius > 0) ? $radius : 5.0;
|
||||||
|
$lat = isset($_GET['lat']) && $_GET['lat'] !== '' ? (float)$_GET['lat'] : null;
|
||||||
|
$lng = isset($_GET['lng']) && $_GET['lng'] !== '' ? (float)$_GET['lng'] : null;
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
if ($q !== '' && $pdo) {
|
function geocode_loc(string $loc): array
|
||||||
|
{
|
||||||
|
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
|
||||||
|
'format' => 'jsonv2',
|
||||||
|
'limit' => 1,
|
||||||
|
'q' => $loc,
|
||||||
|
]);
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n",
|
||||||
|
'timeout' => 6,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$resp = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($resp === false) return [null, null];
|
||||||
|
$json = json_decode($resp, true);
|
||||||
|
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) return [null, null];
|
||||||
|
return [round((float)$json[0]['lat'], 6), round((float)$json[0]['lon'], 6)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pdo && ($q !== '' || $loc !== '' || ($lat !== null && $lng !== null))) {
|
||||||
|
if ($lat === null && $lng === null && $loc !== '') {
|
||||||
|
[$lat, $lng] = geocode_loc($loc);
|
||||||
|
}
|
||||||
|
$geo = ($lat !== null && $lng !== null) ? ['lat' => $lat, 'lng' => $lng, 'radius' => $radius] : null;
|
||||||
$search = new \App\Search($pdo);
|
$search = new \App\Search($pdo);
|
||||||
$results = $search->searchEvents($q, 100);
|
$results = $search->searchEvents($q, 100, $geo);
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<main class="section">
|
<main class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="eyebrow">Suche</p>
|
<p class="eyebrow">Suche</p>
|
||||||
<h1>Events finden</h1>
|
<h1>Events finden</h1>
|
||||||
<form method="get" class="form-grid single" style="margin: 14px 0;">
|
<form method="get" class="form-grid single" style="margin: 14px 0; gap:12px; align-items:end;">
|
||||||
<div class="stack gap-6">
|
<div class="stack gap-6">
|
||||||
<label class="label" for="q">Suchbegriff (Titel, Ort, Beschreibung)</label>
|
<label class="label" for="q">Suchbegriff (Titel, Ort, Beschreibung)</label>
|
||||||
<input id="q" name="q" class="input" value="<?= htmlspecialchars($q, ENT_QUOTES) ?>" placeholder="z. B. Berlin oder Spielplatz">
|
<input id="q" name="q" class="input" value="<?= htmlspecialchars($q, ENT_QUOTES) ?>" placeholder="z. B. Berlin oder Spielplatz">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stack gap-6">
|
||||||
|
<label class="label" for="loc">Ort oder PLZ (optional)</label>
|
||||||
|
<input id="loc" name="loc" class="input" value="<?= htmlspecialchars($loc, ENT_QUOTES) ?>" placeholder="z. B. 10437 oder Berlin">
|
||||||
|
<input type="hidden" name="lat" value="<?= $lat !== null ? htmlspecialchars((string)$lat, ENT_QUOTES) : '' ?>">
|
||||||
|
<input type="hidden" name="lng" value="<?= $lng !== null ? htmlspecialchars((string)$lng, ENT_QUOTES) : '' ?>">
|
||||||
|
</div>
|
||||||
|
<div class="stack gap-6">
|
||||||
|
<label class="label" for="radius">Umkreis (km)</label>
|
||||||
|
<select id="radius" name="radius" class="select">
|
||||||
|
<?php foreach ([1,2,5,10,15,25] as $r): ?>
|
||||||
|
<option value="<?= $r ?>" <?= ((float)$radius === (float)$r) ? 'selected' : '' ?>><?= $r ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<button class="btn" type="submit">Suchen</button>
|
<button class="btn" type="submit">Suchen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($q === ''): ?>
|
<?php if ($q === '' && $loc === '' && $lat === null && $lng === null): ?>
|
||||||
<p class="muted">Bitte gib einen Suchbegriff ein.</p>
|
<p class="muted">Bitte gib einen Suchbegriff oder einen Ort ein.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<h3 style="margin-top: 16px;"><?= count($results) ?> Ergebnis(se) für „<?= htmlspecialchars($q, ENT_QUOTES) ?>“</h3>
|
<h3 style="margin-top: 16px;"><?= count($results) ?> Ergebnis(se)<?= $q !== '' ? ' für „' . htmlspecialchars($q, ENT_QUOTES) . '“' : '' ?></h3>
|
||||||
<?php if (!$results): ?>
|
<?php if (!$results): ?>
|
||||||
<p class="muted">Keine passenden Events gefunden.</p>
|
<p class="muted">Keine passenden Events gefunden.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -39,6 +83,9 @@ if ($q !== '' && $pdo) {
|
|||||||
<span>📍 <?= htmlspecialchars($ev['region'] ?: $ev['city'], ENT_QUOTES) ?></span>
|
<span>📍 <?= htmlspecialchars($ev['region'] ?: $ev['city'], ENT_QUOTES) ?></span>
|
||||||
<span><?= $ev['visibility'] === 'public' ? 'Öffentlich' : 'Mitglieder' ?></span>
|
<span><?= $ev['visibility'] === 'public' ? 'Öffentlich' : 'Mitglieder' ?></span>
|
||||||
<span class="badge"><?= ((int)$ev['allow_kids'] === 1) ? 'Mit Kindern' : 'Ohne Kinder' ?></span>
|
<span class="badge"><?= ((int)$ev['allow_kids'] === 1) ? 'Mit Kindern' : 'Ohne Kinder' ?></span>
|
||||||
|
<?php if (isset($ev['distance_km'])): ?>
|
||||||
|
<span class="badge"><?= number_format((float)$ev['distance_km'], 1) ?> km</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<h3><?= htmlspecialchars($ev['title'], ENT_QUOTES) ?></h3>
|
<h3><?= htmlspecialchars($ev['title'], ENT_QUOTES) ?></h3>
|
||||||
<p class="muted"><?= htmlspecialchars($ev['teaser_public'], ENT_QUOTES) ?></p>
|
<p class="muted"><?= htmlspecialchars($ev['teaser_public'], ENT_QUOTES) ?></p>
|
||||||
|
|||||||
@@ -52,110 +52,54 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Events from backend (injected on page); fallback to empty
|
// Events from backend (injected on page); fallback to empty
|
||||||
const events = Array.isArray(window.__events) ? window.__events : [];
|
const events = Array.isArray(window.__events) ? window.__events : [];
|
||||||
|
|
||||||
const state = { topic: 'all', age: '', region: '', query: '', geo: null };
|
|
||||||
const el = {
|
const el = {
|
||||||
list: document.getElementById('eventList'),
|
sliderTrack: document.getElementById('eventSlider'),
|
||||||
chips: document.querySelectorAll('[data-filter]'),
|
sliderViewport: document.querySelector('.slider__viewport'),
|
||||||
locInput: document.getElementById('locInput'),
|
sliderPrev: document.querySelector('[data-slider-prev]'),
|
||||||
topicSelect: document.getElementById('topicSelect'),
|
sliderNext: document.querySelector('[data-slider-next]'),
|
||||||
ageSelect: document.getElementById('ageSelect'),
|
|
||||||
btnSearch: document.getElementById('btnSearch'),
|
|
||||||
btnGeo: document.getElementById('btnGeo'),
|
|
||||||
modal: document.getElementById('eventModal'),
|
modal: document.getElementById('eventModal'),
|
||||||
modalBody: document.getElementById('eventModalBody'),
|
modalBody: document.getElementById('eventModalBody'),
|
||||||
modalTitle: document.getElementById('eventModalTitle'),
|
modalTitle: document.getElementById('eventModalTitle'),
|
||||||
|
quickForm: document.getElementById('quickSearchForm'),
|
||||||
|
quickLoc: document.getElementById('qsLoc'),
|
||||||
|
quickQuery: document.getElementById('qsQuery'),
|
||||||
|
quickLat: document.getElementById('qsLat'),
|
||||||
|
quickLng: document.getElementById('qsLng'),
|
||||||
|
quickGeo: document.getElementById('quickGeo'),
|
||||||
};
|
};
|
||||||
const searchState = { active: false };
|
|
||||||
|
|
||||||
const fmtDate = (iso) => {
|
const fmtDate = (iso) => {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const haversine = (lat1, lon1, lat2, lon2) => {
|
const smallTag = (label) => `<span class="badge">${label}</span>`;
|
||||||
const toRad = (d) => d * Math.PI / 180;
|
|
||||||
const R = 6371e3;
|
|
||||||
const dLat = toRad(lat2 - lat1);
|
|
||||||
const dLon = toRad(lon2 - lon1);
|
|
||||||
const a = Math.sin(dLat/2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2) ** 2;
|
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
||||||
return R * c;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tag = (label) => `<span class="badge">${label}</span>`;
|
|
||||||
|
|
||||||
const renderCard = (item) => {
|
const renderCard = (item) => {
|
||||||
const guest = !isLoggedIn;
|
const guest = !isLoggedIn;
|
||||||
const access = item.visibility === 'members' && guest ? '<div class="event__access">Nur für Mitglieder</div>' : '';
|
|
||||||
const desc = `<p class="muted">${item.teaser}</p>`;
|
|
||||||
const contact = !guest ? `<div class="muted small">Kontakt: ${item.contact}</div>` : '';
|
|
||||||
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
|
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
|
||||||
const tags = [
|
|
||||||
tag(item.topic === 'kaffee' ? 'Kaffee & Austausch' : item.topic.charAt(0).toUpperCase() + item.topic.slice(1)),
|
|
||||||
tag(kids),
|
|
||||||
item.ageGroup ? tag(`Alter: ${item.ageGroup}`) : '',
|
|
||||||
].filter(Boolean).join('');
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="card">
|
<article class="card event-card-small">
|
||||||
<div class="event__body">
|
<div class="muted small">${fmtDate(item.startsAt)}</div>
|
||||||
${access}
|
|
||||||
<div class="event__meta">
|
|
||||||
<span>${fmtDate(item.startsAt)}</span>
|
|
||||||
<span>📍 ${item.region || item.city}</span>
|
|
||||||
<span>${item.visibility === 'public' ? 'Öffentlich' : 'Mitglieder'}</span>
|
|
||||||
</div>
|
|
||||||
<h3>${item.title}</h3>
|
<h3>${item.title}</h3>
|
||||||
<p class="muted">${guest ? item.teaser : item.description.slice(0, 140) + '...'}</p>
|
<p class="muted">${item.teaser}</p>
|
||||||
${guest ? '' : desc}
|
<div class="muted small">📍 ${item.city || item.region || ''}</div>
|
||||||
<div class="event__tags">${tags}</div>
|
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')}</div>
|
||||||
${contact}
|
<div class="flex gap-8" style="margin-top:8px;">
|
||||||
<div class="flex gap-12">
|
|
||||||
<button class="btn ghost" data-event-detail="${item.id}">Details</button>
|
<button class="btn ghost" data-event-detail="${item.id}">Details</button>
|
||||||
${guest ? '' : '<button class="btn">Teilnehmen</button>'}
|
${guest ? '' : '<button class="btn">Teilnehmen</button>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</article>`;
|
</article>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesFilter = (ev) => {
|
const renderSlider = () => {
|
||||||
const topicOk = state.topic === 'all' || state.topic === ev.topic || state.topic === '';
|
if (!el.sliderTrack) return;
|
||||||
const ageOk = !state.age || ev.ageGroup === state.age || (state.age === 'baby' && ev.ageGroup === 'kids'); // simple fallback
|
if (!events.length) {
|
||||||
const regionField = (ev.region || '').toLowerCase();
|
el.sliderTrack.innerHTML = '<p class="muted">Keine Events vorhanden.</p>';
|
||||||
const cityField = (ev.city || '').toLowerCase();
|
return;
|
||||||
const zipField = (ev.zip || '');
|
|
||||||
const queryHaystack = (ev.title + ev.teaser + ev.description + (ev.city || '') + (ev.region || '')).toLowerCase();
|
|
||||||
const regionOk = !state.region || regionField.includes(state.region) || cityField.includes(state.region) || zipField.startsWith(state.region);
|
|
||||||
const queryOk = !state.query || queryHaystack.includes(state.query);
|
|
||||||
return topicOk && ageOk && regionOk && queryOk;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderEvents = () => {
|
|
||||||
if (!el.list) return;
|
|
||||||
let filtered = events.filter(matchesFilter);
|
|
||||||
filtered.sort((a,b) => new Date(a.startsAt) - new Date(b.startsAt));
|
|
||||||
|
|
||||||
if (state.geo && !searchState.active) {
|
|
||||||
const withCoords = filtered.filter(ev => ev.lat !== null && ev.lng !== null);
|
|
||||||
withCoords.forEach(ev => { ev._distance = haversine(state.geo.lat, state.geo.lng, ev.lat, ev.lng); });
|
|
||||||
withCoords.sort((a,b) => (a._distance || Infinity) - (b._distance || Infinity));
|
|
||||||
const near = withCoords.filter(ev => ev._distance <= 5000).slice(0,5);
|
|
||||||
const fallback = withCoords.slice(0,5);
|
|
||||||
filtered = (near.length ? near : fallback).map(ev => {
|
|
||||||
const copy = { ...ev };
|
|
||||||
if (ev._distance) {
|
|
||||||
copy.teaser = `${ev.teaser} · ca. ${(ev._distance/1000).toFixed(1)} km entfernt`;
|
|
||||||
}
|
}
|
||||||
return copy;
|
el.sliderTrack.innerHTML = events.slice(0, 10).map(renderCard).join('');
|
||||||
});
|
el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => {
|
||||||
} else if (!searchState.active) {
|
|
||||||
filtered = filtered.slice(0,5);
|
|
||||||
}
|
|
||||||
|
|
||||||
el.list.innerHTML = filtered.map(renderCard).join('') || '<p class="muted">Keine Events gefunden.</p>';
|
|
||||||
|
|
||||||
// wire detail buttons
|
|
||||||
el.list.querySelectorAll('[data-event-detail]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const id = parseInt(btn.getAttribute('data-event-detail'), 10);
|
const id = parseInt(btn.getAttribute('data-event-detail'), 10);
|
||||||
const ev = events.find(e => e.id === id);
|
const ev = events.find(e => e.id === id);
|
||||||
@@ -174,42 +118,62 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (el.chips.length > 0) {
|
const scrollSlider = (dir) => {
|
||||||
el.chips[0].classList.add('active');
|
if (!el.sliderViewport) return;
|
||||||
|
const amount = dir === 'next' ? 320 : -320;
|
||||||
|
el.sliderViewport.scrollBy({ left: amount, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
el.sliderPrev?.addEventListener('click', () => scrollSlider('prev'));
|
||||||
|
el.sliderNext?.addEventListener('click', () => scrollSlider('next'));
|
||||||
|
|
||||||
|
// Quick search with geocoding
|
||||||
|
const geocode = async (query) => {
|
||||||
|
const url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query);
|
||||||
|
const res = await fetch(url, { headers: { 'Accept-Language': 'de', 'User-Agent': 'papa-kind-treff/1.0' } });
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data) && data[0]) {
|
||||||
|
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
el.chips.forEach(chip => {
|
el.quickGeo?.addEventListener('click', () => {
|
||||||
chip.addEventListener('click', () => {
|
|
||||||
el.chips.forEach(c => c.classList.remove('active'));
|
|
||||||
chip.classList.add('active');
|
|
||||||
state.topic = chip.dataset.filter;
|
|
||||||
renderEvents();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (el.btnSearch) {
|
|
||||||
el.btnSearch.addEventListener('click', () => {
|
|
||||||
const q = (el.locInput?.value || '').trim();
|
|
||||||
const url = '/search' + (q ? ('?q=' + encodeURIComponent(q)) : '');
|
|
||||||
window.location.href = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el.btnGeo) {
|
|
||||||
el.btnGeo.addEventListener('click', () => {
|
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
alert('Geolocation wird nicht unterstützt.');
|
alert('Geolocation wird nicht unterstützt.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
state.geo = { lat: pos.coords.latitude, lng: pos.coords.longitude };
|
if (el.quickLat && el.quickLng) {
|
||||||
searchState.active = false;
|
el.quickLat.value = pos.coords.latitude.toFixed(6);
|
||||||
renderEvents();
|
el.quickLng.value = pos.coords.longitude.toFixed(6);
|
||||||
|
if (el.quickLoc) el.quickLoc.value = 'Mein Standort';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
() => alert('Standort konnte nicht ermittelt werden.')
|
() => alert('Standort konnte nicht ermittelt werden.')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (el.quickForm) {
|
||||||
|
el.quickForm.addEventListener('submit', async (e) => {
|
||||||
|
if (!el.quickLoc || !el.quickLat || !el.quickLng) return;
|
||||||
|
const hasCoords = el.quickLat.value && el.quickLng.value;
|
||||||
|
const locVal = (el.quickLoc.value || '').trim();
|
||||||
|
if (hasCoords || locVal === '') return;
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const coords = await geocode(locVal);
|
||||||
|
if (coords) {
|
||||||
|
el.quickLat.value = coords.lat.toFixed(6);
|
||||||
|
el.quickLng.value = coords.lng.toFixed(6);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
el.quickForm.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal
|
// Close modal
|
||||||
@@ -218,5 +182,5 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
|
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
|
||||||
|
|
||||||
renderEvents();
|
renderSlider();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ final class Search
|
|||||||
{
|
{
|
||||||
public function __construct(private ?\PDO $pdo) {}
|
public function __construct(private ?\PDO $pdo) {}
|
||||||
|
|
||||||
public function searchEvents(string $query, int $limit = 100): array
|
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
|
||||||
{
|
{
|
||||||
if (!$this->pdo) return [];
|
if (!$this->pdo) return [];
|
||||||
|
|
||||||
$q = trim($query);
|
$q = trim($query);
|
||||||
if ($q === '') return [];
|
$hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']);
|
||||||
|
if ($q === '' && !$hasGeo) return [];
|
||||||
|
|
||||||
$tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== '');
|
$tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== '');
|
||||||
if (!$tokens) {
|
if (!$tokens) {
|
||||||
@@ -39,18 +40,56 @@ final class Search
|
|||||||
}
|
}
|
||||||
$i++;
|
$i++;
|
||||||
}
|
}
|
||||||
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
|
$whereParts = [
|
||||||
|
"starts_at >= NOW()",
|
||||||
|
"status != 'cancelled'",
|
||||||
|
];
|
||||||
|
if ($conditions) {
|
||||||
|
$whereParts[] = implode(' AND ', $conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng";
|
||||||
|
$distanceFiltering = false;
|
||||||
|
|
||||||
|
if ($hasGeo) {
|
||||||
|
$lat = (float)$geo['lat'];
|
||||||
|
$lng = (float)$geo['lng'];
|
||||||
|
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
|
||||||
|
$sql .= ",
|
||||||
|
(6371 * ACOS(LEAST(1,
|
||||||
|
COS(RADIANS(:glat)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(:glng)) +
|
||||||
|
SIN(RADIANS(:glat)) * SIN(RADIANS(lat))
|
||||||
|
))) AS distance_km";
|
||||||
|
$distanceFiltering = true;
|
||||||
|
|
||||||
|
$latRange = $radius / 111.0;
|
||||||
|
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
|
||||||
|
$whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
|
||||||
|
$whereParts[] = "(lat BETWEEN :latMin AND :latMax)";
|
||||||
|
$whereParts[] = "(lng BETWEEN :lngMin AND :lngMax)";
|
||||||
|
$params[':glat'] = $lat;
|
||||||
|
$params[':glng'] = $lng;
|
||||||
|
$params[':latMin'] = $lat - $latRange;
|
||||||
|
$params[':latMax'] = $lat + $latRange;
|
||||||
|
$params[':lngMin'] = $lng - $lngRange;
|
||||||
|
$params[':lngMax'] = $lng + $lngRange;
|
||||||
|
$params[':radius'] = $radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : '';
|
||||||
|
$sql .= " FROM events $where";
|
||||||
|
if ($distanceFiltering) {
|
||||||
|
$sql .= " HAVING distance_km <= :radius";
|
||||||
|
$sql .= " ORDER BY distance_km ASC, starts_at ASC";
|
||||||
|
} else {
|
||||||
|
$sql .= " ORDER BY starts_at ASC";
|
||||||
|
}
|
||||||
|
$sql .= " LIMIT :lim";
|
||||||
|
|
||||||
$sql = "SELECT id, title, teaser_public, description, city, region, starts_at, visibility, allow_kids, location_label
|
|
||||||
FROM events
|
|
||||||
WHERE starts_at >= NOW()
|
|
||||||
AND status != 'cancelled'
|
|
||||||
$where
|
|
||||||
ORDER BY starts_at ASC
|
|
||||||
LIMIT :lim";
|
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
foreach ($params as $k => $v) {
|
foreach ($params as $k => $v) {
|
||||||
$stmt->bindValue($k, $v, \PDO::PARAM_STR);
|
$type = is_int($v) ? \PDO::PARAM_INT : \PDO::PARAM_STR;
|
||||||
|
$stmt->bindValue($k, $v, $type);
|
||||||
}
|
}
|
||||||
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
|
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|||||||
Reference in New Issue
Block a user