@feedmepos/mf-order-setting 0.0.92 → 0.0.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/DisplaySystemView-Doh1JUoe.js +8891 -0
  2. package/dist/{KioskDevicesView-d2WLB0-w.js → KioskDevicesView-DvUq9eFa.js} +1 -1
  3. package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-rs-hNWUW.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-Ce-CHsOK.js} +3 -3
  4. package/dist/{KioskSettingView-DFE3_u5c.js → KioskSettingView-Ba1sLolg.js} +3 -3
  5. package/dist/{KioskView-C3Pi3lWW.js → KioskView-CeKSWYbI.js} +5 -5
  6. package/dist/{OrderSettingsView-Dxg9RjSd.js → OrderSettingsView-CdxkPS_V.js} +22200 -22918
  7. package/dist/{app-BFlQtcsd.js → app-BQ0W0_dt.js} +2536 -1818
  8. package/dist/app.js +1 -1
  9. package/dist/featureFlag-C4gAe_F4.js +846 -0
  10. package/dist/frontend/mf-order/src/api/brandkit/index.d.ts +3 -0
  11. package/dist/frontend/mf-order/src/api/native-app/index.d.ts +2 -0
  12. package/dist/frontend/mf-order/src/app.d.ts +716 -0
  13. package/dist/frontend/mf-order/src/composables/featureFlag.d.ts +7 -0
  14. package/dist/frontend/mf-order/src/composables/useBusinessFeatureFlag.d.ts +4 -0
  15. package/dist/frontend/mf-order/src/composables/useLegacyOdsAccess.d.ts +4 -0
  16. package/dist/frontend/mf-order/src/composables/useMiniProgramPermission.d.ts +5 -0
  17. package/dist/frontend/mf-order/src/main.d.ts +716 -0
  18. package/dist/frontend/mf-order/src/modules/display-system/legacy-ods/interface.d.ts +80 -0
  19. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorOverrideDialog.vue.d.ts +26 -0
  20. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorSelection.vue.d.ts +15 -0
  21. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue.d.ts +24 -0
  22. package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue.d.ts +4 -0
  23. package/dist/frontend/mf-order/src/views/display-system/view-selection.d.ts +18 -0
  24. package/dist/frontend/mf-order/src/views/order-settings/components/push-availability.d.ts +13 -0
  25. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
  26. package/dist/{index-ByiTrmdq.js → index-BFJ4YEfL.js} +2 -2
  27. package/dist/{index-CaO7aaaT.js → index-CGiJXVU3.js} +1 -1
  28. package/dist/{index-s2kWGMtz.js → index-DSB009Br.js} +1 -1
  29. package/dist/{order.dto-C829lA69.js → order.dto-CUp8zhQC.js} +18 -18
  30. package/dist/package/entity/order-platform/tng/tng-menu.do.d.ts +74 -74
  31. package/dist/style.css +1 -1
  32. package/env.d.ts +2 -0
  33. package/package.json +2 -1
  34. package/src/api/brandkit/index.ts +24 -0
  35. package/src/api/native-app/index.ts +26 -0
  36. package/src/api/native-app/native-app.spec.ts +42 -0
  37. package/src/composables/featureFlag.ts +22 -0
  38. package/src/composables/useBusinessFeatureFlag.ts +44 -0
  39. package/src/composables/useLegacyOdsAccess.ts +35 -0
  40. package/src/composables/useMiniProgramPermission.ts +19 -0
  41. package/src/composables/useQrOrderingEnabled.ts +1 -21
  42. package/src/locales/en-US.json +179 -0
  43. package/src/locales/ja-JP.json +179 -0
  44. package/src/locales/th-TH.json +179 -0
  45. package/src/locales/zh-CN.json +179 -0
  46. package/src/modules/display-system/legacy-ods/interface.ts +108 -0
  47. package/src/router/routes.ts +1 -1
  48. package/src/views/display-system/DisplaySystemView.vue +82 -11
  49. package/src/views/display-system/legacy-ods/ColorOverrideDialog.vue +223 -0
  50. package/src/views/display-system/legacy-ods/ColorSelection.vue +67 -0
  51. package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.spec.ts +185 -0
  52. package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue +1677 -0
  53. package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.spec.ts +124 -0
  54. package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue +297 -0
  55. package/src/views/display-system/legacy-ods/brandkit.spec.ts +67 -0
  56. package/src/views/display-system/view-selection.spec.ts +93 -0
  57. package/src/views/display-system/view-selection.ts +54 -0
  58. package/src/views/order-settings/components/NotifyChannelEditor.spec.ts +184 -0
  59. package/src/views/order-settings/components/NotifyChannelEditor.vue +84 -5
  60. package/src/views/order-settings/components/push-availability.spec.ts +59 -0
  61. package/src/views/order-settings/components/push-availability.ts +78 -0
  62. package/src/views/order-settings/queue/QueueSetting.vue +7 -0
  63. package/src/views/order-settings/sms/SmsSetting.vue +11 -0
  64. package/dist/DisplaySystemView-DZS7EVfk.js +0 -6564
  65. package/dist/_plugin-vue_export-helper-CHgC5LLL.js +0 -9
@@ -0,0 +1,124 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
4
+ import { ref } from 'vue'
5
+ import { getBusinessBrandkit, saveBusinessOds } from '@/api/brandkit'
6
+
7
+ const dialog = vi.hoisted(() => ({ open: vi.fn(), close: vi.fn() }))
8
+ vi.mock('@feedmepos/ui-library', () => ({ useDialog: () => dialog }))
9
+ vi.mock('@/api/brandkit', () => ({ getBusinessBrandkit: vi.fn(), saveBusinessOds: vi.fn() }))
10
+ vi.mock('@/components/snackbar', () => ({
11
+ useSnackbarFunctions: () => ({ showError: vi.fn(), showSuccess: vi.fn() })
12
+ }))
13
+ vi.mock('@/composables/useLegacyOdsAccess', () => ({
14
+ useLegacyOdsAccess: () => ({ canAccessOdsTemplateSettings: ref(false) })
15
+ }))
16
+ vi.mock('@feedmepos/mf-common', () => ({
17
+ useCoreStore: () => ({
18
+ currentBusiness: ref({ _id: 'business' }),
19
+ namespace: ref('dev'),
20
+ restaurants: ref([])
21
+ }),
22
+ useI18n: () => ({ t: (key: string) => key })
23
+ }))
24
+ vi.mock('./LegacyOdsEditorDialog.vue', () => ({
25
+ default: {
26
+ name: 'LegacyOdsEditorDialog',
27
+ props: ['modelValue', 'entry', 'allEntries'],
28
+ template: '<div />'
29
+ }
30
+ }))
31
+ import List from './LegacyOdsTemplateList.vue'
32
+
33
+ const wrappers: VueWrapper[] = []
34
+ afterEach(() => {
35
+ wrappers.forEach((wrapper) => wrapper.unmount())
36
+ wrappers.length = 0
37
+ vi.resetAllMocks()
38
+ })
39
+
40
+ async function list() {
41
+ vi.mocked(getBusinessBrandkit).mockResolvedValue({
42
+ ods: [
43
+ { label: 'Default', isDefault: true, restaurantIds: ['outlet'] },
44
+ { label: 'Other', isDefault: false, restaurantIds: ['other'] }
45
+ ]
46
+ })
47
+ const wrapper = mount(List, {
48
+ global: {
49
+ stubs: {
50
+ FmButton: {
51
+ props: ['icon', 'disabled'],
52
+ template: '<button :data-icon="icon" :disabled="disabled" />'
53
+ },
54
+ FmSnackbar: true,
55
+ FmCircularProgress: true
56
+ }
57
+ }
58
+ })
59
+ wrappers.push(wrapper)
60
+ await flushPromises()
61
+ return wrapper
62
+ }
63
+ function buttons(wrapper: VueWrapper, icon: string) {
64
+ return wrapper.findAll(`button[data-icon="${icon}"]`)
65
+ }
66
+
67
+ function editor(wrapper: VueWrapper) {
68
+ return wrapper.findComponent({ name: 'LegacyOdsEditorDialog' })
69
+ }
70
+
71
+ describe('legacy ODS list without the template flag', () => {
72
+ it('saves a new default right away and clears the old default', async () => {
73
+ const wrapper = await list()
74
+ ;(wrapper.vm as unknown as { addOdsEntry: () => void }).addOdsEntry()
75
+ await wrapper.vm.$nextTick()
76
+ expect(editor(wrapper).props('modelValue')).toBe(true)
77
+ expect(editor(wrapper).props('entry')).toMatchObject({ isDefault: false })
78
+ editor(wrapper).vm.$emit('save', { label: 'New default', isDefault: true })
79
+ await flushPromises()
80
+ expect(saveBusinessOds).toHaveBeenCalledWith(
81
+ 'business',
82
+ [
83
+ { label: 'Default', isDefault: false, restaurantIds: ['outlet'] },
84
+ { label: 'Other', isDefault: false, restaurantIds: ['other'] },
85
+ { label: 'New default', isDefault: true }
86
+ ],
87
+ 'dev'
88
+ )
89
+ expect(editor(wrapper).props('modelValue')).toBe(false)
90
+ })
91
+
92
+ it('keeps the editor open when saving fails', async () => {
93
+ const wrapper = await list()
94
+ vi.mocked(saveBusinessOds).mockRejectedValue(new Error('offline'))
95
+ await buttons(wrapper, 'edit')[1].trigger('click')
96
+ editor(wrapper).vm.$emit('save', { label: 'Renamed', isDefault: false })
97
+ await flushPromises()
98
+ expect(editor(wrapper).props('modelValue')).toBe(true)
99
+ expect(editor(wrapper).props('allEntries')[1].label).toBe('Other')
100
+ })
101
+
102
+ it('duplicates and deletes after confirmation, never offering to delete the default', async () => {
103
+ const wrapper = await list()
104
+ expect(buttons(wrapper, 'delete')).toHaveLength(1)
105
+ dialog.open.mockReturnValue({ onPrimary: (callback: () => Promise<void>) => callback() })
106
+ await buttons(wrapper, 'content_copy')[0].trigger('click')
107
+ await flushPromises()
108
+ expect(saveBusinessOds).toHaveBeenLastCalledWith(
109
+ 'business',
110
+ [
111
+ { label: 'Default', isDefault: true, restaurantIds: ['outlet'] },
112
+ { label: 'Other', isDefault: false, restaurantIds: ['other'] },
113
+ { label: 'order.displaySystem.legacyOds.copyLabel', isDefault: false, restaurantIds: [] }
114
+ ],
115
+ 'dev'
116
+ )
117
+ expect(buttons(wrapper, 'delete')).toHaveLength(2)
118
+ await buttons(wrapper, 'delete')[1].trigger('click')
119
+ await flushPromises()
120
+ expect(dialog.open).toHaveBeenCalledTimes(2)
121
+ expect(editor(wrapper).props('allEntries')).toHaveLength(2)
122
+ expect(editor(wrapper).props('allEntries')[0].isDefault).toBe(true)
123
+ })
124
+ })
@@ -0,0 +1,297 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { useCoreStore, useI18n } from '@feedmepos/mf-common'
4
+ import { useDialog } from '@feedmepos/ui-library'
5
+ import { getBusinessBrandkit, saveBusinessOds } from '@/api/brandkit'
6
+ import { useSnackbarFunctions } from '@/components/snackbar'
7
+ import {
8
+ migrateOdsToArray,
9
+ type BrandKit,
10
+ type OdsConfigEntry
11
+ } from '@/modules/display-system/legacy-ods/interface'
12
+ import LegacyOdsEditorDialog from './LegacyOdsEditorDialog.vue'
13
+
14
+ const Core = useCoreStore()
15
+ const { t } = useI18n()
16
+ const dialog = useDialog()
17
+ const { showError, showSuccess } = useSnackbarFunctions()
18
+ const brandkit = ref<BrandKit>({})
19
+ const loading = ref(true)
20
+ const saving = ref(false)
21
+ const loaded = ref(false)
22
+ const odsConfigs = computed(() =>
23
+ migrateOdsToArray(brandkit.value.ods, t('order.displaySystem.legacyOds.default'))
24
+ )
25
+
26
+ const colorOverrideFields = computed(() => [
27
+ {
28
+ key: 'colors.primary',
29
+ title: t('order.displaySystem.legacyOds.color.primary.title'),
30
+ description: t('order.displaySystem.legacyOds.color.primary.description')
31
+ },
32
+ {
33
+ key: 'colors.secondary',
34
+ title: t('order.displaySystem.legacyOds.color.secondary.title'),
35
+ description: t('order.displaySystem.legacyOds.color.secondary.description')
36
+ },
37
+ {
38
+ key: 'colors.accent',
39
+ title: t('order.displaySystem.legacyOds.color.accent.title'),
40
+ description: t('order.displaySystem.legacyOds.color.accent.description')
41
+ },
42
+ {
43
+ key: 'colors.text.onPrimary',
44
+ title: t('order.displaySystem.legacyOds.typography.onPrimary.title'),
45
+ description: t('order.displaySystem.legacyOds.typography.onPrimary.description')
46
+ },
47
+ {
48
+ key: 'colors.text.primary',
49
+ title: t('order.displaySystem.legacyOds.typography.primary.title'),
50
+ description: t('order.displaySystem.legacyOds.typography.primary.description')
51
+ },
52
+ {
53
+ key: 'colors.text.secondary',
54
+ title: t('order.displaySystem.legacyOds.typography.secondary.title'),
55
+ description: t('order.displaySystem.legacyOds.typography.secondary.description')
56
+ },
57
+ {
58
+ key: 'colors.text.tertiary',
59
+ title: t('order.displaySystem.legacyOds.typography.tertiary.title'),
60
+ description: t('order.displaySystem.legacyOds.typography.tertiary.description')
61
+ },
62
+ {
63
+ key: 'colors.neutral.light',
64
+ title: t('order.displaySystem.legacyOds.neutralColors.light.title'),
65
+ description: t('order.displaySystem.legacyOds.neutralColors.light.description')
66
+ },
67
+ {
68
+ key: 'colors.neutral.mid',
69
+ title: t('order.displaySystem.legacyOds.neutralColors.mid.title'),
70
+ description: t('order.displaySystem.legacyOds.neutralColors.mid.description')
71
+ },
72
+ {
73
+ key: 'colors.neutral.dark',
74
+ title: t('order.displaySystem.legacyOds.neutralColors.dark.title'),
75
+ description: t('order.displaySystem.legacyOds.neutralColors.dark.description')
76
+ },
77
+ {
78
+ key: 'colors.background.base',
79
+ title: t('order.displaySystem.legacyOds.backgroundColors.base.title'),
80
+ description: t('order.displaySystem.legacyOds.backgroundColors.base.description')
81
+ },
82
+ {
83
+ key: 'colors.background.elevated',
84
+ title: t('order.displaySystem.legacyOds.backgroundColors.elevated.title'),
85
+ description: t('order.displaySystem.legacyOds.backgroundColors.elevated.description')
86
+ }
87
+ ])
88
+
89
+ const odsEditorOpen = ref(false)
90
+ const odsEditingIndex = ref(-1)
91
+ const odsEditingEntry = ref<OdsConfigEntry>({ isDefault: false })
92
+
93
+ const cloneOds = () => JSON.parse(JSON.stringify(odsConfigs.value)) as OdsConfigEntry[]
94
+
95
+ const entryLabel = (entry: OdsConfigEntry, index: number) =>
96
+ entry.label ||
97
+ (entry.isDefault
98
+ ? t('order.displaySystem.legacyOds.default')
99
+ : t('order.displaySystem.legacyOds.configNumber', { index: index + 1 }))
100
+
101
+ async function persist(ods: OdsConfigEntry[]) {
102
+ const businessId = Core.currentBusiness.value?._id
103
+ const namespace = Core.namespace.value
104
+ if (!businessId || !loaded.value || saving.value) return false
105
+ saving.value = true
106
+ try {
107
+ await saveBusinessOds(businessId, ods, namespace)
108
+ if (Core.currentBusiness.value?._id !== businessId || Core.namespace.value !== namespace) return false
109
+ brandkit.value = { ...brandkit.value, ods }
110
+ showSuccess(t('order.displaySystem.legacyOds.saved'))
111
+ return true
112
+ } catch (error) {
113
+ showError(error instanceof Error ? error.message : t('order.displaySystem.saveFailed'))
114
+ return false
115
+ } finally {
116
+ saving.value = false
117
+ }
118
+ }
119
+
120
+ const addOdsEntry = () => {
121
+ const hasDefault = odsConfigs.value.some((e) => e.isDefault)
122
+ odsEditingIndex.value = -1
123
+ odsEditingEntry.value = {
124
+ label: hasDefault ? '' : t('order.displaySystem.legacyOds.default'),
125
+ isDefault: !hasDefault
126
+ }
127
+ odsEditorOpen.value = true
128
+ }
129
+
130
+ const editOdsEntry = (index: number) => {
131
+ const entry = odsConfigs.value[index]
132
+ if (!entry) return
133
+ odsEditingIndex.value = index
134
+ odsEditingEntry.value = JSON.parse(JSON.stringify(entry))
135
+ odsEditorOpen.value = true
136
+ }
137
+
138
+ const duplicateOdsEntry = (index: number) => {
139
+ const entry = odsConfigs.value[index]
140
+ if (!entry) return
141
+ const label = entryLabel(entry, index)
142
+
143
+ dialog
144
+ .open({
145
+ title: t('order.displaySystem.legacyOds.duplicateConfiguration'),
146
+ message: t('order.displaySystem.legacyOds.duplicateConfirmation', { label }),
147
+ primaryActions: {
148
+ text: t('order.displaySystem.legacyOds.duplicate'),
149
+ close: false
150
+ },
151
+ secondaryActions: {
152
+ text: t('order.displaySystem.legacyOds.cancel'),
153
+ close: true
154
+ }
155
+ })
156
+ .onPrimary(async () => {
157
+ const duplicate = JSON.parse(JSON.stringify(entry)) as OdsConfigEntry
158
+ duplicate.label = t('order.displaySystem.legacyOds.copyLabel', { label })
159
+ duplicate.isDefault = false
160
+ if (entry.isDefault) {
161
+ duplicate.restaurantIds = []
162
+ }
163
+ if (await persist([...cloneOds(), duplicate])) dialog.close()
164
+ })
165
+ }
166
+
167
+ const deleteOdsEntry = (index: number) => {
168
+ const entry = odsConfigs.value[index]
169
+ if (!entry) return
170
+
171
+ dialog
172
+ .open({
173
+ title: t('order.displaySystem.legacyOds.deleteConfiguration'),
174
+ message: t('order.displaySystem.legacyOds.deleteConfirmation', {
175
+ label: entryLabel(entry, index)
176
+ }),
177
+ primaryActions: {
178
+ text: t('order.displaySystem.legacyOds.delete'),
179
+ close: false
180
+ },
181
+ secondaryActions: {
182
+ text: t('order.displaySystem.legacyOds.cancel'),
183
+ close: true
184
+ }
185
+ })
186
+ .onPrimary(async () => {
187
+ const ods = cloneOds()
188
+ ods.splice(index, 1)
189
+ if (await persist(ods)) dialog.close()
190
+ })
191
+ }
192
+
193
+ const onSaveOdsEntry = async (entry: OdsConfigEntry) => {
194
+ const ods = cloneOds()
195
+ if (entry.isDefault) {
196
+ for (const e of ods) {
197
+ e.isDefault = false
198
+ }
199
+ }
200
+ if (odsEditingIndex.value >= 0) {
201
+ ods[odsEditingIndex.value] = entry
202
+ } else {
203
+ ods.push(entry)
204
+ }
205
+ if (await persist(ods)) odsEditorOpen.value = false
206
+ }
207
+
208
+ const restaurantNameMap = computed(() => {
209
+ const map = new Map<string, string>()
210
+ for (const r of Core.restaurants?.value ?? []) {
211
+ map.set(r._id, r.profile.name)
212
+ }
213
+ return map
214
+ })
215
+
216
+ const restaurantNamesForEntry = (entry: OdsConfigEntry) => {
217
+ if (entry.isDefault) return t('order.displaySystem.legacyOds.allRestaurantsDefault')
218
+ if (!entry.restaurantIds?.length) return t('order.displaySystem.legacyOds.noRestaurantsSelected')
219
+ return entry.restaurantIds.map((id) => restaurantNameMap.value.get(id) ?? id).join(', ')
220
+ }
221
+
222
+ watch(
223
+ [() => Core.currentBusiness.value?._id, () => Core.namespace.value],
224
+ async ([businessId, namespace], _, onCleanup) => {
225
+ let cancelled = false
226
+ onCleanup(() => {
227
+ cancelled = true
228
+ })
229
+ odsEditorOpen.value = false
230
+ loaded.value = false
231
+ loading.value = true
232
+ if (!businessId) {
233
+ loading.value = false
234
+ return
235
+ }
236
+ try {
237
+ const result = await getBusinessBrandkit(businessId, namespace)
238
+ if (cancelled) return
239
+ brandkit.value = result
240
+ loaded.value = true
241
+ } catch (error) {
242
+ if (!cancelled)
243
+ showError(error instanceof Error ? error.message : t('order.displaySystem.loadFailed'))
244
+ } finally {
245
+ if (!cancelled) loading.value = false
246
+ }
247
+ },
248
+ { immediate: true }
249
+ )
250
+
251
+ defineExpose({ addOdsEntry })
252
+ </script>
253
+
254
+ <template>
255
+ <section class="mt-12">
256
+ <div v-if="loading" class="flex justify-center py-40"><FmCircularProgress size="lg" /></div>
257
+ <template v-else-if="loaded">
258
+ <div v-if="!odsConfigs.length" class="empty-state">
259
+ <strong>{{ t('order.displaySystem.legacyOds.noConfigsYet') }}</strong>
260
+ <span>{{ t('order.displaySystem.legacyOds.noConfigsDescription') }}</span>
261
+ </div>
262
+ <div v-else class="grid grid-cols-1 gap-16 xl:grid-cols-2">
263
+ <article v-for="(entry, index) in odsConfigs" :key="index" class="resource-card">
264
+ <div class="flex justify-between gap-12">
265
+ <div class="min-w-0">
266
+ <div class="flex items-center gap-8">
267
+ <h3 class="truncate text-lg font-semibold">{{ entryLabel(entry, index) }}</h3>
268
+ <span v-if="entry.isDefault" class="default-badge">{{ t('order.displaySystem.legacyOds.default') }}</span>
269
+ </div>
270
+ <p class="mt-4 truncate text-sm text-gray-500">{{ restaurantNamesForEntry(entry) }}</p>
271
+ </div>
272
+ <div class="flex gap-4">
273
+ <FmButton icon="edit" variant="plain" :disabled="saving" @click="editOdsEntry(index)" />
274
+ <FmButton icon="content_copy" variant="plain" :disabled="saving" @click="duplicateOdsEntry(index)" />
275
+ <FmButton v-if="!entry.isDefault" icon="delete" variant="plain" :disabled="saving" @click="deleteOdsEntry(index)" />
276
+ </div>
277
+ </div>
278
+ </article>
279
+ </div>
280
+ </template>
281
+ <LegacyOdsEditorDialog
282
+ v-model="odsEditorOpen"
283
+ :entry="odsEditingEntry"
284
+ :editing-index="odsEditingIndex"
285
+ :all-entries="odsConfigs"
286
+ :brandkit="brandkit"
287
+ :color-fields="colorOverrideFields"
288
+ :saving="saving"
289
+ @save="onSaveOdsEntry"
290
+ @cancel="odsEditorOpen = false"
291
+ />
292
+ </section>
293
+ </template>
294
+
295
+ <style scoped>
296
+ .empty-state{display:grid;justify-items:center;gap:6px;border:1px dashed #d1d5db;border-radius:12px;padding:56px 20px;color:#6b7280;text-align:center}.empty-state strong{color:#111827}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.default-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}
297
+ </style>
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import axios from 'axios'
3
+ import { getBusinessBrandkit, saveBusinessOds } from '@/api/brandkit'
4
+ import { migrateOdsToArray } from '@/modules/display-system/legacy-ods/interface'
5
+
6
+ vi.mock('axios', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
7
+ beforeEach(() => vi.resetAllMocks())
8
+
9
+ describe('business ODS persistence', () => {
10
+ it('reads the latest brandkit on every save and only replaces ods', async () => {
11
+ const latest = {
12
+ colors: { primary: '#123456' },
13
+ fonts: { brand: 'Inter' },
14
+ layout: { app: { template: { mainView: { id: 'new-app' } } }, kiosk: { currentVersion: 3 } },
15
+ ordering: { app: { singleCategory: true } },
16
+ unknownField: { preserved: true },
17
+ ods: [{ isDefault: true, label: 'Old' }]
18
+ }
19
+ const ods = [{ isDefault: true, label: 'Updated' }]
20
+ vi.mocked(axios.get).mockResolvedValue({ data: latest })
21
+ vi.mocked(axios.post).mockResolvedValue({})
22
+ await saveBusinessOds('business/id', ods, 'dev')
23
+ expect(axios.get).toHaveBeenCalledWith(
24
+ 'https://api.feedmedev.vip/metadata/business%2Fid/brandkit'
25
+ )
26
+ expect(axios.post).toHaveBeenCalledWith(
27
+ 'https://api.feedmedev.vip/metadata/business%2Fid/brandkit',
28
+ { ...latest, ods }
29
+ )
30
+ expect(latest.ods[0].label).toBe('Old')
31
+ const newer = { ...latest, colors: { primary: '#abcdef' } }
32
+ vi.mocked(axios.get).mockResolvedValue({ data: newer })
33
+ await saveBusinessOds('business/id', ods, 'prod')
34
+ expect(axios.post).toHaveBeenLastCalledWith(
35
+ 'https://api.feedme.vip/metadata/business%2Fid/brandkit',
36
+ { ...newer, ods }
37
+ )
38
+ })
39
+
40
+ it('does not overwrite metadata when reading the latest document fails', async () => {
41
+ vi.mocked(axios.get).mockRejectedValue(new Error('offline'))
42
+ await expect(saveBusinessOds('business', [], 'dev')).rejects.toThrow('offline')
43
+ expect(axios.post).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('returns business metadata without altering it', async () => {
47
+ const brandkit = { ods: [] }
48
+ vi.mocked(axios.get).mockResolvedValue({ data: brandkit })
49
+ expect(await getBusinessBrandkit('business', 'prod')).toBe(brandkit)
50
+ })
51
+ })
52
+
53
+ describe('legacy ODS shape', () => {
54
+ it('wraps an object in a default entry and preserves its settings', () => {
55
+ const legacy = {
56
+ layout: { boardWidthPercent: 80 },
57
+ display: { hideCompletedOrders: true },
58
+ brandkitOverride: { colors: { primary: '#fff' } }
59
+ }
60
+ expect(migrateOdsToArray(legacy, 'Default')).toEqual([
61
+ { label: 'Default', isDefault: true, ...legacy }
62
+ ])
63
+ expect(migrateOdsToArray(undefined, 'Default')).toEqual([])
64
+ const entries = [{ isDefault: false }]
65
+ expect(migrateOdsToArray(entries, 'Default')).toBe(entries)
66
+ })
67
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ availableDisplaySystemViews,
4
+ defaultDisplaySystemView,
5
+ isPosV8,
6
+ requestedDisplaySystemView
7
+ } from './view-selection'
8
+
9
+ const v7a = { _id: 'v7a', posVersion: '7.9.0' }
10
+ const v7b = { _id: 'v7b', posVersion: '7.12.31' }
11
+ const v8a = { _id: 'v8a', posVersion: '8.0.0-beta.1' }
12
+ const v8b = { _id: 'v8b', posVersion: '9.0.0' }
13
+ const fresh = { _id: 'fresh', posVersion: null }
14
+
15
+ const both = { canManageOrderSetting: true, canManageBrandkit: true, hasLegacyOdsConfigs: false }
16
+
17
+ describe('available Display System views', () => {
18
+ it('offers each view only to users with its permission', () => {
19
+ expect(availableDisplaySystemViews({ ...both, outlets: [v7a, v8a] })).toEqual(['v8', 'v7'])
20
+ expect(
21
+ availableDisplaySystemViews({ ...both, canManageBrandkit: false, outlets: [v7a] })
22
+ ).toEqual(['v8'])
23
+ expect(
24
+ availableDisplaySystemViews({ ...both, canManageOrderSetting: false, outlets: [v7a] })
25
+ ).toEqual(['v7'])
26
+ expect(
27
+ availableDisplaySystemViews({
28
+ ...both,
29
+ canManageOrderSetting: false,
30
+ canManageBrandkit: false,
31
+ outlets: [v7a]
32
+ })
33
+ ).toEqual([])
34
+ })
35
+
36
+ it('offers the v7 view to an all-v8 business only while it still has v7 ODS configs', () => {
37
+ expect(availableDisplaySystemViews({ ...both, outlets: [v8a, v8b] })).toEqual(['v8'])
38
+ expect(
39
+ availableDisplaySystemViews({ ...both, outlets: [v8a, v8b], hasLegacyOdsConfigs: true })
40
+ ).toEqual(['v8', 'v7'])
41
+ })
42
+
43
+ it('does not count outlets without a POS version as v7', () => {
44
+ expect(availableDisplaySystemViews({ ...both, outlets: [fresh] })).toEqual(['v8'])
45
+ expect(
46
+ availableDisplaySystemViews({ ...both, canManageOrderSetting: false, outlets: [fresh, v8a] })
47
+ ).toEqual([])
48
+ })
49
+ })
50
+
51
+ describe('default Display System view', () => {
52
+ it('follows the business when every outlet runs the same POS', () => {
53
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v7a, v7b])).toBe('v7')
54
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v8a, v8b])).toBe('v8')
55
+ })
56
+
57
+ it('ignores outlets without a POS version, and defaults to v8 when none has one', () => {
58
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v7a, fresh])).toBe('v7')
59
+ expect(defaultDisplaySystemView(['v8', 'v7'], [fresh])).toBe('v8')
60
+ expect(defaultDisplaySystemView(['v8', 'v7'], [])).toBe('v8')
61
+ })
62
+
63
+ it('follows the selected outlet in a mixed business', () => {
64
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v7a, v8a], 'v7a')).toBe('v7')
65
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v7a, v8a], 'v8a')).toBe('v8')
66
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v7a, v8a, fresh], 'fresh')).toBe('v8')
67
+ expect(defaultDisplaySystemView(['v8', 'v7'], [v7a, v8a])).toBe('v8')
68
+ })
69
+
70
+ it('uses the only view a user can open', () => {
71
+ expect(defaultDisplaySystemView(['v7'], [v8a])).toBe('v7')
72
+ expect(defaultDisplaySystemView(['v8'], [v7a])).toBe('v8')
73
+ expect(defaultDisplaySystemView([], [v7a])).toBeUndefined()
74
+ })
75
+ })
76
+
77
+ describe('requested Display System view', () => {
78
+ it('reads ?view and the brand-app ?tab=legacy-ods link', () => {
79
+ expect(requestedDisplaySystemView({ view: 'v7' })).toBe('v7')
80
+ expect(requestedDisplaySystemView({ view: 'v8' })).toBe('v8')
81
+ expect(requestedDisplaySystemView({ tab: 'legacy-ods' })).toBe('v7')
82
+ expect(requestedDisplaySystemView({ view: 'other' })).toBeUndefined()
83
+ expect(requestedDisplaySystemView({})).toBeUndefined()
84
+ })
85
+ })
86
+
87
+ it('parses the major version like the backend', () => {
88
+ for (const posVersion of [undefined, null, '', '7.99', 'invalid']) {
89
+ expect(isPosV8(posVersion)).toBe(false)
90
+ }
91
+ expect(isPosV8('8.0.0-beta.1')).toBe(true)
92
+ expect(isPosV8('10')).toBe(true)
93
+ })
@@ -0,0 +1,54 @@
1
+ export type DisplaySystemView = 'v8' | 'v7'
2
+
3
+ interface Outlet {
4
+ _id: string
5
+ posVersion?: string | null
6
+ }
7
+
8
+ export function isPosV8(posVersion: string | null | undefined): boolean {
9
+ if (!posVersion) return false
10
+ return parseInt(posVersion.split('.')[0]) >= 8
11
+ }
12
+
13
+ // An outlet that has never reported a POS version says nothing about which
14
+ // system the merchant runs, so it is left out of every choice below.
15
+ const isKnownV7 = (outlet: Outlet) => Boolean(outlet.posVersion) && !isPosV8(outlet.posVersion)
16
+
17
+ export function availableDisplaySystemViews(options: {
18
+ canManageOrderSetting: boolean
19
+ canManageBrandkit: boolean
20
+ outlets: Outlet[]
21
+ hasLegacyOdsConfigs: boolean
22
+ }): DisplaySystemView[] {
23
+ const views: DisplaySystemView[] = options.canManageOrderSetting ? ['v8'] : []
24
+ if (
25
+ options.canManageBrandkit &&
26
+ (options.outlets.some(isKnownV7) || options.hasLegacyOdsConfigs)
27
+ ) {
28
+ views.push('v7')
29
+ }
30
+ return views
31
+ }
32
+
33
+ export function defaultDisplaySystemView(
34
+ available: DisplaySystemView[],
35
+ outlets: Outlet[],
36
+ currentOutletId?: string
37
+ ): DisplaySystemView | undefined {
38
+ if (available.length < 2) return available[0]
39
+ const known = outlets.filter((outlet) => outlet.posVersion)
40
+ if (known.length && known.every(isKnownV7)) return 'v7'
41
+ if (known.every((outlet) => isPosV8(outlet.posVersion))) return 'v8'
42
+ const current = outlets.find((outlet) => outlet._id === currentOutletId)
43
+ return current && isKnownV7(current) ? 'v7' : 'v8'
44
+ }
45
+
46
+ // `?tab=legacy-ods` is what the brand-app Brandkit link sends.
47
+ export function requestedDisplaySystemView(query: {
48
+ view?: unknown
49
+ tab?: unknown
50
+ }): DisplaySystemView | undefined {
51
+ if (query.view === 'v7' || query.tab === 'legacy-ods') return 'v7'
52
+ if (query.view === 'v8') return 'v8'
53
+ return undefined
54
+ }