From ee40805ceb4d036eba271ee7219f460a2ae6ae20 Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:23:48 +0530 Subject: [PATCH 1/5] fix(offerings): open the editor for consultation and subscription plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing either type always 404'd. The edit page built its candidate list from the planner query, but that payload carries only webinars, classes and participant counts — consultationPlans/subscriptionPlans were never on it, so notFound() fired unconditionally for those two. Each type now reads the source that actually owns it, and only that one runs. Also in the authoring path: - The planner's Edit control no longer navigates with a missing id. Both spellings produced an unreachable URL (`id ?? ""` collapsed to a double slash, a bare id stringified "undefined"), and the card now disables Edit and Delete for a row that has no id to address. - Breadcrumbs stop linking segments that own no page. `offerings` and `participants` only have dynamic children, and a dynamic param's VALUE is never a URL of its own, so Next was prefetching routes that 404. - ClassPlanSchema's start date is renamed to the field the manifest authors (`schedulingStartDate`); it was `startDate` here, so the resolver stripped the value and no class ever sent one. The adapter now maps it to the API's ISO `startDate` and hydrates it back from the Class row's schedulingPeriodStartsAt. - SubscriptionPlanSchema gains `subscriptionContents`. Absent, the resolver stripped the roadmap and every save posted an empty list, which the PUT treats as "replace with nothing". - The consultation and subscription list reads now include `faqs`. The editor hydrates from them and PUTs the whole array back, so omitting them meant saving an empty set over a plan's FAQ. Co-authored-by: Cursor --- .../bookings/classes/crud-with-plan/route.ts | 4 +- app/api/plans/consultations/route.ts | 5 +- app/api/plans/subscriptions/route.ts | 4 + .../[type]/[offeringId]/edit/page.tsx | 61 +++++-- .../consultant/[consultantId]/layout.tsx | 33 +++- components/offerings/editor/adapters.ts | 34 +++- components/planner/components/EventCard.tsx | 12 +- .../planner/components/EventCarousel.tsx | 1 + .../components/EventManagementDashboard.tsx | 28 ++- components/planner/hooks/usePlanner.ts | 12 +- schemas/plans.ts | 166 ++++++++++-------- 11 files changed, 254 insertions(+), 106 deletions(-) diff --git a/app/api/bookings/classes/crud-with-plan/route.ts b/app/api/bookings/classes/crud-with-plan/route.ts index 1c7c12f1f..9954685bb 100644 --- a/app/api/bookings/classes/crud-with-plan/route.ts +++ b/app/api/bookings/classes/crud-with-plan/route.ts @@ -32,7 +32,9 @@ const ClassContentInputSchema = ClassContentSchema.omit({ const PostClassWithPlanBodySchema = ClassPlanSchema.omit({ planType: true, consultantProfile: true, - startDate: true, + // The form's Date-valued field; this endpoint takes an ISO `startDate` + // string, re-declared below. + schedulingStartDate: true, endDate: true, topics: true, classContents: true, // Omit to override with input schema diff --git a/app/api/plans/consultations/route.ts b/app/api/plans/consultations/route.ts index 4a414faf9..4c27c488e 100644 --- a/app/api/plans/consultations/route.ts +++ b/app/api/plans/consultations/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { ConsultationPlanSchema } from "@/schemas/plans"; import { findOrCreateTopics, transformTopicsToStrings } from "@/lib/topics"; import { marketplaceVisibilityWhere } from "@/lib/api/plans/visibility"; -import { faqCreateNested } from "@/lib/api/plans/content"; +import { faqCreateNested, planContentInclude } from "@/lib/api/plans/content"; import * as Sentry from "@sentry/nextjs"; import { getSession } from "@/lib/auth-server"; export async function GET(request: NextRequest) { @@ -26,6 +26,9 @@ export async function GET(request: NextRequest) { include: { consultantProfile: true, topics: true, + // The offering editor hydrates from this list and PUTs the whole FAQ + // array back, so a list that omits them saves an empty set over them. + ...planContentInclude, }, skip, take: limit, diff --git a/app/api/plans/subscriptions/route.ts b/app/api/plans/subscriptions/route.ts index 330836309..022fb725d 100644 --- a/app/api/plans/subscriptions/route.ts +++ b/app/api/plans/subscriptions/route.ts @@ -4,6 +4,7 @@ import { SubscriptionPlanSchema } from "@/schemas/plans"; import { curriculumCreateNested, faqCreateNested, + planContentInclude, } from "@/lib/api/plans/content"; import { findOrCreateTopics, transformTopicsToStrings } from "@/lib/topics"; import { SlotCalculationService } from "@/utils/slotAllocation/SlotCalculationService"; @@ -35,6 +36,9 @@ export async function GET(request: NextRequest) { subscriptionContents: { orderBy: { order: "asc" }, }, + // The offering editor hydrates from this list and PUTs the whole FAQ + // array back, so a list that omits them saves an empty set over them. + ...planContentInclude, }, skip, take: limit, diff --git a/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx index ee9e6e27a..8cb05f0b4 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx @@ -9,14 +9,36 @@ import { import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { PlannerSkeleton } from "@/components/dashboard/DashboardSkeletons"; import { createConsultantQueries } from "@/lib/dashboard-queries"; +import { + useConsultationPlans, + useSubscriptionPlans, +} from "@/components/planner/hooks/usePlanner"; import { OfferingEditorContainer } from "@/components/offerings/editor/OfferingEditorContainer"; import { OFFERING_MANIFESTS } from "@/components/offerings/editor/manifests"; import type { OfferingType } from "@/components/offerings/editor/manifest"; /** - * Editing reuses the planner's own query rather than inventing a per-offering - * fetch: the planner already loads every offering this consultant owns, so the - * row is usually in cache and the editor opens without a spinner. + * The editor's adapters read the plan out of a planner event wrapper, so the + * flat rows /api/plans/* returns are wrapped exactly as the planner list wraps + * them (EventManagementDashboard). The wrapper's id is the plan id, which is + * also what the planner's Edit button puts in the URL. + */ +function toPlanEvents( + type: "consultation" | "subscription", + plans: Array<{ id: string }> | undefined, +) { + const planKey = + type === "consultation" ? "consultationPlan" : "subscriptionPlan"; + return (plans ?? []).map((plan) => ({ type, id: plan.id, [planKey]: plan })); +} + +/** + * The row being edited comes from whichever query owns that offering type. The + * planner payload carries webinars and classes ONLY, so consultation and + * subscription are read from the same /api/plans/* endpoints the planner list + * uses — sourcing all four from the planner made those two 404 every time. + * + * Only the query the requested type needs runs; the other two stay disabled. */ export default function EditOfferingPage() { const params = useParams(); @@ -25,11 +47,23 @@ export default function EditOfferingPage() { const offeringId = params.offeringId as string; const plannerQuery = createConsultantQueries(consultantId).planner; - const { data, isLoading } = useQuery(plannerQuery); + const planner = useQuery({ + ...plannerQuery, + enabled: type === "webinar" || type === "class", + }); + const consultations = useConsultationPlans(consultantId, { + enabled: type === "consultation", + }); + const subscriptions = useSubscriptionPlans(consultantId, { + enabled: type === "subscription", + }); if (!OFFERING_MANIFESTS[type]) notFound(); - if (isLoading) { + // notFound() must stay unreachable while a source is still in flight, or the + // editor 404s on a row that was about to arrive. A disabled query reports + // isLoading false, so this gate only waits on the one that is running. + if (planner.isLoading || consultations.isLoading || subscriptions.isLoading) { return ( <> @@ -40,12 +74,17 @@ export default function EditOfferingPage() { ); } - const events = [ - ...((data as { consultationPlans?: unknown[] })?.consultationPlans ?? []), - ...((data as { subscriptionPlans?: unknown[] })?.subscriptionPlans ?? []), - ...((data as { webinars?: unknown[] })?.webinars ?? []), - ...((data as { classes?: unknown[] })?.classes ?? []), - ]; + let events: unknown[]; + if (type === "consultation") { + events = toPlanEvents("consultation", consultations.data); + } else if (type === "subscription") { + events = toPlanEvents("subscription", subscriptions.data); + } else { + events = [ + ...(planner.data?.webinars ?? []), + ...(planner.data?.classes ?? []), + ]; + } const initialEvent = events.find( (event) => (event as { id?: string })?.id === offeringId, diff --git a/app/dashboard/consultant/[consultantId]/layout.tsx b/app/dashboard/consultant/[consultantId]/layout.tsx index 5e8568ffb..1e21cd378 100644 --- a/app/dashboard/consultant/[consultantId]/layout.tsx +++ b/app/dashboard/consultant/[consultantId]/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { usePathname, useRouter } from "next/navigation"; +import { useParams, usePathname, useRouter } from "next/navigation"; import { use, useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { motion } from "framer-motion"; @@ -156,6 +156,16 @@ const PAGE_LABELS: Record = { help: "Help", }; +// Segments that group routes without owning a page of their own — a crumb that +// links one makes Next prefetch a URL that 404s, filling the console with +// `?_rsc=` failures. Verified against the route tree: `offerings` has only +// `[type]/…` children and `participants` only `[eventType]/…`. +// +// The other reason a segment is not navigable — being the VALUE of a dynamic +// param, as `subscription` is in `…/offerings/subscription//edit` — needs no +// list, because useParams() already names every one of them. +const PATHLESS_SEGMENTS = new Set(["offerings", "participants"]); + // Opaque record ids (cuid / uuid) in nested routes carry no meaning as crumbs. const looksLikeRecordId = (segment: string) => /^[a-z0-9]{20,}$/i.test(segment) || @@ -363,6 +373,7 @@ function ConsultantLayoutInner({ const consultantId = resolvedParams.consultantId; const basePath = `/dashboard/consultant/${consultantId}`; const pathname = usePathname(); + const routeParams = useParams(); const { data: session, isPending: isSessionLoading } = useSession(); const router = useRouter(); @@ -502,10 +513,23 @@ function ConsultantLayoutInner({ const { overrideLabel } = useBreadcrumbOverride(); + // Every value the current route bound to a dynamic param. Such a segment is + // never a URL of its own, so its crumb must not be a link. + const paramValues = useMemo(() => { + const values = new Set(); + for (const value of Object.values(routeParams ?? {})) { + for (const part of Array.isArray(value) ? value : [value]) { + if (part) values.add(part); + } + } + return values; + }, [routeParams]); + // Full breadcrumb trail — every URL segment after the consultant id // becomes a crumb; opaque record ids are dropped (or replaced with an // override label such as the appointment title). Parent crumbs keep an - // href so users can click back (e.g. Appointments from a detail page). + // href so users can click back (e.g. Appointments from a detail page), + // but only when the accumulated path is a route the app can actually serve. const breadcrumbs = useMemo(() => { const parts = pathname .replace(basePath, "") @@ -526,9 +550,10 @@ function ConsultantLayoutInner({ if (overrideLabel) crumbs.push({ label: overrideLabel, href: acc }); continue; } + const navigable = !PATHLESS_SEGMENTS.has(seg) && !paramValues.has(seg); crumbs.push({ label: PAGE_LABELS[seg] ?? seg, - href: acc, + ...(navigable ? { href: acc } : {}), }); } @@ -541,7 +566,7 @@ function ConsultantLayoutInner({ } return crumb; }); - }, [pathname, basePath, overrideLabel]); + }, [pathname, basePath, overrideLabel, paramValues]); // Memoize StreamProvider children to prevent re-initialization on tab // switches. Must be called before any early returns (Rules of Hooks). diff --git a/components/offerings/editor/adapters.ts b/components/offerings/editor/adapters.ts index 724140c0d..0203abcfa 100644 --- a/components/offerings/editor/adapters.ts +++ b/components/offerings/editor/adapters.ts @@ -42,6 +42,16 @@ const sharedDefaults = { faqs: [] as { question: string; answer: string; order?: number }[], }; +/** + * The date control writes a Date; the class endpoint takes an ISO string. A + * cleared date must stay undefined rather than becoming an invalid one. + */ +const toIsoDate = (value: unknown): string | undefined => { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string" && value) return value; + return undefined; +}; + export interface OfferingAdapter { schema: ZodTypeAny; /** Which bucket the image uploader writes to. */ @@ -132,9 +142,27 @@ export const OFFERING_ADAPTERS: Record = { classContents: [], schedulingStartDate: null, }, - planOf: (event) => - (event as { classPlan?: Record })?.classPlan, + // A class's start date is authored on the plan form but persisted on the + // Class row as `schedulingPeriodStartsAt`, so it is lifted into the form + // values here and mapped back to the API's `startDate` on save. + planOf: (event) => { + const wrapper = event as { + classPlan?: Record; + schedulingPeriodStartsAt?: string | Date | null; + }; + if (!wrapper?.classPlan) return undefined; + return { + ...wrapper.classPlan, + schedulingStartDate: wrapper.schedulingPeriodStartsAt + ? new Date(wrapper.schedulingPeriodStartsAt) + : null, + }; + }, save: (values, consultantId) => - ClassService.saveClass({ classPlan: values } as never, consultantId), + ClassService.saveClass( + { classPlan: values } as never, + consultantId, + toIsoDate(values.schedulingStartDate), + ), }, }; diff --git a/components/planner/components/EventCard.tsx b/components/planner/components/EventCard.tsx index 043c261a3..4acb6143b 100644 --- a/components/planner/components/EventCard.tsx +++ b/components/planner/components/EventCard.tsx @@ -39,6 +39,11 @@ interface EventCardProps { isCollaborated?: boolean; onEdit: () => void; onDelete: () => void; + /** + * False for a row carrying no id — both actions address the offering by id, + * so neither has anything to act on. Defaults to true. + */ + canManage?: boolean; onTrialsClick?: () => void; onJoinMeeting?: () => void; canJoinNow?: boolean; @@ -248,6 +253,7 @@ export function EventCard({ onJoinMeeting, canJoinNow, isJoiningMeeting, + canManage = true, }: Readonly) { const config = eventTypeConfig[eventType]; const Icon = config.icon; @@ -319,9 +325,10 @@ export function EventCard({ + ))} + - - -
- {manifest.sections.map((section) => ( -
({ ))}
-
+ {/* + Pin to the content column, not the viewport: inset-x-0 drew the bar + under the sidebar and made the page look wider than the shell. + md:left-64 matches CollapsibleSidebar's expanded width. + */} +
{publishBlockedReason && (

From ecb87b0a844914a6816bbf24d458a4616e2879dc Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:48:17 +0530 Subject: [PATCH 4/5] fix(offerings): sticky section nav, load plan by id, clear class start date The right-panel overflow-hidden created a second scrollport so Basics / Pricing / FAQ never stuck under the context bar. Clip only the shell; sticky chrome stays on

. Edit fetches /api/plans/{type}/{id} instead of a paginated marketplace list, so ORG_ONLY and off-page plans no longer 404. Cleared class start dates serialize as null so PATCH can null schedulingPeriodStartsAt. Part of #1088. Co-authored-by: Cursor --- .../[type]/[offeringId]/edit/page.tsx | 145 +++++++++++------- .../dashboard/PersonalDashboardShell.tsx | 9 +- .../offerings/editor/OfferingEditor.tsx | 14 +- components/offerings/editor/adapters.ts | 16 +- .../planner/services/events/class-service.ts | 4 +- components/planner/services/planner.ts | 2 +- components/planner/services/types.ts | 3 +- 7 files changed, 119 insertions(+), 74 deletions(-) diff --git a/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx index 8cb05f0b4..25a4d0c6d 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx @@ -8,62 +8,109 @@ import { } from "@/components/dashboard/PageScaffold"; import { DashboardErrorBoundary } from "@/components/DashboardErrorBoundary"; import { PlannerSkeleton } from "@/components/dashboard/DashboardSkeletons"; -import { createConsultantQueries } from "@/lib/dashboard-queries"; -import { - useConsultationPlans, - useSubscriptionPlans, -} from "@/components/planner/hooks/usePlanner"; import { OfferingEditorContainer } from "@/components/offerings/editor/OfferingEditorContainer"; import { OFFERING_MANIFESTS } from "@/components/offerings/editor/manifests"; import type { OfferingType } from "@/components/offerings/editor/manifest"; +const PLAN_PATH: Record string> = { + consultation: (id) => `/api/plans/consultations/${id}`, + subscription: (id) => `/api/plans/subscriptions/${id}`, + webinar: (id) => `/api/plans/webinars/${id}`, + class: (id) => `/api/plans/classes/${id}`, +}; + /** - * The editor's adapters read the plan out of a planner event wrapper, so the - * flat rows /api/plans/* returns are wrapped exactly as the planner list wraps - * them (EventManagementDashboard). The wrapper's id is the plan id, which is - * also what the planner's Edit button puts in the URL. + * Adapters expect a planner-shaped event wrapper, not the bare plan row. + * Class start date lives on the Class instance (`schedulingPeriodStartsAt`), + * so it is lifted onto the wrapper the same way the planner list does. */ -function toPlanEvents( - type: "consultation" | "subscription", - plans: Array<{ id: string }> | undefined, -) { - const planKey = - type === "consultation" ? "consultationPlan" : "subscriptionPlan"; - return (plans ?? []).map((plan) => ({ type, id: plan.id, [planKey]: plan })); +function wrapPlanAsEvent( + type: OfferingType, + plan: Record, +): Record { + const id = String(plan.id ?? ""); + if (type === "consultation") { + return { type, id, consultationPlan: plan }; + } + if (type === "subscription") { + return { type, id, subscriptionPlan: plan }; + } + if (type === "webinar") { + // Same derivation the planner list uses: first slot start on the webinar's + // appointment. The plan row itself has no scheduledAt column. + const webinars = plan.webinars as + | Array<{ + appointment?: { + slotsOfAppointment?: Array<{ startsAt?: string | Date | null }>; + } | null; + }> + | undefined; + const scheduledAt = + webinars?.[0]?.appointment?.slotsOfAppointment?.[0]?.startsAt ?? null; + return { + type, + id, + webinarPlan: { + ...plan, + scheduledAt, + }, + }; + } + const classes = plan.classes as + | Array<{ schedulingPeriodStartsAt?: string | Date | null }> + | undefined; + const start = + classes?.find( + (row) => + row.schedulingPeriodStartsAt !== null && + row.schedulingPeriodStartsAt !== undefined, + )?.schedulingPeriodStartsAt ?? + classes?.[0]?.schedulingPeriodStartsAt ?? + null; + return { + type, + id, + classPlan: plan, + schedulingPeriodStartsAt: start, + }; } /** - * The row being edited comes from whichever query owns that offering type. The - * planner payload carries webinars and classes ONLY, so consultation and - * subscription are read from the same /api/plans/* endpoints the planner list - * uses — sourcing all four from the planner made those two 404 every time. - * - * Only the query the requested type needs runs; the other two stay disabled. + * Load the one offering being edited by id. A paginated list lookup (plus + * marketplaceVisibilityWhere on those list routes) 404'd valid plans that were + * past page one or marked ORG_ONLY — the owner's own edit URL must not depend + * on marketplace visibility or list pagination. */ +function useOfferingEvent(type: OfferingType, offeringId: string) { + return useQuery({ + queryKey: ["offering-edit", type, offeringId], + enabled: !!OFFERING_MANIFESTS[type] && !!offeringId, + queryFn: async () => { + const response = await fetch(PLAN_PATH[type](offeringId)); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Failed to load ${type} plan (${response.status})`); + } + const body = (await response.json()) as { + data?: Record; + }; + if (!body.data) return null; + return wrapPlanAsEvent(type, body.data); + }, + }); +} + export default function EditOfferingPage() { const params = useParams(); const consultantId = params.consultantId as string; const type = params.type as OfferingType; const offeringId = params.offeringId as string; - const plannerQuery = createConsultantQueries(consultantId).planner; - const planner = useQuery({ - ...plannerQuery, - enabled: type === "webinar" || type === "class", - }); - const consultations = useConsultationPlans(consultantId, { - enabled: type === "consultation", - }); - const subscriptions = useSubscriptionPlans(consultantId, { - enabled: type === "subscription", - }); - if (!OFFERING_MANIFESTS[type]) notFound(); - // notFound() must stay unreachable while a source is still in flight, or the - // editor 404s on a row that was about to arrive. A disabled query reports - // isLoading false, so this gate only waits on the one that is running. - if (planner.isLoading || consultations.isLoading || subscriptions.isLoading) { + const offering = useOfferingEvent(type, offeringId); + + if (offering.isLoading) { return ( <> @@ -74,23 +121,11 @@ export default function EditOfferingPage() { ); } - let events: unknown[]; - if (type === "consultation") { - events = toPlanEvents("consultation", consultations.data); - } else if (type === "subscription") { - events = toPlanEvents("subscription", subscriptions.data); - } else { - events = [ - ...(planner.data?.webinars ?? []), - ...(planner.data?.classes ?? []), - ]; - } - - const initialEvent = events.find( - (event) => (event as { id?: string })?.id === offeringId, - ); + // Query failures are real errors (network / 500), not missing rows — let the + // dashboard error boundary render them instead of pretending the plan is gone. + if (offering.isError) throw offering.error; - if (!initialEvent) notFound(); + if (!offering.data) notFound(); return ( <> @@ -103,7 +138,7 @@ export default function EditOfferingPage() { diff --git a/components/dashboard/PersonalDashboardShell.tsx b/components/dashboard/PersonalDashboardShell.tsx index eca2af4bb..092a0e7df 100644 --- a/components/dashboard/PersonalDashboardShell.tsx +++ b/components/dashboard/PersonalDashboardShell.tsx @@ -92,9 +92,10 @@ export function PersonalDashboardShell({ }; return ( - // overflow-hidden is load-bearing: without it, a tall sidebar or page - // grows the DOCUMENT and the window scrolls the whole shell — including - // the context bar that is supposed to stay put above
. + // Shell clips the document so a tall page cannot window-scroll the + // context bar away. The RIGHT PANEL deliberately does NOT set + // overflow-hidden: that creates a second scrollport and breaks + // `position: sticky` for page chrome inside
(Basics/Pricing tabs).
{/* Collapsible sidebar — hidden on mobile, visible on md+ */}
@@ -114,7 +115,7 @@ export function PersonalDashboardShell({
{/* Right panel: context bar + banner + page content */} -
+
{banner} diff --git a/components/offerings/editor/OfferingEditor.tsx b/components/offerings/editor/OfferingEditor.tsx index 4a47cef3f..938ab13fe 100644 --- a/components/offerings/editor/OfferingEditor.tsx +++ b/components/offerings/editor/OfferingEditor.tsx @@ -91,6 +91,7 @@ export function OfferingEditor({ // Keep the section tab in sync with whichever block is in view — otherwise // a wheel-scroll leaves the highlight on the tab the user last clicked. + // Root is
: that is the dashboard scrollport (see PersonalDashboardShell). React.useEffect(() => { const nodes = manifest.sections .map((section) => @@ -99,6 +100,7 @@ export function OfferingEditor({ .filter((node): node is HTMLElement => node !== null); if (nodes.length === 0) return; + const root = document.querySelector("main"); const observer = new IntersectionObserver( (entries) => { // The topmost intersecting section wins; entries arrive unordered. @@ -109,7 +111,7 @@ export function OfferingEditor({ if (id) setActiveSection(id); }, // Bias toward the band just under the sticky section nav. - { root: null, rootMargin: "-20% 0px -55% 0px", threshold: 0 }, + { root, rootMargin: "-20% 0px -55% 0px", threshold: 0 }, ); for (const node of nodes) observer.observe(node); return () => observer.disconnect(); @@ -138,13 +140,11 @@ export function OfferingEditor({ }} > {/* - Sticky chrome for the long form: title + section tabs stay under the - dashboard context bar while the sections scroll. top-0 is relative to -
, which is the only scroll container once the shell clips the - document (see PersonalDashboardShell). No negative margins — those - were widening the scrollport past the content column. + Second navbar (Basics / Pricing / …): sticky to the top of
+ under the dashboard context bar. Solid background — translucent + backdrop-blur let section content bleed through while scrolling. */} -
+

{planId ? "Edit" : "New"} {manifest.noun} diff --git a/components/offerings/editor/adapters.ts b/components/offerings/editor/adapters.ts index 0203abcfa..28839f58f 100644 --- a/components/offerings/editor/adapters.ts +++ b/components/offerings/editor/adapters.ts @@ -43,12 +43,20 @@ const sharedDefaults = { }; /** - * The date control writes a Date; the class endpoint takes an ISO string. A - * cleared date must stay undefined rather than becoming an invalid one. + * The date control writes a Date; the class endpoint takes an ISO string. + * Three outcomes matter for PATCH: + * - ISO string → set the date + * - null → clear an existing date (JSON keeps the key) + * - undefined → omit the field so the route leaves the column alone + * Returning undefined for a cleared field used to drop the key from + * JSON.stringify, so clearing the editor never reached the API. */ -const toIsoDate = (value: unknown): string | undefined => { - if (value instanceof Date) return value.toISOString(); +const toIsoDate = (value: unknown): string | null | undefined => { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } if (typeof value === "string" && value) return value; + if (value === null || value === "") return null; return undefined; }; diff --git a/components/planner/services/events/class-service.ts b/components/planner/services/events/class-service.ts index b5a512cd6..93866b74b 100644 --- a/components/planner/services/events/class-service.ts +++ b/components/planner/services/events/class-service.ts @@ -90,7 +90,7 @@ export class ClassService { static async saveClass( classData: Partial, consultantId: string, - startDate?: string, + startDate?: string | null, ): Promise { try { const title = classData.classPlan?.title; @@ -183,7 +183,7 @@ export class ClassService { isUpdate: boolean, planId: string, classId: string, - startDate?: string, + startDate?: string | null, ): ClassRequestBody { const plan = classData.classPlan; diff --git a/components/planner/services/planner.ts b/components/planner/services/planner.ts index d26a5eed7..65ea2caec 100644 --- a/components/planner/services/planner.ts +++ b/components/planner/services/planner.ts @@ -154,7 +154,7 @@ export class PlannerService { static async saveClass( classData: Partial, consultantId: string, - startDate?: string, + startDate?: string | null, ): Promise { return ClassService.saveClass(classData, consultantId, startDate); } diff --git a/components/planner/services/types.ts b/components/planner/services/types.ts index 033a10520..86d22c45f 100644 --- a/components/planner/services/types.ts +++ b/components/planner/services/types.ts @@ -50,7 +50,8 @@ export interface CreateClassPayload { topics?: string[]; classContents?: ClassContentInput[]; consultantProfileId: string; - startDate?: string; + /** ISO string to set; `null` clears; omit to leave unchanged on PATCH. */ + startDate?: string | null; } export interface ClassContentInput { From 7d0ec9fea56a53feb58d57bd95f92e0ee0ace44e Mon Sep 17 00:00:00 2001 From: Kaustav Ghosh <44238657+teetangh@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:07:05 +0530 Subject: [PATCH 5/5] fix(scheduling): fill the slot-picker viewport without nested shell scroll Drop the 500px calendar cap and size Timings/Allocate/Reschedule to the dashboard content column so the week grid owns the only scrollport. Co-authored-by: Cursor --- .../reschedule/RescheduleClient.tsx | 2 +- .../[appointmentId]/reschedule/page.tsx | 21 ++++---- .../timings/ManageTimingsClient.tsx | 2 +- .../[appointmentId]/timings/page.tsx | 53 ++++++++++--------- .../[requestId]/allocate/AllocateClient.tsx | 2 +- .../requests/[requestId]/allocate/page.tsx | 21 ++++---- .../reschedule/RescheduleClient.tsx | 2 +- .../[appointmentId]/reschedule/page.tsx | 21 ++++---- .../dashboard/DashboardViewportFill.tsx | 31 +++++++++++ components/scheduling/UnifiedCalendar.tsx | 4 +- 10 files changed, 101 insertions(+), 58 deletions(-) create mode 100644 components/dashboard/DashboardViewportFill.tsx diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx index fcbb25f7e..22a101228 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx @@ -63,7 +63,7 @@ export function RescheduleClient({ return ( + {/* The BOOKING now lives in the breadcrumb (RescheduleClient sets it via useSetBreadcrumbLabel) — see the consultee's twin (#1064). */} - +
+ +
-

+ ); } diff --git a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx index 9b11138bb..3a14c8790 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/appointments/[appointmentId]/timings/ManageTimingsClient.tsx @@ -36,7 +36,7 @@ export function ManageTimingsClient({ return ( + {/* The OFFERING now lives in the breadcrumb (ManageTimingsClient sets it via useSetBreadcrumbLabel) — see the reschedule/allocate pages (#1064). */} - - - {resolved.classInfo && ( -
- Plan: {resolved.classInfo.planType} - - {resolved.classInfo.sessionsPerWeek} meetings/week ·{" "} - {resolved.classInfo.durationInMonths} month - {resolved.classInfo.durationInMonths !== 1 ? "s" : ""} ·{" "} - {resolved.classInfo.durationInHours}h/session - -
- )} - - {resolved.classInfo && ( -
- Tip: Each class is{" "} - {Math.ceil(resolved.classInfo.durationInHours / 0.5)} consecutive - 30-min slots. Complete an in-progress class before starting another. - Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly limit - applies. -
- )} +
+ + + {resolved.classInfo && ( +
+ Plan: {resolved.classInfo.planType} + + {resolved.classInfo.sessionsPerWeek} meetings/week ·{" "} + {resolved.classInfo.durationInMonths} month + {resolved.classInfo.durationInMonths !== 1 ? "s" : ""} ·{" "} + {resolved.classInfo.durationInHours}h/session + +
+ )} + + {resolved.classInfo && ( +
+ Tip: Each class is{" "} + {Math.ceil(resolved.classInfo.durationInHours / 0.5)} consecutive + 30-min slots. Complete an in-progress class before starting another. + Max {resolved.classInfo.sessionsPerWeek} classes per day; weekly + limit applies. +
+ )} +
-
+ ); } diff --git a/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx index 70c66d79c..f9a60f319 100644 --- a/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx +++ b/app/dashboard/consultant/[consultantId]/(features)/requests/[requestId]/allocate/AllocateClient.tsx @@ -52,7 +52,7 @@ export function AllocateClient({ return ( + {/* The BOOKING now lives in the breadcrumb itself (AllocateClient sets it via useSetBreadcrumbLabel) — the back link is the breadcrumb's own parent crumb. This line keeps the one thing the breadcrumb can't say: who the task is for (#1064). */} - +
+ +
-
+ ); } diff --git a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx index 2ff0e04b3..88afe0434 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/appointments/[appointmentId]/reschedule/RescheduleClient.tsx @@ -62,7 +62,7 @@ export function RescheduleClient({ return ( + {/* The BOOKING now lives in the breadcrumb (RescheduleClient sets it via useSetBreadcrumbLabel); every reschedule page used to render an identical "Reschedule" heading, so the consultee could not tell which session they were moving (#1064). */} - +
+ +
-
+ ); } diff --git a/components/dashboard/DashboardViewportFill.tsx b/components/dashboard/DashboardViewportFill.tsx new file mode 100644 index 000000000..ce91ff7c4 --- /dev/null +++ b/components/dashboard/DashboardViewportFill.tsx @@ -0,0 +1,31 @@ +import { cn } from "@/utils/tailwind"; + +/** + * Fills the personal-dashboard content column under the context bar (and above + * the mobile tab bar). Same height contract as MessagesTab: an explicit `dvh` + * budget, not `min-h-full` + `flex-1` — flex-grow cannot constrain children + * when the parent's height is indefinite, which is what left the slot calendar + * capped at 500px with empty white space below. + * + * `overflow-hidden` keeps a single inner scrollport (the calendar grid). Do not + * put `overflow-hidden` on `PersonalDashboardShell`'s right panel — that creates + * a second scrollport and breaks `position: sticky` for editor chrome. + */ +export function DashboardViewportFill({ + children, + className, +}: Readonly<{ + children: React.ReactNode; + className?: string; +}>) { + return ( +
+ {children} +
+ ); +} diff --git a/components/scheduling/UnifiedCalendar.tsx b/components/scheduling/UnifiedCalendar.tsx index fee3f2193..312ff6aa2 100644 --- a/components/scheduling/UnifiedCalendar.tsx +++ b/components/scheduling/UnifiedCalendar.tsx @@ -1154,7 +1154,7 @@ export function UnifiedCalendar({ }; return ( -
+
{DAYS.map((day) => (
{day.slice(0, 3)} @@ -1347,7 +1347,7 @@ export function UnifiedCalendar({ {/* Calendar View */} {view === "week" ? ( -
+
{/* Week header */}