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'] ?? ''; + ?> + + + + + + <?= h($title) ?> + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+ + + + + { - 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(); + } + }); }); }); diff --git a/book_offer.php b/book_offer.php new file mode 100644 index 0000000..a3621cf --- /dev/null +++ b/book_offer.php @@ -0,0 +1,24 @@ +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"; +} diff --git a/index.php b/index.php index 7205f3d..eba65e0 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,265 @@ '', + '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'); ?> - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+
+
+
+
+
+
Initial MVP slice · tourist flow
+

Taxi confirmation with immediate upsell recommendations.

+

+
+ Taxi request + Top-3 recommendations + Booking attribution +
+
+
+ Requests + + captured end-to-end +
+
+ CTR + % + offer click-through +
+
+ Bookings + + from taxi flow +
+
+ Avg. delay + + request to booking +
+
+
+
+
+
+
+
+

Create taxi request

+

Save the ride context and instantly generate contextual offers.

+
+ Live MVP +
+ + + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ +
+
+
+
+
-
- - - + + +
+
+
+
+
Workflow
+

The first thin slice follows the real tourist journey.

+
+ Open operations board +
+
+
+
+ 01 +

Capture the taxi request

+

Store pickup, destination, time, party size, and channel using a focused intake form.

+
+
+
+
+ 02 +

Generate contextual offers

+

Score nearby restaurants and experiences by geography, timing, quality, and strategic value.

+
+
+
+
+ 03 +

Book and attribute

+

Confirm the best offer in one click and keep a simple log for CTR and taxi-to-booking conversion.

+
+
+
+
+
+ +
+
+
+
+
Recent activity
+

Live requests and current booking outcomes.

+
+
+
+ +
+
+

No requests yet

+

Submit the first taxi flow above to populate the operations board and recommendation funnel.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
PassengerDestinationChannelStatusCreated
+
+
+
Detail
+
+ +
+
+
+ diff --git a/request_detail.php b/request_detail.php new file mode 100644 index 0000000..8f235fa --- /dev/null +++ b/request_detail.php @@ -0,0 +1,123 @@ + +
+
+
+

Request not found

+ Back to board +
+
+
+ +
+
+
+
+
Request detail
+

Taxi request #

+
+ +
+
+
+
+
+
+

Request summary

+

Minimal operational data captured in a single flow record.

+
+ +
+
+
Passenger
+
Pickup point
+
Destination
+
Pickup time
+
Channel
+
Party size
+
Notes
+
+
+
+
+
+

Attribution timeline

+

Simple event capture inside the single record.

+
+
+
+ Taxi request created +
+
+
+ Recommendations shown +
Offer set:
+
+
+ Recommendation clicked +
+
+
+ Booking completed +
+
+
+
+
+
+
+
+

Recommended offers

+

Stored as the first contextual recommendation set for this request.

+
+
+
+ +
+
+
+

+

+
From € ·
+ + Booked + + Recommended + +
+
+ +
+
+
+
+
+
+ diff --git a/request_success.php b/request_success.php new file mode 100644 index 0000000..33db222 --- /dev/null +++ b/request_success.php @@ -0,0 +1,138 @@ + +
+
+
+

Request not found

+

The taxi flow may have expired or the link is incomplete.

+ Create a new request +
+
+
+ +
+
+
+
+
+
Taxi confirmed
+

Ride for

+
+
+
Pickup
+
+
+
+
Destination
+
+
+
+
ETA
+
min
+
+
+
Channel
+
+
+
+
Pickup time
+
+
+
+
+
Attribution source: taxi confirmation screen · request #
+ +
+
+
+
+
+
+
Top 3 contextual offers
+

Recommended for the confirmed route

+

Scored by destination fit, time window, quality, and simple strategic weighting.

+
+ +
+
+ + + +
+ +
+
+
+
+
+

+
+ Score +
+

+
    +
  • +
  • ·
  • +
  • From € · Commission
  • +
+
+ + + +
+
Meeting point:
+ + + +
+ + + +
+ +
+
+ +
+
+
+
+
+ diff --git a/requests.php b/requests.php new file mode 100644 index 0000000..b9d5854 --- /dev/null +++ b/requests.php @@ -0,0 +1,76 @@ + +
+
+
+
+
Operations board
+

Taxi → consumption performance snapshot.

+
+ New taxi request +
+
+
Taxi requestscaptured
+
Clicksrecommendation engagement
+
CTR%click-through rate
+
Conversion%booking from request
+
+
+ +
+
+

No activity yet

+

The board will populate after the first taxi request and recommendation booking.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDPassengerRoute contextOffer bookedStatusCreated
# +
+
+
+
+
to
+
Pending' ?>Detail
+
+ +
+
+
+