@feedmepos/mf-order-setting 0.0.53 → 0.0.56-dev.1

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