548 lines
20 KiB
PHP
548 lines
20 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/db/config.php';
|
||
|
||
date_default_timezone_set('UTC');
|
||
|
||
function h(?string $value): string
|
||
{
|
||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
function app_name(): string
|
||
{
|
||
return trim((string) ($_SERVER['PROJECT_NAME'] ?? 'TaxiLanz')) ?: 'TaxiLanz';
|
||
}
|
||
|
||
function project_description_fallback(): string
|
||
{
|
||
$envDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? ''));
|
||
if ($envDescription !== '') {
|
||
return $envDescription;
|
||
}
|
||
|
||
return 'TaxiLanz is a web MVP for taxi requests, contextual offers, and fast booking attribution for restaurants and experiences.';
|
||
}
|
||
|
||
function asset_url(string $path): string
|
||
{
|
||
$fullPath = __DIR__ . '/' . ltrim($path, '/');
|
||
$version = file_exists($fullPath) ? (string) filemtime($fullPath) : (string) time();
|
||
|
||
return ltrim($path, '/') . '?v=' . urlencode($version);
|
||
}
|
||
|
||
function destination_options(): array
|
||
{
|
||
return [
|
||
'airport' => 'Airport / arrivals',
|
||
'marina' => 'Marina / waterfront',
|
||
'old-town' => 'Old Town / historic center',
|
||
'beach-strip' => 'Beach strip',
|
||
'volcano-route' => 'Volcano route / excursion area',
|
||
];
|
||
}
|
||
|
||
function source_channel_options(): array
|
||
{
|
||
return [
|
||
'hotel' => 'Hotel desk',
|
||
'web' => 'Website',
|
||
'reception' => 'Reception',
|
||
'partner' => 'Partner concierge',
|
||
];
|
||
}
|
||
|
||
function source_channel_label(string $key): string
|
||
{
|
||
$channels = source_channel_options();
|
||
return $channels[$key] ?? ucfirst(str_replace('-', ' ', $key));
|
||
}
|
||
|
||
function destination_label(string $key): string
|
||
{
|
||
$destinations = destination_options();
|
||
return $destinations[$key] ?? ucfirst(str_replace('-', ' ', $key));
|
||
}
|
||
|
||
function offer_catalog(): array
|
||
{
|
||
return [
|
||
'harbor-lunch' => [
|
||
'slug' => 'harbor-lunch',
|
||
'title' => 'Harbor Lunch Terrace',
|
||
'sector' => 'Restaurant',
|
||
'description' => 'A polished lunch stop with sea views, fast table turnover, and vegetarian options for travelers arriving near the marina.',
|
||
'location' => 'Marina',
|
||
'price_from' => 28,
|
||
'duration' => '60–75 min',
|
||
'quality' => 94,
|
||
'strategic' => 6,
|
||
'time_fit' => ['lunch', 'afternoon'],
|
||
'destinations' => ['marina' => 18, 'airport' => 8],
|
||
'commission' => '12%',
|
||
'availability' => 'Open daily · 12:00–18:00',
|
||
'meeting_point' => '3 minutes from the waterfront drop-off',
|
||
],
|
||
'sunset-wine' => [
|
||
'slug' => 'sunset-wine',
|
||
'title' => 'Sunset Wine Tasting',
|
||
'sector' => 'Experience',
|
||
'description' => 'A small-group tasting with island wines, designed for couples and premium travelers heading toward scenic evening areas.',
|
||
'location' => 'Volcano route',
|
||
'price_from' => 45,
|
||
'duration' => '90 min',
|
||
'quality' => 92,
|
||
'strategic' => 7,
|
||
'time_fit' => ['sunset', 'night'],
|
||
'destinations' => ['volcano-route' => 20, 'old-town' => 9],
|
||
'commission' => '14%',
|
||
'availability' => 'Limited seats · 17:30–21:00',
|
||
'meeting_point' => 'Pickup-friendly venue by the ridge viewpoint',
|
||
],
|
||
'old-town-tapas' => [
|
||
'slug' => 'old-town-tapas',
|
||
'title' => 'Old Town Tapas Table',
|
||
'sector' => 'Restaurant',
|
||
'description' => 'A trusted tapas venue with flexible seating for spontaneous arrivals and a menu suited to mixed groups.',
|
||
'location' => 'Old Town',
|
||
'price_from' => 24,
|
||
'duration' => '75 min',
|
||
'quality' => 90,
|
||
'strategic' => 5,
|
||
'time_fit' => ['lunch', 'night'],
|
||
'destinations' => ['old-town' => 20, 'marina' => 7],
|
||
'commission' => '11%',
|
||
'availability' => 'Walk-ins prioritized · 13:00–23:00',
|
||
'meeting_point' => 'Just inside the historic pedestrian zone',
|
||
],
|
||
'beach-club-pass' => [
|
||
'slug' => 'beach-club-pass',
|
||
'title' => 'Beach Club Day Pass',
|
||
'sector' => 'Experience',
|
||
'description' => 'A relaxed day-pass option with loungers, showers, and easy handoff from taxi to venue staff.',
|
||
'location' => 'Beach strip',
|
||
'price_from' => 39,
|
||
'duration' => 'Half day',
|
||
'quality' => 88,
|
||
'strategic' => 6,
|
||
'time_fit' => ['breakfast', 'lunch', 'afternoon'],
|
||
'destinations' => ['beach-strip' => 20, 'marina' => 8],
|
||
'commission' => '10%',
|
||
'availability' => 'Best before 17:00',
|
||
'meeting_point' => 'Dedicated taxi bay at the entrance',
|
||
],
|
||
'family-brunch' => [
|
||
'slug' => 'family-brunch',
|
||
'title' => 'Family Brunch Patio',
|
||
'sector' => 'Restaurant',
|
||
'description' => 'Comfortable seating, quick service, and child-friendly portions for airport arrivals or early drop-offs.',
|
||
'location' => 'Airport corridor',
|
||
'price_from' => 22,
|
||
'duration' => '50 min',
|
||
'quality' => 87,
|
||
'strategic' => 4,
|
||
'time_fit' => ['breakfast', 'lunch'],
|
||
'destinations' => ['airport' => 18, 'beach-strip' => 6],
|
||
'commission' => '9%',
|
||
'availability' => 'Open from 08:00',
|
||
'meeting_point' => 'Along the arrivals-to-resort route',
|
||
],
|
||
'chef-table' => [
|
||
'slug' => 'chef-table',
|
||
'title' => 'Chef Table Reserve',
|
||
'sector' => 'Restaurant',
|
||
'description' => 'A higher-value dinner reservation with premium conversion potential and a clear confirmation flow.',
|
||
'location' => 'Marina',
|
||
'price_from' => 62,
|
||
'duration' => '120 min',
|
||
'quality' => 95,
|
||
'strategic' => 8,
|
||
'time_fit' => ['night'],
|
||
'destinations' => ['marina' => 14, 'old-town' => 10, 'beach-strip' => 8],
|
||
'commission' => '16%',
|
||
'availability' => 'Reservation only · 19:00–23:30',
|
||
'meeting_point' => 'Inside the marina promenade',
|
||
],
|
||
];
|
||
}
|
||
|
||
function current_time_bucket(DateTimeImmutable $dateTime): string
|
||
{
|
||
$hour = (int) $dateTime->format('G');
|
||
|
||
if ($hour < 11) {
|
||
return 'breakfast';
|
||
}
|
||
if ($hour < 15) {
|
||
return 'lunch';
|
||
}
|
||
if ($hour < 18) {
|
||
return 'afternoon';
|
||
}
|
||
if ($hour < 21) {
|
||
return 'sunset';
|
||
}
|
||
|
||
return 'night';
|
||
}
|
||
|
||
function recommend_offers(string $destinationArea, string $pickupTime, int $limit = 3): array
|
||
{
|
||
$catalog = offer_catalog();
|
||
$dateTime = new DateTimeImmutable($pickupTime);
|
||
$timeBucket = current_time_bucket($dateTime);
|
||
$scored = [];
|
||
|
||
foreach ($catalog as $offer) {
|
||
$destinationScore = $offer['destinations'][$destinationArea] ?? 0;
|
||
$timeScore = in_array($timeBucket, $offer['time_fit'], true) ? 14 : 4;
|
||
$qualityScore = (int) round($offer['quality'] / 10);
|
||
$strategicScore = (int) $offer['strategic'];
|
||
$score = $destinationScore + $timeScore + $qualityScore + $strategicScore;
|
||
|
||
$reasons = [];
|
||
if ($destinationScore >= 14) {
|
||
$reasons[] = 'Strong geographic fit for ' . strtolower(destination_label($destinationArea));
|
||
}
|
||
if ($timeScore >= 14) {
|
||
$reasons[] = 'Best matched to the current arrival window';
|
||
}
|
||
if ($offer['quality'] >= 92) {
|
||
$reasons[] = 'High validation score for a premium first recommendation';
|
||
}
|
||
if ($reasons === []) {
|
||
$reasons[] = 'Qualified fallback with suitable availability and commission potential';
|
||
}
|
||
|
||
$offer['score'] = $score;
|
||
$offer['reasons'] = array_slice($reasons, 0, 2);
|
||
$scored[] = $offer;
|
||
}
|
||
|
||
usort($scored, static function (array $a, array $b): int {
|
||
return $b['score'] <=> $a['score'];
|
||
});
|
||
|
||
return array_slice($scored, 0, $limit);
|
||
}
|
||
|
||
function estimate_eta_minutes(string $destinationArea): int
|
||
{
|
||
return [
|
||
'airport' => 7,
|
||
'marina' => 8,
|
||
'old-town' => 10,
|
||
'beach-strip' => 12,
|
||
'volcano-route' => 14,
|
||
][$destinationArea] ?? 9;
|
||
}
|
||
|
||
function ensure_schema(): void
|
||
{
|
||
static $initialized = false;
|
||
|
||
if ($initialized) {
|
||
return;
|
||
}
|
||
|
||
db()->exec(
|
||
'CREATE TABLE IF NOT EXISTS taxi_requests (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
passenger_name VARCHAR(120) NOT NULL,
|
||
pickup_point VARCHAR(160) NOT NULL,
|
||
destination_area VARCHAR(80) NOT NULL,
|
||
pickup_time DATETIME NOT NULL,
|
||
source_channel VARCHAR(40) NOT NULL,
|
||
party_size TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||
notes VARCHAR(255) DEFAULT NULL,
|
||
status VARCHAR(40) NOT NULL DEFAULT "requested",
|
||
recommendation_status VARCHAR(40) NOT NULL DEFAULT "shown",
|
||
recommended_offer_slugs VARCHAR(255) DEFAULT NULL,
|
||
recommendation_clicked_slug VARCHAR(80) DEFAULT NULL,
|
||
booked_offer_slug VARCHAR(80) DEFAULT NULL,
|
||
booked_offer_title VARCHAR(160) DEFAULT NULL,
|
||
booking_status VARCHAR(40) DEFAULT NULL,
|
||
booking_started_at DATETIME DEFAULT NULL,
|
||
booking_completed_at DATETIME DEFAULT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
updated_at DATETIME NOT NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||
);
|
||
|
||
$initialized = true;
|
||
}
|
||
|
||
function create_taxi_request(array $input): int
|
||
{
|
||
ensure_schema();
|
||
|
||
$recommendedOffers = recommend_offers($input['destination_area'], $input['pickup_time']);
|
||
$recommendedSlugs = implode(',', array_column($recommendedOffers, 'slug'));
|
||
$now = date('Y-m-d H:i:s');
|
||
|
||
$statement = db()->prepare(
|
||
'INSERT INTO taxi_requests (
|
||
passenger_name,
|
||
pickup_point,
|
||
destination_area,
|
||
pickup_time,
|
||
source_channel,
|
||
party_size,
|
||
notes,
|
||
status,
|
||
recommendation_status,
|
||
recommended_offer_slugs,
|
||
created_at,
|
||
updated_at
|
||
) VALUES (
|
||
:passenger_name,
|
||
:pickup_point,
|
||
:destination_area,
|
||
:pickup_time,
|
||
:source_channel,
|
||
:party_size,
|
||
:notes,
|
||
:status,
|
||
:recommendation_status,
|
||
:recommended_offer_slugs,
|
||
:created_at,
|
||
:updated_at
|
||
)'
|
||
);
|
||
|
||
$statement->bindValue(':passenger_name', $input['passenger_name']);
|
||
$statement->bindValue(':pickup_point', $input['pickup_point']);
|
||
$statement->bindValue(':destination_area', $input['destination_area']);
|
||
$statement->bindValue(':pickup_time', $input['pickup_time']);
|
||
$statement->bindValue(':source_channel', $input['source_channel']);
|
||
$statement->bindValue(':party_size', (int) $input['party_size'], PDO::PARAM_INT);
|
||
$statement->bindValue(':notes', $input['notes'] !== '' ? $input['notes'] : null);
|
||
$statement->bindValue(':status', 'confirmed');
|
||
$statement->bindValue(':recommendation_status', 'shown');
|
||
$statement->bindValue(':recommended_offer_slugs', $recommendedSlugs !== '' ? $recommendedSlugs : null);
|
||
$statement->bindValue(':created_at', $now);
|
||
$statement->bindValue(':updated_at', $now);
|
||
$statement->execute();
|
||
|
||
return (int) db()->lastInsertId();
|
||
}
|
||
|
||
function fetch_request(int $id): ?array
|
||
{
|
||
ensure_schema();
|
||
|
||
$statement = db()->prepare('SELECT * FROM taxi_requests WHERE id = :id LIMIT 1');
|
||
$statement->bindValue(':id', $id, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
$record = $statement->fetch();
|
||
|
||
return $record ?: null;
|
||
}
|
||
|
||
function fetch_requests(int $limit = 12): array
|
||
{
|
||
ensure_schema();
|
||
|
||
$statement = db()->prepare('SELECT * FROM taxi_requests ORDER BY created_at DESC LIMIT :limit');
|
||
$statement->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||
$statement->execute();
|
||
|
||
return $statement->fetchAll();
|
||
}
|
||
|
||
function fetch_dashboard_stats(): array
|
||
{
|
||
ensure_schema();
|
||
|
||
$totalRequests = (int) db()->query('SELECT COUNT(*) FROM taxi_requests')->fetchColumn();
|
||
$clicked = (int) db()->query('SELECT COUNT(*) FROM taxi_requests WHERE recommendation_clicked_slug IS NOT NULL')->fetchColumn();
|
||
$booked = (int) db()->query('SELECT COUNT(*) FROM taxi_requests WHERE booked_offer_slug IS NOT NULL')->fetchColumn();
|
||
$avgDelay = db()->query('SELECT AVG(TIMESTAMPDIFF(MINUTE, created_at, booking_completed_at)) FROM taxi_requests WHERE booking_completed_at IS NOT NULL')->fetchColumn();
|
||
|
||
return [
|
||
'total_requests' => $totalRequests,
|
||
'offers_clicked' => $clicked,
|
||
'bookings' => $booked,
|
||
'ctr' => $totalRequests > 0 ? round(($clicked / $totalRequests) * 100, 1) : 0,
|
||
'conversion' => $totalRequests > 0 ? round(($booked / $totalRequests) * 100, 1) : 0,
|
||
'avg_delay' => $avgDelay !== null ? (int) round((float) $avgDelay) : null,
|
||
];
|
||
}
|
||
|
||
function hydrate_recommended_offers(array $request): array
|
||
{
|
||
$recommended = recommend_offers((string) $request['destination_area'], (string) $request['pickup_time']);
|
||
$recommendedBySlug = [];
|
||
|
||
foreach ($recommended as $offer) {
|
||
$recommendedBySlug[$offer['slug']] = $offer;
|
||
}
|
||
|
||
$slugs = array_filter(array_map('trim', explode(',', (string) ($request['recommended_offer_slugs'] ?? ''))));
|
||
$offers = [];
|
||
|
||
foreach ($slugs as $slug) {
|
||
if (isset($recommendedBySlug[$slug])) {
|
||
$offers[] = $recommendedBySlug[$slug];
|
||
}
|
||
}
|
||
|
||
return $offers !== [] ? $offers : $recommended;
|
||
}
|
||
|
||
function offer_by_slug(string $slug): ?array
|
||
{
|
||
$catalog = offer_catalog();
|
||
return $catalog[$slug] ?? null;
|
||
}
|
||
|
||
function book_offer(int $requestId, string $offerSlug): bool
|
||
{
|
||
ensure_schema();
|
||
|
||
$offer = offer_by_slug($offerSlug);
|
||
if ($offer === null) {
|
||
return false;
|
||
}
|
||
|
||
$now = date('Y-m-d H:i:s');
|
||
$statement = db()->prepare(
|
||
'UPDATE taxi_requests
|
||
SET recommendation_clicked_slug = :clicked_slug,
|
||
booked_offer_slug = :booked_offer_slug,
|
||
booked_offer_title = :booked_offer_title,
|
||
booking_status = :booking_status,
|
||
booking_started_at = COALESCE(booking_started_at, :booking_started_at),
|
||
booking_completed_at = :booking_completed_at,
|
||
status = :status,
|
||
updated_at = :updated_at
|
||
WHERE id = :id'
|
||
);
|
||
|
||
$statement->bindValue(':clicked_slug', $offerSlug);
|
||
$statement->bindValue(':booked_offer_slug', $offerSlug);
|
||
$statement->bindValue(':booked_offer_title', $offer['title']);
|
||
$statement->bindValue(':booking_status', 'confirmed');
|
||
$statement->bindValue(':booking_started_at', $now);
|
||
$statement->bindValue(':booking_completed_at', $now);
|
||
$statement->bindValue(':status', 'offer_booked');
|
||
$statement->bindValue(':updated_at', $now);
|
||
$statement->bindValue(':id', $requestId, PDO::PARAM_INT);
|
||
|
||
return $statement->execute();
|
||
}
|
||
|
||
function status_badge_class(string $status): string
|
||
{
|
||
return match ($status) {
|
||
'offer_booked' => 'text-bg-success',
|
||
'confirmed' => 'text-bg-dark',
|
||
default => 'text-bg-secondary',
|
||
};
|
||
}
|
||
|
||
function render_head(string $title, string $description, string $robots = 'index,follow'): void
|
||
{
|
||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||
?>
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title><?= h($title) ?></title>
|
||
<meta name="description" content="<?= h($description) ?>">
|
||
<meta name="robots" content="<?= h($robots) ?>">
|
||
<meta name="theme-color" content="#111827">
|
||
<?php if ($projectDescription): ?>
|
||
<meta property="og:description" content="<?= h($projectDescription) ?>">
|
||
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
|
||
<?php else: ?>
|
||
<meta property="og:description" content="<?= h($description) ?>">
|
||
<meta property="twitter:description" content="<?= h($description) ?>">
|
||
<?php endif; ?>
|
||
<?php if ($projectImageUrl): ?>
|
||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||
<?php endif; ?>
|
||
<meta property="og:title" content="<?= h($title) ?>">
|
||
<meta property="twitter:title" content="<?= h($title) ?>">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||
<link href="<?= h(asset_url('assets/css/custom.css')) ?>" rel="stylesheet">
|
||
</head>
|
||
<?php
|
||
}
|
||
|
||
function render_header(string $active = 'home'): void
|
||
{
|
||
$stats = fetch_dashboard_stats();
|
||
?>
|
||
<body>
|
||
<div class="app-shell">
|
||
<header class="border-bottom bg-white sticky-top">
|
||
<nav class="navbar navbar-expand-lg navbar-light py-3">
|
||
<div class="container">
|
||
<a class="navbar-brand fw-semibold d-flex align-items-center gap-2" href="/">
|
||
<span class="brand-mark"><i class="bi bi-taxi-front"></i></span>
|
||
<span><?= h(app_name()) ?></span>
|
||
</a>
|
||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||
<span class="navbar-toggler-icon"></span>
|
||
</button>
|
||
<div class="collapse navbar-collapse" id="mainNav">
|
||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||
<li class="nav-item"><a class="nav-link <?= $active === 'home' ? 'active' : '' ?>" href="/">Taxi flow</a></li>
|
||
<li class="nav-item"><a class="nav-link <?= $active === 'operations' ? 'active' : '' ?>" href="/requests.php">Operations board</a></li>
|
||
<li class="nav-item"><a class="nav-link <?= $active === 'health' ? 'active' : '' ?>" href="/healthz.php">Health</a></li>
|
||
<li class="nav-item ms-lg-2">
|
||
<span class="mini-stat">
|
||
<strong><?= (int) $stats['total_requests'] ?></strong>
|
||
<span>taxi requests</span>
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
</header>
|
||
<main>
|
||
<?php
|
||
}
|
||
|
||
function render_footer(?string $toastMessage = null): void
|
||
{
|
||
?>
|
||
</main>
|
||
<footer class="border-top bg-white">
|
||
<div class="container py-4 d-flex flex-column flex-md-row justify-content-between gap-3 small text-secondary">
|
||
<div>
|
||
<div class="fw-semibold text-dark mb-1"><?= h(app_name()) ?> · Taxi → Booking MVP</div>
|
||
<div>Thin slice for taxi confirmation, contextual offers, and booking attribution.</div>
|
||
</div>
|
||
<div class="d-flex gap-3 align-items-start">
|
||
<a href="/" class="footer-link">New taxi request</a>
|
||
<a href="/requests.php" class="footer-link">Review requests</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||
<div id="appToast" class="toast align-items-center text-bg-dark border-0" role="status" aria-live="polite" aria-atomic="true" data-toast-message="<?= h($toastMessage ?? '') ?>">
|
||
<div class="d-flex">
|
||
<div class="toast-body"></div>
|
||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||
<script src="<?= h(asset_url('assets/js/main.js')) ?>"></script>
|
||
</body>
|
||
</html>
|
||
<?php
|
||
}
|