true, 'pdo' => $pdo, 'error' => null]; } catch (Throwable $e) { $storage = ['ready' => false, 'pdo' => null, 'error' => $e->getMessage()]; } return $storage; } function urban_hikes_ensure_schema(PDO $pdo): void { $sql = <<exec($sql); } function urban_hikes_seed_demo_routes(PDO $pdo): void { $count = (int)$pdo->query('SELECT COUNT(*) FROM urban_routes')->fetchColumn(); if ($count > 0) { return; } $routes = [ [ 'city' => 'New York', 'title' => 'Riverside to Harlem Heights Loop', 'summary' => 'A long riverside climb with park viewpoints, brownstone blocks, and a high-energy finish near Hamilton Heights.', 'distance_km' => 9.4, 'duration_hours' => 2.8, 'difficulty' => 'Moderate', 'neighborhood' => 'Upper West Side & Harlem', 'start_point' => '72nd Street and Riverside Drive', 'highlights' => "Hudson River Greenway\nGrant\'s Tomb\nCity College steps\nHamilton Heights coffee stops", 'map_url' => 'https://maps.google.com/?q=Riverside+Drive+New+York', 'best_for' => 'First-time visitors who want skyline views with real neighborhood texture', ], [ 'city' => 'London', 'title' => 'Regent\'s Canal to Primrose Hill Walk', 'summary' => 'An easy canal walk with quiet waterside stretches, markets, and one of London\'s best panoramic hilltops.', 'distance_km' => 7.1, 'duration_hours' => 2.2, 'difficulty' => 'Easy', 'neighborhood' => 'Little Venice to Camden', 'start_point' => 'Paddington Basin', 'highlights' => "Houseboat-lined canal\nCamden Lock food scene\nPrimrose Hill lookout\nRegent\'s Park edge paths", 'map_url' => 'https://maps.google.com/?q=Paddington+Basin+London', 'best_for' => 'Travelers with half a day and a camera', ], [ 'city' => 'Tokyo', 'title' => 'Meiji Shrine to Shibuya Ridge Circuit', 'summary' => 'A city hike that shifts from tranquil forested shrine paths into dense retail streets and rooftop views.', 'distance_km' => 8.0, 'duration_hours' => 2.5, 'difficulty' => 'Moderate', 'neighborhood' => 'Harajuku & Shibuya', 'start_point' => 'Meiji Jingumae Station', 'highlights' => "Meiji Shrine grove\nYoyogi Park edges\nCat Street detour\nShibuya Sky finale", 'map_url' => 'https://maps.google.com/?q=Meiji+Jingumae+Station+Tokyo', 'best_for' => 'Visitors who want a calm-to-busy Tokyo contrast', ], [ 'city' => 'Paris', 'title' => 'Canal Saint-Martin to Montmartre Stairs', 'summary' => 'A compact urban hike with waterside strolling, café stops, and a rewarding climb to Sacré-Cœur.', 'distance_km' => 6.3, 'duration_hours' => 2.0, 'difficulty' => 'Moderate', 'neighborhood' => '10th to 18th arrondissement', 'start_point' => 'Jardin Villemin', 'highlights' => "Canal Saint-Martin bridges\nHidden stair streets\nMoulin Rouge edge\nSacré-Cœur terrace", 'map_url' => 'https://maps.google.com/?q=Jardin+Villemin+Paris', 'best_for' => 'Travelers who want a scenic climb without leaving the center', ], [ 'city' => 'Chicago', 'title' => 'Lakefront to Lincoln Park Connector', 'summary' => 'A flat, breezy route along the lake with skyline photo points, gardens, and museum-side paths.', 'distance_km' => 10.2, 'duration_hours' => 3.0, 'difficulty' => 'Easy', 'neighborhood' => 'Streeterville to Lincoln Park', 'start_point' => 'Navy Pier entrance', 'highlights' => "Lakefront Trail\nOlive Park skyline angle\nNorth Avenue Beach\nLincoln Park Conservatory", 'map_url' => 'https://maps.google.com/?q=Navy+Pier+Chicago', 'best_for' => 'Active mornings with lots of photo stops', ], [ 'city' => 'Mexico City', 'title' => 'Chapultepec to Roma Norte Urban Trek', 'summary' => 'A green-to-cultural route crossing the park, museums, boulevards, and food-heavy side streets.', 'distance_km' => 8.8, 'duration_hours' => 2.7, 'difficulty' => 'Challenging', 'neighborhood' => 'Chapultepec & Roma Norte', 'start_point' => 'Estela de Luz', 'highlights' => "Chapultepec forest paths\nMuseum corridor\nAvenida Amsterdam loop\nRoma Norte cafés", 'map_url' => 'https://maps.google.com/?q=Estela+de+Luz+Mexico+City', 'best_for' => 'Walkers who want culture, food, and a full afternoon route', ], ]; $stmt = $pdo->prepare('INSERT INTO urban_routes (city, title, slug, summary, distance_km, duration_hours, difficulty, neighborhood, start_point, highlights, map_url, best_for) VALUES (:city, :title, :slug, :summary, :distance_km, :duration_hours, :difficulty, :neighborhood, :start_point, :highlights, :map_url, :best_for)'); foreach ($routes as $route) { $stmt->execute([ ':city' => $route['city'], ':title' => $route['title'], ':slug' => urban_hikes_slugify($route['title']), ':summary' => $route['summary'], ':distance_km' => $route['distance_km'], ':duration_hours' => $route['duration_hours'], ':difficulty' => $route['difficulty'], ':neighborhood' => $route['neighborhood'], ':start_point' => $route['start_point'], ':highlights' => $route['highlights'], ':map_url' => $route['map_url'], ':best_for' => $route['best_for'], ]); } } function urban_hikes_slugify(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?: 'route'; return trim($value, '-') ?: 'route'; } function urban_hikes_unique_slug(PDO $pdo, string $title): string { $base = urban_hikes_slugify($title); $slug = $base; $suffix = 1; $check = $pdo->prepare('SELECT COUNT(*) FROM urban_routes WHERE slug = :slug'); while (true) { $check->execute([':slug' => $slug]); if ((int)$check->fetchColumn() === 0) { return $slug; } $suffix++; $slug = $base . '-' . $suffix; } } function urban_hikes_fetch_filters(): array { $allowedDifficulties = ['Easy', 'Moderate', 'Challenging']; $difficulty = trim((string)($_GET['difficulty'] ?? '')); return [ 'q' => trim((string)($_GET['q'] ?? '')), 'city' => trim((string)($_GET['city'] ?? '')), 'difficulty' => in_array($difficulty, $allowedDifficulties, true) ? $difficulty : '', 'max_distance' => isset($_GET['max_distance']) && is_numeric((string)$_GET['max_distance']) ? (float)$_GET['max_distance'] : 0.0, ]; } function urban_hikes_search(array $filters): array { $storage = urban_hikes_storage(); if (!$storage['ready']) { return []; } $sql = 'SELECT * FROM urban_routes WHERE 1=1'; $params = []; if ($filters['q'] !== '') { $sql .= ' AND (title LIKE :query OR summary LIKE :query OR highlights LIKE :query OR neighborhood LIKE :query)'; $params[':query'] = '%' . $filters['q'] . '%'; } if ($filters['city'] !== '') { $sql .= ' AND city = :city'; $params[':city'] = $filters['city']; } if ($filters['difficulty'] !== '') { $sql .= ' AND difficulty = :difficulty'; $params[':difficulty'] = $filters['difficulty']; } if ($filters['max_distance'] > 0) { $sql .= ' AND distance_km <= :max_distance'; $params[':max_distance'] = $filters['max_distance']; } $sql .= ' ORDER BY city ASC, distance_km ASC, title ASC'; $stmt = $storage['pdo']->prepare($sql); foreach ($params as $key => $value) { $type = is_numeric($value) ? PDO::PARAM_STR : PDO::PARAM_STR; $stmt->bindValue($key, $value, $type); } $stmt->execute(); return $stmt->fetchAll(); } function urban_hikes_cities(): array { $storage = urban_hikes_storage(); if (!$storage['ready']) { return []; } $stmt = $storage['pdo']->query('SELECT city, COUNT(*) AS route_count FROM urban_routes GROUP BY city ORDER BY route_count DESC, city ASC'); return $stmt->fetchAll(); } function urban_hikes_stats(): array { $storage = urban_hikes_storage(); if (!$storage['ready']) { return ['routes' => 0, 'cities' => 0, 'avg_distance' => 0]; } $stmt = $storage['pdo']->query('SELECT COUNT(*) AS routes, COUNT(DISTINCT city) AS cities, AVG(distance_km) AS avg_distance FROM urban_routes'); $stats = $stmt->fetch() ?: ['routes' => 0, 'cities' => 0, 'avg_distance' => 0]; $stats['avg_distance'] = round((float)$stats['avg_distance'], 1); return $stats; } function urban_hikes_find(int $id): ?array { $storage = urban_hikes_storage(); if (!$storage['ready']) { return null; } $stmt = $storage['pdo']->prepare('SELECT * FROM urban_routes WHERE id = :id LIMIT 1'); $stmt->execute([':id' => $id]); $route = $stmt->fetch(); return $route ?: null; } function urban_hikes_find_by_city(string $city): array { $storage = urban_hikes_storage(); if (!$storage['ready'] || $city === '') { return []; } $stmt = $storage['pdo']->prepare('SELECT * FROM urban_routes WHERE city = :city ORDER BY distance_km ASC, title ASC'); $stmt->execute([':city' => $city]); return $stmt->fetchAll(); } function urban_hikes_city_stats(string $city): ?array { $storage = urban_hikes_storage(); if (!$storage['ready'] || $city === '') { return null; } $sql = 'SELECT city, COUNT(*) AS route_count, AVG(distance_km) AS avg_distance, AVG(duration_hours) AS avg_duration, COUNT(DISTINCT neighborhood) AS area_count, SUM(CASE WHEN difficulty = "Easy" THEN 1 ELSE 0 END) AS easy_count, SUM(CASE WHEN difficulty = "Moderate" THEN 1 ELSE 0 END) AS moderate_count, SUM(CASE WHEN difficulty = "Challenging" THEN 1 ELSE 0 END) AS challenging_count FROM urban_routes WHERE city = :city GROUP BY city LIMIT 1'; $stmt = $storage['pdo']->prepare($sql); $stmt->execute([':city' => $city]); $stats = $stmt->fetch(); return $stats ?: null; } function urban_hikes_related(string $city, int $excludeId, int $limit = 3): array { $storage = urban_hikes_storage(); if (!$storage['ready']) { return []; } $stmt = $storage['pdo']->prepare('SELECT * FROM urban_routes WHERE city = :city AND id <> :exclude_id ORDER BY distance_km ASC, title ASC LIMIT ' . (int)$limit); $stmt->execute([':city' => $city, ':exclude_id' => $excludeId]); return $stmt->fetchAll(); } function urban_hikes_latest(int $limit = 8): array { $storage = urban_hikes_storage(); if (!$storage['ready']) { return []; } $stmt = $storage['pdo']->query('SELECT * FROM urban_routes ORDER BY created_at DESC, id DESC LIMIT ' . (int)$limit); return $stmt->fetchAll(); } function urban_hikes_validation(array $input): array { $difficulties = ['Easy', 'Moderate', 'Challenging']; $clean = [ 'city' => trim((string)($input['city'] ?? '')), 'title' => trim((string)($input['title'] ?? '')), 'summary' => trim((string)($input['summary'] ?? '')), 'distance_km' => trim((string)($input['distance_km'] ?? '')), 'duration_hours' => trim((string)($input['duration_hours'] ?? '')), 'difficulty' => trim((string)($input['difficulty'] ?? '')), 'neighborhood' => trim((string)($input['neighborhood'] ?? '')), 'start_point' => trim((string)($input['start_point'] ?? '')), 'highlights' => trim((string)($input['highlights'] ?? '')), 'map_url' => trim((string)($input['map_url'] ?? '')), 'best_for' => trim((string)($input['best_for'] ?? '')), ]; $errors = []; if ($clean['city'] === '') { $errors['city'] = 'City is required.'; } if ($clean['title'] === '') { $errors['title'] = 'Route title is required.'; } if ($clean['summary'] === '' || strlen($clean['summary']) < 20) { $errors['summary'] = 'Add a short summary of at least 20 characters.'; } if ($clean['distance_km'] === '' || !is_numeric($clean['distance_km']) || (float)$clean['distance_km'] <= 0) { $errors['distance_km'] = 'Distance must be a positive number.'; } if ($clean['duration_hours'] === '' || !is_numeric($clean['duration_hours']) || (float)$clean['duration_hours'] <= 0) { $errors['duration_hours'] = 'Duration must be a positive number.'; } if (!in_array($clean['difficulty'], $difficulties, true)) { $errors['difficulty'] = 'Choose a valid difficulty.'; } if ($clean['neighborhood'] === '') { $errors['neighborhood'] = 'Neighborhood or area is required.'; } if ($clean['start_point'] === '') { $errors['start_point'] = 'Start point is required.'; } if ($clean['highlights'] === '') { $errors['highlights'] = 'Add at least one highlight.'; } if ($clean['map_url'] === '' || filter_var($clean['map_url'], FILTER_VALIDATE_URL) === false) { $errors['map_url'] = 'Map link must be a valid URL.'; } if ($clean['best_for'] === '') { $errors['best_for'] = 'Describe who this route is best for.'; } return [$clean, $errors]; } function urban_hikes_create(array $input): array { $storage = urban_hikes_storage(); [$clean, $errors] = urban_hikes_validation($input); if (!$storage['ready']) { return ['success' => false, 'errors' => ['storage' => 'Database is currently unavailable.'], 'input' => $clean]; } if ($errors) { return ['success' => false, 'errors' => $errors, 'input' => $clean]; } $slug = urban_hikes_unique_slug($storage['pdo'], $clean['title']); $stmt = $storage['pdo']->prepare('INSERT INTO urban_routes (city, title, slug, summary, distance_km, duration_hours, difficulty, neighborhood, start_point, highlights, map_url, best_for) VALUES (:city, :title, :slug, :summary, :distance_km, :duration_hours, :difficulty, :neighborhood, :start_point, :highlights, :map_url, :best_for)'); $stmt->execute([ ':city' => $clean['city'], ':title' => $clean['title'], ':slug' => $slug, ':summary' => $clean['summary'], ':distance_km' => round((float)$clean['distance_km'], 1), ':duration_hours' => round((float)$clean['duration_hours'], 1), ':difficulty' => $clean['difficulty'], ':neighborhood' => $clean['neighborhood'], ':start_point' => $clean['start_point'], ':highlights' => $clean['highlights'], ':map_url' => $clean['map_url'], ':best_for' => $clean['best_for'], ]); return ['success' => true, 'id' => (int)$storage['pdo']->lastInsertId()]; } function urban_hikes_update(int $id, array $input): array { $storage = urban_hikes_storage(); [$clean, $errors] = urban_hikes_validation($input); if (!$storage['ready']) { return ['success' => false, 'errors' => ['storage' => 'Database is currently unavailable.'], 'input' => $clean]; } $existing = urban_hikes_find($id); if (!$existing) { return ['success' => false, 'errors' => ['storage' => 'That route could not be found.'], 'input' => $clean]; } if ($errors) { return ['success' => false, 'errors' => $errors, 'input' => $clean]; } $slug = $existing['title'] === $clean['title'] ? (string)$existing['slug'] : urban_hikes_unique_slug($storage['pdo'], $clean['title']); $stmt = $storage['pdo']->prepare('UPDATE urban_routes SET city = :city, title = :title, slug = :slug, summary = :summary, distance_km = :distance_km, duration_hours = :duration_hours, difficulty = :difficulty, neighborhood = :neighborhood, start_point = :start_point, highlights = :highlights, map_url = :map_url, best_for = :best_for WHERE id = :id LIMIT 1'); $stmt->execute([ ':city' => $clean['city'], ':title' => $clean['title'], ':slug' => $slug, ':summary' => $clean['summary'], ':distance_km' => round((float)$clean['distance_km'], 1), ':duration_hours' => round((float)$clean['duration_hours'], 1), ':difficulty' => $clean['difficulty'], ':neighborhood' => $clean['neighborhood'], ':start_point' => $clean['start_point'], ':highlights' => $clean['highlights'], ':map_url' => $clean['map_url'], ':best_for' => $clean['best_for'], ':id' => $id, ]); return ['success' => true, 'id' => $id]; } function urban_hikes_highlight_items(string $value): array { $items = preg_split('/\r\n|\r|\n/', $value) ?: []; $items = array_values(array_filter(array_map('trim', $items))); if ($items) { return $items; } $items = preg_split('/,/', $value) ?: []; return array_values(array_filter(array_map('trim', $items))); } function urban_hikes_set_flash(string $type, string $message): void { $_SESSION['urban_hikes_flash'] = ['type' => $type, 'message' => $message]; } function urban_hikes_get_flash(): ?array { if (!isset($_SESSION['urban_hikes_flash'])) { return null; } $flash = $_SESSION['urban_hikes_flash']; unset($_SESSION['urban_hikes_flash']); return is_array($flash) ? $flash : null; } function urban_hikes_render_head(string $title, string $description, string $robots = 'index, follow'): void { $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; $metaDescription = $description !== '' ? $description : $projectDescription; ?> <?= htmlspecialchars($title) ?>