@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.
- package/dist/DisplaySystemView-Doh1JUoe.js +8891 -0
- package/dist/{KioskDevicesView-d2WLB0-w.js → KioskDevicesView-DvUq9eFa.js} +1 -1
- 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
- package/dist/{KioskSettingView-DFE3_u5c.js → KioskSettingView-Ba1sLolg.js} +3 -3
- package/dist/{KioskView-C3Pi3lWW.js → KioskView-CeKSWYbI.js} +5 -5
- package/dist/{OrderSettingsView-Dxg9RjSd.js → OrderSettingsView-CdxkPS_V.js} +22200 -22918
- package/dist/{app-BFlQtcsd.js → app-BQ0W0_dt.js} +2536 -1818
- package/dist/app.js +1 -1
- package/dist/featureFlag-C4gAe_F4.js +846 -0
- package/dist/frontend/mf-order/src/api/brandkit/index.d.ts +3 -0
- package/dist/frontend/mf-order/src/api/native-app/index.d.ts +2 -0
- package/dist/frontend/mf-order/src/app.d.ts +716 -0
- package/dist/frontend/mf-order/src/composables/featureFlag.d.ts +7 -0
- package/dist/frontend/mf-order/src/composables/useBusinessFeatureFlag.d.ts +4 -0
- package/dist/frontend/mf-order/src/composables/useLegacyOdsAccess.d.ts +4 -0
- package/dist/frontend/mf-order/src/composables/useMiniProgramPermission.d.ts +5 -0
- package/dist/frontend/mf-order/src/main.d.ts +716 -0
- package/dist/frontend/mf-order/src/modules/display-system/legacy-ods/interface.d.ts +80 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorOverrideDialog.vue.d.ts +26 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/ColorSelection.vue.d.ts +15 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue.d.ts +24 -0
- package/dist/frontend/mf-order/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue.d.ts +4 -0
- package/dist/frontend/mf-order/src/views/display-system/view-selection.d.ts +18 -0
- package/dist/frontend/mf-order/src/views/order-settings/components/push-availability.d.ts +13 -0
- package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
- package/dist/{index-ByiTrmdq.js → index-BFJ4YEfL.js} +2 -2
- package/dist/{index-CaO7aaaT.js → index-CGiJXVU3.js} +1 -1
- package/dist/{index-s2kWGMtz.js → index-DSB009Br.js} +1 -1
- package/dist/{order.dto-C829lA69.js → order.dto-CUp8zhQC.js} +18 -18
- package/dist/package/entity/order-platform/tng/tng-menu.do.d.ts +74 -74
- package/dist/style.css +1 -1
- package/env.d.ts +2 -0
- package/package.json +2 -1
- package/src/api/brandkit/index.ts +24 -0
- package/src/api/native-app/index.ts +26 -0
- package/src/api/native-app/native-app.spec.ts +42 -0
- package/src/composables/featureFlag.ts +22 -0
- package/src/composables/useBusinessFeatureFlag.ts +44 -0
- package/src/composables/useLegacyOdsAccess.ts +35 -0
- package/src/composables/useMiniProgramPermission.ts +19 -0
- package/src/composables/useQrOrderingEnabled.ts +1 -21
- package/src/locales/en-US.json +179 -0
- package/src/locales/ja-JP.json +179 -0
- package/src/locales/th-TH.json +179 -0
- package/src/locales/zh-CN.json +179 -0
- package/src/modules/display-system/legacy-ods/interface.ts +108 -0
- package/src/router/routes.ts +1 -1
- package/src/views/display-system/DisplaySystemView.vue +82 -11
- package/src/views/display-system/legacy-ods/ColorOverrideDialog.vue +223 -0
- package/src/views/display-system/legacy-ods/ColorSelection.vue +67 -0
- package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.spec.ts +185 -0
- package/src/views/display-system/legacy-ods/LegacyOdsEditorDialog.vue +1677 -0
- package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.spec.ts +124 -0
- package/src/views/display-system/legacy-ods/LegacyOdsTemplateList.vue +297 -0
- package/src/views/display-system/legacy-ods/brandkit.spec.ts +67 -0
- package/src/views/display-system/view-selection.spec.ts +93 -0
- package/src/views/display-system/view-selection.ts +54 -0
- package/src/views/order-settings/components/NotifyChannelEditor.spec.ts +184 -0
- package/src/views/order-settings/components/NotifyChannelEditor.vue +84 -5
- package/src/views/order-settings/components/push-availability.spec.ts +59 -0
- package/src/views/order-settings/components/push-availability.ts +78 -0
- package/src/views/order-settings/queue/QueueSetting.vue +7 -0
- package/src/views/order-settings/sms/SmsSetting.vue +11 -0
- package/dist/DisplaySystemView-DZS7EVfk.js +0 -6564
- 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(
|
|
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(
|
|
100
|
+
const route = chain.map(routeLabel).join(' → ')
|
|
62
101
|
return alongside.length
|
|
63
|
-
? t('order.notifyChannel.alongside', { channel:
|
|
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">
|
|
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
|
}
|