@cimplify/sdk 0.95.0 → 0.96.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/dist/react.d.mts +10 -2
- package/dist/react.d.ts +10 -2
- package/dist/react.js +13 -3
- package/dist/react.mjs +13 -3
- package/package.json +1 -1
- package/registry/date-slot-picker.json +1 -1
- package/registry/slot-picker.json +1 -1
package/dist/react.d.mts
CHANGED
|
@@ -2700,10 +2700,16 @@ interface SlotPickerProps {
|
|
|
2700
2700
|
durationValue?: number;
|
|
2701
2701
|
/** Text shown when no slots available. */
|
|
2702
2702
|
emptyMessage?: string;
|
|
2703
|
+
/**
|
|
2704
|
+
* Phone number offered when a day has no bookable times. A service can be
|
|
2705
|
+
* unbookable for reasons the storefront cannot see, and a bare empty grid
|
|
2706
|
+
* reads as a broken site rather than a shop to call.
|
|
2707
|
+
*/
|
|
2708
|
+
contactPhone?: string;
|
|
2703
2709
|
className?: string;
|
|
2704
2710
|
classNames?: SlotPickerClassNames;
|
|
2705
2711
|
}
|
|
2706
|
-
declare function SlotPicker({ slots: slotsProp, serviceId, date, participantCount, selectedSlot, onSlotSelect, groupByTimeOfDay, showPrice, lowCapacityThreshold, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, emptyMessage, className, classNames, }: SlotPickerProps): React$1.ReactElement;
|
|
2712
|
+
declare function SlotPicker({ slots: slotsProp, serviceId, date, participantCount, selectedSlot, onSlotSelect, groupByTimeOfDay, showPrice, lowCapacityThreshold, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, emptyMessage, contactPhone, className, classNames, }: SlotPickerProps): React$1.ReactElement;
|
|
2707
2713
|
|
|
2708
2714
|
interface DateSlotPickerClassNames {
|
|
2709
2715
|
root?: string;
|
|
@@ -2743,10 +2749,12 @@ interface DateSlotPickerProps {
|
|
|
2743
2749
|
hideElapsedSlots?: boolean;
|
|
2744
2750
|
/** Forwarded to `<SlotPicker>` — minimum lead time in minutes. Default: 0. */
|
|
2745
2751
|
minLeadMinutes?: number;
|
|
2752
|
+
/** Forwarded to `<SlotPicker>` — offered when a day has no bookable times. */
|
|
2753
|
+
contactPhone?: string;
|
|
2746
2754
|
className?: string;
|
|
2747
2755
|
classNames?: DateSlotPickerClassNames;
|
|
2748
2756
|
}
|
|
2749
|
-
declare function DateSlotPicker({ serviceId, daysToShow, participantCount, units, selectedSlot, onSlotSelect, availability: availabilityProp, showPrice, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, className, classNames, }: DateSlotPickerProps): React$1.ReactElement;
|
|
2757
|
+
declare function DateSlotPicker({ serviceId, daysToShow, participantCount, units, selectedSlot, onSlotSelect, availability: availabilityProp, showPrice, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, contactPhone, className, classNames, }: DateSlotPickerProps): React$1.ReactElement;
|
|
2750
2758
|
|
|
2751
2759
|
interface DatePickerClassNames {
|
|
2752
2760
|
/** Outer wrapper around the trigger. */
|
package/dist/react.d.ts
CHANGED
|
@@ -2700,10 +2700,16 @@ interface SlotPickerProps {
|
|
|
2700
2700
|
durationValue?: number;
|
|
2701
2701
|
/** Text shown when no slots available. */
|
|
2702
2702
|
emptyMessage?: string;
|
|
2703
|
+
/**
|
|
2704
|
+
* Phone number offered when a day has no bookable times. A service can be
|
|
2705
|
+
* unbookable for reasons the storefront cannot see, and a bare empty grid
|
|
2706
|
+
* reads as a broken site rather than a shop to call.
|
|
2707
|
+
*/
|
|
2708
|
+
contactPhone?: string;
|
|
2703
2709
|
className?: string;
|
|
2704
2710
|
classNames?: SlotPickerClassNames;
|
|
2705
2711
|
}
|
|
2706
|
-
declare function SlotPicker({ slots: slotsProp, serviceId, date, participantCount, selectedSlot, onSlotSelect, groupByTimeOfDay, showPrice, lowCapacityThreshold, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, emptyMessage, className, classNames, }: SlotPickerProps): React$1.ReactElement;
|
|
2712
|
+
declare function SlotPicker({ slots: slotsProp, serviceId, date, participantCount, selectedSlot, onSlotSelect, groupByTimeOfDay, showPrice, lowCapacityThreshold, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, emptyMessage, contactPhone, className, classNames, }: SlotPickerProps): React$1.ReactElement;
|
|
2707
2713
|
|
|
2708
2714
|
interface DateSlotPickerClassNames {
|
|
2709
2715
|
root?: string;
|
|
@@ -2743,10 +2749,12 @@ interface DateSlotPickerProps {
|
|
|
2743
2749
|
hideElapsedSlots?: boolean;
|
|
2744
2750
|
/** Forwarded to `<SlotPicker>` — minimum lead time in minutes. Default: 0. */
|
|
2745
2751
|
minLeadMinutes?: number;
|
|
2752
|
+
/** Forwarded to `<SlotPicker>` — offered when a day has no bookable times. */
|
|
2753
|
+
contactPhone?: string;
|
|
2746
2754
|
className?: string;
|
|
2747
2755
|
classNames?: DateSlotPickerClassNames;
|
|
2748
2756
|
}
|
|
2749
|
-
declare function DateSlotPicker({ serviceId, daysToShow, participantCount, units, selectedSlot, onSlotSelect, availability: availabilityProp, showPrice, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, className, classNames, }: DateSlotPickerProps): React$1.ReactElement;
|
|
2757
|
+
declare function DateSlotPicker({ serviceId, daysToShow, participantCount, units, selectedSlot, onSlotSelect, availability: availabilityProp, showPrice, currency, schedulingMode, durationUnit, durationValue, hideElapsedSlots, minLeadMinutes, contactPhone, className, classNames, }: DateSlotPickerProps): React$1.ReactElement;
|
|
2750
2758
|
|
|
2751
2759
|
interface DatePickerClassNames {
|
|
2752
2760
|
/** Outer wrapper around the trigger. */
|
package/dist/react.js
CHANGED
|
@@ -9263,6 +9263,7 @@ function SlotPicker({
|
|
|
9263
9263
|
hideElapsedSlots = true,
|
|
9264
9264
|
minLeadMinutes = 0,
|
|
9265
9265
|
emptyMessage = "No available slots",
|
|
9266
|
+
contactPhone,
|
|
9266
9267
|
className,
|
|
9267
9268
|
classNames
|
|
9268
9269
|
}) {
|
|
@@ -9295,13 +9296,20 @@ function SlotPicker({
|
|
|
9295
9296
|
);
|
|
9296
9297
|
}
|
|
9297
9298
|
if (slots.length === 0) {
|
|
9298
|
-
return /* @__PURE__ */ jsxRuntime.
|
|
9299
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
9299
9300
|
"div",
|
|
9300
9301
|
{
|
|
9301
9302
|
"data-cimplify-slot-picker": true,
|
|
9302
9303
|
"data-empty": true,
|
|
9303
9304
|
className: cn(className, classNames?.root, classNames?.empty),
|
|
9304
|
-
children:
|
|
9305
|
+
children: [
|
|
9306
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { children: emptyMessage }),
|
|
9307
|
+
contactPhone ? /* @__PURE__ */ jsxRuntime.jsx("p", { children: /* @__PURE__ */ jsxRuntime.jsxs("a", { href: `tel:${contactPhone.replace(/\s+/g, "")}`, children: [
|
|
9308
|
+
"Call ",
|
|
9309
|
+
contactPhone,
|
|
9310
|
+
" to book"
|
|
9311
|
+
] }) }) : null
|
|
9312
|
+
]
|
|
9305
9313
|
}
|
|
9306
9314
|
);
|
|
9307
9315
|
}
|
|
@@ -9428,6 +9436,7 @@ function DateSlotPicker({
|
|
|
9428
9436
|
durationValue,
|
|
9429
9437
|
hideElapsedSlots = true,
|
|
9430
9438
|
minLeadMinutes = 0,
|
|
9439
|
+
contactPhone,
|
|
9431
9440
|
className,
|
|
9432
9441
|
classNames
|
|
9433
9442
|
}) {
|
|
@@ -9577,7 +9586,8 @@ function DateSlotPicker({
|
|
|
9577
9586
|
durationUnit,
|
|
9578
9587
|
durationValue,
|
|
9579
9588
|
hideElapsedSlots,
|
|
9580
|
-
minLeadMinutes
|
|
9589
|
+
minLeadMinutes,
|
|
9590
|
+
contactPhone
|
|
9581
9591
|
}
|
|
9582
9592
|
) })
|
|
9583
9593
|
]
|
package/dist/react.mjs
CHANGED
|
@@ -9239,6 +9239,7 @@ function SlotPicker({
|
|
|
9239
9239
|
hideElapsedSlots = true,
|
|
9240
9240
|
minLeadMinutes = 0,
|
|
9241
9241
|
emptyMessage = "No available slots",
|
|
9242
|
+
contactPhone,
|
|
9242
9243
|
className,
|
|
9243
9244
|
classNames
|
|
9244
9245
|
}) {
|
|
@@ -9271,13 +9272,20 @@ function SlotPicker({
|
|
|
9271
9272
|
);
|
|
9272
9273
|
}
|
|
9273
9274
|
if (slots.length === 0) {
|
|
9274
|
-
return /* @__PURE__ */
|
|
9275
|
+
return /* @__PURE__ */ jsxs(
|
|
9275
9276
|
"div",
|
|
9276
9277
|
{
|
|
9277
9278
|
"data-cimplify-slot-picker": true,
|
|
9278
9279
|
"data-empty": true,
|
|
9279
9280
|
className: cn(className, classNames?.root, classNames?.empty),
|
|
9280
|
-
children:
|
|
9281
|
+
children: [
|
|
9282
|
+
/* @__PURE__ */ jsx("p", { children: emptyMessage }),
|
|
9283
|
+
contactPhone ? /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsxs("a", { href: `tel:${contactPhone.replace(/\s+/g, "")}`, children: [
|
|
9284
|
+
"Call ",
|
|
9285
|
+
contactPhone,
|
|
9286
|
+
" to book"
|
|
9287
|
+
] }) }) : null
|
|
9288
|
+
]
|
|
9281
9289
|
}
|
|
9282
9290
|
);
|
|
9283
9291
|
}
|
|
@@ -9404,6 +9412,7 @@ function DateSlotPicker({
|
|
|
9404
9412
|
durationValue,
|
|
9405
9413
|
hideElapsedSlots = true,
|
|
9406
9414
|
minLeadMinutes = 0,
|
|
9415
|
+
contactPhone,
|
|
9407
9416
|
className,
|
|
9408
9417
|
classNames
|
|
9409
9418
|
}) {
|
|
@@ -9553,7 +9562,8 @@ function DateSlotPicker({
|
|
|
9553
9562
|
durationUnit,
|
|
9554
9563
|
durationValue,
|
|
9555
9564
|
hideElapsedSlots,
|
|
9556
|
-
minLeadMinutes
|
|
9565
|
+
minLeadMinutes,
|
|
9566
|
+
contactPhone
|
|
9557
9567
|
}
|
|
9558
9568
|
) })
|
|
9559
9569
|
]
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "date-slot-picker.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport React, { useState, useMemo, useCallback } from \"react\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport type { AvailableSlot, DayAvailability } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useServiceAvailability } from \"@cimplify/sdk/react\";\nimport { SlotPicker } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface DateSlotPickerClassNames {\n root?: string;\n dateStrip?: string;\n dateButton?: string;\n nav?: string;\n navButton?: string;\n slots?: string;\n loading?: string;\n}\n\nexport interface DateSlotPickerProps {\n /** Service ID to fetch availability and slots for. */\n serviceId: string;\n /** Number of days to show in the date strip. Default: 7. */\n daysToShow?: number;\n /** Number of participants. */\n participantCount?: number;\n /** Whole units needed — forwarded to availability so short days drop out. */\n units?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot, date: string) => void;\n /** Pre-fetched availability data (skips fetch). */\n availability?: DayAvailability[];\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\n /** Forwarded to `<SlotPicker>` to render multi-day stay labels. */\n schedulingMode?: SchedulingMode;\n /** Forwarded to `<SlotPicker>` — unit for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Forwarded to `<SlotPicker>` — value for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Forwarded to `<SlotPicker>` — hide slots already in the past. Default: true. */\n hideElapsedSlots?: boolean;\n /** Forwarded to `<SlotPicker>` — minimum lead time in minutes. Default: 0. */\n minLeadMinutes?: number;\n className?: string;\n classNames?: DateSlotPickerClassNames;\n}\n\nfunction formatDate(dateStr: string): string {\n const date = new Date(dateStr + \"T00:00:00\");\n return date.toLocaleDateString(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\" });\n}\n\nfunction toDateString(date: Date): string {\n return date.toISOString().split(\"T\")[0];\n}\n\nfunction addDays(date: Date, days: number): Date {\n const result = new Date(date);\n result.setDate(result.getDate() + days);\n return result;\n}\n\nexport function DateSlotPicker({\n serviceId,\n daysToShow = 7,\n participantCount,\n units,\n selectedSlot,\n onSlotSelect,\n availability: availabilityProp,\n showPrice = true,\n currency,\n schedulingMode,\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n className,\n classNames,\n}: DateSlotPickerProps): React.ReactElement {\n const [offset, setOffset] = useState(0);\n const [selectedDate, setSelectedDate] = useState<string>(toDateString(new Date()));\n\n const dateRange = useMemo(() => {\n const today = new Date();\n const start = addDays(today, offset);\n const dates: string[] = [];\n for (let i = 0; i < daysToShow; i++) {\n dates.push(toDateString(addDays(start, i)));\n }\n return {\n dates,\n startDate: dates[0],\n endDate: dates[dates.length - 1],\n };\n }, [offset, daysToShow]);\n\n const { days: fetchedDays, isLoading: availabilityLoading } = useServiceAvailability(\n serviceId,\n dateRange.startDate,\n dateRange.endDate,\n {\n participantCount,\n units,\n enabled: availabilityProp === undefined,\n },\n );\n\n const days = availabilityProp ?? fetchedDays;\n\n const availabilityMap = useMemo(() => {\n const map = new Map<string, DayAvailability>();\n for (const day of days) {\n map.set(day.date, day);\n }\n return map;\n }, [days]);\n\n const handlePrev = useCallback(() => {\n setOffset((prev) => Math.max(0, prev - daysToShow));\n }, [daysToShow]);\n\n const handleNext = useCallback(() => {\n setOffset((prev) => prev + daysToShow);\n }, [daysToShow]);\n\n const handleDateChange = useCallback((value: string | number | null) => {\n if (typeof value === \"string\") {\n setSelectedDate(value);\n }\n }, []);\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot) => {\n onSlotSelect?.(slot, selectedDate);\n },\n [onSlotSelect, selectedDate],\n );\n\n return (\n <Tabs.Root\n value={selectedDate}\n onValueChange={handleDateChange}\n data-cimplify-date-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n >\n <div\n data-cimplify-date-nav\n className={cn(\"flex items-center justify-end gap-2\", classNames?.nav)}\n >\n <button\n type=\"button\"\n onClick={handlePrev}\n disabled={offset === 0}\n aria-label=\"Previous dates\"\n data-cimplify-date-nav-prev\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40\",\n classNames?.navButton,\n )}\n >\n ←\n </button>\n <button\n type=\"button\"\n onClick={handleNext}\n aria-label=\"Next dates\"\n data-cimplify-date-nav-next\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\",\n classNames?.navButton,\n )}\n >\n →\n </button>\n </div>\n\n <Tabs.List\n data-cimplify-date-strip\n className={cn(\"grid grid-cols-7 gap-1 sm:gap-2\", classNames?.dateStrip)}\n >\n {dateRange.dates.map((date) => {\n const dayInfo = availabilityMap.get(date);\n const hasAvailability = dayInfo?.has_availability !== false;\n const isSelected = selectedDate === date;\n return (\n <Tabs.Tab\n key={date}\n value={date}\n data-cimplify-date-button\n data-selected={isSelected || undefined}\n data-available={hasAvailability || undefined}\n data-fully-booked={(!hasAvailability) || undefined}\n className={cn(\n \"flex flex-col items-center justify-center rounded-md border border-border bg-background px-1 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[fully-booked]:cursor-not-allowed data-[fully-booked]:opacity-40\",\n classNames?.dateButton,\n )}\n >\n {formatDate(date)}\n </Tabs.Tab>\n );\n })}\n </Tabs.List>\n\n {availabilityLoading && (\n <div\n data-cimplify-date-slot-loading\n aria-busy=\"true\"\n className={cn(\"h-32 rounded-md bg-muted/40 animate-pulse\", classNames?.loading)}\n />\n )}\n\n <div data-cimplify-date-slots className={classNames?.slots}>\n <SlotPicker\n serviceId={serviceId}\n date={selectedDate}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n currency={currency}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n hideElapsedSlots={hideElapsedSlots}\n minLeadMinutes={minLeadMinutes}\n />\n </div>\n </Tabs.Root>\n );\n}\n"
|
|
13
|
+
"content": "\"use client\";\n\nimport React, { useState, useMemo, useCallback } from \"react\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport type { AvailableSlot, DayAvailability } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useServiceAvailability } from \"@cimplify/sdk/react\";\nimport { SlotPicker } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface DateSlotPickerClassNames {\n root?: string;\n dateStrip?: string;\n dateButton?: string;\n nav?: string;\n navButton?: string;\n slots?: string;\n loading?: string;\n}\n\nexport interface DateSlotPickerProps {\n /** Service ID to fetch availability and slots for. */\n serviceId: string;\n /** Number of days to show in the date strip. Default: 7. */\n daysToShow?: number;\n /** Number of participants. */\n participantCount?: number;\n /** Whole units needed — forwarded to availability so short days drop out. */\n units?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot, date: string) => void;\n /** Pre-fetched availability data (skips fetch). */\n availability?: DayAvailability[];\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\n /** Forwarded to `<SlotPicker>` to render multi-day stay labels. */\n schedulingMode?: SchedulingMode;\n /** Forwarded to `<SlotPicker>` — unit for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Forwarded to `<SlotPicker>` — value for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Forwarded to `<SlotPicker>` — hide slots already in the past. Default: true. */\n hideElapsedSlots?: boolean;\n /** Forwarded to `<SlotPicker>` — minimum lead time in minutes. Default: 0. */\n minLeadMinutes?: number;\n /** Forwarded to `<SlotPicker>` — offered when a day has no bookable times. */\n contactPhone?: string;\n className?: string;\n classNames?: DateSlotPickerClassNames;\n}\n\nfunction formatDate(dateStr: string): string {\n const date = new Date(dateStr + \"T00:00:00\");\n return date.toLocaleDateString(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\" });\n}\n\nfunction toDateString(date: Date): string {\n return date.toISOString().split(\"T\")[0];\n}\n\nfunction addDays(date: Date, days: number): Date {\n const result = new Date(date);\n result.setDate(result.getDate() + days);\n return result;\n}\n\nexport function DateSlotPicker({\n serviceId,\n daysToShow = 7,\n participantCount,\n units,\n selectedSlot,\n onSlotSelect,\n availability: availabilityProp,\n showPrice = true,\n currency,\n schedulingMode,\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n contactPhone,\n className,\n classNames,\n}: DateSlotPickerProps): React.ReactElement {\n const [offset, setOffset] = useState(0);\n const [selectedDate, setSelectedDate] = useState<string>(toDateString(new Date()));\n\n const dateRange = useMemo(() => {\n const today = new Date();\n const start = addDays(today, offset);\n const dates: string[] = [];\n for (let i = 0; i < daysToShow; i++) {\n dates.push(toDateString(addDays(start, i)));\n }\n return {\n dates,\n startDate: dates[0],\n endDate: dates[dates.length - 1],\n };\n }, [offset, daysToShow]);\n\n const { days: fetchedDays, isLoading: availabilityLoading } = useServiceAvailability(\n serviceId,\n dateRange.startDate,\n dateRange.endDate,\n {\n participantCount,\n units,\n enabled: availabilityProp === undefined,\n },\n );\n\n const days = availabilityProp ?? fetchedDays;\n\n const availabilityMap = useMemo(() => {\n const map = new Map<string, DayAvailability>();\n for (const day of days) {\n map.set(day.date, day);\n }\n return map;\n }, [days]);\n\n const handlePrev = useCallback(() => {\n setOffset((prev) => Math.max(0, prev - daysToShow));\n }, [daysToShow]);\n\n const handleNext = useCallback(() => {\n setOffset((prev) => prev + daysToShow);\n }, [daysToShow]);\n\n const handleDateChange = useCallback((value: string | number | null) => {\n if (typeof value === \"string\") {\n setSelectedDate(value);\n }\n }, []);\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot) => {\n onSlotSelect?.(slot, selectedDate);\n },\n [onSlotSelect, selectedDate],\n );\n\n return (\n <Tabs.Root\n value={selectedDate}\n onValueChange={handleDateChange}\n data-cimplify-date-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n >\n <div\n data-cimplify-date-nav\n className={cn(\"flex items-center justify-end gap-2\", classNames?.nav)}\n >\n <button\n type=\"button\"\n onClick={handlePrev}\n disabled={offset === 0}\n aria-label=\"Previous dates\"\n data-cimplify-date-nav-prev\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40\",\n classNames?.navButton,\n )}\n >\n ←\n </button>\n <button\n type=\"button\"\n onClick={handleNext}\n aria-label=\"Next dates\"\n data-cimplify-date-nav-next\n className={cn(\n \"grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\",\n classNames?.navButton,\n )}\n >\n →\n </button>\n </div>\n\n <Tabs.List\n data-cimplify-date-strip\n className={cn(\"grid grid-cols-7 gap-1 sm:gap-2\", classNames?.dateStrip)}\n >\n {dateRange.dates.map((date) => {\n const dayInfo = availabilityMap.get(date);\n const hasAvailability = dayInfo?.has_availability !== false;\n const isSelected = selectedDate === date;\n return (\n <Tabs.Tab\n key={date}\n value={date}\n data-cimplify-date-button\n data-selected={isSelected || undefined}\n data-available={hasAvailability || undefined}\n data-fully-booked={(!hasAvailability) || undefined}\n className={cn(\n \"flex flex-col items-center justify-center rounded-md border border-border bg-background px-1 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[fully-booked]:cursor-not-allowed data-[fully-booked]:opacity-40\",\n classNames?.dateButton,\n )}\n >\n {formatDate(date)}\n </Tabs.Tab>\n );\n })}\n </Tabs.List>\n\n {availabilityLoading && (\n <div\n data-cimplify-date-slot-loading\n aria-busy=\"true\"\n className={cn(\"h-32 rounded-md bg-muted/40 animate-pulse\", classNames?.loading)}\n />\n )}\n\n <div data-cimplify-date-slots className={classNames?.slots}>\n <SlotPicker\n serviceId={serviceId}\n date={selectedDate}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n currency={currency}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n hideElapsedSlots={hideElapsedSlots}\n minLeadMinutes={minLeadMinutes}\n contactPhone={contactPhone}\n />\n </div>\n </Tabs.Root>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
12
12
|
"path": "slot-picker.tsx",
|
|
13
|
-
"content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport React, { useMemo } from \"react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useAvailableSlots } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n slotScarcity?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID — used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) — used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `\"multi_day\"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Show a \"N left\" urgency tag when a slot's remaining capacity is at or below\n * this number (and above zero). Slots at zero capacity render as sold out.\n * Default: 3. Set to 0 to disable scarcity tags.\n */\n lowCapacityThreshold?: number;\n /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\n /**\n * Hide slots whose `start_time` is already in the past. Default: true.\n * Set to false to show elapsed slots greyed-out (still un-selectable).\n */\n hideElapsedSlots?: boolean;\n /**\n * Minimum lead time (in minutes) before a slot can be booked. Slots whose\n * start is sooner than `now + minLeadMinutes` are filtered out (when\n * `hideElapsedSlots` is true) or marked unavailable. Default: 0.\n */\n minLeadMinutes?: number;\n /**\n * Service scheduling mode. When `\"multi_day\"`, each slot renders as a\n * stay summary (`\"3 nights: Fri Apr 5, 3:00 PM → Mon Apr 8, 11:00 AM\"`)\n * instead of the time-of-day label. Defaults to `\"intraday\"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit — used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value — used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): \"morning\" | \"afternoon\" | \"evening\" {\n const hour = parseInt(timeStr.split(\"T\").pop()?.split(\":\")[0] ?? timeStr.split(\":\")[0], 10);\n if (hour < 12) return \"morning\";\n if (hour < 17) return \"afternoon\";\n return \"evening\";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: \"Morning\",\n afternoon: \"Afternoon\",\n evening: \"Evening\",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return ([\"morning\", \"afternoon\", \"evening\"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(\":\");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? \"PM\" : \"AM\";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? \"day\" : \"days\";\n const v = value ?? 1;\n if (unit === \"minutes\") return v === 1 ? \"minute\" : \"minutes\";\n if (unit === \"hours\") return v === 1 ? \"hour\" : \"hours\";\n if (unit === \"days\") return v === 1 ? \"day\" : \"days\";\n if (unit === \"weeks\") return v === 1 ? \"week\" : \"weeks\";\n if (unit === \"months\") return v === 1 ? \"month\" : \"months\";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} → ${endLabel}`;\n }\n return `${startLabel} → ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n lowCapacityThreshold = 3,\n currency,\n schedulingMode = \"intraday\",\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n emptyMessage = \"No available slots\",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === \"multi_day\";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const rawSlots = slotsProp ?? fetched;\n // Drop slots that have already elapsed (or fall within the lead-time\n // window). Default behaviour because nothing the merchant can do at\n // the backend stops a client clock from being slightly ahead of the\n // last availability response — this is the same defence other\n // booking flows ship by default.\n const slots = useMemo(() => {\n if (!hideElapsedSlots) return rawSlots;\n const cutoff = Date.now() + minLeadMinutes * 60_000;\n return rawSlots.filter((slot) => {\n const start = Date.parse(slot.start_time);\n return Number.isNaN(start) || start >= cutoff;\n });\n }, [rawSlots, hideElapsedSlots, minLeadMinutes]);\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: \"\", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : \"\";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || \"all\"}\n data-cimplify-slot-group\n className={cn(\"flex flex-col gap-2\", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n \"text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? \"flex flex-col gap-2\"\n : \"grid grid-cols-3 sm:grid-cols-4 gap-2\",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n const capacity = slot.capacity_available ?? slot.remaining_capacity;\n const soldOut = capacity === 0;\n const unavailable = slot.is_available === false || soldOut;\n const lowCapacity =\n lowCapacityThreshold > 0 &&\n capacity != null &&\n capacity > 0 &&\n capacity <= lowCapacityThreshold;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={unavailable}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n \"inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through\",\n isMultiDay && \"justify-between text-left\",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn(\"text-xs opacity-70\", classNames?.slotPrice)}\n >\n <Price amount={slot.price} currency={currency} />\n </span>\n )}\n {lowCapacity && (\n <span\n data-cimplify-slot-scarcity\n className={cn(\"text-xs font-medium text-amber-600\", classNames?.slotScarcity)}\n >\n {capacity} left\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n"
|
|
13
|
+
"content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport React, { useMemo } from \"react\";\nimport type { AvailableSlot } from \"@cimplify/sdk\";\nimport type { DurationUnit, SchedulingMode } from \"@cimplify/sdk\";\nimport type { CurrencyCode } from \"@cimplify/sdk\";\nimport { useAvailableSlots } from \"@cimplify/sdk/react\";\nimport { Price } from \"@cimplify/sdk/react\";\nimport { cn } from \"@cimplify/sdk/react\";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n slotScarcity?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID — used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) — used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `\"multi_day\"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Show a \"N left\" urgency tag when a slot's remaining capacity is at or below\n * this number (and above zero). Slots at zero capacity render as sold out.\n * Default: 3. Set to 0 to disable scarcity tags.\n */\n lowCapacityThreshold?: number;\n /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\n /**\n * Hide slots whose `start_time` is already in the past. Default: true.\n * Set to false to show elapsed slots greyed-out (still un-selectable).\n */\n hideElapsedSlots?: boolean;\n /**\n * Minimum lead time (in minutes) before a slot can be booked. Slots whose\n * start is sooner than `now + minLeadMinutes` are filtered out (when\n * `hideElapsedSlots` is true) or marked unavailable. Default: 0.\n */\n minLeadMinutes?: number;\n /**\n * Service scheduling mode. When `\"multi_day\"`, each slot renders as a\n * stay summary (`\"3 nights: Fri Apr 5, 3:00 PM → Mon Apr 8, 11:00 AM\"`)\n * instead of the time-of-day label. Defaults to `\"intraday\"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit — used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value — used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n /**\n * Phone number offered when a day has no bookable times. A service can be\n * unbookable for reasons the storefront cannot see, and a bare empty grid\n * reads as a broken site rather than a shop to call.\n */\n contactPhone?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): \"morning\" | \"afternoon\" | \"evening\" {\n const hour = parseInt(timeStr.split(\"T\").pop()?.split(\":\")[0] ?? timeStr.split(\":\")[0], 10);\n if (hour < 12) return \"morning\";\n if (hour < 17) return \"afternoon\";\n return \"evening\";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: \"Morning\",\n afternoon: \"Afternoon\",\n evening: \"Evening\",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return ([\"morning\", \"afternoon\", \"evening\"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: \"numeric\", minute: \"2-digit\" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(\":\");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? \"PM\" : \"AM\";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? \"day\" : \"days\";\n const v = value ?? 1;\n if (unit === \"minutes\") return v === 1 ? \"minute\" : \"minutes\";\n if (unit === \"hours\") return v === 1 ? \"hour\" : \"hours\";\n if (unit === \"days\") return v === 1 ? \"day\" : \"days\";\n if (unit === \"weeks\") return v === 1 ? \"week\" : \"weeks\";\n if (unit === \"months\") return v === 1 ? \"month\" : \"months\";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: \"short\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} → ${endLabel}`;\n }\n return `${startLabel} → ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n lowCapacityThreshold = 3,\n currency,\n schedulingMode = \"intraday\",\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\n emptyMessage = \"No available slots\",\n contactPhone,\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === \"multi_day\";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const rawSlots = slotsProp ?? fetched;\n // Drop slots that have already elapsed (or fall within the lead-time\n // window). Default behaviour because nothing the merchant can do at\n // the backend stops a client clock from being slightly ahead of the\n // last availability response — this is the same defence other\n // booking flows ship by default.\n const slots = useMemo(() => {\n if (!hideElapsedSlots) return rawSlots;\n const cutoff = Date.now() + minLeadMinutes * 60_000;\n return rawSlots.filter((slot) => {\n const start = Date.parse(slot.start_time);\n return Number.isNaN(start) || start >= cutoff;\n });\n }, [rawSlots, hideElapsedSlots, minLeadMinutes]);\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy=\"true\"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n {contactPhone ? (\n <p>\n <a href={`tel:${contactPhone.replace(/\\s+/g, \"\")}`}>Call {contactPhone} to book</a>\n </p>\n ) : null}\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: \"\", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : \"\";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn(\"flex flex-col gap-4\", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || \"all\"}\n data-cimplify-slot-group\n className={cn(\"flex flex-col gap-2\", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n \"text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? \"flex flex-col gap-2\"\n : \"grid grid-cols-3 sm:grid-cols-4 gap-2\",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n const capacity = slot.capacity_available ?? slot.remaining_capacity;\n const soldOut = capacity === 0;\n const unavailable = slot.is_available === false || soldOut;\n const lowCapacity =\n lowCapacityThreshold > 0 &&\n capacity != null &&\n capacity > 0 &&\n capacity <= lowCapacityThreshold;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={unavailable}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n \"inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through\",\n isMultiDay && \"justify-between text-left\",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn(\"text-xs opacity-70\", classNames?.slotPrice)}\n >\n <Price amount={slot.price} currency={currency} />\n </span>\n )}\n {lowCapacity && (\n <span\n data-cimplify-slot-scarcity\n className={cn(\"text-xs font-medium text-amber-600\", classNames?.slotScarcity)}\n >\n {capacity} left\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n"
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
}
|