diff --git a/bun.lock b/bun.lock
index b36186e..979b130 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,7 @@
"@dnd-kit/abstract": "^0.2.4",
"@dnd-kit/helpers": "^0.2.4",
"@tanstack/match-sorter-utils": "^8.19.4",
+ "@types/semver": "^7.7.1",
"@xterm/xterm": "^5.5.0",
"@yume-chan/adb": "^2.6.0",
"@yume-chan/adb-credential-web": "^2.1.0",
@@ -22,6 +23,7 @@
"firebase": "^12.14.0",
"idb": "^8.0.3",
"mode-watcher": "^1.1.0",
+ "semver": "^7.8.4",
"usb": "^2.17.0",
"uuid": "^13.0.0",
"xterm-addon-fit": "^0.8.0",
@@ -54,7 +56,7 @@
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"storybook": "^10.3.5",
- "svelte": "^5.55.2",
+ "svelte": "^5.56.3",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.4.6",
"svelte-sonner": "^1.1.0",
@@ -504,6 +506,8 @@
"@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/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=="],
- "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=="],
@@ -862,7 +866,7 @@
"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=="],
@@ -1040,9 +1044,15 @@
"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=="],
- "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=="],
diff --git a/bun.lockb b/bun.lockb
index 73c25fe..600a8f8 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 570a1ee..e0eb61d 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
"@dnd-kit-svelte/svelte": "0.1.3",
"@dnd-kit/abstract": "^0.2.4",
"@dnd-kit/helpers": "^0.2.4",
+ "@logtape/logtape": "^2.2.2",
"@tanstack/match-sorter-utils": "^8.19.4",
"@types/semver": "^7.7.1",
"@xterm/xterm": "^5.5.0",
@@ -77,6 +78,7 @@
"animejs": "^4.3.6",
"firebase": "^12.14.0",
"idb": "^8.0.3",
+ "logtape": "https://github.com/dahlia/logtape.git",
"mode-watcher": "^1.1.0",
"semver": "^7.8.4",
"usb": "^2.17.0",
diff --git a/src/lib/components/AnnouncementDialog.svelte b/src/lib/components/AnnouncementDialog.svelte
index fa67d85..a1ac50b 100644
--- a/src/lib/components/AnnouncementDialog.svelte
+++ b/src/lib/components/AnnouncementDialog.svelte
@@ -1,24 +1,47 @@
+
+{#if pending}
+
+
+
+
+
+
+ {pending.reason}
+
+
+ Press Reconnect to restore the connection.
+
+
+
+
+
+ Dismiss
+
+
+ Reconnect
+
+
+
+
+{/if}
diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte
index 8e71838..3149e4b 100644
--- a/src/lib/components/app-sidebar.svelte
+++ b/src/lib/components/app-sidebar.svelte
@@ -1,4 +1,5 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
Screen Mirror
+ {#if sessionCodec}
+
+ {sessionCodec}
+
+ {/if}
+ {#if sessionSize}
+
+ {sessionSize}
+
+ {/if}
+ {#if frameCount > 0}
+
+ {frameCount} frames
+
+ {/if}
+
+
+
+ Close
+
+
+
+
+
+ {#if loading && frameCount === 0 && !error}
+
+ {#each stepStates as step (step.key)}
+
+ {#if step.isCompleted}
+
+ {:else if step.isActive}
+
+ {:else}
+
+ {/if}
+
{step.label}
+ {#if step.isActive && stepDetail}
+
— {stepDetail}
+ {/if}
+
+ {/each}
+
+ {/if}
+ {#if error}
+
+ {:else if !loading && frameCount === 0 && sessionStarted && !stopping}
+
+ {/if}
+
+ {#if touchLocked && frameCount > 0}
+
+ {/if}
+
+
+
+
+
+
controlWriter?.injectKeyCode({ keyCode: 3, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Home">
+
+
+
controlWriter?.injectKeyCode({ keyCode: 67, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Back">
+
+
+
controlWriter?.injectKeyCode({ keyCode: 187, action: AndroidKeyEventAction.Down, repeat: 0, metaState: 0 })} disabled={!controlWriter} title="Recent Apps">
+
+
+
+
{ touchLocked = !touchLocked; }}
+ >
+ {#if touchLocked}
+
+
+
+
+
+
+
+ Unlock Touch
+ {:else}
+
+
+
+
+
+
+
+
+ Lock Touch
+ {/if}
+
+
+
+
+
+
+ {#each logEntries as entry (entry.id)}
+ {@const iconClass = entry.type === 'ok'
+ ? 'text-emerald-600 dark:text-emerald-400'
+ : entry.type === 'error'
+ ? 'text-red-600 dark:text-red-400'
+ : entry.type === 'warn'
+ ? 'text-yellow-600 dark:text-yellow-400'
+ : 'text-muted-foreground'}
+ {@const msgClass = entry.type === 'ok'
+ ? 'text-emerald-700 dark:text-emerald-300'
+ : entry.type === 'error'
+ ? 'text-red-700 dark:text-red-300'
+ : entry.type === 'warn'
+ ? 'text-yellow-700 dark:text-yellow-300'
+ : 'text-foreground'}
+
+ {entry.time}
+
+ {entry.type === 'ok' ? '✓' : entry.type === 'error' ? '✗' : entry.type === 'warn' ? '!' : '·'}
+
+
+ {entry.message}
+
+
+ {/each}
+ {#if logEntries.length === 0}
+
Waiting…
+ {/if}
+
+
+
+
+
+
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte
index b6f80ef..88f03b6 100644
--- a/src/lib/components/ui/button/button.svelte
+++ b/src/lib/components/ui/button/button.svelte
@@ -13,6 +13,8 @@
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
+ accept: "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-600 dark:hover:bg-emerald-500",
+ reject: "bg-red-600 text-white shadow-sm hover:bg-red-500 dark:bg-red-600 dark:hover:bg-red-500",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
diff --git a/src/lib/core/adb/adb.ts b/src/lib/core/adb/adb.ts
index 419a0ff..cdb93f9 100644
--- a/src/lib/core/adb/adb.ts
+++ b/src/lib/core/adb/adb.ts
@@ -13,15 +13,22 @@ import { AdbScrcpyClient } from '@yume-chan/adb-scrcpy';
import { addNotification } from '../stores/noti';
import { handleAdbPayload } from '../handlers/adbPayloadHandler';
import { GlobalEventBus } from '../utils/eventBus';
-import { adbWriter } from '../stores/adbWriter';
+import { adbWriter, sendToAndroid } from '../stores/adbWriter';
+import { adbReconnect } from '../stores/adbReconnectStore';
import { WritableStream } from '@yume-chan/stream-extra';
import { env } from '$env/dynamic/public';
import { get } from 'svelte/store';
import { adbConnectionStatus } from '../stores/adbConnectionStore';
+import { browser } from '$app/environment';
+import AdbWorker from '$lib/workers/androidPayload.worker?worker';
let _connectionPromise: Promise | null = null;
let syncConnection: any = null;
+let worker: Worker | undefined;
+
+let healthCheckRoutine: any;
+
/**
* Centralized connection function that all pages and components should use.
*
@@ -192,7 +199,7 @@ async function connectWithRetry(
export async function connnectViaWebUSB(connectAndroidServer = true) {
adbConnectionStatus.setConnecting();
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) {
logger.info('connect ', device.name);
@@ -215,11 +222,9 @@ export async function connnectViaWebUSB(connectAndroidServer = true) {
// save device info
await deviceCredentialManager.saveDeviceInfo(device);
} catch (e: any) {
- logger.error('error on connect', e);
-
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
- 'ERR:Device is already in use by another program, please close the program and try again'
+ 'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
);
}
@@ -296,7 +301,7 @@ export async function connectRecipeMenuViaWebUSB() {
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
- 'ERR:Device is already in use by another program, please close the program and try again'
+ 'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
);
}
@@ -336,6 +341,7 @@ export async function connectRecipeMenuDeviceByCred(
}
export async function reconnectAndroidRecipeMenuServer() {
+ adbWriter.set(null);
await connectToAndroidRecipeMenuServer(true);
}
@@ -392,8 +398,8 @@ function sanitizeAdbCommand(command: string): string {
return command;
}
-export async function executeCmd(command: string) {
- command = sanitizeAdbCommand(command);
+export async function executeCmd(command: string, block: boolean = false) {
+ command = block ? sanitizeAdbCommand(command) : command;
let instance = getAdbInstance();
if (!instance) {
@@ -441,6 +447,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.
* Used for commands like `logcat` that run indefinitely.
@@ -652,6 +667,9 @@ export async function pushBinary(
// NOTE: adb reverse is not work by unavailable features support
export async function reconnectAndroidServer() {
+ // Clear any stale writer before reconnecting so the new stream
+ // doesn't conflict with a dead one still referenced in the store.
+ adbWriter.set(null);
await connectToAndroidServer();
}
@@ -678,13 +696,28 @@ async function connectToAndroidServer(maxRetries = 5) {
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
- // logger.info('checking on writer ', writer);
+ if (browser) {
+ worker = new AdbWorker();
+
+ worker.onmessage = (e) => {
+ if (e.data.type === 'DATA') {
+ const raw = e.data.payload;
+ handleAdbPayload(new TextDecoder().decode(raw));
+ }
+ };
+ }
adbWriter.set(writer);
+ if (healthCheckRoutine) {
+ clearInterval(healthCheckRoutine);
+ healthCheckRoutine = null;
+ }
+
if (writer) {
addNotification('INFO:Enable Brewing Mode T on machine');
- const textDecoder = new TextDecoder();
- let buffer = '';
+ healthCheckRoutine = setInterval(() => {
+ sendToAndroid({ type: 'status', payload: {} });
+ }, 1000);
(async () => {
try {
@@ -692,38 +725,28 @@ async function connectToAndroidServer(maxRetries = 5) {
const { value, done } = await reader.read();
if (done) break;
- // decode chunk
- buffer += textDecoder.decode(value, { stream: true });
-
- let lines = buffer.split('\n');
-
- // save potential incomplete
- buffer = lines.pop() ?? '';
-
- for (const line of lines) {
- if (line.trim() === '') continue;
-
- GlobalEventBus.emit('adb:raw-payload', line);
- handleAdbPayload(line);
- }
-
- // GlobalEventBus.emit('adb:raw-payload', new TextDecoder().decode(value));
- // handleAdbPayload(new TextDecoder().decode(value));
+ // Transfer ONLY the buffer, not the stream
+ // Type assertion (as any) fixes the overload error
+ (worker as any).postMessage({ type: 'CHUNK', payload: value }, [value.buffer]);
}
} catch (e) {
- logger.error('read error', e);
- if (isRecoverableError(e)) {
- void connectToAndroidServer();
+ logger.error('Android server read error', e);
+ } finally {
+ adbWriter.set(null);
+
+ if (healthCheckRoutine) {
+ clearInterval(healthCheckRoutine);
+ healthCheckRoutine = null;
}
- } finally {
- adbWriter.set(null);
- addNotification('WARN:Brewing Mode T Offline ...');
- reader.cancel().catch(() => {});
- }
- })();
- return;
- } else {
- addNotification('WARN:Brewing Mode T unavailable');
+ addNotification('WARN:Android server channel offline ...');
+ reader.cancel().catch(() => {});
+ // Prompt the user to reconnect instead of auto-reconnecting
+ adbReconnect.requestReconnect('Android server disconnected');
+ }
+ })();
+ return;
+ } else {
+ addNotification('WARN:Brewing Mode T unavailable');
if (attempt < maxRetries - 1) {
const delay = Math.min(500 * Math.pow(2, attempt) + Math.random() * 500, 5000);
@@ -788,6 +811,9 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
adbWriter.set(writer);
if (writer) {
addNotification('INFO:Enable Android recipe menu channel');
+ healthCheckRoutine = setInterval(() => {
+ sendToAndroid({ type: 'status', payload: {} });
+ }, 1000);
} else {
addNotification('WARN:Android recipe menu channel unavailable');
@@ -830,20 +856,23 @@ async function connectToAndroidRecipeMenuServerOnce(notifyFailure = true, retryO
logger.error('recipe menu read error', e);
} finally {
adbWriter.set(null);
+
+ if (healthCheckRoutine) {
+ clearInterval(healthCheckRoutine);
+ healthCheckRoutine = null;
+ }
addNotification('WARN:Android recipe menu channel offline ...');
reader.cancel().catch(() => {});
- if (retryOnFailure) {
- scheduleRecipeMenuAndroidServerReconnect();
- }
+ // Prompt the user to reconnect instead of auto-scheduling
+ adbReconnect.requestReconnect('Android recipe menu disconnected');
}
})();
} catch (err) {
logger.error('Recipe menu connection failed. Suspect java running or not', err);
adbWriter.set(null);
if (notifyFailure) addNotification('ERR:Fail to enable Android recipe menu channel');
- if (retryOnFailure) {
- scheduleRecipeMenuAndroidServerReconnect();
- }
+ // Prompt the user to reconnect instead of auto-scheduling
+ adbReconnect.requestReconnect('Android recipe menu connection failed');
}
}
diff --git a/src/lib/core/handlers/adbPayloadHandler.ts b/src/lib/core/handlers/adbPayloadHandler.ts
index f20b6e9..a924cee 100644
--- a/src/lib/core/handlers/adbPayloadHandler.ts
+++ b/src/lib/core/handlers/adbPayloadHandler.ts
@@ -19,6 +19,9 @@ type AdbPayload = { type: string; payload: any };
let queuedPromises = new Array>();
+// 10 secs
+const SLOW_PAYLOAD_RESPONSE_MS = 10000;
+
async function handleAdbPayload(raw_payload: string) {
// logger.info('[ADB] Received payload:', raw_payload.slice(0, 300));
const APP_VERSION = env.PUBLIC_APP_SEMVER;
@@ -156,6 +159,22 @@ async function handleAdbPayload(raw_payload: string) {
// show error to user from brew app
addNotification(`ERR:${payload.payload}`);
// send message to server if needed
+ break;
+ case 'status':
+ let current_ts = Date.now();
+ let source_ts = payload.payload?.timestamp ?? current_ts;
+
+ let diff_ts = current_ts - source_ts;
+
+ if (diff_ts == 0) {
+ // no source
+ addNotification('WARN:Unknown time detected in status check.');
+ } else if (diff_ts > 0 && diff_ts < SLOW_PAYLOAD_RESPONSE_MS) {
+ // is acceptable
+ } else {
+ addNotification('WARN:Slow adb response detected');
+ }
+
break;
case 'recipe-export':
if (payload.payload?.content) {
@@ -347,8 +366,73 @@ async function handleAdbPayload(raw_payload: string) {
recipeFromMachine.set(recipeRawFromMachine);
} else {
+ if (sub_type == null) {
+ try {
+ let big_recipe = JSON.parse(JSON.stringify(payload.payload));
+ logger.info(`get large recipe keys: ${Object.keys(big_recipe)}`);
+
+ let recipes = JSON.parse(big_recipe?.recipes);
+ let materials = JSON.parse(big_recipe?.materials);
+ let toppings = JSON.parse(big_recipe?.toppings);
+
+ let updated = {
+ Recipe01: recipes,
+ MaterialSetting: materials,
+ Topping: toppings
+ };
+
+ recipeFromMachine.set(updated);
+
+ // =====================================
+ //
+ // Build Overview
+ //
+
+ let queries = [];
+ for (let rp of recipes) {
+ queries.push({
+ productCode: rp.productCode ?? '',
+ name: rp.name ? rp.name : (rp.otherName ?? ''),
+ description: rp.description
+ ? rp.description
+ : (rp.otherDescription ?? ''),
+ tags: buildTags(rp),
+ status: getMenuStatus(rp.MenuStatus)
+ });
+ }
+
+ let cached_query_from_machine = get(recipeFromMachineQuery);
+
+ cached_query_from_machine = {
+ ...cached_query_from_machine,
+ recipe: queries
+ };
+
+ recipeFromMachineQuery.set(cached_query_from_machine);
+
+ // =====================================
+ //
+ // Send finish signal
+
+ GlobalEventBus.emitUntilConsumed('recipe-event', {
+ type: 'load-recipe',
+ status: 'end',
+ reload: true
+ });
+ } catch (e) {
+ logger.error('some promise failed: ', e);
+
+ GlobalEventBus.emitUntilConsumed('recipe-event', {
+ type: 'load-recipe-fail',
+ status: 'end',
+ reload: true
+ });
+ }
+ } else {
+ logger.info(`unhandled sub type: %${sub_type}%`);
+ }
+
// unhandled sub type
- logger.info('unhandled sub type', payload);
}
break;
diff --git a/src/lib/core/handlers/messageHandler.ts b/src/lib/core/handlers/messageHandler.ts
index 5b3f579..1bd2932 100644
--- a/src/lib/core/handlers/messageHandler.ts
+++ b/src/lib/core/handlers/messageHandler.ts
@@ -22,11 +22,14 @@ import {
handleSheetStreamEnd,
handleSheetStreamError,
handleCatalogsResponse,
+ handlePriceSlotsResponse,
+ isPriceSlotsPayload,
handleListMenuResponse,
sheetCatalogsLoading,
handleRawStreamHeader,
handleRawStreamChunk,
- handleRawStreamEnd
+ handleRawStreamEnd,
+ handleSheetPriceResponse
} from '../stores/sheetStore';
import {
handleGenLayoutBatchStart,
@@ -49,10 +52,10 @@ import { auth as authStore } from '../stores/auth';
import { v4 as uuidv4 } from 'uuid';
import { handleSheetResponseFromNoti } from './sheetNotiHandler';
import { env } from '$env/dynamic/public';
-import * as semver from 'semver';
import { WebCryptoHelper } from '../utils/crypto';
import { GlobalEventBus } from '../utils/eventBus';
import { handleIncomingRemoteShell } from '../stores/remoteShellStore';
+import * as semver from 'semver';
export const messages = writable([]);
@@ -298,26 +301,76 @@ const handlers: Record void> = {
if (from === 'sheet-service' && level === 'content') {
const currentUid = auth.currentUser?.uid;
+ const content = p.content ?? p.value ?? p.payload;
+ const ref = p.ref ?? '';
- if (target && currentUid && target === currentUid) {
- if (!msg && p.content?.catalogs) {
- handleCatalogsResponse(p.content);
- addNotification(`INFO:Loaded ${p.content.catalogs?.length || 0} catalogs`);
+ logger.debug('[Sheet] Notify content received:', {
+ msg,
+ target,
+ 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;
}
// Handle streaming messages (with msg field)
switch (msg) {
+ case 'priceslot':
+ case 'price_slot':
+ handlePriceSlotsResponse(content);
+ addNotification('INFO:Loaded PriceSlot data');
+ break;
case 'start':
- handleSheetStreamStart(p);
- addNotification('INFO:Sheet data streaming started');
+ if (ref === 'price') {
+ addNotification('INFO:Sheet price streaming started');
+ } else {
+ handleSheetStreamStart(p);
+ addNotification('INFO:Sheet data streaming started');
+ }
break;
case 'chunk':
- handleSheetStreamChunk(p);
+ if (isPriceSlotsPayload(content)) {
+ handlePriceSlotsResponse(content);
+ } else if (ref === 'price') {
+ handleSheetPriceResponse(p.country ?? p.payload?.country ?? '', content);
+ } else {
+ handleSheetStreamChunk(p);
+ }
break;
case 'end':
- handleSheetStreamEnd(p);
- addNotification('INFO:Sheet data streaming complete');
+ if (ref === 'price') {
+ addNotification('INFO:Sheet price streaming complete');
+ } else {
+ handleSheetStreamEnd(p);
+ addNotification('INFO:Sheet data streaming complete');
+ }
break;
case 'error':
handleSheetStreamError(p);
@@ -325,8 +378,17 @@ const handlers: Record void> = {
break;
default:
// 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;
}
@@ -404,6 +466,7 @@ const handlers: Record void> = {
country: current_meta?.country ?? '',
content: saved_product_code_to_get_from_sheet,
param: 'price',
+ option: 'price',
stream: true,
request_id
});
@@ -466,7 +529,7 @@ const handlers: Record void> = {
heartbeat: (p) => {
socketConnectionOfflineCount.set(0);
socketAlreadySendHeartbeat.set(0);
- logger.info('heartbeat reset offline count');
+ // logger.info('heartbeat reset offline count');
},
// Raw stream handlers for sheet data (e.g., price)
raw_stream: (p) => {
@@ -481,14 +544,23 @@ const handlers: Record void> = {
// Header for price stream
handleRawStreamHeader('price', p);
},
+ raw_stream_priceslot: (p) => {
+ handleRawStreamHeader('priceslot', p);
+ },
raw_stream_chunk_price: (p) => {
// Chunk for price stream
handleRawStreamChunk('price', p);
},
+ raw_stream_chunk_priceslot: (p) => {
+ handleRawStreamChunk('priceslot', p);
+ },
raw_stream_end_price: (p) => {
// End for price stream
handleRawStreamEnd('price', p);
},
+ raw_stream_end_priceslot: (p) => {
+ handleRawStreamEnd('priceslot', p);
+ },
announce: (p) => {
// Server-pushed announcement (e.g., closing maintenance)
GlobalEventBus.emit('announce', p);
@@ -496,52 +568,78 @@ const handlers: Record void> = {
remote_shell: (p) => {
// Server requests command execution on connected Android device
handleIncomingRemoteShell(p);
+ },
+ firmware_confirm: (p) => {
+ // console.log(`received firmware confirmation: ${JSON.stringify(p)}`);
+ const items = Object.entries(p.history ?? {}).map(([filename, msg]) => ({
+ filename,
+ message: Object.entries(msg as any).map((x) => x[1])
+ }));
+ GlobalEventBus.emit('announce', {
+ title: 'Firmware Confirmation',
+ subtitle: `Please confirm this build contents. Session Ref: ${p.session}`,
+ message: items,
+ buttonText: 'Confirm',
+ type: 'info'
+ });
+ },
+ firmware_report: (p) => {
+ console.log(`received firmware report: ${JSON.stringify(p)}`);
+ },
+ firmware_versions: (p) => {
+ GlobalEventBus.emit('firmware-versions', p.versions ?? {});
}
};
-export async function handleIncomingMessages(raw: string, clientPrivateKey: CryptoKey) {
+export async function handleIncomingMessages(raw: string, clientPrivateKey?: CryptoKey) {
const APP_VERSION = env.PUBLIC_APP_SEMVER;
+ const parsedMessage = JSON.parse(raw);
- const ack: HandshakeAck = JSON.parse(raw);
- // logger.info(`[WS MSG] type=${msg.type}`, msg.payload);
+ const ack: HandshakeAck = parsedMessage;
if (ack != null && ack.status === 'authenticated') {
// has server response
+ if (!clientPrivateKey) return;
sharedKey.set(await WebCryptoHelper.deriveSharedKey(clientPrivateKey, ack.server_public_key));
- addNotification('INFO:Secured Connection');
+ // addNotification('INFO:Secured Connection');
return;
}
- if (semver.satisfies(APP_VERSION, '>=0.0.2')) {
+ if (semver.satisfies(APP_VERSION, '>=0.0.2') && parsedMessage.ciphertext && parsedMessage.iv) {
// secured message decryption
let sharedKeyStore = get(sharedKey);
if (sharedKeyStore) {
- let raw_payload = JSON.parse(raw);
let decrypted_string = await WebCryptoHelper.decryptMessage(
sharedKeyStore,
- raw_payload.ciphertext,
- raw_payload.iv
+ parsedMessage.ciphertext,
+ parsedMessage.iv
);
let actual_message: WSMessage = JSON.parse(decrypted_string);
+ // preprocess type
+ actual_message.type = actual_message.type.replace('-', '_');
+
handlers[actual_message.type]?.(actual_message.payload);
}
} else {
- const msg: WSMessage = JSON.parse(raw);
+ const msg: WSMessage = parsedMessage;
+ if (msg.type !== 'heartbeat') {
+ // logger.debug(`[WS MSG] type=${msg.type}`, msg.payload);
+ }
if (msg == null) {
// error response
addNotification('ERR:No response from server');
return;
}
- // raw streaming type
- // if (msg.type.startsWith('raw_stream')) {
- // // convert
- // let sub_type = msg.type.replace('raw_stream_', '');
- // msg.payload.sub_type = sub_type;
- // msg.type = 'raw_stream';
- // }
+ // raw streaming type
+ // if (msg.type.startsWith('raw_stream')) {
+ // // convert
+ // let sub_type = msg.type.replace('raw_stream_', '');
+ // msg.payload.sub_type = sub_type;
+ // msg.type = 'raw_stream';
+ // }
handlers[msg.type]?.(msg.payload);
}
diff --git a/src/lib/core/handlers/ws_messageSender.ts b/src/lib/core/handlers/ws_messageSender.ts
index 6b10414..43f7851 100644
--- a/src/lib/core/handlers/ws_messageSender.ts
+++ b/src/lib/core/handlers/ws_messageSender.ts
@@ -1,12 +1,12 @@
import { logger } from '$lib/core/utils/logger';
import { get, writable } from 'svelte/store';
import type { OutMessage } from '../types/outMessage';
-import { sharedKey, socketStore } from '../stores/websocketStore';
+import { sharedKey, socketStore, wsAuthReady } from '../stores/websocketStore';
import { addNotification } from '../stores/noti';
import { auth } from '../stores/auth';
import { WebCryptoHelper } from '../utils/crypto';
-import * as semver from 'semver';
import { env } from '$env/dynamic/public';
+import * as semver from 'semver';
export const queue = writable([]);
@@ -23,8 +23,40 @@ function getServiceName(cmdReq: CommandRequest) {
}
}
+function waitForWsAuthReady(timeoutMs = 10000): Promise {
+ if (get(wsAuthReady)) return Promise.resolve(true);
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let unsubscribe = () => {};
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ unsubscribe();
+ resolve(false);
+ }, timeoutMs);
+
+ unsubscribe = wsAuthReady.subscribe((ready) => {
+ if (!ready || settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ unsubscribe();
+ resolve(true);
+ });
+ });
+}
+
// Websocket message wrapper for commands like `sheet`, `command`
export async function sendCommandRequest(target: CommandRequest, values: any): Promise {
+ const authReady = await waitForWsAuthReady();
+ if (!authReady) {
+ console.warn('[WS Send] Skip command request because websocket auth is not ready', {
+ target,
+ param: values?.param
+ });
+ return false;
+ }
+
let srv_name = getServiceName(target);
let curr_user = get(auth);
@@ -85,6 +117,13 @@ export async function sendMessage(
data = JSON.stringify(await WebCryptoHelper.encryptMessage(sharedKeyRes, data));
}
+ // logger.debug('[WS Send]', {
+ // type: logMessage.type,
+ // service: logMessage.payload?.srv_name,
+ // param: logMessage.payload?.values?.param,
+ // bytes: data.length,
+ // secured: isSecuredAppVersion(APP_VERSION)
+ // });
socket.send(data);
return true;
}
diff --git a/src/lib/core/scrcpy/scrcpy-manager.ts b/src/lib/core/scrcpy/scrcpy-manager.ts
new file mode 100644
index 0000000..ea75aa8
--- /dev/null
+++ b/src/lib/core/scrcpy/scrcpy-manager.ts
@@ -0,0 +1,178 @@
+/**
+ * ScrcpyManager — Lifecycle manager for @yume-chan/adb-scrcpy screen mirroring.
+ *
+ * Handles:
+ * - Fetching the scrcpy-server binary (cached after first download)
+ * - Pushing the binary to the Android device
+ * - Starting a scrcpy session (video + optional control)
+ * - Exposing the AdbScrcpyClient for the dialog to consume the video stream
+ * - Clean teardown on close
+ *
+ * IMPORTANT: This manager does NOT consume the video stream. The dialog is
+ * responsible for reading packets from client.videoStream.stream.
+ */
+
+import { AdbScrcpyClient, AdbScrcpyOptions2_3 } from '@yume-chan/adb-scrcpy';
+import { ReadableStream } from '@yume-chan/stream-extra';
+import { AdbInstance } from '../../../routes/state.svelte';
+import { logger } from '../utils/logger';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type ScrcpyStatusStep = 'fetching' | 'pushing' | 'starting' | 'ready' | 'error';
+
+export interface ScrcpyStartOptions {
+ /** Max video width (0 = no limit). Default 1024. */
+ maxSize?: number;
+ /** Video bitrate in bits/s. Default 8 000 000. */
+ videoBitRate?: number;
+ /** Video codec. Default "h264". */
+ videoCodec?: 'h264' | 'h265' | 'av1';
+ /** Max FPS (0 = no limit). Default 0. */
+ maxFps?: number;
+ /** Enable touch / key control. Default true. */
+ control?: boolean;
+ /** Push a specific server version tag. */
+ serverVersion?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/** Path where the scrcpy-server binary lives on the Android device. */
+const SCRCPY_SERVER_DEVICE_PATH = '/data/local/tmp/scrcpy-server.jar';
+
+// ---------------------------------------------------------------------------
+// Binary cache (module-level — persists across session starts)
+// ---------------------------------------------------------------------------
+
+let cachedBinaryData: Uint8Array | null = null;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function createOptions(init: ScrcpyStartOptions) {
+ return new AdbScrcpyOptions2_3({
+ tunnelForward: true,
+ video: true,
+ audio: false,
+ control: init.control ?? true,
+ maxSize: init.maxSize ?? 1024,
+ videoCodec: init.videoCodec ?? 'h264',
+ videoBitRate: init.videoBitRate ?? 8_000_000,
+ maxFps: init.maxFps ?? 0,
+ sendFrameMeta: true
+ });
+}
+
+/**
+ * Fetch the scrcpy-server binary. Uses the local SvelteKit endpoint
+ * (/api/scrcpy-server) to avoid CORS issues with GitHub.
+ * Binary data is cached in memory after the first fetch.
+ */
+async function fetchServerBinary(): Promise> {
+ if (cachedBinaryData) {
+ logger.info('[ScrcpyManager] Using cached scrcpy-server binary');
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(cachedBinaryData!);
+ controller.close();
+ }
+ });
+ }
+
+ logger.info('[ScrcpyManager] Fetching scrcpy-server from /api/scrcpy-server…');
+ const response = await fetch('/api/scrcpy-server');
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch scrcpy-server: ${response.status} ${response.statusText}`);
+ }
+
+ const buffer = await response.arrayBuffer();
+ cachedBinaryData = new Uint8Array(buffer);
+ logger.info(`[ScrcpyManager] Binary cached (${cachedBinaryData.length} bytes)`);
+
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue(cachedBinaryData!);
+ controller.close();
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// ScrcpyManager class
+// ---------------------------------------------------------------------------
+
+class ScrcpyManager {
+ private _client: AdbScrcpyClient> | null = null;
+
+ get client(): AdbScrcpyClient> | null {
+ return this._client;
+ }
+
+ get isActive(): boolean {
+ return this._client !== null;
+ }
+
+ /**
+ * Start a scrcpy screen-mirroring session.
+ *
+ * @param opts Configuration overrides (bitrate, codec, maxSize, …).
+ * @param onStatus Optional callback for step-by-step progress reporting.
+ * @returns The AdbScrcpyClient instance for the active session.
+ */
+ async start(
+ opts: ScrcpyStartOptions = {},
+ onStatus?: (step: ScrcpyStatusStep, detail?: string) => void
+ ): Promise>> {
+ // Clean up any existing session first.
+ if (this._client) {
+ await this.close();
+ }
+
+ const adb = AdbInstance.instance;
+ if (!adb) {
+ throw new Error('No ADB device connected');
+ }
+
+ // 1. Fetch the scrcpy-server binary (cached after first download).
+ onStatus?.('fetching', cachedBinaryData ? 'Using cached binary' : 'Downloading scrcpy-server…');
+ const serverBinary = await fetchServerBinary();
+
+ // 2. Push the binary to the device.
+ onStatus?.('pushing', 'Pushing to device…');
+ await AdbScrcpyClient.pushServer(adb, serverBinary, SCRCPY_SERVER_DEVICE_PATH);
+ logger.info('[ScrcpyManager] Server binary pushed.');
+
+ // 3. Start the scrcpy session.
+ onStatus?.('starting', 'Starting session…');
+ const options = createOptions(opts);
+ const client = await AdbScrcpyClient.start(adb, SCRCPY_SERVER_DEVICE_PATH, options);
+ logger.info('[ScrcpyManager] Session started.');
+
+ this._client = client;
+ onStatus?.('ready', 'Connected');
+ return client;
+ }
+
+ /** Tear down the current session. */
+ async close(): Promise {
+ try {
+ if (this._client) {
+ await this._client.close();
+ }
+ } catch (e) {
+ logger.warn('[ScrcpyManager] Error closing session', e);
+ } finally {
+ this._client = null;
+ }
+ }
+}
+
+/** Singleton instance — import and use anywhere. */
+export const scrcpyManager = new ScrcpyManager();
diff --git a/src/lib/core/services/sheetService.ts b/src/lib/core/services/sheetService.ts
index 6c114f5..2034fb7 100644
--- a/src/lib/core/services/sheetService.ts
+++ b/src/lib/core/services/sheetService.ts
@@ -8,10 +8,18 @@ import {
markSheetPriceAsSent,
sheetPriceLoading,
streamingRawData,
- setPendingProductCodesCountry
+ setPendingProductCodesCountry,
+ setPendingPriceSlotsCountry,
+ priceSlotsLoading,
+ resetPriceSlotsCountry
} from '../stores/sheetStore';
+import type { PriceSlot } from '../stores/sheetStore';
import { setGenLayoutGenerating } from '../stores/genLayoutStore';
+type SheetCellUpdate = { value: string; coord: { row: number; col: number } };
+type SheetRowUpdate = { row_index: number; cells: SheetCellUpdate[] };
+type SheetRowCreate = { header?: string[]; cells: string[] };
+
export async function requestCatalogs(country: string): Promise {
return await sendCommandRequest('sheet', {
country: country,
@@ -19,27 +27,179 @@ export async function requestCatalogs(country: string): Promise {
});
}
-export async function requestPriceSlots(country: string): Promise {
+/**
+ * Register a newly created catalog as a Grist table so it shows in the overview
+ * and menus can be added to it. `catalog` is the .skt filename produced by
+ * /api/catalog-create (e.g. "page_catalog_group_pro_summer_splash.skt").
+ */
+export async function addCatalog(
+ country: string,
+ catalogName: string,
+ catalog: string
+): Promise {
return await sendCommandRequest('sheet', {
country: country,
- param: 'priceslot'
+ catalog: catalog,
+ catalog_name: catalogName,
+ param: 'add/catalog'
});
}
+export async function requestPriceSlots(country: string): Promise {
+ setPendingPriceSlotsCountry(country);
+ resetPriceSlotsCountry(country);
+ return requestPriceSlotOption(country, 'PriceSlot');
+}
+
+export async function requestPriceSlot(country: string, slotNumber: number): Promise {
+ setPendingPriceSlotsCountry(country);
+ return requestPriceSlotOption(country, `PriceSlot${slotNumber}`);
+}
+
+async function requestPriceSlotOption(country: string, option: string): Promise {
+ const request_id = crypto.randomUUID();
+
+ streamingRawData.update((data) => ({
+ ...data,
+ priceslot: {
+ request_id,
+ country,
+ chunks: [],
+ rawParts: []
+ }
+ }));
+ priceSlotsLoading.set(true);
+
+ const values = {
+ country: country,
+ param: 'price',
+ option,
+ stream: true,
+ request_id
+ };
+ logger.debug('[sheetService] Sending PriceSlot request:', values);
+ const sent = await sendCommandRequest('sheet', values);
+ logger.debug('[sheetService] PriceSlot request sent:', sent);
+ if (!sent) {
+ priceSlotsLoading.set(false);
+ }
+ return sent;
+}
+
+export async function refreshPriceSlotList(country: string): Promise {
+ return requestPriceSlotOption(country, 'PriceSlot');
+}
+
export async function updatePriceSlot(
country: string,
- content: {
- slot: number;
- name: string;
- description: string;
- products: { product_code: string; price: number | null; row_index?: number }[];
- }
+ slot: PriceSlot,
+ content: SheetRowUpdate[]
): Promise {
- return await sendCommandRequest('sheet', {
+ // logger.debug('[sheetService] Sending PriceSlot update:', {
+ // country,
+ // slot: slot.slot,
+ // name: slot.name,
+ // description: slot.description,
+ // kind: slot.kind,
+ // rows: content.length,
+ // param: 'update/price',
+ // option: `PriceSlot${slot.slot}`
+ // });
+
+ const sent = await sendCommandRequest('sheet', {
country: country,
content: content,
- param: 'update/priceslot'
+ param: 'update/price',
+ option: `PriceSlot${slot.slot}`
});
+
+ logger.debug('[sheetService] PriceSlot update sent:', {
+ country,
+ slot: slot.slot,
+ sent
+ });
+
+ return sent;
+}
+
+export async function addPriceSlot(
+ country: string,
+ slot: PriceSlot,
+ content: SheetRowCreate[]
+): Promise {
+ logger.debug('[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}`
+ });
+
+ logger.debug('[sheetService] PriceSlot create sent:', {
+ country,
+ slot: slot.slot,
+ sent
+ });
+
+ return sent;
+}
+
+export async function addPriceSlotRows(
+ country: string,
+ slot: PriceSlot,
+ content: SheetRowCreate[]
+): Promise {
+ if (!content || content.length === 0) return true;
+
+ const sent = await sendCommandRequest('sheet', {
+ country: country,
+ content: content,
+ param: 'add/price',
+ option: `PriceSlot${slot.slot}`
+ });
+
+ logger.debug('[sheetService] PriceSlot rows add sent:', {
+ country,
+ slot: slot.slot,
+ rows: content.length,
+ sent
+ });
+
+ return sent;
+}
+
+export async function deletePriceSlotRows(
+ country: string,
+ slot: PriceSlot,
+ rowIds: number[]
+): Promise {
+ if (!rowIds || rowIds.length === 0) return true;
+
+ const sent = await sendCommandRequest('sheet', {
+ country: country,
+ content: rowIds.map((target_id) => ({ target_id })),
+ param: 'delete/price',
+ option: `PriceSlot${slot.slot}`
+ });
+
+ logger.debug('[sheetService] PriceSlot rows delete sent:', {
+ country,
+ slot: slot.slot,
+ rows: rowIds.length,
+ sent
+ });
+
+ return sent;
}
export async function enterRoom(country: string, catalog: string): Promise {
@@ -188,9 +348,13 @@ export async function requestGenLayout(country: string): Promise {
* Request price data from sheet for specific product codes
* NOTE: Can only send once per type (price). Use hasSheetPriceBeenSent to check.
*/
-export async function requestSheetPrice(country: string, productCodes: string[]): Promise {
+export async function requestSheetPrice(
+ country: string,
+ productCodes: string[],
+ force = false
+): Promise {
// Check if already sent
- if (hasSheetPriceBeenSent('price')) {
+ if (!force && hasSheetPriceBeenSent('price')) {
logger.warn('[sheetService] Price request already sent, skipping');
return false;
}
@@ -232,6 +396,48 @@ export async function requestSheetPrice(country: string, productCodes: string[])
country: country,
content: content,
param: 'price',
+ option: 'price',
+ stream: true,
+ request_id
+ });
+ logger.debug('[sheetService] Sheet price request sent:', { country, request_id, sent });
+
+ if (sent) {
+ markSheetPriceAsSent('price');
+ } else {
+ sheetPriceLoading.set(false);
+ }
+
+ return sent;
+}
+
+export async function requestAllSheetPrice(country: string, force = false): Promise {
+ if (!force && hasSheetPriceBeenSent('price')) {
+ console.warn('[sheetService] Price request already sent, skipping');
+ return false;
+ }
+
+ const request_id = crypto.randomUUID();
+
+ streamingRawData.update((data) => ({
+ ...data,
+ price: {
+ request_id,
+ country,
+ chunks: [],
+ rawParts: []
+ }
+ }));
+
+ sheetPriceLoading.set(true);
+
+ logger.debug('[sheetService] Sending all sheet price request:', { country, request_id });
+
+ const sent = await sendCommandRequest('sheet', {
+ country,
+ content: [],
+ param: 'price',
+ option: 'price',
stream: true,
request_id
});
diff --git a/src/lib/core/stores/adbReconnectStore.ts b/src/lib/core/stores/adbReconnectStore.ts
new file mode 100644
index 0000000..6f32109
--- /dev/null
+++ b/src/lib/core/stores/adbReconnectStore.ts
@@ -0,0 +1,68 @@
+/**
+ * ADB reconnection prompt store.
+ *
+ * When the Android server socket dies, instead of silently reconnecting
+ * in the background, we surface a persistent banner asking the user to
+ * confirm the reconnection. This gives the user control over when the
+ * USB/TCP handshake happens (it can be disruptive) while still making
+ * it obvious that the connection is down.
+ *
+ * Flow:
+ * 1. Stream dies or write fails → `requestReconnect('reason')`
+ * 2. Banner appears on every authed page
+ * 3. User taps "Reconnect" → `confirmReconnect()` fires the
+ * stored callback, then clears the pending state.
+ * 4. User dismisses → `dismissReconnect()` clears the state
+ * without reconnecting.
+ */
+import { writable, get } from 'svelte/store';
+
+export interface ReconnectRequest {
+ /** Human-readable reason (shown in the banner). */
+ reason: string;
+ /** Timestamp when the reconnect was requested. */
+ requestedAt: Date;
+}
+
+function createAdbReconnectStore() {
+ const { subscribe, set } = writable(null);
+
+ return {
+ subscribe,
+
+ /**
+ * Signal that the ADB socket has died and a reconnection is needed.
+ * This shows the banner — the user must confirm before we actually
+ * reconnect.
+ */
+ requestReconnect(reason: string = 'Android socket disconnected') {
+ // Don't overwrite if a request is already pending
+ if (get({ subscribe }) !== null) return;
+ set({ reason, requestedAt: new Date() });
+ },
+
+ /**
+ * The user confirmed they want to reconnect.
+ * Callers should perform the reconnection after calling this.
+ */
+ confirmReconnect() {
+ set(null);
+ },
+
+ /**
+ * The user dismissed the banner without reconnecting.
+ */
+ dismissReconnect() {
+ set(null);
+ },
+
+ /**
+ * Returns whether a reconnection is currently pending.
+ */
+ isPending(): boolean {
+ return get({ subscribe }) !== null;
+ }
+ };
+}
+
+export const adbReconnect = createAdbReconnectStore();
diff --git a/src/lib/core/stores/adbWriter.ts b/src/lib/core/stores/adbWriter.ts
index 2575c7c..cadb417 100644
--- a/src/lib/core/stores/adbWriter.ts
+++ b/src/lib/core/stores/adbWriter.ts
@@ -1,12 +1,27 @@
import { logger } from '$lib/core/utils/logger';
import { get, writable } from 'svelte/store';
import { addNotification } from './noti';
+import { adbReconnect } from './adbReconnectStore';
const adbWriter: any = writable(null);
+/**
+ * Tracks whether the Android socket was ever alive in this session.
+ * Used to distinguish:
+ * - First visit (never connected) → auto-connect silently
+ * - Reconnection (was alive, now dead) → show reconnect banner
+ */
+let _wasEverAlive = false;
+
+// Subscribe to writer changes to track liveness
+adbWriter.subscribe((writer: any) => {
+ if (writer && writer.desiredSize !== null && writer.desiredSize !== undefined) {
+ _wasEverAlive = true;
+ }
+});
+
async function sendToAndroid(message: any) {
let writer: any = get(adbWriter);
- // logger.info('writer', writer);
if (!writer) {
addNotification('ERR:No active Android connection');
return false;
@@ -15,26 +30,65 @@ async function sendToAndroid(message: any) {
const encoder = new TextEncoder();
const serializedMessage = JSON.stringify(message);
await writer.write(encoder.encode(serializedMessage + '\n'));
- // logger.info('[ADB] sent', {
- // type: message?.type,
- // bytes: serializedMessage.length,
- // productCode: message?.payload?.data?.productCode,
- // batchCount: Array.isArray(message?.payload?.data) ? message.payload.data.length : undefined,
- // batchProductCodes: Array.isArray(message?.payload?.data)
- // ? message.payload.data.map((menu: any) => menu?.productCode)
- // : undefined
- // });
return true;
} catch (error) {
logger.error('write failed', error);
+ // The underlying stream is dead — clear the writer so
+ // callers don't keep trying to use a stale reference.
+ adbWriter.set(null);
addNotification('ERR:Failed to send message to Android');
+ // Prompt the user to reconnect (this was alive before → reconnection)
+ adbReconnect.requestReconnect('Android socket write failed');
return false;
}
}
-// helper function for checking if connection is ok
+/**
+ * Check whether the ADB writer store currently holds a reference.
+ *
+ * ⚠ This only checks if a writer object is present — it does NOT
+ * guarantee the underlying stream is alive. Use {@link isAdbWriterAlive}
+ * in page code to verify + reconnect if needed.
+ */
function isAdbWriterAvailable() {
return get(adbWriter) != null;
}
-export { sendToAndroid, adbWriter, isAdbWriterAvailable };
+/**
+ * Attempt a lightweight probe to check if the writer's stream is still
+ * writable. Returns `true` if the writer appears alive, `false` if
+ * the stream has been closed or the writer is stale.
+ *
+ * The probe checks `writer.desiredSize` — a standard
+ * `WritableStreamDefaultWriter` property that returns `null` once
+ * the stream has been closed or errored.
+ *
+ * If a stale writer is detected (was alive, now dead), the reconnect
+ * banner is triggered.
+ */
+function isAdbWriterAlive(): boolean {
+ const writer: any = get(adbWriter);
+ if (!writer) return false;
+
+ // WritableStreamDefaultWriter.desiredSize is null after close/error
+ if (writer.desiredSize === null) {
+ adbWriter.set(null);
+ adbReconnect.requestReconnect('Android socket is stale');
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Returns `true` if the Android socket was ever successfully connected
+ * in this browser session.
+ *
+ * Used to decide between auto-connect (first visit) and prompted
+ * reconnect (socket died after being alive).
+ */
+function wasAndroidSocketEverAlive(): boolean {
+ return _wasEverAlive;
+}
+
+export { sendToAndroid, adbWriter, isAdbWriterAvailable, isAdbWriterAlive, wasAndroidSocketEverAlive };
diff --git a/src/lib/core/stores/firmwareStore.ts b/src/lib/core/stores/firmwareStore.ts
new file mode 100644
index 0000000..76ada4e
--- /dev/null
+++ b/src/lib/core/stores/firmwareStore.ts
@@ -0,0 +1,287 @@
+/**
+ * Firmware request store — manages per-country firmware build requests
+ * and available firmware files.
+ *
+ * The data structure is designed to be wired up to a backend API later.
+ * For now it uses a writable store with mock data so the UI can be
+ * developed and tested independently.
+ */
+import { writable, derived, get } from 'svelte/store';
+import { sendMessage } from '../handlers/ws_messageSender';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type FirmwareBuildStatus = 'idle' | 'queued' | 'building' | 'ready' | 'failed';
+
+export interface FirmwareFile {
+ /** Unique identifier. */
+ id: string;
+ /** Filename e.g. `supra-v1.2.3-tha.bin` */
+ filename: string;
+ /** File size in bytes. */
+ size: number;
+ /** Build date ISO string. */
+ builtAt: string;
+ /** Firmware version string. */
+ version: string;
+ /** Download URL (populated when status === 'ready'). */
+ downloadUrl?: string;
+ /** Status of the build. */
+ status: FirmwareBuildStatus;
+ /** Optional status message / error detail. */
+ message?: string;
+}
+
+export interface FirmwareCountryState {
+ /** Country code (e.g. 'tha', 'sgp'). */
+ country: string;
+ /** Human-readable country label. */
+ label: string;
+ /** Current build status for this country. */
+ buildStatus: FirmwareBuildStatus;
+ /** List of firmware files for this country. */
+ files: FirmwareFile[];
+}
+
+export interface FirmwareBuildRequest {
+ country: string;
+ version: string;
+ notes?: string;
+ requester: string;
+ param?: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Country list (derived from the same codes used in productCode.ts)
+// ---------------------------------------------------------------------------
+
+export const FIRMWARE_COUNTRIES = [
+ { code: 'tha', label: 'Thailand' },
+ { code: 'mys', label: 'Malaysia' },
+ { code: 'idr', label: 'Indonesia' },
+ { code: 'aus', label: 'Australia' },
+ { code: 'sgp', label: 'Singapore' },
+ { code: 'dubai', label: 'UAE Dubai' },
+ { code: 'hkg', label: 'Hong Kong' },
+ { code: 'gbr', label: 'United Kingdom' },
+ { code: 'rou', label: 'Romania' },
+ { code: 'lva', label: 'Latvia' },
+ { code: 'est', label: 'Estonia' },
+ { code: 'ltu', label: 'Lithuania' }
+] as const;
+
+// ---------------------------------------------------------------------------
+// Store
+// ---------------------------------------------------------------------------
+
+function createFirmwareStore() {
+ const { subscribe, set, update } = writable>({});
+
+ // Initialise with empty state for each country
+ const initial: Record = {};
+ for (const c of FIRMWARE_COUNTRIES) {
+ initial[c.code] = {
+ country: c.code,
+ label: c.label,
+ buildStatus: 'idle',
+ files: []
+ };
+ }
+ set(initial);
+
+ return {
+ subscribe,
+ update,
+
+ /**
+ * Request a new firmware build for a country.
+ */
+ requestBuild(req: FirmwareBuildRequest) {
+ const countryState = get(firmwareStore)[req.country];
+ if (!countryState) return;
+ if (countryState.buildStatus === 'queued' || countryState.buildStatus === 'building') {
+ return; // Already in progress
+ }
+
+ // Set to queued
+ update((state) => ({
+ ...state,
+ [req.country]: {
+ ...state[req.country],
+ buildStatus: 'queued',
+ files: [
+ {
+ id: `${req.country}-${Date.now()}`,
+ filename: '',
+ size: 0,
+ builtAt: new Date().toISOString(),
+ version: 'unknown',
+ status: 'queued',
+ message: req.notes
+ },
+ ...state[req.country].files
+ ]
+ }
+ }));
+
+ // Simulate build lifecycle (replace with real WS call later)
+ // simulateBuild(req.country);
+
+ sendMessage({
+ type: 'firmware-request',
+ payload: {
+ mode: 'not_full-delay-build',
+ brand: req.country,
+ files: [],
+ param: (req.param as any[]) ?? [],
+ requester: req.requester
+ }
+ });
+ },
+
+ /**
+ * Refresh the file list for a country.
+ */
+ refreshFiles(country: string) {
+ // In production, fetch from server. No-op for now.
+ },
+
+ /**
+ * Reset the store (useful for testing).
+ */
+ reset() {
+ const fresh: Record = {};
+ for (const c of FIRMWARE_COUNTRIES) {
+ fresh[c.code] = {
+ country: c.code,
+ label: c.label,
+ buildStatus: 'idle',
+ files: []
+ };
+ }
+ set(fresh);
+ }
+ };
+}
+
+export const firmwareStore = createFirmwareStore();
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Simulate a build lifecycle: queued → building → ready.
+ * This is a placeholder until the real backend is wired up.
+ */
+function simulateBuild(country: string) {
+ // queued → building (1s)
+ setTimeout(() => {
+ firmwareStore.update((state) => ({
+ ...state,
+ [country]: {
+ ...state[country],
+ buildStatus: 'building',
+ files: state[country].files.map((f: FirmwareFile, i: number) =>
+ i === 0 ? { ...f, status: 'building' as FirmwareBuildStatus } : f
+ )
+ }
+ }));
+ }, 1000);
+
+ // building → ready (3s)
+ setTimeout(() => {
+ const mockSize = 2_400_000 + Math.floor(Math.random() * 800_000); // ~2.4–3.2 MB
+ firmwareStore.update((state: Record) => ({
+ ...state,
+ [country]: {
+ ...state[country],
+ buildStatus: 'ready',
+ files: state[country].files.map((f: FirmwareFile, i: number) =>
+ i === 0
+ ? {
+ ...f,
+ status: 'ready' as FirmwareBuildStatus,
+ size: mockSize,
+ builtAt: new Date().toISOString(),
+ downloadUrl: `#/download/${f.filename}`
+ }
+ : f
+ )
+ }
+ }));
+ }, 4000);
+}
+
+/**
+ * Format bytes into a human-readable string.
+ */
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '—';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ let i = 0;
+ let size = bytes;
+ while (size >= 1024 && i < units.length - 1) {
+ size /= 1024;
+ i++;
+ }
+ return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
+}
+
+/**
+ * Format an ISO date string into a short local date-time.
+ */
+export function formatBuildDate(iso: string): string {
+ try {
+ const d = new Date(iso);
+ return d.toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ } catch {
+ return iso;
+ }
+}
+
+/**
+ * Map a build status to a badge variant for the UI.
+ */
+export function statusBadgeVariant(
+ status: FirmwareBuildStatus
+): 'default' | 'secondary' | 'destructive' | 'outline' {
+ switch (status) {
+ case 'ready':
+ return 'default';
+ case 'building':
+ return 'secondary';
+ case 'failed':
+ return 'destructive';
+ case 'queued':
+ case 'idle':
+ default:
+ return 'outline';
+ }
+}
+
+/**
+ * Map a build status to a human-readable label.
+ */
+export function statusLabel(status: FirmwareBuildStatus): string {
+ switch (status) {
+ case 'idle':
+ return 'Idle';
+ case 'queued':
+ return 'Queued';
+ case 'building':
+ return 'Building';
+ case 'ready':
+ return 'Ready';
+ case 'failed':
+ return 'Failed';
+ }
+}
diff --git a/src/lib/core/stores/remoteShellStore.ts b/src/lib/core/stores/remoteShellStore.ts
index 2ec8c4d..57385d8 100644
--- a/src/lib/core/stores/remoteShellStore.ts
+++ b/src/lib/core/stores/remoteShellStore.ts
@@ -5,9 +5,21 @@ import { addNotification } from './noti';
// Commands that modify device state — always require explicit user confirmation.
const DANGEROUS_COMMAND_PREFIXES = [
- 'rm', 'reboot', 'reboot-bootloader', 'flash', 'format',
- 'mount', 'unmount', 'dd', 'mkfs', 'wipe',
- 'install', 'uninstall', 'pm ', 'am ', 'svc ',
+ 'rm',
+ 'reboot',
+ 'reboot-bootloader',
+ 'flash',
+ 'format',
+ 'mount',
+ 'unmount',
+ 'dd',
+ 'mkfs',
+ 'wipe',
+ 'install',
+ 'uninstall',
+ 'pm ',
+ 'am ',
+ 'svc '
];
function isDangerousCommand(command: string): boolean {
@@ -151,13 +163,15 @@ async function executeAndRespond(req: RemoteShellRequest) {
* Uses the simpler executeCmd() which properly captures exitCode and stderr.
*/
async function executeDirect(req: RemoteShellRequest) {
- const result = await executeCmd(req.commandInput);
+ const result = await executeCmd(req.commandInput, true);
const output = result.output ?? '';
const error = result.error ?? '';
const exitCode = result.exitCode ?? (error ? 1 : 0);
const success = exitCode === 0;
- addNotification(`${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}`);
+ addNotification(
+ `${success ? 'INFO' : 'ERR'}:Remote shell command ${success ? 'completed' : 'failed'}`
+ );
remoteShellRequest.set({
...req,
diff --git a/src/lib/core/stores/sheetStore.ts b/src/lib/core/stores/sheetStore.ts
index 88f930f..5f3c741 100644
--- a/src/lib/core/stores/sheetStore.ts
+++ b/src/lib/core/stores/sheetStore.ts
@@ -25,16 +25,304 @@ export interface PriceSlotProduct {
row_index?: number;
}
+export interface PriceSlotServiceRow {
+ row_index?: number;
+ cells: Record;
+}
+
export interface PriceSlot {
slot: number;
name: string;
description: string;
+ kind?: 'price' | 'service';
+ header?: string[];
products: PriceSlotProduct[];
+ serviceRows?: PriceSlotServiceRow[];
}
export const priceSlots = writable>({});
+export const priceSlotNamespaces = writable>({});
export const priceSlotsLoading = writable(false);
export const priceSlotsError = writable(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>((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) {
+ logger.debug('[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;
+ }
+
+ logger.debug('[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();
+ for (const slot of data[country] ?? []) {
+ merged.set(slot.slot, slot);
+ }
+ for (const slot of loadedSlots) {
+ merged.set(slot.slot, slot);
+ }
+
+ return {
+ ...data,
+ [country]: Array.from(merged.values()).sort((a, b) => a.slot - b.slot)
+ };
+ });
+ }
+ priceSlotNamespaces.update((data) => {
+ const merged = new Map();
+ for (const slot of data[country] ?? []) {
+ merged.set(slot.slot, slot);
+ }
+ 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 = {
THAI: 'Thai',
@@ -79,11 +367,19 @@ export function getCountryPrimaryLanguage(countryCode: string): string {
// Sheet column configuration by country for new_layout_v2
// Maps language keys to column indices and product code columns
-export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record;
- productCode: { hot: number; cold: number; blend: number };
- primaryLanguage: string;
-}> = {
+export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record<
+ string,
+ {
+ // Column→language map for the new-layout-v2 sheet (menu name/desc rows).
+ language: Record;
+ // Column→language map for the name-desc-v2 sheet (Translations). Different
+ // namespace/sheet so the columns can differ from new-layout-v2; falls back
+ // to `language` when not set (countries where the two are identical).
+ nameDescLanguage?: Record;
+ productCode: { hot: number; cold: number; blend: number };
+ primaryLanguage: string;
+ }
+> = {
tha: {
language: { en: 3, th: 4, zh: 5, my: 8 },
productCode: { hot: 9, cold: 10, blend: 11 },
@@ -91,6 +387,7 @@ export const SHEET_COLUMN_CONFIG_BY_COUNTRY: Record = {
+export const PRICE_HEADER_NAMES_BY_COUNTRY: Record<
+ string,
+ {
+ cash_price: string[]; // Possible header names for cash price
+ non_cash_price: string[]; // Possible header names for non-cash price
+ }
+> = {
tha: {
cash_price: ['Price'],
non_cash_price: ['MainPrice']
@@ -367,7 +672,7 @@ export const PRICE_HEADER_NAMES_BY_COUNTRY: Record h.toLowerCase() === name.toLowerCase());
+ const idx = headerArray.findIndex((h) => h.toLowerCase() === name.toLowerCase());
if (idx !== -1) {
// Return col index (header index + 1 because cells start from col 1)
return idx + 1;
@@ -383,7 +688,9 @@ export const lastRequestSheetPrice = writable>({});
// Store: sheetPriceAllRows[country][product_code] = array of {row, cells} (ALL rows for duplicates)
-export const sheetPriceAllRows = writable>>({});
+export const sheetPriceAllRows = writable<
+ Record>
+>({});
// Helper function to get price value from cells using dynamic header lookup
export function getPriceFromCells(
@@ -391,29 +698,43 @@ export function getPriceFromCells(
cells: GristCell[],
priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
): string | null {
+ const colIdx = getPriceColumnIndex(country, priceType);
+ if (colIdx < 0) return null;
+
+ // Find the cell with matching column index
+ const priceCell = cells.find((c) => c.coord?.col === colIdx);
+ return priceCell?.value ?? null;
+}
+
+export function getPriceColumnIndex(
+ country: string,
+ priceType: 'cash_price' | 'non_cash_price' = 'cash_price'
+): number {
const headers = get(sheetPriceHeader)[country];
if (!headers || headers.length === 0) {
logger.warn(`[getPriceFromCells] No header found for country: ${country}`);
- return null;
+ return -1;
}
// Get possible header names for this country
- const headerNames = PRICE_HEADER_NAMES_BY_COUNTRY[country] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
- const possibleNames = priceType === 'cash_price' ? headerNames.cash_price : headerNames.non_cash_price;
+ const headerNames =
+ 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
const colIdx = findHeaderIndex(headers, possibleNames);
//logger.info(`[getPriceFromCells] ${country} ${priceType}: colIdx=${colIdx}, headers=`, headers, 'possibleNames=', possibleNames);
if (colIdx < 0) {
- logger.warn(`[getPriceFromCells] No ${priceType} column found for ${country}, tried:`, possibleNames);
- return null;
+ logger.warn(
+ `[getPriceFromCells] No ${priceType} column found for ${country}, tried:`,
+ possibleNames
+ );
+ return -1;
}
- // Find the cell with matching column index
- const priceCell = cells.find((c) => c.coord?.col === colIdx);
- //logger.info(`[getPriceFromCells] Found cell for col ${colIdx}:`, priceCell);
- return priceCell?.value ?? null;
+ return colIdx;
}
// Store for tracking streaming state
@@ -445,15 +766,20 @@ export const streamingRawData = writable<
// Handler: raw_stream header (e.g., raw_stream_price)
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
- const currentData = get(streamingRawData);
- const existingData = currentData[subtype];
+ const existingData = currentData[targetSubtype];
streamingRawData.update((data) => ({
...data,
- [subtype]: {
+ [targetSubtype]: {
request_id: payload.request_id,
header: payload.header || payload.headers,
country: payload.country || existingData?.country || '',
@@ -462,7 +788,7 @@ export function handleRawStreamHeader(subtype: string, payload: any) {
}
}));
- if (subtype === 'price') {
+ if (targetSubtype === 'price') {
sheetPriceStreamMeta.set({
request_id: payload.request_id,
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)
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 streamData = currentData[subtype];
+ let targetSubtype = subtype;
+ if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
+ targetSubtype = 'priceslot';
+ }
+
+ logger.debug(
+ `[RawStream] Chunk ${payload.idx} for ${targetSubtype}, raw length:`,
+ payload.raw?.length
+ );
+
+ const streamData = currentData[targetSubtype];
if (!streamData || streamData.request_id !== payload.request_id) {
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
streamingRawData.update((data) => ({
...data,
- [subtype]: {
+ [targetSubtype]: {
...streamData,
country: payload.country || streamData.country,
rawParts: [...(streamData.rawParts || []), payload.raw]
@@ -505,25 +839,30 @@ export function handleRawStreamChunk(subtype: string, payload: any) {
streamingRawData.update((data) => ({
...data,
- [subtype]: {
+ [targetSubtype]: {
...streamData,
country: payload.country || streamData.country,
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)
export function handleRawStreamEnd(subtype: string, payload: any) {
- logger.info(`[RawStream] End payload for ${subtype}:`, payload);
-
const currentData = get(streamingRawData);
- const streamData = currentData[subtype];
+ let targetSubtype = subtype;
+ if (subtype === 'price' && currentData.priceslot?.request_id === payload.request_id) {
+ targetSubtype = 'priceslot';
+ }
+
+ logger.debug(`[RawStream] End payload for ${targetSubtype}:`, payload);
+
+ const streamData = currentData[targetSubtype];
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;
}
@@ -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') {
- processSheetPriceData(country, streamData.header || [], chunks);
- sheetPriceStreamMeta.update((meta) => (meta ? { ...meta, status: 'complete' } : null));
- sheetPriceLoading.set(false);
+ if (targetSubtype === 'priceslot' && isPriceSlotsPayload({ slots: chunks })) {
+ handlePriceSlotsResponse({ country, slots: chunks });
+ }
+ if (targetSubtype === 'priceslot') {
+ priceSlotsLoading.set(false);
+ }
+
+ if (targetSubtype === 'price') {
+ const looksLikePriceSlot = chunks.some((item) => {
+ return (
+ String(item?.sheet ?? item?.Sheet ?? '').startsWith('PriceSlot') ||
+ (Array.isArray(item?.sheet) &&
+ item.sheet.some((sheetName: any) => String(sheetName ?? '').startsWith('PriceSlot'))) ||
+ item?.option === 'PriceSlot' ||
+ item?.param === 'priceslot'
+ );
+ });
+
+ 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
streamingRawData.update((data) => {
const newData = { ...data };
- delete newData[subtype];
+ delete newData[targetSubtype];
return newData;
});
}
@@ -601,8 +963,18 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// Find column indices dynamically from header
// product_code header is typically "ProductCode" or similar
- const productCodeIdx = findHeaderIndex(effectiveHeader, ['ProductCode', 'Product_Code', 'product_code', 'Code']);
- logger.info(`[SheetPrice] productCodeIdx from header:`, productCodeIdx, 'header:', effectiveHeader);
+ const productCodeIdx = findHeaderIndex(effectiveHeader, [
+ 'ProductCode',
+ 'Product_Code',
+ 'product_code',
+ 'Code'
+ ]);
+ logger.info(
+ `[SheetPrice] productCodeIdx from header:`,
+ productCodeIdx,
+ 'header:',
+ effectiveHeader
+ );
const priceByProductCode: Record = {};
// Track ALL rows per product code (for duplicates)
@@ -703,7 +1075,10 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
// Log duplicates info
const duplicates = Object.entries(allRowsByProductCode).filter(([_, rows]) => rows.length > 1);
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) {
const sampleKey = Object.keys(priceByProductCode)[0];
@@ -711,6 +1086,13 @@ function processSheetPriceData(country: string, header: string[], chunks: any[])
}
}
+export function handleSheetPriceResponse(country: string, content: any) {
+ const resolvedCountry = country || get(streamingRawData).price?.country || '';
+ const chunks = Array.isArray(content) ? content : [content];
+ processSheetPriceData(resolvedCountry.toLowerCase(), [], chunks);
+ sheetPriceLoading.set(false);
+}
+
// Reset sheet price stores
export function resetSheetPriceStore() {
sheetPriceStreamMeta.set(null);
@@ -770,14 +1152,24 @@ export function loadProductCodesFromCache(country?: string): boolean {
// Only load if country matches (or no country filter specified)
if (data.codes && Array.isArray(data.codes)) {
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
existingProductCodes.set(new Set());
return false;
}
existingProductCodes.set(new Set(data.codes));
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;
}
}
@@ -799,7 +1191,13 @@ export function clearProductCodes() {
export function handleListMenuResponse(payload: { codes: string[]; country?: string }) {
// Use pending country if not in payload
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) {
existingProductCodes.set(new Set(payload.codes));
@@ -815,7 +1213,12 @@ export function handleListMenuResponse(payload: { codes: string[]; country?: str
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) {
logger.warn('[sheetStore] Failed to save to cache:', e);
}
diff --git a/src/lib/core/stores/websocketStore.ts b/src/lib/core/stores/websocketStore.ts
index 7a71e7a..989a5ba 100644
--- a/src/lib/core/stores/websocketStore.ts
+++ b/src/lib/core/stores/websocketStore.ts
@@ -19,6 +19,7 @@ const ENABLE_WS_DEBUG: boolean = false;
export const socketConnectionOfflineCount = writable(0);
export const socketAlreadySendHeartbeat = writable(0);
export const socketStore = writable(null);
+export const wsAuthReady = writable(false);
export const sharedKey = writable(null);
@@ -54,6 +55,31 @@ export function waitForOpenSocket(timeoutMs = 8000): Promise {
});
}
+export async function waitForAuthenticatedSocket(timeoutMs = 10000): Promise {
+ const openSocket = await waitForOpenSocket(timeoutMs);
+ if (!openSocket) return null;
+ if (get(wsAuthReady)) return openSocket;
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let unsubscribe = () => {};
+ const timeout = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ unsubscribe();
+ resolve(null);
+ }, timeoutMs);
+
+ unsubscribe = wsAuthReady.subscribe((ready) => {
+ if (!ready || settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ unsubscribe();
+ resolve(openSocket);
+ });
+ });
+}
+
export async function connectToWebsocket(id_token?: string) {
if (browser) {
// logger.info('connecting to ', env.PUBLIC_WSS);
@@ -64,12 +90,13 @@ export async function connectToWebsocket(id_token?: string) {
let ws_url = env.PUBLIC_WSS;
socket = new WebSocket(ws_url);
+ wsAuthReady.set(false);
sharedKey.set(null);
const { privateKey, publicKeyBase64 } = await WebCryptoHelper.generateKeyPair();
socket.addEventListener('open', async () => {
socketStore.set(socket);
- addNotification('INFO:Connected!');
+ // addNotification('INFO:Connected!');
if (socket) {
clearTimeout(reconnectTimeout);
@@ -89,13 +116,11 @@ export async function connectToWebsocket(id_token?: string) {
sendAuthInfoInterval = setInterval(async () => {
if (get(sharedKey)) {
+ auth_data = get(authStore);
+ perms = get(permission);
// Debug: check if auth_data has uid
- logger.info('[WS Auth] Sending auth info with:', {
- uid: auth_data?.uid,
- name: auth_data?.displayName,
- email: auth_data?.email
- });
- await sendMessage({
+ logger.info('[WS Auth] Sending auth info with [REDACTED]');
+ const sent = await sendMessage({
type: 'auth',
payload: {
user: {
@@ -106,11 +131,12 @@ export async function connectToWebsocket(id_token?: string) {
}
}
});
+ wsAuthReady.set(sent);
clearInterval(sendAuthInfoInterval);
}
- }, 3000);
+ }, 2000);
}
- logger.info(socket);
+ // logger.info(socket);
// heartbeat 10s
socketCheck = setInterval(async () => {
@@ -161,10 +187,12 @@ export async function connectToWebsocket(id_token?: string) {
socket.addEventListener('close', () => {
socketStore.set(null);
+ wsAuthReady.set(false);
sharedKey.set(null);
socket = null;
clearInterval(socketCheck);
+ clearInterval(sendAuthInfoInterval);
if (auth.currentUser && !socket) {
logger.info('try reconnect websocket ...');
@@ -176,12 +204,13 @@ export async function connectToWebsocket(id_token?: string) {
}
});
- socket.addEventListener('error', (e) => {
- // logger.info('WebSocket error: ', e);
- socketStore.set(null);
- sharedKey.set(null);
- clearInterval(socketCheck);
- });
+ socket.addEventListener('error', (e) => {
+ // logger.info('WebSocket error: ', e);
+ socketStore.set(null);
+ wsAuthReady.set(false);
+ sharedKey.set(null);
+ clearInterval(socketCheck);
+ });
} catch (socket_error: any) {
if (ENABLE_WS_DEBUG) {
logger.error('WS_ERR', socket_error);
diff --git a/src/lib/core/types/outMessage.ts b/src/lib/core/types/outMessage.ts
index dbf0a6c..22d8381 100644
--- a/src/lib/core/types/outMessage.ts
+++ b/src/lib/core/types/outMessage.ts
@@ -91,4 +91,25 @@ export type OutMessage =
timestamp: number;
timedOut?: boolean;
};
+ }
+ | {
+ type: 'firmware-request';
+ payload: {
+ mode: 'not_full' | 'not_full-delay-build';
+ brand: string;
+ files: string[];
+ param: ('new-auto-inc-version' | 'save-build-date' | 'save-build-version-override')[];
+ requester: string;
+ };
+ }
+ | {
+ type: 'firmware-confirm';
+ payload: {
+ confirm: boolean;
+ session: string;
+ };
+ }
+ | {
+ type: 'firmware-versions';
+ payload: {};
};
diff --git a/src/lib/core/utils/crypto.ts b/src/lib/core/utils/crypto.ts
index 0803966..7cbe33f 100644
--- a/src/lib/core/utils/crypto.ts
+++ b/src/lib/core/utils/crypto.ts
@@ -1,4 +1,14 @@
export class WebCryptoHelper {
+ private static bytesToBase64(bytes: Uint8Array) {
+ const chunkSize = 0x8000;
+ let binary = '';
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
+ }
+
+ return btoa(binary);
+ }
+
static async generateKeyPair() {
const keyPair = await window.crypto.subtle.generateKey(
{
@@ -10,7 +20,7 @@ export class WebCryptoHelper {
);
const exportedPublic = await window.crypto.subtle.exportKey('raw', keyPair.publicKey);
- const publicKeyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedPublic)));
+ const publicKeyBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(exportedPublic));
return { privateKey: keyPair.privateKey, publicKeyBase64 };
}
@@ -60,8 +70,8 @@ export class WebCryptoHelper {
encodedText
);
- const ciphertextBase64 = btoa(String.fromCharCode(...new Uint8Array(ciphertextBuffer)));
- const ivBase64 = btoa(String.fromCharCode(...iv));
+ const ciphertextBase64 = WebCryptoHelper.bytesToBase64(new Uint8Array(ciphertextBuffer));
+ const ivBase64 = WebCryptoHelper.bytesToBase64(iv);
return { ciphertext: ciphertextBase64, iv: ivBase64 };
}
diff --git a/src/lib/core/utils/logger.ts b/src/lib/core/utils/logger.ts
index e2c5fc4..c035eae 100644
--- a/src/lib/core/utils/logger.ts
+++ b/src/lib/core/utils/logger.ts
@@ -1,91 +1,225 @@
/**
- * Lightweight logger that suppresses output in production.
+ * Structured logger powered by LogTape (@logtape/logtape).
*
- * In development (npm run dev): logs are shown with a prefix tag.
- * In production (npm run build): all console output is suppressed
- * so logs are never exposed to the browser inspector.
+ * Two tiers of logging:
+ * - **logger** — general-purpose logging (suppressed in production for security)
+ * - Dev: `trace`, `debug`, `info`, `warn`, `error` (configurable via PUBLIC_APP_LOG_LEVEL)
+ * - Prod: `info` by default (or what PUBLIC_APP_LOG_LEVEL says)
+ * - **inspector** — error-only logging, ALWAYS active (never suppressed).
+ * Use for security-critical events that must appear in the browser
+ * developer console (e.g. auth failures, API errors).
*
- * Usage:
- * import { logger } from '$lib/core/utils/logger';
- * logger.info('hello', someVar);
- * logger.error('failed', err);
+ * API (100 % backward compatible):
+ * logger.info('message', arg1, …)
+ * logger.error('Failed:', err)
+ * inspector.error('auth failure', uid) // always visible
*/
+import { configureSync, getConfig, getConsoleSink, withFilter, getLogger } from '@logtape/logtape';
+import type { LogLevel } from '@logtape/logtape';
-type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
+// ---------------------------------------------------------------------------
+// LogTape configuration (idempotent — uses getConfig() not a local flag)
+// ---------------------------------------------------------------------------
-const LOG_LEVEL_RANK: Record = {
- trace: -1,
- debug: 0,
- info: 1,
- warn: 2,
- error: 3
+/**
+ * Resolve the log level lazily (not at module-eval time).
+ *
+ * import.meta.env is undefined when Vite loads vite.config.ts, so we
+ * must not read it at the top level. Deferring to first use ensures
+ * Vite has finished initialising by the time we read it.
+ */
+function resolveLogLevel(): LogLevel {
+ const env = import.meta.env as Record | undefined;
+ const isDev = env?.DEV ?? (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production');
+ const configured = env?.PUBLIC_APP_LOG_LEVEL as string | undefined;
+ return (configured || (isDev ? 'trace' : 'info')) as LogLevel;
+}
+
+function ensureConfigured() {
+ // Vite evaluates the config module twice during build (SSR + client).
+ // A local `_configured` boolean resets between evaluations, so we
+ // rely on LogTape's own global state instead.
+ if (getConfig() !== null) return;
+ configureSync({
+ sinks: {
+ console: getConsoleSink(),
+ errorsOnly: withFilter(getConsoleSink(), 'error'),
+ },
+ loggers: [
+ {
+ category: ['supra-app'],
+ sinks: ['console'],
+ lowestLevel: resolveLogLevel(),
+ },
+ {
+ category: ['supra-app', 'inspector'],
+ sinks: ['errorsOnly'],
+ lowestLevel: 'error',
+ },
+ {
+ category: ['logtape', 'meta'],
+ sinks: ['console'],
+ lowestLevel: 'error',
+ },
+ ],
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Internal LogTape loggers (lazy-init)
+// ---------------------------------------------------------------------------
+
+function getBaseLogger() {
+ ensureConfigured();
+ return getLogger(['supra-app']);
+}
+
+function getInspectorLogger() {
+ ensureConfigured();
+ return getLogger(['supra-app', 'inspector']);
+}
+
+// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+
+function isRecord(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !(v instanceof Error) && !Array.isArray(v);
+}
+
+/**
+ * Convert variadic args into a flat message string + optional extras.
+ *
+ * Patterns handled:
+ * logger.info('text') → message, no props
+ * logger.info('text', obj) → message, obj as props if obj is a plain Record
+ * logger.info('text', error) → error overload
+ * logger.info('text', arg1, arg2, …) → concatenated message
+ */
+function normalize(
+ args: unknown[],
+): { message: string; error?: Error; properties?: Record } {
+ if (args.length === 0) return { message: '' };
+
+ const first = args[0];
+ if (args.length === 1) {
+ if (first instanceof Error) return { message: first.message, error: first };
+ return { message: String(first) };
+ }
+
+ if (args.length === 2 && isRecord(args[1])) {
+ return { message: String(first), properties: args[1] };
+ }
+
+ // error overload: logger.warn(msg, error) / logger.error('msg', err)
+ if (args.length === 2 && args[1] instanceof Error) {
+ return { message: String(first), error: args[1] as Error };
+ }
+
+ // multi-arg: concatenate
+ return {
+ message: args.map((a) => (typeof a === 'object' ? safeStringify(a) : String(a))).join(' '),
+ };
+}
+
+function safeStringify(v: unknown): string {
+ if (v instanceof Error) return `${v.name}: ${v.message}`;
+ try {
+ return JSON.stringify(v);
+ } catch {
+ return String(v);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+
+export const logger = {
+ trace(...args: unknown[]) {
+ const { message } = normalize(args);
+ getBaseLogger().trace(message);
+ },
+
+ debug(...args: unknown[]) {
+ const { message, properties } = normalize(args);
+ if (properties) {
+ getBaseLogger().debug(message, properties);
+ } else {
+ getBaseLogger().debug(message);
+ }
+ },
+
+ info(...args: unknown[]) {
+ const { message, properties } = normalize(args);
+ if (properties) {
+ getBaseLogger().info(message, properties);
+ } else {
+ getBaseLogger().info(message);
+ }
+ },
+
+ warn(...args: unknown[]) {
+ const { message, error, properties } = normalize(args);
+ if (properties) {
+ getBaseLogger().warn(message, properties);
+ } else if (error) {
+ getBaseLogger().warn(message, error);
+ } else {
+ getBaseLogger().warn(message);
+ }
+ },
+
+ error(...args: unknown[]) {
+ const { message, error, properties } = normalize(args);
+ if (error && properties) {
+ getBaseLogger().error(error, properties);
+ } else if (error) {
+ getBaseLogger().error(error);
+ } else if (properties) {
+ getBaseLogger().error(message, properties);
+ } else {
+ getBaseLogger().error(message);
+ }
+ },
};
-const ENV_LOG_LEVEL: LogLevel =
- (import.meta.env.PUBLIC_APP_LOG_LEVEL as LogLevel) || 'info';
-const IS_DEV = import.meta.env.DEV;
-
-function shouldLog(level: LogLevel): boolean {
- return IS_DEV && LOG_LEVEL_RANK[level] >= LOG_LEVEL_RANK[ENV_LOG_LEVEL];
-}
-
-function formatArgs(level: LogLevel, args: any[]): any[] {
- return [`[${level.toUpperCase()}]`, ...args];
-}
+// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
/**
- * Extract caller location (file:line) from a stack trace.
- * Stack frame format: " at method (file:///path/file.ts:10:20)"
- * The 3rd frame (index 2) is the caller of the method that created the Error.
+ * Inspector logger — always active, error-only, never suppressed.
+ * Messages are written via the same LogTape infrastructure but on a
+ * separate category (supra-app → inspector) whose sink is hard-filtered to
+ * `error` / `fatal` so security-sensitive events are always visible.
*/
-function getCallerLocation(): string {
- try {
- const stack = new Error().stack?.split('\n');
- if (!stack || stack.length < 3) return 'unknown';
- // stack[0] = "Error", stack[1] = " at getCallerLocation (...)",
- // stack[2] = " at Logger.trace (...)" — skip both
- const caller = stack[2]?.trim();
- // Extract path from common formats:
- // "at method (file:///path/file.ts:10:20)" or "at file:///path/file.ts:10:20"
- const match = caller.match(/(?:\(|at\s+)([^(]+?\.\w+):(\d+)/);
- if (match) return `${match[1]}:${match[2]}`;
- return caller || 'unknown';
- } catch {
- return 'unknown';
- }
-}
-
-class Logger {
- trace(...args: any[]) {
- if (shouldLog('trace')) {
- const location = getCallerLocation();
- console.debug(`[TRACE:${location}]`, ...args);
+export const inspector = {
+ error(...args: unknown[]) {
+ const { message, error, properties } = normalize(args);
+ if (error && properties) {
+ getInspectorLogger().error(error, properties);
+ } else if (error) {
+ getInspectorLogger().error(error);
+ } else if (properties) {
+ getInspectorLogger().error(message, properties);
+ } else {
+ getInspectorLogger().error(message);
}
- }
+ },
- debug(...args: any[]) {
- if (shouldLog('debug')) {
- console.debug(...formatArgs('debug', args));
+ warn(...args: unknown[]) {
+ const { message, error } = normalize(args);
+ // inspector only logs errors; escalate to error level
+ if (error) {
+ getInspectorLogger().error(error);
+ } else if (message) {
+ getInspectorLogger().error(message);
}
- }
+ },
- info(...args: any[]) {
- if (shouldLog('info')) {
- console.info(...formatArgs('info', args));
- }
- }
-
- warn(...args: any[]) {
- if (shouldLog('warn')) {
- console.warn(...formatArgs('warn', args));
- }
- }
-
- error(...args: any[]) {
- if (shouldLog('error')) {
- console.error(...formatArgs('error', args));
- }
- }
-}
-
-export const logger = new Logger();
+ info(...args: unknown[]) {
+ const { message } = normalize(args);
+ // promote to error so it passes the filter
+ if (message) getInspectorLogger().error(message);
+ },
+};
diff --git a/src/lib/core/utils/vite-logger.ts b/src/lib/core/utils/vite-logger.ts
new file mode 100644
index 0000000..836e6f1
--- /dev/null
+++ b/src/lib/core/utils/vite-logger.ts
@@ -0,0 +1,70 @@
+/**
+ * Custom Vite logger that routes Vite's build/dev-server messages
+ * through our LogTape-powered logger for consistent formatting.
+ *
+ * Usage in vite.config.ts:
+ * import { createViteLogger } from './src/lib/core/utils/vite-logger';
+ * export default defineConfig({
+ * customLogger: createViteLogger(),
+ * // ...
+ * });
+ */
+import type { LogOptions, LogErrorOptions } from 'vite';
+import type { Logger } from 'vite';
+import { logger } from './logger';
+
+export function createViteLogger(): Logger {
+ const warnedMessages = new Set();
+
+ return {
+ get hasWarned() {
+ return warnedMessages.size > 0;
+ },
+
+ info(msg: string, options?: LogOptions) {
+ const prefix = options?.environment ? `[${options.environment}]` : '';
+ if (prefix) {
+ logger.info(`${prefix} ${msg}`);
+ } else if (options?.timestamp) {
+ logger.info(`[vite] ${msg}`);
+ } else {
+ logger.info(msg);
+ }
+ },
+
+ warn(msg: string, options?: LogOptions) {
+ warnedMessages.add(msg);
+ const prefix = options?.environment ? `[${options.environment}]` : '';
+ if (prefix) {
+ logger.warn(`${prefix} ${msg}`);
+ } else {
+ logger.warn(msg);
+ }
+ },
+
+ warnOnce(msg: string, options?: LogOptions) {
+ if (warnedMessages.has(msg)) return;
+ this.warn(msg, options);
+ },
+
+ error(msg: string, options?: LogErrorOptions) {
+ const prefix = options?.environment ? `[${options.environment}]` : '';
+ const err = options?.error;
+ const fullMsg = prefix ? `${prefix} ${msg}` : msg;
+ if (err && err instanceof Error) {
+ logger.error(fullMsg, err);
+ } else {
+ logger.error(fullMsg);
+ }
+ },
+
+ clearScreen() {
+ // Let Vite handle screen clearing itself
+ },
+
+ hasErrorLogged(_error: Error | import('rollup').RollupError): boolean {
+ // Don't keep error state — always re-report
+ return false;
+ },
+ };
+}
diff --git a/src/lib/workers/androidPayload.worker.ts b/src/lib/workers/androidPayload.worker.ts
new file mode 100644
index 0000000..c1408d0
--- /dev/null
+++ b/src/lib/workers/androidPayload.worker.ts
@@ -0,0 +1,63 @@
+///
+import { logger } from '$lib/core/utils/logger';
+let chunks: Uint8Array[] = [];
+let totalBytes = 0;
+let expectedLength = -1;
+
+self.onmessage = async (e) => {
+ if (e.data.type === 'CHUNK') {
+ const chunk = e.data.payload;
+ chunks.push(chunk);
+ totalBytes += chunk.length;
+
+ // Loop to process complete messages
+ while (true) {
+ // 1. Try to get length header (need 4 bytes)
+ if (expectedLength === -1 && totalBytes >= 4) {
+ // Need a flat buffer just to read the header
+ const headerBuf = concatChunks(chunks, 4);
+ expectedLength = new DataView(headerBuf.buffer).getInt32(0, false);
+
+ // Remove header bytes from our chunks array
+ removeBytes(4);
+ }
+
+ // 2. Try to get payload
+ if (expectedLength !== -1 && totalBytes >= expectedLength) {
+ const payload = concatChunks(chunks, expectedLength);
+ removeBytes(expectedLength);
+
+ self.postMessage({ type: 'DATA', payload }, [payload.buffer]);
+
+ expectedLength = -1; // Reset for next message
+ } else {
+ break; // Wait for more data
+ }
+ }
+ }
+};
+
+// Helper: Concatenate only what we need
+function concatChunks(chunks: Uint8Array[], length: number): Uint8Array {
+ const result = new Uint8Array(length);
+ let offset = 0;
+ for (let i = 0; i < chunks.length && offset < length; i++) {
+ const chunk = chunks[i];
+ const toCopy = Math.min(chunk.length, length - offset);
+ result.set(chunk.slice(0, toCopy), offset);
+ // If we only used part of the chunk, we keep the rest in the array
+ if (toCopy < chunk.length) {
+ chunks[i] = chunk.slice(toCopy);
+ } else {
+ chunks.shift();
+ i--;
+ }
+ offset += toCopy;
+ }
+ return result;
+}
+
+// Helper: Update total count
+function removeBytes(n: number) {
+ totalBytes -= n;
+}
diff --git a/src/routes/(authed)/+layout.svelte b/src/routes/(authed)/+layout.svelte
index 058b71c..ce27d8d 100644
--- a/src/routes/(authed)/+layout.svelte
+++ b/src/routes/(authed)/+layout.svelte
@@ -1,5 +1,6 @@
-
+
+
+
+
+
+
+
+
Firmware Requests
+
+ Request and download firmware builds per country
+
+
+
+
+
+
+ Refresh
+
+
+
+ Request Build
+
+
+
+
+
+
+
+ {#each FIRMWARE_COUNTRIES as country (country.code)}
+
+ {country.label}
+ {#if allCountries[country.code]?.buildStatus === 'building'}
+
+ {:else if allCountries[country.code]?.buildStatus === 'ready'}
+
+ {/if}
+
+ {/each}
+
+
+ {#each FIRMWARE_COUNTRIES as country (country.code)}
+
+ {@render countryPanel(country.code)}
+
+ {/each}
+
+
+
+
+{#snippet countryPanel(countryCode: string)}
+ {@const state = allCountries[countryCode]}
+ {@const files = state?.files ?? []}
+ {@const buildInProgress = state?.buildStatus === 'queued' || state?.buildStatus === 'building'}
+
+
+
+
+
+
+
+
+ {#if buildInProgress}
+
+ {:else if state?.buildStatus === 'ready'}
+
+ {:else if state?.buildStatus === 'failed'}
+
+ {:else}
+
+ {/if}
+ {state?.label ?? countryCode}
+
+
Firmware build status and available files
+
+
+ {statusLabel(state?.buildStatus ?? 'idle')}
+
+
+
+
+ {#if buildInProgress}
+
+
+ Building firmware…
+ ~{state?.files[0]?.version ?? ''}
+
+
+
+ {:else if files.length === 0}
+
+
+
+
No firmware builds yet
+
+ Click "Request Build" to create a new firmware for {state?.label}
+
+
+
+ {:else}
+
+
+
+
+ Filename
+ Version
+ Size
+ Built
+ Status
+ Download
+
+
+
+ {#each files as file (file.id)}
+ {@const StatusIcon = statusIcon(file.status)}
+
+
+ {file.filename}
+
+
+ {file.version}
+
+
+ {formatFileSize(file.size)}
+
+
+ {formatBuildDate(file.builtAt)}
+
+
+
+
+ {statusLabel(file.status)}
+
+
+
+ {#if file.status === 'ready' && file.downloadUrl}
+ handleDownload(file)}
+ title="Download"
+ >
+
+
+ {:else if file.status === 'building'}
+
+ {:else}
+ —
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+{/snippet}
+
+
+
+
+
+ Request Firmware Build
+
+ Request a new firmware build for
+ {allCountries[selectedCountry]?.label ?? selectedCountry} . You will be notified when the build is ready or need confirmation.
+
+
+
+
+
+
Latest Firmware Version
+
+ {#if latest_versions[selectedCountry].version == 'NO_DATA'}
+
+ Note: this will use latest version provided from source.
+
+ {/if}
+
+
+
+
Latest Firmware Release Date
+
+
+ {#if latest_versions[selectedCountry].date == 'NO_DATA'}
+
Note: this will forcefully save today as build date.
+ {/if}
+
+
+
+
+
+
+ {
+ const checkbox = e.target as HTMLInputElement | null;
+ if (checkbox) {
+ increaseVersion = checkbox.checked;
+ }
+ }}
+ />
+ Set to new version (Optional)
+
+
+
+
+
+ {
+ const checkbox = e.target as HTMLInputElement | null;
+ if (checkbox) {
+ saveThisVersionBuildDate = checkbox.checked;
+ }
+ }}
+ />
+ Save this version's build date (Optional)
+
+
+
+
+
+ {
+ const checkbox = e.target as HTMLInputElement | null;
+ if (checkbox) {
+ saveThisVersionAsLatest = checkbox.checked;
+ }
+ }}
+ />
+ Save this version as latest (Optional)
+
+
+
+
+
+ (buildDialogOpen = false)}>Cancel
+
+ {#if isRequesting}
+
+ Requesting…
+ {:else}
+ Request Build
+ {/if}
+
+
+
+
diff --git a/src/routes/(authed)/recipe/material/+page.svelte b/src/routes/(authed)/recipe/material/+page.svelte
index a542a01..355e3a8 100644
--- a/src/routes/(authed)/recipe/material/+page.svelte
+++ b/src/routes/(authed)/recipe/material/+page.svelte
@@ -1,6 +1,6 @@
-
@@ -461,21 +941,93 @@
{/if}
-
-
- {#if loading}
-
- Loading
- {:else if devRecipe}
- Reload From Android
- {:else}
- Connect & Load
- {/if}
-
+
+
+ Recipe Source
+
+
+
+
+ {adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
+
+
+ {#if loading}
+
+ Loading
+ {:else}
+ Machine
+ {/if}
+
+
+ Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
+
+
+
+
+
+ Load Recipe From Server
+ Select a country to load material data from server.
+
+
+
+ {#each serverCountries as country}
+ selectServerCountry(country)}
+ disabled={loading || saving}
+ >
+ {country}
+
+ {/each}
+
+
+
+ (serverCountryDialogOpen = false)}>Cancel
+
+
+
+
+
+
+
+ Machine Not Connected
+
+ Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
+
+
+
+
+ (machineNotConnectedDialogOpen = false)}>OK
+
+
+
+
@@ -501,31 +1053,60 @@
- {existingMaterial ? 'Edit Material' : 'Add Material'}
+ {isEditingMaterial ? 'Edit Material' : 'Add Material'}
- Create or update one MaterialSetting entry. The JSON preview shows the payload
- before saving to Android.
+ Create or update one MaterialSetting entry. Server-loaded data is read-only until
+ ADB is connected.
- {existingMaterial ? 'Edit Material' : 'Add Material'}
+ {isEditingMaterial ? 'Edit Material' : 'Add Material'}
- {#if existingMaterial}
+ {#if duplicateMaterialOnCreate}
+
+ Material ID {form.id} already exists. Choose another ID before creating.
+
+ {:else if isEditingMaterial && Number(form.id) !== Number(editingMaterialId) && existingMaterial}
+
+ Material ID {form.id} already exists. Choose another ID before saving.
+
+ {:else if isEditingMaterial}
- Material ID {form.id} already exists. Saving will update this MaterialSetting.
+ Editing Material ID {editingMaterialId}.
{/if}
+
+
Material Type
+
(materialTypeDialogOpen = true)}
+ >
+
+ {getSelectedMaterialTypeLabel()}
+
+
+
+
Alternate ID
@@ -548,11 +1129,11 @@
-
-
- Channel
- {
- form.channel = event.currentTarget.value as MaterialChannel;
- applyChannelPreset();
- }}
- >
- {#each channelOptions as option}
- {option.label}
- {/each}
-
-
+
Canister Type
@@ -620,21 +1185,6 @@
-
-
Alarm ID
@@ -688,12 +1238,18 @@
- (showMaterialForm = false)} disabled={saving}
- >Cancel Cancel
Reset
-
- {saving ? 'Saving...' : existingMaterial ? 'Update Material' : 'Create Material'}
+
+ {saving ? 'Saving...' : isEditingMaterial ? 'Update Material' : 'Create Material'}
@@ -715,14 +1271,46 @@
+
+
+
+ Select Material Type
+
+ Choose the type for the new material. Material ID will be generated from existing IDs in
+ that type + 1.
+
+
+
+
+ {#each selectableMaterialTypeOptions as option}
+
selectMaterialType(option.value)}
+ >
+
+
TYPE {option.typeId} - {option.label}
+
+
+ {/each}
+
+
+
+ (materialTypeDialogOpen = false)}>Cancel
+
+
+
+
Existing Materials
-
- Use Edit to update a material, or Delete to remove it after confirmation.
-
+
Add Material
- Loading materials from Android...
+ Loading materials...
{:else if !devRecipe}
- Connect and load recipe first.
+ Load recipe first.
{:else if filteredMaterials.length === 0}
No materials found.
{:else}
ID
-
Thai Name
+
Name ({primaryLanguageLabel})
English Name
Path
Use
@@ -764,7 +1352,7 @@
{#each filteredMaterials as material}
{material.id}
{material.materialName || '-'}
@@ -790,7 +1378,7 @@
variant="destructive"
size="sm"
onclick={() => openDeleteConfirm(material)}
- disabled={saving}
+ disabled={saving || !canWriteToAndroid}
>
Delete
@@ -835,7 +1423,11 @@
>
Cancel
-
+
{saving ? 'Deleting...' : 'Delete Material'}
diff --git a/src/routes/(authed)/recipe/topping/+page.svelte b/src/routes/(authed)/recipe/topping/+page.svelte
index 6de1361..d6f3a16 100644
--- a/src/routes/(authed)/recipe/topping/+page.svelte
+++ b/src/routes/(authed)/recipe/topping/+page.svelte
@@ -1,6 +1,6 @@
-
@@ -553,19 +775,93 @@
{/if}
-
- {#if loading}
-
- Loading
- {:else if devRecipe}
- Reload From Android
- {:else}
- Connect & Load
- {/if}
-
+
+
+ Recipe Source
+
+
+
+
+ {adb.getAdbInstance() ? 'Machine Connected' : 'Connect Machine'}
+
+
+ {#if loading}
+
+ Loading
+ {:else}
+ Machine
+ {/if}
+
+
+ Server{selectedServerCountry ? `: ${selectedServerCountry}` : ''}
+
+
+
+
+
+
+ Load Recipe From Server
+ Select a country to load topping data from server.
+
+
+
+ {#each serverCountries as country}
+ selectServerCountry(country)}
+ disabled={loading || saving}
+ >
+ {country}
+
+ {/each}
+
+
+
+ (serverCountryDialogOpen = false)}>Cancel
+
+
+
+
+
+
+
+ Machine Not Connected
+
+ Connect to the machine with ADB/WebUSB before loading recipe data from Machine.
+
+
+
+
+ (machineNotConnectedDialogOpen = false)}>OK
+
+
+
+
@@ -593,23 +889,23 @@
{activeTab === 'list' ? 'Topping List' : 'Topping Group'}
-
- Switch between list items and groups. Edit/Delete actions are explicit per row.
-
+
- (activeTab = 'list')}
- >
- ToppingList
-
(activeTab = 'group')}
>
ToppingGroup
+ (activeTab = 'list')}
+ >
+ ToppingList
+
@@ -617,12 +913,14 @@
{#if activeTab === 'list'}
- Add Topping Add Topping
{:else}
- Add Group Add Group
{/if}
@@ -631,19 +929,19 @@
{#if loading}
- Loading toppings from Android...
+ Loading toppings...
{:else if !devRecipe}
-
Connect and load recipe first.
+
Load recipe first.
{:else if activeTab === 'list'}
{#if filteredToppingList.length === 0}
No topping list items found.
{:else}
ID
-
Thai Name
+
Name ({primaryLanguageLabel})
English Name
Use
Actions
@@ -651,7 +949,7 @@
{#each filteredToppingList as item}
{item.id}
{item.name || '-'}
@@ -664,14 +962,17 @@
{(item.isUse as boolean) !== false ? 'Use' : 'Not use'}
-
loadListIntoForm(item)}
- >Edit loadListIntoForm(item)}
+ disabled={!canWriteToAndroid}>Edit
openDeleteConfirm('list', item)}
- disabled={saving}
+ disabled={saving || !canWriteToAndroid}
>
Delete
@@ -684,10 +985,10 @@
No topping groups found.
{:else}
ID
- Thai Name
+ Name ({primaryLanguageLabel})
English Name
IDs in group
Use
@@ -695,33 +996,52 @@
{#each filteredToppingGroups as group}
-
-
{group.groupID}
-
{group.name || '-'}
-
{group.otherName || '-'}
-
{group.idInGroup || '-'}
+
openGroupToppingDialog(group)}
+ onkeydown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') openGroupToppingDialog(group);
+ }}
>
-
- {(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
-
-
-
loadGroupIntoForm(group)}
- >Edit {group.groupID}
+
{group.name || '-'}
+
{group.otherName || '-'}
+
{group.idInGroup || '-'}
-
openDeleteConfirm('group', group)}
- disabled={saving}
+
- Delete
-
+ {(group.inUse as boolean) !== false ? 'Use' : 'Not use'}
+
+
+ {
+ event.stopPropagation();
+ loadGroupIntoForm(group);
+ }}
+ disabled={!canWriteToAndroid}>Edit
+ {
+ event.stopPropagation();
+ openDeleteConfirm('group', group);
+ }}
+ disabled={saving || !canWriteToAndroid}
+ >
+ Delete
+
+
{/each}
@@ -731,11 +1051,70 @@
+
+
+
+
+ ToppingList in Group {selectedGroupForToppings?.groupID ?? ''}
+
+
+ {selectedGroupForToppings?.otherName ||
+ selectedGroupForToppings?.name ||
+ 'Selected group'}
+ {selectedGroupForToppings?.idInGroup ? ` (${selectedGroupForToppings.idInGroup})` : ''}
+
+
+
+ {#if selectedGroupForToppings}
+ {@const groupToppings = getToppingListsForGroup(selectedGroupForToppings)}
+
+ {#if groupToppings.length === 0}
+
+ No matching topping list items found for {selectedGroupForToppings.idInGroup ||
+ 'this group'}.
+
+ {:else}
+
+ {#each groupToppings as toppingItem}
+
+
+
+
{toppingItem.id}
+
{toppingItem.name || '-'}
+
{toppingItem.otherName || '-'}
+
+
+ {(toppingItem.isUse as boolean) !== false ? 'Use' : 'Not use'}
+
+
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+ (groupToppingDialogOpen = false)}>Close
+
+
+
+
{existingListItem ? 'Edit Topping' : 'Add Topping'}
- Manage one item inside ToppingList.
+
+ Manage one item inside ToppingList. Server-loaded data is read-only until ADB
+ is connected.
+
@@ -758,7 +1137,7 @@
- Thai Name
+ Name ({primaryLanguageLabel})
@@ -929,7 +1308,7 @@
(listDialogOpen = false)} disabled={saving}
>Cancel
- {saving ? 'Saving...' : 'Save Topping'}
@@ -957,7 +1336,10 @@
{existingGroup ? 'Edit Topping Group' : 'Add Topping Group'}
- Manage one group inside ToppingGroup.
+
+ Manage one group inside ToppingGroup. Server-loaded data is read-only until
+ ADB is connected.
+
@@ -980,7 +1362,7 @@
- Thai Name
+ Name ({primaryLanguageLabel})
@@ -1004,7 +1386,7 @@
(groupDialogOpen = false)} disabled={saving}
>Cancel
- {saving ? 'Saving...' : 'Save Group'}
@@ -1032,9 +1414,9 @@
Delete Topping?
- This will remove the selected entry from Android recipe JSON.
+
+ This will remove the selected entry from Android recipe JSON. ADB is required.
+
{#if pendingDelete}
@@ -1064,7 +1446,11 @@
>
Cancel
-
+
{saving ? 'Deleting...' : 'Delete'}
diff --git a/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte b/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte
index 9a9043b..4cf1cfc 100644
--- a/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte
+++ b/src/routes/(authed)/sheet/add/[country]/[catalog]/+page.svelte
@@ -460,7 +460,7 @@
logger.warn('Failed to get boxid from machine:', e);
}
}
- requestListMenu(country, boxid);
+ await requestListMenu(country, boxid);
// Load available product codes from all sources
await loadAvailableProductCodes();
diff --git a/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte b/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte
index 20e1fb7..2959b39 100644
--- a/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte
+++ b/src/routes/(authed)/sheet/edit/[country]/[catalog]/+page.svelte
@@ -49,7 +49,9 @@
Eye,
Cog,
Upload,
- RotateCcw
+ RotateCcw,
+ Usb,
+ MonitorSmartphone
} from '@lucide/svelte/icons';
import * as adb from '$lib/core/adb/adb';
@@ -345,9 +347,84 @@
}
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;
}
+ // 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
>(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();
+ 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() {
editingItem = null;
isEditMode = false;
@@ -526,11 +603,14 @@
en: 'English',
th: 'Thai',
zh: 'Chinese',
+ zh_hans: 'Mandarin (Simplified)',
+ zh_hant: 'Mandarin (Traditional)',
ja: 'Japanese',
ms: 'Malay',
my: 'Myanmar',
lt: 'Lithuanian',
- ro: 'Romanian'
+ ro: 'Romanian',
+ ar: 'Arabic'
};
const languageLabelsByColumn = Object.fromEntries(
Object.entries(languageColumnMap).map(([key, column]) => [
@@ -539,6 +619,16 @@
])
) as Record;
+ // 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;
+
const nameColumnLabels: Record = {
9: 'Hot product codes',
10: 'Cold product codes',
@@ -597,13 +687,13 @@
}
if (type === 'img') return imageColumnLabels[cell.coord.col] ?? `Column ${cell.coord.col}`;
if (section.key?.endsWith('.name')) {
- return languageLabelsByColumn[cell.coord.col]
- ? `${languageLabelsByColumn[cell.coord.col]} name`
+ return nameDescLabelsByColumn[cell.coord.col]
+ ? `${nameDescLabelsByColumn[cell.coord.col]} name`
: `Column ${cell.coord.col}`;
}
if (section.key?.endsWith('.desc')) {
- return languageLabelsByColumn[cell.coord.col]
- ? `${languageLabelsByColumn[cell.coord.col]} description`
+ return nameDescLabelsByColumn[cell.coord.col]
+ ? `${nameDescLabelsByColumn[cell.coord.col]} description`
: `Column ${cell.coord.col}`;
}
return `Column ${cell.coord.col}`;
@@ -628,10 +718,33 @@
: '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
async function loadPreviewMenus() {
if (!AdbInstance.instance) {
- addNotification('ERR:Machine not connected');
+ // Prompt the user to connect first (same UX as other pages).
+ connectDialogOpen = true;
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//, 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() {
if (!AdbInstance.instance) {
addNotification('ERR:Machine not connected');
@@ -1201,9 +1328,9 @@
try {
for (let i = 0; i < filesToPush.length; i++) {
const file = filesToPush[i];
- const filename = file.file.split('/').pop() || '';
- const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
-
+ const androidPath = genFileToMachinePath(file.file, country);
+ const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
+ await adb.executeCmd(`mkdir -p "${parentDir}"`);
await adb.push(androidPath, file.content);
pushProgress = { current: i + 1, total: filesToPush.length };
@@ -1237,9 +1364,9 @@
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
- const filename = file.file.split('/').pop() || '';
- const androidPath = `${sourceDir}/taobin_project/inter/${country}/xml/multi/v3/${filename}`;
-
+ const androidPath = genFileToMachinePath(file.file, country);
+ const parentDir = androidPath.slice(0, androidPath.lastIndexOf('/'));
+ await adb.executeCmd(`mkdir -p "${parentDir}"`);
await adb.push(androidPath, file.content);
pushProgress = { current: i + 1, total: files.length };
@@ -1471,7 +1598,7 @@
await new Promise((resolve) => setTimeout(resolve, 300));
// Step 1: Enter room via WebSocket (acquire lock)
- const entered = enterRoom(country, catalog);
+ const entered = await enterRoom(country, catalog);
if (!entered) {
addNotification('ERR:WebSocket not connected');
sheetLoading.set(false);
@@ -2078,13 +2205,13 @@
- {#if getVisibleSections(editingItem.new_layout_v2).length > 0}
+ {#if getEditSections(editingItem.new_layout_v2).length > 0}
Menu Data
- {#each getVisibleSections(editingItem.new_layout_v2) as section}
+ {#each getEditSections(editingItem.new_layout_v2) as section}
{getSectionTitle(section)}
@@ -2093,7 +2220,7 @@
>
- {#each getVisibleCells(section) as cell}
+ {#each getEditCells(section) as cell}
{getCellLabel(section, cell)}
@@ -2112,13 +2239,13 @@
{/if}
- {#if getVisibleSections(editingItem.name_desc_v2).length > 0}
+ {#if getNameDescEditSections(editingItem.name_desc_v2).length > 0}
- Translations
+ Names & Descriptions Topping Page
- {#each getVisibleSections(editingItem.name_desc_v2) as section}
+ {#each getNameDescEditSections(editingItem.name_desc_v2) as section}
{section.key}
@@ -2127,7 +2254,7 @@
>
- {#each getVisibleCells(section) as cell}
+ {#each getNameDescEditCells(section) as cell}
{getCellLabel(section, cell)}
@@ -2323,6 +2450,41 @@
+
+
+
+
+
+
+
+
+
+ Connect machine
+
+ Preview reads recipes from the machine. Connect over USB to continue.
+
+
+
+
+
+ (connectDialogOpen = false)} disabled={adbConnecting}>
+ Cancel
+
+
+ {#if adbConnecting}
+
+ Connecting…
+ {:else}
+
+ Connect
+ {/if}
+
+
+
+
+
diff --git a/src/routes/(authed)/sheet/overview/[country]/+page.svelte b/src/routes/(authed)/sheet/overview/[country]/+page.svelte
index 6da4a55..894f54f 100644
--- a/src/routes/(authed)/sheet/overview/[country]/+page.svelte
+++ b/src/routes/(authed)/sheet/overview/[country]/+page.svelte
@@ -12,14 +12,37 @@
type Catalog
} from '$lib/core/stores/sheetStore.js';
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
- import { requestCatalogs } from '$lib/core/services/sheetService.js';
+ import { requestCatalogs, addCatalog } from '$lib/core/services/sheetService.js';
+ import { auth } from '$lib/core/stores/auth.js';
+ import { env } from '$env/dynamic/public';
+ import * as adb from '$lib/core/adb/adb';
+ import { AdbInstance } from '../../../../state.svelte';
import Button from '$lib/components/ui/button/button.svelte';
+ import Input from '$lib/components/ui/input/input.svelte';
+ import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import * as Card from '$lib/components/ui/card/index.js';
+ import * as Dialog from '$lib/components/ui/dialog/index.js';
+ import * as Popover from '$lib/components/ui/popover/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
- import { ArrowRight, Lock, RefreshCw, Star } from '@lucide/svelte/icons';
+ import {
+ ArrowRight,
+ CalendarDays,
+ ChevronLeft,
+ ChevronRight,
+ ImageUp,
+ Images,
+ Lock,
+ MonitorSmartphone,
+ Plus,
+ RefreshCw,
+ Sparkles,
+ Star,
+ Usb,
+ X
+ } from '@lucide/svelte/icons';
const mainCatalogNames = [
'page_catalog_group_coffee.skt',
@@ -63,6 +86,24 @@
let loading = $derived($sheetCatalogsLoading);
let error = $state(null);
let enabledCountries = $state([]);
+ const supportedCountries = new Set([
+ 'aus',
+ 'gbr',
+ 'hkg',
+ 'ltu',
+ 'mys',
+ 'rou',
+ 'sgp',
+ 'tha',
+ 'uae_dubai'
+ ]);
+
+ function getEnabledSheetCountries() {
+ return get(currentPerms)
+ .filter((x) => x.startsWith('document.write'))
+ .map((x) => x.split('.')[2])
+ .filter((country) => supportedCountries.has(country));
+ }
onMount(() => {
// Set department store
@@ -71,8 +112,7 @@
}
// Extract enabled countries from permissions
- const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
- enabledCountries = userPerms.map((x) => x.split('.')[2]);
+ enabledCountries = getEnabledSheetCountries();
// Auto-load catalogs for the selected country
if (selectedCountry) {
@@ -82,8 +122,7 @@
// Retry permissions after 1 second if empty
if (enabledCountries.length === 0) {
setTimeout(() => {
- const retryPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
- enabledCountries = retryPerms.map((x) => x.split('.')[2]);
+ enabledCountries = getEnabledSheetCountries();
}, 1000);
}
});
@@ -111,6 +150,18 @@
}
}
+ async function handleCountryChange(country: string) {
+ if (!country || country === selectedCountry) return;
+
+ selectedCountry = country;
+ departmentStore.set(country);
+ await goto(`/sheet/overview/${country}`, { replaceState: true, noScroll: true });
+ await loadCatalogs();
+ if (bannersOpen) {
+ await loadBannerList();
+ }
+ }
+
function handleEditCatalog(catalog: Catalog) {
if (catalog.status === 'locked') {
addNotification(`WARN:Catalog is locked by ${catalog.locked_by}`);
@@ -119,6 +170,404 @@
goto(`/sheet/edit/${selectedCountry}/${catalog.catalog}`);
}
+
+ // ── Create new promo catalog ──────────────────────────────────────────────
+ let createOpen = $state(false);
+ let creating = $state(false);
+ let createForm = $state({ slug: '', name: '', start: '', end: '' });
+ let bannerFile = $state(null);
+ let bannerPreview = $state(null);
+ let bannerSizeWarning = $state('');
+ let bannerDragging = $state(false);
+ let startCalendarOpen = $state(false);
+ let endCalendarOpen = $state(false);
+ let calendarMonth = $state(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
+ const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ const monthFormatter = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' });
+
+ function slugify(value: string): string {
+ return value
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '');
+ }
+
+ // Live previews shown in the dialog.
+ const slugPreview = $derived(slugify(createForm.slug));
+ const filePreview = $derived(slugPreview ? `page_catalog_group_pro_${slugPreview}.skt` : '');
+ const tagPreview = $derived(
+ slugPreview
+ ? slugPreview
+ .split('_')
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
+ .join('') + 'Pro'
+ : ''
+ );
+
+ function onNameInput(value: string) {
+ const prevAuto = slugify(createForm.name);
+ createForm.name = value;
+ // Keep slug auto-synced only while the user hasn't hand-edited it.
+ if (!createForm.slug || createForm.slug === prevAuto) {
+ createForm.slug = slugify(value);
+ }
+ }
+
+ async function warnIfBannerSizeMismatch(file: File) {
+ const url = URL.createObjectURL(file);
+ bannerSizeWarning = '';
+ try {
+ const image = new Image();
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () => reject(new Error('Unable to read image size'));
+ image.src = url;
+ });
+
+ if (image.naturalWidth !== 484 || image.naturalHeight !== 616) {
+ bannerSizeWarning = `Non-standard size: ${image.naturalWidth} × ${image.naturalHeight} px`;
+ addNotification(
+ `WARN:Recommended banner size is 484 × 616 px, selected ${image.naturalWidth} × ${image.naturalHeight} px`
+ );
+ }
+ } catch {
+ bannerSizeWarning = 'Unable to verify image size';
+ addNotification('WARN:Unable to verify banner image size');
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ async function setBanner(file: File | null) {
+ if (bannerPreview) URL.revokeObjectURL(bannerPreview);
+ bannerFile = file;
+ bannerPreview = file ? URL.createObjectURL(file) : null;
+ if (!file) bannerSizeWarning = '';
+ if (file) await warnIfBannerSizeMismatch(file);
+ }
+
+ function onBannerChange(e: Event) {
+ const input = e.target as HTMLInputElement;
+ void setBanner(input.files?.[0] ?? null);
+ }
+
+ function onBannerDrop(e: DragEvent) {
+ e.preventDefault();
+ bannerDragging = false;
+ const file = e.dataTransfer?.files?.[0];
+ if (file && file.type.startsWith('image/')) void setBanner(file);
+ }
+
+ function resetCreateForm() {
+ createForm = { slug: '', name: '', start: '', end: '' };
+ void setBanner(null);
+ }
+
+ // datetime-local gives "YYYY-MM-DDTHH:MM"; backend wants seconds.
+ function toIsoSeconds(value: string): string {
+ if (!value) return '';
+ return value.length === 16 ? `${value}:00` : value;
+ }
+
+ function getDatePart(value: string) {
+ return value.split('T')[0] ?? '';
+ }
+
+ function getTimePart(value: string) {
+ return value.split('T')[1] ?? '';
+ }
+
+ function setDateTimeField(field: 'start' | 'end', part: 'date' | 'time', value: string) {
+ const currentDate = getDatePart(createForm[field]);
+ const currentTime = getTimePart(createForm[field]);
+ const date = part === 'date' ? value : currentDate;
+ const time = part === 'time' ? value : currentTime || '00:00';
+
+ createForm[field] = date ? `${date}T${time}` : '';
+ }
+
+ function formatDisplayDate(value: string) {
+ const datePart = getDatePart(value);
+ if (!datePart) return 'Select date';
+
+ const [year, month, day] = datePart.split('-').map(Number);
+ if (!year || !month || !day) return datePart;
+
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ }).format(new Date(year, month - 1, day));
+ }
+
+ function formatDateValue(date: Date) {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ }
+
+ function getCalendarDays() {
+ const year = calendarMonth.getFullYear();
+ const month = calendarMonth.getMonth();
+ const firstDay = new Date(year, month, 1).getDay();
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+ return [
+ ...Array.from({ length: firstDay }, () => null),
+ ...Array.from({ length: daysInMonth }, (_, index) => new Date(year, month, index + 1))
+ ];
+ }
+
+ function moveCalendarMonth(offset: number) {
+ calendarMonth = new Date(calendarMonth.getFullYear(), calendarMonth.getMonth() + offset, 1);
+ }
+
+ function openCalendarFor(value: string) {
+ const datePart = getDatePart(value);
+ if (!datePart) return;
+
+ const [year, month] = datePart.split('-').map(Number);
+ if (year && month) calendarMonth = new Date(year, month - 1, 1);
+ }
+
+ function selectCalendarDate(field: 'start' | 'end', date: Date) {
+ setDateTimeField(field, 'date', formatDateValue(date));
+ if (field === 'start') startCalendarOpen = false;
+ else endCalendarOpen = false;
+ }
+
+ function setStartToday() {
+ const today = new Date().toISOString().slice(0, 10);
+ createForm.start = `${today}T00:00`;
+ }
+
+ function setStartTomorrow() {
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ createForm.start = `${tomorrow.toISOString().slice(0, 10)}T00:00`;
+ }
+
+ // Push a created promo's files to the machine so its event folder matches the
+ // server. Text files (.skt, config.json) come from the create response; the
+ // banner bytes come from the in-memory file.
+ async function pushCreatedPromoToMachine(result: any, banner: File) {
+ const root = '/sdcard/coffeevending/taobin_project';
+ const mkdirPush = async (rel: string, write: (path: string) => Promise) => {
+ const path = `${root}/${rel}`;
+ const parent = path.slice(0, path.lastIndexOf('/'));
+ await adb.executeCmd(`mkdir -p "${parent}"`);
+ await write(path);
+ };
+
+ // banner (binary)
+ if (result?.created?.banner) {
+ const bytes = new Uint8Array(await banner.arrayBuffer());
+ await mkdirPush(result.created.banner, (p) => adb.pushBinary(p, bytes));
+ }
+ // .skt + config.json (text)
+ if (result?.created?.skt && result?.content?.skt) {
+ await mkdirPush(result.created.skt, (p) => adb.push(p, result.content.skt));
+ }
+ if (result?.created?.config && result?.content?.config) {
+ await mkdirPush(result.created.config, (p) => adb.push(p, result.content.config));
+ }
+ // shared .ev flag files (flag_init / promotion_tricker_flag / banner_state_control)
+ // so the machine's flag wiring matches the server (same files, normal flow).
+ if (Array.isArray(result?.content?.ev)) {
+ for (const ev of result.content.ev) {
+ if (ev?.path && typeof ev?.content === 'string') {
+ await mkdirPush(ev.path, (p) => adb.push(p, ev.content));
+ }
+ }
+ }
+ }
+
+ async function handleCreateCatalog() {
+ const user = get(auth);
+ if (!user) {
+ addNotification('ERR:Not authenticated');
+ return;
+ }
+ // New flow: a machine must be connected so the promo lands on both the
+ // server and the machine (identical event folder).
+ if (!AdbInstance.instance) {
+ addNotification('ERR:Connect a machine first');
+ return;
+ }
+ const slug = slugPreview;
+ if (!slug || !createForm.name.trim() || !createForm.start || !bannerFile) {
+ addNotification('ERR:Please fill name, slug, start date and select a banner image');
+ return;
+ }
+ const banner = bannerFile;
+
+ creating = true;
+ try {
+ const fd = new FormData();
+ fd.append('country', selectedCountry);
+ fd.append('uid', user.uid);
+ fd.append('displayName', user.displayName ?? '');
+ fd.append('email', user.email ?? '');
+ fd.append('slug', slug);
+ fd.append('name', createForm.name.trim());
+ fd.append('start', toIsoSeconds(createForm.start));
+ fd.append('end', createForm.end ? toIsoSeconds(createForm.end) : 'NONE');
+ fd.append('banner', banner);
+
+ // Upload to server.
+ const res = await fetch('/api/catalog-create', { method: 'POST', body: fd });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.message || data.detail || `Create failed (${res.status})`);
+ }
+ const result = await res.json();
+
+ // Push the same files to the machine (event parity).
+ await pushCreatedPromoToMachine(result, banner);
+
+ // Register the Grist table so it appears in the overview + menus are addable.
+ await addCatalog(selectedCountry, createForm.name.trim(), result.catalog);
+
+ addNotification(`Created ${result.catalog} on server + machine`);
+ createOpen = false;
+ resetCreateForm();
+ // Give the backend a moment to create the table, then refresh.
+ setTimeout(() => void loadCatalogs(), 1000);
+ } catch (e) {
+ addNotification('ERR:' + (e instanceof Error ? e.message : 'Create failed'));
+ } finally {
+ creating = false;
+ }
+ }
+
+ // ── Banner management ─────────────────────────────────────────────────────
+ type PromoBanner = {
+ slug: string;
+ catalog: string;
+ tag: string | null;
+ start: string | null;
+ end: string | null;
+ banner: string | null;
+ };
+
+ const GET_IMAGE = env.PUBLIC_GET_IMAGE;
+ let bannersOpen = $state(false);
+ let bannerList = $state([]);
+ let bannerListLoading = $state(false);
+ let adbConnecting = $state(false);
+ let uploadingSlug = $state(null);
+ let pushingSlug = $state(null);
+ // Bumped after a replace so the reloads instead of showing the cached one.
+ let bannerCacheBust = $state(Date.now());
+
+ function bannerUrl(banner: string | null): string {
+ if (!banner) return '';
+ return `${GET_IMAGE}/${banner}?t=${bannerCacheBust}`;
+ }
+
+ async function openBanners() {
+ bannersOpen = true;
+ await loadBannerList();
+ }
+
+ async function loadBannerList() {
+ bannerListLoading = true;
+ try {
+ const res = await fetch(`/api/catalog-list?country=${encodeURIComponent(selectedCountry)}`);
+ if (!res.ok) throw new Error(`load failed (${res.status})`);
+ const data = await res.json();
+ bannerList = data.catalogs || [];
+ } catch (e) {
+ addNotification('ERR:Failed to load banners');
+ } finally {
+ bannerListLoading = false;
+ }
+ }
+
+ async function connectMachine() {
+ adbConnecting = true;
+ try {
+ // no Android server socket needed — we only push files
+ await adb.connnectViaWebUSB(false);
+ if (AdbInstance.instance) addNotification('Machine connected');
+ } catch (e) {
+ addNotification('ERR:' + (e instanceof Error ? e.message : 'Connect failed'));
+ } finally {
+ adbConnecting = false;
+ }
+ }
+
+ async function onReplaceBanner(promo: PromoBanner, e: Event) {
+ const input = e.target as HTMLInputElement;
+ const file = input.files?.[0];
+ input.value = '';
+ if (!file) return;
+
+ const user = get(auth);
+ if (!user) {
+ addNotification('ERR:Not authenticated');
+ return;
+ }
+
+ // Upload to server only. Pushing to the machine is a separate, manual step
+ // (the "Push" button) so the user controls when it goes to the machine.
+ uploadingSlug = promo.slug;
+ try {
+ const fd = new FormData();
+ fd.append('country', selectedCountry);
+ fd.append('slug', promo.slug);
+ fd.append('uid', user.uid);
+ fd.append('displayName', user.displayName ?? '');
+ fd.append('email', user.email ?? '');
+ fd.append('banner', file);
+
+ const res = await fetch('/api/catalog-banner', { method: 'POST', body: fd });
+ if (!res.ok) {
+ const d = await res.json().catch(() => ({}));
+ throw new Error(d.message || d.detail || `Replace failed (${res.status})`);
+ }
+ await res.json();
+
+ addNotification(`Banner replaced on server: ${promo.slug}`);
+ bannerCacheBust = Date.now();
+ await loadBannerList();
+ } catch (e) {
+ addNotification('ERR:' + (e instanceof Error ? e.message : 'Replace failed'));
+ } finally {
+ uploadingSlug = null;
+ }
+ }
+
+ // Push the banner that's already on the server to the connected machine
+ // (e.g. machine wasn't connected when the banner was replaced).
+ async function pushBannerToMachine(promo: PromoBanner) {
+ if (!AdbInstance.instance) {
+ addNotification('ERR:Machine not connected');
+ return;
+ }
+ if (!promo.banner) {
+ addNotification('WARN:No banner to push');
+ return;
+ }
+ pushingSlug = promo.slug;
+ try {
+ // Fetch via server-side proxy (avoids CORS on the image server).
+ const res = await fetch(`/api/catalog-banner-image?path=${encodeURIComponent(promo.banner)}`);
+ if (!res.ok) throw new Error(`Fetch banner failed (${res.status})`);
+ const bytes = new Uint8Array(await res.arrayBuffer());
+
+ const machinePath = `/sdcard/coffeevending/taobin_project/${promo.banner}`;
+ const parentDir = machinePath.slice(0, machinePath.lastIndexOf('/'));
+ await adb.executeCmd(`mkdir -p "${parentDir}"`);
+ await adb.pushBinary(machinePath, bytes);
+ addNotification(`Pushed banner to machine: ${promo.slug}`);
+ } catch (e) {
+ addNotification('ERR:' + (e instanceof Error ? e.message : 'Push failed'));
+ } finally {
+ pushingSlug = null;
+ }
+ }
-
- {#if loading}
-
- {:else}
-
- {/if}
- Refresh
-
+
+
+
+ Banners
+
+
(createOpen = true)}>
+
+ New Promo
+
+
+ {#if loading}
+
+ {:else}
+
+ {/if}
+ Refresh
+
+
@@ -159,10 +622,7 @@
type="single"
value={selectedCountry}
onValueChange={(v) => {
- if (v) {
- selectedCountry = v;
- goto(`/sheet/overview/${v}`);
- }
+ if (v) void handleCountryChange(v);
}}
>
{:else if error}
-
+
{error}
@@ -219,7 +681,9 @@
variant="secondary"
class="inline-flex shrink-0 items-center gap-1 rounded-full border-yellow-500/30 bg-yellow-500/15 px-2.5 py-1 text-xs text-yellow-700 dark:text-yellow-200"
>
-
+
Main
{/if}
@@ -277,3 +741,547 @@
+
+ {
+ if (!open) resetCreateForm();
+ }}
+>
+
+
+
+
+
+
+
+
+ New Promo Catalog
+
+ Create a promo page, banner & event for {selectedCountry.toUpperCase()}
+
+
+
+
+
+
+
+
+ {#if AdbInstance.instance}
+
+
+ Machine connected
+
+ {:else}
+
+
+ Connect required
+
+ {/if}
+
+ {#if !AdbInstance.instance}
+
+ {#if adbConnecting}
+
+ Connecting…
+ {:else}
+
+ Connect
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Banner image
+
+ {#if bannerSizeWarning}
+
+ {bannerSizeWarning}
+
+ {/if}
+
+ Recommended 484 × 616 px
+
+
+
+
{
+ e.preventDefault();
+ bannerDragging = true;
+ }}
+ ondragleave={() => (bannerDragging = false)}
+ ondrop={onBannerDrop}
+ >
+ {#if bannerPreview}
+
+ {
+ e.preventDefault();
+ setBanner(null);
+ }}
+ aria-label="Remove banner"
+ >
+
+
+ {:else}
+
+
+
+
+
+
Drop a banner here
+
or click to browse image files
+
+
+ {/if}
+
+
+ {#if bannerFile}
+
Selected: {bannerFile.name}
+ {/if}
+
+
+
+
Generated files
+ {#if slugPreview}
+
+
+ File
+ {filePreview}
+
+
+ State
+ {tagPreview}State
+
+
+ {:else}
+
+ Enter a promo name to preview generated output.
+
+ {/if}
+
+
+
+
+
+
+ Promo name
+ onNameInput((e.target as HTMLInputElement).value)}
+ />
+
+
+
+
Slug
+
+
+ Lowercase letters, numbers and underscore only.
+
+
+
+
+
+
+
+
Schedule
+
Choose when this promo banner appears.
+
+
+ Today
+ Tomorrow
+
+
+
+
+
+
Start
+
+
+
+ openCalendarFor(createForm.start)}
+ >
+
+ {formatDisplayDate(createForm.start)}
+
+
+
+
+
moveCalendarMonth(-1)}
+ >
+
+
+
+ {monthFormatter.format(calendarMonth)}
+
+
moveCalendarMonth(1)}
+ >
+
+
+
+
+ {#each weekdayLabels as day}
+
{day}
+ {/each}
+
+
+ {#each getCalendarDays() as day, index (`start-${index}-${day?.toISOString() ?? 'blank'}`)}
+ {#if day}
+
selectCalendarDate('start', day)}
+ >
+ {day.getDate()}
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
+ setDateTimeField('start', 'time', (e.target as HTMLInputElement).value)}
+ />
+
+
+
+
+
+ End optional
+ (createForm.end = '')}>No end
+
+
+
+
+ openCalendarFor(createForm.end)}
+ >
+
+ {formatDisplayDate(createForm.end)}
+
+
+
+
+
moveCalendarMonth(-1)}
+ >
+
+
+
+ {monthFormatter.format(calendarMonth)}
+
+
moveCalendarMonth(1)}
+ >
+
+
+
+
+ {#each weekdayLabels as day}
+
{day}
+ {/each}
+
+
+ {#each getCalendarDays() as day, index (`end-${index}-${day?.toISOString() ?? 'blank'}`)}
+ {#if day}
+
selectCalendarDate('end', day)}
+ >
+ {day.getDate()}
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
+ setDateTimeField('end', 'time', (e.target as HTMLInputElement).value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
(createOpen = false)} disabled={creating}>
+ Cancel
+
+
+ {#if creating}
+
+ Creating…
+ {:else}
+
+ Create & Push
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Promo Banners
+
+ Banner images for {selectedCountry.toUpperCase()} — replace
+ & push to the machine
+
+
+
+
+
+
+
+
+ {#if AdbInstance.instance}
+
+
+ Machine connected
+
+ {:else}
+
+
+ Machine not connected
+
+ {/if}
+
+ {#if !AdbInstance.instance}
+
+ {#if adbConnecting}
+
+ Connecting…
+ {:else}
+
+ Connect
+ {/if}
+
+ {/if}
+
+ {#if !AdbInstance.instance}
+
+ Not connected — replacing a banner will only update the server. Connect to also push it to
+ the machine.
+
+ {/if}
+
+
+
+
+ {#if bannerListLoading}
+
+
+
+ {:else if bannerList.length === 0}
+
+ No web-created promos found for {selectedCountry.toUpperCase()}
+
+ {:else}
+ {#each bannerList as promo (promo.slug)}
+
+
+ {#if promo.banner}
+
+ {:else}
+
+ {/if}
+
+
+
+
{promo.slug}
+ {#if promo.start}
+
+ {promo.start?.slice(0, 10)} → {promo.end && promo.end !== 'NONE'
+ ? promo.end.slice(0, 10)
+ : 'no end'}
+
+ {/if}
+
+
+ {#if promo.banner}
+ pushBannerToMachine(promo)}
+ >
+ {#if pushingSlug === promo.slug}
+
+ Pushing…
+ {:else}
+
+ Push
+ {/if}
+
+ {/if}
+
+ {#if uploadingSlug === promo.slug}
+
+ Uploading…
+ {:else}
+
+ Replace
+ {/if}
+ onReplaceBanner(promo, e)}
+ />
+
+
+
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ Refresh
+
+ (bannersOpen = false)}>Done
+
+
+
diff --git a/src/routes/(authed)/sheet/price/[country]/+page.svelte b/src/routes/(authed)/sheet/price/[country]/+page.svelte
new file mode 100644
index 0000000..3667b69
--- /dev/null
+++ b/src/routes/(authed)/sheet/price/[country]/+page.svelte
@@ -0,0 +1,280 @@
+
+
+
+
+
+
+
+ Price [ {selectedCountry.toUpperCase()} ]
+
+
View main Price sheet data for this country.
+
+
+
+ {#if saving}
+
+ Saving
+ {:else}
+
+ Save Changes ({getChangedUpdates().length})
+ {/if}
+
+
+ {#if loading}
+
+ Loading
+ {:else}
+
+ Refresh
+ {/if}
+
+
+
+
+
+
+
+ {#if loading && rows.length === 0}
+
+
+ Loading Price data...
+
+ {:else if rows.length === 0}
+
+ No Price data loaded. Click Refresh to load data.
+
+ {:else if filteredRows.length === 0}
+
+ No rows match your search.
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
index 86a2542..13f26b2 100644
--- a/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
+++ b/src/routes/(authed)/sheet/priceslot/[country]/+page.svelte
@@ -8,16 +8,34 @@
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
import {
- clearSheetPriceSentTypes,
+ existingProductCodes,
+ getPriceColumnIndex,
getCountryPrimaryLanguage,
getPriceFromCells,
lastRequestSheetPrice,
+ priceSlotNamespaces,
+ priceSlots,
+ priceSlotsError,
+ priceSlotsLoading,
+ sheetPriceAllRows,
+ sheetPriceHeader,
sheetPriceLoading,
+ type GristCell,
type PriceSlot,
- type PriceSlotProduct
+ type PriceSlotProduct,
+ type PriceSlotServiceRow
} from '$lib/core/stores/sheetStore.js';
- import { requestSheetPrice } from '$lib/core/services/sheetService.js';
- import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
+ import {
+ addPriceSlot,
+ addPriceSlotRows,
+ deletePriceSlotRows,
+ requestListMenu,
+ requestPriceSlot,
+ requestPriceSlots,
+ requestSheetPrice,
+ updatePriceSlot
+ } from '$lib/core/services/sheetService.js';
+ import { waitForAuthenticatedSocket } from '$lib/core/stores/websocketStore.js';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
@@ -25,100 +43,106 @@
import * as Select from '$lib/components/ui/select/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import Badge from '$lib/components/ui/badge/badge.svelte';
- import { Calculator, RefreshCw, Save, RotateCcw, Search } from '@lucide/svelte/icons';
+ import {
+ Calculator,
+ Plus,
+ RefreshCw,
+ Save,
+ RotateCcw,
+ Search,
+ Trash2
+ } from '@lucide/svelte/icons';
type AdjustmentMode =
| 'increase_percent'
| 'increase_amount'
- | 'decrease_amount'
- | 'decrease_percent';
+ | 'decrease_percent'
+ | 'decrease_amount';
const adjustmentModeLabels: Record = {
increase_percent: 'Increase by Percentage (%)',
increase_amount: 'Increase by Fixed Amount',
- decrease_amount: 'Decrease by Fixed Amount',
- decrease_percent: 'Decrease by Percentage (%)'
+ decrease_percent: 'Decrease by Percentage (%)',
+ decrease_amount: 'Decrease by Fixed Amount'
};
- const mockProducts: PriceSlotProduct[] = [
- {
- product_code: '12-01-01-0001',
- name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน',
- price: 30,
- row_index: 2
- },
- { product_code: '12-01-01-0003', name: 'HOT AMERICANO | กาแฟดำร้อน', price: 35, row_index: 3 },
- { product_code: '12-01-01-0004', name: 'HOT LATTE | ลาเต้ร้อน', price: 40, row_index: 5 },
- { product_code: '12-01-01-0006', name: 'HOT MOCHA | มอคค่าร้อน', price: 55, row_index: 7 },
- {
- product_code: '12-01-02-0001',
- name: 'Iced AMERICANO | กาแฟดำเย็น',
- price: 40,
- row_index: 16
- },
- { product_code: '12-01-02-0002', name: 'ICED LATTE | ลาเต้เย็น', price: 50, row_index: 17 },
- { product_code: '12-01-02-0003', name: 'ICED MOCHA | มอคค่าเย็น', price: 60, row_index: 18 },
- {
- product_code: '12-02-01-0002',
- name: 'Hot THAI MILK TEA | ชาไทยร้อน',
- price: 40,
- row_index: 27
- },
- {
- product_code: '12-02-01-0004',
- name: 'Hot MATCHA LATTE | มัทฉะลาเต้ร้อน',
- price: 50,
- row_index: 29
- }
- ];
-
- function buildMockSlots(): PriceSlot[] {
- return Array.from({ length: 10 }, (_, index) => {
- const slot = index + 1;
- const increase = slot === 1 ? 15 : slot === 2 ? 25 : slot * 5;
-
- return {
- slot,
- name: slot <= 2 ? `ProfileIncrease${increase}` : `PriceSlot${slot}`,
- description: slot <= 2 ? `increase price ${increase}%` : '',
- products: mockProducts.map((product) => ({
- ...product,
- price:
- product.price === null
- ? null
- : slot <= 2
- ? Math.ceil((product.price * (1 + increase / 100)) / 5) * 5
- : product.price
- }))
- };
- });
- }
+ const emptySlot: PriceSlot = {
+ slot: 0,
+ name: '',
+ description: '',
+ kind: 'price',
+ header: [],
+ products: []
+ };
+ const rowsPerPage = 200;
+ const supportedCountries = new Set([
+ 'aus',
+ 'gbr',
+ 'hkg',
+ 'ltu',
+ 'mys',
+ 'rou',
+ 'sgp',
+ 'tha',
+ 'uae_dubai'
+ ]);
let selectedCountry = $state($page.params.country || get(departmentStore) || '');
let enabledCountries = $state([]);
- let selectedSlot = $state(1);
- const initialSlots = buildMockSlots();
- let slots = $state(initialSlots);
- let savedSnapshot = $state(structuredClone(initialSlots));
- let loading = $state(false);
+ let selectedSlot = $state(0);
+ let localSlots = $state([]);
+ let workingSlot = $state(null);
+ let savedSlot = $state(null);
+ let saveLoading = $state(false);
let productCodeSearch = $state('');
let createDialogOpen = $state(false);
let adjustmentMode = $state('increase_percent');
let adjustmentValue = $state(15);
+ let createSlotNumber = $state(1);
let createName = $state('ProfileIncrease15');
let createDescription = $state('increase price 15%');
+ let productPage = $state(1);
+ let servicePage = $state(1);
+ let requestedSlotKeys = new Set();
+ let requestedProductCodeCountries = new Set();
+ let requestedPriceCountries = new Set();
+ let pendingCreateDialog = $state(false);
+ let deletedProductRowIds = $state([]);
+ let createMode = $state<'from_price' | 'blank'>('from_price');
+ let addDialogOpen = $state(false);
+ let addProductCode = $state('');
+ let addProductName = $state('');
+ let addProductPrice = $state('');
- let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]);
+ let selectedCountryKey = $derived(selectedCountry.toLowerCase());
let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry));
+ let backendSlots = $derived($priceSlots[selectedCountryKey] ?? []);
+ let namespaceSlots = $derived($priceSlotNamespaces[selectedCountryKey] ?? []);
+ let displaySlots = $derived(
+ mergeDisplaySlots(namespaceSlots, backendSlots, localSlots).sort((a, b) => a.slot - b.slot)
+ );
+ let selectedSourceSlot = $derived(
+ displaySlots.find((slot) => slot.slot === selectedSlot) ?? displaySlots[0] ?? null
+ );
+ let currentSlot = $derived(workingSlot ?? emptySlot);
+ let isServiceSlot = $derived(currentSlot.kind === 'service');
+ let serviceHeaders = $derived(currentSlot.header?.filter(Boolean) ?? []);
+ let loading = $derived($priceSlotsLoading);
let basePriceCells = $derived(
$lastRequestSheetPrice[selectedCountry.toLowerCase()] ||
$lastRequestSheetPrice[selectedCountry] ||
{}
);
- let basePricesLoadedCount = $derived(
- mockProducts.filter((product) => getBasePrice(product) !== null).length
+ let basePriceRows = $derived(
+ $sheetPriceAllRows[selectedCountry.toLowerCase()] || $sheetPriceAllRows[selectedCountry] || {}
);
- let basePriceLoading = $derived($sheetPriceLoading);
+ let basePriceRowCount = $derived(
+ Object.values(basePriceRows).reduce((total, rows) => total + rows.length, 0)
+ );
+ let basePriceHeader = $derived(
+ $sheetPriceHeader[selectedCountry.toLowerCase()] || $sheetPriceHeader[selectedCountry] || []
+ );
+ let createPriceBaseLoading = $derived($sheetPriceLoading);
let filteredProducts = $derived(
currentSlot.products.filter((product) => {
const keyword = productCodeSearch.trim().toLowerCase();
@@ -127,12 +151,56 @@
return product.product_code.toLowerCase().includes(keyword);
})
);
- let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1]));
- let hasHeaderChanges = $derived(
- currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name ||
- currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description
+ let filteredServiceRows = $derived(
+ (currentSlot.serviceRows ?? []).filter((row) => {
+ const keyword = productCodeSearch.trim().toLowerCase();
+ if (!keyword) return true;
+
+ return Object.values(row.cells).some((value) => value.toLowerCase().includes(keyword));
+ })
);
- let hasChanges = $derived(changedCount > 0 || hasHeaderChanges);
+ let productPageCount = $derived(Math.max(1, Math.ceil(filteredProducts.length / rowsPerPage)));
+ let servicePageCount = $derived(Math.max(1, Math.ceil(filteredServiceRows.length / rowsPerPage)));
+ let pagedProducts = $derived(
+ filteredProducts.slice((productPage - 1) * rowsPerPage, productPage * rowsPerPage)
+ );
+ let pagedServiceRows = $derived(
+ filteredServiceRows.slice((servicePage - 1) * rowsPerPage, servicePage * rowsPerPage)
+ );
+ let changedCount = $derived(countChangedProducts(currentSlot, savedSlot ?? undefined));
+ let changedServiceCount = $derived(countChangedServiceRows(currentSlot, savedSlot ?? undefined));
+ let addedProductCount = $derived(countAddedProducts(currentSlot, savedSlot ?? undefined));
+ let deletedProductCount = $derived(deletedProductRowIds.length);
+ let totalChangedCount = $derived(
+ changedCount + changedServiceCount + addedProductCount + deletedProductCount
+ );
+ let isLocalSlot = $derived(localSlots.some((slot) => slot.slot === currentSlot.slot));
+ let visibleRowCount = $derived(
+ isServiceSlot ? filteredServiceRows.length : filteredProducts.length
+ );
+ let totalRowCount = $derived(
+ isServiceSlot ? (currentSlot.serviceRows?.length ?? 0) : currentSlot.products.length
+ );
+ let hasHeaderChanges = $derived(
+ currentSlot.name !== savedSlot?.name || currentSlot.description !== savedSlot?.description
+ );
+ let hasChanges = $derived(totalChangedCount > 0 || hasHeaderChanges || isLocalSlot);
+ let resetButtonTitle = $derived(
+ !currentSlot.slot
+ ? 'Select a PriceSlot first'
+ : !savedSlot
+ ? 'This PriceSlot has not been saved yet'
+ : !hasChanges
+ ? 'Reset is available after changing this PriceSlot'
+ : 'Discard unsaved changes and restore the last loaded values'
+ );
+
+ function getEnabledSheetCountries() {
+ return get(currentPerms)
+ .filter((x) => x.startsWith('document.write'))
+ .map((x) => x.split('.')[2])
+ .filter((country) => supportedCountries.has(country));
+ }
onMount(() => {
referenceFromPage.set('priceslot');
@@ -141,11 +209,149 @@
departmentStore.set(selectedCountry);
}
- const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
- enabledCountries = userPerms.map((x) => x.split('.')[2]);
+ enabledCountries = getEnabledSheetCountries();
+
+ if (selectedCountry) {
+ void loadPriceSlots();
+ }
});
+ let lastLoadedSlotSignature = $state('');
+
+ $effect(() => {
+ if (displaySlots.length === 0) {
+ workingSlot = null;
+ savedSlot = null;
+ lastLoadedSlotSignature = '';
+ return;
+ }
+
+ if (!displaySlots.some((slot) => slot.slot === selectedSlot)) {
+ selectedSlot = displaySlots[0]?.slot ?? 0;
+ return;
+ }
+
+ if (!selectedSourceSlot) return;
+
+ const signature = getSlotSignature(selectedSourceSlot);
+ if (signature === lastLoadedSlotSignature) return;
+ if (hasChanges && workingSlot?.slot === selectedSourceSlot.slot) return;
+
+ lastLoadedSlotSignature = signature;
+ workingSlot = clonePriceSlot(selectedSourceSlot);
+ savedSlot = clonePriceSlot(selectedSourceSlot);
+ deletedProductRowIds = [];
+ productCodeSearch = '';
+ productPage = 1;
+ servicePage = 1;
+ void ensureSelectedSlotLoaded(selectedSourceSlot);
+ });
+
+ $effect(() => {
+ if (!pendingCreateDialog) return;
+
+ if (basePriceRowCount > 0) {
+ pendingCreateDialog = false;
+ applyCreateTemplate();
+ createDialogOpen = true;
+ return;
+ }
+
+ if (!$sheetPriceLoading) {
+ const productCodes = getProductCodesForPriceBase();
+ if (productCodes.length > 0) void requestPriceBaseFromProductCodes(productCodes);
+ }
+ });
+
+ $effect(() => {
+ if (currentSlot.products.length === 0) return;
+ if (requestedPriceCountries.has(selectedCountryKey)) return;
+ if (isLocalSlot) return;
+ if (Object.keys(basePriceCells).length > 0) return;
+
+ const productCodes = currentSlot.products
+ .filter((p) => p.product_code)
+ .map((p) => p.product_code);
+ if (productCodes.length === 0) return;
+
+ void requestPriceBaseFromProductCodes(productCodes);
+ });
+
+ function getBaseProducts(): (PriceSlotProduct & { sourceCells: GristCell[] })[] {
+ return Object.entries(basePriceRows).flatMap(([productCode, rows]) =>
+ rows.map((row) => {
+ const productName = getCellValue(row.cells, 2);
+ const price = getPriceFromCells(selectedCountryKey, row.cells, 'cash_price');
+ const parsedPrice = Number(price);
+
+ return {
+ product_code: productCode,
+ name: productName,
+ price: Number.isNaN(parsedPrice) ? null : parsedPrice,
+ row_index: row.row,
+ sourceCells: row.cells
+ };
+ })
+ );
+ }
+
+ function mergeDisplaySlots(
+ namespaces: PriceSlot[],
+ loadedSlots: PriceSlot[],
+ draftSlots: PriceSlot[]
+ ) {
+ const merged = new Map();
+ for (const slot of namespaces) merged.set(slot.slot, slot);
+ for (const slot of loadedSlots) merged.set(slot.slot, slot);
+ for (const slot of draftSlots) merged.set(slot.slot, slot);
+ return Array.from(merged.values());
+ }
+
+ function getSlotSignature(slot: PriceSlot) {
+ return [
+ slot.slot,
+ slot.name,
+ slot.description,
+ slot.kind ?? '',
+ slot.products.length,
+ slot.serviceRows?.length ?? 0
+ ].join('|');
+ }
+
+ function isSlotDataLoaded(slot: PriceSlot) {
+ return (
+ (slot.header?.length ?? 0) > 0 ||
+ slot.products.length > 0 ||
+ (slot.serviceRows?.length ?? 0) > 0
+ );
+ }
+
+ async function ensureSelectedSlotLoaded(slot: PriceSlot) {
+ if (!slot.slot || isLocalSlot || isSlotDataLoaded(slot)) return;
+
+ const requestKey = `${selectedCountryKey}:${slot.slot}`;
+ if (requestedSlotKeys.has(requestKey)) return;
+
+ requestedSlotKeys.add(requestKey);
+ const sent = await requestPriceSlot(selectedCountry, slot.slot);
+ if (!sent) {
+ requestedSlotKeys.delete(requestKey);
+ addNotification(`ERR:Failed to request PriceSlot${slot.slot}`);
+ }
+ }
+
function getBasePrice(product: PriceSlotProduct): number | null {
+ const productWithSource = product as PriceSlotProduct & { sourceCells?: GristCell[] };
+ if (productWithSource.sourceCells?.length) {
+ const price = getPriceFromCells(
+ selectedCountryKey,
+ productWithSource.sourceCells,
+ 'cash_price'
+ );
+ const parsed = Number(price);
+ if (!Number.isNaN(parsed)) return parsed;
+ }
+
const cells = basePriceCells[product.product_code];
if (cells?.length > 0) {
const price = getPriceFromCells(selectedCountry.toLowerCase(), cells, 'cash_price');
@@ -153,10 +359,27 @@
if (!Number.isNaN(parsed)) return parsed;
}
- return product.price;
+ return null;
+ }
+
+ function getCellValue(cells: GristCell[], col: number) {
+ return String(cells.find((cell) => cell.coord?.col === col)?.value ?? '');
}
function getProductNames(product: PriceSlotProduct) {
+ const priceCells = basePriceCells[product.product_code];
+ if (priceCells?.length) {
+ const englishName = getCellValue(priceCells, 2);
+ const localName = getCellValue(priceCells, 3);
+
+ if (englishName) {
+ return {
+ english: englishName,
+ local: localName || englishName
+ };
+ }
+ }
+
const [englishName = '', localName = ''] = product.name.split('|').map((name) => name.trim());
return {
@@ -165,6 +388,10 @@
};
}
+ function clonePriceSlot(slot: PriceSlot): PriceSlot {
+ return JSON.parse(JSON.stringify(slot));
+ }
+
function calculateAdjustedPrice(basePrice: number | null): number | null {
if (basePrice === null) return null;
@@ -183,23 +410,6 @@
return Math.max(0, Math.round(nextPrice));
}
- async function loadBasePrices() {
- const productCodes = mockProducts.map((product) => product.product_code);
- if (productCodes.length === 0) return;
-
- const socket = await waitForOpenSocket();
- if (!socket) {
- addNotification('WARN:WebSocket not connected. Using local base price sample.');
- return;
- }
-
- clearSheetPriceSentTypes();
- const sent = requestSheetPrice(selectedCountry, productCodes);
- if (!sent) {
- addNotification('ERR:Failed to request base prices');
- }
- }
-
function applyCreateTemplate() {
const value = Number(adjustmentValue);
const formattedValue = Number.isNaN(value) ? 0 : value;
@@ -215,15 +425,135 @@
createDescription = `${isIncrease ? 'increase' : 'decrease'} price ${valueLabel}`;
}
- function openCreateDialog() {
- applyCreateTemplate();
+ function getNextPriceSlotNumber() {
+ return Math.max(0, ...displaySlots.map((slot) => slot.slot)) + 1;
+ }
+
+ function getCreateSlotNumber() {
+ const slotNumber = Number(createSlotNumber);
+ if (!Number.isInteger(slotNumber) || slotNumber <= 0) {
+ addNotification('WARN:PriceSlot number must be a positive integer');
+ return null;
+ }
+
+ if (displaySlots.some((slot) => slot.slot === slotNumber)) {
+ addNotification(`WARN:PriceSlot${slotNumber} already exists`);
+ return null;
+ }
+
+ return slotNumber;
+ }
+
+ async function openCreateDialog() {
+ createSlotNumber = getNextPriceSlotNumber();
+ createName = '';
+ createDescription = '';
+
+ if (basePriceRowCount > 0) {
+ createMode = 'from_price';
+ applyCreateTemplate();
+ createDialogOpen = true;
+ return;
+ }
+
+ createMode = 'blank';
createDialogOpen = true;
}
- function createPriceSlotFromBase() {
- const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1;
+ async function ensurePriceBaseRequested() {
+ const productCodes = getProductCodesForPriceBase();
+ if (productCodes.length > 0) {
+ await requestPriceBaseFromProductCodes(productCodes);
+ return;
+ }
- const products = mockProducts.map((product) => ({
+ if (requestedProductCodeCountries.has(selectedCountryKey)) {
+ addNotification('INFO:Waiting for product codes before loading Price data');
+ return;
+ }
+
+ requestedProductCodeCountries.add(selectedCountryKey);
+ const sent = await requestListMenu(selectedCountry);
+ if (sent) {
+ addNotification('INFO:Requesting product codes before loading Price data');
+ } else {
+ requestedProductCodeCountries.delete(selectedCountryKey);
+ pendingCreateDialog = false;
+ addNotification('ERR:Failed to request product codes');
+ }
+ }
+
+ function getProductCodesForPriceBase() {
+ const productCodes = new Set();
+
+ if (requestedProductCodeCountries.has(selectedCountryKey)) {
+ for (const code of $existingProductCodes) productCodes.add(code);
+ }
+
+ for (const product of currentSlot.products) {
+ if (product.product_code) productCodes.add(product.product_code);
+ }
+
+ if (productCodes.size === 0) {
+ for (const slot of backendSlots) {
+ for (const product of slot.products) {
+ if (product.product_code) productCodes.add(product.product_code);
+ }
+ }
+ }
+
+ return Array.from(productCodes);
+ }
+
+ async function requestPriceBaseFromProductCodes(productCodes = getProductCodesForPriceBase()) {
+ if (requestedPriceCountries.has(selectedCountryKey)) return;
+
+ if (productCodes.length === 0) return;
+
+ requestedPriceCountries.add(selectedCountryKey);
+ const sent = await requestSheetPrice(selectedCountry, productCodes, true);
+ if (sent) {
+ addNotification(`INFO:Requesting Price data for ${productCodes.length} product codes`);
+ } else {
+ requestedPriceCountries.delete(selectedCountryKey);
+ pendingCreateDialog = false;
+ addNotification('ERR:Failed to request Price data');
+ }
+ }
+
+ function createPriceSlotFromBase() {
+ const nextSlotNumber = getCreateSlotNumber();
+ if (!nextSlotNumber) return;
+
+ if (createMode === 'blank') {
+ const nextSlot: PriceSlot = {
+ slot: nextSlotNumber,
+ name: createName.trim() || `PriceSlot${nextSlotNumber}`,
+ description: createDescription.trim(),
+ kind: 'price',
+ header: [],
+ products: []
+ };
+
+ localSlots = [...localSlots, nextSlot];
+ selectedSlot = nextSlotNumber;
+ workingSlot = clonePriceSlot(nextSlot);
+ savedSlot = null;
+ lastLoadedSlotSignature = getSlotSignature(nextSlot);
+ productPage = 1;
+ servicePage = 1;
+ createDialogOpen = false;
+ addNotification(`INFO:Created blank PriceSlot${nextSlotNumber}`);
+ return;
+ }
+
+ const baseProducts = getBaseProducts();
+ if (baseProducts.length === 0) {
+ addNotification('WARN:Main Price data is not ready');
+ return;
+ }
+
+ const products = baseProducts.map((product) => ({
...product,
price: calculateAdjustedPrice(getBasePrice(product))
}));
@@ -232,12 +562,18 @@
slot: nextSlotNumber,
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
description: createDescription.trim(),
+ kind: 'price',
+ header: basePriceHeader,
products
};
- slots = [...slots, nextSlot];
- savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)];
+ localSlots = [...localSlots, nextSlot];
selectedSlot = nextSlotNumber;
+ workingSlot = clonePriceSlot(nextSlot);
+ savedSlot = null;
+ lastLoadedSlotSignature = getSlotSignature(nextSlot);
+ productPage = 1;
+ servicePage = 1;
createDialogOpen = false;
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
}
@@ -245,56 +581,432 @@
function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
if (!saved) return 0;
- return current.products.filter((product) => {
- const savedProduct = saved.products.find(
- (item) => item.product_code === product.product_code
+ return current.products.filter((product, index) => {
+ if (!product.row_index) return false;
+ const savedProduct =
+ saved.products.find(
+ (item) => product.row_index !== undefined && item.row_index === product.row_index
+ ) ??
+ saved.products[index] ??
+ saved.products.find((item) => item.product_code === product.product_code);
+ return (
+ savedProduct?.product_code !== product.product_code || savedProduct?.price !== product.price
);
- return savedProduct?.price !== product.price;
}).length;
}
- function updateSlotField(field: 'name' | 'description', value: string) {
- slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot));
+ function countAddedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
+ if (current.kind === 'service') return 0;
+ return current.products.filter((product, index) => {
+ if (product.row_index) return false;
+ const savedProduct = saved?.products[index];
+ return (
+ !savedProduct ||
+ savedProduct.product_code !== product.product_code ||
+ savedProduct.price !== product.price
+ );
+ }).length;
}
- function updateProductPrice(productCode: string, value: string) {
- const price = value === '' ? null : Number(value);
+ function countChangedServiceRows(current: PriceSlot, saved: PriceSlot | undefined): number {
+ if (!saved || current.kind !== 'service') return 0;
- slots = slots.map((slot) => {
- if (slot.slot !== selectedSlot) return slot;
+ return (current.serviceRows ?? []).filter((row, index) => {
+ const savedRow =
+ saved.serviceRows?.find((item) => item.row_index === row.row_index) ??
+ saved.serviceRows?.[index];
+ return JSON.stringify(row.cells) !== JSON.stringify(savedRow?.cells ?? {});
+ }).length;
+ }
+
+ function getChangedPriceRows(current: PriceSlot, saved: PriceSlot | undefined) {
+ return current.products
+ .map((product, index) => {
+ if (!product.row_index) return null;
+
+ const savedProduct =
+ saved?.products.find(
+ (item) => product.row_index !== undefined && item.row_index === product.row_index
+ ) ??
+ saved?.products[index] ??
+ saved?.products.find((item) => item.product_code === product.product_code);
+
+ const cells = [];
+ if (savedProduct?.product_code !== product.product_code) {
+ cells.push({
+ value: product.product_code,
+ coord: { row: product.row_index, col: 1 }
+ });
+ }
+ if (savedProduct?.price !== product.price) {
+ cells.push({
+ value: product.price === null ? '' : String(product.price),
+ coord: { row: product.row_index, col: 5 }
+ });
+ }
+
+ if (cells.length === 0) return null;
+
+ return {
+ row_index: product.row_index,
+ cells
+ };
+ })
+ .filter((row): row is NonNullable => row !== null);
+ }
+
+ function getAddedPriceSlotRows(slot: PriceSlot) {
+ const draftSlot = {
+ ...slot,
+ products: slot.products.filter((product) => !product.row_index)
+ };
+ return getCreatePriceSlotRows(draftSlot).map(({ cells }) => ({ cells }));
+ }
+
+ function getChangedServiceRows(current: PriceSlot, saved: PriceSlot | undefined) {
+ if (current.kind !== 'service') return [];
+
+ return (current.serviceRows ?? [])
+ .map((row, index) => {
+ const savedRow =
+ saved?.serviceRows?.find((item) => item.row_index === row.row_index) ??
+ saved?.serviceRows?.[index];
+ if (!row.row_index) return null;
+
+ const cells = serviceHeaders
+ .map((header, headerIndex) => {
+ const value = row.cells[header] ?? '';
+ if (value === (savedRow?.cells[header] ?? '')) return null;
+
+ return {
+ value,
+ coord: { row: row.row_index!, col: headerIndex + 1 }
+ };
+ })
+ .filter((cell): cell is NonNullable => cell !== null);
+
+ if (cells.length === 0) return null;
+
+ return { row_index: row.row_index, cells };
+ })
+ .filter((row): row is NonNullable => row !== null);
+ }
+
+ function getCreatePriceCellIndex() {
+ const priceColumnIndex = getPriceColumnIndex(selectedCountryKey, 'cash_price');
+ return priceColumnIndex > 0 ? priceColumnIndex - 1 : 4;
+ }
+
+ function getCreatePriceSlotHeader(slot: PriceSlot) {
+ const header = Array.from({ length: 26 }, () => '');
+ const priceCellIndex = getCreatePriceCellIndex();
+
+ header[0] = 'ProductCode';
+ header[1] = 'Name';
+ header[priceCellIndex] = basePriceHeader[priceCellIndex] || 'Price';
+ header[10] = slot.name;
+ header[11] = slot.description;
+
+ return header;
+ }
+
+ function getCreatePriceSlotRows(slot: PriceSlot) {
+ const header = getCreatePriceSlotHeader(slot);
+ const priceCellIndex = getCreatePriceCellIndex();
+
+ return slot.products.map((product, index) => {
+ const productWithSource = product as PriceSlotProduct & { sourceCells?: GristCell[] };
+ const sourceCells = productWithSource.sourceCells ?? [];
+ const cells = Array.from({ length: header.length }, () => '');
+ const englishName = getCellValue(sourceCells, 2) || product.name;
+ const localName = getCellValue(sourceCells, 3);
+
+ cells[0] = product.product_code;
+ cells[1] = englishName;
+ cells[2] = localName;
+ cells[priceCellIndex] = product.price === null ? '' : String(product.price);
return {
- ...slot,
- products: slot.products.map((product) =>
- product.product_code === productCode
- ? { ...product, price: Number.isNaN(price) ? product.price : price }
- : product
- )
+ header: index === 0 ? header : undefined,
+ cells
};
});
}
+ function updateSlotField(field: 'name' | 'description', value: string) {
+ if (!workingSlot) return;
+ workingSlot = { ...workingSlot, [field]: value };
+ }
+
+ function updateProductCode(
+ targetProduct: PriceSlotProduct,
+ fallbackIndex: number,
+ value: string
+ ) {
+ if (!workingSlot) return;
+
+ workingSlot = {
+ ...workingSlot,
+ products: workingSlot.products.map((product, index) => {
+ const sameProduct =
+ targetProduct.row_index !== undefined
+ ? product.row_index === targetProduct.row_index
+ : product === targetProduct || index === fallbackIndex;
+
+ return sameProduct ? { ...product, product_code: value } : product;
+ })
+ };
+ }
+
+ function updateProductPrice(
+ targetProduct: PriceSlotProduct,
+ fallbackIndex: number,
+ value: string
+ ) {
+ const price = value === '' ? null : Number(value);
+
+ if (!workingSlot) return;
+
+ workingSlot = {
+ ...workingSlot,
+ products: workingSlot.products.map((product, index) => {
+ const sameProduct =
+ targetProduct.row_index !== undefined
+ ? product.row_index === targetProduct.row_index
+ : product === targetProduct || index === fallbackIndex;
+
+ return sameProduct
+ ? { ...product, price: Number.isNaN(price) ? product.price : price }
+ : product;
+ })
+ };
+ }
+
+ function openAddDialog() {
+ if (!workingSlot || workingSlot.kind === 'service') return;
+ addProductCode = '';
+ addProductName = '';
+ addProductPrice = '';
+ addDialogOpen = true;
+ }
+
+ function confirmAddProductRow() {
+ if (!workingSlot || workingSlot.kind === 'service') return;
+
+ const code = addProductCode.trim();
+ if (!code) {
+ addNotification('WARN:Product code is required');
+ return;
+ }
+
+ const price = addProductPrice === '' ? null : Number(addProductPrice);
+
+ workingSlot = {
+ ...workingSlot,
+ products: [
+ ...workingSlot.products,
+ {
+ product_code: code,
+ name: addProductName.trim(),
+ price: Number.isNaN(price) ? null : price
+ }
+ ]
+ };
+
+ productPage = Math.max(1, Math.ceil(workingSlot.products.length / rowsPerPage));
+ addDialogOpen = false;
+ addNotification(`INFO:Added product code ${code}`);
+ }
+
+ function deleteProductRow(targetProduct: PriceSlotProduct, fallbackIndex: number) {
+ if (!workingSlot || workingSlot.kind === 'service') return;
+
+ if (targetProduct.row_index) {
+ deletedProductRowIds = Array.from(
+ new Set([...deletedProductRowIds, targetProduct.row_index])
+ );
+ }
+
+ workingSlot = {
+ ...workingSlot,
+ products: workingSlot.products.filter((product, index) => {
+ const sameProduct =
+ targetProduct.row_index !== undefined
+ ? product.row_index === targetProduct.row_index
+ : product === targetProduct || index === fallbackIndex;
+ return !sameProduct;
+ })
+ };
+ productPage = Math.min(
+ productPage,
+ Math.max(1, Math.ceil(workingSlot.products.length / rowsPerPage))
+ );
+ }
+
+ function updateServiceCell(
+ row: PriceSlotServiceRow,
+ fallbackIndex: number,
+ columnName: string,
+ value: string
+ ) {
+ if (!workingSlot) return;
+
+ workingSlot = {
+ ...workingSlot,
+ serviceRows: (workingSlot.serviceRows ?? []).map((serviceRow, index) => {
+ const sameRow =
+ row.row_index !== undefined
+ ? serviceRow.row_index === row.row_index
+ : serviceRow === row || index === fallbackIndex;
+ return sameRow
+ ? {
+ ...serviceRow,
+ cells: {
+ ...serviceRow.cells,
+ [columnName]: value
+ }
+ }
+ : serviceRow;
+ })
+ };
+ }
+
+ function getServiceColumnClass(columnName: string) {
+ const normalized = columnName.toLowerCase();
+ if (normalized === 'value') return 'min-w-[320px]';
+ if (normalized === 'desc') return 'min-w-[300px]';
+ if (normalized === 'l') return 'min-w-[360px]';
+ if (normalized.includes('schedule')) return 'min-w-[300px]';
+ if (normalized.includes('discount')) return 'min-w-[180px]';
+ if (normalized === 'type/key') return 'min-w-[150px]';
+ if (normalized === 'servicetype') return 'min-w-[130px]';
+ if (normalized === 'daytype') return 'min-w-[130px]';
+ if (normalized === 'command') return 'min-w-[130px]';
+ if (normalized === 'extendvalue') return 'min-w-[130px]';
+ if (normalized === 'time(24 hr)' || normalized === 'time( 24 hr)') return 'min-w-[130px]';
+ if (['year', 'month', 'day'].includes(normalized)) return 'min-w-[110px]';
+ return 'min-w-[120px]';
+ }
+
+ function getServiceInputClass(columnName: string) {
+ const normalized = columnName.toLowerCase();
+ const textClass = normalized === 'l' || normalized.includes('schedule') ? '' : 'font-mono';
+ return ['h-10 w-full min-w-0 text-sm', textClass].filter(Boolean).join(' ');
+ }
+
+ function getServiceTableMinWidth() {
+ return serviceHeaders.reduce((total, header) => {
+ const normalized = header.toLowerCase();
+ if (normalized === 'value') return total + 320;
+ if (normalized === 'desc') return total + 300;
+ if (normalized === 'l') return total + 360;
+ if (normalized.includes('schedule')) return total + 300;
+ if (normalized.includes('discount')) return total + 180;
+ if (['type/key', 'servicetype', 'daytype', 'command', 'extendvalue'].includes(normalized)) {
+ return total + 140;
+ }
+ return total + 120;
+ }, 0);
+ }
+
function resetSlot() {
- const savedSlot = savedSnapshot[selectedSlot - 1];
if (!savedSlot) return;
- slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot));
+ workingSlot = clonePriceSlot(savedSlot);
+ deletedProductRowIds = [];
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
}
- function saveSlot() {
- savedSnapshot = savedSnapshot.map((slot) =>
- slot.slot === selectedSlot ? structuredClone(currentSlot) : slot
- );
- addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.');
+ async function saveSlot() {
+ if (!currentSlot.slot) {
+ addNotification('WARN:No PriceSlot selected');
+ return;
+ }
+ if (saveLoading) return;
+
+ const content =
+ currentSlot.kind === 'service'
+ ? getChangedServiceRows(currentSlot, savedSlot ?? undefined)
+ : getChangedPriceRows(currentSlot, savedSlot ?? undefined);
+ const createContent =
+ isLocalSlot && currentSlot.kind !== 'service' ? getCreatePriceSlotRows(currentSlot) : [];
+ const addContent =
+ !isLocalSlot && currentSlot.kind !== 'service' ? getAddedPriceSlotRows(currentSlot) : [];
+ const deleteRowIds = currentSlot.kind !== 'service' ? deletedProductRowIds : [];
+ if (
+ !isLocalSlot &&
+ content.length === 0 &&
+ addContent.length === 0 &&
+ deleteRowIds.length === 0
+ ) {
+ addNotification('WARN:No row changes to save');
+ return;
+ }
+ if (isLocalSlot && createContent.length === 0) {
+ addNotification('WARN:No PriceSlot rows to create');
+ return;
+ }
+
+ saveLoading = true;
+ try {
+ const slotSnapshot = clonePriceSlot(currentSlot);
+ const updateSent =
+ isLocalSlot || content.length === 0
+ ? true
+ : await updatePriceSlot(selectedCountry, slotSnapshot, content);
+ const addSent = isLocalSlot
+ ? await addPriceSlot(selectedCountry, slotSnapshot, createContent)
+ : await addPriceSlotRows(selectedCountry, slotSnapshot, addContent);
+ const deleteSent = await deletePriceSlotRows(selectedCountry, slotSnapshot, deleteRowIds);
+
+ if (!updateSent || !addSent || !deleteSent) {
+ addNotification('ERR:Failed to send PriceSlot update');
+ return;
+ }
+
+ const savedSlotNumber = selectedSlot;
+ savedSlot = clonePriceSlot(currentSlot);
+ deletedProductRowIds = [];
+ localSlots = localSlots.filter((slot) => slot.slot !== savedSlotNumber);
+ requestedSlotKeys.delete(`${selectedCountryKey}:${savedSlotNumber}`);
+ selectedSlot = savedSlotNumber;
+ addNotification(`INFO:PriceSlot${savedSlotNumber} update sent`);
+
+ const refreshed = await requestPriceSlot(selectedCountry, savedSlotNumber);
+ if (!refreshed) {
+ addNotification(`WARN:PriceSlot${savedSlotNumber} saved but refresh failed`);
+ }
+ } catch (error) {
+ console.error('[PriceSlot] Failed to save PriceSlot', error);
+ addNotification('ERR:Failed to send PriceSlot update');
+ } finally {
+ saveLoading = false;
+ }
}
- function loadPriceSlots() {
- loading = true;
- setTimeout(() => {
- loading = false;
- addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.');
- }, 250);
+ async function loadPriceSlots() {
+ localSlots = [];
+ workingSlot = null;
+ savedSlot = null;
+ selectedSlot = 0;
+ requestedSlotKeys = new Set();
+ pendingCreateDialog = false;
+ deletedProductRowIds = [];
+ priceSlotsLoading.set(true);
+ priceSlotsError.set(null);
+
+ const socket = await waitForAuthenticatedSocket();
+ if (!socket) {
+ priceSlotsLoading.set(false);
+ addNotification('ERR:WebSocket auth not ready');
+ return;
+ }
+
+ const sent = await requestPriceSlots(selectedCountry);
+ if (!sent) {
+ priceSlotsLoading.set(false);
+ addNotification('ERR:Failed to request PriceSlot data');
+ }
}
@@ -348,144 +1060,305 @@
-
- {#each slots as slot}
-
(selectedSlot = slot.slot)}
+
+
+ {#if displaySlots.length > 0}
+ {#each displaySlots as slot}
+
{
+ selectedSlot = slot.slot;
+ productPage = 1;
+ servicePage = 1;
+ }}
+ >
+ PriceSlot{slot.slot}
+
+ {/each}
+ {:else}
+
+ {loading ? 'Loading PriceSlot data...' : 'No PriceSlot data loaded'}
+
+ {/if}
+
+
+
+
- PriceSlot{slot.slot}
-
- {/each}
-
-
-
-
-
-
-
PriceSlot{selectedSlot}
-
- {hasChanges ? `${changedCount} changes` : 'No changes'}
-
+
+
+
+ {currentSlot.slot ? `PriceSlot${selectedSlot}` : 'No PriceSlot'}
+
+
+ {hasChanges ? `${totalChangedCount} changes` : 'No changes'}
+
+
+
-
-
-
- Name
- updateSlotField('name', event.currentTarget.value)}
- />
-
-
-
-
- Description
-
- updateSlotField('description', event.currentTarget.value)}
- />
-
-
-
-
-
- Create PriceSlot
-
-
-
- Save Draft
-
-
-
- Reset
-
-
-
-
-
-
-
-
-
Search ProductCode
-
-
+
+ Name
updateSlotField('name', event.currentTarget.value)}
/>
+
+
+
+ Description
+
+ updateSlotField('description', event.currentTarget.value)}
+ />
+
+
+
+
+
+
+
+ {createPriceBaseLoading ? 'Loading Price...' : 'Create PriceSlot'}
+
+
+
+ {saveLoading ? 'Saving...' : 'Save'}
+
+
+
+ Reset
+
+
+
+ Reset discards unsaved changes for the selected PriceSlot.
+
+
+
-
- Showing {filteredProducts.length} of {currentSlot.products.length} products
-
-
-
-
-
- ProductCode
- ProductName [{selectedCountryLanguage}]
- ProductNameEng
- Price
-
-
-
- {#each filteredProducts as product (product.product_code)}
- {@const productNames = getProductNames(product)}
-
-
- {product.product_code}
-
- {productNames.local}
- {productNames.english}
-
-
- updateProductPrice(product.product_code, event.currentTarget.value)}
- />
-
-
- {/each}
- {#if filteredProducts.length === 0}
-
-
- No product code found.
-
-
+
+
+
+
+ {isServiceSlot ? 'Search ServiceType' : 'Search ProductCode'}
+
+
+
+ {
+ productCodeSearch = event.currentTarget.value;
+ productPage = 1;
+ servicePage = 1;
+ }}
+ />
+
+
+
+ {#if !isServiceSlot && currentSlot.slot}
+
+
+ Add ProductCode
+
{/if}
-
-
+
+ Showing {isServiceSlot ? pagedServiceRows.length : pagedProducts.length} of
+ {visibleRowCount} filtered / {totalRowCount}
+ {isServiceSlot ? 'service rows' : 'products'}
+
+
{
+ if (isServiceSlot) servicePage = Math.max(1, servicePage - 1);
+ else productPage = Math.max(1, productPage - 1);
+ }}
+ >
+ Prev
+
+
+ Page {isServiceSlot ? servicePage : productPage} / {isServiceSlot
+ ? servicePageCount
+ : productPageCount}
+
+
= servicePageCount
+ : productPage >= productPageCount}
+ onclick={() => {
+ if (isServiceSlot) servicePage = Math.min(servicePageCount, servicePage + 1);
+ else productPage = Math.min(productPageCount, productPage + 1);
+ }}
+ >
+ Next
+
+
+
+
+
+ {#if loading && displaySlots.length === 0}
+
+ Loading PriceSlot data...
+
+ {:else if $priceSlotsError}
+
+ {$priceSlotsError}
+
+ {:else if displaySlots.length === 0}
+
+ No PriceSlot data from backend.
+
+ {:else if currentSlot.slot && !isServiceSlot && currentSlot.products.length === 0}
+
+ PriceSlot{currentSlot.slot} data is not loaded yet.
+
+ {:else if currentSlot.slot && isServiceSlot && (currentSlot.serviceRows?.length ?? 0) === 0}
+
+ PriceSlot{currentSlot.slot} data is not loaded yet.
+
+ {:else if isServiceSlot}
+
+
+
+
+ {#each serviceHeaders as header}
+ {header}
+ {/each}
+
+
+
+ {#each pagedServiceRows as row, index (`${row.row_index ?? index}-${index}`)}
+
+ {#each serviceHeaders as header}
+
+
+ updateServiceCell(row, index, header, event.currentTarget.value)}
+ />
+
+ {/each}
+
+ {/each}
+ {#if filteredServiceRows.length === 0}
+
+
+ No service rows found.
+
+
+ {/if}
+
+
+
+ {:else}
+
+ {/if}
+
@@ -493,68 +1366,96 @@
Create PriceSlot
-
- Choose how to adjust base prices before creating a new PriceSlot.
-
+ Choose how to create a new PriceSlot.
-
+ {/if}
-
- Adjustment Mode
- {
- if (v) adjustmentMode = v as AdjustmentMode;
- applyCreateTemplate();
- }}
- >
-
- {adjustmentModeLabels[adjustmentMode]}
-
-
- Increase by Percentage (%)
- Increase by Fixed Amount
- Decrease by Percentage (%)
- Decrease by Fixed Amount
-
-
-
-
-
-
- Adjustment Value
- {adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent'
- ? '(%)'
- : ''}
-
- {
- adjustmentValue = Number(event.currentTarget.value);
- applyCreateTemplate();
- }}
- />
-
-
Name
+
+
+
+
+ Add ProductCode
+
+ Enter the product code, name, and price for the new row.
+
+
+
+
+
+
+ (addDialogOpen = false)}>Cancel
+
+
+ Add
+
+
+
+
diff --git a/src/routes/(authed)/tools/adv-upload/+page.svelte b/src/routes/(authed)/tools/adv-upload/+page.svelte
index 613e46b..adc7dde 100644
--- a/src/routes/(authed)/tools/adv-upload/+page.svelte
+++ b/src/routes/(authed)/tools/adv-upload/+page.svelte
@@ -1,143 +1,119 @@
-
-
+
-
-
-
-
Adv Upload
-
Upload advertisement videos (.mp4) to the server
-
+
+
+
+
+
+
+
+
+
Advertisement Upload
+
+
+
-
-
- {isAdbConnected ? 'Machine connected' : 'Machine offline'}
-
+
+
+
+ {isAdbConnected ? 'Machine connected' : 'Machine offline'}
+
- {#if !isAdbConnected}
-
- {#if connecting}
-
- Connecting...
- {:else}
-
- Connect Machine
- {/if}
-
- {/if}
+ {#if !isAdbConnected}
+
+ {#if connecting}
+
+ Connecting...
+ {:else}
+
+ Connect
+ {/if}
+
+ {/if}
- {#if files.length > 0}
-
- Clear All
+ {#if pushingToMachine}
+
+ Pushing {pushProgress.current}/{pushProgress.total}...
+ {:else}
+
+ Push to Machine{filledCount > 0 ? ` (${filledCount})` : ''}
+ {/if}
- {/if}
-
-
- {#if pushingToMachine}
-
- Pushing {pushProgress.current}/{pushProgress.total}...
- {:else}
-
- Push to Machine ({validVideoCount})
- {/if}
-
+ (showScreenMirror = true)}>
+ Show Android Screen
+
-
-
- {#if uploading}
-
- Uploading {uploadProgress.current}/{uploadProgress.total}...
- {:else}
-
- Upload ({uploadableCount})
- {/if}
-
+
+
+ {COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Country'}
+
+
+ {#each COUNTRIES as country}
+ {country.label}
+ {/each}
+
+
+
+
+ {#if uploadingAll}
+
+ Uploading...
+ {:else}
+
+ Upload all{pendingCount > 0 ? ` (${pendingCount})` : ''}
+ {/if}
+
+
+
+
+ {#if pushingToMachine}
+
+
+
+ Pushing {pushProgress.name} · {pushProgress.percent}%
+
+
+ {/if}
-
+
-
-
-
-
-
- Upload Settings
-
-
-
- Country
-
-
- {COUNTRIES.find((c) => c.value === selectedCountry)?.label || 'Select country'}
-
-
- {#each COUNTRIES as country}
- {country.label}
- {/each}
-
-
-
-
-
-
-
-
-
-
-
- Naming & Size Rules
-
-
-
-
-
taobin_adv_menu_*.mp4
-
Menu banner ad
-
1080 × 380
-
-
-
taobin_adv_*.mp4
-
Fullscreen / Idle ad
-
1080 × 608
-
-
-
-
-
-
-
-
-
-
-
- Drop .mp4 videos here or click to browse
+
+
+
+
+
+
+
Slots
- Filenames must follow the taobin_adv_*.mp4 convention
+ Choose a video per slot — it's renamed to the slot's fixed name on upload.
-
-
-
-
-
- {#if pushingToMachine}
-
-
0 ? (pushProgress.current / pushProgress.total) * 100 : 0}
- max={100}
- class="h-2"
- />
-
-
- Sending to machine ({pushProgress.current}/{pushProgress.total}): {pushProgress.name}
-
+
+ {#if onServerCount > 0}
+
+
+ {onServerCount} on server
+
+ {/if}
- {/if}
-
- {#if uploading}
-
-
0
- ? (uploadProgress.current / uploadProgress.total) * 100
- : 0}
- max={100}
- class="h-2"
- />
-
- Uploading: {uploadProgress.current} / {uploadProgress.total}
-
-
- {/if}
+
+ {#each slots as slot (slot.index)}
+
+
+
+ {String(slot.index).padStart(2, '0')}
+
-
- {#if generatingManifest}
-
-
- Generating sync_1.file on machine and uploading...
-
- {/if}
-
-
- {#if files.length > 0}
-
-
-
- Selected Files ({files.length})
-
- {#if files.some((f) => f.status === 'success')}
-
- {files.filter((f) => f.status === 'success').length} uploaded
-
- {/if}
- {#if files.some((f) => f.status === 'error')}
-
- {files.filter((f) => f.status === 'error').length} invalid/failed
-
+
+
+ {#if slot.preview}
+
+
+ {#if slot.status === 'uploading'}
+
+
+
+ {:else if slot.status === 'success'}
+
+
+
+ {/if}
+ {:else if thumbUrls[slot.name]}
+
+
+ {
+ const el = e.currentTarget as HTMLInputElement;
+ pickSlotFile(slot.index, el.files);
+ el.value = '';
+ }}
+ />
+
+
+
+ Replace
+
+
+ {:else}
+
+ {
+ const el = e.currentTarget as HTMLInputElement;
+ pickSlotFile(slot.index, el.files);
+ el.value = '';
+ }}
+ />
+ {#if serverFilesByName[slot.name] !== undefined}
+
+ Loading…
+ {:else}
+
+ Choose
+ {/if}
+
{/if}
-
-
-
-
- {#each files as item (item.id)}
-
-
-
-
-
- {#if item.status === 'uploading'}
-
-
-
- {:else if item.status === 'success'}
-
-
-
- {/if}
-
-
- {#if item.status !== 'uploading'}
-
removeFile(item.id)}
- disabled={uploading}
- >
-
-
- {/if}
-
-
-
-
-
- {item.file.name}
-
-
-
- {item.category === 'menu' ? 'Menu banner' : 'Fullscreen'}
+
+
+
+ {slot.name}
+
+ {#if slot.file}
+
+ {#if !slot.dimChecked}
+ Reading…
+ {:else if slot.dimOk}
+
+ {slot.width}×{slot.height}
+
+ {:else}
+
+ {slot.width ?? '?'}×{slot.height ?? '?'} · exp {MENU_SPEC.width}×{MENU_SPEC.height}
- {#if !item.dimChecked}
- Reading…
- {:else if item.dimOk}
-
- {item.width}×{item.height}
-
- {:else}
-
- {item.width ?? '?'}×{item.height ?? '?'}
-
- {/if}
-
- {(item.file.size / (1024 * 1024)).toFixed(1)} MB
-
-
-
- Expected {item.expectedWidth}×{item.expectedHeight}
-
- {#if item.error}
-
-
- {item.error}
-
{/if}
+
{formatBytes(slot.file.size)}
-
- {/each}
+ {#if slot.error}
+
+
+ {slot.error}
+
+ {/if}
+ {:else}
+
Empty · no file selected
+ {/if}
+
+
+ {#if serverFilesByName[slot.name] !== undefined}
+
+
+
+ On server · {formatBytes(serverFilesByName[slot.name])}
+
+ viewCurrent(slot.name)}
+ disabled={loadingVideoName === slot.name}
+ >
+ {#if loadingVideoName === slot.name}
+
+ Loading
+ {:else}
+
+ View video
+ {/if}
+
+
+ {/if}
+
+
+
+
+ {#if slot.file}
+ uploadSlot(slot.index)}
+ disabled={anyBusy || slot.status === 'uploading'}
+ >
+ {#if slot.status === 'uploading'}
+
+ {:else if slot.status === 'success'}
+
+ Re-upload
+ {:else}
+
+ Upload
+ {/if}
+
+ {#if slot.status !== 'uploading'}
+ clearSlot(slot.index)}
+ aria-label="Remove"
+ >
+
+
+ {/if}
+ {/if}
+
-
-
- {/if}
+ {/each}
+
+
+
+
+
+
+
+
Videos on server
+
+ Currently on the FTP folder for {selectedCountry} — loaded
+ automatically to verify.
+
+
+
+ {#if loadingServerVideos}
+
+ {videoLoadProgress.total > 0
+ ? `Loading ${videoLoadProgress.current}/${videoLoadProgress.total}...`
+ : 'Loading...'}
+ {:else}
+
+ {serverVideosLoaded ? 'Refresh' : 'Load videos'}
+ {/if}
+
+
+
+ {#if !serverVideosLoaded}
+
+
+
Click "Load videos" to preview what's on the server.
+
+ {:else if serverVideos.length === 0}
+ No videos found on the server.
+ {:else}
+
+ {#each serverVideos as video (video.name)}
+
+
+ {#if videoBlobUrls[video.name]}
+
+
+ {:else}
+
playServerVideo(video.name)}
+ disabled={loadingVideoName === video.name}
+ >
+ {#if loadingVideoName === video.name}
+
+ Loading...
+ {:else}
+
+ Play
+ {/if}
+
+ {/if}
+
+
+ {video.name}
+ {formatBytes(video.size)}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if viewingName}
+
+
+
(viewingName = null)}
+ >
+
+
+
e.stopPropagation()}
+ >
+
+ {viewingName}
+ (viewingName = null)}
+ aria-label="Close"
+ >
+
+
+
+
+ {#if videoBlobUrls[viewingName]}
+
+
+ {/if}
+
+
+
+ {/if}
+
+
diff --git a/src/routes/(authed)/tools/brew/+page.svelte b/src/routes/(authed)/tools/brew/+page.svelte
index dd6e0b1..3e4601f 100644
--- a/src/routes/(authed)/tools/brew/+page.svelte
+++ b/src/routes/(authed)/tools/brew/+page.svelte
@@ -32,7 +32,13 @@
import { deviceCredentialManager } from '$lib/core/adb/deviceCredManager';
import { afterNavigate, goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
- import { adbWriter, isAdbWriterAvailable, sendToAndroid } from '$lib/core/stores/adbWriter';
+ import {
+ adbWriter,
+ isAdbWriterAlive,
+ wasAndroidSocketEverAlive,
+ sendToAndroid
+ } from '$lib/core/stores/adbWriter';
+ import { adbReconnect } from '$lib/core/stores/adbReconnectStore';
import { AdbInstance } from '../../../state.svelte';
import {
setOnMenuSavedCallback,
@@ -68,7 +74,7 @@
let recipeLoading = $state(false);
let recipeAutoLoadAttempted = $state(false);
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
- let isAndroidSocketConnected = $derived(Boolean($adbWriter));
+ let isAndroidSocketConnected = $derived(isAdbWriterAlive());
let isRecipeLoaded = $derived(Boolean(devRecipe));
// clear out event
@@ -121,6 +127,7 @@
try {
addNotification('WARN:Load recipe from app memories ...');
+ logger.info('sending request get recipe');
sendToAndroid({
type: 'get_recipe',
payload: {}
@@ -207,7 +214,7 @@
async function connectAdb() {
try {
if (adb.getAdbInstance()) {
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
await adb.reconnectAndroidServer();
}
await loadBrewDataFromConnectedAdb();
@@ -225,7 +232,14 @@
await loadBrewDataFromConnectedAdb();
}
} catch (e: any) {
- addNotification(`ERROR:${e}`);
+ // addNotification(`ERROR:${e}`);
+ GlobalEventBus.emit('announce', {
+ title: 'Device is busy',
+ subtitle: 'Expect `adb` or some related program has been using the device.',
+ message: 'Help: kill the process & try again',
+ buttonText: 'Ok',
+ type: 'error'
+ });
}
}
@@ -239,7 +253,7 @@
async function tryAutoConnect() {
try {
if (adb.getAdbInstance()) {
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
await adb.reconnectAndroidServer();
}
return true;
@@ -272,7 +286,7 @@
}
if (e instanceof AdbDaemonWebUsbDevice.DeviceBusyError) {
addNotification(
- 'ERR:Device is already in use by another program, please close the program and try again'
+ 'ERR:Device is already in use by another program, check if machine has been installed `adb`, kill the process and try again.'
);
}
@@ -334,7 +348,8 @@
}
async function ensureAndroidSocket() {
- if (isAdbWriterAvailable()) return true;
+ // Use the liveness check, not just "is object in store"
+ if (isAdbWriterAlive()) return true;
if (!adb.getAdbInstance()) {
addNotification('ERR:ADB is not connected');
@@ -343,7 +358,7 @@
await adb.reconnectAndroidServer();
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
addNotification('ERR:Android socket is not connected');
return false;
}
@@ -353,7 +368,9 @@
afterNavigate(async () => {
logger.info('after navigate brew');
- await startFetchRecipeFromMachine();
+ // Data loading is handled by the $effect below — no need to
+ // duplicate it here. This hook is kept for future navigation-
+ // specific side effects only.
});
function getMenuStatus(ms: number): RecipeOverview['status'] {
@@ -832,69 +849,45 @@
onDestroy(() => {
unsubRecipeEvent();
clearOnMenuSavedCallback();
+ void adb.goToMachineHome();
});
$effect(() => {
if (!isAdbConnected) {
- recipeAutoLoadAttempted = false;
+ // Don't reset recipeAutoLoadAttempted here — that causes
+ // duplicate loads on back-and-forth navigation.
return;
}
if (isRecipeLoaded || recipeLoading || recipeAutoLoadAttempted) return;
recipeAutoLoadAttempted = true;
- void loadBrewDataFromConnectedAdb();
- });
- $effect(() => {
- const brewAppStatusInterval = setInterval(async () => {
- // schedule status from .brew_web_status.log
- let inst = adb.getAdbInstance();
- if (inst && devRecipe && !recipeLoading) {
- await adb.executeCmd(
- 'tail -n 1 /sdcard/coffeevending/.brew_web_status.log > /sdcard/coffeevending/.brew_web_status.latest.log'
- );
-
- const latestStatusPath = env.PUBLIC_BREW_WEB_LATEST_STATUS;
- if (!latestStatusPath) return;
-
- let brew_status_log = await adb.pull(latestStatusPath);
- if (brew_status_log) {
- let latest_log = brew_status_log;
-
- if (brew_status !== latest_log) {
- brew_status = latest_log;
-
- // noti from machine
- if (latest_log.includes('!!!')) {
- let log = latest_log.split('!!!');
- let noti_cfg = log[1];
-
- if (noti_cfg.includes('=')) {
- let noti_level = noti_cfg.split('=')[1];
-
- let spl = log[0].split(':');
- let pure_msg = spl[spl.length - 1];
-
- // case special message
- if (pure_msg.includes('starting retry process')) {
- // is waiting/idle process
- pure_msg = 'Wait for brewing';
- }
-
- addNotification(`${noti_level}:${pure_msg}`);
- }
- }
+ void (async () => {
+ if (!isAdbWriterAlive()) {
+ // Writer is null or stale. Decide what to do:
+ if (wasAndroidSocketEverAlive()) {
+ // Socket was alive before and died → show reconnect
+ // banner. isAdbWriterAlive() already triggered it
+ // if the writer was stale.
+ adbReconnect.requestReconnect('Android socket disconnected');
+ return;
+ }
+ // First visit — auto-connect the Android server.
+ if (adb.getAdbInstance()) {
+ try {
+ await adb.reconnectAndroidServer();
+ } catch {
+ // Connection failed — user can retry manually.
}
}
- } else if (inst && !devRecipe) {
- // try again
- // await startFetchRecipeFromMachine();
+ // Re-check after connect attempt
+ if (!isAdbWriterAlive()) {
+ return;
+ }
}
- }, 1000);
- return () => {
- clearInterval(brewAppStatusInterval);
- };
+ await loadBrewDataFromConnectedAdb();
+ })();
});
diff --git a/src/routes/(authed)/tools/create-menu/+page.svelte b/src/routes/(authed)/tools/create-menu/+page.svelte
index 994c294..f1b1b84 100644
--- a/src/routes/(authed)/tools/create-menu/+page.svelte
+++ b/src/routes/(authed)/tools/create-menu/+page.svelte
@@ -14,7 +14,7 @@
import { addNotification } from '$lib/core/stores/noti';
import { referenceFromPage } from '$lib/core/stores/recipeStore';
import { env } from '$env/dynamic/public';
- import { adbWriter, isAdbWriterAvailable } from '$lib/core/stores/adbWriter';
+ import { adbWriter, isAdbWriterAlive, wasAndroidSocketEverAlive } from '$lib/core/stores/adbWriter';
import { AdbInstance } from '../../../state.svelte';
import {
categoryOptions,
@@ -36,12 +36,15 @@
clearOnMenuSavedCallback,
clearMenuSaveState
} from '$lib/core/stores/menuSaveStore';
+ import { MenuStatus } from '$lib/core/types/menuStatus';
const sourceDir = '/sdcard/coffeevending';
const stagedMenuStorageKey = 'brew.create-menu.drafts.v1';
const deletedStagedMenuStorageKey = `${stagedMenuStorageKey}.deleted`;
const stagedMenuAndroidPath = `${sourceDir}/cfg/supra_draft_menus.json`;
const saveResponseTimeoutMs = 60000;
+ const recipeStepTargetCount = 30;
+ const toppingSetTargetCount = 13;
// Recipe data from Android
let devRecipe: any | undefined = $state();
@@ -51,11 +54,14 @@
// Country detection from Android
let detectedCountry = $state
('');
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
let isAdbConnected = $derived(Boolean(AdbInstance.instance));
- let isAndroidSocketConnected = $derived(Boolean($adbWriter));
+ let isAndroidSocketConnected = $derived(isAdbWriterAlive());
let isRecipeLoaded = $derived(Boolean(devRecipe));
// Setup popup state
@@ -83,6 +89,14 @@
let createMenuSaving = $state(false);
let editingDraftProductCode: string | null = $state(null);
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
type TempFormData = {
@@ -92,8 +106,6 @@
otherName: string;
description: string;
otherDescription: string;
- cashPrice: string;
- nonCashPrice: string;
image: string;
isUse: boolean;
recipeSteps: any[];
@@ -123,6 +135,8 @@
)
.sort((left: any, right: any) => Number(left?.id ?? 0) - Number(right?.id ?? 0))
);
+ let groupedMaterialOptions = $derived(getGroupedMaterialOptions());
+ let toppingPickerOptions = $derived(getToppingPickerOptions());
let activeToppingSlotMaterials = $derived(
(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 = {
+ 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) {
return {
slot,
@@ -218,6 +284,77 @@
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();
+ 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) {
const slot = Number(material?.id) - 8110;
const slotName = material?.materialOtherName || material?.materialName;
@@ -235,6 +372,94 @@
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) {
if (Array.isArray(value)) {
return value.map(Number).filter((id: number) => Number.isFinite(id) && id > 0);
@@ -298,16 +523,40 @@
try {
const connected = await adb.ensureAdbConnection();
if (connected) {
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
await adb.reconnectAndroidRecipeMenuServer();
}
await loadRecipeFromMachine();
+ return;
+ }
+
+ if (!('usb' in navigator)) {
+ throw new Error('WebUSB not supported');
+ }
+
+ await adb.connectRecipeMenuViaWebUSB();
+ if (adb.getAdbInstance()) {
+ await loadRecipeFromMachine();
}
} catch (e: any) {
addNotification(`ERROR:${e}`);
}
}
+ async function reconnectAndroidSocket() {
+ try {
+ await adb.reconnectAndroidRecipeMenuServer();
+ if (isAdbWriterAlive()) {
+ 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() {
if (recipeLoading) return;
@@ -357,7 +606,12 @@
// No country file means Thailand
detectedCountry = 'THAI';
}
- logger.info('[CreateMenu] Detected country:', detectedCountry, '-> prefix:', detectedCountryCode);
+ logger.info(
+ '[CreateMenu] Detected country:',
+ detectedCountry,
+ '-> prefix:',
+ detectedCountryCode
+ );
} catch (error) {
// Error reading file means Thailand (default)
detectedCountry = 'THAI';
@@ -368,11 +622,11 @@
}
async function ensureAndroidSocket(): Promise {
- if (isAdbWriterAvailable()) return true;
+ if (isAdbWriterAlive()) return true;
try {
await adb.reconnectAndroidRecipeMenuServer();
- if (isAdbWriterAvailable()) return true;
+ if (isAdbWriterAlive()) return true;
} catch {}
addNotification('WARN:Android socket not connected');
@@ -548,8 +802,6 @@
otherName: '',
description: '',
otherDescription: '',
- cashPrice: '0',
- nonCashPrice: '0',
image: '',
isUse: false,
recipeSteps: [createEmptyRecipeStep()],
@@ -735,6 +987,15 @@
).length;
}
+ function ensureRecipePlaceholders(recipes: any[]) {
+ return [
+ ...recipes,
+ ...Array.from({ length: Math.max(0, recipeStepTargetCount - recipes.length) }, () =>
+ createPlaceholderRecipeStep()
+ )
+ ];
+ }
+
function persistStagedMenus() {
localStorage.setItem(stagedMenuStorageKey, JSON.stringify(stagedMenus));
void persistStagedMenusToAndroid();
@@ -744,8 +1005,9 @@
if (!adb.getAdbInstance()) return;
try {
+ const tempPath = `${stagedMenuAndroidPath}.tmp`;
await adb.push(
- stagedMenuAndroidPath,
+ tempPath,
JSON.stringify(
{
version: 1,
@@ -756,6 +1018,8 @@
2
)
);
+ const result = await adb.executeCmd(`mv ${tempPath} ${stagedMenuAndroidPath}`);
+ if (result?.error) throw new Error(String(result.error));
} catch (error) {
logger.error('failed to persist staged menus to Android', error);
addNotification('WARN:Failed to save draft menus to Android');
@@ -804,21 +1068,21 @@
}
function buildMenuFromForm(form: TempFormData) {
- const toppingSet = form.toppingOptions
- .filter((opt) => opt.slot != null && opt.groupID != null)
- .map((opt) => {
- 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
- };
- });
+ const toppingSet = Array.from({ length: toppingSetTargetCount }, () => createEmptyToppingSet());
+ for (const opt of form.toppingOptions) {
+ if (opt.slot == null || opt.groupID == null) continue;
- while (toppingSet.length < 4) {
- toppingSet.push(createEmptyToppingSet());
+ const index = opt.slot - 1;
+ 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) => ({
@@ -833,16 +1097,17 @@
}
}
}
+ const recipes = ensureRecipePlaceholders(recipeSteps);
return {
Description: form.description,
ExtendID: 0,
OnTOP: false,
LastChange: formatAndroidRecipeDate(),
- MenuStatus: 0,
+ MenuStatus: MenuStatus.drafted,
StringParam: ',filter-enable=no,',
TextForWarningBeforePay: Array(8).fill('stg_warning=Invisible,img_warning=none'),
- cashPrice: Number(form.cashPrice) || 0,
+ cashPrice: 0,
changerecipe: '',
EncoderCount: 0,
id: 0,
@@ -850,9 +1115,9 @@
productCode: form.productCode,
name: form.name,
otherName: form.otherName,
- nonCashPrice: Number(form.nonCashPrice) || 0,
+ nonCashPrice: 0,
otherDescription: form.otherDescription,
- recipes: recipeSteps,
+ recipes,
ToppingSet: toppingSet,
SubMenu: [],
total_time: -1,
@@ -863,6 +1128,14 @@
};
}
+ function buildPendingOnlineMenu(menu: any) {
+ return {
+ ...menu,
+ MenuStatus: MenuStatus.pendingOnline,
+ recipes: ensureRecipePlaceholders(menu?.recipes ?? [])
+ };
+ }
+
async function createMenuDraft() {
if (tempForms.length === 0) {
addNotification('ERR:No forms to save');
@@ -908,11 +1181,6 @@
async function saveStagedMenuToAndroid(menu: any) {
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);
@@ -920,7 +1188,7 @@
type: 'save_recipe_menu_file',
payload: {
time: new Date().toLocaleTimeString(),
- data: menu
+ data: buildPendingOnlineMenu(menu)
}
});
if (!sent) {
@@ -954,18 +1222,6 @@
addNotification('WARN:No draft menus ready to save');
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(
'[Create Menu] save all draft menus',
menus.map((menu) => menu.productCode)
@@ -979,7 +1235,7 @@
type: 'save_recipe_menu_file_batch',
payload: {
time: new Date().toLocaleTimeString(),
- data: menus
+ data: menus.map(buildPendingOnlineMenu)
}
});
if (!sent) {
@@ -1126,8 +1382,9 @@
const toppingOptionsFromMenu = (m: any) => {
return (m.ToppingSet ?? [])
- .filter((ts: any) => ts.isUse && Number(ts.groupID) > 0)
- .map((ts: any, index: number) => ({
+ .map((ts: any, index: number) => ({ ts, index }))
+ .filter(({ ts }: { ts: any }) => ts.isUse && Number(ts.groupID) > 0)
+ .map(({ ts, index }: { ts: any; index: number }) => ({
slot: index + 1,
groupID: Number(ts.groupID),
defaultIDSelect: Number(ts.defaultIDSelect) || null
@@ -1142,8 +1399,6 @@
otherName: menu.otherName ?? '',
description: menu.Description ?? '',
otherDescription: menu.otherDescription ?? '',
- cashPrice: String(menu.cashPrice ?? 0),
- nonCashPrice: String(menu.nonCashPrice ?? 0),
image: menu.uriData?.replace(/^img=/, '') ?? '',
isUse: menu.isUse !== false,
recipeSteps: recipeSteps.length > 0 ? recipeSteps : [createEmptyRecipeStep()],
@@ -1184,12 +1439,16 @@
onDestroy(() => {
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
$effect(() => {
if (!isAdbConnected) {
- recipeAutoLoadAttempted = false;
+ // Don't reset recipeAutoLoadAttempted — prevents duplicate loads
+ // on back-and-forth navigation.
detectedCountry = '';
return;
}
@@ -1200,8 +1459,23 @@
// Auto-load recipe data and country
(async () => {
- if (!isAdbWriterAvailable()) {
- await adb.reconnectAndroidRecipeMenuServer();
+ if (!isAdbWriterAlive()) {
+ if (wasAndroidSocketEverAlive()) {
+ // Socket was alive before and died → reconnect banner
+ // already triggered by isAdbWriterAlive() if stale.
+ return;
+ }
+ // First visit — auto-connect the Android recipe menu server.
+ if (adb.getAdbInstance()) {
+ try {
+ await adb.reconnectAndroidRecipeMenuServer();
+ } catch {
+ // Connection failed — user can retry manually.
+ }
+ }
+ if (!isAdbWriterAlive()) {
+ return;
+ }
}
await loadCountryFromMachine();
await loadStagedMenusFromAndroid();
@@ -1237,11 +1511,6 @@
loadRecipeFromMachine()} disabled={recipeLoading}>
{recipeLoading ? 'Loading...' : 'Load Recipe Data'}
- {#if !isAndroidSocketConnected}
- adb.reconnectAndroidRecipeMenuServer()}
- >Reconnect Socket
- {/if}
{:else}
+ Create New Menu
loadRecipeFromMachine()} disabled={recipeLoading}>
@@ -1249,6 +1518,26 @@
{/if}
+ {#if isAdbConnected}
+
+
+ {isAndroidSocketConnected ? 'Socket Connected' : 'Reconnect Socket'}
+
+ {#if !isAndroidSocketConnected}
+ Required before saving
+ {/if}
+ {/if}
+
{#if isAdbConnected}
@@ -1373,7 +1662,10 @@
{#if detectedCountry}
Machine Country
-
{detectedCountry} (prefix: {detectedCountryCode})
+
+ {detectedCountry}
+ (prefix: {detectedCountryCode})
+
{/if}
@@ -1479,11 +1771,11 @@
-
-
- Cash price
- updateActiveFormField('cashPrice', event.currentTarget.value)}
- />
-
-
- Non-cash price
-
- updateActiveFormField('nonCashPrice', event.currentTarget.value)}
- />
-
+
Image file
Material
-
- updateRecipeStepNumber(
- index,
- 'materialPathId',
- event.currentTarget.value
- )}
+ openMaterialPicker(index)}
>
- Select material
- {#each activeMaterials as material}
- {materialDisplayName(material)}
- {/each}
-
+ {getSelectedMaterialName(step.materialPathId)}
+
@@ -1758,51 +2023,37 @@
Slot
- updateToppingSlot(index, e.currentTarget.value)}
+ openToppingPicker('slot', index)}
>
- Select slot
- {#each activeToppingSlotMaterials as material}
-
- {toppingSlotDisplayName(material)}
-
- {/each}
-
+ {getSelectedToppingSlotName(topping.slot)}
+
Topping group
- updateToppingGroup(index, e.currentTarget.value)}
+ openToppingPicker('group', index)}
>
- Select group
- {#each activeToppingGroups as group}
- {toppingGroupDisplayName(group)}
- {/each}
-
+ {getSelectedToppingGroupName(topping.groupID)}
+
Default topping
- updateToppingList(index, e.currentTarget.value)}
+ onclick={() => openToppingPicker('list', index)}
>
- Select topping
- {#each getToppingListsForGroup(topping.groupID) as toppingItem}
- {toppingListDisplayName(toppingItem)}
- {/each}
-
+ {getSelectedToppingListName(topping.defaultIDSelect)}
+
@@ -1823,6 +2074,96 @@
+
+
+
+
+ Select Material
+
+ Choose a material for recipe step {materialPickerStepIndex == null
+ ? ''
+ : materialPickerStepIndex + 1}. Materials are grouped by channel/category.
+
+
+
+
+
+
+ {#if groupedMaterialOptions.length === 0}
+
+ No materials found.
+
+ {:else}
+
+ {#each groupedMaterialOptions as group}
+
+
+
{group.category}
+
{group.materials.length} items
+
+
+ {#each group.materials as material}
+
selectMaterialForActiveStep(Number(material.id))}
+ >
+ {materialDisplayName(material)}
+
+ {material.pathOtherName || material.CanisterType || 'No path/type'}
+
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
+
+
+ (materialPickerOpen = false)}>Cancel
+
+
+
+
+
+
+
+
+ {getToppingPickerTitle()}
+ {getToppingPickerDescription()}
+
+
+
+
+
+ {#if toppingPickerOptions.length === 0}
+
+ {toppingPickerType === 'list' ? 'No toppings found for this group.' : 'No options found.'}
+
+ {:else}
+
+ {#each toppingPickerOptions as option}
+
selectToppingPickerOption(option.value)}
+ >
+ {option.label}
+ {option.description}
+
+ {/each}
+
+ {/if}
+
+
+
+ (toppingPickerOpen = false)}>Cancel
+
+
+
+
diff --git a/src/routes/(authed)/tools/image-upload/+page.svelte b/src/routes/(authed)/tools/image-upload/+page.svelte
index 073d815..be44b1d 100644
--- a/src/routes/(authed)/tools/image-upload/+page.svelte
+++ b/src/routes/(authed)/tools/image-upload/+page.svelte
@@ -121,6 +121,12 @@
files = [];
}
+
+ async function authHeaders(): Promise> {
+ const token = await $auth?.getIdToken?.();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ }
+
async function uploadFiles() {
const currentUser = $auth;
if (!currentUser) {
@@ -159,6 +165,7 @@
const response = await fetch(UPLOAD_PROXY_ENDPOINT, {
method: 'POST',
+ headers: await authHeaders(),
body: formData
});
diff --git a/src/routes/(authed)/tools/video-mainpage/+page.svelte b/src/routes/(authed)/tools/video-mainpage/+page.svelte
new file mode 100644
index 0000000..4848f50
--- /dev/null
+++ b/src/routes/(authed)/tools/video-mainpage/+page.svelte
@@ -0,0 +1,970 @@
+
+
+
+
+
+
+
+
Advertisement Videos
+
+ Main-page & brewing-page videos, scheduled by date
+
+
+
+
+ {isAdbConnected ? 'Machine connected' : 'Machine offline'}
+
+ {#if !isAdbConnected}
+
+ {#if connecting} Connecting...{:else} Connect Machine{/if}
+
+ {:else}
+ (showScreenMirror = true)}>
+ Show Android Screen
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Add a new video
+
+
+ Upload the same clip twice — the main-page version and the brewing-page
+ _long version. Auto-named
+ brewing_adv<N> (next free 1–40, never overwrites a video
+ in use).
+
+
+
+
+
+
Country
+
+ {countryLabel}
+
+ {#each COUNTRIES as c (c.value)}
+ {c.label}
+ {/each}
+
+
+
+ Writes to inter/{country}/video{country === 'tha'
+ ? ' + flat video/'
+ : ''}.
+
+
+
+
Name
+
+
+ Used for the comment & the …VideoEnable variable.
+
+
+
+ Start date
+
+
+
+
End date
+ (optional)
+
+ {#if !endDate}
Blank = open-ended
{/if}
+
+
+
+
+
+
+
+
+ Main-page video
+ brewing_adv<N>.mp4
+
+ {#if mainFile}
+
+
+
+
+
+
+ {mainFile.name} · {fmtMB(mainFile.size)}
+
+ {:else}
+
+
+
+ Click to select .mp4
+
+ {/if}
+
+
+
+
+
+ Brewing-page video
+ brewing_adv<N>_long.mp4
+
+ {#if brewingFile}
+
+
+
+
+
+
+ {brewingFile.name} · {fmtMB(brewingFile.size)}
+
+
+
+ Plays {brewingPlaySeconds}s
+ (length {Math.round(brewingRawSeconds)}s − {DURATION_TRIM})
+
+ {:else}
+
+
+
+ Click to select _long .mp4
+
+ {/if}
+
+
+
+
+ Text overlays (.png, required)
+
+
+ {#each [{ label: 'Thai', name: 'brewing_txt_adv
.png', file: brewingTxtFile, preview: brewingTxtPreview, pick: pickBrewingTxt, clear: clearBrewingTxt }, { label: 'English', name: 'brewing_txt_adv_en.png', file: brewingTxtEnFile, preview: brewingTxtEnPreview, pick: pickBrewingTxtEn, clear: clearBrewingTxtEn }] as t (t.label)}
+
+
{t.label}
+ {#if t.file}
+
+
+
+
+
+ {t.file.name}
+
+ {:else}
+
+
+
+ {t.label} .png
+
+ {/if}
+
+ {/each}
+
+
+
+
+
+ {#if pushProgress.active && submitting}
+
+
+
+ Pushing {pushProgress.name} ({pushProgress.percent}%)
+
+
+ {/if}
+
+
+
+ {#if submitting} Saving...{:else} Create & Push{/if}
+
+
+
+
+
+
+
+
Existing videos
+
+ {#if loadingList} {:else} {/if}Refresh
+
+
+
+
+ {#if managed.length === 0}
+
+ No web-managed videos yet. Ones you add above appear here and can be edited.
+
+ {:else}
+
+ {#each managed as v (v.slug)}
+
+
+
+ {#if v.main}
+
+
+ {:else}
+
+ no main
+
+ {/if}
+
+
+ {#if v.brewing}
+
+
+ {:else}
+
+ no brewing
+
+ {/if}
+
+
+
+
+
{v.name}
+
+ brewing_adv{v.n}
+ {v.range_label}
+ {#if v.brewing?.duration} {v.brewing.duration}s {/if}
+ main
+ brewing
+
+
+
openEdit(v)}>
+ Edit
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
(showReadonly = !showReadonly)}
+ >
+
+
+ Hand-maintained videos ({readonlyList.length})
+ read-only
+ {showReadonly ? 'Click to hide' : 'Click to show'}
+
+ {#if showReadonly}
+
+ {#each readonlyList as v (v.filename)}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Edit video
+
+ {#if editTarget}brewing_adv{editTarget.n} · change dates, rename,
+ or replace either clip{/if}
+
+
+
+
+
+ Name
+
+
+
+
+
+
+
+ Main-page
+
+ {#if editTarget?.main}
+
+
+ {/if}
+
+
+
+ {editMainFile ? editMainFile.name : 'Replace (optional)'}
+
+
+
+
+ Brewing-page
+
+ {#if editTarget?.brewing}
+
+
+ {/if}
+
+
+
+ {editBrewingFile ? editBrewingFile.name : 'Replace (optional)'}
+
+ {#if editBrewingFile}
+
+ Plays {editBrewingPlaySeconds}s
+
+ {/if}
+
+
+ pickEditTxt(e, false)}
+ />
+
+ {editBrewingTxtFile ? 'TH ✓' : 'Text TH (.png)'}
+
+
+ pickEditTxt(e, true)}
+ />
+
+ {editBrewingTxtEnFile ? 'EN ✓' : 'Text EN (.png)'}
+
+
+
+
+
+ {#if pushProgress.active}
+
+
+
+ Pushing {pushProgress.name} ({pushProgress.percent}%)
+
+
+ {/if}
+
+
+
+ (editOpen = false)} disabled={editSaving}
+ >Cancel
+
+ {#if editSaving} Saving...{:else} Save & Push{/if}
+
+
+
+
+
+
diff --git a/src/routes/api/adv-file/+server.ts b/src/routes/api/adv-file/+server.ts
new file mode 100644
index 0000000..149a6da
--- /dev/null
+++ b/src/routes/api/adv-file/+server.ts
@@ -0,0 +1,47 @@
+import { logger } from '$lib/core/utils/logger';
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/public';
+import { verifyAuthToken } from '$lib/server/auth';
+
+// Adv videos are served by the same taobin_image service as menu images.
+const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
+
+// Stream a single adv .mp4 back to the browser. The frontend fetches this with a
+// Bearer token and turns the response into a Blob/object URL for a tag
+// (adv videos live only on the FTP server, so they can't be linked directly).
+export const GET: RequestHandler = async ({ request, url }) => {
+ try {
+ await verifyAuthToken(request);
+
+ const country = url.searchParams.get('country');
+ const filename = url.searchParams.get('filename');
+ if (!country || !filename) {
+ throw error(400, 'Missing country or filename');
+ }
+
+ // POST (not GET): the kong gateway only routes POST to the taobin-image service.
+ const endpoint = `${ADV_API_BASE}/adv/file/${encodeURIComponent(country)}/${encodeURIComponent(filename)}`;
+ const response = await fetch(endpoint, { method: 'POST' });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
+ throw error(response.status, errorData.detail || 'Failed to fetch adv video');
+ }
+
+ return new Response(response.body, {
+ status: 200,
+ headers: {
+ 'Content-Type': response.headers.get('Content-Type') || 'video/mp4',
+ 'Content-Length': response.headers.get('Content-Length') || '',
+ 'Cache-Control': 'no-store'
+ }
+ });
+ } catch (err) {
+ logger.error('[Adv File Proxy] Error:', err);
+ if (err && typeof err === 'object' && 'status' in err) {
+ throw err;
+ }
+ throw error(500, err instanceof Error ? err.message : 'Internal server error');
+ }
+};
diff --git a/src/routes/api/adv-list/+server.ts b/src/routes/api/adv-list/+server.ts
new file mode 100644
index 0000000..4e3f59f
--- /dev/null
+++ b/src/routes/api/adv-list/+server.ts
@@ -0,0 +1,36 @@
+import { logger } from '$lib/core/utils/logger';
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/public';
+import { verifyAuthToken } from '$lib/server/auth';
+
+// Adv videos are served by the same taobin_image service as menu images.
+const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
+
+// List the adv .mp4 files currently on a country's FTP folder (for preview).
+export const POST: RequestHandler = async ({ request }) => {
+ try {
+ await verifyAuthToken(request);
+
+ const { country } = await request.json();
+ if (!country || typeof country !== 'string') {
+ throw error(400, 'Missing country');
+ }
+
+ const endpoint = `${ADV_API_BASE}/adv/list/${encodeURIComponent(country)}`;
+ const response = await fetch(endpoint, { method: 'POST' });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
+ throw error(response.status, errorData.detail || 'Failed to list adv videos');
+ }
+
+ return json(await response.json());
+ } catch (err) {
+ logger.error('[Adv 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');
+ }
+};
diff --git a/src/routes/api/adv-thumb/+server.ts b/src/routes/api/adv-thumb/+server.ts
new file mode 100644
index 0000000..b7e6b69
--- /dev/null
+++ b/src/routes/api/adv-thumb/+server.ts
@@ -0,0 +1,46 @@
+import { logger } from '$lib/core/utils/logger';
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/public';
+import { verifyAuthToken } from '$lib/server/auth';
+
+// Adv videos are served by the same taobin_image service as menu images.
+const ADV_API_BASE = env.PUBLIC_POST_IMAGE;
+
+// Return a small JPEG poster (single frame) for an adv video so the slot grid can
+// show a thumbnail without downloading the whole mp4. The frontend fetches this
+// with a Bearer token and turns the response into a Blob/object URL for an .
+export const GET: RequestHandler = async ({ request, url }) => {
+ try {
+ await verifyAuthToken(request);
+
+ const country = url.searchParams.get('country');
+ const filename = url.searchParams.get('filename');
+ if (!country || !filename) {
+ throw error(400, 'Missing country or filename');
+ }
+
+ // POST (not GET): the kong gateway only routes POST to the taobin-image service.
+ const endpoint = `${ADV_API_BASE}/adv/thumb/${encodeURIComponent(country)}/${encodeURIComponent(filename)}`;
+ const response = await fetch(endpoint, { method: 'POST' });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
+ throw error(response.status, errorData.detail || 'Failed to fetch thumbnail');
+ }
+
+ return new Response(response.body, {
+ status: 200,
+ headers: {
+ 'Content-Type': response.headers.get('Content-Type') || 'image/jpeg',
+ 'Cache-Control': 'no-store'
+ }
+ });
+ } catch (err) {
+ logger.error('[Adv Thumb Proxy] Error:', err);
+ if (err && typeof err === 'object' && 'status' in err) {
+ throw err;
+ }
+ throw error(500, err instanceof Error ? err.message : 'Internal server error');
+ }
+};
diff --git a/src/routes/api/catalog-banner-image/+server.ts b/src/routes/api/catalog-banner-image/+server.ts
new file mode 100644
index 0000000..8970222
--- /dev/null
+++ b/src/routes/api/catalog-banner-image/+server.ts
@@ -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'
+ }
+ });
+};
diff --git a/src/routes/api/catalog-banner/+server.ts b/src/routes/api/catalog-banner/+server.ts
new file mode 100644
index 0000000..cfcfbca
--- /dev/null
+++ b/src/routes/api/catalog-banner/+server.ts
@@ -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');
+ }
+};
diff --git a/src/routes/api/catalog-create/+server.ts b/src/routes/api/catalog-create/+server.ts
new file mode 100644
index 0000000..e5ee024
--- /dev/null
+++ b/src/routes/api/catalog-create/+server.ts
@@ -0,0 +1,63 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/public';
+import {logger} from '$lib/core/utils/logger'
+
+// 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)}`;
+
+ logger.debug('[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');
+ }
+};
diff --git a/src/routes/api/catalog-list/+server.ts b/src/routes/api/catalog-list/+server.ts
new file mode 100644
index 0000000..67d6e24
--- /dev/null
+++ b/src/routes/api/catalog-list/+server.ts
@@ -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');
+ }
+};
diff --git a/src/routes/api/scrcpy-server/+server.ts b/src/routes/api/scrcpy-server/+server.ts
new file mode 100644
index 0000000..7b3fbe3
--- /dev/null
+++ b/src/routes/api/scrcpy-server/+server.ts
@@ -0,0 +1,38 @@
+/**
+ * GET /api/scrcpy-server
+ *
+ * Proxies the scrcpy-server binary from GitHub releases.
+ * Fixes CORS issues (GitHub doesn't send Access-Control-Allow-Origin for binaries).
+ * Server-side fetch has no CORS restrictions.
+ *
+ * Response is cached by the browser for 24 hours via Cache-Control header.
+ */
+
+import type { RequestHandler } from './$types';
+
+const GITHUB_URL =
+ 'https://github.com/Genymobile/scrcpy/releases/download/v2.3/scrcpy-server-v2.3';
+
+export const GET: RequestHandler = async () => {
+ try {
+ const response = await fetch(GITHUB_URL);
+
+ if (!response.ok) {
+ console.error(`[scrcpy-server] GitHub returned ${response.status}`);
+ return new Response(`Failed to fetch scrcpy-server: ${response.status}`, {
+ status: 502
+ });
+ }
+
+ // Stream the response directly — don't buffer the entire binary in memory on the server
+ return new Response(response.body, {
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'Cache-Control': 'public, max-age=86400, immutable'
+ }
+ });
+ } catch (e) {
+ console.error('[scrcpy-server] Fetch failed:', e);
+ return new Response('Failed to fetch scrcpy-server from GitHub', { status: 500 });
+ }
+};
diff --git a/src/routes/api/sheet/stream/+server.ts b/src/routes/api/sheet/stream/+server.ts
index 909771e..112aaa7 100644
--- a/src/routes/api/sheet/stream/+server.ts
+++ b/src/routes/api/sheet/stream/+server.ts
@@ -38,9 +38,10 @@ export async function POST({ request }: RequestEvent) {
// Initialize or update batch
if (!streamCache.has(batch_id)) {
- const cappedTotal = typeof total_chunks === 'number' && total_chunks > 0
- ? Math.min(total_chunks, MAX_CHUNKS_PER_BATCH)
- : MAX_CHUNKS_PER_BATCH;
+ const cappedTotal =
+ typeof total_chunks === 'number' && total_chunks > 0
+ ? Math.min(total_chunks, MAX_CHUNKS_PER_BATCH)
+ : MAX_CHUNKS_PER_BATCH;
streamCache.set(batch_id, {
chunks: [],
status: 'collecting',
@@ -63,7 +64,9 @@ export async function POST({ request }: RequestEvent) {
logger.info(`[API] Received chunk ${current_chunk}/${total_chunks} for batch ${batch_id}`);
} else if (msg === 'end') {
batch.status = 'complete';
- logger.info(`[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}`);
+ logger.info(
+ `[API] Stream complete for batch ${batch_id}, total chunks: ${batch.chunks.length}`
+ );
} else if (msg === 'error') {
batch.status = 'error';
batch.error = content;
diff --git a/src/routes/api/video-mainpage/+server.ts b/src/routes/api/video-mainpage/+server.ts
new file mode 100644
index 0000000..cbe424e
--- /dev/null
+++ b/src/routes/api/video-mainpage/+server.ts
@@ -0,0 +1,81 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/public';
+import {logger} from '$lib/core/utils/logger'
+
+// 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)}`;
+
+ logger.debug('[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');
+ }
+};
diff --git a/src/routes/api/video-mainpage/list/+server.ts b/src/routes/api/video-mainpage/list/+server.ts
new file mode 100644
index 0000000..df9c07b
--- /dev/null
+++ b/src/routes/api/video-mainpage/list/+server.ts
@@ -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');
+ }
+};
diff --git a/src/routes/api/video-mainpage/update/+server.ts b/src/routes/api/video-mainpage/update/+server.ts
new file mode 100644
index 0000000..5c71bfb
--- /dev/null
+++ b/src/routes/api/video-mainpage/update/+server.ts
@@ -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');
+ }
+};
diff --git a/tsconfig.json b/tsconfig.json
index 5f1994a..bf6a383 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,7 +10,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
- "moduleResolution": "bundler"
+ "moduleResolution": "bundler",
+ "lib": ["ESNext", "DOM", "WebWorker"]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
diff --git a/vite.config.ts b/vite.config.ts
index 7f3223c..1db02ae 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,8 +2,10 @@ import devtoolsJson from 'vite-plugin-devtools-json';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
+import { createViteLogger } from './src/lib/core/utils/vite-logger';
export default defineConfig({
+ customLogger: createViteLogger(),
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
ssr: {
noExternal: ['@dnd-kit/core', '@dnd-kit/sortable']