diff --git a/app/(authenticated)/registrations/_components/registrations-view.tsx b/app/(authenticated)/registrations/_components/registrations-view.tsx new file mode 100644 index 0000000..978b7af --- /dev/null +++ b/app/(authenticated)/registrations/_components/registrations-view.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { CalendarDays, Clock, Inbox, User, Users } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { formatDate } from "@/lib/format"; +import { dayOfWeekLabel, serviceTypeLabel } from "@/lib/service-labels"; +import type { RegistrationView } from "../queries"; + +type FilterKey = "all" | "programs" | "private_lessons"; + +export function RegistrationsView({ + registrations, +}: { + registrations: RegistrationView[]; +}) { + const [filter, setFilter] = useState("all"); + + const counts = { + all: registrations.length, + programs: registrations.filter((r) => r.type === "programs").length, + private_lessons: registrations.filter( + (r) => r.type === "private_lessons", + ).length, + }; + + const visible = + filter === "all" + ? registrations + : registrations.filter((r) => r.type === filter); + + if (registrations.length === 0) return ; + + return ( + setFilter(v as FilterKey)} + className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-4 overflow-hidden" + > + + All ({counts.all}) + + Programs ({counts.programs}) + + + Private lessons ({counts.private_lessons}) + + + + + {visible.length === 0 ? ( +

+ Nothing in this category. +

+ ) : ( + visible.map((r) => ( + + )) + )} +
+
+ ); +} + +function EmptyState() { + return ( +
+
+ +
+

No registrations yet

+

+ Services you register for will appear here. +

+
+
+
+ ); +} + +function RegistrationCard({ + registration, +}: { + registration: RegistrationView; +}) { + const { title, type, schedule, durationMinutes, isForChildren, children } = + registration; + const scheduleLabel = schedule + ? `${formatDate(schedule.startDate)} – ${formatDate(schedule.endDate)}` + : "Scheduled after booking"; + const slotsLabel = + schedule && schedule.slots.length > 0 + ? schedule.slots + .map((s) => `${dayOfWeekLabel(s.dayOfWeek)} ${s.time}`) + .join(" · ") + : null; + + return ( + + + {title ?? "Untitled service"} + {scheduleLabel} + + {serviceTypeLabel(type)} + + + + +
+ {slotsLabel && ( + + + {slotsLabel} + + )} + + + {durationMinutes} min + +
+
+ + + {isForChildren && children.length > 0 ? ( +
+ + + {children.length === 1 ? "Child" : "Children"} + + {children.map((c) => ( + + {c.firstName} {c.lastName} + + ))} +
+ ) : ( + + + Registered as yourself + + )} +
+
+ ); +} diff --git a/app/(authenticated)/registrations/page.tsx b/app/(authenticated)/registrations/page.tsx new file mode 100644 index 0000000..ef02f6a --- /dev/null +++ b/app/(authenticated)/registrations/page.tsx @@ -0,0 +1,52 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { requireUser } from "@/lib/auth/require-user"; +import { RegistrationsView } from "./_components/registrations-view"; +import { listRegistrationsForUser } from "./queries"; + +export default function RegistrationsPage() { + return ( + + + + } + > + + + ); +} + +async function RegistrationsContent() { + let userId: string; + try { + ({ userId } = await requireUser()); + } catch { + redirect("/"); + } + + const registrations = await listRegistrationsForUser(userId); + + return ( +
+
+
+

+ My Registrations +

+ + {registrations.length} registered + +
+

+ Services you're registered for. +

+
+ + +
+ ); +} diff --git a/app/(authenticated)/registrations/queries.ts b/app/(authenticated)/registrations/queries.ts new file mode 100644 index 0000000..b55a14f --- /dev/null +++ b/app/(authenticated)/registrations/queries.ts @@ -0,0 +1,124 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + children, + coachingSessions, + serviceBookings, + services, +} from "@/lib/db/schema"; +import { getStripeServiceData } from "@/lib/stripe"; +import type { ProgramSchedule } from "@/app/(authenticated)/services/actions"; + +export type RegisteredChild = { + id: string; + firstName: string; + lastName: string; +}; + +export type RegistrationView = { + serviceId: string; + type: "programs" | "private_lessons"; + isForChildren: boolean; + title: string | null; + schedule: ProgramSchedule | null; + durationMinutes: number; + children: RegisteredChild[]; +}; + +type ServiceGroup = { + service: typeof services.$inferSelect; + childRows: (typeof children.$inferSelect)[]; +}; + +function scheduleFromService( + row: typeof services.$inferSelect, +): ProgramSchedule | null { + if (!row.startDate || !row.endDate) return null; + return { + startDate: row.startDate, + endDate: row.endDate, + slots: row.slots ?? [], + }; +} + +function upsertGroup( + groups: Map, + service: typeof services.$inferSelect, + child: typeof children.$inferSelect | null, +) { + let group = groups.get(service.id); + if (!group) { + group = { service, childRows: [] }; + groups.set(service.id, group); + } + if (child && !group.childRows.some((c) => c.id === child.id)) { + group.childRows.push(child); + } +} + +export async function listRegistrationsForUser( + userId: string, +): Promise { + const [bookingRows, coachingRows] = await Promise.all([ + db + .select({ service: services, child: children }) + .from(serviceBookings) + .innerJoin(services, eq(services.id, serviceBookings.serviceId)) + .leftJoin(children, eq(children.id, serviceBookings.childId)) + .where( + and( + eq(serviceBookings.userId, userId), + eq(serviceBookings.isActive, true), + inArray(serviceBookings.status, ["pending", "confirmed"]), + ), + ), + db + .select({ service: services, child: children }) + .from(coachingSessions) + .innerJoin(services, eq(services.id, coachingSessions.serviceId)) + .leftJoin(children, eq(children.id, coachingSessions.childId)) + .where( + and( + eq(coachingSessions.userId, userId), + inArray(coachingSessions.status, [ + "pending", + "confirmed", + "completed", + ]), + ), + ), + ]); + + const groups = new Map(); + for (const row of bookingRows) upsertGroup(groups, row.service, row.child); + for (const row of coachingRows) upsertGroup(groups, row.service, row.child); + + const views = await Promise.all( + Array.from(groups.values()).map(async ({ service, childRows }) => { + const stripe = await getStripeServiceData(service.stripeProductId); + return { + serviceId: service.id, + type: service.type, + isForChildren: service.isForChildren, + title: stripe?.title ?? null, + schedule: scheduleFromService(service), + durationMinutes: service.durationMinutes, + children: childRows.map((c) => ({ + id: c.id, + firstName: c.firstName, + lastName: c.lastName, + })), + } satisfies RegistrationView; + }), + ); + + return views.sort((a, b) => { + const aStart = a.schedule?.startDate ?? ""; + const bStart = b.schedule?.startDate ?? ""; + if (aStart && bStart && aStart !== bStart) + return bStart.localeCompare(aStart); + if (aStart && !bStart) return -1; + if (!aStart && bStart) return 1; + return (a.title ?? "").localeCompare(b.title ?? ""); + }); +} diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx index 8482f98..17caa29 100644 --- a/app/(authenticated)/services/service-dialog.tsx +++ b/app/(authenticated)/services/service-dialog.tsx @@ -48,6 +48,7 @@ import type { CoordinatorOption, ServiceView, } from "@/app/(authenticated)/services/queries"; +import { DAY_NAMES } from "@/lib/service-labels"; type FormOption = { id: string; name: string }; @@ -61,16 +62,6 @@ type Props = { coordinators: CoordinatorOption[]; forms: FormOption[] } & ( } ); -const DAY_NAMES = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", -] as const; - function FieldError({ messages }: { messages?: string[] }) { if (!messages?.length) return null; return ( diff --git a/app/checkout/[productId]/checkout-flow.tsx b/app/checkout/[productId]/checkout-flow.tsx index 5592ba5..1d93c1e 100644 --- a/app/checkout/[productId]/checkout-flow.tsx +++ b/app/checkout/[productId]/checkout-flow.tsx @@ -25,6 +25,7 @@ import { } from "@/components/ui/stepper"; import type { ServiceView } from "@/app/(authenticated)/services/queries"; import type { ProductDiscountForUser } from "@/lib/stripe"; +import { serviceTypeLabel } from "@/lib/service-labels"; import { checkoutServiceBooking, startPrivateLessonCheckout } from "../actions"; import { AvailabilityCalendar } from "@/components/scheduling/availability-calendar"; @@ -46,10 +47,6 @@ function formatPrice(cents: number | null, currency: string | null) { return `$${(cents / 100).toFixed(2)} ${symbol}`; } -function serviceTypeLabel(type: ServiceView["type"]) { - return type === "private_lessons" ? "Private lesson" : "Program"; -} - function applyDiscount( cents: number, discount: ProductDiscountForUser, diff --git a/lib/auth/require-user.ts b/lib/auth/require-user.ts new file mode 100644 index 0000000..de99a58 --- /dev/null +++ b/lib/auth/require-user.ts @@ -0,0 +1,12 @@ +import { createClient } from "@/utils/supabase/server"; +import { ROLES } from "@/lib/roles"; + +export async function requireUser(): Promise<{ userId: string }> { + const supabase = await createClient(); + const { data } = await supabase.auth.getClaims(); + const claims = data?.claims; + if (claims?.user_role !== ROLES.USER) { + throw new Error("Forbidden"); + } + return { userId: claims.sub }; +} diff --git a/lib/service-labels.ts b/lib/service-labels.ts new file mode 100644 index 0000000..fe661da --- /dev/null +++ b/lib/service-labels.ts @@ -0,0 +1,19 @@ +import type { ServiceType } from "@/app/(authenticated)/services/queries"; + +export function serviceTypeLabel(type: ServiceType): string { + return type === "private_lessons" ? "Private lesson" : "Program"; +} + +export const DAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +export function dayOfWeekLabel(dayOfWeek: number): string { + return DAY_NAMES[dayOfWeek] ?? String(dayOfWeek); +} diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts index 180c019..34994bf 100644 --- a/utils/supabase/middleware.ts +++ b/utils/supabase/middleware.ts @@ -63,5 +63,20 @@ export async function updateSession(request: NextRequest) { } } + // Protect /registrations and /registrations/* — user role only + // TODO: replace with the shared user-dashboard gate once the sidebar / user-dashboard framework PR lands. + if ( + request.nextUrl.pathname === "/registrations" || + request.nextUrl.pathname.startsWith("/registrations/") + ) { + const { data: claimsData } = await supabase.auth.getClaims(); + const role = claimsData?.claims?.user_role; + if (role !== ROLES.USER) { + const url = request.nextUrl.clone(); + url.pathname = "/"; + return NextResponse.redirect(url); + } + } + return supabaseResponse; }