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)/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)/offerings/[type]/[offeringId]/edit/page.tsx b/app/dashboard/consultant/[consultantId]/(features)/offerings/[type]/[offeringId]/edit/page.tsx index ee9e6e27a..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,28 +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 { 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}`, +}; + +/** + * 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 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, + }; +} + /** - * 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. + * 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 { data, isLoading } = useQuery(plannerQuery); - if (!OFFERING_MANIFESTS[type]) notFound(); - if (isLoading) { + const offering = useOfferingEvent(type, offeringId); + + if (offering.isLoading) { return ( <> @@ -40,18 +121,11 @@ 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 ?? []), - ]; - - 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 ( <> @@ -64,7 +138,7 @@ export default function EditOfferingPage() { 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/consultant/[consultantId]/layout.tsx b/app/dashboard/consultant/[consultantId]/layout.tsx index 5e8568ffb..8101e97df 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"; @@ -135,9 +135,14 @@ const PAGE_LABELS: Record = { appointments: "Appointments", participants: "Participants", classes: "Class", + class: "Class", consultations: "Consultation", + consultation: "Consultation", subscriptions: "Subscription", + subscription: "Subscription", webinars: "Webinar", + webinar: "Webinar", + offerings: "Offerings", planner: "Event Planner", requests: "Requests", // Task routes hanging off a record id. Without these the trail ends on the @@ -154,8 +159,27 @@ const PAGE_LABELS: Record = { support: "Support requests", feedback: "Feedback", help: "Help", + edit: "Edit", + new: "New", }; +// Segments that group routes without owning a page of their own — a crumb that +// links the accumulated path makes Next prefetch a URL that 404s. Verified +// against the route tree: `offerings` has only `[type]/…` children and +// `participants` only `[eventType]/…`. +// +// Offerings is special-cased below: the crumb stays, but its href is rewritten +// to the Event Planner, which is the actual listings surface for those rows. +const PATHLESS_SEGMENTS = new Set(["offerings", "participants"]); + +/** Offering types that appear as `/offerings/[type]/…` URL segments. */ +const OFFERING_TYPE_SEGMENTS = new Set([ + "consultation", + "subscription", + "webinar", + "class", +]); + // Opaque record ids (cuid / uuid) in nested routes carry no meaning as crumbs. const looksLikeRecordId = (segment: string) => /^[a-z0-9]{20,}$/i.test(segment) || @@ -169,11 +193,7 @@ interface PageProps { } // Error types and their configurations -type ErrorType = - | "not-found" - | "session-expired" - | "network" - | "unknown"; +type ErrorType = "not-found" | "session-expired" | "network" | "unknown"; function getErrorConfig(errorMessage: string): { type: ErrorType; @@ -355,14 +375,12 @@ export default function ConsultantLayout(props: Readonly) { ); } -function ConsultantLayoutInner({ - children, - params, -}: Readonly) { +function ConsultantLayoutInner({ children, params }: Readonly) { const resolvedParams = use(params); 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,15 +520,31 @@ 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, "") - .split("/") - .filter(Boolean); + const parts = pathname.replace(basePath, "").split("/").filter(Boolean); + const onOfferings = parts[0] === "offerings"; + // Offerings have no list route of their own — the Event Planner is where + // those rows live. Point both the "Offerings" crumb and the type crumb + // (consultation / subscription / …) there so the trail is clickable + // without prefetching a 404. + const offeringsListingHref = `${basePath}/planner`; const crumbs: { label: string; href?: string }[] = []; let acc = basePath; @@ -526,9 +560,22 @@ function ConsultantLayoutInner({ if (overrideLabel) crumbs.push({ label: overrideLabel, href: acc }); continue; } + + if ( + seg === "offerings" || + (onOfferings && OFFERING_TYPE_SEGMENTS.has(seg)) + ) { + crumbs.push({ + label: PAGE_LABELS[seg] ?? seg, + href: offeringsListingHref, + }); + continue; + } + + const navigable = !PATHLESS_SEGMENTS.has(seg) && !paramValues.has(seg); crumbs.push({ label: PAGE_LABELS[seg] ?? seg, - href: acc, + ...(navigable ? { href: acc } : {}), }); } @@ -541,7 +588,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/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/app/dashboard/org-workspace/[orgWorkspaceId]/billing/page.tsx b/app/dashboard/org-workspace/[orgWorkspaceId]/billing/page.tsx index f514b5fd9..93abcba68 100644 --- a/app/dashboard/org-workspace/[orgWorkspaceId]/billing/page.tsx +++ b/app/dashboard/org-workspace/[orgWorkspaceId]/billing/page.tsx @@ -1,7 +1,7 @@ import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query"; import { requireAuth } from "@/lib/auth-guard"; import { getWorkspaceBillingRollup } from "@/lib/data/org-workspace"; -import { workspaceBillingQueryKey } from "../hooks/useWorkspaceBilling"; +import { workspaceBillingQueryKey } from "../workspace-billing-keys"; import { BillingPageClient } from "./BillingPageClient"; /** @@ -23,7 +23,7 @@ export default async function OrgWorkspaceBillingPage({ const queryClient = new QueryClient(); // Key MUST match BillingPageClient's useWorkspaceBilling or hydration - // won't apply. + // won't apply, which is why both read it from workspace-billing-keys. await Promise.allSettled([ queryClient.prefetchQuery({ queryKey: workspaceBillingQueryKey(orgWorkspaceId), diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/home/page.tsx b/app/dashboard/org-workspace/[orgWorkspaceId]/home/page.tsx index e7899100b..a26ce8509 100644 --- a/app/dashboard/org-workspace/[orgWorkspaceId]/home/page.tsx +++ b/app/dashboard/org-workspace/[orgWorkspaceId]/home/page.tsx @@ -4,7 +4,7 @@ import { getOperatorOrganizations, getWorkspaceBillingRollup, } from "@/lib/data/org-workspace"; -import { workspaceBillingQueryKey } from "../hooks/useWorkspaceBilling"; +import { workspaceBillingQueryKey } from "../workspace-billing-keys"; import { HomePageClient } from "./HomePageClient"; /** diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/hooks/useWorkspaceBilling.ts b/app/dashboard/org-workspace/[orgWorkspaceId]/hooks/useWorkspaceBilling.ts index 2ac3827e6..1dc79cf76 100644 --- a/app/dashboard/org-workspace/[orgWorkspaceId]/hooks/useWorkspaceBilling.ts +++ b/app/dashboard/org-workspace/[orgWorkspaceId]/hooks/useWorkspaceBilling.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import type { FundingSource } from "@prisma/client"; +import { workspaceBillingQueryKey } from "../workspace-billing-keys"; /** * Cross-org billing roll-up — the single source of truth shared by the @@ -39,10 +40,6 @@ export interface WorkspaceBillingResponse { perOrg: WorkspaceBillingPerOrgRow[]; } -export function workspaceBillingQueryKey(orgWorkspaceId: string) { - return ["org-workspace-billing", orgWorkspaceId] as const; -} - async function fetchWorkspaceBilling( orgWorkspaceId: string, ): Promise { diff --git a/app/dashboard/org-workspace/[orgWorkspaceId]/workspace-billing-keys.ts b/app/dashboard/org-workspace/[orgWorkspaceId]/workspace-billing-keys.ts new file mode 100644 index 000000000..ff2655761 --- /dev/null +++ b/app/dashboard/org-workspace/[orgWorkspaceId]/workspace-billing-keys.ts @@ -0,0 +1,14 @@ +/** + * The billing roll-up's query key, shared by the two server shells that + * SSR-prefetch it (home, billing) and by the client hook that reads it back. + * + * It lives OUTSIDE useWorkspaceBilling.ts on purpose. That module is "use + * client", which makes every one of its exports a client reference — a server + * component importing this function from there got a proxy and crashed the page + * with "Attempted to call workspaceBillingQueryKey() from the server but + * workspaceBillingQueryKey is on the client". No directive here, so both + * environments can call it. + */ +export function workspaceBillingQueryKey(orgWorkspaceId: string) { + return ["org-workspace-billing", orgWorkspaceId] as const; +} 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/dashboard/PersonalDashboardShell.tsx b/components/dashboard/PersonalDashboardShell.tsx index 70c3c8466..092a0e7df 100644 --- a/components/dashboard/PersonalDashboardShell.tsx +++ b/components/dashboard/PersonalDashboardShell.tsx @@ -92,9 +92,13 @@ export function PersonalDashboardShell({ }; return ( -
+ // 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+ */} -
+
{/* Right panel: context bar + banner + page content */} -
+
{banner} -
+
{children}
diff --git a/components/offerings/editor/OfferingEditor.tsx b/components/offerings/editor/OfferingEditor.tsx index 33ebdd451..938ab13fe 100644 --- a/components/offerings/editor/OfferingEditor.tsx +++ b/components/offerings/editor/OfferingEditor.tsx @@ -89,6 +89,34 @@ export function OfferingEditor({ ?.scrollIntoView({ behavior: "smooth", block: "start" }); }; + // 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) => + document.getElementById(`offering-section-${section.id}`), + ) + .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. + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + const id = visible[0]?.target.id.replace(/^offering-section-/, ""); + if (id) setActiveSection(id); + }, + // Bias toward the band just under the sticky section nav. + { root, rootMargin: "-20% 0px -55% 0px", threshold: 0 }, + ); + for (const node of nodes) observer.observe(node); + return () => observer.disconnect(); + }, [manifest.sections]); + // Publishing validates in full; a draft only has to clear the errors that are // not publish-only, so partial work can still be parked. const submitDraft = form.handleSubmit( @@ -111,35 +139,48 @@ export function OfferingEditor({ e.preventDefault(); }} > -
-

- {planId ? "Edit" : "New"} {manifest.noun} -

- {status === "DRAFT" && Draft} - {status === "PUBLISHED" && Published} + {/* + 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} +

+ {status === "DRAFT" && Draft} + {status === "PUBLISHED" && Published} +
+ +
- - -
- {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 && (

diff --git a/components/offerings/editor/adapters.ts b/components/offerings/editor/adapters.ts index 724140c0d..28839f58f 100644 --- a/components/offerings/editor/adapters.ts +++ b/components/offerings/editor/adapters.ts @@ -42,6 +42,24 @@ const sharedDefaults = { faqs: [] as { question: string; answer: string; order?: number }[], }; +/** + * 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 | 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; +}; + export interface OfferingAdapter { schema: ZodTypeAny; /** Which bucket the image uploader writes to. */ @@ -132,9 +150,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({