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

افزودنِ عضو به تیم

پیش‌نمایش تمام‌صفحه

چند ایمیل در یک فیلد با چیپ و خطای کنارِ فیلد، نقشِ دعوت‌شده‌ها و فرستادنِ دعوت با شمار، اعضای فعلی با حذفِ برگشت‌پذیر، و لینکِ دعوت با کپی.

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

این بلاک از ۱۲ آیتم دیگ استفاده می‌کند: 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.json
pnpm 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.json
yarn 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.json
bunx --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>
  )
}