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..d668515 100644
--- a/src/lib/components/AnnouncementDialog.svelte
+++ b/src/lib/components/AnnouncementDialog.svelte
@@ -3,7 +3,7 @@
import { GlobalEventBus } from '$lib/core/utils/eventBus';
import { XIcon } from '@lucide/svelte/icons';
- export interface AnnouncementPayload {
+ interface AnnouncementPayload {
title?: string;
subtitle?: string;
message: string;
diff --git a/src/lib/components/adb-reconnect-banner.svelte b/src/lib/components/adb-reconnect-banner.svelte
new file mode 100644
index 0000000..b6df9a2
--- /dev/null
+++ b/src/lib/components/adb-reconnect-banner.svelte
@@ -0,0 +1,69 @@
+
+
+{#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..3e9bd9f 100644
--- a/src/lib/components/app-sidebar.svelte
+++ b/src/lib/components/app-sidebar.svelte
@@ -18,6 +18,7 @@
PlusCircle,
ImageUp,
Video,
+ MonitorPlay,
Sun,
Moon
} from '@lucide/svelte/icons';
@@ -126,6 +127,12 @@
url: '/tools/adv-upload',
icon: Video,
requirePerm: ''
+ },
+ {
+ title: 'Main & Brewing Video',
+ url: '/tools/video-mainpage',
+ icon: MonitorPlay,
+ requirePerm: ''
}
]
},
@@ -143,6 +150,12 @@
url: '/departments',
icon: DollarSign,
requirePerm: 'document.write.*'
+ },
+ {
+ title: 'Price',
+ url: '/departments',
+ icon: FileSpreadsheet,
+ requirePerm: 'document.write.*'
}
]
}
@@ -236,7 +249,13 @@
onclick={(e) => {
if (nav.title === 'Sheet') {
e.preventDefault();
- referenceFromPage.set(sub.title === 'PriceSlot' ? 'priceslot' : 'sheet');
+ referenceFromPage.set(
+ sub.title === 'PriceSlot'
+ ? 'priceslot'
+ : sub.title === 'Price'
+ ? 'price'
+ : 'sheet'
+ );
goto(sub.url);
}
}}
diff --git a/src/lib/components/dashboard-quick-adb.svelte b/src/lib/components/dashboard-quick-adb.svelte
index 3ae6e09..f3faae4 100644
--- a/src/lib/components/dashboard-quick-adb.svelte
+++ b/src/lib/components/dashboard-quick-adb.svelte
@@ -1,4 +1,5 @@
-
@@ -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/brew/+page.svelte b/src/routes/(authed)/tools/brew/+page.svelte
index dd6e0b1..eb19457 100644
--- a/src/routes/(authed)/tools/brew/+page.svelte
+++ b/src/routes/(authed)/tools/brew/+page.svelte
@@ -32,7 +32,8 @@
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 +69,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 +122,7 @@
try {
addNotification('WARN:Load recipe from app memories ...');
+ logger.info('sending request get recipe');
sendToAndroid({
type: 'get_recipe',
payload: {}
@@ -207,7 +209,7 @@
async function connectAdb() {
try {
if (adb.getAdbInstance()) {
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
await adb.reconnectAndroidServer();
}
await loadBrewDataFromConnectedAdb();
@@ -239,7 +241,7 @@
async function tryAutoConnect() {
try {
if (adb.getAdbInstance()) {
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
await adb.reconnectAndroidServer();
}
return true;
@@ -334,7 +336,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 +346,7 @@
await adb.reconnectAndroidServer();
- if (!isAdbWriterAvailable()) {
+ if (!isAdbWriterAlive()) {
addNotification('ERR:Android socket is not connected');
return false;
}
@@ -353,7 +356,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 +837,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/video-mainpage/+page.svelte b/src/routes/(authed)/tools/video-mainpage/+page.svelte
new file mode 100644
index 0000000..846a9aa
--- /dev/null
+++ b/src/routes/(authed)/tools/video-mainpage/+page.svelte
@@ -0,0 +1,797 @@
+
+
+
+
+
+
+
+
Advertisement Videos
+
Main-page & brewing-page videos, scheduled by date
+
+
+
+ {isAdbConnected ? 'Machine connected' : 'Machine offline'}
+
+ {#if !isAdbConnected}
+
+ {#if connecting} Connecting...{:else} Connect Machine{/if}
+
+ {/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/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/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']