Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 138 additions & 9 deletions app/explore/experts/[consultantId]/ExpertProfileClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
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,
Expand All @@ -42,6 +50,11 @@
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [slotTimings, setSlotTimings] = useState<TSlotTiming[]>([]);
const [selectedSlot, setSelectedSlot] = useState<TSlotTiming | null>(null);
const [monthAvailability, setMonthAvailability] = useState<
Record<string, DayStatus>
>({});
const [isMonthSummaryReady, setIsMonthSummaryReady] = useState(false);
const monthFetchIdRef = useRef(0);

const timezone = browserTimezone || userDetails?.timezone;

Expand Down Expand Up @@ -110,6 +123,85 @@
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 () => {

Check failure on line 129 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300CbmpqyW&open=AaAufgSZmR300CbmpqyW&pullRequest=1229
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<string, DayStatus> = {};
for (const [dateKey, slots] of Object.entries(
(data ?? {}) as Record<string, (TSlotTiming & { _isPast?: boolean })[]>,
)) {
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";

Check warning on line 187 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300CbmpqyX&open=AaAufgSZmR300CbmpqyX&pullRequest=1229
}

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) {
Expand Down Expand Up @@ -195,7 +287,7 @@
[consultantDetails, toast],
);

const renderCalendar = useCallback(() => {

Check failure on line 290 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 38 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300CbmpqyY&open=AaAufgSZmR300CbmpqyY&pullRequest=1229
const daysInMonth = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
Expand All @@ -208,12 +300,20 @@
).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(
<div key={`empty-${i}`} className="w-10 h-10 lg:w-11 lg:h-11"></div>,
);
days.push(<div key={`empty-${i}`} className={cellClass} />);
}

for (let i = 1; i <= daysInMonth; i++) {
Expand All @@ -226,28 +326,57 @@
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"

Check warning on line 343 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300Cbmpqya&open=AaAufgSZmR300Cbmpqya&pullRequest=1229

Check warning on line 343 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300CbmpqyZ&open=AaAufgSZmR300CbmpqyZ&pullRequest=1229

Check warning on line 343 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300Cbmpqyb&open=AaAufgSZmR300Cbmpqyb&pullRequest=1229
: status === "open"
? "bg-emerald-400"
: status === "partial"
? "bg-amber-400"
: status === "full"
? "bg-rose-400"
: "bg-zinc-700";

Check warning on line 350 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300Cbmpqye&open=AaAufgSZmR300Cbmpqye&pullRequest=1229

Check warning on line 350 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300Cbmpqyc&open=AaAufgSZmR300Cbmpqyc&pullRequest=1229

Check warning on line 350 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300Cbmpqyd&open=AaAufgSZmR300Cbmpqyd&pullRequest=1229

days.push(
<button
key={i}
className={`w-10 h-10 lg:w-11 lg:h-11 rounded-full text-base font-medium transition-all duration-200 flex items-center justify-center
className={`${cellClass} rounded-full text-xs sm:text-sm lg:text-base font-medium transition-all duration-200 flex flex-col items-center justify-center gap-0.5
${
isSelected
? "bg-white text-zinc-900 shadow-md"
: "text-zinc-300 hover:bg-zinc-700/60"
: isDisabled
? "text-zinc-600 cursor-not-allowed"
: "text-zinc-300 hover:bg-zinc-700/60"

Check warning on line 361 in app/explore/experts/[consultantId]/ExpertProfileClient.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AaAufgSZmR300Cbmpqyf&open=AaAufgSZmR300Cbmpqyf&pullRequest=1229
}`}
onClick={() => {
setSelectedDate(date);
setSelectedSlot(null);
}}
disabled={isDisabled}
>
{i}
<span className="leading-none">{i}</span>
{/* Placeholder keeps the number baseline identical on every day */}
<span
className={`h-1 w-1 rounded-full ${isPast ? "bg-transparent" : dotClass}`}
/>
</button>,
);
}

return days;
}, [currentDate, selectedDate]);
}, [currentDate, selectedDate, monthAvailability, isMonthSummaryReady, timezone]);

return (
<main className="bg-muted">
Expand Down Expand Up @@ -322,7 +451,7 @@
setSelectedSlot={setSelectedSlot}
timezone={timezone || "UTC"}
autoOpenTrial={autoOpenTrial}
onRefreshSlots={fetchSlots}
onRefreshSlots={refreshSlots}
/>
</motion.div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -344,29 +353,45 @@ export default function ConsultationPricingToggle({
Book Now
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] lg:max-w-[950px] xl:max-w-[1050px] max-h-[85vh] overflow-y-auto bg-zinc-900 text-white p-0 border border-zinc-800 rounded-2xl shadow-2xl">
<DialogHeader className="p-6 lg:p-8 border-b border-zinc-800">
<DialogTitle className="text-xl lg:text-2xl font-semibold">
<DialogContent
className="z-[1002] inset-0 left-0 top-0 h-[100dvh] w-full max-w-none translate-x-0 translate-y-0 rounded-none border-0 sm:left-[50%] sm:top-[50%] sm:h-[92dvh] sm:w-[calc(100%-3rem)] sm:max-w-[1100px] lg:max-w-[1200px] sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-2xl sm:border sm:border-zinc-800 flex flex-col overflow-hidden bg-zinc-900 text-white p-0 shadow-2xl"
// Inline zIndex: arbitrary-class merge with the z-50 base in
// dialog.tsx is ordering-dependent; the nav (z-[1000]) and
// announcement bar (z-[1001]) must never paint above this.
style={{ zIndex: 1002 }}
>
<DialogHeader className="flex-none p-4 lg:p-6 border-b border-zinc-800">
<DialogTitle className="text-lg sm:text-xl lg:text-2xl font-semibold">
Book {option.title} Consultation
</DialogTitle>
<DialogDescription className="text-zinc-400 text-base">
<DialogDescription className="hidden lg:block text-zinc-400 text-sm lg:text-base">
Select a date and time for your {option.duration}{" "}
consultation
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-10 p-6 lg:p-8">
{/* 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. */}
<div className="flex-1 min-h-0 grid grid-cols-1 mdh:grid-cols-2 mdh:grid-rows-1 gap-4 md:gap-8 lg:gap-10 p-4 sm:p-6 lg:p-8 overflow-y-auto mdh:overflow-hidden">
{/* Calendar Section */}
<div>
<h3 className="text-lg font-semibold mb-5 flex items-center text-white">
<CalendarIcon className="mr-2 h-5 w-5 text-zinc-400" />{" "}
<div className="mdh:min-h-0 flex flex-col">
<h3 className="flex-none text-base sm:text-lg font-semibold mb-2 sm:mb-3 flex items-center text-white">
<CalendarIcon className="mr-2 h-4 w-4 sm:h-5 sm:w-5 text-zinc-400" />{" "}
Select a Date
</h3>
<div className="bg-zinc-800/60 p-5 lg:p-6 rounded-xl border border-zinc-700/50">
<div className="flex justify-between items-center mb-5">
<div className="mdh:min-h-0 mdh:flex-1 flex flex-col bg-zinc-800/60 p-3 sm:p-4 lg:p-5 rounded-xl border border-zinc-700/50 overflow-hidden [--cell:clamp(26px,5.5dvh,36px)] mdh:[container-type:size] mdh:[--cell:clamp(20px,calc(16.6cqh_-_22px),48px)]">
<div className="flex-none flex justify-between items-center mb-2 sm:mb-3">
<Button
variant="ghost"
size="default"
className="text-zinc-400 hover:text-white hover:bg-zinc-700/50 h-10 w-10 text-lg"
className="text-zinc-400 hover:text-white hover:bg-zinc-700/50 h-8 w-8 sm:h-10 sm:w-10 text-base sm:text-lg disabled:opacity-30"
disabled={isViewingCurrentMonth}
aria-label={
isViewingCurrentMonth
? "No earlier months"
: "Previous month"
}
onClick={() =>
setCurrentDate(
new Date(
Expand All @@ -379,7 +404,7 @@ export default function ConsultationPricingToggle({
>
&lt;
</Button>
<span className="font-semibold text-white text-lg">
<span className="font-semibold text-white text-base sm:text-lg">
{currentDate.toLocaleString("default", {
month: "long",
year: "numeric",
Expand All @@ -388,7 +413,7 @@ export default function ConsultationPricingToggle({
<Button
variant="ghost"
size="default"
className="text-zinc-400 hover:text-white hover:bg-zinc-700/50 h-10 w-10 text-lg"
className="text-zinc-400 hover:text-white hover:bg-zinc-700/50 h-8 w-8 sm:h-10 sm:w-10 text-base sm:text-lg"
onClick={() =>
setCurrentDate(
new Date(
Expand All @@ -402,7 +427,7 @@ export default function ConsultationPricingToggle({
&gt;
</Button>
</div>
<div className="grid grid-cols-7 gap-3 text-center text-base font-medium text-zinc-400 mb-3">
<div className="flex-none grid grid-cols-7 justify-items-center gap-x-1 sm:gap-x-3 text-center text-xs sm:text-base font-medium text-zinc-400 mb-1.5 sm:mb-3">
<div>Mo</div>
<div>Tu</div>
<div>We</div>
Expand All @@ -411,17 +436,19 @@ export default function ConsultationPricingToggle({
<div>Sa</div>
<div>Su</div>
</div>
<div className="grid grid-cols-7 gap-2">
<div className="grid grid-cols-7 justify-items-center gap-x-1 gap-y-1 sm:gap-x-2 sm:gap-y-2">
{renderCalendar()}
</div>
{/* Dot colors share the slot list legend below — no
separate calendar legend needed. */}
</div>
</div>

{/* Available Slots Section */}
<div>
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold flex items-center text-white">
<ClockIcon className="mr-2 h-5 w-5 text-zinc-400" />{" "}
<div className="mdh:min-h-0 flex flex-col">
<div className="flex-none flex items-center justify-between mb-3">
<h3 className="text-base sm:text-lg font-semibold flex items-center text-white">
<ClockIcon className="mr-2 h-4 w-4 sm:h-5 sm:w-5 text-zinc-400" />{" "}
Available {selectedDuration} hour Slots
</h3>
{onRefreshSlots && (
Expand All @@ -446,8 +473,8 @@ export default function ConsultationPricingToggle({
)}
</div>
{consultantDetails?.scheduleType && (
<div className="mb-4 p-3 bg-zinc-800/40 rounded-xl border border-zinc-700/50">
<p className="text-sm text-zinc-400">
<div className="flex-none hidden mdh:block mb-3 p-2.5 bg-zinc-800/40 rounded-xl border border-zinc-700/50">
<p className="text-xs sm:text-sm text-zinc-400">
This consultant prefers{" "}
<span
className={`px-2 py-1 rounded text-xs font-medium ${
Expand All @@ -464,7 +491,7 @@ export default function ConsultationPricingToggle({
</p>
</div>
)}
<div className="grid grid-cols-1 gap-3 max-h-[350px] overflow-y-auto pr-2">
<div className="mdh:min-h-0 mdh:flex-1 grid grid-cols-1 content-start gap-2.5 sm:gap-3 max-h-[30dvh] mdh:max-h-none overflow-y-auto pr-1 sm:pr-2">
{availableSlots.length > 0 ? (
<>
{availableSlots.map((slot, index) => {
Expand All @@ -485,7 +512,7 @@ export default function ConsultationPricingToggle({
return (
<button
key={`${slot.slotId}-${index}`}
className={`w-full p-4 text-base font-medium transition-all duration-200 rounded-xl text-left
className={`w-full p-3 sm:p-4 text-sm sm:text-base font-medium transition-all duration-200 rounded-xl text-left
${
isSelected
? "bg-white text-zinc-900 shadow-md ring-2 ring-white"
Expand Down Expand Up @@ -575,9 +602,9 @@ export default function ConsultationPricingToggle({
</div>
</div>
</div>
<div className="bg-zinc-800/50 px-6 lg:px-8 py-5 flex justify-end rounded-b-2xl border-t border-zinc-800">
<div className="flex-none bg-zinc-800/50 px-4 sm:px-6 lg:px-8 py-3 lg:py-4 flex justify-end rounded-b-2xl border-t border-zinc-800">
<Button
className="bg-white text-zinc-900 hover:bg-zinc-100 font-medium px-8 h-12 text-base"
className="bg-white text-zinc-900 hover:bg-zinc-100 font-medium px-6 sm:px-8 h-10 sm:h-12 text-sm sm:text-base"
onClick={
selectedSlot?.isAllocated
? handleRequestForApproval
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export function ExpertPricing({
const hasSubscriptions = subscriptionOptions.length > 0;

return (
<div className="sticky top-24 space-y-4">
<div className="xl:sticky xl:top-[calc(var(--header-height,5rem)+1rem)] space-y-4">
{/* Profile Image Card — refined, no flat border */}
<div className="rounded-3xl overflow-hidden shadow-2xl shadow-black/30 ring-1 ring-white/10">
<div className="aspect-[4/3] relative">
Expand Down
Loading
Loading