Merge branch 'master' into feature/edit-recipe-event-bus
This commit is contained in:
commit
3625712678
28 changed files with 6414 additions and 783 deletions
18
bun.lock
18
bun.lock
|
|
@ -11,6 +11,7 @@
|
||||||
"@dnd-kit/abstract": "^0.2.4",
|
"@dnd-kit/abstract": "^0.2.4",
|
||||||
"@dnd-kit/helpers": "^0.2.4",
|
"@dnd-kit/helpers": "^0.2.4",
|
||||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||||
|
"@types/semver": "^7.7.1",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"@yume-chan/adb": "^2.6.0",
|
"@yume-chan/adb": "^2.6.0",
|
||||||
"@yume-chan/adb-credential-web": "^2.1.0",
|
"@yume-chan/adb-credential-web": "^2.1.0",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"firebase": "^12.14.0",
|
"firebase": "^12.14.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
|
"semver": "^7.8.4",
|
||||||
"usb": "^2.17.0",
|
"usb": "^2.17.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
|
|
@ -54,7 +56,7 @@
|
||||||
"prettier-plugin-svelte": "^3.5.1",
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"storybook": "^10.3.5",
|
"storybook": "^10.3.5",
|
||||||
"svelte": "^5.55.2",
|
"svelte": "^5.56.3",
|
||||||
"svelte-adapter-bun": "^1.0.1",
|
"svelte-adapter-bun": "^1.0.1",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"svelte-sonner": "^1.1.0",
|
"svelte-sonner": "^1.1.0",
|
||||||
|
|
@ -504,6 +506,8 @@
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
|
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="],
|
"@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.14", "", {}, "sha512-Qu3Nn6JFuF4+sHKYl+IcX9vYiI40ogleXzFFSxoE1W94rG98o/kXs8uJ0QSfFzuwBCZWlGfUGpPkgwuuX4PchA=="],
|
||||||
|
|
@ -834,7 +838,7 @@
|
||||||
|
|
||||||
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
|
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||||
|
|
||||||
|
|
@ -862,7 +866,7 @@
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
|
"svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
|
||||||
|
|
||||||
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
|
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
|
||||||
|
|
||||||
|
|
@ -1040,9 +1044,15 @@
|
||||||
|
|
||||||
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"storybook/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"svelte/esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="],
|
"svelte/@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
|
||||||
|
|
||||||
|
"svelte/devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||||
|
|
||||||
|
"svelte/esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
|
||||||
|
|
||||||
"svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="],
|
"svelte-ast-print/esrap": ["esrap@1.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1" } }, "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
import { GlobalEventBus } from '$lib/core/utils/eventBus';
|
||||||
import { XIcon } from '@lucide/svelte/icons';
|
import { XIcon } from '@lucide/svelte/icons';
|
||||||
|
|
||||||
export interface AnnouncementPayload {
|
interface AnnouncementPayload {
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
ImageUp,
|
ImageUp,
|
||||||
Video,
|
Video,
|
||||||
|
MonitorPlay,
|
||||||
Sun,
|
Sun,
|
||||||
Moon
|
Moon
|
||||||
} from '@lucide/svelte/icons';
|
} from '@lucide/svelte/icons';
|
||||||
|
|
@ -126,6 +127,12 @@
|
||||||
url: '/tools/adv-upload',
|
url: '/tools/adv-upload',
|
||||||
icon: Video,
|
icon: Video,
|
||||||
requirePerm: ''
|
requirePerm: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Main & Brewing Video',
|
||||||
|
url: '/tools/video-mainpage',
|
||||||
|
icon: MonitorPlay,
|
||||||
|
requirePerm: ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -143,6 +150,12 @@
|
||||||
url: '/departments',
|
url: '/departments',
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
requirePerm: 'document.write.*'
|
requirePerm: 'document.write.*'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Price',
|
||||||
|
url: '/departments',
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
requirePerm: 'document.write.*'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +249,13 @@
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
if (nav.title === 'Sheet') {
|
if (nav.title === 'Sheet') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet');
|
referenceFromPage.set(
|
||||||
|
sub.title === 'PriceSlot'
|
||||||
|
? 'priceslot'
|
||||||
|
: sub.title === 'Price'
|
||||||
|
? 'price'
|
||||||
|
: 'sheet'
|
||||||
|
);
|
||||||
goto(sub.url);
|
goto(sub.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ async function connectWithRetry<T>(
|
||||||
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
export async function connnectViaWebUSB(connectAndroidServer = true) {
|
||||||
adbConnectionStatus.setConnecting();
|
adbConnectionStatus.setConnecting();
|
||||||
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
const device = await AdbDaemonWebUsbDeviceManager.BROWSER?.requestDevice();
|
||||||
logger.info('usb ok', globalThis.navigator.usb);
|
logger.info('usb ok', (globalThis.navigator as Navigator & { usb?: unknown }).usb);
|
||||||
if (device) {
|
if (device) {
|
||||||
logger.info('connect ', device.name);
|
logger.info('connect ', device.name);
|
||||||
|
|
||||||
|
|
@ -441,6 +441,15 @@ export async function executeCmd(command: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function goToMachineHome() {
|
||||||
|
if (!getAdbInstance()) return;
|
||||||
|
try {
|
||||||
|
await executeCmd('input keyevent KEYCODE_HOME');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[goToMachineHome] error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute an ADB command and stream its output via callbacks.
|
* Execute an ADB command and stream its output via callbacks.
|
||||||
* Used for commands like `logcat` that run indefinitely.
|
* Used for commands like `logcat` that run indefinitely.
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,14 @@ import {
|
||||||
handleSheetStreamEnd,
|
handleSheetStreamEnd,
|
||||||
handleSheetStreamError,
|
handleSheetStreamError,
|
||||||
handleCatalogsResponse,
|
handleCatalogsResponse,
|
||||||
|
handlePriceSlotsResponse,
|
||||||
|
isPriceSlotsPayload,
|
||||||
handleListMenuResponse,
|
handleListMenuResponse,
|
||||||
sheetCatalogsLoading,
|
sheetCatalogsLoading,
|
||||||
handleRawStreamHeader,
|
handleRawStreamHeader,
|
||||||
handleRawStreamChunk,
|
handleRawStreamChunk,
|
||||||
handleRawStreamEnd
|
handleRawStreamEnd,
|
||||||
|
handleSheetPriceResponse
|
||||||
} from '../stores/sheetStore';
|
} from '../stores/sheetStore';
|
||||||
import {
|
import {
|
||||||
handleGenLayoutBatchStart,
|
handleGenLayoutBatchStart,
|
||||||
|
|
@ -49,10 +52,10 @@ import { auth as authStore } from '../stores/auth';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
|
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import * as semver from 'semver';
|
|
||||||
import { WebCryptoHelper } from '../utils/crypto';
|
import { WebCryptoHelper } from '../utils/crypto';
|
||||||
import { GlobalEventBus } from '../utils/eventBus';
|
import { GlobalEventBus } from '../utils/eventBus';
|
||||||
import { handleIncomingRemoteShell } from '../stores/remoteShellStore';
|
import { handleIncomingRemoteShell } from '../stores/remoteShellStore';
|
||||||
|
import * as semver from 'semver';
|
||||||
|
|
||||||
export const messages = writable<string[]>([]);
|
export const messages = writable<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -298,26 +301,76 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
|
|
||||||
if (from === 'sheet-service' && level === 'content') {
|
if (from === 'sheet-service' && level === 'content') {
|
||||||
const currentUid = auth.currentUser?.uid;
|
const currentUid = auth.currentUser?.uid;
|
||||||
|
const content = p.content ?? p.value ?? p.payload;
|
||||||
|
const ref = p.ref ?? '';
|
||||||
|
|
||||||
if (target && currentUid && target === currentUid) {
|
console.log('[Sheet] Notify content received:', {
|
||||||
if (!msg && p.content?.catalogs) {
|
msg,
|
||||||
handleCatalogsResponse(p.content);
|
target,
|
||||||
addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
|
currentUid,
|
||||||
|
contentKeys: content && typeof content === 'object' ? Object.keys(content) : [],
|
||||||
|
contentItems: Array.isArray(content) ? content.length : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!target || (currentUid && target === currentUid)) {
|
||||||
|
if (!msg && content?.catalogs) {
|
||||||
|
handleCatalogsResponse(content);
|
||||||
|
addNotification(`INFO:Loaded ${content.catalogs?.length || 0} catalogs`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!msg &&
|
||||||
|
(content?.priceSlots ||
|
||||||
|
content?.priceslots ||
|
||||||
|
content?.price_slots ||
|
||||||
|
content?.slots ||
|
||||||
|
content?.param === 'priceslot' ||
|
||||||
|
content?.option === 'PriceSlot' ||
|
||||||
|
isPriceSlotsPayload(content))
|
||||||
|
) {
|
||||||
|
handlePriceSlotsResponse(content);
|
||||||
|
addNotification('INFO:Loaded PriceSlot data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg && ref === 'price') {
|
||||||
|
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
|
||||||
|
addNotification('INFO:Loaded sheet price data');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle streaming messages (with msg field)
|
// Handle streaming messages (with msg field)
|
||||||
switch (msg) {
|
switch (msg) {
|
||||||
|
case 'priceslot':
|
||||||
|
case 'price_slot':
|
||||||
|
handlePriceSlotsResponse(content);
|
||||||
|
addNotification('INFO:Loaded PriceSlot data');
|
||||||
|
break;
|
||||||
case 'start':
|
case 'start':
|
||||||
handleSheetStreamStart(p);
|
if (ref === 'price') {
|
||||||
addNotification('INFO:Sheet data streaming started');
|
addNotification('INFO:Sheet price streaming started');
|
||||||
|
} else {
|
||||||
|
handleSheetStreamStart(p);
|
||||||
|
addNotification('INFO:Sheet data streaming started');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'chunk':
|
case 'chunk':
|
||||||
handleSheetStreamChunk(p);
|
if (isPriceSlotsPayload(content)) {
|
||||||
|
handlePriceSlotsResponse(content);
|
||||||
|
} else if (ref === 'price') {
|
||||||
|
handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
|
||||||
|
} else {
|
||||||
|
handleSheetStreamChunk(p);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'end':
|
case 'end':
|
||||||
handleSheetStreamEnd(p);
|
if (ref === 'price') {
|
||||||
addNotification('INFO:Sheet data streaming complete');
|
addNotification('INFO:Sheet price streaming complete');
|
||||||
|
} else {
|
||||||
|
handleSheetStreamEnd(p);
|
||||||
|
addNotification('INFO:Sheet data streaming complete');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
handleSheetStreamError(p);
|
handleSheetStreamError(p);
|
||||||
|
|
@ -325,8 +378,17 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Handle other content notifications from sheet-service
|
// Handle other content notifications from sheet-service
|
||||||
logger.info('[Sheet] Received content:', p.content);
|
logger.info('[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,
|
||||||
|
contentItems: Array.isArray(content) ? content.length : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -404,6 +466,7 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
country: current_meta?.country ?? '',
|
country: current_meta?.country ?? '',
|
||||||
content: saved_product_code_to_get_from_sheet,
|
content: saved_product_code_to_get_from_sheet,
|
||||||
param: 'price',
|
param: 'price',
|
||||||
|
option: 'price',
|
||||||
stream: true,
|
stream: true,
|
||||||
request_id
|
request_id
|
||||||
});
|
});
|
||||||
|
|
@ -481,14 +544,23 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
// Header for price stream
|
// Header for price stream
|
||||||
handleRawStreamHeader('price', p);
|
handleRawStreamHeader('price', p);
|
||||||
},
|
},
|
||||||
|
raw_stream_priceslot: (p) => {
|
||||||
|
handleRawStreamHeader('priceslot', p);
|
||||||
|
},
|
||||||
raw_stream_chunk_price: (p) => {
|
raw_stream_chunk_price: (p) => {
|
||||||
// Chunk for price stream
|
// Chunk for price stream
|
||||||
handleRawStreamChunk('price', p);
|
handleRawStreamChunk('price', p);
|
||||||
},
|
},
|
||||||
|
raw_stream_chunk_priceslot: (p) => {
|
||||||
|
handleRawStreamChunk('priceslot', p);
|
||||||
|
},
|
||||||
raw_stream_end_price: (p) => {
|
raw_stream_end_price: (p) => {
|
||||||
// End for price stream
|
// End for price stream
|
||||||
handleRawStreamEnd('price', p);
|
handleRawStreamEnd('price', p);
|
||||||
},
|
},
|
||||||
|
raw_stream_end_priceslot: (p) => {
|
||||||
|
handleRawStreamEnd('priceslot', p);
|
||||||
|
},
|
||||||
announce: (p) => {
|
announce: (p) => {
|
||||||
// Server-pushed announcement (e.g., closing maintenance)
|
// Server-pushed announcement (e.g., closing maintenance)
|
||||||
GlobalEventBus.emit('announce', p);
|
GlobalEventBus.emit('announce', p);
|
||||||
|
|
@ -499,13 +571,14 @@ const handlers: Record<string, (payload: any) => void> = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function handleIncomingMessages(raw: string, clientPrivateKey: CryptoKey) {
|
export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
|
||||||
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
const APP_VERSION = env.PUBLIC_APP_SEMVER;
|
||||||
|
const parsedMessage = JSON.parse(raw);
|
||||||
|
|
||||||
const ack: HandshakeAck = JSON.parse(raw);
|
const ack: HandshakeAck = parsedMessage;
|
||||||
// logger.info(`[WS MSG] type=${msg.type}`, msg.payload);
|
|
||||||
if (ack != null && ack.status === 'authenticated') {
|
if (ack != null && ack.status === 'authenticated') {
|
||||||
// has server response
|
// has server response
|
||||||
|
if (!clientPrivateKey) return;
|
||||||
|
|
||||||
sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
|
sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
|
||||||
|
|
||||||
|
|
@ -513,35 +586,40 @@ export async function handleIncomingMessages(raw: string, clientPrivateKey: Cryp
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
|
if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
|
||||||
// secured message decryption
|
// secured message decryption
|
||||||
let sharedKeyStore = get(sharedKey);
|
let sharedKeyStore = get(sharedKey);
|
||||||
if (sharedKeyStore) {
|
if (sharedKeyStore) {
|
||||||
let raw_payload = JSON.parse(raw);
|
|
||||||
let decrypted_string = await WebCryptoHelper.decryptMessage(
|
let decrypted_string = await WebCryptoHelper.decryptMessage(
|
||||||
sharedKeyStore,
|
sharedKeyStore,
|
||||||
raw_payload.ciphertext,
|
parsedMessage.ciphertext,
|
||||||
raw_payload.iv
|
parsedMessage.iv
|
||||||
);
|
);
|
||||||
let actual_message: WSMessage = JSON.parse(decrypted_string);
|
let actual_message: WSMessage = JSON.parse(decrypted_string);
|
||||||
|
if (actual_message.type !== 'heartbeat') {
|
||||||
|
// console.log(`[WS MSG] type=${actual_message.type}`, actual_message.payload);
|
||||||
|
}
|
||||||
|
|
||||||
handlers[actual_message.type]?.(actual_message.payload);
|
handlers[actual_message.type]?.(actual_message.payload);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const msg: WSMessage = JSON.parse(raw);
|
const msg: WSMessage = parsedMessage;
|
||||||
|
if (msg.type !== 'heartbeat') {
|
||||||
|
// console.log(`[WS MSG] type=${msg.type}`, msg.payload);
|
||||||
|
}
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
// error response
|
// error response
|
||||||
addNotification('ERR:No response from server');
|
addNotification('ERR:No response from server');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// raw streaming type
|
// raw streaming type
|
||||||
// if (msg.type.startsWith('raw_stream')) {
|
// if (msg.type.startsWith('raw_stream')) {
|
||||||
// // convert
|
// // convert
|
||||||
// let sub_type = msg.type.replace('raw_stream_', '');
|
// let sub_type = msg.type.replace('raw_stream_', '');
|
||||||
// msg.payload.sub_type = sub_type;
|
// msg.payload.sub_type = sub_type;
|
||||||
// msg.type = 'raw_stream';
|
// msg.type = 'raw_stream';
|
||||||
// }
|
// }
|
||||||
|
|
||||||
handlers[msg.type]?.(msg.payload);
|
handlers[msg.type]?.(msg.payload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { logger } from '$lib/core/utils/logger';
|
import { logger } from '$lib/core/utils/logger';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import type { OutMessage } from '../types/outMessage';
|
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 { addNotification } from '../stores/noti';
|
||||||
import { auth } from '../stores/auth';
|
import { auth } from '../stores/auth';
|
||||||
import { WebCryptoHelper } from '../utils/crypto';
|
import { WebCryptoHelper } from '../utils/crypto';
|
||||||
import * as semver from 'semver';
|
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
import * as semver from 'semver';
|
||||||
|
|
||||||
export const queue = writable<string[]>([]);
|
export const queue = writable<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -23,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`
|
// Websocket message wrapper for commands like `sheet`, `command`
|
||||||
export async function sendCommandRequest(target: CommandRequest, values: any): Promise<boolean> {
|
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 srv_name = getServiceName(target);
|
||||||
let curr_user = get(auth);
|
let curr_user = get(auth);
|
||||||
|
|
||||||
|
|
@ -85,6 +117,13 @@ export async function sendMessage(
|
||||||
data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
|
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);
|
socket.send(data);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,18 @@ import {
|
||||||
markSheetPriceAsSent,
|
markSheetPriceAsSent,
|
||||||
sheetPriceLoading,
|
sheetPriceLoading,
|
||||||
streamingRawData,
|
streamingRawData,
|
||||||
setPendingProductCodesCountry
|
setPendingProductCodesCountry,
|
||||||
|
setPendingPriceSlotsCountry,
|
||||||
|
priceSlotsLoading,
|
||||||
|
resetPriceSlotsCountry
|
||||||
} from '../stores/sheetStore';
|
} from '../stores/sheetStore';
|
||||||
|
import type { PriceSlot } from '../stores/sheetStore';
|
||||||
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
|
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> {
|
export async function requestCatalogs(country: string): Promise<boolean> {
|
||||||
return await sendCommandRequest('sheet', {
|
return await sendCommandRequest('sheet', {
|
||||||
country: country,
|
country: country,
|
||||||
|
|
@ -19,27 +27,179 @@ export async function requestCatalogs(country: string): Promise<boolean> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestPriceSlots(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', {
|
return await sendCommandRequest('sheet', {
|
||||||
country: country,
|
country: country,
|
||||||
param: 'priceslot'
|
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) => ({
|
||||||
|
...data,
|
||||||
|
priceslot: {
|
||||||
|
request_id,
|
||||||
|
country,
|
||||||
|
chunks: [],
|
||||||
|
rawParts: []
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
priceSlotsLoading.set(true);
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
country: country,
|
||||||
|
param: 'price',
|
||||||
|
option,
|
||||||
|
stream: true,
|
||||||
|
request_id
|
||||||
|
};
|
||||||
|
console.log('[sheetService] Sending PriceSlot request:', values);
|
||||||
|
const sent = await sendCommandRequest('sheet', values);
|
||||||
|
console.log('[sheetService] PriceSlot request sent:', sent);
|
||||||
|
if (!sent) {
|
||||||
|
priceSlotsLoading.set(false);
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPriceSlotList(country: string): Promise<boolean> {
|
||||||
|
return requestPriceSlotOption(country, 'PriceSlot');
|
||||||
|
}
|
||||||
|
|
||||||
export async function updatePriceSlot(
|
export async function updatePriceSlot(
|
||||||
country: string,
|
country: string,
|
||||||
content: {
|
slot: PriceSlot,
|
||||||
slot: number;
|
content: SheetRowUpdate[]
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
products: { product_code: string; price: number | null; row_index?: number }[];
|
|
||||||
}
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return await sendCommandRequest('sheet', {
|
// 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,
|
country: country,
|
||||||
content: content,
|
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> {
|
export async function enterRoom(country: string, catalog: string): Promise<boolean> {
|
||||||
|
|
@ -188,9 +348,13 @@ export async function requestGenLayout(country: string): Promise<boolean> {
|
||||||
* Request price data from sheet for specific product codes
|
* Request price data from sheet for specific product codes
|
||||||
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
|
* 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
|
// Check if already sent
|
||||||
if (hasSheetPriceBeenSent('price')) {
|
if (!force && hasSheetPriceBeenSent('price')) {
|
||||||
logger.warn('[sheetService] Price request already sent, skipping');
|
logger.warn('[sheetService] Price request already sent, skipping');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +396,48 @@ export async function requestSheetPrice(country: string, productCodes: string[])
|
||||||
country: country,
|
country: country,
|
||||||
content: content,
|
content: content,
|
||||||
param: 'price',
|
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,
|
stream: true,
|
||||||
request_id
|
request_id
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,304 @@ export interface PriceSlotProduct {
|
||||||
row_index?: number;
|
row_index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceSlotServiceRow {
|
||||||
|
row_index?: number;
|
||||||
|
cells: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PriceSlot {
|
export interface PriceSlot {
|
||||||
slot: number;
|
slot: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
kind?: 'price' | 'service';
|
||||||
|
header?: string[];
|
||||||
products: PriceSlotProduct[];
|
products: PriceSlotProduct[];
|
||||||
|
serviceRows?: PriceSlotServiceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
|
export const priceSlots = writable<Record<string, PriceSlot[]>>({});
|
||||||
|
export const priceSlotNamespaces = writable<Record<string, PriceSlot[]>>({});
|
||||||
export const priceSlotsLoading = writable<boolean>(false);
|
export const priceSlotsLoading = writable<boolean>(false);
|
||||||
export const priceSlotsError = writable<string | null>(null);
|
export const priceSlotsError = writable<string | null>(null);
|
||||||
|
let pendingPriceSlotsCountry = '';
|
||||||
|
|
||||||
|
export function setPendingPriceSlotsCountry(country: string) {
|
||||||
|
pendingPriceSlotsCountry = country.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPriceSlotsCountry(country: string) {
|
||||||
|
const key = country.toLowerCase();
|
||||||
|
priceSlots.update((data) => ({
|
||||||
|
...data,
|
||||||
|
[key]: []
|
||||||
|
}));
|
||||||
|
priceSlotNamespaces.update((data) => ({
|
||||||
|
...data,
|
||||||
|
[key]: []
|
||||||
|
}));
|
||||||
|
priceSlotsError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriceSlotProduct(product: any): PriceSlotProduct | null {
|
||||||
|
const cells = Array.isArray(product?.cells) ? product.cells : [];
|
||||||
|
const cellValue = (col: number) => cells.find((cell: any) => cell?.coord?.col === col)?.value;
|
||||||
|
const productCode =
|
||||||
|
product?.product_code ?? product?.ProductCode ?? product?.code ?? cellValue(1);
|
||||||
|
|
||||||
|
if (!productCode) return null;
|
||||||
|
|
||||||
|
const priceValue =
|
||||||
|
product?.price ??
|
||||||
|
product?.Price ??
|
||||||
|
product?.value ??
|
||||||
|
product?.cash_price ??
|
||||||
|
product?.CashPrice ??
|
||||||
|
cellValue(5);
|
||||||
|
const price =
|
||||||
|
priceValue === '' || priceValue === undefined || priceValue === null
|
||||||
|
? null
|
||||||
|
: Number(priceValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
product_code: String(productCode),
|
||||||
|
name: String(
|
||||||
|
product?.name ?? product?.ProductName ?? product?.product_name ?? cellValue(2) ?? ''
|
||||||
|
),
|
||||||
|
price: Number.isNaN(price) ? null : price,
|
||||||
|
row_index: product?.row_index ?? product?.row
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceSlotHeader(slot: any): string[] {
|
||||||
|
const header = Array.isArray(slot?.header) ? slot.header : [];
|
||||||
|
return header.map((value: any) => String(value ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServicePriceSlotHeader(header: string[]): boolean {
|
||||||
|
return header.some((value) => value.toLowerCase() === 'servicetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriceSlotServiceRow(row: any, header: string[]): PriceSlotServiceRow | null {
|
||||||
|
const cells = Array.isArray(row?.cells) ? row.cells : [];
|
||||||
|
const mappedCells = header.reduce<Record<string, string>>((result, columnName, index) => {
|
||||||
|
if (!columnName) return result;
|
||||||
|
const value =
|
||||||
|
row?.[columnName] ??
|
||||||
|
row?.[columnName.replace(/\s+/g, '')] ??
|
||||||
|
cells.find((cell: any) => cell?.coord?.col === index + 1)?.value ??
|
||||||
|
'';
|
||||||
|
result[columnName] = String(value ?? '');
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.values(mappedCells).every((value) => value === '')) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
row_index: row?.row_index ?? row?.row,
|
||||||
|
cells: mappedCells
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriceSlot(slot: any, index: number): PriceSlot {
|
||||||
|
const sheetName = slot?.sheet ?? slot?.Sheet;
|
||||||
|
const displayName = slot?.name ?? slot?.title ?? sheetName;
|
||||||
|
const slotNumber = Number(
|
||||||
|
slot?.slot ?? slot?.price_slot ?? slot?.id ?? displayName?.match?.(/\d+/)?.[0] ?? index + 1
|
||||||
|
);
|
||||||
|
const productsSource = slot?.products ?? slot?.items ?? slot?.rows ?? slot?.payload ?? [];
|
||||||
|
const header = getPriceSlotHeader(slot);
|
||||||
|
const isServiceSlot = isServicePriceSlotHeader(header);
|
||||||
|
const headerName = isServiceSlot ? header[12] : header[10];
|
||||||
|
const headerDescription = isServiceSlot ? header[13] : header[11];
|
||||||
|
const products = (Array.isArray(productsSource) ? productsSource : [])
|
||||||
|
.map(normalizePriceSlotProduct)
|
||||||
|
.filter((product): product is PriceSlotProduct => product !== null);
|
||||||
|
const serviceRows = isServiceSlot
|
||||||
|
? (Array.isArray(productsSource) ? productsSource : [])
|
||||||
|
.map((row) => normalizePriceSlotServiceRow(row, header))
|
||||||
|
.filter((row): row is PriceSlotServiceRow => row !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
slot: Number.isNaN(slotNumber) ? index + 1 : slotNumber,
|
||||||
|
name: String(
|
||||||
|
headerName ?? displayName ?? `PriceSlot${Number.isNaN(slotNumber) ? index + 1 : slotNumber}`
|
||||||
|
),
|
||||||
|
description: String(headerDescription ?? ''),
|
||||||
|
kind: isServiceSlot ? 'service' : 'price',
|
||||||
|
header,
|
||||||
|
products: isServiceSlot ? [] : products,
|
||||||
|
serviceRows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ??
|
||||||
|
content?.slots ??
|
||||||
|
content?.data ??
|
||||||
|
content?.value ??
|
||||||
|
content?.content ??
|
||||||
|
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,
|
||||||
|
sourceItems: Array.isArray(source) ? source.length : undefined
|
||||||
|
});
|
||||||
|
priceSlotsError.set('No PriceSlot data found in backend response');
|
||||||
|
priceSlotsLoading.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
slotListItems: slotList.length
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[PriceSlot] Normalized slots:', {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
for (const slot of normalizedSlots) {
|
||||||
|
merged.set(slot.slot, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
[country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
priceSlotsError.set(null);
|
||||||
|
priceSlotsLoading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPriceSlotsPayload(content: any): boolean {
|
||||||
|
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') ||
|
||||||
|
(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> = {
|
export const countryPrimaryLanguageMap: Record<string, string> = {
|
||||||
THAI: 'Thai',
|
THAI: 'Thai',
|
||||||
|
|
@ -79,11 +367,19 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
|
||||||
|
|
||||||
// Sheet column configuration by country for new_layout_v2
|
// Sheet column configuration by country for new_layout_v2
|
||||||
// Maps language keys to column indices and product code columns
|
// Maps language keys to column indices and product code columns
|
||||||
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
|
||||||
language: Record<string, number>;
|
string,
|
||||||
productCode: { hot: number; cold: number; blend: number };
|
{
|
||||||
primaryLanguage: 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;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
tha: {
|
tha: {
|
||||||
language: { en: 3, th: 4, zh: 5, my: 8 },
|
language: { en: 3, th: 4, zh: 5, my: 8 },
|
||||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||||
|
|
@ -91,6 +387,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
||||||
},
|
},
|
||||||
aus: {
|
aus: {
|
||||||
language: { en: 3, th: 4 },
|
language: { en: 3, th: 4 },
|
||||||
|
nameDescLanguage: { en: 3, th: 4, ms: 7 },
|
||||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||||
primaryLanguage: 'en'
|
primaryLanguage: 'en'
|
||||||
},
|
},
|
||||||
|
|
@ -101,11 +398,13 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
||||||
},
|
},
|
||||||
hkg: {
|
hkg: {
|
||||||
language: { en: 3, zh_hans: 4, zh_hant: 5, th: 6 },
|
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 },
|
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||||
primaryLanguage: 'zh_hant'
|
primaryLanguage: 'zh_hant'
|
||||||
},
|
},
|
||||||
ltu: {
|
ltu: {
|
||||||
language: { en: 3, th: 4, lt: 5, ro: 6 },
|
language: { en: 3, th: 4, lt: 5, ro: 6 },
|
||||||
|
nameDescLanguage: { en: 3, lt: 5, ro: 6 },
|
||||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||||
primaryLanguage: 'lt'
|
primaryLanguage: 'lt'
|
||||||
},
|
},
|
||||||
|
|
@ -131,6 +430,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
||||||
},
|
},
|
||||||
sgp: {
|
sgp: {
|
||||||
language: { en: 3, th: 4 },
|
language: { en: 3, th: 4 },
|
||||||
|
nameDescLanguage: { en: 3 },
|
||||||
productCode: { hot: 9, cold: 10, blend: 11 },
|
productCode: { hot: 9, cold: 10, blend: 11 },
|
||||||
primaryLanguage: 'en'
|
primaryLanguage: 'en'
|
||||||
},
|
},
|
||||||
|
|
@ -152,8 +452,10 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<string, {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSheetColumnConfig(countryCode: string) {
|
export function getSheetColumnConfig(countryCode: string) {
|
||||||
return SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()]
|
return (
|
||||||
|| SHEET_COLUMN_CONFIG_BY_COUNTRY.default;
|
SHEET_COLUMN_CONFIG_BY_COUNTRY[countryCode.toLowerCase()] ||
|
||||||
|
SHEET_COLUMN_CONFIG_BY_COUNTRY.default
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleCatalogsResponse(content: CatalogsResponse) {
|
export function handleCatalogsResponse(content: CatalogsResponse) {
|
||||||
|
|
@ -305,10 +607,13 @@ export interface SheetPriceItem {
|
||||||
|
|
||||||
// Price sheet header name mappings by country
|
// Price sheet header name mappings by country
|
||||||
// Maps our field names to the actual header names in the sheet
|
// Maps our field names to the actual header names in the sheet
|
||||||
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
|
export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<
|
||||||
cash_price: string[]; // Possible header names for cash price
|
string,
|
||||||
non_cash_price: string[]; // Possible header names for non-cash price
|
{
|
||||||
}> = {
|
cash_price: string[]; // Possible header names for cash price
|
||||||
|
non_cash_price: string[]; // Possible header names for non-cash price
|
||||||
|
}
|
||||||
|
> = {
|
||||||
tha: {
|
tha: {
|
||||||
cash_price: ['Price'],
|
cash_price: ['Price'],
|
||||||
non_cash_price: ['MainPrice']
|
non_cash_price: ['MainPrice']
|
||||||
|
|
@ -367,7 +672,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<string, {
|
||||||
// Find column index from header array by matching header names
|
// Find column index from header array by matching header names
|
||||||
export function findHeaderIndex(headerArray: string[], possibleNames: string[]): number {
|
export function findHeaderIndex(headerArray: string[], possibleNames: string[]): number {
|
||||||
for (const name of possibleNames) {
|
for (const name of possibleNames) {
|
||||||
const idx = headerArray.findIndex(h => h.toLowerCase() === name.toLowerCase());
|
const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase());
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
// Return col index (header index + 1 because cells start from col 1)
|
// Return col index (header index + 1 because cells start from col 1)
|
||||||
return idx + 1;
|
return idx + 1;
|
||||||
|
|
@ -383,7 +688,9 @@ export const lastRequestSheetPrice = writable<Record<string, Record<string, Gris
|
||||||
export const sheetPriceHeader = writable<Record<string, string[]>>({});
|
export const sheetPriceHeader = writable<Record<string, string[]>>({});
|
||||||
|
|
||||||
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
|
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
|
||||||
export const sheetPriceAllRows = writable<Record<string, Record<string, { row: number; cells: GristCell[] }[]>>>({});
|
export const sheetPriceAllRows = writable<
|
||||||
|
Record<string, Record<string, { row: number; cells: GristCell[] }[]>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// Helper function to get price value from cells using dynamic header lookup
|
// Helper function to get price value from cells using dynamic header lookup
|
||||||
export function getPriceFromCells(
|
export function getPriceFromCells(
|
||||||
|
|
@ -391,29 +698,43 @@ export function getPriceFromCells(
|
||||||
cells: GristCell[],
|
cells: GristCell[],
|
||||||
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
|
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
|
||||||
): string | null {
|
): 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];
|
const headers = get(sheetPriceHeader)[country];
|
||||||
if (!headers || headers.length === 0) {
|
if (!headers || headers.length === 0) {
|
||||||
logger.warn(`[getPriceFromCells] No header found for country: ${country}`);
|
logger.warn(`[getPriceFromCells] No header found for country: ${country}`);
|
||||||
return null;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get possible header names for this country
|
// Get possible header names for this country
|
||||||
const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
const headerNames =
|
||||||
const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
|
PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
||||||
|
const possibleNames =
|
||||||
|
priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
|
||||||
|
|
||||||
// Find the column index for this price type
|
// Find the column index for this price type
|
||||||
const colIdx = findHeaderIndex(headers, possibleNames);
|
const colIdx = findHeaderIndex(headers, possibleNames);
|
||||||
//logger.info(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
|
//logger.info(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
|
||||||
|
|
||||||
if (colIdx < 0) {
|
if (colIdx < 0) {
|
||||||
logger.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
|
logger.warn(
|
||||||
return null;
|
`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
|
||||||
|
possibleNames
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the cell with matching column index
|
return colIdx;
|
||||||
const priceCell = cells.find((c) => c.coord?.col === colIdx);
|
|
||||||
//logger.info(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
|
|
||||||
return priceCell?.value ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store for tracking streaming state
|
// Store for tracking streaming state
|
||||||
|
|
@ -445,15 +766,20 @@ export const streamingRawData = writable<
|
||||||
|
|
||||||
// Handler: raw_stream header (e.g., raw_stream_price)
|
// Handler: raw_stream header (e.g., raw_stream_price)
|
||||||
export function handleRawStreamHeader(subtype: string, payload: any) {
|
export function handleRawStreamHeader(subtype: string, payload: any) {
|
||||||
logger.info(`[RawStream] Header for ${subtype}:`, payload);
|
let targetSubtype = subtype;
|
||||||
|
const currentData = get(streamingRawData);
|
||||||
|
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
|
||||||
|
targetSubtype = 'priceslot';
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[RawStream] Header for ${targetSubtype}:`, payload);
|
||||||
|
|
||||||
// Get existing stream data to preserve country from request
|
// Get existing stream data to preserve country from request
|
||||||
const currentData = get(streamingRawData);
|
const existingData = currentData[targetSubtype];
|
||||||
const existingData = currentData[subtype];
|
|
||||||
|
|
||||||
streamingRawData.update((data) => ({
|
streamingRawData.update((data) => ({
|
||||||
...data,
|
...data,
|
||||||
[subtype]: {
|
[targetSubtype]: {
|
||||||
request_id: payload.request_id,
|
request_id: payload.request_id,
|
||||||
header: payload.header || payload.headers,
|
header: payload.header || payload.headers,
|
||||||
country: payload.country || existingData?.country || '',
|
country: payload.country || existingData?.country || '',
|
||||||
|
|
@ -462,7 +788,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (subtype === 'price') {
|
if (targetSubtype === 'price') {
|
||||||
sheetPriceStreamMeta.set({
|
sheetPriceStreamMeta.set({
|
||||||
request_id: payload.request_id,
|
request_id: payload.request_id,
|
||||||
country: payload.country || existingData?.country || '',
|
country: payload.country || existingData?.country || '',
|
||||||
|
|
@ -474,10 +800,18 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
|
||||||
|
|
||||||
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
|
// Handler: raw_stream chunk (e.g., raw_stream_chunk_price)
|
||||||
export function handleRawStreamChunk(subtype: string, payload: any) {
|
export function handleRawStreamChunk(subtype: string, payload: any) {
|
||||||
logger.info(`[RawStream] Chunk ${payload.idx} for ${subtype}, raw length:`, payload.raw?.length);
|
|
||||||
|
|
||||||
const currentData = get(streamingRawData);
|
const currentData = get(streamingRawData);
|
||||||
const streamData = currentData[subtype];
|
let targetSubtype = subtype;
|
||||||
|
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
|
||||||
|
targetSubtype = 'priceslot';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`,
|
||||||
|
payload.raw?.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const streamData = currentData[targetSubtype];
|
||||||
|
|
||||||
if (!streamData || streamData.request_id !== payload.request_id) {
|
if (!streamData || streamData.request_id !== payload.request_id) {
|
||||||
logger.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
|
logger.warn(`[RawStream] Chunk received for unknown stream: ${subtype}`);
|
||||||
|
|
@ -489,7 +823,7 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
|
||||||
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
|
// Accumulate raw parts - will be joined and parsed in handleRawStreamEnd
|
||||||
streamingRawData.update((data) => ({
|
streamingRawData.update((data) => ({
|
||||||
...data,
|
...data,
|
||||||
[subtype]: {
|
[targetSubtype]: {
|
||||||
...streamData,
|
...streamData,
|
||||||
country: payload.country || streamData.country,
|
country: payload.country || streamData.country,
|
||||||
rawParts: [...(streamData.rawParts || []), payload.raw]
|
rawParts: [...(streamData.rawParts || []), payload.raw]
|
||||||
|
|
@ -505,25 +839,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
|
||||||
|
|
||||||
streamingRawData.update((data) => ({
|
streamingRawData.update((data) => ({
|
||||||
...data,
|
...data,
|
||||||
[subtype]: {
|
[targetSubtype]: {
|
||||||
...streamData,
|
...streamData,
|
||||||
country: payload.country || streamData.country,
|
country: payload.country || streamData.country,
|
||||||
chunks: [...streamData.chunks, ...contentArray]
|
chunks: [...streamData.chunks, ...contentArray]
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(`[RawStream] Chunk for ${subtype}: +${contentArray.length} items`);
|
logger.info(`[RawStream] Chunk for ${targetSubtype}: +${contentArray.length} items`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler: raw_stream end (e.g., raw_stream_end_price)
|
// Handler: raw_stream end (e.g., raw_stream_end_price)
|
||||||
export function handleRawStreamEnd(subtype: string, payload: any) {
|
export function handleRawStreamEnd(subtype: string, payload: any) {
|
||||||
logger.info(`[RawStream] End payload for ${subtype}:`, payload);
|
|
||||||
|
|
||||||
const currentData = get(streamingRawData);
|
const currentData = get(streamingRawData);
|
||||||
const streamData = currentData[subtype];
|
let targetSubtype = subtype;
|
||||||
|
if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
|
||||||
|
targetSubtype = 'priceslot';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RawStream] End payload for ${targetSubtype}:`, payload);
|
||||||
|
|
||||||
|
const streamData = currentData[targetSubtype];
|
||||||
|
|
||||||
if (!streamData || streamData.request_id !== payload.request_id) {
|
if (!streamData || streamData.request_id !== payload.request_id) {
|
||||||
logger.warn(`[RawStream] End received for unknown stream: ${subtype}`);
|
logger.warn(`[RawStream] End received for unknown stream: ${targetSubtype}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -555,18 +894,41 @@ export function handleRawStreamEnd(subtype: string, payload: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[RawStream] End for ${subtype}: total ${chunks.length} items, country: ${country}`);
|
logger.info(
|
||||||
|
`[RawStream] End for ${targetSubtype}: total ${chunks.length} items, country: ${country}`
|
||||||
|
);
|
||||||
|
|
||||||
if (subtype === 'price') {
|
if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
|
||||||
processSheetPriceData(country, streamData.header || [], chunks);
|
handlePriceSlotsResponse({ country, slots: chunks });
|
||||||
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
|
}
|
||||||
sheetPriceLoading.set(false);
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (looksLikePriceSlot) {
|
||||||
|
handlePriceSlotsResponse({ country, slots: chunks });
|
||||||
|
} else {
|
||||||
|
processSheetPriceData(country, streamData.header || [], chunks);
|
||||||
|
sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
|
||||||
|
sheetPriceLoading.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the streaming data
|
// Clear the streaming data
|
||||||
streamingRawData.update((data) => {
|
streamingRawData.update((data) => {
|
||||||
const newData = { ...data };
|
const newData = { ...data };
|
||||||
delete newData[subtype];
|
delete newData[targetSubtype];
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -601,8 +963,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
||||||
|
|
||||||
// Find column indices dynamically from header
|
// Find column indices dynamically from header
|
||||||
// product_code header is typically "ProductCode" or similar
|
// product_code header is typically "ProductCode" or similar
|
||||||
const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
|
const productCodeIdx = findHeaderIndex(effectiveHeader, [
|
||||||
logger.info(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
|
'ProductCode',
|
||||||
|
'Product_Code',
|
||||||
|
'product_code',
|
||||||
|
'Code'
|
||||||
|
]);
|
||||||
|
logger.info(
|
||||||
|
`[SheetPrice] productCodeIdx from header:`,
|
||||||
|
productCodeIdx,
|
||||||
|
'header:',
|
||||||
|
effectiveHeader
|
||||||
|
);
|
||||||
|
|
||||||
const priceByProductCode: Record<string, GristCell[]> = {};
|
const priceByProductCode: Record<string, GristCell[]> = {};
|
||||||
// Track ALL rows per product code (for duplicates)
|
// Track ALL rows per product code (for duplicates)
|
||||||
|
|
@ -703,7 +1075,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
|
||||||
// Log duplicates info
|
// Log duplicates info
|
||||||
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
|
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
logger.info(`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`, duplicates.slice(0, 3));
|
logger.info(
|
||||||
|
`[SheetPrice] Found ${duplicates.length} product codes with duplicate rows:`,
|
||||||
|
duplicates.slice(0, 3)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
|
if (chunks.length > 0 && Object.keys(priceByProductCode).length > 0) {
|
||||||
const sampleKey = Object.keys(priceByProductCode)[0];
|
const sampleKey = Object.keys(priceByProductCode)[0];
|
||||||
|
|
@ -711,6 +1086,20 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Reset sheet price stores
|
||||||
export function resetSheetPriceStore() {
|
export function resetSheetPriceStore() {
|
||||||
sheetPriceStreamMeta.set(null);
|
sheetPriceStreamMeta.set(null);
|
||||||
|
|
@ -770,14 +1159,24 @@ export function loadProductCodesFromCache(country?: string): boolean {
|
||||||
// Only load if country matches (or no country filter specified)
|
// Only load if country matches (or no country filter specified)
|
||||||
if (data.codes && Array.isArray(data.codes)) {
|
if (data.codes && Array.isArray(data.codes)) {
|
||||||
if (country && data.country && data.country !== country) {
|
if (country && data.country && data.country !== country) {
|
||||||
logger.info('[sheetStore] Cache is for different country:', data.country, '!= requested:', country);
|
logger.info(
|
||||||
|
'[sheetStore] Cache is for different country:',
|
||||||
|
data.country,
|
||||||
|
'!= requested:',
|
||||||
|
country
|
||||||
|
);
|
||||||
// Clear the store for different country
|
// Clear the store for different country
|
||||||
existingProductCodes.set(new Set());
|
existingProductCodes.set(new Set());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
existingProductCodes.set(new Set(data.codes));
|
existingProductCodes.set(new Set(data.codes));
|
||||||
currentProductCodesCountry = data.country || '';
|
currentProductCodesCountry = data.country || '';
|
||||||
logger.info('[sheetStore] Loaded', data.codes.length, 'product codes from cache for', data.country || 'unknown');
|
logger.info(
|
||||||
|
'[sheetStore] Loaded',
|
||||||
|
data.codes.length,
|
||||||
|
'product codes from cache for',
|
||||||
|
data.country || 'unknown'
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -799,7 +1198,13 @@ export function clearProductCodes() {
|
||||||
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
|
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
|
||||||
// Use pending country if not in payload
|
// Use pending country if not in payload
|
||||||
const country = payload.country || pendingProductCodesCountry;
|
const country = payload.country || pendingProductCodesCountry;
|
||||||
logger.info('[sheetStore] Received list_menu_response for', country, ':', payload.codes?.length, 'codes');
|
logger.info(
|
||||||
|
'[sheetStore] Received list_menu_response for',
|
||||||
|
country,
|
||||||
|
':',
|
||||||
|
payload.codes?.length,
|
||||||
|
'codes'
|
||||||
|
);
|
||||||
|
|
||||||
if (payload && payload.codes) {
|
if (payload && payload.codes) {
|
||||||
existingProductCodes.set(new Set(payload.codes));
|
existingProductCodes.set(new Set(payload.codes));
|
||||||
|
|
@ -815,7 +1220,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
logger.info('[sheetStore] Saved', payload.codes.length, 'product codes to cache for', country);
|
logger.info(
|
||||||
|
'[sheetStore] Saved',
|
||||||
|
payload.codes.length,
|
||||||
|
'product codes to cache for',
|
||||||
|
country
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('[sheetStore] Failed to save to cache:', e);
|
logger.warn('[sheetStore] Failed to save to cache:', e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const ENABLE_WS_DEBUG: boolean = false;
|
||||||
export const socketConnectionOfflineCount = writable<number>(0);
|
export const socketConnectionOfflineCount = writable<number>(0);
|
||||||
export const socketAlreadySendHeartbeat = writable<number>(0);
|
export const socketAlreadySendHeartbeat = writable<number>(0);
|
||||||
export const socketStore = writable<WebSocket | null>(null);
|
export const socketStore = writable<WebSocket | null>(null);
|
||||||
|
export const wsAuthReady = writable<boolean>(false);
|
||||||
|
|
||||||
export const sharedKey = writable<CryptoKey | null>(null);
|
export const sharedKey = writable<CryptoKey | null>(null);
|
||||||
|
|
||||||
|
|
@ -54,6 +55,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) {
|
export async function connectToWebsocket(id_token?: string) {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// logger.info('connecting to ', env.PUBLIC_WSS);
|
// logger.info('connecting to ', env.PUBLIC_WSS);
|
||||||
|
|
@ -64,6 +90,7 @@ export async function connectToWebsocket(id_token?: string) {
|
||||||
|
|
||||||
let ws_url = env.PUBLIC_WSS;
|
let ws_url = env.PUBLIC_WSS;
|
||||||
socket = new WebSocket(ws_url);
|
socket = new WebSocket(ws_url);
|
||||||
|
wsAuthReady.set(false);
|
||||||
sharedKey.set(null);
|
sharedKey.set(null);
|
||||||
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
|
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
|
||||||
|
|
||||||
|
|
@ -89,13 +116,16 @@ export async function connectToWebsocket(id_token?: string) {
|
||||||
|
|
||||||
sendAuthInfoInterval = setInterval(async () => {
|
sendAuthInfoInterval = setInterval(async () => {
|
||||||
if (get(sharedKey)) {
|
if (get(sharedKey)) {
|
||||||
|
auth_data = get(authStore);
|
||||||
|
perms = get(permission);
|
||||||
// Debug: check if auth_data has uid
|
// Debug: check if auth_data has uid
|
||||||
logger.info('[WS Auth] Sending auth info with:', {
|
logger.info('[WS Auth] Sending auth info with:', {
|
||||||
uid: auth_data?.uid,
|
uid: auth_data?.uid,
|
||||||
name: auth_data?.displayName,
|
name: auth_data?.displayName,
|
||||||
email: auth_data?.email
|
email: auth_data?.email,
|
||||||
|
date: new Date()
|
||||||
});
|
});
|
||||||
await sendMessage({
|
const sent = await sendMessage({
|
||||||
type: 'auth',
|
type: 'auth',
|
||||||
payload: {
|
payload: {
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -106,9 +136,10 @@ export async function connectToWebsocket(id_token?: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
wsAuthReady.set(sent);
|
||||||
clearInterval(sendAuthInfoInterval);
|
clearInterval(sendAuthInfoInterval);
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
logger.info(socket);
|
logger.info(socket);
|
||||||
|
|
||||||
|
|
@ -161,10 +192,12 @@ export async function connectToWebsocket(id_token?: string) {
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
socketStore.set(null);
|
socketStore.set(null);
|
||||||
|
wsAuthReady.set(false);
|
||||||
sharedKey.set(null);
|
sharedKey.set(null);
|
||||||
socket = null;
|
socket = null;
|
||||||
|
|
||||||
clearInterval(socketCheck);
|
clearInterval(socketCheck);
|
||||||
|
clearInterval(sendAuthInfoInterval);
|
||||||
|
|
||||||
if (auth.currentUser && !socket) {
|
if (auth.currentUser && !socket) {
|
||||||
logger.info('try reconnect websocket ...');
|
logger.info('try reconnect websocket ...');
|
||||||
|
|
@ -179,6 +212,7 @@ export async function connectToWebsocket(id_token?: string) {
|
||||||
socket.addEventListener('error', (e) => {
|
socket.addEventListener('error', (e) => {
|
||||||
// logger.info('WebSocket error: ', e);
|
// logger.info('WebSocket error: ', e);
|
||||||
socketStore.set(null);
|
socketStore.set(null);
|
||||||
|
wsAuthReady.set(false);
|
||||||
sharedKey.set(null);
|
sharedKey.set(null);
|
||||||
clearInterval(socketCheck);
|
clearInterval(socketCheck);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
export class WebCryptoHelper {
|
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() {
|
static async generateKeyPair() {
|
||||||
const keyPair = await window.crypto.subtle.generateKey(
|
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 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 };
|
return { privateKey: keyPair.privateKey, publicKeyBase64 };
|
||||||
}
|
}
|
||||||
|
|
@ -60,8 +70,8 @@ export class WebCryptoHelper {
|
||||||
encodedText
|
encodedText
|
||||||
);
|
);
|
||||||
|
|
||||||
const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer)));
|
const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
|
||||||
const ivBase64 = btoa(String.fromCharCode(...iv));
|
const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
|
||||||
|
|
||||||
return { ciphertext: ciphertextBase64, iv: ivBase64 };
|
return { ciphertext: ciphertextBase64, iv: ivBase64 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@
|
||||||
|
|
||||||
if (refPage === 'priceslot') {
|
if (refPage === 'priceslot') {
|
||||||
await goto(`/sheet/priceslot/${cnt}`);
|
await goto(`/sheet/priceslot/${cnt}`);
|
||||||
|
} else if (refPage === 'price') {
|
||||||
|
await goto(`/sheet/price/${cnt}`);
|
||||||
} else if (refPage === 'sheet') {
|
} else if (refPage === 'sheet') {
|
||||||
await goto(`/sheet/overview/${cnt}`);
|
await goto(`/sheet/overview/${cnt}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -38,7 +40,7 @@
|
||||||
|
|
||||||
// read or write permission
|
// read or write permission
|
||||||
let userCurrentPerms = get(currentPerms).filter((x) => {
|
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.write');
|
||||||
}
|
}
|
||||||
return x.startsWith('document.read');
|
return x.startsWith('document.read');
|
||||||
|
|
@ -51,7 +53,7 @@
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// read or write permission
|
// read or write permission
|
||||||
let userCurrentPerms = get(currentPerms).filter((x) => {
|
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.write');
|
||||||
}
|
}
|
||||||
return x.startsWith('document.read');
|
return x.startsWith('document.read');
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">import { logger } from '$lib/core/utils/logger';
|
<script lang="ts">
|
||||||
|
import { logger } from '$lib/core/utils/logger';
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
@ -11,7 +11,16 @@
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
import { adbConnectionStatus } from '$lib/core/stores/adbConnectionStore';
|
||||||
import { addNotification } from '$lib/core/stores/noti';
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
import { referenceFromPage } from '$lib/core/stores/recipeStore';
|
import {
|
||||||
|
materialFromServerQuery,
|
||||||
|
referenceFromPage,
|
||||||
|
toppingGroupFromServerQuery,
|
||||||
|
toppingListFromServerQuery
|
||||||
|
} from '$lib/core/stores/recipeStore';
|
||||||
|
import { getRecipes } from '$lib/core/client/server';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { permission } from '$lib/core/stores/permissions';
|
||||||
|
import { departmentStore } from '$lib/core/stores/departments';
|
||||||
|
|
||||||
type ToppingListItem = {
|
type ToppingListItem = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -76,18 +85,27 @@
|
||||||
|
|
||||||
const sourceDir = '/sdcard/coffeevending';
|
const sourceDir = '/sdcard/coffeevending';
|
||||||
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
|
const recipePaths = [`${sourceDir}/cfg/recipe_branch_dev.json`, `${sourceDir}/coffeethai02.json`];
|
||||||
|
const machineCountryShortPath = '/mnt/sdcard/coffeevending/country/short';
|
||||||
|
|
||||||
let devRecipe: any = $state(null);
|
let devRecipe: any = $state(null);
|
||||||
let loadedRecipePath = $state('');
|
let loadedRecipePath = $state('');
|
||||||
|
let loadedFromServer = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let activeTab: 'list' | 'group' = $state('list');
|
let activeTab: 'list' | 'group' = $state('group');
|
||||||
let listDialogOpen = $state(false);
|
let listDialogOpen = $state(false);
|
||||||
let groupDialogOpen = $state(false);
|
let groupDialogOpen = $state(false);
|
||||||
let deleteConfirmOpen = $state(false);
|
let deleteConfirmOpen = $state(false);
|
||||||
let pendingDelete: { type: 'list' | 'group'; item: ToppingListItem | ToppingGroup } | null =
|
let pendingDelete: { type: 'list' | 'group'; item: ToppingListItem | ToppingGroup } | null =
|
||||||
$state(null);
|
$state(null);
|
||||||
|
let serverCountries: string[] = $state([]);
|
||||||
|
let selectedServerCountry = $state(get(departmentStore) ?? '');
|
||||||
|
let machineCountryShort = $state('');
|
||||||
|
let serverCountryDialogOpen = $state(false);
|
||||||
|
let machineNotConnectedDialogOpen = $state(false);
|
||||||
|
let groupToppingDialogOpen = $state(false);
|
||||||
|
let selectedGroupForToppings: ToppingGroup | null = $state(null);
|
||||||
|
|
||||||
let listForm: ToppingListForm = $state(createInitialListForm());
|
let listForm: ToppingListForm = $state(createInitialListForm());
|
||||||
let groupForm: ToppingGroupForm = $state(createInitialGroupForm());
|
let groupForm: ToppingGroupForm = $state(createInitialGroupForm());
|
||||||
|
|
@ -109,6 +127,11 @@
|
||||||
let activeGroupCount = $derived(
|
let activeGroupCount = $derived(
|
||||||
toppingGroups.filter((group) => (group.inUse as boolean) !== false).length
|
toppingGroups.filter((group) => (group.inUse as boolean) !== false).length
|
||||||
);
|
);
|
||||||
|
let canWriteToAndroid = $derived(!loadedFromServer && Boolean(adb.getAdbInstance()));
|
||||||
|
let primaryLanguageLabel = $derived(getPrimaryLanguageLabel(getActiveRecipeCountry()));
|
||||||
|
let toppingListGridClass = 'md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px]';
|
||||||
|
let toppingGroupGridClass =
|
||||||
|
'md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px]';
|
||||||
let filteredToppingList = $derived(
|
let filteredToppingList = $derived(
|
||||||
toppingList.filter((item) => {
|
toppingList.filter((item) => {
|
||||||
const text = `${item.id} ${item.name ?? ''} ${item.otherName ?? ''}`.toLowerCase();
|
const text = `${item.id} ${item.name ?? ''} ${item.otherName ?? ''}`.toLowerCase();
|
||||||
|
|
@ -122,6 +145,61 @@
|
||||||
return text.includes(search.toLowerCase());
|
return text.includes(search.toLowerCase());
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function getSupportedServerCountries(permissions: string[]) {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
permissions
|
||||||
|
.filter((item) => item.startsWith('document.read.'))
|
||||||
|
.map((item) => item.split('.')[2])
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThaiCountry(country: string) {
|
||||||
|
return ['tha', 'thai'].includes(country.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCountryShort(country: string) {
|
||||||
|
return country.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveRecipeCountry() {
|
||||||
|
return loadedFromServer ? selectedServerCountry : machineCountryShort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryLanguageLabel(country: string) {
|
||||||
|
const normalized = country.trim().toUpperCase();
|
||||||
|
const languageByCountry: Record<string, string> = {
|
||||||
|
THAI: 'Thai',
|
||||||
|
THA: 'Thai',
|
||||||
|
MYS: 'Malay',
|
||||||
|
IDR: 'Indonesian',
|
||||||
|
AUS: 'English',
|
||||||
|
SGP: 'English',
|
||||||
|
SG: 'English',
|
||||||
|
UAE_DUBAI: 'Arabic',
|
||||||
|
DUBAI: 'Arabic',
|
||||||
|
HKG: 'Chinese',
|
||||||
|
GBR: 'English',
|
||||||
|
ROU: 'Romanian',
|
||||||
|
LVA: 'Latvian',
|
||||||
|
EST: 'Estonian',
|
||||||
|
LTU: 'Lithuanian',
|
||||||
|
USA_PEPSI: 'English'
|
||||||
|
};
|
||||||
|
|
||||||
|
return languageByCountry[normalized] ?? (normalized ? normalized : 'Primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribePermission = permission.subscribe((permissions) => {
|
||||||
|
serverCountries = getSupportedServerCountries(permissions);
|
||||||
|
|
||||||
|
if (!selectedServerCountry && serverCountries.length > 0) {
|
||||||
|
selectedServerCountry = serverCountries[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
let existingListItem = $derived(
|
let existingListItem = $derived(
|
||||||
toppingList.find((item) => Number(item.id) === Number(listForm.id)) ?? null
|
toppingList.find((item) => Number(item.id) === Number(listForm.id)) ?? null
|
||||||
);
|
);
|
||||||
|
|
@ -146,6 +224,25 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getToppingGroupListIDs(group: ToppingGroup) {
|
||||||
|
const raw = group.idInGroup ?? '';
|
||||||
|
if (Array.isArray(raw)) return raw.map(Number).filter(Number.isFinite);
|
||||||
|
return String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map((id) => Number(id.trim()))
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToppingListsForGroup(group: ToppingGroup) {
|
||||||
|
const listIDs = new Set(getToppingGroupListIDs(group));
|
||||||
|
return toppingList.filter((item) => listIDs.has(Number(item.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGroupToppingDialog(group: ToppingGroup) {
|
||||||
|
selectedGroupForToppings = group;
|
||||||
|
groupToppingDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
function createInitialListForm(): ToppingListForm {
|
function createInitialListForm(): ToppingListForm {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|
@ -197,6 +294,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMachineCountryShort() {
|
||||||
|
const content = await pullTextWithRetry(machineCountryShortPath, 5000, 1);
|
||||||
|
return normalizeCountryShort(content ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
async function connectAdb() {
|
async function connectAdb() {
|
||||||
try {
|
try {
|
||||||
if (!adb.getAdbInstance()) {
|
if (!adb.getAdbInstance()) {
|
||||||
|
|
@ -204,12 +306,114 @@
|
||||||
await adb.connectRecipeMenuViaWebUSB();
|
await adb.connectRecipeMenuViaWebUSB();
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadRecipeFromMachine();
|
addNotification('INFO:Machine connected');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
addNotification(`ERR:${error?.message ?? error}`);
|
addNotification(`ERR:${error?.message ?? error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFromMachineSource() {
|
||||||
|
if (!adb.getAdbInstance()) {
|
||||||
|
machineNotConnectedDialogOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadRecipeFromMachine();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServerStore(store: any, timeoutMs = 20000, quietMs = 800) {
|
||||||
|
const current = get(store);
|
||||||
|
if (Array.isArray(current) && current.length > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, quietMs));
|
||||||
|
const latest = get(store);
|
||||||
|
if (Array.isArray(latest) && latest.length === current.length) return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<any[]>((resolve) => {
|
||||||
|
let quietTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (quietTimeout) clearTimeout(quietTimeout);
|
||||||
|
unsubscribe();
|
||||||
|
const latest = get(store);
|
||||||
|
resolve(Array.isArray(latest) ? latest : []);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const unsubscribe = store.subscribe((value: any) => {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
if (quietTimeout) clearTimeout(quietTimeout);
|
||||||
|
quietTimeout = setTimeout(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribe();
|
||||||
|
resolve(value);
|
||||||
|
}, quietMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipeFromServer() {
|
||||||
|
if (loading) return;
|
||||||
|
if (!selectedServerCountry) {
|
||||||
|
addNotification('ERR:Select country before loading from server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
loadedFromServer = false;
|
||||||
|
machineCountryShort = '';
|
||||||
|
referenceFromPage.set('topping');
|
||||||
|
departmentStore.set(selectedServerCountry);
|
||||||
|
materialFromServerQuery.set([]);
|
||||||
|
toppingListFromServerQuery.set([]);
|
||||||
|
toppingGroupFromServerQuery.set([]);
|
||||||
|
try {
|
||||||
|
await getRecipes();
|
||||||
|
const [serverMaterials, serverToppingList, serverToppingGroups] = await Promise.all([
|
||||||
|
waitForServerStore(materialFromServerQuery),
|
||||||
|
waitForServerStore(toppingListFromServerQuery),
|
||||||
|
waitForServerStore(toppingGroupFromServerQuery)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (serverToppingList.length === 0 || serverToppingGroups.length === 0) {
|
||||||
|
addNotification('ERR:Cannot fetch topping data from server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devRecipe = {
|
||||||
|
MaterialSetting: serverMaterials,
|
||||||
|
Topping: {
|
||||||
|
ToppingList: serverToppingList,
|
||||||
|
ToppingGroup: serverToppingGroups
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadedRecipePath = `Server recipe (${selectedServerCountry})`;
|
||||||
|
loadedFromServer = true;
|
||||||
|
setNextListId();
|
||||||
|
setNextGroupId();
|
||||||
|
addNotification('INFO:Topping data loaded from server');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('failed to load toppings from server', error);
|
||||||
|
addNotification(`ERR:Failed to load toppings from server: ${error?.message ?? error}`);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openServerCountryDialog() {
|
||||||
|
if (serverCountries.length === 0) {
|
||||||
|
addNotification('ERR:No readable countries');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCountryDialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectServerCountry(country: string) {
|
||||||
|
selectedServerCountry = country;
|
||||||
|
serverCountryDialogOpen = false;
|
||||||
|
await loadRecipeFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRecipeFromMachine() {
|
async function loadRecipeFromMachine() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!adb.getAdbInstance()) {
|
if (!adb.getAdbInstance()) {
|
||||||
|
|
@ -218,8 +422,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
loadedFromServer = false;
|
||||||
referenceFromPage.set('topping');
|
referenceFromPage.set('topping');
|
||||||
try {
|
try {
|
||||||
|
machineCountryShort = await loadMachineCountryShort();
|
||||||
|
if (!machineCountryShort) addNotification('WARN:Cannot read machine country short');
|
||||||
|
|
||||||
for (const recipePath of recipePaths) {
|
for (const recipePath of recipePaths) {
|
||||||
const content = await pullTextWithRetry(recipePath);
|
const content = await pullTextWithRetry(recipePath);
|
||||||
if (!content || content.trim().length === 0) continue;
|
if (!content || content.trim().length === 0) continue;
|
||||||
|
|
@ -244,6 +452,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistRecipeToAndroid(nextRecipe: any) {
|
async function persistRecipeToAndroid(nextRecipe: any) {
|
||||||
|
if (!adb.getAdbInstance() || loadedFromServer) {
|
||||||
|
throw new Error('ADB is required to save recipe changes to Android');
|
||||||
|
}
|
||||||
|
|
||||||
nextRecipe.Timestamp = new Date().toLocaleString('en-GB', {
|
nextRecipe.Timestamp = new Date().toLocaleString('en-GB', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
|
@ -331,22 +543,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateListForm() {
|
function validateListForm() {
|
||||||
if (!devRecipe) return 'Load recipe from Android first';
|
if (!devRecipe) return 'Load recipe first';
|
||||||
|
if (loadedFromServer) return 'Connect ADB before saving topping changes';
|
||||||
if (!Array.isArray(devRecipe?.Topping?.ToppingList)) return 'Recipe has no ToppingList array';
|
if (!Array.isArray(devRecipe?.Topping?.ToppingList)) return 'Recipe has no ToppingList array';
|
||||||
if (!Number.isFinite(Number(listForm.id)) || Number(listForm.id) < 0)
|
if (!Number.isFinite(Number(listForm.id)) || Number(listForm.id) < 0)
|
||||||
return 'Topping ID is required';
|
return 'Topping ID is required';
|
||||||
if (!listForm.name.trim()) return 'Thai name is required';
|
if (!listForm.name.trim()) return `${primaryLanguageLabel} name is required`;
|
||||||
if (!listForm.otherName.trim()) return 'English name is required';
|
if (!listForm.otherName.trim()) return 'English name is required';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateGroupForm() {
|
function validateGroupForm() {
|
||||||
if (!devRecipe) return 'Load recipe from Android first';
|
if (!devRecipe) return 'Load recipe first';
|
||||||
|
if (loadedFromServer) return 'Connect ADB before saving topping changes';
|
||||||
if (!Array.isArray(devRecipe?.Topping?.ToppingGroup)) return 'Recipe has no ToppingGroup array';
|
if (!Array.isArray(devRecipe?.Topping?.ToppingGroup)) return 'Recipe has no ToppingGroup array';
|
||||||
if (!Number.isFinite(Number(groupForm.groupID)) || Number(groupForm.groupID) <= 0) {
|
if (!Number.isFinite(Number(groupForm.groupID)) || Number(groupForm.groupID) <= 0) {
|
||||||
return 'Group ID is required';
|
return 'Group ID is required';
|
||||||
}
|
}
|
||||||
if (!groupForm.name.trim()) return 'Thai group name is required';
|
if (!groupForm.name.trim()) return `${primaryLanguageLabel} group name is required`;
|
||||||
if (!groupForm.otherName.trim()) return 'English group name is required';
|
if (!groupForm.otherName.trim()) return 'English group name is required';
|
||||||
if (!groupForm.idInGroup.trim()) return 'Topping IDs in group are required';
|
if (!groupForm.idInGroup.trim()) return 'Topping IDs in group are required';
|
||||||
return '';
|
return '';
|
||||||
|
|
@ -502,6 +716,11 @@
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
|
if (loadedFromServer) {
|
||||||
|
addNotification('ERR:Connect ADB before deleting topping data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
|
const nextRecipe = JSON.parse(JSON.stringify(devRecipe));
|
||||||
|
|
@ -533,7 +752,10 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
referenceFromPage.set('topping');
|
referenceFromPage.set('topping');
|
||||||
if (adb.getAdbInstance()) void loadRecipeFromMachine();
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribePermission();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -553,19 +775,93 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" onclick={connectAdb} disabled={loading || saving}>
|
<div class="flex flex-col gap-2 rounded-xl border bg-background p-2 shadow-sm">
|
||||||
{#if loading}
|
<div class="px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
<Spinner />
|
Recipe Source
|
||||||
Loading
|
</div>
|
||||||
{:else if devRecipe}
|
<div class="flex flex-wrap gap-2">
|
||||||
Reload From Android
|
<Button
|
||||||
{:else}
|
variant="outline"
|
||||||
Connect & Load
|
size="sm"
|
||||||
{/if}
|
class="min-w-32 justify-center self-center text-xs font-medium text-muted-foreground"
|
||||||
</Button>
|
onclick={connectAdb}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2.5 w-2.5 rounded-full {adb.getAdbInstance()
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: 'bg-destructive'}"
|
||||||
|
></span>
|
||||||
|
{adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={!loadedFromServer && devRecipe ? 'default' : 'ghost'}
|
||||||
|
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
|
||||||
|
onclick={loadFromMachineSource}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner />
|
||||||
|
Loading
|
||||||
|
{:else}
|
||||||
|
Machine
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={loadedFromServer ? 'default' : 'ghost'}
|
||||||
|
class="min-w-36 justify-center px-5 py-5 text-base font-semibold"
|
||||||
|
onclick={openServerCountryDialog}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={serverCountryDialogOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-lg">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Load Recipe From Server</Dialog.Title>
|
||||||
|
<Dialog.Description>Select a country to load topping data from server.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid gap-2 py-2">
|
||||||
|
{#each serverCountries as country}
|
||||||
|
<Button
|
||||||
|
variant={country === selectedServerCountry ? 'default' : 'outline'}
|
||||||
|
class="h-12 justify-start text-base font-semibold"
|
||||||
|
onclick={() => selectServerCountry(country)}
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
{country}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="outline" onclick={() => (serverCountryDialogOpen = false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={machineNotConnectedDialogOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Machine Not Connected</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button variant="outline" onclick={() => (machineNotConnectedDialogOpen = false)}>OK</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<div class="grid gap-2 md:grid-cols-3">
|
<div class="grid gap-2 md:grid-cols-3">
|
||||||
<div class="rounded-lg border border-sky-200 bg-card px-4 py-3 shadow-sm dark:border-sky-900">
|
<div class="rounded-lg border border-sky-200 bg-card px-4 py-3 shadow-sm dark:border-sky-900">
|
||||||
<div class="mb-2 h-1 w-10 rounded-full bg-sky-500"></div>
|
<div class="mb-2 h-1 w-10 rounded-full bg-sky-500"></div>
|
||||||
|
|
@ -593,23 +889,23 @@
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Card.Title>{activeTab === 'list' ? 'Topping List' : 'Topping Group'}</Card.Title>
|
<Card.Title>{activeTab === 'list' ? 'Topping List' : 'Topping Group'}</Card.Title>
|
||||||
<Card.Description>
|
<!-- <Card.Description>
|
||||||
Switch between list items and groups. Edit/Delete actions are explicit per row.
|
Switch between list items and groups. Connect ADB to write changes back to Android.
|
||||||
</Card.Description>
|
</Card.Description> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
|
||||||
variant={activeTab === 'list' ? 'default' : 'outline'}
|
|
||||||
onclick={() => (activeTab = 'list')}
|
|
||||||
>
|
|
||||||
ToppingList
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'group' ? 'default' : 'outline'}
|
variant={activeTab === 'group' ? 'default' : 'outline'}
|
||||||
onclick={() => (activeTab = 'group')}
|
onclick={() => (activeTab = 'group')}
|
||||||
>
|
>
|
||||||
ToppingGroup
|
ToppingGroup
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeTab === 'list' ? 'default' : 'outline'}
|
||||||
|
onclick={() => (activeTab = 'list')}
|
||||||
|
>
|
||||||
|
ToppingList
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
@ -617,12 +913,14 @@
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<Input class="lg:max-w-md" bind:value={search} placeholder="Search by id, name, code" />
|
<Input class="lg:max-w-md" bind:value={search} placeholder="Search by id, name, code" />
|
||||||
{#if activeTab === 'list'}
|
{#if activeTab === 'list'}
|
||||||
<Button onclick={openAddListDialog} disabled={!devRecipe || loading || saving}
|
<Button
|
||||||
>Add Topping</Button
|
onclick={openAddListDialog}
|
||||||
|
disabled={!devRecipe || loading || saving || !canWriteToAndroid}>Add Topping</Button
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<Button onclick={openAddGroupDialog} disabled={!devRecipe || loading || saving}
|
<Button
|
||||||
>Add Group</Button
|
onclick={openAddGroupDialog}
|
||||||
|
disabled={!devRecipe || loading || saving || !canWriteToAndroid}>Add Group</Button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -631,19 +929,19 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center gap-3 p-4 text-sm text-muted-foreground">
|
<div class="flex items-center gap-3 p-4 text-sm text-muted-foreground">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
Loading toppings from Android...
|
Loading toppings...
|
||||||
</div>
|
</div>
|
||||||
{:else if !devRecipe}
|
{:else if !devRecipe}
|
||||||
<div class="p-4 text-sm text-muted-foreground">Connect and load recipe first.</div>
|
<div class="p-4 text-sm text-muted-foreground">Load recipe first.</div>
|
||||||
{:else if activeTab === 'list'}
|
{:else if activeTab === 'list'}
|
||||||
{#if filteredToppingList.length === 0}
|
{#if filteredToppingList.length === 0}
|
||||||
<div class="p-4 text-sm text-muted-foreground">No topping list items found.</div>
|
<div class="p-4 text-sm text-muted-foreground">No topping list items found.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid {toppingListGridClass} md:items-center"
|
||||||
>
|
>
|
||||||
<span>ID</span>
|
<span>ID</span>
|
||||||
<span>Thai Name</span>
|
<span>Name ({primaryLanguageLabel})</span>
|
||||||
<span>English Name</span>
|
<span>English Name</span>
|
||||||
<span>Use</span>
|
<span>Use</span>
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-right">Actions</span>
|
||||||
|
|
@ -651,7 +949,7 @@
|
||||||
<div class="grid max-h-[70vh] overflow-auto">
|
<div class="grid max-h-[70vh] overflow-auto">
|
||||||
{#each filteredToppingList as item}
|
{#each filteredToppingList as item}
|
||||||
<div
|
<div
|
||||||
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 {toppingListGridClass} md:items-center"
|
||||||
>
|
>
|
||||||
<span class="font-mono font-medium text-primary">{item.id}</span>
|
<span class="font-mono font-medium text-primary">{item.id}</span>
|
||||||
<span class="font-medium">{item.name || '-'}</span>
|
<span class="font-medium">{item.name || '-'}</span>
|
||||||
|
|
@ -664,14 +962,17 @@
|
||||||
{(item.isUse as boolean) !== false ? 'Use' : 'Not use'}
|
{(item.isUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-2 md:justify-end">
|
<div class="flex gap-2 md:justify-end">
|
||||||
<Button variant="outline" size="sm" onclick={() => loadListIntoForm(item)}
|
<Button
|
||||||
>Edit</Button
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => loadListIntoForm(item)}
|
||||||
|
disabled={!canWriteToAndroid}>Edit</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => openDeleteConfirm('list', item)}
|
onclick={() => openDeleteConfirm('list', item)}
|
||||||
disabled={saving}
|
disabled={saving || !canWriteToAndroid}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -684,10 +985,10 @@
|
||||||
<div class="p-4 text-sm text-muted-foreground">No topping groups found.</div>
|
<div class="p-4 text-sm text-muted-foreground">No topping groups found.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
class="hidden border-b bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground md:grid {toppingGroupGridClass} md:items-center"
|
||||||
>
|
>
|
||||||
<span>ID</span>
|
<span>ID</span>
|
||||||
<span>Thai Name</span>
|
<span>Name ({primaryLanguageLabel})</span>
|
||||||
<span>English Name</span>
|
<span>English Name</span>
|
||||||
<span>IDs in group</span>
|
<span>IDs in group</span>
|
||||||
<span>Use</span>
|
<span>Use</span>
|
||||||
|
|
@ -695,33 +996,52 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="grid max-h-[70vh] overflow-auto">
|
<div class="grid max-h-[70vh] overflow-auto">
|
||||||
{#each filteredToppingGroups as group}
|
{#each filteredToppingGroups as group}
|
||||||
<div
|
<div class="border-b">
|
||||||
class="grid gap-3 border-b p-4 text-sm transition-colors hover:bg-primary/5 md:grid-cols-[90px_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_90px_150px] md:items-center"
|
<div
|
||||||
>
|
role="button"
|
||||||
<span class="font-mono font-medium text-primary">{group.groupID}</span>
|
tabindex="0"
|
||||||
<span class="font-medium">{group.name || '-'}</span>
|
class="grid w-full gap-3 p-4 text-left text-sm transition-colors hover:bg-primary/5 {toppingGroupGridClass} md:items-center"
|
||||||
<span class="text-muted-foreground">{group.otherName || '-'}</span>
|
onclick={() => openGroupToppingDialog(group)}
|
||||||
<span class="font-mono text-xs text-muted-foreground">{group.idInGroup || '-'}</span
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') openGroupToppingDialog(group);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span class="font-mono font-medium text-primary">{group.groupID}</span>
|
||||||
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !== false
|
<span class="font-medium">{group.name || '-'}</span>
|
||||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
<span class="text-muted-foreground">{group.otherName || '-'}</span>
|
||||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
<span class="font-mono text-xs text-muted-foreground"
|
||||||
>
|
>{group.idInGroup || '-'}</span
|
||||||
{(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2 md:justify-end">
|
|
||||||
<Button variant="outline" size="sm" onclick={() => loadGroupIntoForm(group)}
|
|
||||||
>Edit</Button
|
|
||||||
>
|
>
|
||||||
<Button
|
<span
|
||||||
variant="destructive"
|
class="w-fit rounded-full px-2.5 py-1 text-xs {(group.inUse as boolean) !==
|
||||||
size="sm"
|
false
|
||||||
onclick={() => openDeleteConfirm('group', group)}
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
disabled={saving}
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
||||||
>
|
>
|
||||||
Delete
|
{(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||||
</Button>
|
</span>
|
||||||
|
<div class="flex gap-2 md:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
loadGroupIntoForm(group);
|
||||||
|
}}
|
||||||
|
disabled={!canWriteToAndroid}>Edit</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openDeleteConfirm('group', group);
|
||||||
|
}}
|
||||||
|
disabled={saving || !canWriteToAndroid}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -731,11 +1051,70 @@
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={groupToppingDialogOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>
|
||||||
|
ToppingList in Group {selectedGroupForToppings?.groupID ?? ''}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{selectedGroupForToppings?.otherName ||
|
||||||
|
selectedGroupForToppings?.name ||
|
||||||
|
'Selected group'}
|
||||||
|
{selectedGroupForToppings?.idInGroup ? ` (${selectedGroupForToppings.idInGroup})` : ''}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if selectedGroupForToppings}
|
||||||
|
{@const groupToppings = getToppingListsForGroup(selectedGroupForToppings)}
|
||||||
|
<div class="grid gap-3 py-2">
|
||||||
|
{#if groupToppings.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No matching topping list items found for {selectedGroupForToppings.idInGroup ||
|
||||||
|
'this group'}.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
{#each groupToppings as toppingItem}
|
||||||
|
<div class="rounded-md border bg-background p-4 text-sm shadow-sm">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="font-mono text-xs text-primary">{toppingItem.id}</div>
|
||||||
|
<div class="mt-1 font-medium">{toppingItem.name || '-'}</div>
|
||||||
|
<div class="text-muted-foreground">{toppingItem.otherName || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2.5 py-1 text-xs {(toppingItem.isUse as boolean) !==
|
||||||
|
false
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'}"
|
||||||
|
>
|
||||||
|
{(toppingItem.isUse as boolean) !== false ? 'Use' : 'Not use'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (groupToppingDialogOpen = false)}>Close</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<Dialog.Root bind:open={listDialogOpen}>
|
<Dialog.Root bind:open={listDialogOpen}>
|
||||||
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
|
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{existingListItem ? 'Edit Topping' : 'Add Topping'}</Dialog.Title>
|
<Dialog.Title>{existingListItem ? 'Edit Topping' : 'Add Topping'}</Dialog.Title>
|
||||||
<Dialog.Description>Manage one item inside <code>ToppingList</code>.</Dialog.Description>
|
<Dialog.Description>
|
||||||
|
Manage one item inside <code>ToppingList</code>. Server-loaded data is read-only until ADB
|
||||||
|
is connected.
|
||||||
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
|
|
@ -758,7 +1137,7 @@
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="topping-name">Thai Name</Label>
|
<Label for="topping-name">Name ({primaryLanguageLabel})</Label>
|
||||||
<Input id="topping-name" bind:value={listForm.name} />
|
<Input id="topping-name" bind:value={listForm.name} />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|
@ -929,7 +1308,7 @@
|
||||||
<Button variant="outline" onclick={() => (listDialogOpen = false)} disabled={saving}
|
<Button variant="outline" onclick={() => (listDialogOpen = false)} disabled={saving}
|
||||||
>Cancel</Button
|
>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button onclick={saveToppingListToAndroid} disabled={saving}
|
<Button onclick={saveToppingListToAndroid} disabled={saving || !canWriteToAndroid}
|
||||||
>{saving ? 'Saving...' : 'Save Topping'}</Button
|
>{saving ? 'Saving...' : 'Save Topping'}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -957,7 +1336,10 @@
|
||||||
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
<Dialog.Content class="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}</Dialog.Title>
|
<Dialog.Title>{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}</Dialog.Title>
|
||||||
<Dialog.Description>Manage one group inside <code>ToppingGroup</code>.</Dialog.Description>
|
<Dialog.Description>
|
||||||
|
Manage one group inside <code>ToppingGroup</code>. Server-loaded data is read-only until
|
||||||
|
ADB is connected.
|
||||||
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
|
@ -980,7 +1362,7 @@
|
||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="group-name">Thai Name</Label>
|
<Label for="group-name">Name ({primaryLanguageLabel})</Label>
|
||||||
<Input id="group-name" bind:value={groupForm.name} />
|
<Input id="group-name" bind:value={groupForm.name} />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|
@ -1004,7 +1386,7 @@
|
||||||
<Button variant="outline" onclick={() => (groupDialogOpen = false)} disabled={saving}
|
<Button variant="outline" onclick={() => (groupDialogOpen = false)} disabled={saving}
|
||||||
>Cancel</Button
|
>Cancel</Button
|
||||||
>
|
>
|
||||||
<Button onclick={saveToppingGroupToAndroid} disabled={saving}
|
<Button onclick={saveToppingGroupToAndroid} disabled={saving || !canWriteToAndroid}
|
||||||
>{saving ? 'Saving...' : 'Save Group'}</Button
|
>{saving ? 'Saving...' : 'Save Group'}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1032,9 +1414,9 @@
|
||||||
<Dialog.Content class="sm:max-w-md">
|
<Dialog.Content class="sm:max-w-md">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Delete Topping?</Dialog.Title>
|
<Dialog.Title>Delete Topping?</Dialog.Title>
|
||||||
<Dialog.Description
|
<Dialog.Description>
|
||||||
>This will remove the selected entry from Android recipe JSON.</Dialog.Description
|
This will remove the selected entry from Android recipe JSON. ADB is required.
|
||||||
>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
{#if pendingDelete}
|
{#if pendingDelete}
|
||||||
|
|
@ -1064,7 +1446,11 @@
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onclick={confirmDelete} disabled={saving}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onclick={confirmDelete}
|
||||||
|
disabled={saving || !canWriteToAndroid}
|
||||||
|
>
|
||||||
{saving ? 'Deleting...' : 'Delete'}
|
{saving ? 'Deleting...' : 'Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,7 @@
|
||||||
logger.warn('Failed to get boxid from machine:', e);
|
logger.warn('Failed to get boxid from machine:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requestListMenu(country, boxid);
|
await requestListMenu(country, boxid);
|
||||||
|
|
||||||
// Load available product codes from all sources
|
// Load available product codes from all sources
|
||||||
await loadAvailableProductCodes();
|
await loadAvailableProductCodes();
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@
|
||||||
Eye,
|
Eye,
|
||||||
Cog,
|
Cog,
|
||||||
Upload,
|
Upload,
|
||||||
RotateCcw
|
RotateCcw,
|
||||||
|
Usb,
|
||||||
|
MonitorSmartphone
|
||||||
} from '@lucide/svelte/icons';
|
} from '@lucide/svelte/icons';
|
||||||
|
|
||||||
import * as adb from '$lib/core/adb/adb';
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
|
@ -345,9 +347,84 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
editingItem = JSON.parse(JSON.stringify(item)); // Deep copy
|
editingItem = JSON.parse(JSON.stringify(item)); // Deep copy
|
||||||
|
// Translations (name-desc-v2) must show EVERY language configured for the
|
||||||
|
// country, even ones with no value yet (e.g. tha = English/Thai/China/Myanmar)
|
||||||
|
// — otherwise empty languages would be filtered out and disappear.
|
||||||
|
ensureNameDescLanguageCells(editingItem);
|
||||||
|
// Snapshot which fields are shown at edit-start so they stay editable even
|
||||||
|
// after the user clears them (otherwise live-filtering by isMeaningfulValue
|
||||||
|
// would drop a field the moment its text is emptied).
|
||||||
|
captureEditFieldKeys(editingItem);
|
||||||
isEditMode = true;
|
isEditMode = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// name-desc-v2 language columns (sorted), for the Translations card.
|
||||||
|
function nameDescColumns(): number[] {
|
||||||
|
return Object.values(nameDescLanguageMap).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure every name/desc section has a cell for each configured language
|
||||||
|
// column, creating empty ones where missing so all languages are editable.
|
||||||
|
function ensureNameDescLanguageCells(item: any) {
|
||||||
|
const cols = nameDescColumns();
|
||||||
|
for (const section of item?.name_desc_v2 ?? []) {
|
||||||
|
if (!section?.key?.endsWith('.name') && !section?.key?.endsWith('.desc')) continue;
|
||||||
|
if (!Array.isArray(section.cells)) section.cells = [];
|
||||||
|
const row = section.cells[0]?.coord?.row ?? section.row_index;
|
||||||
|
for (const col of cols) {
|
||||||
|
if (!section.cells.some((c: SheetCell) => c.coord.col === col)) {
|
||||||
|
section.cells.push({ value: '', coord: { row, col } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translations card: show every language column (existing or just-created).
|
||||||
|
function getNameDescEditSections(sections: SheetSection[] | undefined): SheetSection[] {
|
||||||
|
return (
|
||||||
|
sections?.filter(
|
||||||
|
(s) => s.key?.endsWith('.name') || s.key?.endsWith('.desc')
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNameDescEditCells(section: SheetSection): SheetCell[] {
|
||||||
|
const cols = nameDescColumns();
|
||||||
|
return (
|
||||||
|
section.cells
|
||||||
|
?.filter((cell) => cols.includes(cell.coord.col))
|
||||||
|
.sort((a, b) => a.coord.col - b.coord.col) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let editFieldKeys = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
function editFieldKey(section: SheetSection, cell: SheetCell): string {
|
||||||
|
return `${section.row_index}|${section.key ?? ''}|${cell.coord.col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureEditFieldKeys(item: any) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const list of [item?.new_layout_v2, item?.name_desc_v2]) {
|
||||||
|
for (const section of getVisibleSections(list)) {
|
||||||
|
for (const cell of getVisibleCells(section)) {
|
||||||
|
keys.add(editFieldKey(section, cell));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editFieldKeys = keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit-mode variants: show the fields captured at edit-start (not live-filtered),
|
||||||
|
// so emptying a field doesn't make its input disappear.
|
||||||
|
function getEditCells(section: SheetSection): SheetCell[] {
|
||||||
|
return section.cells?.filter((cell) => editFieldKeys.has(editFieldKey(section, cell))) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditSections(sections: SheetSection[] | undefined): SheetSection[] {
|
||||||
|
return sections?.filter((section) => getEditCells(section).length > 0) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editingItem = null;
|
editingItem = null;
|
||||||
isEditMode = false;
|
isEditMode = false;
|
||||||
|
|
@ -526,11 +603,14 @@
|
||||||
en: 'English',
|
en: 'English',
|
||||||
th: 'Thai',
|
th: 'Thai',
|
||||||
zh: 'Chinese',
|
zh: 'Chinese',
|
||||||
|
zh_hans: 'Mandarin (Simplified)',
|
||||||
|
zh_hant: 'Mandarin (Traditional)',
|
||||||
ja: 'Japanese',
|
ja: 'Japanese',
|
||||||
ms: 'Malay',
|
ms: 'Malay',
|
||||||
my: 'Myanmar',
|
my: 'Myanmar',
|
||||||
lt: 'Lithuanian',
|
lt: 'Lithuanian',
|
||||||
ro: 'Romanian'
|
ro: 'Romanian',
|
||||||
|
ar: 'Arabic'
|
||||||
};
|
};
|
||||||
const languageLabelsByColumn = Object.fromEntries(
|
const languageLabelsByColumn = Object.fromEntries(
|
||||||
Object.entries(languageColumnMap).map(([key, column]) => [
|
Object.entries(languageColumnMap).map(([key, column]) => [
|
||||||
|
|
@ -539,6 +619,16 @@
|
||||||
])
|
])
|
||||||
) as Record<number, string>;
|
) as Record<number, string>;
|
||||||
|
|
||||||
|
// name-desc-v2 is a different sheet namespace whose column→language mapping can
|
||||||
|
// differ from new-layout-v2 → use a separate label map for the Translations card.
|
||||||
|
const nameDescLanguageMap = columnConfig.nameDescLanguage ?? languageColumnMap;
|
||||||
|
const nameDescLabelsByColumn = Object.fromEntries(
|
||||||
|
Object.entries(nameDescLanguageMap).map(([key, column]) => [
|
||||||
|
column,
|
||||||
|
languageLabelsByKey[key] ?? key.toUpperCase()
|
||||||
|
])
|
||||||
|
) as Record<number, string>;
|
||||||
|
|
||||||
const nameColumnLabels: Record<number, string> = {
|
const nameColumnLabels: Record<number, string> = {
|
||||||
9: 'Hot product codes',
|
9: 'Hot product codes',
|
||||||
10: 'Cold product codes',
|
10: 'Cold product codes',
|
||||||
|
|
@ -597,13 +687,13 @@
|
||||||
}
|
}
|
||||||
if (type === 'img') return imageColumnLabels[cell.coord.col] ?? `Column ${cell.coord.col}`;
|
if (type === 'img') return imageColumnLabels[cell.coord.col] ?? `Column ${cell.coord.col}`;
|
||||||
if (section.key?.endsWith('.name')) {
|
if (section.key?.endsWith('.name')) {
|
||||||
return languageLabelsByColumn[cell.coord.col]
|
return nameDescLabelsByColumn[cell.coord.col]
|
||||||
? `${languageLabelsByColumn[cell.coord.col]} name`
|
? `${nameDescLabelsByColumn[cell.coord.col]} name`
|
||||||
: `Column ${cell.coord.col}`;
|
: `Column ${cell.coord.col}`;
|
||||||
}
|
}
|
||||||
if (section.key?.endsWith('.desc')) {
|
if (section.key?.endsWith('.desc')) {
|
||||||
return languageLabelsByColumn[cell.coord.col]
|
return nameDescLabelsByColumn[cell.coord.col]
|
||||||
? `${languageLabelsByColumn[cell.coord.col]} description`
|
? `${nameDescLabelsByColumn[cell.coord.col]} description`
|
||||||
: `Column ${cell.coord.col}`;
|
: `Column ${cell.coord.col}`;
|
||||||
}
|
}
|
||||||
return `Column ${cell.coord.col}`;
|
return `Column ${cell.coord.col}`;
|
||||||
|
|
@ -628,10 +718,33 @@
|
||||||
: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3';
|
: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Machine (ADB) connect for Preview ────────────────────────────────────
|
||||||
|
let connectDialogOpen = $state(false);
|
||||||
|
let adbConnecting = $state(false);
|
||||||
|
|
||||||
|
async function connectMachineForPreview() {
|
||||||
|
adbConnecting = true;
|
||||||
|
try {
|
||||||
|
// no Android server socket needed for preview — only file reads
|
||||||
|
await adb.connnectViaWebUSB(false);
|
||||||
|
if (AdbInstance.instance) {
|
||||||
|
addNotification('Machine connected');
|
||||||
|
connectDialogOpen = false;
|
||||||
|
// Continue straight into the preview the user originally asked for.
|
||||||
|
await loadPreviewMenus();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addNotification('ERR:' + (e instanceof Error ? e.message : 'Connect failed'));
|
||||||
|
} finally {
|
||||||
|
adbConnecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Preview functions
|
// Preview functions
|
||||||
async function loadPreviewMenus() {
|
async function loadPreviewMenus() {
|
||||||
if (!AdbInstance.instance) {
|
if (!AdbInstance.instance) {
|
||||||
addNotification('ERR:Machine not connected');
|
// Prompt the user to connect first (same UX as other pages).
|
||||||
|
connectDialogOpen = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1170,6 +1283,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map a gen-service file path to the machine path, PRESERVING the structure
|
||||||
|
// under taobin_project. The old code flattened to `${v3}/${basename}`, which sent
|
||||||
|
// nested files (banners in event_v3/<slug>/, generated .ev in event/, even
|
||||||
|
// active_promotions.lxml) to the wrong place on the machine.
|
||||||
|
function genFileToMachinePath(genFilePath: string, countryCode: string): string {
|
||||||
|
const marker = '/taobin_project/';
|
||||||
|
const idx = genFilePath.indexOf(marker);
|
||||||
|
if (idx >= 0) {
|
||||||
|
return `${sourceDir}/taobin_project/${genFilePath.slice(idx + marker.length)}`;
|
||||||
|
}
|
||||||
|
const filename = genFilePath.split('/').pop() || '';
|
||||||
|
return `${sourceDir}/taobin_project/inter/${countryCode}/xml/multi/v3/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function pushLayoutFilesToAndroid() {
|
async function pushLayoutFilesToAndroid() {
|
||||||
if (!AdbInstance.instance) {
|
if (!AdbInstance.instance) {
|
||||||
addNotification('ERR:Machine not connected');
|
addNotification('ERR:Machine not connected');
|
||||||
|
|
@ -1201,9 +1328,9 @@
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < filesToPush.length; i++) {
|
for (let i = 0; i < filesToPush.length; i++) {
|
||||||
const file = filesToPush[i];
|
const file = filesToPush[i];
|
||||||
const filename = file.file.split('/').pop() || '';
|
const androidPath = genFileToMachinePath(file.file, country);
|
||||||
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
|
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||||
|
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||||
await adb.push(androidPath, file.content);
|
await adb.push(androidPath, file.content);
|
||||||
pushProgress = { current: i + 1, total: filesToPush.length };
|
pushProgress = { current: i + 1, total: filesToPush.length };
|
||||||
|
|
||||||
|
|
@ -1237,9 +1364,9 @@
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
const filename = file.file.split('/').pop() || '';
|
const androidPath = genFileToMachinePath(file.file, country);
|
||||||
const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
|
const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
|
||||||
|
await adb.executeCmd(`mkdir -p "${parentDir}"`);
|
||||||
await adb.push(androidPath, file.content);
|
await adb.push(androidPath, file.content);
|
||||||
pushProgress = { current: i + 1, total: files.length };
|
pushProgress = { current: i + 1, total: files.length };
|
||||||
|
|
||||||
|
|
@ -1471,7 +1598,7 @@
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
// Step 1: Enter room via WebSocket (acquire lock)
|
// Step 1: Enter room via WebSocket (acquire lock)
|
||||||
const entered = enterRoom(country, catalog);
|
const entered = await enterRoom(country, catalog);
|
||||||
if (!entered) {
|
if (!entered) {
|
||||||
addNotification('ERR:WebSocket not connected');
|
addNotification('ERR:WebSocket not connected');
|
||||||
sheetLoading.set(false);
|
sheetLoading.set(false);
|
||||||
|
|
@ -2078,13 +2205,13 @@
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
{#if getVisibleSections(editingItem.new_layout_v2).length > 0}
|
{#if getEditSections(editingItem.new_layout_v2).length > 0}
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Menu Data</Card.Title>
|
<Card.Title>Menu Data</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="space-y-5">
|
<Card.Content class="space-y-5">
|
||||||
{#each getVisibleSections(editingItem.new_layout_v2) as section}
|
{#each getEditSections(editingItem.new_layout_v2) as section}
|
||||||
<section class="rounded-md border bg-muted/25 p-4">
|
<section class="rounded-md border bg-muted/25 p-4">
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<h3 class="text-base font-semibold">{getSectionTitle(section)}</h3>
|
<h3 class="text-base font-semibold">{getSectionTitle(section)}</h3>
|
||||||
|
|
@ -2093,7 +2220,7 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class={getSectionGridClass(section)}>
|
<div class={getSectionGridClass(section)}>
|
||||||
{#each getVisibleCells(section) as cell}
|
{#each getEditCells(section) as cell}
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs text-muted-foreground uppercase">
|
<Label class="text-xs text-muted-foreground uppercase">
|
||||||
{getCellLabel(section, cell)}
|
{getCellLabel(section, cell)}
|
||||||
|
|
@ -2112,13 +2239,13 @@
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if getVisibleSections(editingItem.name_desc_v2).length > 0}
|
{#if getNameDescEditSections(editingItem.name_desc_v2).length > 0}
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Translations</Card.Title>
|
<Card.Title>Names & Descriptions Topping Page</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="space-y-5">
|
<Card.Content class="space-y-5">
|
||||||
{#each getVisibleSections(editingItem.name_desc_v2) as section}
|
{#each getNameDescEditSections(editingItem.name_desc_v2) as section}
|
||||||
<section class="rounded-md border bg-muted/25 p-4">
|
<section class="rounded-md border bg-muted/25 p-4">
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<h3 class="truncate font-mono text-sm font-semibold">{section.key}</h3>
|
<h3 class="truncate font-mono text-sm font-semibold">{section.key}</h3>
|
||||||
|
|
@ -2127,7 +2254,7 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
{#each getVisibleCells(section) as cell}
|
{#each getNameDescEditCells(section) as cell}
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs text-muted-foreground uppercase">
|
<Label class="text-xs text-muted-foreground uppercase">
|
||||||
{getCellLabel(section, cell)}
|
{getCellLabel(section, cell)}
|
||||||
|
|
@ -2323,6 +2450,41 @@
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Connect machine (ADB) prompt — shown when Preview is clicked while disconnected -->
|
||||||
|
<Dialog.Root bind:open={connectDialogOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-[420px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<MonitorSmartphone class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Dialog.Title>Connect machine</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Preview reads recipes from the machine. Connect over USB to continue.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (connectDialogOpen = false)} disabled={adbConnecting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onclick={connectMachineForPreview} disabled={adbConnecting}>
|
||||||
|
{#if adbConnecting}
|
||||||
|
<Spinner class="mr-2 h-4 w-4" />
|
||||||
|
Connecting…
|
||||||
|
{:else}
|
||||||
|
<Usb class="mr-2 h-4 w-4" />
|
||||||
|
Connect
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Preview Online Menus Dialog -->
|
<!-- Preview Online Menus Dialog -->
|
||||||
<Dialog.Root bind:open={previewDialogOpen}>
|
<Dialog.Root bind:open={previewDialogOpen}>
|
||||||
<Dialog.Content class="w-[98vw] sm:max-w-[1600px]">
|
<Dialog.Content class="w-[98vw] sm:max-w-[1600px]">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
280
src/routes/(authed)/sheet/price/[country]/+page.svelte
Normal file
280
src/routes/(authed)/sheet/price/[country]/+page.svelte
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { addNotification } from '$lib/core/stores/noti.js';
|
||||||
|
import { departmentStore } from '$lib/core/stores/departments.js';
|
||||||
|
import {
|
||||||
|
findHeaderIndex,
|
||||||
|
PRICE_HEADER_NAMES_BY_COUNTRY,
|
||||||
|
sheetPriceAllRows,
|
||||||
|
sheetPriceHeader,
|
||||||
|
sheetPriceLoading,
|
||||||
|
type GristCell
|
||||||
|
} from '$lib/core/stores/sheetStore.js';
|
||||||
|
import {
|
||||||
|
addSheetPrice,
|
||||||
|
requestAllSheetPrice,
|
||||||
|
updateSheetPrice
|
||||||
|
} from '$lib/core/services/sheetService.js';
|
||||||
|
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
|
||||||
|
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||||
|
import { Plus, RefreshCw, Save } from '@lucide/svelte/icons';
|
||||||
|
|
||||||
|
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
||||||
|
let search = $state('');
|
||||||
|
let saving = $state(false);
|
||||||
|
let newProductCode = $state('');
|
||||||
|
let newName = $state('');
|
||||||
|
let newPrice = $state('');
|
||||||
|
let priceEdits = $state<Record<string, string>>({});
|
||||||
|
let selectedCountryKey = $derived(selectedCountry.toLowerCase());
|
||||||
|
let loading = $derived($sheetPriceLoading);
|
||||||
|
let header = $derived($sheetPriceHeader[selectedCountryKey] ?? []);
|
||||||
|
let priceColumnIndex = $derived(getPriceColumnIndex());
|
||||||
|
let rowsByProductCode = $derived($sheetPriceAllRows[selectedCountryKey] ?? {});
|
||||||
|
let rows = $derived(
|
||||||
|
Object.values(rowsByProductCode)
|
||||||
|
.flat()
|
||||||
|
.sort((a, b) => a.row - b.row)
|
||||||
|
);
|
||||||
|
let filteredRows = $derived(
|
||||||
|
rows.filter((row) => {
|
||||||
|
const keyword = search.trim().toLowerCase();
|
||||||
|
if (!keyword) return true;
|
||||||
|
return row.cells.some((cell) =>
|
||||||
|
String(cell.value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(keyword)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let visibleHeader = $derived(
|
||||||
|
Array.from(
|
||||||
|
{ length: Math.max(5, Math.min(12, header.length || 5)) },
|
||||||
|
(_, index) => header[index] || String.fromCharCode(65 + index)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
referenceFromPage.set('price');
|
||||||
|
if (selectedCountry) departmentStore.set(selectedCountry);
|
||||||
|
void loadPrice();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPrice() {
|
||||||
|
if (!selectedCountry) return;
|
||||||
|
|
||||||
|
const socket = await waitForOpenSocket();
|
||||||
|
if (!socket) {
|
||||||
|
addNotification('ERR:WebSocket not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = await requestAllSheetPrice(selectedCountry, true);
|
||||||
|
if (!sent) addNotification('ERR:Failed to request Price data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriceColumnIndex() {
|
||||||
|
const headerNames =
|
||||||
|
PRICE_HEADER_NAMES_BY_COUNTRY[selectedCountryKey] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
||||||
|
const col = findHeaderIndex(header, headerNames.cash_price);
|
||||||
|
return col > 0 ? col : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellValue(cells: GristCell[], column: number) {
|
||||||
|
return String(cells.find((cell) => cell.coord?.col === column)?.value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditKey(row: { row: number }) {
|
||||||
|
return String(row.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditedPrice(row: { row: number; cells: GristCell[] }) {
|
||||||
|
const key = getEditKey(row);
|
||||||
|
return priceEdits[key] ?? getCellValue(row.cells, priceColumnIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditedPrice(row: { row: number }, value: string) {
|
||||||
|
priceEdits = { ...priceEdits, [getEditKey(row)]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChangedUpdates() {
|
||||||
|
return rows
|
||||||
|
.map((row) => {
|
||||||
|
const key = getEditKey(row);
|
||||||
|
if (!(key in priceEdits)) return null;
|
||||||
|
|
||||||
|
const original = getCellValue(row.cells, priceColumnIndex);
|
||||||
|
const value = priceEdits[key];
|
||||||
|
if (value === original) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
row_index: row.row,
|
||||||
|
cells: [{ value, coord: { row: row.row, col: priceColumnIndex } }]
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((update): update is NonNullable<typeof update> => update !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePriceChanges() {
|
||||||
|
const updates = getChangedUpdates();
|
||||||
|
if (updates.length === 0) {
|
||||||
|
addNotification('INFO:No price changes to save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const sent = await updateSheetPrice(selectedCountry, updates);
|
||||||
|
if (!sent) {
|
||||||
|
addNotification('ERR:Failed to send price updates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addNotification(`INFO:Updated ${updates.length} price row(s)`);
|
||||||
|
priceEdits = {};
|
||||||
|
await loadPrice();
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPriceRow() {
|
||||||
|
const productCode = String(newProductCode).trim();
|
||||||
|
const name = String(newName).trim();
|
||||||
|
const price = String(newPrice).trim();
|
||||||
|
|
||||||
|
if (!productCode) {
|
||||||
|
addNotification('WARN:ProductCode is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!price) {
|
||||||
|
addNotification('WARN:Price is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = Array.from({ length: Math.max(header.length, priceColumnIndex) }, () => '');
|
||||||
|
cells[0] = productCode;
|
||||||
|
cells[1] = name;
|
||||||
|
cells[priceColumnIndex - 1] = price;
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const sent = await addSheetPrice(selectedCountry, [{ cells }]);
|
||||||
|
if (!sent) {
|
||||||
|
addNotification('ERR:Failed to add price row');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addNotification(`INFO:Added price row ${productCode}`);
|
||||||
|
newProductCode = '';
|
||||||
|
newName = '';
|
||||||
|
newPrice = '';
|
||||||
|
await loadPrice();
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-background">
|
||||||
|
<div class="w-full px-6 py-8 lg:px-8">
|
||||||
|
<div class="mb-7 flex flex-wrap items-start justify-between gap-5">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl leading-tight font-bold tracking-normal">
|
||||||
|
Price [ {selectedCountry.toUpperCase()} ]
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-muted-foreground">View main Price sheet data for this country.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button onclick={savePriceChanges} disabled={saving || getChangedUpdates().length === 0}>
|
||||||
|
{#if saving}
|
||||||
|
<Spinner class="mr-2 h-4 w-4" />
|
||||||
|
Saving
|
||||||
|
{:else}
|
||||||
|
<Save class="mr-2 h-4 w-4" />
|
||||||
|
Save Changes ({getChangedUpdates().length})
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onclick={loadPrice} disabled={loading || saving}>
|
||||||
|
{#if loading}
|
||||||
|
<Spinner class="mr-2 h-4 w-4" />
|
||||||
|
Loading
|
||||||
|
{:else}
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mb-5 grid gap-3 rounded-xl border bg-card/60 p-4 lg:grid-cols-[1fr_180px_180px_140px_auto]"
|
||||||
|
>
|
||||||
|
<Input placeholder="Search product code, name, or price..." bind:value={search} />
|
||||||
|
<Input placeholder="New ProductCode" bind:value={newProductCode} class="font-mono" />
|
||||||
|
<Input placeholder="Name" bind:value={newName} />
|
||||||
|
<Input placeholder="Price" bind:value={newPrice} type="number" min="0" step="0.01" />
|
||||||
|
<Button variant="outline" onclick={addPriceRow} disabled={saving}>
|
||||||
|
<Plus class="mr-2 h-4 w-4" />
|
||||||
|
Add Row
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border bg-card/60">
|
||||||
|
{#if loading && rows.length === 0}
|
||||||
|
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
<Spinner class="mr-3 h-6 w-6" />
|
||||||
|
Loading Price data...
|
||||||
|
</div>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
No Price data loaded. Click Refresh to load data.
|
||||||
|
</div>
|
||||||
|
{:else if filteredRows.length === 0}
|
||||||
|
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
No rows match your search.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="max-h-[calc(100vh-220px)] overflow-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header class="sticky top-0 z-10 bg-card">
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="w-20">Row</Table.Head>
|
||||||
|
{#each visibleHeader as column}
|
||||||
|
<Table.Head>{column}</Table.Head>
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filteredRows as row, index (`${row.row}-${row.cells[0]?.value ?? ''}-${index}`)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-xs text-muted-foreground">{row.row}</Table.Cell>
|
||||||
|
{#each visibleHeader as _, index}
|
||||||
|
<Table.Cell class={index === 0 ? 'font-mono text-sm' : 'text-sm'}>
|
||||||
|
{#if index + 1 === priceColumnIndex}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
class="h-8 w-28 text-right font-semibold"
|
||||||
|
value={getEditedPrice(row)}
|
||||||
|
oninput={(event) => setEditedPrice(row, event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{row.cells.find((cell) => cell.coord?.col === index + 1)?.value ?? ''}
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -832,6 +832,7 @@
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubRecipeEvent();
|
unsubRecipeEvent();
|
||||||
clearOnMenuSavedCallback();
|
clearOnMenuSavedCallback();
|
||||||
|
void adb.goToMachineHome();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,15 @@
|
||||||
clearOnMenuSavedCallback,
|
clearOnMenuSavedCallback,
|
||||||
clearMenuSaveState
|
clearMenuSaveState
|
||||||
} from '$lib/core/stores/menuSaveStore';
|
} from '$lib/core/stores/menuSaveStore';
|
||||||
|
import { MenuStatus } from '$lib/core/types/menuStatus';
|
||||||
|
|
||||||
const sourceDir = '/sdcard/coffeevending';
|
const sourceDir = '/sdcard/coffeevending';
|
||||||
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
|
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
|
||||||
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
|
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
|
||||||
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
|
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
|
||||||
const saveResponseTimeoutMs = 60000;
|
const saveResponseTimeoutMs = 60000;
|
||||||
|
const recipeStepTargetCount = 30;
|
||||||
|
const toppingSetTargetCount = 13;
|
||||||
|
|
||||||
// Recipe data from Android
|
// Recipe data from Android
|
||||||
let devRecipe: any | undefined = $state();
|
let devRecipe: any | undefined = $state();
|
||||||
|
|
@ -51,7 +54,10 @@
|
||||||
// Country detection from Android
|
// Country detection from Android
|
||||||
let detectedCountry = $state<string>('');
|
let detectedCountry = $state<string>('');
|
||||||
let countryLoading = $state(false);
|
let countryLoading = $state(false);
|
||||||
const detectedCountryCode = $derived(countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || '');
|
const detectedCountryCode = $derived(
|
||||||
|
countryCodeMap[detectedCountry] || countryCodeMap[detectedCountry.toUpperCase()] || ''
|
||||||
|
);
|
||||||
|
const primaryLanguageLabel = $derived(getPrimaryLanguageLabel(detectedCountry));
|
||||||
|
|
||||||
// ADB connection state
|
// ADB connection state
|
||||||
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
||||||
|
|
@ -83,6 +89,14 @@
|
||||||
let createMenuSaving = $state(false);
|
let createMenuSaving = $state(false);
|
||||||
let editingDraftProductCode: string | null = $state(null);
|
let editingDraftProductCode: string | null = $state(null);
|
||||||
let activeTabIndex = $state(0);
|
let activeTabIndex = $state(0);
|
||||||
|
let materialPickerOpen = $state(false);
|
||||||
|
let materialPickerStepIndex: number | null = $state(null);
|
||||||
|
let materialPickerSearch = $state('');
|
||||||
|
type ToppingPickerType = 'slot' | 'group' | 'list';
|
||||||
|
let toppingPickerOpen = $state(false);
|
||||||
|
let toppingPickerType: ToppingPickerType = $state('slot');
|
||||||
|
let toppingPickerOptionIndex: number | null = $state(null);
|
||||||
|
let toppingPickerSearch = $state('');
|
||||||
|
|
||||||
// Per-temp form data
|
// Per-temp form data
|
||||||
type TempFormData = {
|
type TempFormData = {
|
||||||
|
|
@ -92,8 +106,6 @@
|
||||||
otherName: string;
|
otherName: string;
|
||||||
description: string;
|
description: string;
|
||||||
otherDescription: string;
|
otherDescription: string;
|
||||||
cashPrice: string;
|
|
||||||
nonCashPrice: string;
|
|
||||||
image: string;
|
image: string;
|
||||||
isUse: boolean;
|
isUse: boolean;
|
||||||
recipeSteps: any[];
|
recipeSteps: any[];
|
||||||
|
|
@ -123,6 +135,8 @@
|
||||||
)
|
)
|
||||||
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
|
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
|
||||||
);
|
);
|
||||||
|
let groupedMaterialOptions = $derived(getGroupedMaterialOptions());
|
||||||
|
let toppingPickerOptions = $derived(getToppingPickerOptions());
|
||||||
|
|
||||||
let activeToppingSlotMaterials = $derived(
|
let activeToppingSlotMaterials = $derived(
|
||||||
(devRecipe?.MaterialSetting ?? [])
|
(devRecipe?.MaterialSetting ?? [])
|
||||||
|
|
@ -165,6 +179,58 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPlaceholderRecipeStep() {
|
||||||
|
return {
|
||||||
|
MixOrder: 0,
|
||||||
|
StringParam: '',
|
||||||
|
FeedParameter: 0,
|
||||||
|
FeedPattern: 0,
|
||||||
|
isUse: false,
|
||||||
|
materialPathId: 0,
|
||||||
|
powderGram: 0,
|
||||||
|
powderTime: 0,
|
||||||
|
stirTime: 0,
|
||||||
|
syrupGram: 0,
|
||||||
|
syrupTime: 0,
|
||||||
|
waterCold: 0,
|
||||||
|
waterYield: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryLanguageLabel(country: string) {
|
||||||
|
const normalized = country.trim().toUpperCase();
|
||||||
|
const languageByCountry: Record<string, string> = {
|
||||||
|
THAI: 'Thai',
|
||||||
|
THA: 'Thai',
|
||||||
|
MYS: 'Malay',
|
||||||
|
IDR: 'Indonesian',
|
||||||
|
AUS: 'English',
|
||||||
|
SGP: 'English',
|
||||||
|
SG: 'English',
|
||||||
|
UAE_DUBAI: 'Arabic',
|
||||||
|
DUBAI: 'Arabic',
|
||||||
|
HKG: 'Chinese',
|
||||||
|
GBR: 'English',
|
||||||
|
ROU: 'Romanian',
|
||||||
|
LVA: 'Latvian',
|
||||||
|
EST: 'Estonian',
|
||||||
|
LTU: 'Lithuanian',
|
||||||
|
USA_PEPSI: 'English'
|
||||||
|
};
|
||||||
|
|
||||||
|
return languageByCountry[normalized] ?? (normalized ? normalized : 'Local');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryNamePlaceholder() {
|
||||||
|
return primaryLanguageLabel === 'Thai' ? 'ชื่อเมนู' : `Menu name in ${primaryLanguageLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryDescriptionPlaceholder() {
|
||||||
|
return primaryLanguageLabel === 'Thai'
|
||||||
|
? 'คำอธิบายเมนู'
|
||||||
|
: `Menu description in ${primaryLanguageLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
function createEmptyToppingOption(slot: number | null = null) {
|
function createEmptyToppingOption(slot: number | null = null) {
|
||||||
return {
|
return {
|
||||||
slot,
|
slot,
|
||||||
|
|
@ -218,6 +284,77 @@
|
||||||
return `${material.id} - ${material.materialName || material.materialOtherName || 'Unknown'}`;
|
return `${material.id} - ${material.materialName || material.materialOtherName || 'Unknown'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMaterialCategory(material: any) {
|
||||||
|
if (material?.BeanChannel) return 'Bean';
|
||||||
|
if (material?.PowderChannel) return 'Powder';
|
||||||
|
if (material?.SyrupChannel) return 'Syrup';
|
||||||
|
if (material?.FreshSyrupChannel) return 'Fresh Syrup';
|
||||||
|
if (material?.FrozenFruitChannel) return 'Frozen Fruit';
|
||||||
|
if (material?.LeavesChannel) return 'Leaves';
|
||||||
|
if (material?.SodaChannel) return 'Soda';
|
||||||
|
if (material?.ItemChannel) return 'Item';
|
||||||
|
if (material?.IsEquipment) return 'Equipment';
|
||||||
|
return material?.CanisterType || material?.pathOtherName || 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupedMaterialOptions() {
|
||||||
|
const search = materialPickerSearch.trim().toLowerCase();
|
||||||
|
const groups = new Map<string, any[]>();
|
||||||
|
const categoryOrder = [
|
||||||
|
'Bean',
|
||||||
|
'Powder',
|
||||||
|
'Syrup',
|
||||||
|
'Fresh Syrup',
|
||||||
|
'Frozen Fruit',
|
||||||
|
'Leaves',
|
||||||
|
'Soda',
|
||||||
|
'Item',
|
||||||
|
'Equipment',
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const material of activeMaterials) {
|
||||||
|
const text =
|
||||||
|
`${material.id} ${material.materialName ?? ''} ${material.materialOtherName ?? ''} ${material.pathOtherName ?? ''} ${material.CanisterType ?? ''}`.toLowerCase();
|
||||||
|
if (search && !text.includes(search)) continue;
|
||||||
|
|
||||||
|
const category = getMaterialCategory(material);
|
||||||
|
groups.set(category, [...(groups.get(category) ?? []), material]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.entries()]
|
||||||
|
.sort(([left], [right]) => {
|
||||||
|
const leftIndex = categoryOrder.indexOf(left);
|
||||||
|
const rightIndex = categoryOrder.indexOf(right);
|
||||||
|
return (
|
||||||
|
(leftIndex === -1 ? categoryOrder.length : leftIndex) -
|
||||||
|
(rightIndex === -1 ? categoryOrder.length : rightIndex) || left.localeCompare(right)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map(([category, materials]) => ({ category, materials }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedMaterialName(materialPathId: number | null) {
|
||||||
|
if (materialPathId == null || Number(materialPathId) <= 0) return 'Select material';
|
||||||
|
const material = activeMaterials.find(
|
||||||
|
(item: any) => Number(item.id) === Number(materialPathId)
|
||||||
|
);
|
||||||
|
return material ? materialDisplayName(material) : `${materialPathId} - Unknown material`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMaterialPicker(stepIndex: number) {
|
||||||
|
materialPickerStepIndex = stepIndex;
|
||||||
|
materialPickerSearch = '';
|
||||||
|
materialPickerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMaterialForActiveStep(materialPathId: number) {
|
||||||
|
if (materialPickerStepIndex == null) return;
|
||||||
|
updateRecipeStepNumber(materialPickerStepIndex, 'materialPathId', String(materialPathId));
|
||||||
|
materialPickerOpen = false;
|
||||||
|
materialPickerStepIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
function toppingSlotDisplayName(material: any) {
|
function toppingSlotDisplayName(material: any) {
|
||||||
const slot = Number(material?.id) - 8110;
|
const slot = Number(material?.id) - 8110;
|
||||||
const slotName = material?.materialOtherName || material?.materialName;
|
const slotName = material?.materialOtherName || material?.materialName;
|
||||||
|
|
@ -235,6 +372,94 @@
|
||||||
return `${topping?.id ?? '-'} - ${toppingName || 'Unnamed topping'}`;
|
return `${topping?.id ?? '-'} - ${toppingName || 'Unnamed topping'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedToppingSlotName(slot: number | null) {
|
||||||
|
if (slot == null || !Number.isFinite(Number(slot))) return 'Select slot';
|
||||||
|
const material = activeToppingSlotMaterials.find(
|
||||||
|
(item: any) => Number(item.id) - 8110 === Number(slot)
|
||||||
|
);
|
||||||
|
return material ? toppingSlotDisplayName(material) : `Slot ${slot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedToppingGroupName(groupID: number | null) {
|
||||||
|
if (groupID == null || !Number.isFinite(Number(groupID))) return 'Select group';
|
||||||
|
const group = activeToppingGroups.find((item: any) => Number(item.groupID) === Number(groupID));
|
||||||
|
return group ? toppingGroupDisplayName(group) : `Group ${groupID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedToppingListName(toppingID: number | null) {
|
||||||
|
if (toppingID == null || !Number.isFinite(Number(toppingID))) return 'Select topping';
|
||||||
|
const topping = activeToppingLists.find((item: any) => Number(item.id) === Number(toppingID));
|
||||||
|
return topping ? toppingListDisplayName(topping) : `Topping ${toppingID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openToppingPicker(type: ToppingPickerType, optionIndex: number) {
|
||||||
|
toppingPickerType = type;
|
||||||
|
toppingPickerOptionIndex = optionIndex;
|
||||||
|
toppingPickerSearch = '';
|
||||||
|
toppingPickerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToppingPickerTitle() {
|
||||||
|
if (toppingPickerType === 'slot') return 'Select Topping Slot';
|
||||||
|
if (toppingPickerType === 'group') return 'Select Topping Group';
|
||||||
|
return 'Select Default Topping';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToppingPickerDescription() {
|
||||||
|
if (toppingPickerType === 'slot') return 'Choose the physical topping slot for this menu.';
|
||||||
|
if (toppingPickerType === 'group') return 'Choose the topping group shown to the customer.';
|
||||||
|
return 'Choose the default selected topping from the selected group.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveToppingPickerOption() {
|
||||||
|
if (toppingPickerOptionIndex == null) return undefined;
|
||||||
|
return activeForm?.toppingOptions?.[toppingPickerOptionIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToppingPickerOptions() {
|
||||||
|
const search = toppingPickerSearch.trim().toLowerCase();
|
||||||
|
const currentOption = getActiveToppingPickerOption();
|
||||||
|
const options =
|
||||||
|
toppingPickerType === 'slot'
|
||||||
|
? activeToppingSlotMaterials.map((material: any) => ({
|
||||||
|
value: Number(material.id) - 8110,
|
||||||
|
label: toppingSlotDisplayName(material),
|
||||||
|
description: material.pathOtherName || material.CanisterType || 'Topping material slot'
|
||||||
|
}))
|
||||||
|
: toppingPickerType === 'group'
|
||||||
|
? activeToppingGroups.map((group: any) => ({
|
||||||
|
value: Number(group.groupID),
|
||||||
|
label: toppingGroupDisplayName(group),
|
||||||
|
description: `${getToppingListsForGroup(Number(group.groupID)).length} toppings`
|
||||||
|
}))
|
||||||
|
: getToppingListsForGroup(currentOption?.groupID ?? null).map((topping: any) => ({
|
||||||
|
value: Number(topping.id),
|
||||||
|
label: toppingListDisplayName(topping),
|
||||||
|
description:
|
||||||
|
topping?.description || topping?.otherDescription || 'Default topping option'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return options.filter((option: any) => {
|
||||||
|
if (!search) return true;
|
||||||
|
return `${option.value} ${option.label} ${option.description}`.toLowerCase().includes(search);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectToppingPickerOption(value: number) {
|
||||||
|
if (toppingPickerOptionIndex == null) return;
|
||||||
|
|
||||||
|
if (toppingPickerType === 'slot') {
|
||||||
|
updateToppingSlot(toppingPickerOptionIndex, String(value));
|
||||||
|
} else if (toppingPickerType === 'group') {
|
||||||
|
updateToppingGroup(toppingPickerOptionIndex, String(value));
|
||||||
|
} else {
|
||||||
|
updateToppingList(toppingPickerOptionIndex, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
toppingPickerOpen = false;
|
||||||
|
toppingPickerOptionIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeToppingListIDs(value: any) {
|
function normalizeToppingListIDs(value: any) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map(Number).filter((id: number) => Number.isFinite(id) && id > 0);
|
return value.map(Number).filter((id: number) => Number.isFinite(id) && id > 0);
|
||||||
|
|
@ -302,12 +527,36 @@
|
||||||
await adb.reconnectAndroidRecipeMenuServer();
|
await adb.reconnectAndroidRecipeMenuServer();
|
||||||
}
|
}
|
||||||
await loadRecipeFromMachine();
|
await loadRecipeFromMachine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('usb' in navigator)) {
|
||||||
|
throw new Error('WebUSB not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
await adb.connectRecipeMenuViaWebUSB();
|
||||||
|
if (adb.getAdbInstance()) {
|
||||||
|
await loadRecipeFromMachine();
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addNotification(`ERROR:${e}`);
|
addNotification(`ERROR:${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reconnectAndroidSocket() {
|
||||||
|
try {
|
||||||
|
await adb.reconnectAndroidRecipeMenuServer();
|
||||||
|
if (isAdbWriterAvailable()) {
|
||||||
|
addNotification('INFO:Android socket connected');
|
||||||
|
} else {
|
||||||
|
addNotification('WARN:Android socket not connected');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('failed to reconnect android socket', error);
|
||||||
|
addNotification('WARN:Android socket not connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRecipeFromMachine() {
|
async function loadRecipeFromMachine() {
|
||||||
if (recipeLoading) return;
|
if (recipeLoading) return;
|
||||||
|
|
||||||
|
|
@ -357,7 +606,12 @@
|
||||||
// No country file means Thailand
|
// No country file means Thailand
|
||||||
detectedCountry = 'THAI';
|
detectedCountry = 'THAI';
|
||||||
}
|
}
|
||||||
logger.info('[CreateMenu] Detected country:', detectedCountry, '-> prefix:', detectedCountryCode);
|
logger.info(
|
||||||
|
'[CreateMenu] Detected country:',
|
||||||
|
detectedCountry,
|
||||||
|
'-> prefix:',
|
||||||
|
detectedCountryCode
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error reading file means Thailand (default)
|
// Error reading file means Thailand (default)
|
||||||
detectedCountry = 'THAI';
|
detectedCountry = 'THAI';
|
||||||
|
|
@ -548,8 +802,6 @@
|
||||||
otherName: '',
|
otherName: '',
|
||||||
description: '',
|
description: '',
|
||||||
otherDescription: '',
|
otherDescription: '',
|
||||||
cashPrice: '0',
|
|
||||||
nonCashPrice: '0',
|
|
||||||
image: '',
|
image: '',
|
||||||
isUse: false,
|
isUse: false,
|
||||||
recipeSteps: [createEmptyRecipeStep()],
|
recipeSteps: [createEmptyRecipeStep()],
|
||||||
|
|
@ -735,6 +987,15 @@
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureRecipePlaceholders(recipes: any[]) {
|
||||||
|
return [
|
||||||
|
...recipes,
|
||||||
|
...Array.from({ length: Math.max(0, recipeStepTargetCount - recipes.length) }, () =>
|
||||||
|
createPlaceholderRecipeStep()
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function persistStagedMenus() {
|
function persistStagedMenus() {
|
||||||
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
|
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
|
||||||
void persistStagedMenusToAndroid();
|
void persistStagedMenusToAndroid();
|
||||||
|
|
@ -744,8 +1005,9 @@
|
||||||
if (!adb.getAdbInstance()) return;
|
if (!adb.getAdbInstance()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const tempPath = `${stagedMenuAndroidPath}.tmp`;
|
||||||
await adb.push(
|
await adb.push(
|
||||||
stagedMenuAndroidPath,
|
tempPath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
@ -756,6 +1018,8 @@
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const result = await adb.executeCmd(`mv ${tempPath} ${stagedMenuAndroidPath}`);
|
||||||
|
if (result?.error) throw new Error(String(result.error));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('failed to persist staged menus to Android', error);
|
logger.error('failed to persist staged menus to Android', error);
|
||||||
addNotification('WARN:Failed to save draft menus to Android');
|
addNotification('WARN:Failed to save draft menus to Android');
|
||||||
|
|
@ -804,21 +1068,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenuFromForm(form: TempFormData) {
|
function buildMenuFromForm(form: TempFormData) {
|
||||||
const toppingSet = form.toppingOptions
|
const toppingSet = Array.from({ length: toppingSetTargetCount }, () => createEmptyToppingSet());
|
||||||
.filter((opt) => opt.slot != null && opt.groupID != null)
|
for (const opt of form.toppingOptions) {
|
||||||
.map((opt) => {
|
if (opt.slot == null || opt.groupID == null) continue;
|
||||||
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
|
|
||||||
const listGroupIDs = getToppingGroupListIDs(group);
|
|
||||||
return {
|
|
||||||
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
|
|
||||||
defaultIDSelect: opt.defaultIDSelect ?? 0,
|
|
||||||
groupID: String(opt.groupID),
|
|
||||||
isUse: true
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
while (toppingSet.length < 4) {
|
const index = opt.slot - 1;
|
||||||
toppingSet.push(createEmptyToppingSet());
|
if (index < 0 || index >= toppingSet.length) continue;
|
||||||
|
|
||||||
|
const group = activeToppingGroups.find((g: any) => Number(g.groupID) === opt.groupID);
|
||||||
|
const listGroupIDs = getToppingGroupListIDs(group);
|
||||||
|
toppingSet[index] = {
|
||||||
|
ListGroupID: listGroupIDs.length > 0 ? listGroupIDs : [0, 0, 0, 0],
|
||||||
|
defaultIDSelect: opt.defaultIDSelect ?? 0,
|
||||||
|
groupID: String(opt.groupID),
|
||||||
|
isUse: true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipeSteps = form.recipeSteps.filter(hasValidMaterialPathId).map((step) => ({
|
const recipeSteps = form.recipeSteps.filter(hasValidMaterialPathId).map((step) => ({
|
||||||
|
|
@ -833,16 +1097,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const recipes = ensureRecipePlaceholders(recipeSteps);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Description: form.description,
|
Description: form.description,
|
||||||
ExtendID: 0,
|
ExtendID: 0,
|
||||||
OnTOP: false,
|
OnTOP: false,
|
||||||
LastChange: formatAndroidRecipeDate(),
|
LastChange: formatAndroidRecipeDate(),
|
||||||
MenuStatus: 0,
|
MenuStatus: MenuStatus.drafted,
|
||||||
StringParam: ',filter-enable=no,',
|
StringParam: ',filter-enable=no,',
|
||||||
TextForWarningBeforePay: Array(8).fill('stg_warning=Invisible,img_warning=none'),
|
TextForWarningBeforePay: Array(8).fill('stg_warning=Invisible,img_warning=none'),
|
||||||
cashPrice: Number(form.cashPrice) || 0,
|
cashPrice: 0,
|
||||||
changerecipe: '',
|
changerecipe: '',
|
||||||
EncoderCount: 0,
|
EncoderCount: 0,
|
||||||
id: 0,
|
id: 0,
|
||||||
|
|
@ -850,9 +1115,9 @@
|
||||||
productCode: form.productCode,
|
productCode: form.productCode,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
otherName: form.otherName,
|
otherName: form.otherName,
|
||||||
nonCashPrice: Number(form.nonCashPrice) || 0,
|
nonCashPrice: 0,
|
||||||
otherDescription: form.otherDescription,
|
otherDescription: form.otherDescription,
|
||||||
recipes: recipeSteps,
|
recipes,
|
||||||
ToppingSet: toppingSet,
|
ToppingSet: toppingSet,
|
||||||
SubMenu: [],
|
SubMenu: [],
|
||||||
total_time: -1,
|
total_time: -1,
|
||||||
|
|
@ -863,6 +1128,14 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPendingOnlineMenu(menu: any) {
|
||||||
|
return {
|
||||||
|
...menu,
|
||||||
|
MenuStatus: MenuStatus.pendingOnline,
|
||||||
|
recipes: ensureRecipePlaceholders(menu?.recipes ?? [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createMenuDraft() {
|
async function createMenuDraft() {
|
||||||
if (tempForms.length === 0) {
|
if (tempForms.length === 0) {
|
||||||
addNotification('ERR:No forms to save');
|
addNotification('ERR:No forms to save');
|
||||||
|
|
@ -908,11 +1181,6 @@
|
||||||
|
|
||||||
async function saveStagedMenuToAndroid(menu: any) {
|
async function saveStagedMenuToAndroid(menu: any) {
|
||||||
if (!(await ensureAndroidSocket())) return;
|
if (!(await ensureAndroidSocket())) return;
|
||||||
if (getActiveRecipeStepCount(menu) === 0) {
|
|
||||||
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
|
|
||||||
addNotification(`ERR:Select at least one material before saving: ${menu.productCode}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMenuSaving(menu.productCode);
|
setMenuSaving(menu.productCode);
|
||||||
|
|
||||||
|
|
@ -920,7 +1188,7 @@
|
||||||
type: 'save_recipe_menu_file',
|
type: 'save_recipe_menu_file',
|
||||||
payload: {
|
payload: {
|
||||||
time: new Date().toLocaleTimeString(),
|
time: new Date().toLocaleTimeString(),
|
||||||
data: menu
|
data: buildPendingOnlineMenu(menu)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
|
|
@ -954,18 +1222,6 @@
|
||||||
addNotification('WARN:No draft menus ready to save');
|
addNotification('WARN:No draft menus ready to save');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const invalidMenus = menus.filter((menu) => getActiveRecipeStepCount(menu) === 0);
|
|
||||||
if (invalidMenus.length > 0) {
|
|
||||||
for (const menu of invalidMenus) {
|
|
||||||
setMenuSaveError(menu.productCode, 'Select at least one material before saving');
|
|
||||||
}
|
|
||||||
addNotification(
|
|
||||||
`ERR:Select at least one material before saving: ${invalidMenus
|
|
||||||
.map((menu) => menu.productCode)
|
|
||||||
.join(', ')}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'[Create Menu] save all draft menus',
|
'[Create Menu] save all draft menus',
|
||||||
menus.map((menu) => menu.productCode)
|
menus.map((menu) => menu.productCode)
|
||||||
|
|
@ -979,7 +1235,7 @@
|
||||||
type: 'save_recipe_menu_file_batch',
|
type: 'save_recipe_menu_file_batch',
|
||||||
payload: {
|
payload: {
|
||||||
time: new Date().toLocaleTimeString(),
|
time: new Date().toLocaleTimeString(),
|
||||||
data: menus
|
data: menus.map(buildPendingOnlineMenu)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
|
|
@ -1126,8 +1382,9 @@
|
||||||
|
|
||||||
const toppingOptionsFromMenu = (m: any) => {
|
const toppingOptionsFromMenu = (m: any) => {
|
||||||
return (m.ToppingSet ?? [])
|
return (m.ToppingSet ?? [])
|
||||||
.filter((ts: any) => ts.isUse && Number(ts.groupID) > 0)
|
.map((ts: any, index: number) => ({ ts, index }))
|
||||||
.map((ts: any, index: number) => ({
|
.filter(({ ts }: { ts: any }) => ts.isUse && Number(ts.groupID) > 0)
|
||||||
|
.map(({ ts, index }: { ts: any; index: number }) => ({
|
||||||
slot: index + 1,
|
slot: index + 1,
|
||||||
groupID: Number(ts.groupID),
|
groupID: Number(ts.groupID),
|
||||||
defaultIDSelect: Number(ts.defaultIDSelect) || null
|
defaultIDSelect: Number(ts.defaultIDSelect) || null
|
||||||
|
|
@ -1142,8 +1399,6 @@
|
||||||
otherName: menu.otherName ?? '',
|
otherName: menu.otherName ?? '',
|
||||||
description: menu.Description ?? '',
|
description: menu.Description ?? '',
|
||||||
otherDescription: menu.otherDescription ?? '',
|
otherDescription: menu.otherDescription ?? '',
|
||||||
cashPrice: String(menu.cashPrice ?? 0),
|
|
||||||
nonCashPrice: String(menu.nonCashPrice ?? 0),
|
|
||||||
image: menu.uriData?.replace(/^img=/, '') ?? '',
|
image: menu.uriData?.replace(/^img=/, '') ?? '',
|
||||||
isUse: menu.isUse !== false,
|
isUse: menu.isUse !== false,
|
||||||
recipeSteps: recipeSteps.length > 0 ? recipeSteps : [createEmptyRecipeStep()],
|
recipeSteps: recipeSteps.length > 0 ? recipeSteps : [createEmptyRecipeStep()],
|
||||||
|
|
@ -1184,6 +1439,9 @@
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearOnMenuSavedCallback();
|
clearOnMenuSavedCallback();
|
||||||
|
// Leaving Create Menu: dismiss coffeemain's RecipeActivity and let the
|
||||||
|
// XMLEngine kiosk home resume (keeps its current portrait orientation).
|
||||||
|
void adb.goToMachineHome();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load when ADB is connected
|
// Auto-load when ADB is connected
|
||||||
|
|
@ -1237,11 +1495,6 @@
|
||||||
<Button variant="default" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
|
<Button variant="default" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
|
||||||
{recipeLoading ? 'Loading...' : 'Load Recipe Data'}
|
{recipeLoading ? 'Loading...' : 'Load Recipe Data'}
|
||||||
</Button>
|
</Button>
|
||||||
{#if !isAndroidSocketConnected}
|
|
||||||
<Button variant="outline" onclick={() => adb.reconnectAndroidRecipeMenuServer()}
|
|
||||||
>Reconnect Socket</Button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
<Button variant="default" onclick={openSetupPopup}>+ Create New Menu</Button>
|
<Button variant="default" onclick={openSetupPopup}>+ Create New Menu</Button>
|
||||||
<Button variant="outline" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
|
<Button variant="outline" onclick={() => loadRecipeFromMachine()} disabled={recipeLoading}>
|
||||||
|
|
@ -1249,6 +1502,26 @@
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isAdbConnected}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-center self-center text-xs font-medium text-muted-foreground"
|
||||||
|
onclick={reconnectAndroidSocket}
|
||||||
|
disabled={isAndroidSocketConnected}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2.5 w-2.5 rounded-full {isAndroidSocketConnected
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: 'bg-destructive'}"
|
||||||
|
></span>
|
||||||
|
{isAndroidSocketConnected ? 'Socket Connected' : 'Reconnect Socket'}
|
||||||
|
</Button>
|
||||||
|
{#if !isAndroidSocketConnected}
|
||||||
|
<span class="self-center text-xs text-muted-foreground">Required before saving</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Country indicator -->
|
<!-- Country indicator -->
|
||||||
{#if isAdbConnected}
|
{#if isAdbConnected}
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
|
@ -1373,7 +1646,10 @@
|
||||||
{#if detectedCountry}
|
{#if detectedCountry}
|
||||||
<div class="rounded-md border bg-muted/30 p-3">
|
<div class="rounded-md border bg-muted/30 p-3">
|
||||||
<div class="text-sm text-muted-foreground">Machine Country</div>
|
<div class="text-sm text-muted-foreground">Machine Country</div>
|
||||||
<div class="font-semibold">{detectedCountry} <span class="text-muted-foreground">(prefix: {detectedCountryCode})</span></div>
|
<div class="font-semibold">
|
||||||
|
{detectedCountry}
|
||||||
|
<span class="text-muted-foreground">(prefix: {detectedCountryCode})</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -1479,11 +1755,11 @@
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for={`name-${activeForm.temp}`}>Name (Thai)</Label>
|
<Label for={`name-${activeForm.temp}`}>Name ({primaryLanguageLabel})</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`name-${activeForm.temp}`}
|
id={`name-${activeForm.temp}`}
|
||||||
value={activeForm.name}
|
value={activeForm.name}
|
||||||
placeholder="ชื่อเมนู"
|
placeholder={getPrimaryNamePlaceholder()}
|
||||||
oninput={(event) => updateActiveFormField('name', event.currentTarget.value)}
|
oninput={(event) => updateActiveFormField('name', event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1499,10 +1775,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for={`description-${activeForm.temp}`}>Description (Thai)</Label>
|
<Label for={`description-${activeForm.temp}`}
|
||||||
|
>Description ({primaryLanguageLabel})</Label
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id={`description-${activeForm.temp}`}
|
id={`description-${activeForm.temp}`}
|
||||||
value={activeForm.description}
|
value={activeForm.description}
|
||||||
|
placeholder={getPrimaryDescriptionPlaceholder()}
|
||||||
oninput={(event) =>
|
oninput={(event) =>
|
||||||
updateActiveFormField('description', event.currentTarget.value)}
|
updateActiveFormField('description', event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1517,28 +1796,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for={`cashPrice-${activeForm.temp}`}>Cash price</Label>
|
|
||||||
<Input
|
|
||||||
id={`cashPrice-${activeForm.temp}`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={activeForm.cashPrice}
|
|
||||||
oninput={(event) => updateActiveFormField('cashPrice', event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for={`nonCashPrice-${activeForm.temp}`}>Non-cash price</Label>
|
|
||||||
<Input
|
|
||||||
id={`nonCashPrice-${activeForm.temp}`}
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={activeForm.nonCashPrice}
|
|
||||||
oninput={(event) =>
|
|
||||||
updateActiveFormField('nonCashPrice', event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for={`image-${activeForm.temp}`}>Image file</Label>
|
<Label for={`image-${activeForm.temp}`}>Image file</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -1604,23 +1862,14 @@
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label>Material</Label>
|
<Label>Material</Label>
|
||||||
<select
|
<Button
|
||||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
type="button"
|
||||||
value={step.materialPathId == null ? '' : String(step.materialPathId)}
|
variant="outline"
|
||||||
onchange={(event) =>
|
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||||
updateRecipeStepNumber(
|
onclick={() => openMaterialPicker(index)}
|
||||||
index,
|
|
||||||
'materialPathId',
|
|
||||||
event.currentTarget.value
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select material</option>
|
{getSelectedMaterialName(step.materialPathId)}
|
||||||
{#each activeMaterials as material}
|
</Button>
|
||||||
<option value={String(material.id)}
|
|
||||||
>{materialDisplayName(material)}</option
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 sm:grid-cols-4">
|
<div class="grid gap-3 sm:grid-cols-4">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
|
|
@ -1758,51 +2007,37 @@
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label>Slot</Label>
|
<Label>Slot</Label>
|
||||||
<select
|
<Button
|
||||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
type="button"
|
||||||
value={topping.slot == null ? '' : String(topping.slot)}
|
variant="outline"
|
||||||
onchange={(e) => updateToppingSlot(index, e.currentTarget.value)}
|
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||||
|
onclick={() => openToppingPicker('slot', index)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select slot</option>
|
{getSelectedToppingSlotName(topping.slot)}
|
||||||
{#each activeToppingSlotMaterials as material}
|
</Button>
|
||||||
<option value={String(Number(material.id) - 8110)}>
|
|
||||||
{toppingSlotDisplayName(material)}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label>Topping group</Label>
|
<Label>Topping group</Label>
|
||||||
<select
|
<Button
|
||||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
type="button"
|
||||||
value={topping.groupID == null ? '' : String(topping.groupID)}
|
variant="outline"
|
||||||
onchange={(e) => updateToppingGroup(index, e.currentTarget.value)}
|
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||||
|
onclick={() => openToppingPicker('group', index)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select group</option>
|
{getSelectedToppingGroupName(topping.groupID)}
|
||||||
{#each activeToppingGroups as group}
|
</Button>
|
||||||
<option value={String(group.groupID)}
|
|
||||||
>{toppingGroupDisplayName(group)}</option
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label>Default topping</Label>
|
<Label>Default topping</Label>
|
||||||
<select
|
<Button
|
||||||
class="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
type="button"
|
||||||
value={topping.defaultIDSelect == null
|
variant="outline"
|
||||||
? ''
|
class="h-auto min-h-10 justify-start px-3 py-2 text-left font-normal whitespace-normal"
|
||||||
: String(topping.defaultIDSelect)}
|
|
||||||
disabled={topping.groupID == null}
|
disabled={topping.groupID == null}
|
||||||
onchange={(e) => updateToppingList(index, e.currentTarget.value)}
|
onclick={() => openToppingPicker('list', index)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>Select topping</option>
|
{getSelectedToppingListName(topping.defaultIDSelect)}
|
||||||
{#each getToppingListsForGroup(topping.groupID) as toppingItem}
|
</Button>
|
||||||
<option value={String(toppingItem.id)}
|
|
||||||
>{toppingListDisplayName(toppingItem)}</option
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1823,6 +2058,96 @@
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Material Picker Dialog -->
|
||||||
|
<Dialog.Root bind:open={materialPickerOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Select Material</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Choose a material for recipe step {materialPickerStepIndex == null
|
||||||
|
? ''
|
||||||
|
: materialPickerStepIndex + 1}. Materials are grouped by channel/category.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-2">
|
||||||
|
<Input bind:value={materialPickerSearch} placeholder="Search material id, name, path, type" />
|
||||||
|
|
||||||
|
{#if groupedMaterialOptions.length === 0}
|
||||||
|
<div class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
No materials found.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid max-h-[60vh] gap-4 overflow-y-auto pr-1">
|
||||||
|
{#each groupedMaterialOptions as group}
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<div class="flex items-center justify-between border-b bg-muted/40 px-3 py-2">
|
||||||
|
<div class="text-sm font-semibold">{group.category}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{group.materials.length} items</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid divide-y">
|
||||||
|
{#each group.materials as material}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid gap-1 px-3 py-2 text-left text-sm transition-colors hover:bg-primary/5"
|
||||||
|
onclick={() => selectMaterialForActiveStep(Number(material.id))}
|
||||||
|
>
|
||||||
|
<div class="font-medium">{materialDisplayName(material)}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{material.pathOtherName || material.CanisterType || 'No path/type'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (materialPickerOpen = false)}>Cancel</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<!-- Topping Picker Dialog -->
|
||||||
|
<Dialog.Root bind:open={toppingPickerOpen}>
|
||||||
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{getToppingPickerTitle()}</Dialog.Title>
|
||||||
|
<Dialog.Description>{getToppingPickerDescription()}</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-2">
|
||||||
|
<Input bind:value={toppingPickerSearch} placeholder="Search id, name, description" />
|
||||||
|
|
||||||
|
{#if toppingPickerOptions.length === 0}
|
||||||
|
<div class="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{toppingPickerType === 'list' ? 'No toppings found for this group.' : 'No options found.'}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid max-h-[60vh] divide-y overflow-y-auto rounded-md border">
|
||||||
|
{#each toppingPickerOptions as option}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid gap-1 px-3 py-2 text-left text-sm transition-colors hover:bg-primary/5"
|
||||||
|
onclick={() => selectToppingPickerOption(option.value)}
|
||||||
|
>
|
||||||
|
<div class="font-medium">{option.label}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{option.description}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (toppingPickerOpen = false)}>Cancel</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<!-- Brew Confirm Dialog -->
|
<!-- Brew Confirm Dialog -->
|
||||||
<Dialog.Root bind:open={brewConfirmOpen}>
|
<Dialog.Root bind:open={brewConfirmOpen}>
|
||||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
|
|
||||||
797
src/routes/(authed)/tools/video-mainpage/+page.svelte
Normal file
797
src/routes/(authed)/tools/video-mainpage/+page.svelte
Normal file
|
|
@ -0,0 +1,797 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth } from '$lib/core/stores/auth';
|
||||||
|
import { addNotification } from '$lib/core/stores/noti';
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import * as Select from '$lib/components/ui/select/index.js';
|
||||||
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||||
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||||||
|
import Progress from '$lib/components/ui/progress/progress.svelte';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
Film,
|
||||||
|
MonitorPlay,
|
||||||
|
CoffeeIcon,
|
||||||
|
Pencil,
|
||||||
|
Lock,
|
||||||
|
RefreshCw,
|
||||||
|
CalendarDays,
|
||||||
|
Clock,
|
||||||
|
ChevronDown,
|
||||||
|
ImageIcon
|
||||||
|
} from '@lucide/svelte/icons';
|
||||||
|
import * as adb from '$lib/core/adb/adb';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { AdbInstance } from '../../../state.svelte';
|
||||||
|
|
||||||
|
const CREATE_ENDPOINT = '/api/video-mainpage';
|
||||||
|
const LIST_ENDPOINT = '/api/video-mainpage/list';
|
||||||
|
const UPDATE_ENDPOINT = '/api/video-mainpage/update';
|
||||||
|
const MACHINE_PROJECT_DIR = '/sdcard/coffeevending/taobin_project';
|
||||||
|
const GET_IMAGE = env.PUBLIC_GET_IMAGE;
|
||||||
|
const DURATION_TRIM = 4; // brewing play seconds = video length − 4
|
||||||
|
|
||||||
|
// taobin_project-relative path -> served URL (works for video/ and inter/<c>/video/).
|
||||||
|
const videoUrl = (path: string) => `${GET_IMAGE}/${path}`;
|
||||||
|
|
||||||
|
// Only Thailand is enabled for now. To re-enable a country later, uncomment it
|
||||||
|
// here (the backend already supports inter/<country>/video for all of these).
|
||||||
|
const COUNTRIES = [
|
||||||
|
{ value: 'tha', label: 'Thailand (tha)' }
|
||||||
|
// { value: 'aus', label: 'Australia (aus)' },
|
||||||
|
// { value: 'gbr', label: 'United Kingdom (gbr)' },
|
||||||
|
// { value: 'gbr_premium', label: 'UK Premium (gbr_premium)' },
|
||||||
|
// { value: 'hkg', label: 'Hong Kong (hkg)' },
|
||||||
|
// { value: 'ltu', label: 'Lithuania (ltu)' },
|
||||||
|
// { value: 'mys', label: 'Malaysia (mys)' },
|
||||||
|
// { value: 'rou', label: 'Romania (rou)' },
|
||||||
|
// { value: 'sgp', label: 'Singapore (sgp)' },
|
||||||
|
// { value: 'tha_premium', label: 'Thailand Premium (tha_premium)' },
|
||||||
|
// { value: 'uae_dubai', label: 'UAE Dubai (uae_dubai)' },
|
||||||
|
// { value: 'usa', label: 'USA (usa)' }
|
||||||
|
];
|
||||||
|
let country = $state('tha');
|
||||||
|
const countryLabel = $derived(COUNTRIES.find((c) => c.value === country)?.label ?? country);
|
||||||
|
|
||||||
|
interface MediaInfo {
|
||||||
|
filename: string;
|
||||||
|
video: string;
|
||||||
|
size: number | null;
|
||||||
|
duration?: number | null;
|
||||||
|
}
|
||||||
|
interface ManagedVideo {
|
||||||
|
n: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
range_label: string;
|
||||||
|
main: MediaInfo | null;
|
||||||
|
brewing: MediaInfo | null;
|
||||||
|
editable: true;
|
||||||
|
}
|
||||||
|
interface ReadonlyVideo {
|
||||||
|
filename: string;
|
||||||
|
video: string;
|
||||||
|
size: number | null;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── create form ─────────────────────────────────────────────────────────
|
||||||
|
let name = $state('');
|
||||||
|
let startDate = $state('');
|
||||||
|
let endDate = $state('');
|
||||||
|
let mainFile = $state<File | null>(null);
|
||||||
|
let mainPreview = $state('');
|
||||||
|
let brewingFile = $state<File | null>(null);
|
||||||
|
let brewingPreview = $state('');
|
||||||
|
let brewingRawSeconds = $state(0);
|
||||||
|
let brewingTxtFile = $state<File | null>(null);
|
||||||
|
let brewingTxtPreview = $state('');
|
||||||
|
let brewingTxtEnFile = $state<File | null>(null);
|
||||||
|
let brewingTxtEnPreview = $state('');
|
||||||
|
|
||||||
|
const brewingPlaySeconds = $derived(Math.max(1, Math.round(brewingRawSeconds) - DURATION_TRIM));
|
||||||
|
|
||||||
|
let submitting = $state(false);
|
||||||
|
let connecting = $state(false);
|
||||||
|
let pushProgress = $state({ percent: 0, name: '', active: false });
|
||||||
|
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
|
||||||
|
|
||||||
|
// ── list ────────────────────────────────────────────────────────────────
|
||||||
|
let managed = $state<ManagedVideo[]>([]);
|
||||||
|
let readonlyList = $state<ReadonlyVideo[]>([]);
|
||||||
|
let loadingList = $state(false);
|
||||||
|
let showReadonly = $state(false);
|
||||||
|
|
||||||
|
// ── edit dialog ───────────────────────────────────────────────────────────
|
||||||
|
let editOpen = $state(false);
|
||||||
|
let editTarget = $state<ManagedVideo | null>(null);
|
||||||
|
let editName = $state('');
|
||||||
|
let editStart = $state('');
|
||||||
|
let editEnd = $state('');
|
||||||
|
let editMainFile = $state<File | null>(null);
|
||||||
|
let editBrewingFile = $state<File | null>(null);
|
||||||
|
let editBrewingRaw = $state(0);
|
||||||
|
let editBrewingTxtFile = $state<File | null>(null);
|
||||||
|
let editBrewingTxtEnFile = $state<File | null>(null);
|
||||||
|
let editSaving = $state(false);
|
||||||
|
|
||||||
|
const editBrewingPlaySeconds = $derived(Math.max(1, Math.round(editBrewingRaw) - DURATION_TRIM));
|
||||||
|
|
||||||
|
function toIso(d: string): string {
|
||||||
|
return d ? `${d}T00:00:00` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMB(bytes: number | null | undefined): string {
|
||||||
|
return bytes ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVideoDuration(file: File): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const v = document.createElement('video');
|
||||||
|
v.preload = 'metadata';
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
v.onloadedmetadata = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(Number.isFinite(v.duration) ? v.duration : 0);
|
||||||
|
};
|
||||||
|
v.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(0);
|
||||||
|
};
|
||||||
|
v.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function machineVideoNumbers(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const res = await adb.executeCmd(`ls ${MACHINE_PROJECT_DIR}/video`);
|
||||||
|
const out = typeof res === 'object' && res ? ((res as { output?: string }).output ?? '') : '';
|
||||||
|
const nums = [...out.matchAll(/brewing_adv(\d+)/g)].map((m) => m[1]);
|
||||||
|
return [...new Set(nums)].join(',');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectMachine() {
|
||||||
|
if (AdbInstance.instance) {
|
||||||
|
addNotification('INFO:Machine already connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connecting = true;
|
||||||
|
try {
|
||||||
|
await adb.connnectViaWebUSB(false);
|
||||||
|
addNotification(AdbInstance.instance ? 'INFO:Machine connected' : 'WARN:No machine selected');
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(`ERR:Connect failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
connecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMain(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
|
||||||
|
if (mainPreview) URL.revokeObjectURL(mainPreview);
|
||||||
|
mainFile = file;
|
||||||
|
mainPreview = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickBrewing(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
|
||||||
|
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
|
||||||
|
brewingFile = file;
|
||||||
|
brewingPreview = URL.createObjectURL(file);
|
||||||
|
brewingRawSeconds = await readVideoDuration(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPng(event: Event, set: (f: File, url: string) => void) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
|
||||||
|
set(file, URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
function pickBrewingTxt(e: Event) {
|
||||||
|
pickPng(e, (f, url) => {
|
||||||
|
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
|
||||||
|
brewingTxtFile = f;
|
||||||
|
brewingTxtPreview = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function pickBrewingTxtEn(e: Event) {
|
||||||
|
pickPng(e, (f, url) => {
|
||||||
|
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
|
||||||
|
brewingTxtEnFile = f;
|
||||||
|
brewingTxtEnPreview = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMain() {
|
||||||
|
if (mainPreview) URL.revokeObjectURL(mainPreview);
|
||||||
|
mainFile = null;
|
||||||
|
mainPreview = '';
|
||||||
|
}
|
||||||
|
function clearBrewing() {
|
||||||
|
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
|
||||||
|
brewingFile = null;
|
||||||
|
brewingPreview = '';
|
||||||
|
brewingRawSeconds = 0;
|
||||||
|
}
|
||||||
|
function clearBrewingTxt() {
|
||||||
|
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
|
||||||
|
brewingTxtFile = null;
|
||||||
|
brewingTxtPreview = '';
|
||||||
|
}
|
||||||
|
function clearBrewingTxtEn() {
|
||||||
|
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
|
||||||
|
brewingTxtEnFile = null;
|
||||||
|
brewingTxtEnPreview = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushBinaries(items: { rel: string; file: File }[]) {
|
||||||
|
const dirs = [...new Set(items.map((it) => it.rel.slice(0, it.rel.lastIndexOf('/'))))];
|
||||||
|
for (const d of dirs) await adb.executeCmd(`mkdir -p "${MACHINE_PROJECT_DIR}/${d}"`);
|
||||||
|
for (const it of items) {
|
||||||
|
const label = it.rel.split('/').pop() ?? it.rel;
|
||||||
|
pushProgress = { percent: 0, name: label, active: true };
|
||||||
|
const bytes = new Uint8Array(await it.file.arrayBuffer());
|
||||||
|
const ok = await adb.pushBinary(`${MACHINE_PROJECT_DIR}/${it.rel}`, bytes, (sent, total) => {
|
||||||
|
pushProgress = {
|
||||||
|
percent: total > 0 ? Math.round((sent / total) * 100) : 0,
|
||||||
|
name: label,
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!ok) throw new Error(`push ${label} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push each uploaded file to every target path the backend returned (one per base).
|
||||||
|
function targetItems(
|
||||||
|
targets: Record<string, string[]>,
|
||||||
|
files: Record<string, File | null>
|
||||||
|
): { rel: string; file: File }[] {
|
||||||
|
const items: { rel: string; file: File }[] = [];
|
||||||
|
for (const kind of ['main', 'brewing', 'txt', 'txt_en']) {
|
||||||
|
const f = files[kind];
|
||||||
|
if (!f) continue;
|
||||||
|
for (const rel of targets?.[kind] ?? []) items.push({ rel, file: f });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
async function pushScripts(scripts: { path: string; content: string }[]) {
|
||||||
|
for (const s of scripts) {
|
||||||
|
if (!s?.path || typeof s?.content !== 'string') continue;
|
||||||
|
pushProgress = { percent: 100, name: s.path.split('/').pop() ?? s.path, active: true };
|
||||||
|
await adb.push(`${MACHINE_PROJECT_DIR}/${s.path}`, s.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const user = $auth;
|
||||||
|
if (!user) return addNotification('ERR:Not logged in');
|
||||||
|
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
|
||||||
|
if (!name.trim() || !startDate || !mainFile || !brewingFile || !brewingTxtFile || !brewingTxtEnFile)
|
||||||
|
return addNotification(
|
||||||
|
'ERR:Need a name, start date, both videos, and both brewing text overlays (TH + EN)'
|
||||||
|
);
|
||||||
|
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('uid', user.uid);
|
||||||
|
fd.append('displayName', user.displayName || 'unknown');
|
||||||
|
fd.append('email', user.email || 'unknown@email.com');
|
||||||
|
fd.append('name', name.trim());
|
||||||
|
fd.append('country', country);
|
||||||
|
fd.append('start', toIso(startDate));
|
||||||
|
fd.append('end', endDate ? toIso(endDate) : 'NONE');
|
||||||
|
fd.append('machine_numbers', await machineVideoNumbers());
|
||||||
|
fd.append('brewing_duration', String(brewingPlaySeconds));
|
||||||
|
fd.append('video', mainFile);
|
||||||
|
fd.append('brewing_video', brewingFile);
|
||||||
|
fd.append('brewing_txt', brewingTxtFile);
|
||||||
|
fd.append('brewing_txt_en', brewingTxtEnFile);
|
||||||
|
|
||||||
|
const res = await fetch(CREATE_ENDPOINT, { method: 'POST', body: fd });
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(e.detail || e.message || 'Add video failed');
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
await pushBinaries(
|
||||||
|
targetItems(result.targets, {
|
||||||
|
main: mainFile,
|
||||||
|
brewing: brewingFile,
|
||||||
|
txt: brewingTxtFile,
|
||||||
|
txt_en: brewingTxtEnFile
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await pushScripts(result?.content?.scripts ?? []);
|
||||||
|
|
||||||
|
if (result?.sftp?.error)
|
||||||
|
addNotification(`WARN:Uploaded but FTP sync failed: ${result.sftp.error}`);
|
||||||
|
addNotification(`INFO:Added "${name.trim()}" as brewing_adv${result.n} (${country})`);
|
||||||
|
clearMain();
|
||||||
|
clearBrewing();
|
||||||
|
clearBrewingTxt();
|
||||||
|
clearBrewingTxtEn();
|
||||||
|
name = '';
|
||||||
|
startDate = '';
|
||||||
|
endDate = '';
|
||||||
|
await loadList();
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(`ERR:${error instanceof Error ? error.message : 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
pushProgress = { percent: 0, name: '', active: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loadingList = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${LIST_ENDPOINT}?country=${encodeURIComponent(country)}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('list failed');
|
||||||
|
const data = await res.json();
|
||||||
|
managed = data.managed ?? [];
|
||||||
|
readonlyList = data.readonly ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(`ERR:Load list failed: ${error instanceof Error ? error.message : 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
loadingList = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(v: ManagedVideo) {
|
||||||
|
editTarget = v;
|
||||||
|
editName = v.name;
|
||||||
|
editStart = v.start ? v.start.slice(0, 10) : '';
|
||||||
|
editEnd = v.end && v.end !== 'NONE' ? v.end.slice(0, 10) : '';
|
||||||
|
editMainFile = null;
|
||||||
|
editBrewingFile = null;
|
||||||
|
editBrewingRaw = 0;
|
||||||
|
editBrewingTxtFile = null;
|
||||||
|
editBrewingTxtEnFile = null;
|
||||||
|
editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEditMain(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (file && file.name.toLowerCase().endsWith('.mp4')) editMainFile = file;
|
||||||
|
else if (file) addNotification('WARN:Only .mp4 allowed');
|
||||||
|
}
|
||||||
|
async function pickEditBrewing(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.mp4')) return addNotification('WARN:Only .mp4 allowed');
|
||||||
|
editBrewingFile = file;
|
||||||
|
editBrewingRaw = await readVideoDuration(file);
|
||||||
|
}
|
||||||
|
function pickEditTxt(event: Event, en: boolean) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.png')) return addNotification('WARN:Text overlay must be .png');
|
||||||
|
if (en) editBrewingTxtEnFile = file;
|
||||||
|
else editBrewingTxtFile = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
const user = $auth;
|
||||||
|
if (!user || !editTarget) return;
|
||||||
|
if (!AdbInstance.instance) return addNotification('ERR:Connect a machine first');
|
||||||
|
if (!editStart) return addNotification('ERR:Start date required');
|
||||||
|
const target = editTarget;
|
||||||
|
editSaving = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('slug', target.slug);
|
||||||
|
fd.append('uid', user.uid);
|
||||||
|
fd.append('displayName', user.displayName || 'unknown');
|
||||||
|
fd.append('email', user.email || 'unknown@email.com');
|
||||||
|
fd.append('country', country);
|
||||||
|
fd.append('name', editName.trim() || target.name);
|
||||||
|
fd.append('start', toIso(editStart));
|
||||||
|
fd.append('end', editEnd ? toIso(editEnd) : 'NONE');
|
||||||
|
if (editBrewingFile) fd.append('brewing_duration', String(editBrewingPlaySeconds));
|
||||||
|
else if (target.brewing?.duration) fd.append('brewing_duration', String(target.brewing.duration));
|
||||||
|
if (editMainFile) fd.append('video', editMainFile);
|
||||||
|
if (editBrewingFile) fd.append('brewing_video', editBrewingFile);
|
||||||
|
if (editBrewingTxtFile) fd.append('brewing_txt', editBrewingTxtFile);
|
||||||
|
if (editBrewingTxtEnFile) fd.append('brewing_txt_en', editBrewingTxtEnFile);
|
||||||
|
|
||||||
|
const res = await fetch(UPDATE_ENDPOINT, { method: 'POST', body: fd });
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(e.detail || 'Update failed');
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
await pushBinaries(
|
||||||
|
targetItems(result.targets ?? {}, {
|
||||||
|
main: editMainFile,
|
||||||
|
brewing: editBrewingFile,
|
||||||
|
txt: editBrewingTxtFile,
|
||||||
|
txt_en: editBrewingTxtEnFile
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await pushScripts(result?.content?.scripts ?? []);
|
||||||
|
|
||||||
|
if (result?.sftp?.error)
|
||||||
|
addNotification(`WARN:Updated but FTP sync failed: ${result.sftp.error}`);
|
||||||
|
addNotification(`INFO:Updated "${target.name}" (${country})`);
|
||||||
|
editOpen = false;
|
||||||
|
await loadList();
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(`ERR:${error instanceof Error ? error.message : 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
pushProgress = { percent: 0, name: '', active: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load (and reload) the list whenever the selected country changes; runs on mount.
|
||||||
|
$effect(() => {
|
||||||
|
void country;
|
||||||
|
loadList();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
if (mainPreview) URL.revokeObjectURL(mainPreview);
|
||||||
|
if (brewingPreview) URL.revokeObjectURL(brewingPreview);
|
||||||
|
if (brewingTxtPreview) URL.revokeObjectURL(brewingTxtPreview);
|
||||||
|
if (brewingTxtEnPreview) URL.revokeObjectURL(brewingTxtEnPreview);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="sticky top-0 z-10 border-b bg-background">
|
||||||
|
<div class="flex items-center justify-between px-8 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Advertisement Videos</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">Main-page & brewing-page videos, scheduled by date</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Badge variant={isAdbConnected ? 'default' : 'secondary'}>
|
||||||
|
{isAdbConnected ? 'Machine connected' : 'Machine offline'}
|
||||||
|
</Badge>
|
||||||
|
{#if !isAdbConnected}
|
||||||
|
<Button variant="outline" onclick={connectMachine} disabled={connecting}>
|
||||||
|
{#if connecting}<Spinner class="mr-2 h-4 w-4" />Connecting...{:else}<MonitorPlay class="mr-2 h-4 w-4" />Connect Machine{/if}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-8">
|
||||||
|
<div class="mx-auto max-w-5xl space-y-8">
|
||||||
|
<!-- Create -->
|
||||||
|
<Card.Root class="overflow-hidden shadow-sm">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-lg">
|
||||||
|
<Upload class="h-5 w-5 text-muted-foreground" /> Add a new video
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Upload the same clip twice — the main-page version and the brewing-page
|
||||||
|
<code class="font-mono">_long</code> version. Auto-named
|
||||||
|
<code class="font-mono">brewing_adv<N></code> (next free 1–40, never overwrites a video in use).
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Country</Label>
|
||||||
|
<Select.Root type="single" bind:value={country}>
|
||||||
|
<Select.Trigger class="w-full">{countryLabel}</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each COUNTRIES as c (c.value)}
|
||||||
|
<Select.Item value={c.value}>{c.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Writes to <code class="font-mono">inter/{country}/video</code>{country === 'tha' ? ' + flat video/' : ''}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="v-name">Name</Label>
|
||||||
|
<Input id="v-name" bind:value={name} placeholder="e.g. Bas Bew Bow Brewing" />
|
||||||
|
<p class="text-xs text-muted-foreground">Used for the comment & the <code class="font-mono">…VideoEnable</code> variable.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="v-start" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> Start date</Label>
|
||||||
|
<Input id="v-start" type="date" bind:value={startDate} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="v-end" class="flex items-center gap-1.5"><CalendarDays class="h-3.5 w-3.5" /> End date <span class="text-muted-foreground">(optional)</span></Label>
|
||||||
|
<Input id="v-end" type="date" bind:value={endDate} />
|
||||||
|
{#if !endDate}<p class="text-xs text-muted-foreground">Blank = open-ended</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- two upload tiles -->
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<!-- main -->
|
||||||
|
<div class="rounded-xl border p-3">
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Film class="h-4 w-4 text-muted-foreground" /> Main-page video
|
||||||
|
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv<N>.mp4</Badge>
|
||||||
|
</div>
|
||||||
|
{#if mainFile}
|
||||||
|
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={mainPreview} class="h-full w-full object-contain" muted controls></video>
|
||||||
|
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearMain}><X class="h-3.5 w-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 truncate text-xs text-muted-foreground" title={mainFile.name}>{mainFile.name} · {fmtMB(mainFile.size)}</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
|
||||||
|
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickMain} />
|
||||||
|
<Film class="mb-2 h-8 w-8" />
|
||||||
|
<span class="text-sm font-medium">Click to select .mp4</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- brewing -->
|
||||||
|
<div class="rounded-xl border p-3">
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<CoffeeIcon class="h-4 w-4 text-muted-foreground" /> Brewing-page video
|
||||||
|
<Badge variant="outline" class="ml-auto font-mono text-[10px]">brewing_adv<N>_long.mp4</Badge>
|
||||||
|
</div>
|
||||||
|
{#if brewingFile}
|
||||||
|
<div class="relative aspect-video overflow-hidden rounded-lg bg-black">
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={brewingPreview} class="h-full w-full object-contain" muted controls></video>
|
||||||
|
<button class="absolute right-1.5 top-1.5 rounded-full bg-black/60 p-1 text-white" onclick={clearBrewing}><X class="h-3.5 w-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 truncate text-xs text-muted-foreground" title={brewingFile.name}>{brewingFile.name} · {fmtMB(brewingFile.size)}</p>
|
||||||
|
<p class="mt-1 flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<Clock class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Plays {brewingPlaySeconds}s
|
||||||
|
<span class="text-muted-foreground">(length {Math.round(brewingRawSeconds)}s − {DURATION_TRIM})</span>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex aspect-video cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground transition hover:bg-muted/50 hover:text-foreground">
|
||||||
|
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickBrewing} />
|
||||||
|
<CoffeeIcon class="mb-2 h-8 w-8" />
|
||||||
|
<span class="text-sm font-medium">Click to select _long .mp4</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- text overlays (required) -->
|
||||||
|
<div class="mt-3 border-t pt-3">
|
||||||
|
<p class="mb-2 flex items-center gap-1.5 text-xs font-semibold">
|
||||||
|
<ImageIcon class="h-3.5 w-3.5 text-muted-foreground" /> Text overlays (.png, required)
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
{#each [{ label: 'Thai', name: 'brewing_txt_adv<N>.png', file: brewingTxtFile, preview: brewingTxtPreview, pick: pickBrewingTxt, clear: clearBrewingTxt }, { label: 'English', name: 'brewing_txt_adv<N>_en.png', file: brewingTxtEnFile, preview: brewingTxtEnPreview, pick: pickBrewingTxtEn, clear: clearBrewingTxtEn }] as t (t.label)}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-[11px] font-medium text-muted-foreground">{t.label}</p>
|
||||||
|
{#if t.file}
|
||||||
|
<div class="relative overflow-hidden rounded-md border bg-muted/30">
|
||||||
|
<img src={t.preview} alt={t.label} class="h-20 w-full object-contain" />
|
||||||
|
<button class="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white" onclick={t.clear}><X class="h-3 w-3" /></button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={t.file.name}>{t.file.name}</p>
|
||||||
|
{:else}
|
||||||
|
<label class="flex h-20 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed text-[11px] text-muted-foreground transition hover:bg-muted/50">
|
||||||
|
<input type="file" accept=".png,image/png" class="hidden" onchange={t.pick} />
|
||||||
|
<ImageIcon class="h-4 w-4" /> {t.label} .png
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pushProgress.active && submitting}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Progress value={pushProgress.percent} max={100} class="h-2" />
|
||||||
|
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="justify-end gap-2 border-t bg-muted/30 py-4">
|
||||||
|
<Button size="lg" onclick={handleSubmit} disabled={submitting || !isAdbConnected}>
|
||||||
|
{#if submitting}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Create & Push{/if}
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Existing -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">Existing videos</h2>
|
||||||
|
<Button variant="ghost" size="sm" onclick={loadList} disabled={loadingList}>
|
||||||
|
{#if loadingList}<Spinner class="mr-2 h-3.5 w-3.5" />{:else}<RefreshCw class="mr-2 h-3.5 w-3.5" />{/if}Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- managed -->
|
||||||
|
{#if managed.length === 0}
|
||||||
|
<p class="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
No web-managed videos yet. Ones you add above appear here and can be edited.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{#each managed as v (v.slug)}
|
||||||
|
<Card.Root class="overflow-hidden">
|
||||||
|
<div class="grid grid-cols-2 gap-px bg-border">
|
||||||
|
<div class="bg-black">
|
||||||
|
{#if v.main}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={videoUrl(v.main.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
|
||||||
|
{:else}
|
||||||
|
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no main</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="bg-black">
|
||||||
|
{#if v.brewing}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={videoUrl(v.brewing.video)} class="aspect-video w-full object-contain" preload="metadata" muted controls></video>
|
||||||
|
{:else}
|
||||||
|
<div class="flex aspect-video items-center justify-center text-xs text-muted-foreground">no brewing</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2 p-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold" title={v.name}>{v.name}</p>
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<Badge variant="secondary" class="font-mono">brewing_adv{v.n}</Badge>
|
||||||
|
<span class="flex items-center gap-1"><CalendarDays class="h-3 w-3" />{v.range_label}</span>
|
||||||
|
{#if v.brewing?.duration}<span class="flex items-center gap-1"><Clock class="h-3 w-3" />{v.brewing.duration}s</span>{/if}
|
||||||
|
<Badge variant={v.main ? 'default' : 'outline'} class="text-[10px]">main</Badge>
|
||||||
|
<Badge variant={v.brewing ? 'default' : 'outline'} class="text-[10px]">brewing</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => openEdit(v)}>
|
||||||
|
<Pencil class="mr-1.5 h-3.5 w-3.5" />Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- read-only -->
|
||||||
|
<div class="rounded-lg border bg-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 p-3 text-sm font-semibold text-muted-foreground transition hover:bg-muted/40"
|
||||||
|
onclick={() => (showReadonly = !showReadonly)}
|
||||||
|
>
|
||||||
|
<ChevronDown class="h-4 w-4 shrink-0 transition-transform {showReadonly ? 'rotate-180' : ''}" />
|
||||||
|
<Lock class="h-3.5 w-3.5" />
|
||||||
|
Hand-maintained videos ({readonlyList.length})
|
||||||
|
<Badge variant="secondary" class="ml-1 text-[10px]">read-only</Badge>
|
||||||
|
<span class="ml-auto text-xs font-normal">{showReadonly ? 'Click to hide' : 'Click to show'}</span>
|
||||||
|
</button>
|
||||||
|
{#if showReadonly}
|
||||||
|
<div class="grid grid-cols-2 gap-3 p-3 pt-0 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
{#each readonlyList as v (v.filename)}
|
||||||
|
<div class="overflow-hidden rounded-md border bg-muted/30">
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={videoUrl(v.video)} class="aspect-video w-full bg-black object-contain" preload="metadata" muted controls></video>
|
||||||
|
<p class="truncate px-1.5 py-1 font-mono text-[10px]" title={v.filename}>{v.filename}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit dialog -->
|
||||||
|
<Dialog.Root bind:open={editOpen}>
|
||||||
|
<Dialog.Content class="sm:max-w-2xl">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Edit video</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{#if editTarget}<code class="font-mono">brewing_adv{editTarget.n}</code> · change dates, rename, or replace either clip{/if}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="e-name">Name</Label>
|
||||||
|
<Input id="e-name" bind:value={editName} />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="e-start">Start date</Label>
|
||||||
|
<Input id="e-start" type="date" bind:value={editStart} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="e-end">End date <span class="text-muted-foreground">(optional)</span></Label>
|
||||||
|
<Input id="e-end" type="date" bind:value={editEnd} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border p-2">
|
||||||
|
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><Film class="h-3.5 w-3.5 text-muted-foreground" /> Main-page</p>
|
||||||
|
{#if editTarget?.main}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={videoUrl(editTarget.main.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
|
||||||
|
{/if}
|
||||||
|
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
|
||||||
|
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditMain} />
|
||||||
|
<Upload class="h-3.5 w-3.5" /> {editMainFile ? editMainFile.name : 'Replace (optional)'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border p-2">
|
||||||
|
<p class="mb-1.5 flex items-center gap-1.5 text-xs font-semibold"><CoffeeIcon class="h-3.5 w-3.5 text-muted-foreground" /> Brewing-page</p>
|
||||||
|
{#if editTarget?.brewing}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video src={videoUrl(editTarget.brewing.video)} class="aspect-video w-full rounded bg-black object-contain" preload="metadata" muted controls></video>
|
||||||
|
{/if}
|
||||||
|
<label class="mt-2 flex cursor-pointer items-center justify-center gap-1.5 rounded border border-dashed p-1.5 text-xs text-muted-foreground hover:bg-muted/50">
|
||||||
|
<input type="file" accept=".mp4,video/mp4" class="hidden" onchange={pickEditBrewing} />
|
||||||
|
<Upload class="h-3.5 w-3.5" /> {editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
|
||||||
|
</label>
|
||||||
|
{#if editBrewingFile}
|
||||||
|
<p class="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground"><Clock class="h-3 w-3" />Plays {editBrewingPlaySeconds}s</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||||
|
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
|
||||||
|
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, false)} />
|
||||||
|
<ImageIcon class="h-3 w-3" /> {editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center justify-center gap-1 rounded border border-dashed p-1.5 text-[11px] text-muted-foreground hover:bg-muted/50">
|
||||||
|
<input type="file" accept=".png,image/png" class="hidden" onchange={(e) => pickEditTxt(e, true)} />
|
||||||
|
<ImageIcon class="h-3 w-3" /> {editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pushProgress.active}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Progress value={pushProgress.percent} max={100} class="h-2" />
|
||||||
|
<p class="text-center text-xs text-muted-foreground">Pushing {pushProgress.name} ({pushProgress.percent}%)</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (editOpen = false)} disabled={editSaving}>Cancel</Button>
|
||||||
|
<Button onclick={submitEdit} disabled={editSaving || !isAdbConnected}>
|
||||||
|
{#if editSaving}<Spinner class="mr-2 h-4 w-4" />Saving...{:else}<Upload class="mr-2 h-4 w-4" />Save & Push{/if}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
24
src/routes/api/catalog-banner-image/+server.ts
Normal file
24
src/routes/api/catalog-banner-image/+server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const GET_IMAGE = env.PUBLIC_GET_IMAGE;
|
||||||
|
|
||||||
|
// Server-side fetch of a banner image so the browser can read its bytes
|
||||||
|
// (to push to the machine via ADB) without hitting CORS on the image server.
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const path = url.searchParams.get('path');
|
||||||
|
if (!path) throw error(400, 'Missing path');
|
||||||
|
|
||||||
|
const target = `${GET_IMAGE}/${path}`;
|
||||||
|
const res = await fetch(target);
|
||||||
|
if (!res.ok) throw error(res.status, 'Banner fetch failed');
|
||||||
|
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
return new Response(buf, {
|
||||||
|
headers: {
|
||||||
|
'content-type': res.headers.get('content-type') || 'application/octet-stream',
|
||||||
|
'cache-control': 'no-store'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
40
src/routes/api/catalog-banner/+server.ts
Normal file
40
src/routes/api/catalog-banner/+server.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
|
// Replace an existing promo's banner image.
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const country = formData.get('country') as string;
|
||||||
|
const slug = formData.get('slug') as string;
|
||||||
|
const uid = formData.get('uid') as string;
|
||||||
|
const displayName = formData.get('displayName') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const banner = formData.get('banner') as File;
|
||||||
|
|
||||||
|
if (!country || !slug || !uid || !displayName || !email || !banner) {
|
||||||
|
throw error(400, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
`${API_BASE}/catalog/banner/${encodeURIComponent(country)}/${encodeURIComponent(slug)}` +
|
||||||
|
`/${encodeURIComponent(uid)}/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
const upstream = new FormData();
|
||||||
|
upstream.append('banner', banner);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, { method: 'POST', body: upstream });
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw error(response.status, data.detail || 'Replace banner failed');
|
||||||
|
}
|
||||||
|
return json(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) throw err;
|
||||||
|
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
62
src/routes/api/catalog-create/+server.ts
Normal file
62
src/routes/api/catalog-create/+server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
// New promo catalogs are created by the same taobin_image service as menu images.
|
||||||
|
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const country = formData.get('country') as string;
|
||||||
|
const uid = formData.get('uid') as string;
|
||||||
|
const displayName = formData.get('displayName') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const slug = formData.get('slug') as string;
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const start = formData.get('start') as string;
|
||||||
|
// 'NONE' means open-ended (no end date).
|
||||||
|
const end = (formData.get('end') as string) || 'NONE';
|
||||||
|
const bannerIndex = (formData.get('banner_index') as string) ?? '1';
|
||||||
|
const banner = formData.get('banner') as File;
|
||||||
|
|
||||||
|
if (!country || !uid || !displayName || !email || !slug || !name || !start || !banner) {
|
||||||
|
throw error(400, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
`${API_BASE}/catalog/create/${encodeURIComponent(country)}/${encodeURIComponent(uid)}` +
|
||||||
|
`/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
console.log('[Catalog Create Proxy] Endpoint:', endpoint, 'slug:', slug);
|
||||||
|
|
||||||
|
const upstream = new FormData();
|
||||||
|
upstream.append('slug', slug);
|
||||||
|
upstream.append('name', name);
|
||||||
|
upstream.append('start', start);
|
||||||
|
upstream.append('end', end);
|
||||||
|
upstream.append('banner_index', bannerIndex);
|
||||||
|
upstream.append('banner', banner);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: upstream
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw error(response.status, errorData.detail || 'Create catalog failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Catalog Create Proxy] Error:', err);
|
||||||
|
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
26
src/routes/api/catalog-list/+server.ts
Normal file
26
src/routes/api/catalog-list/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
|
// List web-created promos (slug, banner path, schedule) for a country.
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const country = url.searchParams.get('country');
|
||||||
|
if (!country) throw error(400, 'Missing country');
|
||||||
|
|
||||||
|
const endpoint = `${API_BASE}/catalog/list/${encodeURIComponent(country)}`;
|
||||||
|
// POST: the Kong route fronting taobin-image only allows POST.
|
||||||
|
const response = await fetch(endpoint, { method: 'POST' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw error(response.status, data.detail || 'List catalogs failed');
|
||||||
|
}
|
||||||
|
return json(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) throw err;
|
||||||
|
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
80
src/routes/api/video-mainpage/+server.ts
Normal file
80
src/routes/api/video-mainpage/+server.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
// Main-page advertisement videos are wired into video/script1.ev by the same
|
||||||
|
// taobin_image service that handles menu images / promo catalogs.
|
||||||
|
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const uid = formData.get('uid') as string;
|
||||||
|
const displayName = formData.get('displayName') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const country = (formData.get('country') as string) || 'tha';
|
||||||
|
const start = formData.get('start') as string;
|
||||||
|
// 'NONE' means open-ended (no end date).
|
||||||
|
const end = (formData.get('end') as string) || 'NONE';
|
||||||
|
const machineNumbers = (formData.get('machine_numbers') as string) || '';
|
||||||
|
const brewingDuration = (formData.get('brewing_duration') as string) || '1';
|
||||||
|
const video = formData.get('video') as File;
|
||||||
|
const brewingVideo = formData.get('brewing_video') as File;
|
||||||
|
const brewingTxt = formData.get('brewing_txt') as File;
|
||||||
|
const brewingTxtEn = formData.get('brewing_txt_en') as File;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!uid ||
|
||||||
|
!displayName ||
|
||||||
|
!email ||
|
||||||
|
!name ||
|
||||||
|
!start ||
|
||||||
|
!video ||
|
||||||
|
!brewingVideo ||
|
||||||
|
!brewingTxt ||
|
||||||
|
!brewingTxtEn
|
||||||
|
) {
|
||||||
|
throw error(400, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
`${API_BASE}/video/mainpage/${encodeURIComponent(uid)}` +
|
||||||
|
`/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
console.log('[Video MainPage Proxy] Endpoint:', endpoint, 'name:', name);
|
||||||
|
|
||||||
|
const upstream = new FormData();
|
||||||
|
upstream.append('name', name);
|
||||||
|
upstream.append('country', country);
|
||||||
|
upstream.append('start', start);
|
||||||
|
upstream.append('end', end);
|
||||||
|
upstream.append('machine_numbers', machineNumbers);
|
||||||
|
upstream.append('brewing_duration', brewingDuration);
|
||||||
|
upstream.append('video', video);
|
||||||
|
upstream.append('brewing_video', brewingVideo);
|
||||||
|
upstream.append('brewing_txt', brewingTxt);
|
||||||
|
upstream.append('brewing_txt_en', brewingTxtEn);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: upstream
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw error(response.status, errorData.detail || 'Add main-page video failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Video MainPage Proxy] Error:', err);
|
||||||
|
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
26
src/routes/api/video-mainpage/list/+server.ts
Normal file
26
src/routes/api/video-mainpage/list/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
|
// List a country's main-page videos (managed + read-only). POST because the Kong
|
||||||
|
// route fronting taobin-image is POST-only.
|
||||||
|
export const POST: RequestHandler = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const country = (url.searchParams.get('country') || 'tha').toLowerCase();
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE}/video/mainpage/list/${encodeURIComponent(country)}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const e = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw error(response.status, e.detail || 'List main-page videos failed');
|
||||||
|
}
|
||||||
|
return json(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Video MainPage List Proxy] Error:', err);
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) throw err;
|
||||||
|
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
56
src/routes/api/video-mainpage/update/+server.ts
Normal file
56
src/routes/api/video-mainpage/update/+server.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const API_BASE = env.PUBLIC_POST_IMAGE;
|
||||||
|
|
||||||
|
// Edit a web-managed main-page video: change date range and/or replace the .mp4.
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const slug = formData.get('slug') as string;
|
||||||
|
const uid = formData.get('uid') as string;
|
||||||
|
const displayName = formData.get('displayName') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const country = (formData.get('country') as string) || 'tha';
|
||||||
|
const start = formData.get('start') as string;
|
||||||
|
const end = (formData.get('end') as string) || 'NONE';
|
||||||
|
const name = formData.get('name') as string | null;
|
||||||
|
const brewingDuration = formData.get('brewing_duration') as string | null;
|
||||||
|
const video = formData.get('video') as File | null;
|
||||||
|
const brewingVideo = formData.get('brewing_video') as File | null;
|
||||||
|
const brewingTxt = formData.get('brewing_txt') as File | null;
|
||||||
|
const brewingTxtEn = formData.get('brewing_txt_en') as File | null;
|
||||||
|
|
||||||
|
if (!slug || !uid || !displayName || !email || !start) {
|
||||||
|
throw error(400, 'Missing required fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint =
|
||||||
|
`${API_BASE}/video/mainpage/update/${encodeURIComponent(slug)}/${encodeURIComponent(uid)}` +
|
||||||
|
`/${encodeURIComponent(displayName)}/${encodeURIComponent(email)}`;
|
||||||
|
|
||||||
|
const upstream = new FormData();
|
||||||
|
upstream.append('country', country);
|
||||||
|
upstream.append('start', start);
|
||||||
|
upstream.append('end', end);
|
||||||
|
if (name) upstream.append('name', name);
|
||||||
|
if (brewingDuration) upstream.append('brewing_duration', brewingDuration);
|
||||||
|
if (video) upstream.append('video', video);
|
||||||
|
if (brewingVideo) upstream.append('brewing_video', brewingVideo);
|
||||||
|
if (brewingTxt) upstream.append('brewing_txt', brewingTxt);
|
||||||
|
if (brewingTxtEn) upstream.append('brewing_txt_en', brewingTxtEn);
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, { method: 'POST', body: upstream });
|
||||||
|
if (!response.ok) {
|
||||||
|
const e = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw error(response.status, e.detail || 'Update main-page video failed');
|
||||||
|
}
|
||||||
|
return json(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Video MainPage Update Proxy] Error:', err);
|
||||||
|
if (err && typeof err === 'object' && 'status' in err) throw err;
|
||||||
|
throw error(500, err instanceof Error ? err.message : 'Internal server error');
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue