TimeSlotPicker.tsx155 lines · main
1import { useCallback, useMemo, useRef, useState } from 'react'
2import { cn } from 'ui'
3
4import type { TimeSlot } from './types'
5
6interface TimeSlotPickerProps {
7 slots: TimeSlot[]
8 timezone: string
9 selectedSlot: TimeSlot | null
10 onSelectSlot: (slot: TimeSlot) => void
11 onBack: () => void
12 selectedDate: string
13}
14
15export default function TimeSlotPicker({
16 slots,
17 timezone,
18 selectedSlot,
19 onSelectSlot,
20 onBack,
21 selectedDate,
22}: TimeSlotPickerProps) {
23 const dateLabel = useMemo(() => {
24 const [year, month, day] = selectedDate.split('-').map(Number)
25 const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0))
26 return date.toLocaleDateString('en-US', {
27 weekday: 'long',
28 month: 'long',
29 day: 'numeric',
30 timeZone: timezone,
31 })
32 }, [selectedDate, timezone])
33
34 const formattedSlots = useMemo(
35 () =>
36 slots.map((slot) => ({
37 ...slot,
38 label: new Date(slot.startMillisUtc).toLocaleTimeString('en-US', {
39 hour: 'numeric',
40 minute: '2-digit',
41 timeZone: timezone,
42 }),
43 })),
44 [slots, timezone]
45 )
46
47 return (
48 <div className="w-full">
49 <div className="flex items-center gap-2 mb-4">
50 <button
51 type="button"
52 onClick={onBack}
53 className="p-1.5 rounded-md text-foreground-lighter hover:bg-surface-300/50 hover:text-foreground transition-colors"
54 aria-label="Back to calendar"
55 >
56 <svg
57 width="16"
58 height="16"
59 viewBox="0 0 16 16"
60 fill="none"
61 xmlns="http://www.w3.org/2000/svg"
62 >
63 <path
64 d="M10 12L6 8L10 4"
65 stroke="currentColor"
66 strokeWidth="1.5"
67 strokeLinecap="round"
68 strokeLinejoin="round"
69 />
70 </svg>
71 </button>
72 <span className="text-foreground font-medium text-sm">
73 {dateLabel}
74 <span className="text-foreground-lighter font-normal">
75 {' · '}
76 {
77 new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short' })
78 .formatToParts(new Date())
79 .find((p) => p.type === 'timeZoneName')?.value
80 }
81 </span>
82 </span>
83 </div>
84
85 {formattedSlots.length === 0 ? (
86 <p className="text-foreground-lighter text-sm text-center py-8">
87 No available times for this date.
88 </p>
89 ) : (
90 <ScrollFadeList>
91 {formattedSlots.map((slot, idx) => {
92 const isSelected = selectedSlot?.startMillisUtc === slot.startMillisUtc
93 return (
94 <button
95 key={`${idx}-${slot.startMillisUtc}`}
96 type="button"
97 onClick={() => onSelectSlot(slot)}
98 className={cn(
99 'w-full px-4 py-2.5 rounded-md text-sm text-left transition-colors border',
100 isSelected
101 ? 'border-brand-500 bg-brand-500/10 text-brand-500 font-medium'
102 : 'border-muted text-foreground hover:border-foreground-lighter hover:bg-surface-300/30'
103 )}
104 >
105 {slot.label}
106 </button>
107 )
108 })}
109 </ScrollFadeList>
110 )}
111 </div>
112 )
113}
114
115function ScrollFadeList({ children }: { children: React.ReactNode }) {
116 const ref = useRef<HTMLDivElement>(null)
117 const [fadeTop, setFadeTop] = useState(false)
118 const [fadeBottom, setFadeBottom] = useState(false)
119
120 const updateFades = useCallback(() => {
121 const el = ref.current
122 if (!el) return
123 setFadeTop(el.scrollTop > 2)
124 setFadeBottom(el.scrollTop + el.clientHeight < el.scrollHeight - 2)
125 }, [])
126
127 const onRef = useCallback(
128 (el: HTMLDivElement | null) => {
129 ;(ref as React.MutableRefObject<HTMLDivElement | null>).current = el
130 if (el) {
131 updateFades()
132 }
133 },
134 [updateFades]
135 )
136
137 const top = fadeTop ? 'transparent, black 12px' : 'black, black'
138 const bottom = fadeBottom ? 'black calc(100% - 12px), transparent' : 'black, black'
139 const mask = `linear-gradient(to bottom, ${top}, ${bottom})`
140
141 return (
142 <div
143 ref={onRef}
144 onScroll={updateFades}
145 className="flex flex-col gap-2 max-h-[320px] overflow-y-auto pr-1 py-1"
146 style={{
147 maskImage: mask,
148 WebkitMaskImage: mask,
149 transition: 'mask-image 0.15s ease, -webkit-mask-image 0.15s ease',
150 }}
151 >
152 {children}
153 </div>
154 )
155}