pdo) return []; $q = trim($query); $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 !== ''); if (!$tokens) { $tokens = [$q]; } $conditions = []; $bind = []; $i = 0; foreach ($tokens as $tok) { $tok = trim($tok); if ($tok === '') continue; $conditions[] = "(title LIKE :t{$i}a OR teaser_public LIKE :t{$i}b OR description LIKE :t{$i}c OR city LIKE :t{$i}d OR region LIKE :t{$i}e OR zip LIKE :t{$i}f)"; $bind[":t{$i}a"] = '%' . $tok . '%'; $bind[":t{$i}b"] = '%' . $tok . '%'; $bind[":t{$i}c"] = '%' . $tok . '%'; $bind[":t{$i}d"] = '%' . $tok . '%'; $bind[":t{$i}e"] = '%' . $tok . '%'; $bind[":t{$i}f"] = '%' . $tok . '%'; $i++; } $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)"; $bind[':glat'] = $lat; $bind[':glng'] = $lng; $bind[':latMin'] = $lat - $latRange; $bind[':latMax'] = $lat + $latRange; $bind[':lngMin'] = $lng - $lngRange; $bind[':lngMax'] = $lng + $lngRange; $bind[':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"; $bind[':lim'] = (int)$limit; $stmt = $this->pdo->prepare($sql); foreach ($bind as $name => $value) { $paramName = $name[0] === ':' ? $name : ':' . $name; $type = is_int($value) ? \PDO::PARAM_INT : \PDO::PARAM_STR; $stmt->bindValue($paramName, $value, $type); } if (defined('APP_ENV') && APP_ENV === 'staging') { $ph = []; if (preg_match_all('/:([a-zA-Z0-9_]+)/', $sql, $m)) { $ph = array_unique($m[0]); } $paramKeys = array_keys($bind); error_log('Search placeholders: ' . json_encode($ph)); error_log('Search params: ' . json_encode($paramKeys)); } try { $stmt->execute(); } catch (\PDOException $e) { error_log('Search SQL: ' . $sql); error_log('Search bind: ' . print_r($bind, true)); throw $e; } return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; } }