From 1128e4f4cac5871fa14d703afbd5913a1652c730 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 6 Apr 2026 06:47:41 +0000 Subject: [PATCH] Auto commit: 2026-04-06T06:47:41.441Z --- assets/css/custom.css | 690 ++++++++++++++++++---------------- assets/js/main.js | 56 ++- bookings/index.php | 84 +++++ bookings/success.php | 94 +++++ healthz/index.php | 24 ++ includes/taxilanz.php | 843 ++++++++++++++++++++++++++++++++++++++++++ index.php | 313 ++++++++-------- offers/index.php | 157 ++++++++ operations/index.php | 142 +++++++ rides/confirmed.php | 149 ++++++++ rides/index.php | 58 +++ 11 files changed, 2112 insertions(+), 498 deletions(-) create mode 100644 bookings/index.php create mode 100644 bookings/success.php create mode 100644 healthz/index.php create mode 100644 includes/taxilanz.php create mode 100644 offers/index.php create mode 100644 operations/index.php create mode 100644 rides/confirmed.php create mode 100644 rides/index.php diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..c1025c5 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,449 @@ +:root { + --app-bg: #f3f4f1; + --app-surface: #ffffff; + --app-surface-muted: #f7f7f5; + --app-border: #d8dbd2; + --app-border-strong: #c7cbc2; + --app-text: #121416; + --app-muted: #5e646b; + --app-accent: #0f62fe; + --app-accent-soft: #e9f0ff; + --app-success: #16763b; + --app-warning: #8a5b00; + --app-shadow: 0 14px 36px rgba(18, 20, 22, 0.04); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --shell-max: 1240px; +} + +html { + scroll-behavior: smooth; +} + body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; margin: 0; - min-height: 100vh; + background: var(--app-bg); + color: var(--app-text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.5; + letter-spacing: -0.01em; } -.main-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; +img { + display: block; + max-width: 100%; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.app-shell { + max-width: var(--shell-max); } -.chat-container { - width: 100%; - max-width: 600px; +.app-header { + position: sticky; + top: 0; + z-index: 1030; + background: rgba(243, 244, 241, 0.94); + backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(199, 203, 194, 0.9); +} + +.app-footer { background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); +.navbar-brand.brand-mark { font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; + letter-spacing: -0.03em; + color: var(--app-text); +} + +.nav-link { + color: var(--app-muted); + font-size: 0.95rem; +} + +.nav-link.active, +.nav-link:hover, +.nav-link:focus { + color: var(--app-text); +} + +.app-statusbar { + padding-top: 1rem; +} + +.status-chip, +.status-badge, +.chip, +.metric-pill { + display: inline-flex; align-items: center; + gap: 0.4rem; + border: 1px solid var(--app-border); + background: var(--app-surface); + border-radius: 999px; + padding: 0.38rem 0.7rem; + font-size: 0.82rem; + color: var(--app-muted); } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1.25rem; +.status-badge { + color: var(--app-text); + background: var(--app-surface-muted); } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; +.status-badge.success { + border-color: rgba(22, 118, 59, 0.22); + color: var(--app-success); + background: rgba(22, 118, 59, 0.08); } -::-webkit-scrollbar-track { +.status-badge.subtle { background: transparent; } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; +.eyebrow, +.metric-label, +.detail-kicker, +.route-preview-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--app-muted); } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); +.hero-shell, +.card-shell, +.metric-card, +.detail-card, +.route-preview, +.notice-panel, +.empty-state, +.offer-card, +.list-row { + background: var(--app-surface); + border: 1px solid var(--app-border); + border-radius: var(--radius-lg); + box-shadow: var(--app-shadow); } -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +.hero-shell { + min-height: 100%; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } +.display-title { + font-size: clamp(2rem, 3vw, 3.2rem); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.04em; + margin: 0; } -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; +.section-title { + font-size: clamp(1.35rem, 2vw, 2rem); + font-weight: 700; + letter-spacing: -0.03em; } -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; +.offer-detail-title { + font-size: clamp(1.6rem, 2.3vw, 2.4rem); } -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); +.lead { + font-size: 1rem; + max-width: 48rem; } -.chat-input-area form { - display: flex; +.metric-grid { + display: grid; gap: 0.75rem; } -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; +.metric-card { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.18rem; } -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); +.metric-card.full { + min-height: 100%; } -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; +.metric-card strong { + font-size: 2rem; + line-height: 1; +} + +.workflow-list { + display: grid; + gap: 0.8rem; + color: var(--app-text); +} + +.form-control-app { + min-height: 48px; + border-radius: var(--radius-md); + border: 1px solid var(--app-border-strong); + background: #fff; + color: var(--app-text); + padding: 0.8rem 0.95rem; +} + +.form-control-app:focus { + border-color: var(--app-accent); + box-shadow: 0 0 0 0.2rem rgba(15, 98, 254, 0.14); +} + +textarea.form-control-app { + min-height: 120px; +} + +.btn { + border-radius: 10px; font-weight: 600; - transition: all 0.3s ease; + min-height: 46px; + padding: 0.72rem 1rem; } -.chat-input-area button:hover { +.btn-app-primary { + background: var(--app-text); + border: 1px solid var(--app-text); + color: #fff; +} + +.btn-app-primary:hover, +.btn-app-primary:focus { background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; + border-color: #000; color: #fff; +} + +.btn-app-secondary { + background: var(--app-surface); + border: 1px solid var(--app-border-strong); + color: var(--app-text); +} + +.btn-app-secondary:hover, +.btn-app-secondary:focus { + background: var(--app-surface-muted); + border-color: var(--app-text); + color: var(--app-text); +} + +.text-link { + color: var(--app-text); text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; } -.table td { - background: #fff; - padding: 1rem; - border: none; +.text-link:hover, +.text-link:focus { + text-decoration: underline; } -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { +.section-head { display: flex; + align-items: flex-start; justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; gap: 1rem; } -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); +.summary-block { + display: grid; + gap: 0.85rem; } -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; +.summary-block.slim { + gap: 0.6rem; } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.summary-line { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.8rem; + border-bottom: 1px solid var(--app-border); } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.summary-line:last-child { + padding-bottom: 0; + border-bottom: 0; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; +.summary-line span { + color: var(--app-muted); } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); +.notice-panel, +.route-preview, +.detail-card, +.empty-state { padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); } -.history-table { +.offer-card { + overflow: hidden; + height: 100%; +} + +.offer-card.emphasis { + border-color: rgba(15, 98, 254, 0.22); +} + +.offer-image, +.offer-hero-image { width: 100%; + object-fit: cover; + background: #eceee8; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.offer-image { + aspect-ratio: 16 / 10; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.offer-hero-image { + aspect-ratio: 16 / 9; } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.offer-body { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; } -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file +.offer-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.offer-meta, +.offer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.offer-text { + margin: 0; + color: var(--app-muted); +} + +.stack-list { + display: grid; + gap: 0.8rem; +} + +.list-row { + padding: 1rem; + text-decoration: none; + color: inherit; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.list-row:hover, +.list-row:focus { + border-color: var(--app-text); +} + +.table-app thead th { + border-bottom-color: var(--app-border-strong); + color: var(--app-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.table-app tbody td { + border-bottom-color: var(--app-border); +} + +.event-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.event-grid strong { + display: block; + font-size: 1.2rem; +} + +.event-grid small { + color: var(--app-muted); + text-transform: capitalize; +} + +.app-toast { + width: min(360px, calc(100vw - 2rem)); + background: rgba(18, 20, 22, 0.96); + color: #fff; + border-radius: 12px; +} + +.app-toast .toast-header { + background: rgba(255, 255, 255, 0.05); + color: #fff; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.app-toast .btn-close { + filter: invert(1); +} + +.empty-state.compact { + padding: 1rem; +} + +.empty-state strong { + display: block; + margin-bottom: 0.35rem; +} + +.form-label { + font-weight: 600; + margin-bottom: 0.45rem; +} + +:focus-visible { + outline: 2px solid rgba(15, 98, 254, 0.45); + outline-offset: 2px; +} + +@media (max-width: 991.98px) { + .section-head, + .summary-line { + flex-direction: column; + align-items: flex-start; + } + + .list-row { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 575.98px) { + .app-statusbar { + padding-top: 0.75rem; + } + + .metric-card strong { + font-size: 1.6rem; + } + + .event-grid { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..be568b8 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,33 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + if (window.bootstrap) { + document.querySelectorAll('.toast').forEach((toastEl) => { + const toast = new bootstrap.Toast(toastEl, { delay: 4200 }); + toast.show(); + }); + } - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + const pickupInput = document.querySelector('[data-route-source]'); + const destinationInput = document.querySelector('[data-route-destination]'); + const preview = document.querySelector('[data-route-preview]'); + + const syncRoutePreview = () => { + if (!preview) return; + const pickup = pickupInput?.value.trim() || 'Origen'; + const destination = destinationInput?.value.trim() || 'Destino'; + preview.textContent = `${pickup} → ${destination}`; }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + [pickupInput, destinationInput].forEach((input) => { + input?.addEventListener('input', syncRoutePreview); + }); + syncRoutePreview(); - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + const now = new Date(); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + const minDateTime = now.toISOString().slice(0, 16); + document.querySelectorAll('[data-min-now]').forEach((input) => { + if (!input.getAttribute('min')) { + input.setAttribute('min', minDateTime); } }); }); diff --git a/bookings/index.php b/bookings/index.php new file mode 100644 index 0000000..6e59d91 --- /dev/null +++ b/bookings/index.php @@ -0,0 +1,84 @@ + (int) ($_POST['offer_id'] ?? 0), + 'ride_uuid' => trim((string) ($_POST['ride_uuid'] ?? '')), + 'customer_name' => trim((string) ($_POST['customer_name'] ?? '')), + 'customer_email' => trim((string) ($_POST['customer_email'] ?? '')), + 'customer_phone' => trim((string) ($_POST['customer_phone'] ?? '')), + 'party_size' => (int) ($_POST['party_size'] ?? 1), + 'booking_for' => trim((string) ($_POST['booking_for'] ?? '')), + 'notes' => trim((string) ($_POST['notes'] ?? '')), +]; + +$offer = $payload['offer_id'] > 0 ? find_offer_by_id($payload['offer_id']) : null; +if (!$offer) { + set_flash('danger', 'Oferta no disponible', 'No hemos podido iniciar la reserva porque la oferta no existe.'); + redirect_to('/'); +} + +$errors = []; +if ($payload['customer_name'] === '') { + $errors[] = 'Introduce tu nombre.'; +} +if ($payload['customer_email'] === '' && $payload['customer_phone'] === '') { + $errors[] = 'Indica email o telefono.'; +} +if ($payload['customer_email'] !== '' && !filter_var($payload['customer_email'], FILTER_VALIDATE_EMAIL)) { + $errors[] = 'El email no es valido.'; +} +if ($payload['party_size'] < 1 || $payload['party_size'] > 12) { + $errors[] = 'El numero de personas debe estar entre 1 y 12.'; +} +if ($payload['booking_for'] !== '') { + $timestamp = strtotime($payload['booking_for']); + if ($timestamp === false || $timestamp < time() - 300) { + $errors[] = 'La fecha de reserva debe ser actual o futura.'; + } +} + +if ($errors) { + remember_form('booking_form', [ + 'customer_name' => $payload['customer_name'], + 'customer_email' => $payload['customer_email'], + 'customer_phone' => $payload['customer_phone'], + 'party_size' => $payload['party_size'], + 'booking_for' => $payload['booking_for'], + 'notes' => $payload['notes'], + ]); + set_flash('danger', 'Reserva incompleta', implode(' ', $errors)); + $backUrl = '/offers/?slug=' . urlencode($offer['slug']); + if ($payload['ride_uuid'] !== '') { + $backUrl .= '&ride=' . urlencode($payload['ride_uuid']); + } + redirect_to($backUrl); +} + +try { + $booking = create_booking($payload); + set_flash('success', 'Reserva confirmada', 'La solicitud de reserva ya quedo registrada y lista para seguimiento.'); + redirect_to('/bookings/success.php?booking=' . urlencode($booking['uuid'])); +} catch (Throwable $e) { + remember_form('booking_form', [ + 'customer_name' => $payload['customer_name'], + 'customer_email' => $payload['customer_email'], + 'customer_phone' => $payload['customer_phone'], + 'party_size' => $payload['party_size'], + 'booking_for' => $payload['booking_for'], + 'notes' => $payload['notes'], + ]); + set_flash('danger', 'No pudimos guardar la reserva', 'La oferta sigue disponible para que vuelvas a intentarlo.'); + $backUrl = '/offers/?slug=' . urlencode($offer['slug']); + if ($payload['ride_uuid'] !== '') { + $backUrl .= '&ride=' . urlencode($payload['ride_uuid']); + } + redirect_to($backUrl); +} diff --git a/bookings/success.php b/bookings/success.php new file mode 100644 index 0000000..97bb7f5 --- /dev/null +++ b/bookings/success.php @@ -0,0 +1,94 @@ + +
+
+ No encontramos esa reserva. +

Vuelve al inicio para crear una nueva solicitud o revisar otras ofertas.

+ Volver al inicio +
+
+ +
+
+
+
+
+
Estado final
+

Reserva confirmada

+

Referencia

+
+ +
+
+
Offer
+
Cliente
+
Contacto
+
Personas
+
Momento
+
Importe demo
+
+
+ Que se guardo +

La reserva, el importe estimado, la comision de demo y los eventos de inicio y finalizacion ya quedaron registrados en la base de datos.

+
+ +
+
+
+
+
+
+
Siguiente micro-CTA
+

Continua explorando

+

Un siguiente paso pequeño mantiene el flujo vivo y deja claro el potencial comercial del producto.

+
+
+ + + +
+ No hay mas sugerencias ahora. +

Puedes volver al inicio para lanzar otro trayecto y generar nuevas recomendaciones.

+
+ +
+
+
+ diff --git a/healthz/index.php b/healthz/index.php new file mode 100644 index 0000000..3f08e1c --- /dev/null +++ b/healthz/index.php @@ -0,0 +1,24 @@ +query('SELECT 1'); + http_response_code(200); + echo json_encode([ + 'status' => 'ok', + 'app' => 'TaxiLanz MVP', + 'time' => gmdate('c'), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'message' => 'Health check failed', + 'time' => gmdate('c'), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} diff --git a/includes/taxilanz.php b/includes/taxilanz.php new file mode 100644 index 0000000..8370ca6 --- /dev/null +++ b/includes/taxilanz.php @@ -0,0 +1,843 @@ +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 ' ' . h($pageTitleFull) . ''; + echo ' '; + echo ' '; + echo ' '; + echo ' '; + echo ' '; + if ($projectImageUrl !== '') { + echo ' '; + echo ' '; + } + echo ' '; + echo ' '; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ' '; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + + if ($flash) { + echo '
'; + echo '
'; + echo '
'; + echo ' ' . h($flash['title']) . ''; + echo ' '; + echo '
'; + echo '
' . h($flash['message']) . '
'; + echo '
'; + echo '
'; + } + + echo '
'; + echo '
Conserjeria de movilidad para Lanzarote
'; + echo '
UX demo · bootstrap 5 · flujo real
'; + echo '
'; +} + +function render_page_end(): void +{ + $jsVersion = is_file(__DIR__ . '/../assets/js/main.js') ? (string) filemtime(__DIR__ . '/../assets/js/main.js') : (string) time(); + + echo '
'; + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; +} diff --git a/index.php b/index.php index 7205f3d..c3e02ee 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,173 @@ - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
+
+
+
+
+
Taxi confirmado + monetizacion contextual
+

Hoy en Lanzarote, pide tu taxi y aprovecha la espera.

+

TaxiLanz conecta la solicitud, la confirmacion y las mejores recomendaciones de la zona en un flujo limpio, rapido y listo para demo.

+
+ Confirmacion inmediata + Top 3 ofertas relevantes + Reserva sin pago +
+
+
+
+
+ Solicitudes + + registradas en esta demo +
+
+ Reservas + + cerradas desde recomendaciones +
+
+ Eventos + + tracking basico activo +
+
+
+
+
+ +
+
+
+
Flujo principal
+

Solicitar taxi

+

Introduce origen, destino y una hora opcional. Confirmaremos un ETA de demo y activaremos recomendaciones afines.

+
+ Paso 1 +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Vista previa + Origen → Destino + Tu solicitud se marcara como confirmada y se usara para decidir las recomendaciones del bloque siguiente. +
+
+
+ + Ver actividad reciente +
+
+
-
- - - + +
+
+
+
+
Cómo funciona
+

Thin slice operativo

+
+ MVP +
+
    +
  1. Se crea una solicitud de taxi con origen, destino y hora opcional.
  2. +
  3. Se confirma el trayecto con ETA de demo y se registra el evento inicial.
  4. +
  5. Se recomiendan 3 offers publicadas segun contexto y prioridad.
  6. +
  7. El usuario entra en una oferta, completa la reserva y ve la confirmacion.
  8. +
+
+ +
+
+
+
Actividad reciente
+

Ultimos movimientos

+
+ Abrir operaciones +
+
+ +
+ No hay solicitudes aun. +

Crea la primera para ver la confirmacion y las recomendaciones activadas.

+
+ + + +
+ +
ETA min ·
+
+ +
+ + +
+
+
+ + +
+
+
+
Mientras esperas
+

Offers destacadas para empezar la demo

+

Estas experiencias y servicios se siembran automaticamente y sirven de base para las recomendaciones de cada ride.

+
+ 12 seeds +
+
+ +
+
+ <?= h($offer['title']) ?> +
+
+ + +
+

+

+ +
+
+
+ +
+
+ diff --git a/offers/index.php b/offers/index.php new file mode 100644 index 0000000..d8a0d40 --- /dev/null +++ b/offers/index.php @@ -0,0 +1,157 @@ + (int) $ride['id'], + 'offer_id' => (int) $offer['id'], + 'meta' => [ + 'offer_slug' => $offer['slug'], + 'source' => 'ride-confirmed', + ], + ]); + $_SESSION[$clickedKey] = true; + } +} + +if (!$offer) { + http_response_code(404); + render_page_start('Oferta no encontrada', 'No se encontro la oferta solicitada.', 'home'); + ?> +
+
+ La oferta ya no esta disponible. +

Vuelve al inicio para explorar las experiencias y servicios vigentes.

+ Volver al inicio +
+
+ +
+
+
+ <?= h($offer['title']) ?> +
+
+ + +
+

+

+
+
Precio desde
+
Duracion min
+
Estado
+
+
+ Resumen +

+
+
+
+
+ +
+
+
+
+
Reserva rapida
+

Confirmar interest

+

No hay pago real. Solo capturamos la reserva y generamos confirmacion para la demo.

+
+ Paso 3 +
+ + +
+ Recomendacion vinculada al ride +

Ruta activa: · ETA min.

+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Volver +
+
+
+
+
+ +
+
+
+
Siguiente sugerencia
+

Otras offers que podrian encajar

+
+ Cross-sell +
+
+ +
+ +
+ +
+
+ diff --git a/operations/index.php b/operations/index.php new file mode 100644 index 0000000..aa3914b --- /dev/null +++ b/operations/index.php @@ -0,0 +1,142 @@ + +
+
+
+ Solicitudes + + ultimos registros creados +
+
+
+
+ Reservas + + confirmadas en esta demo +
+
+
+
+ Eventos + + tracking basico +
+
+
+ +
+
+
+
+
+
Rides
+

Solicitudes recientes

+
+ Nueva solicitud +
+ +
+ No hay rides aun. +

La tabla se llena automaticamente despues de crear una solicitud desde el inicio.

+
+ +
+ + + + + + + + + + + + + + + + + + + +
RutaETAEstadoDetalle
+ +
+
minAbrir
+
+ +
+
+ +
+
+
+
+
Bookings
+

Reservas recientes

+
+ Live demo +
+ +
+ No hay reservas aun. +

Abre una offer desde la pantalla confirmada y completa el formulario para ver conversiones aqui.

+
+ +
+ + + + + + + + + + + + + + + + + + + +
OfertaClienteImporteDetalle
+ +
+
Abrir
+
+ + +
+ Eventos contabilizados +
+ +
+ + +
+ +
+
+
+
+
+ diff --git a/rides/confirmed.php b/rides/confirmed.php new file mode 100644 index 0000000..b5b5c64 --- /dev/null +++ b/rides/confirmed.php @@ -0,0 +1,149 @@ + +
+
+ No encontramos esa solicitud. +

Puede que la referencia no exista o que la demo se haya reiniciado.

+ Volver al inicio +
+
+ +
+
+
+
+
+
Estado del ride
+

Taxi confirmado

+

Referencia

+
+ ETA min +
+
+
+ Origen + +
+
+ Destino + +
+
+ Salida + +
+
+ Canal + +
+
+
+ Mientras esperas +

El motor simple prioriza offers publicadas segun zona, categoria y prioridad operativa. Este es el momento clave de conversión del MVP.

+
+ +
+
+
+
+
+
+
Señales usadas
+

Por que estas ofertas

+

El primer MVP evita IA y usa reglas simples para mantenerse rapido, visible y facil de depurar.

+
+
+
+
+
+ Contexto + +

Inferido a partir del origen y el destino de la solicitud.

+
+
+
+
+ Idioma + +

Se conserva como metadato util para personalizacion futura.

+
+
+
+
+ Prioridad + Featured + score +

Las offers destacadas y disponibles ahora reciben empuje adicional.

+
+
+
+
+ Tracking + Eventos activos +

Ya registramos solicitud creada y vistas de recomendacion para analisis basico.

+
+
+
+
+
+
+ +
+
+
+
Top 3 recomendado
+

Mientras llega tu taxi...

+

Estas cards enlazan al detalle y al formulario de reserva. Ese paso ya guarda booking y eventos reales.

+
+ Conversión +
+
+ +
+ +
+ +
+
+ diff --git a/rides/index.php b/rides/index.php new file mode 100644 index 0000000..bf28f8f --- /dev/null +++ b/rides/index.php @@ -0,0 +1,58 @@ + $pickup, + 'destination_label' => $destination, + 'scheduled_for' => $scheduledFor, + ]); + set_flash('danger', 'Solicitud incompleta', implode(' ', $errors)); + redirect_to('/'); +} + +try { + $ride = create_ride([ + 'pickup_label' => $pickup, + 'destination_label' => $destination, + 'scheduled_for' => $scheduledFor, + ]); + set_flash('success', 'Taxi confirmado', 'Tu trayecto ya tiene ETA y recomendaciones activadas para esta espera.'); + redirect_to('/rides/confirmed.php?ride=' . urlencode($ride['uuid'])); +} catch (Throwable $e) { + remember_form('ride_request', [ + 'pickup_label' => $pickup, + 'destination_label' => $destination, + 'scheduled_for' => $scheduledFor, + ]); + set_flash('danger', 'No pudimos confirmar el taxi', 'Vuelve a intentarlo. El formulario sigue listo para una nueva solicitud.'); + redirect_to('/'); +}