Deploy Ladill POS / deploy (push) Successful in 23s
The "links" slice — a guest scans a table's QR, browses the menu (with
modifiers), and submits an order that lands on that table's open tab and fires
straight to the Kitchen Display.
- Public, auth-free flow scoped by an unguessable table short_code:
GET /t/{code} (menu + client cart), POST /t/{code}/order (throttled),
GET /t/{code}/done. Orders open/append the table's dine-in tab, add lines as
source=guest, and send to the kitchen.
- Staff print a per-table QR (Settings → table → QR; renders client-side to the
public menu URL). short_code is generated lazily.
- Guest lines are badged on the ticket and the KDS so staff can tell them apart;
staff still settle the tab as usual (cash / Ladill Pay).
- Extracted PosSaleService::buildProductLine as the single product+modifier
price resolver, now shared by staff and guest ordering (client prices never
trusted).
Schema additive: pos_tables.short_code, pos_sale_lines.source. New
PosRestaurantTest covers the guest order firing to the kitchen; suite green (12).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
10 KiB
PHP
155 lines
10 KiB
PHP
<!DOCTYPE html>
|
||
<html lang="en" class="h-full bg-slate-100">
|
||
<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() }}">
|
||
<meta name="robots" content="noindex">
|
||
<title>{{ $storeName }} · {{ $table->label }}</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'])
|
||
</head>
|
||
<body class="min-h-screen font-sans antialiased">
|
||
<div x-data="posTableOrder(@js([
|
||
'products' => $products,
|
||
'currency' => $currency,
|
||
'orderUrl' => route('pos.table.order', $table->short_code),
|
||
]))" class="mx-auto max-w-lg px-4 pb-28 pt-5">
|
||
|
||
<header class="mb-4">
|
||
<h1 class="text-xl font-bold text-slate-900">{{ $storeName }}</h1>
|
||
<p class="text-sm text-slate-500">{{ $table->label }} · order & we'll bring it over</p>
|
||
</header>
|
||
|
||
<input type="search" x-model="search" placeholder="Search the menu…"
|
||
class="mb-3 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
|
||
<div x-show="categories.length" class="mb-3 flex flex-wrap gap-1.5">
|
||
<button type="button" @click="activeCategory = ''" :class="activeCategory === '' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-600 border border-slate-200'"
|
||
class="rounded-full px-3 py-1 text-xs font-medium">All</button>
|
||
<template x-for="c in categories" :key="c">
|
||
<button type="button" @click="activeCategory = c" :class="activeCategory === c ? 'bg-indigo-600 text-white' : 'bg-white text-slate-600 border border-slate-200'"
|
||
class="rounded-full px-3 py-1 text-xs font-medium" x-text="c"></button>
|
||
</template>
|
||
</div>
|
||
|
||
<p x-show="flash" x-text="flash" x-cloak class="mb-3 rounded-xl border border-red-100 bg-red-50 px-4 py-2 text-sm text-red-700"></p>
|
||
|
||
<div class="space-y-2">
|
||
<template x-for="p in filteredProducts" :key="p.id">
|
||
<button type="button" @click="tap(p)"
|
||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-left transition active:scale-[.99]">
|
||
<span class="min-w-0">
|
||
<span class="block text-sm font-semibold text-slate-900" x-text="p.name"></span>
|
||
<span x-show="p.modifier_groups.length" class="text-[11px] font-medium text-indigo-500">choose options</span>
|
||
</span>
|
||
<span class="shrink-0 text-sm font-semibold text-slate-700" x-text="money(p.price_minor)"></span>
|
||
</button>
|
||
</template>
|
||
<template x-if="filteredProducts.length === 0">
|
||
<p class="py-10 text-center text-sm text-slate-400">Nothing on the menu yet.</p>
|
||
</template>
|
||
</div>
|
||
|
||
{{-- Floating cart bar --}}
|
||
<div x-show="cartCount > 0" x-cloak class="fixed inset-x-0 bottom-0 z-30 border-t border-slate-200 bg-white/95 p-3 backdrop-blur">
|
||
<div class="mx-auto max-w-lg">
|
||
<button type="button" @click="cartOpen = true"
|
||
class="flex w-full items-center justify-between rounded-xl bg-indigo-600 px-4 py-3 text-sm font-semibold text-white">
|
||
<span><span x-text="cartCount"></span> item(s)</span>
|
||
<span>View order · <span x-text="money(cartTotal)"></span></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Modifier picker --}}
|
||
<div x-show="picker.open" x-cloak class="fixed inset-0 z-50 flex items-end justify-center bg-slate-900/40 sm:items-center sm:p-4" @click.self="closePicker()">
|
||
<div class="w-full max-w-md overflow-hidden rounded-t-2xl bg-white shadow-xl sm:rounded-2xl">
|
||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||
<p class="text-sm font-semibold text-slate-900" x-text="picker.product?.name"></p>
|
||
<button @click="closePicker()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100">
|
||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="max-h-[60vh] space-y-4 overflow-y-auto px-5 py-4">
|
||
<template x-for="g in picker.groups" :key="g.id">
|
||
<div>
|
||
<p class="text-xs font-semibold text-slate-600">
|
||
<span x-text="g.name"></span>
|
||
<span class="font-normal text-slate-400" x-text="'(choose ' + g.min + (g.max ? '–' + g.max : '+') + ')'"></span>
|
||
</p>
|
||
<div class="mt-2 flex flex-wrap gap-2">
|
||
<template x-for="m in g.modifiers" :key="m.id">
|
||
<button type="button" @click="toggleModifier(g, m)"
|
||
:class="isSelected(g, m) ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-600'"
|
||
class="rounded-lg border px-3 py-1.5 text-sm font-medium">
|
||
<span x-text="m.name"></span>
|
||
<span x-show="m.price_delta_minor" class="text-xs opacity-70"
|
||
x-text="(m.price_delta_minor > 0 ? ' +' : ' ') + money(m.price_delta_minor).replace(currency + ' ', '')"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div>
|
||
<label class="text-xs font-semibold text-slate-600">Notes</label>
|
||
<input type="text" x-model="picker.notes" maxlength="200" placeholder="e.g. no onions"
|
||
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 border-t border-slate-100 px-5 py-3">
|
||
<span class="text-sm font-semibold text-slate-900" x-text="money(pickerPrice)"></span>
|
||
<button type="button" @click="confirmPicker()" :disabled="! pickerValid" class="btn-primary disabled:opacity-40">Add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Cart sheet --}}
|
||
<div x-show="cartOpen" x-cloak class="fixed inset-0 z-50 flex items-end justify-center bg-slate-900/40 sm:items-center sm:p-4" @click.self="cartOpen = false">
|
||
<div class="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl bg-white shadow-xl sm:rounded-2xl">
|
||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3">
|
||
<p class="text-sm font-semibold text-slate-900">Your order · {{ $table->label }}</p>
|
||
<button @click="cartOpen = false" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100">
|
||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="flex-1 space-y-2 overflow-y-auto px-5 py-4">
|
||
<template x-for="i in cart" :key="i.key">
|
||
<div class="rounded-xl border border-slate-100 p-2.5">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<p class="truncate text-sm font-medium text-slate-900" x-text="i.name"></p>
|
||
<p x-show="i.modifier_names.length" x-cloak class="text-xs text-slate-500" x-text="i.modifier_names.join(', ')"></p>
|
||
<p x-show="i.notes" x-cloak class="text-xs text-rose-600" x-text="i.notes"></p>
|
||
</div>
|
||
<div class="text-right">
|
||
<p class="text-sm font-semibold text-slate-900" x-text="money(i.unit_minor * i.quantity)"></p>
|
||
<div class="mt-1 flex items-center justify-end gap-1.5">
|
||
<button type="button" @click="dec(i)" class="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 text-slate-600">−</button>
|
||
<span class="w-5 text-center text-sm" x-text="i.quantity"></span>
|
||
<button type="button" @click="inc(i)" class="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 text-slate-600">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="border-t border-slate-100 p-4">
|
||
<input type="text" x-model="customerName" placeholder="Your name (optional)"
|
||
class="mb-2 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||
<div class="mb-3 flex items-center justify-between text-base font-semibold text-slate-900">
|
||
<span>Total</span><span x-text="money(cartTotal)"></span>
|
||
</div>
|
||
<button type="button" @click="submit()" :disabled="busy || cart.length === 0" class="btn-primary btn-primary-lg w-full disabled:opacity-40">
|
||
Send order to kitchen
|
||
</button>
|
||
<p class="mt-2 text-center text-[11px] text-slate-400">Pay at the table when you're done.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|