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 { presenceStatus } from '@/lib/presence-logic'
|
||||||
import { DuesCard } from '@/components/billing/dues-card'
|
import { DuesCard } from '@/components/billing/dues-card'
|
||||||
import { ProfileForm, type ProfileInitial } from './profile-form'
|
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'> = {
|
const STATUS_VARIANT: Record<CertStatus, 'default' | 'outline'> = {
|
||||||
active: 'default',
|
active: 'default',
|
||||||
@@ -35,8 +37,8 @@ export type ClassSignup = {
|
|||||||
attended: boolean
|
attended: boolean
|
||||||
signed_up_at: string
|
signed_up_at: string
|
||||||
class_sessions:
|
class_sessions:
|
||||||
| { 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 }
|
||||||
| { 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
|
| null
|
||||||
}
|
}
|
||||||
export type Reservation = {
|
export type Reservation = {
|
||||||
@@ -235,6 +237,14 @@ export function MePortalClient({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -268,6 +278,14 @@ export function MePortalClient({
|
|||||||
</p>
|
</p>
|
||||||
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-0.5">{r.notes}</p>}
|
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-0.5">{r.notes}</p>}
|
||||||
</div>
|
</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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user