diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 62c2681d9..fa1891ff0 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -463,7 +463,11 @@ against `en.json`, the derived print-label table, and a `tsc` pass over the fron orders in a Preparing column (`pending`, `preparing`) and a Ready column (`ready`). It re-reads `GET /settings/customer-display` on every poll, so changes made in Settings > Kitchen Display apply without reloading the board. The tenant-wide config sets which statuses show, the refresh interval, -and whether the last-updated time is shown. The same settings card shows the display URL with copy +and whether the last-updated time is shown. At desktop widths, each column renders complete bill or +order numbers in readable rows and independently rotates measured pages every ten seconds when +needed. Newly ready orders take priority on the next rotation, interleaved with existing pages. At +narrow widths the rows remain in one scrollable flow. A failed poll retains the last successful +orders and reports that the feed is stale. The same settings card shows the display URL with copy and open actions. Strings come from the `customerDisplay` message namespace. An owner or manager can also pair a standalone device (a TV or tablet browser) from the same card. @@ -475,7 +479,7 @@ offers only the local preview; if address lookup fails, it shows an error and re moves the token from the URL fragment into `localStorage` and polls `GET /api/customer-display/feed` with it instead of using a staff session, so it needs no login and can read nothing but order numbers and statuses. When the feed answers `401` (the token was -revoked), the board clears the stored token and shows an unpaired screen. +revoked), the board clears the stored token and orders, then shows an unpaired screen. ## Tech stack diff --git a/frontend/e2e/customer-display-pairing-addresses.spec.ts b/frontend/e2e/customer-display-pairing-addresses.spec.ts index 12fe90846..51c98561d 100644 --- a/frontend/e2e/customer-display-pairing-addresses.spec.ts +++ b/frontend/e2e/customer-display-pairing-addresses.spec.ts @@ -144,10 +144,20 @@ test('customer display pairing uses the selected POS address and reuses its toke }); await devicePage.goto(pairingUrl); await expect(devicePage.getByText('E2E Cafe', { exact: true })).toBeVisible(); - await expect(devicePage.getByText('A-42', { exact: true })).toBeVisible(); + await expect( + devicePage + .getByTestId('customer-display-ready-scroll') + .getByText('A-42', { exact: true }) + .filter({ visible: true }), + ).toBeVisible(); await expect(devicePage).toHaveURL(`${localOrigin}/customer-display/`); await devicePage.reload(); - await expect(devicePage.getByText('A-42', { exact: true })).toBeVisible(); + await expect( + devicePage + .getByTestId('customer-display-ready-scroll') + .getByText('A-42', { exact: true }) + .filter({ visible: true }), + ).toBeVisible(); expect(feedRequests).toBeGreaterThanOrEqual(2); revoked = true; await expect( diff --git a/frontend/e2e/layout-integrity.spec.ts b/frontend/e2e/layout-integrity.spec.ts index e46f3ac3c..8b474e01c 100644 --- a/frontend/e2e/layout-integrity.spec.ts +++ b/frontend/e2e/layout-integrity.spec.ts @@ -1560,9 +1560,8 @@ test.describe('layout fixtures only delete what they created', () => { /** * The customer-facing order display polls /api/orders every three seconds. The - * specs below serve that one endpoint from an in-memory list so the number of - * tiles per section is exact and can change between polls; the rest of the - * page keeps its real auth, tenant and theme wiring. + * specs below serve a controlled in-memory list while retaining the real auth, + * tenant and theme wiring. */ interface DisplayOrder { id: string; @@ -1598,146 +1597,598 @@ function displayOrders(preparingCount: number, readyCount: number): DisplayOrder async function mockDisplayOrders( page: Page, orders: DisplayOrder[], -): Promise<{ replace: (next: DisplayOrder[]) => void }> { +): Promise<{ replace: (next: DisplayOrder[]) => void; setUnavailable: (value: boolean) => void }> { let current = orders; + let unavailable = false; await page.route( - (url) => url.pathname === '/api/orders', + (url) => url.pathname === '/api/settings/customer-display', (route) => route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ orders: current }), + body: JSON.stringify({ + statuses: ['pending', 'preparing', 'ready'], + refresh_seconds: 3, + show_updated: true, + }), }), ); + await page.route( + (url) => url.pathname === '/api/orders', + (route) => { + if (unavailable) { + return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ orders: current }), + }); + }, + ); return { replace: (next) => { current = next; }, + setUnavailable: (value) => { + unavailable = value; + }, }; } -test.describe('customer display keeps its headers visible over independently scrolling tile lists', () => { - test('the header and both section headings stay visible while the tile lists scroll', async ({ +test.describe('customer display wraps full receipt numbers and pages on TV', () => { + test('the board fits readable rows at 720p, 1080p and 4K without clipping identifiers', async ({ page, }) => { - await page.setViewportSize({ width: 1440, height: 900 }); + await page.setViewportSize({ width: 1280, height: 720 }); await loginAsManager(page); - await mockDisplayOrders(page, displayOrders(24, 30)); + await page.clock.install(); + const orders = displayOrders(100, 100); + const longReceiptNumber = + 'INV-2026-NORTH-REGION-COUNTER-04-VERY-LONG-RECEIPT-00000123456789012345678901234567890'; + orders[0].bill.bill_number = longReceiptNumber; + orders[1].bill.bill_number = ''; + await mockDisplayOrders(page, orders); await page.goto(`${BASE}/customer-display`); const preparing = page.getByTestId('customer-display-preparing-scroll'); const ready = page.getByTestId('customer-display-ready-scroll'); + const receiptRow = preparing.locator('[data-order-id="display-preparing-1"]:visible'); + await receiptRow.waitFor(); + await page.clock.pauseAt(new Date(Date.now() + 1000)); + await expect(receiptRow.locator('span')).toHaveText(longReceiptNumber); await expect( - preparing.getByText('10024', { exact: true }), - 'the preparing tiles render', - ).toBeVisible(); - await expect(ready.getByText('20030', { exact: true }), 'the ready tiles render').toBeVisible(); + preparing.locator('[data-order-id="display-preparing-2"]:visible'), + 'an order without a bill number uses its full order-number fallback', + ).toHaveText('P2'); + + for (const viewport of [ + { width: 1280, height: 720, screenshot: '/tmp/flo-customer-display-720p.png' }, + { width: 1920, height: 1080, screenshot: '/tmp/flo-customer-display-1080p.png' }, + { width: 3840, height: 2160, screenshot: '/tmp/flo-customer-display-4k.png' }, + ]) { + await page.setViewportSize(viewport); + await expect(receiptRow.locator('span')).toHaveText(longReceiptNumber); + await page.waitForFunction(() => { + return ['customer-display-preparing-scroll', 'customer-display-ready-scroll'].every( + (testId) => { + const board = document.querySelector(`[data-testid="${testId}"]`); + if (!board) return false; + const visibleRows = [ + ...board.querySelectorAll('[data-testid="customer-display-order-row"]'), + ].filter((row) => getComputedStyle(row).visibility === 'visible'); + return visibleRows.length > 0 && visibleRows.length < 100; + }, + ); + }); + + for (const [name, board] of [ + ['preparing', preparing], + ['ready', ready], + ] as const) { + const layout = await board.evaluate((element) => { + const boardBounds = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const usableTop = boardBounds.top + Number.parseFloat(style.paddingTop); + const usableBottom = boardBounds.bottom - Number.parseFloat(style.paddingBottom); + const visibleRows = [ + ...element.querySelectorAll('[data-testid="customer-display-order-row"]'), + ] + .filter((row) => getComputedStyle(row).visibility === 'visible') + .map((row) => { + const bounds = row.getBoundingClientRect(); + return { top: bounds.top, bottom: bounds.bottom }; + }); + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + usableTop, + usableBottom, + visibleRows, + }; + }); + expect( + layout.scrollWidth, + `${name} TV board has no horizontal overflow`, + ).toBeLessThanOrEqual(layout.clientWidth); + expect(layout.visibleRows.length, `${name} board shows a measured page`).toBeGreaterThan(0); + expect(layout.visibleRows.length).toBeLessThan(100); + expect( + layout.visibleRows.every( + (row) => row.top >= layout.usableTop - 1 && row.bottom <= layout.usableBottom + 1, + ), + `${name} TV rows fit vertically inside the usable column area`, + ).toBe(true); + } + const documentWidths = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect( + documentWidths.scrollWidth, + 'the document has no horizontal overflow', + ).toBeLessThanOrEqual(documentWidths.clientWidth); + await page.screenshot({ path: viewport.screenshot }); + } + + const wrappedRow = await receiptRow.boundingBox(); + expect(wrappedRow, 'the long receipt row has bounds').not.toBeNull(); + expect(wrappedRow!.height, 'the full receipt number wraps onto multiple lines').toBeGreaterThan( + 72, + ); + await expect(page.locator('header'), 'the business header stays visible').toBeVisible(); + }); + + test('polling keeps the ten-second page interval and alternates new ready orders with older pages', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loginAsManager(page); + await page.clock.install(); + await page.clock.pauseAt(new Date(Date.now() + 1000)); + const initialOrders = displayOrders(20, 50); + const feed = await mockDisplayOrders(page, initialOrders); + const originalReadyOrderIds = new Set( + initialOrders.filter((order) => order.status === 'ready').map((order) => order.id), + ); + await page.goto(`${BASE}/customer-display`); + await page.clock.runFor(1); + await expect(page.locator('header')).toBeVisible(); + await page.clock.runFor(1); - const header = page.locator('header'); - const preparingHeading = page.getByRole('heading', { name: 'PREPARING' }); - const readyHeading = page.getByRole('heading', { name: 'READY FOR PICKUP' }); + const ready = page.getByTestId('customer-display-ready-scroll'); + const readReadyIds = () => + ready + .locator('[data-order-id^="display-ready-"]:visible') + .evaluateAll((rows) => rows.map((row) => row.getAttribute('data-order-id'))); + await expect( + ready.locator('[data-order-id="display-ready-1"]:visible'), + 'the first ordinary ready page is initially shown', + ).toBeVisible(); + const firstOrdinaryPage = await readReadyIds(); + expect( + firstOrdinaryPage.length, + 'the ready column starts with one of several pages', + ).toBeLessThan(50); const pinned = { - header: await elementTop(header, 'display header'), - preparingHeading: await elementTop(preparingHeading, 'preparing heading'), - readyHeading: await elementTop(readyHeading, 'ready heading'), + header: await elementTop(page.locator('header'), 'display header'), + heading: await elementTop( + page.getByRole('heading', { name: 'READY FOR PICKUP' }), + 'ready heading', + ), }; + const updatedAt = page.getByText(/^Updated /); + await expect(updatedAt).toBeVisible(); + const initialUpdatedText = (await updatedAt.textContent()) ?? ''; + await page.clock.runFor(1); + const updatedFeed = page.waitForResponse( + (response) => new URL(response.url()).pathname === '/api/orders' && response.status() === 200, + ); - const preparingScrolled = await scrollToEnd(preparing); + const newReadyOrders = [ + { + id: 'display-ready-new-1', + order_number: 'NEW-1', + bill: { bill_number: 'PRIORITY-NEW-1' }, + status: 'ready' as const, + created_at: new Date(Date.UTC(2026, 0, 3, 0, 0, 1)).toISOString(), + }, + { + id: 'display-ready-new-2', + order_number: 'NEW-2', + bill: { bill_number: 'PRIORITY-NEW-2' }, + status: 'ready' as const, + created_at: new Date(Date.UTC(2026, 0, 3, 0, 0, 2)).toISOString(), + }, + ]; + feed.replace([...displayOrders(20, 50), newReadyOrders[0]]); + await page.clock.runFor(3000); + await updatedFeed; + await page.clock.runFor(0); expect( - preparingScrolled.scrollTop, - 'the preparing tile list owns its own scroll', - ).toBeGreaterThan(0); - expectPinned(await elementTop(header, 'display header'), pinned.header, 'display header'); + await updatedAt.textContent(), + 'the changed feed is accepted before the current page expires', + ).not.toBe(initialUpdatedText); + expect( + await ready.locator('[data-order-id="display-ready-new-1"]:visible').count(), + 'a newly ready order waits for the current page interval', + ).toBe(0); + await page.clock.runFor(6900); + expect( + await ready.locator('[data-order-id="display-ready-new-1"]:visible').count(), + 'the current page remains visible until its interval expires', + ).toBe(0); + + await page.clock.runFor(300); + await expect( + ready.locator('[data-order-id="display-ready-new-1"]:visible'), + 'the first rotation prioritizes newly ready orders', + ).toBeVisible(); expectPinned( - await elementTop(preparingHeading, 'preparing heading'), - pinned.preparingHeading, - 'preparing heading', + await elementTop(page.locator('header'), 'display header'), + pinned.header, + 'display header', ); expectPinned( - await elementTop(readyHeading, 'ready heading'), - pinned.readyHeading, + await elementTop(page.getByRole('heading', { name: 'READY FOR PICKUP' }), 'ready heading'), + pinned.heading, 'ready heading', ); - expect( - await ready.evaluate((element) => element.scrollTop), - 'scrolling the preparing tiles leaves the ready tiles in place', - ).toBe(0); - const preparingBox = await preparing.boundingBox(); - const lastPreparing = await preparing.getByText('10024', { exact: true }).boundingBox(); - expect(preparingBox).not.toBeNull(); - expect(lastPreparing, 'the last preparing tile has bounds').not.toBeNull(); + const nextFeedUpdate = page.waitForResponse( + (response) => new URL(response.url()).pathname === '/api/orders' && response.status() === 200, + ); + feed.replace([...displayOrders(20, 50), ...newReadyOrders]); + await page.clock.runFor(3000); + await nextFeedUpdate; + await page.clock.runFor(0); + await page.clock.runFor(7000); + await expect.poll(readReadyIds).not.toEqual(firstOrdinaryPage); + const secondOrdinaryPage = await readReadyIds(); expect( - lastPreparing!.y + lastPreparing!.height, - 'the last preparing tile is reachable', - ).toBeLessThanOrEqual(preparingBox!.y + preparingBox!.height + 1); - - const readyScrolled = await scrollToEnd(ready); - expect(readyScrolled.scrollTop, 'the ready tile list owns its own scroll').toBeGreaterThan(0); + secondOrdinaryPage.length, + 'the next ordinary page is nonempty during arrivals', + ).toBeGreaterThan(0); expect( - await preparing.evaluate((element) => element.scrollTop), - 'scrolling the ready tiles leaves the preparing tiles in place', - ).toBe(preparingScrolled.scrollTop); - expectPinned(await elementTop(header, 'display header'), pinned.header, 'display header'); + secondOrdinaryPage.every((id) => !!id && originalReadyOrderIds.has(id)), + 'the next rotation returns to older eligible orders during arrivals', + ).toBe(true); + expectPinned( + await elementTop(page.locator('header'), 'display header'), + pinned.header, + 'display header', + ); expectPinned( - await elementTop(readyHeading, 'ready heading'), - pinned.readyHeading, + await elementTop(page.getByRole('heading', { name: 'READY FOR PICKUP' }), 'ready heading'), + pinned.heading, 'ready heading', ); - const readyBox = await ready.boundingBox(); - const lastReady = await ready.getByText('20030', { exact: true }).boundingBox(); - expect(readyBox).not.toBeNull(); - expect(lastReady, 'the last ready tile has bounds').not.toBeNull(); + await page.clock.runFor(20_000); + const laterOrdinaryPage = await readReadyIds(); + expect( + laterOrdinaryPage.length, + 'continued arrivals keep a nonempty ordinary page in rotation', + ).toBeGreaterThan(0); expect( - lastReady!.y + lastReady!.height, - 'the last ready tile is reachable', - ).toBeLessThanOrEqual(readyBox!.y + readyBox!.height + 1); + laterOrdinaryPage.every((id) => !!id && originalReadyOrderIds.has(id)), + 'continued arrivals keep older eligible orders in the rotation', + ).toBe(true); }); - test('the polling refresh keeps the scrolled tile list where the guest left it', async ({ - page, - }) => { + for (const feedKind of ['paired', 'signed-in'] as const) { + test(`a stalled ${feedKind} feed reports stale data and resumes polling`, async ({ + page, + browser, + request, + }) => { + const isPaired = feedKind === 'paired'; + const feedPath = isPaired ? '/api/customer-display/feed' : '/api/orders'; + let routePage = page; + let displayContext: Awaited> | undefined; + let displayTokenId: string | undefined; + let displayToken: string | undefined; + let requestCount = 0; + let releaseHeldResponse!: () => void; + let markHeldRequestStarted!: () => void; + let markHeldRequestFinished!: () => void; + const heldResponse = new Promise((resolve) => { + releaseHeldResponse = resolve; + }); + const heldRequestStarted = new Promise((resolve) => { + markHeldRequestStarted = resolve; + }); + const heldRequestFinished = new Promise((resolve) => { + markHeldRequestFinished = resolve; + }); + const initialOrders = displayOrders(1, 1); + const recoveredOrders = displayOrders(2, 1); + const responseBody = (orders: DisplayOrder[]) => + isPaired + ? { + business_name: 'Flo Cafe', + config: { + statuses: ['pending', 'preparing', 'ready'], + refresh_seconds: 3, + show_updated: true, + }, + orders: orders.map((order) => ({ + id: order.id, + number: order.bill.bill_number || order.order_number, + status: order.status, + })), + } + : { orders }; + + try { + if (isPaired) { + const created = await request.post(`${BASE}/api/settings/customer-display/tokens`, { + headers: { Authorization: `Bearer ${getE2eToken()}` }, + data: { label: 'Stalled feed test' }, + }); + expect(created.status(), 'the test display token is created').toBe(201); + const createdToken = (await created.json()) as { id: string; token: string }; + displayTokenId = createdToken.id; + displayToken = createdToken.token; + displayContext = await browser.newContext(); + routePage = await displayContext.newPage(); + } else { + await loginAsManager(routePage); + await routePage.route( + (url) => url.pathname === '/api/settings/customer-display', + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + statuses: ['pending', 'preparing', 'ready'], + refresh_seconds: 3, + show_updated: true, + }), + }), + ); + } + await routePage.setViewportSize({ width: 1440, height: 900 }); + await routePage.route( + (url) => url.pathname === feedPath, + async (route) => { + requestCount += 1; + if (requestCount === 2) { + markHeldRequestStarted(); + await heldResponse; + try { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(responseBody(initialOrders)), + }); + } catch { + // The browser aborts this deliberately held request when its deadline expires. + } + markHeldRequestFinished(); + return; + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + responseBody(requestCount === 1 ? initialOrders : recoveredOrders), + ), + }); + }, + ); + + await routePage.goto( + isPaired ? `${BASE}/customer-display/#token=${displayToken}` : `${BASE}/customer-display`, + ); + const preparing = routePage.getByTestId('customer-display-preparing-scroll'); + const firstOrder = preparing.locator('[data-order-id="display-preparing-1"]:visible'); + await expect(firstOrder, 'the first successful feed populates the board').toBeVisible(); + + await routePage.waitForTimeout(3000); + await heldRequestStarted; + await routePage.waitForTimeout(10_000); + const staleMessage = routePage.getByText( + 'Connection lost. Showing the last available orders.', + ); + await expect(staleMessage, 'the stalled feed is reported as stale data').toBeVisible(); + await expect(firstOrder, 'the last successful order stays visible').toBeVisible(); + + const recoveredResponse = routePage.waitForResponse( + (response) => new URL(response.url()).pathname === feedPath && response.status() === 200, + ); + await routePage.waitForTimeout(2500); + await recoveredResponse; + await expect( + preparing.locator('[data-order-id="display-preparing-2"]:visible'), + 'a later poll recovers after the stalled request is aborted', + ).toBeVisible(); + await expect(staleMessage, 'the successful recovery clears the stale warning').toHaveCount( + 0, + ); + } finally { + releaseHeldResponse(); + if (requestCount === 2) await heldRequestFinished; + await displayContext?.close(); + if (displayTokenId) { + await request.delete(`${BASE}/api/settings/customer-display/tokens/${displayTokenId}`, { + headers: { Authorization: `Bearer ${getE2eToken()}` }, + }); + } + } + }); + } + + test('each column pages independently when only one has multiple pages', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await loginAsManager(page); - const feed = await mockDisplayOrders(page, displayOrders(20, 4)); + await page.clock.install(); + const feed = await mockDisplayOrders(page, displayOrders(30, 2)); await page.goto(`${BASE}/customer-display`); const preparing = page.getByTestId('customer-display-preparing-scroll'); + const ready = page.getByTestId('customer-display-ready-scroll'); + const readIds = (board: typeof preparing) => + board + .locator('[data-order-id]:visible') + .evaluateAll((rows) => rows.map((row) => row.getAttribute('data-order-id'))); await expect( - preparing.getByText('10020', { exact: true }), - 'the preparing tiles render', + preparing.locator('[data-order-id^="display-preparing-"]:visible').first(), ).toBeVisible(); - const scrolled = await scrollToEnd(preparing); - expect(scrolled.scrollTop, 'the preparing tile list is scrolled').toBeGreaterThan(0); - const headerBefore = await elementTop(page.locator('header'), 'display header'); + await expect(ready.locator('[data-order-id^="display-ready-"]:visible').first()).toBeVisible(); + const firstPreparingPage = await readIds(preparing); + const readyPage = await readIds(ready); + expect(firstPreparingPage.length, 'preparing has multiple pages').toBeLessThan(30); + expect(readyPage).toEqual(['display-ready-1', 'display-ready-2']); + + await page.clock.runFor(10_000); + await expect.poll(() => readIds(preparing)).not.toEqual(firstPreparingPage); + expect(await readIds(ready), 'the single ready page does not rotate').toEqual(readyPage); + + feed.replace(displayOrders(30, 3)); + await page.clock.runFor(3000); + await expect + .poll(() => readIds(ready), { + message: 'a newly ready order that fits on the single ready page is shown on poll', + }) + .toEqual(['display-ready-1', 'display-ready-2', 'display-ready-3']); + }); - // The next poll adds one preparing tile and one ready tile. - feed.replace(displayOrders(21, 5)); - await expect( - preparing.getByText('10021', { exact: true }), - 'the poll renders the new order', - ).toBeVisible({ timeout: 15000 }); - await expect( - page.getByTestId('customer-display-ready-scroll').getByText('20005', { exact: true }), - 'the poll renders the new ready order', - ).toBeVisible(); + test('rotation pauses while the page is hidden and resumes on a fresh ten-second interval', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loginAsManager(page); + await page.clock.install(); + await mockDisplayOrders(page, displayOrders(30, 0)); + await page.goto(`${BASE}/customer-display`); - const after = await preparing.evaluate((element) => element.scrollTop); - expect( - Math.abs(after - scrolled.scrollTop), - 'the refresh does not jump the scrolled list', - ).toBeLessThanOrEqual(1); - expectPinned( - await elementTop(page.locator('header'), 'display header'), - headerBefore, - 'display header', + const preparing = page.getByTestId('customer-display-preparing-scroll'); + const readIds = () => + preparing + .locator('[data-order-id]:visible') + .evaluateAll((rows) => rows.map((row) => row.getAttribute('data-order-id'))); + await expect(preparing.locator('[data-order-id="display-preparing-1"]:visible')).toBeVisible(); + await page.clock.pauseAt(new Date(Date.now() + 1000)); + const currentPage = await readIds(); + expect(currentPage.length).toBeGreaterThan(0); + + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.clock.runFor(0); + await page.clock.runFor(20_000); + expect(await readIds(), 'hidden time does not advance the board').toEqual(currentPage); + + await page.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.clock.runFor(0); + await page.clock.runFor(9500); + expect(await readIds(), 'resuming starts a fresh page interval').toEqual(currentPage); + await page.clock.runFor(700); + await expect.poll(readIds).not.toEqual(currentPage); + }); + + test('orders removed from the eligible feed leave the board on the next poll', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loginAsManager(page); + await page.clock.install(); + const feed = await mockDisplayOrders(page, displayOrders(4, 8)); + await page.goto(`${BASE}/customer-display`); + + const ready = page.getByTestId('customer-display-ready-scroll'); + await expect(ready.locator('[data-order-id="display-ready-1"]:visible')).toBeVisible(); + feed.replace(displayOrders(4, 0)); + await page.clock.runFor(3000); + await expect(page.getByText('No orders are ready yet')).toBeVisible(); + await expect(ready.locator('[data-order-id^="display-ready-"]:visible')).toHaveCount(0); + }); + + test('a delayed response longer than the poll interval still updates the board', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loginAsManager(page); + let delayOrders = false; + let delayedRequestCount = 0; + let inFlightDelayedRequests = 0; + await page.route( + (url) => url.pathname === '/api/settings/customer-display', + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + statuses: ['pending', 'preparing', 'ready'], + refresh_seconds: 3, + show_updated: true, + }), + }), + ); + await page.route( + (url) => url.pathname === '/api/orders', + async (route) => { + let orders = displayOrders(1, 0); + if (delayOrders) { + delayedRequestCount += 1; + inFlightDelayedRequests += 1; + orders = displayOrders(2, 0); + await new Promise((resolve) => setTimeout(resolve, 4000)); + inFlightDelayedRequests -= 1; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ orders }), + }); + }, ); + await page.goto(`${BASE}/customer-display`); + + const preparing = page.getByTestId('customer-display-preparing-scroll'); + await expect( + preparing.locator('[data-order-id="display-preparing-1"]:visible'), + 'the initial response populates the board before the delayed poll', + ).toBeVisible(); + await page.waitForTimeout(500); + delayOrders = true; + await expect.poll(() => delayedRequestCount, { timeout: 5000 }).toBe(1); + await page.waitForTimeout(3200); + expect(delayedRequestCount, 'the next poll tick skips while a request is pending').toBe(1); + expect(inFlightDelayedRequests, 'only one delayed request is in flight').toBe(1); + await expect( + preparing.locator('[data-order-id="display-preparing-2"]:visible'), + 'the slow response is accepted after a later poll tick was skipped', + ).toBeVisible({ timeout: 2000 }); + expect(inFlightDelayedRequests).toBe(0); }); - test('a narrow viewport uses one reachable content flow instead of two tile windows', async ({ + test('a network failure retains the last successful rows and reports stale data', async ({ page, }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await loginAsManager(page); + const feed = await mockDisplayOrders(page, displayOrders(3, 2)); + await page.goto(`${BASE}/customer-display`); + + const preparing = page.getByTestId('customer-display-preparing-scroll'); + const firstOrder = preparing.locator('[data-order-id="display-preparing-1"]:visible'); + await expect(firstOrder, 'the successful response displays the current order').toBeVisible(); + feed.setUnavailable(true); + await expect( + page.getByText('Connection lost. Showing the last available orders.'), + 'the network failure is reported as stale data', + ).toBeVisible({ timeout: 10000 }); + await expect(firstOrder, 'the last successful rows remain visible').toBeVisible(); + }); + + test('a narrow viewport keeps all rows in one reachable content flow', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await loginAsManager(page); await mockDisplayOrders(page, displayOrders(12, 12)); @@ -1745,46 +2196,29 @@ test.describe('customer display keeps its headers visible over independently scr const preparing = page.getByTestId('customer-display-preparing-scroll'); const ready = page.getByTestId('customer-display-ready-scroll'); - await expect( - preparing.getByText('10012', { exact: true }), - 'the preparing tiles render', - ).toBeVisible(); - await expect(ready.getByText('20012', { exact: true }), 'the ready tiles render').toBeVisible(); + await expect(preparing.locator('[data-order-id="display-preparing-12"]:visible')).toBeVisible(); + await expect(ready.locator('[data-order-id="display-ready-12"]:visible')).toBeVisible(); + await expect(preparing.locator('[data-order-id^="display-preparing-"]:visible')).toHaveCount( + 12, + ); + await expect(ready.locator('[data-order-id^="display-ready-"]:visible')).toHaveCount(12); - const preparingMetrics = await preparing.evaluate((element) => ({ - scrollHeight: element.scrollHeight, - clientHeight: element.clientHeight, + const widths = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, })); - const readyMetrics = await ready.evaluate((element) => ({ - scrollHeight: element.scrollHeight, - clientHeight: element.clientHeight, - })); - expect( - preparingMetrics.scrollHeight, - 'the preparing tiles are not squeezed into one column window', - ).toBeLessThanOrEqual(preparingMetrics.clientHeight + 1); - expect( - readyMetrics.scrollHeight, - 'the ready tiles are not squeezed into one column window', - ).toBeLessThanOrEqual(readyMetrics.clientHeight + 1); - - const viewport = page.viewportSize(); - expect(viewport, 'viewport is set').not.toBeNull(); - const lastReady = ready.getByText('20012', { exact: true }); + expect(widths.scrollWidth, 'the mobile flow has no horizontal overflow').toBeLessThanOrEqual( + widths.clientWidth, + ); + const lastReady = ready.locator('[data-order-id="display-ready-12"]:visible'); await lastReady.scrollIntoViewIfNeeded(); const lastReadyBox = await lastReady.boundingBox(); - expect(lastReadyBox, 'the last ready tile has bounds').not.toBeNull(); - expect(lastReadyBox!.y, 'the last ready tile is not above the viewport').toBeGreaterThanOrEqual( - -1, - ); - expect( - lastReadyBox!.y + lastReadyBox!.height, - 'the last ready tile is reachable in the single flow', - ).toBeLessThanOrEqual(viewport!.height + 1); - await expect(page.locator('header'), 'the business header stays reachable').toBeVisible(); + expect(lastReadyBox, 'the last mobile row has bounds').not.toBeNull(); + expect(lastReadyBox!.y + lastReadyBox!.height).toBeLessThanOrEqual(844 + 1); + await expect(page.locator('header'), 'the business header stays visible').toBeVisible(); }); - test('the display keeps its chrome through loading, empty and connection-error states', async ({ + test('the display keeps its chrome through loading, empty and stale-feed states', async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 900 }); @@ -1792,7 +2226,6 @@ test.describe('customer display keeps its headers visible over independently scr await page.goto(`${BASE}/customer-display`); await expect(page.getByTestId('customer-display-preparing-scroll')).toBeVisible(); - // Loading: the first answer is held back, so the loading branch is visible. await page.route( (url) => url.pathname === '/api/orders', async (route) => { @@ -1810,8 +2243,6 @@ test.describe('customer display keeps its headers visible over independently scr page.locator('header'), 'the business header stays visible while loading', ).toBeVisible(); - - // Empty: both sections explain themselves without collapsing the page. await expect( page.getByText('No orders currently being prepared'), 'the empty preparing state renders', @@ -1820,19 +2251,18 @@ test.describe('customer display keeps its headers visible over independently scr page.getByText('No orders are ready yet'), 'the empty ready state renders', ).toBeVisible(); - const emptyTileHeight = await page + const emptyBoardHeight = await page .getByTestId('customer-display-preparing-scroll') .evaluate((element) => element.clientHeight); - expect(emptyTileHeight, 'the empty tile area still occupies usable space').toBeGreaterThan(0); + expect(emptyBoardHeight, 'the empty board still occupies usable space').toBeGreaterThan(0); - // Connection error: the header reports the retry while the sections stay put. await page.route( (url) => url.pathname === '/api/orders', (route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }), ); await expect( - page.getByText('Connection problem — retrying…'), - 'the connection error is reported', + page.getByText('Connection lost. Showing the last available orders.'), + 'the stale-data state is reported after a successful empty response', ).toBeVisible({ timeout: 15000 }); await expect( page.getByText('No orders currently being prepared'), diff --git a/frontend/src/app/customer-display/page.tsx b/frontend/src/app/customer-display/page.tsx index f4bd07891..d0d39aacf 100644 --- a/frontend/src/app/customer-display/page.tsx +++ b/frontend/src/app/customer-display/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useTranslations } from 'use-intl'; import api from '@/lib/api'; @@ -21,6 +21,488 @@ import { readStoredDisplayToken, } from '@/lib/customer-display-token'; +type OrderGroup = 'priority' | 'ordinary'; + +interface OrderBoardPage { + orderIds: string[]; + group: OrderGroup; +} + +interface OrderBoardSectionProps { + accent: 'blue' | 'emerald'; + emptyMessage: string; + hint: string; + isReady: boolean; + newlyReadyOrderIds: ReadonlySet; + onPriorityOrdersShown: (orderIds: string[]) => void; + orders: BoardOrder[]; + title: string; +} + +const ORDER_PAGE_INTERVAL_MS = 10_000; +const CUSTOMER_DISPLAY_REQUEST_TIMEOUT_MS = 10_000; +const ORDER_ROW_GAP_FALLBACK = 12; +const ORDER_ROW_HEIGHT_FALLBACK = 72; + +function getOrderId(order: BoardOrder): string { + return String(order.id); +} + +function getPageKey(orderIds: string[]): string { + return orderIds.join('\u001f'); +} + +function paginateOrders( + orders: BoardOrder[], + availableHeight: number, + rowHeights: ReadonlyMap, + rowGap: number, +): BoardOrder[][] { + if (orders.length === 0) return []; + if (availableHeight <= 0) return [orders]; + + const pages: BoardOrder[][] = []; + let page: BoardOrder[] = []; + let usedHeight = 0; + + for (const order of orders) { + const rowHeight = rowHeights.get(getOrderId(order)) ?? ORDER_ROW_HEIGHT_FALLBACK; + const nextHeight = rowHeight + (page.length > 0 ? rowGap : 0); + if (page.length > 0 && usedHeight + nextHeight > availableHeight) { + pages.push(page); + page = []; + usedHeight = 0; + } + if (page.length > 0) usedHeight += rowGap; + page.push(order); + usedHeight += rowHeight; + } + + if (page.length > 0) pages.push(page); + return pages; +} + +function orderIdsFit( + orderIds: string[], + availableHeight: number, + rowHeights: ReadonlyMap, + rowGap: number, +): boolean { + if (availableHeight <= 0) return true; + const height = orderIds.reduce( + (total, id) => total + (rowHeights.get(id) ?? ORDER_ROW_HEIGHT_FALLBACK), + Math.max(0, orderIds.length - 1) * rowGap, + ); + return height <= availableHeight + 1; +} + +function OrderBoardRow({ + accent, + measureRef, + order, +}: { + accent: 'blue' | 'emerald'; + measureRef?: (node: HTMLDivElement | null) => void; + order: BoardOrder; +}) { + return ( +
+ + {order.number} + +
+ ); +} + +function OrderBoardSection({ + accent, + emptyMessage, + hint, + isReady, + newlyReadyOrderIds, + onPriorityOrdersShown, + orders, + title, +}: OrderBoardSectionProps) { + const [isPagedLayout, setIsPagedLayout] = useState(false); + const [isPageVisible, setIsPageVisible] = useState(true); + const [metrics, setMetrics] = useState<{ + availableHeight: number; + rowHeights: Map; + rowGap: number; + }>({ availableHeight: 0, rowHeights: new Map(), rowGap: ORDER_ROW_GAP_FALLBACK }); + const [activePage, setActivePage] = useState({ orderIds: [], group: 'ordinary' }); + const activePageRef = useRef(activePage); + const ordinaryPageCursorRef = useRef(0); + const listRef = useRef(null); + const probeContainerRef = useRef(null); + const probeRowsRef = useRef(new Map()); + const priorityCallbackRef = useRef(onPriorityOrdersShown); + + const orderById = useMemo( + () => new Map(orders.map((order) => [getOrderId(order), order])), + [orders], + ); + const priorityOrders = useMemo( + () => (isReady ? orders.filter((order) => newlyReadyOrderIds.has(getOrderId(order))) : []), + [isReady, newlyReadyOrderIds, orders], + ); + const ordinaryOrders = useMemo( + () => (isReady ? orders.filter((order) => !newlyReadyOrderIds.has(getOrderId(order))) : orders), + [isReady, newlyReadyOrderIds, orders], + ); + const areOrdersMeasured = useMemo( + () => + metrics.availableHeight > 0 && + orders.every((order) => metrics.rowHeights.has(getOrderId(order))), + [metrics.availableHeight, metrics.rowHeights, orders], + ); + const combinedPages = useMemo( + () => paginateOrders(orders, metrics.availableHeight, metrics.rowHeights, metrics.rowGap), + [metrics.availableHeight, metrics.rowGap, metrics.rowHeights, orders], + ); + const priorityPages = useMemo( + () => + combinedPages.length <= 1 || priorityOrders.length === 0 + ? [] + : paginateOrders( + priorityOrders, + metrics.availableHeight, + metrics.rowHeights, + metrics.rowGap, + ), + [ + combinedPages.length, + metrics.availableHeight, + metrics.rowGap, + metrics.rowHeights, + priorityOrders, + ], + ); + const ordinaryPages = useMemo( + () => + combinedPages.length <= 1 || priorityOrders.length === 0 + ? combinedPages + : paginateOrders( + ordinaryOrders, + metrics.availableHeight, + metrics.rowHeights, + metrics.rowGap, + ), + [ + combinedPages, + metrics.availableHeight, + metrics.rowGap, + metrics.rowHeights, + ordinaryOrders, + priorityOrders.length, + ], + ); + const hasMultiplePages = isPagedLayout && combinedPages.length > 1; + const priorityPagesRef = useRef(priorityPages); + const ordinaryPagesRef = useRef(ordinaryPages); + + useLayoutEffect(() => { + priorityCallbackRef.current = onPriorityOrdersShown; + priorityPagesRef.current = priorityPages; + ordinaryPagesRef.current = ordinaryPages; + }, [onPriorityOrdersShown, ordinaryPages, priorityPages]); + + const updateActivePage = useCallback((nextPage: OrderBoardPage) => { + const current = activePageRef.current; + if ( + current.group === nextPage.group && + getPageKey(current.orderIds) === getPageKey(nextPage.orderIds) + ) { + return; + } + activePageRef.current = nextPage; + setActivePage(nextPage); + }, []); + + const selectNextPage = useCallback( + (currentPriorityPages: BoardOrder[][], currentOrdinaryPages: BoardOrder[][]) => { + const active = activePageRef.current; + const activeKey = getPageKey(active.orderIds); + const hasAlternateOrdinaryPage = currentOrdinaryPages.some( + (page) => getPageKey(page.map(getOrderId)) !== activeKey, + ); + let selectedGroup: OrderGroup | null = null; + if (active.group === 'priority' && hasAlternateOrdinaryPage) { + selectedGroup = 'ordinary'; + } else if (currentPriorityPages.length > 0) { + selectedGroup = 'priority'; + } else if (currentOrdinaryPages.length > 0) { + selectedGroup = 'ordinary'; + } + if (!selectedGroup) return; + + let selectedPage: BoardOrder[] | undefined; + if (selectedGroup === 'priority') { + selectedPage = currentPriorityPages[0]; + } else { + const cursor = ordinaryPageCursorRef.current % currentOrdinaryPages.length; + let selectedIndex = cursor; + for (let offset = 0; offset < currentOrdinaryPages.length; offset += 1) { + const candidateIndex = (cursor + offset) % currentOrdinaryPages.length; + const candidateKey = getPageKey(currentOrdinaryPages[candidateIndex].map(getOrderId)); + if (candidateKey !== activeKey || currentOrdinaryPages.length === 1) { + selectedIndex = candidateIndex; + break; + } + } + selectedPage = currentOrdinaryPages[selectedIndex]; + ordinaryPageCursorRef.current = (selectedIndex + 1) % currentOrdinaryPages.length; + } + + if (!selectedPage) return; + const orderIds = selectedPage.map(getOrderId); + updateActivePage({ orderIds, group: selectedGroup }); + if (selectedGroup === 'priority') { + priorityCallbackRef.current(orderIds); + } + }, + [updateActivePage], + ); + + const measureRows = useCallback(() => { + if (!isPagedLayout || !isPageVisible) return; + const list = listRef.current; + const probes = probeContainerRef.current; + if (!list || !probes) return; + + const style = window.getComputedStyle(list); + const availableHeight = Math.max( + 0, + list.clientHeight - + Number.parseFloat(style.paddingTop) - + Number.parseFloat(style.paddingBottom), + ); + const measuredRowGap = Number.parseFloat(window.getComputedStyle(probes).rowGap); + const rowGap = Number.isFinite(measuredRowGap) ? measuredRowGap : ORDER_ROW_GAP_FALLBACK; + const rowHeights = new Map( + [...probeRowsRef.current].map(([id, row]) => [id, row.getBoundingClientRect().height]), + ); + setMetrics((current) => { + const sameHeights = + current.rowHeights.size === rowHeights.size && + [...rowHeights].every(([id, height]) => { + const previousHeight = current.rowHeights.get(id); + return previousHeight !== undefined && Math.abs(previousHeight - height) < 0.5; + }); + if ( + Math.abs(current.availableHeight - availableHeight) < 0.5 && + Math.abs(current.rowGap - rowGap) < 0.5 && + sameHeights + ) { + return current; + } + return { availableHeight, rowHeights, rowGap }; + }); + }, [isPagedLayout, isPageVisible]); + + useEffect(() => { + const media = window.matchMedia('(min-width: 768px)'); + const updateLayout = () => setIsPagedLayout(media.matches); + updateLayout(); + media.addEventListener('change', updateLayout); + return () => media.removeEventListener('change', updateLayout); + }, []); + + useEffect(() => { + const updateVisibility = () => setIsPageVisible(document.visibilityState === 'visible'); + updateVisibility(); + document.addEventListener('visibilitychange', updateVisibility); + return () => document.removeEventListener('visibilitychange', updateVisibility); + }, []); + + useEffect(() => { + if (!isPagedLayout || !isPageVisible) return; + const list = listRef.current; + const probes = probeContainerRef.current; + if (!list || !probes) return; + + const observer = new ResizeObserver(measureRows); + observer.observe(list); + observer.observe(probes); + measureRows(); + return () => observer.disconnect(); + }, [isPagedLayout, isPageVisible, measureRows]); + + useLayoutEffect(() => { + measureRows(); + }, [measureRows, orders]); + + useLayoutEffect(() => { + const active = activePageRef.current; + const eligible = new Set(orders.map(getOrderId)); + const retainedIds = active.orderIds.filter((id) => eligible.has(id)); + + if (!isPagedLayout) { + if (getPageKey(active.orderIds) !== getPageKey(retainedIds)) { + updateActivePage({ ...active, orderIds: retainedIds }); + } + return; + } + + if (orders.length === 0) { + updateActivePage({ orderIds: [], group: 'ordinary' }); + ordinaryPageCursorRef.current = 0; + return; + } + + if (!areOrdersMeasured) { + if (getPageKey(active.orderIds) !== getPageKey(retainedIds)) { + updateActivePage({ ...active, orderIds: retainedIds }); + } + return; + } + + if (combinedPages.length === 1) { + const orderIds = combinedPages[0].map(getOrderId); + updateActivePage({ + orderIds, + group: 'ordinary', + }); + ordinaryPageCursorRef.current = 0; + if (priorityOrders.length > 0) { + priorityCallbackRef.current(priorityOrders.map(getOrderId)); + } + return; + } + + if ( + retainedIds.length > 0 && + orderIdsFit(retainedIds, metrics.availableHeight, metrics.rowHeights, metrics.rowGap) + ) { + if (getPageKey(active.orderIds) !== getPageKey(retainedIds)) { + updateActivePage({ ...active, orderIds: retainedIds }); + } + return; + } + + const allPages = [ + ...priorityPages.map((page) => ({ page, group: 'priority' as const })), + ...ordinaryPages.map((page) => ({ page, group: 'ordinary' as const })), + ]; + + if (retainedIds.length > 0) { + const currentPage = allPages.find(({ page }) => + page.some((order) => getOrderId(order) === retainedIds[0]), + ); + if (currentPage) { + const orderIds = currentPage.page.map(getOrderId); + updateActivePage({ orderIds, group: currentPage.group }); + if (currentPage.group === 'priority') { + priorityCallbackRef.current(orderIds); + } else { + const pageIndex = ordinaryPages.findIndex( + (page) => getPageKey(page.map(getOrderId)) === getPageKey(orderIds), + ); + ordinaryPageCursorRef.current = (pageIndex + 1) % ordinaryPages.length; + } + return; + } + } + + selectNextPage(priorityPages, ordinaryPages); + }, [ + areOrdersMeasured, + combinedPages, + isPagedLayout, + metrics.availableHeight, + metrics.rowGap, + metrics.rowHeights, + orders, + ordinaryPages, + priorityOrders, + priorityPages, + selectNextPage, + updateActivePage, + ]); + + useEffect(() => { + if (!isPagedLayout || !isPageVisible || !hasMultiplePages) return; + const interval = window.setInterval(() => { + if (document.visibilityState !== 'visible') return; + selectNextPage(priorityPagesRef.current, ordinaryPagesRef.current); + }, ORDER_PAGE_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [hasMultiplePages, isPagedLayout, isPageVisible, selectNextPage]); + + const visibleOrders = isPagedLayout + ? activePage.orderIds + .map((id) => orderById.get(id)) + .filter((order): order is BoardOrder => !!order) + : orders; + const dataTestId = isReady + ? 'customer-display-ready-scroll' + : 'customer-display-preparing-scroll'; + + return ( +
+
+

+ {title} +

+

+ {hint} +

+
+
+ {visibleOrders.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {visibleOrders.map((order) => ( + + ))} +
+ )} + {isPagedLayout && ( + + )} +
+
+ ); +} + function toBoardOrder(order: Order): BoardOrder { return { id: order.id, @@ -35,7 +517,10 @@ export default function CustomerDisplayPage() { const tc = useTranslations('common'); const { formatTime } = useFormatDate(); const { user, currentTenant, loading: authLoading, loadFromStorage } = useAuthStore(); + const userId = user?.id ?? null; + const tenantId = currentTenant?.id ?? null; const [orders, setOrders] = useState([]); + const [newlyReadyOrderIds, setNewlyReadyOrderIds] = useState>(new Set()); const [pairedToken, setPairedToken] = useState(undefined); const [unpaired, setUnpaired] = useState(false); const [businessName, setBusinessName] = useState(''); @@ -43,9 +528,158 @@ export default function CustomerDisplayPage() { const [error, setError] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); const [config, setConfig] = useState(CUSTOMER_DISPLAY_DEFAULTS); + const feedSessionRef = useRef(0); + const feedRequestRef = useRef(0); + const feedInFlightRef = useRef<{ + session: number; + request: number; + controller: AbortController; + timeout: number; + } | null>(null); + const previousReadyOrderIdsRef = useRef | null>(null); + const readyTrackingContextRef = useRef<{ + pairedToken: string | null; + userId: number | null; + tenantId: number | null; + } | null>(null); + const configRef = useRef(config); + useLayoutEffect(() => { + configRef.current = config; + }, [config]); + + const acceptOrders = useCallback((nextOrders: BoardOrder[]) => { + const nextReadyIds = new Set( + nextOrders.filter((order) => order.status === 'ready').map(getOrderId), + ); + const previousReadyIds = previousReadyOrderIdsRef.current; + if (previousReadyIds) { + setNewlyReadyOrderIds((current) => { + const next = new Set([...current].filter((id) => nextReadyIds.has(id))); + for (const id of nextReadyIds) { + if (!previousReadyIds.has(id)) next.add(id); + } + return next; + }); + } else { + setNewlyReadyOrderIds(new Set()); + } + previousReadyOrderIdsRef.current = nextReadyIds; + setOrders(nextOrders); + }, []); + + const fetchPairedFeed = useCallback( + async (token: string, session: number) => { + if (feedInFlightRef.current?.session === session) return; + const request = ++feedRequestRef.current; + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(), + CUSTOMER_DISPLAY_REQUEST_TIMEOUT_MS, + ); + feedInFlightRef.current = { session, request, controller, timeout }; + try { + const response = await fetch(`${window.location.origin}/api${CUSTOMER_DISPLAY_FEED_PATH}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + signal: controller.signal, + }); + if (response.status === 401) { + if (session !== feedSessionRef.current) return; + feedSessionRef.current += 1; + feedRequestRef.current += 1; + window.clearTimeout(timeout); + feedInFlightRef.current = null; + previousReadyOrderIdsRef.current = null; + setNewlyReadyOrderIds(new Set()); + clearStoredDisplayToken(); + setOrders([]); + setLastUpdated(null); + setError(false); + setUnpaired(true); + return; + } + const feed: unknown = await response.json(); + if (!response.ok || !isCustomerDisplayFeed(feed)) throw new Error('feed unavailable'); + if (session !== feedSessionRef.current || request !== feedRequestRef.current) return; + setConfig(feed.config); + setBusinessName(feed.business_name); + acceptOrders(feed.orders); + setError(false); + setLastUpdated(new Date()); + } catch { + if (session === feedSessionRef.current && request === feedRequestRef.current) + setError(true); + } finally { + if ( + feedInFlightRef.current?.session === session && + feedInFlightRef.current.request === request + ) { + window.clearTimeout(timeout); + feedInFlightRef.current = null; + } + if (session === feedSessionRef.current && request === feedRequestRef.current) + setLoading(false); + } + }, + [acceptOrders], + ); + + const fetchOrders = useCallback( + async (session: number) => { + if (feedInFlightRef.current?.session === session) return; + const request = ++feedRequestRef.current; + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(), + CUSTOMER_DISPLAY_REQUEST_TIMEOUT_MS, + ); + feedInFlightRef.current = { session, request, controller, timeout }; + try { + const { data: latestConfig } = await api + .get('/settings/customer-display', { signal: controller.signal }) + .catch(() => ({ data: null })); + const validConfig = isCustomerDisplayConfig(latestConfig) ? latestConfig : null; + const activeConfig: CustomerDisplayConfig = validConfig ?? configRef.current; + const { data } = await api.get('/orders', { + params: { per_page: 50 }, + signal: controller.signal, + }); + if (session !== feedSessionRef.current || request !== feedRequestRef.current) return; + const nextOrders = (data.orders || []) + .filter((order: Order) => (activeConfig.statuses as string[]).includes(order.status)) + .sort((a: Order, b: Order) => a.created_at.localeCompare(b.created_at)) + .map(toBoardOrder); + if (validConfig) { + configRef.current = validConfig; + setConfig(validConfig); + } + acceptOrders(nextOrders); + setError(false); + setLastUpdated(new Date()); + } catch { + if (session === feedSessionRef.current && request === feedRequestRef.current) + setError(true); + } finally { + if ( + feedInFlightRef.current?.session === session && + feedInFlightRef.current.request === request + ) { + window.clearTimeout(timeout); + feedInFlightRef.current = null; + } + if (session === feedSessionRef.current && request === feedRequestRef.current) + setLoading(false); + } + }, + [acceptOrders], + ); + + const markPriorityOrdersShown = useCallback((orderIds: string[]) => { + const shown = new Set(orderIds); + setNewlyReadyOrderIds((current) => new Set([...current].filter((id) => !shown.has(id)))); + }, []); useEffect(() => { - // The token lives in the URL fragment or localStorage, which only exist in the browser. const resolve = setTimeout(() => { const token = consumeDisplayTokenFromUrl() ?? readStoredDisplayToken(); setPairedToken(token); @@ -60,65 +694,54 @@ export default function CustomerDisplayPage() { } }, [pairedToken, authLoading, user, router]); - const fetchPairedFeed = async (token: string) => { - try { - const response = await fetch(`${window.location.origin}/api${CUSTOMER_DISPLAY_FEED_PATH}`, { - headers: { Authorization: `Bearer ${token}` }, - cache: 'no-store', - }); - if (response.status === 401) { - clearStoredDisplayToken(); - setUnpaired(true); - return; - } - const feed: unknown = await response.json(); - if (!response.ok || !isCustomerDisplayFeed(feed)) throw new Error('feed unavailable'); - setConfig(feed.config); - setBusinessName(feed.business_name); - setOrders(feed.orders); - setError(false); - setLastUpdated(new Date()); - } catch { - setError(true); - } finally { - setLoading(false); + useEffect(() => { + if (pairedToken === undefined || unpaired) return; + if (pairedToken === null && (userId === null || tenantId === null)) { + readyTrackingContextRef.current = null; + previousReadyOrderIdsRef.current = null; + return; } - }; - const fetchOrders = async () => { - try { - const { data: latestConfig } = await api - .get('/settings/customer-display') - .catch(() => ({ data: null })); - const validConfig = isCustomerDisplayConfig(latestConfig) ? latestConfig : null; - const activeConfig: CustomerDisplayConfig = validConfig ?? config; - if (validConfig) setConfig(validConfig); - const { data } = await api.get('/orders', { params: { per_page: 50 } }); - const nextOrders = (data.orders || []) - .filter((order: Order) => (activeConfig.statuses as string[]).includes(order.status)) - .sort((a: Order, b: Order) => a.created_at.localeCompare(b.created_at)) - .map(toBoardOrder); - setOrders(nextOrders); - setError(false); - setLastUpdated(new Date()); - } catch { - setError(true); - } finally { - setLoading(false); + const readyTrackingContext = { + pairedToken, + userId, + tenantId, + }; + const previousContext = readyTrackingContextRef.current; + if ( + !previousContext || + previousContext.pairedToken !== readyTrackingContext.pairedToken || + previousContext.userId !== readyTrackingContext.userId || + previousContext.tenantId !== readyTrackingContext.tenantId + ) { + previousReadyOrderIdsRef.current = null; + readyTrackingContextRef.current = readyTrackingContext; } - }; - useEffect(() => { - if (pairedToken === undefined || unpaired) return; - if (pairedToken === null && (!user || !currentTenant)) return; - const refresh = () => (pairedToken ? fetchPairedFeed(pairedToken) : fetchOrders()); + const session = ++feedSessionRef.current; + const refresh = () => + pairedToken ? fetchPairedFeed(pairedToken, session) : fetchOrders(session); const initial = setTimeout(refresh, 0); const interval = setInterval(refresh, config.refresh_seconds * 1000); return () => { clearTimeout(initial); clearInterval(interval); + if (feedSessionRef.current === session) feedSessionRef.current += 1; + const inFlight = feedInFlightRef.current; + if (inFlight?.session === session) { + window.clearTimeout(inFlight.timeout); + inFlight.controller.abort(); + } }; - }, [pairedToken, unpaired, user, currentTenant, config.refresh_seconds]); + }, [ + pairedToken, + unpaired, + userId, + tenantId, + config.refresh_seconds, + fetchOrders, + fetchPairedFeed, + ]); const preparing = useMemo( () => orders.filter((order) => order.status === 'pending' || order.status === 'preparing'), @@ -149,17 +772,21 @@ export default function CustomerDisplayPage() { } return ( -
-
-
-
+
+
+
+
{pairedToken ? businessName : currentTenant?.business_name}
-
{t('orderStatus')}
+
+ {t('orderStatus')} +
-
+
{error - ? t('connectionProblem') + ? lastUpdated + ? t('staleData') + : t('connectionProblem') : lastUpdated ? config.show_updated ? t('updatedAt', { time: formatTime(lastUpdated, { second: '2-digit' }) }) @@ -169,77 +796,36 @@ export default function CustomerDisplayPage() {
{loading ? ( -
+
{t('loadingOrders')}
) : (
{showPreparing && ( -
-
-

- {t('preparingTitle')} -

-

{t('preparingHint')}

-
-
- {preparing.length === 0 ? ( -
- {t('noPreparing')} -
- ) : ( - preparing.map((order) => ( -
-
- {t('orderLabel')} -
-
{order.number}
-
- )) - )} -
-
+ )} - {showReady && ( -
-
-

- {t('readyTitle')} -

-

{t('readyHint')}

-
-
- {ready.length === 0 ? ( -
- {t('noReady')} -
- ) : ( - ready.map((order) => ( -
-
- {t('orderLabel')} -
-
{order.number}
-
- )) - )} -
-
+ )}
)} diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 3986cbbab..f9e7812d2 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -219,6 +219,7 @@ "loadingOrders": "جارٍ تحميل الطلبات…", "connecting": "جارٍ الاتصال…", "connectionProblem": "مشكلة في الاتصال — إعادة المحاولة…", + "staleData": "انقطع الاتصال. يتم عرض آخر الطلبات المتاحة.", "updatedAt": "آخر تحديث {time}", "settingsTitle": "شاشة العملاء", "settingsHint": "اختر الطلبات التي تظهر على لوحة حالة الطلبات الموجّهة للعملاء.", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index f94e45426..647a8d267 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -219,6 +219,7 @@ "loadingOrders": "অর্ডার লোড হচ্ছে…", "connecting": "সংযোগ হচ্ছে…", "connectionProblem": "সংযোগে সমস্যা — আবার চেষ্টা করা হচ্ছে…", + "staleData": "সংযোগ বিচ্ছিন্ন হয়েছে। সর্বশেষ উপলভ্য অর্ডারগুলো দেখানো হচ্ছে।", "updatedAt": "আপডেট করা হয়েছে {time}", "settingsTitle": "কাস্টমার ডিসপ্লে", "settingsHint": "গ্রাহকদের জন্য অর্ডার স্ট্যাটাস বোর্ডে কোন অর্ডার দেখানো হবে তা বেছে নিন।", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 5a981aa45..b60a2d3cf 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -219,6 +219,7 @@ "loadingOrders": "Bestellungen werden geladen…", "connecting": "Verbindung wird hergestellt…", "connectionProblem": "Verbindungsproblem — neuer Versuch…", + "staleData": "Verbindung unterbrochen. Die zuletzt verfügbaren Bestellungen werden angezeigt.", "updatedAt": "Aktualisiert {time}", "settingsTitle": "Kundenanzeige", "settingsHint": "Legen Sie fest, welche Bestellungen auf der kundenseitigen Statusanzeige erscheinen.", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index dd039b035..d64233df5 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -219,6 +219,7 @@ "loadingOrders": "Loading orders…", "connecting": "Connecting…", "connectionProblem": "Connection problem — retrying…", + "staleData": "Connection lost. Showing the last available orders.", "updatedAt": "Updated {time}", "settingsTitle": "Customer display", "settingsHint": "Choose which orders appear on the customer-facing order status board.", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index ee55593af..5e9730c88 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -219,6 +219,7 @@ "loadingOrders": "Cargando pedidos…", "connecting": "Conectando…", "connectionProblem": "Problema de conexión — reintentando…", + "staleData": "Se perdió la conexión. Se muestran los últimos pedidos disponibles.", "updatedAt": "Actualizado {time}", "settingsTitle": "Pantalla de clientes", "settingsHint": "Elija qué pedidos aparecen en el tablero de estado de pedidos para clientes.", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index b6f445856..0eef97117 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -219,6 +219,7 @@ "loadingOrders": "در حال بارگذاری سفارش‌ها…", "connecting": "در حال اتصال…", "connectionProblem": "مشکل در اتصال — تلاش مجدد…", + "staleData": "ارتباط قطع شد. آخرین سفارش‌های در دسترس نمایش داده می‌شوند.", "updatedAt": "به‌روزرسانی شد {time}", "settingsTitle": "نمایشگر مشتری", "settingsHint": "انتخاب کنید کدام سفارش‌ها در تابلوی وضعیت سفارش برای مشتریان نمایش داده شوند.", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index aee6e542b..605d3499d 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -219,6 +219,7 @@ "loadingOrders": "Nilo-load ang mga order…", "connecting": "Kumokonekta…", "connectionProblem": "May problema sa koneksyon — sinusubukang muli…", + "staleData": "Nawala ang koneksyon. Ipinapakita ang mga huling order na available.", "updatedAt": "Na-update {time}", "settingsTitle": "Display ng customer", "settingsHint": "Piliin kung aling mga order ang lalabas sa status board ng order para sa mga customer.", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index bd5e24f86..a02fe194f 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -219,6 +219,7 @@ "loadingOrders": "Chargement des commandes…", "connecting": "Connexion…", "connectionProblem": "Problème de connexion — nouvelle tentative…", + "staleData": "Connexion perdue. Affichage des dernières commandes disponibles.", "updatedAt": "Mis à jour {time}", "settingsTitle": "Écran client", "settingsHint": "Choisissez les commandes affichées sur le tableau d'état des commandes destiné aux clients.", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index 50f37ea4a..1edbc6e94 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -219,6 +219,7 @@ "loadingOrders": "ऑर्डर लोड हो रहे हैं…", "connecting": "कनेक्ट हो रहा है…", "connectionProblem": "कनेक्शन में समस्या — दोबारा प्रयास हो रहा है…", + "staleData": "कनेक्शन टूट गया। उपलब्ध नवीनतम ऑर्डर दिखाए जा रहे हैं।", "updatedAt": "अपडेट किया गया {time}", "settingsTitle": "कस्टमर डिस्प्ले", "settingsHint": "चुनें कि ग्राहकों के लिए ऑर्डर स्टेटस बोर्ड पर कौन-से ऑर्डर दिखें।", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 1efea9868..78ab81892 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -219,6 +219,7 @@ "loadingOrders": "Memuat pesanan…", "connecting": "Menyambungkan…", "connectionProblem": "Masalah koneksi — mencoba lagi…", + "staleData": "Koneksi terputus. Menampilkan pesanan terakhir yang tersedia.", "updatedAt": "Diperbarui {time}", "settingsTitle": "Layar pelanggan", "settingsHint": "Pilih pesanan yang tampil di papan status pesanan untuk pelanggan.", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 351feef7f..4affb558f 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -219,6 +219,7 @@ "loadingOrders": "Caricamento ordini…", "connecting": "Connessione in corso…", "connectionProblem": "Problema di connessione — nuovo tentativo…", + "staleData": "Connessione persa. Sono mostrati gli ultimi ordini disponibili.", "updatedAt": "Aggiornato {time}", "settingsTitle": "Display clienti", "settingsHint": "Scegli quali ordini mostrare sulla bacheca di stato degli ordini per i clienti.", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index b75b141f0..5f84c2aa6 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -219,6 +219,7 @@ "loadingOrders": "注文を読み込み中…", "connecting": "接続中…", "connectionProblem": "接続に問題があります — 再試行中…", + "staleData": "接続が切れました。最後に取得できた注文を表示しています。", "updatedAt": "更新 {time}", "settingsTitle": "カスタマーディスプレイ", "settingsHint": "お客様向けの注文状況ボードに表示する注文を選択します。", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index 06202f422..edeb5bb66 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -219,6 +219,7 @@ "loadingOrders": "주문을 불러오는 중…", "connecting": "연결 중…", "connectionProblem": "연결 문제 — 다시 시도하는 중…", + "staleData": "연결이 끊겼습니다. 마지막으로 확인한 주문을 표시합니다.", "updatedAt": "업데이트됨 {time}", "settingsTitle": "고객용 디스플레이", "settingsHint": "고객용 주문 상태 보드에 표시할 주문을 선택하세요.", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 4464364d1..a646e963e 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -219,6 +219,7 @@ "loadingOrders": "अर्डरहरू लोड हुँदैछन्…", "connecting": "जडान हुँदैछ…", "connectionProblem": "जडानमा समस्या — फेरि प्रयास गर्दै…", + "staleData": "जडान विच्छेद भयो। उपलब्ध पछिल्ला अर्डरहरू देखाइँदै छन्।", "updatedAt": "अद्यावधिक गरिएको {time}", "settingsTitle": "ग्राहक डिस्प्ले", "settingsHint": "ग्राहकका लागि अर्डर स्थिति बोर्डमा कुन अर्डर देखिने भन्ने छान्नुहोस्।", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index 051cab3c0..6841ca767 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -219,6 +219,7 @@ "loadingOrders": "Bestellingen laden…", "connecting": "Verbinden…", "connectionProblem": "Verbindingsprobleem — opnieuw proberen…", + "staleData": "Verbinding verbroken. De laatst beschikbare bestellingen worden weergegeven.", "updatedAt": "Bijgewerkt {time}", "settingsTitle": "Klantscherm", "settingsHint": "Kies welke bestellingen op het statusbord voor klanten verschijnen.", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 6fdc239c5..12a251eea 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -219,6 +219,7 @@ "loadingOrders": "A carregar pedidos…", "connecting": "A ligar…", "connectionProblem": "Problema de ligação — a tentar novamente…", + "staleData": "Conexão perdida. Exibindo os pedidos mais recentes disponíveis.", "updatedAt": "Atualizado {time}", "settingsTitle": "Ecrã do cliente", "settingsHint": "Escolha que pedidos aparecem no painel de estado dos pedidos para clientes.", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index bfd814221..ec10dfe5c 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -219,6 +219,7 @@ "loadingOrders": "Загрузка заказов…", "connecting": "Подключение…", "connectionProblem": "Проблема с подключением — повторная попытка…", + "staleData": "Соединение потеряно. Показаны последние доступные заказы.", "updatedAt": "Обновлено {time}", "settingsTitle": "Экран для клиентов", "settingsHint": "Выберите, какие заказы отображаются на табло статуса заказов для клиентов.", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index d73d6db78..a543907a6 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -219,6 +219,7 @@ "loadingOrders": "Po ngarkohen porositë…", "connecting": "Po lidhet…", "connectionProblem": "Problem me lidhjen — po provohet përsëri…", + "staleData": "Lidhja u ndërpre. Po shfaqen porositë e fundit të disponueshme.", "updatedAt": "Përditësuar {time}", "settingsTitle": "Ekrani i klientit", "settingsHint": "Zgjidhni cilat porosi shfaqen në tabelën e statusit të porosive për klientët.", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index cf20ffad2..80e7e50d7 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -219,6 +219,7 @@ "loadingOrders": "กำลังโหลดคำสั่งซื้อ…", "connecting": "กำลังเชื่อมต่อ…", "connectionProblem": "การเชื่อมต่อมีปัญหา — กำลังลองใหม่…", + "staleData": "การเชื่อมต่อขาดหาย กำลังแสดงคำสั่งซื้อล่าสุดที่มี", "updatedAt": "อัปเดตเมื่อ {time}", "settingsTitle": "หน้าจอลูกค้า", "settingsHint": "เลือกว่าจะแสดงคำสั่งซื้อใดบนกระดานสถานะคำสั่งซื้อสำหรับลูกค้า", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 85b4939bd..1174f1c61 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -219,6 +219,7 @@ "loadingOrders": "Siparişler yükleniyor…", "connecting": "Bağlanıyor…", "connectionProblem": "Bağlantı sorunu — yeniden deneniyor…", + "staleData": "Bağlantı kesildi. Son kullanılabilir siparişler gösteriliyor.", "updatedAt": "Güncellendi {time}", "settingsTitle": "Müşteri ekranı", "settingsHint": "Müşteri sipariş durumu panosunda hangi siparişlerin görüneceğini seçin.", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index 96ea25587..4fa28a9ec 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -219,6 +219,7 @@ "loadingOrders": "آرڈرز لوڈ ہو رہے ہیں…", "connecting": "منسلک ہو رہا ہے…", "connectionProblem": "کنکشن میں مسئلہ — دوبارہ کوشش کی جا رہی ہے…", + "staleData": "کنکشن منقطع ہو گیا۔ آخری دستیاب آرڈرز دکھائے جا رہے ہیں۔", "updatedAt": "اپ ڈیٹ کیا گیا {time}", "settingsTitle": "کسٹمر ڈسپلے", "settingsHint": "منتخب کریں کہ گاہکوں کے لیے آرڈر اسٹیٹس بورڈ پر کون سے آرڈر نظر آئیں۔", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index fdb2de0c0..8c8a9cba3 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -219,6 +219,7 @@ "loadingOrders": "Đang tải đơn hàng…", "connecting": "Đang kết nối…", "connectionProblem": "Sự cố kết nối — đang thử lại…", + "staleData": "Mất kết nối. Đang hiển thị các đơn hàng gần nhất hiện có.", "updatedAt": "Đã cập nhật {time}", "settingsTitle": "Màn hình khách hàng", "settingsHint": "Chọn các đơn hàng hiển thị trên bảng trạng thái đơn hàng dành cho khách.", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 545d87b13..89cd5c521 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -219,6 +219,7 @@ "loadingOrders": "正在載入訂單…", "connecting": "連線中…", "connectionProblem": "連線發生問題——重試中…", + "staleData": "連線已中斷。正在顯示最近可用的訂單。", "updatedAt": "更新於 {time}", "settingsTitle": "顧客顯示螢幕", "settingsHint": "選擇哪些訂單顯示在面向顧客的訂單狀態看板上。", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index 5b8e9a0ca..8937c0b3f 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -219,6 +219,7 @@ "loadingOrders": "正在加载订单…", "connecting": "正在连接…", "connectionProblem": "连接出现问题——正在重试…", + "staleData": "连接已中断。正在显示最近可用的订单。", "updatedAt": "更新于 {time}", "settingsTitle": "顾客显示屏", "settingsHint": "选择哪些订单显示在面向顾客的订单状态看板上。",