@octabits-io/nuxt-ui-kit 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/ai/index.d.ts +255 -0
- package/dist/ai/index.js +389 -0
- package/dist/dates/index.d.ts +42 -0
- package/dist/dates/index.js +108 -0
- package/dist/index.d.ts +453 -0
- package/dist/index.js +526 -0
- package/dist/zod/index.d.ts +24 -0
- package/dist/zod/index.js +17 -0
- package/package.json +102 -0
- package/src/components/AiResultReviewCard.vue +67 -0
- package/src/components/ConfirmDialog.vue +60 -0
- package/src/components/DateInput.vue +66 -0
- package/src/components/DateRangeInput.vue +651 -0
- package/src/components/PeriodDisplay.vue +76 -0
- package/src/components/SubSidebar.vue +85 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shipped as source: the consumer's Vite compiles this SFC. All imports are
|
|
3
|
+
// explicit — no reliance on the consumer's auto-import configuration.
|
|
4
|
+
// i18n key contract: dateRange.* (checkIn/checkOut/errors.*/availability*/
|
|
5
|
+
// atTime/nextDay/checking) and period.travel.nights / period.booking.days.
|
|
6
|
+
import { computed, ref, watch } from 'vue'
|
|
7
|
+
import { useI18n } from 'vue-i18n'
|
|
8
|
+
import { CalendarDate } from '@internationalized/date'
|
|
9
|
+
import type { DateValue } from '@internationalized/date'
|
|
10
|
+
import { addDays, differenceInDays, eachDayOfInterval, format, parseISO } from 'date-fns'
|
|
11
|
+
import UInputDate from '@nuxt/ui/components/InputDate.vue'
|
|
12
|
+
import UPopover from '@nuxt/ui/components/Popover.vue'
|
|
13
|
+
import UButton from '@nuxt/ui/components/Button.vue'
|
|
14
|
+
import UCalendar from '@nuxt/ui/components/Calendar.vue'
|
|
15
|
+
import UIcon from '@nuxt/ui/components/Icon.vue'
|
|
16
|
+
import {
|
|
17
|
+
calculateDays,
|
|
18
|
+
createDateFormatter,
|
|
19
|
+
shiftIso,
|
|
20
|
+
type Period,
|
|
21
|
+
} from '@octabits-io/nuxt-ui-kit/dates'
|
|
22
|
+
|
|
23
|
+
type AvailabilityStatus = {
|
|
24
|
+
status: 'available' | 'unavailable' | 'partial'
|
|
25
|
+
conflictDates?: string[]
|
|
26
|
+
message?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type InputSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<{
|
|
32
|
+
modelValue: Period
|
|
33
|
+
minDays?: number
|
|
34
|
+
maxDays?: number
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
size?: InputSize
|
|
37
|
+
/** Calendar trigger icon. Defaults to a plain calendar; pass e.g. `i-lucide-calendar-check` for booking/occupancy ranges. */
|
|
38
|
+
icon?: string
|
|
39
|
+
startLabel?: string
|
|
40
|
+
endLabel?: string
|
|
41
|
+
availabilityCheck?: (period: Period) => Promise<AvailabilityStatus>
|
|
42
|
+
blockedDates?: string[]
|
|
43
|
+
softBlockedDates?: string[]
|
|
44
|
+
checkInTime?: string
|
|
45
|
+
checkOutTime?: string
|
|
46
|
+
/**
|
|
47
|
+
* Date semantics of the *end* input, matching `PeriodDisplay`.
|
|
48
|
+
*
|
|
49
|
+
* `booking` (default) → the end input shows the bound period's `end`
|
|
50
|
+
* directly (inclusive last booked day); span counted in days.
|
|
51
|
+
*
|
|
52
|
+
* `travel` → the end input and its calendar show the **departure date**
|
|
53
|
+
* (`end` + 1 day); span counted in nights. The bound `modelValue` stays in
|
|
54
|
+
* booking semantics — conversion happens only at the input boundary.
|
|
55
|
+
*/
|
|
56
|
+
kind?: 'travel' | 'booking'
|
|
57
|
+
}>(), {
|
|
58
|
+
minDays: 1,
|
|
59
|
+
maxDays: undefined,
|
|
60
|
+
disabled: false,
|
|
61
|
+
size: 'md',
|
|
62
|
+
icon: 'i-lucide-calendar',
|
|
63
|
+
startLabel: undefined,
|
|
64
|
+
endLabel: undefined,
|
|
65
|
+
availabilityCheck: undefined,
|
|
66
|
+
blockedDates: () => [],
|
|
67
|
+
softBlockedDates: () => [],
|
|
68
|
+
checkInTime: undefined,
|
|
69
|
+
checkOutTime: undefined,
|
|
70
|
+
kind: 'booking',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const emit = defineEmits<{
|
|
74
|
+
'update:modelValue': [value: Period]
|
|
75
|
+
'change': [payload: { period: Period; isValid: boolean }]
|
|
76
|
+
'availability': [status: AvailabilityStatus]
|
|
77
|
+
}>()
|
|
78
|
+
|
|
79
|
+
const { t, locale } = useI18n()
|
|
80
|
+
const { formatDate, formatDateMedium } = createDateFormatter({ getLocale: () => locale.value })
|
|
81
|
+
|
|
82
|
+
const touched = ref(false)
|
|
83
|
+
const startPopoverOpen = ref(false)
|
|
84
|
+
const endPopoverOpen = ref(false)
|
|
85
|
+
|
|
86
|
+
// ISO (YYYY-MM-DD) of the day under the cursor in whichever calendar is open.
|
|
87
|
+
// Only one popover is open at a time, so a single shared ref suffices. Drives
|
|
88
|
+
// the live span preview/count; cleared when both popovers close.
|
|
89
|
+
const hoveredDay = ref<string | null>(null)
|
|
90
|
+
watch([startPopoverOpen, endPopoverOpen], ([s, e]) => {
|
|
91
|
+
if (!s && !e) hoveredDay.value = null
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const startPopoverTitle = computed(() => props.startLabel || t('dateRange.checkIn'))
|
|
95
|
+
const endPopoverTitle = computed(() => props.endLabel || t('dateRange.checkOut'))
|
|
96
|
+
|
|
97
|
+
// In travel mode the end input/calendar shows the departure date = booking
|
|
98
|
+
// end + 1 day. The bound modelValue always stays in booking semantics.
|
|
99
|
+
const travelOffset = computed(() => (props.kind === 'travel' ? 1 : 0))
|
|
100
|
+
|
|
101
|
+
/** The end date as displayed in the end input/calendar (departure in travel mode). */
|
|
102
|
+
const displayEnd = computed(() => shiftIso(props.modelValue.end, travelOffset.value))
|
|
103
|
+
|
|
104
|
+
// reka-ui's UCalendar emits a wider union (DateValue | DateRange | DateValue[]
|
|
105
|
+
// | null | undefined) than what we use. Accept it and narrow to CalendarDate.
|
|
106
|
+
type CalendarEmitValue = DateValue | DateValue[] | { start?: DateValue, end?: DateValue } | null | undefined
|
|
107
|
+
|
|
108
|
+
// UPSTREAM BUG (nuxt/ui ≥ 4.9.0): UCalendar accepts `default-placeholder` but
|
|
109
|
+
// never reads it — the month/year view-switching feature (nuxt/ui#6582) moved
|
|
110
|
+
// placeholder state into a local ref initialized from `placeholder` →
|
|
111
|
+
// `modelValue` → `defaultValue` → today, so an empty calendar always opens on
|
|
112
|
+
// the current month. Both popover calendars below therefore bind `:placeholder`
|
|
113
|
+
// (initial value only; internal month navigation still works) so the empty end
|
|
114
|
+
// calendar opens on the start date's month and vice versa. Revert to
|
|
115
|
+
// `:default-placeholder` once fixed upstream.
|
|
116
|
+
|
|
117
|
+
function commitPeriod(start: string, end: string) {
|
|
118
|
+
touched.value = true
|
|
119
|
+
emit('update:modelValue', { start, end })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onStartCalendarSelect(v: CalendarEmitValue) {
|
|
123
|
+
const date = v instanceof CalendarDate ? v : undefined
|
|
124
|
+
startPopoverOpen.value = false
|
|
125
|
+
if (!date) {
|
|
126
|
+
startDate.value = undefined
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const iso = date.toString()
|
|
130
|
+
const end = props.modelValue.end
|
|
131
|
+
// Ordering checks run in display (travel) space so a departure date never
|
|
132
|
+
// ends up before the arrival date.
|
|
133
|
+
if (end && iso >= shiftIso(end, travelOffset.value) && travelOffset.value > 0) {
|
|
134
|
+
// Picking a "start" on/after the existing departure: re-anchor the range
|
|
135
|
+
// to start on the picked date, clearing the end for re-selection.
|
|
136
|
+
commitPeriod(iso, '')
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (end && iso > end && travelOffset.value === 0) {
|
|
140
|
+
// Picking a "start" later than the existing end: order the two instead of
|
|
141
|
+
// leaving an invalid range — the picked date becomes the end, the old end
|
|
142
|
+
// becomes the start.
|
|
143
|
+
commitPeriod(end, iso)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
startDate.value = date
|
|
147
|
+
if (!end) {
|
|
148
|
+
// Defer past the current event loop tick: the click that selected the
|
|
149
|
+
// start date is still bubbling, and reka-ui's outside-click detector
|
|
150
|
+
// would otherwise immediately dismiss the freshly-opened end popover.
|
|
151
|
+
// We need a delay long enough for both the start popover's exit transition
|
|
152
|
+
// and reka-ui's DismissableLayer teardown to finish.
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
endPopoverOpen.value = true
|
|
155
|
+
}, 150)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onEndCalendarSelect(v: CalendarEmitValue) {
|
|
160
|
+
const date = v instanceof CalendarDate ? v : undefined
|
|
161
|
+
endPopoverOpen.value = false
|
|
162
|
+
if (!date) {
|
|
163
|
+
endDate.value = undefined
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
// In travel mode the picked day is the departure date; the bound end is the
|
|
167
|
+
// last booked day (departure − 1).
|
|
168
|
+
const iso = date.toString()
|
|
169
|
+
const start = props.modelValue.start
|
|
170
|
+
if (start && iso === start && travelOffset.value > 0) {
|
|
171
|
+
// Departure on the arrival day is a zero-night stay — ignore.
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (start && iso < start) {
|
|
175
|
+
// Picking an "end" earlier than the existing start: order the two instead
|
|
176
|
+
// of leaving an invalid range — the picked date becomes the start, the old
|
|
177
|
+
// start becomes the end (converted back to booking semantics).
|
|
178
|
+
commitPeriod(iso, shiftIso(start, -travelOffset.value))
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
endDate.value = date
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- ISO ↔ CalendarDate bridge ---
|
|
185
|
+
|
|
186
|
+
function isoToCalendar(iso: string): CalendarDate | undefined {
|
|
187
|
+
if (!iso) return undefined
|
|
188
|
+
const [y, m, d] = iso.split('-').map(Number)
|
|
189
|
+
if (!y || !m || !d) return undefined
|
|
190
|
+
return new CalendarDate(y, m, d)
|
|
191
|
+
}
|
|
192
|
+
function dateValueToIso(v: DateValue | undefined | null): string {
|
|
193
|
+
return v ? v.toString() : ''
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const startDate = computed<CalendarDate | undefined>({
|
|
197
|
+
get: () => isoToCalendar(props.modelValue.start),
|
|
198
|
+
set: (v) => {
|
|
199
|
+
touched.value = true
|
|
200
|
+
emit('update:modelValue', { start: dateValueToIso(v), end: props.modelValue.end })
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
const endDate = computed<CalendarDate | undefined>({
|
|
204
|
+
get: () => isoToCalendar(displayEnd.value),
|
|
205
|
+
set: (v) => {
|
|
206
|
+
touched.value = true
|
|
207
|
+
emit('update:modelValue', {
|
|
208
|
+
start: props.modelValue.start,
|
|
209
|
+
end: shiftIso(dateValueToIso(v), -travelOffset.value),
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// --- Blocked-date predicate (used by both inputs & their popover calendars) ---
|
|
215
|
+
|
|
216
|
+
const blockedSet = computed(() => new Set(props.blockedDates))
|
|
217
|
+
const softBlockedSet = computed(() => new Set(props.softBlockedDates))
|
|
218
|
+
function isDateDisabled(date: DateValue): boolean {
|
|
219
|
+
return blockedSet.value.has(date.toString())
|
|
220
|
+
}
|
|
221
|
+
function isDateSoftBlocked(date: DateValue): boolean {
|
|
222
|
+
const iso = date.toString()
|
|
223
|
+
return softBlockedSet.value.has(iso) && !blockedSet.value.has(iso)
|
|
224
|
+
}
|
|
225
|
+
// End-side predicates run in display space: in travel mode a departure day D
|
|
226
|
+
// only occupies the night up to D − 1, so D is selectable iff D − 1 is free.
|
|
227
|
+
// This keeps "checkout-only" days (the start day of the next booking) pickable
|
|
228
|
+
// as departure dates for back-to-back stays.
|
|
229
|
+
function isEndDateDisabled(date: DateValue): boolean {
|
|
230
|
+
return blockedSet.value.has(shiftIso(date.toString(), -travelOffset.value))
|
|
231
|
+
}
|
|
232
|
+
function isEndDateSoftBlocked(date: DateValue): boolean {
|
|
233
|
+
const iso = shiftIso(date.toString(), -travelOffset.value)
|
|
234
|
+
return softBlockedSet.value.has(iso) && !blockedSet.value.has(iso)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Range preview (anchor + live span) shown inside the calendar popovers ---
|
|
238
|
+
//
|
|
239
|
+
// Each single-date calendar only knows its own date, so the *other* endpoint
|
|
240
|
+
// was previously invisible. We mark it (the "anchor") and shade the span from
|
|
241
|
+
// it to the day being chosen (the selected opposite date, or — while picking —
|
|
242
|
+
// the hovered day). ISO YYYY-MM-DD strings sort chronologically, so plain
|
|
243
|
+
// string comparison gives the ordering without CalendarDate.compare.
|
|
244
|
+
|
|
245
|
+
type CalendarSide = 'start' | 'end'
|
|
246
|
+
type DayRole = 'anchor' | 'target' | 'inRange' | null
|
|
247
|
+
|
|
248
|
+
// For the end calendar the anchor is the start date (and vice versa); the
|
|
249
|
+
// target is the hovered day, falling back to this calendar's own date. All
|
|
250
|
+
// dates here are in display space — the end side shows the departure date in
|
|
251
|
+
// travel mode.
|
|
252
|
+
function anchorFor(side: CalendarSide): string | undefined {
|
|
253
|
+
return (side === 'end' ? props.modelValue.start : displayEnd.value) || undefined
|
|
254
|
+
}
|
|
255
|
+
function targetFor(side: CalendarSide): string | undefined {
|
|
256
|
+
// Hover wins so re-editing an already-set endpoint previews the new candidate
|
|
257
|
+
// span live; falls back to this calendar's own committed date at rest.
|
|
258
|
+
const own = side === 'end' ? displayEnd.value : props.modelValue.start
|
|
259
|
+
return hoveredDay.value || own || undefined
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function dayRole(dayIso: string, anchor?: string, target?: string): DayRole {
|
|
263
|
+
if (!anchor) return null
|
|
264
|
+
if (dayIso === anchor) return 'anchor'
|
|
265
|
+
if (!target || target === anchor) return null
|
|
266
|
+
const [lo, hi] = anchor <= target ? [anchor, target] : [target, anchor]
|
|
267
|
+
if (dayIso === target) return 'target'
|
|
268
|
+
if (dayIso > lo && dayIso < hi) return 'inRange'
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const dayRoleClasses: Record<Exclude<DayRole, null>, string> = {
|
|
273
|
+
anchor: 'bg-primary text-inverted font-medium',
|
|
274
|
+
target: 'bg-primary/30 text-primary font-medium',
|
|
275
|
+
inRange: 'bg-primary/15 text-primary',
|
|
276
|
+
}
|
|
277
|
+
const PILL_BASE = 'inline-flex items-center justify-center rounded px-1.5'
|
|
278
|
+
|
|
279
|
+
// Full pill classes for a day cell. Soft-block (amber) wins over range styling;
|
|
280
|
+
// '' leaves the plain number (no padding, unchanged from before).
|
|
281
|
+
function dayPillClass(date: DateValue, side: CalendarSide): string {
|
|
282
|
+
const softBlocked = side === 'end' ? isEndDateSoftBlocked(date) : isDateSoftBlocked(date)
|
|
283
|
+
if (softBlocked) return `${PILL_BASE} bg-warning/20 text-warning line-through`
|
|
284
|
+
const iso = date.toString()
|
|
285
|
+
// This calendar's own selected date is rendered natively by UCalendar (solid
|
|
286
|
+
// primary circle with contrast text). Don't override its colour, or the
|
|
287
|
+
// number disappears (text-primary on a primary background).
|
|
288
|
+
const own = side === 'end' ? displayEnd.value : props.modelValue.start
|
|
289
|
+
if (iso === own) return ''
|
|
290
|
+
const role = dayRole(iso, anchorFor(side), targetFor(side))
|
|
291
|
+
return role ? `${PILL_BASE} ${dayRoleClasses[role]}` : ''
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Live "N nights/days" label for the popover header — only while both the
|
|
295
|
+
// anchor and a target (selected or hovered) exist. Count + wording mirror
|
|
296
|
+
// PeriodDisplay so the header matches the period shown alongside the input.
|
|
297
|
+
function spanLabel(side: CalendarSide): string {
|
|
298
|
+
const anchor = anchorFor(side)
|
|
299
|
+
const target = targetFor(side)
|
|
300
|
+
if (!anchor || !target || anchor === target) return ''
|
|
301
|
+
const [start, end] = anchor <= target ? [anchor, target] : [target, anchor]
|
|
302
|
+
if (props.kind === 'travel') {
|
|
303
|
+
// Display space: `end` is already the departure date.
|
|
304
|
+
const nights = differenceInDays(new Date(end), new Date(start))
|
|
305
|
+
if (nights <= 0) return ''
|
|
306
|
+
return t('period.travel.nights', nights)
|
|
307
|
+
}
|
|
308
|
+
const days = calculateDays({ start, end }) // inclusive booked days
|
|
309
|
+
if (days <= 0) return ''
|
|
310
|
+
return t('period.booking.days', days)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Compute blocked dates that fall inside the selected range (excluding the
|
|
314
|
+
// endpoints themselves — those are reported as a more specific error).
|
|
315
|
+
const blockedInsideRange = computed<string[]>(() => {
|
|
316
|
+
const { start, end } = props.modelValue
|
|
317
|
+
if (!start || !end || end < start) return []
|
|
318
|
+
if (props.blockedDates.length === 0) return []
|
|
319
|
+
try {
|
|
320
|
+
return eachDayOfInterval({ start: parseISO(start), end: parseISO(end) })
|
|
321
|
+
.map(d => format(d, 'yyyy-MM-dd'))
|
|
322
|
+
.filter(iso => blockedSet.value.has(iso) && iso !== start && iso !== end)
|
|
323
|
+
} catch {
|
|
324
|
+
return []
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const startIsBlocked = computed(() =>
|
|
329
|
+
!!props.modelValue.start && blockedSet.value.has(props.modelValue.start),
|
|
330
|
+
)
|
|
331
|
+
const endIsBlocked = computed(() =>
|
|
332
|
+
!!props.modelValue.end && blockedSet.value.has(props.modelValue.end),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
const errorMessage = computed<string | null>(() => {
|
|
336
|
+
const { start, end } = props.modelValue
|
|
337
|
+
if (!touched.value && !start && !end) return null
|
|
338
|
+
if (!start || !end) return t('dateRange.errors.bothRequired')
|
|
339
|
+
if (end < start) return t('dateRange.errors.endAfterStart')
|
|
340
|
+
|
|
341
|
+
const days = calculateDays({ start, end })
|
|
342
|
+
if (props.minDays > 1 && days < props.minDays) {
|
|
343
|
+
return t('dateRange.errors.minDays', { n: props.minDays })
|
|
344
|
+
}
|
|
345
|
+
if (props.maxDays !== undefined && days > props.maxDays) {
|
|
346
|
+
return t('dateRange.errors.maxDays', { n: props.maxDays })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (startIsBlocked.value) {
|
|
350
|
+
return t('dateRange.errors.blockedDate', { date: formatDate(start) })
|
|
351
|
+
}
|
|
352
|
+
if (endIsBlocked.value) {
|
|
353
|
+
return t('dateRange.errors.blockedDate', { date: formatDate(end) })
|
|
354
|
+
}
|
|
355
|
+
if (blockedInsideRange.value.length > 0) {
|
|
356
|
+
return t('dateRange.errors.blockedRange', {
|
|
357
|
+
dates: blockedInsideRange.value.map(formatDate).join(', '),
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return null
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const isValid = computed(() => errorMessage.value === null
|
|
365
|
+
&& !!props.modelValue.start
|
|
366
|
+
&& !!props.modelValue.end)
|
|
367
|
+
|
|
368
|
+
const inputColor = computed<'error' | undefined>(() =>
|
|
369
|
+
errorMessage.value !== null && touched.value ? 'error' : undefined,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
// Mark hard-blocked cells in the calendar popover. reka-ui sets data-disabled
|
|
373
|
+
// when is-date-disabled returns true (hard block, unselectable). Soft-blocked
|
|
374
|
+
// dates are painted via the #day slot below so the cell stays clickable —
|
|
375
|
+
// passing them to is-date-unavailable would prevent selection.
|
|
376
|
+
const calendarUi = {
|
|
377
|
+
cellTrigger:
|
|
378
|
+
'data-[disabled]:!bg-error/15 data-[disabled]:!text-error data-[disabled]:line-through data-[disabled]:!opacity-100',
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- Availability check ---
|
|
382
|
+
|
|
383
|
+
const availabilityStatus = ref<AvailabilityStatus | null>(null)
|
|
384
|
+
const availabilityLoading = ref(false)
|
|
385
|
+
|
|
386
|
+
// Minimal trailing-edge debounce — avoids a @vueuse/core peer for one helper.
|
|
387
|
+
function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number) {
|
|
388
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
389
|
+
return (...args: A) => {
|
|
390
|
+
clearTimeout(timer)
|
|
391
|
+
timer = setTimeout(() => fn(...args), ms)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const runAvailabilityCheck = debounce(async (period: Period) => {
|
|
396
|
+
if (!props.availabilityCheck) return
|
|
397
|
+
availabilityLoading.value = true
|
|
398
|
+
try {
|
|
399
|
+
const result = await props.availabilityCheck(period)
|
|
400
|
+
availabilityStatus.value = result
|
|
401
|
+
emit('availability', result)
|
|
402
|
+
} catch (err) {
|
|
403
|
+
availabilityStatus.value = null
|
|
404
|
+
console.warn('[DateRangeInput] availabilityCheck failed', err)
|
|
405
|
+
} finally {
|
|
406
|
+
availabilityLoading.value = false
|
|
407
|
+
}
|
|
408
|
+
}, 300)
|
|
409
|
+
|
|
410
|
+
watch(
|
|
411
|
+
() => [props.modelValue.start, props.modelValue.end, props.availabilityCheck] as const,
|
|
412
|
+
([start, end, check]) => {
|
|
413
|
+
if (!check) {
|
|
414
|
+
availabilityStatus.value = null
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
if (!isValid.value) {
|
|
418
|
+
availabilityStatus.value = null
|
|
419
|
+
availabilityLoading.value = false
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
runAvailabilityCheck({ start, end })
|
|
423
|
+
},
|
|
424
|
+
{ immediate: true },
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
watch(
|
|
428
|
+
() => ({ ...props.modelValue }),
|
|
429
|
+
(period) => {
|
|
430
|
+
emit('change', { period, isValid: isValid.value })
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
const availabilityIcon = computed(() => {
|
|
435
|
+
switch (availabilityStatus.value?.status) {
|
|
436
|
+
case 'available': return 'i-lucide-check-circle-2'
|
|
437
|
+
case 'unavailable': return 'i-lucide-alert-circle'
|
|
438
|
+
case 'partial': return 'i-lucide-alert-triangle'
|
|
439
|
+
default: return ''
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
const availabilityColor = computed(() => {
|
|
444
|
+
switch (availabilityStatus.value?.status) {
|
|
445
|
+
case 'available': return 'text-success'
|
|
446
|
+
case 'unavailable': return 'text-error'
|
|
447
|
+
case 'partial': return 'text-warning'
|
|
448
|
+
default: return ''
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const availabilityMessage = computed(() => {
|
|
453
|
+
const s = availabilityStatus.value
|
|
454
|
+
if (!s) return ''
|
|
455
|
+
if (s.message) return s.message
|
|
456
|
+
switch (s.status) {
|
|
457
|
+
case 'available': return t('dateRange.availabilityOk')
|
|
458
|
+
case 'unavailable': return t('dateRange.availabilityConflict')
|
|
459
|
+
case 'partial': return t('dateRange.availabilityPartial')
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
// --- Check-in / check-out time hint ---
|
|
464
|
+
|
|
465
|
+
const showTimeHint = computed(() =>
|
|
466
|
+
props.checkInTime !== undefined || props.checkOutTime !== undefined,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
/** Customer-facing checkout date: the day after the (inclusive) last booked day. */
|
|
470
|
+
function checkoutDateIso(end: string): string {
|
|
471
|
+
if (!end) return ''
|
|
472
|
+
try {
|
|
473
|
+
return format(addDays(parseISO(end), 1), 'yyyy-MM-dd')
|
|
474
|
+
} catch {
|
|
475
|
+
return ''
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const checkInHint = computed(() => {
|
|
480
|
+
if (!props.checkInTime) return ''
|
|
481
|
+
const start = props.modelValue.start
|
|
482
|
+
if (start) {
|
|
483
|
+
return `${t('dateRange.checkIn')}: ${t('dateRange.atTime', {
|
|
484
|
+
date: formatDateMedium(start),
|
|
485
|
+
time: props.checkInTime,
|
|
486
|
+
})}`
|
|
487
|
+
}
|
|
488
|
+
return `${t('dateRange.checkIn')} ${props.checkInTime}`
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
const checkOutHint = computed(() => {
|
|
492
|
+
if (!props.checkOutTime) return ''
|
|
493
|
+
const end = props.modelValue.end
|
|
494
|
+
// In travel mode the end input already shows the departure date, so the
|
|
495
|
+
// "(next day)" clarifier is only useful for booking-semantics inputs.
|
|
496
|
+
const suffix = props.kind === 'booking' ? ` ${t('dateRange.nextDay')}` : ''
|
|
497
|
+
if (end) {
|
|
498
|
+
const iso = checkoutDateIso(end)
|
|
499
|
+
return `${t('dateRange.checkOut')}: ${t('dateRange.atTime', {
|
|
500
|
+
date: iso ? formatDateMedium(iso) : '',
|
|
501
|
+
time: props.checkOutTime,
|
|
502
|
+
})}${suffix}`
|
|
503
|
+
}
|
|
504
|
+
return `${t('dateRange.checkOut')} ${props.checkOutTime}${suffix}`
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
const timeHintParts = computed(() =>
|
|
508
|
+
[checkInHint.value, checkOutHint.value].filter(Boolean),
|
|
509
|
+
)
|
|
510
|
+
</script>
|
|
511
|
+
|
|
512
|
+
<template>
|
|
513
|
+
<div class="flex flex-col gap-1">
|
|
514
|
+
<div class="flex items-center gap-2">
|
|
515
|
+
<UInputDate
|
|
516
|
+
v-model="startDate"
|
|
517
|
+
:is-date-disabled="isDateDisabled"
|
|
518
|
+
:size="size"
|
|
519
|
+
:disabled="disabled"
|
|
520
|
+
:color="inputColor"
|
|
521
|
+
:aria-label="startLabel"
|
|
522
|
+
class="flex-1"
|
|
523
|
+
>
|
|
524
|
+
<template #trailing>
|
|
525
|
+
<UPopover v-model:open="startPopoverOpen">
|
|
526
|
+
<UButton
|
|
527
|
+
color="neutral"
|
|
528
|
+
variant="link"
|
|
529
|
+
size="sm"
|
|
530
|
+
:icon="icon"
|
|
531
|
+
:aria-label="startLabel"
|
|
532
|
+
:disabled="disabled"
|
|
533
|
+
class="px-0"
|
|
534
|
+
/>
|
|
535
|
+
<template #content>
|
|
536
|
+
<div class="flex flex-col" @pointerleave="hoveredDay = null">
|
|
537
|
+
<p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
|
|
538
|
+
<span class="text-xs font-medium text-muted uppercase tracking-wide">
|
|
539
|
+
{{ startPopoverTitle }}
|
|
540
|
+
</span>
|
|
541
|
+
<span v-if="spanLabel('start')" class="text-xs font-medium text-primary">
|
|
542
|
+
{{ spanLabel('start') }}
|
|
543
|
+
</span>
|
|
544
|
+
</p>
|
|
545
|
+
<UCalendar
|
|
546
|
+
:model-value="startDate"
|
|
547
|
+
:placeholder="startDate ?? endDate"
|
|
548
|
+
:is-date-disabled="isDateDisabled"
|
|
549
|
+
:ui="calendarUi"
|
|
550
|
+
class="p-2"
|
|
551
|
+
@update:model-value="onStartCalendarSelect"
|
|
552
|
+
>
|
|
553
|
+
<template #day="{ day }">
|
|
554
|
+
<span :class="dayPillClass(day, 'start')" @pointerenter="hoveredDay = day.toString()">
|
|
555
|
+
{{ day.day }}
|
|
556
|
+
</span>
|
|
557
|
+
</template>
|
|
558
|
+
</UCalendar>
|
|
559
|
+
</div>
|
|
560
|
+
</template>
|
|
561
|
+
</UPopover>
|
|
562
|
+
</template>
|
|
563
|
+
</UInputDate>
|
|
564
|
+
|
|
565
|
+
<span class="text-muted shrink-0" aria-hidden="true">→</span>
|
|
566
|
+
|
|
567
|
+
<UInputDate
|
|
568
|
+
v-model="endDate"
|
|
569
|
+
:is-date-disabled="isEndDateDisabled"
|
|
570
|
+
:size="size"
|
|
571
|
+
:disabled="disabled"
|
|
572
|
+
:color="inputColor"
|
|
573
|
+
:aria-label="endLabel"
|
|
574
|
+
class="flex-1"
|
|
575
|
+
>
|
|
576
|
+
<template #trailing>
|
|
577
|
+
<UPopover v-model:open="endPopoverOpen">
|
|
578
|
+
<UButton
|
|
579
|
+
color="neutral"
|
|
580
|
+
variant="link"
|
|
581
|
+
size="sm"
|
|
582
|
+
:icon="icon"
|
|
583
|
+
:aria-label="endLabel"
|
|
584
|
+
:disabled="disabled"
|
|
585
|
+
class="px-0"
|
|
586
|
+
/>
|
|
587
|
+
<template #content>
|
|
588
|
+
<div class="flex flex-col" @pointerleave="hoveredDay = null">
|
|
589
|
+
<p class="flex items-baseline justify-between gap-2 px-3 pt-2 pb-1">
|
|
590
|
+
<span class="text-xs font-medium text-muted uppercase tracking-wide">
|
|
591
|
+
{{ endPopoverTitle }}
|
|
592
|
+
</span>
|
|
593
|
+
<span v-if="spanLabel('end')" class="text-xs font-medium text-primary">
|
|
594
|
+
{{ spanLabel('end') }}
|
|
595
|
+
</span>
|
|
596
|
+
</p>
|
|
597
|
+
<UCalendar
|
|
598
|
+
:model-value="endDate"
|
|
599
|
+
:placeholder="endDate ?? startDate"
|
|
600
|
+
:is-date-disabled="isEndDateDisabled"
|
|
601
|
+
:ui="calendarUi"
|
|
602
|
+
class="p-2"
|
|
603
|
+
@update:model-value="onEndCalendarSelect"
|
|
604
|
+
>
|
|
605
|
+
<template #day="{ day }">
|
|
606
|
+
<span :class="dayPillClass(day, 'end')" @pointerenter="hoveredDay = day.toString()">
|
|
607
|
+
{{ day.day }}
|
|
608
|
+
</span>
|
|
609
|
+
</template>
|
|
610
|
+
</UCalendar>
|
|
611
|
+
</div>
|
|
612
|
+
</template>
|
|
613
|
+
</UPopover>
|
|
614
|
+
</template>
|
|
615
|
+
</UInputDate>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
<!-- Slot for a derived summary (e.g. the resulting travel period) shown
|
|
619
|
+
between the inputs and the time/availability hints. -->
|
|
620
|
+
<slot name="summary" />
|
|
621
|
+
|
|
622
|
+
<p v-if="showTimeHint && timeHintParts.length" class="text-xs text-muted">
|
|
623
|
+
{{ timeHintParts.join(' • ') }}
|
|
624
|
+
</p>
|
|
625
|
+
|
|
626
|
+
<p v-if="errorMessage" class="text-sm text-error">
|
|
627
|
+
{{ errorMessage }}
|
|
628
|
+
</p>
|
|
629
|
+
|
|
630
|
+
<div
|
|
631
|
+
v-else-if="availabilityCheck && (availabilityLoading || availabilityStatus)"
|
|
632
|
+
class="flex items-center gap-1.5 text-sm"
|
|
633
|
+
:class="availabilityLoading ? 'text-muted' : availabilityColor"
|
|
634
|
+
>
|
|
635
|
+
<template v-if="availabilityLoading">
|
|
636
|
+
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
|
637
|
+
<span>{{ t('dateRange.checking') }}</span>
|
|
638
|
+
</template>
|
|
639
|
+
<template v-else-if="availabilityStatus">
|
|
640
|
+
<UIcon :name="availabilityIcon" />
|
|
641
|
+
<span>{{ availabilityMessage }}</span>
|
|
642
|
+
<span
|
|
643
|
+
v-if="availabilityStatus.status === 'partial' && availabilityStatus.conflictDates?.length"
|
|
644
|
+
class="text-muted"
|
|
645
|
+
>
|
|
646
|
+
({{ availabilityStatus.conflictDates.map(formatDate).join(', ') }})
|
|
647
|
+
</span>
|
|
648
|
+
</template>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
</template>
|