feat(me): self-serve cancel for class signups + reservations (P3c)

Activity tab: confirm-dialog Cancel on registered/waitlisted class
signups (cancelMySignup) and reserved equipment reservations
(cancelReservation) via the existing actions. Reusable CancelAction
(AlertDialog + toast + refresh). Buttons only shown on cancellable
states; the actions still enforce ownership/space scoping server-side.
521 tests; build clean (UI not browser-tested from here).
This commit is contained in:
Moheeb Zara
2026-05-18 15:48:24 -07:00
parent ef210c9fe5
commit 6189e2023d
2 changed files with 83 additions and 2 deletions
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/alert-dialog'
// Confirm-then-run wrapper for a self-serve cancel. The action returns the
// project-standard { error } | { data/success } shape.
export function CancelAction({
title,
description,
action,
successMessage,
}: {
title: string
description: string
action: () => Promise<{ error?: string } | Record<string, unknown>>
successMessage: string
}) {
const router = useRouter()
const [busy, setBusy] = useState(false)
async function run() {
setBusy(true)
const res = await action()
setBusy(false)
if (res && 'error' in res && res.error) return toast.error(res.error as string)
toast.success(successMessage)
router.refresh()
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" disabled={busy}>
{busy ? 'Cancelling…' : 'Cancel'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep it</AlertDialogCancel>
<AlertDialogAction onClick={run}>Cancel it</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+20 -2
View File
@@ -12,6 +12,8 @@ import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic'
import { presenceStatus } from '@/lib/presence-logic'
import { DuesCard } from '@/components/billing/dues-card'
import { ProfileForm, type ProfileInitial } from './profile-form'
import { CancelAction } from './cancel-action'
import { cancelMySignup, cancelReservation } from '@/lib/actions'
const STATUS_VARIANT: Record<CertStatus, 'default' | 'outline'> = {
active: 'default',
@@ -35,8 +37,8 @@ export type ClassSignup = {
attended: boolean
signed_up_at: string
class_sessions:
| { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }
| { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }[]
| { id: string; starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }
| { id: string; starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }[]
| null
}
export type Reservation = {
@@ -235,6 +237,14 @@ export function MePortalClient({
</p>
)}
</div>
{sess && (cs.status === 'registered' || cs.status === 'waitlisted') && (
<CancelAction
title="Cancel signup?"
description={`Cancel your spot in ${titleOf(cs)}? If a seat frees up, the next waitlisted member is promoted.`}
successMessage="Signup cancelled."
action={() => cancelMySignup({ sessionId: sess.id })}
/>
)}
</li>
)
})}
@@ -268,6 +278,14 @@ export function MePortalClient({
</p>
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-0.5">{r.notes}</p>}
</div>
{r.status === 'reserved' && (
<CancelAction
title="Cancel reservation?"
description={`Cancel your reservation for ${eq?.name ?? 'this equipment'}? The slot will be freed for others.`}
successMessage="Reservation cancelled."
action={() => cancelReservation({ reservationId: r.id })}
/>
)}
</li>
)
})}