From 47ee23777d91d384473e1f6a3b86fa287a49b324 Mon Sep 17 00:00:00 2001 From: thanawat saiyota Date: Tue, 30 Jun 2026 17:38:59 +0700 Subject: [PATCH] feat: main & brewing video tool, catalog APIs, sheet/recipe updates - Add /tools/video-mainpage page (main + brewing-page advertisement videos, date-gated, per-country, push to machine over ADB) + api/video-mainpage create/list/update proxies; sidebar entry "Main & Brewing Video" - Add catalog API proxies (catalog-create, catalog-list, catalog-banner, catalog-banner-image) - Sheet: overview/edit/add/priceslot/price updates, stores & services - Misc: adb, websocket/message handlers, crypto, recipe & brew tweaks Co-Authored-By: Claude Opus 4.8 --- src/lib/components/app-sidebar.svelte | 21 +- src/lib/core/adb/adb.ts | 3 +- src/lib/core/handlers/messageHandler.ts | 41 +- src/lib/core/handlers/ws_messageSender.ts | 52 +- src/lib/core/services/sheetService.ts | 197 ++- src/lib/core/stores/sheetStore.ts | 206 +++- src/lib/core/stores/websocketStore.ts | 40 +- src/lib/core/utils/crypto.ts | 16 +- src/routes/(authed)/departments/+page.svelte | 6 +- .../(authed)/recipe/material/+page.svelte | 802 +++++++++++-- .../(authed)/recipe/topping/+page.svelte | 538 +++++++-- .../add/[country]/[catalog]/+page.svelte | 2 +- .../edit/[country]/[catalog]/+page.svelte | 204 +++- .../sheet/overview/[country]/+page.svelte | 1058 ++++++++++++++++- .../sheet/price/[country]/+page.svelte | 280 +++++ .../sheet/priceslot/[country]/+page.svelte | 968 +++++++++++++-- src/routes/(authed)/tools/brew/+page.svelte | 1 + .../(authed)/tools/create-menu/+page.svelte | 575 +++++++-- .../tools/video-mainpage/+page.svelte | 797 +++++++++++++ .../api/catalog-banner-image/+server.ts | 24 + src/routes/api/catalog-banner/+server.ts | 40 + src/routes/api/catalog-create/+server.ts | 62 + src/routes/api/catalog-list/+server.ts | 26 + src/routes/api/video-mainpage/+server.ts | 80 ++ src/routes/api/video-mainpage/list/+server.ts | 26 + .../api/video-mainpage/update/+server.ts | 56 + 26 files changed, 5582 insertions(+), 539 deletions(-) create mode 100644 src/routes/(authed)/sheet/price/[country]/+page.svelte create mode 100644 src/routes/(authed)/tools/video-mainpage/+page.svelte create mode 100644 src/routes/api/catalog-banner-image/+server.ts create mode 100644 src/routes/api/catalog-banner/+server.ts create mode 100644 src/routes/api/catalog-create/+server.ts create mode 100644 src/routes/api/catalog-list/+server.ts create mode 100644 src/routes/api/video-mainpage/+server.ts create mode 100644 src/routes/api/video-mainpage/list/+server.ts create mode 100644 src/routes/api/video-mainpage/update/+server.ts diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index 34de1f0..57a6194 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -17,6 +17,7 @@ PlusCircle, ImageUp, Video, + MonitorPlay, Sun, Moon } from '@lucide/svelte/icons'; @@ -125,6 +126,12 @@ url: '/tools/adv-upload', icon: Video, requirePerm: '' + }, + { + title: 'Main & Brewing Video', + url: '/tools/video-mainpage', + icon: MonitorPlay, + requirePerm: '' } ] }, @@ -142,6 +149,12 @@ url: '/departments', icon: DollarSign, requirePerm: 'document.write.*' + }, + { + title: 'Price', + url: '/departments', + icon: FileSpreadsheet, + requirePerm: 'document.write.*' } ] } @@ -235,7 +248,13 @@ onclick={(e) => { if (nav.title === 'Sheet') { e.preventDefault(); - referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet'); + referenceFromPage.set( + sub.title === 'PriceSlot' + ? 'priceslot' + : sub.title === 'Price' + ? 'price' + : 'sheet' + ); goto(sub.url); } }} diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts index 716911e..c5b0576 100644 --- a/src/lib/core/adb/adb.ts +++ b/src/lib/core/adb/adb.ts @@ -137,7 +137,7 @@ async function connectWithRetry( export async function connnectViaWebUSB(connectAndroidServer = true) { const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice(); - console.log('usb ok', globalThis.navigator.usb); + console.log('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb); if (device) { console.log('connect ', device.name); @@ -362,7 +362,6 @@ export async function executeCmd(command: string) { } } - export async function goToMachineHome() { if (!getAdbInstance()) return; try { diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts index 6bf44b2..9e7b8f6 100644 --- a/src/lib/core/handlers/messageHandler.ts +++ b/src/lib/core/handlers/messageHandler.ts @@ -27,7 +27,8 @@ import { sheetCatalogsLoading, handleRawStreamHeader, handleRawStreamChunk, - handleRawStreamEnd + handleRawStreamEnd, + handleSheetPriceResponse } from '../stores/sheetStore'; import { handleGenLayoutBatchStart, @@ -293,13 +294,14 @@ const handlers: Record void> = { if (from === 'sheet-service' && level === 'content') { const currentUid = auth.currentUser?.uid; const content = p.content ?? p.value ?? p.payload; + const ref = p.ref ?? ''; console.log('[Sheet] Notify content received:', { msg, target, currentUid, contentKeys: content && typeof content === 'object' ? Object.keys(content) : [], - content + contentItems: Array.isArray(content) ? content.length : undefined }); if (!target || (currentUid && target === currentUid)) { @@ -324,6 +326,12 @@ const handlers: Record void> = { return; } + if (!msg && ref === 'price') { + handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content); + addNotification('INFO:Loaded sheet price data'); + return; + } + // Handle streaming messages (with msg field) switch (msg) { case 'priceslot': @@ -332,19 +340,29 @@ const handlers: Record void> = { addNotification('INFO:Loaded PriceSlot data'); break; case 'start': - handleSheetStreamStart(p); - addNotification('INFO:Sheet data streaming started'); + if (ref === 'price') { + addNotification('INFO:Sheet price streaming started'); + } else { + handleSheetStreamStart(p); + addNotification('INFO:Sheet data streaming started'); + } break; case 'chunk': if (isPriceSlotsPayload(content)) { handlePriceSlotsResponse(content); + } else if (ref === 'price') { + handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content); } else { handleSheetStreamChunk(p); } break; case 'end': - handleSheetStreamEnd(p); - addNotification('INFO:Sheet data streaming complete'); + if (ref === 'price') { + addNotification('INFO:Sheet price streaming complete'); + } else { + handleSheetStreamEnd(p); + addNotification('INFO:Sheet data streaming complete'); + } break; case 'error': handleSheetStreamError(p); @@ -352,14 +370,16 @@ const handlers: Record void> = { break; default: // Handle other content notifications from sheet-service - console.log('[Sheet] Received content:', content); + console.log('[Sheet] Received content:', { + contentItems: Array.isArray(content) ? content.length : undefined + }); } } else { console.warn('[Sheet] Ignored content because target does not match current user:', { target, currentUid, msg, - content + contentItems: Array.isArray(content) ? content.length : undefined }); } return; @@ -438,6 +458,7 @@ const handlers: Record void> = { country: current_meta?.country ?? '', content: saved_product_code_to_get_from_sheet, param: 'price', + option: 'price', stream: true, request_id }); @@ -564,7 +585,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry ); let actual_message: WSMessage = JSON.parse(decrypted_string); if (actual_message.type !== 'heartbeat') { - console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload); + // console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload); } handlers[actual_message.type]?.(actual_message.payload); @@ -572,7 +593,7 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey?: Cry } else { const msg: WSMessage = parsedMessage; if (msg.type !== 'heartbeat') { - console.log(`[WS MSG] type=${msg.type}`, msg.payload); + // console.log(`[WS MSG] type=${msg.type}`, msg.payload); } if (msg == null) { // error response diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts index 852dfcb..7c68cdd 100644 --- a/src/lib/core/handlers/ws_messageSender.ts +++ b/src/lib/core/handlers/ws_messageSender.ts @@ -1,14 +1,17 @@ import { get, writable } from 'svelte/store'; import type { OutMessage } from '../types/outMessage'; -import { sharedKey, socketStore } from '../stores/websocketStore'; +import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore'; import { addNotification } from '../stores/noti'; import { auth } from '../stores/auth'; import { WebCryptoHelper } from '../utils/crypto'; -import * as semver from 'semver'; import { env } from '$env/dynamic/public'; export const queue = writable([]); +function isSecuredAppVersion(version: string | undefined) { + return version?.startsWith('0.0.2') ?? false; +} + type CommandRequest = 'sheet' | 'command'; function getServiceName(cmdReq: CommandRequest) { @@ -20,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) { } } +function waitForWsAuthReady(timeoutMs = 10000): Promise { + if (get(wsAuthReady)) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(false); + }, timeoutMs); + + unsubscribe = wsAuthReady.subscribe((ready) => { + if (!ready || settled) return; + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve(true); + }); + }); +} + // Websocket message wrapper for commands like `sheet`, `command` export async function sendCommandRequest(target: CommandRequest, values: any): Promise { + const authReady = await waitForWsAuthReady(); + if (!authReady) { + console.warn('[WS Send] Skip command request because websocket auth is not ready', { + target, + param: values?.param + }); + return false; + } + let srv_name = getServiceName(target); let curr_user = get(auth); @@ -71,10 +106,10 @@ export async function sendMessage( return false; } - // console.log('send v2', APP_VERSION, semver.satisfies(APP_VERSION, '^0.0.2')); + // console.log('send v2', APP_VERSION, isSecuredAppVersion(APP_VERSION)); - if (semver.satisfies(APP_VERSION, '^0.0.2')) { - // console.log('sending secured'); + if (isSecuredAppVersion(APP_VERSION)) { + console.log('sending secured'); let sharedKeyRes = get(sharedKey); // do encrypt @@ -82,6 +117,13 @@ export async function sendMessage( data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data)); } + // console.log('[WS Send]', { + // type: logMessage.type, + // service: logMessage.payload?.srv_name, + // param: logMessage.payload?.values?.param, + // bytes: data.length, + // secured: isSecuredAppVersion(APP_VERSION) + // }); socket.send(data); return true; } diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts index ac9d4f5..9b89acd 100644 --- a/src/lib/core/services/sheetService.ts +++ b/src/lib/core/services/sheetService.ts @@ -15,6 +15,10 @@ import { import type { PriceSlot } from '../stores/sheetStore'; import { setGenLayoutGenerating } from '../stores/genLayoutStore'; +type SheetCellUpdate = { value: string; coord: { row: number; col: number } }; +type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] }; +type SheetRowCreate = { header?: string[]; cells: string[] }; + export async function requestCatalogs(country: string): Promise { return await sendCommandRequest('sheet', { country: country, @@ -22,9 +26,36 @@ export async function requestCatalogs(country: string): Promise { }); } +/** + * Register a newly created catalog as a Grist table so it shows in the overview + * and menus can be added to it. `catalog` is the .skt filename produced by + * /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt"). + */ +export async function addCatalog( + country: string, + catalogName: string, + catalog: string +): Promise { + return await sendCommandRequest('sheet', { + country: country, + catalog: catalog, + catalog_name: catalogName, + param: 'add/catalog' + }); +} + export async function requestPriceSlots(country: string): Promise { setPendingPriceSlotsCountry(country); resetPriceSlotsCountry(country); + return requestPriceSlotOption(country, 'PriceSlot'); +} + +export async function requestPriceSlot(country: string, slotNumber: number): Promise { + setPendingPriceSlotsCountry(country); + return requestPriceSlotOption(country, `PriceSlot${slotNumber}`); +} + +async function requestPriceSlotOption(country: string, option: string): Promise { const request_id = crypto.randomUUID(); streamingRawData.update((data) => ({ @@ -41,7 +72,7 @@ export async function requestPriceSlots(country: string): Promise { const values = { country: country, param: 'price', - option: 'PriceSlot', + option, stream: true, request_id }; @@ -54,12 +85,120 @@ export async function requestPriceSlots(country: string): Promise { return sent; } -export async function updatePriceSlot(country: string, content: PriceSlot): Promise { - return await sendCommandRequest('sheet', { +export async function refreshPriceSlotList(country: string): Promise { + return requestPriceSlotOption(country, 'PriceSlot'); +} + +export async function updatePriceSlot( + country: string, + slot: PriceSlot, + content: SheetRowUpdate[] +): Promise { + // console.log('[sheetService] Sending PriceSlot update:', { + // country, + // slot: slot.slot, + // name: slot.name, + // description: slot.description, + // kind: slot.kind, + // rows: content.length, + // param: 'update/price', + // option: `PriceSlot${slot.slot}` + // }); + + const sent = await sendCommandRequest('sheet', { country: country, content: content, - param: 'update/priceslot' + param: 'update/price', + option: `PriceSlot${slot.slot}` }); + + console.log('[sheetService] PriceSlot update sent:', { + country, + slot: slot.slot, + sent + }); + + return sent; +} + +export async function addPriceSlot( + country: string, + slot: PriceSlot, + content: SheetRowCreate[] +): Promise { + console.log('[sheetService] Sending PriceSlot create:', { + country, + slot: slot.slot, + name: slot.name, + description: slot.description, + kind: slot.kind, + rows: content.length, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + const sent = await sendCommandRequest('sheet', { + country: country, + content: content, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + console.log('[sheetService] PriceSlot create sent:', { + country, + slot: slot.slot, + sent + }); + + return sent; +} + +export async function addPriceSlotRows( + country: string, + slot: PriceSlot, + content: SheetRowCreate[] +): Promise { + if (!content || content.length === 0) return true; + + const sent = await sendCommandRequest('sheet', { + country: country, + content: content, + param: 'add/price', + option: `PriceSlot${slot.slot}` + }); + + console.log('[sheetService] PriceSlot rows add sent:', { + country, + slot: slot.slot, + rows: content.length, + sent + }); + + return sent; +} + +export async function deletePriceSlotRows( + country: string, + slot: PriceSlot, + rowIds: number[] +): Promise { + if (!rowIds || rowIds.length === 0) return true; + + const sent = await sendCommandRequest('sheet', { + country: country, + content: rowIds.map((target_id) => ({ target_id })), + param: 'delete/price', + option: `PriceSlot${slot.slot}` + }); + + console.log('[sheetService] PriceSlot rows delete sent:', { + country, + slot: slot.slot, + rows: rowIds.length, + sent + }); + + return sent; } export async function enterRoom(country: string, catalog: string): Promise { @@ -208,9 +347,13 @@ export async function requestGenLayout(country: string): Promise { * Request price data from sheet for specific product codes * NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check. */ -export async function requestSheetPrice(country: string, productCodes: string[]): Promise { +export async function requestSheetPrice( + country: string, + productCodes: string[], + force = false +): Promise { // Check if already sent - if (hasSheetPriceBeenSent('price')) { + if (!force && hasSheetPriceBeenSent('price')) { console.warn('[sheetService] Price request already sent, skipping'); return false; } @@ -252,6 +395,48 @@ export async function requestSheetPrice(country: string, productCodes: string[]) country: country, content: content, param: 'price', + option: 'price', + stream: true, + request_id + }); + console.log('[sheetService] Sheet price request sent:', { country, request_id, sent }); + + if (sent) { + markSheetPriceAsSent('price'); + } else { + sheetPriceLoading.set(false); + } + + return sent; +} + +export async function requestAllSheetPrice(country: string, force = false): Promise { + if (!force && hasSheetPriceBeenSent('price')) { + console.warn('[sheetService] Price request already sent, skipping'); + return false; + } + + const request_id = crypto.randomUUID(); + + streamingRawData.update((data) => ({ + ...data, + price: { + request_id, + country, + chunks: [], + rawParts: [] + } + })); + + sheetPriceLoading.set(true); + + console.log('[sheetService] Sending all sheet price request:', { country, request_id }); + + const sent = await sendCommandRequest('sheet', { + country, + content: [], + param: 'price', + option: 'price', stream: true, request_id }); diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts index a17421b..cecd26f 100644 --- a/src/lib/core/stores/sheetStore.ts +++ b/src/lib/core/stores/sheetStore.ts @@ -40,6 +40,7 @@ export interface PriceSlot { } export const priceSlots = writable>({}); +export const priceSlotNamespaces = writable>({}); export const priceSlotsLoading = writable(false); export const priceSlotsError = writable(null); let pendingPriceSlotsCountry = ''; @@ -54,6 +55,10 @@ export function resetPriceSlotsCountry(country: string) { ...data, [key]: [] })); + priceSlotNamespaces.update((data) => ({ + ...data, + [key]: [] + })); priceSlotsError.set(null); } @@ -150,12 +155,22 @@ function normalizePriceSlot(slot: any, index: number): PriceSlot { }; } -export function handlePriceSlotsResponse(content: any) { - console.log('[PriceSlot] Raw backend response:', content); - const country = String( - content?.country ?? content?.Country ?? pendingPriceSlotsCountry - ).toLowerCase(); - const source = +function normalizePriceSlotNamespace(sheetName: string, index: number): PriceSlot { + const slotNumber = Number(sheetName.match(/\d+/)?.[0] ?? index + 1); + const slot = Number.isNaN(slotNumber) ? index + 1 : slotNumber; + + return { + slot, + name: sheetName || `PriceSlot${slot}`, + description: '', + kind: 'price', + header: [], + products: [] + }; +} + +function getPriceSlotSource(content: any) { + return ( content?.priceSlots ?? content?.priceslots ?? content?.price_slots ?? @@ -163,31 +178,70 @@ export function handlePriceSlotsResponse(content: any) { content?.data ?? content?.value ?? content?.content ?? - content; - const slotList = Array.isArray(source) - ? source - : typeof source === 'object' && source - ? Object.entries(source).map(([key, value]) => ({ - ...(typeof value === 'object' && value ? value : {}), - name: (value as any)?.name ?? key - })) - : []; + content + ); +} + +function getPriceSlotItems(content: any): any[] { + const source = getPriceSlotSource(content); + + if (Array.isArray(source)) { + return source.flatMap((item) => { + if (Array.isArray(item?.sheet)) { + return item.sheet.map((sheetName: any, index: number) => + normalizePriceSlotNamespace(String(sheetName ?? ''), index) + ); + } + return [item]; + }); + } + if (Array.isArray(source?.sheet)) { + return source.sheet.map((sheetName: any, index: number) => + normalizePriceSlotNamespace(String(sheetName ?? ''), index) + ); + } + if (typeof source?.sheet === 'string' && source.sheet.startsWith('PriceSlot')) return [source]; + if (typeof source === 'object' && source) { + return Object.entries(source).map(([key, value]) => ({ + ...(typeof value === 'object' && value ? value : {}), + name: (value as any)?.name ?? key + })); + } + + return []; +} + +export function handlePriceSlotsResponse(content: any) { + console.log('[PriceSlot] Raw backend response:', { + items: Array.isArray(content) ? content.length : undefined, + keys: + content && typeof content === 'object' && !Array.isArray(content) ? Object.keys(content) : [] + }); + const country = String( + content?.country ?? content?.Country ?? pendingPriceSlotsCountry + ).toLowerCase(); + const source = getPriceSlotSource(content); + const slotList = getPriceSlotItems(content); if (!country || slotList.length === 0) { - console.warn('[PriceSlot] No slot list found:', { country, source, content }); + console.warn('[PriceSlot] No slot list found:', { + country, + sourceItems: Array.isArray(source) ? source.length : undefined + }); priceSlotsError.set('No PriceSlot data found in backend response'); priceSlotsLoading.set(false); return; } - const normalizedSlots = slotList - .map(normalizePriceSlot) - .filter((slot) => - slot.kind === 'service' ? (slot.serviceRows?.length ?? 0) > 0 : slot.products.length > 0 - ); + const normalizedSlots = slotList.map((slot, index) => + isPriceSlotNamespace(slot) ? slot : normalizePriceSlot(slot, index) + ); if (normalizedSlots.length === 0) { - console.warn('[PriceSlot] Response did not include usable rows:', { country, slotList }); + console.warn('[PriceSlot] Response did not include usable rows:', { + country, + slotListItems: slotList.length + }); return; } @@ -195,15 +249,41 @@ export function handlePriceSlotsResponse(content: any) { country, slots: normalizedSlots.length, firstSlot: normalizedSlots[0] + ? { + slot: normalizedSlots[0].slot, + name: normalizedSlots[0].name, + kind: normalizedSlots[0].kind, + products: normalizedSlots[0].products.length, + serviceRows: normalizedSlots[0].serviceRows?.length ?? 0 + } + : undefined }); - priceSlots.update((data) => { - const merged = new Map(); + const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any)); + + if (loadedSlots.length > 0) { + priceSlots.update((data) => { + const merged = new Map(); + for (const slot of data[country] ?? []) { + merged.set(slot.slot, slot); + } + for (const slot of loadedSlots) { + merged.set(slot.slot, slot); + } + + return { + ...data, + [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot) + }; + }); + } + priceSlotNamespaces.update((data) => { + const merged = new Map(); for (const slot of data[country] ?? []) { - merged.set(`${slot.slot}:${slot.name}`, slot); + merged.set(slot.slot, slot); } for (const slot of normalizedSlots) { - merged.set(`${slot.slot}:${slot.name}`, slot); + merged.set(slot.slot, slot); } return { @@ -216,19 +296,31 @@ export function handlePriceSlotsResponse(content: any) { } export function isPriceSlotsPayload(content: any): boolean { - const source = - content?.priceSlots ?? - content?.priceslots ?? - content?.price_slots ?? - content?.slots ?? - content?.data ?? - content?.value ?? - content?.content ?? - content; + const source = getPriceSlotSource(content); if (content?.param === 'priceslot' || content?.option === 'PriceSlot') return true; + if (Array.isArray(source?.sheet)) { + return source.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot')); + } + if (typeof source?.sheet === 'string') return source.sheet.startsWith('PriceSlot'); if (!Array.isArray(source)) return false; - return source.some((item) => String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot')); + return source.some( + (item) => + String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + (Array.isArray(item?.sheet) && + item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) + ); +} + +function isPriceSlotNamespace(slot: any): slot is PriceSlot { + return ( + typeof slot?.slot === 'number' && + Array.isArray(slot?.products) && + slot.products.length === 0 && + Array.isArray(slot?.header) && + slot.header.length === 0 && + slot.name?.startsWith?.('PriceSlot') + ); } export const countryPrimaryLanguageMap: Record = { @@ -277,7 +369,12 @@ export function getCountryPrimaryLanguage(countryCode: string): string { export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< string, { + // Column→language map for the new-layout-v2 sheet (menu name/desc rows). language: Record; + // Column→language map for the name-desc-v2 sheet (Translations). Different + // namespace/sheet so the columns can differ from new-layout-v2; falls back + // to `language` when not set (countries where the two are identical). + nameDescLanguage?: Record; productCode: { hot: number; cold: number; blend: number }; primaryLanguage: string; } @@ -289,6 +386,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< }, aus: { language: { en: 3, th: 4 }, + nameDescLanguage: { en: 3, th: 4, ms: 7 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'en' }, @@ -299,11 +397,13 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< }, hkg: { language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 }, + nameDescLanguage: { en: 3, zh_hans: 4, zh_hant: 5 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'zh_hant' }, ltu: { language: { en: 3, th: 4, lt: 5, ro: 6 }, + nameDescLanguage: { en: 3, lt: 5, ro: 6 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'lt' }, @@ -329,6 +429,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record< }, sgp: { language: { en: 3, th: 4 }, + nameDescLanguage: { en: 3 }, productCode: { hot: 9, cold: 10, blend: 11 }, primaryLanguage: 'en' }, @@ -596,10 +697,22 @@ export function getPriceFromCells( cells: GristCell[], priceType: 'cash_price' | 'non_cash_price' = 'cash_price' ): string | null { + const colIdx = getPriceColumnIndex(country, priceType); + if (colIdx < 0) return null; + + // Find the cell with matching column index + const priceCell = cells.find((c) => c.coord?.col === colIdx); + return priceCell?.value ?? null; +} + +export function getPriceColumnIndex( + country: string, + priceType: 'cash_price' | 'non_cash_price' = 'cash_price' +): number { const headers = get(sheetPriceHeader)[country]; if (!headers || headers.length === 0) { console.warn(`[getPriceFromCells] No header found for country: ${country}`); - return null; + return -1; } // Get possible header names for this country @@ -617,13 +730,10 @@ export function getPriceFromCells( `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames ); - return null; + return -1; } - // Find the cell with matching column index - const priceCell = cells.find((c) => c.coord?.col === colIdx); - //console.log(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell); - return priceCell?.value ?? null; + return colIdx; } // Store for tracking streaming state @@ -790,11 +900,16 @@ export function handleRawStreamEnd(subtype: string, payload: any) { if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) { handlePriceSlotsResponse({ country, slots: chunks }); } + if (targetSubtype === 'priceslot') { + priceSlotsLoading.set(false); + } if (targetSubtype === 'price') { const looksLikePriceSlot = chunks.some((item) => { return ( String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') || + (Array.isArray(item?.sheet) && + item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) || item?.option === 'PriceSlot' || item?.param === 'priceslot' ); @@ -970,6 +1085,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[]) } } +export function handleSheetPriceResponse(country: string, content: any) { + const resolvedCountry = country || get(streamingRawData).price?.country || ''; + const chunks = Array.isArray(content) ? content : [content]; + processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks); + sheetPriceLoading.set(false); +} + // Reset sheet price stores export function resetSheetPriceStore() { sheetPriceStreamMeta.set(null); diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts index 4fa779c..ca540da 100644 --- a/src/lib/core/stores/websocketStore.ts +++ b/src/lib/core/stores/websocketStore.ts @@ -18,6 +18,7 @@ const ENABLE_WS_DEBUG: boolean = false; export const socketConnectionOfflineCount = writable(0); export const socketAlreadySendHeartbeat = writable(0); export const socketStore = writable(null); +export const wsAuthReady = writable(false); export const sharedKey = writable(null); @@ -53,6 +54,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise { }); } +export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise { + const openSocket = await waitForOpenSocket(timeoutMs); + if (!openSocket) return null; + if (get(wsAuthReady)) return openSocket; + + return new Promise((resolve) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + unsubscribe(); + resolve(null); + }, timeoutMs); + + unsubscribe = wsAuthReady.subscribe((ready) => { + if (!ready || settled) return; + settled = true; + clearTimeout(timeout); + unsubscribe(); + resolve(openSocket); + }); + }); +} + export async function connectToWebsocket(id_token?: string) { if (browser) { // console.log('connecting to ', env.PUBLIC_WSS); @@ -63,6 +89,7 @@ export async function connectToWebsocket(id_token?: string) { let ws_url = env.PUBLIC_WSS; socket = new WebSocket(ws_url); + wsAuthReady.set(false); sharedKey.set(null); const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair(); @@ -87,13 +114,16 @@ export async function connectToWebsocket(id_token?: string) { sendAuthInfoInterval = setInterval(async () => { if (get(sharedKey)) { + auth_data = get(authStore); + perms = get(permission); // Debug: check if auth_data has uid console.log('[WS Auth] Sending auth info with:', { uid: auth_data?.uid, name: auth_data?.displayName, - email: auth_data?.email + email: auth_data?.email, + date: new Date() }); - await sendMessage({ + const sent = await sendMessage({ type: 'auth', payload: { user: { @@ -104,9 +134,10 @@ export async function connectToWebsocket(id_token?: string) { } } }); + wsAuthReady.set(sent); clearInterval(sendAuthInfoInterval); } - }, 3000); + }, 2000); } console.log(socket); @@ -159,10 +190,12 @@ export async function connectToWebsocket(id_token?: string) { socket.addEventListener('close', () => { socketStore.set(null); + wsAuthReady.set(false); sharedKey.set(null); socket = null; clearInterval(socketCheck); + clearInterval(sendAuthInfoInterval); if (auth.currentUser && !socket) { console.log('try reconnect websocket ...'); @@ -177,6 +210,7 @@ export async function connectToWebsocket(id_token?: string) { socket.addEventListener('error', (e) => { // console.log('WebSocket error: ', e); socketStore.set(null); + wsAuthReady.set(false); sharedKey.set(null); }); } catch (socket_error: any) { diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts index 0803966..7cbe33f 100644 --- a/src/lib/core/utils/crypto.ts +++ b/src/lib/core/utils/crypto.ts @@ -1,4 +1,14 @@ export class WebCryptoHelper { + private static bytesToBase64(bytes: Uint8Array) { + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + + return btoa(binary); + } + static async generateKeyPair() { const keyPair = await window.crypto.subtle.generateKey( { @@ -10,7 +20,7 @@ export class WebCryptoHelper { ); const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); - const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic))); + const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic)); return { privateKey: keyPair.privateKey, publicKeyBase64 }; } @@ -60,8 +70,8 @@ export class WebCryptoHelper { encodedText ); - const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer))); - const ivBase64 = btoa(String.fromCharCode(...iv)); + const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer)); + const ivBase64 = WebCryptoHelper.bytesToBase64(iv); return { ciphertext: ciphertextBase64, iv: ivBase64 }; } diff --git a/src/routes/(authed)/departments/+page.svelte b/src/routes/(authed)/departments/+page.svelte index 77ce3d9..31a295c 100644 --- a/src/routes/(authed)/departments/+page.svelte +++ b/src/routes/(authed)/departments/+page.svelte @@ -27,6 +27,8 @@ if (refPage === 'priceslot') { await goto(`/sheet/priceslot/${cnt}`); + } else if (refPage === 'price') { + await goto(`/sheet/price/${cnt}`); } else if (refPage === 'sheet') { await goto(`/sheet/overview/${cnt}`); } else { @@ -37,7 +39,7 @@ // read or write permission let userCurrentPerms = get(currentPerms).filter((x) => { - if (refPage === 'sheet') { + if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') { return x.startsWith('document.write'); } return x.startsWith('document.read'); @@ -50,7 +52,7 @@ setTimeout(() => { // read or write permission let userCurrentPerms = get(currentPerms).filter((x) => { - if (refPage === 'sheet') { + if (refPage === 'sheet' || refPage === 'priceslot' || refPage === 'price') { return x.startsWith('document.write'); } return x.startsWith('document.read'); diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte index e7beba8..a91f747 100644 --- a/src/routes/(authed)/recipe/material/+page.svelte +++ b/src/routes/(authed)/recipe/material/+page.svelte @@ -1,5 +1,5 @@ @@ -459,21 +939,93 @@ {/if} -
- +
+
+ Recipe Source +
+
+ + + +
+ + + + Load Recipe From Server + Select a country to load material data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -499,31 +1051,60 @@ - {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - Create or update one MaterialSetting entry. The JSON preview shows the payload - before saving to Android. + Create or update one MaterialSetting entry. Server-loaded data is read-only until + ADB is connected.
- {existingMaterial ? 'Edit Material' : 'Add Material'} + {isEditingMaterial ? 'Edit Material' : 'Add Material'} - {#if existingMaterial} + {#if duplicateMaterialOnCreate} +
+ Material ID {form.id} already exists. Choose another ID before creating. +
+ {:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial} +
+ Material ID {form.id} already exists. Choose another ID before saving. +
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting. + Editing Material ID {editingMaterialId}.
{/if} +
+ + +
+
- + +

+ Generated from the highest existing ID in this type + 1. +

@@ -546,11 +1127,11 @@
- +
@@ -582,23 +1163,7 @@
-
-
- - -
+
@@ -618,21 +1183,6 @@

-
-
- - -
-
- - -
-
-
@@ -686,12 +1236,18 @@
- Cancel -
@@ -713,14 +1269,46 @@ + + + + Select Material Type + + Choose the type for the new material. Material ID will be generated from existing IDs in + that type + 1. + + + +
+ {#each selectableMaterialTypeOptions as option} + + {/each} +
+ + + + +
+
+
Existing Materials - - Use Edit to update a material, or Delete to remove it after confirmation. - +
- Loading materials from Android... + Loading materials...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if filteredMaterials.length === 0}
No materials found.
{:else}
+ + + + Load Recipe From Server + Select a country to load topping data from server. + + +
+ {#each serverCountries as country} + + {/each} +
+ +
+ +
+
+
+ + + + + Machine Not Connected + + Connect to the machine with ADB/WebUSB before loading recipe data from Machine. + + + +
+ +
+
+
+
@@ -591,23 +887,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'} - - Switch between list items and groups. Edit/Delete actions are explicit per row. - +
- +
@@ -615,12 +911,14 @@
{#if activeTab === 'list'} - Add Topping {:else} - Add Group {/if}
@@ -629,19 +927,19 @@ {#if loading}
- Loading toppings from Android... + Loading toppings...
{:else if !devRecipe} -
Connect and load recipe first.
+
Load recipe first.
{:else if activeTab === 'list'} {#if filteredToppingList.length === 0}
No topping list items found.
{:else}