Auto commit: 2026-04-06T05:35:15.514Z
This commit is contained in:
parent
0f1e1c2905
commit
d1693a6dc6
547
app.php
Normal file
547
app.php
Normal file
@ -0,0 +1,547 @@
|
||||
<?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
|
||||
}
|
||||
@ -1,403 +1,425 @@
|
||||
:root {
|
||||
--bg: #f5f5f4;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #fafaf9;
|
||||
--border: #e7e5e4;
|
||||
--border-strong: #d6d3d1;
|
||||
--text: #111827;
|
||||
--text-soft: #6b7280;
|
||||
--accent: #111827;
|
||||
--accent-soft: #f3f4f6;
|
||||
--success-soft: #ecfdf3;
|
||||
--shadow: 0 12px 32px rgba(17, 24, 39, 0.06);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 18px;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--space-7: 3rem;
|
||||
}
|
||||
|
||||
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;
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
main {
|
||||
min-height: calc(100vh - 145px);
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.navbar-brand,
|
||||
.nav-link,
|
||||
.footer-link {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 0.9rem !important;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.nav-link:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.8rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
.mini-stat {
|
||||
display: inline-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;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-muted);
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
line-height: 1.1;
|
||||
min-width: 115px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
.mini-stat strong {
|
||||
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);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
.mini-stat span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.message.visitor {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
.section-block {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.message.bot {
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
border-bottom-left-radius: 4px;
|
||||
.section-hero {
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
.hero-panel,
|
||||
.card-panel,
|
||||
.offer-card,
|
||||
.metric-card,
|
||||
.step-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chat-input-area form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
.hero-panel {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.card-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
|
||||
.display-title {
|
||||
font-size: clamp(2rem, 5vw, 3.6rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
.lead-copy {
|
||||
color: var(--text-soft);
|
||||
max-width: 58ch;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.chat-input-area button:hover {
|
||||
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;
|
||||
color: #fff;
|
||||
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;
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.table td {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
.chip,
|
||||
.reason-pill,
|
||||
.score-pill,
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.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;
|
||||
.status-pill {
|
||||
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;
|
||||
.reason-pill {
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
.metric-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
font-size: clamp(1.4rem, 3vw, 1.9rem);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.metric-card small,
|
||||
.metric-label,
|
||||
.offer-copy,
|
||||
.offer-meta,
|
||||
.summary-list dt,
|
||||
.section-heading p,
|
||||
.step-card p,
|
||||
.empty-state p {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.card-head,
|
||||
.section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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);
|
||||
.section-title {
|
||||
font-size: clamp(1.6rem, 4vw, 2.25rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #212529;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #0088cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
.form-label {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
margin-top: 0.5rem;
|
||||
.form-control,
|
||||
.form-select {
|
||||
border-radius: var(--radius-sm);
|
||||
border-color: var(--border-strong);
|
||||
min-height: 2.95rem;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
.btn:focus {
|
||||
border-color: #374151;
|
||||
box-shadow: 0 0 0 0.2rem rgba(17, 24, 39, 0.12) !important;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
.btn {
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.btn-lg {
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.step-card,
|
||||
.offer-card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.step-index {
|
||||
display: inline-flex;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 0.85rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}
|
||||
.step-card h3 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.table-slim th,
|
||||
.table-slim td {
|
||||
padding: 1rem 1.1rem;
|
||||
border-color: var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-slim thead th {
|
||||
background: var(--surface-muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1rem;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-list div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 120px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.summary-list div:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.summary-list dt {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.summary-list dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.sticky-summary {
|
||||
position: sticky;
|
||||
top: 6rem;
|
||||
}
|
||||
|
||||
.offer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.offer-card-active {
|
||||
border-color: #16a34a;
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.offer-meta li {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.reason-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.compact-offer {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timeline-item-muted {
|
||||
border-left-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.toast {
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.sticky-summary {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.metric-grid,
|
||||
.summary-list div {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.card-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.table-slim th,
|
||||
.table-slim td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
const pickupInput = document.getElementById('pickup_time');
|
||||
if (pickupInput && !pickupInput.value) {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + 20);
|
||||
now.setSeconds(0, 0);
|
||||
pickupInput.value = now.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
};
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const message = chatInput.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
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 toastElement = document.getElementById('appToast');
|
||||
if (toastElement && typeof bootstrap !== 'undefined') {
|
||||
const message = toastElement.dataset.toastMessage || '';
|
||||
if (message.trim() !== '') {
|
||||
const body = toastElement.querySelector('.toast-body');
|
||||
if (body) {
|
||||
body.textContent = message;
|
||||
}
|
||||
const toast = new bootstrap.Toast(toastElement, { delay: 3200 });
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.book-offer-btn').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Confirming booking…';
|
||||
const form = button.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
24
book_offer.php
Normal file
24
book_offer.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_schema();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
$requestId = filter_input(INPUT_POST, 'request_id', FILTER_VALIDATE_INT);
|
||||
$offerSlug = trim((string) ($_POST['offer_slug'] ?? ''));
|
||||
|
||||
if (!$requestId || $offerSlug === '' || !offer_by_slug($offerSlug)) {
|
||||
header('Location: /requests.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
book_offer($requestId, $offerSlug);
|
||||
header('Location: /request_success.php?id=' . $requestId . '&booked=1');
|
||||
exit;
|
||||
17
healthz.php
Normal file
17
healthz.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
try {
|
||||
ensure_schema();
|
||||
db()->query('SELECT 1');
|
||||
http_response_code(200);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "ok\n";
|
||||
} catch (Throwable $exception) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "error\n";
|
||||
}
|
||||
407
index.php
407
index.php
@ -1,150 +1,265 @@
|
||||
<?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');
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_schema();
|
||||
|
||||
$errors = [];
|
||||
$input = [
|
||||
'passenger_name' => '',
|
||||
'pickup_point' => '',
|
||||
'destination_area' => 'marina',
|
||||
'pickup_time' => (new DateTimeImmutable('+20 minutes'))->format('Y-m-d\TH:i'),
|
||||
'source_channel' => 'hotel',
|
||||
'party_size' => '2',
|
||||
'notes' => '',
|
||||
];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input['passenger_name'] = trim((string) ($_POST['passenger_name'] ?? ''));
|
||||
$input['pickup_point'] = trim((string) ($_POST['pickup_point'] ?? ''));
|
||||
$input['destination_area'] = trim((string) ($_POST['destination_area'] ?? ''));
|
||||
$input['pickup_time'] = trim((string) ($_POST['pickup_time'] ?? ''));
|
||||
$input['source_channel'] = trim((string) ($_POST['source_channel'] ?? ''));
|
||||
$input['party_size'] = trim((string) ($_POST['party_size'] ?? '1'));
|
||||
$input['notes'] = trim((string) ($_POST['notes'] ?? ''));
|
||||
|
||||
if ($input['passenger_name'] === '') {
|
||||
$errors['passenger_name'] = 'Add a passenger or booking reference.';
|
||||
}
|
||||
if ($input['pickup_point'] === '') {
|
||||
$errors['pickup_point'] = 'Pickup point is required.';
|
||||
}
|
||||
if (!array_key_exists($input['destination_area'], destination_options())) {
|
||||
$errors['destination_area'] = 'Choose a valid destination area.';
|
||||
}
|
||||
|
||||
$dateTime = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $input['pickup_time']);
|
||||
if (!$dateTime) {
|
||||
$errors['pickup_time'] = 'Choose a valid pickup time.';
|
||||
} else {
|
||||
$input['pickup_time'] = $dateTime->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (!array_key_exists($input['source_channel'], source_channel_options())) {
|
||||
$errors['source_channel'] = 'Choose a valid source channel.';
|
||||
}
|
||||
|
||||
$partySize = filter_var($input['party_size'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 8]]);
|
||||
if ($partySize === false) {
|
||||
$errors['party_size'] = 'Party size must be between 1 and 8.';
|
||||
}
|
||||
$input['party_size'] = (string) ($partySize ?: 1);
|
||||
|
||||
if ($errors === []) {
|
||||
$requestId = create_taxi_request([
|
||||
'passenger_name' => $input['passenger_name'],
|
||||
'pickup_point' => $input['pickup_point'],
|
||||
'destination_area' => $input['destination_area'],
|
||||
'pickup_time' => $input['pickup_time'],
|
||||
'source_channel' => $input['source_channel'],
|
||||
'party_size' => (int) $input['party_size'],
|
||||
'notes' => $input['notes'],
|
||||
]);
|
||||
|
||||
header('Location: /request_success.php?id=' . $requestId . '&created=1');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$stats = fetch_dashboard_stats();
|
||||
$recentRequests = fetch_requests(6);
|
||||
$heroDescription = 'Confirm a taxi request and immediately surface the top 2–3 contextual offers with simple booking attribution.';
|
||||
|
||||
render_head(app_name() . ' | Taxi request workflow', $heroDescription);
|
||||
render_header('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="section-block section-hero">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-7">
|
||||
<div class="hero-panel">
|
||||
<div class="eyebrow">Initial MVP slice · tourist flow</div>
|
||||
<h1 class="display-title">Taxi confirmation with immediate upsell recommendations.</h1>
|
||||
<p class="lead-copy"><?= h($heroDescription) ?></p>
|
||||
<div class="d-flex flex-wrap gap-2 mt-4">
|
||||
<span class="chip"><i class="bi bi-check2-circle"></i> Taxi request</span>
|
||||
<span class="chip"><i class="bi bi-stars"></i> Top-3 recommendations</span>
|
||||
<span class="chip"><i class="bi bi-journal-check"></i> Booking attribution</span>
|
||||
</div>
|
||||
<div class="metric-grid mt-4">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Requests</span>
|
||||
<strong><?= (int) $stats['total_requests'] ?></strong>
|
||||
<small>captured end-to-end</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">CTR</span>
|
||||
<strong><?= h(number_format((float) $stats['ctr'], 1)) ?>%</strong>
|
||||
<small>offer click-through</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Bookings</span>
|
||||
<strong><?= (int) $stats['bookings'] ?></strong>
|
||||
<small>from taxi flow</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Avg. delay</span>
|
||||
<strong><?= $stats['avg_delay'] !== null ? (int) $stats['avg_delay'] . 'm' : '—' ?></strong>
|
||||
<small>request to booking</small>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="card-panel shadow-sm">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Create taxi request</h2>
|
||||
<p class="text-secondary mb-0">Save the ride context and instantly generate contextual offers.</p>
|
||||
</div>
|
||||
<span class="status-pill">Live MVP</span>
|
||||
</div>
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="alert alert-danger mt-3 mb-0" role="alert">Please review the highlighted fields and try again.</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" class="row g-3 mt-1" novalidate>
|
||||
<div class="col-12">
|
||||
<label for="passenger_name" class="form-label">Passenger / booking reference</label>
|
||||
<input type="text" class="form-control <?= isset($errors['passenger_name']) ? 'is-invalid' : '' ?>" id="passenger_name" name="passenger_name" value="<?= h($input['passenger_name']) ?>" placeholder="e.g. Room 204 · Sofia M.">
|
||||
<?php if (isset($errors['passenger_name'])): ?><div class="invalid-feedback"><?= h($errors['passenger_name']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="pickup_point" class="form-label">Pickup point</label>
|
||||
<input type="text" class="form-control <?= isset($errors['pickup_point']) ? 'is-invalid' : '' ?>" id="pickup_point" name="pickup_point" value="<?= h($input['pickup_point']) ?>" placeholder="Hotel lobby, gate, cruise terminal...">
|
||||
<?php if (isset($errors['pickup_point'])): ?><div class="invalid-feedback"><?= h($errors['pickup_point']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="destination_area" class="form-label">Destination area</label>
|
||||
<select class="form-select <?= isset($errors['destination_area']) ? 'is-invalid' : '' ?>" id="destination_area" name="destination_area">
|
||||
<?php foreach (destination_options() as $value => $label): ?>
|
||||
<option value="<?= h($value) ?>" <?= $input['destination_area'] === $value ? 'selected' : '' ?>><?= h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($errors['destination_area'])): ?><div class="invalid-feedback"><?= h($errors['destination_area']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="pickup_time" class="form-label">Pickup time</label>
|
||||
<input type="datetime-local" class="form-control <?= isset($errors['pickup_time']) ? 'is-invalid' : '' ?>" id="pickup_time" name="pickup_time" value="<?= h(str_replace(' ', 'T', substr($input['pickup_time'], 0, 16))) ?>">
|
||||
<?php if (isset($errors['pickup_time'])): ?><div class="invalid-feedback"><?= h($errors['pickup_time']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="source_channel" class="form-label">Source channel</label>
|
||||
<select class="form-select <?= isset($errors['source_channel']) ? 'is-invalid' : '' ?>" id="source_channel" name="source_channel">
|
||||
<?php foreach (source_channel_options() as $value => $label): ?>
|
||||
<option value="<?= h($value) ?>" <?= $input['source_channel'] === $value ? 'selected' : '' ?>><?= h($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (isset($errors['source_channel'])): ?><div class="invalid-feedback"><?= h($errors['source_channel']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="party_size" class="form-label">Party size</label>
|
||||
<input type="number" min="1" max="8" class="form-control <?= isset($errors['party_size']) ? 'is-invalid' : '' ?>" id="party_size" name="party_size" value="<?= h($input['party_size']) ?>">
|
||||
<?php if (isset($errors['party_size'])): ?><div class="invalid-feedback"><?= h($errors['party_size']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="notes" class="form-label">Context notes <span class="text-secondary">(optional)</span></label>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="Family, mobility needs, preferred cuisine, celebration, arrival context..."><?= h($input['notes']) ?></textarea>
|
||||
</div>
|
||||
<div class="col-12 d-grid">
|
||||
<button type="submit" class="btn btn-dark btn-lg">Confirm taxi and generate offers</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
</section>
|
||||
|
||||
<section class="section-block border-top bg-white">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="eyebrow">Workflow</div>
|
||||
<h2 class="section-title">The first thin slice follows the real tourist journey.</h2>
|
||||
</div>
|
||||
<a href="/requests.php" class="btn btn-outline-dark">Open operations board</a>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-4">
|
||||
<article class="step-card h-100">
|
||||
<span class="step-index">01</span>
|
||||
<h3>Capture the taxi request</h3>
|
||||
<p>Store pickup, destination, time, party size, and channel using a focused intake form.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<article class="step-card h-100">
|
||||
<span class="step-index">02</span>
|
||||
<h3>Generate contextual offers</h3>
|
||||
<p>Score nearby restaurants and experiences by geography, timing, quality, and strategic value.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<article class="step-card h-100">
|
||||
<span class="step-index">03</span>
|
||||
<h3>Book and attribute</h3>
|
||||
<p>Confirm the best offer in one click and keep a simple log for CTR and taxi-to-booking conversion.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section-block border-top">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="eyebrow">Recent activity</div>
|
||||
<h2 class="section-title">Live requests and current booking outcomes.</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-panel p-0 overflow-hidden">
|
||||
<?php if ($recentRequests === []): ?>
|
||||
<div class="empty-state text-center">
|
||||
<div class="empty-icon"><i class="bi bi-car-front"></i></div>
|
||||
<h3>No requests yet</h3>
|
||||
<p>Submit the first taxi flow above to populate the operations board and recommendation funnel.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-slim align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Passenger</th>
|
||||
<th>Destination</th>
|
||||
<th>Channel</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($recentRequests as $request): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($request['passenger_name']) ?></div>
|
||||
<div class="text-secondary small"><?= h($request['pickup_point']) ?></div>
|
||||
</td>
|
||||
<td><?= h(destination_label((string) $request['destination_area'])) ?></td>
|
||||
<td><?= h(source_channel_label((string) $request['source_channel'])) ?></td>
|
||||
<td><span class="badge rounded-pill <?= h(status_badge_class((string) $request['status'])) ?>"><?= h(str_replace('_', ' ', (string) $request['status'])) ?></span></td>
|
||||
<td><?= h(date('d M H:i', strtotime((string) $request['created_at']))) ?></td>
|
||||
<td class="text-end"><a href="/request_detail.php?id=<?= (int) $request['id'] ?>" class="btn btn-sm btn-outline-dark">Detail</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
|
||||
123
request_detail.php
Normal file
123
request_detail.php
Normal file
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_schema();
|
||||
|
||||
$requestId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
|
||||
$request = $requestId ? fetch_request($requestId) : null;
|
||||
|
||||
if (!$request) {
|
||||
http_response_code(404);
|
||||
render_head(app_name() . ' | Request not found', 'The selected request is unavailable.', 'noindex,nofollow');
|
||||
render_header('operations');
|
||||
?>
|
||||
<section class="section-block">
|
||||
<div class="container">
|
||||
<div class="card-panel text-center py-5">
|
||||
<h1 class="h3 mb-3">Request not found</h1>
|
||||
<a href="/requests.php" class="btn btn-dark">Back to board</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
render_footer();
|
||||
exit;
|
||||
}
|
||||
|
||||
$recommendedOffers = hydrate_recommended_offers($request);
|
||||
$bookedOffer = !empty($request['booked_offer_slug']) ? offer_by_slug((string) $request['booked_offer_slug']) : null;
|
||||
|
||||
render_head(app_name() . ' | Request #' . (int) $request['id'], 'Detailed taxi request attribution and recommendation view.', 'noindex,follow');
|
||||
render_header('operations');
|
||||
?>
|
||||
<section class="section-block">
|
||||
<div class="container">
|
||||
<div class="section-heading mb-4">
|
||||
<div>
|
||||
<div class="eyebrow">Request detail</div>
|
||||
<h1 class="section-title">Taxi request #<?= (int) $request['id'] ?></h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/request_success.php?id=<?= (int) $request['id'] ?>" class="btn btn-outline-dark">Open confirmation view</a>
|
||||
<a href="/requests.php" class="btn btn-dark">Back to board</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="card-panel">
|
||||
<div class="card-head mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Request summary</h2>
|
||||
<p class="text-secondary mb-0">Minimal operational data captured in a single flow record.</p>
|
||||
</div>
|
||||
<span class="badge rounded-pill <?= h(status_badge_class((string) $request['status'])) ?>"><?= h(str_replace('_', ' ', (string) $request['status'])) ?></span>
|
||||
</div>
|
||||
<dl class="summary-list mb-0">
|
||||
<div><dt>Passenger</dt><dd><?= h($request['passenger_name']) ?></dd></div>
|
||||
<div><dt>Pickup point</dt><dd><?= h($request['pickup_point']) ?></dd></div>
|
||||
<div><dt>Destination</dt><dd><?= h(destination_label((string) $request['destination_area'])) ?></dd></div>
|
||||
<div><dt>Pickup time</dt><dd><?= h(date('d M Y · H:i', strtotime((string) $request['pickup_time']))) ?></dd></div>
|
||||
<div><dt>Channel</dt><dd><?= h(source_channel_label((string) $request['source_channel'])) ?></dd></div>
|
||||
<div><dt>Party size</dt><dd><?= (int) $request['party_size'] ?></dd></div>
|
||||
<div><dt>Notes</dt><dd><?= $request['notes'] ? h((string) $request['notes']) : '—' ?></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-panel mt-3">
|
||||
<div class="card-head mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Attribution timeline</h2>
|
||||
<p class="text-secondary mb-0">Simple event capture inside the single record.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<strong>Taxi request created</strong>
|
||||
<div class="text-secondary small"><?= h(date('d M Y · H:i', strtotime((string) $request['created_at']))) ?></div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<strong>Recommendations shown</strong>
|
||||
<div class="text-secondary small">Offer set: <?= h((string) $request['recommended_offer_slugs']) ?></div>
|
||||
</div>
|
||||
<div class="timeline-item <?= $request['recommendation_clicked_slug'] ? '' : 'timeline-item-muted' ?>">
|
||||
<strong>Recommendation clicked</strong>
|
||||
<div class="text-secondary small"><?= $request['recommendation_clicked_slug'] ? h((string) $request['recommendation_clicked_slug']) : 'Pending' ?></div>
|
||||
</div>
|
||||
<div class="timeline-item <?= $request['booking_completed_at'] ? '' : 'timeline-item-muted' ?>">
|
||||
<strong>Booking completed</strong>
|
||||
<div class="text-secondary small"><?= $request['booking_completed_at'] ? h(date('d M Y · H:i', strtotime((string) $request['booking_completed_at']))) : 'Pending' ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card-panel">
|
||||
<div class="card-head mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Recommended offers</h2>
|
||||
<p class="text-secondary mb-0">Stored as the first contextual recommendation set for this request.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($recommendedOffers as $offer): ?>
|
||||
<div class="col-md-6">
|
||||
<article class="offer-card compact-offer <?= $bookedOffer && $bookedOffer['slug'] === $offer['slug'] ? 'offer-card-active' : '' ?>">
|
||||
<div class="small text-secondary text-uppercase mb-2"><?= h($offer['sector']) ?></div>
|
||||
<h3 class="h6 mb-2"><?= h($offer['title']) ?></h3>
|
||||
<p class="text-secondary mb-3"><?= h($offer['description']) ?></p>
|
||||
<div class="small text-secondary mb-2">From €<?= (int) $offer['price_from'] ?> · <?= h($offer['duration']) ?></div>
|
||||
<?php if ($bookedOffer && $bookedOffer['slug'] === $offer['slug']): ?>
|
||||
<span class="badge text-bg-success rounded-pill">Booked</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-light rounded-pill border text-dark">Recommended</span>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
138
request_success.php
Normal file
138
request_success.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_schema();
|
||||
|
||||
$requestId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
|
||||
$request = $requestId ? fetch_request($requestId) : null;
|
||||
|
||||
if (!$request) {
|
||||
http_response_code(404);
|
||||
render_head(app_name() . ' | Request not found', 'The selected taxi request could not be found.', 'noindex,nofollow');
|
||||
render_header();
|
||||
?>
|
||||
<section class="section-block">
|
||||
<div class="container">
|
||||
<div class="card-panel text-center py-5">
|
||||
<h1 class="h3 mb-3">Request not found</h1>
|
||||
<p class="text-secondary mb-4">The taxi flow may have expired or the link is incomplete.</p>
|
||||
<a href="/" class="btn btn-dark">Create a new request</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
render_footer();
|
||||
exit;
|
||||
}
|
||||
|
||||
$recommendedOffers = hydrate_recommended_offers($request);
|
||||
$bookedOffer = !empty($request['booked_offer_slug']) ? offer_by_slug((string) $request['booked_offer_slug']) : null;
|
||||
$toast = isset($_GET['created']) ? 'Taxi confirmed. Recommendations are ready.' : null;
|
||||
if (isset($_GET['booked'])) {
|
||||
$toast = 'Booking confirmed and attributed to the taxi confirmation screen.';
|
||||
}
|
||||
|
||||
render_head(app_name() . ' | Taxi confirmed', 'Taxi confirmation with contextual recommendations and one-click booking.');
|
||||
render_header();
|
||||
?>
|
||||
<section class="section-block">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-4">
|
||||
<div class="card-panel sticky-summary">
|
||||
<div class="eyebrow">Taxi confirmed</div>
|
||||
<h1 class="h3 mb-3">Ride for <?= h($request['passenger_name']) ?></h1>
|
||||
<dl class="summary-list mb-0">
|
||||
<div>
|
||||
<dt>Pickup</dt>
|
||||
<dd><?= h($request['pickup_point']) ?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Destination</dt>
|
||||
<dd><?= h(destination_label((string) $request['destination_area'])) ?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>ETA</dt>
|
||||
<dd><?= estimate_eta_minutes((string) $request['destination_area']) ?> min</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Channel</dt>
|
||||
<dd><?= h(source_channel_label((string) $request['source_channel'])) ?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Pickup time</dt>
|
||||
<dd><?= h(date('d M Y · H:i', strtotime((string) $request['pickup_time']))) ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="divider"></div>
|
||||
<div class="small text-secondary">Attribution source: taxi confirmation screen · request #<?= (int) $request['id'] ?></div>
|
||||
<div class="d-grid gap-2 mt-4">
|
||||
<a href="/request_detail.php?id=<?= (int) $request['id'] ?>" class="btn btn-outline-dark">View request detail</a>
|
||||
<a href="/" class="btn btn-light border">Create another request</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-8">
|
||||
<div class="card-panel mb-3">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="eyebrow">Top 3 contextual offers</div>
|
||||
<h2 class="h4 mb-1">Recommended for the confirmed route</h2>
|
||||
<p class="text-secondary mb-0">Scored by destination fit, time window, quality, and simple strategic weighting.</p>
|
||||
</div>
|
||||
<span class="badge rounded-pill <?= h(status_badge_class((string) $request['status'])) ?>"><?= h(str_replace('_', ' ', (string) $request['status'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($bookedOffer): ?>
|
||||
<div class="alert alert-success d-flex gap-3 align-items-start" role="alert">
|
||||
<i class="bi bi-check-circle-fill fs-4"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">Booking confirmed: <?= h($bookedOffer['title']) ?></div>
|
||||
<div class="small">This booking is stored as a conversion from the taxi confirmation flow.</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="row g-3">
|
||||
<?php foreach ($recommendedOffers as $offer): ?>
|
||||
<div class="col-md-6 col-xl-4">
|
||||
<article class="offer-card h-100 <?= $bookedOffer && $bookedOffer['slug'] === $offer['slug'] ? 'offer-card-active' : '' ?>">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<div class="small text-secondary text-uppercase mb-2"><?= h($offer['sector']) ?></div>
|
||||
<h3 class="h5 mb-2"><?= h($offer['title']) ?></h3>
|
||||
</div>
|
||||
<span class="score-pill">Score <?= (int) ($offer['score'] ?? 0) ?></span>
|
||||
</div>
|
||||
<p class="text-secondary offer-copy"><?= h($offer['description']) ?></p>
|
||||
<ul class="list-unstyled offer-meta mb-3">
|
||||
<li><i class="bi bi-geo-alt"></i> <?= h($offer['location']) ?></li>
|
||||
<li><i class="bi bi-clock"></i> <?= h($offer['duration']) ?> · <?= h($offer['availability']) ?></li>
|
||||
<li><i class="bi bi-currency-euro"></i> From €<?= (int) $offer['price_from'] ?> · Commission <?= h($offer['commission']) ?></li>
|
||||
</ul>
|
||||
<div class="reason-stack mb-3">
|
||||
<?php foreach (($offer['reasons'] ?? []) as $reason): ?>
|
||||
<span class="reason-pill"><?= h($reason) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="small text-secondary mb-3">Meeting point: <?= h($offer['meeting_point']) ?></div>
|
||||
<?php if ($bookedOffer && $bookedOffer['slug'] === $offer['slug']): ?>
|
||||
<button class="btn btn-success w-100" type="button" disabled>Booked from taxi flow</button>
|
||||
<?php else: ?>
|
||||
<form method="post" action="/book_offer.php" class="d-grid">
|
||||
<input type="hidden" name="request_id" value="<?= (int) $request['id'] ?>">
|
||||
<input type="hidden" name="offer_slug" value="<?= h($offer['slug']) ?>">
|
||||
<button type="submit" class="btn btn-dark book-offer-btn">Book this offer</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer($toast); ?>
|
||||
76
requests.php
Normal file
76
requests.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/app.php';
|
||||
|
||||
ensure_schema();
|
||||
|
||||
$requests = fetch_requests(50);
|
||||
$stats = fetch_dashboard_stats();
|
||||
|
||||
render_head(app_name() . ' | Operations board', 'Operations board for taxi requests, conversions, and recommendation performance.');
|
||||
render_header('operations');
|
||||
?>
|
||||
<section class="section-block">
|
||||
<div class="container">
|
||||
<div class="section-heading mb-4">
|
||||
<div>
|
||||
<div class="eyebrow">Operations board</div>
|
||||
<h1 class="section-title">Taxi → consumption performance snapshot.</h1>
|
||||
</div>
|
||||
<a href="/" class="btn btn-dark">New taxi request</a>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-lg-3"><div class="metric-card h-100"><span class="metric-label">Taxi requests</span><strong><?= (int) $stats['total_requests'] ?></strong><small>captured</small></div></div>
|
||||
<div class="col-6 col-lg-3"><div class="metric-card h-100"><span class="metric-label">Clicks</span><strong><?= (int) $stats['offers_clicked'] ?></strong><small>recommendation engagement</small></div></div>
|
||||
<div class="col-6 col-lg-3"><div class="metric-card h-100"><span class="metric-label">CTR</span><strong><?= h(number_format((float) $stats['ctr'], 1)) ?>%</strong><small>click-through rate</small></div></div>
|
||||
<div class="col-6 col-lg-3"><div class="metric-card h-100"><span class="metric-label">Conversion</span><strong><?= h(number_format((float) $stats['conversion'], 1)) ?>%</strong><small>booking from request</small></div></div>
|
||||
</div>
|
||||
<div class="card-panel p-0 overflow-hidden">
|
||||
<?php if ($requests === []): ?>
|
||||
<div class="empty-state text-center">
|
||||
<div class="empty-icon"><i class="bi bi-clipboard-data"></i></div>
|
||||
<h2 class="h4">No activity yet</h2>
|
||||
<p>The board will populate after the first taxi request and recommendation booking.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-slim align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Passenger</th>
|
||||
<th>Route context</th>
|
||||
<th>Offer booked</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($requests as $request): ?>
|
||||
<tr>
|
||||
<td>#<?= (int) $request['id'] ?></td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($request['passenger_name']) ?></div>
|
||||
<div class="text-secondary small"><?= h(source_channel_label((string) $request['source_channel'])) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div><?= h($request['pickup_point']) ?></div>
|
||||
<div class="text-secondary small">to <?= h(destination_label((string) $request['destination_area'])) ?></div>
|
||||
</td>
|
||||
<td><?= $request['booked_offer_title'] ? h((string) $request['booked_offer_title']) : '<span class="text-secondary">Pending</span>' ?></td>
|
||||
<td><span class="badge rounded-pill <?= h(status_badge_class((string) $request['status'])) ?>"><?= h(str_replace('_', ' ', (string) $request['status'])) ?></span></td>
|
||||
<td><?= h(date('d M H:i', strtotime((string) $request['created_at']))) ?></td>
|
||||
<td class="text-end"><a href="/request_detail.php?id=<?= (int) $request['id'] ?>" class="btn btn-sm btn-outline-dark">Detail</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php render_footer(); ?>
|
||||
Loading…
x
Reference in New Issue
Block a user