Deploy Ladill Merchant / deploy (push) Successful in 1m5s
Standardize customer-facing Ladill Pay checkout shells across products, open them on all viewports, and escape iframe callbacks back to the product flow.
152 lines
7.5 KiB
PHP
152 lines
7.5 KiB
PHP
@php
|
|
$c = $qrCode->content();
|
|
$title = $c['booking_title'] ?? $qrCode->label ?? 'Book an appointment';
|
|
$services = $c['services'] ?? [];
|
|
$hasCover = ! empty($c['cover_path']);
|
|
$hasLogo = ! empty($c['logo_path']);
|
|
$brand = $c['brand_color'] ?? '#4f46e5';
|
|
$slotsUrl = $qrCode->publicPath('booking/slots');
|
|
$bookUrl = $qrCode->publicPath('booking');
|
|
$csrfToken = csrf_token();
|
|
@endphp
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{ $title }}</title>
|
|
@include('partials.favicon')
|
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<style>[x-cloak] { display: none !important; } body { font-family: 'Figtree', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }</style>
|
|
</head>
|
|
<body class="min-h-screen bg-slate-50 antialiased">
|
|
<div x-data="{
|
|
step: 1,
|
|
serviceIndex: 0,
|
|
date: '',
|
|
slots: [],
|
|
selectedSlot: null,
|
|
loadingSlots: false,
|
|
loading: false,
|
|
errorMsg: '',
|
|
customerName: '',
|
|
customerEmail: '',
|
|
customerPhone: '',
|
|
showSheet: false,
|
|
checkoutUrl: '',
|
|
services: @js($services),
|
|
async loadSlots() {
|
|
if (!this.date) return;
|
|
this.loadingSlots = true;
|
|
this.selectedSlot = null;
|
|
this.slots = [];
|
|
try {
|
|
const res = await fetch(@js($slotsUrl) + '?date=' + encodeURIComponent(this.date) + '&service_index=' + this.serviceIndex);
|
|
const data = await res.json();
|
|
this.slots = data.slots || [];
|
|
} catch (e) {
|
|
this.errorMsg = 'Could not load times. Try again.';
|
|
}
|
|
this.loadingSlots = false;
|
|
},
|
|
async submitBooking() {
|
|
if (!this.customerName.trim()) { this.errorMsg = 'Enter your full name.'; return; }
|
|
if (!this.customerPhone.trim()) { this.errorMsg = 'Enter your phone number.'; return; }
|
|
if (!this.selectedSlot) { this.errorMsg = 'Pick a time slot.'; return; }
|
|
this.loading = true; this.errorMsg = '';
|
|
try {
|
|
const res = await fetch(@js($bookUrl), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': @js($csrfToken) },
|
|
body: JSON.stringify({
|
|
customer_name: this.customerName,
|
|
customer_email: this.customerEmail,
|
|
customer_phone: this.customerPhone,
|
|
service_index: this.serviceIndex,
|
|
starts_at: this.selectedSlot.starts_at,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) { this.errorMsg = data.error; this.loading = false; return; }
|
|
if (data.confirmed && data.redirect_url) {
|
|
window.location.href = data.redirect_url;
|
|
return;
|
|
}
|
|
this.checkoutUrl = data.checkout_url;
|
|
this.showSheet = true;
|
|
this.loading = false;
|
|
} catch (e) {
|
|
this.errorMsg = 'Network error. Please try again.';
|
|
this.loading = false;
|
|
}
|
|
}
|
|
}" class="mx-auto max-w-lg min-h-screen bg-white shadow-sm">
|
|
<div class="border-b border-slate-100 px-6 py-8 text-center" style="background: linear-gradient(135deg, {{ $brand }}15, #fff)">
|
|
@if($hasLogo)
|
|
<img src="{{ $qrCode->publicPath('menu-logo') }}" alt="" class="mx-auto h-14 w-14 rounded-2xl object-cover ring-2 ring-white shadow">
|
|
@endif
|
|
<h1 class="mt-4 text-2xl font-bold text-slate-900">{{ $title }}</h1>
|
|
@if(!empty($c['description']))
|
|
<p class="mt-2 text-sm text-slate-600">{{ $c['description'] }}</p>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="space-y-6 p-6">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">1. Service</p>
|
|
<div class="mt-3 space-y-2">
|
|
<template x-for="(svc, idx) in services" :key="idx">
|
|
<button type="button" @click="serviceIndex = idx; date = ''; slots = []; selectedSlot = null"
|
|
class="flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition"
|
|
:class="serviceIndex === idx ? 'border-indigo-300 bg-indigo-50' : 'border-slate-200 hover:border-slate-300'">
|
|
<div>
|
|
<p class="font-semibold text-slate-900" x-text="svc.name"></p>
|
|
<p class="text-xs text-slate-500" x-text="svc.duration_minutes + ' min' + (svc.description ? ' · ' + svc.description : '')"></p>
|
|
</div>
|
|
<p class="text-sm font-bold text-slate-900" x-text="svc.price_ghs > 0 ? 'GHS ' + Number(svc.price_ghs).toFixed(2) : 'Free'"></p>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">2. Date</p>
|
|
<input type="date" x-model="date" @change="loadSlots()" :min="new Date().toISOString().slice(0,10)"
|
|
class="mt-3 w-full rounded-xl border border-slate-200 px-4 py-3 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
|
</div>
|
|
|
|
<div x-show="date" x-cloak>
|
|
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">3. Time</p>
|
|
<p x-show="loadingSlots" class="mt-3 text-sm text-slate-500">Loading times…</p>
|
|
<p x-show="!loadingSlots && slots.length === 0 && date" class="mt-3 text-sm text-slate-500">No slots available this day.</p>
|
|
<div class="mt-3 grid grid-cols-3 gap-2">
|
|
<template x-for="slot in slots" :key="slot.starts_at">
|
|
<button type="button" @click="selectedSlot = slot"
|
|
class="rounded-xl border px-2 py-2.5 text-sm font-medium transition"
|
|
:class="selectedSlot?.starts_at === slot.starts_at ? 'border-indigo-400 bg-indigo-50 text-indigo-800' : 'border-slate-200 text-slate-700 hover:border-slate-300'"
|
|
x-text="slot.label"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="selectedSlot" x-cloak class="space-y-3">
|
|
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">4. Your details</p>
|
|
<input type="text" x-model="customerName" placeholder="Full name *" class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
|
<input type="tel" x-model="customerPhone" placeholder="Phone number *" class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
|
<div x-show="errorMsg" x-cloak class="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600" x-text="errorMsg"></div>
|
|
<button type="button" @click="submitBooking()" :disabled="loading"
|
|
class="w-full rounded-2xl py-4 text-base font-bold text-white transition disabled:opacity-60"
|
|
style="background: {{ $brand }}">
|
|
<span x-text="loading ? 'Processing…' : 'Confirm booking'">Confirm booking</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@include('partials.paystack-sheet')
|
|
</div>
|
|
</body>
|
|
</html>
|