Skip to content
Open
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
158 changes: 158 additions & 0 deletions app/(authenticated)/registrations/_components/registrations-view.tsx
Original file line number Diff line number Diff line change
@@ -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<FilterKey>("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 <EmptyState />;

return (
<Tabs
value={filter}
onValueChange={(v) => setFilter(v as FilterKey)}
className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-4 overflow-hidden"
>
<TabsList className="h-auto min-h-8 shrink-0 justify-start border border-border">
<TabsTrigger value="all">All ({counts.all})</TabsTrigger>
<TabsTrigger value="programs">
Programs ({counts.programs})
</TabsTrigger>
<TabsTrigger value="private_lessons">
Private lessons ({counts.private_lessons})
</TabsTrigger>
</TabsList>

<TabsContent
value={filter}
className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-y-auto pr-1 focus-visible:outline-none"
>
{visible.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing in this category.
</p>
) : (
visible.map((r) => (
<RegistrationCard key={r.serviceId} registration={r} />
))
)}
</TabsContent>
</Tabs>
);
}

function EmptyState() {
return (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed p-10 text-center">
<Inbox className="size-10 text-muted-foreground" />
<div className="space-y-1">
<p className="text-base font-medium">No registrations yet</p>
<p className="text-sm text-muted-foreground">
Services you register for will appear here.
</p>
</div>
</div>
</div>
);
}

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 (
<Card size="sm">
<CardHeader>
<CardTitle>{title ?? "Untitled service"}</CardTitle>
<CardDescription>{scheduleLabel}</CardDescription>
<CardAction>
<Badge variant="secondary">{serviceTypeLabel(type)}</Badge>
</CardAction>
</CardHeader>

<CardContent>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{slotsLabel && (
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="size-3.5" />
{slotsLabel}
</span>
)}
<span className="inline-flex items-center gap-1.5">
<Clock className="size-3.5" />
{durationMinutes} min
</span>
</div>
</CardContent>

<CardFooter>
{isForChildren && children.length > 0 ? (
<div className="flex w-full flex-wrap items-center gap-x-2 gap-y-1.5 text-sm">
<span className="inline-flex items-center gap-1.5 font-medium">
<Users className="size-3.5" />
{children.length === 1 ? "Child" : "Children"}
</span>
{children.map((c) => (
<Badge key={c.id} variant="outline">
{c.firstName} {c.lastName}
</Badge>
))}
</div>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<User className="size-3.5" />
Registered as yourself
</span>
)}
</CardFooter>
</Card>
);
}
52 changes: 52 additions & 0 deletions app/(authenticated)/registrations/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<Spinner className="size-8 text-muted-foreground" />
</div>
}
>
<RegistrationsContent />
</Suspense>
);
}

async function RegistrationsContent() {
let userId: string;
try {
({ userId } = await requireUser());
} catch {
redirect("/");
}

const registrations = await listRegistrationsForUser(userId);

return (
<main className="flex h-full max-h-full min-h-0 w-full min-w-0 flex-1 flex-col gap-6 overflow-hidden p-8">
<div className="flex shrink-0 flex-col gap-2">
<div className="flex items-center gap-3">
<h1 className="font-heading text-3xl font-bold">
My Registrations
</h1>
<Badge variant="secondary">
{registrations.length} registered
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Services you&apos;re registered for.
</p>
</div>

<RegistrationsView registrations={registrations} />
</main>
);
}
124 changes: 124 additions & 0 deletions app/(authenticated)/registrations/queries.ts
Original file line number Diff line number Diff line change
@@ -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<string, ServiceGroup>,
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<RegistrationView[]> {
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<string, ServiceGroup>();
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 ?? "");
});
}
11 changes: 1 addition & 10 deletions app/(authenticated)/services/service-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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 (
Expand Down
5 changes: 1 addition & 4 deletions app/checkout/[productId]/checkout-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions lib/auth/require-user.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading