39498-vm/app.php
2026-04-06 05:35:15 +00:00

548 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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' => '6075 min',
'quality' => 94,
'strategic' => 6,
'time_fit' => ['lunch', 'afternoon'],
'destinations' => ['marina' => 18, 'airport' => 8],
'commission' => '12%',
'availability' => 'Open daily · 12:0018: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:3021: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:0023: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:0023: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
}