Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
1128e4f4ca Auto commit: 2026-04-06T06:47:41.441Z 2026-04-06 06:47:41 +00:00
11 changed files with 2112 additions and 498 deletions

View File

@ -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;
}
.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));
}
}

View File

@ -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);
}
});
});

84
bookings/index.php Normal file
View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
app_boot();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
redirect_to('/');
}
$payload = [
'offer_id' => (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);
}

94
bookings/success.php Normal file
View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
app_boot();
$bookingUuid = trim((string) ($_GET['booking'] ?? ''));
$booking = $bookingUuid !== '' ? find_booking_by_uuid($bookingUuid) : null;
if (!$booking) {
http_response_code(404);
render_page_start('Reserva no encontrada', 'No se encontro la reserva solicitada.', 'home');
?>
<section class="card-shell p-5 text-center">
<div class="empty-state">
<strong>No encontramos esa reserva.</strong>
<p class="text-secondary mb-4">Vuelve al inicio para crear una nueva solicitud o revisar otras ofertas.</p>
<a class="btn btn-app-primary" href="/">Volver al inicio</a>
</div>
</section>
<?php
render_page_end();
exit;
}
$offer = find_offer_by_slug((string) $booking['offer_slug']);
$nextOffers = $offer ? related_offers($offer, 2) : [];
render_page_start(
'Reserva confirmada',
'La reserva fue registrada correctamente y ya puedes continuar explorando la siguiente recomendacion.',
'home'
);
?>
<section class="row g-4 mb-4">
<div class="col-12 col-lg-7">
<div class="card-shell p-4 p-lg-5 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Estado final</div>
<h1 class="section-title mb-1">Reserva confirmada</h1>
<p class="text-secondary mb-0">Referencia <?= h($booking['uuid']) ?></p>
</div>
<span class="status-badge success"><?= h(status_label($booking['status'])) ?></span>
</div>
<div class="summary-block mb-4">
<div class="summary-line"><span>Offer</span><strong><?= h($booking['offer_title']) ?></strong></div>
<div class="summary-line"><span>Cliente</span><strong><?= h($booking['customer_name'] ?? 'Sin nombre') ?></strong></div>
<div class="summary-line"><span>Contacto</span><strong><?= h($booking['customer_email'] ?: ($booking['customer_phone'] ?: 'Pendiente')) ?></strong></div>
<div class="summary-line"><span>Personas</span><strong><?= h((string) $booking['party_size']) ?></strong></div>
<div class="summary-line"><span>Momento</span><strong><?= h(format_datetime($booking['booking_for'])) ?></strong></div>
<div class="summary-line"><span>Importe demo</span><strong><?= h(format_currency($booking['amount'])) ?></strong></div>
</div>
<div class="notice-panel mb-4">
<strong>Que se guardo</strong>
<p class="mb-0 text-secondary">La reserva, el importe estimado, la comision de demo y los eventos de inicio y finalizacion ya quedaron registrados en la base de datos.</p>
</div>
<div class="d-flex flex-column flex-sm-row gap-2">
<a class="btn btn-app-primary" href="/operations/">Ver operaciones</a>
<a class="btn btn-app-secondary" href="<?= !empty($booking['ride_uuid']) ? '/rides/confirmed.php?ride=' . urlencode((string) $booking['ride_uuid']) : '/' ?>">Volver al flujo</a>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card-shell p-4 p-lg-5 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Siguiente micro-CTA</div>
<h2 class="section-title mb-1">Continua explorando</h2>
<p class="text-secondary mb-0">Un siguiente paso pequeño mantiene el flujo vivo y deja claro el potencial comercial del producto.</p>
</div>
</div>
<?php if ($nextOffers): ?>
<div class="stack-list">
<?php foreach ($nextOffers as $item): ?>
<a class="list-row" href="/offers/?slug=<?= urlencode($item['slug']) ?><?= !empty($booking['ride_uuid']) ? '&ride=' . urlencode((string) $booking['ride_uuid']) : '' ?>">
<div>
<strong><?= h($item['title']) ?></strong>
<div class="text-secondary small"><?= h(category_label($item['category'])) ?> · <?= h(format_currency($item['price_from'])) ?></div>
</div>
<span class="text-link">Abrir</span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="empty-state compact">
<strong>No hay mas sugerencias ahora.</strong>
<p class="mb-0 text-secondary">Puedes volver al inicio para lanzar otro trayecto y generar nuevas recomendaciones.</p>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php render_page_end(); ?>

24
healthz/index.php Normal file
View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
header('Content-Type: application/json; charset=utf-8');
try {
app_boot();
db()->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);
}

843
includes/taxilanz.php Normal file
View File

@ -0,0 +1,843 @@
<?php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
function app_boot(): void
{
static $booted = false;
if ($booted) {
return;
}
ensure_schema();
seed_offers();
$booted = true;
}
function ensure_schema(): void
{
$pdo = db();
$statements = [
<<<'SQL'
CREATE TABLE IF NOT EXISTS rides (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
pickup_label VARCHAR(255) NOT NULL,
pickup_lat DECIMAL(10, 7) NULL,
pickup_lng DECIMAL(10, 7) NULL,
destination_label VARCHAR(255) NOT NULL,
destination_lat DECIMAL(10, 7) NULL,
destination_lng DECIMAL(10, 7) NULL,
scheduled_for DATETIME NULL,
status ENUM('pending', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'confirmed',
eta_minutes INT NULL,
source_channel ENUM('app', 'hotel', 'reception', 'web') NOT NULL DEFAULT 'web',
context_zone VARCHAR(120) NULL,
locale VARCHAR(20) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_rides_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS offers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
slug VARCHAR(190) NOT NULL UNIQUE,
category ENUM('restaurant', 'experience', 'activity', 'product', 'service') NOT NULL,
excerpt VARCHAR(255) NULL,
description TEXT NULL,
location_label VARCHAR(255) NULL,
lat DECIMAL(10, 7) NULL,
lng DECIMAL(10, 7) NULL,
price_from DECIMAL(10, 2) NULL,
duration_minutes INT NULL,
image_url VARCHAR(500) NULL,
status ENUM('draft', 'published', 'paused') NOT NULL DEFAULT 'published',
is_featured TINYINT(1) NOT NULL DEFAULT 0,
priority_score INT NOT NULL DEFAULT 0,
available_now TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_offers_status_priority (status, priority_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS bookings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
ride_id INT UNSIGNED NULL,
offer_id INT UNSIGNED NOT NULL,
customer_name VARCHAR(255) NULL,
customer_email VARCHAR(255) NULL,
customer_phone VARCHAR(80) NULL,
party_size INT NOT NULL DEFAULT 1,
booking_for DATETIME NULL,
status ENUM('started', 'confirmed', 'cancelled') NOT NULL DEFAULT 'confirmed',
amount DECIMAL(10, 2) NULL,
commission_amount DECIMAL(10, 2) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_bookings_offer_created (offer_id, created_at),
INDEX idx_bookings_ride_created (ride_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS events (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_type ENUM('request_created', 'recommendation_viewed', 'recommendation_clicked', 'booking_started', 'booking_completed') NOT NULL,
ride_id INT UNSIGNED NULL,
offer_id INT UNSIGNED NULL,
booking_id INT UNSIGNED NULL,
session_id VARCHAR(120) NULL,
meta JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_events_type_created (event_type, created_at),
INDEX idx_events_ride_created (ride_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL,
];
foreach ($statements as $sql) {
$pdo->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 '<!doctype html>';
echo '<html lang="es">';
echo '<head>';
echo ' <meta charset="utf-8">';
echo ' <meta name="viewport" content="width=device-width, initial-scale=1">';
echo ' <title>' . h($pageTitleFull) . '</title>';
echo ' <meta name="description" content="' . h($metaDescription) . '">';
echo ' <meta property="og:title" content="' . h($pageTitleFull) . '">';
echo ' <meta property="og:description" content="' . h($metaDescription) . '">';
echo ' <meta property="twitter:title" content="' . h($pageTitleFull) . '">';
echo ' <meta property="twitter:description" content="' . h($metaDescription) . '">';
if ($projectImageUrl !== '') {
echo ' <meta property="og:image" content="' . h($projectImageUrl) . '">';
echo ' <meta property="twitter:image" content="' . h($projectImageUrl) . '">';
}
echo ' <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">';
echo ' <link rel="stylesheet" href="/assets/css/custom.css?v=' . h($cssVersion) . '">';
echo '</head>';
echo '<body>';
echo '<header class="app-header">';
echo ' <div class="container-fluid app-shell px-3 px-lg-4">';
echo ' <nav class="navbar navbar-expand-lg navbar-light py-3 px-0">';
echo ' <a class="navbar-brand brand-mark" href="/">TaxiLanz</a>';
echo ' <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Mostrar navegacion">';
echo ' <span class="navbar-toggler-icon"></span>';
echo ' </button>';
echo ' <div class="collapse navbar-collapse" id="mainNav">';
echo ' <ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">';
echo ' <li class="nav-item"><a class="nav-link ' . ($activeNav === 'home' ? 'active' : '') . '" href="/">Solicitar taxi</a></li>';
echo ' <li class="nav-item"><a class="nav-link ' . ($activeNav === 'operations' ? 'active' : '') . '" href="/operations/">Operaciones</a></li>';
echo ' <li class="nav-item"><a class="nav-link ' . ($activeNav === 'health' ? 'active' : '') . '" href="/healthz/">Health</a></li>';
echo ' </ul>';
echo ' </div>';
echo ' </nav>';
echo ' </div>';
echo '</header>';
echo '<main class="pb-5">';
echo ' <div class="container-fluid app-shell px-3 px-lg-4">';
if ($flash) {
echo '<div class="toast-container position-fixed top-0 end-0 p-3">';
echo ' <div class="toast app-toast border-0 show" role="status" aria-live="polite" aria-atomic="true">';
echo ' <div class="toast-header">';
echo ' <strong class="me-auto">' . h($flash['title']) . '</strong>';
echo ' <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Cerrar"></button>';
echo ' </div>';
echo ' <div class="toast-body">' . h($flash['message']) . '</div>';
echo ' </div>';
echo '</div>';
}
echo ' <div class="app-statusbar d-flex flex-wrap align-items-center justify-content-between gap-2 mb-4">';
echo ' <div class="status-chip">Conserjeria de movilidad para Lanzarote</div>';
echo ' <div class="small text-secondary">UX demo · bootstrap 5 · flujo real</div>';
echo ' </div>';
}
function render_page_end(): void
{
$jsVersion = is_file(__DIR__ . '/../assets/js/main.js') ? (string) filemtime(__DIR__ . '/../assets/js/main.js') : (string) time();
echo ' </div>';
echo '</main>';
echo '<footer class="app-footer border-top">';
echo ' <div class="container-fluid app-shell px-3 px-lg-4 py-3 d-flex flex-column flex-lg-row justify-content-between gap-2 text-secondary small">';
echo ' <span>TaxiLanz MVP · solicitud, recomendacion y reserva en una sola experiencia.</span>';
echo ' <span>Preparado para ampliar con tracking fino y dashboard operativo.</span>';
echo ' </div>';
echo '</footer>';
echo '<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>';
echo '<script src="/assets/js/main.js?v=' . h($jsVersion) . '"></script>';
echo '</body>';
echo '</html>';
}

313
index.php
View File

@ -1,150 +1,173 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/includes/taxilanz.php';
app_boot();
$featuredOffers = featured_offers(6);
$rides = recent_rides(5);
$bookings = recent_bookings(5);
$rideSummary = event_summary();
$oldRide = old_form('ride_request');
render_page_start(
'Solicitar taxi y activar recomendaciones',
'Pide un taxi en Lanzarote, recibe ETA inmediata y desbloquea reservas recomendadas mientras esperas.',
'home'
);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<section class="row g-4 align-items-start mb-4">
<div class="col-12 col-xl-7">
<div class="hero-shell p-4 p-lg-5 mb-4">
<div class="row g-4 align-items-center">
<div class="col-12 col-lg-8">
<div class="eyebrow mb-3">Taxi confirmado + monetizacion contextual</div>
<h1 class="display-title mb-3">Hoy en Lanzarote, pide tu taxi y aprovecha la espera.</h1>
<p class="lead text-secondary mb-4">TaxiLanz conecta la solicitud, la confirmacion y las mejores recomendaciones de la zona en un flujo limpio, rapido y listo para demo.</p>
<div class="d-flex flex-wrap gap-2">
<span class="metric-pill">Confirmacion inmediata</span>
<span class="metric-pill">Top 3 ofertas relevantes</span>
<span class="metric-pill">Reserva sin pago</span>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="metric-grid">
<div class="metric-card">
<span class="metric-label">Solicitudes</span>
<strong><?= count($rides) ?></strong>
<small>registradas en esta demo</small>
</div>
<div class="metric-card">
<span class="metric-label">Reservas</span>
<strong><?= count($bookings) ?></strong>
<small>cerradas desde recomendaciones</small>
</div>
<div class="metric-card">
<span class="metric-label">Eventos</span>
<strong><?= array_sum($rideSummary) ?></strong>
<small>tracking basico activo</small>
</div>
</div>
</div>
</div>
</div>
<div class="card-shell p-4 p-lg-5" id="request-taxi">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Flujo principal</div>
<h2 class="section-title mb-1">Solicitar taxi</h2>
<p class="text-secondary mb-0">Introduce origen, destino y una hora opcional. Confirmaremos un ETA de demo y activaremos recomendaciones afines.</p>
</div>
<span class="status-badge">Paso 1</span>
</div>
<form action="/rides/" method="post" class="row g-3" novalidate>
<div class="col-12 col-md-6">
<label class="form-label" for="pickup_label">Origen</label>
<input class="form-control form-control-app" id="pickup_label" name="pickup_label" type="text" placeholder="Hotel, aeropuerto o punto de recogida" value="<?= h($oldRide['pickup_label'] ?? '') ?>" required data-route-source>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="destination_label">Destino</label>
<input class="form-control form-control-app" id="destination_label" name="destination_label" type="text" placeholder="Marina, playa, restaurante o atraccion" value="<?= h($oldRide['destination_label'] ?? '') ?>" required data-route-destination>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="scheduled_for">Hora opcional</label>
<input class="form-control form-control-app" id="scheduled_for" name="scheduled_for" type="datetime-local" value="<?= h($oldRide['scheduled_for'] ?? '') ?>" data-min-now>
</div>
<div class="col-12 col-md-6">
<div class="route-preview h-100">
<span class="route-preview-label">Vista previa</span>
<strong data-route-preview>Origen Destino</strong>
<small class="text-secondary">Tu solicitud se marcara como confirmada y se usara para decidir las recomendaciones del bloque siguiente.</small>
</div>
</div>
<div class="col-12 d-flex flex-column flex-sm-row gap-2 pt-2">
<button class="btn btn-app-primary" type="submit">Pedir taxi</button>
<a class="btn btn-app-secondary" href="/operations/">Ver actividad reciente</a>
</div>
</form>
</div>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</body>
</html>
<div class="col-12 col-xl-5">
<div class="card-shell p-4 mb-4">
<div class="section-head mb-3">
<div>
<div class="eyebrow">Cómo funciona</div>
<h2 class="section-title mb-1">Thin slice operativo</h2>
</div>
<span class="status-badge">MVP</span>
</div>
<ol class="workflow-list mb-0 ps-3">
<li>Se crea una solicitud de taxi con origen, destino y hora opcional.</li>
<li>Se confirma el trayecto con ETA de demo y se registra el evento inicial.</li>
<li>Se recomiendan 3 offers publicadas segun contexto y prioridad.</li>
<li>El usuario entra en una oferta, completa la reserva y ve la confirmacion.</li>
</ol>
</div>
<div class="card-shell p-4">
<div class="section-head mb-3">
<div>
<div class="eyebrow">Actividad reciente</div>
<h2 class="section-title mb-1">Ultimos movimientos</h2>
</div>
<a class="text-link" href="/operations/">Abrir operaciones</a>
</div>
<div class="stack-list">
<?php if (!$rides): ?>
<div class="empty-state compact">
<strong>No hay solicitudes aun.</strong>
<p class="mb-0 text-secondary">Crea la primera para ver la confirmacion y las recomendaciones activadas.</p>
</div>
<?php else: ?>
<?php foreach ($rides as $ride): ?>
<a class="list-row" href="/rides/confirmed.php?ride=<?= urlencode($ride['uuid']) ?>">
<div>
<strong><?= h($ride['pickup_label']) ?> → <?= h($ride['destination_label']) ?></strong>
<div class="text-secondary small">ETA <?= h((string) $ride['eta_minutes']) ?> min · <?= h(format_datetime($ride['created_at'])) ?></div>
</div>
<span class="status-badge subtle"><?= h(status_label($ride['status'])) ?></span>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</section>
<section class="card-shell p-4 p-lg-5 mb-4">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Mientras esperas</div>
<h2 class="section-title mb-1">Offers destacadas para empezar la demo</h2>
<p class="text-secondary mb-0">Estas experiencias y servicios se siembran automaticamente y sirven de base para las recomendaciones de cada ride.</p>
</div>
<span class="status-badge">12 seeds</span>
</div>
<div class="row g-3">
<?php foreach ($featuredOffers as $offer): ?>
<div class="col-12 col-md-6 col-xl-4">
<article class="offer-card h-100">
<img src="<?= h($offer['image_url']) ?>" class="offer-image" alt="<?= h($offer['title']) ?>" loading="lazy" width="640" height="420">
<div class="offer-body">
<div class="offer-meta">
<span class="chip"><?= h(category_label($offer['category'])) ?></span>
<span class="text-secondary small"><?= h($offer['location_label'] ?? 'Lanzarote') ?></span>
</div>
<h3 class="offer-title"><?= h($offer['title']) ?></h3>
<p class="offer-text"><?= h(excerpt_for_offer($offer)) ?></p>
<div class="offer-footer">
<div>
<strong><?= h(format_currency($offer['price_from'])) ?></strong>
<small class="text-secondary d-block"><?= h((string) $offer['duration_minutes']) ?> min aprox.</small>
</div>
<a class="btn btn-app-secondary btn-sm" href="/offers/?slug=<?= urlencode($offer['slug']) ?>">Ver detalle</a>
</div>
</div>
</article>
</div>
<?php endforeach; ?>
</div>
</section>
<?php render_page_end(); ?>

157
offers/index.php Normal file
View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
app_boot();
$slug = trim((string) ($_GET['slug'] ?? ''));
$offer = $slug !== '' ? find_offer_by_slug($slug) : null;
$rideUuid = trim((string) ($_GET['ride'] ?? ''));
$ride = $rideUuid !== '' ? find_ride_by_uuid($rideUuid) : null;
if ($offer && $ride) {
$clickedKey = 'clicked_offer_' . $ride['uuid'] . '_' . $offer['id'];
if (empty($_SESSION[$clickedKey])) {
log_event('recommendation_clicked', [
'ride_id' => (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');
?>
<section class="card-shell p-5 text-center">
<div class="empty-state">
<strong>La oferta ya no esta disponible.</strong>
<p class="text-secondary mb-4">Vuelve al inicio para explorar las experiencias y servicios vigentes.</p>
<a class="btn btn-app-primary" href="/">Volver al inicio</a>
</div>
</section>
<?php
render_page_end();
exit;
}
$related = related_offers($offer, 3);
$oldBooking = old_form('booking_form');
render_page_start(
$offer['title'],
excerpt_for_offer($offer),
'home'
);
?>
<section class="row g-4 mb-4">
<div class="col-12 col-lg-7">
<article class="card-shell overflow-hidden h-100">
<img src="<?= h($offer['image_url']) ?>" class="offer-hero-image" alt="<?= h($offer['title']) ?>" loading="lazy" width="1280" height="720">
<div class="p-4 p-lg-5">
<div class="offer-meta mb-3">
<span class="chip"><?= h(category_label($offer['category'])) ?></span>
<span class="text-secondary small"><?= h($offer['location_label'] ?? 'Lanzarote') ?></span>
</div>
<h1 class="section-title offer-detail-title mb-3"><?= h($offer['title']) ?></h1>
<p class="lead text-secondary mb-4"><?= h(excerpt_for_offer($offer)) ?></p>
<div class="summary-block mb-4">
<div class="summary-line"><span>Precio desde</span><strong><?= h(format_currency($offer['price_from'])) ?></strong></div>
<div class="summary-line"><span>Duracion</span><strong><?= h((string) $offer['duration_minutes']) ?> min</strong></div>
<div class="summary-line"><span>Estado</span><strong><?= $offer['available_now'] ? 'Disponible ahora' : 'Bajo demanda' ?></strong></div>
</div>
<div class="detail-card mb-0">
<span class="detail-kicker">Resumen</span>
<p class="mb-0 text-secondary"><?= h($offer['description'] ?? 'Seleccion recomendada para la fase de espera del trayecto.') ?></p>
</div>
</div>
</article>
</div>
<div class="col-12 col-lg-5">
<div class="card-shell p-4 p-lg-5 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Reserva rapida</div>
<h2 class="section-title mb-1">Confirmar interest</h2>
<p class="text-secondary mb-0">No hay pago real. Solo capturamos la reserva y generamos confirmacion para la demo.</p>
</div>
<span class="status-badge">Paso 3</span>
</div>
<?php if ($ride): ?>
<div class="notice-panel mb-4">
<strong>Recomendacion vinculada al ride</strong>
<p class="mb-0 text-secondary">Ruta activa: <?= h($ride['pickup_label']) ?> → <?= h($ride['destination_label']) ?> · ETA <?= h((string) $ride['eta_minutes']) ?> min.</p>
</div>
<?php endif; ?>
<form action="/bookings/" method="post" class="row g-3" novalidate>
<input type="hidden" name="offer_id" value="<?= h((string) $offer['id']) ?>">
<input type="hidden" name="ride_uuid" value="<?= h($ride['uuid'] ?? '') ?>">
<div class="col-12">
<label class="form-label" for="customer_name">Nombre</label>
<input class="form-control form-control-app" id="customer_name" name="customer_name" type="text" value="<?= h($oldBooking['customer_name'] ?? '') ?>" required>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="customer_email">Email</label>
<input class="form-control form-control-app" id="customer_email" name="customer_email" type="email" value="<?= h($oldBooking['customer_email'] ?? '') ?>" placeholder="Opcional si dejas telefono">
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="customer_phone">Telefono</label>
<input class="form-control form-control-app" id="customer_phone" name="customer_phone" type="text" value="<?= h($oldBooking['customer_phone'] ?? '') ?>" placeholder="Opcional si dejas email">
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="party_size">Personas</label>
<input class="form-control form-control-app" id="party_size" name="party_size" type="number" min="1" max="12" step="1" value="<?= h((string) ($oldBooking['party_size'] ?? '2')) ?>">
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="booking_for">Fecha/hora</label>
<input class="form-control form-control-app" id="booking_for" name="booking_for" type="datetime-local" value="<?= h($oldBooking['booking_for'] ?? '') ?>" data-min-now>
</div>
<div class="col-12">
<label class="form-label" for="notes">Notas</label>
<textarea class="form-control form-control-app" id="notes" name="notes" rows="4" placeholder="Alergias, punto de encuentro, preferencias...\n"><?= h($oldBooking['notes'] ?? '') ?></textarea>
</div>
<div class="col-12 d-flex flex-column flex-sm-row gap-2 pt-2">
<button class="btn btn-app-primary" type="submit">Reservar ahora</button>
<a class="btn btn-app-secondary" href="<?= $ride ? '/rides/confirmed.php?ride=' . urlencode($ride['uuid']) : '/' ?>">Volver</a>
</div>
</form>
</div>
</div>
</section>
<section class="card-shell p-4 p-lg-5">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Siguiente sugerencia</div>
<h2 class="section-title mb-1">Otras offers que podrian encajar</h2>
</div>
<span class="status-badge subtle">Cross-sell</span>
</div>
<div class="row g-3">
<?php foreach ($related as $item): ?>
<div class="col-12 col-md-6 col-xl-4">
<article class="offer-card h-100">
<img src="<?= h($item['image_url']) ?>" class="offer-image" alt="<?= h($item['title']) ?>" loading="lazy" width="640" height="420">
<div class="offer-body">
<div class="offer-meta">
<span class="chip"><?= h(category_label($item['category'])) ?></span>
<span class="text-secondary small"><?= h($item['location_label'] ?? 'Lanzarote') ?></span>
</div>
<h3 class="offer-title"><?= h($item['title']) ?></h3>
<p class="offer-text"><?= h(excerpt_for_offer($item)) ?></p>
<a class="btn btn-app-secondary w-100" href="/offers/?slug=<?= urlencode($item['slug']) ?><?= $ride ? '&ride=' . urlencode($ride['uuid']) : '' ?>">Explorar</a>
</div>
</article>
</div>
<?php endforeach; ?>
</div>
</section>
<?php render_page_end(); ?>

142
operations/index.php Normal file
View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
app_boot();
$rides = recent_rides(12);
$bookings = recent_bookings(12);
$events = event_summary();
render_page_start(
'Operaciones',
'Panel ligero para revisar solicitudes, reservas y volumen minimo de eventos del MVP.',
'operations'
);
?>
<section class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="metric-card full">
<span class="metric-label">Solicitudes</span>
<strong><?= count($rides) ?></strong>
<small>ultimos registros creados</small>
</div>
</div>
<div class="col-12 col-md-4">
<div class="metric-card full">
<span class="metric-label">Reservas</span>
<strong><?= count($bookings) ?></strong>
<small>confirmadas en esta demo</small>
</div>
</div>
<div class="col-12 col-md-4">
<div class="metric-card full">
<span class="metric-label">Eventos</span>
<strong><?= array_sum($events) ?></strong>
<small>tracking basico</small>
</div>
</div>
</section>
<section class="row g-4">
<div class="col-12 col-xl-6">
<div class="card-shell p-4 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Rides</div>
<h1 class="section-title mb-1">Solicitudes recientes</h1>
</div>
<a class="text-link" href="/">Nueva solicitud</a>
</div>
<?php if (!$rides): ?>
<div class="empty-state compact">
<strong>No hay rides aun.</strong>
<p class="mb-0 text-secondary">La tabla se llena automaticamente despues de crear una solicitud desde el inicio.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-app align-middle mb-0">
<thead>
<tr>
<th>Ruta</th>
<th>ETA</th>
<th>Estado</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
<?php foreach ($rides as $ride): ?>
<tr>
<td>
<strong><?= h($ride['pickup_label']) ?></strong>
<div class="text-secondary small"> <?= h($ride['destination_label']) ?></div>
</td>
<td><?= h((string) $ride['eta_minutes']) ?> min</td>
<td><span class="status-badge subtle"><?= h(status_label($ride['status'])) ?></span></td>
<td><a class="text-link" href="/rides/confirmed.php?ride=<?= urlencode($ride['uuid']) ?>">Abrir</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card-shell p-4 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Bookings</div>
<h2 class="section-title mb-1">Reservas recientes</h2>
</div>
<span class="status-badge">Live demo</span>
</div>
<?php if (!$bookings): ?>
<div class="empty-state compact">
<strong>No hay reservas aun.</strong>
<p class="mb-0 text-secondary">Abre una offer desde la pantalla confirmada y completa el formulario para ver conversiones aqui.</p>
</div>
<?php else: ?>
<div class="table-responsive mb-4">
<table class="table table-app align-middle mb-0">
<thead>
<tr>
<th>Oferta</th>
<th>Cliente</th>
<th>Importe</th>
<th>Detalle</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $booking): ?>
<tr>
<td>
<strong><?= h($booking['offer_title']) ?></strong>
<div class="text-secondary small"><?= h(format_datetime($booking['created_at'])) ?></div>
</td>
<td><?= h($booking['customer_name'] ?: 'Pendiente') ?></td>
<td><?= h(format_currency($booking['amount'])) ?></td>
<td><a class="text-link" href="/bookings/success.php?booking=<?= urlencode($booking['uuid']) ?>">Abrir</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="detail-card">
<span class="detail-kicker">Eventos contabilizados</span>
<div class="event-grid mt-3">
<?php foreach (['request_created','recommendation_viewed','recommendation_clicked','booking_started','booking_completed'] as $eventType): ?>
<div>
<strong><?= $events[$eventType] ?? 0 ?></strong>
<small><?= h(str_replace('_', ' ', $eventType)) ?></small>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</section>
<?php render_page_end(); ?>

149
rides/confirmed.php Normal file
View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
app_boot();
$rideUuid = trim((string) ($_GET['ride'] ?? ''));
$ride = $rideUuid !== '' ? find_ride_by_uuid($rideUuid) : null;
if (!$ride) {
http_response_code(404);
render_page_start('Solicitud no encontrada', 'No se encontro la solicitud de taxi solicitada.', 'home');
?>
<section class="card-shell p-5 text-center">
<div class="empty-state">
<strong>No encontramos esa solicitud.</strong>
<p class="text-secondary mb-4">Puede que la referencia no exista o que la demo se haya reiniciado.</p>
<a class="btn btn-app-primary" href="/">Volver al inicio</a>
</div>
</section>
<?php
render_page_end();
exit;
}
$recommendations = recommendations_for_ride($ride, 3);
log_recommendation_views($ride, $recommendations);
render_page_start(
'Taxi confirmado',
'Tu taxi ya esta confirmado y TaxiLanz te propone experiencias y servicios relevantes mientras esperas.',
'home'
);
?>
<section class="row g-4 mb-4">
<div class="col-12 col-lg-5">
<div class="card-shell p-4 p-lg-5 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Estado del ride</div>
<h1 class="section-title mb-1">Taxi confirmado</h1>
<p class="text-secondary mb-0">Referencia <?= h($ride['uuid']) ?></p>
</div>
<span class="status-badge success">ETA <?= h((string) $ride['eta_minutes']) ?> min</span>
</div>
<div class="summary-block mb-4">
<div class="summary-line">
<span>Origen</span>
<strong><?= h($ride['pickup_label']) ?></strong>
</div>
<div class="summary-line">
<span>Destino</span>
<strong><?= h($ride['destination_label']) ?></strong>
</div>
<div class="summary-line">
<span>Salida</span>
<strong><?= h(format_datetime($ride['scheduled_for'])) ?></strong>
</div>
<div class="summary-line">
<span>Canal</span>
<strong><?= h(strtoupper((string) $ride['source_channel'])) ?></strong>
</div>
</div>
<div class="notice-panel mb-4">
<strong>Mientras esperas</strong>
<p class="mb-0 text-secondary">El motor simple prioriza offers publicadas segun zona, categoria y prioridad operativa. Este es el momento clave de conversión del MVP.</p>
</div>
<div class="d-flex flex-column flex-sm-row gap-2">
<a class="btn btn-app-primary" href="#recommendations">Ver recomendaciones</a>
<a class="btn btn-app-secondary" href="/operations/">Ir a operaciones</a>
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<div class="card-shell p-4 p-lg-5 h-100">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Señales usadas</div>
<h2 class="section-title mb-1">Por que estas ofertas</h2>
<p class="text-secondary mb-0">El primer MVP evita IA y usa reglas simples para mantenerse rapido, visible y facil de depurar.</p>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="detail-card h-100">
<span class="detail-kicker">Contexto</span>
<strong><?= h($ride['context_zone'] ?? 'general') ?></strong>
<p class="text-secondary mb-0">Inferido a partir del origen y el destino de la solicitud.</p>
</div>
</div>
<div class="col-12 col-md-6">
<div class="detail-card h-100">
<span class="detail-kicker">Idioma</span>
<strong><?= h($ride['locale'] ?? 'es-ES') ?></strong>
<p class="text-secondary mb-0">Se conserva como metadato util para personalizacion futura.</p>
</div>
</div>
<div class="col-12 col-md-6">
<div class="detail-card h-100">
<span class="detail-kicker">Prioridad</span>
<strong>Featured + score</strong>
<p class="text-secondary mb-0">Las offers destacadas y disponibles ahora reciben empuje adicional.</p>
</div>
</div>
<div class="col-12 col-md-6">
<div class="detail-card h-100">
<span class="detail-kicker">Tracking</span>
<strong>Eventos activos</strong>
<p class="text-secondary mb-0">Ya registramos solicitud creada y vistas de recomendacion para analisis basico.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="card-shell p-4 p-lg-5" id="recommendations">
<div class="section-head mb-4">
<div>
<div class="eyebrow">Top 3 recomendado</div>
<h2 class="section-title mb-1">Mientras llega tu taxi...</h2>
<p class="text-secondary mb-0">Estas cards enlazan al detalle y al formulario de reserva. Ese paso ya guarda booking y eventos reales.</p>
</div>
<span class="status-badge">Conversión</span>
</div>
<div class="row g-3">
<?php foreach ($recommendations as $offer): ?>
<div class="col-12 col-xl-4">
<article class="offer-card h-100 emphasis">
<img src="<?= h($offer['image_url']) ?>" class="offer-image" alt="<?= h($offer['title']) ?>" loading="lazy" width="640" height="420">
<div class="offer-body">
<div class="offer-meta">
<span class="chip"><?= h(category_label($offer['category'])) ?></span>
<span class="text-secondary small">Score <?= h((string) $offer['recommendation_score']) ?></span>
</div>
<h3 class="offer-title"><?= h($offer['title']) ?></h3>
<p class="offer-text"><?= h(excerpt_for_offer($offer)) ?></p>
<div class="summary-block slim mb-3">
<div class="summary-line"><span>Ubicacion</span><strong><?= h($offer['location_label'] ?? 'Lanzarote') ?></strong></div>
<div class="summary-line"><span>Desde</span><strong><?= h(format_currency($offer['price_from'])) ?></strong></div>
</div>
<a class="btn btn-app-primary w-100" href="/offers/?slug=<?= urlencode($offer['slug']) ?>&ride=<?= urlencode($ride['uuid']) ?>">Ver detalle y reservar</a>
</div>
</article>
</div>
<?php endforeach; ?>
</div>
</section>
<?php render_page_end(); ?>

58
rides/index.php Normal file
View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/taxilanz.php';
app_boot();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
redirect_to('/');
}
$pickup = trim((string) ($_POST['pickup_label'] ?? ''));
$destination = trim((string) ($_POST['destination_label'] ?? ''));
$scheduledFor = trim((string) ($_POST['scheduled_for'] ?? ''));
$errors = [];
if ($pickup === '') {
$errors[] = 'Introduce un origen.';
}
if ($destination === '') {
$errors[] = 'Introduce un destino.';
}
if ($pickup !== '' && $destination !== '' && strtolower($pickup) === strtolower($destination)) {
$errors[] = 'Origen y destino deben ser distintos.';
}
if ($scheduledFor !== '') {
$timestamp = strtotime($scheduledFor);
if ($timestamp === false || $timestamp < time() - 300) {
$errors[] = 'La hora indicada debe ser actual o futura.';
}
}
if ($errors) {
remember_form('ride_request', [
'pickup_label' => $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('/');
}