Prune the upstream Mini/QR mobile subsystem from POS
Deploy Ladill POS / deploy (push) Successful in 33s

POS was forked from Ladill Mini and carried Mini's entire mobile/QR
subsystem (API, QR codes, wallet, payments, push, Afia) which polluted the
ladill_pos schema with 8 unused tables and left ~half the test suite red.

Remove the dead subsystem: Api/Mini/Qr/Public/Search/WellKnown controllers,
Mini/Qr/Afia/Notifications services, the 8 unused models, QrCodePolicy,
Support/Qr + Support/Events, the two mini: scheduled commands, the Mini/QR
view trees, and their (failing) tests. Empty routes/api.php (POS is web-only)
and strip dead schedules from routes/console.php.

Keep QrTeamMember — it is the platform team-membership model that POS's
SetActingAccount middleware and SSO login depend on for multi-account access.
Also keep notifications + personal_access_tokens (used by POS).

Drops 7 migrations (qr product/settings, mini_payments, push tokens); the 8
orphan tables are dropped from the live ladill_pos DB separately. Test suite
is green (8 passed) and all routes resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-24 00:08:16 +00:00
co-authored by Claude Opus 4.8
parent 57862acb47
commit 46b5091b1e
119 changed files with 3 additions and 15877 deletions
@@ -1,5 +0,0 @@
@props(['variant' => 'banner'])
<p {{ $attributes->merge(['class' => 'mt-1 text-[10px] leading-snug text-slate-400']) }}>
Recommended: {{ \App\Support\Qr\QrCoverImageSpec::label($variant) }}. JPG, PNG, or WebP.
</p>
@@ -1,30 +0,0 @@
@props([
'id',
'title',
'description',
])
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<button type="button"
@click="toggleSection('{{ $id }}')"
class="flex w-full items-start gap-4 px-5 py-4 text-left transition hover:bg-slate-50/80">
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
{!! $icon ?? '' !!}
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-slate-900">{{ $title }}</span>
<span class="mt-0.5 block text-xs leading-relaxed text-slate-500">{{ $description }}</span>
</span>
<svg class="mt-1 h-5 w-5 shrink-0 text-slate-400 transition"
:class="openSection === '{{ $id }}' ? 'rotate-180' : ''"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/>
</svg>
</button>
<div x-show="openSection === '{{ $id }}'" x-cloak class="border-t border-slate-100">
<div class="px-5 py-5">
{{ $slot }}
</div>
</div>
</div>
-68
View File
@@ -1,68 +0,0 @@
<x-user-layout>
<x-slot name="title">Overview</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Overview</h1>
<p class="mt-1 text-sm text-slate-500">Today's takings, your payment QRs, and recent incoming payments.</p>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Today's takings</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($todayMinor) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Payments today</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ number_format($todayCount) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Payment QRs</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $paymentQrCount }}</p>
</div>
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-5">
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<h2 class="font-semibold text-slate-900">Recent payments</h2>
<a href="{{ route('mini.payments.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">View all</a>
</div>
@if($recentPayments->isEmpty())
<p class="px-6 py-8 text-sm text-slate-500">No payments yet. Print your payment QR and start accepting payments.</p>
@else
<div class="divide-y divide-slate-100">
@foreach($recentPayments as $payment)
<div class="flex items-center justify-between px-6 py-4">
<div>
<p class="font-medium text-slate-900">{{ $payment->payer_name ?: 'Walk-in customer' }}</p>
<p class="text-xs text-slate-500">{{ $payment->qrCode?->label }} · {{ $payment->paid_at?->diffForHumans() }}</p>
</div>
<span class="text-sm font-semibold text-emerald-700">{{ $fmt($payment->merchant_amount_minor) }}</span>
</div>
@endforeach
</div>
@endif
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="font-semibold text-slate-900">Quick links</h2>
<div class="mt-4 space-y-3">
<a href="{{ route('mini.payment-qrs.create') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
<span class="font-medium text-slate-700">Create payment QR</span>
<span class="text-slate-400"></span>
</a>
<a href="{{ route('mini.payment-qrs.index') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
<span class="font-medium text-slate-700">My payment QRs</span>
<span class="text-slate-400"></span>
</a>
<a href="{{ route('mini.payouts') }}" class="flex items-center justify-between rounded-xl border border-slate-100 px-4 py-3 text-sm hover:bg-slate-50">
<span class="font-medium text-slate-700">Payouts & withdrawals</span>
<span class="text-slate-400"></span>
</a>
</div>
</div>
</div>
</div>
</x-user-layout>
@@ -1,48 +0,0 @@
<x-user-layout>
<x-slot name="title">Create Payment QR</x-slot>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center gap-3 py-1 lg:hidden">
<a href="{{ route('mini.payment-qrs.index') }}"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition hover:text-slate-900">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/>
</svg>
</a>
<img src="{{ asset('images/logo/ladillmini-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillmini-logo.svg')) ?: '1' }}"
alt="Ladill Mini" class="h-5 w-auto shrink-0">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900">Create payment QR</span>
</div>
<div class="hidden lg:block">
<a href="{{ route('mini.payment-qrs.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> Back to payment QRs</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Create payment QR</h1>
<p class="mt-1 text-sm text-slate-500">Customers scan, enter an amount, and pay. Your QR is generated automatically no styling needed.</p>
</div>
@if(session('error'))
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
@endif
<form method="post" action="{{ route('mini.payment-qrs.store') }}" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">QR label</label>
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120" placeholder="e.g. Main till"
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Internal name shown in your dashboard only.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Business name</label>
<input type="text" name="business_name" value="{{ old('business_name') }}" required maxlength="120" placeholder="e.g. Kofi's Shop"
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">Shown on the customer payment screen.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch / till (optional)</label>
<input type="text" name="branch_label" value="{{ old('branch_label') }}" maxlength="80" placeholder="e.g. Osu branch"
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<button type="submit" class="btn-primary w-full">
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
Create payment QR
</button>
</form>
</div>
</x-user-layout>
@@ -1,40 +0,0 @@
<x-user-layout>
<x-slot name="title">My Payment QR</x-slot>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-slate-900">My Payment QR</h1>
<p class="mt-1 text-sm text-slate-500">Static QRs to print or display one per till or branch.</p>
</div>
<x-btn.create :href="route('mini.payment-qrs.create')">New payment QR</x-btn.create>
</div>
@if(session('success'))
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
@if($qrCodes->isEmpty())
<div class="rounded-2xl border border-dashed border-slate-200 bg-white px-6 py-12 text-center">
<p class="text-sm text-slate-500">No payment QRs yet.</p>
<a href="{{ route('mini.payment-qrs.create') }}" class="mt-3 inline-block text-sm font-semibold text-indigo-600 hover:text-indigo-800">Create your first payment QR</a>
</div>
@else
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach($qrCodes as $qr)
@php $c = $qr->content(); @endphp
<a href="{{ route('mini.payment-qrs.show', $qr) }}" class="rounded-2xl border border-slate-200 bg-white p-5 transition hover:border-indigo-200 hover:shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ $qr->label }}</p>
<p class="mt-0.5 text-sm text-slate-500">{{ $c['business_name'] ?? '' }}</p>
@if(!empty($c['branch_label']))
<p class="mt-1 text-xs text-slate-400">{{ $c['branch_label'] }}</p>
@endif
</div>
<img src="{{ $previewDataUris[$qr->id] ?? '' }}" alt="" class="h-16 w-16 rounded-lg border border-slate-100 bg-white object-contain p-1">
</div>
<p class="mt-4 truncate text-xs text-indigo-600">{{ $qr->publicUrl() }}</p>
</a>
@endforeach
</div>
@endif
</div>
</x-user-layout>
@@ -1,38 +0,0 @@
@php
$businessName = $qrCode->content()['business_name'] ?? $qrCode->label;
@endphp
<x-modal name="delete-payment-qr" maxWidth="md">
<div class="px-5 pb-6 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
<div class="flex flex-col items-center text-center sm:items-start sm:text-left">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"/>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-slate-900">Delete payment QR?</h2>
<p class="mt-2 text-sm leading-relaxed text-slate-500">
<span class="font-medium text-slate-700">{{ $qrCode->label }}</span>
@if($businessName !== $qrCode->label)
<span class="text-slate-400">· {{ $businessName }}</span>
@endif
will be removed permanently. The payment link will stop working and printed codes for this till will no longer accept payments.
</p>
<p class="mt-3 break-all rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-500">{{ $qrCode->publicUrl() }}</p>
</div>
<form method="post" action="{{ route('mini.payment-qrs.destroy', $qrCode) }}" class="mt-6 flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end">
@csrf
@method('DELETE')
<button type="button"
@click="$dispatch('close-modal', 'delete-payment-qr')"
class="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Cancel
</button>
<button type="submit"
class="rounded-xl bg-red-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-700">
Delete payment QR
</button>
</form>
</div>
</x-modal>
@@ -1,37 +0,0 @@
@php
$shareUrl = $qrCode->publicUrl();
$shareEnc = urlencode($shareUrl);
$shareText = urlencode($qrCode->label . ' — pay with QR');
@endphp
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'png']) }}"
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">PNG</a>
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'svg']) }}"
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">SVG</a>
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'pdf']) }}"
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">PDF</a>
<div class="relative" x-data="{ open: false, copied: false }" @click.outside="open = false">
<button type="button"
@click="if(navigator.share){navigator.share({title:@js($qrCode->label),url:@js($shareUrl)}).catch(()=>{open=!open})}else{open=!open}"
class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50">
Share
</button>
<div x-show="open" x-cloak
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
<a href="https://wa.me/?text={{ $shareText }}%20{{ $shareEnc }}" target="_blank" rel="noopener"
@click="open = false"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
WhatsApp
</a>
<button type="button"
@click="navigator.clipboard.writeText(@js($shareUrl)).then(() => { copied = true; setTimeout(() => { copied = false; open = false }, 1500) })"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition hover:bg-slate-50"
:class="copied ? 'text-emerald-600' : 'text-slate-700'">
<span x-text="copied ? 'Copied!' : 'Copy link'">Copy link</span>
</button>
</div>
</div>
</div>
@@ -1,35 +0,0 @@
@php
$showDownloads = $showDownloads ?? true;
@endphp
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="relative flex items-center justify-center bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40 px-8 py-10">
<div class="w-full max-w-xs">
<div class="overflow-hidden rounded-2xl bg-white p-4 shadow-2xl ring-1 ring-black/5">
<img src="{{ $previewDataUri }}"
alt="Payment QR code for {{ $qrCode->label }}"
class="aspect-square w-full object-contain">
</div>
</div>
</div>
@if ($showDownloads)
<div class="border-t border-slate-100 bg-slate-50/50 px-6 py-5">
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Download</p>
<div class="grid grid-cols-3 gap-2">
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'png']) }}"
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-indigo-300 hover:bg-indigo-50">
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-indigo-700">PNG</span>
</a>
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'svg']) }}"
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-violet-300 hover:bg-violet-50">
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-violet-700">SVG</span>
</a>
<a href="{{ route('mini.payment-qrs.download', [$qrCode, 'pdf']) }}"
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-rose-300 hover:bg-rose-50">
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-rose-700">PDF</span>
</a>
</div>
</div>
@endif
</div>
@@ -1,81 +0,0 @@
<x-user-layout>
<x-slot name="title">{{ $qrCode->label }}</x-slot>
@php $c = $qrCode->content(); @endphp
<div class="mx-auto max-w-6xl space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 py-1 lg:hidden">
<a href="{{ route('mini.payment-qrs.index') }}"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition hover:text-slate-900">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</a>
<img src="{{ asset('images/logo/ladillmini-logo.svg') }}?v={{ @filemtime(public_path('images/logo/ladillmini-logo.svg')) ?: '1' }}"
alt="Ladill Mini" class="h-5 w-auto shrink-0">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900">{{ $qrCode->label }}</span>
</div>
<div class="hidden lg:block">
<a href="{{ route('mini.payment-qrs.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> All payment QRs</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $qrCode->label }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $c['business_name'] ?? '' }}@if(!empty($c['branch_label'])) · {{ $c['branch_label'] }}@endif</p>
</div>
</div>
@include('mini.payment-qrs.partials.header-actions', ['qrCode' => $qrCode])
</div>
@if(session('success'))
<div class="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
<div class="grid gap-6 lg:grid-cols-[380px_1fr]">
<div class="lg:sticky lg:top-6 lg:self-start">
@include('mini.payment-qrs.partials.preview-card', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'showDownloads' => false,
])
<p class="mt-4 break-all text-center text-sm text-indigo-600">{{ $qrCode->publicUrl() }}</p>
</div>
<div class="space-y-4">
<form method="post" action="{{ route('mini.payment-qrs.update', $qrCode) }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
@method('PATCH')
<h2 class="font-semibold text-slate-900">Settings</h2>
<div>
<label class="block text-sm font-medium text-slate-700">Label</label>
<input type="text" name="label" value="{{ old('label', $qrCode->label) }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Business name</label>
<input type="text" name="business_name" value="{{ old('business_name', $c['business_name'] ?? '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch / till</label>
<input type="text" name="branch_label" value="{{ old('branch_label', $c['branch_label'] ?? '') }}" class="mt-1 w-full rounded-xl border-slate-200 text-sm">
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="is_active" value="1" @checked($qrCode->is_active) class="rounded border-slate-300 text-indigo-600">
QR is active (accepts payments)
</label>
<button type="submit" class="btn-primary">Save changes</button>
</form>
<div class="rounded-2xl border border-red-100 bg-red-50/40 p-6">
<h2 class="font-semibold text-red-700">Danger zone</h2>
<p class="mt-1 text-sm text-slate-500">Remove this payment QR and deactivate its link.</p>
<button type="button"
@click="$dispatch('open-modal', 'delete-payment-qr')"
class="mt-4 rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-semibold text-red-700 transition hover:bg-red-50">
Delete payment QR
</button>
</div>
@include('mini.payment-qrs.partials.delete-modal', ['qrCode' => $qrCode])
</div>
</div>
</div>
</x-user-layout>
-54
View File
@@ -1,54 +0,0 @@
<x-user-layout>
<x-slot name="title">Payments</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Payments</h1>
<p class="mt-1 text-sm text-slate-500">Live feed of incoming customer payments across all your payment QRs.</p>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@if($payments->isEmpty())
<p class="px-6 py-10 text-sm text-slate-500">No payments yet.</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-6 py-3">When</th>
<th class="px-6 py-3">Payer</th>
<th class="px-6 py-3">QR / till</th>
<th class="px-6 py-3">Note</th>
<th class="px-6 py-3">Amount</th>
<th class="px-6 py-3">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach($payments as $payment)
<tr>
<td class="px-6 py-4 text-slate-600">{{ $payment->paid_at?->format('M j, g:i A') ?? $payment->created_at->format('M j, g:i A') }}</td>
<td class="px-6 py-4">
<p class="font-medium text-slate-900">{{ $payment->payer_name ?: 'Walk-in customer' }}</p>
@if($payment->payer_email)
<p class="text-xs text-slate-500">{{ $payment->payer_email }}</p>
@endif
</td>
<td class="px-6 py-4 text-slate-600">{{ $payment->qrCode?->label }}</td>
<td class="px-6 py-4 text-slate-500">{{ $payment->payer_note ?: '—' }}</td>
<td class="px-6 py-4 font-semibold text-slate-900">{{ $fmt($payment->status === \App\Models\MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) }}</td>
<td class="px-6 py-4">
<span class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium {{ $payment->status === \App\Models\MiniPayment::STATUS_PAID ? 'bg-emerald-50 text-emerald-700' : ($payment->status === \App\Models\MiniPayment::STATUS_FAILED ? 'bg-red-50 text-red-700' : 'bg-amber-50 text-amber-700') }}">
{{ ucfirst($payment->status) }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if($payments->hasPages())
<div class="border-t border-slate-100 px-6 py-4">{{ $payments->links() }}</div>
@endif
@endif
</div>
</div>
</x-user-layout>
-27
View File
@@ -1,27 +0,0 @@
<x-user-layout>
<x-slot name="title">Payouts</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">Payouts</h1>
<p class="mt-1 text-sm text-slate-500">Takings settle into your Ladill wallet (3.5% fee), then withdraw to bank or MoMo.</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Total received (net)</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($revenueMinor) }}</p>
</div>
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-5">
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Available in wallet</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-6 py-4 text-sm text-slate-600">
Payments are collected through Ladill Pay and credited to your Ladill wallet. Withdraw to bank or MoMo from your account wallet payout and withdrawal history is there.
</div>
<a href="{{ $accountWalletUrl }}" class="btn-primary">
Open wallet & withdraw
<span aria-hidden="true"></span>
</a>
</div>
</x-user-layout>
-69
View File
@@ -1,69 +0,0 @@
<x-user-layout>
<x-slot name="title">Settings</x-slot>
<div class="mx-auto max-w-2xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
<p class="mt-0.5 text-sm text-slate-500">Notification preferences for your payment QRs.</p>
@if (session('success'))
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
@csrf
@method('PUT')
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
<p class="mt-1 text-xs text-slate-500">Choose what we email you about payments and your account.</p>
<div class="mt-4">
<label for="notify_email" class="block text-[11px] font-medium text-slate-500">Notifications email</label>
<input type="email" id="notify_email" name="notify_email"
value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('notify_email') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
</div>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">Incoming payments</span>
<span class="block text-xs text-slate-400">Email when a customer pays via your payment QR.</span>
</span>
<input type="checkbox" name="notify_registrations" value="1"
@checked(old('notify_registrations', $settings->notify_registrations ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">Payouts & settlements</span>
<span class="block text-xs text-slate-400">Email when takings settle into your wallet.</span>
</span>
<input type="checkbox" name="notify_payouts" value="1"
@checked(old('notify_payouts', $settings->notify_payouts ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">Ladill Mini updates</span>
<span class="block text-xs text-slate-400">Occasional emails about new features and improvements.</span>
</span>
<input type="checkbox" name="product_updates" value="1"
@checked(old('product_updates', $settings->product_updates ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
</div>
<button type="submit" class="btn-primary">
Save settings
</button>
</form>
<p class="mt-6 text-xs text-slate-400">
Profile, password, and security are managed on
<a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.
</p>
</div>
</x-user-layout>
@@ -1 +0,0 @@
@include('auth.signed-out')
@@ -1,178 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@include('partials.favicon')
<title>{{ $qrCode->label }}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: #f1f5f9;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
height: 100dvh;
color: #1e293b;
}
#toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
height: 3.25rem;
background: #fff;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
z-index: 20;
}
.tb-label {
font-size: 0.8rem;
font-weight: 600;
color: #64748b;
max-width: 65%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-download {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.9rem;
border-radius: 9999px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
color: #334155;
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
transition: background 0.15s;
}
.tb-download:hover { background: #e2e8f0; }
.tb-download svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; }
#scroll {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.25rem 0.75rem;
gap: 0.75rem;
}
#loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.875rem;
color: #94a3b8;
font-size: 0.8125rem;
padding: 4rem 0;
width: 100%;
}
.spinner {
width: 2rem; height: 2rem;
border: 2px solid #e2e8f0;
border-top-color: #94a3b8;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.pdf-page {
display: block;
max-width: 100%;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
background: #fff;
}
#bottom {
flex-shrink: 0;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
color: #94a3b8;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="tb-label">{{ $qrCode->label }}</span>
@if($allowDownload)
<a href="{{ route('qr.public.file', $qrCode->short_code) }}" download class="tb-download">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"/>
</svg>
Download
</a>
@else
<span></span>
@endif
</div>
<div id="scroll">
<div id="loading">
<div class="spinner"></div>
Loading document…
</div>
</div>
<div id="bottom"><span id="page-info"></span></div>
<script type="module">
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.0.379/pdf.worker.min.mjs';
const fileUrl = @json($fileUrl);
const scroll = document.getElementById('scroll');
const loading = document.getElementById('loading');
const pageInfo = document.getElementById('page-info');
async function main() {
const pdf = await pdfjsLib.getDocument(fileUrl).promise;
const n = pdf.numPages;
pageInfo.textContent = n + ' page' + (n !== 1 ? 's' : '');
loading.remove();
const maxW = scroll.clientWidth - 24;
for (let i = 1; i <= n; i++) {
const pg = await pdf.getPage(i);
const vp0 = pg.getViewport({ scale: 1 });
const scale = Math.min(maxW / vp0.width, 2);
const vp = pg.getViewport({ scale });
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = vp.width * dpr;
canvas.height = vp.height * dpr;
canvas.style.width = vp.width + 'px';
canvas.style.height = vp.height + 'px';
scroll.appendChild(canvas);
await pg.render({ canvasContext: canvas.getContext('2d'), viewport: pg.getViewport({ scale: scale * dpr }) }).promise;
}
}
main().catch(err => {
loading.innerHTML = '<p style="color:#ef4444;text-align:center;padding:1rem">Failed to load document.</p>';
console.error(err);
});
</script>
</body>
</html>
@@ -1,73 +0,0 @@
@php
$c = $qrCode->content();
$evColor = $c['brand_color'] ?? '#4f46e5';
$evName = $c['name'] ?? $qrCode->label;
$isContribution = ($c['mode'] ?? 'ticketing') === 'contributions';
$badgeUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=' . urlencode($registration->badge_code);
@endphp
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $isContribution ? 'Thank you' : "You're registered" }} {{ $evName }}</title>
@include('partials.favicon')
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-100 flex items-center justify-center px-4 py-10">
<div class="w-full max-w-sm">
<div class="overflow-hidden rounded-3xl bg-white shadow-xl">
<div class="px-6 py-7 text-center" style="background:linear-gradient(160deg, {{ $evColor }} 0%, {{ $evColor }}cc 100%);">
<div class="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-white/20">
<svg class="h-8 w-8 text-white" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
</div>
<h1 class="text-xl font-black text-white">{{ $isContribution ? 'Thank you!' : "You're registered!" }}</h1>
<p class="mt-1 text-sm text-white/85">{{ $evName }}</p>
</div>
<div class="px-6 py-6 text-center">
@if($isContribution)
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your contribution has been received.</p>
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-6">
<p class="text-[10px] font-bold uppercase tracking-widest text-slate-400">{{ $registration->tier_name }}</p>
<p class="mt-1 text-3xl font-black" style="color:{{ $evColor }}">{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}</p>
<p class="mt-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">Reference</p>
<p class="font-mono text-sm font-bold tracking-[0.15em] text-slate-700">{{ $registration->badge_code }}</p>
</div>
@if(($registration->badge_fields ?? []))
<div class="mt-5 space-y-2 text-left text-sm">
@foreach(($registration->badge_fields ?? []) as $label => $value)
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">{{ $label }}</span><span class="font-semibold text-slate-800">{{ $value }}</span></div>
@endforeach
</div>
@endif
<p class="mt-5 text-xs text-slate-400">A receipt was sent to {{ $registration->attendee_email }}.</p>
@else
<p class="text-sm text-slate-500">Hi <span class="font-semibold text-slate-800">{{ $registration->attendee_name }}</span>, your spot is confirmed.</p>
<div class="mt-5 rounded-2xl border border-slate-200 bg-slate-50 px-5 py-5">
<img src="{{ $badgeUrl }}" alt="Badge QR" class="mx-auto h-40 w-40 rounded-xl bg-white p-2 shadow-sm">
<p class="mt-3 text-[10px] font-bold uppercase tracking-widest text-slate-400">Badge code</p>
<p class="font-mono text-2xl font-black tracking-[0.2em]" style="color:{{ $evColor }}">{{ $registration->badge_code }}</p>
</div>
<div class="mt-5 space-y-2 text-left text-sm">
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">Ticket</span><span class="font-semibold text-slate-800">{{ $registration->tier_name }}</span></div>
@if($registration->isPaid())
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">Paid</span><span class="font-semibold text-slate-800">{{ $registration->currency }} {{ number_format($registration->amountCedis(), 2) }}</span></div>
@endif
@foreach(($registration->badge_fields ?? []) as $label => $value)
<div class="flex justify-between border-b border-slate-100 pb-2"><span class="text-slate-400">{{ $label }}</span><span class="font-semibold text-slate-800">{{ $value }}</span></div>
@endforeach
</div>
<p class="mt-5 text-xs text-slate-400">Show this badge code at check-in. A confirmation was sent to {{ $registration->attendee_email }}.</p>
@endif
</div>
</div>
</div>
</body>
</html>
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@include('partials.favicon')
<title>QR code inactive</title>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; background: #f8fafc; color: #334155; padding: 2rem; text-align: center; }
</style>
</head>
<body>
<div>
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem;">This QR code is not active</h1>
<p style="margin: 0; font-size: 0.875rem; color: #64748b;">The owner has paused this link. Try again later.</p>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Payment confirmed</title>
<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" />
@vite(['resources/css/app.css'])
</head>
<body class="min-h-screen bg-emerald-50/40 font-sans antialiased">
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4 py-10 text-center">
<div class="rounded-3xl border border-emerald-100 bg-white p-8 shadow-sm">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
</div>
<h1 class="mt-4 text-xl font-bold text-slate-900">Payment successful</h1>
<p class="mt-2 text-sm text-slate-600">
{{ $payment->currency }} {{ number_format($payment->amount_minor / 100, 2) }} paid to
{{ $qrCode->content()['business_name'] ?? $qrCode->label }}.
</p>
<p class="mt-4 text-xs text-slate-500">Reference: {{ $payment->reference }}</p>
</div>
</main>
</body>
</html>
@@ -1,114 +0,0 @@
@php
$content = $qrCode->content();
$businessName = $content['business_name'] ?? $qrCode->label;
$currency = $content['currency'] ?? 'GHS';
$payUrl = route('qr.public.payment.pay', $qrCode->short_code);
$csrf = csrf_token();
@endphp
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ $csrf }}">
<title>Pay {{ $businessName }}</title>
<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" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen bg-slate-50 font-sans text-slate-900 antialiased"
x-data="miniPaymentLanding({
payUrl: @js($payUrl),
csrf: @js($csrf),
amount: @js(old('amount')),
errorMsg: @js(session('error')),
})">
{{-- Mobile: Ladill Mini branding + fixed payment sheet --}}
<div class="md:hidden">
<div class="fixed inset-0 overflow-hidden bg-slate-50">
<div class="flex h-full flex-col items-center justify-center px-6 pb-56 text-center pt-[max(2.5rem,env(safe-area-inset-top))]">
<img src="{{ asset('images/launcher-icons/mini.svg') }}?v={{ @filemtime(public_path('images/launcher-icons/mini.svg')) ?: '1' }}"
alt="Ladill Mini" class="h-14 w-14 object-contain">
<h1 class="mt-4 text-xl font-bold text-slate-900">{{ $businessName }}</h1>
@if(!empty($content['branch_label']))
<p class="mt-1 text-sm text-slate-500">{{ $content['branch_label'] }}</p>
@endif
</div>
</div>
<div class="mini-payment-bottom-bleed md:hidden"
:style="sheetBleedStyle"
aria-hidden="true"></div>
<div class="mini-payment-sheet rounded-t-3xl border-t border-slate-200/80 bg-white px-5 pt-3 shadow-[0_-12px_40px_rgba(15,23,42,0.12)] md:hidden"
:style="paymentSheetStyle">
<div class="mx-auto mb-4 h-1 w-10 rounded-full bg-slate-200"></div>
<div x-show="errorMsg" x-cloak class="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMsg"></div>
<label for="amount-mobile" class="sr-only">Amount ({{ $currency }})</label>
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-lg font-semibold text-slate-400">{{ $currency }}</span>
<input type="number" id="amount-mobile" x-model="amount" step="0.01" min="0.01" required autofocus
inputmode="decimal"
@keydown.enter.prevent="submitPay()"
class="w-full rounded-2xl border-slate-200 py-4 pl-16 pr-4 text-3xl font-bold tracking-tight text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="0.00">
</div>
<button type="button" @click="submitPay()" :disabled="loading"
class="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-4 text-base font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
<svg x-show="loading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-text="loading ? 'Opening payment…' : 'Pay'">Pay</span>
</button>
<p class="mt-3 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
</div>
</div>
{{-- Desktop: centered card --}}
<main class="mx-auto hidden min-h-screen max-w-sm flex-col justify-center px-4 py-8 md:flex">
<div class="rounded-3xl border border-slate-200/80 bg-white p-6 shadow-sm">
<div class="text-center">
@if(!empty($content['logo_path']))
<img src="{{ route('qr.public.payment.logo', $qrCode->short_code) }}" alt="" class="mx-auto h-14 w-14 rounded-2xl object-cover">
@endif
<h1 class="mt-3 text-lg font-bold text-slate-900">{{ $businessName }}</h1>
@if(!empty($content['branch_label']))
<p class="mt-0.5 text-sm text-slate-500">{{ $content['branch_label'] }}</p>
@endif
</div>
<div x-show="errorMsg" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMsg"></div>
<div class="mt-6">
<label for="amount-desktop" class="sr-only">Amount ({{ $currency }})</label>
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-lg font-semibold text-slate-400">{{ $currency }}</span>
<input type="number" id="amount-desktop" x-model="amount" step="0.01" min="0.01" required
inputmode="decimal"
@keydown.enter.prevent="submitPay()"
class="w-full rounded-2xl border-slate-200 py-4 pl-16 pr-4 text-3xl font-bold tracking-tight text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="0.00">
</div>
<button type="button" @click="submitPay()" :disabled="loading"
class="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 py-4 text-base font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
<svg x-show="loading" x-cloak class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-text="loading ? 'Opening payment…' : 'Pay'">Pay</span>
</button>
</div>
<p class="mt-4 text-center text-[11px] text-slate-400">Secured by Paystack. Powered by Ladill Pay</p>
</div>
</main>
@include('partials.paystack-sheet')
</body>
</html>
@@ -1,176 +0,0 @@
<x-user-layout>
@php $isContribution = ($qrCode->content()['mode'] ?? 'ticketing') === 'contributions'; @endphp
<x-slot name="title">{{ $isContribution ? 'Contributions' : 'Attendees' }} {{ $qrCode->label }}</x-slot>
<div class="mx-auto max-w-5xl space-y-6"
x-data="{
selected: [],
toggle(id) { const i = this.selected.indexOf(id); i === -1 ? this.selected.push(id) : this.selected.splice(i, 1); },
get qs() { return this.selected.map(id => 'ids[]=' + id).join('&'); },
printSelected() { window.open(@js(route('events.badges', $qrCode)) + '?auto=1&' + this.qs, '_blank'); },
zplSelected() { window.location = @js(route('events.badges.zpl', $qrCode)) + '?' + this.qs; }
}">
{{-- Flash --}}
@foreach(['success', 'error'] as $flash)
@if(session($flash))
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
{{ session($flash) }}
</div>
@endif
@endforeach
{{-- Header --}}
<div>
<a href="{{ route('events.show', $qrCode) }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
{{ $qrCode->label }}
</a>
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $isContribution ? 'Contributions' : 'Attendees' }}</h1>
</div>
{{-- Share programme outline --}}
@if($programme)
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
</span>
<div>
<p class="text-sm font-semibold text-slate-900">Programme outline</p>
<p class="text-xs text-slate-500">Email &amp; text the <span class="font-medium text-slate-600">{{ $programme->label }}</span> programme link to all confirmed attendees.</p>
</div>
</div>
<form method="POST" action="{{ route('events.attendees.share-programme', $qrCode) }}"
x-data="{ busy: false }"
@submit="async ($event) => {
if (busy) return;
if (! await $store.ladillConfirm.ask({
title: 'Send programme?',
message: 'Send the programme outline to all confirmed attendees by email and SMS?',
confirmLabel: 'Send',
variant: 'primary',
})) { $event.preventDefault(); return; }
busy = true;
}"
class="shrink-0">
@csrf
<button type="submit" :disabled="busy"
class="btn-primary w-full sm:w-auto">
<span x-text="busy ? 'Sending…' : 'Share with attendees'">Share with attendees</span>
</button>
</form>
</div>
@endif
{{-- Stats --}}
<div class="grid {{ $isContribution ? 'grid-cols-2' : 'grid-cols-3' }} gap-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['total']) }}</p>
<p class="text-xs text-slate-500">{{ $isContribution ? 'Contributions' : 'Registered' }}</p>
</div>
@unless($isContribution)
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['checked_in']) }}</p>
<p class="text-xs text-slate-500">Checked in</p>
</div>
@endunless
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format($stats['revenue'], 2) }}</p>
<p class="text-xs text-slate-500">{{ $isContribution ? 'Total raised' : 'Revenue' }}</p>
</div>
</div>
{{-- Toolbar --}}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form method="GET" class="flex items-center gap-2">
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search name, email, code…"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
<select name="status" onchange="this.form.submit()" class="rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-600 focus:border-indigo-400 focus:outline-none">
<option value="">All</option>
<option value="confirmed" @selected(request('status') === 'confirmed')>Confirmed</option>
<option value="pending" @selected(request('status') === 'pending')>Pending</option>
</select>
</form>
@unless($isContribution)
<div class="flex flex-wrap items-center gap-2">
<span x-show="selected.length" x-cloak class="text-xs font-medium text-slate-500"><span x-text="selected.length"></span> selected</span>
<button type="button" x-show="selected.length" x-cloak @click="printSelected()"
class="btn-primary btn-primary-sm">Print selected</button>
<button type="button" x-show="selected.length" x-cloak @click="zplSelected()"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">ZPL selected</button>
<a href="{{ route('events.badges', $qrCode) }}?auto=1" target="_blank"
class="rounded-xl bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white hover:bg-slate-800 transition">Print all badges</a>
<a href="{{ route('events.badges.zpl', $qrCode) }}"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">Download ZPL</a>
</div>
@endunless
</div>
{{-- Table --}}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50/70 text-left text-xs uppercase tracking-wide text-slate-400">
<tr>
@unless($isContribution)<th class="px-4 py-3 w-8"></th>@endunless
<th class="px-4 py-3">{{ $isContribution ? 'Contributor' : 'Attendee' }}</th>
<th class="px-4 py-3">{{ $isContribution ? 'Category' : 'Ticket' }}</th>
@unless($isContribution)<th class="px-4 py-3">Badge</th>@endunless
<th class="px-4 py-3">Status</th>
@unless($isContribution)<th class="px-4 py-3 text-right">Check-in</th>@endunless
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse($registrations as $reg)
<tr class="hover:bg-slate-50/60">
@unless($isContribution)
<td class="px-4 py-3">
@if($reg->status === \App\Models\QrEventRegistration::STATUS_CONFIRMED)
<input type="checkbox" :checked="selected.includes({{ $reg->id }})" @change="toggle({{ $reg->id }})"
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
@endif
</td>
@endunless
<td class="px-4 py-3">
<p class="font-semibold text-slate-800">{{ $reg->attendee_name }}</p>
<p class="text-xs text-slate-400">{{ $reg->attendee_email }}</p>
@foreach(($reg->badge_fields ?? []) as $k => $v)
<span class="mr-1 inline-block rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">{{ $k }}: {{ $v }}</span>
@endforeach
</td>
<td class="px-4 py-3">
<span class="font-medium text-slate-700">{{ $reg->tier_name }}</span>
@if($reg->isPaid())<p class="text-xs text-slate-400">GHS {{ number_format($reg->amountCedis(), 2) }}</p>@endif
</td>
@unless($isContribution)
<td class="px-4 py-3"><code class="rounded bg-slate-100 px-2 py-0.5 text-xs">{{ $reg->badge_code }}</code></td>
@endunless
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {{ $reg->status === 'confirmed' ? 'bg-emerald-50 text-emerald-700' : ($reg->status === 'pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-500') }}">
{{ ucfirst($reg->status) }}
</span>
</td>
@unless($isContribution)
<td class="px-4 py-3 text-right">
<form method="POST" action="{{ route('events.attendees.check-in', [$qrCode, $reg]) }}">
@csrf @method('PATCH')
<button type="submit" class="rounded-lg border px-2.5 py-1 text-xs font-semibold transition {{ $reg->checked_in_at ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 text-slate-500 hover:bg-slate-50' }}">
{{ $reg->checked_in_at ? '✓ In' : 'Check in' }}
</button>
</form>
</td>
@endunless
</tr>
@empty
<tr><td colspan="{{ $isContribution ? 3 : 6 }}" class="px-4 py-12 text-center text-sm text-slate-400">{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if($registrations->hasPages())
<div class="border-t border-slate-100 px-4 py-3">{{ $registrations->links() }}</div>
@endif
</div>
</div>
</x-user-layout>
-85
View File
@@ -1,85 +0,0 @@
@php
$c = $qrCode->content();
$evColor = $c['brand_color'] ?? '#4f46e5';
$evName = $c['name'] ?? $qrCode->label;
$hasLogo = !empty($c['logo_path']);
$logoUrl = $hasLogo ? route('qr.public.event.logo', $qrCode->short_code) : null;
$size = $c['badge_size'] ?? '4x3';
// Physical badge dimensions (inches)
[$bw, $bh] = match ($size) {
'4x6' => ['4in', '6in'],
'cr80' => ['3.375in', '2.125in'],
default => ['4in', '3in'],
};
$stack = $size === '4x6'; // tall badge → stack vertically
@endphp
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
@include('partials.favicon')
<title>Badges {{ $evName }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #e5e7eb; color: #0f172a; }
.toolbar { position: sticky; top: 0; display: flex; gap: 10px; justify-content: center; padding: 14px; background: #fff; border-bottom: 1px solid #e2e8f0; }
.toolbar button { border: none; border-radius: 10px; padding: 9px 18px; font-size: 13px; font-weight: 700; cursor: pointer; }
.btn-print { background: {{ $evColor }}; color: #fff; }
.btn-close { background: #f1f5f9; color: #475569; }
.sheet { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 24px; }
.badge {
width: {{ $bw }}; height: {{ $bh }};
background: #fff; border: 1px solid #cbd5e1; border-radius: 10px; overflow: hidden;
display: flex; flex-direction: column;
}
.badge-head { background: {{ $evColor }}; color: #fff; padding: 8px 10px; display: flex; align-items: center; gap: 7px; }
.badge-head img { height: 22px; width: 22px; object-fit: contain; background: #fff; border-radius: 4px; padding: 1px; }
.badge-head .ev { font-size: 11px; font-weight: 800; letter-spacing: .02em; line-height: 1.1; }
.badge-body { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 6px 10px; }
.badge-name { font-size: 22px; font-weight: 900; line-height: 1.05; }
.badge-tier { margin-top: 3px; font-size: 11px; font-weight: 700; color: {{ $evColor }}; text-transform: uppercase; letter-spacing: .05em; }
.badge-fields { margin-top: 4px; font-size: 9.5px; color: #64748b; }
.badge-foot { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-top: 1px solid #e2e8f0; }
.badge-foot code { font-family: ui-monospace, monospace; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
.badge-foot img { height: 44px; width: 44px; }
@media print {
.toolbar { display: none; }
body { background: #fff; }
.sheet { padding: 0; gap: 0; }
.badge { border: none; border-radius: 0; page-break-after: always; break-after: page; }
@page { size: {{ $bw }} {{ $bh }}; margin: 0; }
}
</style>
</head>
<body @if($auto) onload="window.print()" @endif>
<div class="toolbar">
<button class="btn-print" onclick="window.print()">Print {{ count($registrations) }} badge{{ count($registrations) === 1 ? '' : 's' }}</button>
<button class="btn-close" onclick="window.close()">Close</button>
</div>
<div class="sheet">
@forelse($registrations as $reg)
@php $badgeQr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' . urlencode($reg->badge_code); @endphp
<div class="badge">
<div class="badge-head">
@if($hasLogo)<img src="{{ $logoUrl }}" alt="">@endif
<span class="ev">{{ $evName }}</span>
</div>
<div class="badge-body">
<div class="badge-name">{{ $reg->attendee_name }}</div>
<div class="badge-tier">{{ $reg->tier_name }}</div>
@if(!empty($reg->badge_fields))
<div class="badge-fields">{{ collect($reg->badge_fields)->map(fn($v,$k) => $v)->implode(' · ') }}</div>
@endif
</div>
<div class="badge-foot">
<code>{{ $reg->badge_code }}</code>
<img src="{{ $badgeQr }}" alt="{{ $reg->badge_code }}">
</div>
</div>
@empty
<p style="padding:40px;color:#64748b;">No confirmed attendees to print.</p>
@endforelse
</div>
</body>
</html>
-303
View File
@@ -1,303 +0,0 @@
<x-user-layout>
@php
$createType = old('type', $requestedType ?? \App\Models\QrCode::TYPE_EVENT);
$isProgramme = $createType === \App\Models\QrCode::TYPE_ITINERARY;
$defaultStyle = \App\Support\Qr\QrStyleDefaults::merge(old('style') ?: ($accountDefaultStyle ?? null));
@endphp
<x-slot name="title">{{ $isProgramme ? 'New programme' : 'Create event' }}</x-slot>
<div class="mx-auto max-w-6xl space-y-6"
x-data="qrCustomizer({
previewUrl: @js(route('events.style-preview')),
csrf: @js(csrf_token()),
shortCode: 'preview',
style: @js($defaultStyle),
balance: @js((float) $wallet->spendableBalance()),
price: @js((float) $pricePerQr),
topupUrl: @js($topupUrl),
type: @js($createType),
wizard: @js(! $isProgramme),
})">
@if(session('error'))
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
@endif
{{-- Mobile page header --}}
<div class="flex items-center gap-3 py-1 lg:hidden">
<a href="{{ $isProgramme ? route('programmes.index') : route('events.index') }}"
class="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white shadow-sm text-slate-500 hover:text-slate-900 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</a>
<span class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</span>
</div>
{{-- Desktop page header --}}
<div class="hidden lg:block">
<a href="{{ $isProgramme ? route('programmes.index') : route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
{{ $isProgramme ? 'Programmes' : 'Events' }}
</a>
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</h1>
<p class="mt-1 text-sm text-slate-500">
{{ $isProgramme ? 'Build a schedule outline and share it with attendees.' : 'Set up your event, then design the QR attendees will scan.' }}
</p>
</div>
@unless($isProgramme)
{{-- Event creation wizard steps --}}
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:px-6">
<ol class="flex items-center justify-between gap-2">
@foreach(['Event details', 'QR code', 'Review & create'] as $i => $label)
@php $n = $i + 1; @endphp
<li class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold transition"
:class="step >= {{ $n }} ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-400'">{{ $n }}</span>
<span class="hidden truncate text-xs font-semibold sm:block"
:class="step >= {{ $n }} ? 'text-slate-900' : 'text-slate-400'">{{ $label }}</span>
@if($n < 3)
<span class="mx-1 hidden h-px flex-1 bg-slate-200 sm:block"></span>
@endif
</li>
@endforeach
</ol>
</div>
@endunless
<form x-ref="createForm" action="{{ route('events.store') }}" method="POST" enctype="multipart/form-data"
class="grid grid-cols-1 gap-8"
:class="(!wizard || step >= 2) ? 'lg:grid-cols-[380px_1fr]' : ''"
@submit="wizard ? wizardSubmit($event) : submitOrTopup($event)">
@csrf
<input type="hidden" name="type" value="{{ $createType }}">
{{-- QR preview (step 2 for events, always for programmes) --}}
<div class="lg:sticky lg:top-6 lg:self-start"
x-show="!wizard || step === 2 || step === 3"
x-cloak
:class="(!wizard || step >= 2) ? 'block' : 'hidden'">
<div class="hidden lg:block">
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
</div>
</div>
<div class="space-y-5 pb-4 lg:pb-0"
:class="wizard && step >= 2 ? 'lg:col-start-2' : (wizard && step === 1 ? 'lg:col-span-full' : '')">
{{-- Step 1 / Programme: Event details --}}
<div x-show="!wizard || step === 1" class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
<h2 class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'Programme details' : 'Event details' }}</h2>
<p class="mt-0.5 text-xs text-slate-400">{{ $isProgramme ? 'Title, schedule, and venue for this outline.' : 'Name, dates, ticketing, and registration settings.' }}</p>
</div>
<div class="space-y-4 p-5">
<div>
<label class="block text-xs font-semibold text-slate-600">Label</label>
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120"
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"
placeholder="{{ $isProgramme ? 'e.g. Annual conference programme' : 'e.g. Tech summit 2026' }}">
</div>
<div x-data="{
slug: @js(old('custom_short_code', '')),
status: '',
timer: null,
checkUrl: @js(route('events.check-slug')),
baseUrl: @js(\App\Models\QrCode::publicBaseUrl() . '/q'),
check() {
clearTimeout(this.timer);
const s = this.slug.trim();
if (s === '') { this.status = ''; return; }
if (!/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/.test(s)) { this.status = 'invalid'; return; }
this.status = 'checking';
this.timer = setTimeout(() => {
fetch(this.checkUrl + '?code=' + encodeURIComponent(s))
.then(r => r.json())
.then(d => { this.status = d.available ? 'available' : 'taken'; })
.catch(() => { this.status = ''; });
}, 400);
},
}">
<label class="block text-xs font-semibold text-slate-600">Custom link <span class="font-normal text-slate-400">(optional)</span></label>
<div class="mt-1.5 flex items-stretch rounded-xl border focus-within:border-indigo-400 focus-within:ring-1 focus-within:ring-indigo-400/30"
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none"
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
{{ \App\Models\QrCode::publicBaseUrl() }}/q/
</span>
<input type="text" name="custom_short_code"
x-model="slug"
@input="check()"
maxlength="20"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
class="min-w-0 flex-1 rounded-r-xl bg-white px-3 py-2.5 text-sm placeholder:text-slate-400 focus:outline-none"
placeholder="my-brand">
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-[11px]">
<template x-if="status === 'checking'"><span class="text-slate-400">Checking…</span></template>
<template x-if="status === 'available'">
<span class="flex items-center gap-1 text-green-600">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
Available
</span>
</template>
<template x-if="status === 'taken'"><span class="text-red-500">Already taken</span></template>
<template x-if="status === 'invalid'"><span class="text-red-500">Invalid format</span></template>
<template x-if="status === ''"><span class="text-slate-400">Leave blank to auto-generate</span></template>
</div>
</div>
<div class="space-y-3">
@include('qr-codes.partials.type-fields-create')
</div>
@unless($isProgramme)
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
You will design the QR code in the next step. Details can be updated anytime without reprinting.
</p>
@else
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
Attendees scan your Ladill event link update details anytime without reprinting the QR.
</p>
@endunless
</div>
</div>
{{-- Step 2: QR customization (events) or inline (programmes) --}}
<div x-show="!wizard || step === 2">
@include('qr-codes.partials.customization-fields', [
'style' => $defaultStyle,
'moduleStyles' => $moduleStyles,
'cornerOuterStyles' => $cornerOuterStyles,
'cornerInnerStyles' => $cornerInnerStyles,
'frameStyles' => $frameStyles,
])
</div>
{{-- Step 3: Review & create (events only) --}}
<div x-show="wizard && step === 3" x-cloak class="space-y-5">
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
<h2 class="text-sm font-semibold text-slate-900">Review your event</h2>
<p class="mt-0.5 text-xs text-slate-400">Confirm details before publishing. GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance.</p>
</div>
<div class="space-y-3 p-5 text-sm">
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
<span class="text-slate-500">Event</span>
<span class="font-semibold text-slate-900 text-right" x-text="reviewEventName()"></span>
</div>
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
<span class="text-slate-500">Admin label</span>
<span class="font-semibold text-slate-900 text-right" x-text="reviewLabel()"></span>
</div>
<div class="flex justify-between gap-4">
<span class="text-slate-500">QR balance after</span>
<span class="font-semibold text-slate-900">GHS <span x-text="Math.max(0, balance - price).toFixed(2)"></span></span>
</div>
</div>
</div>
<div class="lg:hidden">
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
</div>
</div>
{{-- Desktop navigation --}}
<div class="hidden gap-3 lg:flex">
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
class="rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition">
Back
</button>
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
class="btn-primary flex-1">
Continue
</button>
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
class="btn-primary flex-1">
{{ $isProgramme ? 'Create programme' : 'Create event' }}
</button>
</div>
</div>
</form>
{{-- Mobile sticky action bar --}}
<div x-show="!showPreviewModal"
class="mobile-action-bar lg:hidden">
<div class="mobile-action-bar__toolbar">
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-600">
Back
</button>
<button type="button" x-show="wizard && (step === 2 || step === 3)" @click="showPreviewModal = true"
class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-slate-50 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
Preview
</button>
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
class="btn-primary flex-1">
Continue
</button>
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
class="btn-primary flex-1">
Create
</button>
</div>
<div class="mobile-action-bar__inset-fill" aria-hidden="true"></div>
</div>
{{-- Preview modal --}}
<div x-show="showPreviewModal" x-cloak
x-transition:enter="transition-opacity duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="showPreviewModal = false"
class="fixed inset-0 z-[60] flex flex-col bg-black/60 backdrop-blur-sm lg:hidden"
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="flex items-center justify-between px-5 py-4">
<span class="text-sm font-semibold text-white">Preview</span>
<button type="button" @click="showPreviewModal = false"
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-xs overflow-hidden rounded-2xl bg-white shadow-2xl"
:style="frameStyle === 'thin'
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
<div :class="{
'p-[8px]': frameStyle === 'thin',
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
'p-4': frameStyle === 'none',
}">
<div x-ref="qrPreviewModal"
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
<div x-show="frameStyle === 'scan_me'" x-cloak
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
class="pt-2.5 pb-0.5 text-center">
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
class="text-[11px] font-bold tracking-[0.18em]"></span>
</div>
<div x-show="frameStyle === 'tap_to_scan'" x-cloak class="mt-2.5 flex justify-center pb-0.5">
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</x-user-layout>
-86
View File
@@ -1,86 +0,0 @@
<x-user-layout>
<x-slot name="title">Events</x-slot>
<div class="space-y-6"
x-data="balanceGate({
balance: @js((float) $wallet->spendableBalance()),
price: @js((float) $pricePerQr),
topupUrl: @js($topupUrl),
href: @js(route('events.create')),
})">
@foreach(['success', 'error'] as $flash)
@if(session($flash))
<div class="rounded-lg border px-4 py-3 {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
<p class="text-sm">{{ session($flash) }}</p>
</div>
@endif
@endforeach
<div class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white">
<div class="absolute inset-0 bg-gradient-to-br from-violet-50/80 via-white to-indigo-50/60"></div>
<div class="relative px-6 py-8 sm:px-10">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-xl">
<div class="inline-flex items-center gap-1.5 rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold text-violet-700">
Tickets · Contributions · Free registration
</div>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Your events</h1>
<p class="mt-2 text-sm leading-6 text-slate-600">
Create ticketed events, manage attendees, print badges, and attach programme outlines.
</p>
<div class="mt-6">
<button type="button"
@click="goOrTopup($event)"
class="btn-primary">
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
Create event
</button>
</div>
</div>
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">GHS {{ number_format($wallet->spendableBalance(), 2) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Account balance</p>
</div>
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $qrCodes->count() }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Events</p>
</div>
<div class="mobile-stats-card shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ number_format($totalRegistrations) }}</p>
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">Registrations</p>
</div>
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
<div class="border-b border-slate-100 px-6 py-4">
<h2 class="text-sm font-semibold text-slate-900">Your events</h2>
</div>
@if($qrCodes->isEmpty())
<p class="px-6 py-10 text-center text-sm text-slate-500">No events yet. Create your first event to get started.</p>
@else
<div class="divide-y divide-slate-100">
@foreach($qrCodes as $code)
<a href="{{ route('events.show', $code) }}" class="flex items-center gap-4 px-6 py-4 transition hover:bg-slate-50">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg {{ $code->is_active ? 'bg-violet-100' : 'bg-slate-100 opacity-50' }}">
<img src="{{ asset('images/ladill-icons/events.svg') }}?v={{ @filemtime(public_path('images/ladill-icons/events.svg')) ?: '1' }}" alt="" class="h-5 w-5 object-contain" width="20" height="20">
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-slate-900">{{ $code->label }}</p>
<p class="truncate text-xs text-slate-500">{{ $code->typeLabel() }} · {{ $code->publicUrl() }}</p>
</div>
<div class="text-right text-xs text-slate-500">
<p class="font-semibold text-slate-900">{{ number_format($code->registrations_count) }} registered</p>
<p>{{ $code->is_active ? 'Active' : 'Paused' }}</p>
</div>
</a>
@endforeach
</div>
@endif
</div>
</div>
</x-user-layout>
@@ -1,530 +0,0 @@
@php
$style = \App\Support\Qr\QrStyleDefaults::merge($style ?? null);
$moduleStyles = $moduleStyles ?? \App\Support\Qr\QrModuleStyleCatalog::visible();
$cornerOuterStyles = $cornerOuterStyles ?? \App\Support\Qr\QrCornerStyleCatalog::outerStyles();
$cornerInnerStyles = $cornerInnerStyles ?? \App\Support\Qr\QrCornerStyleCatalog::innerStyles();
$frameStyles = $frameStyles ?? \App\Support\Qr\QrFrameStyleCatalog::visible();
$qrBits = [1,0,1,0,1, 0,1,1,0,0, 1,1,0,1,1, 0,0,1,0,1, 1,0,1,1,0];
@endphp
@include('qr-codes.partials.qr-customizer-script')
{{-- ── Quick style presets ──────────────────────────────────────────── --}}
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-3.5">
<p class="text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Quick styles</p>
</div>
<div class="grid grid-cols-4 gap-px bg-slate-100/80">
@foreach([
[
'key' => 'classic',
'label' => 'Classic',
'fg' => '#000000',
'bg' => '#ffffff',
'outer' => 'square',
'inner' => 'square',
'dot' => false,
],
[
'key' => 'rounded',
'label' => 'Rounded',
'fg' => '#111827',
'bg' => '#ffffff',
'outer' => 'rounded',
'inner' => 'square',
'dot' => false,
],
[
'key' => 'branded',
'label' => 'Branded',
'fg' => '#1d4ed8',
'bg' => '#ffffff',
'outer' => 'rounded',
'inner' => 'square',
'dot' => false,
],
[
'key' => 'dots',
'label' => 'Dots',
'fg' => '#0f172a',
'bg' => '#ffffff',
'outer' => 'square',
'inner' => 'dot',
'dot' => true,
],
] as $preset)
@php
$outerRadius = match($preset['outer']) { 'rounded' => '3px', 'circle' => '50%', default => '1px' };
$innerRadius = $preset['inner'] === 'dot' ? '50%' : '1px';
$dotRadius = $preset['dot'] ? '50%' : '1px';
@endphp
<button type="button"
@click="setStylePreset('{{ $preset['key'] }}')"
class="group flex flex-col items-center gap-2.5 bg-white px-3 py-4 text-center transition hover:bg-slate-50/80 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400">
{{-- Mini QR illustration --}}
<span class="relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style="background-color: {{ $preset['bg'] }}">
{{-- Top-left finder --}}
<span class="absolute left-[4px] top-[4px] flex h-[14px] w-[14px] items-center justify-center"
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
<span class="block h-[5px] w-[5px]"
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
</span>
{{-- Top-right finder --}}
<span class="absolute right-[4px] top-[4px] flex h-[14px] w-[14px] items-center justify-center"
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
<span class="block h-[5px] w-[5px]"
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
</span>
{{-- Bottom-left finder --}}
<span class="absolute bottom-[4px] left-[4px] flex h-[14px] w-[14px] items-center justify-center"
style="border: 2.5px solid {{ $preset['fg'] }}; border-radius: {{ $outerRadius }}; background: transparent">
<span class="block h-[5px] w-[5px]"
style="background: {{ $preset['fg'] }}; border-radius: {{ $innerRadius }}"></span>
</span>
{{-- Data dots (5×3 grid, avoiding finder corners) --}}
<span class="absolute inset-0 flex items-center justify-center">
<span class="grid grid-cols-3 gap-[2px] translate-x-[5px]">
@foreach([1,0,1,0,1,0,1,1,0] as $bit)
<span class="h-[3px] w-[3px]"
style="{{ $bit ? 'background:' . $preset['fg'] . ';border-radius:' . $dotRadius : '' }}"></span>
@endforeach
</span>
</span>
</span>
<span class="text-[11px] font-semibold text-slate-600 group-hover:text-slate-900">{{ $preset['label'] }}</span>
</button>
@endforeach
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
{{-- Pill tab nav --}}
<div class="border-b border-slate-100 bg-slate-50/70 px-4 py-3">
<div class="flex gap-1 rounded-xl bg-slate-200/50 p-1">
@foreach([
['id' => 'body', 'label' => 'Body'],
['id' => 'colors', 'label' => 'Colors'],
['id' => 'eyes', 'label' => 'Eyes'],
['id' => 'logo', 'label' => 'Logo'],
['id' => 'frame', 'label' => 'Frame'],
] as $tab)
<button type="button"
@click="openSection = '{{ $tab['id'] }}'"
:class="openSection === '{{ $tab['id'] }}'
? 'bg-white shadow-sm text-slate-900'
: 'text-slate-500 hover:text-slate-700'"
class="flex-1 rounded-lg py-1.5 text-[11px] font-semibold transition-all duration-150">
{{ $tab['label'] }}
</button>
@endforeach
</div>
</div>
<div class="p-5">
{{-- ── Body ─────────────────────────────────────────────────────── --}}
<div x-show="openSection === 'body'" x-cloak
x-transition:enter="transition-opacity duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100">
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Dot shape</p>
<div class="grid grid-cols-2 gap-2.5">
@foreach($moduleStyles as $key => $meta)
<label class="group cursor-pointer">
<input type="radio" name="style[module_style]" value="{{ $key }}" x-model="moduleStyle" class="sr-only">
<span class="flex items-center gap-3 rounded-xl border-2 bg-white px-3.5 py-3 transition-all"
:class="moduleStyle === '{{ $key }}'
? 'border-indigo-500 bg-indigo-50/60'
: 'border-slate-200 group-hover:border-slate-300'">
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
@if ($key === 'square')
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-slate-800" fill="currentColor">
<path d="M0,0V11H11V0H0ZM7,7h-3v-3h3v3ZM13,0V11h11V0H13Zm7,7h-3v-3h3v3ZM0,13v11H11V13H0Zm7,7h-3v-3h3v3Zm10-3h-4v-4h4v4Zm3,3h-3v-3h3v3Zm-3,4h-4v-4h4v4Zm7-7h-4v-4h4v4Z"/>
</svg>
@elseif ($key === 'dots')
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-slate-800" fill="currentColor">
<path d="M7,0h-3C1.794,0,0,1.794,0,4v3c0,2.206,1.794,4,4,4h3c2.206,0,4-1.794,4-4V4C11,1.794,9.206,0,7,0Zm2,7c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2V4c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3Zm-2-2v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1Zm10,6h3c2.206,0,4-1.794,4-4V4C24,1.794,22.206,0,20,0h-3C14.794,0,13,1.794,13,4v3c0,2.206,1.794,4,4,4Zm-2-7c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2V4Zm2,2v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1ZM7,13h-3c-2.206,0-4,1.794-4,4v3c0,2.206,1.794,4,4,4h3c2.206,0,4-1.794,4-4v-3c0-2.206-1.794-4-4-4Zm2,7c0,1.103-.897,2-2,2h-3c-1.103,0-2-.897-2-2v-3c0-1.103,.897-2,2-2h3c1.103,0,2,.897,2,2v3Zm-2-2v1c0,.552-.448,1-1,1h-1c-.552,0-1-.448-1-1v-1c0-.552,.448-1,1-1h1c.552,0,1,.448,1,1Zm10-3.5v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Zm3,4h0c0,.828-.672,1.5-1.5,1.5h0c-.828,0-1.5-.672-1.5-1.5h0c0-.828,.672-1.5,1.5-1.5h0c.828,0,1.5,.672,1.5,1.5Zm-3,3v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Zm7-7v1c0,.828-.672,1.5-1.5,1.5h-1c-.828,0-1.5-.672-1.5-1.5v-1c0-.828,.672-1.5,1.5-1.5h1c.828,0,1.5,.672,1.5,1.5Z"/>
</svg>
@else
<span class="grid grid-cols-4 gap-[2px] p-1.5">
@foreach($qrBits as $bit)
<span class="block aspect-square {{ $bit ? ('bg-slate-800 ' . ($meta['circular'] ? 'rounded-full' : '')) : 'bg-transparent' }}"></span>
@endforeach
</span>
@endif
</span>
<span class="text-xs font-semibold text-slate-700">{{ $meta['label'] }}</span>
</span>
</label>
@endforeach
</div>
</div>
{{-- ── Colors ───────────────────────────────────────────────────── --}}
<div x-show="openSection === 'colors'" x-cloak
x-transition:enter="transition-opacity duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="space-y-5">
<div>
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Quick presets</p>
<div class="flex flex-wrap gap-2.5">
@foreach([
['fg' => '#000000', 'bg' => '#ffffff', 'label' => 'Classic'],
['fg' => '#1d4ed8', 'bg' => '#ffffff', 'label' => 'Blue'],
['fg' => '#047857', 'bg' => '#ffffff', 'label' => 'Green'],
['fg' => '#7c3aed', 'bg' => '#ffffff', 'label' => 'Purple'],
['fg' => '#c2410c', 'bg' => '#fff7ed', 'label' => 'Orange'],
['fg' => '#be185d', 'bg' => '#fdf4ff', 'label' => 'Pink'],
['fg' => '#0f172a', 'bg' => '#ecfdf5', 'label' => 'Dark teal'],
] as $preset)
<button type="button"
@click="fg = '{{ $preset['fg'] }}'; bg = '{{ $preset['bg'] }}'; schedulePreview()"
title="{{ $preset['label'] }}"
class="h-8 w-8 rounded-full border-2 border-white shadow ring-1 ring-slate-300/60 transition hover:scale-110 hover:ring-indigo-400"
style="background-color: {{ $preset['fg'] }}"></button>
@endforeach
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Foreground</label>
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
<input type="color" name="style[foreground]" x-model="fg" @input="schedulePreview()"
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
<input type="text" x-model="fg" @change="schedulePreview()"
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
</div>
</div>
<div>
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Background</label>
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
<input type="color" name="style[background]" x-model="bg" @input="schedulePreview()"
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
<input type="text" x-model="bg" @change="schedulePreview()"
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
</div>
</div>
</div>
<div>
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Gradient</p>
<input type="hidden" name="style[gradient_type]" :value="gradientType">
<div class="flex gap-1 rounded-xl bg-slate-200/50 p-1">
@foreach([['none', 'None'], ['linear', 'Linear'], ['radial', 'Radial']] as [$val, $lbl])
<button type="button"
@click="gradientType = '{{ $val }}'"
:class="gradientType === '{{ $val }}' ? 'bg-white shadow-sm text-slate-900' : 'text-slate-500 hover:text-slate-700'"
class="flex-1 rounded-lg py-1.5 text-[11px] font-semibold transition-all duration-150">
{{ $lbl }}
</button>
@endforeach
</div>
<div x-show="gradientType !== 'none'" x-cloak class="mt-3 space-y-3">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-xs font-semibold text-slate-600">Start color</label>
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
<input type="color" name="style[gradient_color1]" x-model="gradientColor1" @input="schedulePreview()"
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
<input type="text" x-model="gradientColor1" @change="schedulePreview()"
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
</div>
</div>
<div>
<label class="mb-1.5 block text-xs font-semibold text-slate-600">End color</label>
<div class="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50/80 p-2">
<input type="color" name="style[gradient_color2]" x-model="gradientColor2" @input="schedulePreview()"
class="h-9 w-10 shrink-0 cursor-pointer rounded-lg border-0 bg-transparent p-0.5">
<input type="text" x-model="gradientColor2" @change="schedulePreview()"
class="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 font-mono text-sm focus:border-indigo-400 focus:outline-none">
</div>
</div>
</div>
<div x-show="gradientType === 'linear'" class="space-y-1">
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
<span>Rotation</span>
<span x-text="gradientRotation + '°'" class="font-normal text-slate-400"></span>
</label>
<input type="range" name="style[gradient_rotation]" min="0" max="360" x-model.number="gradientRotation"
class="w-full accent-indigo-600">
</div>
</div>
</div>
</div>
{{-- ── Eyes ─────────────────────────────────────────────────────── --}}
<div x-show="openSection === 'eyes'" x-cloak
x-transition:enter="transition-opacity duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="space-y-5">
<div>
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Outer frame</p>
<div class="grid grid-cols-3 gap-2.5">
@foreach([
['value' => 'square', 'label' => 'Square', 'shape' => 'h-7 w-7 border-[3px] border-slate-800'],
['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-7 w-7 rounded-[5px] border-[3px] border-slate-800'],
['value' => 'circle', 'label' => 'Circle', 'shape' => 'h-7 w-7 rounded-full border-[3px] border-slate-800'],
] as $opt)
<label class="group cursor-pointer">
<input type="radio" name="style[finder_outer]" value="{{ $opt['value'] }}" x-model="finderOuter" class="sr-only">
<span class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all"
:class="finderOuter === '{{ $opt['value'] }}'
? 'border-indigo-500 bg-indigo-50/60'
: 'border-slate-200 group-hover:border-slate-300'">
<span class="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
<span class="{{ $opt['shape'] }}"></span>
</span>
<span class="text-[11px] font-semibold text-slate-600">{{ $opt['label'] }}</span>
</span>
</label>
@endforeach
</div>
</div>
<div>
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Inner dot</p>
<div class="grid grid-cols-3 gap-2.5">
@foreach([
['value' => 'square', 'label' => 'Square', 'shape' => 'h-5 w-5 rounded-[2px] bg-slate-800'],
['value' => 'rounded', 'label' => 'Rounded', 'shape' => 'h-5 w-5 rounded-[4px] bg-slate-800'],
['value' => 'dot', 'label' => 'Dot', 'shape' => 'h-5 w-5 rounded-full bg-slate-800'],
] as $opt)
<label class="group cursor-pointer">
<input type="radio" name="style[finder_inner]" value="{{ $opt['value'] }}" x-model="finderInner" class="sr-only">
<span class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all"
:class="finderInner === '{{ $opt['value'] }}'
? 'border-indigo-500 bg-indigo-50/60'
: 'border-slate-200 group-hover:border-slate-300'">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
<span class="{{ $opt['shape'] }}"></span>
</span>
<span class="text-[11px] font-semibold text-slate-600">{{ $opt['label'] }}</span>
</span>
</label>
@endforeach
</div>
</div>
</div>
{{-- ── Logo ─────────────────────────────────────────────────────── --}}
<div x-show="openSection === 'logo'" x-cloak
x-transition:enter="transition-opacity duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="space-y-4">
<div>
<p class="text-xs font-semibold text-slate-700">Center logo</p>
<p class="mt-0.5 text-xs leading-5 text-slate-400">Placed in the center. Error correction is raised automatically when a logo is present.</p>
</div>
<label class="flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/60 py-8 text-center transition hover:border-indigo-400 hover:bg-indigo-50/20">
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-slate-200/60">
<svg class="h-6 w-6 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/>
</svg>
</span>
<div>
<p class="text-sm font-semibold text-slate-700">Click to upload</p>
<p class="text-xs text-slate-400">PNG, JPG, GIF, WebP · max 2 MB</p>
</div>
<input type="file" name="logo" accept="image/png,image/jpeg,image/gif,image/webp" x-ref="logoInput" @change="onLogoChange()" class="sr-only">
</label>
{{-- Logo options: shown when a logo is present (uploaded or existing) --}}
<div x-show="hasExistingLogo || _rawLogoFile" x-cloak class="space-y-4 border-t border-slate-100 pt-4">
{{-- Size --}}
<div>
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
<span>Logo size</span>
<span x-text="Math.round(logoSize * 100) + '%'" class="font-normal text-slate-400"></span>
</label>
<input type="range" min="10" max="40" step="1"
:value="Math.round(logoSize * 100)"
@input="logoSize = parseFloat($event.target.value) / 100"
class="mt-2 w-full accent-indigo-600">
<input type="hidden" name="style[logo_size]" :value="logoSize">
</div>
{{-- Padding --}}
<div>
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
<span>Padding</span>
<span x-text="logoMargin" class="font-normal text-slate-400"></span>
</label>
<input type="range" name="style[logo_margin]" min="0" max="15" x-model.number="logoMargin"
class="mt-2 w-full accent-indigo-600">
</div>
{{-- White background toggle --}}
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-600">White background</span>
<button type="button" role="switch" :aria-checked="logoWhiteBg"
@click="logoWhiteBg = !logoWhiteBg"
:class="logoWhiteBg ? 'bg-indigo-500' : 'bg-slate-200'"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none">
<span :class="logoWhiteBg ? 'translate-x-5' : 'translate-x-1'"
class="inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform"></span>
</button>
<input type="hidden" name="style[logo_white_bg]" :value="logoWhiteBg ? '1' : '0'">
</div>
{{-- Shape --}}
<div>
<p class="mb-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Shape</p>
<div class="grid grid-cols-3 gap-2">
{{-- Original: landscape rectangle logo keeps its natural aspect ratio --}}
<button type="button"
@click="logoShape = 'none'"
:class="logoShape === 'none' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
<span class="h-4 w-7 rounded-[2px] bg-slate-800"></span>
</span>
<span class="text-[11px] font-semibold text-slate-600">Original</span>
</button>
{{-- Rounded: square crop with rounded corners --}}
<button type="button"
@click="logoShape = 'rounded'"
:class="logoShape === 'rounded' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
<span class="h-7 w-7 rounded-xl bg-slate-800"></span>
</span>
<span class="text-[11px] font-semibold text-slate-600">Rounded</span>
</button>
{{-- Circle: square crop clipped to circle --}}
<button type="button"
@click="logoShape = 'circle'"
:class="logoShape === 'circle' ? 'border-indigo-500 bg-indigo-50/60' : 'border-slate-200 hover:border-slate-300'"
class="flex flex-col items-center gap-2 rounded-xl border-2 bg-white p-3 transition-all">
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-50 ring-1 ring-slate-200/60">
<span class="h-7 w-7 rounded-full bg-slate-800"></span>
</span>
<span class="text-[11px] font-semibold text-slate-600">Circle</span>
</button>
</div>
<input type="hidden" name="style[logo_shape]" :value="logoShape">
</div>
</div>
@if(!empty($showRemoveLogo))
<label class="flex items-center gap-2.5 text-sm text-slate-600">
<input type="checkbox" name="remove_logo" value="1" x-model="removeLogo" @change="schedulePreview()"
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
Remove current logo
</label>
@endif
</div>
{{-- ── Frame ────────────────────────────────────────────────────── --}}
<div x-show="openSection === 'frame'" x-cloak
x-transition:enter="transition-opacity duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
class="space-y-5">
<div>
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Frame style</p>
<div class="grid grid-cols-2 gap-2.5">
@foreach($frameStyles as $key => $meta)
<label class="group cursor-pointer">
<input type="radio" name="style[frame_style]" value="{{ $key }}" x-model="frameStyle" class="sr-only">
<span class="flex items-center gap-3 rounded-xl border-2 bg-white p-3 transition-all"
:class="frameStyle === '{{ $key }}'
? 'border-indigo-500 bg-indigo-50/60'
: 'border-slate-200 group-hover:border-slate-300'">
<span class="flex h-10 w-9 shrink-0 items-end justify-center overflow-hidden rounded-lg border border-slate-200 bg-white p-1 shadow-sm">
@if($key === 'none')
<span class="h-8 w-8 rounded-sm bg-slate-200 opacity-40"></span>
@elseif($meta['mode'] === 'border')
<span class="h-8 w-8 rounded-sm border-2 border-slate-500 bg-slate-100"></span>
@elseif($meta['mode'] === 'label')
<span class="flex h-9 w-8 flex-col items-stretch gap-0.5">
<span class="flex-1 rounded-sm border border-slate-300 bg-slate-100"></span>
<span class="rounded-sm bg-slate-700 py-0.5 text-center text-[5px] font-bold leading-none text-white">SCAN</span>
</span>
@else
<span class="flex h-9 w-8 flex-col items-stretch gap-0.5">
<span class="flex-1 rounded-sm border border-slate-300 bg-slate-100"></span>
<span class="rounded-full bg-slate-700 py-0.5 text-center text-[5px] font-bold leading-none text-white">TAP</span>
</span>
@endif
</span>
<span class="min-w-0">
<span class="block text-xs font-semibold text-slate-800">{{ $meta['label'] }}</span>
<span class="block text-[10px] leading-4 text-slate-400">{{ $meta['description'] }}</span>
</span>
</span>
</label>
@endforeach
</div>
</div>
{{-- Frame colour --}}
<div x-show="frameStyle !== 'none'" x-cloak class="space-y-1">
<label class="block text-xs font-semibold text-slate-600">Frame colour</label>
<div class="flex gap-2">
<input type="color" name="style[frame_color]" x-model="frameColor" @input="schedulePreview()"
class="h-9 w-9 shrink-0 cursor-pointer rounded-lg border border-slate-200 bg-white p-0.5">
<input type="text" x-model="frameColor" @change="schedulePreview()" maxlength="7"
class="flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
</div>
</div>
{{-- Frame text -- only for frames that show a CTA label --}}
<div x-show="frameStyle === 'scan_me' || frameStyle === 'tap_to_scan'" x-cloak class="space-y-1">
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
<span>Label text</span>
<span class="font-normal text-slate-400" x-text="frameStyle === 'tap_to_scan' ? 'Default: TAP TO SCAN' : 'Default: SCAN ME'"></span>
</label>
<input type="text" name="style[frame_text]" x-model="frameText"
maxlength="100"
:placeholder="frameStyle === 'tap_to_scan' ? 'TAP TO SCAN' : 'SCAN ME'"
class="w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
</div>
<div class="space-y-4 border-t border-slate-100 pt-4">
<div>
<label class="block text-xs font-semibold text-slate-600">Error correction</label>
<select name="style[error_correction]" x-model="ecc"
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none">
<option value="L">Low 7% recovery</option>
<option value="M">Medium 15% recovery</option>
<option value="Q">Quartile 25% recovery</option>
<option value="H">High 30% recovery</option>
</select>
</div>
<div>
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
<span>Output scale</span>
<span x-text="scale + '×'" class="font-normal text-slate-400"></span>
</label>
<input type="range" name="style[scale]" min="4" max="16" x-model.number="scale"
class="mt-2 w-full accent-indigo-600">
</div>
<div>
<label class="flex items-center justify-between text-xs font-semibold text-slate-600">
<span>Quiet zone</span>
<span x-text="margin + ' modules'" class="font-normal text-slate-400"></span>
</label>
<input type="range" name="style[margin]" min="0" max="10" x-model.number="margin"
class="mt-2 w-full accent-indigo-600">
</div>
</div>
</div>
</div>
</div>
@@ -1,669 +0,0 @@
@once('qr-customizer-alpine')
<script>
(function () {
const registerQrCustomizer = () => {
Alpine.data('qrCustomizer', (config) => ({
previewUrl: config.previewUrl,
shortCode: config.shortCode || 'preview',
qrData: config.qrData || 'https://ladill.com',
existingLogoPath: config.existingLogoPath || null,
hasExistingLogo: Boolean(config.hasExistingLogo),
removeLogo: false,
previewLoading: false,
previewTimer: null,
showPreviewModal: false,
openSection: config.openSection || 'body',
balance: config.balance ?? null,
price: config.price ?? null,
topupModalId: config.topupModalId ?? null,
topupUrl: config.topupUrl ?? null,
canonicalSyncUrl: config.canonicalSyncUrl ?? null,
csrf: config.csrf ?? null,
_canonicalSynced: false,
type: config.type ?? 'url',
wizard: Boolean(config.wizard),
step: config.wizard ? 1 : 0,
fg: config.style.foreground,
bg: config.style.background,
ecc: config.style.error_correction,
margin: Number(config.style.margin),
moduleStyle: config.style.module_style,
finderOuter: config.style.finder_outer,
finderInner: config.style.finder_inner,
frameStyle: config.style.frame_style,
frameText: config.style.frame_text || '',
frameColor: config.style.frame_color || '#000000',
scale: Number(config.style.scale),
gradientType: config.style.gradient_type || 'none',
gradientColor1: config.style.gradient_color1 || '#000000',
gradientColor2: config.style.gradient_color2 || '#7c3aed',
gradientRotation: Number(config.style.gradient_rotation ?? 45),
logoSize: Number(config.style.logo_size ?? 0.3),
logoMargin: Number(config.style.logo_margin ?? 5),
logoWhiteBg: Boolean(config.style.logo_white_bg),
logoShape: config.style.logo_shape || 'none',
_rawLogoFile: null,
_processedLogoUrl: null,
init() {
[
'fg', 'bg', 'ecc', 'margin', 'moduleStyle',
'finderOuter', 'finderInner', 'frameStyle', 'frameText', 'frameColor', 'scale',
'gradientType', 'gradientColor1', 'gradientColor2', 'gradientRotation',
'logoSize', 'logoMargin',
].forEach((field) => {
this.$watch(field, () => this.schedulePreview());
});
// These require canvas re-processing before preview
['logoWhiteBg', 'logoShape'].forEach((field) => {
this.$watch(field, () => this._reprocessLogo());
});
this.schedulePreview();
if (config.existingLogoUrl) {
this._loadExistingLogo(config.existingLogoUrl);
}
this.$watch('showPreviewModal', (val) => {
if (val) this.schedulePreview();
});
},
toggleSection(id) {
this.openSection = this.openSection === id ? null : id;
},
schedulePreview() {
clearTimeout(this.previewTimer);
this.previewTimer = setTimeout(() => this.refreshPreview(), 200);
},
async onLogoChange() {
const input = this.$refs.logoInput;
this._rawLogoFile = input?.files?.[0] || null;
if (this._rawLogoFile) {
await this._reprocessLogo();
} else {
this._processedLogoUrl = null;
this.schedulePreview();
}
},
async _reprocessLogo() {
if (!this._rawLogoFile) return;
const result = await this._processLogoImage(this._rawLogoFile);
if (result) {
this._processedLogoUrl = result;
this.schedulePreview();
}
},
// Load existing logo from data URI (edit view) and apply canvas processing
_loadExistingLogo(dataUri) {
fetch(dataUri)
.then(r => r.blob())
.then(blob => {
this._rawLogoFile = new File([blob], 'logo', { type: blob.type || 'image/png' });
return this._processLogoImage(this._rawLogoFile);
})
.then(result => {
if (result) {
this._processedLogoUrl = result;
this.schedulePreview();
}
})
.catch(() => {
this._processedLogoUrl = dataUri;
this.schedulePreview();
});
},
// Apply shape clipping and/or white background to logo via canvas.
// For circle/rounded shapes a center-square crop is used so the shape looks balanced.
// For no-shape logos the natural aspect ratio is preserved.
_processLogoImage(file) {
return new Promise((resolve) => {
const img = new Image();
const raw = URL.createObjectURL(file);
img.onload = () => {
const needsSquareCrop = this.logoShape !== 'none';
const sw = img.naturalWidth;
const sh = img.naturalHeight;
let cw, ch, sx, sy, sSize;
if (needsSquareCrop) {
// Crop to center square for circle/rounded shapes
sSize = Math.min(sw, sh);
sx = (sw - sSize) / 2;
sy = (sh - sSize) / 2;
cw = ch = 300;
} else {
// Preserve natural aspect ratio
const MAX = 600;
const scale = Math.min(1, MAX / Math.max(sw, sh));
cw = Math.round(sw * scale);
ch = Math.round(sh * scale);
sx = sy = 0;
sSize = null;
}
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (needsSquareCrop) {
ctx.beginPath();
if (this.logoShape === 'circle') {
ctx.arc(cw / 2, ch / 2, cw / 2, 0, Math.PI * 2);
} else {
// Rounded — 20% corner radius
const r = cw * 0.2;
ctx.moveTo(r, 0);
ctx.lineTo(cw - r, 0);
ctx.arcTo(cw, 0, cw, r, r);
ctx.lineTo(cw, ch - r);
ctx.arcTo(cw, ch, cw - r, ch, r);
ctx.lineTo(r, ch);
ctx.arcTo(0, ch, 0, ch - r, r);
ctx.lineTo(0, r);
ctx.arcTo(0, 0, r, 0, r);
ctx.closePath();
}
ctx.clip();
}
if (this.logoWhiteBg) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cw, ch);
}
if (sSize !== null) {
ctx.drawImage(img, sx, sy, sSize, sSize, 0, 0, cw, ch);
} else {
ctx.drawImage(img, 0, 0, cw, ch);
}
URL.revokeObjectURL(raw);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => {
URL.revokeObjectURL(raw);
resolve(null);
};
img.src = raw;
});
},
setStylePreset(preset) {
const presets = {
classic: {
fg: '#000000', bg: '#ffffff', moduleStyle: 'square',
finderOuter: 'square', finderInner: 'square',
frameStyle: 'none', ecc: 'M', margin: 4,
gradientType: 'none',
},
rounded: {
fg: '#111827', bg: '#ffffff', moduleStyle: 'square',
finderOuter: 'rounded', finderInner: 'square',
frameStyle: 'scan_me', ecc: 'Q', margin: 4,
gradientType: 'none',
},
branded: {
fg: '#1d4ed8', bg: '#ffffff', moduleStyle: 'square',
finderOuter: 'rounded', finderInner: 'square',
frameStyle: 'tap_to_scan', ecc: 'Q', margin: 4,
gradientType: 'none',
},
dots: {
fg: '#0f172a', bg: '#ffffff', moduleStyle: 'dots',
finderOuter: 'square', finderInner: 'dot',
frameStyle: 'scan_me', ecc: 'H', margin: 5,
gradientType: 'none',
},
};
Object.assign(this, presets[preset] || presets.classic);
this.schedulePreview();
},
setEyePreset(preset) {
const presets = {
square: { finderOuter: 'square', finderInner: 'square' },
rounded: { finderOuter: 'rounded', finderInner: 'square' },
target: { finderOuter: 'circle', finderInner: 'dot' },
};
Object.assign(this, presets[preset] || presets.square);
this.schedulePreview();
},
redirectTopup() {
if (this.topupUrl) {
window.location.href = this.topupUrl;
return true;
}
if (this.topupModalId) {
window.dispatchEvent(new CustomEvent('open-modal', {
detail: this.topupModalId,
bubbles: true,
}));
return true;
}
return false;
},
checkBalance(event) {
if (this.balance === null || this.price === null) {
return;
}
if (this.balance <= 0 || this.balance < this.price) {
event?.preventDefault();
this.redirectTopup();
}
},
nextStep(event) {
if (!this.wizard) {
return;
}
if (this.step === 1 && !this.validateWizardStep1()) {
event?.preventDefault();
return;
}
if (this.step < 3) {
this.step++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
prevStep() {
if (this.wizard && this.step > 1) {
this.step--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
validateWizardStep1() {
const form = this.$refs.createForm;
const label = form?.querySelector('[name=label]');
if (!label?.value?.trim()) {
label?.focus();
label?.reportValidity?.();
return false;
}
return label.checkValidity?.() !== false;
},
wizardSubmit(event) {
if (this.wizard && this.step < 3) {
event?.preventDefault();
this.nextStep(event);
return;
}
this.submitOrTopup(event);
},
reviewLabel() {
return this.$refs.createForm?.querySelector('[name=label]')?.value?.trim() || '';
},
reviewEventName() {
const eventBlock = this.$refs.createForm?.querySelector('[data-event-name]');
return eventBlock?.value?.trim() || this.reviewLabel();
},
submitOrTopup(event) {
if (this.balance <= 0 || this.balance < this.price) {
event?.preventDefault();
this.redirectTopup();
return;
}
if (event?.type === 'submit') {
return;
}
const form = this.$el.tagName === 'FORM' ? this.$el : this.$refs.createForm;
form?.requestSubmit();
},
_buildGradient() {
if (this.gradientType === 'none') return null;
return {
type: this.gradientType,
rotation: this.gradientType === 'linear' ? (Math.PI * this.gradientRotation / 180) : 0,
colorStops: [
{ offset: 0, color: this.gradientColor1 },
{ offset: 1, color: this.gradientColor2 },
],
};
},
_buildQrOptions(width, height) {
const dotsTypeMap = { square: 'square', dots: 'dots' };
const cornersSquareMap = { square: 'square', rounded: 'extra-rounded', circle: 'dot' };
// 'rounded' maps to 'square'; SVG post-processing applies the actual corner radius
const cornersDotMap = { square: 'square', rounded: 'square', dot: 'dot' };
const gradient = this._buildGradient();
const options = {
width,
height,
type: 'svg',
data: this.qrData,
margin: Math.round(Number(this.margin) * 4),
qrOptions: { errorCorrectionLevel: this.ecc || 'M' },
dotsOptions: {
type: dotsTypeMap[this.moduleStyle] || 'square',
...(gradient ? { gradient } : { color: this.fg }),
},
cornersSquareOptions: {
type: cornersSquareMap[this.finderOuter] || 'square',
...(gradient ? { gradient } : { color: this.fg }),
},
cornersDotOptions: {
type: cornersDotMap[this.finderInner] || 'square',
...(gradient ? { gradient } : { color: this.fg }),
},
backgroundOptions: { color: this.bg },
};
if (this._processedLogoUrl && !this.removeLogo) {
options.image = this._processedLogoUrl;
options.imageOptions = {
crossOrigin: 'anonymous',
margin: this.logoMargin,
imageSize: this.logoSize,
hideBackgroundDots: true,
};
}
return options;
},
// Post-process SVG to give corner inner dots rounded corners.
// qr-code-styling renders square cornersDot as <rect> elements inside <clipPath> in <defs>.
// Adding rx/ry directly to those rects rounds the clip mask → the visible dot appears rounded.
_applyRoundedInnerDot(container) {
const svg = container.querySelector('svg');
if (!svg) return;
const defs = svg.querySelector('defs');
if (!defs) { this._applyRoundedInnerDotFallback(container); return; }
// Collect all square <rect> elements (width === height) inside <clipPath> elements,
// grouped by their size value.
const groups = new Map();
defs.querySelectorAll('clipPath rect').forEach(rect => {
const w = rect.getAttribute('width');
const h = rect.getAttribute('height');
if (w && h && w === h) {
if (!groups.has(w)) groups.set(w, []);
groups.get(w).push(rect);
}
});
// Find the group of exactly 3 with the smallest size — that is the inner finder dot
// (one rect per finder eye; outer ring uses <path>, individual modules form much larger groups).
let targetRects = null, minSize = Infinity;
for (const [sizeStr, rects] of groups) {
if (rects.length !== 3) continue;
const size = parseFloat(sizeStr);
if (size < minSize) { minSize = size; targetRects = rects; }
}
if (!targetRects) { this._applyRoundedInnerDotFallback(container); return; }
const r = (minSize * 0.28).toFixed(3);
for (const rect of targetRects) {
rect.setAttribute('rx', r);
rect.setAttribute('ry', r);
}
},
// Fallback: use qrcode-generator to compute finder inner dot pixel positions, then
// overdraw rounded SVG <rect> elements at those positions.
_applyRoundedInnerDotFallback(container) {
const svg = container.querySelector('svg');
if (!svg || typeof window.qrcode === 'undefined') return;
try {
const qr = window.qrcode(0, this.ecc || 'M');
qr.addData(this.qrData);
qr.make();
const n = qr.getModuleCount();
const svgW = svg.viewBox?.baseVal?.width || parseFloat(svg.getAttribute('width') || '500');
const marginPx = Math.round(Number(this.margin) * 4);
const modulePx = (svgW - 2 * marginPx) / n;
const dotSize = modulePx * 3;
const r = dotSize * 0.28;
const fill = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
const NS = 'http://www.w3.org/2000/svg';
for (const [row, col] of [[2, 2], [2, n - 5], [n - 5, 2]]) {
const x = marginPx + col * modulePx;
const y = marginPx + row * modulePx;
// Cover the square dot with the background color
const eraser = document.createElementNS(NS, 'rect');
eraser.setAttribute('x', x); eraser.setAttribute('y', y);
eraser.setAttribute('width', dotSize); eraser.setAttribute('height', dotSize);
eraser.setAttribute('fill', this.bg);
svg.appendChild(eraser);
// Draw rounded corner dot
const dot = document.createElementNS(NS, 'rect');
dot.setAttribute('x', x); dot.setAttribute('y', y);
dot.setAttribute('width', dotSize); dot.setAttribute('height', dotSize);
dot.setAttribute('rx', r); dot.setAttribute('ry', r);
dot.setAttribute('fill', fill);
svg.appendChild(dot);
}
} catch (_) { /* silent */ }
},
refreshPreview() {
if (typeof window.QRCodeStyling === 'undefined') return;
this.previewLoading = true;
try {
const container = this.$refs.qrPreview;
if (container) {
container.innerHTML = '';
new window.QRCodeStyling(this._buildQrOptions(500, 500)).append(container);
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(container);
}
const modalContainer = this.$refs.qrPreviewModal;
if (modalContainer && this.showPreviewModal) {
modalContainer.innerHTML = '';
new window.QRCodeStyling(this._buildQrOptions(320, 320)).append(modalContainer);
if (this.finderInner === 'rounded') this._applyRoundedInnerDot(modalContainer);
}
} finally {
this.previewLoading = false;
this._maybeSyncCanonical();
}
},
// Persist the exact rendered QR as the canonical SVG+PNG (single source of
// truth) so downloads match the preview. Only on pages with a real /q code
// (canonicalSyncUrl set — i.e. the show page), once per load.
_maybeSyncCanonical() {
if (!this.canonicalSyncUrl || this._canonicalSynced) return;
if (typeof window.QRCodeStyling === 'undefined') return;
const svgEl = this.$refs.qrPreview?.querySelector('svg');
if (!svgEl) return;
this._canonicalSynced = true;
// Defer so finder-dot post-processing + DOM settle before serializing.
setTimeout(() => this.syncCanonical(), 300);
},
async syncCanonical() {
try {
const svgEl = this.$refs.qrPreview?.querySelector('svg');
if (!svgEl) return;
const inner = new XMLSerializer().serializeToString(svgEl);
const finalSvg = this._buildFramedSvg(inner);
const png = await this._svgToPng(finalSvg);
if (!png) return;
const fd = new FormData();
fd.append('svg', finalSvg);
fd.append('png', png);
await fetch(this.canonicalSyncUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': this.csrf || '', 'Accept': 'application/json' },
body: fd,
});
} catch (_) { /* non-fatal: server fallback render remains */ }
},
_xmlEscape(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
// Composite the CSS preview frame (thin border / SCAN ME label / TAP TO
// SCAN pill) into a single vector SVG, with the QR nested as vector — so
// downloads include the frame exactly like the preview. No frame => QR as-is.
_buildFramedSvg(qrSvg) {
const fs = this.frameStyle;
if (!fs || fs === 'none') return qrSvg;
const color = this.frameColor || '#000000';
const Q = 1000, pad = 72, radius = 56;
const W = Q + pad * 2;
// Nest the QR vector: ensure a viewBox, drop intrinsic width/height, place it.
const wm = qrSvg.match(/<svg\b[^>]*?\swidth\s*=\s*"([\d.]+)/i);
const hm = qrSvg.match(/<svg\b[^>]*?\sheight\s*=\s*"([\d.]+)/i);
const ow = wm ? wm[1] : '500';
const oh = hm ? hm[1] : '500';
let qr = qrSvg;
if (!/viewBox\s*=/i.test(qr)) {
qr = qr.replace(/<svg\b/i, `<svg viewBox="0 0 ${ow} ${oh}"`);
}
qr = qr
.replace(/(<svg\b[^>]*?)\swidth\s*=\s*"[^"]*"/i, '$1')
.replace(/(<svg\b[^>]*?)\sheight\s*=\s*"[^"]*"/i, '$1')
.replace(/<svg\b/i, `<svg x="${pad}" y="${pad}" width="${Q}" height="${Q}"`);
let labelH = 0, labelMarkup = '', border = '';
if (fs === 'scan_me') {
labelH = 124;
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'SCAN ME').toUpperCase();
labelMarkup =
`<line x1="${pad}" y1="${Q + pad + 26}" x2="${W - pad}" y2="${Q + pad + 26}" stroke="${color}" stroke-opacity="0.25" stroke-width="2"/>` +
`<text x="${W / 2}" y="${Q + pad + 86}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="700" letter-spacing="7" fill="${color}">${this._xmlEscape(txt)}</text>`;
} else if (fs === 'tap_to_scan') {
labelH = 142;
const txt = ((this.frameText && this.frameText.trim()) ? this.frameText : 'TAP TO SCAN').toUpperCase();
const pillH = 78;
const pillW = Math.min(W - pad * 2, 160 + txt.length * 24);
const pillX = (W - pillW) / 2, pillY = Q + pad + 30;
labelMarkup =
`<rect x="${pillX}" y="${pillY}" width="${pillW}" height="${pillH}" rx="${pillH / 2}" fill="${color}"/>` +
`<text x="${W / 2}" y="${pillY + pillH / 2 + 13}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="36" font-weight="700" letter-spacing="5" fill="${this.frameTextColor}">${this._xmlEscape(txt)}</text>`;
}
const H = Q + pad * 2 + labelH;
if (fs === 'thin') {
border = `<rect x="11" y="11" width="${W - 22}" height="${H - 22}" rx="${radius - 6}" fill="none" stroke="${color}" stroke-width="18"/>`;
}
// No XML prolog here — the server's sanitizeSvg prepends it (a literal
// XML prolog in a Blade file would be read as a PHP open tag).
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
`<rect x="0" y="0" width="${W}" height="${H}" rx="${radius}" fill="#ffffff"/>` +
border + qr + labelMarkup +
'</svg>';
},
_svgToPng(svgString) {
return new Promise((resolve) => {
try {
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
try {
const w = img.naturalWidth || 1000;
const h = img.naturalHeight || 1000;
const f = Math.max(1, 1000 / Math.max(w, h)); // upscale small to ~1000 for crisp print
const cw = Math.round(w * f), ch = Math.round(h * f);
const c = document.createElement('canvas');
c.width = cw; c.height = ch;
const ctx = c.getContext('2d');
ctx.fillStyle = (this.frameStyle && this.frameStyle !== 'none') ? '#ffffff' : (this.bg || '#ffffff');
ctx.fillRect(0, 0, cw, ch);
ctx.drawImage(img, 0, 0, cw, ch);
URL.revokeObjectURL(url);
resolve(c.toDataURL('image/png'));
} catch (_) { URL.revokeObjectURL(url); resolve(null); }
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
} catch (_) { resolve(null); }
});
},
// Returns black or white depending on frameColor luminance, for readable CTA text
get frameTextColor() {
return this._hexToLuminance(this.frameColor) > 0.35 ? '#000000' : '#ffffff';
},
// ── Scanability validation ────────────────────────────────────────
get scanability() {
let score = 100;
const warnings = [];
// Contrast check — use gradient start colour when gradient is active
const effectiveFg = this.gradientType !== 'none' ? this.gradientColor1 : this.fg;
const ratio = this._contrastRatio(effectiveFg, this.bg);
if (ratio < 2.0) {
score -= 50;
warnings.push('Very low contrast — code will likely fail to scan.');
} else if (ratio < 3.0) {
score -= 30;
warnings.push('Low contrast between foreground and background.');
} else if (ratio < 4.5) {
score -= 10;
warnings.push('Contrast is acceptable but could be improved.');
}
// Logo size checks
const hasLogo = Boolean(this._processedLogoUrl) && !this.removeLogo;
if (hasLogo) {
if (this.logoSize > 0.40) {
score -= 25;
warnings.push('Logo is too large and may block scanning.');
} else if (this.logoSize > 0.30) {
score -= 10;
warnings.push('Large logo — use High (H) error correction.');
}
if (this.logoSize > 0.25 && this.ecc === 'L') {
score -= 15;
warnings.push('Logo requires at least Medium (M) error correction.');
}
}
// Extreme customisation warning
const hasGradient = this.gradientType !== 'none';
const complexEyes = this.finderOuter === 'circle' || this.finderInner === 'dot';
if (hasLogo && hasGradient && complexEyes && this.ecc === 'L') {
score -= 10;
warnings.push('Many effects combined — consider higher error correction.');
}
score = Math.max(0, score);
let label, level;
if (score >= 80) { label = 'Excellent'; level = 'excellent'; }
else if (score >= 60) { label = 'Good'; level = 'good'; }
else { label = 'Warning'; level = 'warning'; }
return { score, label, level, warnings };
},
_hexToLuminance(hex) {
hex = (hex || '#000000').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
if (hex.length !== 6) return 0;
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const lin = c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
},
_contrastRatio(hex1, hex2) {
const l1 = this._hexToLuminance(hex1);
const l2 = this._hexToLuminance(hex2);
const light = Math.max(l1, l2);
const dark = Math.min(l1, l2);
return (light + 0.05) / (dark + 0.05);
},
}));
};
if (window.Alpine) {
registerQrCustomizer();
} else {
document.addEventListener('alpine:init', registerQrCustomizer);
}
})();
</script>
@endonce
@@ -1,188 +0,0 @@
@php
$showDownloads = $showDownloads ?? true;
@endphp
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
{{-- QR display area with gradient background --}}
<div class="relative flex items-center justify-center bg-gradient-to-br from-indigo-50/60 via-white to-violet-50/40 px-8 py-10">
{{-- Spinner overlay --}}
<div x-show="previewLoading" x-cloak
class="absolute inset-0 z-10 flex items-center justify-center bg-white/60 backdrop-blur-[2px]">
<svg class="h-8 w-8 animate-spin text-indigo-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"/>
<path class="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
{{-- Floating QR card --}}
<div class="w-full max-w-xs transition-all duration-300"
:class="previewLoading ? 'opacity-40 scale-[0.97]' : 'opacity-100 scale-100'">
<div class="overflow-hidden rounded-2xl bg-white shadow-2xl"
:style="frameStyle === 'thin'
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
{{-- Frame wrapper: adds padding + CTA for label/pill frames --}}
<div :class="{
'p-[8px]': frameStyle === 'thin',
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
'p-4': frameStyle === 'none',
}">
{{-- QR code area SVG renderer only, no static fallback --}}
<div x-ref="qrPreview"
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
{{-- Scan me label --}}
<div x-show="frameStyle === 'scan_me'" x-cloak
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
class="pt-2.5 pb-0.5 text-center">
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
class="text-[11px] font-bold tracking-[0.18em]"></span>
</div>
{{-- Tap to scan pill --}}
<div x-show="frameStyle === 'tap_to_scan'" x-cloak
class="mt-2.5 flex justify-center pb-0.5">
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
</div>
</div>
</div>
</div>
</div>
{{-- Scanability score --}}
<div class="border-t border-slate-100 px-5 py-4">
<div class="flex items-center justify-between">
<span class="text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Scan quality</span>
<span class="text-xs font-bold tabular-nums"
:class="{
'text-emerald-600': scanability.level === 'excellent',
'text-amber-500': scanability.level === 'good',
'text-red-500': scanability.level === 'warning',
}"
x-text="scanability.score + '% — ' + scanability.label"></span>
</div>
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all duration-300"
:style="'width: ' + scanability.score + '%'"
:class="{
'bg-emerald-500': scanability.level === 'excellent',
'bg-amber-400': scanability.level === 'good',
'bg-red-500': scanability.level === 'warning',
}"></div>
</div>
<template x-if="scanability.warnings.length > 0">
<ul class="mt-2.5 space-y-1.5">
<template x-for="w in scanability.warnings" :key="w">
<li class="flex items-start gap-1.5">
<svg class="mt-px h-3.5 w-3.5 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
</svg>
<span class="text-[11px] leading-4 text-amber-700" x-text="w"></span>
</li>
</template>
</ul>
</template>
</div>
{{-- Download + Share buttons --}}
@if($showDownloads && isset($qrCode))
@php
$shareUrl = $qrCode->publicUrl();
$shareEnc = urlencode($shareUrl);
$shareText = urlencode($qrCode->label . ' — scan my QR code');
@endphp
<div class="border-t border-slate-100 bg-slate-50/50 px-6 py-5">
<p class="mb-3 text-[10px] font-semibold uppercase tracking-[0.1em] text-slate-400">Download &amp; Share</p>
<div class="grid grid-cols-4 gap-2">
<a href="{{ route('events.download', [$qrCode, 'png']) }}"
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-indigo-300 hover:bg-indigo-50">
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12M12 16.5V3"/>
</svg>
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-indigo-700">PNG</span>
</a>
<a href="{{ route('events.download', [$qrCode, 'svg']) }}"
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-violet-300 hover:bg-violet-50">
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-violet-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"/>
</svg>
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-violet-700">SVG</span>
</a>
<a href="{{ route('events.download', [$qrCode, 'pdf']) }}"
class="group flex flex-col items-center gap-1.5 rounded-xl border border-slate-200 bg-white py-3.5 text-center transition hover:border-rose-300 hover:bg-rose-50">
<svg class="h-4 w-4 text-slate-400 transition group-hover:text-rose-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/>
</svg>
<span class="text-xs font-semibold text-slate-600 transition group-hover:text-rose-700">PDF</span>
</a>
{{-- Share button with popover --}}
<div class="relative" x-data="{ open: false, copied: false }">
<button type="button"
@click="open = !open"
:class="open ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white hover:border-sky-300 hover:bg-sky-50'"
class="group flex w-full flex-col items-center gap-1.5 rounded-xl border py-3.5 text-center transition">
<svg class="h-4 w-4 transition" :class="open ? 'text-sky-500' : 'text-slate-400 group-hover:text-sky-500'" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"/>
</svg>
<span class="text-xs font-semibold transition" :class="open ? 'text-sky-700' : 'text-slate-600 group-hover:text-sky-700'">Share</span>
</button>
<div x-show="open" x-cloak
@click.outside="open = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute bottom-full right-0 z-50 mb-2 w-52 origin-bottom-right rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl">
<a href="https://wa.me/?text={{ $shareText }}%20{{ $shareEnc }}" target="_blank" rel="noopener"
@click="open = false"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<svg class="h-4 w-4 shrink-0 text-green-500" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
WhatsApp
</a>
<a href="https://twitter.com/intent/tweet?url={{ $shareEnc }}&text={{ $shareText }}" target="_blank" rel="noopener"
@click="open = false"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<svg class="h-4 w-4 shrink-0 text-slate-800" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
X (Twitter)
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ $shareEnc }}" target="_blank" rel="noopener"
@click="open = false"
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<svg class="h-4 w-4 shrink-0 text-blue-600" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
Facebook
</a>
<div class="my-1 border-t border-slate-100"></div>
<button type="button"
@click="navigator.clipboard.writeText('{{ $shareUrl }}').then(() => { copied = true; setTimeout(() => { copied = false; open = false }, 1500) })"
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition hover:bg-slate-50"
:class="copied ? 'text-emerald-600' : 'text-slate-700'">
<svg x-show="!copied" class="h-4 w-4 shrink-0 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"/></svg>
<svg x-show="copied" x-cloak class="h-4 w-4 shrink-0 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
<span x-text="copied ? 'Copied!' : 'Copy link'">Copy link</span>
</button>
</div>
</div>
</div>
</div>
@endif
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-343
View File
@@ -1,343 +0,0 @@
<x-user-layout>
<x-slot name="title">{{ $qrCode->label }}</x-slot>
@php
$qrStyle = $qrCode->style();
@endphp
<div class="mx-auto max-w-6xl space-y-6"
x-data="qrCustomizer({
previewUrl: @js(route('events.style-preview')),
csrf: @js(csrf_token()),
canonicalSyncUrl: @js(route('events.canonical-image', $qrCode)),
shortCode: @js($qrCode->short_code),
qrData: @js($qrCode->encodedPayload()),
initialPreview: @js($previewDataUri),
existingLogoPath: @js($qrStyle['logo_path'] ?? null),
existingLogoUrl: @js($logoDataUri ?? null),
hasExistingLogo: @js(! empty($qrStyle['logo_path'])),
style: @js(\App\Support\Qr\QrStyleDefaults::merge($qrStyle)),
})">
@foreach(['success', 'error'] as $flash)
@if(session($flash))
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
{{ session($flash) }}
</div>
@endif
@endforeach
{{-- Mobile page header --}}
<div class="flex items-center gap-3 py-1 lg:hidden">
<a href="{{ route('events.index') }}"
class="flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white shadow-sm text-slate-500 hover:text-slate-900 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</a>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900">{{ $qrCode->label }}</span>
</div>
{{-- Desktop page header --}}
<div class="hidden lg:block">
<a href="{{ route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
QR Codes
</a>
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $qrCode->label }}</h1>
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $qrCode->is_active ? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' : 'bg-slate-100 text-slate-500' }}">
{{ $qrCode->is_active ? 'Active' : 'Paused' }}
</span>
<span class="text-xs text-slate-400">{{ $qrCode->typeLabel() }}</span>
<code class="rounded-lg bg-slate-100 px-2 py-0.5 text-xs text-slate-600">{{ $qrCode->publicUrl() }}</code>
</div>
</div>
{{-- Main 2-column layout --}}
<form x-ref="updateForm" action="{{ route('events.update', $qrCode) }}" method="POST" enctype="multipart/form-data"
class="grid grid-cols-1 gap-8 lg:grid-cols-[380px_1fr]">
@csrf
@method('PATCH')
{{-- Left: Sticky preview + downloads (desktop only) --}}
<div class="hidden lg:block lg:sticky lg:top-6 lg:self-start">
@include('qr-codes.partials.qr-preview-card', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'showDownloads' => true,
])
</div>
{{-- Right: Controls --}}
<div class="space-y-5 pb-4 lg:pb-0">
{{-- Content first --}}
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
<h2 class="text-sm font-semibold text-slate-900">Content</h2>
<p class="mt-0.5 text-xs text-slate-400">
@if($qrCode->type === \App\Models\QrCode::TYPE_WIFI)
Auto-join is locked to the network set when this code was created. Editing updates your saved info but won't change the printed code.
@else
Edits are free. The printed QR stays the same.
@endif
</p>
</div>
<div class="space-y-4 p-5">
<div>
<label class="block text-xs font-semibold text-slate-600">Label</label>
<input type="text" name="label" value="{{ $qrCode->label }}"
class="mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
</div>
@include('qr-codes.partials.type-fields-edit')
<label class="flex items-center gap-2.5 text-sm text-slate-700">
<input type="checkbox" name="is_active" value="1" @checked($qrCode->is_active)
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
QR is active
</label>
</div>
</div>
{{-- Customizer --}}
@include('qr-codes.partials.customization-fields', [
'style' => $qrStyle,
'moduleStyles' => $moduleStyles,
'cornerOuterStyles' => $cornerOuterStyles,
'cornerInnerStyles' => $cornerInnerStyles,
'frameStyles' => $frameStyles,
'showRemoveLogo' => ! empty($qrStyle['logo_path']),
])
{{-- Desktop save button --}}
<button type="submit"
class="hidden lg:block w-full rounded-xl bg-slate-900 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-700 focus:ring-offset-2">
Save changes
</button>
</div>
</form>
{{-- Mobile sticky action bar --}}
<div x-show="!showPreviewModal"
class="mobile-action-bar lg:hidden">
<div class="mobile-action-bar__toolbar">
<button type="button"
@click="showPreviewModal = true"
class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-slate-50 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>
Preview
</button>
<button type="button"
@click="$refs.updateForm?.requestSubmit()"
class="flex flex-1 items-center justify-center gap-2 rounded-xl bg-slate-900 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-slate-700">
Save
</button>
</div>
<div class="mobile-action-bar__inset-fill" aria-hidden="true"></div>
</div>
{{-- Preview modal --}}
<div x-show="showPreviewModal" x-cloak
x-transition:enter="transition-opacity duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@keydown.escape.window="showPreviewModal = false"
class="fixed inset-0 z-[60] flex flex-col bg-black/60 backdrop-blur-sm lg:hidden"
style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="flex items-center justify-between px-5 py-4">
<span class="text-sm font-semibold text-white">Preview</span>
<button type="button" @click="showPreviewModal = false"
class="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 text-white hover:bg-white/30 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-xs overflow-hidden rounded-2xl bg-white shadow-2xl"
:style="frameStyle === 'thin'
? 'box-shadow: 0 0 0 2px ' + frameColor + ', 0 25px 50px -12px rgb(0 0 0 / 0.25)'
: 'box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25), 0 0 0 1px rgb(0 0 0 / 0.06)'">
<div :class="{
'p-[8px]': frameStyle === 'thin',
'p-[14px]': frameStyle === 'scan_me' || frameStyle === 'tap_to_scan',
'p-4': frameStyle === 'none',
}">
<div x-ref="qrPreviewModal"
class="aspect-square w-full [&>svg]:block [&>svg]:h-full [&>svg]:w-full"></div>
<div x-show="frameStyle === 'scan_me'" x-cloak
:style="'border-top: 1px solid ' + frameColor + '40; color: ' + frameColor"
class="pt-2.5 pb-0.5 text-center">
<span x-text="(frameText && frameText.trim() ? frameText : 'SCAN ME').toUpperCase()"
class="text-[11px] font-bold tracking-[0.18em]"></span>
</div>
<div x-show="frameStyle === 'tap_to_scan'" x-cloak class="mt-2.5 flex justify-center pb-0.5">
<span x-text="(frameText && frameText.trim() ? frameText : 'TAP TO SCAN').toUpperCase()"
:style="'background-color: ' + frameColor + '; color: ' + frameTextColor"
class="rounded-full px-5 py-1.5 text-[10px] font-bold tracking-widest"></span>
</div>
</div>
</div>
</div>
{{-- Downloads pinned to bottom of modal --}}
@php
$mobileShareUrl = $qrCode->publicUrl();
$mobileShareText = urlencode($qrCode->label . ' — scan my QR code');
$mobileShareEnc = urlencode($mobileShareUrl);
@endphp
<div class="grid grid-cols-4 gap-2 px-6 pb-6">
<a href="{{ route('events.download', [$qrCode, 'png']) }}"
class="flex flex-col items-center gap-1 rounded-xl border border-white/20 bg-white/10 py-3 text-center text-white transition hover:bg-white/20">
<span class="text-xs font-semibold">PNG</span>
</a>
<a href="{{ route('events.download', [$qrCode, 'svg']) }}"
class="flex flex-col items-center gap-1 rounded-xl border border-white/20 bg-white/10 py-3 text-center text-white transition hover:bg-white/20">
<span class="text-xs font-semibold">SVG</span>
</a>
<a href="{{ route('events.download', [$qrCode, 'pdf']) }}"
class="flex flex-col items-center gap-1 rounded-xl border border-white/20 bg-white/10 py-3 text-center text-white transition hover:bg-white/20">
<span class="text-xs font-semibold">PDF</span>
</a>
<button type="button"
@click="if(navigator.share){navigator.share({title:@js($qrCode->label),url:@js($mobileShareUrl)}).catch(()=>{})}else{window.open('https://wa.me/?text={{ $mobileShareText }}%20{{ $mobileShareEnc }}','_blank')}"
class="flex flex-col items-center gap-1 rounded-xl border border-sky-400/40 bg-sky-500/20 py-3 text-center text-sky-200 transition hover:bg-sky-500/30">
<span class="text-xs font-semibold">Share</span>
</button>
</div>
</div>
{{-- Orders (Book / Menu / Shop) --}}
@if(isset($orders) && !is_null($orders))
<div class="mt-10 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-slate-900">Orders</h2>
<span class="text-xs text-slate-400">{{ $orders->count() }} paid</span>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ $orders->count() }}</p>
<p class="mt-0.5 text-xs text-slate-400">Total orders</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format((float) $orders->sum('amount_ghs'), 2) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Revenue earned</p>
</div>
</div>
@if($orders->isEmpty())
<div class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center">
<p class="text-sm text-slate-400">No paid orders yet.</p>
</div>
@else
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<table class="w-full">
<thead>
<tr class="border-b border-slate-100 bg-slate-50/70">
<th class="px-5 py-3 text-left text-xs font-semibold text-slate-500">Customer</th>
<th class="hidden px-5 py-3 text-left text-xs font-semibold text-slate-500 sm:table-cell">Items</th>
<th class="px-5 py-3 text-right text-xs font-semibold text-slate-500">Amount</th>
<th class="px-5 py-3 text-right text-xs font-semibold text-slate-500">Date</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@foreach($orders as $order)
<tr class="transition-colors hover:bg-slate-50/50">
<td class="px-5 py-3.5">
<p class="text-sm font-medium text-slate-900">{{ $order->customer_name ?: '—' }}</p>
<p class="text-xs text-slate-400">{{ $order->customer_email ?: '' }}</p>
</td>
<td class="hidden px-5 py-3.5 text-sm text-slate-600 sm:table-cell">
{{ collect($order->items)->map(fn($i) => ($i['qty'] > 1 ? $i['qty'].'× ' : '').$i['name'])->join(', ') }}
</td>
<td class="px-5 py-3.5 text-right text-sm font-semibold text-slate-900 whitespace-nowrap">
GHS {{ number_format((float) $order->amount_ghs, 2) }}
</td>
<td class="px-5 py-3.5 text-right text-xs text-slate-400 whitespace-nowrap">
{{ $order->paid_at?->format('M j, Y') }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
{{-- Scan analytics --}}
<div class="mt-10 space-y-5">
<h2 class="text-base font-semibold text-slate-900">Analytics</h2>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['total_scans']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Total scans</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['unique_scans']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Unique scans</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_7d']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Last 7 days</p>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<p class="text-2xl font-bold text-slate-900">{{ number_format($summary['scans_30d']) }}</p>
<p class="mt-0.5 text-xs text-slate-400">Last 30 days</p>
</div>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-6 shadow-sm">
<h3 class="text-sm font-semibold text-slate-900">Scans last 30 days</h3>
@php $maxDaily = max(1, $dailyScans->max('total')); @endphp
<div class="mt-5 flex h-28 items-end gap-px">
@foreach($dailyScans as $day)
<div class="group relative flex flex-1 flex-col items-center justify-end"
title="{{ $day->date }}: {{ $day->total }}">
<div class="w-full rounded-t-sm bg-indigo-400/70 transition-colors group-hover:bg-indigo-600"
style="height: {{ max(2, ($day->total / $maxDaily) * 100) }}%"></div>
</div>
@endforeach
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<h3 class="text-sm font-semibold text-slate-900">Devices</h3>
<ul class="mt-4 space-y-2.5">
@forelse($devices as $row)
<li class="flex items-center justify-between text-sm">
<span class="text-slate-600">{{ ucfirst($row['label']) }}</span>
<span class="font-semibold text-slate-900">{{ $row['total'] }}</span>
</li>
@empty
<li class="text-sm text-slate-400">No scans yet</li>
@endforelse
</ul>
</div>
<div class="rounded-2xl border border-slate-200/80 bg-white p-5 shadow-sm">
<h3 class="text-sm font-semibold text-slate-900">Browsers</h3>
<ul class="mt-4 space-y-2.5">
@forelse($browsers as $row)
<li class="flex items-center justify-between text-sm">
<span class="text-slate-600">{{ $row['label'] }}</span>
<span class="font-semibold text-slate-900">{{ $row['total'] }}</span>
</li>
@empty
<li class="text-sm text-slate-400">No scans yet</li>
@endforelse
</ul>
</div>
</div>
</div>
</div>
</x-user-layout>
@@ -1,13 +0,0 @@
<x-user-layout>
<x-slot name="title">Billing</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
<p class="mt-0.5 text-sm text-slate-500">QR code creation is billed from your Ladill wallet (GHS {{ number_format(config('qr.price_per_qr_ghs', 5), 2) }} per code).</p>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Current balance</p>
<p class="mt-1 text-3xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
<a href="{{ $topupUrl }}" class="btn-primary mt-4">Top up wallet</a>
</div>
</div>
</x-user-layout>
@@ -1,87 +0,0 @@
<x-user-layout>
<x-slot name="title">Developers</x-slot>
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your QR codes programmatically.</p>
@if($newToken)
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
<p class="text-sm font-semibold text-emerald-900">Your new token copy it now</p>
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied </span>
</button>
</div>
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf
<div class="flex-1">
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
<input type="text" name="name" required placeholder="e.g. CI server"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<button class="btn-primary">Generate token</button>
</form>
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
@forelse($tokens as $token)
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
<div>
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
<p class="text-xs text-slate-400">
Created {{ $token->created_at->diffForHumans() }} ·
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
</p>
</div>
<x-confirm-dialog
:name="'revoke-token-'.$token->id"
title="Revoke API token?"
:message="'Revoke '.$token->name.'? Apps using this token will stop working.'"
:action="route('account.developers.destroy', $token->id)"
method="DELETE"
confirm-label="Revoke"
>
<x-slot:trigger>
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
</x-slot:trigger>
</x-confirm-dialog>
</div>
@empty
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
@endforelse
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
<h2 class="text-sm font-semibold text-white">Quick start</h2>
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/qr-codes \
-H "Authorization: Bearer &lt;your-token&gt;" \
-H "Accept: application/json"</code></pre>
<p class="mt-3 text-[11px] text-slate-400">Endpoints:</p>
<ul class="mt-1.5 space-y-1 text-[11px] text-slate-400">
<li><span class="font-mono text-slate-300">GET /me</span> token user and acting account</li>
<li><span class="font-mono text-slate-300">GET /qr-codes</span> list your codes</li>
<li><span class="font-mono text-slate-300">GET /qr-codes/{id}</span> code details</li>
<li><span class="font-mono text-slate-300">GET /qr-codes/{id}/analytics</span> scan stats</li>
<li><span class="font-mono text-slate-300">POST /qr-codes</span> create (url, link_list, wifi, business, app)</li>
<li><span class="font-mono text-slate-300">PATCH /qr-codes/{id}</span> update label, content, pause/resume</li>
</ul>
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl -X POST {{ $apiBase }}/qr-codes \
-H "Authorization: Bearer &lt;your-token&gt;" \
-H "Content-Type: application/json" \
-d '{"label":"Homepage","type":"url","destination_url":"https://example.com"}'</code></pre>
<p class="mt-3 text-[11px] text-slate-500">Team access: pass <span class="font-mono text-slate-300">X-Ladill-Account: &lt;owner-user-id&gt;</span>. PDF codes need the web app. Regenerate tokens to get write access if yours only has read.</p>
</div>
</div>
</x-user-layout>
@@ -1,260 +0,0 @@
<x-user-layout>
<x-slot name="title">Settings</x-slot>
@php
$ed = $eventDefaults;
$badgeFieldLabels = old('event_defaults.badge_fields', $ed['badge_fields'] ?? ['Company', 'Role']);
@endphp
<div class="mx-auto max-w-2xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Settings</h1>
<p class="mt-0.5 text-sm text-slate-500">Notifications, defaults for new events, and QR preferences.</p>
@if (session('success'))
<div class="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</div>
@endif
<form method="POST" action="{{ route('account.settings.update') }}" class="mt-6 space-y-4">
@csrf
@method('PUT')
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Notifications</h2>
<p class="mt-1 text-xs text-slate-500">Choose what we email you about your events and account.</p>
<div class="mt-4">
<label for="notify_email" class="block text-[11px] font-medium text-slate-500">Notifications email</label>
<input type="email" id="notify_email" name="notify_email"
value="{{ old('notify_email', $settings->notify_email ?? $account->email) }}"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('notify_email') <p class="mt-1 text-xs text-rose-600">{{ $message }}</p> @enderror
</div>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">New registrations</span>
<span class="block text-xs text-slate-400">Email when someone registers or buys a ticket.</span>
</span>
<input type="checkbox" name="notify_registrations" value="1"
@checked(old('notify_registrations', $settings->notify_registrations ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">Payouts & settlements</span>
<span class="block text-xs text-slate-400">Email when ticket revenue is ready to withdraw.</span>
</span>
<input type="checkbox" name="notify_payouts" value="1"
@checked(old('notify_payouts', $settings->notify_payouts ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">Low QR balance</span>
<span class="block text-xs text-slate-400">Email when your wallet is too low to publish a new event QR.</span>
</span>
<input type="checkbox" name="low_balance_alerts" value="1"
@checked(old('low_balance_alerts', $settings->low_balance_alerts ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
<label class="mt-4 flex items-center justify-between gap-4">
<span>
<span class="block text-sm font-medium text-slate-800">Ladill Events updates</span>
<span class="block text-xs text-slate-400">Occasional emails about new features and improvements.</span>
</span>
<input type="checkbox" name="product_updates" value="1"
@checked(old('product_updates', $settings->product_updates ?? true))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">New event defaults</h2>
<p class="mt-1 text-xs text-slate-500">Pre-fill the create flow so every new event starts with your usual setup.</p>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="default_currency" class="block text-[11px] font-medium text-slate-500">Currency</label>
<select id="default_currency" name="event_defaults[currency]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach (['GHS' => 'GHS (Ghanaian Cedi)', 'USD' => 'USD (US Dollar)', 'NGN' => 'NGN (Nigerian Naira)', 'KES' => 'KES (Kenyan Shilling)'] as $code => $label)
<option value="{{ $code }}" @selected(old('event_defaults.currency', $ed['currency']) === $code)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="default_mode" class="block text-[11px] font-medium text-slate-500">Collection type</label>
<select id="default_mode" name="event_defaults[mode]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<option value="ticketing" @selected(old('event_defaults.mode', $ed['mode']) === 'ticketing')>Ticketed event</option>
<option value="contributions" @selected(old('event_defaults.mode', $ed['mode']) === 'contributions')>Contributions / gifts</option>
<option value="free" @selected(old('event_defaults.mode', $ed['mode']) === 'free')>Free registration</option>
</select>
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="default_badge_size" class="block text-[11px] font-medium text-slate-500">Badge size</label>
<select id="default_badge_size" name="event_defaults[badge_size]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach (['4x3' => '4 × 3 in (landscape)', '4x6' => '4 × 6 in (lanyard)', 'cr80' => 'CR80 card'] as $size => $label)
<option value="{{ $size }}" @selected(old('event_defaults.badge_size', $ed['badge_size']) === $size)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label for="default_brand_color" class="block text-[11px] font-medium text-slate-500">Brand color</label>
<input type="color" id="default_brand_color" name="event_defaults[brand_color]"
value="{{ old('event_defaults.brand_color', $ed['brand_color']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
</div>
<div class="mt-4">
<label for="default_organizer" class="block text-[11px] font-medium text-slate-500">Default organizer name</label>
<input type="text" id="default_organizer" name="event_defaults[organizer]" maxlength="120"
value="{{ old('event_defaults.organizer', $ed['organizer']) }}"
placeholder="e.g. CAPBuSS Events"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
<div class="mt-4" x-data="{ fields: @js(array_values($badgeFieldLabels)) }">
<label class="block text-[11px] font-medium text-slate-500">Default badge fields</label>
<p class="mt-0.5 text-[11px] text-slate-400">Extra attendee fields shown on badges (e.g. Company, Role).</p>
<div class="mt-2 space-y-2">
<template x-for="(field, i) in fields" :key="i">
<div class="flex items-center gap-2">
<input type="text" :name="'event_defaults[badge_fields]['+i+']'" x-model="fields[i]" maxlength="40"
class="flex-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
<button type="button" x-show="fields.length > 1" @click="fields.splice(i, 1)"
class="rounded-lg border border-slate-200 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">Remove</button>
</div>
</template>
</div>
<button type="button" @click="fields.push('')"
class="mt-2 text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add field</button>
</div>
<label class="mt-4 flex items-center justify-between gap-4 rounded-xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<span>
<span class="block text-sm font-medium text-slate-800">Registration open by default</span>
<span class="block text-xs text-slate-400">New events accept sign-ups unless you turn this off.</span>
</span>
<input type="checkbox" name="event_defaults[registration_open]" value="1"
@checked(old('event_defaults.registration_open', $ed['registration_open']))
class="h-5 w-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</label>
</div>
<details class="group rounded-2xl border border-slate-200 bg-white">
<summary class="cursor-pointer list-none p-5 marker:content-none">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">QR code defaults</h2>
<p class="mt-1 text-xs text-slate-500">Style applied when you design event QR codes.</p>
</div>
<svg class="h-5 w-5 shrink-0 text-slate-400 transition group-open:rotate-180" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>
</div>
</summary>
<div class="space-y-4 border-t border-slate-100 px-5 pb-5 pt-4">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="foreground" class="block text-[11px] font-medium text-slate-500">Foreground</label>
<input type="color" id="foreground" name="default_style[foreground]"
value="{{ old('default_style.foreground', $style['foreground']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
<div>
<label for="background" class="block text-[11px] font-medium text-slate-500">Background</label>
<input type="color" id="background" name="default_style[background]"
value="{{ old('default_style.background', $style['background']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="module_style" class="block text-[11px] font-medium text-slate-500">Module style</label>
<select id="module_style" name="default_style[module_style]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach ($moduleStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.module_style', $style['module_style']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="error_correction" class="block text-[11px] font-medium text-slate-500">Error correction</label>
<select id="error_correction" name="default_style[error_correction]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach (['L' => 'Low', 'M' => 'Medium', 'Q' => 'Quartile', 'H' => 'High'] as $key => $label)
<option value="{{ $key }}" @selected(old('default_style.error_correction', $style['error_correction']) === $key)>{{ $label }} ({{ $key }})</option>
@endforeach
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="finder_outer" class="block text-[11px] font-medium text-slate-500">Corner outer</label>
<select id="finder_outer" name="default_style[finder_outer]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach ($cornerOuterStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.finder_outer', $style['finder_outer']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="finder_inner" class="block text-[11px] font-medium text-slate-500">Corner inner</label>
<select id="finder_inner" name="default_style[finder_inner]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach ($cornerInnerStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.finder_inner', $style['finder_inner']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="frame_style" class="block text-[11px] font-medium text-slate-500">Frame</label>
<select id="frame_style" name="default_style[frame_style]"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@foreach ($frameStyles as $key => $meta)
<option value="{{ $key }}" @selected(old('default_style.frame_style', $style['frame_style']) === $key)>{{ $meta['label'] }}</option>
@endforeach
</select>
</div>
<div>
<label for="frame_color" class="block text-[11px] font-medium text-slate-500">Frame color</label>
<input type="color" id="frame_color" name="default_style[frame_color]"
value="{{ old('default_style.frame_color', $style['frame_color']) }}"
class="mt-1 h-10 w-full cursor-pointer rounded-lg border border-slate-200 bg-white p-1">
</div>
</div>
<div>
<label for="frame_text" class="block text-[11px] font-medium text-slate-500">Frame label</label>
<input type="text" id="frame_text" name="default_style[frame_text]" maxlength="100"
value="{{ old('default_style.frame_text', $style['frame_text']) }}"
placeholder="SCAN ME"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
</div>
</div>
</details>
<button type="submit" class="btn-primary">
Save settings
</button>
</form>
<p class="mt-6 text-xs text-slate-400">
Profile, password, and security are managed on
<a href="{{ ladill_account_url('account-settings') }}" class="font-medium text-indigo-600 hover:text-indigo-800">account.ladill.com</a>.
</p>
</div>
</x-user-layout>
-92
View File
@@ -1,92 +0,0 @@
<x-user-layout>
<x-slot name="title">Team</x-slot>
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this accounts QR codes.</p>
@if($canManage)
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
@csrf
<div class="flex-1">
<label class="block text-[11px] font-medium text-slate-500">Email</label>
<input type="email" name="email" required placeholder="teammate@example.com"
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-[11px] font-medium text-slate-500">Role</label>
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<button class="btn-primary">Send invite</button>
</form>
<p class="mt-2 text-[11px] text-slate-400">Admins can manage QR codes and the team. Members can manage QR codes. Invitees join by signing in with that email.</p>
</div>
@endif
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
<ul class="divide-y divide-slate-50">
<li class="flex items-center justify-between px-5 py-3.5">
<div class="flex items-center gap-3">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
<div>
<p class="text-sm font-medium text-slate-900">
{{ $account->name ?? $account->email }}
@if($isOwner)<span class="text-xs font-normal text-slate-400">(you)</span>@endif
</p>
<p class="text-xs text-slate-400">{{ $account->email }}</p>
</div>
</div>
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
</li>
@forelse($members as $member)
<li class="flex items-center justify-between px-5 py-3.5">
<div class="flex items-center gap-3">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
<div>
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
<p class="text-xs text-slate-400">{{ $member->email }}</p>
</div>
</div>
<div class="flex items-center gap-3">
@if($member->status === 'invited')
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
@endif
@if($canManage)
<form method="POST" action="{{ route('account.team.role', $member) }}">
@csrf @method('PATCH')
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
<option value="member" @selected($member->role === 'member')>Member</option>
<option value="admin" @selected($member->role === 'admin')>Admin</option>
</select>
</form>
<x-confirm-dialog
:name="'remove-member-'.$member->id"
title="Remove team member?"
:message="'Remove '.$member->email.' from this team?'"
:action="route('account.team.destroy', $member)"
method="DELETE"
confirm-label="Remove"
>
<x-slot:trigger>
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
</x-slot:trigger>
</x-confirm-dialog>
@else
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
@endif
</div>
</li>
@empty
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
@endforelse
</ul>
</div>
</div>
</x-user-layout>
@@ -1,23 +0,0 @@
<x-user-layout>
<x-slot name="title">Wallet</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Wallet</h1>
<p class="mt-0.5 text-sm text-slate-500">Your Ladill wallet funds QR Plus and other Ladill apps.</p>
<div class="mt-6 rounded-2xl bg-gradient-to-br from-indigo-700 via-indigo-600 to-blue-500 p-6 text-white shadow-sm">
<p class="text-xs font-medium uppercase tracking-wide text-indigo-100">Balance</p>
<p class="mt-1 text-3xl font-semibold">{{ $fmt($balanceMinor) }}</p>
<a href="{{ $topupUrl }}" class="mt-4 inline-block rounded-lg bg-white/15 px-4 py-2 text-sm font-semibold text-white backdrop-blur transition hover:bg-white/25">Add funds</a>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Spent on QR Plus</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($spentMinor) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Refunded / credited</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($creditedMinor) }}</p>
</div>
</div>
</div>
</x-user-layout>
-45
View File
@@ -1,45 +0,0 @@
<x-user-layout>
<x-slot name="title">Overview</x-slot>
@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp
<div class="space-y-6">
<div>
<h1 class="text-xl font-semibold text-slate-900">QR Plus</h1>
<p class="mt-1 text-sm text-slate-500">Dynamic utility QR codes links, WiFi, business pages & more.</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Wallet balance</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Active codes</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $activeCount }}</p>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<p class="text-xs font-medium uppercase tracking-wide text-slate-400">Scans (30d)</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ number_format($scans30d) }}</p>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<h2 class="font-semibold text-slate-900">Recent codes</h2>
<a href="{{ route('events.create') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">Create code</a>
</div>
@if($recentCodes->isEmpty())
<p class="px-6 py-8 text-sm text-slate-500">No QR codes yet. <a href="{{ route('events.create') }}" class="font-medium text-indigo-600">Create your first code</a>.</p>
@else
<div class="divide-y divide-slate-100">
@foreach($recentCodes as $code)
<a href="{{ route('events.show', $code) }}" class="flex items-center justify-between px-6 py-4 hover:bg-slate-50">
<div>
<p class="font-medium text-slate-900">{{ $code->label }}</p>
<p class="text-xs text-slate-500">{{ $code->typeLabel() }}</p>
</div>
<span class="text-xs text-slate-500">{{ number_format($code->scans_total) }} scans</span>
</a>
@endforeach
</div>
@endif
</div>
</div>
</x-user-layout>
-15
View File
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Signed out Ladill Events</title>
@include('partials.favicon')
</head>
<body class="flex min-h-screen items-center justify-center bg-slate-100 font-sans antialiased">
<div class="text-center">
<p class="text-slate-600">You have been signed out of Ladill Events.</p>
<a href="{{ route('sso.connect') }}" class="mt-4 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-800">Sign in again</a>
</div>
</body>
</html>
-13
View File
@@ -1,13 +0,0 @@
<x-user-layout>
<x-slot name="title">Search</x-slot>
@include('partials.search-screen', [
'query' => $query,
'results' => $results,
'backUrl' => route('mini.dashboard'),
'searchUrl' => route('mini.search'),
'heading' => 'Find payments',
'placeholder' => 'Search payer, note, reference, QR…',
'emptyHint' => 'Use at least 2 characters to search payer names, notes, references, or QR labels.',
])
</x-user-layout>