Add dual-screen customer display for the register.
Deploy Ladill POS / deploy (push) Successful in 56s
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:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PosCustomerDisplay extends Model
|
||||
{
|
||||
public const PHASE_IDLE = 'idle';
|
||||
|
||||
public const PHASE_CART = 'cart';
|
||||
|
||||
public const PHASE_PAYMENT = 'payment';
|
||||
|
||||
public const PHASE_TIP = 'tip';
|
||||
|
||||
public const PHASE_SIGNATURE = 'signature';
|
||||
|
||||
public const PHASE_RECEIPT = 'receipt';
|
||||
|
||||
public const PHASE_PROMO = 'promo';
|
||||
|
||||
public const PHASES = [
|
||||
self::PHASE_IDLE,
|
||||
self::PHASE_CART,
|
||||
self::PHASE_PAYMENT,
|
||||
self::PHASE_TIP,
|
||||
self::PHASE_SIGNATURE,
|
||||
self::PHASE_RECEIPT,
|
||||
self::PHASE_PROMO,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'location_id',
|
||||
'owner_ref',
|
||||
'token',
|
||||
'phase',
|
||||
'payload',
|
||||
'customer_action',
|
||||
'last_pushed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload' => 'array',
|
||||
'customer_action' => 'array',
|
||||
'last_pushed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PosLocation::class, 'location_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Pos;
|
||||
|
||||
use App\Models\PosCustomerDisplay;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\PosSale;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CustomerDisplayService
|
||||
{
|
||||
public function ensureForLocation(PosLocation $location): PosCustomerDisplay
|
||||
{
|
||||
$display = PosCustomerDisplay::query()->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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<array{name: string, quantity: float|int, unit_price_minor: int, discount_minor: int, line_total_minor: int, note: null}>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('pos_customer_displays', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Customer display · {{ $state['location_name'] ?? 'POS' }}</title>
|
||||
@include('partials.favicon')
|
||||
<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'])
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
html, body { overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full bg-slate-950 font-sans text-white antialiased"
|
||||
x-data="customerDisplay(@js([
|
||||
'initial' => $state,
|
||||
'stateUrl' => $stateUrl,
|
||||
'streamUrl' => $streamUrl,
|
||||
'actionUrl' => $actionUrl,
|
||||
'csrf' => csrf_token(),
|
||||
]))">
|
||||
<div class="flex h-full min-h-dvh flex-col">
|
||||
{{-- Brand bar --}}
|
||||
<header class="flex items-center justify-between gap-4 border-b border-white/10 px-6 py-4 sm:px-10">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<template x-if="state.brand?.logo_url">
|
||||
<img :src="state.brand.logo_url" alt="" class="h-10 w-10 rounded-xl object-cover bg-white/10">
|
||||
</template>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-lg font-semibold tracking-tight" x-text="state.location_name"></p>
|
||||
<p class="text-xs text-slate-400" x-show="state.brand?.header" x-text="state.brand.header"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-slate-500">
|
||||
<span x-text="clock"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="relative flex flex-1 flex-col overflow-hidden">
|
||||
{{-- IDLE / PROMO --}}
|
||||
<section x-show="isPhase('idle') || isPhase('promo')" x-cloak
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6 px-8 text-center">
|
||||
<template x-if="state.promo?.image_url">
|
||||
<img :src="state.promo.image_url" alt="" class="h-28 w-28 rounded-3xl object-cover bg-white/10 shadow-2xl">
|
||||
</template>
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold tracking-tight sm:text-5xl" x-text="state.promo?.headline || state.location_name"></h1>
|
||||
<p class="mx-auto mt-4 max-w-xl text-lg text-slate-300" x-text="state.promo?.body || 'Welcome'"></p>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">Your order will appear here</p>
|
||||
</section>
|
||||
|
||||
{{-- CART + shared totals for payment / tip --}}
|
||||
<section x-show="isPhase('cart') || isPhase('payment') || isPhase('tip') || isPhase('signature')" x-cloak
|
||||
class="flex flex-1 flex-col overflow-hidden lg:flex-row">
|
||||
<div class="flex flex-1 flex-col overflow-hidden px-6 py-5 sm:px-10">
|
||||
<div class="mb-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-300">Order summary</p>
|
||||
<p class="text-sm text-slate-400" x-show="state.customer_name">
|
||||
For <span class="text-white" x-text="state.customer_name"></span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500" x-show="state.sale_reference" x-text="state.sale_reference"></p>
|
||||
</div>
|
||||
|
||||
<ul class="flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
<template x-for="(line, i) in (state.lines || [])" :key="i">
|
||||
<li class="flex items-start justify-between gap-4 rounded-2xl bg-white/5 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-white">
|
||||
<span class="text-slate-400" x-text="line.quantity + '×'"></span>
|
||||
<span x-text="line.name"></span>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400" x-show="line.note" x-text="line.note"></p>
|
||||
<p class="mt-0.5 text-xs text-emerald-300" x-show="line.discount_minor > 0"
|
||||
x-text="'Discount −' + money(line.discount_minor)"></p>
|
||||
</div>
|
||||
<p class="shrink-0 font-semibold tabular-nums" x-text="money(line.line_total_minor)"></p>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 space-y-1.5 border-t border-white/10 pt-4 text-sm">
|
||||
<div class="flex justify-between text-slate-300">
|
||||
<span>Subtotal</span>
|
||||
<span class="tabular-nums" x-text="money(state.subtotal_minor)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-emerald-300" x-show="state.discount_minor > 0">
|
||||
<span>Discounts</span>
|
||||
<span class="tabular-nums" x-text="'−' + money(state.discount_minor)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-slate-300" x-show="state.tax_minor > 0">
|
||||
<span>Tax</span>
|
||||
<span class="tabular-nums" x-text="money(state.tax_minor)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-slate-300" x-show="state.tip?.selected_minor > 0">
|
||||
<span>Tip</span>
|
||||
<span class="tabular-nums" x-text="money(state.tip.selected_minor)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between text-2xl font-bold tracking-tight">
|
||||
<span>Total</span>
|
||||
<span class="tabular-nums text-indigo-300" x-text="money(grandTotal())"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loyalty --}}
|
||||
<div x-show="state.loyalty" x-cloak
|
||||
class="mt-4 rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm">
|
||||
<p class="font-semibold text-amber-200" x-text="state.loyalty?.label || 'Loyalty'"></p>
|
||||
<div class="mt-1 flex flex-wrap gap-4 text-amber-100/90">
|
||||
<span x-show="state.loyalty?.points_balance != null">
|
||||
Balance: <strong x-text="state.loyalty.points_balance"></strong> pts
|
||||
</span>
|
||||
<span x-show="state.loyalty?.points_earn">
|
||||
This visit: +<strong x-text="state.loyalty.points_earn"></strong>
|
||||
</span>
|
||||
<span x-show="state.loyalty?.points_redeem">
|
||||
Redeeming: −<strong x-text="state.loyalty.points_redeem"></strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Right panel: payment / tip / signature --}}
|
||||
<aside class="flex w-full flex-col justify-center gap-5 border-t border-white/10 bg-slate-900/60 px-6 py-6 lg:w-[420px] lg:border-l lg:border-t-0 sm:px-8">
|
||||
{{-- Payment --}}
|
||||
<div x-show="isPhase('payment')" class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-300">Payment</p>
|
||||
<p class="text-xl font-semibold" x-text="state.payment?.message || 'Please complete payment'"></p>
|
||||
<p class="text-sm text-slate-400 capitalize" x-show="state.payment?.status"
|
||||
x-text="'Status: ' + (state.payment?.status || '')"></p>
|
||||
|
||||
<div class="flex flex-wrap gap-2" x-show="state.payment?.options?.length">
|
||||
<template x-for="opt in (state.payment?.options || [])" :key="opt">
|
||||
<button type="button" @click="choosePayment(opt)"
|
||||
class="rounded-xl border border-white/15 bg-white/5 px-3 py-2 text-sm font-medium hover:bg-white/10"
|
||||
x-text="opt"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="qrData()" class="flex flex-col items-center gap-3 rounded-2xl bg-white p-4 text-slate-900">
|
||||
<img :src="qrImage(qrData())" alt="Payment QR" class="h-48 w-48">
|
||||
<p class="text-center text-xs text-slate-600">Scan to pay with your phone</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Tip (restaurants) --}}
|
||||
<div x-show="isPhase('tip')" class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-300">Add a tip</p>
|
||||
<p class="text-xl font-semibold">How much would you like to tip?</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template x-for="pct in (state.tip?.options_percent || [10,15,18,20])" :key="pct">
|
||||
<button type="button" @click="selectTipPercent(pct)"
|
||||
:class="state.tip?.selected_percent === pct ? 'bg-indigo-500 text-white ring-2 ring-indigo-300' : 'bg-white/5 hover:bg-white/10'"
|
||||
class="rounded-2xl px-3 py-4 text-center transition">
|
||||
<span class="block text-2xl font-bold" x-text="pct + '%'"></span>
|
||||
<span class="mt-1 block text-xs text-slate-300" x-text="money(tipAmount(pct))"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" @click="selectTipPercent(0)"
|
||||
class="w-full rounded-xl border border-white/15 px-3 py-2.5 text-sm text-slate-300 hover:bg-white/5">
|
||||
No tip
|
||||
</button>
|
||||
<div x-show="state.tip?.custom_allowed" class="flex gap-2">
|
||||
<input type="number" min="0" step="0.01" x-model="customTip"
|
||||
placeholder="Custom amount"
|
||||
class="flex-1 rounded-xl border-0 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-400">
|
||||
<button type="button" @click="selectCustomTip()"
|
||||
class="rounded-xl bg-indigo-500 px-4 py-2 text-sm font-semibold hover:bg-indigo-400">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Signature --}}
|
||||
<div x-show="isPhase('signature')" class="space-y-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-300">Signature</p>
|
||||
<p class="text-lg font-semibold" x-text="state.signature?.prompt || 'Please sign below'"></p>
|
||||
<div class="overflow-hidden rounded-2xl bg-white">
|
||||
<canvas x-ref="sigCanvas" class="h-48 w-full touch-none"
|
||||
@pointerdown="sigStart($event)"
|
||||
@pointermove="sigMove($event)"
|
||||
@pointerup="sigEnd()"
|
||||
@pointerleave="sigEnd()"></canvas>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="clearSignature()"
|
||||
class="flex-1 rounded-xl border border-white/15 px-3 py-2 text-sm hover:bg-white/5">Clear</button>
|
||||
<button type="button" @click="submitSignature()"
|
||||
class="flex-1 rounded-xl bg-indigo-500 px-3 py-2 text-sm font-semibold hover:bg-indigo-400">Done</button>
|
||||
</div>
|
||||
<p x-show="sigSaved" class="text-sm text-emerald-300">Signature saved — thank you.</p>
|
||||
</div>
|
||||
|
||||
{{-- Cart-only helper --}}
|
||||
<div x-show="isPhase('cart')" class="text-center text-sm text-slate-400">
|
||||
<p>Please review your order</p>
|
||||
<p class="mt-1 text-xs">Prices shown include any applied discounts</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
{{-- RECEIPT --}}
|
||||
<section x-show="isPhase('receipt')" x-cloak
|
||||
class="flex flex-1 flex-col items-center justify-center gap-5 px-8 text-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/20 text-3xl text-emerald-300">✓</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold sm:text-4xl" x-text="state.receipt?.message || 'Thank you!'"></h1>
|
||||
<p class="mt-2 text-slate-400" x-show="state.receipt?.reference || state.sale_reference">
|
||||
Receipt <span class="text-white" x-text="state.receipt?.reference || state.sale_reference"></span>
|
||||
</p>
|
||||
<p class="mt-4 text-4xl font-bold tabular-nums text-indigo-300" x-text="money(state.receipt?.total_minor ?? state.total_minor)"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="state.receipt?.digital_url" class="rounded-2xl bg-white p-4">
|
||||
<img :src="qrImage(state.receipt.digital_url)" alt="Digital receipt QR" class="h-40 w-40">
|
||||
<p class="mt-2 text-xs text-slate-600">Scan for digital receipt</p>
|
||||
</div>
|
||||
|
||||
<div x-show="state.receipt?.email_prompt !== false" class="w-full max-w-md">
|
||||
<p class="mb-2 text-sm text-slate-400">Email me a digital receipt</p>
|
||||
<div class="flex gap-2">
|
||||
<input type="email" x-model="receiptEmail" placeholder="you@email.com"
|
||||
class="flex-1 rounded-xl border-0 bg-white/10 px-3 py-2.5 text-sm text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-400">
|
||||
<button type="button" @click="sendReceiptEmail()"
|
||||
class="rounded-xl bg-indigo-500 px-4 py-2.5 text-sm font-semibold hover:bg-indigo-400">Send</button>
|
||||
</div>
|
||||
<p x-show="emailSent" class="mt-2 text-sm text-emerald-300">Request sent to the till.</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500" x-show="state.brand?.footer" x-text="state.brand.footer"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function customerDisplay(config) {
|
||||
return {
|
||||
state: config.initial || {},
|
||||
stateUrl: config.stateUrl,
|
||||
streamUrl: config.streamUrl,
|
||||
actionUrl: config.actionUrl,
|
||||
csrf: config.csrf,
|
||||
clock: '',
|
||||
customTip: '',
|
||||
receiptEmail: '',
|
||||
emailSent: false,
|
||||
sigSaved: false,
|
||||
drawing: false,
|
||||
clockTimer: null,
|
||||
pollTimer: null,
|
||||
es: null,
|
||||
|
||||
init() {
|
||||
this.tickClock();
|
||||
this.clockTimer = setInterval(() => this.tickClock(), 1000);
|
||||
this.connectStream();
|
||||
this.pollTimer = setInterval(() => this.fetchState(), 4000);
|
||||
this.$nextTick(() => this.resizeCanvas());
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
// Same-browser dual window: instant cart updates without waiting for SSE.
|
||||
try {
|
||||
this.bc = new BroadcastChannel('pos-customer-display');
|
||||
this.bc.onmessage = (e) => {
|
||||
if (e.data?.type === 'state' && e.data.state) {
|
||||
this.state = e.data.state;
|
||||
}
|
||||
};
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearInterval(this.clockTimer);
|
||||
clearInterval(this.pollTimer);
|
||||
if (this.es) this.es.close();
|
||||
try { this.bc?.close(); } catch (_) {}
|
||||
},
|
||||
|
||||
tickClock() {
|
||||
this.clock = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
isPhase(name) {
|
||||
return (this.state.phase || 'idle') === name;
|
||||
},
|
||||
|
||||
money(minor) {
|
||||
const cur = this.state.currency || 'GHS';
|
||||
const n = (Number(minor) || 0) / 100;
|
||||
return cur + ' ' + n.toFixed(2);
|
||||
},
|
||||
|
||||
grandTotal() {
|
||||
const base = Number(this.state.total_minor) || 0;
|
||||
const tip = Number(this.state.tip?.selected_minor) || 0;
|
||||
return base + tip;
|
||||
},
|
||||
|
||||
tipAmount(pct) {
|
||||
const base = Number(this.state.tip?.base_minor) || Number(this.state.total_minor) || 0;
|
||||
return Math.round(base * (Number(pct) / 100));
|
||||
},
|
||||
|
||||
qrData() {
|
||||
return this.state.payment?.qr_url || this.state.payment?.checkout_url || null;
|
||||
},
|
||||
|
||||
qrImage(data) {
|
||||
if (!data) return '';
|
||||
return 'https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=' + encodeURIComponent(data);
|
||||
},
|
||||
|
||||
connectStream() {
|
||||
if (!this.streamUrl || typeof EventSource === 'undefined') return;
|
||||
try {
|
||||
this.es = new EventSource(this.streamUrl);
|
||||
this.es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data && data.phase) this.state = data;
|
||||
} catch (_) {}
|
||||
};
|
||||
this.es.onerror = () => {
|
||||
this.es?.close();
|
||||
setTimeout(() => this.connectStream(), 2000);
|
||||
};
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async fetchState() {
|
||||
try {
|
||||
const res = await fetch(this.stateUrl, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
this.state = await res.json();
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
async postAction(body) {
|
||||
const res = await fetch(this.actionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.state) this.state = data.state;
|
||||
return data;
|
||||
},
|
||||
|
||||
async selectTipPercent(pct) {
|
||||
const tip_minor = pct > 0 ? this.tipAmount(pct) : 0;
|
||||
await this.postAction({ type: 'tip', tip_percent: pct, tip_minor });
|
||||
},
|
||||
|
||||
async selectCustomTip() {
|
||||
const amount = Math.round(parseFloat(this.customTip || 0) * 100);
|
||||
if (amount < 0) return;
|
||||
await this.postAction({ type: 'tip', tip_percent: null, tip_minor: amount });
|
||||
},
|
||||
|
||||
async choosePayment(opt) {
|
||||
await this.postAction({ type: 'payment_option', payment_option: opt });
|
||||
},
|
||||
|
||||
async sendReceiptEmail() {
|
||||
if (!this.receiptEmail) return;
|
||||
await this.postAction({ type: 'receipt_email', email: this.receiptEmail });
|
||||
this.emailSent = true;
|
||||
},
|
||||
|
||||
resizeCanvas() {
|
||||
const c = this.$refs.sigCanvas;
|
||||
if (!c) return;
|
||||
const rect = c.getBoundingClientRect();
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
c.width = Math.floor(rect.width * ratio);
|
||||
c.height = Math.floor(rect.height * ratio);
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.strokeStyle = '#0f172a';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
},
|
||||
|
||||
sigPoint(e) {
|
||||
const c = this.$refs.sigCanvas;
|
||||
const r = c.getBoundingClientRect();
|
||||
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
||||
},
|
||||
|
||||
sigStart(e) {
|
||||
this.drawing = true;
|
||||
const c = this.$refs.sigCanvas;
|
||||
const ctx = c.getContext('2d');
|
||||
const p = this.sigPoint(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
c.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
|
||||
sigMove(e) {
|
||||
if (!this.drawing) return;
|
||||
const ctx = this.$refs.sigCanvas.getContext('2d');
|
||||
const p = this.sigPoint(e);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
sigEnd() {
|
||||
this.drawing = false;
|
||||
},
|
||||
|
||||
clearSignature() {
|
||||
this.sigSaved = false;
|
||||
this.resizeCanvas();
|
||||
},
|
||||
|
||||
async submitSignature() {
|
||||
const dataUrl = this.$refs.sigCanvas?.toDataURL('image/png');
|
||||
if (!dataUrl) return;
|
||||
await this.postAction({ type: 'signature', signature_data_url: dataUrl });
|
||||
this.sigSaved = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,11 @@
|
||||
'products' => $products,
|
||||
'lookupUrl' => $barcodeLookupUrl,
|
||||
'csrf' => csrf_token(),
|
||||
'customerDisplayUrl' => $customerDisplayUrl,
|
||||
'customerDisplayPushUrl' => $customerDisplayPushUrl,
|
||||
'customerDisplayActionsUrl' => $customerDisplayActionsUrl,
|
||||
'isRestaurant' => $isRestaurant,
|
||||
'currency' => $location->currency,
|
||||
]))">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -24,7 +29,12 @@
|
||||
])>Restaurant</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" @click="openCustomerDisplay()"
|
||||
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
|
||||
title="Open the customer-facing screen on a second monitor">
|
||||
Customer screen
|
||||
</button>
|
||||
<button type="button" @click="tab = 'catalog'" :class="tab === 'catalog' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700 ring-1 ring-slate-200'"
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium">Catalog</button>
|
||||
<button type="button" @click="tab = 'quick'" :class="tab === 'quick' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700 ring-1 ring-slate-200'"
|
||||
@@ -111,11 +121,20 @@
|
||||
<div id="line-fields"></div>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-2">
|
||||
<template x-if="isRestaurant">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button type="button" @click="requestTip()" :disabled="cart.length === 0"
|
||||
class="rounded-xl border border-slate-200 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Ask tip</button>
|
||||
<button type="button" @click="requestSignature()" :disabled="cart.length === 0"
|
||||
class="rounded-xl border border-slate-200 px-3 py-2 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Ask signature</button>
|
||||
</div>
|
||||
</template>
|
||||
<button type="submit" name="payment_method" value="pay" :disabled="cart.length === 0"
|
||||
class="btn-primary w-full disabled:opacity-50">Charge MoMo / card</button>
|
||||
<button type="submit" name="payment_method" value="cash" :disabled="cart.length === 0"
|
||||
class="w-full rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50">Record cash</button>
|
||||
</div>
|
||||
<p x-show="customerActionNote" x-cloak class="mt-3 text-xs text-indigo-700" x-text="customerActionNote"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (_) {}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\PosCustomerDisplay;
|
||||
use App\Models\PosLocation;
|
||||
use App\Models\User;
|
||||
use App\Services\Pos\CustomerDisplayService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CustomerDisplayTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function owner(): User
|
||||
{
|
||||
return User::create([
|
||||
'public_id' => '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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user