diff --git a/app/explore/experts/[consultantId]/ExpertProfileClient.tsx b/app/explore/experts/[consultantId]/ExpertProfileClient.tsx index 5d1d4bb2f..dc7e9f65d 100644 --- a/app/explore/experts/[consultantId]/ExpertProfileClient.tsx +++ b/app/explore/experts/[consultantId]/ExpertProfileClient.tsx @@ -26,6 +26,14 @@ interface ExpertProfileClientProps { reviews: TConsultantReview[]; } +// Per-date rollup shown as dots under each calendar day. Derived client-side +// from the same range response the dialog uses for its slot list. +// - open: at least one plainly free slot +// - partial: bookable but every free slot needs approval / is partially taken +// - full: slots exist but none are bookable (fully booked or past) +// Days missing from the map have no slots at all. +type DayStatus = "open" | "partial" | "full"; + export function ExpertProfileClient({ consultantDetails, userDetails, @@ -42,6 +50,11 @@ export function ExpertProfileClient({ const [selectedDate, setSelectedDate] = useState(new Date()); const [slotTimings, setSlotTimings] = useState([]); const [selectedSlot, setSelectedSlot] = useState(null); + const [monthAvailability, setMonthAvailability] = useState< + Record + >({}); + const [isMonthSummaryReady, setIsMonthSummaryReady] = useState(false); + const monthFetchIdRef = useRef(0); const timezone = browserTimezone || userDetails?.timezone; @@ -110,6 +123,85 @@ export function ExpertProfileClient({ fetchSlots(); }, [fetchSlots]); + // Month-wide rollup so the calendar can show which days have slots before + // the user clicks one. Reuses the same range endpoint as fetchSlots; only + // requests today onward so past days never bloat the payload. + const fetchMonthAvailability = useCallback(async () => { + if (!consultantDetails || !timezone || isTimezoneLoading) return; + + const now = new Date(); + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const monthStart = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + 1, + ); + const startDateInUtc = monthStart > todayStart ? monthStart : todayStart; + const endDateInUtc = new Date( + currentDate.getFullYear(), + currentDate.getMonth() + 1, + 0, + 23, + 59, + 59, + 999, + ); + if (startDateInUtc > endDateInUtc) return; + + setMonthAvailability({}); + setIsMonthSummaryReady(false); + const requestId = ++monthFetchIdRef.current; + + try { + const response = await fetch( + `/api/slots/availability-with-allocation/${ + consultantDetails.id + }?startDateInUtc=${startDateInUtc.toISOString()}&endDateInUtc=${endDateInUtc.toISOString()}&timezone=${encodeURIComponent(timezone)}`, + ); + + if (!response.ok) { + throw new Error("Failed to fetch month availability"); + } + + const { data } = await response.json(); + if (requestId !== monthFetchIdRef.current) return; // stale month flip + + const summary: Record = {}; + for (const [dateKey, slots] of Object.entries( + (data ?? {}) as Record, + )) { + let hasOpen = false; + let hasPartial = false; + for (const slot of slots ?? []) { + if ((slot as TSlotTiming & { _isPast?: boolean })._isPast) continue; + const status = slot.bookingStatus || "available"; + if (status === "fully-booked") continue; + if (status === "partially-booked" || slot.isAllocated) + hasPartial = true; + else hasOpen = true; + } + summary[dateKey] = hasOpen ? "open" : hasPartial ? "partial" : "full"; + } + + setMonthAvailability(summary); + setIsMonthSummaryReady(true); + } catch (error) { + console.error("Error fetching month availability:", error); + } + }, [currentDate, consultantDetails, timezone, isTimezoneLoading]); + + useEffect(() => { + fetchMonthAvailability(); + }, [fetchMonthAvailability]); + + const refreshSlots = useCallback(async () => { + await Promise.all([fetchSlots(), fetchMonthAvailability()]); + }, [fetchSlots, fetchMonthAvailability]); + const handleConsultationBooking = useCallback( async (consultationPlanId: string) => { if (!selectedSlot || !consultantDetails) { @@ -208,12 +300,20 @@ export function ExpertProfileClient({ ).getDay(); const adjustedFirstDay = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1; - const days = []; + const now = new Date(); + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + // Cell size comes from --cell on the calendar card (clamp of viewport + // height) so 6 rows + chrome always fit inside the dialog without it + // growing past the screen; width shrinks with it on narrow panes too. + const cellClass = "h-[var(--cell,40px)] w-[var(--cell,40px)]"; + const days: JSX.Element[] = []; for (let i = 0; i < adjustedFirstDay; i++) { - days.push( -
, - ); + days.push(
); } for (let i = 1; i <= daysInMonth; i++) { @@ -226,28 +326,57 @@ export function ExpertProfileClient({ selectedDate?.getDate() === i && selectedDate?.getMonth() === currentDate.getMonth() && selectedDate?.getFullYear() === currentDate.getFullYear(); + const dateKey = formatInTimeZone(date, timezone || "UTC", "yyyy-MM-dd"); + const status = monthAvailability[dateKey]; + const isPast = date < todayStart; + // Past days and days with zero slots are not clickable; fully-booked + // days stay clickable so the rose slot list explains why. + const isDisabled = isPast || (isMonthSummaryReady && !status); + + const dotClass = isSelected + ? status === "open" + ? "bg-emerald-600" + : status === "partial" + ? "bg-amber-500" + : status === "full" + ? "bg-rose-500" + : "bg-zinc-300" + : status === "open" + ? "bg-emerald-400" + : status === "partial" + ? "bg-amber-400" + : status === "full" + ? "bg-rose-400" + : "bg-zinc-700"; days.push( , ); } return days; - }, [currentDate, selectedDate]); + }, [currentDate, selectedDate, monthAvailability, isMonthSummaryReady, timezone]); return (
@@ -322,7 +451,7 @@ export function ExpertProfileClient({ setSelectedSlot={setSelectedSlot} timezone={timezone || "UTC"} autoOpenTrial={autoOpenTrial} - onRefreshSlots={fetchSlots} + onRefreshSlots={refreshSlots} />
diff --git a/app/explore/experts/[consultantId]/components/ConsultationPricingToggle.tsx b/app/explore/experts/[consultantId]/components/ConsultationPricingToggle.tsx index dd421ad0a..2d9edd2f1 100644 --- a/app/explore/experts/[consultantId]/components/ConsultationPricingToggle.tsx +++ b/app/explore/experts/[consultantId]/components/ConsultationPricingToggle.tsx @@ -87,6 +87,15 @@ export default function ConsultationPricingToggle({ const selectedDuration = activePlanOption?.durationInHours ?? 1; + // Past days are disabled in the grid, so browsing earlier months is pointless. + const isViewingCurrentMonth = useMemo(() => { + const now = new Date(); + return ( + currentDate.getFullYear() === now.getFullYear() && + currentDate.getMonth() === now.getMonth() + ); + }, [currentDate]); + const availableSlots = useMemo((): SlotWithStatus[] => { if ( !slotTimings || @@ -344,29 +353,45 @@ export default function ConsultationPricingToggle({ Book Now - - - + + + Book {option.title} Consultation - + Select a date and time for your {option.duration}{" "} consultation -
+ {/* Stretch-to-fit body: the dialog never scrolls on mdh+ + (wide AND tall) screens — each pane flexes and only the + slot list scrolls internally. Short/wide screens fall + back to the stacked, body-scrollable layout. */} +
{/* Calendar Section */} -
-

- {" "} +
+

+ {" "} Select a Date

-
-
+
+
- + {currentDate.toLocaleString("default", { month: "long", year: "numeric", @@ -388,7 +413,7 @@ export default function ConsultationPricingToggle({
-
+
Mo
Tu
We
@@ -411,17 +436,19 @@ export default function ConsultationPricingToggle({
Sa
Su
-
+
{renderCalendar()}
+ {/* Dot colors share the slot list legend below — no + separate calendar legend needed. */}
{/* Available Slots Section */} -
-
-

- {" "} +
+
+

+ {" "} Available {selectedDuration} hour Slots

{onRefreshSlots && ( @@ -446,8 +473,8 @@ export default function ConsultationPricingToggle({ )}
{consultantDetails?.scheduleType && ( -
-

+

+

This consultant prefers{" "}

)} -
+
{availableSlots.length > 0 ? ( <> {availableSlots.map((slot, index) => { @@ -485,7 +512,7 @@ export default function ConsultationPricingToggle({ return (
-
+