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/app/(authenticated)/layout.tsx b/app/(authenticated)/layout.tsx index 8753ffa..db58ad5 100644 --- a/app/(authenticated)/layout.tsx +++ b/app/(authenticated)/layout.tsx @@ -5,34 +5,36 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppSidebar } from "@/components/app-sidebar"; +async function AuthGate({ children }: { children: React.ReactNode }) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + return <>{children}; +} + export default function AuthenticatedLayout({ children, }: { children: React.ReactNode; }) { - return ( - - {children} - - ); -} - -async function AuthShell({ children }: { children: React.ReactNode }) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - if (!user) redirect("/login"); - - return ( - - - - -
- {children} -
-
-
-
+ + + + +
+ + {children} + +
+
+
+
); - } +} 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/service-dialog.tsx b/app/(authenticated)/services/service-dialog.tsx new file mode 100644 index 0000000..6fbd022 --- /dev/null +++ b/app/(authenticated)/services/service-dialog.tsx @@ -0,0 +1,477 @@ +"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[0]}

; +} + +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 isProgramSchedule(value: unknown): value is ProgramSchedule { + if (!value || typeof value !== "object") return false; + const v = value as Partial; + return ( + typeof v.startDate === "string" && + typeof v.endDate === "string" && + Array.isArray(v.slots) && + v.slots.every( + (s) => typeof s?.dayOfWeek === "number" && typeof s?.time === "string", + ) + ); +} + +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(service?.coachId ?? ""); + const [state, formAction, pending] = useActionState< + ServiceActionState, + FormData + >(isEdit ? updateService : createService, null); + + React.useEffect(() => { + if (service) { + setType(service.type); + setCoachId(service.coachId ?? ""); + } + }, [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(""); + } + } + }, [state, isEdit, props]); + + const errors = state?.errors; + + const dialogControl = isEdit + ? { open: props.open, onOpenChange: props.onOpenChange } + : {}; + + const showForm = !isEdit || service !== null; + + const initialSchedule = + service && isProgramSchedule(service.scheduledAt) + ? 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 && ( + + )} + +
+ + + +
+ +
+ +