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