@feedmepos/mf-order-setting 0.0.84 → 0.0.85
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-hmlT9gsb.js +6485 -0
- package/dist/{KioskDevicesView-DdwFuenU.js → KioskDevicesView-fW6M9Hao.js} +1 -1
- package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-CxtLm_oh.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-GrsnYo80.js} +3 -3
- package/dist/KioskSettingView-BdEPftHH.js +839 -0
- package/dist/{KioskView-fwBwUGCQ.js → KioskView-Bwk9S0tc.js} +82 -70
- package/dist/{OrderSettingsView-RhlyZmzs.js → OrderSettingsView-DVXfVdXj.js} +30858 -30699
- package/dist/{app-ClOkRxOZ.js → app-BnrruYco.js} +74 -12
- package/dist/app.js +1 -1
- package/dist/display-preview/assets/BandBoard-DIcfXsNV.js +1 -0
- package/dist/display-preview/assets/CarouselBlock-XAhTyEWB.js +1 -0
- package/dist/display-preview/assets/{CategoryBoard-DClMUGfS.js → CategoryBoard-DBKe18hv.js} +1 -1
- package/dist/display-preview/assets/ImageBlock-BCbnYSHq.js +1 -0
- package/dist/display-preview/assets/PickupMode-Df_TPCh8.js +1 -0
- package/dist/display-preview/assets/{StatusBoard-BVESD28J.js → StatusBoard-28z3bPOf.js} +1 -1
- package/dist/display-preview/assets/TextBlock-CvJKITtx.js +1 -0
- package/dist/display-preview/assets/VideoBlock-Dop43wDv.js +1 -0
- package/dist/display-preview/assets/index-C6DET5zJ.css +1 -0
- package/dist/display-preview/assets/index-cqMoOpSN.js +2 -0
- package/dist/display-preview/assets/pickupView-DRMVXqrc.js +1 -0
- package/dist/display-preview/index.html +2 -2
- package/dist/frontend/mf-order/src/app.d.ts +62 -0
- package/dist/frontend/mf-order/src/main.d.ts +62 -0
- package/dist/frontend/mf-order/src/modules/display-system/interface.d.ts +21 -0
- package/dist/frontend/mf-order/src/modules/order-setting/kiosk/coverCarousel.d.ts +69 -0
- package/dist/frontend/mf-order/src/modules/order-setting/kiosk/interface.d.ts +5 -1
- package/dist/frontend/mf-order/src/stores/order-setting/index.d.ts +90 -3
- package/dist/frontend/mf-order/src/views/display-system/DisplayFieldControl.vue.d.ts +3 -0
- package/dist/frontend/mf-order/src/views/display-system/DisplayInspector.vue.d.ts +3 -13
- package/dist/frontend/mf-order/src/views/order-settings/auto-send-kitchen.d.ts +58 -0
- package/dist/frontend/mf-order/src/views/order-settings/delivery/inhouse/InHouseDelivery.vue.d.ts +16 -1
- package/dist/frontend/mf-order/src/views/order-settings/pickup/PickUpSettingDialogContent.vue.d.ts +16 -1
- package/dist/frontend/mf-order/src/views/order-settings/useAutoSendGuard.d.ts +48 -0
- package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
- package/dist/{index-CKEbr9Es.js → index-D5wABuUx.js} +1 -1
- package/dist/{index-5k7kGCpJ.js → index-D7IWjZdy.js} +2 -2
- package/dist/{index-I2YGevce.js → index-LyvBqKnv.js} +1 -1
- package/dist/{order.dto-Dbn-hRrm.js → order.dto-D7HGiueX.js} +3579 -3428
- package/dist/package/entity/alert/investigation.dto.d.ts +25 -6
- package/dist/package/entity/alert/lark-callback.dto.d.ts +31 -12
- package/dist/package/entity/alert/suggestion.dto.d.ts +4 -4
- package/dist/package/entity/display/blocks.d.ts +42 -76
- package/dist/package/entity/incoming-order/incoming-order-to-bill.dto.d.ts +47 -36
- package/dist/package/entity/incoming-order/incoming-order.dto.d.ts +1 -0
- package/dist/package/entity/index.d.ts +2 -0
- package/dist/package/entity/index.frontend.d.ts +2 -0
- package/dist/package/entity/order/effects/effect.dto.d.ts +266 -0
- package/dist/package/entity/order/order-item/order-item.dto.d.ts +36 -36
- package/dist/package/entity/order/order.do.d.ts +7 -64
- package/dist/package/entity/order/order.dto.d.ts +13006 -795
- package/dist/package/entity/order-platform/grabfood/grabfood-settings.do.d.ts +13 -0
- package/dist/package/entity/order-platform/grabfood/grabfood.dto.d.ts +3 -0
- package/dist/package/entity/order-platform/menu.dto.d.ts +10 -10
- package/dist/package/entity/order-platform/shopeefood/shopeefood-order.dto.d.ts +11 -8
- package/dist/package/entity/order-setting/auto-accept.dto.d.ts +94 -0
- package/dist/package/entity/order-setting/auto-send.resolve.d.ts +110 -0
- package/dist/package/entity/order-setting/delivery/inhouse-delivery.do.d.ts +3 -0
- package/dist/package/entity/order-setting/delivery/inhouse-delivery.dto.d.ts +3 -0
- package/dist/package/entity/order-setting/dine-in/dine-in.do.d.ts +3 -0
- package/dist/package/entity/order-setting/dine-in/dine-in.dto.d.ts +3 -0
- package/dist/package/entity/order-setting/kiosk/kiosk.do.d.ts +26 -0
- package/dist/package/entity/order-setting/kiosk/kiosk.dto.d.ts +39 -0
- package/dist/package/entity/order-setting/order-setting.do.d.ts +57 -4
- package/dist/package/entity/order-setting/order-setting.dto.d.ts +259 -9
- package/dist/package/entity/order-setting/pickup/pickup.do.d.ts +3 -0
- package/dist/package/entity/order-setting/pickup/pickup.dto.d.ts +3 -0
- package/dist/package/entity/order-setting/queue/queue.do.d.ts +2 -2
- package/dist/package/entity/order-setting/queue/queue.dto.d.ts +2 -2
- package/dist/package/entity/queue/queue.dto.d.ts +48 -48
- package/dist/package/entity/restaurant/restaurant.dto.d.ts +46 -8
- package/dist/package/entity/websocket/websocket.dto.d.ts +88 -0
- package/dist/style.css +1 -1
- package/package.json +2 -2
- package/src/locales/en-US.json +22 -2
- package/src/locales/ja-JP.json +12 -1
- package/src/locales/th-TH.json +12 -1
- package/src/locales/zh-CN.json +22 -2
- package/src/modules/display-system/interface.spec.ts +32 -18
- package/src/modules/display-system/interface.ts +32 -0
- package/src/modules/order-setting/kiosk/coverCarousel.spec.ts +172 -0
- package/src/modules/order-setting/kiosk/coverCarousel.ts +186 -0
- package/src/modules/order-setting/kiosk/interface.ts +138 -127
- package/src/stores/order-setting/mapper.spec.ts +26 -0
- package/src/stores/order-setting/mapper.ts +305 -293
- package/src/views/display-system/DisplayFieldControl.vue +46 -0
- package/src/views/display-system/DisplayInspector.vue +8 -3
- package/src/views/display-system/DisplaySystemView.vue +7 -3
- package/src/views/display-system/DisplayTemplateEditor.vue +28 -2
- package/src/views/kiosk/settings/KioskSettingView.vue +176 -60
- package/src/views/order-settings/auto-send-kitchen.spec.ts +137 -0
- package/src/views/order-settings/auto-send-kitchen.ts +92 -0
- package/src/views/order-settings/auto-send-resolve.spec.ts +123 -0
- package/src/views/order-settings/delivery/DeliverySetting.vue +10 -0
- package/src/views/order-settings/delivery/inhouse/InHouseDelivery.vue +40 -5
- package/src/views/order-settings/dine-in/DineInSetting.vue +38 -5
- package/src/views/order-settings/pickup/PickUpSetting.vue +1 -0
- package/src/views/order-settings/pickup/PickUpSettingDialogContent.vue +38 -5
- package/src/views/order-settings/queue/QueueSetting.vue +6 -9
- package/src/views/order-settings/useAutoSendGuard.ts +142 -0
- package/tsconfig.app.json +10 -1
- package/dist/DisplaySystemView-4Bz8p26A.js +0 -5892
- package/dist/KioskSettingView-D57KtQ-t.js +0 -711
- package/dist/display-preview/assets/BandBoard-BrDTYDc9.js +0 -1
- package/dist/display-preview/assets/CarouselBlock-CrkGxoGk.js +0 -1
- package/dist/display-preview/assets/ImageBlock-DtgfHd5G.js +0 -1
- package/dist/display-preview/assets/PickupMode-DKmqGlG8.js +0 -1
- package/dist/display-preview/assets/TextBlock-gAfeCCmE.js +0 -1
- package/dist/display-preview/assets/index-BB0ReVXy.js +0 -2
- package/dist/display-preview/assets/index-BWVpFSIw.css +0 -1
- package/dist/display-preview/assets/pickupView-C-BGcOu8.js +0 -1
|
@@ -11,6 +11,9 @@ const props = defineProps<{
|
|
|
11
11
|
value: unknown
|
|
12
12
|
mediaEnabled: boolean
|
|
13
13
|
imageIsUsable: (url: string) => boolean
|
|
14
|
+
/** Sibling prop of a video field. The thumbnail cannot come from `value`:
|
|
15
|
+
* that is the .mp4, which no <img> will render. */
|
|
16
|
+
posterUrl?: string
|
|
14
17
|
}>()
|
|
15
18
|
|
|
16
19
|
const emit = defineEmits<{
|
|
@@ -29,6 +32,7 @@ const seconds = computed(() => Math.round(
|
|
|
29
32
|
(typeof props.value === 'number' ? props.value : props.field.min ?? 10_000) / 1000
|
|
30
33
|
))
|
|
31
34
|
const imageUrl = computed(() => String(props.value ?? ''))
|
|
35
|
+
const videoPoster = computed(() => props.posterUrl ?? '')
|
|
32
36
|
|
|
33
37
|
const selectItems = computed<ISelectItem[]>(() =>
|
|
34
38
|
(props.field.options ?? []).map((option) => ({
|
|
@@ -71,6 +75,39 @@ const selectItems = computed<ISelectItem[]>(() =>
|
|
|
71
75
|
:step="field.step ?? 1"
|
|
72
76
|
@update:model-value="(next) => emit('update', next)"
|
|
73
77
|
/>
|
|
78
|
+
<div v-else-if="field.control === 'video'" class="media-field">
|
|
79
|
+
<span class="media-label">{{ t(field.labelKey) }}</span>
|
|
80
|
+
<img
|
|
81
|
+
v-if="imageIsUsable(videoPoster)"
|
|
82
|
+
class="media-thumb"
|
|
83
|
+
:src="videoPoster"
|
|
84
|
+
alt=""
|
|
85
|
+
@error="emit('image-error', videoPoster)"
|
|
86
|
+
>
|
|
87
|
+
<!-- A set video with no poster is the undecodable case, not a cosmetic
|
|
88
|
+
gap, so it reads as a warning rather than an empty state. -->
|
|
89
|
+
<span v-else :class="imageUrl ? 'media-warning' : 'media-empty'">
|
|
90
|
+
{{ imageUrl
|
|
91
|
+
? t('order.displaySystem.editor.ui.videoUndecodable')
|
|
92
|
+
: t('order.displaySystem.editor.ui.noVideo') }}
|
|
93
|
+
</span>
|
|
94
|
+
<div class="media-actions">
|
|
95
|
+
<FmButton
|
|
96
|
+
variant="secondary"
|
|
97
|
+
:label="imageUrl
|
|
98
|
+
? t('order.displaySystem.editor.ui.replaceVideo')
|
|
99
|
+
: t('order.displaySystem.editor.ui.chooseVideo')"
|
|
100
|
+
:disabled="!mediaEnabled"
|
|
101
|
+
@click="emit('pick-media')"
|
|
102
|
+
/>
|
|
103
|
+
<FmButton
|
|
104
|
+
v-if="imageUrl"
|
|
105
|
+
variant="plain"
|
|
106
|
+
:label="t('order.displaySystem.editor.ui.clearVideo')"
|
|
107
|
+
@click="emit('clear-image')"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
74
111
|
<div v-else-if="field.control === 'image'" class="media-field">
|
|
75
112
|
<span class="media-label">{{ t(field.labelKey) }}</span>
|
|
76
113
|
<img
|
|
@@ -132,6 +169,15 @@ const selectItems = computed<ISelectItem[]>(() =>
|
|
|
132
169
|
color: #9ca3af;
|
|
133
170
|
font-size: 12px;
|
|
134
171
|
}
|
|
172
|
+
.media-warning {
|
|
173
|
+
display: grid;
|
|
174
|
+
height: 72px;
|
|
175
|
+
place-items: center;
|
|
176
|
+
border: 1px dashed #d1d5db;
|
|
177
|
+
border-radius: 8px;
|
|
178
|
+
color: #b54708;
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
}
|
|
135
181
|
.media-actions {
|
|
136
182
|
display: flex;
|
|
137
183
|
gap: 6px;
|
|
@@ -4,7 +4,7 @@ import type { FmTabProps } from '@feedmepos/ui-library'
|
|
|
4
4
|
import { useI18n } from '@feedmepos/mf-common'
|
|
5
5
|
import type { BlockEditorControl, BlockEditorField, BlockEditorItemVariant } from '@entity'
|
|
6
6
|
|
|
7
|
-
import type { DisplayBlockDraft, DisplayZoneDraft } from '@/modules/display-system/interface'
|
|
7
|
+
import type { DisplayBlockDraft, DisplayMediaTarget, DisplayZoneDraft } from '@/modules/display-system/interface'
|
|
8
8
|
import { blockDefinition } from '@/modules/display-system/interface'
|
|
9
9
|
import DisplayFieldControl from './DisplayFieldControl.vue'
|
|
10
10
|
|
|
@@ -24,7 +24,7 @@ const emit = defineEmits<{
|
|
|
24
24
|
/** `path` may be dotted (`items.0.url`), so one handler covers block props
|
|
25
25
|
* and list items alike. */
|
|
26
26
|
(event: 'set-prop', payload: { path: string; control: BlockEditorControl; value: unknown }): void
|
|
27
|
-
(event: 'pick-media', target:
|
|
27
|
+
(event: 'pick-media', target: DisplayMediaTarget): void
|
|
28
28
|
(event: 'clear-background'): void
|
|
29
29
|
(event: 'clear-prop', path: string): void
|
|
30
30
|
(event: 'add-item', payload: { path: string; variant: string }): void
|
|
@@ -238,7 +238,12 @@ function canRemoveItem(field: BlockEditorField): boolean {
|
|
|
238
238
|
:media-enabled="mediaEnabled"
|
|
239
239
|
:image-is-usable="imageIsUsable"
|
|
240
240
|
@update="(value) => emit('set-prop', { path: field.path, control: field.control, value })"
|
|
241
|
-
|
|
241
|
+
:poster-url="typeof values.posterUrl === 'string' ? values.posterUrl : undefined"
|
|
242
|
+
@pick-media="emit('pick-media', {
|
|
243
|
+
kind: 'block',
|
|
244
|
+
path: field.path,
|
|
245
|
+
mediaKind: field.control === 'video' ? 'video' : 'image'
|
|
246
|
+
})"
|
|
242
247
|
@clear-image="emit('clear-prop', field.path)"
|
|
243
248
|
@image-error="(url) => emit('image-error', url)"
|
|
244
249
|
/>
|
|
@@ -5,7 +5,7 @@ import type { FmTabProps, ISelectItem } from '@feedmepos/ui-library'
|
|
|
5
5
|
|
|
6
6
|
import { useSnackbarFunctions } from '@/components/snackbar'
|
|
7
7
|
import type { CreateDisplayScreen, DisplayScreen, DisplayTemplate, DisplayTemplateDraft } from '@/modules/display-system/interface'
|
|
8
|
-
import { displayTemplatePayload, displayTemplateToDraft, templateCapabilities, walkZones } from '@/modules/display-system/interface'
|
|
8
|
+
import { displayTemplatePayload, displayTemplateToDraft, templateCapabilities, unlicensedScreenIds, walkZones } from '@/modules/display-system/interface'
|
|
9
9
|
import { useDisplaySystemStore } from '@/stores/display-system'
|
|
10
10
|
import DisplayScreenEditor from './DisplayScreenEditor.vue'
|
|
11
11
|
import DisplayTemplateEditor from './DisplayTemplateEditor.vue'
|
|
@@ -27,6 +27,10 @@ const saving = ref(false)
|
|
|
27
27
|
const tabs = computed<FmTabProps[]>(() => [{ label: t('order.displaySystem.screens'), value: 'screens' }, { label: t('order.displaySystem.templates'), value: 'templates' }])
|
|
28
28
|
const restaurantItems = computed<ISelectItem[]>(() => core.restaurants.value.map((restaurant) => ({ label: restaurant.profile.name, value: restaurant._id })))
|
|
29
29
|
const entitlement = computed(() => store.screenCapabilities)
|
|
30
|
+
// Oldest screens keep the licence, so the newest lose it first. Merchants can
|
|
31
|
+
// act on this: buy another screen, or pause one.
|
|
32
|
+
const unlicensedScreens = computed(() =>
|
|
33
|
+
unlicensedScreenIds(store.screens, entitlement.value?.screenLimit ?? 0))
|
|
30
34
|
const remainingScreens = computed(() => Math.max(0, (entitlement.value?.screenLimit ?? 0) - (entitlement.value?.screenUsed ?? 0)))
|
|
31
35
|
const applicableTemplates = computed(() => store.templates.filter((template) => !template.restaurantIds?.length || template.restaurantIds.includes(selectedRestaurantId.value)))
|
|
32
36
|
const canAddScreen = computed(() => Boolean(selectedRestaurantId.value && entitlement.value?.posV8 && remainingScreens.value > 0 && applicableTemplates.value.length))
|
|
@@ -125,7 +129,7 @@ watch(() => core.restaurants.value.length, async (length) => { if (!selectedRest
|
|
|
125
129
|
<div v-if="store.loadingScreens" class="flex justify-center py-40"><FmCircularProgress size="lg" /></div>
|
|
126
130
|
<div v-else-if="!store.screens.length" class="empty-state"><strong>{{ t('order.displaySystem.noScreens') }}</strong><span>Create a screen and assign one applicable template.</span></div>
|
|
127
131
|
<div v-else class="grid grid-cols-1 gap-16 xl:grid-cols-2">
|
|
128
|
-
<article v-for="screen in store.screens" :key="screen._id" class="resource-card"><div class="flex justify-between gap-12"><div class="min-w-0"><div class="flex flex-wrap items-center gap-8"><h3 class="truncate text-lg font-semibold">{{ screen.name }}</h3><span class="status-badge" :class="screen.status.toLowerCase()">{{ screen.status }}</span><span class="online-dot" :class="{ online: isOnline(screen) }">{{ isOnline(screen) ? t('order.displaySystem.online') : t('order.displaySystem.offline') }}</span></div><p class="mt-4 text-sm text-gray-500">{{ templateName(screen.templateId) }}</p><p v-if="orientationMismatch(screen)" class="mt-2 text-xs text-orange-700">Reported viewport orientation does not match the template.</p></div><div class="flex gap-4"><FmButton :icon="screen.status === 'DISABLED' ? 'play_arrow' : 'pause'" variant="plain" @click="toggleStatus(screen)" /><FmButton icon="edit" variant="plain" @click="editScreen(screen)" /><FmButton icon="delete" variant="plain" @click="removeScreen(screen)" /></div></div><dl class="meta-grid"><div><dt>{{ t('order.displaySystem.deviceId') }}</dt><dd>{{ screen.deviceId || '—' }}</dd></div><div><dt>{{ t('order.displaySystem.lastSeen') }}</dt><dd>{{ screen.lastSeenAt ? new Date(screen.lastSeenAt).toLocaleString() : '—' }}</dd></div></dl></article>
|
|
132
|
+
<article v-for="screen in store.screens" :key="screen._id" class="resource-card"><div class="flex justify-between gap-12"><div class="min-w-0"><div class="flex flex-wrap items-center gap-8"><h3 class="truncate text-lg font-semibold">{{ screen.name }}</h3><span class="status-badge" :class="screen.status.toLowerCase()">{{ screen.status }}</span><span class="online-dot" :class="{ online: isOnline(screen) }">{{ isOnline(screen) ? t('order.displaySystem.online') : t('order.displaySystem.offline') }}</span><span v-if="unlicensedScreens.has(screen._id)" class="status-badge unlicensed">{{ t('order.displaySystem.unlicensed') }}</span></div><p class="mt-4 text-sm text-gray-500">{{ templateName(screen.templateId) }}</p><p v-if="orientationMismatch(screen)" class="mt-2 text-xs text-orange-700">Reported viewport orientation does not match the template.</p><p v-if="unlicensedScreens.has(screen._id)" class="mt-2 text-xs text-red-700">{{ t('order.displaySystem.unlicensedNote') }}</p></div><div class="flex gap-4"><FmButton :icon="screen.status === 'DISABLED' ? 'play_arrow' : 'pause'" variant="plain" @click="toggleStatus(screen)" /><FmButton icon="edit" variant="plain" @click="editScreen(screen)" /><FmButton icon="delete" variant="plain" @click="removeScreen(screen)" /></div></div><dl class="meta-grid"><div><dt>{{ t('order.displaySystem.deviceId') }}</dt><dd>{{ screen.deviceId || '—' }}</dd></div><div><dt>{{ t('order.displaySystem.lastSeen') }}</dt><dd>{{ screen.lastSeenAt ? new Date(screen.lastSeenAt).toLocaleString() : '—' }}</dd></div></dl></article>
|
|
129
133
|
</div>
|
|
130
134
|
</section>
|
|
131
135
|
<section v-else class="mt-12">
|
|
@@ -140,5 +144,5 @@ watch(() => core.restaurants.value.length, async (length) => { if (!selectedRest
|
|
|
140
144
|
</template>
|
|
141
145
|
|
|
142
146
|
<style scoped>
|
|
143
|
-
.display-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e5e7eb;padding-bottom:14px}.display-controls{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px}.display-restaurant-select{width:min(360px,100%)}.display-outlet{display:flex;align-items:center;gap:16px}.display-entitlement{color:#6b7280;font-size:12px;white-space:nowrap}.restriction-note{margin-top:12px;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px;color:#9a3412;background:#fff7ed;font-size:13px}.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}.layout-thumb{display:flex;height:auto;min-height:96px;margin-top:16px;border-radius:10px;background:#f3f4f6;padding:5px;pointer-events:none}.thumb-caption{margin-top:8px;color:#6b7280;font-size:11px}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.mode-badge,.status-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}.status-badge.active{color:#166534;background:#dcfce7}.status-badge.disabled{color:#991b1b;background:#fee2e2}.online-dot{color:#6b7280;font-size:11px}.online-dot::before{display:inline-block;width:7px;height:7px;margin-right:5px;border-radius:50%;background:#9ca3af;content:''}.online-dot.online{color:#15803d}.online-dot.online::before{background:#22c55e}.meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px}.meta-grid dt{color:#6b7280;font-size:11px}.meta-grid dd{overflow:hidden;margin-top:2px;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.display-toolbar{align-items:flex-start}.display-controls{align-items:stretch;flex-direction:column}.display-outlet{align-items:stretch;flex-direction:column;gap:8px}.display-restaurant-select{width:100%}}
|
|
147
|
+
.display-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e5e7eb;padding-bottom:14px}.display-controls{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-top:12px}.display-restaurant-select{width:min(360px,100%)}.display-outlet{display:flex;align-items:center;gap:16px}.display-entitlement{color:#6b7280;font-size:12px;white-space:nowrap}.restriction-note{margin-top:12px;border:1px solid #fed7aa;border-radius:10px;padding:10px 12px;color:#9a3412;background:#fff7ed;font-size:13px}.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}.layout-thumb{display:flex;height:auto;min-height:96px;margin-top:16px;border-radius:10px;background:#f3f4f6;padding:5px;pointer-events:none}.thumb-caption{margin-top:8px;color:#6b7280;font-size:11px}.resource-card{border:1px solid #e5e7eb;border-radius:12px;padding:20px;background:white;box-shadow:0 2px 8px #0000000a}.mode-badge,.status-badge{border-radius:999px;padding:3px 8px;background:#f3f4f6;font-size:11px;font-weight:700}.status-badge.active{color:#166534;background:#dcfce7}.status-badge.disabled{color:#991b1b;background:#fee2e2}.status-badge.unlicensed{color:#b42318;background:#fef3f2;border:1px solid #fecdca}.online-dot{color:#6b7280;font-size:11px}.online-dot::before{display:inline-block;width:7px;height:7px;margin-right:5px;border-radius:50%;background:#9ca3af;content:''}.online-dot.online{color:#15803d}.online-dot.online::before{background:#22c55e}.meta-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px}.meta-grid dt{color:#6b7280;font-size:11px}.meta-grid dd{overflow:hidden;margin-top:2px;font-size:12px;text-overflow:ellipsis;white-space:nowrap}@media(max-width:640px){.display-toolbar{align-items:flex-start}.display-controls{align-items:stretch;flex-direction:column}.display-outlet{align-items:stretch;flex-direction:column;gap:8px}.display-restaurant-select{width:100%}}
|
|
144
148
|
</style>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@/api/brandkit'
|
|
12
12
|
import type {
|
|
13
13
|
DisplayCapability,
|
|
14
|
+
DisplayMediaTarget,
|
|
14
15
|
DisplaySplitDraft,
|
|
15
16
|
DisplayTemplate,
|
|
16
17
|
DisplayTemplateDraft,
|
|
@@ -69,7 +70,7 @@ const dragging = ref(false)
|
|
|
69
70
|
/** Type carried by the palette drag, so a drop knows what to insert. */
|
|
70
71
|
const draggedType = ref('')
|
|
71
72
|
const mediaPickerOpen = ref(false)
|
|
72
|
-
const mediaTarget = ref<
|
|
73
|
+
const mediaTarget = ref<DisplayMediaTarget>({ kind: 'background' })
|
|
73
74
|
const brokenImages = ref(new Set<string>())
|
|
74
75
|
let previewBrandKitRequest = 0
|
|
75
76
|
|
|
@@ -380,7 +381,7 @@ function imageIsUsable(url: string): boolean {
|
|
|
380
381
|
return Boolean(url) && !brokenImages.value.has(url)
|
|
381
382
|
}
|
|
382
383
|
|
|
383
|
-
function pickMedia(target:
|
|
384
|
+
function pickMedia(target: DisplayMediaTarget): void {
|
|
384
385
|
mediaTarget.value = target
|
|
385
386
|
mediaPickerOpen.value = true
|
|
386
387
|
}
|
|
@@ -398,10 +399,32 @@ function selectMedia(selection: MediaSelection): void {
|
|
|
398
399
|
} else if (target.kind === 'block') {
|
|
399
400
|
const holder = propHolder(target.path)
|
|
400
401
|
if (holder) holder.holder[holder.key] = selection.url
|
|
402
|
+
if (selection.kind === 'video') applyVideoPoster(target.path, selection)
|
|
401
403
|
}
|
|
402
404
|
mediaPickerOpen.value = false
|
|
403
405
|
}
|
|
404
406
|
|
|
407
|
+
/** The poster is a sibling prop that comes from the server: a video's key ends
|
|
408
|
+
* in a file extension while its poster hangs off the key segments, so it cannot
|
|
409
|
+
* be built from the url.
|
|
410
|
+
*
|
|
411
|
+
* hasPoster === false leaves it empty on purpose. It does not mean "no
|
|
412
|
+
* thumbnail" -- the uploading browser could not decode the file, which is the
|
|
413
|
+
* best signal available that a screen may not play it either. An empty poster
|
|
414
|
+
* against a set url is what makes the field render that warning, with no extra
|
|
415
|
+
* state to keep in sync. */
|
|
416
|
+
function applyVideoPoster(path: string, selection: MediaSelection): void {
|
|
417
|
+
const posterPath = path.replace(/(^|\.)url$/, '$1posterUrl')
|
|
418
|
+
// A replace that matched nothing would resolve to the url's own prop and
|
|
419
|
+
// overwrite the video with its poster.
|
|
420
|
+
if (posterPath === path) return
|
|
421
|
+
const poster = propHolder(posterPath)
|
|
422
|
+
if (!poster) return
|
|
423
|
+
poster.holder[poster.key] = selection.asset.hasPoster === false
|
|
424
|
+
? ''
|
|
425
|
+
: selection.asset.posterUrl ?? ''
|
|
426
|
+
}
|
|
427
|
+
|
|
405
428
|
function clearBackground(): void {
|
|
406
429
|
if (selectedZone.value) selectedZone.value.background = undefined
|
|
407
430
|
}
|
|
@@ -628,11 +651,14 @@ function save(): void {
|
|
|
628
651
|
</div>
|
|
629
652
|
</section>
|
|
630
653
|
|
|
654
|
+
<!-- Only the field being filled decides what is offered: a zone
|
|
655
|
+
background and every carousel item render as an image. -->
|
|
631
656
|
<MediaPickerModal
|
|
632
657
|
v-model:open="mediaPickerOpen"
|
|
633
658
|
:z-index="70"
|
|
634
659
|
:business-id="mediaBusinessId"
|
|
635
660
|
:restaurant-id="previewRestaurantId || undefined"
|
|
661
|
+
:kinds="[mediaTarget.kind === 'block' ? mediaTarget.mediaKind ?? 'image' : 'image']"
|
|
636
662
|
@select="selectMedia"
|
|
637
663
|
/>
|
|
638
664
|
</div>
|
|
@@ -188,67 +188,118 @@
|
|
|
188
188
|
</div>
|
|
189
189
|
</div>
|
|
190
190
|
|
|
191
|
-
<!--
|
|
191
|
+
<!--
|
|
192
|
+
Cover Image Section
|
|
193
|
+
|
|
194
|
+
One ordered list per orientation, not one image: the kiosk landing screen
|
|
195
|
+
cross-fades between them. `coverCarousel.ts` is what turns a list back
|
|
196
|
+
into the two stored fields, so nothing here has to know that a
|
|
197
|
+
single-image merchant keeps writing `coverImage*` exactly as before.
|
|
198
|
+
-->
|
|
192
199
|
<div class="flex flex-col gap-2">
|
|
193
200
|
<span class="fm-typo-en-title-sm-600">{{ t('order.coverImage') }}</span>
|
|
194
201
|
<span class="fm-typo-en-body-sm-400 text-fm-color-typo-secondary">{{
|
|
195
202
|
t('order.coverImageFallbackHint')
|
|
196
203
|
}}</span>
|
|
204
|
+
<span class="fm-typo-en-body-sm-400 text-fm-color-typo-secondary">{{
|
|
205
|
+
t('order.coverCarouselHint', { max: MAX_COVER_IMAGES })
|
|
206
|
+
}}</span>
|
|
197
207
|
|
|
198
|
-
<div
|
|
199
|
-
|
|
208
|
+
<div
|
|
209
|
+
v-for="orientation in COVER_ORIENTATIONS"
|
|
210
|
+
:key="orientation.value"
|
|
211
|
+
class="border p-[1.5rem] fm-corner-radius-lg flex flex-col gap-4 my-5 w-11/12"
|
|
212
|
+
>
|
|
213
|
+
<span class="fm-typo-en-body-md-600">{{ t(orientation.labelKey) }}</span>
|
|
200
214
|
<span class="fm-typo-en-body-sm-400 text-fm-color-typo-secondary">{{
|
|
201
|
-
t(
|
|
215
|
+
t(orientation.hintKey)
|
|
202
216
|
}}</span>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
|
|
218
|
+
<div class="flex flex-wrap items-start gap-4">
|
|
219
|
+
<div
|
|
220
|
+
v-for="(image, index) in coverImages(orientation.value)"
|
|
221
|
+
:key="`${orientation.value}-${index}-${image}`"
|
|
222
|
+
class="flex flex-col gap-2"
|
|
223
|
+
>
|
|
224
|
+
<div class="relative">
|
|
225
|
+
<img
|
|
226
|
+
:src="image"
|
|
227
|
+
:alt="`${t(orientation.labelKey)} ${index + 1}`"
|
|
228
|
+
:class="[
|
|
229
|
+
orientation.previewClass,
|
|
230
|
+
'object-cover rounded-md border border-fm-color-neutral-gray-200'
|
|
231
|
+
]"
|
|
232
|
+
/>
|
|
233
|
+
<!--
|
|
234
|
+
The number is the point of the list: order is what the kiosk
|
|
235
|
+
fades through, and a wall of thumbnails does not show it.
|
|
236
|
+
|
|
237
|
+
`bg-fm-color-typo-primary`, not the neutral-gray-500 you would
|
|
238
|
+
expect: that token generates NOTHING in this app (measured
|
|
239
|
+
transparent), which left white text sitting on a photo. gray-400
|
|
240
|
+
and gray-200 do exist — the 500 step simply is not in the bg
|
|
241
|
+
scale.
|
|
242
|
+
-->
|
|
243
|
+
<span
|
|
244
|
+
class="absolute top-2 left-2 px-2 rounded-full bg-fm-color-typo-primary text-fm-color-neutral-white fm-typo-en-body-sm-400"
|
|
245
|
+
>
|
|
246
|
+
{{ index + 1 }}
|
|
247
|
+
</span>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="flex items-center gap-1">
|
|
250
|
+
<FmButton
|
|
251
|
+
variant="tertiary"
|
|
252
|
+
size="md"
|
|
253
|
+
prepend-icon="arrow_back"
|
|
254
|
+
:disabled="index === 0"
|
|
255
|
+
:aria-label="t('order.coverCarouselMoveEarlier')"
|
|
256
|
+
@click="moveCoverImage(orientation.value, index, index - 1)"
|
|
257
|
+
/>
|
|
258
|
+
<FmButton
|
|
259
|
+
variant="tertiary"
|
|
260
|
+
size="md"
|
|
261
|
+
prepend-icon="arrow_forward"
|
|
262
|
+
:disabled="index === coverImages(orientation.value).length - 1"
|
|
263
|
+
:aria-label="t('order.coverCarouselMoveLater')"
|
|
264
|
+
@click="moveCoverImage(orientation.value, index, index + 1)"
|
|
265
|
+
/>
|
|
266
|
+
<FmButton
|
|
267
|
+
variant="tertiary"
|
|
268
|
+
size="md"
|
|
269
|
+
prepend-icon="delete"
|
|
270
|
+
:aria-label="t('common.delete')"
|
|
271
|
+
@click="removeCoverImage(orientation.value, index)"
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<FmDropField
|
|
277
|
+
v-if="coverImages(orientation.value).length < MAX_COVER_IMAGES"
|
|
278
|
+
accept="image/*"
|
|
279
|
+
:max-file-size="MAX_IMAGE_FILE_SIZE"
|
|
280
|
+
:content-class="orientation.dropClass"
|
|
281
|
+
@file-upload="addCoverImage(orientation.value, $event)"
|
|
282
|
+
@file-rejected="handleCoverImageRejected(orientation.value, $event)"
|
|
222
283
|
/>
|
|
223
284
|
</div>
|
|
224
285
|
</div>
|
|
225
286
|
|
|
226
|
-
|
|
227
|
-
|
|
287
|
+
<!--
|
|
288
|
+
Only worth showing once there is something to fade between — with a
|
|
289
|
+
single image the kiosk never starts its timer.
|
|
290
|
+
-->
|
|
291
|
+
<div v-if="hasCoverCarousel" class="w-11/12">
|
|
292
|
+
<FmStepperField
|
|
293
|
+
v-model="coverIntervalSeconds"
|
|
294
|
+
class="max-w-[240px]"
|
|
295
|
+
:label="t('order.coverCarouselInterval')"
|
|
296
|
+
:min="MIN_COVER_INTERVAL_SECONDS"
|
|
297
|
+
:max="MAX_COVER_INTERVAL_SECONDS"
|
|
298
|
+
:placeholder="String(DEFAULT_COVER_INTERVAL_SECONDS)"
|
|
299
|
+
/>
|
|
228
300
|
<span class="fm-typo-en-body-sm-400 text-fm-color-typo-secondary">{{
|
|
229
|
-
t('order.
|
|
301
|
+
t('order.coverCarouselIntervalHint', { seconds: DEFAULT_COVER_INTERVAL_SECONDS })
|
|
230
302
|
}}</span>
|
|
231
|
-
<FmDropField
|
|
232
|
-
v-if="!getCoverImagePreview('coverImagePortrait')"
|
|
233
|
-
accept="image/*"
|
|
234
|
-
:max-file-size="MAX_IMAGE_FILE_SIZE"
|
|
235
|
-
content-class="w-[240px] aspect-[9/16]"
|
|
236
|
-
@file-upload="uploadCoverImage('coverImagePortrait', $event)"
|
|
237
|
-
@file-rejected="handleCoverImageRejected('coverImagePortrait', $event)"
|
|
238
|
-
/>
|
|
239
|
-
<div v-else class="flex flex-col gap-2">
|
|
240
|
-
<img
|
|
241
|
-
:src="getCoverImagePreview('coverImagePortrait')!"
|
|
242
|
-
:alt="t('order.coverImagePortrait')"
|
|
243
|
-
class="max-w-[200px] aspect-[9/16] object-cover rounded-md border border-fm-color-neutral-gray-200"
|
|
244
|
-
/>
|
|
245
|
-
<FmButton
|
|
246
|
-
variant="tertiary"
|
|
247
|
-
class="mr-auto"
|
|
248
|
-
:label="t('common.delete')"
|
|
249
|
-
@click="clearCoverImage('coverImagePortrait')"
|
|
250
|
-
/>
|
|
251
|
-
</div>
|
|
252
303
|
</div>
|
|
253
304
|
</div>
|
|
254
305
|
|
|
@@ -340,6 +391,18 @@ import { useLoading } from '@/composables/loading'
|
|
|
340
391
|
import { useSnackbar } from '@feedmepos/ui-library'
|
|
341
392
|
import { useCoreStore, useI18n } from '@feedmepos/mf-common'
|
|
342
393
|
import { uploadImageFile } from '@/utils/firebase-storage'
|
|
394
|
+
import {
|
|
395
|
+
applyCoverImages,
|
|
396
|
+
applyIntervalSeconds,
|
|
397
|
+
DEFAULT_COVER_INTERVAL_SECONDS,
|
|
398
|
+
MAX_COVER_IMAGES,
|
|
399
|
+
MAX_COVER_INTERVAL_SECONDS,
|
|
400
|
+
MIN_COVER_INTERVAL_SECONDS,
|
|
401
|
+
readCoverImages,
|
|
402
|
+
readIntervalSeconds,
|
|
403
|
+
reorderCoverImages,
|
|
404
|
+
type CoverOrientation
|
|
405
|
+
} from '@/modules/order-setting/kiosk/coverCarousel'
|
|
343
406
|
|
|
344
407
|
const { t } = useI18n()
|
|
345
408
|
const CoreStore = useCoreStore()
|
|
@@ -481,28 +544,79 @@ function updateEPaymentSetting(form: FdoEPaymentMethod) {
|
|
|
481
544
|
}
|
|
482
545
|
|
|
483
546
|
const restaurantStore = useRestaurantStore()
|
|
484
|
-
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Landing-screen cover images
|
|
550
|
+
//
|
|
551
|
+
// The editor works on ONE list per orientation; coverCarousel.ts decides how
|
|
552
|
+
// that list is stored (single field vs carousel list). Every write goes
|
|
553
|
+
// through applyCoverImages / applyIntervalSeconds so the two never drift.
|
|
554
|
+
// ---------------------------------------------------------------------------
|
|
555
|
+
|
|
485
556
|
const MAX_IMAGE_FILE_SIZE = 10 * 1000 * 1000
|
|
486
557
|
|
|
487
|
-
|
|
488
|
-
|
|
558
|
+
const COVER_ORIENTATIONS = [
|
|
559
|
+
{
|
|
560
|
+
value: 'landscape' as CoverOrientation,
|
|
561
|
+
labelKey: 'order.coverImageLandscape',
|
|
562
|
+
hintKey: 'order.coverImageLandscapeHint',
|
|
563
|
+
previewClass: 'w-[280px] aspect-video',
|
|
564
|
+
dropClass: 'w-[280px] aspect-video'
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
value: 'portrait' as CoverOrientation,
|
|
568
|
+
labelKey: 'order.coverImagePortrait',
|
|
569
|
+
hintKey: 'order.coverImagePortraitHint',
|
|
570
|
+
previewClass: 'w-[160px] aspect-[9/16]',
|
|
571
|
+
dropClass: 'w-[160px] aspect-[9/16]'
|
|
572
|
+
}
|
|
573
|
+
] as const
|
|
574
|
+
|
|
575
|
+
function coverImages(orientation: CoverOrientation): string[] {
|
|
576
|
+
return readCoverImages(kioskOrderSettingForm.value, orientation)
|
|
489
577
|
}
|
|
490
578
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
579
|
+
const hasCoverCarousel = computed(
|
|
580
|
+
() => coverImages('landscape').length > 1 || coverImages('portrait').length > 1
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
function setCoverImages(orientation: CoverOrientation, images: string[]) {
|
|
584
|
+
Object.assign(
|
|
585
|
+
kioskOrderSettingForm.value,
|
|
586
|
+
applyCoverImages(kioskOrderSettingForm.value, orientation, images)
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function moveCoverImage(orientation: CoverOrientation, from: number, to: number) {
|
|
591
|
+
setCoverImages(orientation, reorderCoverImages(coverImages(orientation), from, to))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function removeCoverImage(orientation: CoverOrientation, index: number) {
|
|
595
|
+
const next = coverImages(orientation).filter((_, i) => i !== index)
|
|
596
|
+
setCoverImages(orientation, next)
|
|
496
597
|
}
|
|
497
598
|
|
|
498
|
-
|
|
599
|
+
const coverIntervalSeconds = computed<number | null>({
|
|
600
|
+
get: () => readIntervalSeconds(kioskOrderSettingForm.value),
|
|
601
|
+
set: (seconds) => {
|
|
602
|
+
Object.assign(
|
|
603
|
+
kioskOrderSettingForm.value,
|
|
604
|
+
applyIntervalSeconds(kioskOrderSettingForm.value, seconds)
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
async function addCoverImage(orientation: CoverOrientation, file: File) {
|
|
610
|
+
if (coverImages(orientation).length >= MAX_COVER_IMAGES) return
|
|
499
611
|
try {
|
|
500
612
|
const imageUrl = await startAsyncCall(async () => {
|
|
501
|
-
|
|
613
|
+
// Every upload gets its own generated filename, so several images can
|
|
614
|
+
// share one prefix without overwriting each other.
|
|
615
|
+
const path = `/restaurant/${props.restaurantId}/kiosk/cover-${orientation}`
|
|
502
616
|
return await uploadImageFile(path, file)
|
|
503
617
|
})
|
|
504
618
|
|
|
505
|
-
|
|
619
|
+
setCoverImages(orientation, [...coverImages(orientation), imageUrl])
|
|
506
620
|
SnackBar.open({
|
|
507
621
|
title: t('order.imageUploadSuccess'),
|
|
508
622
|
type: 'success'
|
|
@@ -517,11 +631,13 @@ async function uploadCoverImage(field: CoverImageField, file: File) {
|
|
|
517
631
|
}
|
|
518
632
|
|
|
519
633
|
function handleCoverImageRejected(
|
|
520
|
-
|
|
634
|
+
orientation: CoverOrientation,
|
|
521
635
|
rejected: { file: File; reason: string[] }[]
|
|
522
636
|
) {
|
|
523
637
|
const fieldName =
|
|
524
|
-
|
|
638
|
+
orientation === 'landscape'
|
|
639
|
+
? t('order.coverImageLandscape')
|
|
640
|
+
: t('order.coverImagePortrait')
|
|
525
641
|
const firstReason = rejected[0]?.reason?.[0]
|
|
526
642
|
SnackBar.open({
|
|
527
643
|
title: firstReason
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { OrderSettingChannelAutoAccept } from '@entity'
|
|
3
|
+
import {
|
|
4
|
+
autoCloseSublabelKey,
|
|
5
|
+
autoSendFromSelectValue,
|
|
6
|
+
autoSendSelectValue,
|
|
7
|
+
requiresAutoSendConfirmation
|
|
8
|
+
} from './auto-send-kitchen'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Presentation only. The resolution these helpers read is shared with the
|
|
12
|
+
* backend stamper and is covered by `auto-send-resolve.spec.ts`; what is
|
|
13
|
+
* asserted here is what the merchant SEES and when they are asked to confirm.
|
|
14
|
+
*
|
|
15
|
+
* Note the change of role since the backend invariant landed: the dialog is no
|
|
16
|
+
* longer the safety mechanism, so "fails open" below means "stays quiet", not
|
|
17
|
+
* "lets a bad pair through" — the stamper suppresses the close either way.
|
|
18
|
+
*/
|
|
19
|
+
const channel = (
|
|
20
|
+
autoAccept: boolean,
|
|
21
|
+
autoAcceptPaidOnly = false
|
|
22
|
+
): OrderSettingChannelAutoAccept => ({ autoAccept, autoAcceptPaidOnly })
|
|
23
|
+
|
|
24
|
+
describe('autoSendSelectValue', () => {
|
|
25
|
+
it('maps unset to the inherit option', () => {
|
|
26
|
+
expect(autoSendSelectValue(null)).toBe('null')
|
|
27
|
+
// `undefined` matters as much as `null`: a settings document written before
|
|
28
|
+
// the field existed parses to undefined, and mapping it with a bare
|
|
29
|
+
// String() renders a select with nothing selected.
|
|
30
|
+
expect(autoSendSelectValue(undefined)).toBe('null')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('maps explicit booleans to their own option', () => {
|
|
34
|
+
expect(autoSendSelectValue(true)).toBe('true')
|
|
35
|
+
expect(autoSendSelectValue(false)).toBe('false')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('autoSendFromSelectValue', () => {
|
|
40
|
+
it('round-trips through the select', () => {
|
|
41
|
+
expect(autoSendFromSelectValue('null')).toBeNull()
|
|
42
|
+
expect(autoSendFromSelectValue('true')).toBe(true)
|
|
43
|
+
expect(autoSendFromSelectValue('false')).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('leaves unset as null rather than false, so inheritance is never pinned', () => {
|
|
47
|
+
expect(autoSendFromSelectValue('null')).not.toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('requiresAutoSendConfirmation', () => {
|
|
52
|
+
it.each<[string, boolean | null, OrderSettingChannelAutoAccept | null, boolean]>([
|
|
53
|
+
['explicitly disabled', false, channel(true), true],
|
|
54
|
+
['inherits auto-accept off — the reported bug', null, channel(false), true],
|
|
55
|
+
['explicitly enabled', true, channel(false), false],
|
|
56
|
+
['inherits auto-accept on', null, channel(true), false],
|
|
57
|
+
// Evaluated for a PAID order: those reach the kitchen here, and unpaid ones
|
|
58
|
+
// could never auto-close anyway, so there is nothing to warn about.
|
|
59
|
+
['inherits paid-only auto-accept', null, channel(true, true), false],
|
|
60
|
+
['unknown auto-accept stays quiet', null, null, false]
|
|
61
|
+
])('%s', (_label, autoSendKitchen, ch, expected) => {
|
|
62
|
+
expect(requiresAutoSendConfirmation(autoSendKitchen, ch)).toBe(expected)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('still asks when kitchen send is explicitly off on a paid-only channel', () => {
|
|
66
|
+
// Explicit `false` suppresses the kitchen for PAID orders too — the ones
|
|
67
|
+
// that can actually close — so auto-close would be entirely inert.
|
|
68
|
+
expect(requiresAutoSendConfirmation(false, channel(true, true))).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('still honours an explicit choice when auto-accept is unknown', () => {
|
|
72
|
+
// REGRESSION GUARD. An explicit value never consults auto-accept —
|
|
73
|
+
// `resolveAutoSend` returns it before reaching the fallback — so the answer
|
|
74
|
+
// is fully determined and an unreadable channel must not swallow it.
|
|
75
|
+
// Checking the channel first (which this guard briefly did) left a merchant
|
|
76
|
+
// who had chosen "Disabled" with no dialog, no warning sublabel and no
|
|
77
|
+
// reverse-clear: they saved the inert pair with no feedback anywhere.
|
|
78
|
+
expect(requiresAutoSendConfirmation(false, null)).toBe(true)
|
|
79
|
+
expect(requiresAutoSendConfirmation(true, null)).toBe(false)
|
|
80
|
+
// Inherited values are explicit too, for the channels that inherit.
|
|
81
|
+
expect(requiresAutoSendConfirmation(null, null, false)).toBe(true)
|
|
82
|
+
expect(requiresAutoSendConfirmation(null, null, true)).toBe(false)
|
|
83
|
+
// Only a genuinely unresolved value falls through, and THAT fails open.
|
|
84
|
+
expect(requiresAutoSendConfirmation(null, null)).toBe(false)
|
|
85
|
+
expect(requiresAutoSendConfirmation(null, null, null)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('inheriting channels (queue, kiosk)', () => {
|
|
89
|
+
it('reads the inherited value when its own is unset', () => {
|
|
90
|
+
expect(requiresAutoSendConfirmation(null, channel(true), false)).toBe(true)
|
|
91
|
+
expect(requiresAutoSendConfirmation(null, channel(false), true)).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('lets its own explicit value win over the inherited one', () => {
|
|
95
|
+
// This is the Queue-page hole: an explicit queue `false` beats a dine-in
|
|
96
|
+
// `true` that the dine-in dialog pinned.
|
|
97
|
+
expect(requiresAutoSendConfirmation(false, channel(true), true)).toBe(true)
|
|
98
|
+
expect(requiresAutoSendConfirmation(true, channel(false), false)).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('autoCloseSublabelKey', () => {
|
|
104
|
+
it('flags a STORED pair that is already inert', () => {
|
|
105
|
+
// The merchant arrived in this state without touching a control, so the
|
|
106
|
+
// dialog never fired for them — the row has to say so.
|
|
107
|
+
expect(autoCloseSublabelKey(null, channel(false), true)).toBe(
|
|
108
|
+
'order.autoCloseBillRequiresAutoSend'
|
|
109
|
+
)
|
|
110
|
+
expect(autoCloseSublabelKey(false, channel(true), true)).toBe(
|
|
111
|
+
'order.autoCloseBillRequiresAutoSend'
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('stays neutral while auto-close is off, however auto-send resolves', () => {
|
|
116
|
+
expect(autoCloseSublabelKey(null, channel(false), false)).toBe('order.autoCloseBillSublabel')
|
|
117
|
+
expect(autoCloseSublabelKey(false, channel(true), null)).toBe('order.autoCloseBillSublabel')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('stays neutral for the combinations that do something', () => {
|
|
121
|
+
expect(autoCloseSublabelKey(true, channel(false), true)).toBe('order.autoCloseBillSublabel')
|
|
122
|
+
expect(autoCloseSublabelKey(null, channel(true), true)).toBe('order.autoCloseBillSublabel')
|
|
123
|
+
expect(autoCloseSublabelKey(null, channel(true, true), true)).toBe(
|
|
124
|
+
'order.autoCloseBillSublabel'
|
|
125
|
+
)
|
|
126
|
+
expect(autoCloseSublabelKey(null, null, true)).toBe('order.autoCloseBillSublabel')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('follows the inherited value for queue and kiosk', () => {
|
|
130
|
+
expect(autoCloseSublabelKey(null, channel(true), true, false)).toBe(
|
|
131
|
+
'order.autoCloseBillRequiresAutoSend'
|
|
132
|
+
)
|
|
133
|
+
expect(autoCloseSublabelKey(null, channel(false), true, true)).toBe(
|
|
134
|
+
'order.autoCloseBillSublabel'
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
})
|