@feedmepos/mf-order-setting 0.0.93 → 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 (32) hide show
  1. package/dist/{DisplaySystemView-BMRCs7o8.js → DisplaySystemView-Doh1JUoe.js} +3 -3
  2. package/dist/{KioskDevicesView-BCZOqou3.js → KioskDevicesView-DvUq9eFa.js} +1 -1
  3. package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-4wFO9co5.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-Ce-CHsOK.js} +3 -3
  4. package/dist/{KioskSettingView-DIvpRwFV.js → KioskSettingView-Ba1sLolg.js} +3 -3
  5. package/dist/{KioskView-w4txC9m2.js → KioskView-CeKSWYbI.js} +5 -5
  6. package/dist/{OrderSettingsView-BPuBA2QC.js → OrderSettingsView-CdxkPS_V.js} +17715 -17601
  7. package/dist/{app-xZAaURng.js → app-BQ0W0_dt.js} +289 -241
  8. package/dist/app.js +1 -1
  9. package/dist/{featureFlag-Cbj0uMaM.js → featureFlag-C4gAe_F4.js} +1 -1
  10. package/dist/frontend/mf-order/src/api/native-app/index.d.ts +2 -0
  11. package/dist/frontend/mf-order/src/app.d.ts +48 -0
  12. package/dist/frontend/mf-order/src/main.d.ts +48 -0
  13. package/dist/frontend/mf-order/src/views/order-settings/components/push-availability.d.ts +13 -0
  14. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
  15. package/dist/{index-BAFwfvqv.js → index-BFJ4YEfL.js} +2 -2
  16. package/dist/{index-BxQKdXyN.js → index-CGiJXVU3.js} +1 -1
  17. package/dist/{index-Cipbdi6i.js → index-DSB009Br.js} +1 -1
  18. package/dist/{order.dto-yLICIGDQ.js → order.dto-CUp8zhQC.js} +18 -18
  19. package/dist/package/entity/order-platform/tng/tng-menu.do.d.ts +74 -74
  20. package/package.json +1 -1
  21. package/src/api/native-app/index.ts +26 -0
  22. package/src/api/native-app/native-app.spec.ts +42 -0
  23. package/src/locales/en-US.json +12 -0
  24. package/src/locales/ja-JP.json +12 -0
  25. package/src/locales/th-TH.json +12 -0
  26. package/src/locales/zh-CN.json +12 -0
  27. package/src/views/order-settings/components/NotifyChannelEditor.spec.ts +184 -0
  28. package/src/views/order-settings/components/NotifyChannelEditor.vue +84 -5
  29. package/src/views/order-settings/components/push-availability.spec.ts +59 -0
  30. package/src/views/order-settings/components/push-availability.ts +78 -0
  31. package/src/views/order-settings/queue/QueueSetting.vue +7 -0
  32. package/src/views/order-settings/sms/SmsSetting.vue +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedmepos/mf-order-setting",
3
- "version": "0.0.93",
3
+ "version": "0.0.94",
4
4
  "type": "module",
5
5
  "module": "./dist/app.js",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,26 @@
1
+ import axios from 'axios'
2
+
3
+ /**
4
+ * Whether a business has its own native app, from the public metadata endpoint
5
+ * Connect uses for the same gate (marketing-services `api/nativeApp.ts`). Push
6
+ * notifications only reach customers through a native app.
7
+ *
8
+ * Kept off the authenticated portal clients: the endpoint is public, and the
9
+ * portal token must not go to it.
10
+ */
11
+ const publicClient = axios.create({ withCredentials: false, timeout: 5000 })
12
+
13
+ export function nativeAppUrl(businessId: string, isDev: boolean): string {
14
+ const host = isDev ? 'api.feedmedev.vip' : 'api.feedme.vip'
15
+ return `https://${host}/metadata/${encodeURIComponent(businessId)}/native-app`
16
+ }
17
+
18
+ export async function getNativeApp(businessId: string, isDev: boolean): Promise<boolean> {
19
+ if (!/^[0-9a-f]{24}$/i.test(businessId)) throw new Error('Invalid business ID')
20
+ const { data } = await publicClient.get(nativeAppUrl(businessId, isDev))
21
+ // An answer for another business, or without `native`, is not an answer.
22
+ if (data?.businessId !== businessId || typeof data?.native !== 'boolean') {
23
+ throw new Error('Unexpected native-app response')
24
+ }
25
+ return data.native
26
+ }
@@ -0,0 +1,42 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const { get } = vi.hoisted(() => ({ get: vi.fn() }))
4
+ vi.mock('axios', () => ({ default: { create: () => ({ get }) } }))
5
+
6
+ import { getNativeApp, nativeAppUrl } from '.'
7
+
8
+ const BUSINESS = '69cddfe676e1e9001cd9450a'
9
+
10
+ beforeEach(() => get.mockReset())
11
+
12
+ describe('getNativeApp', () => {
13
+ it('reads the public metadata endpoint of the right environment', () => {
14
+ expect(nativeAppUrl(BUSINESS, true)).toBe(
15
+ `https://api.feedmedev.vip/metadata/${BUSINESS}/native-app`
16
+ )
17
+ expect(nativeAppUrl(BUSINESS, false)).toBe(
18
+ `https://api.feedme.vip/metadata/${BUSINESS}/native-app`
19
+ )
20
+ })
21
+
22
+ it('returns `native` for the business asked about', async () => {
23
+ get.mockResolvedValue({
24
+ data: { businessId: BUSINESS, native: true, platforms: ['ios', 'android'] }
25
+ })
26
+ await expect(getNativeApp(BUSINESS, true)).resolves.toBe(true)
27
+ get.mockResolvedValue({ data: { businessId: BUSINESS, native: false, platforms: [] } })
28
+ await expect(getNativeApp(BUSINESS, true)).resolves.toBe(false)
29
+ })
30
+
31
+ it('rejects an answer for another business, or one without `native`', async () => {
32
+ get.mockResolvedValue({ data: { businessId: '6ad6d30f77c221001cd6c1a2', native: true } })
33
+ await expect(getNativeApp(BUSINESS, true)).rejects.toThrow()
34
+ get.mockResolvedValue({ data: { businessId: BUSINESS } })
35
+ await expect(getNativeApp(BUSINESS, true)).rejects.toThrow()
36
+ })
37
+
38
+ it('does not call out for a malformed business id', async () => {
39
+ await expect(getNativeApp('../x', true)).rejects.toThrow()
40
+ expect(get).not.toHaveBeenCalled()
41
+ })
42
+ })
@@ -503,6 +503,18 @@
503
503
  "hint": "Tried in order. The next channel is used only if the one before it cannot reach the customer.",
504
504
  "add": "Add channel",
505
505
  "pushNote": "Push only reaches customers who have the app installed with notifications allowed. Otherwise the next channel is tried.",
506
+ "pushInRoute": "{channel} ({status})",
507
+ "pushAvailability": {
508
+ "checking": "Checking availability",
509
+ "requiresNativeApp": "Requires a native app",
510
+ "unavailable": "Unable to check availability",
511
+ "contactSales": "Contact sales",
512
+ "dialogMessage": "Push notifications require a native app. Please contact your FeedMe sales representative to enable one.",
513
+ "retry": "Retry",
514
+ "removePush": "Remove Push",
515
+ "removeBeforeSave": "Confirm native-app availability or remove Push before saving.",
516
+ "close": "Close"
517
+ },
506
518
  "alongside": "{channel} is always sent, plus {route}",
507
519
  "everyone": "All customers: {route}",
508
520
  "member": "Signed-in customers: {route}",
@@ -407,6 +407,18 @@
407
407
  "hint": "上から順に試します。前のチャネルでお客様に届かない場合のみ、次のチャネルを使います。",
408
408
  "add": "チャネルを追加",
409
409
  "pushNote": "プッシュ通知は、アプリをインストールし通知を許可しているお客様にのみ届きます。届かない場合は次のチャネルを試します。",
410
+ "pushInRoute": "{channel}({status})",
411
+ "pushAvailability": {
412
+ "checking": "利用可否を確認中",
413
+ "requiresNativeApp": "ネイティブアプリが必要です",
414
+ "unavailable": "利用可否を確認できませんでした",
415
+ "contactSales": "営業担当に連絡",
416
+ "dialogMessage": "プッシュ通知にはネイティブアプリが必要です。有効にするには FeedMe の営業担当者にお問い合わせください。",
417
+ "retry": "再試行",
418
+ "removePush": "プッシュを削除",
419
+ "removeBeforeSave": "保存する前にネイティブアプリの利用可否を確認するか、プッシュを削除してください。",
420
+ "close": "閉じる"
421
+ },
410
422
  "alongside": "{channel}は常に送信、加えて {route}",
411
423
  "everyone": "すべてのお客様:{route}",
412
424
  "member": "ログイン済みのお客様:{route}",
@@ -503,6 +503,18 @@
503
503
  "hint": "ลองตามลำดับ ช่องทางถัดไปจะใช้เมื่อช่องทางก่อนหน้าส่งถึงลูกค้าไม่ได้เท่านั้น",
504
504
  "add": "เพิ่มช่องทาง",
505
505
  "pushNote": "การแจ้งเตือนแบบพุชจะส่งถึงเฉพาะลูกค้าที่ติดตั้งแอปและอนุญาตการแจ้งเตือน มิฉะนั้นจะลองช่องทางถัดไป",
506
+ "pushInRoute": "{channel} ({status})",
507
+ "pushAvailability": {
508
+ "checking": "กำลังตรวจสอบความพร้อมใช้งาน",
509
+ "requiresNativeApp": "ต้องมีแอปเนทีฟ",
510
+ "unavailable": "ไม่สามารถตรวจสอบความพร้อมใช้งานได้",
511
+ "contactSales": "ติดต่อฝ่ายขาย",
512
+ "dialogMessage": "การแจ้งเตือนแบบพุชต้องมีแอปเนทีฟ โปรดติดต่อเจ้าหน้าที่ฝ่ายขาย FeedMe ของคุณเพื่อเปิดใช้งาน",
513
+ "retry": "ลองอีกครั้ง",
514
+ "removePush": "นำพุชออก",
515
+ "removeBeforeSave": "ยืนยันความพร้อมใช้งานของแอปเนทีฟหรือนำพุชออกก่อนบันทึก",
516
+ "close": "ปิด"
517
+ },
506
518
  "alongside": "ส่ง{channel}เสมอ และ {route}",
507
519
  "everyone": "ลูกค้าทุกคน: {route}",
508
520
  "member": "ลูกค้าที่เข้าสู่ระบบ: {route}",
@@ -507,6 +507,18 @@
507
507
  "hint": "按顺序尝试。只有前一个渠道无法送达顾客时,才会使用下一个。",
508
508
  "add": "添加渠道",
509
509
  "pushNote": "推送通知只能送达已安装应用并允许通知的顾客,否则会尝试下一个渠道。",
510
+ "pushInRoute": "{channel}({status})",
511
+ "pushAvailability": {
512
+ "checking": "正在检查可用性",
513
+ "requiresNativeApp": "需要原生应用",
514
+ "unavailable": "无法检查可用性",
515
+ "contactSales": "联系销售",
516
+ "dialogMessage": "推送通知需要原生应用。请联系您的 FeedMe 销售代表以启用原生应用。",
517
+ "retry": "重试",
518
+ "removePush": "移除推送",
519
+ "removeBeforeSave": "请确认原生应用可用,或移除推送后再保存。",
520
+ "close": "关闭"
521
+ },
510
522
  "alongside": "始终发送{channel},另外:{route}",
511
523
  "everyone": "所有顾客:{route}",
512
524
  "member": "已登录顾客:{route}",
@@ -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(