diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index d26c6fa19..62c2681d9 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -467,11 +467,15 @@ and whether the last-updated time is shown. The same settings card shows the dis 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. -Pairing creates a token and shows a `/customer-display/#token=...` link once. The board 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. +The card loads the POS server's LAN addresses and uses the selected address for both the display URL +and pairing link. The primary LAN address is selected first; other network interfaces, mDNS, and a +clearly labelled local preview can be selected before pairing. Switching addresses updates the +one-time link without creating another token. If no LAN address is available, the card says so and +offers only the local preview; if address lookup fails, it shows an error and retry action. The board +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. ## Tech stack diff --git a/frontend/e2e/customer-display-pairing-addresses.spec.ts b/frontend/e2e/customer-display-pairing-addresses.spec.ts new file mode 100644 index 000000000..12fe90846 --- /dev/null +++ b/frontend/e2e/customer-display-pairing-addresses.spec.ts @@ -0,0 +1,222 @@ +import { test, expect, type Page } from '@playwright/test'; +import { E2E_BASE_URL as BASE } from './helpers/urls'; + +async function startSettingsSession(page: Page): Promise { + await page.addInitScript(() => { + localStorage.setItem('token', 'customer-display-address-test-token'); + }); + await page.route('**/api/**', async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === '/api/auth/me') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + user: { + id: 'e2e-owner', + name: 'E2E Owner', + email: 'owner@flo.local', + role: 'owner', + category_ids: [], + }, + tenants: [ + { + id: 1, + business_name: 'E2E Cafe', + role: 'owner', + plan: 'free', + status: 'active', + business_type: 'restaurant', + language: 'en', + }, + ], + }), + }); + return; + } + await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }); + await page.goto(`${BASE}/auth/login`); + await page.waitForURL(/\/pos(?:\/|$)/, { timeout: 20000 }); +} + +const posInfo = { + mdns_url: 'http://flo.local:4321', + ip_url: 'http://10.42.0.25:4321', + ips_data: [ + { ip: '10.42.0.25', url: 'http://10.42.0.25:4321', qr_data: null }, + { ip: '192.168.1.25', url: 'http://192.168.1.25:4321', qr_data: null }, + ], +}; + +test('customer display pairing uses the selected POS address and reuses its token when switched', async ({ + page, + browser, +}) => { + await startSettingsSession(page); + let networkInfoReads = 0; + let tokenCreates = 0; + const token = `cdt_${'a'.repeat(64)}`; + await page.route('**/api/pos-info', async (route) => { + networkInfoReads += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(posInfo), + }); + }); + await page.route('**/api/settings/customer-display/tokens', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"tokens":[]}' }); + return; + } + tokenCreates += 1; + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'paired-display-1', + label: 'Counter TV', + token, + created_at: '2026-10-14T12:00:00.000Z', + }), + }); + }); + await page.goto(`${BASE}/settings?tab=pos`); + await page.getByRole('button', { name: 'Kitchen Display', exact: true }).click(); + await expect(page.getByText('Primary LAN address', { exact: true })).toBeVisible(); + await expect(page.getByRole('radio', { name: /Primary LAN address/ })).toBeChecked(); + await expect( + page.getByText('http://10.42.0.25:4321/customer-display/', { exact: true }), + ).toBeVisible(); + expect(networkInfoReads).toBe(1); + + await page.getByLabel('Display name', { exact: true }).fill('Counter TV'); + await page.getByRole('button', { name: 'Pair display', exact: true }).click(); + let pairingUrl = 'http://10.42.0.25:4321/customer-display/#token=' + token; + await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible(); + + await page.getByText('Show other addresses', { exact: true }).click(); + await page.getByRole('radio', { name: /192\.168\.1\.25:4321/ }).check(); + await expect( + page.getByText('http://192.168.1.25:4321/customer-display/', { exact: true }), + ).toBeVisible(); + pairingUrl = 'http://192.168.1.25:4321/customer-display/#token=' + token; + await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible(); + await page.getByRole('radio', { name: /flo\.local:4321/ }).check(); + pairingUrl = 'http://flo.local:4321/customer-display/#token=' + token; + await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible(); + await page.getByRole('radio', { name: /This computer only \(local preview\)/ }).check(); + const localOrigin = new URL(BASE).origin; + await expect(page.getByText(`${localOrigin}/customer-display/`, { exact: true })).toBeVisible(); + pairingUrl = `${localOrigin}/customer-display/#token=${token}`; + await expect(page.getByText(pairingUrl, { exact: true })).toBeVisible(); + expect(tokenCreates).toBe(1); + + const deviceContext = await browser.newContext(); + const devicePage = await deviceContext.newPage(); + let feedRequests = 0; + let revoked = false; + await devicePage.route('**/api/customer-display/feed', async (route) => { + feedRequests += 1; + if (revoked) { + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: '{"error":"revoked"}', + }); + return; + } + expect(route.request().headers().authorization).toBe(`Bearer ${token}`); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + business_name: 'E2E Cafe', + config: { + statuses: ['pending', 'preparing', 'ready'], + refresh_seconds: 3, + show_updated: false, + }, + orders: [{ id: 1, number: 'A-42', status: 'ready' }], + }), + }); + }); + 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).toHaveURL(`${localOrigin}/customer-display/`); + await devicePage.reload(); + await expect(devicePage.getByText('A-42', { exact: true })).toBeVisible(); + expect(feedRequests).toBeGreaterThanOrEqual(2); + revoked = true; + await expect( + devicePage.getByRole('heading', { name: 'This display is not paired', exact: true }), + ).toBeVisible({ timeout: 10000 }); + await deviceContext.close(); +}); + +test('customer display pairing shows no-LAN and network lookup failures explicitly', async ({ + page, +}) => { + await startSettingsSession(page); + let networkInfoReads = 0; + const localToken = `cdt_${'b'.repeat(64)}`; + await page.route('**/api/pos-info', async (route) => { + networkInfoReads += 1; + if (networkInfoReads === 1) { + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: '{"error":"offline"}', + }); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + mdns_url: 'http://flo.local:3001', + ip_url: 'http://127.0.0.1:3001', + ips_data: [{ ip: '127.0.0.1', url: 'http://127.0.0.1:3001', qr_data: null }], + }), + }); + }); + await page.route('**/api/settings/customer-display/tokens', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"tokens":[]}' }); + return; + } + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'paired-display-local', + label: 'Local preview', + token: localToken, + created_at: '2026-10-14T12:00:00.000Z', + }), + }); + }); + + await page.goto(`${BASE}/settings?tab=pos`); + await page.getByRole('button', { name: 'Kitchen Display', exact: true }).click(); + await expect(page.getByText('Could not load network addresses.', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(page.getByRole('status')).toContainText( + 'LAN pairing is unavailable. Select the local preview to use this computer only.', + ); + await expect(page.locator('input[name="customer-display-pairing-address"]')).toHaveCount(1); + await expect( + page.getByRole('radio', { name: /This computer only \(local preview\)/ }), + ).not.toBeChecked(); + await expect(page.getByText('http://flo.local:3001', { exact: true })).toHaveCount(0); + await page.getByRole('radio', { name: /This computer only \(local preview\)/ }).check(); + await page.getByLabel('Display name', { exact: true }).fill('Local preview'); + await page.getByRole('button', { name: 'Pair display', exact: true }).click(); + const localOrigin = new URL(BASE).origin; + await expect( + page.getByText(`${localOrigin}/customer-display/#token=${localToken}`, { exact: true }), + ).toBeVisible(); + expect(networkInfoReads).toBe(2); +}); diff --git a/frontend/e2e/customer-display-pairing.spec.ts b/frontend/e2e/customer-display-pairing.spec.ts index ae255fe88..fd9618cde 100644 --- a/frontend/e2e/customer-display-pairing.spec.ts +++ b/frontend/e2e/customer-display-pairing.spec.ts @@ -28,6 +28,16 @@ async function openKdsSettingsTab(page: Page): Promise { await expect(page.getByRole('heading', { name: 'Paired displays', exact: true })).toBeVisible({ timeout: 30_000, }); + const checkedAddress = page.locator('input[name="customer-display-pairing-address"]:checked'); + const lanUnavailable = page.getByText( + 'LAN pairing is unavailable. Select the local preview to use this computer only.', + { exact: true }, + ); + await expect(checkedAddress.or(lanUnavailable).first()).toBeVisible({ timeout: 30_000 }); + if ((await checkedAddress.count()) === 0) { + await page.getByRole('radio', { name: /This computer only \(local preview\)/ }).check(); + await expect(checkedAddress).toHaveCount(1); + } } async function revokeAllDisplayTokens(request: APIRequestContext): Promise { diff --git a/frontend/e2e/settings-request-budget.spec.ts b/frontend/e2e/settings-request-budget.spec.ts index d3a9837f2..45e68ae16 100644 --- a/frontend/e2e/settings-request-budget.spec.ts +++ b/frontend/e2e/settings-request-budget.spec.ts @@ -1258,7 +1258,9 @@ test('Network pairing cards explain local and VPN/mesh QR choices across setting 'Use when devices connect via a VPN or mesh network across different subnets or locations'; const expectLocalAddressCard = async () => { const localAddress = 'http://10.42.0.25:3001'; - const localCard = page.getByText(localAddress, { exact: true }).locator('xpath=..'); + const localCard = page + .getByRole('link', { name: localAddress, exact: true }) + .locator('xpath=..'); await expect(localCard.getByRole('link')).toHaveAttribute('href', localAddress); await expect(localCard.getByText('Local Network', { exact: true })).toBeVisible(); await expect(localCard).toContainText(englishLocalHint); diff --git a/frontend/src/components/settings/CustomerDisplayCard.tsx b/frontend/src/components/settings/CustomerDisplayCard.tsx index 378e4c466..79e23dca9 100644 --- a/frontend/src/components/settings/CustomerDisplayCard.tsx +++ b/frontend/src/components/settings/CustomerDisplayCard.tsx @@ -28,13 +28,100 @@ interface PairedDisplay { last_seen_at: string | null; } -function PairedDisplays() { +interface PosNetworkInfo { + ip_url: string; + mdns_url: string; + ips_data: { url: string }[]; +} + +interface PairingAddresses { + primary: string | null; + other: string[]; +} + +type NetworkInfoStatus = 'loading' | 'ready' | 'error'; + +interface PairedDisplaysProps { + networkInfoStatus: NetworkInfoStatus; + primaryOrigin: string | null; + otherOrigins: string[]; + localPreviewOrigin: string | null; + selectedOrigin: string; + onSelectOrigin: (origin: string) => void; + onRetryNetworkInfo: () => void; +} + +function getHttpOrigin(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} + +function isPosNetworkInfo(value: unknown): value is PosNetworkInfo { + if (!value || typeof value !== 'object') return false; + const info = value as Record; + return ( + getHttpOrigin(info.ip_url) !== null && + getHttpOrigin(info.mdns_url) !== null && + Array.isArray(info.ips_data) && + info.ips_data.every( + (entry) => !!entry && typeof entry === 'object' && getHttpOrigin(entry.url) !== null, + ) + ); +} + +function getNetworkOrigin(value: string): string | null { + const origin = getHttpOrigin(value); + if (!origin) return null; + const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '::1' || + hostname.startsWith('127.') + ) { + return null; + } + return origin; +} + +function getPairingAddresses(info: PosNetworkInfo): PairingAddresses { + const localOrigins = info.ips_data + .map(({ url }) => getNetworkOrigin(url)) + .filter((origin): origin is string => origin !== null); + const primary = getNetworkOrigin(info.ip_url) ?? localOrigins[0] ?? null; + if (!primary) return { primary: null, other: [] }; + + const other = [ + ...new Set([...localOrigins, getNetworkOrigin(info.mdns_url)].filter(Boolean)), + ].filter((origin): origin is string => origin !== null && origin !== primary); + return { primary, other }; +} + +function PairedDisplays({ + networkInfoStatus, + primaryOrigin, + otherOrigins, + localPreviewOrigin, + selectedOrigin, + onSelectOrigin, + onRetryNetworkInfo, +}: PairedDisplaysProps) { const t = useTranslations('customerDisplay'); const { formatDateTime } = useFormatDate(); + const tSettings = useTranslations('settings'); const [displays, setDisplays] = useState([]); const [label, setLabel] = useState(''); const [busy, setBusy] = useState(false); - const [pairingLink, setPairingLink] = useState<{ id: string; url: string } | null>(null); + const [pairingLink, setPairingLink] = useState<{ id: string; token: string; url: string } | null>( + null, + ); + const tCommon = useTranslations('common'); useEffect(() => { const controller = new AbortController(); @@ -48,7 +135,20 @@ function PairedDisplays() { return () => controller.abort(); }, []); + function selectPairingAddress(origin: string) { + onSelectOrigin(origin); + setPairingLink((current) => + current + ? { + ...current, + url: `${origin}${CUSTOMER_DISPLAY_PATH}#token=${encodeURIComponent(current.token)}`, + } + : current, + ); + } + async function pair() { + if (!selectedOrigin || networkInfoStatus === 'loading') return; setBusy(true); try { const { data } = await api.post('/settings/customer-display/tokens', { label }); @@ -59,7 +159,8 @@ function PairedDisplays() { ]); setPairingLink({ id: data.id, - url: `${window.location.origin}${CUSTOMER_DISPLAY_PATH}#token=${data.token}`, + token: data.token, + url: `${selectedOrigin}${CUSTOMER_DISPLAY_PATH}#token=${encodeURIComponent(data.token)}`, }); setLabel(''); } catch (error) { @@ -99,7 +200,7 @@ function PairedDisplays() {

{t('pairedTitle')}

{t('pairedHint')}

-
+
-
+ {networkInfoStatus === 'error' && ( +
+ {t('networkInfoLoadFailed')} + +
+ )} + {networkInfoStatus === 'ready' && !primaryOrigin && ( +

+ {t('lanPairingUnavailable')} +

+ )} + +
+ {t('urlLabel')} + {networkInfoStatus === 'loading' && ( +

{tCommon('loading')}

+ )} + {primaryOrigin && ( + + )} + {(otherOrigins.length > 0 || localPreviewOrigin) && ( +
+ + {t('otherAddresses')} + +
+ {otherOrigins.map((origin) => ( + + ))} + {localPreviewOrigin && + ![primaryOrigin, ...otherOrigins].includes(localPreviewOrigin) && ( + + )} +
+
+ )} +
+ + + {pairingLink && (

{t('pairedLinkTitle')}

@@ -183,10 +375,15 @@ export function CustomerDisplayCard() { const [config, setConfig] = useState(CUSTOMER_DISPLAY_DEFAULTS); const [saved, setSaved] = useState(CUSTOMER_DISPLAY_DEFAULTS); const [saving, setSaving] = useState(false); - const displayUrl = - typeof window === 'undefined' - ? CUSTOMER_DISPLAY_PATH - : `${window.location.origin}${CUSTOMER_DISPLAY_PATH}`; + const [networkInfoAttempt, setNetworkInfoAttempt] = useState(0); + const [networkInfoStatus, setNetworkInfoStatus] = useState('loading'); + const [networkAddresses, setNetworkAddresses] = useState(null); + const [selectedOrigin, setSelectedOrigin] = useState(''); + const localPreviewOrigin = + typeof window === 'undefined' ? null : getHttpOrigin(window.location.origin); + const primaryOrigin = networkAddresses?.primary ?? null; + const otherOrigins = networkAddresses?.other ?? []; + const displayUrl = selectedOrigin ? `${selectedOrigin}${CUSTOMER_DISPLAY_PATH}` : ''; useEffect(() => { const controller = new AbortController(); @@ -201,8 +398,37 @@ export function CustomerDisplayCard() { return () => controller.abort(); }, []); + useEffect(() => { + const controller = new AbortController(); + api + .get('/pos-info', { signal: controller.signal }) + .then(({ data }) => { + if (controller.signal.aborted) return; + if (!isPosNetworkInfo(data)) throw new Error('invalid response'); + const addresses = getPairingAddresses(data); + setNetworkAddresses(addresses); + setNetworkInfoStatus('ready'); + const availableOrigins = [addresses.primary, ...addresses.other, localPreviewOrigin]; + setSelectedOrigin((current) => + current && availableOrigins.includes(current) ? current : addresses.primary || '', + ); + }) + .catch(() => { + if (controller.signal.aborted) return; + setNetworkAddresses(null); + setNetworkInfoStatus('error'); + setSelectedOrigin((current) => (current === localPreviewOrigin ? current : '')); + }); + return () => controller.abort(); + }, [networkInfoAttempt, localPreviewOrigin]); + const dirty = JSON.stringify(config) !== JSON.stringify(saved); + function retryNetworkInfo() { + setNetworkInfoStatus('loading'); + setNetworkInfoAttempt((attempt) => attempt + 1); + } + function toggleStatus(status: CustomerDisplayStatus) { setConfig((prev) => { const next = prev.statuses.includes(status) @@ -258,18 +484,20 @@ export function CustomerDisplayCard() { dir="ltr" className="flex-1 min-w-0 truncate rounded-lg border border-border bg-muted px-3 py-2 text-sm" > - {displayUrl} + {displayUrl || (networkInfoStatus === 'loading' ? tCommon('loading') : '')}
- +
); } diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 7a19fad92..85ed16d30 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "اختر حالة واحدة على الأقل", "pairedTitle": "الشاشات المقترنة", "pairedHint": "اقرن تلفازًا أو جهازًا لوحيًا ليعرض اللوحة دون تسجيل دخول الموظفين. يرى الجهاز المقترن أرقام الطلبات وحالاتها فقط.", + "primaryLanAddress": "عنوان الشبكة المحلية الرئيسي", + "otherAddresses": "عرض العناوين الأخرى", + "localPreviewAddress": "هذا الكمبيوتر فقط (معاينة محلية)", + "lanPairingUnavailable": "الإقران عبر الشبكة المحلية غير متاح. اختر المعاينة المحلية لاستخدام هذا الكمبيوتر فقط.", + "networkInfoLoadFailed": "تعذر تحميل عناوين الشبكة.", "pairLabel": "اسم الشاشة", "pairLabelPlaceholder": "مثال: شاشة الكاونتر", "pairCreate": "اقتران الشاشة", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index d1bc972ab..dbf775919 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "কমপক্ষে একটি স্ট্যাটাস বেছে নিন", "pairedTitle": "পেয়ার করা ডিসপ্লে", "pairedHint": "স্টাফ লগইন ছাড়াই বোর্ড দেখানোর জন্য একটি টিভি বা ট্যাবলেট পেয়ার করুন। পেয়ার করা ডিভাইস শুধু অর্ডার নম্বর ও অবস্থা দেখতে পায়।", + "primaryLanAddress": "প্রধান স্থানীয় নেটওয়ার্ক ঠিকানা", + "otherAddresses": "অন্যান্য ঠিকানাগুলি দেখুন", + "localPreviewAddress": "শুধু এই কম্পিউটার (স্থানীয় প্রিভিউ)", + "lanPairingUnavailable": "স্থানীয় নেটওয়ার্কে পেয়ার করা যাচ্ছে না। শুধু এই কম্পিউটারে ব্যবহার করতে স্থানীয় প্রিভিউ বেছে নিন।", + "networkInfoLoadFailed": "নেটওয়ার্ক ঠিকানাগুলি লোড করা যায়নি।", "pairLabel": "ডিসপ্লের নাম", "pairLabelPlaceholder": "যেমন: কাউন্টার টিভি", "pairCreate": "ডিসপ্লে পেয়ার করুন", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 5edf5196f..0d14686d9 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Wählen Sie mindestens einen Status", "pairedTitle": "Gekoppelte Anzeigen", "pairedHint": "Koppeln Sie einen Fernseher oder ein Tablet, damit er die Tafel ohne Mitarbeiter-Login anzeigt. Ein gekoppeltes Gerät sieht nur Bestellnummern und Status.", + "primaryLanAddress": "Primäre LAN-Adresse", + "otherAddresses": "Weitere Adressen anzeigen", + "localPreviewAddress": "Nur auf diesem Computer (lokale Vorschau)", + "lanPairingUnavailable": "LAN-Kopplung ist nicht verfügbar. Wähle die lokale Vorschau, um nur diesen Computer zu verwenden.", + "networkInfoLoadFailed": "Netzwerkadressen konnten nicht geladen werden.", "pairLabel": "Name der Anzeige", "pairLabelPlaceholder": "z. B. Theken-TV", "pairCreate": "Anzeige koppeln", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 49a66e590..309896914 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Select at least one status", "pairedTitle": "Paired displays", "pairedHint": "Pair a TV or tablet so it can show the board without a staff login. A paired device can only see order numbers and statuses.", + "primaryLanAddress": "Primary LAN address", + "otherAddresses": "Show other addresses", + "localPreviewAddress": "This computer only (local preview)", + "lanPairingUnavailable": "LAN pairing is unavailable. Select the local preview to use this computer only.", + "networkInfoLoadFailed": "Could not load network addresses.", "pairLabel": "Display name", "pairLabelPlaceholder": "e.g. Counter TV", "pairCreate": "Pair display", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 3d09a8db8..24f0a5d5a 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Seleccione al menos un estado", "pairedTitle": "Pantallas vinculadas", "pairedHint": "Vincule un televisor o una tableta para que muestre el tablero sin iniciar sesión como personal. Un dispositivo vinculado solo ve números de pedido y estados.", + "primaryLanAddress": "Dirección LAN principal", + "otherAddresses": "Mostrar otras direcciones", + "localPreviewAddress": "Solo este ordenador (vista previa local)", + "lanPairingUnavailable": "No se puede emparejar por LAN. Selecciona la vista previa local para usar solo este ordenador.", + "networkInfoLoadFailed": "No se pudieron cargar las direcciones de red.", "pairLabel": "Nombre de la pantalla", "pairLabelPlaceholder": "p. ej., TV del mostrador", "pairCreate": "Vincular pantalla", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 30597f00d..ddd532924 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "حداقل یک وضعیت را انتخاب کنید", "pairedTitle": "نمایشگرهای جفت‌شده", "pairedHint": "یک تلویزیون یا تبلت را جفت کنید تا بدون ورود کارکنان تابلو را نشان دهد. دستگاه جفت‌شده فقط شماره و وضعیت سفارش‌ها را می‌بیند.", + "primaryLanAddress": "نشانی اصلی شبکه محلی", + "otherAddresses": "نمایش نشانی‌های دیگر", + "localPreviewAddress": "فقط در این رایانه (پیش‌نمایش محلی)", + "lanPairingUnavailable": "جفت‌سازی شبکه محلی در دسترس نیست. برای استفاده فقط روی این رایانه، پیش‌نمایش محلی را انتخاب کنید.", + "networkInfoLoadFailed": "نشانی‌های شبکه بارگیری نشدند.", "pairLabel": "نام نمایشگر", "pairLabelPlaceholder": "مثلاً تلویزیون پیشخوان", "pairCreate": "جفت کردن نمایشگر", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 0651b22da..ec951097e 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Pumili ng kahit isang status", "pairedTitle": "Mga naka-pair na display", "pairedHint": "Mag-pair ng TV o tablet para maipakita nito ang board nang walang login ng staff. Numero at status lang ng order ang nakikita ng naka-pair na device.", + "primaryLanAddress": "Pangunahing LAN address", + "otherAddresses": "Ipakita ang iba pang address", + "localPreviewAddress": "Sa computer na ito lang (lokal na preview)", + "lanPairingUnavailable": "Hindi available ang pagpapares sa LAN. Piliin ang lokal na preview para gamitin lang ang computer na ito.", + "networkInfoLoadFailed": "Hindi ma-load ang mga network address.", "pairLabel": "Pangalan ng display", "pairLabelPlaceholder": "hal. TV sa counter", "pairCreate": "I-pair ang display", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index dfabccef5..a9a91747d 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Sélectionnez au moins un état", "pairedTitle": "Écrans associés", "pairedHint": "Associez un téléviseur ou une tablette pour afficher le tableau sans connexion du personnel. Un appareil associé ne voit que les numéros et états des commandes.", + "primaryLanAddress": "Adresse LAN principale", + "otherAddresses": "Afficher les autres adresses", + "localPreviewAddress": "Cet ordinateur uniquement (aperçu local)", + "lanPairingUnavailable": "L’association sur le réseau local est indisponible. Choisissez l’aperçu local pour utiliser cet ordinateur uniquement.", + "networkInfoLoadFailed": "Impossible de charger les adresses réseau.", "pairLabel": "Nom de l'écran", "pairLabelPlaceholder": "p. ex. TV du comptoir", "pairCreate": "Associer l'écran", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index a2a365650..8bd063e4b 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "कम से कम एक स्थिति चुनें", "pairedTitle": "पेयर किए गए डिस्प्ले", "pairedHint": "स्टाफ लॉगिन के बिना बोर्ड दिखाने के लिए टीवी या टैबलेट पेयर करें। पेयर किया गया डिवाइस केवल ऑर्डर नंबर और स्थिति देख सकता है।", + "primaryLanAddress": "मुख्य LAN पता", + "otherAddresses": "अन्य पते दिखाएँ", + "localPreviewAddress": "केवल यह कंप्यूटर (स्थानीय पूर्वावलोकन)", + "lanPairingUnavailable": "LAN पेयरिंग उपलब्ध नहीं है। केवल इसी कंप्यूटर पर उपयोग करने के लिए स्थानीय पूर्वावलोकन चुनें।", + "networkInfoLoadFailed": "नेटवर्क पते लोड नहीं हो सके।", "pairLabel": "डिस्प्ले का नाम", "pairLabelPlaceholder": "जैसे: काउंटर टीवी", "pairCreate": "डिस्प्ले पेयर करें", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index a3b51433d..956e72ad3 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Pilih setidaknya satu status", "pairedTitle": "Layar terhubung", "pairedHint": "Hubungkan TV atau tablet agar dapat menampilkan papan tanpa login staf. Perangkat yang terhubung hanya melihat nomor dan status pesanan.", + "primaryLanAddress": "Alamat LAN utama", + "otherAddresses": "Tampilkan alamat lain", + "localPreviewAddress": "Hanya komputer ini (pratinjau lokal)", + "lanPairingUnavailable": "Pemasangan melalui LAN tidak tersedia. Pilih pratinjau lokal untuk menggunakan komputer ini saja.", + "networkInfoLoadFailed": "Alamat jaringan tidak dapat dimuat.", "pairLabel": "Nama layar", "pairLabelPlaceholder": "mis. TV kasir", "pairCreate": "Hubungkan layar", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index fe861e0fd..009e71497 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Seleziona almeno uno stato", "pairedTitle": "Display associati", "pairedHint": "Associa un televisore o un tablet per mostrare la bacheca senza accesso del personale. Un dispositivo associato vede solo numeri e stati degli ordini.", + "primaryLanAddress": "Indirizzo LAN principale", + "otherAddresses": "Mostra altri indirizzi", + "localPreviewAddress": "Solo questo computer (anteprima locale)", + "lanPairingUnavailable": "L'associazione tramite LAN non è disponibile. Seleziona l'anteprima locale per usare solo questo computer.", + "networkInfoLoadFailed": "Impossibile caricare gli indirizzi di rete.", "pairLabel": "Nome del display", "pairLabelPlaceholder": "es. TV del banco", "pairCreate": "Associa display", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index 06b07a20b..417c0da86 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "ステータスを1つ以上選択してください", "pairedTitle": "ペアリング済みディスプレイ", "pairedHint": "スタッフのログインなしで掲示板を表示できるよう、テレビやタブレットをペアリングします。ペアリング済みの端末には注文番号とステータスのみが表示されます。", + "primaryLanAddress": "メインのLANアドレス", + "otherAddresses": "他のアドレスを表示", + "localPreviewAddress": "このコンピューターのみ(ローカルプレビュー)", + "lanPairingUnavailable": "LANでのペアリングは利用できません。このコンピューターだけで使用するにはローカルプレビューを選択してください。", + "networkInfoLoadFailed": "ネットワークアドレスを読み込めませんでした。", "pairLabel": "ディスプレイ名", "pairLabelPlaceholder": "例:カウンターTV", "pairCreate": "ディスプレイをペアリング", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index 7e421cbf0..c919ca281 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "상태를 하나 이상 선택하세요", "pairedTitle": "페어링된 디스플레이", "pairedHint": "직원 로그인 없이 보드를 표시할 TV나 태블릿을 페어링하세요. 페어링된 기기는 주문 번호와 상태만 볼 수 있습니다.", + "primaryLanAddress": "기본 LAN 주소", + "otherAddresses": "다른 주소 표시", + "localPreviewAddress": "이 컴퓨터에서만 (로컬 미리보기)", + "lanPairingUnavailable": "LAN 페어링을 사용할 수 없습니다. 이 컴퓨터에서만 사용하려면 로컬 미리보기를 선택하세요.", + "networkInfoLoadFailed": "네트워크 주소를 불러오지 못했습니다.", "pairLabel": "디스플레이 이름", "pairLabelPlaceholder": "예: 카운터 TV", "pairCreate": "디스플레이 페어링", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index d331ba4ff..a0ca8158c 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "कम्तीमा एउटा स्थिति छान्नुहोस्", "pairedTitle": "जोडा बनाइएका डिस्प्ले", "pairedHint": "स्टाफ लगइनबिना बोर्ड देखाउन टिभी वा ट्याब्लेट जोडा बनाउनुहोस्। जोडा बनाइएको उपकरणले अर्डर नम्बर र स्थिति मात्र देख्न सक्छ।", + "primaryLanAddress": "मुख्य LAN ठेगाना", + "otherAddresses": "अन्य ठेगानाहरू देखाउनुहोस्", + "localPreviewAddress": "यो कम्प्युटरमा मात्र (स्थानीय पूर्वावलोकन)", + "lanPairingUnavailable": "LAN जोडा उपलब्ध छैन। यो कम्प्युटरमा मात्र प्रयोग गर्न स्थानीय पूर्वावलोकन छान्नुहोस्।", + "networkInfoLoadFailed": "नेटवर्क ठेगानाहरू लोड गर्न सकिएन।", "pairLabel": "डिस्प्लेको नाम", "pairLabelPlaceholder": "जस्तै: काउन्टर टिभी", "pairCreate": "डिस्प्ले जोडा बनाउनुहोस्", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index d864779de..34b6b7cba 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Selecteer minstens één status", "pairedTitle": "Gekoppelde schermen", "pairedHint": "Koppel een tv of tablet zodat het bord zonder personeelslogin wordt getoond. Een gekoppeld apparaat ziet alleen bestelnummers en statussen.", + "primaryLanAddress": "Primair LAN-adres", + "otherAddresses": "Andere adressen tonen", + "localPreviewAddress": "Alleen deze computer (lokale preview)", + "lanPairingUnavailable": "Koppelen via LAN is niet beschikbaar. Kies de lokale preview om deze computer alleen te gebruiken.", + "networkInfoLoadFailed": "Netwerkadressen konden niet worden geladen.", "pairLabel": "Naam van het scherm", "pairLabelPlaceholder": "bijv. Balie-tv", "pairCreate": "Scherm koppelen", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 5f755d402..3c07c2321 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Selecione pelo menos um estado", "pairedTitle": "Ecrãs emparelhados", "pairedHint": "Emparelhe uma TV ou tablet para mostrar o painel sem início de sessão do pessoal. Um dispositivo emparelhado só vê números e estados dos pedidos.", + "primaryLanAddress": "Endereço LAN principal", + "otherAddresses": "Mostrar outros endereços", + "localPreviewAddress": "Apenas neste computador (pré-visualização local)", + "lanPairingUnavailable": "O emparelhamento por LAN não está disponível. Selecione a pré-visualização local para usar apenas este computador.", + "networkInfoLoadFailed": "Não foi possível carregar os endereços de rede.", "pairLabel": "Nome do ecrã", "pairLabelPlaceholder": "p. ex. TV do balcão", "pairCreate": "Emparelhar ecrã", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index d170eafae..21dbd0648 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Выберите хотя бы один статус", "pairedTitle": "Подключённые экраны", "pairedHint": "Подключите телевизор или планшет, чтобы он показывал табло без входа сотрудника. Подключённое устройство видит только номера и статусы заказов.", + "primaryLanAddress": "Основной адрес в локальной сети", + "otherAddresses": "Показать другие адреса", + "localPreviewAddress": "Только этот компьютер (локальный просмотр)", + "lanPairingUnavailable": "Сопряжение по локальной сети недоступно. Выберите локальный просмотр, чтобы использовать только этот компьютер.", + "networkInfoLoadFailed": "Не удалось загрузить сетевые адреса.", "pairLabel": "Название экрана", "pairLabelPlaceholder": "напр. ТВ на стойке", "pairCreate": "Подключить экран", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index e5b34a634..a4dc4b339 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Zgjidhni të paktën një status", "pairedTitle": "Ekranet e çiftuara", "pairedHint": "Çiftoni një televizor ose tablet që të shfaqë tabelën pa hyrje të stafit. Pajisja e çiftuar sheh vetëm numrat dhe statuset e porosive.", + "primaryLanAddress": "Adresa kryesore LAN", + "otherAddresses": "Shfaq adresat e tjera", + "localPreviewAddress": "Vetëm në këtë kompjuter (parapamje lokale)", + "lanPairingUnavailable": "Çiftimi përmes LAN-it nuk është i disponueshëm. Zgjidhni parapamjen lokale për ta përdorur vetëm në këtë kompjuter.", + "networkInfoLoadFailed": "Adresat e rrjetit nuk mund të ngarkoheshin.", "pairLabel": "Emri i ekranit", "pairLabelPlaceholder": "p.sh. TV e banakut", "pairCreate": "Çifto ekranin", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index 436a10514..81ec90770 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "เลือกอย่างน้อยหนึ่งสถานะ", "pairedTitle": "จอที่จับคู่แล้ว", "pairedHint": "จับคู่ทีวีหรือแท็บเล็ตเพื่อแสดงกระดานโดยไม่ต้องล็อกอินพนักงาน อุปกรณ์ที่จับคู่จะเห็นเฉพาะหมายเลขและสถานะคำสั่งซื้อ", + "primaryLanAddress": "ที่อยู่ LAN หลัก", + "otherAddresses": "แสดงที่อยู่อื่น", + "localPreviewAddress": "เฉพาะคอมพิวเตอร์เครื่องนี้ (ตัวอย่างในเครื่อง)", + "lanPairingUnavailable": "ไม่สามารถจับคู่ผ่าน LAN ได้ เลือกตัวอย่างในเครื่องเพื่อใช้เฉพาะคอมพิวเตอร์เครื่องนี้", + "networkInfoLoadFailed": "โหลดที่อยู่เครือข่ายไม่สำเร็จ", "pairLabel": "ชื่อจอแสดงผล", "pairLabelPlaceholder": "เช่น ทีวีหน้าเคาน์เตอร์", "pairCreate": "จับคู่จอแสดงผล", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index dad65a012..c57a94470 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "En az bir durum seçin", "pairedTitle": "Eşleştirilmiş ekranlar", "pairedHint": "Personel girişi olmadan panoyu göstermesi için bir TV veya tablet eşleştirin. Eşleştirilmiş cihaz yalnızca sipariş numaralarını ve durumlarını görür.", + "primaryLanAddress": "Birincil LAN adresi", + "otherAddresses": "Diğer adresleri göster", + "localPreviewAddress": "Yalnızca bu bilgisayar (yerel önizleme)", + "lanPairingUnavailable": "LAN üzerinden eşleştirme kullanılamıyor. Yalnızca bu bilgisayarda kullanmak için yerel önizlemeyi seçin.", + "networkInfoLoadFailed": "Ağ adresleri yüklenemedi.", "pairLabel": "Ekran adı", "pairLabelPlaceholder": "örn. Kasa TV'si", "pairCreate": "Ekranı eşleştir", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index 6616885ca..3c39fcc63 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "کم از کم ایک حیثیت منتخب کریں", "pairedTitle": "جوڑے گئے ڈسپلے", "pairedHint": "اسٹاف لاگ اِن کے بغیر بورڈ دکھانے کے لیے ٹی وی یا ٹیبلٹ جوڑیں۔ جوڑا گیا آلہ صرف آرڈر نمبر اور حیثیت دیکھ سکتا ہے۔", + "primaryLanAddress": "بنیادی LAN پتہ", + "otherAddresses": "دیگر پتے دکھائیں", + "localPreviewAddress": "صرف یہ کمپیوٹر (مقامی پیش منظر)", + "lanPairingUnavailable": "LAN جوڑنا دستیاب نہیں ہے۔ صرف اس کمپیوٹر پر استعمال کے لیے مقامی پیش منظر منتخب کریں۔", + "networkInfoLoadFailed": "نیٹ ورک پتے لوڈ نہیں ہو سکے۔", "pairLabel": "ڈسپلے کا نام", "pairLabelPlaceholder": "مثلاً کاؤنٹر ٹی وی", "pairCreate": "ڈسپلے جوڑیں", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index f8cfa61cd..40c31d7f6 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "Chọn ít nhất một trạng thái", "pairedTitle": "Màn hình đã ghép nối", "pairedHint": "Ghép nối TV hoặc máy tính bảng để hiển thị bảng mà không cần nhân viên đăng nhập. Thiết bị đã ghép nối chỉ thấy số và trạng thái đơn hàng.", + "primaryLanAddress": "Địa chỉ LAN chính", + "otherAddresses": "Hiện địa chỉ khác", + "localPreviewAddress": "Chỉ máy tính này (bản xem trước cục bộ)", + "lanPairingUnavailable": "Không thể ghép nối qua LAN. Hãy chọn bản xem trước cục bộ để chỉ dùng trên máy tính này.", + "networkInfoLoadFailed": "Không tải được địa chỉ mạng.", "pairLabel": "Tên màn hình", "pairLabelPlaceholder": "vd: TV quầy", "pairCreate": "Ghép nối màn hình", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index d1882ca4f..8b3451fd5 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "請至少選擇一個狀態", "pairedTitle": "已配對的顯示螢幕", "pairedHint": "配對電視或平板,讓它無需員工登入即可顯示看板。已配對的裝置只能看到訂單編號與狀態。", + "primaryLanAddress": "主要 LAN 位址", + "otherAddresses": "顯示其他位址", + "localPreviewAddress": "僅限這台電腦(本機預覽)", + "lanPairingUnavailable": "LAN 配對目前無法使用。選擇本機預覽即可僅在這台電腦上使用。", + "networkInfoLoadFailed": "無法載入網路位址。", "pairLabel": "顯示螢幕名稱", "pairLabelPlaceholder": "例如:櫃檯電視", "pairCreate": "配對顯示螢幕", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index c70e4284e..d3f564e5b 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -237,6 +237,11 @@ "atLeastOneStatus": "请至少选择一个状态", "pairedTitle": "已配对的显示屏", "pairedHint": "配对电视或平板,使其无需员工登录即可显示看板。已配对的设备只能看到订单号和状态。", + "primaryLanAddress": "主要局域网地址", + "otherAddresses": "显示其他地址", + "localPreviewAddress": "仅此电脑(本地预览)", + "lanPairingUnavailable": "局域网配对不可用。选择本地预览即可仅在此电脑上使用。", + "networkInfoLoadFailed": "无法加载网络地址。", "pairLabel": "显示屏名称", "pairLabelPlaceholder": "例如:柜台电视", "pairCreate": "配对显示屏",