@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,184 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { flushPromises, mount } from '@vue/test-utils'
4
+ import { defineComponent, h, ref } from 'vue'
5
+
6
+ const BUSINESS = '69cddfe676e1e9001cd9450a'
7
+ const getNativeApp = vi.fn()
8
+ const openDialog = vi.fn()
9
+
10
+ vi.mock('@feedmepos/mf-common', () => ({
11
+ // Parameters are shown as key(value|value), so the summary's nesting can be read.
12
+ useI18n: () => ({
13
+ t: (key: string, params?: Record<string, string>) =>
14
+ params ? `${key}(${Object.values(params).join('|')})` : key
15
+ }),
16
+ useCoreStore: () => ({
17
+ currentCountry: ref('MY'),
18
+ currentBusiness: ref({ _id: BUSINESS }),
19
+ namespace: ref('dev')
20
+ })
21
+ }))
22
+ vi.mock('@feedmepos/ui-library', () => ({ useDialog: () => ({ open: openDialog }) }))
23
+ vi.mock('@/api/native-app', () => ({ getNativeApp: (...args: unknown[]) => getNativeApp(...args) }))
24
+
25
+ import NotifyChannelEditor from './NotifyChannelEditor.vue'
26
+ import { resetPushAvailability } from './push-availability'
27
+
28
+ const FmButton = defineComponent({
29
+ props: { label: String, icon: String, variant: String, disabled: Boolean },
30
+ emits: ['click'],
31
+ setup:
32
+ (props, { emit }) =>
33
+ () =>
34
+ h('button', { 'data-icon': props.icon, onClick: () => emit('click') }, props.label)
35
+ })
36
+ const FmSelect = defineComponent({
37
+ name: 'FmSelect',
38
+ props: { items: Array, modelValue: null, placeholder: String },
39
+ setup: () => () => h('div', { class: 'fm-select' })
40
+ })
41
+
42
+ async function mountEditor(channels: string[], readonly = false) {
43
+ const w = mount(NotifyChannelEditor, {
44
+ props: { modelValue: channels as never, readonly },
45
+ global: { stubs: { FmButton, FmSelect } }
46
+ })
47
+ await flushPromises()
48
+ return w
49
+ }
50
+
51
+ const offered = (w: Awaited<ReturnType<typeof mountEditor>>) =>
52
+ (w.findComponent(FmSelect).props('items') as { value: string }[]).map((item) => item.value)
53
+
54
+ beforeEach(() => {
55
+ resetPushAvailability()
56
+ getNativeApp.mockReset()
57
+ openDialog.mockReset()
58
+ })
59
+
60
+ describe('NotifyChannelEditor: push only with a native app', () => {
61
+ it('asks once for the current business, on the right environment', async () => {
62
+ getNativeApp.mockResolvedValue(true)
63
+ await mountEditor([])
64
+ await mountEditor([])
65
+ expect(getNativeApp).toHaveBeenCalledTimes(1)
66
+ expect(getNativeApp).toHaveBeenCalledWith(BUSINESS, true)
67
+ })
68
+
69
+ it('native: offers Push notification, with no availability note', async () => {
70
+ getNativeApp.mockResolvedValue(true)
71
+ const w = await mountEditor([])
72
+ expect(offered(w)).toEqual(['SMS', 'APP_INBOX', 'PUSH_NOTIFICATION'])
73
+ expect(w.find('.push-availability').exists()).toBe(false)
74
+ })
75
+
76
+ it('non-native: does not offer Push, says why, and offers Contact sales', async () => {
77
+ getNativeApp.mockResolvedValue(false)
78
+ const w = await mountEditor([])
79
+ expect(offered(w)).toEqual(['SMS', 'APP_INBOX'])
80
+ expect(w.find('.push-availability').text()).toContain(
81
+ 'order.notifyChannel.pushAvailability.requiresNativeApp'
82
+ )
83
+ expect(w.find('.retry-push').exists()).toBe(false)
84
+ await w.find('.contact-sales').trigger('click')
85
+ expect(openDialog).toHaveBeenCalledWith(
86
+ expect.objectContaining({ message: 'order.notifyChannel.pushAvailability.dialogMessage' })
87
+ )
88
+ })
89
+
90
+ it('while checking: does not offer Push yet', async () => {
91
+ getNativeApp.mockReturnValue(new Promise(() => {}))
92
+ const w = await mountEditor([])
93
+ expect(offered(w)).toEqual(['SMS', 'APP_INBOX'])
94
+ expect(w.find('.push-availability').text()).toContain(
95
+ 'order.notifyChannel.pushAvailability.checking'
96
+ )
97
+ })
98
+
99
+ it('failed: does not offer Push, and Retry checks again', async () => {
100
+ getNativeApp.mockRejectedValueOnce(new Error('timeout')).mockResolvedValueOnce(true)
101
+ const w = await mountEditor([])
102
+ expect(offered(w)).toEqual(['SMS', 'APP_INBOX'])
103
+ expect(w.find('.push-availability').text()).toContain(
104
+ 'order.notifyChannel.pushAvailability.unavailable'
105
+ )
106
+ expect(w.find('.contact-sales').exists()).toBe(false)
107
+
108
+ await w.find('.retry-push').trigger('click')
109
+ await flushPromises()
110
+ expect(getNativeApp).toHaveBeenCalledTimes(2)
111
+ expect(offered(w)).toEqual(['SMS', 'APP_INBOX', 'PUSH_NOTIFICATION'])
112
+ })
113
+
114
+ it('a stored push on a non-native business is shown, flagged, and can be removed', async () => {
115
+ getNativeApp.mockResolvedValue(false)
116
+ const w = await mountEditor(['PUSH_NOTIFICATION', 'SMS'])
117
+ expect(w.text()).toContain('1. order.notifyChannel.channel.PUSH_NOTIFICATION')
118
+ expect(w.find('.remove-before-save').text()).toBe(
119
+ 'order.notifyChannel.pushAvailability.removeBeforeSave'
120
+ )
121
+ expect(w.text()).not.toContain('order.notifyChannel.pushNote')
122
+
123
+ await w.find('.remove-push').trigger('click')
124
+ expect(w.emitted('update:modelValue')).toEqual([[['SMS']]])
125
+ })
126
+
127
+ it('a stored push on a native business is left alone', async () => {
128
+ getNativeApp.mockResolvedValue(true)
129
+ const w = await mountEditor(['PUSH_NOTIFICATION', 'SMS'])
130
+ expect(w.find('.remove-push').exists()).toBe(false)
131
+ expect(w.find('.remove-before-save').exists()).toBe(false)
132
+ expect(w.text()).toContain('order.notifyChannel.pushNote')
133
+ })
134
+
135
+ describe('the summary', () => {
136
+ const PUSH = 'order.notifyChannel.channel.PUSH_NOTIFICATION'
137
+ const SMS = 'order.notifyChannel.channel.SMS'
138
+ const INBOX = 'order.notifyChannel.channel.APP_INBOX'
139
+ const unconfirmed = (status: string) =>
140
+ `order.notifyChannel.pushInRoute(${PUSH}|order.notifyChannel.pushAvailability.${status})`
141
+
142
+ it('read-only, no native app: does not claim push, and says why', async () => {
143
+ getNativeApp.mockResolvedValue(false)
144
+ const w = await mountEditor(['PUSH_NOTIFICATION', 'SMS'], true)
145
+ expect(w.text()).toContain(
146
+ `order.notifyChannel.member(${unconfirmed('requiresNativeApp')} → ${SMS})`
147
+ )
148
+ expect(w.text()).not.toContain(`${PUSH} → ${SMS}`)
149
+ expect(w.text()).toContain(`order.notifyChannel.guest(${SMS})`)
150
+ })
151
+
152
+ it('read-only, check failed or still checking: push is marked the same way', async () => {
153
+ getNativeApp.mockRejectedValue(new Error('timeout'))
154
+ const failed = await mountEditor(['PUSH_NOTIFICATION', 'SMS'], true)
155
+ expect(failed.text()).toContain(`${unconfirmed('unavailable')} → ${SMS}`)
156
+
157
+ resetPushAvailability()
158
+ getNativeApp.mockReturnValue(new Promise(() => {}))
159
+ const checking = await mountEditor(['PUSH_NOTIFICATION', 'SMS'], true)
160
+ expect(checking.text()).toContain(`${unconfirmed('checking')} → ${SMS}`)
161
+ })
162
+
163
+ it('keeps App Inbox alongside, as routing still does with push in the list', async () => {
164
+ getNativeApp.mockResolvedValue(false)
165
+ const w = await mountEditor(['PUSH_NOTIFICATION', 'APP_INBOX', 'SMS'], true)
166
+ expect(w.text()).toContain(
167
+ `order.notifyChannel.alongside(${INBOX}|${unconfirmed('requiresNativeApp')} → ${SMS})`
168
+ )
169
+ })
170
+
171
+ it('the editable summary is labelled the same way', async () => {
172
+ getNativeApp.mockResolvedValue(false)
173
+ const w = await mountEditor(['PUSH_NOTIFICATION', 'SMS'])
174
+ expect(w.text()).toContain(`${unconfirmed('requiresNativeApp')} → ${SMS}`)
175
+ })
176
+
177
+ it('native: push is listed plainly', async () => {
178
+ getNativeApp.mockResolvedValue(true)
179
+ const w = await mountEditor(['PUSH_NOTIFICATION', 'SMS'], true)
180
+ expect(w.text()).toContain(`order.notifyChannel.member(${PUSH} → ${SMS})`)
181
+ expect(w.text()).not.toContain('order.notifyChannel.pushInRoute')
182
+ })
183
+ })
184
+ })
@@ -2,8 +2,10 @@
2
2
  import { computed } from 'vue'
3
3
  import { F_ORDER_NOTIFY_CHANNEL, planSends, resolveChannels } from '@entity'
4
4
  import { useCoreStore, useI18n } from '@feedmepos/mf-common'
5
+ import { useDialog } from '@feedmepos/ui-library'
5
6
 
6
7
  import { canChooseNotifyChannels } from './notify-channel-country'
8
+ import { pushBlocked, usePushAvailability } from './push-availability'
7
9
 
8
10
  const props = defineProps<{
9
11
  modelValue?: F_ORDER_NOTIFY_CHANNEL[] | null
@@ -26,8 +28,34 @@ const SELECTABLE: F_ORDER_NOTIFY_CHANNEL[] = [SMS, APP_INBOX, PUSH_NOTIFICATION]
26
28
 
27
29
  const channels = computed(() => props.modelValue ?? [])
28
30
 
31
+ // Push only when the business has a native app, as in Connect. See push-availability.ts.
32
+ const { availability, retry } = usePushAvailability()
33
+ const pushOffered = computed(() => availability.value === 'native')
34
+ const blocked = computed(() => pushBlocked(channels.value, availability.value))
35
+ const pushAvailabilityText = computed(() =>
36
+ t(
37
+ 'order.notifyChannel.pushAvailability.' +
38
+ (availability.value === 'checking'
39
+ ? 'checking'
40
+ : availability.value === 'non-native'
41
+ ? 'requiresNativeApp'
42
+ : 'unavailable')
43
+ )
44
+ )
45
+
46
+ const Dialog = useDialog()
47
+ function contactSales() {
48
+ Dialog.open({
49
+ title: t('order.notifyChannel.pushAvailability.contactSales'),
50
+ message: t('order.notifyChannel.pushAvailability.dialogMessage'),
51
+ primaryActions: { text: t('order.notifyChannel.pushAvailability.close'), close: true }
52
+ })
53
+ }
54
+
29
55
  const addable = computed(() =>
30
- SELECTABLE.filter((c) => !channels.value.includes(c)).map((c) => ({
56
+ SELECTABLE.filter(
57
+ (c) => !channels.value.includes(c) && (c !== PUSH_NOTIFICATION || pushOffered.value)
58
+ ).map((c) => ({
31
59
  label: channelLabel(c),
32
60
  value: c
33
61
  }))
@@ -37,7 +65,18 @@ function channelLabel(channel: F_ORDER_NOTIFY_CHANNEL) {
37
65
  return t(`order.notifyChannel.channel.${channel}`)
38
66
  }
39
67
 
68
+ // The summary keeps a stored push where routing puts it (it still decides whether
69
+ // App Inbox goes alongside), but must not claim it reaches anyone until confirmed.
70
+ function routeLabel(channel: F_ORDER_NOTIFY_CHANNEL) {
71
+ if (channel !== PUSH_NOTIFICATION || pushOffered.value) return channelLabel(channel)
72
+ return t('order.notifyChannel.pushInRoute', {
73
+ channel: channelLabel(channel),
74
+ status: pushAvailabilityText.value
75
+ })
76
+ }
77
+
40
78
  function add(channel: F_ORDER_NOTIFY_CHANNEL) {
79
+ if (channel === PUSH_NOTIFICATION && !pushOffered.value) return
41
80
  emit('update:modelValue', [...channels.value, channel])
42
81
  }
43
82
 
@@ -58,9 +97,9 @@ function describe(hasAccount: boolean) {
58
97
  const { alongside, chain } = planSends(
59
98
  resolveChannels({ configured: channels.value, hasAppInboxUid: hasAccount })
60
99
  )
61
- const route = chain.map(channelLabel).join(' → ')
100
+ const route = chain.map(routeLabel).join(' → ')
62
101
  return alongside.length
63
- ? t('order.notifyChannel.alongside', { channel: channelLabel(alongside[0]), route })
102
+ ? t('order.notifyChannel.alongside', { channel: routeLabel(alongside[0]), route })
64
103
  : route
65
104
  }
66
105
 
@@ -88,7 +127,22 @@ const summary = computed(() => {
88
127
  :key="channel"
89
128
  class="flex items-center gap-2 border border-fm-color-neutral-gray-200 rounded px-3 py-1"
90
129
  >
91
- <span class="flex-grow">{{ i + 1 }}. {{ channelLabel(channel) }}</span>
130
+ <span class="flex-grow">
131
+ {{ i + 1 }}. {{ channelLabel(channel) }}
132
+ <span
133
+ v-if="channel === PUSH_NOTIFICATION && !pushOffered"
134
+ class="ml-2 inline-flex rounded-[6px] bg-fm-color-system-warning-100 px-2 py-[3px] fm-typo-en-body-sm-400 text-fm-color-system-warning-400"
135
+ >
136
+ {{ pushAvailabilityText }}
137
+ </span>
138
+ </span>
139
+ <FmButton
140
+ v-if="channel === PUSH_NOTIFICATION && !pushOffered"
141
+ class="remove-push"
142
+ variant="tertiary"
143
+ :label="t('order.notifyChannel.pushAvailability.removePush')"
144
+ @click="remove(i)"
145
+ />
92
146
  <FmButton
93
147
  icon="arrow_upward"
94
148
  variant="tertiary"
@@ -111,8 +165,33 @@ const summary = computed(() => {
111
165
  :placeholder="t('order.notifyChannel.add')"
112
166
  @update:model-value="add"
113
167
  />
168
+ <div v-if="!pushOffered" class="push-availability flex items-center gap-2">
169
+ <span class="fm-typo-en-body-sm-400 text-fm-color-neutral-gray-400">
170
+ {{ channelLabel(PUSH_NOTIFICATION) }}: {{ pushAvailabilityText }}
171
+ </span>
172
+ <FmButton
173
+ v-if="availability === 'non-native'"
174
+ class="contact-sales"
175
+ variant="tertiary"
176
+ :label="t('order.notifyChannel.pushAvailability.contactSales')"
177
+ @click="contactSales"
178
+ />
179
+ <FmButton
180
+ v-if="availability === 'failed'"
181
+ class="retry-push"
182
+ variant="tertiary"
183
+ :label="t('order.notifyChannel.pushAvailability.retry')"
184
+ @click="retry"
185
+ />
186
+ </div>
187
+ <span
188
+ v-if="blocked"
189
+ class="remove-before-save fm-typo-en-body-sm-400 text-fm-color-system-warning-400"
190
+ >
191
+ {{ t('order.notifyChannel.pushAvailability.removeBeforeSave') }}
192
+ </span>
114
193
  <span
115
- v-if="channels.includes(PUSH_NOTIFICATION)"
194
+ v-else-if="channels.includes(PUSH_NOTIFICATION)"
116
195
  class="fm-typo-en-body-sm-400 text-fm-color-neutral-gray-400"
117
196
  >
118
197
  {{ t('order.notifyChannel.pushNote') }}
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ vi.mock('@feedmepos/mf-common', () => ({ useCoreStore: vi.fn() }))
6
+ vi.mock('@/api/native-app', () => ({ getNativeApp: vi.fn() }))
7
+
8
+ import { pushBlocked } from './push-availability'
9
+
10
+ const read = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
11
+
12
+ describe('pushBlocked', () => {
13
+ it('blocks a stored push unless the business is known to have a native app', () => {
14
+ expect(pushBlocked(['PUSH_NOTIFICATION', 'SMS'], 'non-native')).toBe(true)
15
+ expect(pushBlocked(['SMS', 'PUSH_NOTIFICATION'], 'failed')).toBe(true)
16
+ expect(pushBlocked(['PUSH_NOTIFICATION'], 'checking')).toBe(true)
17
+ expect(pushBlocked(['PUSH_NOTIFICATION'], 'native')).toBe(false)
18
+ })
19
+
20
+ it('never blocks a list without push', () => {
21
+ expect(pushBlocked(['SMS', 'APP_INBOX'], 'non-native')).toBe(false)
22
+ expect(pushBlocked([], 'failed')).toBe(false)
23
+ expect(pushBlocked(undefined, 'non-native')).toBe(false)
24
+ expect(pushBlocked(null, 'checking')).toBe(false)
25
+ })
26
+ })
27
+
28
+ // The settings pages are not mounted in tests (they need the portal's APIs), so
29
+ // their save guards are checked in the source.
30
+ describe('saves are blocked while a stored push cannot be confirmed', () => {
31
+ it('queue setting: before the order setting is written', () => {
32
+ const page = read('../queue/QueueSetting.vue')
33
+ const save = page.slice(page.indexOf('async function updateOrderSetting'))
34
+ const guard = save.indexOf('pushBlocked(queueSettings.value.channels, pushAvailability.value)')
35
+ expect(guard).toBeGreaterThan(-1)
36
+ expect(guard).toBeLessThan(save.indexOf('OrderSettingApi.updateOrderSetting'))
37
+ expect(save.slice(guard, save.indexOf('OrderSettingApi.updateOrderSetting'))).toContain(
38
+ "showError(t('order.notifyChannel.pushAvailability.removeBeforeSave'))"
39
+ )
40
+ })
41
+
42
+ it('SMS setting: on Confirm of the row being edited, and when a row is turned on', () => {
43
+ const page = read('../sms/SmsSetting.vue')
44
+ const confirm = page.slice(page.indexOf('async function confirmTextField'))
45
+ expect(
46
+ confirm.indexOf('pushBlocked(setting.channels, pushAvailability.value)')
47
+ ).toBeGreaterThan(-1)
48
+ expect(confirm.indexOf('pushBlocked(setting.channels, pushAvailability.value)')).toBeLessThan(
49
+ confirm.indexOf('await updateOrderSetting()')
50
+ )
51
+ const toggle = page.slice(page.indexOf('async function toggleEnable'))
52
+ expect(
53
+ toggle.indexOf('enable && pushBlocked(sms.channels, pushAvailability.value)')
54
+ ).toBeGreaterThan(-1)
55
+ expect(
56
+ toggle.indexOf('enable && pushBlocked(sms.channels, pushAvailability.value)')
57
+ ).toBeLessThan(toggle.indexOf('await updateOrderSetting()'))
58
+ })
59
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Push notification is offered only when the business has a native app, as in
3
+ * Connect's broadcast editor. Without one, a push has no device to reach.
4
+ *
5
+ * - `checking`: the answer is not in yet. Push is not offered.
6
+ * - `native`: push is offered.
7
+ * - `non-native`: push is not offered; the merchant is told why and can ask sales.
8
+ * - `failed`: the check failed or timed out. Push is not offered; the merchant can retry.
9
+ *
10
+ * The answer is kept per business for the whole page, so the SMS setting's many
11
+ * editors and the save guards share one request.
12
+ */
13
+ import { computed, reactive, watch } from 'vue'
14
+ import { F_ORDER_NOTIFY_CHANNEL } from '@entity'
15
+ import { useCoreStore } from '@feedmepos/mf-common'
16
+
17
+ import { getNativeApp } from '@/api/native-app'
18
+
19
+ export type PushAvailability = 'checking' | 'native' | 'non-native' | 'failed'
20
+
21
+ const states = reactive(new Map<string, PushAvailability>())
22
+ const requests = new Map<string, number>()
23
+
24
+ /**
25
+ * A stored push may not be saved again until the business is known to have a
26
+ * native app. Like Connect, "still checking" and "check failed" block too.
27
+ */
28
+ export function pushBlocked(
29
+ channels: F_ORDER_NOTIFY_CHANNEL[] | null | undefined,
30
+ availability: PushAvailability
31
+ ): boolean {
32
+ return (
33
+ !!channels?.includes(F_ORDER_NOTIFY_CHANNEL.Enum.PUSH_NOTIFICATION) && availability !== 'native'
34
+ )
35
+ }
36
+
37
+ async function check(businessId: string, isDev: boolean) {
38
+ const request = (requests.get(businessId) ?? 0) + 1
39
+ requests.set(businessId, request)
40
+ states.set(businessId, 'checking')
41
+ let next: PushAvailability
42
+ try {
43
+ next = (await getNativeApp(businessId, isDev)) ? 'native' : 'non-native'
44
+ } catch {
45
+ next = 'failed'
46
+ }
47
+ // A retry started meanwhile owns the answer.
48
+ if (requests.get(businessId) === request) states.set(businessId, next)
49
+ }
50
+
51
+ export function usePushAvailability() {
52
+ const { currentBusiness, namespace } = useCoreStore()
53
+ const businessId = computed(() => currentBusiness.value?._id ?? null)
54
+
55
+ watch(
56
+ businessId,
57
+ (id) => {
58
+ if (id && !states.has(id)) void check(id, namespace.value === 'dev')
59
+ },
60
+ { immediate: true }
61
+ )
62
+
63
+ const availability = computed<PushAvailability>(() =>
64
+ businessId.value ? (states.get(businessId.value) ?? 'checking') : 'checking'
65
+ )
66
+
67
+ function retry() {
68
+ if (businessId.value) void check(businessId.value, namespace.value === 'dev')
69
+ }
70
+
71
+ return { availability, retry }
72
+ }
73
+
74
+ /** Tests only: forget every answer. */
75
+ export function resetPushAvailability() {
76
+ states.clear()
77
+ requests.clear()
78
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from '@entity'
12
12
  import RestaurantSelector from '../components/RestaurantSelector.vue'
13
13
  import NotifyChannelEditor from '../components/NotifyChannelEditor.vue'
14
+ import { pushBlocked, usePushAvailability } from '../components/push-availability'
14
15
  import { useCoreStore, useI18n } from '@feedmepos/mf-common'
15
16
  import { useSnackbarFunctions } from '@/components/snackbar'
16
17
  import { useDialog } from '@feedmepos/ui-library'
@@ -32,6 +33,7 @@ const presetRemarks = ref<string[]>([])
32
33
  const allowOverrideQueueCode = ref<boolean>(false)
33
34
 
34
35
  const Dialog = useDialog()
36
+ const { availability: pushAvailability } = usePushAvailability()
35
37
 
36
38
  onMounted(() => {
37
39
  init()
@@ -58,6 +60,11 @@ async function updateOrderSetting() {
58
60
  showError(problem)
59
61
  return
60
62
  }
63
+ // Same rule as Connect: a stored push is not saved again for a business without a native app.
64
+ if (!queueSettings.value.disableSMS && pushBlocked(queueSettings.value.channels, pushAvailability.value)) {
65
+ showError(t('order.notifyChannel.pushAvailability.removeBeforeSave'))
66
+ return
67
+ }
61
68
  await startAsyncCallWithErr(async () => {
62
69
  if (orderSettingDefault.value) {
63
70
  await OrderSettingApi.updateOrderSetting(
@@ -13,6 +13,7 @@ import {
13
13
  } from '@entity'
14
14
  import RestaurantSelector from '../components/RestaurantSelector.vue'
15
15
  import NotifyChannelEditor from '../components/NotifyChannelEditor.vue'
16
+ import { pushBlocked, usePushAvailability } from '../components/push-availability'
16
17
 
17
18
  import { useCoreStore } from '@feedmepos/mf-common'
18
19
  import { Dinero } from '@feedmepos/core'
@@ -23,6 +24,7 @@ const { t } = useI18n()
23
24
  import { SMS } from '@common'
24
25
 
25
26
  const { showSuccess, showError } = useSnackbarFunctions()
27
+ const { availability: pushAvailability } = usePushAvailability()
26
28
  const { currentRestaurant } = useCoreStore()
27
29
  const { isLoading, startAsyncCallWithErr, startLoading } = useLoading()
28
30
 
@@ -98,6 +100,10 @@ async function toggleEnable(enable: boolean, sms: OrderSmsSettingDto) {
98
100
  showError(t('order.notifyChannel.messageRequired'))
99
101
  return
100
102
  }
103
+ if (enable && pushBlocked(sms.channels, pushAvailability.value)) {
104
+ showError(t('order.notifyChannel.pushAvailability.removeBeforeSave'))
105
+ return
106
+ }
101
107
  const smsSetting = orderSmsSettings.value.map((_sms: OrderSmsSettingDto) => {
102
108
  if (_sms.action === sms.action && _sms.type === sms.type) {
103
109
  _sms.enable = enable
@@ -134,6 +140,11 @@ async function confirmTextField(index: number) {
134
140
  showError(t('order.notifyChannel.messageRequired'))
135
141
  return
136
142
  }
143
+ // Same rule as Connect: a stored push is not saved again for a business without a native app.
144
+ if (pushBlocked(setting.channels, pushAvailability.value)) {
145
+ showError(t('order.notifyChannel.pushAvailability.removeBeforeSave'))
146
+ return
147
+ }
137
148
  await updateOrderSetting()
138
149
  }
139
150
  }