Add self-service kiosk for queue ticket issuance.
Deploy Ladill Queue / deploy (push) Successful in 36s
Deploy Ladill Queue / deploy (push) Successful in 36s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -56,6 +56,7 @@ class DeviceController extends Controller
|
|||||||
return view('qms.devices.create', [
|
return view('qms.devices.create', [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
'branches' => $branches,
|
'branches' => $branches,
|
||||||
|
'queues' => ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(),
|
||||||
'deviceTypes' => config('qms.device_types'),
|
'deviceTypes' => config('qms.device_types'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -70,8 +71,37 @@ class DeviceController extends Controller
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.device_types')))],
|
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.device_types')))],
|
||||||
'branch_id' => ['nullable', 'exists:queue_branches,id'],
|
'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([
|
$device = Device::create([
|
||||||
'owner_ref' => $owner,
|
'owner_ref' => $owner,
|
||||||
'organization_id' => $organization->id,
|
'organization_id' => $organization->id,
|
||||||
@@ -79,6 +109,7 @@ class DeviceController extends Controller
|
|||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'type' => $validated['type'],
|
'type' => $validated['type'],
|
||||||
'status' => 'offline',
|
'status' => 'offline',
|
||||||
|
'config' => $config,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$token = $this->devices->generateToken($device);
|
$token = $this->devices->generateToken($device);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ namespace App\Http\Controllers\Qms;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ServiceQueue;
|
use App\Models\ServiceQueue;
|
||||||
use App\Services\Qms\DeviceService;
|
use App\Services\Qms\DeviceService;
|
||||||
|
use App\Services\Qms\KioskService;
|
||||||
use App\Services\Qms\QueueEngine;
|
use App\Services\Qms\QueueEngine;
|
||||||
use App\Services\Qms\TicketPresenter;
|
use App\Services\Qms\TicketPresenter;
|
||||||
|
use App\Support\OrganizationBranding;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
@@ -16,21 +18,31 @@ class KioskDeviceController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected QueueEngine $engine,
|
protected QueueEngine $engine,
|
||||||
protected DeviceService $devices,
|
protected DeviceService $devices,
|
||||||
|
protected KioskService $kiosk,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(Request $request): View
|
public function show(Request $request): View
|
||||||
{
|
{
|
||||||
$device = $request->attributes->get('qms.device');
|
$device = $request->attributes->get('qms.device');
|
||||||
$queues = ServiceQueue::query()
|
$device->loadMissing('organization');
|
||||||
->where('organization_id', $device->organization_id)
|
$queues = $this->kiosk->queuesFor($device);
|
||||||
->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id))
|
$settings = $this->kiosk->settings($device);
|
||||||
->where('is_active', true)
|
$organization = $device->organization;
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$this->devices->recordHeartbeat($device);
|
$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
|
public function issue(Request $request): JsonResponse
|
||||||
@@ -45,8 +57,10 @@ class KioskDeviceController extends Controller
|
|||||||
'priority' => ['nullable', 'string'],
|
'priority' => ['nullable', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
abort_unless($this->kiosk->queueAllowedForIssue($device, (int) $validated['queue_id']), 403);
|
||||||
|
|
||||||
$queue = ServiceQueue::findOrFail($validated['queue_id']);
|
$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, [
|
$ticket = $this->engine->issueTicket($queue, $device->owner_ref, [
|
||||||
'customer_name' => $validated['customer_name'] ?? null,
|
'customer_name' => $validated['customer_name'] ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Qms;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class KioskService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{service_queue_ids: list<int>, 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<int>
|
||||||
|
*/
|
||||||
|
public function assignedQueueIds(Device $device): array
|
||||||
|
{
|
||||||
|
return $this->settings($device)['service_queue_ids'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, ServiceQueue>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -427,3 +427,107 @@ html:has(.qms-display) body {
|
|||||||
radial-gradient(ellipse 60% 40% at 50% 0%, rgb(199 210 254 / 0.5), transparent),
|
radial-gradient(ellipse 60% 40% at 50% 0%, rgb(199 210 254 / 0.5), transparent),
|
||||||
rgb(255 255 255 / 0.97);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,18 +46,43 @@ export function registerKioskFlow(Alpine) {
|
|||||||
ticket: null,
|
ticket: null,
|
||||||
error: null,
|
error: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
resetCountdown: null,
|
||||||
|
resetTimer: null,
|
||||||
|
countdownTimer: null,
|
||||||
issueUrl: config.issueUrl,
|
issueUrl: config.issueUrl,
|
||||||
csrf: config.csrf,
|
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) {
|
selectQueue(id) {
|
||||||
this.queueId = id;
|
this.queueId = id;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
if (! this.collectName && ! this.collectPhone) {
|
||||||
|
this.issue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.step = 'details';
|
this.step = 'details';
|
||||||
},
|
},
|
||||||
|
|
||||||
async issue() {
|
async issue() {
|
||||||
if (! this.queueId) return;
|
if (! this.queueId || this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(this.issueUrl, {
|
const res = await fetch(this.issueUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -73,9 +98,14 @@ export function registerKioskFlow(Alpine) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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.ticket = data.data;
|
||||||
this.step = 'ticket';
|
this.step = 'ticket';
|
||||||
|
this.scheduleReset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = e.message || 'Something went wrong';
|
this.error = e.message || 'Something went wrong';
|
||||||
} finally {
|
} 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() {
|
reset() {
|
||||||
|
this.clearResetTimers();
|
||||||
this.step = 'select';
|
this.step = 'select';
|
||||||
this.queueId = null;
|
this.queueId = this.queues.length === 1 ? this.queues[0].id : null;
|
||||||
this.customerName = '';
|
this.customerName = '';
|
||||||
this.customerPhone = '';
|
this.customerPhone = '';
|
||||||
this.ticket = null;
|
this.ticket = null;
|
||||||
this.error = 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`;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,89 @@
|
|||||||
<x-app-layout title="Register device">
|
<x-app-layout title="Register device">
|
||||||
<div class="mx-auto max-w-lg">
|
<div class="mx-auto max-w-lg">
|
||||||
<form method="POST" action="{{ route('qms.devices.store') }}" class="space-y-4 rounded-2xl border bg-white p-6">
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('qms.devices.store') }}"
|
||||||
|
class="space-y-4 rounded-2xl border bg-white p-6"
|
||||||
|
x-data="{ type: '{{ old('type', 'kiosk') }}' }"
|
||||||
|
>
|
||||||
@csrf
|
@csrf
|
||||||
<div><label class="block text-sm font-medium">Name</label><input name="name" required class="mt-1 w-full rounded-lg border-slate-300 text-sm"></div>
|
<div>
|
||||||
<div><label class="block text-sm font-medium">Type</label>
|
<label class="block text-sm font-medium">Name</label>
|
||||||
<select name="type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<input name="name" value="{{ old('name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@foreach ($deviceTypes as $k => $v)<option value="{{ $k }}">{{ $v }}</option>@endforeach
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Type</label>
|
||||||
|
<select name="type" x-model="type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@foreach ($deviceTypes as $k => $v)
|
||||||
|
<option value="{{ $k }}" @selected(old('type', 'kiosk') === $k)>{{ $v }}</option>
|
||||||
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div><label class="block text-sm font-medium">Branch</label>
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Branch</label>
|
||||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@foreach ($branches as $b)<option value="{{ $b->id }}">{{ $b->name }}</option>@endforeach
|
@foreach ($branches as $b)
|
||||||
|
<option value="{{ $b->id }}" @selected((string) old('branch_id') === (string) $b->id)>{{ $b->name }}</option>
|
||||||
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Optional. Limits which queues appear on this kiosk.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div x-show="type === 'kiosk'" x-cloak class="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-slate-900">Kiosk queues</p>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Leave unchecked to show all active queues for the branch.</p>
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
@foreach ($queues as $q)
|
||||||
|
<label class="flex gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="queue_ids[]"
|
||||||
|
value="{{ $q->id }}"
|
||||||
|
@checked(in_array($q->id, old('queue_ids', [])))
|
||||||
|
>
|
||||||
|
{{ $q->name }}
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Welcome message</label>
|
||||||
|
<input
|
||||||
|
name="welcome_message"
|
||||||
|
value="{{ old('welcome_message') }}"
|
||||||
|
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
|
||||||
|
placeholder="Tap a service below to get your ticket"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="collect_name" value="1" @checked(old('collect_name'))>
|
||||||
|
Ask for name
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="collect_phone" value="1" @checked(old('collect_phone'))>
|
||||||
|
Ask for phone
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium">Auto-reset (seconds)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="reset_seconds"
|
||||||
|
min="5"
|
||||||
|
max="120"
|
||||||
|
value="{{ old('reset_seconds', 15) }}"
|
||||||
|
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Return to the welcome screen after a ticket is issued.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary w-full">Register</button>
|
<button type="submit" class="btn-primary w-full">Register</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,50 +1,175 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="h-full bg-slate-900">
|
<html lang="en" class="h-full overflow-hidden bg-slate-50 font-sans">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<title>Kiosk · {{ $device->name }}</title>
|
<meta name="theme-color" content="#f8fafc">
|
||||||
|
<title>{{ $device->name }} · Queue Kiosk</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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
</head>
|
</head>
|
||||||
<body class="flex min-h-screen items-center justify-center p-6 text-white" x-data="kioskFlow({ issueUrl: '{{ route('qms.kiosk.device.issue', $device->device_token) }}', csrf: '{{ csrf_token() }}' })">
|
<body
|
||||||
<div class="w-full max-w-lg">
|
class="qms-kiosk font-sans text-slate-900"
|
||||||
<h1 class="mb-8 text-center text-3xl font-bold">{{ $device->name }}</h1>
|
x-data="kioskFlow(@js([
|
||||||
|
'issueUrl' => route('qms.kiosk.device.issue', $device->device_token),
|
||||||
<template x-if="step === 'select'">
|
'csrf' => csrf_token(),
|
||||||
<div class="space-y-3">
|
'queues' => $queues->map(fn ($q) => [
|
||||||
<p class="mb-4 text-center text-slate-300">Select a service</p>
|
'id' => $q->id,
|
||||||
@foreach ($queues as $queue)
|
'name' => $q->name,
|
||||||
<button type="button" @click="selectQueue({{ $queue->id }})"
|
'prefix' => $q->prefix,
|
||||||
class="w-full rounded-2xl border border-slate-600 bg-slate-800 px-6 py-5 text-left text-xl font-semibold hover:border-indigo-400"
|
'color' => $q->color,
|
||||||
style="border-left: 6px solid {{ $queue->color }}">
|
])->values()->all(),
|
||||||
{{ $queue->name }}
|
'collectName' => $settings['collect_name'],
|
||||||
</button>
|
'collectPhone' => $settings['collect_phone'],
|
||||||
@endforeach
|
'resetSeconds' => $settings['reset_seconds'],
|
||||||
|
'welcomeMessage' => $settings['welcome_message'] ?? 'Tap a service below to get your ticket',
|
||||||
|
]))"
|
||||||
|
x-init="init()"
|
||||||
|
>
|
||||||
|
<div class="qms-kiosk__shell">
|
||||||
|
<header class="shrink-0 border-b border-slate-200/80 bg-white/90 px-5 py-4 backdrop-blur-md lg:px-8">
|
||||||
|
<div class="mx-auto flex max-w-3xl items-center justify-between gap-4">
|
||||||
|
<img
|
||||||
|
src="{{ $logoUrl }}"
|
||||||
|
alt="{{ $logoAlt }}"
|
||||||
|
class="h-8 w-auto max-w-[min(100%,220px)] object-contain object-left lg:h-10"
|
||||||
|
>
|
||||||
|
<p class="text-right text-sm font-medium text-slate-500">{{ $device->name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</header>
|
||||||
|
|
||||||
<template x-if="step === 'details'">
|
<main class="qms-kiosk__main px-5 py-6 lg:px-8">
|
||||||
<div class="rounded-2xl bg-slate-800 p-6 space-y-4">
|
<div class="w-full max-w-2xl">
|
||||||
<input type="text" x-model="customerName" placeholder="Your name (optional)" class="w-full rounded-lg border-slate-600 bg-slate-900 px-4 py-3 text-white">
|
{{-- Queue selection --}}
|
||||||
<input type="tel" x-model="customerPhone" placeholder="Phone (optional)" class="w-full rounded-lg border-slate-600 bg-slate-900 px-4 py-3 text-white">
|
<div x-show="step === 'select'" x-cloak>
|
||||||
<p x-show="error" class="text-red-400 text-sm" x-text="error"></p>
|
<div class="text-center">
|
||||||
<div class="flex gap-3">
|
<h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">Welcome</h1>
|
||||||
<button type="button" @click="reset()" class="flex-1 rounded-lg border border-slate-600 py-3">Back</button>
|
<p class="mt-2 text-base text-slate-500" x-text="welcomeMessage"></p>
|
||||||
<button type="button" @click="issue()" :disabled="loading" class="flex-1 rounded-lg bg-indigo-600 py-3 font-semibold">Get ticket</button>
|
</div>
|
||||||
|
|
||||||
|
<template x-if="queues.length === 1">
|
||||||
|
<div class="mt-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="qms-kiosk__ticket-card w-full transition hover:ring-2 hover:ring-indigo-200"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="selectQueue(queues[0].id)"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Get ticket for</p>
|
||||||
|
<p class="mt-3 text-2xl font-semibold text-slate-900" x-text="queues[0].name"></p>
|
||||||
|
<p class="mt-6 text-sm font-semibold text-indigo-600" x-show="!loading">Tap here to continue</p>
|
||||||
|
<p class="mt-6 text-sm font-semibold text-indigo-600" x-show="loading">Issuing your ticket…</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="queues.length > 1">
|
||||||
|
<div class="qms-kiosk__queue-grid mt-8">
|
||||||
|
<template x-for="queue in queues" :key="queue.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="qms-kiosk__queue-btn"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="selectQueue(queue.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="qms-kiosk__queue-mark"
|
||||||
|
:style="'background-color:' + (queue.color || '#4f46e5')"
|
||||||
|
x-text="queue.prefix"
|
||||||
|
></span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-lg font-semibold text-slate-900" x-text="queue.name"></span>
|
||||||
|
<span class="mt-0.5 block text-sm text-slate-500">Tap to get ticket</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="queues.length === 0">
|
||||||
|
<div class="qms-kiosk__ticket-card mt-8">
|
||||||
|
<p class="text-lg font-semibold text-slate-900">No services available</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Ask a staff member for assistance.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p x-show="error" class="mt-4 text-center text-sm text-rose-600" x-text="error"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Optional details --}}
|
||||||
|
<div x-show="step === 'details'" x-cloak>
|
||||||
|
<div class="qms-kiosk__ticket-card">
|
||||||
|
<h2 class="text-xl font-semibold text-slate-900">Your details</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Optional information to help staff assist you.</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4 text-left">
|
||||||
|
<div x-show="collectName">
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="customerName"
|
||||||
|
class="mt-1 w-full rounded-xl border-slate-300 text-base shadow-sm"
|
||||||
|
placeholder="Your name"
|
||||||
|
autocomplete="name"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div x-show="collectPhone">
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
x-model="customerPhone"
|
||||||
|
class="mt-1 w-full rounded-xl border-slate-300 text-base shadow-sm"
|
||||||
|
placeholder="Phone number"
|
||||||
|
autocomplete="tel"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p x-show="error" class="mt-4 text-sm text-rose-600" x-text="error"></p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex gap-3">
|
||||||
|
<button type="button" class="btn-secondary flex-1" @click="reset()" :disabled="loading">Back</button>
|
||||||
|
<button type="button" class="btn-primary flex-1" @click="issue()" :disabled="loading">
|
||||||
|
<span x-show="!loading">Get ticket</span>
|
||||||
|
<span x-show="loading">Please wait…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Ticket issued --}}
|
||||||
|
<div x-show="step === 'ticket' && ticket" x-cloak>
|
||||||
|
<div class="qms-kiosk__ticket-card">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Your ticket number</p>
|
||||||
|
<p class="qms-kiosk__ticket-number mt-4" x-text="ticket?.ticket_number"></p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-2 text-sm text-slate-600">
|
||||||
|
<p x-show="ticket?.position">
|
||||||
|
<span class="font-medium text-slate-900" x-text="ticket?.position"></span>
|
||||||
|
<span> ahead of you</span>
|
||||||
|
</p>
|
||||||
|
<p x-show="formatWait(ticket?.estimated_wait_seconds)" x-text="formatWait(ticket?.estimated_wait_seconds)"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-8 text-base text-slate-600">Please take a seat. Your number will be called on the display.</p>
|
||||||
|
|
||||||
|
<button type="button" class="btn-primary mt-8 w-full" @click="reset()">
|
||||||
|
Done
|
||||||
|
<span x-show="resetCountdown" x-text="'(' + resetCountdown + 's)'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</main>
|
||||||
|
|
||||||
<template x-if="step === 'ticket' && ticket">
|
<footer class="shrink-0 border-t border-slate-200 bg-white/80 px-5 py-3 lg:px-8">
|
||||||
<div class="rounded-2xl bg-indigo-600 p-8 text-center">
|
<div class="mx-auto flex max-w-3xl items-center justify-center gap-2">
|
||||||
<p class="text-sm uppercase tracking-widest opacity-80">Your number</p>
|
<span class="text-[10px] font-medium uppercase tracking-wider text-slate-400">Powered by</span>
|
||||||
<p class="mt-2 text-6xl font-bold" x-text="ticket.ticket_number"></p>
|
<img src="{{ $poweredByLogoUrl }}" alt="Ladill Queue" class="h-3.5 w-auto opacity-80">
|
||||||
<p class="mt-4 text-xl" x-text="ticket.queue?.name"></p>
|
|
||||||
<p class="mt-6 text-lg" x-text="ticket.estimated_wait_seconds ? 'Est. wait: ' + Math.ceil(ticket.estimated_wait_seconds / 60) + ' mins' : ''"></p>
|
|
||||||
<button type="button" @click="reset()" class="mt-8 rounded-lg bg-white/20 px-6 py-3">Done</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\ServiceQueue;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Qms\DeviceService;
|
||||||
|
use App\Services\Qms\OrganizationResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class KioskDeviceTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: User, 1: Organization, 2: Branch, 3: ServiceQueue, 4: Device, 5: string}
|
||||||
|
*/
|
||||||
|
protected function setUpKiosk(): array
|
||||||
|
{
|
||||||
|
$user = User::create([
|
||||||
|
'public_id' => '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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user