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,122 @@
<?php
namespace App\Http\Controllers\Pos;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosCustomerDisplay;
use App\Services\Pos\CustomerDisplayService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class CustomerDisplayController extends Controller
{
use ScopesToAccount;
public function __construct(private CustomerDisplayService $displays) {}
/** Operator: open/link info for the customer-facing screen. */
public function info(Request $request): JsonResponse
{
$location = $this->location($request);
$display = $this->displays->ensureForLocation($location);
return response()->json([
'url' => $this->displays->displayUrl($display),
'token' => $display->token,
'phase' => $display->phase,
]);
}
public function rotate(Request $request): JsonResponse
{
$location = $this->location($request);
$display = $this->displays->rotateToken($this->displays->ensureForLocation($location));
return response()->json([
'url' => $this->displays->displayUrl($display),
'token' => $display->token,
]);
}
/** Operator: push cart / phase updates from the register. */
public function push(Request $request): JsonResponse
{
$data = $request->validate([
'phase' => ['nullable', 'string', Rule::in(PosCustomerDisplay::PHASES)],
'lines' => ['nullable', 'array'],
'lines.*.name' => ['required_with:lines', 'string', 'max:200'],
'lines.*.quantity' => ['required_with:lines', 'numeric', 'min:0'],
'lines.*.unit_price_minor' => ['required_with:lines', 'integer', 'min:0'],
'lines.*.discount_minor' => ['nullable', 'integer', 'min:0'],
'lines.*.note' => ['nullable', 'string', 'max:200'],
'discount_minor' => ['nullable', 'integer', 'min:0'],
'tax_minor' => ['nullable', 'integer', 'min:0'],
'customer_name' => ['nullable', 'string', 'max:120'],
'loyalty' => ['nullable', 'array'],
'loyalty.points_balance' => ['nullable', 'integer'],
'loyalty.points_earn' => ['nullable', 'integer'],
'loyalty.points_redeem' => ['nullable', 'integer'],
'loyalty.label' => ['nullable', 'string', 'max:120'],
'payment' => ['nullable', 'array'],
'tip' => ['nullable', 'array'],
'signature' => ['nullable', 'array'],
'receipt' => ['nullable', 'array'],
'promo' => ['nullable', 'array'],
'message' => ['nullable', 'string', 'max:500'],
'sale_reference' => ['nullable', 'string', 'max:64'],
]);
$location = $this->location($request);
$phase = $data['phase'] ?? null;
$lines = $data['lines'] ?? null;
$extras = array_filter([
'discount_minor' => $data['discount_minor'] ?? null,
'tax_minor' => $data['tax_minor'] ?? null,
'customer_name' => $data['customer_name'] ?? null,
'loyalty' => $data['loyalty'] ?? null,
'payment' => $data['payment'] ?? null,
'tip' => $data['tip'] ?? null,
'signature' => $data['signature'] ?? null,
'receipt' => $data['receipt'] ?? null,
'promo' => $data['promo'] ?? null,
'message' => $data['message'] ?? null,
'sale_reference' => $data['sale_reference'] ?? null,
], fn ($v) => $v !== null);
if ($phase === PosCustomerDisplay::PHASE_CART || ($lines !== null && $phase === null)) {
$display = $this->displays->pushCart($location, $lines ?? [], $extras);
} elseif ($phase !== null) {
if ($lines !== null) {
$extras['lines'] = $lines;
}
$display = $this->displays->push($location, $phase, $extras);
} else {
$display = $this->displays->push($location, PosCustomerDisplay::PHASE_IDLE, $extras);
}
return response()->json([
'ok' => true,
'phase' => $display->phase,
'updated_at' => optional($display->last_pushed_at)->toIso8601String(),
]);
}
/** Operator: read (and optionally clear) the latest customer action (tip, signature, email). */
public function actions(Request $request): JsonResponse
{
$location = $this->location($request);
$display = $this->displays->ensureForLocation($location);
$action = $display->customer_action;
if ($request->boolean('clear') && $action) {
$this->displays->clearCustomerAction($display);
}
return response()->json([
'action' => $action,
]);
}
}
@@ -7,6 +7,7 @@ use App\Http\Controllers\Pos\Concerns\ScopesToAccount;
use App\Models\PosLocation;
use App\Models\PosProduct;
use App\Models\PosSale;
use App\Services\Pos\CustomerDisplayService;
use App\Services\Pos\PosBarcodeLookupService;
use App\Services\Pos\PosLocationService;
use App\Services\Pos\PosSaleService;
@@ -25,18 +26,24 @@ class RegisterController extends Controller
private PosSaleService $sales,
private PosLocationService $locations,
private PosBarcodeLookupService $barcodes,
private CustomerDisplayService $customerDisplays,
) {}
public function index(Request $request): View
{
$owner = $this->ownerRef($request);
$location = $this->location($request);
$display = $this->customerDisplays->ensureForLocation($location);
return view('pos.register', [
'products' => $this->registerProducts($owner, $location),
'location' => $location,
'crmCustomers' => $this->crmCustomers($owner),
'barcodeLookupUrl' => route('pos.register.lookup'),
'customerDisplayUrl' => $this->customerDisplays->displayUrl($display),
'customerDisplayPushUrl' => route('pos.customer-display.push'),
'customerDisplayActionsUrl' => route('pos.customer-display.actions'),
'isRestaurant' => $location->isRestaurant(),
]);
}
@@ -163,6 +170,7 @@ class RegisterController extends Controller
if ($data['payment_method'] === 'cash') {
$this->sales->recordCashPayment($sale);
$this->customerDisplays->pushReceipt($location, $sale->fresh(['lines']));
if ($location->printer_auto_print) {
return redirect()
@@ -176,18 +184,26 @@ class RegisterController extends Controller
}
$result = $this->sales->initiatePayCheckout($sale, $merchant);
$checkoutUrl = $result['checkout_url'] ?? null;
$this->customerDisplays->pushPayment($location, $sale->fresh(['lines']), [
'checkout_url' => $checkoutUrl,
'qr_url' => $checkoutUrl,
'message' => 'Complete payment with card or MoMo',
'options' => ['Card / MoMo'],
]);
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'checkout_url' => $result['checkout_url'],
'checkout_url' => $checkoutUrl,
'sale_id' => $sale->id,
]);
}
return redirect()
->route('pos.sales.show', $sale)
->with('checkout_url', $result['checkout_url'])
->with('success', 'Payment sheet ready — hand the device to the customer for card or MoMo.');
->with('checkout_url', $checkoutUrl)
->with('success', 'Payment sheet ready — customer screen shows the QR; till sheet is for the operator.');
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}