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 = h($booking['uuid']) ?>
+
+
= h(status_label($booking['status'])) ?>
+
+
+
Offer= h($booking['offer_title']) ?>
+
Cliente= h($booking['customer_name'] ?? 'Sin nombre') ?>
+
Contacto= h($booking['customer_email'] ?: ($booking['customer_phone'] ?: 'Pendiente')) ?>
+
Personas= h((string) $booking['party_size']) ?>
+
Momento= h(format_datetime($booking['booking_for'])) ?>
+
Importe demo= h(format_currency($booking['amount'])) ?>
+
+
+
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 ' ';
+
+ if ($flash) {
+ echo '
';
+ 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…
-
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
+
+
+
+
+
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
+ = count($rides) ?>
+ registradas en esta demo
+
+
+ Reservas
+ = count($bookings) ?>
+ cerradas desde recomendaciones
+
+
+ Eventos
+ = array_sum($rideSummary) ?>
+ 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
+
+
+
-
-
-
-
+
+
+
+
+
+
Cómo funciona
+
Thin slice operativo
+
+
MVP
+
+
+ - Se crea una solicitud de taxi con origen, destino y hora opcional.
+ - Se confirma el trayecto con ETA de demo y se registra el evento inicial.
+ - Se recomiendan 3 offers publicadas segun contexto y prioridad.
+ - El usuario entra en una oferta, completa la reserva y ve la confirmacion.
+
+
+
+
+
+
+
+
+
+
+
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(category_label($offer['category'])) ?>
+ = h($offer['location_label'] ?? 'Lanzarote') ?>
+
+
= h($offer['title']) ?>
+
= h(excerpt_for_offer($offer)) ?>
+
+
+
+
+
+
+
+
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(category_label($offer['category'])) ?>
+ = h($offer['location_label'] ?? 'Lanzarote') ?>
+
+
= h($offer['title']) ?>
+
= h(excerpt_for_offer($offer)) ?>
+
+
Precio desde= h(format_currency($offer['price_from'])) ?>
+
Duracion= h((string) $offer['duration_minutes']) ?> min
+
Estado= $offer['available_now'] ? 'Disponible ahora' : 'Bajo demanda' ?>
+
+
+
Resumen
+
= h($offer['description'] ?? 'Seleccion recomendada para la fase de espera del trayecto.') ?>
+
+
+
+
+
+
+
+
+
+
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: = h($ride['pickup_label']) ?> → = h($ride['destination_label']) ?> · ETA = h((string) $ride['eta_minutes']) ?> min.
+
+
+
+
+
+
+
+
+
+
+
+
Siguiente sugerencia
+
Otras offers que podrian encajar
+
+
Cross-sell
+
+
+
+
+
+
+
+
+ = h(category_label($item['category'])) ?>
+ = h($item['location_label'] ?? 'Lanzarote') ?>
+
+
= h($item['title']) ?>
+
= h(excerpt_for_offer($item)) ?>
+
Explorar
+
+
+
+
+
+
+
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
+ = count($rides) ?>
+ ultimos registros creados
+
+
+
+
+ Reservas
+ = count($bookings) ?>
+ confirmadas en esta demo
+
+
+
+
+ Eventos
+ = array_sum($events) ?>
+ tracking basico
+
+
+
+
+
+
+
+
+
+
+
No hay rides aun.
+
La tabla se llena automaticamente despues de crear una solicitud desde el inicio.
+
+
+
+
+
+
+ | Ruta |
+ ETA |
+ Estado |
+ Detalle |
+
+
+
+
+
+ |
+ = h($ride['pickup_label']) ?>
+ → = h($ride['destination_label']) ?>
+ |
+ = h((string) $ride['eta_minutes']) ?> min |
+ = h(status_label($ride['status'])) ?> |
+ Abrir |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Bookings
+
Reservas recientes
+
+
Live demo
+
+
+
+
No hay reservas aun.
+
Abre una offer desde la pantalla confirmada y completa el formulario para ver conversiones aqui.
+
+
+
+
+
+
+ | Oferta |
+ Cliente |
+ Importe |
+ Detalle |
+
+
+
+
+
+ |
+ = h($booking['offer_title']) ?>
+ = h(format_datetime($booking['created_at'])) ?>
+ |
+ = h($booking['customer_name'] ?: 'Pendiente') ?> |
+ = h(format_currency($booking['amount'])) ?> |
+ Abrir |
+
+
+
+
+
+
+
+
+
Eventos contabilizados
+
+
+
+ = $events[$eventType] ?? 0 ?>
+ = h(str_replace('_', ' ', $eventType)) ?>
+
+
+
+
+
+
+
+
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 = h($ride['uuid']) ?>
+
+
ETA = h((string) $ride['eta_minutes']) ?> min
+
+
+
+ Origen
+ = h($ride['pickup_label']) ?>
+
+
+ Destino
+ = h($ride['destination_label']) ?>
+
+
+ Salida
+ = h(format_datetime($ride['scheduled_for'])) ?>
+
+
+ Canal
+ = h(strtoupper((string) $ride['source_channel'])) ?>
+
+
+
+
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
+
= h($ride['context_zone'] ?? 'general') ?>
+
Inferido a partir del origen y el destino de la solicitud.
+
+
+
+
+
Idioma
+
= h($ride['locale'] ?? 'es-ES') ?>
+
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
+
+
+
+
+
+
+
+
+ = h(category_label($offer['category'])) ?>
+ Score = h((string) $offer['recommendation_score']) ?>
+
+
= h($offer['title']) ?>
+
= h(excerpt_for_offer($offer)) ?>
+
+
Ubicacion= h($offer['location_label'] ?? 'Lanzarote') ?>
+
Desde= h(format_currency($offer['price_from'])) ?>
+
+
Ver detalle y reservar
+
+
+
+
+
+
+
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('/');
+}