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

اشتراک‌گذاری و نظرها

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

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

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

این بلاک از ۱۱ آیتم دیگ استفاده می‌کند: avatarbuttonchipcontrol-sizedropdown-menuiconsinputpersianselecttextareatoast

پکیج‌منیجر پیش‌فرض 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/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/textarea.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/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/textarea.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/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/textarea.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/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/dropdown-menu.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/select.json https://docs.digdesign.ir/r/textarea.json https://docs.digdesign.ir/r/toast.json

۲. کد صفحه

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

share-comments.tsx
"use client";

import * as React from "react";

import {
  AvatarGenderIcon,
  CopyIcon,
  EditIcon,
  FileTextIcon,
  GlobeIcon,
  MoreHorizontalIcon,
  PlusIcon,
  TrashIcon,
  XIcon,
} from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Chip, ChipCloseButton, ChipLabel } from "@/components/ui/chip";
import { ControlSizeProvider } from "@/components/ui/control-size";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/toast";
import { formatRelativeTime, isValidEmail, toFaDigits } from "@/lib/persian";
import { avatarPhoto } from "@/lib/stock-images";

/** سقفِ ایمیل در یک دعوت؛ فهرستِ بلندتر را با فایل یا لینکِ عمومی بفرستید. */
const MAX_INVITES = 10;

const ACCESS = {
  view: "فقط مشاهده",
  comment: "نظر دادن",
  edit: "ویرایش",
} as const;
type Access = keyof typeof ACCESS;

const MEMBERS = [
  {
    name: "آرمیتا رستگار",
    email: "armita@dig.ir",
    gender: "female" as const,
    access: "edit" as Access,
    you: true,
  },
  {
    name: "کیان مرادی",
    email: "kian@dig.ir",
    gender: "male" as const,
    access: "comment" as Access,
    you: false,
  },
];

type Comment = {
  id: number;
  name: string;
  gender: "male" | "female";
  text: string;
  date: string;
};
const COMMENTS: Comment[] = [
  {
    id: 27,
    name: "کیان مرادی",
    gender: "male",
    text: "آرمیتا، می‌شه یه نگاه به صفحهٔ پرداخت بندازی؟ دکمهٔ تأیید روی موبایل زیرِ کیبورد می‌ره.",
    date: "2026-10-05T09:48:00",
  },
  {
    id: 31,
    name: "آرمیتا رستگار",
    gender: "female",
    text: "دیدم؛ دکمه رو چسبان به پایینِ صفحه می‌کنم و فاصلهٔ امنِ کیبورد رو هم حساب می‌کنم.",
    date: "2026-10-05T10:12:00",
  },
];

/** انتخابِ سطحِ دسترسی؛ کوچک و بی‌حاشیه تا ردیف شلوغ نشود. */
function AccessSelect({
  value,
  onChange,
  label,
}: {
  value: Access;
  onChange: (value: Access) => void;
  label: string;
}) {
  return (
    <Select value={value} onValueChange={(next) => onChange(next as Access)}>
      {/* بی‌حاشیه و کم‌جا؛ قابِ کاملِ Select این ردیف‌ها را سنگین می‌کرد. */}
      <SelectTrigger
        aria-label={label}
        className="w-auto shrink-0 gap-1 border-0 bg-card px-2 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground"
      >
        <SelectValue />
      </SelectTrigger>
      <SelectContent align="end">
        {(Object.keys(ACCESS) as Access[]).map((key) => (
          <SelectItem key={key} value={key}>
            {ACCESS[key]}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

function ShareCard() {
  const [emails, setEmails] = React.useState(["sara@dig.ir"]);
  const [draft, setDraft] = React.useState("");
  const [linkAccess, setLinkAccess] = React.useState<Access>("view");
  const [members, setMembers] = React.useState(MEMBERS);

  function addEmail() {
    const value = draft.trim();
    if (emails.length >= MAX_INVITES) return;
    if (!isValidEmail(value)) {
      toast.error("ایمیل معتبر نیست.", { description: "مثل name@example.com" });
      return;
    }
    if (!emails.includes(value)) setEmails((list) => [...list, value]);
    setDraft("");
  }

  return (
    <section
      aria-labelledby="share-title"
      className="flex flex-col overflow-hidden rounded-xl border border-border bg-card"
    >
      <header className="flex items-center gap-3 border-b border-border p-4">
        <span
          aria-hidden
          className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[color-mix(in_oklab,var(--brand-secondary)_14%,var(--card))] text-brand-secondary [&_svg]:size-5"
        >
          <FileTextIcon />
        </span>
        <div className="flex min-w-0 flex-col gap-0.5">
          <h2 id="share-title" className="font-semibold">
            اشتراک‌گذاری
          </h2>
          <span className="truncate text-xs text-muted-foreground">
            طراحی صفحهٔ پرداخت
          </span>
        </div>
      </header>
      {/* دعوت: ورودی ثابت بالا، چیپ‌های افزوده‌شده زیرش تا سقفِ مشخص. */}
      <div className="flex flex-col gap-3 p-4">
        <h3 className="text-sm font-semibold">دعوت با ایمیل</h3>
        <form
          className="flex gap-2"
          onSubmit={(event) => {
            event.preventDefault();
            addEmail();
          }}
        >
          <Input
            type="email"
            dir="ltr"
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            aria-label="ایمیلِ تازه"
            placeholder="name@example.com"
            disabled={emails.length >= MAX_INVITES}
            className="min-w-0 flex-1"
          />
          <Button
            type="submit"
            variant="outline"
            disabled={emails.length >= MAX_INVITES || draft.trim() === ""}
          >
            <PlusIcon />
            افزودن
          </Button>
        </form>
        {emails.length > 0 && (
          <ul aria-label="ایمیل‌های دعوت" className="flex flex-wrap gap-2">
            {emails.map((email) => (
              <li key={email}>
                <Chip variant="soft">
                  <ChipLabel dir="ltr">{email}</ChipLabel>
                  <ChipCloseButton
                    aria-label={`حذف ${email}`}
                    onClick={() =>
                      setEmails((list) => list.filter((item) => item !== email))
                    }
                  />
                </Chip>
              </li>
            ))}
          </ul>
        )}
        <span className="text-xs text-muted-foreground">
          {toFaDigits(emails.length)} از {toFaDigits(MAX_INVITES)} نفر در این
          دعوت
        </span>
      </div>

      <div className="mx-4 flex items-center gap-3 rounded-xl bg-muted p-3">
        <span
          aria-hidden
          className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-muted-foreground [&_svg]:size-4"
        >
          <GlobeIcon />
        </span>
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <span className="text-sm font-medium">لینکِ عمومی</span>
          <span className="text-xs text-muted-foreground">
            هر کس لینک را داشته باشد
          </span>
        </div>
        <AccessSelect
          value={linkAccess}
          onChange={setLinkAccess}
          label="دسترسی لینکِ عمومی"
        />
      </div>

      <div className="flex flex-col gap-3 p-4">
        <h3 className="text-sm font-semibold">
          اعضا{" "}
          <span className="font-normal text-muted-foreground">
            ({toFaDigits(members.length)})
          </span>
        </h3>
        <ul className="flex flex-col gap-3">
          {members.map((member, index) => (
            <li key={member.email} className="flex items-center gap-3">
              <Avatar
                size="sm"
                alt={member.name}
                src={avatarPhoto(member.name, member.gender)}
                icon={<AvatarGenderIcon gender={member.gender} />}
                className="ring-1 ring-border"
              />
              <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="truncate text-sm font-medium">
                  {member.name}
                  {member.you && (
                    <span className="font-normal text-muted-foreground">
                      {" "}
                      (شما)
                    </span>
                  )}
                </span>
                <span
                  dir="ltr"
                  className="self-start truncate text-xs text-muted-foreground"
                >
                  {member.email}
                </span>
              </div>
              <AccessSelect
                value={member.access}
                onChange={(access) =>
                  setMembers((list) =>
                    list.map((item, i) =>
                      i === index ? { ...item, access } : item,
                    ),
                  )
                }
                label={`دسترسی ${member.name}`}
              />
            </li>
          ))}
        </ul>
      </div>

      {/* پایینِ کارت: اقدامِ ثانویه و اصلی کنارِ هم، روی سطحِ کمی متفاوت. */}
      <div className="mt-auto flex items-center justify-end gap-2 border-t border-border bg-muted p-4">
        <Button
          variant="ghost"
          onClick={() => {
            navigator.clipboard
              ?.writeText("https://dig.example/p/checkout")
              .catch(() => {});
            toast.success("لینک کپی شد.");
          }}
        >
          <CopyIcon />
          کپی لینک
        </Button>
        <Button
          disabled={emails.length === 0}
          onClick={() => {
            toast.success("دعوت‌نامه ارسال شد.", {
              description: `${toFaDigits(emails.length)} نفر`,
            });
            setEmails([]);
          }}
        >
          دعوت
        </Button>
      </div>
    </section>
  );
}

function CommentsPanel() {
  const [comments, setComments] = React.useState(COMMENTS);
  const [text, setText] = React.useState("");

  function send() {
    const value = text.trim();
    if (!value) return;
    setComments((list) => [
      ...list,
      {
        id: (list.at(-1)?.id ?? 0) + 1,
        name: "آرمیتا رستگار",
        gender: "female",
        text: value,
        date: new Date().toISOString(),
      },
    ]);
    setText("");
  }

  return (
    <section
      aria-labelledby="comments-title"
      className="flex flex-col overflow-hidden rounded-xl border border-border bg-card"
    >
      <header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
        <h2 id="comments-title" className="font-semibold">
          نظرها{" "}
          <span className="font-normal text-muted-foreground">
            ({toFaDigits(comments.length)})
          </span>
        </h2>
        <Button variant="ghost" size="icon" aria-label="بستنِ نظرها">
          <XIcon />
        </Button>
      </header>

      <ol className="flex flex-col divide-y divide-border">
        {comments.map((comment) => (
          <li key={comment.id} className="flex flex-col gap-3 p-4">
            <div className="flex items-center gap-3">
              <Avatar
                size="sm"
                alt={comment.name}
                src={avatarPhoto(comment.name, comment.gender)}
                icon={<AvatarGenderIcon gender={comment.gender} />}
                className="ring-1 ring-border"
              />
              <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                <span className="text-sm font-semibold">{comment.name}</span>
                <span className="text-xs text-muted-foreground">
                  {formatRelativeTime(comment.date)}
                </span>
              </div>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    variant="ghost"
                    size="icon"
                    aria-label={`گزینه‌های نظرِ ${comment.name}`}
                  >
                    <MoreHorizontalIcon />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem>
                    <CopyIcon />
                    کپی لینکِ نظر
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <EditIcon />
                    ویرایش
                  </DropdownMenuItem>
                  <DropdownMenuItem
                    variant="destructive"
                    onSelect={() => {
                      const before = comments;
                      setComments((list) =>
                        list.filter((item) => item.id !== comment.id),
                      );
                      toast("نظر حذف شد.", {
                        action: {
                          label: "برگردان",
                          onClick: () => setComments(before),
                        },
                      });
                    }}
                  >
                    <TrashIcon />
                    حذف
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
            <p className="text-sm leading-7">{comment.text}</p>
            <div className="flex items-center gap-1 text-sm text-muted-foreground">
              <Button variant="ghost">پاسخ</Button>
            </div>
          </li>
        ))}
      </ol>

      {/* نوشتنِ نظر: ورودی روی سطحِ muted، دکمهٔ ارسال در گوشه و فقط با متن فعال. */}
      <div className="mt-auto border-t border-border p-4">
        <div className="flex flex-col gap-3 rounded-xl border border-input bg-muted p-3 focus-within:border-foreground">
          <Textarea
            aria-label="نظرِ تازه"
            rows={2}
            value={text}
            onChange={(event) => setText(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter" && (event.metaKey || event.ctrlKey))
                send();
            }}
            placeholder="نظرتان را بنویسید…"
            className="resize-none border-0 bg-muted shadow-none focus-visible:shadow-none"
          />
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">
              برای ارسال ⌘ + Enter
            </span>
            <Button onClick={send} disabled={!text.trim()}>
              ارسال
            </Button>
          </div>
        </div>
      </div>
    </section>
  );
}

export default function ShareCommentsBlock() {
  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 sm:p-6">
        <header className="flex flex-col gap-1">
          <h1 className="text-2xl font-bold">اشتراک‌گذاری و نظرها</h1>
          <p className="text-sm text-muted-foreground">
            هم‌تیمی‌ها را به فایلِ «طراحی صفحهٔ پرداخت» دعوت کنید و همان‌جا
            دربارهٔ آن گفت‌وگو کنید.
          </p>
        </header>
        <ControlSizeProvider size="xl">
          <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-2">
            <ShareCard />
            <CommentsPanel />
          </div>
        </ControlSizeProvider>
      </main>
    </ControlSizeProvider>
  );
}