اعضای تیم
پیشنمایش تمامصفحهکارتِ هر عضو با عکس، نقش و آخرین فعالیت، تغییرِ نقش و حذف از منو (مالک محافظتشده)، دعوت با دیالوگِ ایمیل و نقش، دعوتهای در انتظار با ارسالِ دوباره و لغو، و سهمیهٔ اعضا.
۱. نصب کامپوننتها
این بلاک از ۱۳ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}