Deploy Ladill Merchant / deploy (push) Successful in 38s
Use the shared Inline sheet, hold busy until payment UI opens, and avoid locking page scroll during the handoff.
157 lines
7.9 KiB
PHP
157 lines
7.9 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: '',
|
|
accessCode: '',
|
|
publicKey: '',
|
|
returnUrl: '',
|
|
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) {
|
|
window.LadillPayCheckout?.cancel?.(); 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; window.LadillPayCheckout?.prepare?.(); 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) { window.LadillPayCheckout?.cancel?.(); 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.accessCode = data.access_code || '';
|
|
this.publicKey = data.public_key || '';
|
|
this.returnUrl = data.callback_url || '';
|
|
this.showSheet = true; // loading cleared when payment UI opens (ladill-pay-opened)
|
|
} catch (e) {
|
|
window.LadillPayCheckout?.cancel?.(); 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>
|