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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user