From 8b68fad82cfcaf0b0ddc7573020b27c9963bf031 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 10 Jun 2026 10:24:39 +0000 Subject: [PATCH] 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 --- .../Merchant/StorefrontController.php | 64 ++-- .../Controllers/Public/QrScanController.php | 4 + app/Services/Qr/QrCodeManagerService.php | 7 +- .../merchant/storefronts/create.blade.php | 116 +++----- .../partials/editor-fields.blade.php | 276 ++++++++++++++++++ .../partials/payment-shipping.blade.php | 48 +++ .../views/merchant/storefronts/show.blade.php | 143 +++++---- .../views/public/qr/storefront.blade.php | 202 +++++++++++++ 8 files changed, 674 insertions(+), 186 deletions(-) create mode 100644 resources/views/merchant/storefronts/partials/editor-fields.blade.php create mode 100644 resources/views/merchant/storefronts/partials/payment-shipping.blade.php create mode 100644 resources/views/public/qr/storefront.blade.php diff --git a/app/Http/Controllers/Merchant/StorefrontController.php b/app/Http/Controllers/Merchant/StorefrontController.php index 86ebae3..7e3c373 100644 --- a/app/Http/Controllers/Merchant/StorefrontController.php +++ b/app/Http/Controllers/Merchant/StorefrontController.php @@ -7,6 +7,7 @@ use App\Models\QrCode; use App\Services\Qr\QrCodeManagerService; use App\Services\Qr\QrImageGeneratorService; use App\Services\Qr\QrPdfExporter; +use App\Support\Qr\QrTypeCatalog; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -42,9 +43,14 @@ class StorefrontController extends Controller ]); } - public function create(): View + public function create(Request $request): View { - return view('merchant.storefronts.create'); + $requestedType = (string) $request->query('type', QrCode::TYPE_SHOP); + if (! QrTypeCatalog::isValid($requestedType)) { + $requestedType = QrCode::TYPE_SHOP; + } + + return view('merchant.storefronts.create', ['requestedType' => $requestedType]); } public function store(Request $request): RedirectResponse @@ -53,28 +59,17 @@ class StorefrontController extends Controller $request->validate([ 'label' => ['required', 'string', 'max:120'], - 'name' => ['required', 'string', 'max:120'], - 'org_type' => ['required', 'in:church,school,mosque,ngo,club'], - 'denomination' => ['nullable', 'string', 'max:120'], - 'description' => ['nullable', 'string', 'max:2000'], - 'phone' => ['nullable', 'string', 'max:30'], - 'email' => ['nullable', 'email', 'max:200'], - 'website' => ['nullable', 'url', 'max:2048'], - 'address' => ['nullable', 'string', 'max:500'], - 'service_times' => ['nullable', 'string', 'max:200'], - 'brand_color' => ['nullable', 'string', 'max:20'], - 'collection_types' => ['nullable', 'array', 'min:1'], - 'collection_types.*' => ['string', 'max:80'], - 'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], - 'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'type' => ['required', 'in:'.implode(',', QrTypeCatalog::storefrontTypes())], + 'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'item_images' => ['nullable', 'array'], + 'item_images.*' => ['array'], + 'item_images.*.*' => ['image', 'max:4096'], ]); $data = array_merge($request->all(), [ - 'type' => QrCode::TYPE_SHOP, - 'currency' => 'GHS', - 'accepts_payment' => true, - 'church_logo' => $request->file('church_logo'), - 'church_cover' => $request->file('church_cover'), + 'menu_logo' => $request->file('menu_logo'), + 'menu_cover' => $request->file('menu_cover'), ]); try { @@ -83,7 +78,8 @@ class StorefrontController extends Controller return back()->withInput()->with('error', $e->getMessage()); } - return redirect()->route('merchant.storefronts.show', $qrCode)->with('success', 'Storefront created.'); + return redirect()->route('merchant.storefronts.show', $qrCode) + ->with('success', QrTypeCatalog::label($qrCode->type).' storefront created.'); } public function show(QrCode $storefront): View @@ -102,28 +98,18 @@ class StorefrontController extends Controller $request->validate([ 'label' => ['sometimes', 'string', 'max:120'], - 'name' => ['sometimes', 'string', 'max:120'], - 'org_type' => ['sometimes', 'in:church,school,mosque,ngo,club'], - 'denomination' => ['nullable', 'string', 'max:120'], - 'description' => ['nullable', 'string', 'max:2000'], - 'phone' => ['nullable', 'string', 'max:30'], - 'email' => ['nullable', 'email', 'max:200'], - 'website' => ['nullable', 'url', 'max:2048'], - 'address' => ['nullable', 'string', 'max:500'], - 'service_times' => ['nullable', 'string', 'max:200'], - 'brand_color' => ['nullable', 'string', 'max:20'], - 'collection_types' => ['nullable', 'array', 'min:1'], - 'collection_types.*' => ['string', 'max:80'], 'is_active' => ['sometimes', 'boolean'], - 'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], - 'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'], + 'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'], + 'item_images' => ['nullable', 'array'], + 'item_images.*' => ['array'], + 'item_images.*.*' => ['image', 'max:4096'], ]); $data = array_merge($request->all(), [ - 'accepts_payment' => true, 'is_active' => $request->boolean('is_active', $storefront->is_active), - 'church_logo' => $request->file('church_logo'), - 'church_cover' => $request->file('church_cover'), + 'menu_logo' => $request->file('menu_logo'), + 'menu_cover' => $request->file('menu_cover'), ]); try { diff --git a/app/Http/Controllers/Public/QrScanController.php b/app/Http/Controllers/Public/QrScanController.php index 849499d..58ea24d 100644 --- a/app/Http/Controllers/Public/QrScanController.php +++ b/app/Http/Controllers/Public/QrScanController.php @@ -43,6 +43,10 @@ class QrScanController extends Controller return view('public.qr.booking-landing', ['qrCode' => $qrCode]); } + if ($qrCode->isShopType() || $qrCode->isMenuType()) { + return view('public.qr.storefront', ['qrCode' => $qrCode]); + } + if ($qrCode->usesLandingPage()) { return view('public.qr.landing', ['qrCode' => $qrCode]); } diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 39571a4..50ce0e7 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -37,12 +37,12 @@ class QrCodeManagerService $wallet = $this->walletFor($user); $type = (string) ($data['type'] ?? ''); - if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true) && ! $wallet->canCreateQr()) { + if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP, QrCode::TYPE_MENU, QrCode::TYPE_BOOKING], true) && ! $wallet->canCreateQr()) { throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.'); } $validated = $this->payloadValidator->validateForCreate($type, $data); - $style = in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true) + $style = in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP, QrCode::TYPE_MENU, QrCode::TYPE_BOOKING], true) ? QrStyleDefaults::defaults() : QrStyleDefaults::merge($data['style'] ?? null); @@ -155,7 +155,7 @@ class QrCodeManagerService 'destination_updated_at' => now(), ]); - if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP], true)) { + if (! in_array($type, [QrCode::TYPE_PAYMENT, QrCode::TYPE_SHOP, QrCode::TYPE_MENU, QrCode::TYPE_BOOKING], true)) { $this->billing->debitForQrCreation($wallet, $qrCode); } $this->imageGenerator->generateAndStore($qrCode); @@ -352,7 +352,6 @@ class QrCodeManagerService QrCode::TYPE_LINK_LIST => ['links'], QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'], QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'], - QrCode::TYPE_SHOP => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'], QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'], QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'], QrCode::TYPE_DOCUMENT => ['allow_download'], diff --git a/resources/views/merchant/storefronts/create.blade.php b/resources/views/merchant/storefronts/create.blade.php index f543c67..e499005 100644 --- a/resources/views/merchant/storefronts/create.blade.php +++ b/resources/views/merchant/storefronts/create.blade.php @@ -1,98 +1,56 @@ - Create Giving Page - @php - $giveOldTypes = old('collection_types', ['Offering','Tithe','Order','Harvest']); - $giveOldOrg = old('org_type', 'shop'); - @endphp -
+ Create storefront + @php $createType = old('type', $requestedType ?? \App\Models\QrCode::TYPE_SHOP); @endphp + +
- ← All storefronts + + + All storefronts +

Create storefront

-

Set up your organisation, causes, and online merchant QR.

+

Pick what you're selling, then add your products, menu, or bookable services.

+ @if(session('error'))
{{ session('error') }}
@endif + @if($errors->any()) +
+
    + @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach +
+
+ @endif +
@csrf +
- - -
-
-

Organisation type

-
- @foreach(['shop'=>'Church','school'=>'School','mosque'=>'Mosque','ngo'=>'NGO','club'=>'Club'] as $key => $label) - +

Storefront type

+
+ @foreach(\App\Support\Qr\QrTypeCatalog::all() as $key => $meta) + @endforeach
- +
- +
- - Dashboard label + +

Private name to find this storefront in your dashboard.

- -
- - -
- -
-
- - -
-
- - -
-
-
- - -
-
-

Giving categories

- -
- -
-
- - -
-
+ + @include('merchant.storefronts.partials.editor-fields', ['content' => []]) +
diff --git a/resources/views/merchant/storefronts/partials/editor-fields.blade.php b/resources/views/merchant/storefronts/partials/editor-fields.blade.php new file mode 100644 index 0000000..db8b4eb --- /dev/null +++ b/resources/views/merchant/storefronts/partials/editor-fields.blade.php @@ -0,0 +1,276 @@ +@php + /** @var array $content */ + $content = $content ?? []; + $defaultSections = [['name' => 'Products', 'items' => [['name' => '', 'description' => '', 'price' => '', 'image_path' => '']]]]; + $seedSections = old('sections', ! empty($content['sections']) ? $content['sections'] : $defaultSections); + $seedServices = old('services', ! empty($content['services']) + ? $content['services'] + : [['name' => '', 'duration_minutes' => 30, 'price_ghs' => '', 'description' => '']]); +@endphp + +{{-- Only the active type's block is in the DOM (x-if), so inputs never collide across types. --}} + +{{-- ============================ BOOKING ============================ --}} + + +{{-- ============================ MENU ============================ --}} + + +{{-- ============================ SHOP ============================ --}} + diff --git a/resources/views/merchant/storefronts/partials/payment-shipping.blade.php b/resources/views/merchant/storefronts/partials/payment-shipping.blade.php new file mode 100644 index 0000000..4734d59 --- /dev/null +++ b/resources/views/merchant/storefronts/partials/payment-shipping.blade.php @@ -0,0 +1,48 @@ +@php + $content = $content ?? []; + $noun = $noun ?? 'items'; + $word = $word ?? 'shipping'; + $hasFlat = old('shipping_type', $content['shipping_type'] ?? 'none') === 'flat'; +@endphp + + +
+ + +
+
+ + +
+
+ + +

Waive the fee when the cart reaches this amount. Enter 0 to always charge.

+
+
+
diff --git a/resources/views/merchant/storefronts/show.blade.php b/resources/views/merchant/storefronts/show.blade.php index 6f05f1d..cde46a7 100644 --- a/resources/views/merchant/storefronts/show.blade.php +++ b/resources/views/merchant/storefronts/show.blade.php @@ -1,82 +1,97 @@ {{ $qrCode->label }} - @php $c = $qrCode->content(); @endphp -
+ @php + $content = $qrCode->content(); + $publicUrl = route('qr.public.resolve', ['shortCode' => $qrCode->short_code]); + $typeLabel = \App\Support\Qr\QrTypeCatalog::label($qrCode->type); + @endphp + +
-
- ← All storefronts +
+ + + All storefronts +

{{ $qrCode->label }}

-

{{ $c['name'] ?? '' }}@if(!empty($c['denomination'])) · {{ $c['denomination'] }}@endif

+

+ {{ $typeLabel }} + @if($qrCode->is_active) + ● Live + @else + ● Inactive + @endif +

- @include('merchant.storefronts.partials.header-actions', ['qrCode' => $qrCode]) + + View live + +
@if(session('success'))
{{ session('success') }}
@endif - -
-
- @include('merchant.storefronts.partials.preview-card', [ - 'qrCode' => $qrCode, - 'previewDataUri' => $previewDataUri, - 'showDownloads' => false, - ]) -

{{ $qrCode->publicUrl() }}

+ @if(session('error')) +
{{ session('error') }}
+ @endif + @if($errors->any()) +
+
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
+ @endif -
-
- @csrf - @method('PATCH') -

Settings

-
- - +
+ {{-- QR + sharing --}} +
+
+ @if($previewDataUri) + QR code + @endif +

{{ $publicUrl }}

+
+ @foreach(['png' => 'PNG', 'svg' => 'SVG', 'pdf' => 'PDF'] as $fmt => $flabel) + {{ $flabel }} + @endforeach
-
- - -
-
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- @foreach($c['collection_types'] ?? ['Offering','Tithe','Order'] as $i => $type) - - @endforeach - - - -
-

Danger zone

-

Remove this storefront and deactivate its QR.

-
- @include('merchant.storefronts.partials.delete-modal', ['qrCode' => $qrCode]) + +
+ @csrf @method('DELETE') + +
+ + {{-- Edit form --}} +
+ @csrf @method('PATCH') + +
+ + +
+ + + + @include('merchant.storefronts.partials.editor-fields', ['content' => $content]) + + +
diff --git a/resources/views/public/qr/storefront.blade.php b/resources/views/public/qr/storefront.blade.php new file mode 100644 index 0000000..c5a79b1 --- /dev/null +++ b/resources/views/public/qr/storefront.blade.php @@ -0,0 +1,202 @@ +@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 + + + + + + {{ $title }} + @include('partials.favicon') + + + + +
+ + {{-- Cover + header --}} +
+ @if($coverUrl)@endif +
+
+
+ @if($logoUrl) + {{ $title }} + @else + {{ mb_substr($title, 0, 1) }} + @endif +
+
+

{{ $title }}

+

{{ $isShop ? 'Shop' : 'Menu' }} · Powered by Ladill

+
+
+ + @if(session('order_error')) +
{{ session('order_error') }}
+ @endif + + {{-- Catalogue --}} +
+ @forelse($sections as $section) +
+

{{ $section['name'] ?? '' }}

+
+ @foreach(($section['items'] ?? []) as $iIdx => $item) + @php + $price = (float) ($item['price'] ?? 0); + $key = ($section['name'] ?? 's').'-'.$iIdx; + $img = ! empty($item['image_path']); + @endphp +
+ @if($img) + + @endif +
+

{{ $item['name'] ?? '' }}

+ @if(!empty($item['description']))

{{ $item['description'] }}

@endif +

{{ $cur }} {{ number_format($price, 2) }}

+
+ @if($acceptsPayment && $price > 0) + + @endif +
+ @endforeach +
+
+ @empty +

This storefront has no items yet.

+ @endforelse +
+ + @if($acceptsPayment) + {{-- Cart bar --}} +
+
+
+ + +
+ +
+
+ + {{-- Cart / checkout sheet --}} +
+
+
+
+

Your order

+ +
+ +
+ +
+ +
+
Subtotal
+
Delivery
+
Total
+
+ +
+ + + +
+ +

+ + +

Secure payment · You'll get an order confirmation.

+
+
+ @endif +
+ + + +