Auto commit: 2026-04-06T06:47:41.441Z
This commit is contained in:
parent
2c28aa8246
commit
1128e4f4ca
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
84
bookings/index.php
Normal 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
94
bookings/success.php
Normal 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
24
healthz/index.php
Normal 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
843
includes/taxilanz.php
Normal 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
313
index.php
@ -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
157
offers/index.php
Normal 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
142
operations/index.php
Normal 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
149
rides/confirmed.php
Normal 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
58
rides/index.php
Normal 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('/');
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user