exec($sql); } } function seed_offers(): void { $pdo = db(); $existing = (int) $pdo->query('SELECT COUNT(*) FROM offers')->fetchColumn(); if ($existing >= 10) { return; } $offers = [ [ 'title' => 'Lilium Bistro Marina', 'slug' => 'lilium-bistro-marina', 'category' => 'restaurant', 'excerpt' => 'Cena junto a la marina con cocina canaria contemporanea.', 'description' => 'Mesa frente al paseo maritimo, carta corta y servicio agil para antes o despues del trayecto. Ideal para cerrar la tarde con cocina local refinada.', 'location_label' => 'Marina Lanzarote', 'price_from' => 32.00, 'duration_minutes' => 90, 'image_url' => 'https://images.pexels.com/photos/262978/pexels-photo-262978.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 1, 'priority_score' => 97, ], [ 'title' => 'Tegala Terraza Reserva', 'slug' => 'tegala-terraza-reserva', 'category' => 'restaurant', 'excerpt' => 'Restaurante elegante para una cena tranquila con vistas.', 'description' => 'Propuesta de autor con menu flexible, buena para parejas y grupos pequenos. La reserva puede confirmarse sin prepago en esta demo.', 'location_label' => 'Puerto del Carmen', 'price_from' => 45.00, 'duration_minutes' => 110, 'image_url' => 'https://images.pexels.com/photos/67468/pexels-photo-67468.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 1, 'priority_score' => 90, ], [ 'title' => 'Brisa Market Tapas', 'slug' => 'brisa-market-tapas', 'category' => 'restaurant', 'excerpt' => 'Tapas locales, vino volcanico y servicio rapido.', 'description' => 'Parada perfecta si buscas algo informal pero bien curado. Carta pensada para viajeros con poco tiempo y ganas de probar producto local.', 'location_label' => 'Arrecife Centro', 'price_from' => 18.00, 'duration_minutes' => 60, 'image_url' => 'https://images.pexels.com/photos/1640774/pexels-photo-1640774.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 78, ], [ 'title' => 'Costa Norte Breakfast Club', 'slug' => 'costa-norte-breakfast-club', 'category' => 'restaurant', 'excerpt' => 'Desayunos premium cerca de hoteles y apartamentos.', 'description' => 'Cafe de especialidad, bowls y opciones ligeras para arrancar el dia. Muy util para llegadas tempranas o salidas al aeropuerto.', 'location_label' => 'Costa Teguise', 'price_from' => 14.00, 'duration_minutes' => 45, 'image_url' => 'https://images.pexels.com/photos/376464/pexels-photo-376464.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 74, ], [ 'title' => 'Sunset Catamaran Escape', 'slug' => 'sunset-catamaran-escape', 'category' => 'experience', 'excerpt' => 'Salida al atardecer con bebida incluida desde la marina.', 'description' => 'Experiencia premium de dos horas pensada para turistas que ya tienen el traslado resuelto y quieren aprovechar el hueco perfecto del dia.', 'location_label' => 'Puerto Calero Marina', 'price_from' => 69.00, 'duration_minutes' => 120, 'image_url' => 'https://images.pexels.com/photos/1430676/pexels-photo-1430676.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 1, 'priority_score' => 96, ], [ 'title' => 'Volcanic Winery Tasting', 'slug' => 'volcanic-winery-tasting', 'category' => 'experience', 'excerpt' => 'Cata guiada en La Geria con opcion de traslado.', 'description' => 'Recorrido breve y elegante por bodega local con degustacion seleccionada. Encaja muy bien para parejas o pequenos grupos.', 'location_label' => 'La Geria', 'price_from' => 36.00, 'duration_minutes' => 75, 'image_url' => 'https://images.pexels.com/photos/1407858/pexels-photo-1407858.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 1, 'priority_score' => 88, ], [ 'title' => 'Cesar Manrique Studio Visit', 'slug' => 'cesar-manrique-studio-visit', 'category' => 'experience', 'excerpt' => 'Visita cultural compacta para encajar entre trayectos.', 'description' => 'Entrada y recorrido sugerido por una de las visitas iconicas de la isla. Pensado para viajeros que buscan algo curado y facil de reservar.', 'location_label' => 'Tahiche', 'price_from' => 22.00, 'duration_minutes' => 70, 'image_url' => 'https://images.pexels.com/photos/161154/stained-glass-spiral-circle-pattern-161154.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 80, ], [ 'title' => 'North Coast Scenic Route', 'slug' => 'north-coast-scenic-route', 'category' => 'experience', 'excerpt' => 'Ruta panoramica de media tarde con paradas cortas.', 'description' => 'Un circuito ligero con vistas potentes, paradas fotografiables y ritmo tranquilo. Muy recomendable si el destino final esta en el norte.', 'location_label' => 'Haria y Mirador del Rio', 'price_from' => 54.00, 'duration_minutes' => 150, 'image_url' => 'https://images.pexels.com/photos/417074/pexels-photo-417074.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 77, ], [ 'title' => 'Surf Starter Session', 'slug' => 'surf-starter-session', 'category' => 'activity', 'excerpt' => 'Clase corta para principiantes cerca de la playa.', 'description' => 'Sesion guiada con equipo incluido y bloque horario facil de reservar. Encaja especialmente bien si te mueves hacia la costa.', 'location_label' => 'Famara', 'price_from' => 40.00, 'duration_minutes' => 90, 'image_url' => 'https://images.pexels.com/photos/416676/pexels-photo-416676.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 83, ], [ 'title' => 'Timanfaya Express Walk', 'slug' => 'timanfaya-express-walk', 'category' => 'activity', 'excerpt' => 'Ventana corta para ver paisaje volcanico sin dedicar medio dia.', 'description' => 'Actividad orientada a visitantes con agenda compacta. Incluye una propuesta muy clara de horario y punto de encuentro.', 'location_label' => 'Timanfaya', 'price_from' => 28.00, 'duration_minutes' => 80, 'image_url' => 'https://images.pexels.com/photos/1553945/pexels-photo-1553945.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 81, ], [ 'title' => 'Beach E-bike Circuit', 'slug' => 'beach-e-bike-circuit', 'category' => 'activity', 'excerpt' => 'Recorrido suave por costa y paseo con bicicleta electrica.', 'description' => 'Una propuesta ligera, con baja friccion para turistas que quieren hacer algo util entre check-in, playa y cena.', 'location_label' => 'Playa Blanca', 'price_from' => 29.00, 'duration_minutes' => 100, 'image_url' => 'https://images.pexels.com/photos/100582/pexels-photo-100582.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 76, ], [ 'title' => 'Airport Fast Track Assist', 'slug' => 'airport-fast-track-assist', 'category' => 'service', 'excerpt' => 'Soporte rapido para salidas y llegadas con equipaje.', 'description' => 'Servicio pensado para familias o viajeros premium que quieren resolver rapido colas, maletas y coordinacion con su traslado.', 'location_label' => 'Aeropuerto ACE', 'price_from' => 25.00, 'duration_minutes' => 30, 'image_url' => 'https://images.pexels.com/photos/1008155/pexels-photo-1008155.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 1, 'priority_score' => 89, ], [ 'title' => 'Island Essentials Welcome Pack', 'slug' => 'island-essentials-welcome-pack', 'category' => 'product', 'excerpt' => 'Pack listo con agua, snacks y esenciales de llegada.', 'description' => 'Producto sencillo para anadir valor inmediato a la llegada. Facil de entender y facil de confirmar dentro del mismo flujo.', 'location_label' => 'Entrega en hotel o recepcion', 'price_from' => 19.00, 'duration_minutes' => 15, 'image_url' => 'https://images.pexels.com/photos/2101187/pexels-photo-2101187.jpeg?auto=compress&cs=tinysrgb&w=1200', 'is_featured' => 0, 'priority_score' => 72, ], ]; $sql = 'INSERT INTO offers (uuid, title, slug, category, excerpt, description, location_label, price_from, duration_minutes, image_url, status, is_featured, priority_score, available_now) VALUES (:uuid, :title, :slug, :category, :excerpt, :description, :location_label, :price_from, :duration_minutes, :image_url, :status, :is_featured, :priority_score, :available_now)'; $stmt = $pdo->prepare($sql); foreach ($offers as $offer) { $existsStmt = $pdo->prepare('SELECT id FROM offers WHERE slug = :slug LIMIT 1'); $existsStmt->execute(['slug' => $offer['slug']]); if ($existsStmt->fetch()) { continue; } $stmt->execute([ 'uuid' => uuid_v4(), 'title' => $offer['title'], 'slug' => $offer['slug'], 'category' => $offer['category'], 'excerpt' => $offer['excerpt'], 'description' => $offer['description'], 'location_label' => $offer['location_label'], 'price_from' => $offer['price_from'], 'duration_minutes' => $offer['duration_minutes'], 'image_url' => $offer['image_url'], 'status' => 'published', 'is_featured' => $offer['is_featured'], 'priority_score' => $offer['priority_score'], 'available_now' => 1, ]); } } function h(mixed $value): string { return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } function uuid_v4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); } function project_name(): string { $name = trim((string) ($_SERVER['PROJECT_NAME'] ?? 'TaxiLanz')); return $name !== '' ? $name : 'TaxiLanz'; } function redirect_to(string $path): never { header('Location: ' . $path, true, 303); exit; } function set_flash(string $tone, string $title, string $message): void { $_SESSION['flash'] = [ 'tone' => $tone, 'title' => $title, 'message' => $message, ]; } function pull_flash(): ?array { if (!isset($_SESSION['flash'])) { return null; } $flash = $_SESSION['flash']; unset($_SESSION['flash']); return is_array($flash) ? $flash : null; } function remember_form(string $key, array $payload): void { $_SESSION['forms'][$key] = $payload; } function old_form(string $key): array { $data = $_SESSION['forms'][$key] ?? []; unset($_SESSION['forms'][$key]); return is_array($data) ? $data : []; } function locale_code(): string { $lang = (string) ($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'es-ES'); return substr($lang, 0, 5); } function infer_context_zone(string $pickup, string $destination): string { $haystack = strtolower($pickup . ' ' . $destination); $map = [ 'aeropuerto' => 'airport', 'airport' => 'airport', 'marina' => 'marina', 'puerto' => 'harbor', 'harbour' => 'harbor', 'hotel' => 'hotel', 'resort' => 'hotel', 'playa' => 'beach', 'beach' => 'beach', 'famara' => 'north-coast', 'timanfaya' => 'volcanic-park', 'geria' => 'wine-route', ]; foreach ($map as $needle => $zone) { if (str_contains($haystack, $needle)) { return $zone; } } return 'general'; } function create_ride(array $payload): array { $pdo = db(); $uuid = uuid_v4(); $eta = random_int(4, 11); $scheduledFor = $payload['scheduled_for'] !== '' ? $payload['scheduled_for'] : null; $zone = infer_context_zone((string) $payload['pickup_label'], (string) $payload['destination_label']); $stmt = $pdo->prepare( 'INSERT INTO rides (uuid, pickup_label, destination_label, scheduled_for, status, eta_minutes, source_channel, context_zone, locale) VALUES (:uuid, :pickup_label, :destination_label, :scheduled_for, :status, :eta_minutes, :source_channel, :context_zone, :locale)' ); $stmt->execute([ 'uuid' => $uuid, 'pickup_label' => $payload['pickup_label'], 'destination_label' => $payload['destination_label'], 'scheduled_for' => $scheduledFor, 'status' => 'confirmed', 'eta_minutes' => $eta, 'source_channel' => 'web', 'context_zone' => $zone, 'locale' => locale_code(), ]); $ride = find_ride_by_uuid($uuid); if (!$ride) { throw new RuntimeException('Ride was created but could not be loaded.'); } log_event('request_created', [ 'ride_id' => (int) $ride['id'], 'meta' => [ 'pickup' => $ride['pickup_label'], 'destination' => $ride['destination_label'], 'scheduled_for' => $ride['scheduled_for'], ], ]); return $ride; } function create_booking(array $payload): array { $pdo = db(); $uuid = uuid_v4(); $offer = find_offer_by_id((int) $payload['offer_id']); if (!$offer) { throw new RuntimeException('Offer not found for booking.'); } $rideId = null; if (!empty($payload['ride_uuid'])) { $ride = find_ride_by_uuid((string) $payload['ride_uuid']); $rideId = $ride ? (int) $ride['id'] : null; } $partySize = max(1, min(12, (int) $payload['party_size'])); $baseAmount = isset($offer['price_from']) ? (float) $offer['price_from'] : 0.0; $amount = in_array($offer['category'], ['restaurant', 'activity'], true) ? $baseAmount * $partySize : $baseAmount; $commission = $amount > 0 ? round($amount * 0.12, 2) : null; $bookingFor = $payload['booking_for'] !== '' ? $payload['booking_for'] : null; log_event('booking_started', [ 'ride_id' => $rideId, 'offer_id' => (int) $offer['id'], 'meta' => [ 'offer_slug' => $offer['slug'], 'party_size' => $partySize, ], ]); $stmt = $pdo->prepare( 'INSERT INTO bookings (uuid, ride_id, offer_id, customer_name, customer_email, customer_phone, party_size, booking_for, status, amount, commission_amount, notes) VALUES (:uuid, :ride_id, :offer_id, :customer_name, :customer_email, :customer_phone, :party_size, :booking_for, :status, :amount, :commission_amount, :notes)' ); $stmt->execute([ 'uuid' => $uuid, 'ride_id' => $rideId, 'offer_id' => (int) $offer['id'], 'customer_name' => $payload['customer_name'] ?: null, 'customer_email' => $payload['customer_email'] ?: null, 'customer_phone' => $payload['customer_phone'] ?: null, 'party_size' => $partySize, 'booking_for' => $bookingFor, 'status' => 'confirmed', 'amount' => $amount > 0 ? $amount : null, 'commission_amount' => $commission, 'notes' => $payload['notes'] ?: null, ]); $booking = find_booking_by_uuid($uuid); if (!$booking) { throw new RuntimeException('Booking was created but could not be loaded.'); } log_event('booking_completed', [ 'ride_id' => $rideId, 'offer_id' => (int) $offer['id'], 'booking_id' => (int) $booking['id'], 'meta' => [ 'booking_uuid' => $booking['uuid'], 'amount' => $booking['amount'], 'commission' => $booking['commission_amount'], ], ]); return $booking; } function log_event(string $eventType, array $payload = []): void { $pdo = db(); $stmt = $pdo->prepare( 'INSERT INTO events (event_type, ride_id, offer_id, booking_id, session_id, meta) VALUES (:event_type, :ride_id, :offer_id, :booking_id, :session_id, :meta)' ); $meta = $payload['meta'] ?? null; $stmt->execute([ 'event_type' => $eventType, 'ride_id' => $payload['ride_id'] ?? null, 'offer_id' => $payload['offer_id'] ?? null, 'booking_id' => $payload['booking_id'] ?? null, 'session_id' => session_id() ?: null, 'meta' => $meta ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null, ]); } function featured_offers(int $limit = 6): array { $stmt = db()->prepare('SELECT * FROM offers WHERE status = :status ORDER BY is_featured DESC, priority_score DESC, created_at DESC LIMIT ' . max(1, $limit)); $stmt->execute(['status' => 'published']); return $stmt->fetchAll() ?: []; } function find_offer_by_slug(string $slug): ?array { $stmt = db()->prepare('SELECT * FROM offers WHERE slug = :slug LIMIT 1'); $stmt->execute(['slug' => $slug]); $offer = $stmt->fetch(); return $offer ?: null; } function find_offer_by_id(int $id): ?array { $stmt = db()->prepare('SELECT * FROM offers WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $id]); $offer = $stmt->fetch(); return $offer ?: null; } function find_ride_by_uuid(string $uuid): ?array { $stmt = db()->prepare('SELECT * FROM rides WHERE uuid = :uuid LIMIT 1'); $stmt->execute(['uuid' => $uuid]); $ride = $stmt->fetch(); return $ride ?: null; } function find_booking_by_uuid(string $uuid): ?array { $stmt = db()->prepare( 'SELECT b.*, o.title AS offer_title, o.slug AS offer_slug, o.category AS offer_category, r.uuid AS ride_uuid FROM bookings b JOIN offers o ON o.id = b.offer_id LEFT JOIN rides r ON r.id = b.ride_id WHERE b.uuid = :uuid LIMIT 1' ); $stmt->execute(['uuid' => $uuid]); $booking = $stmt->fetch(); return $booking ?: null; } function recent_rides(int $limit = 8): array { $stmt = db()->prepare('SELECT * FROM rides ORDER BY created_at DESC LIMIT ' . max(1, $limit)); $stmt->execute(); return $stmt->fetchAll() ?: []; } function recent_bookings(int $limit = 8): array { $stmt = db()->prepare( 'SELECT b.*, o.title AS offer_title, o.slug AS offer_slug FROM bookings b JOIN offers o ON o.id = b.offer_id ORDER BY b.created_at DESC LIMIT ' . max(1, $limit) ); $stmt->execute(); return $stmt->fetchAll() ?: []; } function event_summary(): array { $rows = db()->query('SELECT event_type, COUNT(*) AS total FROM events GROUP BY event_type')->fetchAll() ?: []; $summary = []; foreach ($rows as $row) { $summary[$row['event_type']] = (int) $row['total']; } return $summary; } function recommendations_for_ride(array $ride, int $limit = 3): array { $offers = featured_offers(12); $routeContext = strtolower(trim(($ride['pickup_label'] ?? '') . ' ' . ($ride['destination_label'] ?? '') . ' ' . ($ride['context_zone'] ?? ''))); $scored = []; foreach ($offers as $offer) { $score = (int) ($offer['priority_score'] ?? 0); $location = strtolower((string) ($offer['location_label'] ?? '')); $category = (string) $offer['category']; if ((int) $offer['is_featured'] === 1) { $score += 16; } if ((int) $offer['available_now'] === 1) { $score += 8; } $rules = [ 'airport' => ['service' => 24, 'product' => 15, 'restaurant' => 6], 'hotel' => ['restaurant' => 18, 'service' => 10, 'experience' => 8], 'beach' => ['activity' => 22, 'experience' => 14], 'marina' => ['experience' => 20, 'restaurant' => 12], 'harbor' => ['experience' => 18, 'restaurant' => 10], 'wine-route' => ['experience' => 18, 'restaurant' => 8], 'volcanic-park' => ['activity' => 16, 'experience' => 12], 'general' => ['experience' => 6, 'restaurant' => 5], ]; $zone = (string) ($ride['context_zone'] ?? 'general'); if (isset($rules[$zone][$category])) { $score += $rules[$zone][$category]; } if ($location !== '' && str_contains($routeContext, strtolower($location))) { $score += 10; } if (str_contains($routeContext, 'playa') || str_contains($routeContext, 'beach')) { if ($category === 'activity') { $score += 6; } } $offer['recommendation_score'] = $score; $scored[] = $offer; } usort($scored, static function (array $a, array $b): int { return ($b['recommendation_score'] <=> $a['recommendation_score']) ?: (($b['priority_score'] ?? 0) <=> ($a['priority_score'] ?? 0)); }); return array_slice($scored, 0, $limit); } function log_recommendation_views(array $ride, array $offers): void { $sessionKey = 'seen_recommendations_' . $ride['uuid']; $seen = $_SESSION[$sessionKey] ?? []; if (!is_array($seen)) { $seen = []; } foreach ($offers as $offer) { $offerId = (int) $offer['id']; if (in_array($offerId, $seen, true)) { continue; } log_event('recommendation_viewed', [ 'ride_id' => (int) $ride['id'], 'offer_id' => $offerId, 'meta' => [ 'offer_slug' => $offer['slug'], 'score' => $offer['recommendation_score'] ?? null, ], ]); $seen[] = $offerId; } $_SESSION[$sessionKey] = $seen; } function related_offers(array $offer, int $limit = 3): array { $stmt = db()->prepare('SELECT * FROM offers WHERE status = :status AND slug <> :slug ORDER BY (category = :category) DESC, is_featured DESC, priority_score DESC LIMIT ' . max(1, $limit)); $stmt->execute([ 'status' => 'published', 'slug' => $offer['slug'], 'category' => $offer['category'], ]); return $stmt->fetchAll() ?: []; } function format_datetime(?string $value): string { if (!$value) { return 'Ahora mismo'; } try { return (new DateTime($value))->format('d/m/Y · H:i'); } catch (Throwable $e) { return $value; } } function format_currency(mixed $value): string { if ($value === null || $value === '') { return 'A consultar'; } return number_format((float) $value, 2, ',', '.') . ' €'; } function category_label(string $value): string { return match ($value) { 'restaurant' => 'Restaurante', 'experience' => 'Experiencia', 'activity' => 'Actividad', 'product' => 'Producto', 'service' => 'Servicio', default => ucfirst($value), }; } function status_label(string $value): string { return match ($value) { 'pending' => 'Pendiente', 'confirmed' => 'Confirmado', 'completed' => 'Completado', 'cancelled' => 'Cancelado', 'started' => 'Iniciado', default => ucfirst($value), }; } function excerpt_for_offer(array $offer): string { $excerpt = trim((string) ($offer['excerpt'] ?? '')); if ($excerpt !== '') { return $excerpt; } $description = trim((string) ($offer['description'] ?? '')); if ($description === '') { return 'Seleccion cuidadosamente preparada para completar el trayecto.'; } return substr($description, 0, 120) . (strlen($description) > 120 ? '…' : ''); } function page_url(string $path): string { return $path; } function render_page_start(string $pageTitle, string $pageDescription, string $activeNav = 'home'): void { $projectName = project_name(); $metaDescription = $pageDescription !== '' ? $pageDescription : ((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? '')); if ($metaDescription === '') { $metaDescription = 'TaxiLanz confirma taxis y convierte la espera en recomendaciones y reservas utiles.'; } $projectImageUrl = (string) ($_SERVER['PROJECT_IMAGE_URL'] ?? ''); $pageTitleFull = trim($pageTitle . ' | ' . $projectName); $cssVersion = is_file(__DIR__ . '/../assets/css/custom.css') ? (string) filemtime(__DIR__ . '/../assets/css/custom.css') : (string) time(); $jsVersion = is_file(__DIR__ . '/../assets/js/main.js') ? (string) filemtime(__DIR__ . '/../assets/js/main.js') : (string) time(); $flash = pull_flash(); echo ''; echo ''; echo '
'; echo ' '; echo ' '; echo '