diff --git a/.env.example b/.env.example index 3d74f8a..c41a2a8 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ STRIPE_PRICE_ID= STRIPE_SECRET_KEY= NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= -WORDPRESS_API_URL=http://xxx.com +WORDPRESS_API_URL=https://example.com WORDPRESS_USERNAME=mcld-dashboard WORDPRESS_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" WORDPRESS_POST_TYPE=posts diff --git a/.gitignore b/.gitignore index be360b6..333d1cf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ /.next/ /out/ +/.react-email + # production /build @@ -50,8 +52,6 @@ next-env.d.ts *.swo *~ -# drizzle -drizzle/meta/ # supabase .supabase/ diff --git a/__tests__/services-actions.test.ts b/__tests__/services-actions.test.ts new file mode 100644 index 0000000..9aa951f --- /dev/null +++ b/__tests__/services-actions.test.ts @@ -0,0 +1,224 @@ +/** + * @jest-environment node + */ +import { + createService, + updateService, +} from "@/app/(authenticated)/services/actions"; + +// db mock +const insertValues = jest.fn().mockResolvedValue(undefined); +const insert = jest.fn(() => ({ values: insertValues })) as jest.Mock; + +const selectLimit = jest.fn(); +const selectWhere = jest.fn(() => ({ limit: selectLimit })); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +const updateWhere = jest.fn().mockResolvedValue(undefined); +const updateSet = jest.fn(() => ({ where: updateWhere })); +const update = jest.fn(() => ({ set: updateSet })) as jest.Mock; + +jest.mock("@/lib/db", () => ({ + db: { + insert: (...args: unknown[]) => insert(...args), + select: (...args: unknown[]) => select(...args), + update: (...args: unknown[]) => update(...args), + }, +})); + +// stripe mock +const createProduct = jest.fn(); +const createPrice = jest.fn(); +const updateProduct = jest.fn(); +const replaceProductPrice = jest.fn(); +const getStripeServiceData = jest.fn(); + +jest.mock("@/lib/stripe", () => ({ + createProduct: (...args: unknown[]) => createProduct(...args), + createPrice: (...args: unknown[]) => createPrice(...args), + updateProduct: (...args: unknown[]) => updateProduct(...args), + replaceProductPrice: (...args: unknown[]) => replaceProductPrice(...args), + getStripeServiceData: (...args: unknown[]) => getStripeServiceData(...args), +})); + +// auth + cache mocks +const requireAdmin = jest.fn(); +jest.mock("@/lib/auth/require-admin", () => ({ + requireAdmin: (...args: unknown[]) => requireAdmin(...args), +})); + +jest.mock("next/cache", () => ({ + revalidatePath: jest.fn(), + updateTag: jest.fn(), +})); + +const COORDINATOR_A = "11111111-1111-1111-1111-111111111111"; +const COORDINATOR_B = "22222222-2222-2222-2222-222222222222"; +const SERVICE_ID = "33333333-3333-3333-3333-333333333333"; + +function fd(obj: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(obj)) f.append(k, v); + return f; +} + +beforeEach(() => { + jest.clearAllMocks(); + requireAdmin.mockResolvedValue(undefined); + createProduct.mockResolvedValue({ productId: "prod_1" }); + createPrice.mockResolvedValue(undefined); + updateProduct.mockResolvedValue(undefined); + replaceProductPrice.mockResolvedValue({ priceId: "price_new" }); + getStripeServiceData.mockResolvedValue({ priceCents: 5000 }); +}); + +describe("createService", () => { + it("rejects a private lesson without a coordinator", async () => { + const result = await createService( + null, + fd({ + title: "1:1 Lesson", + description: "A private session", + type: "private_lessons", + duration_minutes: "60", + price_cad: "50.00", + }), + ); + + expect(result?.errors?.coordinator_id).toBeDefined(); + expect(insert).not.toHaveBeenCalled(); + expect(createProduct).not.toHaveBeenCalled(); + }); + + it("persists the coordinator when creating a private lesson", async () => { + const result = await createService( + null, + fd({ + title: "1:1 Lesson", + description: "A private session", + type: "private_lessons", + duration_minutes: "60", + price_cad: "50.00", + requires_subscription: "true", + coordinator_id: COORDINATOR_A, + }), + ); + + expect(result).toEqual({ message: "Service created." }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + type: "private_lessons", + coordinatorId: COORDINATOR_A, + }), + ); + }); + + it("creates a program with no coordinator", async () => { + const result = await createService( + null, + fd({ + title: "Summer Program", + description: "Group program", + type: "programs", + duration_minutes: "60", + price_cad: "100.00", + start_date: "2026-01-01", + end_date: "2026-02-01", + slots: JSON.stringify([{ dayOfWeek: 1, time: "10:00" }]), + requires_subscription: "true", + }), + ); + + expect(result).toEqual({ message: "Service created." }); + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ type: "programs", coordinatorId: null }), + ); + }); +}); + +describe("updateService", () => { + it("reassigns the coordinator on a private lesson", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, coordinator_id: COORDINATOR_B }), + ); + + expect(result).toEqual({ message: "Service updated." }); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ coordinatorId: COORDINATOR_B }), + ); + }); + + it("does not recreate the Stripe price when the amount is unchanged", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + getStripeServiceData.mockResolvedValue({ priceCents: 5000 }); + + const result = await updateService( + null, + fd({ + service_id: SERVICE_ID, + coordinator_id: COORDINATOR_B, + price_cad: "50.00", + }), + ); + + expect(result).toEqual({ message: "Service updated." }); + expect(replaceProductPrice).not.toHaveBeenCalled(); + }); + + it("recreates the Stripe price when the amount changes", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + getStripeServiceData.mockResolvedValue({ priceCents: 5000 }); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, price_cad: "75.00" }), + ); + + expect(result).toEqual({ message: "Service updated." }); + expect(replaceProductPrice).toHaveBeenCalledWith("prod_1", 7500); + }); + + it("rejects clearing the coordinator on a private lesson", async () => { + selectLimit.mockResolvedValue([ + { + id: SERVICE_ID, + type: "private_lessons", + status: "active", + stripeProductId: "prod_1", + }, + ]); + + const result = await updateService( + null, + fd({ service_id: SERVICE_ID, coordinator_id: "" }), + ); + + expect(result?.errors?.coordinator_id).toBeDefined(); + expect(updateSet).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/users-actions.test.ts b/__tests__/users-actions.test.ts new file mode 100644 index 0000000..e7c3e1b --- /dev/null +++ b/__tests__/users-actions.test.ts @@ -0,0 +1,90 @@ +/** + * @jest-environment node + */ +import { deleteUserAdmin } from "@/app/(authenticated)/users/actions"; + +// db mock +const selectLimit = jest.fn(); +const selectWhere = jest.fn(() => ({ limit: selectLimit })); +const selectFrom = jest.fn(() => ({ where: selectWhere })); +const select = jest.fn(() => ({ from: selectFrom })) as jest.Mock; + +jest.mock("@/lib/db", () => ({ + db: { + select: (...args: unknown[]) => select(...args), + }, +})); + +// supabase admin mock +const deleteUser = jest.fn(); +jest.mock("@/utils/supabase/admin", () => ({ + createAdminClient: () => ({ + auth: { admin: { deleteUser: (...args: unknown[]) => deleteUser(...args) } }, + }), +})); + +// auth + cache + stripe mocks +const requireAdmin = jest.fn(); +jest.mock("@/lib/auth/require-admin", () => ({ + requireAdmin: (...args: unknown[]) => requireAdmin(...args), +})); + +jest.mock("next/cache", () => ({ revalidatePath: jest.fn() })); + +// actions.ts imports grantComplimentarySubscription from @/lib/stripe, which +// instantiates the Stripe client at module load. +jest.mock("@/lib/stripe", () => ({ + grantComplimentarySubscription: jest.fn(), +})); + +const COORDINATOR_ID = "11111111-1111-1111-1111-111111111111"; + +function fd(obj: Record): FormData { + const f = new FormData(); + for (const [k, v] of Object.entries(obj)) f.append(k, v); + return f; +} + +beforeEach(() => { + jest.clearAllMocks(); + requireAdmin.mockResolvedValue(undefined); + selectLimit.mockResolvedValue([]); + deleteUser.mockResolvedValue({ error: null }); +}); + +describe("deleteUserAdmin", () => { + it("returns Unauthorized when the caller is not an admin", async () => { + requireAdmin.mockRejectedValue(new Error("Forbidden")); + + const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID })); + + expect(result).toEqual({ errors: { _form: ["Unauthorized"] } }); + expect(deleteUser).not.toHaveBeenCalled(); + }); + + it("blocks deletion when the coordinator is assigned to a private lesson", async () => { + selectLimit.mockResolvedValue([{ id: "svc-1" }]); + + const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID })); + + expect(result?.errors?._form?.[0]).toMatch(/private lesson/i); + expect(deleteUser).not.toHaveBeenCalled(); + }); + + it("deletes the user when not assigned to any private lesson", async () => { + selectLimit.mockResolvedValue([]); + + const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID })); + + expect(result).toEqual({ message: "User deleted." }); + expect(deleteUser).toHaveBeenCalledWith(COORDINATOR_ID); + }); + + it("surfaces an auth error from Supabase", async () => { + deleteUser.mockResolvedValue({ error: { message: "boom" } }); + + const result = await deleteUserAdmin(null, fd({ user_id: COORDINATOR_ID })); + + expect(result).toEqual({ errors: { _form: ["boom"] } }); + }); +}); diff --git a/app/(authenticated)/account/_components/status-badge.tsx b/app/(authenticated)/account/_components/status-badge.tsx new file mode 100644 index 0000000..947e49c --- /dev/null +++ b/app/(authenticated)/account/_components/status-badge.tsx @@ -0,0 +1,21 @@ +import { Badge } from "@/components/ui/badge"; + +const STATUS_CLASS: Record = { + confirmed: "bg-green-700/80 text-white", + completed: "bg-green-700/80 text-white", + pending: "bg-amber-700/80 text-white", + awaiting_payment: "bg-amber-700/80 text-white", + cancelled: "bg-muted text-muted-foreground", +}; + +export function StatusBadge({ status }: { status: string }) { + return ( + + {status.replace(/_/g, " ")} + + ); +} diff --git a/app/(authenticated)/account/bookings/page.tsx b/app/(authenticated)/account/bookings/page.tsx new file mode 100644 index 0000000..dc9c2eb --- /dev/null +++ b/app/(authenticated)/account/bookings/page.tsx @@ -0,0 +1,92 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getCurrentUser } from "@/lib/auth/current-user"; +import { formatDate, formatDateFromInstant } from "@/lib/format"; +import { StatusBadge } from "../_components/status-badge"; +import { listBookingsForUser, type AccountBooking } from "./queries"; + +export default function BookingsPage() { + return ( + }> + + + ); +} + +function whenLabel(booking: AccountBooking): string { + if (booking.scheduledAt) return formatDateFromInstant(booking.scheduledAt); + if (booking.startDate && booking.endDate) { + return `${formatDate(booking.startDate)} – ${formatDate(booking.endDate)}`; + } + if (booking.startDate) return formatDate(booking.startDate); + return "To be scheduled"; +} + +async function BookingsContent() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const bookings = await listBookingsForUser(user.id); + + return ( +
+
+

Bookings

+

+ Programs and private lessons booked on your account. +

+
+ + {bookings.length === 0 ? ( +

+ You have no bookings yet. +

+ ) : ( +
+ + + + Service + Attendee + When + Booked + Status + + + + {bookings.map((booking) => ( + + + {booking.title} + {booking.kind === "private_lesson" && ( + + private lesson + + )} + + {booking.attendee ?? "You"} + {whenLabel(booking)} + + {formatDateFromInstant(booking.bookedAt)} + + + + + + ))} + +
+
+ )} +
+ ); +} diff --git a/app/(authenticated)/account/bookings/queries.ts b/app/(authenticated)/account/bookings/queries.ts new file mode 100644 index 0000000..b8730f9 --- /dev/null +++ b/app/(authenticated)/account/bookings/queries.ts @@ -0,0 +1,124 @@ +import { and, desc, eq, ne } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + children, + coachingSessions, + serviceBookings, + services, +} from "@/lib/db/schema"; +import { getStripeServiceData } from "@/lib/stripe"; + +export type AccountBooking = { + id: string; + kind: "service" | "private_lesson"; + title: string; + status: string; + bookedAt: Date; + scheduledAt: Date | null; + startDate: string | null; + endDate: string | null; + attendee: string | null; +}; + +async function resolveServiceTitles( + productIds: string[], +): Promise> { + const unique = [...new Set(productIds)]; + + const entries = await Promise.all( + unique.map(async (productId) => { + try { + const data = await getStripeServiceData(productId); + return [productId, data?.title ?? "Service"] as const; + } catch { + return [productId, "Service"] as const; + } + }), + ); + + return new Map(entries); +} + +export async function listBookingsForUser( + userId: string, +): Promise { + const [bookingRows, sessionRows] = await Promise.all([ + db + .select({ + id: serviceBookings.id, + status: serviceBookings.status, + bookedAt: serviceBookings.createdAt, + stripeProductId: services.stripeProductId, + startDate: services.startDate, + endDate: services.endDate, + childFirstName: children.firstName, + childLastName: children.lastName, + }) + .from(serviceBookings) + .innerJoin(services, eq(services.id, serviceBookings.serviceId)) + .leftJoin(children, eq(children.id, serviceBookings.childId)) + .where( + and( + eq(serviceBookings.userId, userId), + ne(serviceBookings.status, "awaiting_payment"), + ), + ) + .orderBy(desc(serviceBookings.createdAt)), + + db + .select({ + id: coachingSessions.id, + status: coachingSessions.status, + bookedAt: coachingSessions.createdAt, + scheduledAt: coachingSessions.scheduledAt, + stripeProductId: services.stripeProductId, + childFirstName: children.firstName, + childLastName: children.lastName, + }) + .from(coachingSessions) + .innerJoin(services, eq(services.id, coachingSessions.serviceId)) + .leftJoin(children, eq(children.id, coachingSessions.childId)) + .where( + and( + eq(coachingSessions.userId, userId), + ne(coachingSessions.status, "awaiting_payment"), + ), + ) + .orderBy(desc(coachingSessions.createdAt)), + ]); + + const titles = await resolveServiceTitles([ + ...bookingRows.map((r) => r.stripeProductId), + ...sessionRows.map((r) => r.stripeProductId), + ]); + + const attendeeName = (first: string | null, last: string | null) => + first && last ? `${first} ${last}` : null; + + const bookings: AccountBooking[] = [ + ...bookingRows.map((r) => ({ + id: r.id, + kind: "service" as const, + title: titles.get(r.stripeProductId) ?? "Service", + status: r.status, + bookedAt: r.bookedAt, + scheduledAt: null, + startDate: r.startDate, + endDate: r.endDate, + attendee: attendeeName(r.childFirstName, r.childLastName), + })), + ...sessionRows.map((r) => ({ + id: r.id, + kind: "private_lesson" as const, + title: titles.get(r.stripeProductId) ?? "Private lesson", + status: r.status, + bookedAt: r.bookedAt, + scheduledAt: r.scheduledAt, + startDate: null, + endDate: null, + attendee: attendeeName(r.childFirstName, r.childLastName), + })), + ]; + + return bookings.sort((a, b) => b.bookedAt.getTime() - a.bookedAt.getTime()); +} diff --git a/app/(authenticated)/account/children/page.tsx b/app/(authenticated)/account/children/page.tsx new file mode 100644 index 0000000..6d173e0 --- /dev/null +++ b/app/(authenticated)/account/children/page.tsx @@ -0,0 +1,118 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { getCurrentUser } from "@/lib/auth/current-user"; +import { formatDate } from "@/lib/format"; +import { listChildrenForParent } from "./queries"; + +export default function ChildrenPage() { + return ( + }> + + + ); +} + +function DetailRow({ label, value }: { label: string; value: string | null }) { + return ( +
+ {label} + + {value?.trim() ? value : "—"} + +
+ ); +} + +async function ChildrenContent() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const kids = await listChildrenForParent(user.id); + + return ( +
+
+

Children

+

+ Children linked to your account. +

+
+ + {kids.length === 0 ? ( +

+ No children are linked to your account yet. Children are added + when you register one for a service. +

+ ) : ( +
+ {kids.map((child) => ( + + + + {child.firstName} {child.lastName} + + + Born {formatDate(child.dob)} ·{" "} + {child.gender.replace(/_/g, " ")} + + + +
+ + + +
+ +
+

+ Emergency contacts +

+ {child.emergencyContacts.length === 0 ? ( +

+ None on file. +

+ ) : ( + child.emergencyContacts.map((contact) => ( +
+

+ {contact.fullName}{" "} + + ({contact.relationship}) + +

+

+ {contact.phoneNumber} ·{" "} + {contact.emailAddress} +

+
+ )) + )} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/(authenticated)/account/children/queries.ts b/app/(authenticated)/account/children/queries.ts new file mode 100644 index 0000000..05359dc --- /dev/null +++ b/app/(authenticated)/account/children/queries.ts @@ -0,0 +1,69 @@ +import { asc, eq, inArray } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { children, emergencyContacts } from "@/lib/db/schema"; + +export type AccountChildContact = { + fullName: string; + emailAddress: string; + phoneNumber: string; + relationship: string; +}; + +export type AccountChild = { + id: string; + firstName: string; + lastName: string; + dob: string; + gender: string; + allergies: string | null; + medicalConditions: string | null; + medications: string | null; + emergencyContacts: AccountChildContact[]; +}; + +export async function listChildrenForParent( + parentId: string, +): Promise { + const childRows = await db + .select() + .from(children) + .where(eq(children.parentId, parentId)) + .orderBy(asc(children.firstName), asc(children.lastName)); + + if (childRows.length === 0) return []; + + const contactRows = await db + .select({ + childId: emergencyContacts.childId, + fullName: emergencyContacts.fullName, + emailAddress: emergencyContacts.emailAddress, + phoneNumber: emergencyContacts.phoneNumber, + relationship: emergencyContacts.relationship, + }) + .from(emergencyContacts) + .where( + inArray( + emergencyContacts.childId, + childRows.map((c) => c.id), + ), + ); + + const contactsByChild = new Map(); + for (const { childId, ...contact } of contactRows) { + const list = contactsByChild.get(childId) ?? []; + list.push(contact); + contactsByChild.set(childId, list); + } + + return childRows.map((child) => ({ + id: child.id, + firstName: child.firstName, + lastName: child.lastName, + dob: child.dob, + gender: child.gender, + allergies: child.allergies, + medicalConditions: child.medicalConditions, + medications: child.medications, + emergencyContacts: contactsByChild.get(child.id) ?? [], + })); +} diff --git a/app/(authenticated)/account/layout.tsx b/app/(authenticated)/account/layout.tsx new file mode 100644 index 0000000..279ecbb --- /dev/null +++ b/app/(authenticated)/account/layout.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { redirect } from "next/navigation"; +import { getCurrentUser } from "@/lib/auth/current-user"; + +export default async function AccountLayout({ + children, +}: { + children: ReactNode; +}) { + const user = await getCurrentUser(); + + if (!user) { + redirect("/login"); + } + + return ( +
+ {children} +
+ ); +} diff --git a/app/(authenticated)/account/page.tsx b/app/(authenticated)/account/page.tsx new file mode 100644 index 0000000..e8d76d8 --- /dev/null +++ b/app/(authenticated)/account/page.tsx @@ -0,0 +1,195 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { CheckoutButton } from "@/components/subscribe-button"; +import { getCurrentUser } from "@/lib/auth/current-user"; +import { formatDate, formatDateFromInstant } from "@/lib/format"; +import { formatCents } from "@/lib/money"; +import { getSubscriptionDetails } from "@/lib/stripe"; +import { StatusBadge } from "./_components/status-badge"; +import { listBookingsForUser } from "./bookings/queries"; +import { listMyTransactions } from "./transactions/queries"; + +const SUBSCRIPTION_PRICE_ID = process.env.STRIPE_PRICE_ID!; + +const RECENT_LIMIT = 3; + +export default function AccountOverviewPage() { + return ( + }> + + + ); +} + +async function OverviewContent() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const [subscription, bookings, transactions] = await Promise.all([ + getSubscriptionDetails(user.id), + listBookingsForUser(user.id), + listMyTransactions(user.id), + ]); + + const upcoming = bookings.filter( + (b) => b.status !== "cancelled" && b.status !== "completed", + ); + const recentTransactions = transactions.data.slice(0, RECENT_LIMIT); + + return ( +
+
+

Overview

+

+ Signed in as {user.email} +

+
+ +
+ + + Subscription + + {subscription + ? "Your current plan" + : "Subscribe to unlock premium features"} + + + + {subscription ? ( + <> +
+ + Plan + + + {subscription.planName} + +
+
+ + Price + + + {formatCents(subscription.priceAmount, "cad")}/ + {subscription.priceInterval} + +
+
+ + Status + +
+ + + Active + +
+
+ {subscription.cancelAtPeriodEnd && ( +

+ Cancels at end of billing period +

+ )} + + ) : ( + + )} +
+
+ + + + Upcoming bookings + + + View all bookings + + + + + {upcoming.length === 0 ? ( +

+ Nothing booked right now. +

+ ) : ( + upcoming.slice(0, RECENT_LIMIT).map((booking) => ( +
+
+

+ {booking.title} +

+

+ {booking.attendee ?? "You"} + {booking.startDate && + ` · from ${formatDate(booking.startDate)}`} +

+
+ +
+ )) + )} +
+
+ + + + Recent transactions + + + View all transactions + + + + + {recentTransactions.length === 0 ? ( +

+ No payments yet. +

+ ) : ( + recentTransactions.map((transaction) => ( +
+
+

+ {transaction.description} +

+

+ {formatDateFromInstant( + new Date(transaction.created * 1000), + )} +

+
+ + {formatCents( + transaction.amount, + transaction.currency, + )} + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/app/(authenticated)/account/profile/_components/profile-form.tsx b/app/(authenticated)/account/profile/_components/profile-form.tsx new file mode 100644 index 0000000..21bbe85 --- /dev/null +++ b/app/(authenticated)/account/profile/_components/profile-form.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useActionState, useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { updateMyProfile, type ProfileActionState } from "../actions"; + +const GENDER_OPTIONS = [ + { value: "male", label: "Male" }, + { value: "female", label: "Female" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +export type ProfileFormValues = { + firstName: string; + lastName: string; + phone: string | null; + address: string | null; + dob: string | null; + gender: string | null; +}; + +function FieldError({ errors }: { errors?: string[] }) { + if (!errors?.length) return null; + return

{errors[0]}

; +} + +export function ProfileForm({ profile }: { profile: ProfileFormValues }) { + const [state, formAction, pending] = useActionState< + ProfileActionState, + FormData + >(updateMyProfile, null); + + const lastStateRef = useRef(state); + + useEffect(() => { + if (state === lastStateRef.current) return; + lastStateRef.current = state; + + if (state?.message) toast.success(state.message); + if (state?.errors?._form) toast.error(state.errors._form[0]); + }, [state]); + + return ( +
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+ +
+ + +
+
+ ); +} diff --git a/app/(authenticated)/account/profile/actions.ts b/app/(authenticated)/account/profile/actions.ts new file mode 100644 index 0000000..2bfdbb0 --- /dev/null +++ b/app/(authenticated)/account/profile/actions.ts @@ -0,0 +1,62 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { profiles } from "@/lib/db/schema"; +import { getCurrentUser } from "@/lib/auth/current-user"; +import { updateProfileSchema } from "./schema"; + +export type ProfileActionState = { + errors?: Record; + message?: string; +} | null; + +export async function updateMyProfile( + _prev: ProfileActionState, + formData: FormData, +): Promise { + const user = await getCurrentUser(); + + if (!user) { + return { errors: { _form: ["You must be signed in to do that."] } }; + } + + const parsed = updateProfileSchema.safeParse({ + first_name: formData.get("first_name") ?? "", + last_name: formData.get("last_name") ?? "", + phone: formData.get("phone") ?? "", + address: formData.get("address") ?? "", + dob: formData.get("dob") ?? "", + gender: formData.get("gender") ?? "", + }); + + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + const { first_name, last_name, phone, address, dob, gender } = parsed.data; + + try { + await db + .update(profiles) + .set({ + firstName: first_name, + lastName: last_name, + phone, + address, + dob, + gender: gender as typeof profiles.$inferInsert.gender, + updatedAt: new Date(), + }) + .where(eq(profiles.id, user.id)); + } catch (error) { + console.error("Failed to update profile", error); + return { errors: { _form: ["Could not save your profile. Try again."] } }; + } + + revalidatePath("/account/profile"); + revalidatePath("/account"); + + return { message: "Profile updated." }; +} diff --git a/app/(authenticated)/account/profile/page.tsx b/app/(authenticated)/account/profile/page.tsx new file mode 100644 index 0000000..dab29cd --- /dev/null +++ b/app/(authenticated)/account/profile/page.tsx @@ -0,0 +1,78 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { signout } from "@/app/login/actions"; +import { getCurrentUser } from "@/lib/auth/current-user"; +import { formatDateFromInstant } from "@/lib/format"; +import { getAccountProfile } from "../queries"; +import { ProfileForm } from "./_components/profile-form"; + +export default function ProfilePage() { + return ( + }> + + + ); +} + +async function ProfileContent() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const profile = await getAccountProfile(user.id); + + return ( +
+
+

Profile

+

+ {profile + ? `Signed in as ${user.email} · member since ${formatDateFromInstant(profile.memberSince)}` + : `Signed in as ${user.email}`} +

+
+ + + + Your details + + Keep these up to date so coordinators can reach you. To change + your email address, contact us. + + + + {profile ? ( + + ) : ( +

+ We could not load your profile details. +

+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/app/(authenticated)/account/profile/schema.ts b/app/(authenticated)/account/profile/schema.ts new file mode 100644 index 0000000..36ba24e --- /dev/null +++ b/app/(authenticated)/account/profile/schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { genderEnum } from "@/lib/db/schema"; + +const optionalText = (max: number) => + z + .string() + .max(max) + .transform((value) => { + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; + }); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export const updateProfileSchema = z.object({ + first_name: z.string().trim().min(1, "First name is required").max(80), + last_name: z.string().trim().min(1, "Last name is required").max(80), + phone: optionalText(40), + address: optionalText(200), + dob: optionalText(10).refine( + (value) => + value === null || + (DATE_RE.test(value) && + !Number.isNaN(Date.parse(value)) && + Date.parse(value) <= Date.now()), + "Enter a valid date of birth in the past", + ), + gender: z + .string() + .transform((value) => (value.trim() === "" ? null : value.trim())) + .refine( + (value) => + value === null || + (genderEnum.enumValues as readonly string[]).includes(value), + "Select a valid option", + ), +}); diff --git a/app/(authenticated)/account/queries.ts b/app/(authenticated)/account/queries.ts new file mode 100644 index 0000000..cfd8b22 --- /dev/null +++ b/app/(authenticated)/account/queries.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { profiles } from "@/lib/db/schema"; + +export type AccountProfile = { + firstName: string; + lastName: string; + phone: string | null; + address: string | null; + dob: string | null; + gender: string | null; + memberSince: Date; +}; + +export async function getAccountProfile( + userId: string, +): Promise { + const profile = await db.query.profiles.findFirst({ + where: eq(profiles.id, userId), + columns: { + firstName: true, + lastName: true, + phone: true, + address: true, + dob: true, + gender: true, + createdAt: true, + }, + }); + + if (!profile) return null; + + return { + firstName: profile.firstName, + lastName: profile.lastName, + phone: profile.phone, + address: profile.address, + dob: profile.dob, + gender: profile.gender, + memberSince: profile.createdAt, + }; +} diff --git a/app/(authenticated)/account/transactions/page.tsx b/app/(authenticated)/account/transactions/page.tsx new file mode 100644 index 0000000..5eefd83 --- /dev/null +++ b/app/(authenticated)/account/transactions/page.tsx @@ -0,0 +1,106 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { getCurrentUser } from "@/lib/auth/current-user"; +import { formatDateFromInstant } from "@/lib/format"; +import { formatCents } from "@/lib/money"; +import { listMyTransactions } from "./queries"; + +export default function TransactionsPage() { + return ( + }> + + + ); +} + +async function TransactionsContent() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const { data: transactions } = await listMyTransactions(user.id); + + return ( +
+
+

Transactions

+

+ Payments made on your account. +

+
+ + {transactions.length === 0 ? ( +

+ You have no transactions yet. +

+ ) : ( +
+ + + + Date + Description + Amount + Status + + + + {transactions.map((transaction) => { + const partiallyRefunded = + !transaction.refunded && + transaction.amountRefunded > 0; + + return ( + + + {formatDateFromInstant( + new Date(transaction.created * 1000), + )} + + + {transaction.description} + + + {formatCents( + transaction.amount, + transaction.currency, + )} + + + {transaction.refunded ? ( + + refunded + + ) : partiallyRefunded ? ( + + {formatCents( + transaction.amountRefunded, + transaction.currency, + )}{" "} + refunded + + ) : ( + + paid + + )} + + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/app/(authenticated)/account/transactions/queries.ts b/app/(authenticated)/account/transactions/queries.ts new file mode 100644 index 0000000..5cb59bb --- /dev/null +++ b/app/(authenticated)/account/transactions/queries.ts @@ -0,0 +1,27 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { profiles } from "@/lib/db/schema"; +import { + listCustomerCharges, + type PaginatedTransactions, +} from "@/lib/stripe-transactions"; + +const TRANSACTIONS_PAGE_SIZE = 25; + +export async function listMyTransactions( + userId: string, +): Promise { + const profile = await db.query.profiles.findFirst({ + where: eq(profiles.id, userId), + columns: { stripeCustomerId: true }, + }); + + if (!profile?.stripeCustomerId) { + return { data: [], hasMore: false, firstId: null, lastId: null }; + } + + return listCustomerCharges({ + customerId: profile.stripeCustomerId, + limit: TRANSACTIONS_PAGE_SIZE, + }); +} diff --git a/app/(authenticated)/checkout/actions.test.ts b/app/(authenticated)/checkout/actions.test.ts deleted file mode 100644 index d438a0f..0000000 --- a/app/(authenticated)/checkout/actions.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @jest-environment node - */ -import { checkoutDonation } from "./actions"; - -const createSession = jest.fn(); -const retrieveProduct = jest.fn(); -const retrievePrice = jest.fn(); -const getUser = jest.fn(); -const getOrCreateStripeCustomer = jest.fn(); -const headersGet = jest.fn(); - -jest.mock("@/lib/stripe", () => ({ - stripe: { - checkout: { sessions: { create: (...args: unknown[]) => createSession(...args) } }, - products: { retrieve: (...args: unknown[]) => retrieveProduct(...args) }, - prices: { retrieve: (...args: unknown[]) => retrievePrice(...args) }, - }, - getOrCreateStripeCustomer: (...args: unknown[]) => - getOrCreateStripeCustomer(...args), -})); - -jest.mock("@/utils/supabase/server", () => ({ - createClient: async () => ({ auth: { getUser } }), -})); - -jest.mock("next/headers", () => ({ - headers: async () => ({ get: (...args: unknown[]) => headersGet(...args) }), -})); - -const authedUser = { - data: { user: { id: "user-1", email: "donor@test.com" } }, -}; - -beforeEach(() => { - jest.clearAllMocks(); - process.env.STRIPE_DONATION_PRODUCT_ID = "prod_test123"; - getUser.mockResolvedValue(authedUser); - getOrCreateStripeCustomer.mockResolvedValue("cus_123"); - retrieveProduct.mockResolvedValue({ default_price: "price_donation" }); - retrievePrice.mockResolvedValue({ custom_unit_amount: { preset: 1000 } }); - createSession.mockResolvedValue({ url: "https://stripe.test/session" }); - headersGet.mockReturnValue("https://app.test"); -}); - -describe("checkoutDonation", () => { - it("requires an authenticated user", async () => { - getUser.mockResolvedValue({ data: { user: null } }); - const result = await checkoutDonation(); - expect(result).toEqual({ error: "Not authenticated" }); - expect(createSession).not.toHaveBeenCalled(); - }); - - it("returns an error when the donation product env var is not set", async () => { - delete process.env.STRIPE_DONATION_PRODUCT_ID; - const result = await checkoutDonation(); - expect(result).toEqual({ error: "Donation product not configured" }); - expect(createSession).not.toHaveBeenCalled(); - }); - - it("creates a checkout session using the donation product's default price", async () => { - const result = await checkoutDonation(); - - expect(retrieveProduct).toHaveBeenCalledWith("prod_test123"); - expect(getOrCreateStripeCustomer).toHaveBeenCalledWith( - "user-1", - "donor@test.com", - ); - expect(createSession).toHaveBeenCalledWith( - expect.objectContaining({ - customer: "cus_123", - mode: "payment", - metadata: { type: "donation" }, - line_items: [{ price: "price_donation", quantity: 1 }], - }), - ); - expect(result).toEqual({ url: "https://stripe.test/session" }); - }); - - it("derives success/cancel URLs from the request origin", async () => { - await checkoutDonation(); - expect(createSession).toHaveBeenCalledWith( - expect.objectContaining({ - success_url: "https://app.test/checkout/success", - cancel_url: "https://app.test/checkout/cancel", - }), - ); - }); - - it("returns an error when Stripe omits the checkout URL", async () => { - createSession.mockResolvedValue({ url: null }); - const result = await checkoutDonation(); - expect(result).toEqual({ error: "Stripe did not return a checkout URL" }); - }); - - it("throws when the request origin is missing", async () => { - headersGet.mockReturnValue(null); - await expect(checkoutDonation()).rejects.toThrow( - "Missing request origin", - ); - expect(createSession).not.toHaveBeenCalled(); - }); -}); diff --git a/app/(authenticated)/checkout/actions.ts b/app/(authenticated)/checkout/actions.ts deleted file mode 100644 index 7cdd042..0000000 --- a/app/(authenticated)/checkout/actions.ts +++ /dev/null @@ -1,198 +0,0 @@ -"use server"; - -import { headers } from "next/headers"; -import { and, eq } from "drizzle-orm"; -import type Stripe from "stripe"; - -import { db } from "@/lib/db"; -import { coachingSessions, serviceBookings, services } from "@/lib/db/schema"; -import { getOrCreateStripeCustomer, stripe } from "@/lib/stripe"; -import { createClient } from "@/utils/supabase/server"; - -export type CheckoutResult = { url: string } | { error: string }; - -async function getDefaultPriceId(stripeProductId: string): Promise { - const product = await stripe.products.retrieve(stripeProductId); - const defaultPriceId = product.default_price; - if (!defaultPriceId) { - throw new Error(`Stripe product ${stripeProductId} has no default price`); - } - return defaultPriceId as string; -} - -async function getRequestOrigin(): Promise { - const origin = (await headers()).get("origin"); - if (!origin) { - throw new Error("Missing request origin"); - } - return origin; -} - -type CreateSessionResult = - | { session: Stripe.Checkout.Session } - | { error: string }; - -async function createStripeCheckoutSession(params: { - userId: string; - email: string; - stripeProductId: string; - metadata: Record; -}): Promise { - const priceId = await getDefaultPriceId(params.stripeProductId); - - const customerId = await getOrCreateStripeCustomer( - params.userId, - params.email, - ); - const origin = await getRequestOrigin(); - - const session = await stripe.checkout.sessions.create({ - customer: customerId, - mode: "payment", - payment_method_types: ["card"], - line_items: [{ price: priceId, quantity: 1 }], - success_url: `${origin}/checkout/success`, - cancel_url: `${origin}/checkout/cancel`, - metadata: params.metadata, - }); - - if (!session.url) - return { error: "Stripe did not return a checkout URL" }; - return { session }; -} - -export async function checkoutServiceBooking({ - serviceId, -}: { - serviceId: string; -}): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; - - const service = await db.query.services.findFirst({ - where: eq(services.id, serviceId), - }); - if (!service) return { error: "Service not found" }; - if (service.status !== "active") - return { error: "Service is not available" }; - if (service.type !== "programs") - return { error: "Service is not a program" }; - - const [row] = await db - .insert(serviceBookings) - .values({ - userId: user.id, - serviceId: service.id, - status: "awaiting_payment", - }) - .returning({ id: serviceBookings.id }); - - const result = await createStripeCheckoutSession({ - userId: user.id, - email: user.email!, - stripeProductId: service.stripeProductId, - metadata: { - type: "program", - bookingId: row.id, - }, - }); - if ("error" in result) { - await db.delete(serviceBookings).where(eq(serviceBookings.id, row.id)); - return { error: result.error }; - } - - await db - .update(serviceBookings) - .set({ stripeOrderId: result.session.id }) - .where(eq(serviceBookings.id, row.id)); - - return { url: result.session.url! }; -} - -export async function checkoutDonation(): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; - - // This product can have a variable price, adjusted by Stripe Checkout - const donationProductId = process.env.STRIPE_DONATION_PRODUCT_ID; - if (!donationProductId) return { error: "Donation product not configured" }; - - const product = await stripe.products.retrieve(donationProductId); - const priceId = product.default_price; - - if (!priceId || typeof priceId !== "string") { - return { error: "Donation product has no default price" }; - } - - const price = await stripe.prices.retrieve(priceId); - if (!price.custom_unit_amount) { - return { error: "Donation price is not configured for customer chosen amounts" }; - } - - const result = await createStripeCheckoutSession({ - userId: user.id, - email: user.email!, - stripeProductId: donationProductId, - metadata: { type: "donation" }, - }); - if ("error" in result) return { error: result.error }; - - return { url: result.session.url! }; -} - -export async function checkoutCoachingSession({ - coachingSessionId, -}: { - coachingSessionId: string; -}): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return { error: "Not authenticated" }; - - const row = await db.query.coachingSessions.findFirst({ - where: and( - eq(coachingSessions.id, coachingSessionId), - eq(coachingSessions.userId, user.id), - ), - }); - if (!row) return { error: "Coaching session not found" }; - if (row.status !== "awaiting_payment") - return { error: "Coaching session is not awaiting payment" }; - - const service = await db.query.services.findFirst({ - where: eq(services.id, row.serviceId), - }); - if (!service) return { error: "Service not found" }; - - const result = await createStripeCheckoutSession({ - userId: user.id, - email: user.email!, - stripeProductId: service.stripeProductId, - metadata: { - type: "private_lesson", - coachingSessionId: row.id, - }, - }); - if ("error" in result) { - await db - .delete(coachingSessions) - .where(eq(coachingSessions.id, row.id)); - return { error: result.error }; - } - - await db - .update(coachingSessions) - .set({ stripeOrderId: result.session.id }) - .where(eq(coachingSessions.id, row.id)); - - return { url: result.session.url! }; -} - diff --git a/app/(authenticated)/finance/page.tsx b/app/(authenticated)/finance/page.tsx index 3aa3ed2..1714422 100644 --- a/app/(authenticated)/finance/page.tsx +++ b/app/(authenticated)/finance/page.tsx @@ -1,4 +1,8 @@ -export default function FinancePage() { +import { requireAdminArea } from "@/lib/auth/current-user"; + +export default async function FinancePage() { + await requireAdminArea(); + return (

Finance

diff --git a/app/(authenticated)/forms/_components/form-question-shared.ts b/app/(authenticated)/forms/_components/form-question-shared.ts index c2747e3..fc04994 100644 --- a/app/(authenticated)/forms/_components/form-question-shared.ts +++ b/app/(authenticated)/forms/_components/form-question-shared.ts @@ -1,4 +1,4 @@ -import type { ExtraQuestionType } from "../queries"; +import type { FormQuestionType } from "../queries"; export type DraftOption = { id: string; @@ -8,12 +8,12 @@ export type DraftOption = { export type DraftQuestion = { clientId: string; - type: ExtraQuestionType; + type: FormQuestionType; prompt: string; options?: DraftOption[]; }; -export const QUESTION_TYPE_LABELS: Record = { +export const QUESTION_TYPE_LABELS: Record = { text: "Text", multiple_choices: "Multiple choice", checkboxes: "Checkboxes", @@ -28,7 +28,7 @@ export function emptyQuestion(): DraftQuestion { }; } -export function needsOptions(type: ExtraQuestionType): boolean { +export function needsOptions(type: FormQuestionType): boolean { return type === "multiple_choices" || type === "checkboxes"; } diff --git a/app/(authenticated)/forms/_components/question-edit-dialog.tsx b/app/(authenticated)/forms/_components/question-edit-dialog.tsx index 42d1827..9177920 100644 --- a/app/(authenticated)/forms/_components/question-edit-dialog.tsx +++ b/app/(authenticated)/forms/_components/question-edit-dialog.tsx @@ -29,7 +29,7 @@ import { needsOptions, QUESTION_TYPE_LABELS, } from "./form-question-shared"; -import type { ExtraQuestionType } from "../queries"; +import type { FormQuestionType } from "../queries"; function FieldError({ messages }: { messages?: string[] }) { if (!messages?.length) return null; @@ -207,7 +207,7 @@ export function QuestionEditDialog({ + to its first option without re-syncing the controlled value. */} + +
+ + setIsForChildren(checked === true) + } + /> + +
+ + + {isForChildren && ( +
+ + + +
+ )} + +
+
+ + setRequiresSubscription(checked === true) + } + /> + + +
+
+ {type === "programs" && ( - + + - + )} diff --git a/app/(authenticated)/services/services-data-table.tsx b/app/(authenticated)/services/services-data-table.tsx index 268ce43..63f4794 100644 --- a/app/(authenticated)/services/services-data-table.tsx +++ b/app/(authenticated)/services/services-data-table.tsx @@ -1,8 +1,9 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Archive, ArchiveRestore, Ban, Pencil, Power } from "lucide-react"; +import { Archive, ArchiveRestore, Ban, Pencil, Power, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -10,9 +11,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DataTable } from "@/components/data-table"; +import { UsersDataTable } from "@/app/(authenticated)/users/_components/users-data-table"; import { formatDate } from "@/lib/format"; -import { statusBadgeClass } from "@/lib/service-status"; +import { statusBadgeClass, subscriptionBadgeClass } from "@/lib/service-badges"; import { setServiceStatus } from "@/app/(authenticated)/services/actions"; import type { ServiceView } from "@/app/(authenticated)/services/queries"; @@ -43,6 +44,7 @@ export function ServicesDataTable({ { accessorKey: "title", header: "Program", + meta: { colWidth: "42%" }, cell: ({ row }) => ( {row.original.title ?? "—"} ), @@ -50,6 +52,7 @@ export function ServicesDataTable({ { accessorKey: "status", header: "Status", + meta: { colWidth: "13%" }, cell: ({ row }) => ( ), }, + { + accessorKey: "requiresSubscription", + header: "Subscription", + cell: ({ row }) => ( + + {row.original.requiresSubscription ? "Required" : "Not required"} + + ), + }, { id: "startDate", header: "Start Date", + meta: { colWidth: "17%" }, cell: ({ row }) => { const s = row.original.scheduledAt; return s ? formatDate(s.startDate) : "—"; @@ -72,6 +90,7 @@ export function ServicesDataTable({ { id: "endDate", header: "End Date", + meta: { colWidth: "17%" }, cell: ({ row }) => { const s = row.original.scheduledAt; return s ? formatDate(s.endDate) : "—"; @@ -79,11 +98,22 @@ export function ServicesDataTable({ }, { id: "actions", - header: () =>
Actions
, + header: "Actions", + meta: { colWidth: "11%", thClassName: "text-right", tdClassName: "text-right" }, cell: ({ row }) => { const s = row.original; return (
+ + + + + View registered + {(s.status === "active" || s.status === "disabled") && ( @@ -172,7 +202,7 @@ export function ServicesDataTable({ ); return ( - ("active"); const [editing, setEditing] = React.useState(null); @@ -28,13 +31,13 @@ export function ServicesTable({ }, [services, tab]); return ( - <> +
setTab(v as StatusTab)} - className="w-full" + className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden" > -
+
{statusTabs.map((status) => ( @@ -42,22 +45,30 @@ export function ServicesTable({ ))} - +
- + { if (!v) setEditing(null); }} /> - +
); } diff --git a/app/(authenticated)/settings/page.tsx b/app/(authenticated)/settings/page.tsx index 8ea74e7..dbaa15a 100644 --- a/app/(authenticated)/settings/page.tsx +++ b/app/(authenticated)/settings/page.tsx @@ -1,4 +1,8 @@ -export default function SettingsPage() { +import { requireAdminArea } from "@/lib/auth/current-user"; + +export default async function SettingsPage() { + await requireAdminArea(); + return (

Settings

diff --git a/app/(authenticated)/users/_components/components/user-transactions-modals.tsx b/app/(authenticated)/users/_components/components/user-transactions-modals.tsx new file mode 100644 index 0000000..7d3f42b --- /dev/null +++ b/app/(authenticated)/users/_components/components/user-transactions-modals.tsx @@ -0,0 +1,511 @@ +"use client"; + +import * as React from "react"; +import { + ArrowLeft, + ArrowRight, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Spinner } from "@/components/ui/spinner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { getUserTransactions, createTransactionRefund } from "@/app/(authenticated)/users/actions"; +import type { UserTransaction } from "@/app/(authenticated)/users/actions"; + +export interface UserTransactionsModalProps { + userName: string; + userEmail: string; + stripeCustomerId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function UserTransactionsModal({ + userName, + userEmail, + stripeCustomerId, + open, + onOpenChange, +}: UserTransactionsModalProps) { + + const [transactions, setTransactions] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [submitting, setSubmitting] = React.useState(null); + const [error, setError] = React.useState(null); + + const [hasMore, setHasMore] = React.useState(false); + const [cursors, setCursors] = React.useState<{ firstId: string | null; lastId: string | null }>({ + firstId: null, + lastId: null, + }); + const [history, setHistory] = React.useState>([]); + + const [selectedTransaction, setSelectedTransaction] = React.useState(null); + const [refundAmounts, setRefundAmounts] = React.useState>({}); + const [confirmRefund, setConfirmRefund] = React.useState<{ + chargeId: string; + isFullRefund: boolean; + amountCents: number; + displayAmount: string; + idempotencyKey: string; + } | null>(null); + + const fetchTransactions = React.useCallback( + async (params?: { startingAfter?: string }) => { + if (!stripeCustomerId) return; + setLoading(true); + setError(null); + try { + const result = await getUserTransactions({ + customerId: stripeCustomerId, + startingAfter: params?.startingAfter, + }); + setTransactions(result.data); + setHasMore(result.hasMore); + setCursors({ + firstId: result.firstId, + lastId: result.lastId, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch transactions"); + } finally { + setLoading(false); + } + }, + [stripeCustomerId] + ); + + React.useEffect(() => { + if (open && stripeCustomerId) { + setHistory([]); + setSelectedTransaction(null); + fetchTransactions(); + } else if (!open) { + setTransactions([]); + setSelectedTransaction(null); + setError(null); + } + }, [open, stripeCustomerId, fetchTransactions]); + + + const handleNextPage = async () => { + if (!cursors.lastId || !hasMore) return; + setHistory((prev) => [...prev, cursors]); + await fetchTransactions({ startingAfter: cursors.lastId }); + }; + + const handlePrevPage = async () => { + if (history.length === 0) return; + + const newHistory = history.slice(0, -1); + setHistory(newHistory); + + if (newHistory.length === 0) { + await fetchTransactions(); + } else { + const prevPageCursors = newHistory[newHistory.length - 1]; + await fetchTransactions({ startingAfter: prevPageCursors.lastId ?? undefined }); + } + }; + + // Step 1: Validates and prompts the admin via confirmation dialog + const initiateRefund = (chargeId: string, isFullRefund: boolean, maxRefundableCents: number, currency: string) => { + let refundAmountCents = maxRefundableCents; + let displayAmount = formatAmount(maxRefundableCents, currency); + + if (!isFullRefund) { + const rawAmount = refundAmounts[chargeId] || ""; + const parsedAmount = parseFloat(rawAmount); + if (isNaN(parsedAmount) || parsedAmount <= 0) { + toast.error("Please enter a valid refund amount"); + return; + } + + refundAmountCents = Math.round(parsedAmount * 100); + if (refundAmountCents > maxRefundableCents) { + toast.error(`Refund amount cannot exceed remaining refundable balance (${formatAmount(maxRefundableCents, currency)})`); + return; + } + displayAmount = formatAmount(refundAmountCents, currency); + } + + const idempotencyKey = typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : Math.random().toString(36).substring(2) + Date.now().toString(36); + + setConfirmRefund({ + chargeId, + isFullRefund, + amountCents: refundAmountCents, + displayAmount, + idempotencyKey, + }); + }; + + // Step 2: Executes the refund Server Action after confirmation + const executeRefund = async () => { + if (!confirmRefund) return; + const { chargeId, isFullRefund, amountCents, idempotencyKey } = confirmRefund; + setConfirmRefund(null); // Close confirmation immediately + + setSubmitting(chargeId); + try { + const fd = new FormData(); + fd.append("chargeId", chargeId); + fd.append("idempotencyKey", idempotencyKey); + if (!isFullRefund) { + fd.append("amountCents", String(amountCents)); + } + fd.append("customerId", stripeCustomerId) + + const result = await createTransactionRefund(null, fd); + + if (result?.errors) { + toast.error("Refund failed", { + description: Object.values(result.errors).flat().join(" "), + }); + } else if (result?.status === "succeeded") { + const formattedAmount = formatAmount(amountCents, result.updatedTransaction?.currency || "cad"); + toast.success(`Refunded ${formattedAmount}`); + + setRefundAmounts((prev) => ({ ...prev, [chargeId]: "" })); + + if (result.updatedTransaction) { + setTransactions((prev) => + prev.map((t) => (t.id === chargeId ? result.updatedTransaction! : t)) + ); + setSelectedTransaction(result.updatedTransaction); + } + } else if (result?.status === "pending") { + toast.info("Refund pending. The transaction will reconcile shortly."); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to process refund"); + } finally { + setSubmitting(null); + } + }; + + function formatAmount(cents: number, currency: string) { + const dollars = cents / 100; + return new Intl.NumberFormat("en-CA", { + style: "currency", + currency: currency.toUpperCase(), + }).format(dollars); + } + + function formatDate(timestamp: number) { + return new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "short", + day: "numeric", + }).format(new Date(timestamp * 1000)); + } + + function formatRefundStatus(t: UserTransaction): string { + const total = formatAmount(t.amount, t.currency); + const remainingCents = t.amount - t.amountRefunded + const refundable = formatAmount(remainingCents, t.currency); + + if (t.refunded || (t.amountRefunded > 0 && remainingCents <= 0)) { + return "Fully refunded - nothing left to refund"; + } + if (t.amountRefunded > 0){ + const refunded = formatAmount(t.amountRefunded, t.currency); + return `${refunded} refunded of ${total} · ${refundable} refundable — partially refunded`; + } + return `Paid ${total} — no refunds`; + } + + function formatRefundStatusShort(t: UserTransaction): string { + const remainingCents = t.amount - t.amountRefunded; + + if (t.refunded || (t.amountRefunded > 0 && remainingCents <= 0)) { + return "Fully refunded"; + } + if (t.amountRefunded > 0) { + return "Partially refunded"; + } + return "Paid"; + } + + return ( + <> + + + + User transactions + + {userName && userName.trim() !== userEmail ? `${userName} (${userEmail})` : userEmail} + + +
+ {loading && transactions.length === 0 ? ( +
+ +

Loading transaction history...

+
+ ) : error ? ( +
+ {error} +
+ ) : transactions.length === 0 ? ( +
+ No transactions found for this customer on Stripe. +
+ ) : ( +
+ + + + Date + Description + Amount + Refund Status + + + + {transactions.map((t) => ( + setSelectedTransaction(t)} + title="Click to view details or issue a refund" + > + + {formatDate(t.created)} + + + {t.description} + + + {formatAmount(t.amount, t.currency)} + + + {formatRefundStatusShort(t)} + + + ))} + +
+
+ + )} +
+ +
+ + +
+ +
+
+
+ + {/* Transaction Details & Refund Dialog */} + !isOpen && setSelectedTransaction(null)}> + + + Refund Transaction + + Manage refunds for this transaction. + + + + {selectedTransaction && ( +
+
+
+ Date + {formatDate(selectedTransaction.created)} +
+
+ Amount + {formatAmount(selectedTransaction.amount, selectedTransaction.currency)} +
+
+ Description + + {selectedTransaction.description} + +
+
+ Refund Status + + {formatRefundStatus(selectedTransaction)} + +
+
+ + {selectedTransaction.refunds.length > 0 && ( +
+ + Refund history + +
    3 && + "max-h-[4.5rem] overflow-y-auto pr-1" + )} + > + {selectedTransaction.refunds.map((r) => ( +
  • + {formatAmount(r.amount, selectedTransaction.currency)} + {formatDate(r.created)} + {r.status ?? "unknown"} +
  • + ))} +
+
+ )} + + {/* Refund Inputs */} + {(selectedTransaction.amount - selectedTransaction.amountRefunded) > 0 ? ( +
+
+ + setRefundAmounts({ ...refundAmounts, [selectedTransaction.id]: e.target.value })} + className="h-9 text-xs" + /> +
+
+ ) : ( +

+ No further refunds can be issued for this charge. +

+ )} +
+ )} + + + + {selectedTransaction && (selectedTransaction.amount - selectedTransaction.amountRefunded) > 0 && ( + <> + + + + )} + +
+
+ + {/* Refund Confirmation Alert Dialog */} + !isOpen && setConfirmRefund(null)}> + + + Confirm Refund + + Are you sure you want to issue a {confirmRefund?.isFullRefund ? "full" : "partial"} refund of{" "} + {confirmRefund?.displayAmount}? This action will transfer money back to the customer on Stripe and cannot be undone. + + + + setConfirmRefund(null)}>Cancel + + Confirm Refund + + + + + +); +} \ No newline at end of file diff --git a/app/(authenticated)/users/_components/user-actions-cell.tsx b/app/(authenticated)/users/_components/user-actions-cell.tsx index 2a826e2..a6af05b 100644 --- a/app/(authenticated)/users/_components/user-actions-cell.tsx +++ b/app/(authenticated)/users/_components/user-actions-cell.tsx @@ -1,17 +1,32 @@ "use client"; import { useRef, useState } from "react"; -import { Pencil, Tag } from "lucide-react"; + +import { Pencil, Tag, Trash2, ReceiptText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { DiscountModal, type ActiveDiscount, type DiscountService } from "@/components/discount-modal"; import { getUserDiscountModalData, applyDiscountToCustomerProduct, removeCouponById, } from "@/app/(authenticated)/discounts/actions"; +import { deleteUserAdmin } from "@/app/(authenticated)/users/actions"; import { toast } from "sonner"; import { profileRoleLabel, type UserRow } from "../profile-role-label"; +import { UserTransactionsModal } from "./components/user-transactions-modals"; + interface UserActionsCellProps { user: UserRow; @@ -19,10 +34,13 @@ interface UserActionsCellProps { } export function UserActionsCell({ user, onEdit }: UserActionsCellProps) { + const [txOpen, setTxOpen] = useState(false); const [open, setOpen] = useState(false); const [services, setServices] = useState([]); const [discounts, setDiscounts] = useState([]); const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const servicesFetched = useRef(false); const fetchModalData = async (forceServices = false) => { @@ -71,6 +89,25 @@ export function UserActionsCell({ user, onEdit }: UserActionsCellProps) { } }; + const handleDelete = async () => { + setDeleting(true); + try { + const fd = new FormData(); + fd.append("user_id", user.id); + const result = await deleteUserAdmin(null, fd); + if (result?.errors) { + toast.error("Failed to delete user", { + description: Object.values(result.errors).flat().join(" "), + }); + } else { + toast.success("User deleted"); + setConfirmOpen(false); + } + } finally { + setDeleting(false); + } + }; + const handleRemove = async (couponId: string) => { if (!user.stripeCustomerId) return; const result = await removeCouponById(couponId, user.stripeCustomerId); @@ -115,6 +152,62 @@ export function UserActionsCell({ user, onEdit }: UserActionsCellProps) { {user.stripeCustomerId ? "Manage discounts" : "No Stripe customer"} + + + + + + {user.stripeCustomerId ? "View transactions" : "No Stripe customer"} + + + + + + + + + + Delete user + + + + Delete user? + + This permanently deletes {user.firstName} {user.lastName} + ’s account. This action cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + handleDelete(); + }} + disabled={deleting} + > + {deleting ? "Deleting..." : "Delete"} + + + + +
); } diff --git a/app/(authenticated)/users/_components/user-admin-dialog.tsx b/app/(authenticated)/users/_components/user-admin-dialog.tsx index 37c1605..f47e4a3 100644 --- a/app/(authenticated)/users/_components/user-admin-dialog.tsx +++ b/app/(authenticated)/users/_components/user-admin-dialog.tsx @@ -247,7 +247,9 @@ function CreateUserFormContent({ User - Coach + + Coordinator + Admin @@ -422,7 +424,9 @@ function EditUserFormContent({ User - Coach + + Coordinator + Admin diff --git a/app/(authenticated)/users/_components/users-columns.tsx b/app/(authenticated)/users/_components/users-columns.tsx index 4509dd5..cba8fec 100644 --- a/app/(authenticated)/users/_components/users-columns.tsx +++ b/app/(authenticated)/users/_components/users-columns.tsx @@ -1,7 +1,6 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback} from "@/components/ui/avatar"; import { profileRoleLabel, type UserRow } from "../profile-role-label"; import { UserActionsCell } from "./user-actions-cell"; @@ -51,24 +50,6 @@ export function getUsersColumns( ), }, - { - id: "status", - header: "Status", - meta: { colWidth: "14%" }, - cell: ({ row }) => { - const active = row.original.isActive; - return ( - - - {active ? "Active" : "Inactive"} - - ); - }, - }, { id: "lastLoginAt", header: "Last Login", diff --git a/app/(authenticated)/users/actions.test.ts b/app/(authenticated)/users/actions.test.ts new file mode 100644 index 0000000..ea7483c --- /dev/null +++ b/app/(authenticated)/users/actions.test.ts @@ -0,0 +1,207 @@ +/** + * @jest-environment node + */ +import { + getUserTransactions, + createTransactionRefund, + } from "./actions"; + + const chargesList = jest.fn(); + const chargesRetrieve = jest.fn(); + const refundsCreate = jest.fn(); + const requireAdmin = jest.fn(); + + jest.mock("@/lib/stripe", () => ({ + stripe: { + charges: { + list: (...args: unknown[]) => chargesList(...args), + retrieve: (...args: unknown[]) => chargesRetrieve(...args), + }, + refunds: { + create: (...args: unknown[]) => refundsCreate(...args), + }, + }, + })); + + jest.mock("@/lib/auth/require-admin", () => ({ + requireAdmin: (...args: unknown[]) => requireAdmin(...args), + })); + + const CUSTOMER_ID = "cus_123"; + + function refundFormData(fields: Record) { + const fd = new FormData(); + for (const [key, value] of Object.entries({ + customerId: CUSTOMER_ID, + ...fields, + })) { + fd.append(key, value); + } + return fd; + } + + const sampleCharge = { + id: "ch_test", + amount: 5000, + amount_refunded: 0, + refunded: false, + created: 1700000000, + currency: "cad", + description: "Yearly plan", + payment_intent: "pi_test", + refunds: { data: [] }, + metadata: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + requireAdmin.mockResolvedValue(undefined); + }); + + describe("getUserTransactions", () => { + it("requires admin", async () => { + requireAdmin.mockRejectedValue(new Error("Forbidden")); + + await expect( + getUserTransactions({ customerId: "cus_123" }), + ).rejects.toThrow("Forbidden"); + + expect(chargesList).not.toHaveBeenCalled(); + }); + + it("maps charges from Stripe", async () => { + chargesList.mockResolvedValue({ + data: [sampleCharge], + has_more: false, + }); + + const result = await getUserTransactions({ customerId: "cus_123" }); + + expect(chargesList).toHaveBeenCalledWith( + expect.objectContaining({ customer: "cus_123", limit: 10 }), + ); + expect(result.data[0]).toMatchObject({ + id: "ch_test", + amount: 5000, + description: "Yearly plan", + currency: "cad", + }); + expect(result.hasMore).toBe(false); + }); + }); + + describe("createTransactionRefund", () => { + it("returns unauthorized when not admin", async () => { + requireAdmin.mockRejectedValue(new Error("Forbidden")); + + const result = await createTransactionRefund( + null, + refundFormData({ chargeId: "ch_test", idempotencyKey: "key-1" }), + ); + + expect(result).toEqual({ errors: { _form: ["Unauthorized"] } }); + expect(chargesRetrieve).not.toHaveBeenCalled(); + }); + + it("rejects when charge belongs to a different customer", async () => { + chargesRetrieve.mockResolvedValue({ + customer: "cus_other", + amount: 5000, + amount_refunded: 0, + }); + + const result = await createTransactionRefund( + null, + refundFormData({ chargeId: "ch_test", idempotencyKey: "key-1" }), + ); + + expect(result?.errors?._form).toContain( + "Charge does not belong to this customer.", + ); + expect(refundsCreate).not.toHaveBeenCalled(); + }); + + it("rejects when charge is already fully refunded", async () => { + chargesRetrieve.mockResolvedValue({ + customer: CUSTOMER_ID, + amount: 5000, + amount_refunded: 5000, + }); + + const result = await createTransactionRefund( + null, + refundFormData({ chargeId: "ch_test", idempotencyKey: "key-1" }), + ); + + expect(result?.errors?._form).toContain( + "This charge is already fully refunded.", + ); + expect(refundsCreate).not.toHaveBeenCalled(); + }); + + it("rejects partial refund above remaining balance", async () => { + chargesRetrieve.mockResolvedValue({ + customer: CUSTOMER_ID, + amount: 5000, + amount_refunded: 1000, + }); + + const result = await createTransactionRefund( + null, + refundFormData({ + chargeId: "ch_test", + idempotencyKey: "key-1", + amountCents: "5000", + }), + ); + + expect(result?.errors?._form).toContain( + "Refund amount exceeds the remaining refundable balance.", + ); + expect(refundsCreate).not.toHaveBeenCalled(); + }); + + it("issues a full refund and returns updated transaction", async () => { + chargesRetrieve + .mockResolvedValueOnce({ + customer: CUSTOMER_ID, + amount: 5000, + amount_refunded: 0, + }) + .mockResolvedValueOnce({ + ...sampleCharge, + customer: CUSTOMER_ID, + amount_refunded: 5000, + refunded: true, + refunds: { + data: [ + { + id: "re_test", + amount: 5000, + status: "succeeded", + created: 1700000001, + }, + ], + }, + }); + + refundsCreate.mockResolvedValue({ + id: "re_test", + amount: 5000, + status: "succeeded", + created: 1700000001, + }); + + const result = await createTransactionRefund( + null, + refundFormData({ chargeId: "ch_test", idempotencyKey: "key-1" }), + ); + + expect(refundsCreate).toHaveBeenCalledWith( + { charge: "ch_test" }, + { idempotencyKey: "key-1" }, + ); + expect(result?.status).toBe("succeeded"); + expect(result?.updatedTransaction?.amountRefunded).toBe(5000); + }); + }); \ No newline at end of file diff --git a/app/(authenticated)/users/actions.ts b/app/(authenticated)/users/actions.ts index 6208150..a5a33e4 100644 --- a/app/(authenticated)/users/actions.ts +++ b/app/(authenticated)/users/actions.ts @@ -1,15 +1,24 @@ "use server"; import { revalidatePath } from "next/cache"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; import { db } from "@/lib/db"; -import { profiles } from "@/lib/db/schema"; +import { profiles, services } from "@/lib/db/schema"; import { requireAdmin } from "@/lib/auth/require-admin"; import { createAdminClient } from "@/utils/supabase/admin"; import type { Role } from "@/lib/roles"; import { ROLES } from "@/lib/roles"; import { createUserAdminSchema, updateUserAdminSchema } from "./schema"; -import { grantComplimentarySubscription } from "@/lib/stripe"; +import { grantComplimentarySubscription , stripe} from "@/lib/stripe"; +import type Stripe from "stripe"; +import { getTransactionsSchema, createRefundSchema} from "./schema"; +import { listCustomerCharges } from "@/lib/stripe-transactions"; +import type { + TransactionRefund, + UserTransaction, + PaginatedTransactions, +} from "@/lib/stripe-transactions"; export type UserAdminActionState = { errors?: Record; @@ -17,6 +26,14 @@ export type UserAdminActionState = { data?: Record; } | null; +export type RefundActionState = { + errors?: Record; + message?: string; + status?: "succeeded" | "pending" | "failed"; + refund?: TransactionRefund; + updatedTransaction? : UserTransaction; +} | null + const USERS_PATH = "/users"; export async function updateUserAdmin( @@ -50,10 +67,10 @@ export async function updateUserAdmin( } const admin = createAdminClient(); - const { error: authError } = await admin.auth.admin.updateUserById( - user_id, - { email, app_metadata: { user_role: role } }, - ); + const { error: authError } = await admin.auth.admin.updateUserById(user_id, { + email, + app_metadata: { user_role: role }, + }); if (authError) { const message = /prod_|price_|stripe/i.test(authError.message) @@ -66,15 +83,14 @@ export async function updateUserAdmin( } try { - await db .update(profiles) .set({ role: role as Role, updatedAt: new Date() }) .where(eq(profiles.id, user_id)); } catch { - return { - errors: { _form: ["Failed to update profile. Please try again."] } - } + return { + errors: { _form: ["Failed to update profile. Please try again."] }, + }; } revalidatePath(USERS_PATH); @@ -105,14 +121,8 @@ export async function createUserAdmin( return { errors: parsed.error.flatten().fieldErrors }; } - const { - first_name, - last_name, - email, - password, - role, - subscription_months, - } = parsed.data; + const { first_name, last_name, email, password, role, subscription_months } = + parsed.data; const admin = createAdminClient(); const { data: authData, error: authError } = @@ -187,3 +197,224 @@ export async function createUserAdmin( revalidatePath(USERS_PATH); return { message: "User created.", data: { user_id: userId } }; } + +const deleteUserAdminSchema = z.object({ + user_id: z.string().uuid(), +}); + +export async function deleteUserAdmin( + _prev: UserAdminActionState, + formData: FormData, +): Promise { + try { + await requireAdmin(); + } catch { + return { errors: { _form: ["Unauthorized"] } }; + } + + const parsed = deleteUserAdminSchema.safeParse({ + user_id: formData.get("user_id"), + }); + + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + const { user_id } = parsed.data; + const assigned = await db + .select({ id: services.id }) + .from(services) + .where( + and( + eq(services.coordinatorId, user_id), + eq(services.type, "private_lessons"), + ), + ) + .limit(1); + + if (assigned.length > 0) { + return { + errors: { + _form: [ + "This coordinator is assigned to one or more private lessons. Reassign those lessons to another coordinator before deleting the account.", + ], + }, + }; + } + + const admin = createAdminClient(); + const { error: authError } = await admin.auth.admin.deleteUser(user_id); + + if (authError) { + return { errors: { _form: [authError.message] } }; + } + + revalidatePath(USERS_PATH); + return { message: "User deleted." }; +} + +export type { + TransactionRefund, + UserTransaction, + PaginatedTransactions, +} from "@/lib/stripe-transactions"; + +export async function getUserTransactions( + input: { + customerId: string; + limit?:number; + startingAfter?: string; + } +): Promise { + await requireAdmin(); + + const parsed = getTransactionsSchema.parse(input) + + return listCustomerCharges(parsed); +} + +export async function createTransactionRefund( + _prev: RefundActionState, + formData: FormData +): Promise { + try { + await requireAdmin(); + } catch { + return {errors : { _form : ["Unauthorized"]}}; + } + + const amountRaw = formData.get("amountCents"); + const amountCents = amountRaw? Number(amountRaw): undefined; + + const parsed = createRefundSchema.safeParse({ + chargeId: formData.get("chargeId"), + amountCents, + idempotencyKey: formData.get("idempotencyKey"), + customerId: formData.get("customerId") + }); + + if (!parsed.success) { + return {errors : parsed.error.flatten().fieldErrors}; + } + const {chargeId, amountCents: refundAmount, idempotencyKey, customerId} = parsed.data; + + + try { + const charge = await stripe.charges.retrieve(chargeId); + const chargeCustomerId = + typeof charge.customer === "string" + ? charge.customer + : charge.customer?.id ?? null; + + if (chargeCustomerId !== customerId){ + return { + errors: { _form: ["Charge does not belong to this customer."] }, + status: "failed", + }; + } + + const remainingRefundable = charge.amount - charge.amount_refunded; + + if (remainingRefundable <= 0) { + return { + errors: { _form: ["This charge is already fully refunded."] }, + status: "failed", + }; + } + + if (refundAmount !== undefined && refundAmount > remainingRefundable) { + return { + errors: { + _form: ["Refund amount exceeds the remaining refundable balance."], + }, + status: "failed", + }; + } + + + const refundParams: Stripe.RefundCreateParams = { + charge:chargeId, + }; + + if (refundAmount !== undefined) { + refundParams.amount = refundAmount; + } + + const refund = await stripe.refunds.create(refundParams, { idempotencyKey + }) + + if (refund.status === "failed" || refund.status === "canceled") { + const reason = + refund.failure_reason ?? + refund.status; + return { + errors : { _form : [`Refund ${reason}.`]}, + status: "failed", + } + } + + const updatedCharge = await stripe.charges.retrieve(chargeId, { + expand: [ "refunds", "payment_intent"], + }) + + let description = updatedCharge.description || ""; + if (!description && updatedCharge.payment_intent && typeof updatedCharge.payment_intent !== "string") { + description = + updatedCharge.payment_intent.description || + updatedCharge.payment_intent.metadata?.productName || + updatedCharge.payment_intent.metadata?.description || + ""; + } + if (!description) { + description = updatedCharge.metadata?.productName || updatedCharge.metadata?.description || "Payment"; + } + + const refunds = + updatedCharge.refunds?.data?.map((r: Stripe.Refund) => ({ + id: r.id, + amount: r.amount, + status: r.status, + created: r.created, + })) || []; + + const updatedTransaction: UserTransaction = { + id: updatedCharge.id, + amount: updatedCharge.amount, + amountRefunded: updatedCharge.amount_refunded, + refunded: updatedCharge.refunded, + created: updatedCharge.created, + description, + paymentIntentId: + typeof updatedCharge.payment_intent === "string" + ? updatedCharge.payment_intent + : updatedCharge.payment_intent?.id || null, + refunds, + currency: updatedCharge.currency, + + } + + return { + message: + refund.status === "succeeded" + ? "Refund issued successfully." + : "Refund pending.", + status: refund.status === "succeeded" ? "succeeded" : "pending", + refund: { + id: refund.id, + amount: refund.amount, + status: refund.status, + created: refund.created, + }, + updatedTransaction, + }; + + + } catch (error) { + return { + errors: { + _form: [error instanceof Error ? error.message : "Failed to create refund."], + }, + status: "failed", + }; + } +} diff --git a/app/(authenticated)/users/profile-role-label.ts b/app/(authenticated)/users/profile-role-label.ts index b36a111..df580ad 100644 --- a/app/(authenticated)/users/profile-role-label.ts +++ b/app/(authenticated)/users/profile-role-label.ts @@ -2,7 +2,7 @@ const ROLE_LABELS: Record = { admin: "Admin", - coach: "Coach", + coordinator: "Coordinator", user: "User", }; diff --git a/app/(authenticated)/users/schema.ts b/app/(authenticated)/users/schema.ts index 887f32f..c3eb4ba 100644 --- a/app/(authenticated)/users/schema.ts +++ b/app/(authenticated)/users/schema.ts @@ -20,4 +20,17 @@ export const updateUserAdminSchema = z.object({ user_id: z.string().uuid(), email: z.string().email(), role: z.enum(Object.values(ROLES) as [string, ...string[]]), -}) \ No newline at end of file +}) + +export const getTransactionsSchema = z.object({ + customerId: z.string().min(1,"Customer ID is required"), + limit: z.number().min(1).max(100).optional().default(10), + startingAfter: z.string().optional(), +}); + +export const createRefundSchema = z.object({ + chargeId: z.string().min(1, "Charge ID is required"), + amountCents: z.coerce.number().int().positive().optional(), + idempotencyKey: z.string().min(1,"Idempotency key is required"), + customerId: z.string().min(1, "Customer ID is required") +}) diff --git a/app/api/public/services/route.ts b/app/api/public/services/route.ts index 285190c..669fc75 100644 --- a/app/api/public/services/route.ts +++ b/app/api/public/services/route.ts @@ -15,6 +15,7 @@ export async function GET() { startDate: services.startDate, endDate: services.endDate, slots: services.slots, + requiresSubscription: services.requiresSubscription, }) .from(services) .where(eq(services.status, "active")); @@ -59,6 +60,7 @@ export async function GET() { startDate: row.startDate, endDate: row.endDate, slots: row.slots, + requiresSubscription: row.requiresSubscription, }; }); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index eaf7998..05bf797 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -9,7 +9,7 @@ import { } from "@/lib/db/schema"; import { and, eq } from "drizzle-orm"; import Stripe from "stripe"; -import { notifyCoachOfBooking } from "@/app/scheduling/notifications"; +import { sendCoordinatorBookingEmail } from "@/app/coaching/notifications"; const allowedEvents: Stripe.Event.Type[] = [ "checkout.session.completed", @@ -54,7 +54,7 @@ export async function POST(request: NextRequest) { if (metadata.type === "private_lesson" && metadata.coachingSessionId) { const updated = await db .update(coachingSessions) - .set({ status: "pending", stripeOrderId: session.id }) + .set({ status: "confirmed", stripeOrderId: session.id }) .where( and( eq(coachingSessions.id, metadata.coachingSessionId), @@ -65,7 +65,7 @@ export async function POST(request: NextRequest) { const transitioned = updated[0]; if (transitioned) { - await notifyCoachOfBooking(transitioned.id); + await sendCoordinatorBookingEmail(transitioned.id); } return NextResponse.json({ received: true }); } diff --git a/app/checkout/[productId]/checkout-flow.tsx b/app/checkout/[productId]/checkout-flow.tsx index 22374b0..5592ba5 100644 --- a/app/checkout/[productId]/checkout-flow.tsx +++ b/app/checkout/[productId]/checkout-flow.tsx @@ -288,8 +288,8 @@ export function CheckoutFlow({ service, discount }: CheckoutFlowProps) {

Click and drag any time blocks that work for you over the - next two weeks. Your coach will confirm a slot from your - choices. + next two weeks. Your coordinator will be in touch to + confirm a time from your choices.

diff --git a/app/coaching/actions.ts b/app/coaching/actions.ts index 134d9b5..623d11a 100644 --- a/app/coaching/actions.ts +++ b/app/coaching/actions.ts @@ -36,14 +36,15 @@ export async function submitAvailabilities({ return { error: "Service is not available" }; if (service.type !== "private_lessons") return { error: "Service is not a private lesson" }; - if (!service.coachId) return { error: "Service has no coach assigned" }; + if (!service.coordinatorId) + return { error: "Service has no coordinator assigned" }; const [row] = await db .insert(coachingSessions) .values({ userId: user.id, serviceId: service.id, - coachId: service.coachId, + coordinatorId: service.coordinatorId, selectedTimeSlots: availabilities, status: "awaiting_payment", }) diff --git a/app/coaching/notifications.ts b/app/coaching/notifications.ts new file mode 100644 index 0000000..78fdcde --- /dev/null +++ b/app/coaching/notifications.ts @@ -0,0 +1,181 @@ +import "server-only"; + +import { asc, eq } from "drizzle-orm"; +import { pgSchema, uuid, text } from "drizzle-orm/pg-core"; + +import { db } from "@/lib/db"; +import { + children, + coachingSessions, + emergencyContacts, + formQuestionAnswers, + formQuestions, + profiles, +} from "@/lib/db/schema"; +import { getService } from "@/app/(authenticated)/services/queries"; +import { sendEmail } from "@/lib/email/client"; +import { coordinatorBookingEmail, type ChildInfo } from "@/lib/email/templates"; +import { userHasActiveSubscription } from "@/lib/stripe"; +import type { TimeSlot } from "@/lib/scheduling/time-slot"; + +const authSchema = pgSchema("auth"); +const authUsers = authSchema.table("users", { + id: uuid("id").primaryKey(), + email: text("email"), +}); + +type ProfileWithEmail = { + firstName: string; + lastName: string; + email: string | null; + address: string | null; + gender: string | null; + dob: string | null; + phone: string | null; +}; + +async function getProfileWithEmail( + profileId: string, +): Promise { + const [row] = await db + .select({ + firstName: profiles.firstName, + lastName: profiles.lastName, + email: authUsers.email, + address: profiles.address, + gender: profiles.gender, + dob: profiles.dob, + phone: profiles.phone, + }) + .from(profiles) + .innerJoin(authUsers, eq(authUsers.id, profiles.id)) + .where(eq(profiles.id, profileId)) + .limit(1); + return row ?? null; +} + +async function loadChildInfo(childId: string): Promise { + const [child] = await db + .select() + .from(children) + .where(eq(children.id, childId)) + .limit(1); + if (!child) return null; + + const contacts = await db + .select({ + fullName: emergencyContacts.fullName, + relationship: emergencyContacts.relationship, + phoneNumber: emergencyContacts.phoneNumber, + emailAddress: emergencyContacts.emailAddress, + }) + .from(emergencyContacts) + .where(eq(emergencyContacts.childId, childId)); + + const answers = await db + .select({ + prompt: formQuestions.prompt, + answer: formQuestionAnswers.answer, + sortOrder: formQuestions.sortOrder, + }) + .from(formQuestionAnswers) + .innerJoin( + formQuestions, + eq(formQuestions.id, formQuestionAnswers.formQuestionId), + ) + .where(eq(formQuestionAnswers.childId, childId)) + .orderBy(asc(formQuestions.sortOrder)); + + return { + firstName: child.firstName, + lastName: child.lastName, + dob: child.dob, + gender: child.gender, + allergies: child.allergies, + medicalConditions: child.medicalConditions, + medications: child.medications, + emergencyContacts: contacts, + formAnswers: answers.map((a) => ({ prompt: a.prompt, answer: a.answer })), + }; +} + +export async function sendCoordinatorBookingEmail( + sessionId: string, +): Promise { + try { + const [session] = await db + .select() + .from(coachingSessions) + .where(eq(coachingSessions.id, sessionId)) + .limit(1); + if (!session) { + console.error( + "[sendCoordinatorBookingEmail] session missing", + sessionId, + ); + return; + } + + const [coordinator, client, service, hasActiveSubscription] = + await Promise.all([ + getProfileWithEmail(session.coordinatorId), + getProfileWithEmail(session.userId), + getService(session.serviceId), + userHasActiveSubscription(session.userId), + ]); + + if (!coordinator?.email) { + console.error( + "[sendCoordinatorBookingEmail] coordinator email missing", + sessionId, + ); + return; + } + if (!client) { + console.error( + "[sendCoordinatorBookingEmail] client profile missing", + sessionId, + ); + return; + } + + const serviceTitle = service?.title ?? "Private lesson"; + const durationMinutes = service?.durationMinutes ?? 60; + + let scheduledSlot: TimeSlot | null = null; + if (session.scheduledAt) { + const start = session.scheduledAt; + const end = new Date(start.getTime() + durationMinutes * 60_000); + scheduledSlot = { start: start.toISOString(), end: end.toISOString() }; + } + + const child = session.childId + ? await loadChildInfo(session.childId) + : null; + + const email = await coordinatorBookingEmail({ + coordinatorName: + `${coordinator.firstName} ${coordinator.lastName}`.trim(), + serviceTitle, + client: { + firstName: client.firstName, + lastName: client.lastName, + email: client.email ?? "—", + address: client.address, + gender: client.gender, + dob: client.dob, + phone: client.phone, + hasActiveSubscription, + }, + scheduledSlot, + requestedAvailability: + (session.selectedTimeSlots as TimeSlot[] | null) ?? [], + notes: session.notes, + child, + }); + + await sendEmail({ to: coordinator.email, ...email }); + } catch (e) { + console.error("[sendCoordinatorBookingEmail]", e); + } +} diff --git a/app/scheduling/actions.ts b/app/scheduling/actions.ts deleted file mode 100644 index 38f1fdb..0000000 --- a/app/scheduling/actions.ts +++ /dev/null @@ -1,287 +0,0 @@ -"use server"; - -import { eq,and } from "drizzle-orm"; -import { z } from "zod"; -import { db } from "@/lib/db"; -import { coachingSessions } from "@/lib/db/schema"; -import { createClient } from "@/utils/supabase/server"; - -export type TimeSlot = { start: string; end: string }; - -export type SchedulingActionState = { - errors?: Record; - message?: string; -} | null; - -const iso8601 = z.string().datetime({offset: true, message: "Must be an ISO 8601 datetime with timezone"}); - -const timeSlotSchema = z - .object({ - start: iso8601, - end: iso8601, - }) - .refine((slot) => new Date(slot.end) > new Date(slot.start), { - message: "End time must be after start time", - }); - -const selectAvailabilitiesSchema = z.object({ - session_id: z.string().uuid("Invalid session ID"), - time_slots: z - .array(timeSlotSchema) - .min(1, "At least one time slot is required") - .max(50,"You can select up to 50 time slots"), - notes: z - .string() - .max(2000, "Message must be 2000 characters or fewer") - .optional(), -}); - -const selectTimeSlotSchema = z.object({ - session_id: z.string().uuid("Invalid session ID"), - start: iso8601, - end: iso8601, -}); - -async function getAuthenticatedUserId(): Promise { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - return user?.id ?? null; -} - -/** - * Fetch a coaching session and verify the caller is either the coach or user. - * Returns the session row or an error state. - */ -async function getAuthorizedSession( - sessionId: string, - userId: string, -): Promise< - | { ok: true; session: typeof coachingSessions.$inferSelect } - | { ok: false; state: SchedulingActionState } -> { - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.id, sessionId)) - .limit(1); - - if (!session) { - return { - ok: false, - state: { errors: { _form: ["Coaching session not found"] } }, - }; - } - - if (session.coachId !== userId && session.userId !== userId) { - return { - ok: false, - state: { errors: { _form: ["You are not authorized to modify this session"] } }, - }; - } - - return { ok: true, session }; -} - - -function parseJsonField(formData: FormData, key:string): T | null { - const value = formData.get(key); - if (typeof value !== "string") return null; - try { - return JSON.parse(value) as T; - } catch { - return null - } -} - -function normalizeSlots(slot: TimeSlot): TimeSlot { - return { - start: new Date(slot.start).toISOString(), - end: new Date(slot.end).toISOString() - } -} - - -/** - * Set (or replace) the available time slots on an existing coaching session. - * - * The payload is an array of `{ start, end }` ISO 8601 strings. - */ -export async function selectAvailabilities( - _prev: SchedulingActionState, - formData: FormData, -): Promise { - // 1. Auth gate - const callerId = await getAuthenticatedUserId(); - if (!callerId) { - return { errors: { _form: ["You must be logged in"] } }; - } - - // 2. Parse & validate input - const rawTimeSlots = parseJsonField(formData, "time_slots"); - if (!rawTimeSlots) { - return {errors: {time_slots: ["Invalid time slots format"]}}; - } - - const rawNotes = formData.get("notes"); - const parsed = selectAvailabilitiesSchema.safeParse({ - session_id: formData.get("session_id")?.toString(), - time_slots: rawTimeSlots, - notes: - typeof rawNotes === "string" && rawNotes.trim().length > 0 - ? rawNotes - : undefined, - }) - - if (!parsed.success) { - return {errors: parsed.error.flatten().fieldErrors} - } - - const sessionId = parsed.data.session_id; - const timeSlots = parsed.data.time_slots.map(normalizeSlots) - const notes = parsed.data.notes ?? null; - - // 3. Authorization: caller must be the coach or user on this session - const result = await getAuthorizedSession(sessionId, callerId); - if (!result.ok) { - return result.state; - } - - // 4. Only pending sessions can have their availabilities changed - if (result.session.status !== "pending") { - return { - errors: { - _form: ["Availabilities can only be set for pending sessions"], - }, - }; - } - - // 5. Update the session - try { - await db - .update(coachingSessions) - .set({ - selectedTimeSlots: timeSlots, - notes, - updatedAt: new Date(), - }) - .where(eq(coachingSessions.id, sessionId)); - } catch (e) { - console.error("[SELECT_AVAILABILITIES]", e); - return { - errors: { - _form: ["Could not update availabilities"], - }, - }; - } - - return { message: "Availabilities updated." }; -} - -/** - * Select a confirmed time slot for an existing coaching session. - * - * The chosen `{ start, end }` must be one of the `selected_time_slots` - * already stored on the session. On success, `scheduled_at` is set to the - * slot's start time and the session status becomes `confirmed`. - */ -export async function selectTimeSlot( - _prev: SchedulingActionState, - formData: FormData, -): Promise { - // 1. Auth gate - const callerId = await getAuthenticatedUserId(); - if (!callerId) { - return { errors: { _form: ["You must be logged in"] } }; - } - - // 2. Parse & validate input - const parsed = selectTimeSlotSchema.safeParse({ - session_id: formData.get("session_id")?.toString(), - start: formData.get("start")?.toString(), - end: formData.get("end")?.toString(), - }); - - if (!parsed.success) { - return { errors: parsed.error.flatten().fieldErrors }; - } - - const { session_id} = parsed.data; - const {start, end} = normalizeSlots({start: parsed.data.start, end: parsed.data.end}) - - // 3. Authorization - const result = await getAuthorizedSession(session_id, callerId); - if (!result.ok) { - return result.state; - } - - const { session } = result; - - // 4. Only pending sessions can be confirmed - if (session.status !== "pending") { - return { - errors: { - _form: ["Only pending sessions can be confirmed"], - }, - }; - } - - // 5. Verify the slot exists in the session's available time slots - const slots = session.selectedTimeSlots as TimeSlot[] | null; - if (!slots || !Array.isArray(slots)) { - return { - errors: { - _form: ["No available time slots have been set for this session"], - }, - }; - } - - const matchingSlot = slots.find( - (s) => s.start === start && s.end === end, - ); - if (!matchingSlot) { - return { - errors: { - _form: [ - "The selected time slot is not among the available options", - ], - }, - }; - } - - // 5. Confirm the session with the chosen slot - try { - const updatedRows = await db - .update(coachingSessions) - .set({ - scheduledAt: new Date(start), - status: "confirmed", - updatedAt: new Date(), - }) - .where( - and( - eq(coachingSessions.id, session_id), - eq(coachingSessions.status, "pending"), - ), - ) - .returning({ id: coachingSessions.id }); - - if (updatedRows.length === 0) { - return { - errors: { - _form: ["This session is no longer available for confirmation"], - }, - }; - } - } catch (e) { - console.error("[SELECT_TIME_SLOT]", e); - return { - errors: { - _form: ["Could not confirm the time slot"], - }, - }; - } - - return { message: "Time slot confirmed." }; -} \ No newline at end of file diff --git a/app/scheduling/coach/[token]/coach-scheduler.tsx b/app/scheduling/coach/[token]/coach-scheduler.tsx deleted file mode 100644 index d570e1e..0000000 --- a/app/scheduling/coach/[token]/coach-scheduler.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { AvailabilityCalendar } from "@/components/scheduling/availability-calendar"; -import { Button } from "@/components/ui/button"; -import { intersectTimeSlots } from "@/lib/scheduling/overlap"; -import type { TimeSlot, Weekday } from "@/lib/scheduling/time-slot"; - -import { submitCoachAvailabilities } from "@/app/scheduling/token-actions"; - -const ALL_DAYS: Weekday[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; - -type CalendarWindow = { - anchor: Date; - weeks: number; - startHour: number; - endHour: number; -}; - -function deriveWindow(slots: TimeSlot[]): CalendarWindow { - if (slots.length === 0) { - const anchor = startOfWeek(new Date()); - return { anchor, weeks: 2, startHour: 8, endHour: 20 }; - } - - const starts = slots.map((s) => new Date(s.start)); - const ends = slots.map((s) => new Date(s.end)); - const min = new Date(Math.min(...starts.map((d) => d.getTime()))); - const max = new Date(Math.max(...ends.map((d) => d.getTime()))); - - const anchor = startOfWeek(min); - const dayMs = 86_400_000; - const spanDays = - Math.floor((startOfDay(max).getTime() - anchor.getTime()) / dayMs) + 1; - const weeks = Math.max(1, Math.ceil(spanDays / 7)); - - const startHour = Math.min(...starts.map((d) => d.getHours())); - const endHour = Math.max( - ...ends.map((d) => d.getHours() + (d.getMinutes() > 0 ? 1 : 0)), - ); - - return { - anchor, - weeks, - startHour: Math.max(0, Math.min(startHour, 23)), - endHour: Math.min(24, Math.max(endHour, startHour + 1)), - }; -} - -function startOfDay(d: Date): Date { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x; -} - -function startOfWeek(d: Date): Date { - const x = startOfDay(d); - x.setDate(x.getDate() - x.getDay()); - return x; -} - -type SubmitState = - | { kind: "idle" } - | { kind: "submitting" } - | { kind: "error"; message: string } - | { kind: "done" }; - -export function CoachScheduler({ - token, - clientName, - clientSlots, - initialCoachSlots, -}: { - token: string; - clientName: string; - clientSlots: TimeSlot[]; - initialCoachSlots: TimeSlot[]; -}) { - const calendarWindow = React.useMemo( - () => deriveWindow(clientSlots), - [clientSlots], - ); - const [slots, setSlots] = React.useState(initialCoachSlots); - const [state, setState] = React.useState({ kind: "idle" }); - - const overlap = React.useMemo( - () => intersectTimeSlots(clientSlots, slots), - [clientSlots, slots], - ); - const matchCount = overlap.length; - - async function handleSubmit() { - setState({ kind: "submitting" }); - const result = await submitCoachAvailabilities({ token, slots }); - if (result.ok) { - setState({ kind: "done" }); - } else { - setState({ kind: "error", message: result.error }); - } - } - - if (state.kind === "done") { - return ( -
-

- Availability sent -

-

- We've emailed {clientName} the {matchCount} matching{" "} - {matchCount === 1 ? "window" : "windows"} so they can confirm a - time. You can close this page. -

-
- ); - } - - return ( -
- - -
- -
- -
-

- {matchCount > 0 ? ( - <> - - {matchCount} - {" "} - matching {matchCount === 1 ? "window" : "windows"} with{" "} - {clientName} - - ) : ( - "No overlap yet" - )} -

- -
- - {state.kind === "error" ? ( -

{state.message}

- ) : null} -
- ); -} - -function Legend() { - return ( -
- - - -
- ); -} - -function LegendSwatch({ - className, - label, -}: { - className: string; - label: string; -}) { - return ( - - - {label} - - ); -} diff --git a/app/scheduling/coach/[token]/page.tsx b/app/scheduling/coach/[token]/page.tsx deleted file mode 100644 index affaa44..0000000 --- a/app/scheduling/coach/[token]/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Suspense } from "react"; -import { Loader2 } from "lucide-react"; - -import { getSessionByToken } from "@/app/scheduling/queries"; - -import { CoachScheduler } from "./coach-scheduler"; - -export default function CoachSchedulingPage({ - params, -}: { - params: Promise<{ token: string }>; -}) { - return ( - }> - - - ); -} - -async function CoachSchedulingContent({ - params, -}: { - params: Promise<{ token: string }>; -}) { - const { token } = await params; - const view = await getSessionByToken(token); - - if (!view || view.role !== "coach") { - return ( - - ); - } - - if (view.status !== "pending") { - return ( - - ); - } - if (view.coachSlots.length > 0) { - return ( - - ); - } - - return ( -
-
-

- Set your availability, {view.coachName} -

-

- {view.clientName} booked {view.serviceTitle}. - Select the times that also work for you, and the matching windows - will turn solid green. -

-
- - -
- ); -} - -function SchedulingFallback() { - return ( -
- -
- ); -} - -function Notice({ title, message }: { title: string; message: string }) { - return ( -
-

{title}

-

{message}

-
- ); -} diff --git a/app/scheduling/confirm/[token]/client-confirm.tsx b/app/scheduling/confirm/[token]/client-confirm.tsx deleted file mode 100644 index 1fa08d7..0000000 --- a/app/scheduling/confirm/[token]/client-confirm.tsx +++ /dev/null @@ -1,201 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { confirmFinalSlot } from "@/app/scheduling/token-actions"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { expandToBookableSlots } from "@/lib/scheduling/overlap"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; -import { cn } from "@/lib/utils"; - -function formatDay(slot: TimeSlot): string { - return new Date(slot.start).toLocaleDateString("en-US", { - weekday: "long", - month: "short", - day: "numeric", - }); -} - -function formatTime(d: Date): string { - return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); -} - -function formatWindow(slot: TimeSlot): string { - const start = new Date(slot.start); - const end = new Date(slot.end); - return `${formatTime(start)} – ${formatTime(end)}`; -} - -function formatConfirmed(slot: TimeSlot): string { - const start = new Date(slot.start); - const end = new Date(slot.end); - const day = formatDay(slot); - return `${day} · ${formatTime(start)} – ${formatTime(end)}`; -} - -type State = - | { kind: "idle" } - | { kind: "submitting" } - | { kind: "error"; message: string } - | { kind: "done"; slot: TimeSlot }; - -export function ClientConfirm({ - token, - serviceTitle, - coachName, - windows, - durationMinutes, -}: { - token: string; - serviceTitle: string; - coachName: string; - windows: TimeSlot[]; - durationMinutes: number; -}) { - const bookableByWindow = React.useMemo( - () => - windows.map((window) => - expandToBookableSlots([window], durationMinutes), - ), - [windows, durationMinutes], - ); - - const [selectedWindow, setSelectedWindow] = React.useState(0); - const [selectedStart, setSelectedStart] = React.useState( - null, - ); - const [state, setState] = React.useState({ kind: "idle" }); - - const options = bookableByWindow[selectedWindow] ?? []; - - React.useEffect(() => { - setSelectedStart(null); - }, [selectedWindow]); - - const selectedSlot = React.useMemo(() => { - if (!selectedStart) return null; - return options.find((s) => s.start === selectedStart) ?? null; - }, [options, selectedStart]); - - async function handleConfirm() { - if (!selectedSlot) return; - setState({ kind: "submitting" }); - const result = await confirmFinalSlot({ token, slot: selectedSlot }); - if (result.ok) { - setState({ kind: "done", slot: selectedSlot }); - } else { - setState({ kind: "error", message: result.error }); - } - } - - if (state.kind === "done") { - return ( -
-

- Session confirmed -

-

- Your {serviceTitle} session with {coachName} is booked for{" "} - {formatConfirmed(state.slot)}. A confirmation - email is on its way. -

-
- ); - } - - return ( -
-

- Each session is {durationMinutes} minutes. Pick a day, then choose - your exact start time. -

- -
- Available days -
- {windows.map((window, i) => { - const isSelected = selectedWindow === i; - const count = bookableByWindow[i]?.length ?? 0; - return ( - - ); - })} -
-
- - {options.length > 0 ? ( -
- - -
- ) : null} - - {selectedSlot ? ( -
- Your session: - {formatConfirmed(selectedSlot)} -
- ) : null} - - - - {state.kind === "error" ? ( -

{state.message}

- ) : null} -
- ); -} diff --git a/app/scheduling/confirm/[token]/page.tsx b/app/scheduling/confirm/[token]/page.tsx deleted file mode 100644 index 53fa365..0000000 --- a/app/scheduling/confirm/[token]/page.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Suspense } from "react"; -import { Loader2 } from "lucide-react"; - -import { getSessionByToken } from "@/app/scheduling/queries"; - -import { ClientConfirm } from "./client-confirm"; - -export default function ConfirmSchedulingPage({ - params, -}: { - params: Promise<{ token: string }>; -}) { - return ( - }> - - - ); -} - -async function ConfirmSchedulingContent({ - params, -}: { - params: Promise<{ token: string }>; -}) { - const { token } = await params; - const view = await getSessionByToken(token); - - if (!view || view.role !== "client") { - return ( - - ); - } - - if (view.status === "confirmed") { - return ( - - ); - } - - if (view.status !== "pending" || view.overlap.length === 0) { - return ( - - ); - } - - return ( -
-
-

- Pick your time -

-

- {view.coachName} is available on these days for{" "} - {view.serviceTitle} ({view.durationMinutes}{" "} - min). Pick the exact time that works best for you. -

-
- - -
- ); -} - -function formatDateTime(iso: string): string { - return new Date(iso).toLocaleString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -function ConfirmFallback() { - return ( -
- -
- ); -} - -function Notice({ title, message }: { title: string; message: string }) { - return ( -
-

{title}

-

{message}

-
- ); -} diff --git a/app/scheduling/notifications.ts b/app/scheduling/notifications.ts deleted file mode 100644 index 971f176..0000000 --- a/app/scheduling/notifications.ts +++ /dev/null @@ -1,163 +0,0 @@ -import "server-only"; - -import { eq } from "drizzle-orm"; - -import { db } from "@/lib/db"; -import { coachingSessions } from "@/lib/db/schema"; -import { getService } from "@/app/(authenticated)/services/queries"; -import { appUrl, formatAddress, sendEmail } from "@/lib/email/client"; -import { - clientPickEmail, - coachInviteEmail, - confirmationEmail, -} from "@/lib/email/templates"; -import { intersectTimeSlots } from "@/lib/scheduling/overlap"; -import { generateScheduleToken } from "@/lib/scheduling/token"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; - -import { getProfileContact } from "./queries"; - -async function loadSession(sessionId: string) { - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.id, sessionId)) - .limit(1); - return session ?? null; -} - -export async function notifyCoachOfBooking(sessionId: string): Promise { - try { - const session = await loadSession(sessionId); - if (!session) return; - - let token = session.coachToken; - if (!token) { - token = generateScheduleToken(); - await db - .update(coachingSessions) - .set({ coachToken: token }) - .where(eq(coachingSessions.id, sessionId)); - } - - const [coach, client, service] = await Promise.all([ - getProfileContact(session.coachId), - getProfileContact(session.userId), - getService(session.serviceId), - ]); - if (!coach) { - console.error( - "[notifyCoachOfBooking] coach contact missing", - sessionId, - ); - return; - } - - const email = coachInviteEmail({ - coachName: coach.name, - clientName: client?.name ?? "Your client", - serviceTitle: service?.title ?? "Private lesson", - clientSlots: (session.selectedTimeSlots as TimeSlot[]) ?? [], - url: appUrl(`/scheduling/coach/${token}`), - }); - await sendEmail({ - to: coach.email, - replyTo: client ? formatAddress(client.name, client.email) : undefined, - ...email, - }); - } catch (e) { - console.error("[notifyCoachOfBooking]", e); - } -} - -export async function notifyClientToPick(sessionId: string): Promise { - try { - const session = await loadSession(sessionId); - if (!session) return; - - const overlap = intersectTimeSlots( - (session.selectedTimeSlots as TimeSlot[]) ?? [], - (session.coachTimeSlots as TimeSlot[] | null) ?? [], - ); - if (overlap.length === 0) return; - - let token = session.clientToken; - if (!token) { - token = generateScheduleToken(); - await db - .update(coachingSessions) - .set({ clientToken: token }) - .where(eq(coachingSessions.id, sessionId)); - } - - const [client, coach, service] = await Promise.all([ - getProfileContact(session.userId), - getProfileContact(session.coachId), - getService(session.serviceId), - ]); - if (!client) { - console.error( - "[notifyClientToPick] client contact missing", - sessionId, - ); - return; - } - - const email = clientPickEmail({ - clientName: client.name, - coachName: coach?.name ?? "Your coach", - serviceTitle: service?.title ?? "Private lesson", - overlap, - url: appUrl(`/scheduling/confirm/${token}`), - }); - await sendEmail({ - to: client.email, - replyTo: coach ? formatAddress(coach.name, coach.email) : undefined, - ...email, - }); - } catch (e) { - console.error("[notifyClientToPick]", e); - } -} - -export async function notifyBothConfirmed( - sessionId: string, - slot: TimeSlot, -): Promise { - try { - const session = await loadSession(sessionId); - if (!session) return; - - const [client, coach, service] = await Promise.all([ - getProfileContact(session.userId), - getProfileContact(session.coachId), - getService(session.serviceId), - ]); - const serviceTitle = service?.title ?? "Private lesson"; - - if (client && coach) { - await sendEmail({ - to: client.email, - replyTo: formatAddress(coach.name, coach.email), - ...confirmationEmail({ - recipientName: client.name, - otherPartyName: coach.name, - serviceTitle, - slot, - }), - }); - await sendEmail({ - to: coach.email, - replyTo: formatAddress(client.name, client.email), - ...confirmationEmail({ - recipientName: coach.name, - otherPartyName: client.name, - serviceTitle, - slot, - }), - }); - } - } catch (e) { - console.error("[notifyBothConfirmed]", e); - } -} diff --git a/app/scheduling/queries.ts b/app/scheduling/queries.ts deleted file mode 100644 index e3c67fc..0000000 --- a/app/scheduling/queries.ts +++ /dev/null @@ -1,114 +0,0 @@ -import "server-only"; - -import { eq, or } from "drizzle-orm"; -import { pgSchema, uuid, text } from "drizzle-orm/pg-core"; - -import { db } from "@/lib/db"; -import { coachingSessions, profiles } from "@/lib/db/schema"; -import { getService } from "@/app/(authenticated)/services/queries"; -import { intersectTimeSlots } from "@/lib/scheduling/overlap"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; - -const authSchema = pgSchema("auth"); -const authUsers = authSchema.table("users", { - id: uuid("id").primaryKey(), - email: text("email"), -}); - -export type ProfileContact = { name: string; email: string }; -export type CoachScheduleView = { - role: "coach"; - sessionId: string; - status: string; - serviceTitle: string; - coachName: string; - clientName: string; - clientSlots: TimeSlot[]; - coachSlots: TimeSlot[]; -}; - -export type ClientScheduleView = { - role: "client"; - sessionId: string; - status: string; - serviceTitle: string; - coachName: string; - overlap: TimeSlot[]; - durationMinutes: number; - scheduledAt: string | null; -}; - -export async function getProfileContact( - profileId: string, -): Promise { - const [row] = await db - .select({ - firstName: profiles.firstName, - lastName: profiles.lastName, - email: authUsers.email, - }) - .from(profiles) - .innerJoin(authUsers, eq(authUsers.id, profiles.id)) - .where(eq(profiles.id, profileId)) - .limit(1); - - if (!row || !row.email) return null; - return { name: `${row.firstName} ${row.lastName}`.trim(), email: row.email }; -} - -export type ScheduleView = CoachScheduleView | ClientScheduleView; - -export async function getSessionByToken( - token: string, -): Promise { - if (!token) return null; - - const [session] = await db - .select() - .from(coachingSessions) - .where( - or( - eq(coachingSessions.coachToken, token), - eq(coachingSessions.clientToken, token), - ), - ) - .limit(1); - - if (!session) return null; - - const service = await getService(session.serviceId); - const serviceTitle = service?.title ?? "Private lesson"; - const clientSlots = (session.selectedTimeSlots as TimeSlot[]) ?? []; - const coachSlots = (session.coachTimeSlots as TimeSlot[] | null) ?? []; - - if (session.coachToken === token) { - const [clientContact, coachContact] = await Promise.all([ - getProfileContact(session.userId), - getProfileContact(session.coachId), - ]); - return { - role: "coach", - sessionId: session.id, - status: session.status, - serviceTitle, - coachName: coachContact?.name ?? "Coach", - clientName: clientContact?.name ?? "Your client", - clientSlots, - coachSlots, - }; - } - - const contact = await getProfileContact(session.coachId); - return { - role: "client", - sessionId: session.id, - status: session.status, - serviceTitle, - coachName: contact?.name ?? "Your coach", - overlap: intersectTimeSlots(clientSlots, coachSlots), - durationMinutes: service?.durationMinutes ?? 60, - scheduledAt: session.scheduledAt - ? session.scheduledAt.toISOString() - : null, - }; -} diff --git a/app/scheduling/token-actions.ts b/app/scheduling/token-actions.ts deleted file mode 100644 index adec006..0000000 --- a/app/scheduling/token-actions.ts +++ /dev/null @@ -1,184 +0,0 @@ -"use server"; - -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; - -import { db } from "@/lib/db"; -import { coachingSessions } from "@/lib/db/schema"; -import { intersectTimeSlots, isBookableSlot } from "@/lib/scheduling/overlap"; -import { getService } from "@/app/(authenticated)/services/queries"; -import type { TimeSlot } from "@/lib/scheduling/time-slot"; - -import { notifyBothConfirmed, notifyClientToPick } from "./notifications"; - -const iso8601 = z - .string() - .datetime({ offset: true, message: "Must be an ISO 8601 datetime" }); - -const slotSchema = z - .object({ start: iso8601, end: iso8601 }) - .refine((s) => new Date(s.end) > new Date(s.start), { - message: "End must be after start", - }); - -const submitSchema = z.object({ - token: z.string().min(1), - slots: z.array(slotSchema).min(1, "Select at least one availability window"), -}); - -const confirmSchema = z.object({ - token: z.string().min(1), - slot: slotSchema, -}); - -export type CoachSubmitResult = - | { ok: true; overlapCount: number } - | { ok: false; error: string }; - -export type ConfirmResult = { ok: true } | { ok: false; error: string }; - -function normalize(slot: TimeSlot): TimeSlot { - return { - start: new Date(slot.start).toISOString(), - end: new Date(slot.end).toISOString(), - }; -} - -export async function submitCoachAvailabilities( - input: z.input, -): Promise { - const parsed = submitSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: parsed.error.issues[0]?.message ?? "Invalid input", - }; - } - const { token } = parsed.data; - const slots = parsed.data.slots.map(normalize); - - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.coachToken, token)) - .limit(1); - - if (!session) - return { ok: false, error: "This scheduling link is invalid." }; - if (session.status !== "pending") { - return { - ok: false, - error: "This session can no longer be edited.", - }; - } - - const overlap = intersectTimeSlots( - (session.selectedTimeSlots as TimeSlot[]) ?? [], - slots, - ); - if (overlap.length === 0) { - return { - ok: false, - error: "None of your times overlap with the client's availability. Pick at least one window that matches theirs.", - }; - } - - try { - const updated = await db - .update(coachingSessions) - .set({ - coachTimeSlots: slots, - coachToken: null, - updatedAt: new Date(), - }) - .where( - and( - eq(coachingSessions.id, session.id), - eq(coachingSessions.coachToken, token), - ), - ) - .returning({ id: coachingSessions.id }); - - if (updated.length === 0) { - return { - ok: false, - error: "Your availability has already been submitted.", - }; - } - } catch (e) { - console.error("[submitCoachAvailabilities]", e); - return { ok: false, error: "Could not save your availability." }; - } - - await notifyClientToPick(session.id); - return { ok: true, overlapCount: overlap.length }; -} - -export async function confirmFinalSlot( - input: z.input, -): Promise { - const parsed = confirmSchema.safeParse(input); - if (!parsed.success) { - return { - ok: false, - error: parsed.error.issues[0]?.message ?? "Invalid input", - }; - } - const { token } = parsed.data; - const slot = normalize(parsed.data.slot); - - const [session] = await db - .select() - .from(coachingSessions) - .where(eq(coachingSessions.clientToken, token)) - .limit(1); - - if (!session) - return { ok: false, error: "This scheduling link is invalid." }; - if (session.status !== "pending") { - return { ok: false, error: "This session has already been confirmed." }; - } - - const overlap = intersectTimeSlots( - (session.selectedTimeSlots as TimeSlot[]) ?? [], - (session.coachTimeSlots as TimeSlot[] | null) ?? [], - ); - const service = await getService(session.serviceId); - const durationMinutes = service?.durationMinutes ?? 60; - if (!isBookableSlot(slot, overlap, durationMinutes)) { - return { - ok: false, - error: "That time isn't one of the available options.", - }; - } - - try { - const updated = await db - .update(coachingSessions) - .set({ - scheduledAt: new Date(slot.start), - status: "confirmed", - updatedAt: new Date(), - }) - .where( - and( - eq(coachingSessions.id, session.id), - eq(coachingSessions.status, "pending"), - ), - ) - .returning({ id: coachingSessions.id }); - - if (updated.length === 0) { - return { - ok: false, - error: "This session is no longer available for confirmation.", - }; - } - } catch (e) { - console.error("[confirmFinalSlot]", e); - return { ok: false, error: "Could not confirm your time slot." }; - } - - await notifyBothConfirmed(session.id, slot); - return { ok: true }; -} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index ac6f6f7..4198b6a 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,7 +1,9 @@ "use client"; +import * as React from "react"; import { usePathname } from "next/navigation"; import Link from "next/link"; +import { Collapsible } from "radix-ui"; import { LayoutGrid, BookOpen, @@ -10,6 +12,12 @@ import { MonitorSmartphone, Settings, Form, + CalendarCheck, + Baby, + Receipt, + UserRound, + ChevronRight, + type LucideIcon, } from "lucide-react"; import { Sidebar, @@ -21,25 +29,175 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, SidebarTrigger, + useSidebar, } from "@/components/ui/sidebar"; import Image from "next/image"; import { cn } from "@/lib/utils"; +import { isAdminRole, ROLES, type Role } from "@/lib/roles"; -const navItems = [ +type NavLink = { title: string; href: string; icon: LucideIcon }; +type NavGroup = { title: string; icon: LucideIcon; children: NavLink[] }; +type NavEntry = NavLink | NavGroup; + +function isGroup(entry: NavEntry): entry is NavGroup { + return "children" in entry; +} + +const ADMIN_NAV: NavEntry[] = [ { title: "OVERVIEW", href: "/", icon: LayoutGrid }, { title: "SERVICES", href: "/services", icon: BookOpen }, { title: "USERS", href: "/users", icon: Users }, { title: "FINANCE", href: "/finance", icon: CreditCard }, { title: "MEMBERSHIPS", href: "/memberships", icon: MonitorSmartphone }, - { title: "FORMS" , href: "/forms", icon: Form} + { title: "FORMS", href: "/forms", icon: Form }, ]; +const USER_NAV: NavEntry[] = [ + { title: "OVERVIEW", href: "/account", icon: LayoutGrid }, + { + title: "MY ACCOUNT", + icon: UserRound, + children: [ + { title: "Bookings", href: "/account/bookings", icon: CalendarCheck }, + { title: "Children", href: "/account/children", icon: Baby }, + { + title: "Transactions", + href: "/account/transactions", + icon: Receipt, + }, + { title: "Profile", href: "/account/profile", icon: Settings }, + ], + }, +]; + +function isNavItemActive(pathname: string, href: string): boolean { + if (href === "/" || href === "/account") return pathname === href; + return pathname === href || pathname.startsWith(`${href}/`); +} + +const ACTIVE_LEAF_CLASS = + "translate-x-0.5 bg-white text-sidebar-accent-foreground shadow-sm hover:bg-white data-active:bg-white data-active:text-sidebar-accent-foreground"; + +function NavLinkItem({ item, pathname }: { item: NavLink; pathname: string }) { + const isActive = isNavItemActive(pathname, item.href); + + return ( + + + + + + {item.title} + + + + + ); +} + +function NavGroupItem({ + item, + pathname, +}: { + item: NavGroup; + pathname: string; +}) { + const { state, setOpen: setSidebarOpen } = useSidebar(); + const hasActiveChild = item.children.some((child) => + isNavItemActive(pathname, child.href), + ); + const [open, setOpen] = React.useState(hasActiveChild); + const isIconMode = state === "collapsed"; + + React.useEffect(() => { + if (hasActiveChild) setOpen(true); + }, [hasActiveChild]); + + return ( + setOpen(isIconMode ? true : next)} + asChild + > + + + { + if (isIconMode) setSidebarOpen(true); + }} + > + + + {item.title} + + + + + + + {item.children.map((child) => { + const isActive = isNavItemActive(pathname, child.href); + return ( + + + + + + {child.title} + + + + + ); + })} + + + + + ); +} + export function AppSidebar({ + role = ROLES.USER, className, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { role?: Role }) { const pathname = usePathname(); + const isAdmin = isAdminRole(role); + const navItems = isAdmin ? ADMIN_NAV : USER_NAV; return ( - {navItems.map((item) => { - const isActive = pathname === item.href; - return ( - - - - - - {item.title} - - - - - ); - })} + {navItems.map((item) => + isGroup(item) ? ( + + ) : ( + + ), + )} - - - - - - Settings - - - - + {isAdmin && ( + + + + + + Settings + + + + + )} ); } diff --git a/components/scheduling/availability-form.tsx b/components/scheduling/availability-form.tsx deleted file mode 100644 index e004484..0000000 --- a/components/scheduling/availability-form.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import * as React from "react"; -import { useActionState } from "react"; - -import { AvailabilityCalendar } from "@/components/scheduling/availability-calendar"; -import { MessageYourCoach } from "@/components/scheduling/message-your-coach"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import type { TimeSlot, Weekday } from "@/lib/scheduling/time-slot"; -import { - selectAvailabilities, - type SchedulingActionState, -} from "@/app/scheduling/actions"; - -type AvailabilityFormProps = { - sessionId: string; - weeks?: number; - daysOfWeek?: Weekday[]; - startHour?: number; - endHour?: number; - className?: string; -}; - -const DEFAULT_DAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri"]; - -export function AvailabilityForm({ - sessionId, - weeks = 2, - daysOfWeek = DEFAULT_DAYS, - startHour = 8, - endHour = 20, - className, -}: AvailabilityFormProps) { - const [slots, setSlots] = React.useState([]); - const [coachMessage, setCoachMessage] = React.useState(""); - const [anchor, setAnchor] = React.useState(null); - - React.useEffect(() => { - setAnchor(new Date()); - }, []); - - const [state, formAction, pending] = useActionState< - SchedulingActionState, - FormData - >(selectAvailabilities, null); - - const errors = state?.errors; - - const hours = React.useMemo(() => { - const ms = slots.reduce( - (acc, s) => - acc + (new Date(s.end).getTime() - new Date(s.start).getTime()), - 0, - ); - return ms / 3_600_000; - }, [slots]); - - const hasSelection = slots.length > 0; - - return ( -
- - - - - -
- - - - - {state?.message && !errors ? ( -

{state.message}

- ) : null} -
- -
-

- {hasSelection ? ( - <> - - {hours.toLocaleString(undefined, { - maximumFractionDigits: 1, - })} - {" "} - hours selected - - ) : ( - "Select your availability" - )} -

-
- - -
-
- - ); -} - -function FormErrors({ errors }: { errors?: Record }) { - if (!errors) return null; - const messages = Object.values(errors).flat(); - if (messages.length === 0) return null; - return ( -
    - {messages.map((m, i) => ( -
  • {m}
  • - ))} -
- ); -} diff --git a/components/scheduling/message-your-coach.tsx b/components/scheduling/message-your-coach.tsx deleted file mode 100644 index 28c6328..0000000 --- a/components/scheduling/message-your-coach.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; - -type MessageYourCoachProps = { - name: string; - value: string; - onChange: (value: string) => void; - className?: string; -}; - -export function MessageYourCoach({ - name, - value, - onChange, - className, -}: MessageYourCoachProps) { - const id = React.useId(); - return ( -
- -