From 38b7f967143a12cbc65dae6e7aa1ef7493751fb3 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 15 Jul 2026 15:35:44 +0000 Subject: [PATCH] Add dual-screen customer display for the register. Token-gated full-screen customer view shows order, payment QR, tips, signature, receipt, and promo phases while the till keeps operator UI. --- .../Pos/CustomerDisplayController.php | 122 +++++ .../Controllers/Pos/RegisterController.php | 22 +- .../Public/CustomerDisplayController.php | 146 ++++++ app/Models/PosCustomerDisplay.php | 57 +++ app/Services/Pos/CustomerDisplayService.php | 275 +++++++++++ ...000_create_pos_customer_displays_table.php | 28 ++ .../views/pos/customer-display.blade.php | 439 ++++++++++++++++++ resources/views/pos/register.blade.php | 171 ++++++- routes/web.php | 24 + tests/Feature/CustomerDisplayTest.php | 146 ++++++ 10 files changed, 1426 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Pos/CustomerDisplayController.php create mode 100644 app/Http/Controllers/Public/CustomerDisplayController.php create mode 100644 app/Models/PosCustomerDisplay.php create mode 100644 app/Services/Pos/CustomerDisplayService.php create mode 100644 database/migrations/2026_07_15_160000_create_pos_customer_displays_table.php create mode 100644 resources/views/pos/customer-display.blade.php create mode 100644 tests/Feature/CustomerDisplayTest.php diff --git a/app/Http/Controllers/Pos/CustomerDisplayController.php b/app/Http/Controllers/Pos/CustomerDisplayController.php new file mode 100644 index 0000000..6a4f8ea --- /dev/null +++ b/app/Http/Controllers/Pos/CustomerDisplayController.php @@ -0,0 +1,122 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Pos/RegisterController.php b/app/Http/Controllers/Pos/RegisterController.php index 2231f67..24a9886 100644 --- a/app/Http/Controllers/Pos/RegisterController.php +++ b/app/Http/Controllers/Pos/RegisterController.php @@ -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()); } diff --git a/app/Http/Controllers/Public/CustomerDisplayController.php b/app/Http/Controllers/Public/CustomerDisplayController.php new file mode 100644 index 0000000..8b27759 --- /dev/null +++ b/app/Http/Controllers/Public/CustomerDisplayController.php @@ -0,0 +1,146 @@ +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(); + } +} diff --git a/app/Models/PosCustomerDisplay.php b/app/Models/PosCustomerDisplay.php new file mode 100644 index 0000000..165ee2c --- /dev/null +++ b/app/Models/PosCustomerDisplay.php @@ -0,0 +1,57 @@ + 'array', + 'customer_action' => 'array', + 'last_pushed_at' => 'datetime', + ]; + } + + public function location(): BelongsTo + { + return $this->belongsTo(PosLocation::class, 'location_id'); + } +} diff --git a/app/Services/Pos/CustomerDisplayService.php b/app/Services/Pos/CustomerDisplayService.php new file mode 100644 index 0000000..3b98aaf --- /dev/null +++ b/app/Services/Pos/CustomerDisplayService.php @@ -0,0 +1,275 @@ +where('location_id', $location->id)->first(); + + if ($display) { + return $display; + } + + return PosCustomerDisplay::create([ + 'location_id' => $location->id, + 'owner_ref' => $location->owner_ref, + 'token' => $this->freshToken(), + 'phase' => PosCustomerDisplay::PHASE_IDLE, + 'payload' => $this->defaultPayload($location), + 'last_pushed_at' => now(), + ]); + } + + public function rotateToken(PosCustomerDisplay $display): PosCustomerDisplay + { + $display->update(['token' => $this->freshToken()]); + + return $display->fresh(); + } + + /** + * @param array $payload + */ + public function push(PosLocation $location, string $phase, array $payload = []): PosCustomerDisplay + { + if (! in_array($phase, PosCustomerDisplay::PHASES, true)) { + $phase = PosCustomerDisplay::PHASE_IDLE; + } + + $display = $this->ensureForLocation($location); + $base = $this->defaultPayload($location); + $merged = array_replace_recursive($base, $payload); + $merged['phase'] = $phase; + $merged['updated_at'] = now()->toIso8601String(); + + $display->update([ + 'phase' => $phase, + 'payload' => $merged, + 'customer_action' => null, + 'last_pushed_at' => now(), + ]); + + return $display->fresh(); + } + + /** + * @param array{name: string, quantity: int|float, unit_price_minor: int, discount_minor?: int, note?: string}[] $lines + * @param array $extras + */ + public function pushCart(PosLocation $location, array $lines, array $extras = []): PosCustomerDisplay + { + $normalized = []; + $subtotal = 0; + $discount = (int) ($extras['discount_minor'] ?? 0); + + foreach ($lines as $line) { + $qty = max(0, (float) ($line['quantity'] ?? 0)); + $unit = (int) ($line['unit_price_minor'] ?? 0); + $lineDiscount = (int) ($line['discount_minor'] ?? 0); + $lineTotal = (int) round($unit * $qty) - $lineDiscount; + $subtotal += $lineTotal; + $normalized[] = [ + 'name' => (string) ($line['name'] ?? 'Item'), + 'quantity' => $qty, + 'unit_price_minor' => $unit, + 'discount_minor' => $lineDiscount, + 'line_total_minor' => max(0, $lineTotal), + 'note' => isset($line['note']) ? (string) $line['note'] : null, + ]; + } + + $total = max(0, $subtotal - $discount); + + if ($normalized === []) { + return $this->push($location, PosCustomerDisplay::PHASE_IDLE, $extras); + } + + return $this->push($location, PosCustomerDisplay::PHASE_CART, array_merge($extras, [ + 'lines' => $normalized, + 'subtotal_minor' => $subtotal, + 'discount_minor' => $discount, + 'total_minor' => $total, + 'currency' => $location->currency, + ])); + } + + /** + * @param array $extras + */ + public function pushPayment(PosLocation $location, PosSale $sale, array $extras = []): PosCustomerDisplay + { + return $this->push($location, PosCustomerDisplay::PHASE_PAYMENT, array_merge([ + 'lines' => $this->linesFromSale($sale), + 'subtotal_minor' => (int) $sale->subtotal_minor, + 'total_minor' => (int) $sale->total_minor, + 'currency' => $sale->currency, + 'payment' => [ + 'method' => $sale->payment_method, + 'status' => $sale->status, + 'message' => $extras['message'] ?? 'Complete payment on the terminal or scan the code', + 'qr_url' => $extras['qr_url'] ?? null, + 'checkout_url' => $extras['checkout_url'] ?? null, + 'options' => $extras['options'] ?? ['Card / MoMo', 'Cash'], + ], + 'sale_reference' => $sale->reference, + ], $extras)); + } + + /** + * @param array $extras + */ + public function pushReceipt(PosLocation $location, PosSale $sale, array $extras = []): PosCustomerDisplay + { + return $this->push($location, PosCustomerDisplay::PHASE_RECEIPT, array_merge([ + 'lines' => $this->linesFromSale($sale), + 'subtotal_minor' => (int) $sale->subtotal_minor, + 'total_minor' => (int) $sale->total_minor, + 'currency' => $sale->currency, + 'receipt' => [ + 'reference' => $sale->reference, + 'total_minor' => (int) $sale->total_minor, + 'digital_url' => $extras['digital_url'] ?? null, + 'message' => $extras['message'] ?? 'Thank you for your purchase', + 'email_prompt' => true, + ], + 'sale_reference' => $sale->reference, + ], $extras)); + } + + /** + * @param array $action + */ + public function recordCustomerAction(PosCustomerDisplay $display, array $action): PosCustomerDisplay + { + $display->update([ + 'customer_action' => array_merge($action, [ + 'received_at' => now()->toIso8601String(), + ]), + ]); + + return $display->fresh(); + } + + public function clearCustomerAction(PosCustomerDisplay $display): void + { + $display->update(['customer_action' => null]); + } + + /** + * Public payload for the customer-facing screen (no operator secrets). + * + * @return array + */ + public function publicState(PosCustomerDisplay $display): array + { + $payload = is_array($display->payload) ? $display->payload : []; + $location = $display->location; + + return [ + 'phase' => $display->phase, + 'updated_at' => optional($display->last_pushed_at)->toIso8601String() + ?? ($payload['updated_at'] ?? null), + 'location_name' => $location?->name ?? ($payload['location_name'] ?? 'Store'), + 'currency' => $payload['currency'] ?? ($location?->currency ?? 'GHS'), + 'brand' => [ + 'logo_url' => $location?->receiptLogoUrl(), + 'header' => $location?->receipt_header, + 'footer' => $location?->receipt_footer, + ], + 'lines' => $payload['lines'] ?? [], + 'subtotal_minor' => (int) ($payload['subtotal_minor'] ?? 0), + 'discount_minor' => (int) ($payload['discount_minor'] ?? 0), + 'tax_minor' => (int) ($payload['tax_minor'] ?? 0), + 'total_minor' => (int) ($payload['total_minor'] ?? 0), + 'loyalty' => $payload['loyalty'] ?? null, + 'payment' => $payload['payment'] ?? null, + 'tip' => $payload['tip'] ?? null, + 'signature' => $payload['signature'] ?? null, + 'receipt' => $payload['receipt'] ?? null, + 'promo' => $payload['promo'] ?? ($this->defaultPayload($location ?? new PosLocation)['promo'] ?? null), + 'customer_name' => $payload['customer_name'] ?? null, + 'sale_reference' => $payload['sale_reference'] ?? null, + 'message' => $payload['message'] ?? null, + ]; + } + + /** + * @return array + */ + public function defaultPayload(?PosLocation $location): array + { + $name = $location?->name ?? 'Welcome'; + + return [ + 'location_name' => $name, + 'currency' => $location?->currency ?? 'GHS', + 'lines' => [], + 'subtotal_minor' => 0, + 'discount_minor' => 0, + 'tax_minor' => 0, + 'total_minor' => 0, + 'loyalty' => null, + 'payment' => null, + 'tip' => [ + 'enabled' => (bool) $location?->isRestaurant(), + 'options_percent' => [10, 15, 18, 20], + 'custom_allowed' => true, + 'selected_percent' => null, + 'selected_minor' => null, + 'base_minor' => 0, + ], + 'signature' => [ + 'required' => false, + 'data_url' => null, + 'prompt' => 'Please sign below', + ], + 'receipt' => null, + 'promo' => [ + 'headline' => $name, + 'body' => 'Thank you for shopping with us.', + 'image_url' => $location?->receiptLogoUrl(), + ], + 'message' => null, + ]; + } + + public function displayUrl(PosCustomerDisplay $display): string + { + return route('pos.customer-display.show', ['token' => $display->token]); + } + + private function freshToken(): string + { + return Str::random(48); + } + + /** + * @return list + */ + private function linesFromSale(PosSale $sale): array + { + $sale->loadMissing('lines'); + + return $sale->lines->map(function ($line) { + $qty = (float) $line->quantity; + $unit = (int) $line->unit_price_minor; + $total = (int) ($line->line_total_minor ?? round($unit * $qty)); + + return [ + 'name' => (string) $line->name, + 'quantity' => $qty, + 'unit_price_minor' => $unit, + 'discount_minor' => 0, + 'line_total_minor' => $total, + 'note' => $line->notes ?? null, + ]; + })->values()->all(); + } +} diff --git a/database/migrations/2026_07_15_160000_create_pos_customer_displays_table.php b/database/migrations/2026_07_15_160000_create_pos_customer_displays_table.php new file mode 100644 index 0000000..643d0a9 --- /dev/null +++ b/database/migrations/2026_07_15_160000_create_pos_customer_displays_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('location_id')->unique()->constrained('pos_locations')->cascadeOnDelete(); + $table->string('owner_ref', 64)->index(); + $table->string('token', 64)->unique(); + $table->string('phase', 32)->default('idle'); + $table->json('payload')->nullable(); + $table->json('customer_action')->nullable(); + $table->timestamp('last_pushed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pos_customer_displays'); + } +}; diff --git a/resources/views/pos/customer-display.blade.php b/resources/views/pos/customer-display.blade.php new file mode 100644 index 0000000..69c0028 --- /dev/null +++ b/resources/views/pos/customer-display.blade.php @@ -0,0 +1,439 @@ + + + + + + + Customer display · {{ $state['location_name'] ?? 'POS' }} + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + +
+ {{-- Brand bar --}} +
+
+ +
+

+

+
+
+
+ +
+
+ +
+ {{-- IDLE / PROMO --}} +
+ +
+

+

+
+

Your order will appear here

+
+ + {{-- CART + shared totals for payment / tip --}} +
+
+
+
+

Order summary

+

+ For +

+
+

+
+ +
    + +
+ +
+
+ Subtotal + +
+
+ Discounts + +
+
+ Tax + +
+
+ Tip + +
+
+ Total + +
+
+ + {{-- Loyalty --}} +
+

+
+ + Balance: pts + + + This visit: + + + + Redeeming: − + +
+
+
+ + {{-- Right panel: payment / tip / signature --}} + +
+ + {{-- RECEIPT --}} +
+
+
+

+

+ Receipt +

+

+
+ +
+ Digital receipt QR +

Scan for digital receipt

+
+ +
+

Email me a digital receipt

+
+ + +
+

Request sent to the till.

+
+ +

+
+
+
+ + + + diff --git a/resources/views/pos/register.blade.php b/resources/views/pos/register.blade.php index e10934a..be32c31 100644 --- a/resources/views/pos/register.blade.php +++ b/resources/views/pos/register.blade.php @@ -3,6 +3,11 @@ 'products' => $products, 'lookupUrl' => $barcodeLookupUrl, 'csrf' => csrf_token(), + 'customerDisplayUrl' => $customerDisplayUrl, + 'customerDisplayPushUrl' => $customerDisplayPushUrl, + 'customerDisplayActionsUrl' => $customerDisplayActionsUrl, + 'isRestaurant' => $isRestaurant, + 'currency' => $location->currency, ]))">
@@ -24,7 +29,12 @@ ])>Restaurant
-
+
+
+
+

@@ -128,6 +147,11 @@ products: config.products || [], lookupUrl: config.lookupUrl || '', csrf: config.csrf || '', + customerDisplayUrl: config.customerDisplayUrl || '', + customerDisplayPushUrl: config.customerDisplayPushUrl || '', + customerDisplayActionsUrl: config.customerDisplayActionsUrl || '', + isRestaurant: !!config.isRestaurant, + currency: config.currency || 'GHS', cart: [], quickAmount: '', quickLabel: 'Sale', @@ -136,8 +160,19 @@ scanOk: '', scanBuffer: '', scanTimer: null, + pushTimer: null, + actionTimer: null, + customerActionNote: '', + customerWindow: null, + bc: null, init() { document.addEventListener('keydown', (e) => this.onGlobalKeydown(e)); + this.$watch('cart', () => this.scheduleDisplayPush(), { deep: true }); + try { + this.bc = new BroadcastChannel('pos-customer-display'); + } catch (_) {} + this.actionTimer = setInterval(() => this.pollCustomerActions(), 3000); + this.scheduleDisplayPush(); }, onGlobalKeydown(e) { if (this.shouldIgnoreScan(e)) return; @@ -259,6 +294,140 @@ container.appendChild(input); }); }); + // Best-effort: show payment phase on customer screen before navigation. + this.pushDisplay({ + phase: 'payment', + lines: this.cartLinesPayload(), + payment: { + method: e.submitter?.value || 'pay', + status: 'pending', + message: e.submitter?.value === 'cash' + ? 'Cash payment at the register' + : 'Complete payment with card or MoMo', + options: e.submitter?.value === 'cash' ? ['Cash'] : ['Card / MoMo'], + }, + }); + }, + + openCustomerDisplay() { + if (!this.customerDisplayUrl) return; + this.customerWindow = window.open( + this.customerDisplayUrl, + 'posCustomerDisplay', + 'noopener,noreferrer' + ); + this.scheduleDisplayPush(); + }, + + cartLinesPayload() { + return this.cart.map((line) => ({ + name: line.name, + quantity: line.quantity, + unit_price_minor: line.unit_price_minor, + discount_minor: line.discount_minor || 0, + note: line.note || null, + })); + }, + + scheduleDisplayPush() { + clearTimeout(this.pushTimer); + this.pushTimer = setTimeout(() => this.pushCartToDisplay(), 250); + }, + + async pushCartToDisplay() { + const lines = this.cartLinesPayload(); + const payload = lines.length === 0 + ? { phase: 'idle' } + : { + phase: 'cart', + lines, + customer_name: document.getElementById('customer_name')?.value || null, + tip: this.isRestaurant ? { + enabled: true, + options_percent: [10, 15, 18, 20], + custom_allowed: true, + base_minor: this.cartTotal(), + } : undefined, + }; + await this.pushDisplay(payload); + }, + + async requestTip() { + if (this.cart.length === 0) return; + await this.pushDisplay({ + phase: 'tip', + lines: this.cartLinesPayload(), + tip: { + enabled: true, + options_percent: [10, 15, 18, 20], + custom_allowed: true, + base_minor: this.cartTotal(), + }, + }); + this.customerActionNote = 'Tip request shown on customer screen.'; + }, + + async requestSignature() { + if (this.cart.length === 0) return; + await this.pushDisplay({ + phase: 'signature', + lines: this.cartLinesPayload(), + signature: { + required: true, + prompt: 'Please sign to confirm your order', + }, + }); + this.customerActionNote = 'Signature pad shown on customer screen.'; + }, + + async pushDisplay(body) { + if (!this.customerDisplayPushUrl) return; + try { + const res = await fetch(this.customerDisplayPushUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': this.csrf, + }, + body: JSON.stringify(body), + }); + // Mirror to same-browser customer window via BroadcastChannel when possible. + if (this.bc && res.ok) { + // Customer screen will also pick up via SSE; BC is a fast path after its next poll. + } + } catch (_) {} + }, + + async pollCustomerActions() { + if (!this.customerDisplayActionsUrl) return; + try { + const res = await fetch(this.customerDisplayActionsUrl + '?clear=1', { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + if (!res.ok) return; + const data = await res.json(); + const action = data.action; + if (!action?.type) return; + if (action.type === 'tip') { + const tip = action.tip_minor != null + ? (this.currency + ' ' + (Number(action.tip_minor) / 100).toFixed(2)) + : ((action.tip_percent ?? 0) + '%'); + this.customerActionNote = 'Customer selected tip: ' + tip; + } else if (action.type === 'signature') { + this.customerActionNote = 'Customer signature captured.'; + } else if (action.type === 'receipt_email' && action.email) { + this.customerActionNote = 'Customer requested receipt email: ' + action.email; + const emailInput = document.getElementById('customer_email'); + if (emailInput && !emailInput.value) emailInput.value = action.email; + } else if (action.type === 'payment_option') { + this.customerActionNote = 'Customer chose payment: ' + (action.payment_option || ''); + } + } catch (_) {} }, }; } diff --git a/routes/web.php b/routes/web.php index ec800e1..8100eb1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,12 +10,14 @@ use App\Http\Controllers\Pos\KitchenController; use App\Http\Controllers\Pos\MenuController; use App\Http\Controllers\Pos\ProductController; use App\Http\Controllers\Pos\ProController; +use App\Http\Controllers\Pos\CustomerDisplayController; use App\Http\Controllers\Pos\RegisterController; use App\Http\Controllers\Pos\SaleController; use App\Http\Controllers\Pos\SettingsController; use App\Http\Controllers\Pos\TableController; use App\Http\Controllers\Pos\TicketController; use App\Http\Controllers\NotificationController; +use App\Http\Controllers\Public\CustomerDisplayController as PublicCustomerDisplayController; use App\Http\Controllers\Public\TableOrderController; use App\Http\Controllers\WalletBalanceController; use Illuminate\Support\Facades\Route; @@ -41,6 +43,23 @@ Route::get('/t/{code}', [TableOrderController::class, 'menu'])->name('pos.table. Route::post('/t/{code}/order', [TableOrderController::class, 'store'])->middleware('throttle:20,1')->name('pos.table.order'); Route::get('/t/{code}/done', [TableOrderController::class, 'confirmed'])->name('pos.table.confirmed'); +// Customer-facing dual-screen display (token-gated; no operator chrome). +Route::get('/customer-display/{token}', [PublicCustomerDisplayController::class, 'show']) + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.customer-display.show'); +Route::get('/customer-display/{token}/state', [PublicCustomerDisplayController::class, 'state']) + ->middleware('throttle:120,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.customer-display.state'); +Route::get('/customer-display/{token}/stream', [PublicCustomerDisplayController::class, 'stream']) + ->middleware('throttle:60,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.customer-display.stream'); +Route::post('/customer-display/{token}/action', [PublicCustomerDisplayController::class, 'action']) + ->middleware('throttle:60,1') + ->where('token', '[A-Za-z0-9]{32,64}') + ->name('pos.customer-display.action'); + Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/wallet/balance', [WalletBalanceController::class, 'balance'])->name('wallet.balance'); @@ -61,6 +80,11 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/register/charge', [RegisterController::class, 'charge'])->name('pos.register.charge'); Route::post('/register/mode', [RegisterController::class, 'setMode'])->name('pos.mode.set'); + Route::get('/register/customer-display', [CustomerDisplayController::class, 'info'])->name('pos.customer-display.info'); + Route::post('/register/customer-display/push', [CustomerDisplayController::class, 'push'])->name('pos.customer-display.push'); + Route::post('/register/customer-display/rotate', [CustomerDisplayController::class, 'rotate'])->name('pos.customer-display.rotate'); + Route::get('/register/customer-display/actions', [CustomerDisplayController::class, 'actions'])->name('pos.customer-display.actions'); + Route::get('/sales', [SaleController::class, 'index'])->name('pos.sales.index'); Route::get('/sales/{sale}/receipt', [SaleController::class, 'receipt'])->name('pos.sales.receipt'); Route::get('/sales/{sale}', [SaleController::class, 'show'])->name('pos.sales.show'); diff --git a/tests/Feature/CustomerDisplayTest.php b/tests/Feature/CustomerDisplayTest.php new file mode 100644 index 0000000..d6c2583 --- /dev/null +++ b/tests/Feature/CustomerDisplayTest.php @@ -0,0 +1,146 @@ + 'owner-'.uniqid(), + 'name' => 'Owner', + 'email' => uniqid().'@owner.example.com', + ]); + } + + private function locationFor(User $owner): PosLocation + { + return PosLocation::create([ + 'owner_ref' => $owner->public_id, + 'name' => 'Front counter', + 'currency' => 'GHS', + 'is_default' => true, + ]); + } + + protected function setUp(): void + { + parent::setUp(); + $this->withoutMiddleware(EnsurePlatformSession::class); + $this->withoutVite(); + } + + public function test_operator_can_push_cart_and_public_screen_reads_it(): void + { + $owner = $this->owner(); + $this->locationFor($owner); + + $this->actingAs($owner) + ->postJson(route('pos.customer-display.push'), [ + 'phase' => 'cart', + 'lines' => [ + [ + 'name' => 'Blue Shirt', + 'quantity' => 2, + 'unit_price_minor' => 5000, + 'discount_minor' => 500, + ], + ], + 'discount_minor' => 0, + 'loyalty' => [ + 'label' => 'Rewards', + 'points_balance' => 120, + 'points_earn' => 9, + ], + ]) + ->assertOk() + ->assertJsonPath('phase', 'cart'); + + $display = PosCustomerDisplay::query()->first(); + $this->assertNotNull($display); + + $this->getJson(route('pos.customer-display.state', ['token' => $display->token])) + ->assertOk() + ->assertJsonPath('phase', 'cart') + ->assertJsonPath('lines.0.name', 'Blue Shirt') + ->assertJsonPath('total_minor', 9500) + ->assertJsonPath('loyalty.points_balance', 120); + } + + public function test_customer_can_submit_tip_and_signature_actions(): void + { + $owner = $this->owner(); + $location = $this->locationFor($owner); + $display = app(CustomerDisplayService::class)->pushCart($location, [ + ['name' => 'Lunch', 'quantity' => 1, 'unit_price_minor' => 10000], + ], [ + 'tip' => [ + 'enabled' => true, + 'options_percent' => [10, 15, 20], + 'custom_allowed' => true, + 'base_minor' => 10000, + ], + ]); + + app(CustomerDisplayService::class)->push($location, PosCustomerDisplay::PHASE_TIP, [ + 'lines' => $display->fresh()->payload['lines'] ?? [], + 'total_minor' => 10000, + 'tip' => [ + 'enabled' => true, + 'options_percent' => [10, 15, 20], + 'custom_allowed' => true, + 'base_minor' => 10000, + ], + ]); + + $token = $display->fresh()->token; + + $this->postJson(route('pos.customer-display.action', ['token' => $token]), [ + 'type' => 'tip', + 'tip_percent' => 15, + 'tip_minor' => 1500, + ])->assertOk()->assertJsonPath('ok', true); + + $this->actingAs($owner) + ->getJson(route('pos.customer-display.actions')) + ->assertOk() + ->assertJsonPath('action.type', 'tip') + ->assertJsonPath('action.tip_minor', 1500); + + $this->postJson(route('pos.customer-display.action', ['token' => $token]), [ + 'type' => 'signature', + 'signature_data_url' => 'data:image/png;base64,abc', + ])->assertOk(); + + $this->get(route('pos.customer-display.show', ['token' => $token])) + ->assertOk() + ->assertSee('Order summary', false); + } + + public function test_invalid_token_is_not_found(): void + { + $this->get(route('pos.customer-display.show', ['token' => str_repeat('a', 48)])) + ->assertNotFound(); + } + + public function test_operator_info_returns_display_url(): void + { + $owner = $this->owner(); + $this->locationFor($owner); + + $this->actingAs($owner) + ->getJson(route('pos.customer-display.info')) + ->assertOk() + ->assertJsonStructure(['url', 'token', 'phase']); + } +}