diff --git a/app.php b/app.php new file mode 100644 index 0000000..69efd72 --- /dev/null +++ b/app.php @@ -0,0 +1,547 @@ + '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'] ?? ''; + ?> + + +
+ + += ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-This page will update automatically as the plan is implemented.
-Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
= h($heroDescription) ?>
+Save the ride context and instantly generate contextual offers.
+Store pickup, destination, time, party size, and channel using a focused intake form.
+Score nearby restaurants and experiences by geography, timing, quality, and strategic value.
+Confirm the best offer in one click and keep a simple log for CTR and taxi-to-booking conversion.
+Submit the first taxi flow above to populate the operations board and recommendation funnel.
+| Passenger | +Destination | +Channel | +Status | +Created | ++ |
|---|---|---|---|---|---|
|
+ = h($request['passenger_name']) ?>
+ = h($request['pickup_point']) ?>
+ |
+ = h(destination_label((string) $request['destination_area'])) ?> | += h(source_channel_label((string) $request['source_channel'])) ?> | += h(str_replace('_', ' ', (string) $request['status'])) ?> | += h(date('d M H:i', strtotime((string) $request['created_at']))) ?> | +Detail | +
Minimal operational data captured in a single flow record.
+Simple event capture inside the single record.
+Stored as the first contextual recommendation set for this request.
+= h($offer['description']) ?>
+The taxi flow may have expired or the link is incomplete.
+ Create a new request +Scored by destination fit, time window, quality, and simple strategic weighting.
+= h($offer['description']) ?>
+ +The board will populate after the first taxi request and recommendation booking.
+| ID | +Passenger | +Route context | +Offer booked | +Status | +Created | ++ |
|---|---|---|---|---|---|---|
| #= (int) $request['id'] ?> | +
+ = h($request['passenger_name']) ?>
+ = h(source_channel_label((string) $request['source_channel'])) ?>
+ |
+
+ = h($request['pickup_point']) ?>
+ to = h(destination_label((string) $request['destination_area'])) ?>
+ |
+ = $request['booked_offer_title'] ? h((string) $request['booked_offer_title']) : 'Pending' ?> | += h(str_replace('_', ' ', (string) $request['status'])) ?> | += h(date('d M H:i', strtotime((string) $request['created_at']))) ?> | +Detail | +