Add dual-screen customer display for the register.
Deploy Ladill POS / deploy (push) Successful in 56s

Token-gated full-screen customer view shows order, payment QR, tips,
signature, receipt, and promo phases while the till keeps operator UI.
This commit is contained in:
isaacclad
2026-07-15 15:35:44 +00:00
parent 5e31bdf629
commit 38b7f96714
10 changed files with 1426 additions and 4 deletions
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\PosCustomerDisplay;
use App\Services\Pos\CustomerDisplayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CustomerDisplayController extends Controller
{
public function __construct(private CustomerDisplayService $displays) {}
public function show(string $token): View
{
$display = $this->find($token);
return view('pos.customer-display', [
'display' => $display,
'state' => $this->displays->publicState($display),
'stateUrl' => route('pos.customer-display.state', ['token' => $token]),
'streamUrl' => route('pos.customer-display.stream', ['token' => $token]),
'actionUrl' => route('pos.customer-display.action', ['token' => $token]),
]);
}
public function state(string $token): JsonResponse
{
$display = $this->find($token);
return response()->json($this->displays->publicState($display));
}
public function stream(Request $request, string $token): StreamedResponse
{
$display = $this->find($token);
$id = $display->id;
$request->session()->save();
$response = new StreamedResponse(function () use ($id) {
while (ob_get_level() > 0) {
@ob_end_flush();
}
@set_time_limit(0);
$start = time();
$lastHash = null;
while (true) {
$display = PosCustomerDisplay::query()->find($id);
if (! $display) {
echo "event: end\ndata: {}\n\n";
break;
}
$payload = $this->displays->publicState($display);
$json = json_encode($payload);
$hash = md5((string) $json);
if ($hash !== $lastHash) {
echo 'data: '.$json."\n\n";
$lastHash = $hash;
} else {
echo ": ping\n\n";
}
@ob_flush();
@flush();
if (connection_aborted() || (time() - $start) >= 25) {
break;
}
usleep(400000);
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('Connection', 'keep-alive');
$response->headers->set('X-Accel-Buffering', 'no');
return $response;
}
public function action(Request $request, string $token): JsonResponse
{
$display = $this->find($token);
$data = $request->validate([
'type' => ['required', 'string', 'in:tip,signature,receipt_email,payment_option'],
'tip_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'tip_minor' => ['nullable', 'integer', 'min:0'],
'signature_data_url' => ['nullable', 'string', 'max:500000'],
'email' => ['nullable', 'email', 'max:255'],
'payment_option' => ['nullable', 'string', 'max:40'],
]);
$action = ['type' => $data['type']];
if ($data['type'] === 'tip') {
$action['tip_percent'] = $data['tip_percent'] ?? null;
$action['tip_minor'] = $data['tip_minor'] ?? null;
// Reflect selection on the screen immediately.
$payload = $display->payload ?? [];
$tip = $payload['tip'] ?? [];
$tip['selected_percent'] = $data['tip_percent'] ?? null;
$tip['selected_minor'] = $data['tip_minor'] ?? null;
$payload['tip'] = $tip;
$display->update(['payload' => $payload, 'last_pushed_at' => now()]);
}
if ($data['type'] === 'signature') {
$action['signature_data_url'] = $data['signature_data_url'] ?? null;
$payload = $display->payload ?? [];
$sig = $payload['signature'] ?? [];
$sig['data_url'] = $data['signature_data_url'] ?? null;
$payload['signature'] = $sig;
$display->update(['payload' => $payload, 'last_pushed_at' => now()]);
}
if ($data['type'] === 'receipt_email') {
$action['email'] = $data['email'] ?? null;
}
if ($data['type'] === 'payment_option') {
$action['payment_option'] = $data['payment_option'] ?? null;
}
$this->displays->recordCustomerAction($display->fresh(), $action);
return response()->json([
'ok' => true,
'state' => $this->displays->publicState($display->fresh()),
]);
}
private function find(string $token): PosCustomerDisplay
{
return PosCustomerDisplay::query()
->where('token', $token)
->with('location')
->firstOrFail();
}
}