@feedmepos/mf-order-setting 0.0.50 → 0.0.52-dev.0

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 (63) hide show
  1. package/.tsbuildinfo +1 -0
  2. package/dist/{KioskDevicesView-u14hzPbE.js → KioskDevicesView-Vy9FLX1n.js} +1 -1
  3. package/dist/{KioskDevicesView.vue_vue_type_script_setup_true_lang-DBgRDIoS.js → KioskDevicesView.vue_vue_type_script_setup_true_lang-DhZPOEEQ.js} +2 -2
  4. package/dist/{KioskSettingView-DmvtZcV1.js → KioskSettingView-cE-JdCBB.js} +206 -208
  5. package/dist/{KioskView-M8V91gD5.js → KioskView-BYs5bem0.js} +4 -4
  6. package/dist/OrderSettingsView-C4aEpC1j.js +56063 -0
  7. package/dist/{app-CLewMjcd.js → app-CwYXsqxX.js} +184 -20
  8. package/dist/app.js +1 -1
  9. package/dist/{dayjs.min-DCTYRWyD.js → dayjs.min-JEYIJz2D.js} +1 -1
  10. package/dist/frontend/mf-order/src/api/reservation/index.d.ts +8 -0
  11. package/dist/frontend/mf-order/src/app.d.ts +164 -0
  12. package/dist/frontend/mf-order/src/main.d.ts +164 -0
  13. package/dist/frontend/mf-order/src/stores/restaurant/index.d.ts +3 -3
  14. package/dist/frontend/mf-order/src/views/all-orders/ReflowOrder.vue.d.ts +2 -2
  15. package/dist/frontend/mf-order/src/views/order-settings/delivery/inhouse/InHouseDelivery.vue.d.ts +2 -2
  16. package/dist/frontend/mf-order/src/views/order-settings/reservation/CopySettingsSheet.vue.d.ts +186 -0
  17. package/dist/frontend/mf-order/src/views/order-settings/reservation/CustomSelect.vue.d.ts +15 -0
  18. package/dist/frontend/mf-order/src/views/order-settings/reservation/CustomTimePicker.vue.d.ts +10 -0
  19. package/dist/frontend/mf-order/src/views/order-settings/reservation/ReservationSetting.vue.d.ts +2 -0
  20. package/dist/{index-B7LtJeBJ.js → index-DZCjODMx.js} +2 -2
  21. package/dist/{menu.dto-Co7iXHNr.js → menu.dto-D9CDVLiP.js} +22865 -20028
  22. package/dist/package/entity/food-court/order.do.d.ts +47 -2
  23. package/dist/package/entity/food-court/order.dto.d.ts +0 -3
  24. package/dist/package/entity/incoming-order/incoming-order-to-bill.dto.d.ts +12356 -1
  25. package/dist/package/entity/incoming-order/incoming-order.do.d.ts +3 -22266
  26. package/dist/package/entity/incoming-order/incoming-order.dto.d.ts +18 -0
  27. package/dist/package/entity/index.d.ts +5 -0
  28. package/dist/package/entity/kiosk/marketing/marketing.dto.d.ts +1 -19864
  29. package/dist/package/entity/order/order-item/order-item.dto.d.ts +24 -3714
  30. package/dist/package/entity/order/order.do.d.ts +8 -0
  31. package/dist/package/entity/order/order.dto.d.ts +118 -0
  32. package/dist/package/entity/order-platform/external/menu/external-master-menu.do.d.ts +20 -0
  33. package/dist/package/entity/order-platform/external/menu/external-menu.do.d.ts +23 -0
  34. package/dist/package/entity/order-platform/menu.dto.d.ts +34 -0
  35. package/dist/package/entity/order-setting/order-setting.do.d.ts +861 -0
  36. package/dist/package/entity/order-setting/reservationV2/reservation.do.d.ts +1269 -0
  37. package/dist/package/entity/queue/queue.do.d.ts +1 -11
  38. package/dist/package/entity/queue/queue.dto.d.ts +25 -0
  39. package/dist/package/entity/reservation/reservation.do.d.ts +101 -0
  40. package/dist/package/entity/reservation/reservation.dto.d.ts +325 -0
  41. package/dist/package/entity/reservation/reservation.enum.d.ts +3 -0
  42. package/dist/package/entity/reservation/reservation.utils.d.ts +152 -0
  43. package/dist/style.css +1 -0
  44. package/package.json +3 -3
  45. package/src/api/reservation/index.ts +28 -0
  46. package/src/assets/images/not-found.png +0 -0
  47. package/src/locales/en-US.json +56 -0
  48. package/src/locales/th-TH.json +54 -0
  49. package/src/locales/zh-CN.json +54 -0
  50. package/src/main.ts +7 -5
  51. package/src/stores/order-setting/mapper.ts +50 -50
  52. package/src/views/kiosk/settings/KioskPaymentTypeSection.vue +1 -19
  53. package/src/views/order-settings/OrderSettingsView.vue +7 -2
  54. package/src/views/order-settings/delivery/integrated-delivery/IntegratedDelivery.vue +3 -1
  55. package/src/views/order-settings/drive-thru/DriveThruSetting.vue +13 -28
  56. package/src/views/order-settings/reservation/CopySettingsSheet.vue +238 -0
  57. package/src/views/order-settings/reservation/CustomSelect.vue +99 -0
  58. package/src/views/order-settings/reservation/CustomTimePicker.vue +201 -0
  59. package/src/views/order-settings/reservation/ReservationSetting.vue +1246 -0
  60. package/src/views/order-settings/servicecharge/ServiceChargeRule.vue +5 -1
  61. package/tsconfig.app.json +8 -6
  62. package/dist/OrderSettingsView-Bl3LshG3.js +0 -51603
  63. package/dist/frontend/mf-order/tsconfig.app.tsbuildinfo +0 -1
@@ -0,0 +1,1246 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted, computed } from 'vue'
3
+ import { ReservationApi } from '@/api/reservation'
4
+ import { useLoading } from '@/composables/loading'
5
+ import {
6
+ type FdoOrderReservationSettingsV2,
7
+ FdoReservationRange,
8
+ FdoOrderReservationSettingsV2 as ReservationSettingsSchema,
9
+ generateDayTimeSlotsWithStatus,
10
+ categorizeTimeSlotsWithStatus,
11
+ validateTimeRangeOverlap,
12
+ validateSameDayTimeRange,
13
+ validateLeadDuration
14
+ } from '@entity'
15
+ import RestaurantSelector from '../components/RestaurantSelector.vue'
16
+ import CustomTimePicker from './CustomTimePicker.vue'
17
+ import CustomSelect from './CustomSelect.vue'
18
+ import { useCoreStore, useI18n } from '@feedmepos/mf-common'
19
+ import { useSnackbarFunctions } from '@/components/snackbar'
20
+ import { useDialog } from '@feedmepos/ui-library'
21
+ import moment from 'moment'
22
+ import CopySettingsSheet from './CopySettingsSheet.vue'
23
+ import notfound from '@/assets/images/not-found.png'
24
+
25
+
26
+ const { t } = useI18n()
27
+ const { showSuccess } = useSnackbarFunctions()
28
+ const { currentRestaurant } = useCoreStore()
29
+ const { isLoading, startAsyncCallWithErr } = useLoading()
30
+ const dialog = useDialog()
31
+
32
+ const DEFAULT_GUEST_MESSAGE = `Please take note of the following important details before making a reservation:
33
+
34
+ 1. Dining Time Limit
35
+ - Our restaurant enforces a dining time limit of 1 hour and 30 minutes.
36
+
37
+ 2. 15-Minute Holding Policy
38
+ - We can hold your reservation for a maximum of 15 minutes. If we are unable to reach you after this time, your table may be released without prior notice. If you anticipate being late or need to cancel, please call us to let us know so we can either hold your table or offer it to other guests.`
39
+
40
+ const DEFAULT_CANCELLATION_POLICY = `Cancellation Policy
41
+
42
+ Free cancellation up to 24 hours before your reservation. Please contact the outlet for any last-minute changes.`
43
+
44
+ const reservationSettings = ref<FdoOrderReservationSettingsV2>({
45
+ ranges: [
46
+ {
47
+ _id: '',
48
+ enable: false,
49
+ bookingDuration: 60,
50
+ enablePreorder: true,
51
+ minLeadDuration: {
52
+ value: 0,
53
+ unit: 'day'
54
+ },
55
+ maxLeadDuration: {
56
+ value: 30,
57
+ unit: 'day'
58
+ },
59
+ name: '',
60
+ operatingHours: {
61
+ 0: {
62
+ enable: false,
63
+ hours: []
64
+ },
65
+ 1: {
66
+ enable: false,
67
+ hours: []
68
+ },
69
+ 2: {
70
+ enable: false,
71
+ hours: []
72
+ },
73
+ 3: {
74
+ enable: false,
75
+ hours: []
76
+ },
77
+ 4: {
78
+ enable: false,
79
+ hours: []
80
+ },
81
+ 5: {
82
+ enable: false,
83
+ hours: []
84
+ },
85
+ 6: {
86
+ enable: false,
87
+ hours: []
88
+ }
89
+ },
90
+ preferences: [],
91
+ capacityTiers: [],
92
+ slotInterval: 30,
93
+ guestMessage: DEFAULT_GUEST_MESSAGE,
94
+ cancellationPolicy: DEFAULT_CANCELLATION_POLICY
95
+ }
96
+ ],
97
+ posCanOverbook: false,
98
+ draftHoldTimeMinutes: 15,
99
+ smsEnabled: true,
100
+ emailEnabled: true,
101
+ notifyOnConfirm: true,
102
+ notifyOnCancel: true
103
+ })
104
+
105
+ const rangeSetting = ref<FdoReservationRange>({
106
+ _id: '',
107
+ enable: true,
108
+ bookingDuration: 60,
109
+ enablePreorder: true,
110
+ minLeadDuration: {
111
+ value: 0,
112
+ unit: 'day'
113
+ },
114
+ maxLeadDuration: {
115
+ value: 30,
116
+ unit: 'day'
117
+ },
118
+ name: '',
119
+ operatingHours: {
120
+ 0: {
121
+ enable: true,
122
+ hours: []
123
+ },
124
+ 1: {
125
+ enable: true,
126
+ hours: [
127
+ { start: '09:00', end: '14:00' },
128
+ { start: '17:00', end: '22:00' }
129
+ ]
130
+ },
131
+ 2: {
132
+ enable: true,
133
+ hours: [
134
+ { start: '09:00', end: '14:00' },
135
+ { start: '17:00', end: '22:00' }
136
+ ]
137
+ },
138
+ 3: {
139
+ enable: true,
140
+ hours: [
141
+ { start: '09:00', end: '14:00' },
142
+ { start: '17:00', end: '22:00' }
143
+ ]
144
+ },
145
+ 4: {
146
+ enable: true,
147
+ hours: [
148
+ { start: '09:00', end: '14:00' },
149
+ { start: '17:00', end: '22:00' }
150
+ ]
151
+ },
152
+ 5: {
153
+ enable: true,
154
+ hours: [
155
+ { start: '09:00', end: '14:00' },
156
+ { start: '17:00', end: '22:00' }
157
+ ]
158
+ },
159
+ 6: {
160
+ enable: true,
161
+ hours: []
162
+ }
163
+ },
164
+ preferences: [],
165
+ capacityTiers: [
166
+ {
167
+ _id: '1',
168
+ minPax: 1,
169
+ maxPax: 2,
170
+ capacity: 10
171
+ },
172
+ {
173
+ _id: '2',
174
+ minPax: 3,
175
+ maxPax: 4,
176
+ capacity: 8
177
+ },
178
+ {
179
+ _id: '3',
180
+ minPax: 5,
181
+ maxPax: 6,
182
+ capacity: 5
183
+ },
184
+ {
185
+ _id: '4',
186
+ minPax: 7,
187
+ maxPax: null,
188
+ capacity: 3
189
+ }
190
+ ],
191
+ slotInterval: 30,
192
+ guestMessage: DEFAULT_GUEST_MESSAGE,
193
+ cancellationPolicy: DEFAULT_CANCELLATION_POLICY
194
+ })
195
+
196
+ const isSaving = ref(false)
197
+
198
+ const optionText = ref('')
199
+ const selectedPreviewDay = ref(1) // Monday by default
200
+ const editingOption = ref<{ prefIndex: number; optIndex: number } | null>(null)
201
+
202
+ // Track if user explicitly selected custom mode to prevent auto-switching to preset
203
+ const isCustomMode = ref(false)
204
+
205
+ // Track expanded state for each time segment
206
+ const expandedSegments = ref({
207
+ morning: false,
208
+ afternoon: false,
209
+ evening: false
210
+ })
211
+
212
+ // Sync rangeSetting with the first range in reservationSettings
213
+ // This ensures rangeSetting always reflects the first range (for future multi-range support)
214
+ watch(
215
+ () => reservationSettings.value.ranges[0],
216
+ (firstRange) => {
217
+ if (firstRange) {
218
+ rangeSetting.value = firstRange
219
+ }
220
+ },
221
+ { deep: true, immediate: true }
222
+ )
223
+
224
+ // Also sync changes from rangeSetting back to the first range
225
+ watch(
226
+ () => rangeSetting.value,
227
+ (newRangeSetting) => {
228
+ if (reservationSettings.value.ranges[0]) {
229
+ reservationSettings.value.ranges[0] = newRangeSetting
230
+ } else {
231
+ reservationSettings.value.ranges.push(newRangeSetting)
232
+ }
233
+ },
234
+ { deep: true }
235
+ )
236
+
237
+ // Computed property for preset selection
238
+ const selectedPreset = computed(() => {
239
+ // If user explicitly chose custom mode, stay in custom regardless of value
240
+ if (isCustomMode.value) {
241
+ return 'custom'
242
+ }
243
+
244
+ // Otherwise, check if value matches a preset
245
+ if (
246
+ rangeSetting.value.maxLeadDuration.unit === 'day' &&
247
+ (rangeSetting.value.maxLeadDuration.value === 30 ||
248
+ rangeSetting.value.maxLeadDuration.value === 60 ||
249
+ rangeSetting.value.maxLeadDuration.value === 90)
250
+ ) {
251
+ return rangeSetting.value.maxLeadDuration.value
252
+ }
253
+ return 'custom'
254
+ })
255
+
256
+ // Generate time slots based on operating hours and slot interval
257
+ // Now includes unavailable slots (between operating hour gaps)
258
+ const availableSlots = computed(() => {
259
+ const day = selectedPreviewDay.value as 0 | 1 | 2 | 3 | 4 | 5 | 6
260
+ const { operatingHours, slotInterval, bookingDuration } = rangeSetting.value
261
+
262
+ if (!operatingHours[day]?.enable || !operatingHours[day].hours.length) {
263
+ return { morning: [], afternoon: [], evening: [] }
264
+ }
265
+
266
+ // Use shared utility to generate slots with availability status
267
+ // This generates slots only from earliest to latest operating hour (not full 24h)
268
+ // Example: If hours are 10:00-12:00, 15:00-21:00, shows 10:00-21:00 range
269
+ const slotsWithStatus = generateDayTimeSlotsWithStatus(
270
+ operatingHours[day],
271
+ slotInterval,
272
+ bookingDuration
273
+ )
274
+
275
+ // Categorize slots by time of day
276
+ return categorizeTimeSlotsWithStatus(slotsWithStatus)
277
+ })
278
+
279
+ // Computed properties for displaying limited slots (max 2 rows = 10 items per segment)
280
+ const displayedSlots = computed(() => {
281
+ const ITEMS_PER_ROW = 5
282
+ const MAX_ROWS = 2
283
+ const MAX_ITEMS = ITEMS_PER_ROW * MAX_ROWS // 10 items
284
+
285
+ const limitSlots = (slots: any[], isExpanded: boolean) => {
286
+ if (isExpanded || slots.length <= MAX_ITEMS) {
287
+ return slots
288
+ }
289
+ // Show MAX_ITEMS - 1 slots, then replace the last one with "more" indicator
290
+ return slots.slice(0, MAX_ITEMS - 1)
291
+ }
292
+
293
+ return {
294
+ morning: limitSlots(availableSlots.value.morning, expandedSegments.value.morning),
295
+ afternoon: limitSlots(availableSlots.value.afternoon, expandedSegments.value.afternoon),
296
+ evening: limitSlots(availableSlots.value.evening, expandedSegments.value.evening)
297
+ }
298
+ })
299
+
300
+ // Check if segment has more items than can be displayed (needs "more" button)
301
+ const hasMoreItems = computed(() => {
302
+ const MAX_ITEMS = 10
303
+ return {
304
+ morning: availableSlots.value.morning.length > MAX_ITEMS,
305
+ afternoon: availableSlots.value.afternoon.length > MAX_ITEMS,
306
+ evening: availableSlots.value.evening.length > MAX_ITEMS
307
+ }
308
+ })
309
+
310
+ const reservationPreview = computed(() => {
311
+ const today = moment()
312
+ const { minLeadDuration, maxLeadDuration, operatingHours } = rangeSetting.value
313
+ const startDate = today.clone().add(minLeadDuration.value, minLeadDuration.unit).startOf('d')
314
+ const endDate = today.clone().add(maxLeadDuration.value, maxLeadDuration.unit).startOf('d')
315
+
316
+ type Day = keyof typeof operatingHours
317
+
318
+ // Get the hours for start and end days
319
+ const startDayHours = operatingHours[startDate.day() as Day].hours
320
+ const endDayHours = operatingHours[endDate.day() as Day].hours
321
+
322
+ // Check if hours exist for both days
323
+ if (!startDayHours || startDayHours.length === 0 || !endDayHours || endDayHours.length === 0) {
324
+ return {
325
+ start: startDate.format('DD MMM YYYY'),
326
+ end: endDate.format('DD MMM YYYY'),
327
+ startHour: '--:--',
328
+ endHour: '--:--'
329
+ }
330
+ }
331
+
332
+ // use operatingHours and get the earlier starting hour to return a start&end for the preview, return the formatted time
333
+ const startHour = [...startDayHours].sort((a, b) =>
334
+ moment(a.start, 'HH:mm').diff(moment(b.start, 'HH:mm'))
335
+ )[0]
336
+ const endHour = [...endDayHours].sort((a, b) =>
337
+ moment(b.end, 'HH:mm').diff(moment(a.end, 'HH:mm'))
338
+ )[0]
339
+
340
+ return {
341
+ start: startDate.format('DD MMM YYYY'),
342
+ end: endDate.format('DD MMM YYYY'),
343
+ startHour: moment(startHour.start, 'HH:mm').format('hh:mm A'),
344
+ endHour: moment(endHour.end, 'HH:mm').format('hh:mm A')
345
+ }
346
+ })
347
+
348
+ onMounted(() => {
349
+ init()
350
+ })
351
+
352
+ watch(
353
+ () => currentRestaurant.value,
354
+ async (v) => {
355
+ await init()
356
+ // Only use restaurant's operating hours as fallback if no settings from backend
357
+ // or if ranges is empty (new setup)
358
+ if (
359
+ v &&
360
+ v.profile.operatingHours &&
361
+ (!reservationSettings.value.ranges || reservationSettings.value.ranges.length === 0)
362
+ ) {
363
+ console.log('Using restaurant operating hours as fallback:', v.profile.operatingHours)
364
+ rangeSetting.value.operatingHours = v.profile.operatingHours
365
+ }
366
+ },
367
+ { immediate: true }
368
+ )
369
+
370
+ async function readReservationSetting(): Promise<FdoOrderReservationSettingsV2> {
371
+ return await startAsyncCallWithErr(async () => {
372
+ if (!currentRestaurant.value?._id) {
373
+ throw new Error('No restaurant selected')
374
+ }
375
+ return await ReservationApi.getReservationSetting(currentRestaurant.value._id)
376
+ })
377
+ }
378
+
379
+ async function updateReservationSetting() {
380
+ // Check for lead duration validation errors
381
+ const leadDurationError = validateLeadDurationLocal()
382
+ if (leadDurationError) {
383
+ dialog.open({
384
+ title: t('order.validationError'),
385
+ message: leadDurationError,
386
+ primaryActions: { text: t('order.ok'), close: true }
387
+ })
388
+ return
389
+ }
390
+
391
+ // Check for time range validation errors
392
+ if (hasAnyTimeRangeErrors()) {
393
+ dialog.open({
394
+ title: t('order.validationError'),
395
+ message: 'Please fix all time range errors before saving.',
396
+ primaryActions: { text: t('order.ok'), close: true }
397
+ })
398
+ return
399
+ }
400
+
401
+ isSaving.value = true
402
+ try {
403
+ await startAsyncCallWithErr(async () => {
404
+ if (!currentRestaurant.value?._id) {
405
+ throw new Error('No restaurant selected')
406
+ }
407
+
408
+ // Validate with Zod schema
409
+ const validated = ReservationSettingsSchema.parse(reservationSettings.value)
410
+ console.log(validated);
411
+ await ReservationApi.updateReservationSetting(currentRestaurant.value._id, validated)
412
+ await init()
413
+ })
414
+ showSuccess(t('order.settingUpdated'))
415
+ } finally {
416
+ isSaving.value = false
417
+ }
418
+ }
419
+
420
+ async function init() {
421
+ if (currentRestaurant.value) {
422
+ const settings = await readReservationSetting()
423
+ reservationSettings.value = {
424
+ ranges: settings.ranges || [],
425
+ posCanOverbook: settings.posCanOverbook || false,
426
+ draftHoldTimeMinutes: settings.draftHoldTimeMinutes || 15,
427
+ smsEnabled: settings.smsEnabled ?? true,
428
+ emailEnabled: settings.emailEnabled ?? true,
429
+ notifyOnConfirm: settings.notifyOnConfirm ?? true,
430
+ notifyOnCancel: settings.notifyOnCancel ?? true
431
+ }
432
+ // Sync rangeSetting with the first range if it exists
433
+ if (settings.ranges && settings.ranges.length > 0) {
434
+ rangeSetting.value = settings.ranges[0]
435
+ }
436
+
437
+ // Reset custom mode - let the computed property determine the initial state
438
+ isCustomMode.value = false
439
+ }
440
+ }
441
+
442
+ function updateSetting<K extends keyof FdoOrderReservationSettingsV2>(
443
+ key: K,
444
+ value: FdoOrderReservationSettingsV2[K]
445
+ ) {
446
+ reservationSettings.value[key] = value
447
+ }
448
+
449
+ function formatOperatingHours(windows: Array<{ start: string; end: string }>) {
450
+ if (windows.length === 0) return t('order.noOperatingHours')
451
+ if (windows.length === 1) return `${windows[0].start} - ${windows[0].end}`
452
+ return `${windows[0].start} - ${windows[0].end} (+${windows.length - 1})`
453
+ }
454
+
455
+ function updateMaxLeadPreset(value: number | 'custom') {
456
+ if (value === 'custom') {
457
+ // User explicitly selected custom mode
458
+ isCustomMode.value = true
459
+ // Switch to custom mode - set to a non-preset value to trigger custom UI
460
+ // Keep the current value if it's already custom, otherwise set to 1 day as starting point
461
+ if (
462
+ rangeSetting.value.maxLeadDuration.unit !== 'day' ||
463
+ ![30, 60, 90].includes(rangeSetting.value.maxLeadDuration.value)
464
+ ) {
465
+ // Already custom, don't change
466
+ return
467
+ }
468
+ // Set to 1 day as default custom value
469
+ rangeSetting.value.maxLeadDuration = {
470
+ value: 1,
471
+ unit: 'day'
472
+ }
473
+ return
474
+ }
475
+ // User selected a preset - exit custom mode
476
+ isCustomMode.value = false
477
+ rangeSetting.value.maxLeadDuration = {
478
+ value,
479
+ unit: 'day'
480
+ }
481
+ }
482
+
483
+ function updateMinLeadValue(value: number) {
484
+ rangeSetting.value.minLeadDuration.value = value
485
+ }
486
+
487
+ function updateMinLeadUnit(unit: 'hour' | 'day') {
488
+ rangeSetting.value.minLeadDuration.unit = unit
489
+ }
490
+
491
+ function updateMaxLeadValue(value: number) {
492
+ rangeSetting.value.maxLeadDuration.value = value
493
+ }
494
+
495
+ function updateMaxLeadUnit(unit: 'hour' | 'day') {
496
+ rangeSetting.value.maxLeadDuration.unit = unit
497
+ }
498
+
499
+ function updateSlotInterval(value: number) {
500
+ rangeSetting.value.slotInterval = value
501
+ }
502
+
503
+ function updateBookingDuration(value: number) {
504
+ rangeSetting.value.bookingDuration = value
505
+ }
506
+
507
+ function addPreferenceOption(
508
+ preference: (typeof rangeSetting.value.preferences)[number],
509
+ value?: string
510
+ ) {
511
+ if (value || optionText.value.trim()) {
512
+ if (!preference.options) {
513
+ preference.options = []
514
+ }
515
+ preference.options.push(value ?? optionText.value.trim())
516
+ optionText.value = ''
517
+ }
518
+ }
519
+
520
+ function removePreferenceOption(
521
+ preference: (typeof rangeSetting.value.preferences)[number],
522
+ option: string
523
+ ) {
524
+ if (preference.options) {
525
+ const index = preference.options.indexOf(option)
526
+ if (index > -1) {
527
+ preference.options.splice(index, 1)
528
+ }
529
+ }
530
+ }
531
+
532
+ function addPreference() {
533
+ rangeSetting.value.preferences.push({
534
+ name: '',
535
+ type: 'radio',
536
+ options: ['Option 1']
537
+ })
538
+ }
539
+
540
+ function removePreference(index: number) {
541
+ rangeSetting.value.preferences.splice(index, 1)
542
+ }
543
+
544
+ function startEditingOption(prefIndex: number, optIndex: number) {
545
+ editingOption.value = { prefIndex, optIndex }
546
+ }
547
+
548
+ function saveOptionEdit(prefIndex: number, optIndex: number, newValue: string) {
549
+ if (newValue.trim()) {
550
+ rangeSetting.value.preferences[prefIndex].options[optIndex] = newValue.trim()
551
+ }
552
+ editingOption.value = null
553
+ }
554
+
555
+ function isEditingOption(prefIndex: number, optIndex: number): boolean {
556
+ return editingOption.value?.prefIndex === prefIndex && editingOption.value?.optIndex === optIndex
557
+ }
558
+
559
+ function toggleGuestMessage(enabled: boolean) {
560
+ if (enabled) {
561
+ rangeSetting.value.guestMessage = DEFAULT_GUEST_MESSAGE
562
+ } else {
563
+ rangeSetting.value.guestMessage = null
564
+ }
565
+ }
566
+
567
+ function toggleCancellationPolicy(enabled: boolean) {
568
+ if (enabled) {
569
+ rangeSetting.value.cancellationPolicy = DEFAULT_CANCELLATION_POLICY
570
+ } else {
571
+ rangeSetting.value.cancellationPolicy = null
572
+ }
573
+ }
574
+
575
+ function toggleSegmentExpansion(segment: 'morning' | 'afternoon' | 'evening') {
576
+ expandedSegments.value[segment] = !expandedSegments.value[segment]
577
+ }
578
+
579
+ // Validation for operating hours
580
+ function validateTimeRange(day: 0 | 1 | 2 | 3 | 4 | 5 | 6, hourIndex: number): string | null {
581
+ const dayHours = rangeSetting.value.operatingHours[day].hours
582
+ const range = dayHours[hourIndex]
583
+
584
+ // Validate same day time range (end > start)
585
+ if (!validateSameDayTimeRange(range)) {
586
+ return 'End time must be after start time'
587
+ }
588
+
589
+ // Check for overlaps with other time ranges
590
+ for (let i = 0; i < dayHours.length; i++) {
591
+ if (i === hourIndex) continue
592
+
593
+ // Use shared utility to check overlap
594
+ if (validateTimeRangeOverlap(range, dayHours[i])) {
595
+ return 'Time ranges cannot overlap'
596
+ }
597
+ }
598
+
599
+ return null
600
+ }
601
+
602
+ function getTimeRangeError<T>(day: T, hourIndex: number): string | null {
603
+ return validateTimeRange(day as 0 | 1 | 2 | 3 | 4 | 5 | 6, hourIndex)
604
+ }
605
+
606
+ function hasAnyTimeRangeErrors(): boolean {
607
+ for (let day = 0; day <= 6; day++) {
608
+ const dayHours = rangeSetting.value.operatingHours[day as 0 | 1 | 2 | 3 | 4 | 5 | 6].hours
609
+ for (let i = 0; i < dayHours.length; i++) {
610
+ if (validateTimeRange(day as 0 | 1 | 2 | 3 | 4 | 5 | 6, i)) {
611
+ return true
612
+ }
613
+ }
614
+ }
615
+ return false
616
+ }
617
+
618
+ function deleteTimeRange(day: 0 | 1 | 2 | 3 | 4 | 5 | 6, hourIndex: number) {
619
+ rangeSetting.value.operatingHours[day].hours.splice(hourIndex, 1)
620
+
621
+ // If no time ranges left, turn off the toggle
622
+ if (rangeSetting.value.operatingHours[day].hours.length === 0) {
623
+ rangeSetting.value.operatingHours[day].enable = false
624
+ }
625
+ }
626
+
627
+ // Validation for lead duration
628
+ function validateLeadDurationLocal(): string | null {
629
+ const { minLeadDuration, maxLeadDuration } = rangeSetting.value
630
+
631
+ // Use shared utility to validate
632
+ if (!validateLeadDuration(minLeadDuration, maxLeadDuration)) {
633
+ return 'Maximum lead time must be greater than minimum lead time'
634
+ }
635
+
636
+ if (minLeadDuration.value < 0 || maxLeadDuration.value < 0) {
637
+ return 'Lead time values must be positive'
638
+ }
639
+
640
+ return null
641
+ }
642
+
643
+ function convertToMinutes(value: number, unit: 'minute' | 'hour' | 'day'): number {
644
+ // This function is kept for backward compatibility but could be removed
645
+ // in favor of using convertDurationToMinutes directly
646
+ switch (unit) {
647
+ case 'minute':
648
+ return value
649
+ case 'hour':
650
+ return value * 60
651
+ case 'day':
652
+ return value * 60 * 24
653
+ default:
654
+ return value
655
+ }
656
+ }
657
+
658
+ function handleCopySettings(copiedSettings: Partial<FdoOrderReservationSettingsV2>) {
659
+ // Apply the copied settings to the current settings
660
+ if (copiedSettings.ranges && copiedSettings.ranges.length > 0) {
661
+ const copiedRange = copiedSettings.ranges[0]
662
+
663
+ // Update the first range in reservationSettings
664
+ if (reservationSettings.value.ranges && reservationSettings.value.ranges.length > 0) {
665
+ // Merge the copied range properties into the existing range
666
+ reservationSettings.value.ranges[0] = {
667
+ ...reservationSettings.value.ranges[0],
668
+ ...copiedRange
669
+ }
670
+
671
+ // This will automatically sync to rangeSetting via the watch
672
+ }
673
+ }
674
+
675
+ // Reset custom mode when copying settings - let computed property determine state
676
+ isCustomMode.value = false
677
+
678
+ showSuccess('Settings copied successfully. Please review and save changes.')
679
+ }
680
+ </script>
681
+
682
+ <template>
683
+ <RestaurantSelector />
684
+ <div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
685
+ <FmCircularProgress size="xxl" />
686
+ </div>
687
+ <div v-else class="p-[1.5rem] flex flex-col gap-24 w-full max-w-4xl">
688
+ <!-- <FmCard variant="outlined" class="p-5">
689
+ <div class="mb-5">
690
+ <FmSwitch
691
+ :model-value="reservationSettings.smsEnabled"
692
+ @update:model-value="(v: boolean) => updateSetting('smsEnabled', v)"
693
+ :label="t('order.smsEnabled')"
694
+ label-placement="right"
695
+ :sublabel="t('order.smsEnabledDescription')"
696
+ />
697
+ </div>
698
+ <div class="mb-5">
699
+ <FmSwitch
700
+ :model-value="reservationSettings.emailEnabled"
701
+ @update:model-value="(v: boolean) => updateSetting('emailEnabled', v)"
702
+ :label="t('order.emailEnabled')"
703
+ label-placement="right"
704
+ :sublabel="t('order.emailEnabledDescription')"
705
+ />
706
+ </div>
707
+ <div class="mb-5">
708
+ <FmSwitch
709
+ :model-value="reservationSettings.notifyOnConfirm"
710
+ @update:model-value="(v: boolean) => updateSetting('notifyOnConfirm', v)"
711
+ :label="t('order.notifyOnConfirm')"
712
+ label-placement="right"
713
+ :sublabel="t('order.notifyOnConfirmDescription')"
714
+ />
715
+ </div>
716
+ <div class="mb-5">
717
+ <FmSwitch
718
+ :model-value="reservationSettings.notifyOnCancel"
719
+ @update:model-value="(v: boolean) => updateSetting('notifyOnCancel', v)"
720
+ :label="t('order.notifyOnCancel')"
721
+ label-placement="right"
722
+ :sublabel="t('order.notifyOnCancelDescription')"
723
+ />
724
+ </div>
725
+ </FmCard> -->
726
+
727
+ <div>
728
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-16">
729
+ {{ t('order.reservationStatus') }}
730
+ </div>
731
+ <FmSwitch label-placement="right" :label="'Enable reservation'"
732
+ :sublabel="'Enable this to make the outlet available for reservations. This setting does not impact walk-in dining.'"
733
+ :model-value="rangeSetting.enable ?? false" @update:model-value="(v: boolean) => (rangeSetting.enable = v)" />
734
+
735
+ <div class="ml-56 my-8">
736
+ <CopySettingsSheet :current-settings="reservationSettings" @apply="handleCopySettings" />
737
+ </div>
738
+ </div>
739
+
740
+ <template v-if="rangeSetting.enable">
741
+ <!-- Notification Settings Section -->
742
+ <div class="flex flex-col">
743
+ <div class="flex-grow fm-typo-en-title-sm-600 my-8">
744
+ {{ t('order.notificationSettings') }}
745
+ </div>
746
+ <FmCard variant="outlined" class="p-5">
747
+ <div class="mb-5">
748
+ <FmTextField type="number" :model-value="reservationSettings.draftHoldTimeMinutes" @update:model-value="
749
+ (v: string | number) =>
750
+ updateSetting('draftHoldTimeMinutes', v === '' ? 15 : Number(v))
751
+ " :label="t('order.draftHoldTimeMinutes')" suffix="minutes" :hint="t('order.draftHoldTimeDescription')"
752
+ class="max-w-md" />
753
+ </div>
754
+ <div class="mb-5">
755
+ <FmSwitch :model-value="reservationSettings.posCanOverbook"
756
+ @update:model-value="(v: boolean) => updateSetting('posCanOverbook', v)"
757
+ :label="t('order.posCanOverbook')" label-placement="right"
758
+ :sublabel="t('order.posCanOverbookDescription')" />
759
+ </div>
760
+ </FmCard>
761
+ </div>
762
+
763
+ <div class="flex flex-col">
764
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">
765
+ {{ t('order.reservationAvailability') }}
766
+ </div>
767
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
768
+ This shows when guests can make reservations based on your current settings. The
769
+ availability updates automatically.
770
+ </div>
771
+ <!-- Availability settings (eg. the min/max lead, and time interval + booking duration) -->
772
+ <div class="mb-24">
773
+ <div class="mb-8">Preview</div>
774
+ <div class="flex items-center gap-4 mb-8">
775
+ <div
776
+ class="w-fit border-1 border-fm-color-neutral-gray-200 rounded-md py-4 px-8 bg-fm-color-neutral-gray-100 flex items-center gap-8 text-fm-color-typo-tertiary">
777
+ <FmIcon name="calendar_month" outline />
778
+ <div class="pr-6">
779
+ {{ reservationPreview.start + ' ' + reservationPreview.startHour }}
780
+ </div>
781
+ </div>
782
+ <div>to</div>
783
+ <div
784
+ class="w-fit border-1 border-fm-color-neutral-gray-200 rounded-md py-4 px-8 bg-fm-color-neutral-gray-100 flex items-center gap-8 text-fm-color-typo-tertiary">
785
+ <FmIcon name="calendar_month" outline />
786
+ <div class="pr-6">
787
+ {{ reservationPreview.end + ' ' + reservationPreview.endHour }}
788
+ </div>
789
+ </div>
790
+ </div>
791
+ <div class="fm-typo-en-body-md-400 text-fm-color-typo-secondary">
792
+ Reservations will open on
793
+ <strong> {{ reservationPreview.start + ', ' + reservationPreview.startHour }} </strong>.
794
+ Guests will not be able to make reservations before this time.
795
+ </div>
796
+ </div>
797
+
798
+ <div class="mb-24">
799
+ <div class="mb-8">Presets</div>
800
+ <FmRadioGroup :model-value="selectedPreset"
801
+ @update:model-value="(v: number | 'custom') => updateMaxLeadPreset(v)">
802
+ <FmRadio label="30 days" :value="30">
803
+ <template #label>
804
+ <div>30 days <span class="text-fm-color-typo-secondary">(default)</span></div>
805
+ </template>
806
+ </FmRadio>
807
+ <FmRadio label="60 days" :value="60" />
808
+ <FmRadio label="90 days" :value="90" />
809
+ <FmRadio label="Custom" :value="'custom'" />
810
+ </FmRadioGroup>
811
+
812
+ <!-- Custom preset fields -->
813
+ <div v-if="selectedPreset === 'custom'"
814
+ class="ml-32 mt-12 p-16 border rounded-md bg-fm-color-neutral-gray-50">
815
+ <div class="mb-16">
816
+ <div class="mb-8 fm-typo-en-body-md-600 flex items-center gap-8">
817
+ Minimum Lead Time
818
+ <FmTooltip
819
+ :content="'The earliest time a guest can make a reservation. For example, if you set this to 1 day, guests can only make reservations from tomorrow onwards.'">
820
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
821
+ </FmTooltip>
822
+ </div>
823
+ <div class="flex items-center gap-8">
824
+ <FmStepperField :model-value="rangeSetting.minLeadDuration.value"
825
+ @update:model-value="(v: number) => updateMinLeadValue(v)" :min="0" class="" />
826
+ <FmSelect :model-value="rangeSetting.minLeadDuration.unit"
827
+ @update:model-value="(v: 'hour' | 'day') => updateMinLeadUnit(v)" :items="[
828
+ { label: 'Hours', value: 'hour' },
829
+ { label: 'Days', value: 'day' }
830
+ ]" class="w-120" />
831
+ </div>
832
+ </div>
833
+
834
+ <div>
835
+ <div class="mb-8 fm-typo-en-body-md-600 flex items-center gap-8">
836
+ Maximum Lead Time
837
+ <FmTooltip
838
+ :content="'The furthest in advance a guest can make a reservation. For example, if you set this to 30 days, guests can only make reservations up to 30 days from today.'">
839
+ <FmIcon name="info" outline size="sm" class="cursor-pointer" />
840
+ </FmTooltip>
841
+ </div>
842
+ <div class="flex items-center gap-8">
843
+ <FmStepperField :model-value="rangeSetting.maxLeadDuration.value"
844
+ @update:model-value="(v: number) => updateMaxLeadValue(v)" :min="0" class="" />
845
+ <FmSelect :model-value="rangeSetting.maxLeadDuration.unit"
846
+ @update:model-value="(v: 'hour' | 'day') => updateMaxLeadUnit(v)" :items="[
847
+ { label: 'Hours', value: 'hour' },
848
+ { label: 'Days', value: 'day' }
849
+ ]" class="w-120" />
850
+ </div>
851
+ </div>
852
+
853
+ <!-- Validation error message -->
854
+ <div v-if="validateLeadDurationLocal()" class="mt-16 text-sm text-red-600">
855
+ {{ validateLeadDurationLocal() }}
856
+ </div>
857
+ </div>
858
+ </div>
859
+ </div>
860
+
861
+ <div class="flex flex-col">
862
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">
863
+ {{ t('order.operatingWindows') }}
864
+ </div>
865
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-12">
866
+ Set the hours your outlet is open for business. Reservations and walk-ins outside these
867
+ hours will not be allowed.
868
+ </div>
869
+
870
+ <!-- Operating hour setting -->
871
+ <div class="mb-32">
872
+ <div class="grid grid-cols-[1fr_1fr_3fr] items-start">
873
+ <div class="p-12 fm-typo-en-body-md-600 text-fm-color-typo-secondary bg-fm-color-neutral-gray-100">
874
+ Day
875
+ </div>
876
+ <div class="p-12 fm-typo-en-body-md-600 text-fm-color-typo-secondary bg-fm-color-neutral-gray-100">
877
+ Open / Closed
878
+ </div>
879
+ <div class="p-12 fm-typo-en-body-md-600 text-fm-color-typo-secondary bg-fm-color-neutral-gray-100">
880
+ Hours
881
+ </div>
882
+ <template v-for="day in [1, 2, 3, 4, 5, 6, 0] as const" :key="day">
883
+ <div class="fm-typo-en-body-md-400 py-12 px-12">
884
+ {{ moment().day(day).format('dddd') }}
885
+ </div>
886
+ <template v-if="rangeSetting.operatingHours[day as 0 | 1 | 2 | 3 | 4 | 5 | 6]">
887
+ <template v-for="hours in [rangeSetting.operatingHours[day as 0 | 1 | 2 | 3 | 4 | 5 | 6]]"
888
+ :key="`hours-${day}`">
889
+ <div class="px-12 py-8">
890
+ <FmSwitch label-placement="right" :model-value="hours.enable" @update:model-value="
891
+ (v) => {
892
+ rangeSetting.operatingHours[day].enable = v
893
+ rangeSetting.operatingHours[day].hours = v
894
+ ? [
895
+ {
896
+ start: '00:00',
897
+ end: '23:59'
898
+ }
899
+ ]
900
+ : []
901
+ }
902
+ " :label="hours.enable ? 'Open' : 'Closed'" />
903
+ </div>
904
+ <div class="px-12 self-center">
905
+ <div v-if="!hours.enable" class="">-</div>
906
+ <div v-else class="flex flex-col">
907
+ <div v-for="(hour, hi) in hours.hours" :key="hi" class="flex flex-col gap-4">
908
+ <div class="flex gap-4 items-center justify-between">
909
+ <div class="flex gap-12 items-center flex-1 justify-between py-8">
910
+ <CustomTimePicker v-model="hour.start" :min-time="hours.hours[hi - 1]?.end ?? '00:00'"
911
+ class="grow" />
912
+ <div class="text-center w-16 flex-shrink-0">to</div>
913
+ <CustomTimePicker v-model="hour.end" :min-time="hour.start" class="grow" />
914
+ </div>
915
+ <div class="mr-8 w-32 flex-shrink">
916
+ <FmButton variant="plain" icon="add" v-if="hi == 0" :disabled="hours.hours.length >= 2"
917
+ @click="hours.hours.push({ start: hour.end, end: '23:59' })" />
918
+ <FmButton variant="plain" icon="delete" v-if="hi > 0" @click="
919
+ deleteTimeRange(day as unknown as 0 | 1 | 2 | 3 | 4 | 5 | 6, hi)
920
+ " />
921
+ </div>
922
+ </div>
923
+ <div v-if="getTimeRangeError(day, hi)" class="text-sm text-red-600 ml-4">
924
+ {{ getTimeRangeError(day, hi) }}
925
+ </div>
926
+ </div>
927
+ </div>
928
+ </div>
929
+ </template>
930
+ </template>
931
+ </template>
932
+ </div>
933
+ </div>
934
+
935
+ <FmCard variant="outlined" class="grid grid-cols-[1fr_2fr]">
936
+ <div class="border-r py-[24px] px-16">
937
+ <div class="mb-24">
938
+ <div class="mb-8 fm-typo-en-body-lg-600">Time Interval</div>
939
+ <FmRadioGroup :model-value="rangeSetting.slotInterval"
940
+ @update:model-value="(v: number) => updateSlotInterval(v)">
941
+ <FmRadio label="15 min" :value="15" />
942
+ <FmRadio label="30 min (default)" :value="30">
943
+ <template #label>30 min <span class="text-fm-color-typo-secondary">(default)</span></template>
944
+ </FmRadio>
945
+ <FmRadio label="60 min" :value="60" />
946
+ </FmRadioGroup>
947
+ </div>
948
+
949
+ <div class="mb-24">
950
+ <div class="mb-8 fm-typo-en-body-lg-600">Dining Duration</div>
951
+ <FmRadioGroup :model-value="rangeSetting.bookingDuration"
952
+ @update:model-value="(v: number) => updateBookingDuration(v)">
953
+ <FmRadio label="60 min" :value="60" />
954
+ <FmRadio label="90 min" :value="90">
955
+ <template #label>90 min <span class="text-fm-color-typo-secondary">(default)</span></template>
956
+ </FmRadio>
957
+
958
+ <FmRadio label="120 min" :value="120" />
959
+ </FmRadioGroup>
960
+ </div>
961
+ </div>
962
+
963
+ <div class="p-16 flex flex-col gap-16">
964
+ <div class="flex items-center justify-between w-full">
965
+ <div class="fm-typo-en-body-lg-600">Available reservation slots (preview)</div>
966
+ <FmSelect v-model="selectedPreviewDay" :items="[
967
+ { label: 'Monday', value: 1 },
968
+ { label: 'Tuesday', value: 2 },
969
+ { label: 'Wednesday', value: 3 },
970
+ { label: 'Thursday', value: 4 },
971
+ { label: 'Friday', value: 5 },
972
+ { label: 'Saturday', value: 6 },
973
+ { label: 'Sunday', value: 0 }
974
+ ]" />
975
+ </div>
976
+
977
+ <template v-if="availableSlots.morning.length > 0">
978
+ <div class="fm-typo-en-body-md-600">Morning</div>
979
+ <div class="grid grid-cols-5 gap-8">
980
+ <div v-for="slot in displayedSlots.morning" :key="slot.time"
981
+ class="border-1 rounded-md text-center p-8 transition-colors" :class="{
982
+ 'bg-[#fafafa] text-fm-color-typo-primary': slot.available,
983
+ 'bg-[#f0f0f0] text-fm-color-typo-disabled border-dashed': !slot.available
984
+ }">
985
+ {{ slot.time }}
986
+ </div>
987
+ <div v-if="hasMoreItems.morning && !expandedSegments.morning"
988
+ class="border-2 rounded-md text-center p-8 transition-colors cursor-pointer border-fm-color-primary text-fm-color-primary"
989
+ @click="toggleSegmentExpansion('morning')">
990
+ more
991
+ </div>
992
+ </div>
993
+ </template>
994
+
995
+ <template v-if="availableSlots.afternoon.length > 0">
996
+ <div class="fm-typo-en-body-md-600">Afternoon</div>
997
+ <div class="grid grid-cols-5 gap-8">
998
+ <div v-for="slot in displayedSlots.afternoon" :key="slot.time"
999
+ class="border-1 rounded-md text-center p-8 transition-colors" :class="{
1000
+ 'bg-[#fafafa] text-fm-color-typo-primary': slot.available,
1001
+ 'bg-[#f0f0f0] text-fm-color-typo-disabled border-dashed': !slot.available
1002
+ }">
1003
+ {{ slot.time }}
1004
+ </div>
1005
+ <div v-if="hasMoreItems.afternoon && !expandedSegments.afternoon"
1006
+ class="border-2 rounded-md text-center p-8 transition-colors cursor-pointer border-fm-color-primary text-fm-color-primary"
1007
+ @click="toggleSegmentExpansion('afternoon')">
1008
+ more
1009
+ </div>
1010
+ </div>
1011
+ </template>
1012
+
1013
+ <template v-if="availableSlots.evening.length > 0">
1014
+ <div class="fm-typo-en-body-md-600">Evening</div>
1015
+ <div class="grid grid-cols-5 gap-8">
1016
+ <div v-for="slot in displayedSlots.evening" :key="slot.time"
1017
+ class="border-1 rounded-md text-center p-8 transition-colors" :class="{
1018
+ 'bg-[#fafafa] text-fm-color-typo-primary': slot.available,
1019
+ 'bg-[#f0f0f0] text-fm-color-typo-disabled border-dashed': !slot.available
1020
+ }">
1021
+ {{ slot.time }}
1022
+ </div>
1023
+ <div v-if="hasMoreItems.evening && !expandedSegments.evening"
1024
+ class="border-2 rounded-md text-center p-8 transition-colors cursor-pointer border-fm-color-primary text-fm-color-primary"
1025
+ @click="toggleSegmentExpansion('evening')">
1026
+ more
1027
+ </div>
1028
+ </div>
1029
+ </template>
1030
+
1031
+ <div v-if="
1032
+ !rangeSetting.operatingHours[selectedPreviewDay as 0 | 1 | 2 | 3 | 4 | 5 | 6]
1033
+ ?.enable
1034
+ " class="text-center text-fm-color-typo-secondary flex flex-col items-center gap-16 p-24">
1035
+ <img :src="notfound" class="aspect-square w-[150px]" />
1036
+ Uh-oh! This outlet is closed on the selected day. <br>
1037
+ Please select another day to view available time slots.
1038
+ </div>
1039
+ </div>
1040
+ </FmCard>
1041
+ </div>
1042
+
1043
+ <div class="flex flex-col">
1044
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">
1045
+ {{ t('order.capacity') }}
1046
+ </div>
1047
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1048
+ Set the maximum number of guests allowed for each time slot.
1049
+ </div>
1050
+
1051
+ <!-- Table Capacity Settings -->
1052
+ <div>
1053
+ <div class="grid grid-cols-[4fr_4fr_4fr_1fr]">
1054
+ <div class="bg-fm-color-neutral-gray-100 p-12">Table Type</div>
1055
+ <div class="bg-fm-color-neutral-gray-100 p-12">Guest Range</div>
1056
+ <div class="bg-fm-color-neutral-gray-100 p-12">Available Table Count</div>
1057
+ <div class="bg-fm-color-neutral-gray-100 p-12"></div>
1058
+ </div>
1059
+ <div class="grid grid-cols-[4fr_4fr_4fr_1fr] items-center gap-8" v-for="tier in rangeSetting.capacityTiers"
1060
+ :key="tier._id">
1061
+ <div class="p-8">{{ tier.minPax }}{{ tier.maxPax ? `-${tier.maxPax}` : '+' }} pax</div>
1062
+ <div class="flex items-center gap-4">
1063
+ <FmStepperField v-model="tier.minPax" />
1064
+ <div>to</div>
1065
+ <FmStepperField :model-value="tier.maxPax ?? null" @update:model-value="(v) => (tier.maxPax = v)" />
1066
+ </div>
1067
+ <div class="flex items-center gap-8">
1068
+ <FmButton variant="tertiary" icon="remove" @click="tier.capacity--" :disabled="tier.capacity <= 1" />
1069
+ <div class="w-32 text-center">{{ tier.capacity }}</div>
1070
+ <FmButton variant="tertiary" icon="add" @click="tier.capacity++" />
1071
+
1072
+ <!-- <FmStepperField :model-value="tier.capacity ?? null" @update:model-value="(v) => (tier.capacity = v)" /> -->
1073
+ </div>
1074
+ <FmButton icon="delete" variant="plain" @click="
1075
+ rangeSetting.capacityTiers.splice(rangeSetting.capacityTiers.indexOf(tier), 1)
1076
+ " />
1077
+ </div>
1078
+ <div>
1079
+ <FmButton label="Add table type" icon="add" variant="plain" @click="
1080
+ rangeSetting.capacityTiers.push({
1081
+ _id: new Date().toISOString(),
1082
+ minPax: ([...rangeSetting.capacityTiers].pop()?.maxPax ?? 0) + 1,
1083
+ maxPax: null,
1084
+ capacity: 0
1085
+ })
1086
+ " />
1087
+ </div>
1088
+ </div>
1089
+ </div>
1090
+
1091
+ <div>
1092
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-16">Preorder</div>
1093
+ <FmSwitch label-placement="right" :label="'Enable preorder'"
1094
+ :sublabel="'Enable this to allow guests to place preorders when making a reservation. This allows them to order food in advance.'"
1095
+ :model-value="rangeSetting.enablePreorder"
1096
+ @update:model-value="(v: boolean) => (rangeSetting.enablePreorder = v)" />
1097
+ </div>
1098
+
1099
+ <div class="flex flex-col">
1100
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Guest Preferences</div>
1101
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1102
+ Allow guests to select preferences during reservation, such as seating preferences,
1103
+ allergies, or other notes.
1104
+ </div>
1105
+
1106
+ <FmSwitch class="mb-8" label="Enable preferences" label-placement="right"
1107
+ :model-value="rangeSetting.preferences.length > 0" @update:model-value="
1108
+ (v) => {
1109
+ if (v == true) {
1110
+ rangeSetting.preferences = [
1111
+ {
1112
+ name: '',
1113
+ type: 'radio',
1114
+ options: ['Option 1']
1115
+ }
1116
+ ]
1117
+ } else {
1118
+ rangeSetting.preferences = []
1119
+ }
1120
+ }
1121
+ " />
1122
+
1123
+ <FmCard v-for="(preference, pIndex) in rangeSetting.preferences" class="p-16 mb-12" variant="outlined"
1124
+ :key="pIndex">
1125
+ <div class="fm-typo-en-body-lg-600 mb-8">Category title</div>
1126
+ <div class="grid grid-cols-[6fr_3fr_1fr] items-start gap-24 mb-24">
1127
+ <div>
1128
+ <FmTextField v-model="preference.name" />
1129
+ <div class="fm-typo-en-body-md-400 text-fm-color-typo-tertiary mt-8">
1130
+ This title will be shown to guests during reservation.
1131
+ </div>
1132
+ </div>
1133
+ <CustomSelect v-model="preference.type" :items="[
1134
+ { label: 'Single choice', value: 'radio', icon: 'radio' },
1135
+ { label: 'Multiple choice', value: 'checkbox', icon: 'checkbox' }
1136
+ ]" />
1137
+ <FmButton variant="tertiary" icon="delete" @click="removePreference(pIndex)" />
1138
+ </div>
1139
+
1140
+ <div v-if="preference.type == 'checkbox' || preference.type == 'radio'">
1141
+ <div class="mb-8 fm-typo-en-body-md-600">Options</div>
1142
+ <div v-for="(option, oIndex) in preference.options" :key="oIndex"
1143
+ class="flex items-center gap-4 gap-y-8 mb-8">
1144
+ <div class="flex items-center w-full">
1145
+ <FmCheckbox v-if="preference.type == 'checkbox'" disabled :value="option" :model-value="false" readonly
1146
+ class="mr-8" />
1147
+ <FmRadio v-if="preference.type == 'radio'" disabled :value="option" :model-value="false" readonly />
1148
+
1149
+ <!-- Editable option label -->
1150
+ <input v-if="isEditingOption(pIndex, oIndex)" type="text"
1151
+ class="flex-1 outline-none border-b-2 border-fm-color-primary px-4 py-2" :value="option" @blur="
1152
+ (e) => saveOptionEdit(pIndex, oIndex, (e.target as HTMLInputElement).value)
1153
+ " @keyup.enter="
1154
+ (e) => saveOptionEdit(pIndex, oIndex, (e.target as HTMLInputElement).value)
1155
+ " ref="optionInput" />
1156
+ <div v-else-if="option != 'Other'"
1157
+ class="flex-1 cursor-pointer hover:bg-fm-color-neutral-gray-100 px-4 py-2 rounded transition-colors"
1158
+ @click="startEditingOption(pIndex, oIndex)" title="Click to edit">
1159
+ {{ option }}
1160
+ </div>
1161
+ <div v-else-if="option == 'Other'" class="flex-1 px-4 py-2 border-b border-fm-color-neutral-gray-200">
1162
+ Other:
1163
+ </div>
1164
+ </div>
1165
+ <FmButton variant="plain" icon="close" :class="{ 'opacity-0 pointer-events-nonex': oIndex == 0 }"
1166
+ @click="oIndex != 0 && removePreferenceOption(preference, option)" />
1167
+ </div>
1168
+ <div class="flex items-center">
1169
+ <FmCheckbox v-if="preference.type == 'checkbox'" disabled :value="null" :model-value="false" readonly />
1170
+ <div v-if="preference.type == 'radio'" class="flex items-center">
1171
+ <FmRadio disabled :value="null" :model-value="false" readonly />
1172
+ </div>
1173
+ <div class="flex items-center w-full">
1174
+ <FmButton variant="tertiary" class="text-fm-color-typo-secondary" label="add option" @click="
1175
+ addPreferenceOption(preference, `Option ${preference.options.length + 1}`)
1176
+ ">
1177
+ <template #default>
1178
+ <div>Add option</div>
1179
+ </template>
1180
+ </FmButton>
1181
+ <template v-if="!preference.options.includes('Other')">
1182
+ <div>or</div>
1183
+ <FmButton variant="plain" label='add "Other"' @click="addPreferenceOption(preference, 'Other')" />
1184
+ </template>
1185
+ </div>
1186
+ </div>
1187
+ </div>
1188
+ </FmCard>
1189
+
1190
+ <FmCard variant="outlined" class="border-dashed p-16" v-if="rangeSetting.preferences.length">
1191
+ <FmButton label="Add another preference category" icon="add" variant="plain"
1192
+ class="border-1 border-fm-color-primary rounded-lg" @click="addPreference" />
1193
+ </FmCard>
1194
+ </div>
1195
+
1196
+ <div class="flex flex-col">
1197
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Guest Message</div>
1198
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1199
+ Add important information or notes guests should know before their visit.
1200
+ </div>
1201
+
1202
+ <FmSwitch class="mb-16" label="Enable guest message" label-placement="right" :model-value="rangeSetting.guestMessage !== null && rangeSetting.guestMessage !== undefined
1203
+ " @update:model-value="toggleGuestMessage" />
1204
+
1205
+ <div v-if="rangeSetting.guestMessage !== null && rangeSetting.guestMessage !== undefined">
1206
+ <div class="mb-8 fm-typo-en-body-md-600">Message</div>
1207
+ <FmTextarea v-model="rangeSetting.guestMessage" :maxLength="600"
1208
+ placeholder="Please take note of the following important details before making a reservation:"
1209
+ class="mb-4" />
1210
+ <div class="text-right text-fm-color-typo-tertiary fm-typo-en-body-sm-400">
1211
+ {{ rangeSetting.guestMessage?.length || 0 }} / 600 characters
1212
+ </div>
1213
+ </div>
1214
+ </div>
1215
+
1216
+ <div class="flex flex-col">
1217
+ <div class="flex-grow fm-typo-en-title-sm-600 mb-4">Cancellation Policy</div>
1218
+ <div class="fm-typo-en-body-lg-400 text-fm-color-typo-secondary mb-24">
1219
+ Set the rules guests should follow when cancelling a reservation.
1220
+ </div>
1221
+
1222
+ <FmSwitch class="mb-16" label="Enable cancellation policy" label-placement="right" :model-value="rangeSetting.cancellationPolicy !== null &&
1223
+ rangeSetting.cancellationPolicy !== undefined
1224
+ " @update:model-value="toggleCancellationPolicy" />
1225
+
1226
+ <div v-if="
1227
+ rangeSetting.cancellationPolicy !== null &&
1228
+ rangeSetting.cancellationPolicy !== undefined
1229
+ ">
1230
+ <div class="mb-8 fm-typo-en-body-md-600">Message</div>
1231
+ <FmTextarea v-model="rangeSetting.cancellationPolicy" :maxLength="200" placeholder="Cancellation Policy"
1232
+ class="mb-4" />
1233
+ <div class="text-right text-fm-color-typo-tertiary fm-typo-en-body-sm-400">
1234
+ {{ rangeSetting.cancellationPolicy?.length || 0 }} / 200 characters
1235
+ </div>
1236
+ </div>
1237
+ </div>
1238
+ </template>
1239
+
1240
+ <!-- Save Button -->
1241
+ <div class="flex mt-5">
1242
+ <FmButton variant="primary" :label="t('order.saveAllChanges')" class="mr-auto" @click="updateReservationSetting"
1243
+ :loading="isSaving" />
1244
+ </div>
1245
+ </div>
1246
+ </template>