افزودنِ عضو به تیم
پیشنمایش تمامصفحهچند ایمیل در یک فیلد با چیپ و خطای کنارِ فیلد، نقشِ دعوتشدهها و فرستادنِ دعوت با شمار، اعضای فعلی با حذفِ برگشتپذیر، و لینکِ دعوت با کپی.
۱. نصب کامپوننتها
این بلاک از ۱۲ آیتم دیگ استفاده میکند: avatarbuttoncardchipcontrol-sizeiconslabelpersianselectseparatortext-fieldtoast
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/avatar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.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/select.json https://docs.digdesign.ir/r/separator.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/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.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/select.json https://docs.digdesign.ir/r/separator.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/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.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/select.json https://docs.digdesign.ir/r/separator.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/button.json https://docs.digdesign.ir/r/card.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.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/select.json https://docs.digdesign.ir/r/separator.json https://docs.digdesign.ir/r/text-field.json https://docs.digdesign.ir/r/toast.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
team-invite.tsx
"use client"
import * as React from "react"
import { CheckIcon, CopyIcon, TrashIcon } from "@/components/icons"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Chip, ChipCloseButton, ChipLabel } from "@/components/ui/chip"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { TextField } from "@/components/ui/text-field"
import { toast } from "@/components/ui/toast"
import { isValidEmail, toFaDigits } from "@/lib/persian"
import { avatarPhoto } from "@/lib/stock-images"
type Member = { name: string; gender: "male" | "female"; role: string }
const INITIAL: Member[] = [
{ name: "امیر رضایی", gender: "male", role: "طراحِ رابط · تیمِ ۱" },
{ name: "سارا محمدی", gender: "female", role: "توسعهدهندهٔ iOS · تیمِ ۲" },
{ name: "نگار احمدی", gender: "female", role: "توسعهدهندهٔ فرانتاند · تیمِ ۱" },
]
const LINK = "avand.example/invite/team-554433"
/**
* افزودنِ عضو به تیم: چند ایمیل در یک فیلد (با Enter یا ویرگول چیپ میشوند، ایمیلِ نامعتبر خطای کنارِ
* فیلد میگیرد)، نقشِ دعوتشدهها و «فرستادنِ دعوت» با شمار؛ اعضای فعلی با «حذف» که برگشتپذیر است؛ و
* جداکنندهٔ «یا» با لینکِ دعوت و دکمهٔ کپی که نتیجه را روی خودش نشان میدهد.
*/
export default function TeamInviteBlock() {
const [emails, setEmails] = React.useState<string[]>(["mahsa@dig.ir"])
const [draft, setDraft] = React.useState("")
const [error, setError] = React.useState("")
const [role, setRole] = React.useState("member")
const [members, setMembers] = React.useState(INITIAL)
const [copied, setCopied] = React.useState(false)
function commit(value: string) {
const parts = value.split(/[,،\s]+/).filter(Boolean)
const bad = parts.find((part) => !isValidEmail(part))
if (bad) {
setError(`«${bad}» ایمیلِ معتبری نیست؛ مثل name@example.com`)
return
}
setEmails((list) => [...list, ...parts.filter((part) => !list.includes(part))])
setDraft("")
setError("")
}
function remove(member: Member) {
setMembers((list) => list.filter((item) => item.name !== member.name))
toast(`${member.name} از تیم حذف شد.`, { action: { label: "برگردان", onClick: () => setMembers((list) => [...list, member]) } })
}
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-xl flex-col gap-6 p-4 sm:p-6">
<Card>
<CardHeader className="border-b border-border pb-5">
<CardTitle asChild>
<h1 className="text-xl font-bold">افزودنِ عضو به تیم</h1>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-8">
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
if (draft) commit(draft)
else if (emails.length) {
toast.success(`دعوت برای ${toFaDigits(emails.length)} نفر فرستاده شد.`)
setEmails([])
}
}}
>
<div className="flex flex-col gap-3">
<TextField
label="ایمیلِ اعضا"
type="email"
dir="ltr"
value={draft}
onValueChange={(value) => {
setDraft(value)
setError("")
}}
onKeyDown={(event) => {
if ((event.key === "," || event.key === "Enter") && draft) {
event.preventDefault()
commit(draft)
}
if (event.key === "Backspace" && !draft) setEmails((list) => list.slice(0, -1))
}}
onBlur={() => draft && commit(draft)}
placeholder="name@example.com"
invalid={Boolean(error)}
errorMessage={error}
description="چند ایمیل را با ویرگول یا Enter جدا کن."
/>
{/* ایمیلهای افزودهشده زیرِ فیلد؛ هر کدام با ضربدر برداشته میشود. */}
{emails.length > 0 && (
<ul aria-label="ایمیلهای دعوت" className="flex flex-wrap gap-2">
{emails.map((email) => (
<li key={email}>
<Chip variant="soft">
<ChipLabel>
<bdi dir="ltr">{email}</bdi>
</ChipLabel>
<ChipCloseButton aria-label={`برداشتنِ ${email}`} onClick={() => setEmails((list) => list.filter((item) => item !== email))} />
</Chip>
</li>
))}
</ul>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
<div className="flex flex-col gap-2">
<Label id="invite-role">نقش</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger aria-labelledby="invite-role" className="w-full">
<SelectValue />
</SelectTrigger>
<ControlSizeProvider size="xl" asChild>
<SelectContent>
<SelectItem value="admin">مدیر؛ همهچیز</SelectItem>
<SelectItem value="member">عضو؛ ویرایشِ پروژهها</SelectItem>
<SelectItem value="viewer">بیننده؛ فقط دیدن</SelectItem>
</SelectContent>
</ControlSizeProvider>
</Select>
</div>
<Button type="submit" disabled={emails.length === 0 && !draft}>
{emails.length > 1 ? `فرستادنِ ${toFaDigits(emails.length)} دعوت` : "فرستادنِ دعوت"}
</Button>
</div>
</form>
<section aria-labelledby="members-title" className="flex flex-col gap-3">
<h2 id="members-title" className="text-sm font-semibold text-muted-foreground">
اعضای فعلی · {toFaDigits(members.length)}
</h2>
<ul className="flex flex-col gap-4">
{members.map((member) => (
<li key={member.name} className="flex items-center gap-3">
<Avatar alt={member.name} src={avatarPhoto(member.name, member.gender)} className="size-11" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-bold">{member.name}</span>
<span className="truncate text-sm text-muted-foreground">{member.role}</span>
</span>
<ControlSizeProvider size="lg" asChild>
<Button variant="ghost" onClick={() => remove(member)} className="shrink-0 gap-1.5 px-3 text-destructive hover:bg-destructive-subtle hover:text-destructive">
<TrashIcon />
حذف
</Button>
</ControlSizeProvider>
</li>
))}
</ul>
</section>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Separator className="flex-1" />
یا
<Separator className="flex-1" />
</div>
<section aria-labelledby="link-title" className="flex flex-col gap-3">
<h2 id="link-title" className="text-sm font-semibold text-muted-foreground">
لینکِ دعوت را بفرست
</h2>
<div className="flex items-center gap-2 rounded-lg border border-border bg-subtle p-1.5 ps-4">
<bdi dir="ltr" className="min-w-0 flex-1 truncate text-sm [font-feature-settings:'ss01'_1,'ss02'_0]">
{LINK}
</bdi>
<ControlSizeProvider size="lg" asChild>
<Button
variant="outline"
onClick={async () => {
await navigator.clipboard?.writeText(`https://${LINK}`).catch(() => undefined)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className={`shrink-0 bg-card ${copied ? "text-success" : ""}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? "کپی شد" : "کپی"}
</Button>
</ControlSizeProvider>
</div>
<p className="text-xs text-muted-foreground">لینک تا ۷ روز معتبر است و هر کس آن را داشته باشد با نقشِ «عضو» وارد میشود.</p>
</section>
</CardContent>
</Card>
</main>
</ControlSizeProvider>
)
}