Deploy Ladill Merchant / deploy (push) Successful in 35s
- Storefronts use Figtree as the default font (shop/menu, booking, book) - Remove "Powered by Ladill" branding from the storefront header - Fix storefront logo hiding behind the cover (logo row now relative z-10) - Add a Share button to storefront QR downloads using the Web Share API (native mobile share sheet for image+link; clipboard fallback) - storefronts:import-legacy: --deactivate-source now also retires the platform copy of already-imported (skipped) codes, no --refresh needed Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 = route('qr.public.booking.slots', $qrCode->short_code);
|
|
$bookUrl = route('qr.public.booking.store', $qrCode->short_code);
|
|
$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.customerEmail.includes('@')) { this.errorMsg = 'Enter a valid email.'; 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;
|
|
}
|
|
if (window.innerWidth < 768) {
|
|
this.checkoutUrl = data.checkout_url;
|
|
this.showSheet = true;
|
|
this.loading = false;
|
|
} else {
|
|
window.location.href = data.checkout_url;
|
|
}
|
|
} 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="{{ route('qr.public.menu.logo', $qrCode->short_code) }}" 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="email" x-model="customerEmail" placeholder="Email *" class="w-full rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
|
<input type="tel" x-model="customerPhone" placeholder="Phone (optional)" 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>
|