بلاک‌ها · فروشگاهی

نظرات و امتیاز

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

میانگین و توزیع امتیاز، فرم ثبت نظر با امتیاز و فهرست نظرات.

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

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