Fix public display not showing queue stats and ticket data.
Deploy Ladill Queue / deploy (push) Successful in 43s

Server-render initial payload, poll with same-origin relative URLs, fall back to branch queues when none assigned, and improve contrast so stats remain visible if custom CSS is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 22:51:29 +00:00
co-authored by Cursor
parent 2243ccf95a
commit 326769204d
5 changed files with 125 additions and 44 deletions
@@ -22,10 +22,13 @@ class DisplayPublicController extends Controller
$screen->loadMissing(['organization', 'branch']); $screen->loadMissing(['organization', 'branch']);
$this->displays->touch($screen); $this->displays->touch($screen);
$payload = $this->displays->payload($screen);
return view('qms.display.public', [ return view('qms.display.public', [
'screen' => $screen, 'screen' => $screen,
'dataUrl' => route('qms.display.data', $token), 'initialPayload' => $payload,
'playedUrl' => route('qms.display.announcement.played', ['token' => $token, 'announcement' => '__ID__']), 'dataUrl' => '/display/'.$token.'/data',
'playedUrl' => '/display/'.$token.'/announcements/__ID__/played',
'pollMs' => config('qms.display_poll_ms', 1200), 'pollMs' => config('qms.display_poll_ms', 1200),
]); ]);
} }
+32 -5
View File
@@ -27,10 +27,11 @@ class DisplayService
public function payload(DisplayScreen $screen): array public function payload(DisplayScreen $screen): array
{ {
$screen->loadMissing(['branch', 'organization']); $screen->loadMissing(['branch', 'organization']);
$queueIds = $screen->service_queue_ids ?? []; $queueIds = $this->resolveQueueIds($screen);
$queues = ServiceQueue::query() $queues = ServiceQueue::query()
->whereIn('id', $queueIds) ->whereIn('id', $queueIds)
->where('is_active', true) ->where('is_active', true)
->orderBy('name')
->get(); ->get();
$nowServing = Ticket::query() $nowServing = Ticket::query()
@@ -44,7 +45,9 @@ class DisplayService
'ticket_number' => $t->ticket_number, 'ticket_number' => $t->ticket_number,
'queue_name' => $t->serviceQueue?->name, 'queue_name' => $t->serviceQueue?->name,
'counter' => $t->counter?->name, 'counter' => $t->counter?->name,
]); ])
->values()
->all();
$waiting = Ticket::query() $waiting = Ticket::query()
->whereIn('service_queue_id', $queueIds) ->whereIn('service_queue_id', $queueIds)
@@ -72,13 +75,37 @@ class DisplayService
'queues' => $queues->map(fn (ServiceQueue $q) => [ 'queues' => $queues->map(fn (ServiceQueue $q) => [
'name' => $q->name, 'name' => $q->name,
'prefix' => $q->prefix, 'prefix' => $q->prefix,
'is_paused' => $q->is_paused, 'is_paused' => (bool) $q->is_paused,
]), ])->values()->all(),
'announcements' => app(VoiceAnnouncementService::class)->pendingForBranch( 'announcements' => app(VoiceAnnouncementService::class)->pendingForBranch(
(int) $screen->branch_id, (int) $screen->branch_id,
array_map('intval', $screen->service_queue_ids ?? []), $queueIds,
), ),
'updated_at' => now()->toIso8601String(), 'updated_at' => now()->toIso8601String(),
]; ];
} }
/**
* @return list<int>
*/
protected function resolveQueueIds(DisplayScreen $screen): array
{
$ids = array_values(array_unique(array_filter(array_map(
fn ($id) => (int) $id,
$screen->service_queue_ids ?? [],
))));
if ($ids !== []) {
return $ids;
}
return ServiceQueue::query()
->where('organization_id', $screen->organization_id)
->when($screen->branch_id, fn ($query) => $query->where('branch_id', $screen->branch_id))
->where('is_active', true)
->orderBy('name')
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
}
} }
+15 -1
View File
@@ -94,24 +94,38 @@ export function registerKioskFlow(Alpine) {
})); }));
Alpine.data('displayBoard', (config = {}) => ({ Alpine.data('displayBoard', (config = {}) => ({
payload: null, payload: config.initial ?? null,
announcedIds: {}, announcedIds: {},
isAnnouncing: false, isAnnouncing: false,
speechReady: false, speechReady: false,
voices: [], voices: [],
clock: '', clock: '',
dateLabel: '', dateLabel: '',
pollError: null,
dataUrl: typeof config === 'string' ? config : config.dataUrl, dataUrl: typeof config === 'string' ? config : config.dataUrl,
playedUrlTemplate: config.playedUrl ?? null, playedUrlTemplate: config.playedUrl ?? null,
pollMs: config.pollMs ?? 1200, pollMs: config.pollMs ?? 1200,
csrf: config.csrf ?? document.querySelector('meta[name="csrf-token"]')?.content ?? '', csrf: config.csrf ?? document.querySelector('meta[name="csrf-token"]')?.content ?? '',
formatCount(value) {
return value === null || value === undefined ? '—' : String(value);
},
formatWait(minutes) {
return minutes === null || minutes === undefined ? '—' : String(minutes);
},
async poll() { async poll() {
try { try {
const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } }); const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } });
if (! res.ok) {
throw new Error(`Display poll failed (${res.status})`);
}
this.payload = await res.json(); this.payload = await res.json();
this.pollError = null;
this.maybeAnnounce(this.payload?.announcements ?? []); this.maybeAnnounce(this.payload?.announcements ?? []);
} catch (e) { } catch (e) {
this.pollError = e.message || 'Could not refresh display data';
console.error('Display poll failed', e); console.error('Display poll failed', e);
} }
}, },
+48 -35
View File
@@ -1,15 +1,25 @@
@php
$p = $initialPayload;
@endphp
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="h-full"> <html lang="en" class="h-full bg-slate-950">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="theme-color" content="#020617">
<title>{{ $screen->name }} · Queue Display</title> <title>{{ $screen->name }} · Queue Display</title>
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
</head> </head>
<body <body
class="qms-display qms-display__grid-bg" class="qms-display qms-display__grid-bg min-h-full bg-slate-950 text-slate-100"
x-data="displayBoard({ dataUrl: '{{ $dataUrl }}', playedUrl: '{{ $playedUrl }}', pollMs: {{ $pollMs }}, csrf: '{{ csrf_token() }}' })" x-data="displayBoard(@js([
'dataUrl' => $dataUrl,
'playedUrl' => $playedUrl,
'pollMs' => $pollMs,
'csrf' => csrf_token(),
'initial' => $initialPayload,
]))"
x-init="init()" x-init="init()"
> >
{{-- Voice unlock required by browsers before TTS --}} {{-- Voice unlock required by browsers before TTS --}}
@@ -19,7 +29,7 @@
@click="unlockSpeech()" @click="unlockSpeech()"
@keydown.enter.prevent="unlockSpeech()" @keydown.enter.prevent="unlockSpeech()"
tabindex="0" tabindex="0"
class="qms-display__unlock fixed inset-0 z-50 flex cursor-pointer items-center justify-center p-8 text-center outline-none" class="qms-display__unlock fixed inset-0 z-50 flex cursor-pointer items-center justify-center bg-slate-950/95 p-8 text-center outline-none"
role="button" role="button"
aria-label="Enable voice announcements" aria-label="Enable voice announcements"
> >
@@ -34,31 +44,34 @@
</div> </div>
</div> </div>
<div
x-show="pollError"
x-cloak
class="fixed left-1/2 top-4 z-40 max-w-lg -translate-x-1/2 rounded-xl border border-rose-400/30 bg-rose-950/90 px-4 py-3 text-sm text-rose-100 shadow-lg"
x-text="pollError"
></div>
<div class="flex min-h-screen flex-col"> <div class="flex min-h-screen flex-col">
{{-- Header --}} {{-- Header --}}
<header class="border-b border-white/10 bg-slate-950/60 backdrop-blur-md"> <header class="border-b border-white/10 bg-slate-950/80 backdrop-blur-md">
<div class="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-5 lg:px-10"> <div class="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-5 lg:px-10">
<div class="min-w-0"> <div class="min-w-0">
<p <p class="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-300">
class="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-300" {{ $p['screen']['organization_name'] ?? $screen->organization?->name ?? 'Queue display' }}
x-text="payload?.screen?.organization_name ?? '{{ $screen->organization?->name ?? 'Queue display' }}'" </p>
></p> <h1 class="mt-1 truncate text-2xl font-semibold tracking-tight text-white lg:text-3xl">
<h1 {{ $p['screen']['name'] ?? $screen->name }}
class="mt-1 truncate text-2xl font-semibold tracking-tight text-white lg:text-3xl" </h1>
x-text="payload?.screen?.name ?? '{{ $screen->name }}'" @if (! empty($p['screen']['branch_name']))
></h1> <p class="mt-1 text-sm text-slate-400">{{ $p['screen']['branch_name'] }}</p>
<p @endif
class="mt-1 text-sm text-slate-400"
x-show="payload?.screen?.branch_name"
x-text="payload?.screen?.branch_name"
></p>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-emerald-300"> <div class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-emerald-300">
<span class="qms-display__live-dot h-2 w-2 rounded-full bg-emerald-400"></span> <span class="qms-display__live-dot h-2 w-2 rounded-full bg-emerald-400"></span>
Live Live
</div> </div>
<p class="mt-2 font-mono text-4xl font-semibold tabular-nums tracking-tight text-white" x-text="clock"></p> <p class="mt-2 font-mono text-4xl font-semibold tabular-nums tracking-tight text-white" x-text="clock">--:--</p>
<p class="text-sm text-slate-400" x-text="dateLabel"></p> <p class="text-sm text-slate-400" x-text="dateLabel"></p>
</div> </div>
</div> </div>
@@ -93,39 +106,39 @@
class="qms-display__stat flex flex-1 flex-col items-center justify-center rounded-3xl px-8 py-16 text-center" class="qms-display__stat flex flex-1 flex-col items-center justify-center rounded-3xl px-8 py-16 text-center"
x-show="!(payload?.now_serving ?? []).length" x-show="!(payload?.now_serving ?? []).length"
> >
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10"> <div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10">
<svg class="h-7 w-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> <svg class="h-7 w-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
</div>
<p class="mt-5 text-2xl font-semibold text-white">Please wait</p>
<p class="mt-2 max-w-sm text-slate-400">Your ticket number will appear here when it is called.</p>
</div> </div>
<p class="mt-5 text-2xl font-semibold text-white">Please wait</p>
<p class="mt-2 max-w-sm text-slate-400">Your ticket number will appear here when it is called.</p>
</div>
</section> </section>
{{-- Stats sidebar --}} {{-- Stats sidebar --}}
<aside class="grid w-full gap-4 lg:w-80 lg:shrink-0"> <aside class="grid w-full gap-4 lg:w-80 lg:shrink-0">
<div class="qms-display__stat rounded-2xl p-6 text-center"> <div class="qms-display__stat rounded-2xl bg-slate-900/80 p-6 text-center ring-1 ring-white/10">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Waiting</p> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Waiting</p>
<p class="mt-2 text-5xl font-bold tabular-nums text-white" x-text="payload?.waiting_count ?? '—'"></p> <p class="mt-2 text-5xl font-bold tabular-nums text-slate-50" x-text="formatCount(payload?.waiting_count)">{{ $p['waiting_count'] ?? 0 }}</p>
<p class="mt-1 text-sm text-slate-500">customers in queue</p> <p class="mt-1 text-sm text-slate-500">customers in queue</p>
</div> </div>
<div class="qms-display__stat rounded-2xl p-6 text-center"> <div class="qms-display__stat rounded-2xl bg-slate-900/80 p-6 text-center ring-1 ring-white/10">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Estimated wait</p> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Estimated wait</p>
<p class="mt-2 text-5xl font-bold tabular-nums text-white"> <p class="mt-2 text-5xl font-bold tabular-nums text-slate-50">
<span x-text="payload?.estimated_wait_minutes ? payload.estimated_wait_minutes : '—'"></span> <span x-text="formatWait(payload?.estimated_wait_minutes)">{{ $p['estimated_wait_minutes'] ?? '—' }}</span>
<span class="text-2xl font-medium text-slate-400" x-show="payload?.estimated_wait_minutes">min</span> <span class="text-2xl font-medium text-slate-400" x-show="payload?.estimated_wait_minutes">min</span>
</p> </p>
<p class="mt-1 text-sm text-slate-500">based on todays average</p> <p class="mt-1 text-sm text-slate-500">based on todays average</p>
</div> </div>
<div class="qms-display__stat rounded-2xl p-6"> <div class="qms-display__stat rounded-2xl bg-slate-900/80 p-6 ring-1 ring-white/10">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Service queues</p> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Service queues</p>
<ul class="mt-4 space-y-2"> <ul class="mt-4 space-y-2">
<template x-for="q in (payload?.queues ?? [])" :key="q.name"> <template x-for="q in (payload?.queues ?? [])" :key="q.name + (q.prefix ?? '')">
<li class="flex items-center justify-between gap-3 rounded-xl bg-white/5 px-3 py-2.5 ring-1 ring-white/5"> <li class="flex items-center justify-between gap-3 rounded-xl bg-white/5 px-3 py-2.5 ring-1 ring-white/5">
<span class="truncate font-medium text-white" x-text="q.name"></span> <span class="truncate font-medium text-slate-100" x-text="q.name"></span>
<span <span
class="shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold" class="shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold"
:class="q.is_paused ? 'bg-amber-500/15 text-amber-300' : 'bg-emerald-500/15 text-emerald-300'" :class="q.is_paused ? 'bg-amber-500/15 text-amber-300' : 'bg-emerald-500/15 text-emerald-300'"
@@ -140,7 +153,7 @@
</main> </main>
{{-- Footer --}} {{-- Footer --}}
<footer class="border-t border-white/10 bg-slate-950/60 px-6 py-4 lg:px-10"> <footer class="border-t border-white/10 bg-slate-950/80 px-6 py-4 lg:px-10">
<div class="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 text-xs text-slate-500"> <div class="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 text-xs text-slate-500">
<p>Powered by Ladill Queue</p> <p>Powered by Ladill Queue</p>
<p x-show="payload?.updated_at" x-text="payload?.updated_at ? 'Updated ' + new Date(payload.updated_at).toLocaleTimeString() : ''"></p> <p x-show="payload?.updated_at" x-text="payload?.updated_at ? 'Updated ' + new Date(payload.updated_at).toLocaleTimeString() : ''"></p>
+25 -1
View File
@@ -81,7 +81,31 @@ class DisplayVoiceTest extends TestCase
$response = $this->getJson(route('qms.display.data', $screen->access_token)); $response = $this->getJson(route('qms.display.data', $screen->access_token));
$response->assertOk() $response->assertOk()
->assertJsonPath('announcements.0.message', 'Ticket A001, please proceed to Counter 1.'); ->assertJsonPath('announcements.0.message', 'Ticket A001, please proceed to Counter 1.')
->assertJsonPath('waiting_count', 0)
->assertJsonPath('now_serving.0.ticket_number', 'A001');
}
public function test_display_falls_back_to_branch_queues_when_none_assigned(): void
{
[$user, , $branch, $queue] = $this->setUpOrg();
$screen = DisplayScreen::create([
'owner_ref' => $user->public_id,
'organization_id' => $queue->organization_id,
'branch_id' => $branch->id,
'name' => 'Lobby',
'layout' => 'standard',
'service_queue_ids' => [],
'is_active' => true,
]);
app(QueueEngine::class)->issueTicket($queue, $user->public_id);
$this->getJson(route('qms.display.data', $screen->access_token))
->assertOk()
->assertJsonPath('waiting_count', 1)
->assertJsonPath('queues.0.name', 'Reception');
} }
public function test_display_marks_announcement_played(): void public function test_display_marks_announcement_played(): void