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 <noreply@anthropic.com>
This commit is contained in:
parent
6011b92b7b
commit
47ee23777d
26 changed files with 5582 additions and 539 deletions
|
|
@ -137,7 +137,7 @@ async function connectWithRetry<T>(
|
|||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import {
|
|||
sheetCatalogsLoading,
|
||||
handleRawStreamHeader,
|
||||
handleRawStreamChunk,
|
||||
handleRawStreamEnd
|
||||
handleRawStreamEnd,
|
||||
handleSheetPriceResponse
|
||||
} from '../stores/sheetStore';
|
||||
import {
|
||||
handleGenLayoutBatchStart,
|
||||
|
|
@ -293,13 +294,14 @@ const handlers: Record<string, (payload: any) => 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<string, (payload: any) => 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<string, (payload: any) => 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<string, (payload: any) => 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<string, (payload: any) => 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
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
return await sendCommandRequest('sheet', {
|
||||
country: country,
|
||||
|
|
@ -22,9 +26,36 @@ export async function requestCatalogs(country: string): Promise<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
return await sendCommandRequest('sheet', {
|
||||
country: country,
|
||||
catalog: catalog,
|
||||
catalog_name: catalogName,
|
||||
param: 'add/catalog'
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestPriceSlots(country: string): Promise<boolean> {
|
||||
setPendingPriceSlotsCountry(country);
|
||||
resetPriceSlotsCountry(country);
|
||||
return requestPriceSlotOption(country, 'PriceSlot');
|
||||
}
|
||||
|
||||
export async function requestPriceSlot(country: string, slotNumber: number): Promise<boolean> {
|
||||
setPendingPriceSlotsCountry(country);
|
||||
return requestPriceSlotOption(country, `PriceSlot${slotNumber}`);
|
||||
}
|
||||
|
||||
async function requestPriceSlotOption(country: string, option: string): Promise<boolean> {
|
||||
const request_id = crypto.randomUUID();
|
||||
|
||||
streamingRawData.update((data) => ({
|
||||
|
|
@ -41,7 +72,7 @@ export async function requestPriceSlots(country: string): Promise<boolean> {
|
|||
const values = {
|
||||
country: country,
|
||||
param: 'price',
|
||||
option: 'PriceSlot',
|
||||
option,
|
||||
stream: true,
|
||||
request_id
|
||||
};
|
||||
|
|
@ -54,12 +85,120 @@ export async function requestPriceSlots(country: string): Promise<boolean> {
|
|||
return sent;
|
||||
}
|
||||
|
||||
export async function updatePriceSlot(country: string, content: PriceSlot): Promise<boolean> {
|
||||
return await sendCommandRequest('sheet', {
|
||||
export async function refreshPriceSlotList(country: string): Promise<boolean> {
|
||||
return requestPriceSlotOption(country, 'PriceSlot');
|
||||
}
|
||||
|
||||
export async function updatePriceSlot(
|
||||
country: string,
|
||||
slot: PriceSlot,
|
||||
content: SheetRowUpdate[]
|
||||
): Promise<boolean> {
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
|
|
@ -208,9 +347,13 @@ export async function requestGenLayout(country: string): Promise<boolean> {
|
|||
* 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<boolean> {
|
||||
export async function requestSheetPrice(
|
||||
country: string,
|
||||
productCodes: string[],
|
||||
force = false
|
||||
): Promise<boolean> {
|
||||
// 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<boolean> {
|
||||
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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export interface PriceSlot {
|
|||
}
|
||||
|
||||
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
|
||||
export const priceSlotNamespaces = writable<Record<string, PriceSlot[]>>({});
|
||||
export const priceSlotsLoading = writable<boolean>(false);
|
||||
export const priceSlotsError = writable<string | null>(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<string, PriceSlot>();
|
||||
const loadedSlots = normalizedSlots.filter((slot) => !isPriceSlotNamespace(slot as any));
|
||||
|
||||
if (loadedSlots.length > 0) {
|
||||
priceSlots.update((data) => {
|
||||
const merged = new Map<number, PriceSlot>();
|
||||
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<number, PriceSlot>();
|
||||
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<string, string> = {
|
||||
|
|
@ -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<string, number>;
|
||||
// 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<string, number>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ENABLE_WS_DEBUG: boolean = false;
|
|||
export const socketConnectionOfflineCount = writable<number>(0);
|
||||
export const socketAlreadySendHeartbeat = writable<number>(0);
|
||||
export const socketStore = writable<WebSocket | null>(null);
|
||||
export const wsAuthReady = writable<boolean>(false);
|
||||
|
||||
export const sharedKey = writable<CryptoKey | null>(null);
|
||||
|
||||
|
|
@ -53,6 +54,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise<WebSocket | null> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise<WebSocket | null> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue