From 89c7c5bdfedac750b830ffbbdeca1107033656f8 Mon Sep 17 00:00:00 2001 From: JhiNResH Date: Sat, 18 Jul 2026 10:02:10 +0800 Subject: [PATCH 1/3] feat: add live order queue demo Add buyer-scoped live tracking, browser notifications, protected merchant queues, and a deterministic capacity-aware ETA fix for the Raposa demo. --- docs/sllr-mcp-runbook.md | 17 ++- src/core/merchantApi.ts | 11 +- src/core/orders.ts | 77 ++++++++++++- src/scripts/smoke.ts | 76 +++++++++++-- src/server.ts | 19 +++- src/ui/agenticPos.ts | 35 +++++- src/ui/raposa.ts | 233 +++++++++++++++++++++++++++++++++------ 7 files changed, 409 insertions(+), 59 deletions(-) diff --git a/docs/sllr-mcp-runbook.md b/docs/sllr-mcp-runbook.md index ef0425a..116a474 100644 --- a/docs/sllr-mcp-runbook.md +++ b/docs/sllr-mcp-runbook.md @@ -94,12 +94,25 @@ ordering still works unless the server sets `SLLR_REQUIRE_BUYER_AUTH=true`. - `solana_pay`: prepare Solana Pay URL with a unique reference. - `helio` / `moonpay`: open checkout handoff and verify webhook. -6. Check order status. +6. Check order status from the buyer-scoped surface. ```text - GET /orders/{orderId} + GET /buyer/orders + Authorization: Bearer *** ``` + Each order includes a derived `tracking` snapshot with `queuePosition`, + `ordersAhead`, live estimated wait, promised ready time, and last update. Use + this buyer-scoped endpoint for public clients; `GET /orders/{orderId}` is also + buyer-gated when `SLLR_REQUIRE_BUYER_AUTH=true`. + + The Raposa demo page short-polls this buyer-owned feed every two seconds, + always updates its in-page status, and can emit an optional browser + notification after the user grants permission. It stops polling after a + rejected order or canonical receipt. Merchant boards poll the protected + merchant order feed with merchant authorization; do not expose that feed to + public buyer agents. + 7. Record merchant fulfillment, then issue receipt memory. ```text diff --git a/src/core/merchantApi.ts b/src/core/merchantApi.ts index df14421..4829dfb 100644 --- a/src/core/merchantApi.ts +++ b/src/core/merchantApi.ts @@ -1,4 +1,4 @@ -import { attachPaymentProofMutation, createOrder, fulfillOrderMutation, getOrder, listOrders } from "./orders.js"; +import { attachPaymentProofMutation, createOrder, fulfillOrderMutation, getOrder, listOrders, withLiveOrderTrackingBatch } from "./orders.js"; import { quoteOrder } from "./quote.js"; import { allMerchantProfiles, merchantForId } from "../merchants/profiles.js"; import { recurringSuggestion } from "./recurring.js"; @@ -415,12 +415,13 @@ export async function createMerchantOrder(merchantId: string, payload: Record { export async function listOrdersForBuyer(buyerId: string, limit?: number): Promise { const ids = await sllrStore().indexMembers(buyerOrdersIndex(buyerId)); const boundedIds = limit === undefined ? ids : ids.slice(-Math.max(1, Math.min(limit, 500))); - return (await loadOrdersByIds(boundedIds)).sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + const orders = (await loadOrdersByIds(boundedIds)).sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + return withLiveOrderTrackingBatch(orders); } function addMinutes(date: Date, minutes: number) { @@ -109,7 +110,7 @@ async function activePickupOrders(merchantId: string, productionClass: string) { // Scheduled pickups already occupy their target capacity window and must // not inflate the queue for an order placed right now. && order.promise.scheduledPickup !== true - && !["rejected", "claimed", "fulfilled", "receipt_issued"].includes(order.status) + && !["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(order.status) )); } @@ -117,25 +118,91 @@ async function activePickupOrders(merchantId: string, productionClass: string) { // by quotes and order promises, so a quote can never show "~7 min" while the // created order silently computes 52 (the trust bug the pilot audit caught). // null for non-pickup items. -export async function estimatedPickupWaitMinutes(merchantId: string, item: CatalogItem, quantity = 1): Promise { +export async function estimatedPickupWaitMinutes( + merchantId: string, + item: CatalogItem, + quantity = 1, + now = new Date(), +): Promise { if (!item.fulfillment.includes("pickup")) return null; const productionClass = productionClassFor(item); const activeAhead = (await activePickupOrders(merchantId, productionClass)).length; const capacity = CAPACITY_BY_PRODUCTION_CLASS[productionClass]; const prepMinutes = Math.max(item.prepMinutes || 5, 1); const baseWait = prepMinutes + Math.floor(activeAhead / capacity) * CAPACITY_WINDOW_MINUTES; - const now = new Date(); const desiredReadyAt = addMinutes(now, baseWait); for (let offset = 0; offset < 32; offset += 1) { const probeAt = addMinutes(desiredReadyAt, offset * CAPACITY_WINDOW_MINUTES); const window = await capacityWindowAt(merchantId, productionClass, probeAt); if (window.available >= quantity) { - return baseWait + offset * CAPACITY_WINDOW_MINUTES; + const windowStartWait = Math.ceil((new Date(window.startsAt).getTime() - now.getTime()) / 60_000); + return Math.max(baseWait, windowStartWait); } } return baseWait + 32 * CAPACITY_WINDOW_MINUTES; } +function queueKey(order: SellerOrder) { + return `${order.merchantId}:${order.promise.productionClass}`; +} + +function trackingSnapshot(order: SellerOrder, queue: SellerOrder[]) { + const isPickup = order.promise.productionClass !== "shipping"; + const terminal = ["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(order.status); + if (!isPickup) { + return { + ...order, + tracking: { + live: true, + queuePosition: null, + ordersAhead: null, + estimatedWaitMinutes: null, + promisedReadyAt: order.promise.promisedReadyAt, + updatedAt: order.updatedAt, + }, + }; + } + + const index = terminal ? -1 : queue.findIndex((candidate) => candidate.id === order.id); + const promised = order.promise.promisedReadyAt ? new Date(order.promise.promisedReadyAt).getTime() : Number.NaN; + const remaining = terminal + ? 0 + : Number.isFinite(promised) + ? Math.max(0, Math.ceil((promised - Date.now()) / 60_000)) + : order.promise.estimatedWaitMinutes; + + return { + ...order, + tracking: { + live: true, + queuePosition: index >= 0 ? index + 1 : null, + ordersAhead: index >= 0 ? index : 0, + estimatedWaitMinutes: remaining, + promisedReadyAt: order.promise.promisedReadyAt, + updatedAt: order.updatedAt, + }, + }; +} + +export async function withLiveOrderTrackingBatch(orders: SellerOrder[]) { + const queues = new Map(); + for (const candidate of await allOrders()) { + if ( + candidate.promise.productionClass === "shipping" + || candidate.promise.scheduledPickup === true + || ["rejected", "ready", "claimed", "fulfilled", "receipt_issued"].includes(candidate.status) + ) continue; + const key = queueKey(candidate); + const queue = queues.get(key) || []; + queue.push(candidate); + queues.set(key, queue); + } + for (const queue of queues.values()) { + queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + return orders.map((order) => trackingSnapshot(order, queues.get(queueKey(order)) || [])); +} + async function pickupPromise(merchant: MerchantProfile, item: CatalogItem, input: OrderRequest, now: Date, quantity: number): Promise { if (!item.fulfillment.includes("pickup")) { return { diff --git a/src/scripts/smoke.ts b/src/scripts/smoke.ts index 730f01e..f191f96 100644 --- a/src/scripts/smoke.ts +++ b/src/scripts/smoke.ts @@ -1621,14 +1621,17 @@ async function smokeEtaReconfirm(origin: string) { if (!r.ok) throw new Error(`saturation order ${i} failed: ${r.status}`); } - // Quote is HONEST about the queue now: 2 + 15 = 17 min, not prep-only. + // Quote is HONEST about the queue now: at least the 17-minute queue floor, + // plus at most the alignment to the next 15-minute capacity window. const q1 = await postJson(origin, "/merchants/game-day-boba/quote", fruitTea) as { etaMinutes?: number }; - if (q1.etaMinutes !== 17) throw new Error(`queue-aware quote ETA should be 17, got ${q1.etaMinutes}`); + if (!q1.etaMinutes || q1.etaMinutes < 17 || q1.etaMinutes > 20) { + throw new Error(`queue-aware quote ETA should be 17-20 min, got ${q1.etaMinutes}`); + } const prev = process.env.SLLR_ETA_RECONFIRM; process.env.SLLR_ETA_RECONFIRM = "true"; try { - // Wait (17) exceeds the buyer's 10-min deadline โ†’ 409 reconfirm, no order. + // Wait exceeds the buyer's 10-min deadline โ†’ 409 reconfirm, no order. const blocked = await fetch(`${origin}/merchants/game-day-boba/orders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(fruitTea), }); @@ -1639,8 +1642,9 @@ async function smokeEtaReconfirm(origin: string) { // Buyer re-confirms the longer wait โ†’ order created, promise matches the // same queue-aware formula (no contradiction). const ok = await postJson(origin, "/merchants/game-day-boba/orders", { ...fruitTea, acceptDelay: true }) as { order?: { promise?: { estimatedWaitMinutes?: number } } }; - if (ok.order?.promise?.estimatedWaitMinutes !== 17) { - throw new Error(`reconfirmed order promise should be 17 min, got ${JSON.stringify(ok.order?.promise?.estimatedWaitMinutes)}`); + const orderEta = ok.order?.promise?.estimatedWaitMinutes; + if (!orderEta || orderEta < 17 || orderEta > 20 || Math.abs(orderEta - q1.etaMinutes) > 1) { + throw new Error(`reconfirmed order promise should match the fresh queue ETA, got ${JSON.stringify(orderEta)}`); } // No deadline + no stale quote โ†’ unaffected by the gate. const free = await fetch(`${origin}/merchants/game-day-boba/orders`, { @@ -2584,10 +2588,17 @@ async function smokeBuyerAuth(origin: string) { // GET /buyer/orders with the token lists it. const myOrdersRes = await fetch(`${origin}/buyer/orders`, { headers: { authorization: `Bearer ${token}` } }); - const myOrders = await myOrdersRes.json() as { buyerId?: string; orders?: Array<{ id?: string }> }; - if (!myOrdersRes.ok || myOrders.buyerId !== session.buyerId || !myOrders.orders?.some((o) => o.id === authed.order?.id)) { + const myOrders = await myOrdersRes.json() as { + buyerId?: string; + orders?: Array<{ id?: string; tracking?: { live?: boolean; queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const myTrackedOrder = myOrders.orders?.find((order) => order.id === authed.order?.id); + if (!myOrdersRes.ok || myOrders.buyerId !== session.buyerId || !myTrackedOrder) { throw new Error(`/buyer/orders did not list the authed order: ${JSON.stringify(myOrders)}`); } + if (!myTrackedOrder.tracking?.live || !myTrackedOrder.tracking.queuePosition) { + throw new Error(`/buyer/orders did not include live queue tracking: ${JSON.stringify(myTrackedOrder)}`); + } // /buyer/orders without a token is rejected. const noToken = await fetch(`${origin}/buyer/orders`); @@ -2682,6 +2693,13 @@ async function smokeBuyerAuth(origin: string) { paymentMode: "counter", }) as { order?: { buyerId?: string } }; if (stillOk.order?.buyerId !== session.buyerId) throw new Error(`Authed consented order under require-auth should succeed: ${JSON.stringify(stillOk)}`); + const ownOrderRead = await fetch(`${origin}/orders/${authed.order?.id}`, { headers: { authorization: `Bearer ${token}` } }); + if (ownOrderRead.status !== 200) throw new Error(`Buyer should read own order, got ${ownOrderRead.status}`); + const strangerSession = await postJson(origin, "/buyer/session", { label: "stranger buyer" }) as { token?: string }; + const strangerRead = await fetch(`${origin}/orders/${authed.order?.id}`, { + headers: { authorization: `Bearer ${strangerSession.token}` }, + }); + if (strangerRead.status !== 403) throw new Error(`Buyer A must not read Buyer B order, got ${strangerRead.status}`); } finally { if (prev === undefined) delete process.env.SLLR_REQUIRE_BUYER_AUTH; else process.env.SLLR_REQUIRE_BUYER_AUTH = prev; } @@ -3459,12 +3477,25 @@ async function main() { } const raposaTerminal = await fetch(`${origin}/raposa`).then((response) => response.text()); - if (!raposaTerminal.includes("Raposa Promise Terminal") || !raposaTerminal.includes("/raposa/order")) { + if ( + !raposaTerminal.includes("Raposa Promise Terminal") + || !raposaTerminal.includes("/raposa/order") + || !raposaTerminal.includes("/merchants/\" + merchantId + \"/orders?demo=true") + || !raposaTerminal.includes("Enable notifications") + ) { throw new Error("Raposa terminal page did not render expected staff controls."); } const raposaOrderPage = await fetch(`${origin}/raposa/order`).then((response) => response.text()); - if (!raposaOrderPage.includes("Order from Raposa") || !raposaOrderPage.includes("Ask Raposa for pickup promise")) { + if ( + !raposaOrderPage.includes("Order from Raposa") + || !raposaOrderPage.includes("Ask Raposa for pickup promise") + || !raposaOrderPage.includes("/buyer/session") + || !raposaOrderPage.includes("/buyer/orders") + || !raposaOrderPage.includes("window.sessionStorage") + || !raposaOrderPage.includes("Enable status notifications") + || raposaOrderPage.includes("window.localStorage.setItem(buyerTokenKey") + ) { throw new Error("Raposa customer order page did not render expected order form."); } @@ -3535,10 +3566,24 @@ async function main() { throw new Error(`Pickup order did not include a pickup promise: ${JSON.stringify(pickupOrder)}`); } - const terminalList = await fetch(`${origin}/orders?merchantId=raposa-coffee`).then((response) => response.json()) as { orders?: Array<{ id?: string }> }; - if (!terminalList.orders?.some((order) => order.id === pickupOrder.order?.id)) { + const protectedList = await fetch(`${origin}/merchants/raposa-coffee/orders`); + if (protectedList.status !== 401) { + throw new Error(`Merchant order listing without auth should be 401, got ${protectedList.status}`); + } + const terminalList = await fetch(`${origin}/orders?merchantId=raposa-coffee&demo=true`).then((response) => response.json()) as { + orders?: Array<{ id?: string; tracking?: { live?: boolean; queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const trackedPickup = terminalList.orders?.find((order) => order.id === pickupOrder.order?.id); + if (!trackedPickup) { throw new Error(`Merchant terminal did not list pickup order: ${JSON.stringify(terminalList)}`); } + const trackedMerchantList = await getJson(origin, "/merchants/raposa-coffee/orders?demo=true") as { + orders?: Array<{ id?: string; tracking?: { live?: boolean; queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const trackedMerchantOrder = trackedMerchantList.orders?.find((order) => order.id === pickupOrder.order?.id); + if (!trackedMerchantOrder?.tracking?.live || !trackedMerchantOrder.tracking.queuePosition) { + throw new Error(`Pickup order did not expose live queue tracking: ${JSON.stringify(trackedMerchantOrder)}`); + } const accepted = await postJson(origin, `/orders/${pickupOrder.order.id}/accept`, { merchantId: "raposa-coffee", @@ -3559,6 +3604,13 @@ async function main() { if (ready.status !== "ready" || !ready.order?.promise?.readyAt) { throw new Error(`Merchant ready signal failed: ${JSON.stringify(ready)}`); } + const afterReady = await getJson(origin, "/merchants/raposa-coffee/orders?demo=true") as { + orders?: Array<{ id?: string; tracking?: { queuePosition?: number | null; ordersAhead?: number | null } }>; + }; + const readyTracking = afterReady.orders?.find((order) => order.id === pickupOrder.order?.id)?.tracking; + if (!readyTracking || readyTracking.queuePosition !== null || readyTracking.ordersAhead !== 0) { + throw new Error(`Ready order should leave the production queue: ${JSON.stringify(readyTracking)}`); + } const claimed = await postJson(origin, `/orders/${pickupOrder.order.id}/claim`, { merchantId: "raposa-coffee", @@ -3641,7 +3693,7 @@ async function main() { throw new Error(`Merchant-scoped SOLYD order was not created: ${JSON.stringify(merchantOrder)}`); } - const merchantOrders = await getJson(origin, "/merchants/solyd/orders") as { orders?: Array<{ id?: string }> }; + const merchantOrders = await getJson(origin, "/merchants/solyd/orders?demo=true") as { orders?: Array<{ id?: string }> }; if (!merchantOrders.orders?.some((order) => order.id === merchantOrder.order?.id)) { throw new Error(`Merchant-scoped orders did not include SOLYD order: ${JSON.stringify(merchantOrders)}`); } diff --git a/src/server.ts b/src/server.ts index 89165e6..893c377 100644 --- a/src/server.ts +++ b/src/server.ts @@ -681,6 +681,11 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv return json(response, 201, await createMerchantOrder(merchantId, await bindBuyer(request, await body(request)))); } if (request.method === "GET" && action === "orders") { + await requireMerchantAuth( + request.headers, + { demo: url.searchParams.get("demo") === "true" }, + merchantId, + ); return json(response, 200, await listMerchantOrders(merchantId, url.searchParams.get("status"))); } if (request.method === "POST" && action === "payment") { @@ -836,10 +841,17 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv }); } if (request.method === "GET" && url.pathname === "/orders") { + const merchantId = url.searchParams.get("merchantId") || ""; + if (!merchantId) return json(response, 400, { error: "merchantId is required for merchant order listing." }); + await requireMerchantAuth( + request.headers, + { demo: url.searchParams.get("demo") === "true" }, + merchantId, + ); return json(response, 200, { product: "SLL-R merchant terminal", orders: await listOrders({ - merchantId: url.searchParams.get("merchantId") || undefined, + merchantId, status: url.searchParams.get("status") as never || undefined, }), }); @@ -870,6 +882,11 @@ export async function handleSllrRequest(request: IncomingMessage, response: Serv if (wantsHtml) { return html(response, order ? 200 : 404, orderLandingPage(order, url.searchParams)); } + if (order?.buyerId && process.env.SLLR_REQUIRE_BUYER_AUTH === "true") { + const session = await resolveBuyer(buyerTokenFrom(request.headers), new Date().toISOString()); + if (!session) return json(response, 401, { error: "Buyer authentication is required to read this order." }); + if (session.buyerId !== order.buyerId) return json(response, 403, { error: "This order belongs to another buyer." }); + } return json(response, order ? 200 : 404, order ? { product: "SLL-R merchant terminal", order } : { error: `Unknown order: ${orderId}` }); } if (request.method === "POST" && action === "review") { diff --git a/src/ui/agenticPos.ts b/src/ui/agenticPos.ts index 3d56e65..fb600b6 100644 --- a/src/ui/agenticPos.ts +++ b/src/ui/agenticPos.ts @@ -284,6 +284,8 @@ export function merchantTerminalPage(merchantId: string, origin: string) {
Customer agent + + connecting
@@ -326,6 +328,8 @@ const countEl = document.getElementById("count"); const refreshButton = document.getElementById("refresh"); const staffKeyButton = document.getElementById("staffKeyBtn"); const keyStatusEl = document.getElementById("keyStatus"); +const notifyStaffButton = document.getElementById("notifyStaff"); +const liveConnectionEl = document.getElementById("liveConnection"); // One-time ?staffKey=... link saves the staff key to this browser then strips // it from the URL; staff can also set/update it via the ๐Ÿ”‘ button. The key is @@ -399,6 +403,7 @@ function renderOrder(order) { const canClaim = order.status === "ready"; const canFulfill = order.status === "accepted" || order.status === "payment_backed" || order.status === "pending_payment"; const promise = order.promise || {}; + const tracking = order.tracking || {}; return \`
@@ -418,6 +423,7 @@ function renderOrder(order) { Wait: \${escapeText(promise.estimatedWaitMinutes ?? "n/a")} min Promised: \${escapeText(timeText(promise.promisedReadyAt))} Ready: \${escapeText(timeText(promise.readyAt))} + \${tracking.queuePosition ? \`Queue #\${escapeText(tracking.queuePosition)} ยท \${escapeText(tracking.ordersAhead)} ahead\` : ""}
\${order.receipt ? \`
Receipt: \${escapeText(order.receipt.receiptHash)}\\nClaim: \${escapeText(order.receipt.claimUrl)}
\` : ""}
@@ -444,11 +450,29 @@ function beep() { } async function loadOrders() { - const response = await fetch("/orders?merchantId=" + encodeURIComponent(merchantId)); + const staffSecret = window.localStorage.getItem("sllrStaffSecret"); + const response = await fetch("/merchants/" + encodeURIComponent(merchantId) + "/orders?demo=true", { + headers: staffSecret ? { "x-sllr-merchant-payment-secret": staffSecret } : {} + }); + if (!response.ok) { + liveConnectionEl.textContent = "auth required"; + if (response.status === 401) promptStaffKey(); + return; + } const json = await response.json(); const orders = json.orders || []; - if (prevCount !== null && orders.length > prevCount) beep(); // new order arrived + if (prevCount !== null && orders.length > prevCount) { + beep(); + if ("Notification" in window && Notification.permission === "granted") { + const newest = orders[0]; + new Notification("New SLL-R order", { + body: newest.item.name + " ยท " + newest.id.slice(-6), + tag: "sllr-merchant-" + newest.id + }); + } + } prevCount = orders.length; + liveConnectionEl.textContent = "live ยท 2s"; countEl.textContent = orders.length + (orders.length === 1 ? " order" : " orders"); ordersEl.innerHTML = orders.length ? orders.map(renderOrder).join("") : '
No orders yet. Open the customer agent page to create one.
'; } @@ -483,8 +507,13 @@ async function toggle86(itemId, btn) { } refreshButton.addEventListener("click", loadOrders); +notifyStaffButton.addEventListener("click", async () => { + if (!("Notification" in window)) return; + const permission = await Notification.requestPermission(); + notifyStaffButton.textContent = permission === "granted" ? "Notifications enabled" : "Notifications unavailable"; +}); loadOrders(); loadAvailability(); -setInterval(loadOrders, 5000); +setInterval(loadOrders, 2000); `); } diff --git a/src/ui/raposa.ts b/src/ui/raposa.ts index 5e4d132..6969e00 100644 --- a/src/ui/raposa.ts +++ b/src/ui/raposa.ts @@ -163,6 +163,8 @@ export function raposaTerminalPage(origin: string) {
Customer order page + + connecting
@@ -186,7 +188,7 @@ export function raposaTerminalPage(origin: string) {

Customer QR URL

${origin}/raposa/order

API queue

-
${origin}/orders?merchantId=raposa-coffee
+
${origin}/merchants/raposa-coffee/orders

Proof level

pickup_promise + ready_signal + customer_claim
@@ -199,6 +201,9 @@ const countEl = document.getElementById("count"); const refreshButton = document.getElementById("refresh"); const staffKeyButton = document.getElementById("staffKeyBtn"); const keyStatusEl = document.getElementById("keyStatus"); +const notifyStaffButton = document.getElementById("notifyStaff"); +const liveConnectionEl = document.getElementById("liveConnection"); +let knownOrderIds = null; // Staff key bootstrap: a one-time ?staffKey=... link saves the key to this // browser and is then stripped from the URL so it is not left in history. @@ -272,6 +277,7 @@ function renderOrder(order) { const canReady = order.status === "accepted" || order.status === "payment_backed"; const canClaim = order.status === "ready"; const promise = order.promise || {}; + const tracking = order.tracking || {}; return \`
@@ -291,6 +297,7 @@ function renderOrder(order) { Est. wait: \${escapeText(promise.estimatedWaitMinutes ?? "n/a")} min Promised: \${escapeText(timeText(promise.promisedReadyAt))} Ready: \${escapeText(timeText(promise.readyAt))} + \${tracking.queuePosition ? \`Queue #\${escapeText(tracking.queuePosition)} ยท \${escapeText(tracking.ordersAhead)} ahead\` : ""} \${promise.delayMinutes ? \`Delay: \${escapeText(promise.delayMinutes)} min\` : ""}
\${order.receipt ? \`
Receipt: \${escapeText(order.receipt.receiptHash)}\\nClaim: \${escapeText(order.receipt.claimUrl)}
\` : ""} @@ -307,9 +314,29 @@ function renderOrder(order) { async function loadOrders() { refreshButton.disabled = true; try { - const response = await fetch("/orders?merchantId=" + merchantId); + const staffSecret = window.localStorage.getItem("sllrStaffSecret"); + const response = await fetch("/merchants/" + merchantId + "/orders?demo=true", { + headers: staffSecret ? { "x-sllr-merchant-payment-secret": staffSecret } : {} + }); + if (!response.ok) { + liveConnectionEl.textContent = "auth required"; + if (response.status === 401) promptStaffKey(); + return; + } const payload = await response.json(); const orders = payload.orders || []; + if (knownOrderIds) { + orders.filter((order) => !knownOrderIds.has(order.id)).forEach((order) => { + if ("Notification" in window && Notification.permission === "granted") { + new Notification("New SLL-R order", { + body: order.item.name + " ยท " + order.id.slice(-6), + tag: "sllr-merchant-" + order.id + }); + } + }); + } + knownOrderIds = new Set(orders.map((order) => order.id)); + liveConnectionEl.textContent = "live ยท 2s"; countEl.textContent = orders.length + (orders.length === 1 ? " order" : " orders"); ordersEl.innerHTML = orders.length ? orders.map(renderOrder).join("") @@ -320,8 +347,13 @@ async function loadOrders() { } refreshButton.addEventListener("click", loadOrders); +notifyStaffButton.addEventListener("click", async () => { + if (!("Notification" in window)) return; + const permission = await Notification.requestPermission(); + notifyStaffButton.textContent = permission === "granted" ? "Notifications enabled" : "Notifications unavailable"; +}); loadOrders(); -setInterval(loadOrders, 5000); +setInterval(loadOrders, 2000); `); } @@ -355,8 +387,12 @@ export function raposaOrderPage() { I can pick up in this many minutes - +
+ + +
+
Live tracking starts after an order is confirmed.