This commit is contained in:
2026-07-22 21:15:44 +02:00
parent 1176b98522
commit 751b6996c4
8 changed files with 375 additions and 19 deletions

View File

@@ -94,6 +94,13 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
<li>Ort: <?= htmlspecialchars($profile['city'], ENT_QUOTES) ?> <?= htmlspecialchars($profile['zip'], ENT_QUOTES) ?></li>
<li>E-Mail: <?= htmlspecialchars($profile['email'], ENT_QUOTES) ?></li>
<li>Telefon: <?= htmlspecialchars($profile['contact_phone'], ENT_QUOTES) ?></li>
<li>Standortfreigabe: <?=
htmlspecialchars(match ($profile['location_tracking_preference'] ?? 'prompt') {
'enabled' => 'Immer verwenden',
'disabled' => 'Deaktiviert',
default => 'Beim nächsten Mal fragen',
}, ENT_QUOTES)
?></li>
<li>Beruf: <?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?></li>
<li>Sprachen: <?= htmlspecialchars($profile['languages'], ENT_QUOTES) ?></li>
<li>About: <?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></li>
@@ -256,6 +263,15 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
<label class="label" for="pProf">Beruf</label>
<input id="pProf" name="profession" class="input" value="<?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?>">
</div>
<div class="stack gap-6">
<label class="label" for="pLocationPref">Standortfreigabe</label>
<select id="pLocationPref" name="location_tracking_preference" class="select">
<option value="disabled" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'disabled') ? 'selected' : '' ?>>Deaktiviert</option>
<option value="prompt" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'prompt') ? 'selected' : '' ?>>Beim nächsten Mal fragen</option>
<option value="enabled" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'enabled') ? 'selected' : '' ?>>Immer verwenden</option>
</select>
<p class="muted small">Bei aktiver Freigabe wird dein aktueller Standort für passende Treffen in deiner Nähe verwendet. Du kannst die Abfrage hier jederzeit wieder auf Nachfrage oder komplett aus stellen.</p>
</div>
<div class="stack gap-6">
<label class="label" for="pAbout">Kurzvorstellung</label>
<textarea id="pAbout" name="about" class="textarea" rows="3"><?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></textarea>

View File

@@ -7,7 +7,14 @@ $threadsForJs = [];
try {
$pdo = $app->pdo();
if ($pdo) {
$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 = $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 >= DATE_SUB(NOW(), INTERVAL 120 DAY)
AND status != "cancelled"
ORDER BY starts_at ASC
LIMIT 60
');
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($rows as $r) {
@@ -81,7 +88,7 @@ try {
<section class="section alt" id="quicksearch">
<div class="container">
<div class="section__intro">
<h2>Starte direkt mit der Suche nach passenden Treffen.</h2>
<h2>Suche nach Treffen in deiner Nähe</h2>
</div>
<form id="quickSearchForm" class="grid grid-3 quicksearch-form" style="gap: 12px; align-items:flex-end;" action="/search" method="get">
<div class="stack gap-6">

View File

@@ -13,6 +13,18 @@ if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
}
$debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true;
$locationTrackingPreference = 'prompt';
if (isset($_SESSION['user_id'])) {
try {
$pdo = $app->pdo();
if ($pdo) {
$profileSettings = new \App\ProfileSettings($pdo);
$locationTrackingPreference = $profileSettings->getLocationTrackingPreference((int)$_SESSION['user_id']);
}
} catch (\Throwable) {
$locationTrackingPreference = 'prompt';
}
}
$debugFiles = [];
if ($debugEnabled) {
$debugDir = __DIR__ . '/../../debug';
@@ -57,7 +69,7 @@ if ($debugEnabled) {
<?php asset_styles(); ?>
<?php asset_scripts('header'); ?>
</head>
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>">
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>" data-location-preference="<?= htmlspecialchars($locationTrackingPreference, ENT_QUOTES) ?>">
<?php tpl('matomo', 'structure'); ?>
<?php tpl('nav', 'structure'); ?>

View File

@@ -15,7 +15,7 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
<nav class="nav-links" aria-label="Hauptmenü">
<a href="/">Home</a>
<a href="/search">Suche</a>
<a href="/#events">Termine</a>
<a href="/#events" data-nav-events>Termine</a>
<a href="/community">Community</a>
</nav>
<div class="nav-actions">
@@ -35,7 +35,7 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
<div class="mobile-menu" id="mobileMenu">
<a href="/">Home</a>
<a href="/search">Suche</a>
<a href="/#events">Termine</a>
<a href="/#events" data-nav-events>Termine</a>
<a href="/community">Community</a>
<?php if ($isLoggedIn): ?>
<a class="btn ghost" href="/dashboard">Dashboard</a>

View File

@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => {
const body = document.body;
const isLoggedIn = body.dataset.auth === '1';
const childGender = body.dataset.childGender || '';
const locationPreference = body.dataset.locationPreference || 'prompt';
const header = document.querySelector('.site-header');
const headerSentinel = document.getElementById('headerSentinel');
@@ -52,6 +53,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Events from backend (injected on page); fallback to empty
const events = Array.isArray(window.__events) ? window.__events : [];
const threads = Array.isArray(window.__threads) ? window.__threads : [];
const EVENTS_RADIUS_KM = 20;
const LOCATION_STORAGE_KEY = 'pkt_user_location';
const el = {
sliderTrack: document.getElementById('eventSlider'),
@@ -71,6 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
quickLat: document.getElementById('qsLat'),
quickLng: document.getElementById('qsLng'),
quickGeo: document.getElementById('quickGeo'),
eventsSection: document.getElementById('events'),
};
const fmtDate = (iso) => {
@@ -78,36 +82,182 @@ document.addEventListener('DOMContentLoaded', () => {
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
};
const now = () => new Date();
const setCookie = (name, value, days = 30) => {
const expires = new Date(Date.now() + (days * 86400000)).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
};
const parseCookie = (name) => {
const prefix = `${name}=`;
return document.cookie
.split(';')
.map(item => item.trim())
.find(item => item.startsWith(prefix))
?.slice(prefix.length) || '';
};
const persistLocation = (location) => {
if (!location || !Number.isFinite(location.lat) || !Number.isFinite(location.lng)) return;
const payload = {
lat: Number(location.lat.toFixed(6)),
lng: Number(location.lng.toFixed(6)),
label: location.label || 'Mein Standort',
savedAt: new Date().toISOString(),
};
try {
window.localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
}
setCookie('pkt_user_location', JSON.stringify(payload), 30);
if (el.quickLat) el.quickLat.value = payload.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = payload.lng.toFixed(6);
if (el.quickLoc && (!el.quickLoc.value || el.quickLoc.value === 'Mein Standort')) {
el.quickLoc.value = payload.label;
}
};
const clearStoredLocation = () => {
try {
window.localStorage.removeItem(LOCATION_STORAGE_KEY);
} catch (err) {
// ignore
}
document.cookie = 'pkt_user_location=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
if (el.quickLat) el.quickLat.value = '';
if (el.quickLng) el.quickLng.value = '';
if (el.quickLoc) el.quickLoc.value = '';
};
const getStoredLocation = () => {
const candidates = [];
try {
const local = window.localStorage.getItem(LOCATION_STORAGE_KEY);
if (local) candidates.push(local);
} catch (err) {
// ignore
}
const cookieValue = parseCookie('pkt_user_location');
if (cookieValue) candidates.push(decodeURIComponent(cookieValue));
for (const entry of candidates) {
try {
const parsed = JSON.parse(entry);
if (parsed && Number.isFinite(parsed.lat) && Number.isFinite(parsed.lng)) {
return {
lat: Number(parsed.lat),
lng: Number(parsed.lng),
label: parsed.label || 'Mein Standort',
};
}
} catch (err) {
// ignore invalid payloads
}
}
return null;
};
const haversineKm = (lat1, lng1, lat2, lng2) => {
const toRad = (value) => value * Math.PI / 180;
const earthRadiusKm = 6371;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const smallTag = (label) => `<span class="badge">${label}</span>`;
const getRankedEvents = (sourceEvents, location) => {
const normalized = sourceEvents.map(item => {
const startsAtDate = new Date(item.startsAt);
const isFuture = startsAtDate >= now();
const hasGeo = Number.isFinite(item.lat) && Number.isFinite(item.lng);
const distanceKm = (location && hasGeo)
? haversineKm(location.lat, location.lng, item.lat, item.lng)
: null;
return {
...item,
startsAtDate,
isFuture,
distanceKm,
};
});
if (!location) {
return normalized
.filter(item => item.isFuture)
.sort((a, b) => a.startsAtDate - b.startsAtDate)
.slice(0, 10);
}
const nearby = normalized.filter(item => item.distanceKm !== null && item.distanceKm <= EVENTS_RADIUS_KM);
const byDistanceThenDate = (a, b) => {
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
return a.startsAtDate - b.startsAtDate;
};
const upcomingNearby = nearby
.filter(item => item.isFuture)
.sort(byDistanceThenDate);
if (upcomingNearby.length) {
return upcomingNearby.slice(0, 10);
}
const missedNearby = nearby
.filter(item => !item.isFuture)
.sort((a, b) => {
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
return b.startsAtDate - a.startsAtDate;
})
.map(item => ({ ...item, missed: true }));
if (missedNearby.length) {
return missedNearby.slice(0, 10);
}
return normalized
.filter(item => item.isFuture)
.sort((a, b) => a.startsAtDate - b.startsAtDate)
.slice(0, 10);
};
const renderCard = (item) => {
const guest = !isLoggedIn;
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
const distanceTag = Number.isFinite(item.distanceKm) ? smallTag(`${item.distanceKm.toFixed(1)} km`) : '';
const missedTag = item.missed ? smallTag('Das hast du verpasst') : '';
return `
<article class="card event-card-small">
<div class="muted small">${fmtDate(item.startsAt)}</div>
<h3>${item.title}</h3>
<p class="muted">${item.teaser}</p>
<div class="muted small">📍 ${item.city || item.region || ''}</div>
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')}</div>
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')} ${distanceTag} ${missedTag}</div>
<div class="flex gap-8" style="margin-top:8px;">
<button class="btn ghost" data-event-detail="${item.id}">Details</button>
${guest ? '' : '<button class="btn">Teilnehmen</button>'}
${guest || item.missed ? '' : '<button class="btn">Teilnehmen</button>'}
</div>
</article>`;
};
const renderSlider = () => {
const renderSlider = (location = null) => {
if (!el.sliderTrack) return;
if (!events.length) {
const rankedEvents = getRankedEvents(events, location);
if (!rankedEvents.length) {
el.sliderTrack.innerHTML = '<p class="muted">Keine Events vorhanden.</p>';
return;
}
el.sliderTrack.innerHTML = events.slice(0, 10).map(renderCard).join('');
el.sliderTrack.innerHTML = rankedEvents.map(renderCard).join('');
el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-event-detail'), 10);
const ev = events.find(e => e.id === id);
const ev = rankedEvents.find(e => e.id === id);
if (!ev || !el.modal || !el.modalBody || !el.modalTitle) return;
el.modalTitle.textContent = ev.title;
el.modalBody.innerHTML = `
@@ -115,6 +265,8 @@ document.addEventListener('DOMContentLoaded', () => {
<p class="muted">${ev.teaser}</p>
<p>${ev.description}</p>
${ev.locationLabel ? `<p><strong>Ort:</strong> ${ev.locationLabel}</p>` : ''}
${Number.isFinite(ev.distanceKm) ? `<p><strong>Entfernung:</strong> ${ev.distanceKm.toFixed(1)} km</p>` : ''}
${ev.missed ? '<p><strong>Hinweis:</strong> Das hast du verpasst.</p>' : ''}
<p><strong>Kinder:</strong> ${ev.allowKids ? 'Mit Kindern' : 'Ohne Kinder'}</p>
<p><strong>Sichtbarkeit:</strong> ${ev.visibility === 'public' ? 'Öffentlich' : 'Nur Mitglieder'}</p>
`;
@@ -160,6 +312,56 @@ document.addEventListener('DOMContentLoaded', () => {
el.threadSliderPrev?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'prev'));
el.threadSliderNext?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'next'));
const applyStoredLocationToForm = () => {
const stored = getStoredLocation();
if (!stored) return null;
if (el.quickLat) el.quickLat.value = stored.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = stored.lng.toFixed(6);
if (el.quickLoc && !el.quickLoc.value) el.quickLoc.value = stored.label || 'Mein Standort';
return stored;
};
const requestLocation = (options = {}) => new Promise((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const location = {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
label: 'Mein Standort',
};
persistLocation(location);
resolve(location);
},
() => resolve(null),
{
enableHighAccuracy: true,
timeout: options.timeout || 10000,
maximumAge: options.maximumAge || 300000,
}
);
});
const ensureEventsLocation = async () => {
if (locationPreference === 'disabled') {
clearStoredLocation();
renderSlider(null);
return null;
}
const stored = applyStoredLocationToForm();
if (stored && locationPreference !== 'enabled') {
renderSlider(stored);
return stored;
}
const location = await requestLocation();
renderSlider(location || stored);
return location;
};
// Quick search with geocoding
const geocode = async (query) => {
const url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query);
@@ -178,11 +380,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
navigator.geolocation.getCurrentPosition(
(pos) => {
if (el.quickLat && el.quickLng) {
el.quickLat.value = pos.coords.latitude.toFixed(6);
el.quickLng.value = pos.coords.longitude.toFixed(6);
if (el.quickLoc) el.quickLoc.value = 'Mein Standort';
}
persistLocation({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
label: 'Mein Standort',
});
renderSlider(getStoredLocation());
},
() => alert('Standort konnte nicht ermittelt werden.')
);
@@ -215,6 +418,30 @@ document.addEventListener('DOMContentLoaded', () => {
});
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
renderSlider();
document.querySelectorAll('[data-nav-events]').forEach(link => {
link.addEventListener('click', async () => {
if (window.location.pathname === '/' && window.location.hash === '#events') {
await ensureEventsLocation();
}
});
});
const initialLocation = locationPreference === 'disabled' ? null : applyStoredLocationToForm();
if (locationPreference === 'disabled') {
clearStoredLocation();
}
renderSlider(initialLocation);
renderThreadSlider();
if (locationPreference === 'enabled' && window.location.pathname === '/') {
ensureEventsLocation();
} else if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();
}
window.addEventListener('hashchange', () => {
if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();
}
});
});

View File

@@ -29,6 +29,7 @@ CREATE TABLE user_profiles (
region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt',
contact_phone VARBINARY(512) NULL,
contact_email VARBINARY(512) NULL,
profession VARBINARY(512) NULL,

View File

@@ -158,6 +158,7 @@ final class AccountPages
$community = $pdo ? new Community($pdo, $communityConfig) : null;
$communityAccess = $pdo ? new CommunityAccess($pdo, $communityConfig) : null;
$communityMigration = $pdo ? new CommunityMigration($pdo) : null;
$profileSettings = $pdo ? new ProfileSettings($pdo) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
@@ -168,7 +169,11 @@ final class AccountPages
$languages = implode(', ', array_map('trim', $languages));
}
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id');
$locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
if ($profileSettings) {
$profileSettings->ensureSchema();
}
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, location_tracking_preference=:locationPref, updated_at=NOW() WHERE user_id=:id');
$stmt?->execute([
'name' => trim((string)$_POST['display_name']),
'fname' => trim((string)$_POST['first_name']),
@@ -179,6 +184,7 @@ final class AccountPages
'langs' => trim((string)$languages),
'about' => trim((string)$_POST['about']),
'phone' => $phoneEnc,
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
'id' => $userId,
]);
$info = 'Profil gespeichert.';
@@ -321,8 +327,12 @@ final class AccountPages
'about' => '',
'email' => '',
'contact_phone' => '',
'location_tracking_preference' => 'prompt',
];
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
if ($profileSettings) {
$profileSettings->ensureSchema();
}
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
$stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) {

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App;
final class ProfileSettings
{
private bool $schemaEnsured = false;
private array $columnCache = [];
public function __construct(private \PDO $pdo)
{
}
public function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) {
$this->pdo->exec(
"ALTER TABLE user_profiles
ADD COLUMN location_tracking_preference ENUM('disabled','prompt','enabled')
NOT NULL DEFAULT 'prompt'
AFTER lng"
);
$this->columnCache['user_profiles.location_tracking_preference'] = true;
}
$this->schemaEnsured = true;
}
public function getLocationTrackingPreference(int $userId): string
{
if ($userId <= 0) {
return 'prompt';
}
$this->ensureSchema();
$stmt = $this->pdo->prepare('SELECT location_tracking_preference FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => $userId]);
$value = (string)($stmt->fetchColumn() ?: 'prompt');
return in_array($value, ['disabled', 'prompt', 'enabled'], true) ? $value : 'prompt';
}
public function updateLocationTrackingPreference(int $userId, string $preference): void
{
if ($userId <= 0) {
return;
}
$this->ensureSchema();
$preference = in_array($preference, ['disabled', 'prompt', 'enabled'], true) ? $preference : 'prompt';
$stmt = $this->pdo->prepare('UPDATE user_profiles SET location_tracking_preference = :pref, updated_at = NOW() WHERE user_id = :id');
$stmt->execute([
'pref' => $preference,
'id' => $userId,
]);
}
private function hasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
if (array_key_exists($cacheKey, $this->columnCache)) {
return $this->columnCache[$cacheKey];
}
$stmt = $this->pdo->prepare("
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :tableName
AND COLUMN_NAME = :columnName
");
$stmt->execute([
'tableName' => $table,
'columnName' => $column,
]);
return $this->columnCache[$cacheKey] = ((int)$stmt->fetchColumn() > 0);
}
}