Blocksblock
Invitation
Accept or decline an invitation to join an organization.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/invitationUsage
"use client"
import { InvitationCard } from "@/components/invitation-card"
import { toast } from "@/components/ui/toast"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
export function Demo() {
return (
<div className="flex min-h-svh items-center justify-center bg-muted/30 p-6">
<div className="w-full max-w-md rounded-2xl border bg-card p-8 shadow-sm">
<InvitationCard
organization={{ name: "Analytical Engines" }}
inviter={{ name: "Ada Lovelace", email: "ada@particular.tech" }}
role="Admin"
account={{ name: "Grace Hopper", email: "grace@example.com" }}
onDecision={async (accept) => {
await wait(700)
toast.add({ title: accept ? "Joined Analytical Engines" : "Invitation declined", type: accept ? "success" : "info" })
}}
footer={
<a href="#" className="underline underline-offset-4 hover:text-foreground">
Not you? Switch account
</a>
}
/>
</div>
</div>
)
}Source
"use client"
import * as React from "react"
import { cn } from "cn"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
export type InvitationCardProps = {
organization: { name: string; logo?: string | null }
/** Without a name, only the email is shown. */
inviter: { name?: string | null; email: string }
/** Display label of the role the invitee gets, e.g. "Admin". */
role: string
/** The signed-in account that will join. */
account: { name: string; email: string; image?: string | null }
/** Resolve once recorded; the caller usually navigates away. Reject with an Error to show its message. */
onDecision: (accept: boolean) => Promise<void>
/** Shown under the buttons, e.g. "Not you? Switch account". */
footer?: React.ReactNode
className?: string
}
function initials(name: string) {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("")
}
function InvitationCard({ organization, inviter, role, account, onDecision, footer, className }: InvitationCardProps) {
const [pending, setPending] = React.useState<"accept" | "decline" | null>(null)
const [decided, setDecided] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const decide = async (accept: boolean) => {
setPending(accept ? "accept" : "decline")
setError(null)
try {
await onDecision(accept)
setDecided(true)
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Something went wrong. Try again.")
} finally {
setPending(null)
}
}
return (
<div className={cn("mx-auto flex w-full max-w-md flex-col gap-6", className)}>
<div className="flex justify-center">
<Avatar className="size-14 rounded-xl">
{organization.logo && <AvatarImage src={organization.logo} alt="" />}
<AvatarFallback className="rounded-xl text-base">{initials(organization.name)}</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col gap-1 text-center">
<h1 className="font-heading text-xl font-semibold tracking-tight text-balance">Join {organization.name}</h1>
<p className="text-sm text-muted-foreground">
{inviter.name ? `${inviter.name} (${inviter.email})` : inviter.email} invited you as{" "}
<span className="font-medium text-foreground">{role}</span>.
</p>
</div>
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<Avatar className="size-8">
{account.image && <AvatarImage src={account.image} alt="" />}
<AvatarFallback className="text-xs">{initials(account.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 text-sm">
<div className="truncate font-medium">{account.name}</div>
<div className="truncate text-muted-foreground">{account.email}</div>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="lg" disabled={pending !== null || decided} onClick={() => void decide(false)}>
{pending === "decline" && <Spinner data-icon="inline-start" />}
Decline
</Button>
<Button variant="brand" size="lg" disabled={pending !== null || decided} onClick={() => void decide(true)}>
{pending === "accept" && <Spinner data-icon="inline-start" />}
Join organization
</Button>
</div>
{footer && <div className="text-center text-sm text-muted-foreground">{footer}</div>}
</div>
)
}
export { InvitationCard }