Files
ladill-pos/resources/views/public/table-menu.blade.php
T
isaaccladandClaude Opus 4.8 d4f4821d96
Deploy Ladill POS / deploy (push) Successful in 23s
Restaurant mode Phase 3: table-QR self-ordering into the kitchen
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>
2026-06-24 20:13:28 +00:00

155 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &amp; 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>