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 ( + + {!isEdit && ( + + + + )} + + + + {isEdit ? "Edit service" : "New service"} + + + {isEdit + ? "Update fields below. Only changed fields are saved." + : "Create a coaching session or bookable service. Pricing is stored in Stripe."} + + + + {showForm && ( +
+ {service && ( + + )} + +
+ + setTitle(e.target.value)} + /> + +
+ +
+ +