بلاک‌ها · پنل مدیریت

کارتِ هر عضو با عکس، نقش و آخرین فعالیت، تغییرِ نقش و حذف از منو (مالک محافظت‌شده)، دعوت با دیالوگِ ایمیل و نقش، دعوت‌های در انتظار با ارسالِ دوباره و لغو، و سهمیهٔ اعضا.

۱. نصب کامپوننت‌ها

این بلاک از ۱۳ آیتم دیگ استفاده می‌کند: avatarbadgebuttoncontrol-sizedialogdropdown-menuiconslabelpersiansearch-fieldselecttext-fieldtoast

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dialog.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

team-members.tsx
"use client"

import * as React from "react"

import { EnvelopeIcon, MoreHorizontalIcon, PlusIcon, RefreshIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Label } from "@/components/ui/label"
import { SearchField } from "@/components/ui/search-field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { TextField } from "@/components/ui/text-field"
import { toast } from "@/components/ui/toast"
import { formatRelativeTime, isValidEmail, toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"

type Role = "owner" | "admin" | "member" | "viewer"
const ROLES: Record<Role, { label: string; color: "violet" | "info" | "success" | "warning" }> = {
  owner: { label: "مالک", color: "violet" },
  admin: { label: "مدیر", color: "info" },
  member: { label: "عضو", color: "success" },
  viewer: { label: "بیننده", color: "warning" },
}

const MEMBERS: { name: string; email: string; role: Role; photo: StockImageName; active: string }[] = [
  { name: "سارا محمدی", email: "sara@sekke.example", role: "owner", photo: "personWoman3", active: "2026-10-06T09:00:00" },
  { name: "نیما رضایی", email: "nima@sekke.example", role: "admin", photo: "personMan1", active: "2026-10-06T07:30:00" },
  { name: "مهسا کریمی", email: "mahsa@sekke.example", role: "member", photo: "personWoman1", active: "2026-10-05T18:10:00" },
  { name: "آرش نوری", email: "arash@sekke.example", role: "member", photo: "personMan2", active: "2026-10-02T11:00:00" },
  { name: "لیلا احمدی", email: "leila@sekke.example", role: "viewer", photo: "personWoman2", active: "2026-09-28T15:00:00" },
]

const INVITES = [
  { email: "reza@example.com", role: "member" as Role, sent: "2026-10-04T10:00:00" },
  { email: "niloufar@example.com", role: "viewer" as Role, sent: "2026-10-01T16:00:00" },
]

/**
 * تیم و دعوت: کارتِ هر عضو با عکس، نقش و آخرین فعالیت؛ تغییرِ نقش و حذف از منو
 * (مالک قابلِ حذف نیست)، دعوت با دیالوگ (ایمیل و نقش) و دعوت‌های در انتظار با
 * ارسالِ دوباره و لغو. سهمیهٔ اعضا کنارِ عنوان.
 */
export default function TeamMembersBlock() {
  const [members, setMembers] = React.useState(MEMBERS)
  const [query, setQuery] = React.useState("")
  const [inviteEmail, setInviteEmail] = React.useState("")
  const [open, setOpen] = React.useState(false)
  const key = toSearchKey(query)
  const visible = members.filter((member) => toSearchKey(member.name + member.email).includes(key))

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-6xl flex-col gap-8 p-4 sm:p-6">
        <header className="flex flex-wrap items-end justify-between gap-x-4 gap-y-8">
          <div className="flex flex-col gap-1">
            <h1 className="text-2xl font-bold">اعضای تیم</h1>
            <p className="text-muted-foreground">
              {toFaDigits(MEMBERS.length)} از {toFaDigits(10)} عضوِ پلنِ حرفه‌ای
            </p>
          </div>
          <div className="flex w-full flex-wrap gap-3 sm:w-auto">
            <SearchField aria-label="جست‌وجو در اعضا" placeholder="نام یا ایمیل" value={query} onValueChange={setQuery} className="w-full sm:w-64" />
            <Dialog open={open} onOpenChange={setOpen}>
              <DialogTrigger asChild>
                <Button>
                  <PlusIcon />
                  دعوتِ عضو
                </Button>
              </DialogTrigger>
              <ControlSizeProvider size="xl" asChild>
                <DialogContent>
                  <DialogHeader>
                    <DialogTitle>دعوت به تیم</DialogTitle>
                    <DialogDescription>لینکِ پیوستن به این ایمیل فرستاده می‌شود و تا ۷ روز معتبر است.</DialogDescription>
                  </DialogHeader>
                  <ControlSizeProvider size="xl" className="flex flex-col gap-4">
                    <TextField label="ایمیل" type="email" dir="ltr" value={inviteEmail} onValueChange={setInviteEmail} placeholder="name@example.com" required />
                    <div className="flex flex-col gap-2">
                      <Label id="invite-role-label">نقش</Label>
                      <Select defaultValue="member">
                        <SelectTrigger aria-labelledby="invite-role-label" className="w-full">
                          <SelectValue />
                        </SelectTrigger>
                        <SelectContent>
                          <SelectItem value="admin">مدیر: همه‌چیز به‌جز صورتحساب</SelectItem>
                          <SelectItem value="member">عضو: ساخت و ویرایش</SelectItem>
                          <SelectItem value="viewer">بیننده: فقط دیدن</SelectItem>
                        </SelectContent>
                      </Select>
                    </div>
                  </ControlSizeProvider>
                  <DialogFooter>
                    <Button variant="outline" onClick={() => setOpen(false)}>
                      انصراف
                    </Button>
                    <Button
                      disabled={!isValidEmail(inviteEmail)}
                      onClick={() => {
                        setOpen(false)
                        setInviteEmail("")
                        toast.success("دعوت‌نامه فرستاده شد.")
                      }}
                    >
                      ارسالِ دعوت
                    </Button>
                  </DialogFooter>
                </DialogContent>
              </ControlSizeProvider>
            </Dialog>
          </div>
        </header>

        <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {visible.map((member) => (
            <li key={member.email} className="flex flex-col gap-5 rounded-xl border border-border bg-card p-5">
              <div className="flex items-start gap-3">
                <Avatar size="lg" alt={member.name} src={stockImages[member.photo].src} />
                <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                  <span className="truncate font-semibold">{member.name}</span>
                  <span className="truncate text-sm text-muted-foreground">
                    <bdi dir="ltr">{member.email}</bdi>
                  </span>
                </div>
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <Button variant="ghost" size="icon" aria-label={`گزینه‌های ${member.name}`} disabled={member.role === "owner"} className="-me-2 -mt-1">
                      <MoreHorizontalIcon />
                    </Button>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="end">
                    <DropdownMenuLabel>تغییرِ نقش</DropdownMenuLabel>
                    {(["admin", "member", "viewer"] as Role[])
                      .filter((role) => role !== member.role)
                      .map((role) => (
                        <DropdownMenuItem key={role} onSelect={() => toast.success(`نقشِ ${member.name} «${ROLES[role].label}» شد.`)}>
                          {ROLES[role].label}
                        </DropdownMenuItem>
                      ))}
                    <DropdownMenuSeparator />
                    <DropdownMenuItem
                      variant="destructive"
                      onSelect={() => {
                        const before = members
                        setMembers((list) => list.filter((item) => item.email !== member.email))
                        toast(`${member.name} از تیم حذف شد.`, { action: { label: "برگردان", onClick: () => setMembers(before) } })
                      }}
                    >
                      حذف از تیم
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
              </div>
              <div className="flex items-center justify-between gap-2 border-t border-border pt-4 text-sm">
                <Badge variant="soft" color={ROLES[member.role].color}>
                  {ROLES[member.role].label}
                </Badge>
                <span className="text-muted-foreground">فعال {formatRelativeTime(new Date(member.active))}</span>
              </div>
            </li>
          ))}
        </ul>

        <section aria-labelledby="team-invites" className="flex flex-col gap-4">
          <h2 id="team-invites" className="text-lg font-bold">
            دعوت‌های در انتظار
          </h2>
          <ul className="flex flex-col divide-y divide-border rounded-xl border border-border bg-subtle">
            {INVITES.map((invite) => (
              <li key={invite.email} className="flex flex-wrap items-center gap-3 p-4">
                <span aria-hidden className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground [&>svg]:size-4">
                  <EnvelopeIcon />
                </span>
                <span className="flex min-w-0 flex-1 flex-col">
                  <bdi dir="ltr" className="truncate text-start text-sm font-medium">
                    {invite.email}
                  </bdi>
                  <span className="text-xs text-muted-foreground">
                    {ROLES[invite.role].label} · فرستاده {formatRelativeTime(new Date(invite.sent))}
                  </span>
                </span>
                <Button variant="ghost" onClick={() => toast.success("دعوت دوباره فرستاده شد.")}>
                  <RefreshIcon />
                  ارسالِ دوباره
                </Button>
                <Button variant="ghost" className="text-destructive hover:text-destructive">
                  لغو
                </Button>
              </li>
            ))}
          </ul>
        </section>
      </main>
    </ControlSizeProvider>
  )
}