@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.
Files changed (109) hide show
  1. package/dist/DisplaySystemView-hmlT9gsb.js +6485 -0
  2. package/dist/{KioskDevicesView-DdwFuenU.js → KioskDevicesView-fW6M9Hao.js} +1 -1
  3. 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
  4. package/dist/KioskSettingView-BdEPftHH.js +839 -0
  5. package/dist/{KioskView-fwBwUGCQ.js → KioskView-Bwk9S0tc.js} +82 -70
  6. package/dist/{OrderSettingsView-RhlyZmzs.js → OrderSettingsView-DVXfVdXj.js} +30858 -30699
  7. package/dist/{app-ClOkRxOZ.js → app-BnrruYco.js} +74 -12
  8. package/dist/app.js +1 -1
  9. package/dist/display-preview/assets/BandBoard-DIcfXsNV.js +1 -0
  10. package/dist/display-preview/assets/CarouselBlock-XAhTyEWB.js +1 -0
  11. package/dist/display-preview/assets/{CategoryBoard-DClMUGfS.js → CategoryBoard-DBKe18hv.js} +1 -1
  12. package/dist/display-preview/assets/ImageBlock-BCbnYSHq.js +1 -0
  13. package/dist/display-preview/assets/PickupMode-Df_TPCh8.js +1 -0
  14. package/dist/display-preview/assets/{StatusBoard-BVESD28J.js → StatusBoard-28z3bPOf.js} +1 -1
  15. package/dist/display-preview/assets/TextBlock-CvJKITtx.js +1 -0
  16. package/dist/display-preview/assets/VideoBlock-Dop43wDv.js +1 -0
  17. package/dist/display-preview/assets/index-C6DET5zJ.css +1 -0
  18. package/dist/display-preview/assets/index-cqMoOpSN.js +2 -0
  19. package/dist/display-preview/assets/pickupView-DRMVXqrc.js +1 -0
  20. package/dist/display-preview/index.html +2 -2
  21. package/dist/frontend/mf-order/src/app.d.ts +62 -0
  22. package/dist/frontend/mf-order/src/main.d.ts +62 -0
  23. package/dist/frontend/mf-order/src/modules/display-system/interface.d.ts +21 -0
  24. package/dist/frontend/mf-order/src/modules/order-setting/kiosk/coverCarousel.d.ts +69 -0
  25. package/dist/frontend/mf-order/src/modules/order-setting/kiosk/interface.d.ts +5 -1
  26. package/dist/frontend/mf-order/src/stores/order-setting/index.d.ts +90 -3
  27. package/dist/frontend/mf-order/src/views/display-system/DisplayFieldControl.vue.d.ts +3 -0
  28. package/dist/frontend/mf-order/src/views/display-system/DisplayInspector.vue.d.ts +3 -13
  29. package/dist/frontend/mf-order/src/views/order-settings/auto-send-kitchen.d.ts +58 -0
  30. package/dist/frontend/mf-order/src/views/order-settings/delivery/inhouse/InHouseDelivery.vue.d.ts +16 -1
  31. package/dist/frontend/mf-order/src/views/order-settings/pickup/PickUpSettingDialogContent.vue.d.ts +16 -1
  32. package/dist/frontend/mf-order/src/views/order-settings/useAutoSendGuard.d.ts +48 -0
  33. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +1 -1
  34. package/dist/{index-CKEbr9Es.js → index-D5wABuUx.js} +1 -1
  35. package/dist/{index-5k7kGCpJ.js → index-D7IWjZdy.js} +2 -2
  36. package/dist/{index-I2YGevce.js → index-LyvBqKnv.js} +1 -1
  37. package/dist/{order.dto-Dbn-hRrm.js → order.dto-D7HGiueX.js} +3579 -3428
  38. package/dist/package/entity/alert/investigation.dto.d.ts +25 -6
  39. package/dist/package/entity/alert/lark-callback.dto.d.ts +31 -12
  40. package/dist/package/entity/alert/suggestion.dto.d.ts +4 -4
  41. package/dist/package/entity/display/blocks.d.ts +42 -76
  42. package/dist/package/entity/incoming-order/incoming-order-to-bill.dto.d.ts +47 -36
  43. package/dist/package/entity/incoming-order/incoming-order.dto.d.ts +1 -0
  44. package/dist/package/entity/index.d.ts +2 -0
  45. package/dist/package/entity/index.frontend.d.ts +2 -0
  46. package/dist/package/entity/order/effects/effect.dto.d.ts +266 -0
  47. package/dist/package/entity/order/order-item/order-item.dto.d.ts +36 -36
  48. package/dist/package/entity/order/order.do.d.ts +7 -64
  49. package/dist/package/entity/order/order.dto.d.ts +13006 -795
  50. package/dist/package/entity/order-platform/grabfood/grabfood-settings.do.d.ts +13 -0
  51. package/dist/package/entity/order-platform/grabfood/grabfood.dto.d.ts +3 -0
  52. package/dist/package/entity/order-platform/menu.dto.d.ts +10 -10
  53. package/dist/package/entity/order-platform/shopeefood/shopeefood-order.dto.d.ts +11 -8
  54. package/dist/package/entity/order-setting/auto-accept.dto.d.ts +94 -0
  55. package/dist/package/entity/order-setting/auto-send.resolve.d.ts +110 -0
  56. package/dist/package/entity/order-setting/delivery/inhouse-delivery.do.d.ts +3 -0
  57. package/dist/package/entity/order-setting/delivery/inhouse-delivery.dto.d.ts +3 -0
  58. package/dist/package/entity/order-setting/dine-in/dine-in.do.d.ts +3 -0
  59. package/dist/package/entity/order-setting/dine-in/dine-in.dto.d.ts +3 -0
  60. package/dist/package/entity/order-setting/kiosk/kiosk.do.d.ts +26 -0
  61. package/dist/package/entity/order-setting/kiosk/kiosk.dto.d.ts +39 -0
  62. package/dist/package/entity/order-setting/order-setting.do.d.ts +57 -4
  63. package/dist/package/entity/order-setting/order-setting.dto.d.ts +259 -9
  64. package/dist/package/entity/order-setting/pickup/pickup.do.d.ts +3 -0
  65. package/dist/package/entity/order-setting/pickup/pickup.dto.d.ts +3 -0
  66. package/dist/package/entity/order-setting/queue/queue.do.d.ts +2 -2
  67. package/dist/package/entity/order-setting/queue/queue.dto.d.ts +2 -2
  68. package/dist/package/entity/queue/queue.dto.d.ts +48 -48
  69. package/dist/package/entity/restaurant/restaurant.dto.d.ts +46 -8
  70. package/dist/package/entity/websocket/websocket.dto.d.ts +88 -0
  71. package/dist/style.css +1 -1
  72. package/package.json +2 -2
  73. package/src/locales/en-US.json +22 -2
  74. package/src/locales/ja-JP.json +12 -1
  75. package/src/locales/th-TH.json +12 -1
  76. package/src/locales/zh-CN.json +22 -2
  77. package/src/modules/display-system/interface.spec.ts +32 -18
  78. package/src/modules/display-system/interface.ts +32 -0
  79. package/src/modules/order-setting/kiosk/coverCarousel.spec.ts +172 -0
  80. package/src/modules/order-setting/kiosk/coverCarousel.ts +186 -0
  81. package/src/modules/order-setting/kiosk/interface.ts +138 -127
  82. package/src/stores/order-setting/mapper.spec.ts +26 -0
  83. package/src/stores/order-setting/mapper.ts +305 -293
  84. package/src/views/display-system/DisplayFieldControl.vue +46 -0
  85. package/src/views/display-system/DisplayInspector.vue +8 -3
  86. package/src/views/display-system/DisplaySystemView.vue +7 -3
  87. package/src/views/display-system/DisplayTemplateEditor.vue +28 -2
  88. package/src/views/kiosk/settings/KioskSettingView.vue +176 -60
  89. package/src/views/order-settings/auto-send-kitchen.spec.ts +137 -0
  90. package/src/views/order-settings/auto-send-kitchen.ts +92 -0
  91. package/src/views/order-settings/auto-send-resolve.spec.ts +123 -0
  92. package/src/views/order-settings/delivery/DeliverySetting.vue +10 -0
  93. package/src/views/order-settings/delivery/inhouse/InHouseDelivery.vue +40 -5
  94. package/src/views/order-settings/dine-in/DineInSetting.vue +38 -5
  95. package/src/views/order-settings/pickup/PickUpSetting.vue +1 -0
  96. package/src/views/order-settings/pickup/PickUpSettingDialogContent.vue +38 -5
  97. package/src/views/order-settings/queue/QueueSetting.vue +6 -9
  98. package/src/views/order-settings/useAutoSendGuard.ts +142 -0
  99. package/tsconfig.app.json +10 -1
  100. package/dist/DisplaySystemView-4Bz8p26A.js +0 -5892
  101. package/dist/KioskSettingView-D57KtQ-t.js +0 -711
  102. package/dist/display-preview/assets/BandBoard-BrDTYDc9.js +0 -1
  103. package/dist/display-preview/assets/CarouselBlock-CrkGxoGk.js +0 -1
  104. package/dist/display-preview/assets/ImageBlock-DtgfHd5G.js +0 -1
  105. package/dist/display-preview/assets/PickupMode-DKmqGlG8.js +0 -1
  106. package/dist/display-preview/assets/TextBlock-gAfeCCmE.js +0 -1
  107. package/dist/display-preview/assets/index-BB0ReVXy.js +0 -2
  108. package/dist/display-preview/assets/index-BWVpFSIw.css +0 -1
  109. 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: { kind: 'background' } | { kind: 'block'; path: string }): void
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
- @pick-media="emit('pick-media', { kind: 'block', path: field.path })"
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<{ kind: 'background' } | { kind: 'block'; path: string }>({ kind: 'background' })
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: { kind: 'background' } | { kind: 'block'; path: string }): void {
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
- <!-- Cover Image Section -->
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 class="border p-[1.5rem] fm-corner-radius-lg flex flex-col gap-4 my-5 w-11/12">
199
- <span class="fm-typo-en-body-md-600">{{ t('order.coverImageLandscape') }}</span>
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('order.coverImageLandscapeHint')
215
+ t(orientation.hintKey)
202
216
  }}</span>
203
- <FmDropField
204
- v-if="!getCoverImagePreview('coverImageLandscape')"
205
- accept="image/*"
206
- :max-file-size="MAX_IMAGE_FILE_SIZE"
207
- content-class="w-full max-w-[400px] aspect-video"
208
- @file-upload="uploadCoverImage('coverImageLandscape', $event)"
209
- @file-rejected="handleCoverImageRejected('coverImageLandscape', $event)"
210
- />
211
- <div v-else class="flex flex-col gap-2">
212
- <img
213
- :src="getCoverImagePreview('coverImageLandscape')!"
214
- :alt="t('order.coverImageLandscape')"
215
- class="max-w-[400px] aspect-video object-cover rounded-md border border-fm-color-neutral-gray-200"
216
- />
217
- <FmButton
218
- variant="tertiary"
219
- class="mr-auto"
220
- :label="t('common.delete')"
221
- @click="clearCoverImage('coverImageLandscape')"
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
- <div class="border p-[1.5rem] fm-corner-radius-lg flex flex-col gap-4 my-5 w-11/12">
227
- <span class="fm-typo-en-body-md-600">{{ t('order.coverImagePortrait') }}</span>
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.coverImagePortraitHint')
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
- type CoverImageField = 'coverImageLandscape' | 'coverImagePortrait'
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
- function clearCoverImage(field: CoverImageField) {
488
- kioskOrderSettingForm.value[field] = null
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
- function getCoverImagePreview(field: CoverImageField): string | null {
492
- const value = kioskOrderSettingForm.value[field]
493
- if (!value) return null
494
- const trimmed = value.trim()
495
- return trimmed.length > 0 ? trimmed : null
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
- async function uploadCoverImage(field: CoverImageField, file: File) {
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
- const path = `/restaurant/${props.restaurantId}/kiosk/${field}`
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
- kioskOrderSettingForm.value[field] = imageUrl
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
- field: CoverImageField,
634
+ orientation: CoverOrientation,
521
635
  rejected: { file: File; reason: string[] }[]
522
636
  ) {
523
637
  const fieldName =
524
- field === 'coverImageLandscape' ? t('order.coverImageLandscape') : t('order.coverImagePortrait')
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
+ })