Deploy Ladill Merchant / deploy (push) Successful in 46s
Public order/pay POSTs hit ladl.link first; satellite CSRF tokens cannot bind to Link (or platform/satellite) sessions, which returned 419 and showed "Could not start checkout." Except public checkout paths and return access_code from owner-gateway Paystack init. Sync contained Paystack sheet.
218 lines
12 KiB
PHP
218 lines
12 KiB
PHP
@php
|
||
$c = $qrCode->content();
|
||
$sections = $c['sections'] ?? [];
|
||
$title = $c['title'] ?? $qrCode->label ?? 'Storefront';
|
||
$cur = $c['currency'] ?? 'GHS';
|
||
$brand = $c['brand_color'] ?? '#4f46e5';
|
||
$acceptsPayment = ! empty($c['accepts_payment']);
|
||
$shippingType = $c['shipping_type'] ?? 'none';
|
||
$shippingFee = (float) ($c['shipping_fee'] ?? 0);
|
||
$freeAbove = (float) ($c['free_shipping_above'] ?? 0);
|
||
$isShop = $qrCode->type === \App\Models\QrCode::TYPE_SHOP;
|
||
$hasLogo = ! empty($c['logo_path']);
|
||
$hasCover = ! empty($c['cover_path']);
|
||
$logoUrl = $hasLogo ? $qrCode->publicPath('menu-logo') : null;
|
||
$coverUrl = $hasCover ? $qrCode->publicPath('menu-cover') : null;
|
||
$orderUrl = $qrCode->publicPath('order');
|
||
@endphp
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<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>body{font-family:'Figtree',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}[x-cloak]{display:none!important}</style>
|
||
</head>
|
||
<body class="bg-slate-50 text-slate-900 antialiased">
|
||
<div x-data="storefront({
|
||
cur: @js($cur),
|
||
acceptsPayment: {{ $acceptsPayment ? 'true' : 'false' }},
|
||
shippingType: @js($shippingType),
|
||
shippingFee: {{ $shippingFee }},
|
||
freeAbove: {{ $freeAbove }},
|
||
orderUrl: @js($orderUrl),
|
||
csrf: @js(csrf_token()),
|
||
})" class="mx-auto max-w-2xl px-4 pb-32 pt-0">
|
||
@include('partials.paystack-sheet')
|
||
|
||
{{-- Cover + header --}}
|
||
<div class="relative -mx-4 mb-4 h-36 sm:h-44" style="background:{{ $brand }}">
|
||
@if($coverUrl)<img src="{{ $coverUrl }}" alt="" class="h-full w-full object-cover opacity-90">@endif
|
||
</div>
|
||
<div class="relative z-10 -mt-12 flex items-end gap-4 px-1">
|
||
<div class="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-white shadow-md ring-4 ring-white" style="color:{{ $brand }}">
|
||
@if($logoUrl)
|
||
<img src="{{ $logoUrl }}" alt="{{ $title }}" class="h-full w-full object-cover">
|
||
@else
|
||
<span class="text-2xl font-black">{{ mb_substr($title, 0, 1) }}</span>
|
||
@endif
|
||
</div>
|
||
<div class="pb-1">
|
||
<h1 class="text-xl font-bold text-slate-900">{{ $title }}</h1>
|
||
<p class="text-xs text-slate-500">{{ $isShop ? 'Shop' : 'Menu' }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
@if(session('order_error'))
|
||
<div class="mt-4 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('order_error') }}</div>
|
||
@endif
|
||
|
||
{{-- Catalogue --}}
|
||
<div class="mt-6 space-y-7">
|
||
@forelse($sections as $section)
|
||
<div>
|
||
<h2 class="mb-3 text-sm font-bold uppercase tracking-wide text-slate-500">{{ $section['name'] ?? '' }}</h2>
|
||
<div class="space-y-3">
|
||
@foreach(($section['items'] ?? []) as $iIdx => $item)
|
||
@php
|
||
$price = (float) ($item['price'] ?? 0);
|
||
$key = ($section['name'] ?? 's').'-'.$iIdx;
|
||
$img = ! empty($item['image_path']);
|
||
@endphp
|
||
<div class="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white p-3">
|
||
@if($img)
|
||
<img src="{{ $qrCode->publicPath('item-image/'.$loop->parent->index.'/'.$iIdx) }}" alt="" class="h-16 w-16 shrink-0 rounded-xl object-cover">
|
||
@endif
|
||
<div class="min-w-0 flex-1">
|
||
<p class="truncate text-sm font-semibold text-slate-900">{{ $item['name'] ?? '' }}</p>
|
||
@if(!empty($item['description']))<p class="truncate text-xs text-slate-500">{{ $item['description'] }}</p>@endif
|
||
<p class="mt-0.5 text-sm font-bold" style="color:{{ $brand }}">{{ $cur }} {{ number_format($price, 2) }}</p>
|
||
</div>
|
||
@if($acceptsPayment && $price > 0)
|
||
<button type="button"
|
||
@click="add(@js($item['name'] ?? ''), {{ $price }}, @js($key))"
|
||
class="shrink-0 rounded-xl px-3 py-2 text-xs font-bold text-white shadow-sm" style="background:{{ $brand }}">Add</button>
|
||
@endif
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
</div>
|
||
@empty
|
||
<p class="rounded-xl border border-slate-200 bg-white px-4 py-8 text-center text-sm text-slate-500">This storefront has no items yet.</p>
|
||
@endforelse
|
||
</div>
|
||
|
||
@if($acceptsPayment)
|
||
{{-- Cart bar --}}
|
||
<div x-show="count > 0" x-cloak class="fixed inset-x-0 bottom-0 z-30 border-t border-slate-200 bg-white/95 backdrop-blur">
|
||
<div class="mx-auto flex max-w-2xl items-center justify-between gap-3 px-4 py-3">
|
||
<div class="text-sm">
|
||
<span class="font-semibold text-slate-900" x-text="count + (count === 1 ? ' item' : ' items')"></span>
|
||
<span class="text-slate-500" x-text="'· ' + @js($cur) + ' ' + total.toFixed(2)"></span>
|
||
</div>
|
||
<button type="button" @click="open = true" class="rounded-xl px-5 py-2.5 text-sm font-bold text-white shadow-sm" style="background:{{ $brand }}">View cart</button>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Cart / checkout sheet --}}
|
||
<div x-show="open" x-cloak class="fixed inset-0 z-40 flex items-end justify-center sm:items-center" @keydown.escape.window="open = false">
|
||
<div class="absolute inset-0 bg-black/40" @click="open = false"></div>
|
||
<div class="relative max-h-[90vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-5 shadow-xl sm:rounded-3xl">
|
||
<div class="mb-4 flex items-center justify-between">
|
||
<h3 class="text-lg font-bold text-slate-900">Your order</h3>
|
||
<button type="button" @click="open = false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<template x-for="(line, k) in cart" :key="k">
|
||
<div class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2">
|
||
<div class="min-w-0 flex-1">
|
||
<p class="truncate text-sm font-semibold text-slate-800" x-text="line.name"></p>
|
||
<p class="text-xs text-slate-500" x-text="@js($cur) + ' ' + line.price.toFixed(2)"></p>
|
||
</div>
|
||
<button type="button" @click="dec(k)" class="h-7 w-7 rounded-lg border border-slate-200 text-slate-600">−</button>
|
||
<span class="w-6 text-center text-sm font-semibold" x-text="line.qty"></span>
|
||
<button type="button" @click="inc(k)" class="h-7 w-7 rounded-lg border border-slate-200 text-slate-600">+</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="mt-4 space-y-1 border-t border-slate-100 pt-3 text-sm">
|
||
<div class="flex justify-between text-slate-600"><span>Subtotal</span><span x-text="@js($cur) + ' ' + subtotal.toFixed(2)"></span></div>
|
||
<div x-show="shipping > 0" class="flex justify-between text-slate-600"><span>Delivery</span><span x-text="@js($cur) + ' ' + shipping.toFixed(2)"></span></div>
|
||
<div class="flex justify-between text-base font-bold text-slate-900"><span>Total</span><span x-text="@js($cur) + ' ' + total.toFixed(2)"></span></div>
|
||
</div>
|
||
|
||
<div class="mt-4 space-y-2">
|
||
<input type="text" x-model="name" placeholder="Your name *" class="w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||
<input type="tel" x-model="phone" placeholder="Phone *" class="w-full rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm">
|
||
</div>
|
||
|
||
<p x-show="errorMsg" x-cloak class="mt-3 rounded-xl border border-red-100 bg-red-50 px-3 py-2 text-sm text-red-600" x-text="errorMsg"></p>
|
||
|
||
<button type="button" @click="checkout()" :disabled="loading"
|
||
class="mt-4 w-full rounded-xl py-3 text-sm font-bold text-white shadow-sm disabled:opacity-60" style="background:{{ $brand }}">
|
||
<span x-text="loading ? 'Processing…' : ('Pay ' + @js($cur) + ' ' + total.toFixed(2))"></span>
|
||
</button>
|
||
<p class="mt-2 text-center text-[11px] text-slate-400">Secure payment · You'll get an order confirmation.</p>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<script>
|
||
function storefront(cfg) {
|
||
return {
|
||
...cfg,
|
||
open: false,
|
||
loading: false,
|
||
errorMsg: '',
|
||
showSheet: false,
|
||
checkoutUrl: '',
|
||
accessCode: '',
|
||
publicKey: '',
|
||
returnUrl: '',
|
||
name: '', email: '', phone: '',
|
||
cart: {},
|
||
add(name, price, key) {
|
||
if (this.cart[key]) { this.cart[key].qty++; }
|
||
else { this.cart[key] = { name, price, qty: 1 }; }
|
||
},
|
||
inc(k) { this.cart[k].qty++; },
|
||
dec(k) { if (--this.cart[k].qty <= 0) delete this.cart[k]; if (Object.keys(this.cart).length === 0) this.open = false; },
|
||
get count() { return Object.values(this.cart).reduce((n, l) => n + l.qty, 0); },
|
||
get subtotal() { return Object.values(this.cart).reduce((s, l) => s + l.price * l.qty, 0); },
|
||
get shipping() {
|
||
if (this.shippingType !== 'flat' || this.subtotal <= 0) return 0;
|
||
if (this.freeAbove > 0 && this.subtotal >= this.freeAbove) return 0;
|
||
return this.shippingFee;
|
||
},
|
||
get total() { return this.subtotal + this.shipping; },
|
||
async checkout() {
|
||
this.errorMsg = '';
|
||
if (!this.name.trim() || !this.phone.trim()) { this.errorMsg = 'Please fill in your name and phone.'; return; }
|
||
if (this.count === 0) { this.errorMsg = 'Your cart is empty.'; return; }
|
||
this.loading = true; window.LadillPayCheckout?.prepare?.();
|
||
try {
|
||
const items = Object.values(this.cart).map(l => ({ name: l.name, price: l.price, qty: l.qty }));
|
||
const res = await fetch(this.orderUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': this.csrf, 'Accept': 'application/json' },
|
||
body: JSON.stringify({ customer_name: this.name, customer_email: this.email, customer_phone: this.phone, shipping_fee: this.shipping, items }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) { this.errorMsg = data.error || 'Could not start checkout.'; this.loading = false; return; }
|
||
if (!data.checkout_url) { this.errorMsg = 'Could not start checkout.'; this.loading = false; return; }
|
||
this.open = false;
|
||
this.checkoutUrl = data.checkout_url;
|
||
this.accessCode = data.access_code || '';
|
||
this.publicKey = data.public_key || '';
|
||
this.returnUrl = data.callback_url || '';
|
||
this.showSheet = true;
|
||
this.loading = false;
|
||
} catch (e) {
|
||
window.LadillPayCheckout?.cancel?.(); this.errorMsg = 'Network error. Please try again.';
|
||
this.loading = false;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|