From 492eec9cda08d3a5f806cc022e5f6f44d09ec092 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 29 Jun 2026 23:25:51 +0000 Subject: [PATCH] Add self-service kiosk for queue ticket issuance. Customers can pick a service, optionally enter details, and receive a ticket on a branded touchscreen flow with admin-configurable queues and auto-reset. Co-authored-by: Cursor --- app/Http/Controllers/Qms/DeviceController.php | 31 +++ .../Controllers/Qms/KioskDeviceController.php | 30 ++- app/Services/Qms/KioskService.php | 72 +++++++ resources/css/app.css | 104 ++++++++++ resources/js/kiosk-flow.js | 74 ++++++- resources/views/qms/devices/create.blade.php | 84 +++++++- resources/views/qms/kiosk/device.blade.php | 195 ++++++++++++++---- storage/framework/views/.gitignore | 2 + tests/Feature/KioskDeviceTest.php | 144 +++++++++++++ 9 files changed, 683 insertions(+), 53 deletions(-) create mode 100644 app/Services/Qms/KioskService.php create mode 100644 storage/framework/views/.gitignore create mode 100644 tests/Feature/KioskDeviceTest.php diff --git a/app/Http/Controllers/Qms/DeviceController.php b/app/Http/Controllers/Qms/DeviceController.php index 04fc024..5a27543 100644 --- a/app/Http/Controllers/Qms/DeviceController.php +++ b/app/Http/Controllers/Qms/DeviceController.php @@ -56,6 +56,7 @@ class DeviceController extends Controller return view('qms.devices.create', [ 'organization' => $organization, 'branches' => $branches, + 'queues' => ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(), 'deviceTypes' => config('qms.device_types'), ]); } @@ -70,8 +71,37 @@ class DeviceController extends Controller 'name' => ['required', 'string', 'max:255'], 'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.device_types')))], 'branch_id' => ['nullable', 'exists:queue_branches,id'], + 'queue_ids' => ['nullable', 'array'], + 'queue_ids.*' => ['integer', 'exists:queue_service_queues,id'], + 'collect_name' => ['nullable', 'boolean'], + 'collect_phone' => ['nullable', 'boolean'], + 'reset_seconds' => ['nullable', 'integer', 'min:5', 'max:120'], + 'welcome_message' => ['nullable', 'string', 'max:255'], ]); + $config = null; + if ($validated['type'] === 'kiosk') { + $queueIds = array_map('intval', $validated['queue_ids'] ?? []); + if ($queueIds !== []) { + $allowed = ServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('id', $queueIds) + ->pluck('id') + ->all(); + $queueIds = array_values(array_intersect($queueIds, $allowed)); + } + + $config = [ + 'service_queue_ids' => $queueIds, + 'collect_name' => $request->boolean('collect_name'), + 'collect_phone' => $request->boolean('collect_phone'), + 'reset_seconds' => $validated['reset_seconds'] ?? 15, + 'welcome_message' => filled($validated['welcome_message'] ?? null) + ? $validated['welcome_message'] + : null, + ]; + } + $device = Device::create([ 'owner_ref' => $owner, 'organization_id' => $organization->id, @@ -79,6 +109,7 @@ class DeviceController extends Controller 'name' => $validated['name'], 'type' => $validated['type'], 'status' => 'offline', + 'config' => $config, ]); $token = $this->devices->generateToken($device); diff --git a/app/Http/Controllers/Qms/KioskDeviceController.php b/app/Http/Controllers/Qms/KioskDeviceController.php index 3b43a71..0449cbd 100644 --- a/app/Http/Controllers/Qms/KioskDeviceController.php +++ b/app/Http/Controllers/Qms/KioskDeviceController.php @@ -5,8 +5,10 @@ namespace App\Http\Controllers\Qms; use App\Http\Controllers\Controller; use App\Models\ServiceQueue; use App\Services\Qms\DeviceService; +use App\Services\Qms\KioskService; use App\Services\Qms\QueueEngine; use App\Services\Qms\TicketPresenter; +use App\Support\OrganizationBranding; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -16,21 +18,31 @@ class KioskDeviceController extends Controller public function __construct( protected QueueEngine $engine, protected DeviceService $devices, + protected KioskService $kiosk, ) {} public function show(Request $request): View { $device = $request->attributes->get('qms.device'); - $queues = ServiceQueue::query() - ->where('organization_id', $device->organization_id) - ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) - ->where('is_active', true) - ->orderBy('name') - ->get(); + $device->loadMissing('organization'); + $queues = $this->kiosk->queuesFor($device); + $settings = $this->kiosk->settings($device); + $organization = $device->organization; $this->devices->recordHeartbeat($device); - return view('qms.kiosk.device', compact('device', 'queues')); + return view('qms.kiosk.device', [ + 'device' => $device, + 'queues' => $queues, + 'settings' => $settings, + 'logoUrl' => $organization + ? OrganizationBranding::logoUrl($organization) + : OrganizationBranding::assetUrl(), + 'logoAlt' => $organization + ? OrganizationBranding::logoAlt($organization) + : 'Ladill Queue', + 'poweredByLogoUrl' => OrganizationBranding::assetUrl(OrganizationBranding::POWERED_BY_LOGO), + ]); } public function issue(Request $request): JsonResponse @@ -45,8 +57,10 @@ class KioskDeviceController extends Controller 'priority' => ['nullable', 'string'], ]); + abort_unless($this->kiosk->queueAllowedForIssue($device, (int) $validated['queue_id']), 403); + $queue = ServiceQueue::findOrFail($validated['queue_id']); - abort_unless($queue->organization_id === $device->organization_id, 403); + abort_if($queue->is_paused || ! $queue->is_active, 422, 'This queue is not accepting tickets right now.'); $ticket = $this->engine->issueTicket($queue, $device->owner_ref, [ 'customer_name' => $validated['customer_name'] ?? null, diff --git a/app/Services/Qms/KioskService.php b/app/Services/Qms/KioskService.php new file mode 100644 index 0000000..00e0404 --- /dev/null +++ b/app/Services/Qms/KioskService.php @@ -0,0 +1,72 @@ +, collect_name: bool, collect_phone: bool, reset_seconds: int, welcome_message: ?string} + */ + public function settings(Device $device): array + { + $config = $device->config ?? []; + + return [ + 'service_queue_ids' => array_values(array_filter(array_map( + 'intval', + $config['service_queue_ids'] ?? [], + ))), + 'collect_name' => (bool) ($config['collect_name'] ?? false), + 'collect_phone' => (bool) ($config['collect_phone'] ?? false), + 'reset_seconds' => max(5, (int) ($config['reset_seconds'] ?? 15)), + 'welcome_message' => filled($config['welcome_message'] ?? null) + ? (string) $config['welcome_message'] + : null, + ]; + } + + /** + * Queue IDs configured on the device (empty = all branch queues). + * + * @return list + */ + public function assignedQueueIds(Device $device): array + { + return $this->settings($device)['service_queue_ids']; + } + + /** + * @return Collection + */ + public function queuesFor(Device $device): Collection + { + $ids = $this->assignedQueueIds($device); + + $query = ServiceQueue::query() + ->where('organization_id', $device->organization_id) + ->where('is_active', true) + ->where('is_paused', false) + ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) + ->when($ids !== [], fn ($q) => $q->whereIn('id', $ids)) + ->orderBy('name'); + + return $query->get(); + } + + public function queueAllowedForIssue(Device $device, int $queueId): bool + { + $ids = $this->assignedQueueIds($device); + + $query = ServiceQueue::query() + ->where('organization_id', $device->organization_id) + ->where('id', $queueId) + ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) + ->when($ids !== [], fn ($q) => $q->whereIn('id', $ids)); + + return $query->exists(); + } +} diff --git a/resources/css/app.css b/resources/css/app.css index c37a41a..98cbbee 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -427,3 +427,107 @@ html:has(.qms-display) body { radial-gradient(ellipse 60% 40% at 50% 0%, rgb(199 210 254 / 0.5), transparent), rgb(255 255 255 / 0.97); } + +/* Self-service kiosk */ +html:has(.qms-kiosk), +html:has(.qms-kiosk) body, +html:has(.qms-kiosk) .qms-kiosk { + font-family: 'Figtree', ui-sans-serif, system-ui, sans-serif; +} + +html:has(.qms-kiosk), +html:has(.qms-kiosk) body { + height: 100%; + overflow: hidden; +} + +.qms-kiosk { + display: flex; + flex-direction: column; + height: 100dvh; + max-height: 100dvh; + overflow: hidden; + background: + radial-gradient(ellipse 90% 60% at 50% -20%, rgb(238 242 255 / 0.9), transparent), + linear-gradient(180deg, rgb(248 250 252) 0%, rgb(255 255 255) 100%); + color: rgb(15 23 42); +} + +.qms-kiosk__shell { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.qms-kiosk__main { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.qms-kiosk__queue-grid { + display: grid; + width: 100%; + gap: 0.75rem; +} + +.qms-kiosk__queue-btn { + display: flex; + width: 100%; + align-items: center; + gap: 1rem; + border-radius: 1rem; + border: 1px solid rgb(226 232 240); + background: #fff; + padding: 1rem 1.25rem; + text-align: left; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.04); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.qms-kiosk__queue-btn:hover { + border-color: rgb(165 180 252); + box-shadow: 0 8px 24px -12px rgb(79 70 229 / 0.25); +} + +.qms-kiosk__queue-btn:disabled { + opacity: 0.6; + pointer-events: none; +} + +.qms-kiosk__queue-mark { + display: flex; + height: 2.75rem; + width: 2.75rem; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 0.75rem; + font-size: 0.875rem; + font-weight: 700; + color: #fff; +} + +.qms-kiosk__ticket-card { + width: 100%; + border-radius: 1.5rem; + border: 1px solid rgb(226 232 240); + background: #fff; + padding: clamp(1.5rem, 5vh, 3rem); + text-align: center; + box-shadow: + 0 1px 2px rgb(15 23 42 / 0.04), + 0 24px 48px -20px rgb(79 70 229 / 0.18); +} + +.qms-kiosk__ticket-number { + font-size: clamp(3.5rem, 16vh, 7rem); + font-weight: 700; + line-height: 1; + letter-spacing: -0.04em; + color: rgb(67 56 202); +} diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index 69529a7..8e6c89e 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -46,18 +46,43 @@ export function registerKioskFlow(Alpine) { ticket: null, error: null, loading: false, + resetCountdown: null, + resetTimer: null, + countdownTimer: null, issueUrl: config.issueUrl, csrf: config.csrf, + queues: config.queues ?? [], + collectName: config.collectName ?? false, + collectPhone: config.collectPhone ?? false, + resetSeconds: config.resetSeconds ?? 15, + welcomeMessage: config.welcomeMessage ?? 'Tap a service below to get your ticket', + + init() { + if (this.queues.length === 1) { + this.queueId = this.queues[0].id; + } + }, selectQueue(id) { this.queueId = id; + this.error = null; + + if (! this.collectName && ! this.collectPhone) { + this.issue(); + return; + } + this.step = 'details'; }, async issue() { - if (! this.queueId) return; + if (! this.queueId || this.loading) { + return; + } + this.loading = true; this.error = null; + try { const res = await fetch(this.issueUrl, { method: 'POST', @@ -73,9 +98,14 @@ export function registerKioskFlow(Alpine) { }), }); const data = await res.json(); - if (! res.ok) throw new Error(data.message || 'Could not issue ticket'); + + if (! res.ok) { + throw new Error(data.message || 'Could not issue ticket'); + } + this.ticket = data.data; this.step = 'ticket'; + this.scheduleReset(); } catch (e) { this.error = e.message || 'Something went wrong'; } finally { @@ -83,13 +113,51 @@ export function registerKioskFlow(Alpine) { } }, + scheduleReset() { + this.clearResetTimers(); + this.resetCountdown = this.resetSeconds; + + this.countdownTimer = setInterval(() => { + this.resetCountdown -= 1; + if (this.resetCountdown <= 0) { + this.clearResetTimers(); + } + }, 1000); + + this.resetTimer = setTimeout(() => this.reset(), this.resetSeconds * 1000); + }, + + clearResetTimers() { + if (this.resetTimer) { + clearTimeout(this.resetTimer); + this.resetTimer = null; + } + if (this.countdownTimer) { + clearInterval(this.countdownTimer); + this.countdownTimer = null; + } + this.resetCountdown = null; + }, + reset() { + this.clearResetTimers(); this.step = 'select'; - this.queueId = null; + this.queueId = this.queues.length === 1 ? this.queues[0].id : null; this.customerName = ''; this.customerPhone = ''; this.ticket = null; this.error = null; + this.loading = false; + }, + + formatWait(seconds) { + if (! seconds) { + return null; + } + + const minutes = Math.ceil(seconds / 60); + + return minutes <= 1 ? 'About 1 minute' : `About ${minutes} minutes`; }, })); diff --git a/resources/views/qms/devices/create.blade.php b/resources/views/qms/devices/create.blade.php index 685172e..bc4d1a5 100644 --- a/resources/views/qms/devices/create.blade.php +++ b/resources/views/qms/devices/create.blade.php @@ -1,19 +1,89 @@
-
+ @csrf -
-
- +
+
+ +
-
+
+ +

Optional. Limits which queues appear on this kiosk.

+ +
+
+

Kiosk queues

+

Leave unchecked to show all active queues for the branch.

+
+ @foreach ($queues as $q) + + @endforeach +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +

Return to the welcome screen after a ticket is issued.

+
+
+
diff --git a/resources/views/qms/kiosk/device.blade.php b/resources/views/qms/kiosk/device.blade.php index 669dc9e..453c084 100644 --- a/resources/views/qms/kiosk/device.blade.php +++ b/resources/views/qms/kiosk/device.blade.php @@ -1,50 +1,175 @@ - + - + - Kiosk · {{ $device->name }} + + {{ $device->name }} · Queue Kiosk + + @vite(['resources/css/app.css', 'resources/js/app.js']) - -
-

{{ $device->name }}

- - + - + - +
diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/KioskDeviceTest.php b/tests/Feature/KioskDeviceTest.php new file mode 100644 index 0000000..c6267cf --- /dev/null +++ b/tests/Feature/KioskDeviceTest.php @@ -0,0 +1,144 @@ + 'test-owner-uuid', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + $resolver = app(OrganizationResolver::class); + $org = $resolver->completeOnboarding($user, [ + 'organization_name' => 'Test Org', + 'industry' => 'retail', + 'appointment_mode' => 'hybrid', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + $branch = Branch::first(); + $queue = ServiceQueue::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Reception', + 'prefix' => 'A', + 'strategy' => 'fifo', + 'is_active' => true, + ]); + + $device = Device::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Lobby Kiosk', + 'type' => 'kiosk', + 'status' => 'offline', + 'config' => [ + 'service_queue_ids' => [$queue->id], + 'collect_name' => false, + 'collect_phone' => false, + 'reset_seconds' => 15, + ], + ]); + + $token = app(DeviceService::class)->generateToken($device); + + return [$user, $org, $branch, $queue, $device, $token]; + } + + public function test_kiosk_page_loads_with_assigned_queues(): void + { + [, , , $queue, , $token] = $this->setUpKiosk(); + + $response = $this->get(route('qms.kiosk.device', $token)); + + $response->assertOk(); + $response->assertSee('Welcome', false); + $response->assertSee($queue->name, false); + $response->assertSee('Powered by', false); + } + + public function test_kiosk_issues_ticket_for_allowed_queue(): void + { + [, , , $queue, , $token] = $this->setUpKiosk(); + + $response = $this->postJson(route('qms.kiosk.device.issue', $token), [ + 'queue_id' => $queue->id, + ]); + + $response->assertOk(); + $response->assertJsonPath('data.queue.name', 'Reception'); + $response->assertJsonPath('data.source', 'kiosk'); + $this->assertNotEmpty($response->json('data.ticket_number')); + } + + public function test_kiosk_rejects_unassigned_queue(): void + { + [$user, $org, $branch, , , $token] = $this->setUpKiosk(); + + $otherQueue = ServiceQueue::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Billing', + 'prefix' => 'B', + 'strategy' => 'fifo', + 'is_active' => true, + ]); + + $response = $this->postJson(route('qms.kiosk.device.issue', $token), [ + 'queue_id' => $otherQueue->id, + ]); + + $response->assertForbidden(); + } + + public function test_kiosk_rejects_paused_queue(): void + { + [, , , $queue, , $token] = $this->setUpKiosk(); + $queue->update(['is_paused' => true]); + + $response = $this->postJson(route('qms.kiosk.device.issue', $token), [ + 'queue_id' => $queue->id, + ]); + + $response->assertStatus(422); + } + + public function test_non_kiosk_device_cannot_access_kiosk_routes(): void + { + [$user, $org, $branch] = array_slice($this->setUpKiosk(), 0, 3); + + $printer = Device::create([ + 'owner_ref' => $user->public_id, + 'organization_id' => $org->id, + 'branch_id' => $branch->id, + 'name' => 'Printer', + 'type' => 'printer', + 'status' => 'offline', + ]); + $token = app(DeviceService::class)->generateToken($printer); + + $this->get(route('qms.kiosk.device', $token))->assertForbidden(); + } +}