@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
@@ -100,6 +100,12 @@
100
100
  "autoIntegrationCountryNotSupport": "プラットフォームは現在の国での自動連携をサポートしていません。",
101
101
  "autoSendKitchen": "キッチンに自動送信",
102
102
  "autoSendKitchenSublabel": "POSバージョン7以上が必要です",
103
+ "followAutoAccept": "自動受注に従う",
104
+ "autoSendKitchenChannelSublabel": "注文を受け付けたときにキッチンへ送信します。「自動受注に従う」は現在の動作を維持し、自動受注のときのみキッチンに通知します。",
105
+ "autoCloseBillRequiresAutoSend": "請求書の自動締めは、注文が自動受注されキッチンに送信された場合にのみ適用されます。それ以外は請求書は開いたままになります。締めた請求書はキッチンに送信できなくなるためです。",
106
+ "autoCloseBillEnableKitchenTitle": "「キッチンに自動送信」も有効にしますか?",
107
+ "autoCloseBillEnableKitchenMessage": "このチャネルの注文はキッチンへ自動送信されないため、支払い済みの請求書がキッチンに届く前に締められます。締めた請求書はキッチンに送信できません。「キッチンに自動送信」も有効にすると、受け付けたすべての注文が先にキッチンへ届きます。",
108
+ "autoCloseBillEnableKitchenConfirm": "両方を有効にする",
103
109
  "presetRemarks": "プリセット備考",
104
110
  "editPresetRemarks": "プリセット備考を編集",
105
111
  "addNewRemark": "新しい備考を追加",
@@ -238,6 +244,11 @@
238
244
  "coverImageLandscapeHint": "推奨サイズ: 1920 x 1080",
239
245
  "coverImagePortraitHint": "推奨サイズ: 1080 x 1920",
240
246
  "coverImageFallbackHint": "未設定の場合、レストラン設定のカバー画像が使用されます",
247
+ "coverCarouselHint": "向きごとに最大 {max} 枚まで追加できます。2 枚以上ある場合、キオスクのトップ画面が表示順にフェードで切り替わります。",
248
+ "coverCarouselInterval": "1 枚あたりの表示秒数",
249
+ "coverCarouselIntervalHint": "次の画像に切り替わるまでの表示時間です。空欄の場合は既定の {seconds} 秒になります。",
250
+ "coverCarouselMoveEarlier": "前に移動",
251
+ "coverCarouselMoveLater": "後に移動",
241
252
  "uploadImage": "画像をアップロード",
242
253
  "imageUrl": "画像URL",
243
254
  "imageUrlPlaceholder": "画像URLを入力",
@@ -294,7 +305,7 @@
294
305
  "printReceiptAfterClose": "自動締め後にレシートを印刷",
295
306
  "printReceiptAfterCloseSublabel": "請求書が自動締めされた後にレシートを印刷します",
296
307
  "autoCloseBill": "請求書を自動締め",
297
- "autoCloseBillSublabel": "注文を受け付けると請求書を自動で締めます",
308
+ "autoCloseBillSublabel": "注文を受け付けると請求書を自動で締めます。未払いの請求書は、支払いが完了するまで開いたままになります。",
298
309
  "prefix": "プレフィックス",
299
310
  "preOrder": "事前注文",
300
311
  "preOrderHint": "{today}に注文、お客様は{start}から{end}の間で注文可能です。最大{maxDays}日間(本日含む)。",
@@ -193,6 +193,12 @@
193
193
  "autoIntegrationCountryNotSupport": "แพลตฟอร์มไม่รองรับการเชื่อมต่อการจัดส่งแบบไดนามิกในประเทศ/ภูมิภาคปัจจุบัน",
194
194
  "autoSendKitchen": "ส่งเข้าครัวอัตโนมัติ",
195
195
  "autoSendKitchenSublabel": "ต้องใช้ POS เวอร์ชัน 7 ขึ้นไป",
196
+ "followAutoAccept": "ใช้การตั้งค่าตามการรับออเดอร์อัตโนมัติ",
197
+ "autoSendKitchenChannelSublabel": "ส่งออเดอร์เข้าครัวเมื่อรับออเดอร์แล้ว เลือก “ใช้การตั้งค่าตามการรับออเดอร์อัตโนมัติ” เพื่อคงพฤติกรรมเดิม คือแจ้งครัวเฉพาะเมื่อรับออเดอร์อัตโนมัติเท่านั้น",
198
+ "autoCloseBillRequiresAutoSend": "การปิดบิลอัตโนมัติจะทำงานเฉพาะเมื่อออเดอร์ถูกรับอัตโนมัติและส่งเข้าครัวแล้วเท่านั้น มิฉะนั้นบิลจะยังเปิดอยู่ เพราะบิลที่ปิดแล้วไม่สามารถส่งเข้าครัวได้อีก",
199
+ "autoCloseBillEnableKitchenTitle": "เปิด “ส่งเข้าครัวอัตโนมัติ” ด้วยหรือไม่?",
200
+ "autoCloseBillEnableKitchenMessage": "ออเดอร์ของช่องทางนี้ไม่ถูกส่งเข้าครัวอัตโนมัติ บิลที่ชำระเงินแล้วจึงถูกปิดก่อนที่ครัวจะเห็น และบิลที่ปิดแล้วไม่สามารถส่งเข้าครัวได้อีก การเปิด “ส่งเข้าครัวอัตโนมัติ” ด้วยจะทำให้ทุกออเดอร์ที่รับแล้วถึงครัวก่อนเสมอ",
201
+ "autoCloseBillEnableKitchenConfirm": "เปิดทั้งสองอย่าง",
196
202
  "presetRemarks": "หมายเหตุที่ตั้งไว้ล่วงหน้า",
197
203
  "editPresetRemarks": "แก้ไขหมายเหตุที่ตั้งไว้ล่วงหน้า",
198
204
  "addNewRemark": "เพิ่มหมายเหตุใหม่",
@@ -336,6 +342,11 @@
336
342
  "coverImageLandscapeHint": "ขนาดที่แนะนำ: 1920 x 1080",
337
343
  "coverImagePortraitHint": "ขนาดที่แนะนำ: 1080 x 1920",
338
344
  "coverImageFallbackHint": "หากไม่ตั้งค่า ระบบจะใช้ Cover Image จากการตั้งค่าร้านอาหารโดยอัตโนมัติ",
345
+ "coverCarouselHint": "เพิ่มได้สูงสุด {max} รูปต่อการวางแนว หากมีตั้งแต่ 2 รูปขึ้นไป หน้าแรกของตู้คีออสก์จะค่อย ๆ เปลี่ยนภาพตามลำดับที่แสดง",
346
+ "coverCarouselInterval": "จำนวนวินาทีต่อรูป",
347
+ "coverCarouselIntervalHint": "แสดงแต่ละรูปนานเท่าใดก่อนเปลี่ยนเป็นรูปถัดไป เว้นว่างไว้เพื่อใช้ค่าเริ่มต้น {seconds} วินาที",
348
+ "coverCarouselMoveEarlier": "เลื่อนขึ้นก่อน",
349
+ "coverCarouselMoveLater": "เลื่อนไปหลัง",
339
350
  "uploadImage": "อัปโหลดรูปภาพ",
340
351
  "imageUrl": "ลิงก์รูปภาพ",
341
352
  "imageUrlPlaceholder": "กรอกลิงก์รูปภาพ",
@@ -403,7 +414,7 @@
403
414
  "printReceiptAfterClose": "พิมพ์ใบเสร็จหลังจากปิดบิลอัตโนมัติ",
404
415
  "printReceiptAfterCloseSublabel": "พิมพ์ใบเสร็จหลังจากปิดบิลอัตโนมัติแล้ว",
405
416
  "autoCloseBill": "ปิดบิลอัตโนมัติ",
406
- "autoCloseBillSublabel": "ปิดบิลโดยอัตโนมัติเมื่อรับออเดอร์แล้ว",
417
+ "autoCloseBillSublabel": "ปิดบิลโดยอัตโนมัติเมื่อรับออเดอร์แล้ว บิลที่ยังไม่ชำระเงินจะยังเปิดอยู่จนกว่าจะชำระเงินครบ",
407
418
  "processing": "กำลังประมวลผล",
408
419
  "productSelected": "ผลิตภัณฑ์ที่เลือก",
409
420
  "promptAnonymousLoginDialog": "แสดงหน้าต่างให้เข้าสู่ระบบเมื่อสั่งอาหารโดยไม่เข้าสู่ระบบ",
@@ -193,6 +193,12 @@
193
193
  "autoIntegrationCountryNotSupport": "平台不支持当前国家/地区的自动配送对接。",
194
194
  "autoSendKitchen": "自动发送至厨房",
195
195
  "autoSendKitchenSublabel": "需要POS版本7或以上",
196
+ "followAutoAccept": "跟随自动接单",
197
+ "autoSendKitchenChannelSublabel": "接受订单时自动发送至厨房。选择「跟随自动接单」维持现有行为:仅在订单自动接单时通知厨房。",
198
+ "autoCloseBillRequiresAutoSend": "仅当订单自动接单并已发送至厨房时,才会自动关闭账单。否则账单保持开启,因为已关闭的账单无法再发送至厨房。",
199
+ "autoCloseBillEnableKitchenTitle": "同时开启「自动发送至厨房」?",
200
+ "autoCloseBillEnableKitchenMessage": "此渠道的订单不会自动发送至厨房,已付款的账单会在厨房看到之前被关闭,而已关闭的账单无法再发送至厨房。同时开启「自动发送至厨房」可确保每笔已接受的订单先送达厨房。",
201
+ "autoCloseBillEnableKitchenConfirm": "同时开启",
196
202
  "presetRemarks": "预设备注",
197
203
  "editPresetRemarks": "编辑预设备注",
198
204
  "addNewRemark": "添加新备注",
@@ -338,6 +344,11 @@
338
344
  "coverImageLandscapeHint": "推荐尺寸:1920 x 1080",
339
345
  "coverImagePortraitHint": "推荐尺寸:1080 x 1920",
340
346
  "coverImageFallbackHint": "若不设置,将默认使用餐厅设置中的 Cover Image",
347
+ "coverCarouselHint": "每个方向最多可添加 {max} 张图片。有两张或以上时,自助点餐机首页会按显示顺序淡入淡出切换。",
348
+ "coverCarouselInterval": "每张图片显示秒数",
349
+ "coverCarouselIntervalHint": "每张图片显示多久后切换到下一张。留空则使用默认的 {seconds} 秒。",
350
+ "coverCarouselMoveEarlier": "往前移",
351
+ "coverCarouselMoveLater": "往后移",
341
352
  "uploadImage": "上传图片",
342
353
  "imageUrl": "图片链接",
343
354
  "imageUrlPlaceholder": "请输入图片链接",
@@ -404,7 +415,7 @@
404
415
  "printReceiptAfterClose": "自动关闭账单后打印收据",
405
416
  "printReceiptAfterCloseSublabel": "账单自动关闭后打印收据",
406
417
  "autoCloseBill": "自动关闭账单",
407
- "autoCloseBillSublabel": "订单接受后自动关闭账单",
418
+ "autoCloseBillSublabel": "订单接受后自动关闭账单。尚未付款的账单会保持开启,直到结清为止。",
408
419
  "pricePerMessage": "每条短信的价格",
409
420
  "processing": "处理中",
410
421
  "productSelected": "已选择商品",
@@ -584,6 +595,7 @@
584
595
  "queueBandBoard": "排队叫号板",
585
596
  "mediaCarousel": "轮播",
586
597
  "mediaImage": "图片",
598
+ "mediaVideo": "视频",
587
599
  "infoText": "文字"
588
600
  },
589
601
  "fields": {
@@ -610,7 +622,8 @@
610
622
  "title": "标题",
611
623
  "subtitle": "副标题",
612
624
  "text": "文字",
613
- "image": "图片"
625
+ "image": "图片",
626
+ "video": "视频"
614
627
  },
615
628
  "values": {
616
629
  "horizontal": "左右并排",
@@ -684,6 +697,11 @@
684
697
  "replaceImage": "更换",
685
698
  "clearImage": "移除",
686
699
  "noImage": "未设置",
700
+ "chooseVideo": "选择视频",
701
+ "replaceVideo": "更换",
702
+ "clearVideo": "移除",
703
+ "noVideo": "未设置",
704
+ "videoUndecodable": "可能无法在屏幕上播放,建议改用 MP4",
687
705
  "modules": "该区域的模块",
688
706
  "addModule": "添加模块",
689
707
  "addModulePlaceholder": "选择要添加的模块",
@@ -724,6 +742,8 @@
724
742
  "allRestaurants": "所有餐厅",
725
743
  "online": "在线",
726
744
  "offline": "离线",
745
+ "unlicensed": "未授权",
746
+ "unlicensedNote": "超出屏幕授权数量,POS 会在这块屏上标记未授权。可以再买一块,或停用其中一块。",
727
747
  "deviceId": "设备 ID",
728
748
  "lastSeen": "最后在线",
729
749
  "appVersion": "应用版本",
@@ -2,24 +2,7 @@ import { DISPLAY_BLOCK_DEFINITIONS } from '@entity'
2
2
  import { reactive } from 'vue'
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
5
- import {
6
- addArea,
7
- clearBlock,
8
- crossDirection,
9
- defaultBlock,
10
- defaultDisplayTemplateDraft,
11
- displayTemplatePayload,
12
- mergeGroup,
13
- removeArea,
14
- setBlock,
15
- splitArea,
16
- swapBlocks,
17
- templateCapabilities,
18
- walkZones,
19
- zoneFitsMinimum,
20
- zoneParent,
21
- type DisplaySplitDraft
22
- } from './interface'
5
+ import { addArea, clearBlock, crossDirection, defaultBlock, defaultDisplayTemplateDraft, displayTemplatePayload, mergeGroup, removeArea, setBlock, splitArea, swapBlocks, templateCapabilities, type DisplaySplitDraft, unlicensedScreenIds, walkZones, zoneFitsMinimum, zoneParent } from './interface'
23
6
 
24
7
  describe('display template drafts', () => {
25
8
  it('serializes a reactive editor draft into an API payload', () => {
@@ -145,3 +128,34 @@ describe('display template drafts', () => {
145
128
  expect(zoneFitsMinimum(draft.root, queueZone.id, minimum, 0.5)).toBe(true)
146
129
  })
147
130
  })
131
+
132
+ describe('unlicensedScreenIds', () => {
133
+ const screen = (id: string, createdAt: string, status = 'ACTIVE') =>
134
+ ({ _id: id, createdAt, status }) as never
135
+
136
+ // Mirrors rebuildSnapshotsLocked: oldest first, everything past the limit
137
+ // loses its licence.
138
+ it('takes the licence from the newest screens', () => {
139
+ const screens = [
140
+ screen('new', '2026-08-26T07:11:30.064Z'),
141
+ screen('old', '2026-08-24T14:08:46.632Z')
142
+ ]
143
+ expect([...unlicensedScreenIds(screens, 1)]).toEqual(['new'])
144
+ expect([...unlicensedScreenIds(screens, 2)]).toEqual([])
145
+ expect([...unlicensedScreenIds(screens, 0)].sort()).toEqual(['new', 'old'])
146
+ })
147
+
148
+ // A paused screen frees its slot, so it must not consume one here either.
149
+ it('ignores disabled screens', () => {
150
+ const screens = [
151
+ screen('paused', '2026-08-20T00:00:00.000Z', 'DISABLED'),
152
+ screen('live', '2026-08-24T00:00:00.000Z')
153
+ ]
154
+ expect([...unlicensedScreenIds(screens, 1)]).toEqual([])
155
+ })
156
+
157
+ it('breaks a createdAt tie by id, the way the host does', () => {
158
+ const screens = [screen('b', 'same'), screen('a', 'same')]
159
+ expect([...unlicensedScreenIds(screens, 1)]).toEqual(['b'])
160
+ })
161
+ })
@@ -74,11 +74,43 @@ function cloneJsonValue<T>(value: T): T {
74
74
  return JSON.parse(JSON.stringify(value)) as T
75
75
  }
76
76
 
77
+ /**
78
+ * Screens the POS will watermark as unlicensed.
79
+ *
80
+ * Mirrors `rebuildSnapshotsLocked` in
81
+ * `go/packages/display_host/runtime/source.go` — that is the authority, because
82
+ * the POS decides this offline from its own cached entitlement. Kept as one
83
+ * function so the duplicate is in a single place: DISABLED screens do not
84
+ * count, the rest are ordered oldest first, and everything past the limit
85
+ * loses its licence.
86
+ */
87
+ export function unlicensedScreenIds(
88
+ screens: Pick<DisplayScreen, '_id' | 'status' | 'createdAt'>[],
89
+ screenLimit: number
90
+ ): Set<string> {
91
+ const ordered = screens
92
+ .filter((screen) => screen.status !== 'DISABLED')
93
+ .sort((left, right) =>
94
+ left.createdAt === right.createdAt
95
+ ? left._id.localeCompare(right._id)
96
+ : (left.createdAt ?? '').localeCompare(right.createdAt ?? '')
97
+ )
98
+ return new Set(ordered.slice(Math.max(0, screenLimit)).map((screen) => screen._id))
99
+ }
100
+
101
+ /** Where a media pick lands, and which kind the picker should offer. Absent
102
+ * mediaKind means image: a zone background and every carousel item render as
103
+ * one. */
104
+ export type DisplayMediaTarget =
105
+ | { kind: 'background' }
106
+ | { kind: 'block'; path: string; mediaKind?: 'image' | 'video' }
107
+
77
108
  const blockDefaults: Record<string, Record<string, unknown>> = {
78
109
  'pickup.categoryBoard': { rotateMs: 10000, showProgressBar: false, showDeliveryIcons: true, showCurrentOrder: true, announce: true },
79
110
  'pickup.statusBoard': { layout: 'HORIZONTAL', columns: 2, rotateMs: 10000, showDeliveryIcons: true, showOrderCount: false, showEstimatedTime: false, showCurrentOrder: true, announce: true },
80
111
  'queue.bandBoard': { showWaitingCount: true, upcomingCount: 3, rotateMs: 10000, showHeader: true, announce: true },
81
112
  'media.image': { url: '', objectFit: 'COVER' },
113
+ 'media.video': { url: '', objectFit: 'COVER' },
82
114
  'media.carousel': { items: [{ kind: 'IMAGE', url: '', objectFit: 'COVER' }], intervalMs: 8000, transition: 'FADE' },
83
115
  'info.text': { text: 'Welcome', size: 'MD', align: 'CENTER' }
84
116
  }
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ applyCoverImages,
4
+ applyIntervalSeconds,
5
+ MAX_COVER_IMAGES,
6
+ readCoverImages,
7
+ readIntervalSeconds,
8
+ reorderCoverImages,
9
+ type CoverImageFields
10
+ } from './coverCarousel'
11
+
12
+ const empty: CoverImageFields = {
13
+ coverImageLandscape: null,
14
+ coverImagePortrait: null
15
+ }
16
+
17
+ function fields(over: Partial<CoverImageFields>): CoverImageFields {
18
+ return { ...empty, ...over }
19
+ }
20
+
21
+ describe('readCoverImages', () => {
22
+ it('is empty when nothing is set', () => {
23
+ expect(readCoverImages(empty, 'portrait')).toEqual([])
24
+ })
25
+
26
+ it('reads the single image as a one-entry list', () => {
27
+ expect(readCoverImages(fields({ coverImagePortrait: 'p.jpg' }), 'portrait')).toEqual(['p.jpg'])
28
+ })
29
+
30
+ it('prefers the carousel list over the single image', () => {
31
+ const f = fields({
32
+ coverImagePortrait: 'old.jpg',
33
+ coverCarousel: { portrait: ['a.jpg', 'b.jpg'] }
34
+ })
35
+ expect(readCoverImages(f, 'portrait')).toEqual(['a.jpg', 'b.jpg'])
36
+ })
37
+
38
+ it('falls back to the single image when the list for that orientation is empty', () => {
39
+ const f = fields({
40
+ coverImagePortrait: 'p.jpg',
41
+ coverCarousel: { landscape: ['a.jpg', 'b.jpg'] }
42
+ })
43
+ expect(readCoverImages(f, 'portrait')).toEqual(['p.jpg'])
44
+ expect(readCoverImages(f, 'landscape')).toEqual(['a.jpg', 'b.jpg'])
45
+ })
46
+
47
+ it('drops blanks and duplicates', () => {
48
+ const f = fields({ coverCarousel: { portrait: ['a.jpg', ' ', 'a.jpg', ' b.jpg '] } })
49
+ expect(readCoverImages(f, 'portrait')).toEqual(['a.jpg', 'b.jpg'])
50
+ })
51
+
52
+ it('treats a whitespace-only single image as unset', () => {
53
+ expect(readCoverImages(fields({ coverImagePortrait: ' ' }), 'portrait')).toEqual([])
54
+ })
55
+ })
56
+
57
+ describe('applyCoverImages', () => {
58
+ it('stores one image on the single field only, leaving no carousel behind', () => {
59
+ const next = applyCoverImages(empty, 'portrait', ['p.jpg'])
60
+ expect(next.coverImagePortrait).toBe('p.jpg')
61
+ expect(next.coverCarousel).toBeNull()
62
+ })
63
+
64
+ it('stores two or more on the carousel list and mirrors the first to the single field', () => {
65
+ const next = applyCoverImages(empty, 'portrait', ['a.jpg', 'b.jpg', 'c.jpg'])
66
+ expect(next.coverCarousel?.portrait).toEqual(['a.jpg', 'b.jpg', 'c.jpg'])
67
+ // An older kiosk build knows only this field.
68
+ expect(next.coverImagePortrait).toBe('a.jpg')
69
+ })
70
+
71
+ it('clearing goes back to the pre-feature document', () => {
72
+ const f = fields({
73
+ coverImagePortrait: 'a.jpg',
74
+ coverCarousel: { portrait: ['a.jpg', 'b.jpg'] }
75
+ })
76
+ const next = applyCoverImages(f, 'portrait', [])
77
+ expect(next.coverImagePortrait).toBeNull()
78
+ expect(next.coverCarousel).toBeNull()
79
+ })
80
+
81
+ it('dropping back to one image clears that orientation list', () => {
82
+ const f = fields({
83
+ coverImagePortrait: 'a.jpg',
84
+ coverCarousel: { portrait: ['a.jpg', 'b.jpg'] }
85
+ })
86
+ const next = applyCoverImages(f, 'portrait', ['b.jpg'])
87
+ expect(next.coverImagePortrait).toBe('b.jpg')
88
+ expect(next.coverCarousel).toBeNull()
89
+ })
90
+
91
+ it('does not touch the other orientation', () => {
92
+ const f = fields({
93
+ coverImageLandscape: 'l.jpg',
94
+ coverCarousel: { landscape: ['l.jpg', 'l2.jpg'], intervalMs: 8000 }
95
+ })
96
+ const next = applyCoverImages(f, 'portrait', ['p1.jpg', 'p2.jpg'])
97
+ expect(next.coverImageLandscape).toBe('l.jpg')
98
+ expect(next.coverCarousel?.landscape).toEqual(['l.jpg', 'l2.jpg'])
99
+ expect(next.coverCarousel?.intervalMs).toBe(8000)
100
+ })
101
+
102
+ it('does not promote the other orientation single image into a list', () => {
103
+ const f = fields({ coverImagePortrait: 'p.jpg' })
104
+ const next = applyCoverImages(f, 'landscape', ['a.jpg', 'b.jpg'])
105
+ expect(next.coverImagePortrait).toBe('p.jpg')
106
+ expect(next.coverCarousel?.portrait).toBeUndefined()
107
+ })
108
+
109
+ it('caps the list', () => {
110
+ const many = Array.from({ length: MAX_COVER_IMAGES + 3 }, (_, i) => `i${i}.jpg`)
111
+ const next = applyCoverImages(empty, 'portrait', many)
112
+ expect(next.coverCarousel?.portrait).toHaveLength(MAX_COVER_IMAGES)
113
+ })
114
+
115
+ it('does not mutate the input', () => {
116
+ const f = fields({ coverCarousel: { portrait: ['a.jpg', 'b.jpg'] } })
117
+ applyCoverImages(f, 'portrait', ['c.jpg'])
118
+ expect(f.coverCarousel?.portrait).toEqual(['a.jpg', 'b.jpg'])
119
+ })
120
+ })
121
+
122
+ describe('interval', () => {
123
+ it('reads nothing when unset', () => {
124
+ expect(readIntervalSeconds(empty)).toBeNull()
125
+ expect(readIntervalSeconds(fields({ coverCarousel: { portrait: ['a.jpg'] } }))).toBeNull()
126
+ })
127
+
128
+ it('reads milliseconds back as seconds', () => {
129
+ expect(readIntervalSeconds(fields({ coverCarousel: { intervalMs: 9000 } }))).toBe(9)
130
+ })
131
+
132
+ it('stores seconds as milliseconds when a carousel exists', () => {
133
+ const f = fields({ coverCarousel: { portrait: ['a.jpg', 'b.jpg'] } })
134
+ expect(applyIntervalSeconds(f, 10).coverCarousel?.intervalMs).toBe(10_000)
135
+ })
136
+
137
+ it('clamps below the kiosk fade floor and above the ceiling', () => {
138
+ const f = fields({ coverCarousel: { portrait: ['a.jpg', 'b.jpg'] } })
139
+ expect(applyIntervalSeconds(f, 0).coverCarousel?.intervalMs).toBe(2000)
140
+ expect(applyIntervalSeconds(f, 999).coverCarousel?.intervalMs).toBe(60_000)
141
+ })
142
+
143
+ it('clearing it drops the field so the kiosk default applies', () => {
144
+ const f = fields({ coverCarousel: { portrait: ['a.jpg', 'b.jpg'], intervalMs: 9000 } })
145
+ const next = applyIntervalSeconds(f, null)
146
+ expect(next.coverCarousel?.intervalMs).toBeUndefined()
147
+ expect(next.coverCarousel?.portrait).toEqual(['a.jpg', 'b.jpg'])
148
+ })
149
+
150
+ it('is a no-op sub-object when there is nothing to animate', () => {
151
+ expect(applyIntervalSeconds(empty, 10).coverCarousel).toBeNull()
152
+ })
153
+ })
154
+
155
+ describe('reorderCoverImages', () => {
156
+ it('moves an image', () => {
157
+ expect(reorderCoverImages(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b'])
158
+ expect(reorderCoverImages(['a', 'b', 'c'], 0, 1)).toEqual(['b', 'a', 'c'])
159
+ })
160
+
161
+ it('is a no-op out of range or in place', () => {
162
+ expect(reorderCoverImages(['a', 'b'], 0, 0)).toEqual(['a', 'b'])
163
+ expect(reorderCoverImages(['a', 'b'], -1, 1)).toEqual(['a', 'b'])
164
+ expect(reorderCoverImages(['a', 'b'], 0, 5)).toEqual(['a', 'b'])
165
+ })
166
+
167
+ it('does not mutate the input', () => {
168
+ const list = ['a', 'b', 'c']
169
+ reorderCoverImages(list, 0, 2)
170
+ expect(list).toEqual(['a', 'b', 'c'])
171
+ })
172
+ })
@@ -0,0 +1,186 @@
1
+ import type { OrderKioskSettings } from '@entity'
2
+
3
+ /**
4
+ * Landing-screen background images for the kiosk, as the settings form sees
5
+ * them: ONE ordered list per orientation.
6
+ *
7
+ * The stored shape is two fields, not one, and that is deliberate — see
8
+ * FdoOrderKioskCoverCarousel in package/entity. `coverImage{Landscape,Portrait}`
9
+ * is the original single image; `coverCarousel.{landscape,portrait}` is the
10
+ * multi-image list that wins whenever it has entries. Keeping both means no
11
+ * migration and no behaviour change for merchants who never open this editor.
12
+ *
13
+ * These helpers are the only place that knows how one list maps onto those two
14
+ * fields, so the view never has to reason about it:
15
+ *
16
+ * - 0 or 1 image -> written to the single field, carousel list cleared. A
17
+ * merchant who uploads one picture ends up with exactly the document they
18
+ * would have had before this feature existed.
19
+ * - 2 or more -> written to the carousel list, AND the first image is
20
+ * mirrored into the single field. Kiosks update over the air, so some
21
+ * devices are still running a build that has never heard of `coverCarousel`;
22
+ * the mirror leaves them showing the first image instead of silently falling
23
+ * back to the restaurant profile photo.
24
+ */
25
+
26
+ export type CoverOrientation = 'landscape' | 'portrait'
27
+
28
+ type CoverCarousel = NonNullable<OrderKioskSettings['coverCarousel']>
29
+
30
+ /** The subset of the kiosk form these helpers read and rewrite. */
31
+ export interface CoverImageFields {
32
+ coverImageLandscape: string | null
33
+ coverImagePortrait: string | null
34
+ coverCarousel?: CoverCarousel | null
35
+ }
36
+
37
+ /**
38
+ * Enough for a landing loop, few enough that the merchant is not uploading a
39
+ * slideshow into a document the kiosk fetches on every boot.
40
+ */
41
+ export const MAX_COVER_IMAGES = 8
42
+
43
+ /**
44
+ * Kept in step with kiosk-v2's HomeCoverBackground.vue: it defaults to 6000ms
45
+ * and clamps anything below FADE_MS + 400 = 1600ms, so offering less than 2s
46
+ * here would just be a number the kiosk ignores.
47
+ */
48
+ export const DEFAULT_COVER_INTERVAL_SECONDS = 6
49
+ export const MIN_COVER_INTERVAL_SECONDS = 2
50
+ export const MAX_COVER_INTERVAL_SECONDS = 60
51
+
52
+ const SINGLE_FIELD = {
53
+ landscape: 'coverImageLandscape',
54
+ portrait: 'coverImagePortrait'
55
+ } as const
56
+
57
+ function cleanList(list?: string[] | null): string[] {
58
+ if (!Array.isArray(list)) return []
59
+ const seen = new Set<string>()
60
+ const out: string[] = []
61
+ for (const entry of list) {
62
+ const url = typeof entry === 'string' ? entry.trim() : ''
63
+ // A duplicate URL is invisible on the kiosk anyway: HomeCoverBackground
64
+ // de-duplicates because a repeated url collides as a `:key`.
65
+ if (!url || seen.has(url)) continue
66
+ seen.add(url)
67
+ out.push(url)
68
+ }
69
+ return out
70
+ }
71
+
72
+ function storedList(fields: CoverImageFields, orientation: CoverOrientation): string[] {
73
+ return cleanList(fields.coverCarousel?.[orientation])
74
+ }
75
+
76
+ function storedSingle(fields: CoverImageFields, orientation: CoverOrientation): string | null {
77
+ const value = fields[SINGLE_FIELD[orientation]]
78
+ const trimmed = typeof value === 'string' ? value.trim() : ''
79
+ return trimmed.length > 0 ? trimmed : null
80
+ }
81
+
82
+ /**
83
+ * The images to show in the editor for one orientation, in display order.
84
+ * Mirrors the kiosk's own resolution order, so what the merchant sees here is
85
+ * what the kiosk will render.
86
+ */
87
+ export function readCoverImages(
88
+ fields: CoverImageFields,
89
+ orientation: CoverOrientation
90
+ ): string[] {
91
+ const list = storedList(fields, orientation)
92
+ if (list.length > 0) return list
93
+ const single = storedSingle(fields, orientation)
94
+ return single ? [single] : []
95
+ }
96
+
97
+ /** The stored hold time as whole seconds, or null when the merchant never set one. */
98
+ export function readIntervalSeconds(fields: CoverImageFields): number | null {
99
+ const ms = fields.coverCarousel?.intervalMs
100
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return null
101
+ return Math.round(ms / 1000)
102
+ }
103
+
104
+ function buildCarousel(
105
+ landscape: string[],
106
+ portrait: string[],
107
+ intervalMs: number | null | undefined
108
+ ): CoverCarousel | null {
109
+ // Nothing is animating, so the whole sub-object is noise — and dropping it
110
+ // keeps a merchant who deleted their extra images back at the exact document
111
+ // they had before.
112
+ if (landscape.length === 0 && portrait.length === 0) return null
113
+ return {
114
+ landscape: landscape.length > 0 ? landscape : undefined,
115
+ portrait: portrait.length > 0 ? portrait : undefined,
116
+ intervalMs: typeof intervalMs === 'number' && intervalMs > 0 ? intervalMs : undefined
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The cover fields to store for a new list on one orientation. Pure: returns a
122
+ * patch, never mutates `fields`.
123
+ *
124
+ * The other orientation is carried across from what is STORED, not from what
125
+ * `readCoverImages` would show. Reading would promote that side's single image
126
+ * into a one-entry carousel list, which changes its document for no reason.
127
+ */
128
+ export function applyCoverImages(
129
+ fields: CoverImageFields,
130
+ orientation: CoverOrientation,
131
+ images: string[]
132
+ ): CoverImageFields {
133
+ const next = cleanList(images).slice(0, MAX_COVER_IMAGES)
134
+ const other: CoverOrientation = orientation === 'landscape' ? 'portrait' : 'landscape'
135
+
136
+ const lists: Record<CoverOrientation, string[]> = {
137
+ [orientation]: next.length > 1 ? next : [],
138
+ [other]: storedList(fields, other)
139
+ } as Record<CoverOrientation, string[]>
140
+
141
+ return {
142
+ coverImageLandscape:
143
+ orientation === 'landscape' ? (next[0] ?? null) : fields.coverImageLandscape,
144
+ coverImagePortrait:
145
+ orientation === 'portrait' ? (next[0] ?? null) : fields.coverImagePortrait,
146
+ coverCarousel: buildCarousel(
147
+ lists.landscape,
148
+ lists.portrait,
149
+ fields.coverCarousel?.intervalMs
150
+ )
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The cover fields to store for a new hold time. `null` clears it, which puts
156
+ * the kiosk back on its own default rather than pinning a number.
157
+ */
158
+ export function applyIntervalSeconds(
159
+ fields: CoverImageFields,
160
+ seconds: number | null
161
+ ): CoverImageFields {
162
+ const clamped =
163
+ seconds == null || !Number.isFinite(seconds)
164
+ ? null
165
+ : Math.min(MAX_COVER_INTERVAL_SECONDS, Math.max(MIN_COVER_INTERVAL_SECONDS, Math.round(seconds)))
166
+
167
+ return {
168
+ coverImageLandscape: fields.coverImageLandscape,
169
+ coverImagePortrait: fields.coverImagePortrait,
170
+ coverCarousel: buildCarousel(
171
+ storedList(fields, 'landscape'),
172
+ storedList(fields, 'portrait'),
173
+ clamped == null ? null : clamped * 1000
174
+ )
175
+ }
176
+ }
177
+
178
+ /** Move the image at `from` to `to`, returning a new list. Out-of-range is a no-op. */
179
+ export function reorderCoverImages(images: string[], from: number, to: number): string[] {
180
+ if (from === to) return [...images]
181
+ if (from < 0 || to < 0 || from >= images.length || to >= images.length) return [...images]
182
+ const next = [...images]
183
+ const [moved] = next.splice(from, 1)
184
+ next.splice(to, 0, moved)
185
+ return next
186
+ }