Particular.Tech
Blocksblock

Invitation

Accept or decline an invitation to join an organization.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/invitation
npm dependencies
  • cn@^0.4.0
Installs these registry items

Usage

invitation-demo.tsx
"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

blocks/invitation/invitation-card.tsx
"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 }