نظرات و امتیاز
پیشنمایش تمامصفحهمیانگین و توزیع امتیاز، فرم ثبت نظر با امتیاز و فهرست نظرات.
۱. نصب کامپوننتها
این بلاک از ۱۲ آیتم دیگ استفاده میکند: avatarbadgebuttoncontrol-sizeiconslabelpersianratingstat-tiletextareatoasttoggle-group
پکیجمنیجر پیشفرض 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/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/stat-tile.json https://docs.digdesign.ir/r/textarea.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.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/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/stat-tile.json https://docs.digdesign.ir/r/textarea.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.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/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/stat-tile.json https://docs.digdesign.ir/r/textarea.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.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/icons.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/stat-tile.json https://docs.digdesign.ir/r/textarea.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
reviews.tsx
"use client";
import * as React from "react";
import {
AvatarGenderIcon,
CheckIcon,
StarIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} 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 { Label } from "@/components/ui/label";
import { Rating } from "@/components/ui/rating";
import { StatTileBar } from "@/components/ui/stat-tile";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/toast";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { formatNumber, formatRelativeTime, toFaDigits } from "@/lib/persian";
import { avatarPhoto } from "@/lib/stock-images";
type Review = {
id: number;
name: string;
gender: "male" | "female";
rating: number;
text: string;
date: string;
verified: boolean;
helpful: number;
tags: string[];
};
const INITIAL: Review[] = [
{
id: 31,
name: "سارا محمدی",
gender: "female",
rating: 5,
text: "کیفیت عالی و ارسال خیلی سریع بود. بستهبندی هم تمیز بود و صدای هدفون از چیزی که انتظار داشتم بهتر است.",
date: "2026-09-27T10:00:00",
verified: true,
helpful: 12,
tags: ["کیفیتِ ساخت", "ارسالِ سریع"],
},
{
id: 27,
name: "علی کریمی",
gender: "male",
rating: 4,
text: "در کل راضیام؛ فقط رنگش کمی با عکس فرق داشت. باتری واقعاً دو روز کار میکند.",
date: "2026-09-20T18:30:00",
verified: true,
helpful: 5,
tags: ["ارزشِ خرید"],
},
{
id: 19,
name: "نیلوفر احمدی",
gender: "female",
rating: 3,
text: "صدا خوب است ولی بعد از دو ساعت کمی روی گوش فشار میآورد.",
date: "2026-09-11T09:15:00",
verified: false,
helpful: 2,
tags: [],
},
];
const DISTRIBUTION = [
{ stars: 5, count: 86 },
{ stars: 4, count: 31 },
{ stars: 3, count: 9 },
{ stars: 2, count: 3 },
{ stars: 1, count: 2 },
];
const TAGS = ["کیفیتِ ساخت", "ارسالِ سریع", "ارزشِ خرید", "راحتی"];
const MAX = 300;
export default function ReviewsBlock() {
const [reviews, setReviews] = React.useState(INITIAL);
const [rating, setRating] = React.useState(0);
const [tags, setTags] = React.useState<string[]>([]);
const [text, setText] = React.useState("");
const [filter, setFilter] = React.useState("all");
// رأی هر نظر: up یا down؛ دو دکمه انحصاریاند، کلیکِ دوباره رأی را برمیدارد.
const [votes, setVotes] = React.useState<Record<number, "up" | "down">>({});
const total = DISTRIBUTION.reduce((sum, row) => sum + row.count, 0);
const average =
DISTRIBUTION.reduce((sum, row) => sum + row.stars * row.count, 0) / total;
const visible = reviews.filter((review) =>
filter === "five"
? review.rating === 5
: filter === "verified"
? review.verified
: true,
);
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setReviews((list) => [
{
id: Date.now() % 1000,
name: "آرمیتا رستگار",
gender: "female",
rating,
text,
date: new Date().toISOString(),
verified: true,
helpful: 0,
tags,
},
...list,
]);
setRating(0);
setTags([]);
setText("");
toast.success("ممنون، نظرتان ثبت شد.", {
description: "پس از بررسی نمایش داده میشود.",
});
}
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-5xl flex-col gap-8 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">
هدفون بیسیم دیگ پرو · {formatNumber(total)} نظرِ ثبتشده
</p>
</header>
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
<div className="flex flex-col gap-6">
{/* خلاصه: عددِ بزرگ و پخشِ امتیازها کنارِ هم، نوارها به رنگِ ستاره. */}
<section
aria-label="خلاصهٔ امتیازها"
className="grid grid-cols-1 gap-6 rounded-xl border border-border bg-card p-5 sm:grid-cols-[12rem_minmax(0,1fr)]"
>
<div className="flex flex-col items-center justify-center gap-2 text-center sm:border-e sm:border-border sm:pe-6">
<p className="text-5xl font-bold">
{formatNumber(average, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})}
</p>
<Rating value={average} readOnly aria-label="میانگین امتیاز" />
<p className="text-sm text-muted-foreground">
از {formatNumber(total)} نظر
</p>
<Badge variant="soft" color="success" className="mt-1">
٪۹۲ پیشنهاد میکنند
</Badge>
{/* در موبایل فرمِ نظر بعد از همهٔ نظرهاست؛ این دکمه مستقیم به آن میبرد. */}
<Button asChild variant="outline" className="mt-3 lg:hidden">
<a href="#write-review">نوشتنِ نظر</a>
</Button>
</div>
<ul className="flex flex-col justify-center gap-3">
{DISTRIBUTION.map((row) => (
<li
key={row.stars}
className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-3 text-sm"
>
<span className="flex items-center gap-1 font-medium">
{toFaDigits(row.stars)}
<StarIcon aria-hidden className="size-3.5 text-warning" />
</span>
<StatTileBar
value={(row.count / total) * 100}
color="var(--warning)"
aria-label={`${toFaDigits(row.stars)} ستاره`}
/>
<span className="text-end text-muted-foreground tabular-nums">
{formatNumber(row.count)}
</span>
</li>
))}
</ul>
</section>
<section
aria-labelledby="reviews-title"
className="flex flex-col gap-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 id="reviews-title" className="text-lg font-bold">
نظراتِ خریداران
</h2>
<ToggleGroup
type="single"
variant="segmented"
value={filter}
onValueChange={(value) => value && setFilter(value as string)}
aria-label="فیلترِ نظرها"
>
<ToggleGroupItem value="all">همه</ToggleGroupItem>
<ToggleGroupItem value="five">۵ ستاره</ToggleGroupItem>
<ToggleGroupItem value="verified">
خرید تأییدشده
</ToggleGroupItem>
</ToggleGroup>
</div>
{visible.map((review) => {
const vote = votes[review.id];
const toggle = (next: "up" | "down") =>
setVotes((current) => {
const copy = { ...current };
if (copy[review.id] === next) delete copy[review.id];
else copy[review.id] = next;
return copy;
});
return (
<article
key={review.id}
className="flex flex-col gap-4 rounded-xl border border-border bg-card p-5"
>
<div className="flex items-start gap-3">
<Avatar
alt={review.name}
src={avatarPhoto(review.name, review.gender)}
icon={<AvatarGenderIcon gender={review.gender} />}
className="ring-1 ring-border"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{review.name}</span>
{review.verified && (
<Badge
variant="soft"
color="success"
size="sm"
className="gap-1"
>
<CheckIcon />
خرید تأییدشده
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(review.date)}
</span>
</div>
<Rating
value={review.rating}
readOnly
size="sm"
aria-label={`امتیاز ${toFaDigits(review.rating)} از ۵`}
/>
</div>
<p className="leading-7">{review.text}</p>
{review.tags.length > 0 && (
<ul className="flex flex-wrap gap-2">
{review.tags.map((tag) => (
<li key={tag}>
<Badge variant="soft">{tag}</Badge>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2 border-t border-border pt-3 text-sm text-muted-foreground">
<span>مفید بود؟</span>
<Button
variant="ghost"
aria-pressed={vote === "up"}
aria-label={`مفید بود، ${toFaDigits(review.helpful + (vote === "up" ? 1 : 0))} نفر`}
onClick={() => toggle("up")}
className={
vote === "up"
? "text-success hover:text-success"
: undefined
}
>
<ThumbsUpIcon filled={vote === "up"} />
{toFaDigits(review.helpful + (vote === "up" ? 1 : 0))}
</Button>
<Button
variant="ghost"
aria-pressed={vote === "down"}
aria-label="مفید نبود"
onClick={() => toggle("down")}
className={
vote === "down"
? "text-destructive hover:text-destructive"
: undefined
}
>
<ThumbsDownIcon filled={vote === "down"} />
{toFaDigits(vote === "down" ? 1 : 0)}
</Button>
<Button variant="ghost">پاسخ</Button>
</div>
</article>
);
})}
</section>
</div>
{/* فرمِ نظر کنارِ فهرست و چسبان؛ امتیاز با برچسبِ زنده، برچسبهای آماده، شمارندهٔ متن. */}
<aside
id="write-review"
aria-labelledby="write-title"
className="scroll-mt-6 lg:sticky lg:top-6"
>
<ControlSizeProvider size="xl">
<form
onSubmit={onSubmit}
className="flex flex-col gap-6 rounded-xl border border-border bg-card p-5"
>
<div className="flex flex-col gap-1">
<h2 id="write-title" className="text-lg font-bold">
نظرِ شما چیست؟
</h2>
<p className="text-sm text-muted-foreground">
تجربهتان به خریدارانِ بعدی کمک میکند.
</p>
</div>
<div className="flex flex-col gap-2">
<Label id="rating-label">امتیاز</Label>
<Rating
name="rating"
value={rating}
onValueChange={setRating}
showLabel
aria-labelledby="rating-label"
/>
</div>
<div className="flex flex-col gap-2">
<Label id="tags-label">چه چیزی را پسندیدید؟</Label>
<ToggleGroup
type="multiple"
variant="outline"
separated
value={tags}
onValueChange={(value) => setTags(value as string[])}
aria-labelledby="tags-label"
className="flex-wrap gap-2"
>
{TAGS.map((tag) => (
<ToggleGroupItem
key={tag}
value={tag}
className="rounded-full! px-3 text-sm"
>
{tag}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="comment">متنِ نظر</Label>
<Textarea
id="comment"
name="comment"
rows={4}
maxLength={MAX}
required
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="چه چیزی را دوست داشتید و چه چیزی را نه؟"
/>
<span className="self-end text-xs text-muted-foreground tabular-nums">
{toFaDigits(text.length)} / {toFaDigits(MAX)}
</span>
</div>
<Button
type="submit"
fullWidth
disabled={rating === 0 || text.trim().length === 0}
>
ثبتِ نظر
</Button>
</form>
</ControlSizeProvider>
</aside>
</div>
</main>
</ControlSizeProvider>
);
}