@feedmepos/mf-order-setting 0.0.54 → 0.0.56-dev.2

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