Files
ladill-merchant/resources/views/public/qr/storefront.blade.php
T
isaaccladandClaude Opus 4.8 8b68fad82c
Deploy Ladill Merchant / deploy (push) Successful in 1m1s
Build real shop/menu/booking storefronts (replace church giving UI).
The storefront create/edit/manage UI and the public storefront page were
give's church-donation flow relabeled. Replace with genuine merchant
storefronts, reusing the QR-core manager + validator (which already build
sections/services content):

- Merchant create: type picker (shop/menu/booking) + per-type editors in a
  shared partial (products/menu items with prices; bookable services with
  days/hours). x-if per type so inputs never collide across types.
- StorefrontController store/update/create now delegate to QrCodeManagerService
  for all three types (was hardcoded church org_type/denomination/collection).
- Storefront show = QR preview + download + live toggle + delete + full editor.
- QrCodeManagerService: shop/menu/booking are free (no QR-wallet gate); drop
  the duplicate church TYPE_SHOP arm in hasContentChanges.
- Public: new storefront catalog+cart view for shop/menu (posts items[] to the
  existing order/Pay flow); route shop+menu to it. Booking already had a real
  public page; church TYPE_SHOP landing branch retired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:24:39 +00:00

203 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 ? route('qr.public.menu.logo', $qrCode->short_code) : null;
$coverUrl = $hasCover ? route('qr.public.menu.cover', $qrCode->short_code) : null;
$orderUrl = route('qr.public.order', $qrCode->short_code);
@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')
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</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">
{{-- 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="-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' }} · Powered by Ladill</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="{{ route('qr.public.item-image', [$qrCode->short_code, $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="email" x-model="email" placeholder="Email *" 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: '',
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.email.trim() || !this.phone.trim()) { this.errorMsg = 'Please fill in your name, email and phone.'; return; }
if (this.count === 0) { this.errorMsg = 'Your cart is empty.'; return; }
this.loading = true;
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; }
window.location = data.checkout_url;
} catch (e) {
this.errorMsg = 'Network error. Please try again.';
this.loading = false;
}
},
};
}
</script>
</body>
</html>