diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f99ddee..b7e5ebb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,16 +15,18 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+
- uses: actions/setup-node@v4
with:
node-version: "25"
- cache: npm
+ cache: pnpm
- name: Install dependencies
- run: npm ci
+ run: pnpm install --frozen-lockfile
- name: Lint
- run: npm run lint
+ run: pnpm lint
- name: Unit tests
- run: npm run test:ci
+ run: pnpm test:ci
diff --git a/__tests__/sanity.test.tsx b/__tests__/sanity.test.tsx
index a450991..25d1b4e 100644
--- a/__tests__/sanity.test.tsx
+++ b/__tests__/sanity.test.tsx
@@ -1,10 +1,9 @@
import { render, screen } from "@testing-library/react";
-import DashboardPage from "@/app/(authenticated)/dashboard/page";
+import { TestComponent } from "./test-component";
describe("jest setup", () => {
- it("runs React + Testing Library", async () => {
- const jsx = await DashboardPage();
- render(jsx);
- expect(screen.getByText("You are admin")).toBeInTheDocument();
- });
+ it("runs React + Testing Library", () => {
+ render();
+ expect(screen.getByText("ok")).toBeInTheDocument();
+ });
});
diff --git a/__tests__/test-component.tsx b/__tests__/test-component.tsx
new file mode 100644
index 0000000..6b9a47a
--- /dev/null
+++ b/__tests__/test-component.tsx
@@ -0,0 +1,3 @@
+export function TestComponent() {
+ return
ok
;
+}
diff --git a/app/(authenticated)/checkout/success/page.tsx b/app/(authenticated)/checkout/success/page.tsx
index c1c0ba1..16186c8 100644
--- a/app/(authenticated)/checkout/success/page.tsx
+++ b/app/(authenticated)/checkout/success/page.tsx
@@ -1,3 +1,4 @@
+import { Suspense } from "react";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { syncStripeData } from "@/lib/stripe";
@@ -5,7 +6,15 @@ import { db } from "@/lib/db";
import { profiles } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
-export default async function CheckoutSuccessPage() {
+export default function CheckoutSuccessPage() {
+ return (
+
+
+
+ );
+}
+
+async function SyncAndRedirect(): Promise {
const supabase = await createClient();
const {
data: { user },
diff --git a/app/(authenticated)/dashboard/page.tsx b/app/(authenticated)/dashboard/page.tsx
deleted file mode 100644
index 6489369..0000000
--- a/app/(authenticated)/dashboard/page.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-export default async function DashboardPage() {
- return (
-
- Admin Dashboard
-
- You are admin
-
-
- );
-}
diff --git a/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx
index e4e307b..db58ad5 100644
--- a/app/(authenticated)/layout.tsx
+++ b/app/(authenticated)/layout.tsx
@@ -1,14 +1,11 @@
+import { Suspense } from "react";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AppSidebar } from "@/components/app-sidebar";
-export default async function AuthenticatedLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
+async function AuthGate({ children }: { children: React.ReactNode }) {
const supabase = await createClient();
const {
data: { user },
@@ -18,13 +15,23 @@ export default async function AuthenticatedLayout({
redirect("/login");
}
+ return <>{children}>;
+}
+
+export default function AuthenticatedLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
return (
- {children}
+
+ {children}
+
diff --git a/app/(authenticated)/page.tsx b/app/(authenticated)/page.tsx
index c5ba855..2a99867 100644
--- a/app/(authenticated)/page.tsx
+++ b/app/(authenticated)/page.tsx
@@ -1,3 +1,4 @@
+import { Suspense } from "react";
import { createClient } from "@/utils/supabase/server";
import { signout } from "@/app/login/actions";
import { getSubscriptionDetails } from "@/lib/stripe";
@@ -14,7 +15,15 @@ import { CheckoutButton } from "@/components/subscribe-button";
const SUBSCRIPTION_PRICE_ID = process.env.STRIPE_PRICE_ID!;
const PRODUCT_PRICE_ID = process.env.STRIPE_PRODUCT_PRICE_ID!;
-export default async function Page() {
+export default function Page() {
+ return (
+
+
+
+ );
+}
+
+async function HomeContent() {
const supabase = await createClient();
const {
data: { user },
diff --git a/app/(authenticated)/services/actions.ts b/app/(authenticated)/services/actions.ts
new file mode 100644
index 0000000..635ea5b
--- /dev/null
+++ b/app/(authenticated)/services/actions.ts
@@ -0,0 +1,428 @@
+"use server";
+
+import { revalidatePath, updateTag } from "next/cache";
+import { eq } from "drizzle-orm";
+import { z } from "zod";
+import { db } from "@/lib/db";
+import { services, type ProgramSlot as DbProgramSlot } from "@/lib/db/schema";
+import { requireAdmin } from "@/lib/auth/require-admin";
+import { cadStringToCents } from "@/lib/money";
+import {
+ createPrice,
+ createProduct,
+ deactivateActivePricesForProduct,
+ updateProduct,
+} from "@/lib/stripe";
+
+export type ServiceActionState = {
+ errors?: Record;
+ message?: string;
+} | null;
+
+export type ProgramSlot = DbProgramSlot;
+
+export type ProgramSchedule = {
+ startDate: string;
+ endDate: string;
+ slots: ProgramSlot[];
+};
+
+const SERVICES_PATH = "/services";
+const SERVICES_TAG = "services";
+
+const serviceTypeSchema = z.enum(["private_lessons", "programs"]);
+const statusSchema = z.enum(["active", "disabled", "archived", "deleted"]);
+
+const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
+
+const slotSchema = z.object({
+ dayOfWeek: z.number().int().min(0).max(6),
+ time: z.string().regex(/^\d{2}:\d{2}$/, "Invalid time"),
+});
+
+const baseFields = z.object({
+ title: z.string().min(1, "Title is required").max(500),
+ description: z.string().min(1, "Description is required").max(1000),
+ type: serviceTypeSchema,
+ duration_minutes: z.coerce.number().int().min(1).max(24 * 60),
+ price_cad: z.string().min(1, "Price is required"),
+});
+
+const ALLOWED_TRANSITIONS: Record<
+ z.infer,
+ ReadonlyArray>
+> = {
+ active: ["disabled", "archived"],
+ disabled: ["active", "archived"],
+ archived: ["active", "deleted"],
+ deleted: [],
+};
+
+function bustServicesCache() {
+ updateTag(SERVICES_TAG);
+ revalidatePath(SERVICES_PATH);
+}
+
+function field(formData: FormData, name: string): string | undefined {
+ const v = formData.get(name);
+ return v === null ? undefined : v.toString();
+}
+
+type ParseResult =
+ | { ok: true; value: T }
+ | { ok: false; errors: Record };
+
+function parseProgramSchedule(formData: FormData): ParseResult {
+ const startRaw = field(formData, "start_date");
+ const endRaw = field(formData, "end_date");
+ const slotsRaw = field(formData, "slots");
+ const errors: Record = {};
+
+ const start = startRaw ? isoDateSchema.safeParse(startRaw) : null;
+ const end = endRaw ? isoDateSchema.safeParse(endRaw) : null;
+
+ if (!startRaw) errors.start_date = ["Start date is required"];
+ else if (start && !start.success) errors.start_date = ["Invalid start date"];
+
+ if (!endRaw) errors.end_date = ["End date is required"];
+ else if (end && !end.success) errors.end_date = ["Invalid end date"];
+
+ let slots: ProgramSlot[] = [];
+ if (!slotsRaw) {
+ errors.slots = ["At least one slot is required"];
+ } else {
+ try {
+ const parsed = JSON.parse(slotsRaw);
+ const result = z.array(slotSchema).min(1).safeParse(parsed);
+ if (!result.success) {
+ errors.slots = ["At least one valid slot is required"];
+ } else {
+ slots = result.data;
+ }
+ } catch {
+ errors.slots = ["Invalid slot format"];
+ }
+ }
+
+ if (startRaw && endRaw && start?.success && end?.success) {
+ if (startRaw > endRaw) {
+ errors.end_date = ["End date must be on or after start date"];
+ }
+ }
+
+ if (Object.keys(errors).length > 0) return { ok: false, errors };
+ return {
+ ok: true,
+ value: { startDate: startRaw!, endDate: endRaw!, slots },
+ };
+}
+
+function parseCoachId(formData: FormData): ParseResult {
+ const raw = field(formData, "coach_id");
+ if (!raw) return { ok: false, errors: { coach_id: ["Select a coach"] } };
+ const result = z.string().uuid().safeParse(raw);
+ if (!result.success) {
+ return { ok: false, errors: { coach_id: ["Invalid coach"] } };
+ }
+ return { ok: true, value: result.data };
+}
+
+export async function createService(
+ _prev: ServiceActionState,
+ formData: FormData,
+): Promise {
+ try {
+ await requireAdmin();
+ } catch {
+ return { errors: { _form: ["Unauthorized"] } };
+ }
+
+ const errors: Record = {};
+
+ const parsed = baseFields.safeParse({
+ title: formData.get("title"),
+ description: formData.get("description") ?? "",
+ type: formData.get("type"),
+ duration_minutes: formData.get("duration_minutes"),
+ price_cad: formData.get("price_cad"),
+ });
+ if (!parsed.success) {
+ Object.assign(errors, parsed.error.flatten().fieldErrors);
+ }
+
+ // Validate price format independently so its error reports alongside
+ // schedule/coach errors instead of in a separate round-trip.
+ const priceRaw = formData.get("price_cad")?.toString() ?? "";
+ let cents: number | null = null;
+ if (priceRaw && !errors.price_cad) {
+ cents = cadStringToCents(priceRaw);
+ if (cents === null) {
+ errors.price_cad = ["Enter a valid price in CAD"];
+ }
+ }
+
+ // Schedule / coach checks key off the submitted type, not parsed.data,
+ // so they still run when baseFields fails on unrelated fields.
+ const typeRaw = formData.get("type")?.toString();
+ let scheduledAtValue: ProgramSchedule | null = null;
+ if (typeRaw === "programs") {
+ const result = parseProgramSchedule(formData);
+ if (!result.ok) {
+ Object.assign(errors, result.errors);
+ } else {
+ scheduledAtValue = result.value;
+ }
+ } else if (typeRaw === "private_lessons") {
+ const coach = parseCoachId(formData);
+ if (!coach.ok) Object.assign(errors, coach.errors);
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return { errors };
+ }
+
+ // Safe: we only reach here if baseFields parsed AND price validated.
+ const { title, type, duration_minutes } = parsed.data!;
+ const description = parsed.data!.description.trim();
+ const priceCents = cents as number;
+
+ let createdProductId: string | null = null;
+ try {
+ const { productId } = await createProduct({
+ name: title,
+ description,
+ });
+ createdProductId = productId;
+
+ await createPrice(productId, priceCents);
+
+ await db.insert(services).values({
+ type,
+ startDate: scheduledAtValue?.startDate ?? null,
+ endDate: scheduledAtValue?.endDate ?? null,
+ slots: scheduledAtValue?.slots ?? null,
+ durationMinutes: duration_minutes,
+ stripeProductId: productId,
+ status: "active",
+ });
+ } catch (e) {
+ if (createdProductId) {
+ try {
+ await updateProduct(createdProductId, { active: false });
+ } catch {}
+ }
+ console.error(e);
+ return {
+ errors: {
+ _form: [
+ e instanceof Error ? e.message : "Could not create service",
+ ],
+ },
+ };
+ }
+
+ bustServicesCache();
+ return { message: "Service created." };
+}
+
+/**
+ * PATCH-style update: every field except `service_id` is optional. Fields
+ * not present in the payload are not touched (DB nor Stripe). The frontend
+ * is expected to send only the fields the admin actually changed.
+ */
+const updateFields = z.object({
+ service_id: z.string().uuid(),
+ title: z.string().min(1, "Title cannot be empty").max(500).optional(),
+ description: z.string().min(1, "Description cannot be empty").max(1000).optional(),
+ duration_minutes: z.coerce.number().int().min(1).max(24 * 60).optional(),
+ price_cad: z.string().min(1, "Price cannot be empty").optional(),
+});
+
+export async function updateService(
+ _prev: ServiceActionState,
+ formData: FormData,
+): Promise {
+ try {
+ await requireAdmin();
+ } catch {
+ return { errors: { _form: ["Unauthorized"] } };
+ }
+
+ const errors: Record = {};
+
+ const parsed = updateFields.safeParse({
+ service_id: field(formData, "service_id"),
+ title: field(formData, "title") || undefined,
+ description: field(formData, "description") || undefined,
+ duration_minutes: field(formData, "duration_minutes") || undefined,
+ price_cad: field(formData, "price_cad") || undefined,
+ });
+ if (!parsed.success) {
+ Object.assign(errors, parsed.error.flatten().fieldErrors);
+ }
+
+ // Validate price format independently from baseFields so its error
+ // surfaces alongside any schedule errors in a single round-trip.
+ const priceRaw = field(formData, "price_cad");
+ let cents: number | undefined;
+ if (priceRaw && !errors.price_cad) {
+ const parsedCents = cadStringToCents(priceRaw);
+ if (parsedCents === null) {
+ errors.price_cad = ["Enter a valid price in CAD"];
+ } else {
+ cents = parsedCents;
+ }
+ }
+
+ // service_id is required for the lookup; bail if it's missing/invalid.
+ const serviceId = parsed.success ? parsed.data.service_id : undefined;
+ if (!serviceId) {
+ return { errors };
+ }
+
+ const [row] = await db
+ .select()
+ .from(services)
+ .where(eq(services.id, serviceId))
+ .limit(1);
+ if (!row) {
+ return { errors: { ...errors, _form: ["Service not found"] } };
+ }
+ if (row.status !== "active" && row.status !== "disabled") {
+ return {
+ errors: {
+ ...errors,
+ _form: ["Only active or disabled services can be edited"],
+ },
+ };
+ }
+
+ let scheduledAtValue: ProgramSchedule | undefined;
+ if (row.type === "programs" && formData.has("start_date")) {
+ const result = parseProgramSchedule(formData);
+ if (!result.ok) {
+ Object.assign(errors, result.errors);
+ } else {
+ scheduledAtValue = result.value;
+ }
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return { errors };
+ }
+
+ const { title, description, duration_minutes } = parsed.data!;
+ const service_id = serviceId;
+
+ try {
+ await updateProduct(row.stripeProductId, {
+ name: title,
+ description: description?.trim(),
+ });
+
+ if (cents !== undefined) {
+ // Stripe Prices are immutable: deactivate the current active
+ // price(s) and create a new one at the new amount.
+ await deactivateActivePricesForProduct(row.stripeProductId);
+ await createPrice(row.stripeProductId, cents);
+ }
+
+ const dbPatch: Partial = {};
+ if (duration_minutes !== undefined) dbPatch.durationMinutes = duration_minutes;
+ if (scheduledAtValue !== undefined) {
+ dbPatch.startDate = scheduledAtValue.startDate;
+ dbPatch.endDate = scheduledAtValue.endDate;
+ dbPatch.slots = scheduledAtValue.slots;
+ }
+
+ if (Object.keys(dbPatch).length > 0) {
+ dbPatch.updatedAt = new Date();
+ await db
+ .update(services)
+ .set(dbPatch)
+ .where(eq(services.id, service_id));
+ }
+ } catch (e) {
+ console.error(e);
+ return {
+ errors: {
+ _form: [
+ e instanceof Error ? e.message : "Could not update service",
+ ],
+ },
+ };
+ }
+
+ bustServicesCache();
+ return { message: "Service updated." };
+}
+
+const statusFields = z.object({
+ service_id: z.string().uuid(),
+ status: statusSchema,
+});
+
+export async function setServiceStatus(
+ _prev: ServiceActionState,
+ formData: FormData,
+): Promise {
+ try {
+ await requireAdmin();
+ } catch {
+ return { errors: { _form: ["Unauthorized"] } };
+ }
+
+ const parsed = statusFields.safeParse({
+ service_id: formData.get("service_id"),
+ status: formData.get("status"),
+ });
+ if (!parsed.success) {
+ return { errors: parsed.error.flatten().fieldErrors };
+ }
+
+ const { service_id, status: nextStatus } = parsed.data;
+
+ const [row] = await db
+ .select()
+ .from(services)
+ .where(eq(services.id, service_id))
+ .limit(1);
+ if (!row) {
+ return { errors: { _form: ["Service not found"] } };
+ }
+
+ if (row.status === nextStatus) {
+ return { message: "No change." };
+ }
+ if (!ALLOWED_TRANSITIONS[row.status].includes(nextStatus)) {
+ return {
+ errors: {
+ _form: [`Cannot change status from ${row.status} to ${nextStatus}`],
+ },
+ };
+ }
+
+ try {
+ await db
+ .update(services)
+ .set({ status: nextStatus, updatedAt: new Date() })
+ .where(eq(services.id, service_id));
+
+ // Stripe product is active only when DB status === "active".
+ await updateProduct(row.stripeProductId, {
+ active: nextStatus === "active",
+ });
+ } catch (e) {
+ console.error(e);
+ return {
+ errors: {
+ _form: [
+ e instanceof Error ? e.message : "Could not update service status",
+ ],
+ },
+ };
+ }
+
+ bustServicesCache();
+ return { message: "Service status updated." };
+}
diff --git a/app/(authenticated)/services/page.tsx b/app/(authenticated)/services/page.tsx
index ac4b257..be7d832 100644
--- a/app/(authenticated)/services/page.tsx
+++ b/app/(authenticated)/services/page.tsx
@@ -1,7 +1,16 @@
-export default function ServicesPage() {
- return (
-
- Services
-
- )
+import { listCoaches, listServices } from "./queries";
+import { ServicesTable } from "./services-table";
+
+export default async function ServicesPage() {
+ const [services, coaches] = await Promise.all([
+ listServices(),
+ listCoaches(),
+ ]);
+
+ return (
+
+ Services
+
+
+ );
}
diff --git a/app/(authenticated)/services/queries.ts b/app/(authenticated)/services/queries.ts
new file mode 100644
index 0000000..1a62d40
--- /dev/null
+++ b/app/(authenticated)/services/queries.ts
@@ -0,0 +1,130 @@
+import { cacheTag } from "next/cache";
+import { and, asc, desc, eq, inArray } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { profiles, services } from "@/lib/db/schema";
+import { getStripeServiceData } from "@/lib/stripe";
+import type { ProgramSchedule } from "@/app/(authenticated)/services/actions";
+
+const SERVICES_TAG = "services";
+const COACHES_TAG = "coaches";
+
+export type ServiceStatus = "active" | "disabled" | "archived" | "deleted";
+export type ServiceType = "private_lessons" | "programs";
+
+export type ServiceView = {
+ id: string;
+ type: ServiceType;
+ scheduledAt: ProgramSchedule | null;
+ durationMinutes: number;
+ status: ServiceStatus;
+ stripeProductId: string;
+ createdAt: Date;
+ updatedAt: Date;
+ title: string | null;
+ description: string | null;
+ priceCents: number | null;
+ priceCurrency: string | null;
+};
+
+function rowToSchedule(
+ row: typeof services.$inferSelect,
+): ProgramSchedule | null {
+ if (!row.startDate || !row.endDate) return null;
+ return {
+ startDate: row.startDate,
+ endDate: row.endDate,
+ slots: row.slots ?? [],
+ };
+}
+
+async function buildServiceView(row: typeof services.$inferSelect): Promise {
+ const stripeData = await getStripeServiceData(row.stripeProductId);
+ return {
+ id: row.id,
+ type: row.type,
+ scheduledAt: rowToSchedule(row),
+ durationMinutes: row.durationMinutes,
+ status: row.status,
+ stripeProductId: row.stripeProductId,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ title: stripeData?.title ?? null,
+ description: stripeData?.description ?? null,
+ priceCents: stripeData?.priceCents ?? null,
+ priceCurrency: stripeData?.priceCurrency ?? null,
+ };
+}
+
+/**
+ * List services, optionally filtered by status. Defaults to all
+ * non-deleted statuses.
+ *
+ * Cached via Next Cache Components with no explicit TTL; bust via the
+ * `services` tag from the mutation actions in `./actions.ts`.
+ */
+export async function listServices(opts?: {
+ status?: ServiceStatus | ReadonlyArray;
+}): Promise {
+ "use cache";
+ cacheTag(SERVICES_TAG);
+
+ const statusFilter = opts?.status
+ ? Array.isArray(opts.status)
+ ? opts.status
+ : [opts.status]
+ : (["active", "disabled", "archived"] as ServiceStatus[]);
+
+ const rows = await db
+ .select()
+ .from(services)
+ .where(inArray(services.status, statusFilter))
+ .orderBy(desc(services.createdAt));
+
+ return Promise.all(rows.map(buildServiceView));
+}
+
+/**
+ * Fetch a single service by id, including its Stripe-derived fields.
+ * Returns null if the service does not exist.
+ *
+ * Cached via Next Cache Components; bust via the `services` tag.
+ */
+export async function getService(id: string): Promise {
+ "use cache";
+ cacheTag(SERVICES_TAG);
+
+ const [row] = await db
+ .select()
+ .from(services)
+ .where(and(eq(services.id, id)))
+ .limit(1);
+ if (!row) return null;
+ return buildServiceView(row);
+}
+
+export type CoachOption = {
+ id: string;
+ firstName: string;
+ lastName: string;
+};
+
+/**
+ * List all profiles with the `coach` role, ordered by name.
+ *
+ * Cached via Next Cache Components; bust via the `coaches` tag when
+ * coach assignments change.
+ */
+export async function listCoaches(): Promise {
+ "use cache";
+ cacheTag(COACHES_TAG);
+
+ return db
+ .select({
+ id: profiles.id,
+ firstName: profiles.firstName,
+ lastName: profiles.lastName,
+ })
+ .from(profiles)
+ .where(eq(profiles.role, "coach"))
+ .orderBy(asc(profiles.firstName), asc(profiles.lastName));
+}
diff --git a/app/(authenticated)/services/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx
new file mode 100644
index 0000000..84a980b
--- /dev/null
+++ b/app/(authenticated)/services/service-dialog.tsx
@@ -0,0 +1,488 @@
+"use client";
+
+import * as React from "react";
+import { useActionState } from "react";
+import { format } from "date-fns";
+import { CalendarIcon, DollarSign, Plus, X } from "lucide-react";
+import { type DateRange } from "react-day-picker";
+
+import { Button } from "@/components/ui/button";
+import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
+import { Calendar } from "@/components/ui/calendar";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { centsToMoneyString } from "@/lib/money";
+
+import {
+ createService,
+ updateService,
+ type ProgramSchedule,
+ type ProgramSlot,
+ type ServiceActionState,
+} from "@/app/(authenticated)/services/actions";
+import type {
+ CoachOption,
+ ServiceView,
+} from "@/app/(authenticated)/services/queries";
+
+type Props = { coaches: CoachOption[] } & (
+ | { mode: "add" }
+ | {
+ mode: "edit";
+ service: ServiceView | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ }
+);
+
+const DAY_NAMES = [
+ "Sunday",
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+] as const;
+
+function FieldError({ messages }: { messages?: string[] }) {
+ if (!messages?.length) return null;
+ return (
+
+ {messages.map((m, i) => (
+ - {m}
+ ))}
+
+ );
+}
+
+function toISODate(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+function fromISODate(value: string | undefined): Date | undefined {
+ if (!value) return undefined;
+ const [y, m, d] = value.split("-").map(Number);
+ if (!y || !m || !d) return undefined;
+ return new Date(y, m - 1, d);
+}
+
+function DateRangePicker({
+ value,
+ onChange,
+}: {
+ value: DateRange | undefined;
+ onChange: (v: DateRange | undefined) => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+function ProgramScheduleFields({
+ initial,
+ errors,
+}: {
+ initial: ProgramSchedule | null;
+ errors?: Record;
+}) {
+ const [range, setRange] = React.useState(() => {
+ const from = fromISODate(initial?.startDate);
+ const to = fromISODate(initial?.endDate);
+ if (!from && !to) return undefined;
+ return { from, to };
+ });
+ const [slots, setSlots] = React.useState(
+ initial?.slots ?? [{ dayOfWeek: 1, time: "" }],
+ );
+
+ const updateSlot = (idx: number, patch: Partial) =>
+ setSlots((prev) =>
+ prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
+ );
+ const addSlot = () =>
+ setSlots((prev) => [...prev, { dayOfWeek: 1, time: "" }]);
+ const removeSlot = (idx: number) =>
+ setSlots((prev) => prev.filter((_, i) => i !== idx));
+
+ const startDate = range?.from ? toISODate(range.from) : "";
+ const endDate = range?.to ? toISODate(range.to) : "";
+
+ return (
+
+
+ Weekly schedule
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {slots.map((slot, idx) => (
+
+
+
+ updateSlot(idx, { time: e.target.value })
+ }
+ className="w-36"
+ />
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export function ServiceDialog(props: Props) {
+ const isEdit = props.mode === "edit";
+ const service = isEdit ? props.service : null;
+ const { coaches } = props;
+
+ const [type, setType] = React.useState<"programs" | "private_lessons">(
+ service?.type ?? "programs",
+ );
+ const [coachId, setCoachId] = React.useState("");
+ const [title, setTitle] = React.useState(service?.title ?? "");
+ const [description, setDescription] = React.useState(
+ service?.description ?? "",
+ );
+ const [durationMinutes, setDurationMinutes] = React.useState(
+ String(service?.durationMinutes ?? 60),
+ );
+ const [priceCad, setPriceCad] = React.useState(
+ centsToMoneyString(service?.priceCents ?? null),
+ );
+ const [state, formAction, pending] = useActionState<
+ ServiceActionState,
+ FormData
+ >(isEdit ? updateService : createService, null);
+
+ React.useEffect(() => {
+ if (service) {
+ setType(service.type);
+ setCoachId("");
+ setTitle(service.title ?? "");
+ setDescription(service.description ?? "");
+ setDurationMinutes(String(service.durationMinutes ?? 60));
+ setPriceCad(centsToMoneyString(service.priceCents));
+ }
+ }, [service]);
+
+ const closeRef = React.useRef(null);
+ const prevState = React.useRef(null);
+ React.useEffect(() => {
+ if (state === prevState.current) return;
+ prevState.current = state;
+ if (state?.message && !state.errors) {
+ if (isEdit && props.mode === "edit") {
+ props.onOpenChange(false);
+ } else {
+ closeRef.current?.click();
+ setType("programs");
+ setCoachId("");
+ setTitle("");
+ setDescription("");
+ setDurationMinutes("60");
+ setPriceCad("");
+ }
+ }
+ }, [state, isEdit, props]);
+
+ const errors = state?.errors;
+
+ const dialogControl = isEdit
+ ? { open: props.open, onOpenChange: props.onOpenChange }
+ : {};
+
+ const showForm = !isEdit || service !== null;
+
+ const initialSchedule = service?.scheduledAt ?? null;
+
+ return (
+
+ );
+}
diff --git a/app/(authenticated)/services/services-data-table.tsx b/app/(authenticated)/services/services-data-table.tsx
new file mode 100644
index 0000000..268ce43
--- /dev/null
+++ b/app/(authenticated)/services/services-data-table.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import * as React from "react";
+import { ColumnDef } from "@tanstack/react-table";
+import { Archive, ArchiveRestore, Ban, Pencil, Power } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { DataTable } from "@/components/data-table";
+import { formatDate } from "@/lib/format";
+import { statusBadgeClass } from "@/lib/service-status";
+
+import { setServiceStatus } from "@/app/(authenticated)/services/actions";
+import type { ServiceView } from "@/app/(authenticated)/services/queries";
+
+export function ServicesDataTable({
+ services,
+ onEdit,
+}: {
+ services: ServiceView[];
+ onEdit: (service: ServiceView) => void;
+}) {
+ const [pending, startTransition] = React.useTransition();
+
+ const runStatus = React.useCallback(
+ (id: string, next: "active" | "disabled" | "archived") => {
+ const fd = new FormData();
+ fd.set("service_id", id);
+ fd.set("status", next);
+ startTransition(() => {
+ setServiceStatus(null, fd);
+ });
+ },
+ [],
+ );
+
+ const columns = React.useMemo[]>(
+ () => [
+ {
+ accessorKey: "title",
+ header: "Program",
+ cell: ({ row }) => (
+ {row.original.title ?? "—"}
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.status}
+
+ ),
+ },
+ {
+ id: "startDate",
+ header: "Start Date",
+ cell: ({ row }) => {
+ const s = row.original.scheduledAt;
+ return s ? formatDate(s.startDate) : "—";
+ },
+ },
+ {
+ id: "endDate",
+ header: "End Date",
+ cell: ({ row }) => {
+ const s = row.original.scheduledAt;
+ return s ? formatDate(s.endDate) : "—";
+ },
+ },
+ {
+ id: "actions",
+ header: () => Actions
,
+ cell: ({ row }) => {
+ const s = row.original;
+ return (
+
+ {(s.status === "active" || s.status === "disabled") && (
+
+
+
+
+ Edit
+
+ )}
+ {s.status === "active" && (
+
+
+
+
+ Disable
+
+ )}
+ {s.status === "disabled" && (
+
+
+
+
+ Re-enable
+
+ )}
+ {(s.status === "active" || s.status === "disabled") && (
+
+
+
+
+ Archive
+
+ )}
+ {s.status === "archived" && (
+
+
+
+
+ Restore
+
+ )}
+
+ );
+ },
+ },
+ ],
+ [pending, runStatus, onEdit],
+ );
+
+ return (
+ `${n} service${n === 1 ? "" : "s"}`}
+ />
+ );
+}
diff --git a/app/(authenticated)/services/services-table.tsx b/app/(authenticated)/services/services-table.tsx
new file mode 100644
index 0000000..e84bbdb
--- /dev/null
+++ b/app/(authenticated)/services/services-table.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+import { ServiceDialog } from "./service-dialog";
+import { ServicesDataTable } from "./services-data-table";
+import type { CoachOption, ServiceView } from "./queries";
+
+type StatusTab = "all" | "active" | "disabled" | "archived";
+
+export function ServicesTable({
+ services,
+ coaches,
+}: {
+ services: ServiceView[];
+ coaches: CoachOption[];
+}) {
+ const [tab, setTab] = React.useState("active");
+ const [editing, setEditing] = React.useState(null);
+ const statusTabs: StatusTab[] = ["all", "active", "disabled", "archived"];
+
+ const filtered = React.useMemo(() => {
+ return tab === "all"
+ ? services
+ : services.filter((service) => service.status === tab);
+ }, [services, tab]);
+
+ return (
+ <>
+ setTab(v as StatusTab)}
+ className="w-full"
+ >
+
+
+ {statusTabs.map((status) => (
+
+ {status.charAt(0).toUpperCase() + status.slice(1)}
+
+ ))}
+
+
+
+
+
+
+
+
+ {
+ if (!v) setEditing(null);
+ }}
+ />
+ >
+ );
+}
diff --git a/components/data-table.tsx b/components/data-table.tsx
new file mode 100644
index 0000000..d9464f9
--- /dev/null
+++ b/components/data-table.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getPaginationRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+interface DataTableProps {
+ columns: ColumnDef[];
+ data: TData[];
+ emptyMessage?: string;
+ rowLabel?: (count: number) => string;
+}
+
+export function DataTable({
+ columns,
+ data,
+ emptyMessage = "No results.",
+ rowLabel = (n) => `${n} row${n === 1 ? "" : "s"}`,
+}: DataTableProps) {
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ });
+
+ return (
+
+
+
+
+ {table.getHeaderGroups().map((hg) => (
+
+ {hg.headers.map((h) => (
+
+ {h.isPlaceholder
+ ? null
+ : flexRender(
+ h.column.columnDef.header,
+ h.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((r) => (
+
+ {r.getVisibleCells().map((c) => (
+
+ {flexRender(
+ c.column.columnDef.cell,
+ c.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {emptyMessage}
+
+
+ )}
+
+
+
+
+
+ {rowLabel(data.length)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/ui/button-group.tsx b/components/ui/button-group.tsx
new file mode 100644
index 0000000..692fb07
--- /dev/null
+++ b/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+ "group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/components/ui/calendar.tsx b/components/ui/calendar.tsx
new file mode 100644
index 0000000..66b65f5
--- /dev/null
+++ b/components/ui/calendar.tsx
@@ -0,0 +1,222 @@
+"use client"
+
+import * as React from "react"
+import {
+ DayPicker,
+ getDefaultClassNames,
+ type DayButton,
+ type Locale,
+} from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ locale,
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"]
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ locale={locale}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString(locale?.code, { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "relative flex flex-col gap-4 md:flex-row",
+ defaultClassNames.months
+ ),
+ month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
+ nav: cn(
+ "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative rounded-(--cell-radius)",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute inset-0 bg-popover opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "font-medium select-none",
+ captionLayout === "label"
+ ? "text-sm"
+ : "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("mt-2 flex w-full", defaultClassNames.week),
+ week_number_header: cn(
+ "w-(--cell-size) select-none",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] text-muted-foreground select-none",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn(
+ "relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
+ defaultClassNames.range_end
+ ),
+ today: cn(
+ "rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ )
+ }
+
+ if (orientation === "right") {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: ({ ...props }) => (
+
+ ),
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ locale,
+ ...props
+}: React.ComponentProps & { locale?: Partial }) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+