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

کارت‌های محصول با فیلتر دسته، بازهٔ قیمت و امتیاز، فیلترهای فعال و مرتب‌سازی.

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

این بلاک از ۲۰ آیتم دیگ استفاده می‌کند: accordionadd-to-cartbadgebreadcrumbbuttoncheckboxchipcontrol-sizeempty-stateiconsinputlabelpaginationpersianproduct-cardratingsearch-fieldslidertoasttoggle-group

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/accordion.json https://docs.digdesign.ir/r/add-to-cart.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/breadcrumb.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/product-card.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/slider.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/accordion.json https://docs.digdesign.ir/r/add-to-cart.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/breadcrumb.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/product-card.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/slider.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/accordion.json https://docs.digdesign.ir/r/add-to-cart.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/breadcrumb.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/product-card.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/slider.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/accordion.json https://docs.digdesign.ir/r/add-to-cart.json https://docs.digdesign.ir/r/badge.json https://docs.digdesign.ir/r/breadcrumb.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/checkbox.json https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/empty-state.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input.json https://docs.digdesign.ir/r/label.json https://docs.digdesign.ir/r/pagination.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/product-card.json https://docs.digdesign.ir/r/rating.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/slider.json https://docs.digdesign.ir/r/toast.json https://docs.digdesign.ir/r/toggle-group.json

۲. کد صفحه

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

product-list.tsx
"use client"

import * as React from "react"

import { SearchIcon } from "@/components/icons"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Badge } from "@/components/ui/badge"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { AddToCart } from "@/components/ui/add-to-cart"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Chip, ChipCloseButton, ChipLabel } from "@/components/ui/chip"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"
import { ProductCard, ProductCardBadge, ProductCardBody, ProductCardCategory, ProductCardFooter, ProductCardGrid, ProductCardMedia, ProductCardPrice, ProductCardTitle } from "@/components/ui/product-card"
import { Rating } from "@/components/ui/rating"
import { SearchField } from "@/components/ui/search-field"
import { Slider } from "@/components/ui/slider"
import { toast } from "@/components/ui/toast"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatCurrency, toFaDigits, toSearchKey } from "@/lib/persian"

const CATEGORIES = [
  { id: "all", title: "همهٔ محصولات", count: 48 },
  { id: "headphones", title: "هدفون", count: 18 },
  { id: "earbuds", title: "ایرباد", count: 12 },
  { id: "speakers", title: "اسپیکر", count: 9 },
  { id: "gaming", title: "گیمینگ", count: 6 },
  { id: "accessories", title: "لوازم جانبی", count: 3 },
]
const BRANDS = ["دیگ", "سونیک", "آوا"]
const PRODUCTS = [
  { id: "1", title: "هدفون بی‌سیم دیگ پرو", category: "headphones", brand: "دیگ", price: 3_490_000, oldPrice: 4_100_000, rating: 4.4, reviews: 312, tag: "پرفروش" },
  { id: "2", title: "هدفون سونیک لایت", category: "headphones", brand: "سونیک", price: 1_250_000, rating: 3.9, reviews: 86 },
  { id: "3", title: "ایرباد آوا مینی", category: "earbuds", brand: "آوا", price: 1_890_000, oldPrice: 2_200_000, rating: 4.1, reviews: 140, tag: "جدید" },
  { id: "4", title: "هدست گیمینگ دیگ", category: "gaming", brand: "دیگ", price: 4_750_000, rating: 4.6, reviews: 58 },
  { id: "5", title: "اسپیکر آوا بوم", category: "speakers", brand: "آوا", price: 2_300_000, rating: 4.2, reviews: 97 },
  { id: "6", title: "هدفون سونیک استودیو", category: "headphones", brand: "سونیک", price: 5_900_000, oldPrice: 6_500_000, rating: 4.8, reviews: 204 },
  { id: "7", title: "ایرباد دیگ اسپرت", category: "earbuds", brand: "دیگ", price: 2_150_000, rating: 4.3, reviews: 75, tag: "جدید" },
  { id: "8", title: "اسپیکر سونیک کیوب", category: "speakers", brand: "سونیک", price: 1_480_000, rating: 4.0, reviews: 39 },
  { id: "9", title: "پایهٔ رومیزی هدفون", category: "accessories", brand: "آوا", price: 690_000, rating: 4.5, reviews: 22 },
]
const RECOMMENDED = [
  { id: "r1", title: "ایرباد آوا مینی", price: 1_890_000 },
  { id: "r2", title: "هدفون سونیک استودیو", price: 5_900_000 },
  { id: "r3", title: "اسپیکر آوا بوم", price: 2_300_000 },
  { id: "r4", title: "هدست گیمینگ دیگ", price: 4_750_000 },
  { id: "r5", title: "کابل شارژ بافته‌شده", price: 180_000 },
]
const PRICE = { min: 0, max: 6_000_000 }
const PAGE_SIZE = 6

export default function ProductListBlock() {
  const [category, setCategory] = React.useState("all")
  const [brands, setBrands] = React.useState<string[]>([])
  const [price, setPrice] = React.useState([PRICE.min, PRICE.max])
  const [sort, setSort] = React.useState("popular")
  const [query, setQuery] = React.useState("")
  const [page, setPage] = React.useState(1)

  const key = toSearchKey(query)
  const items = PRODUCTS.filter(
    (item) =>
      (category === "all" || item.category === category) &&
      (brands.length === 0 || brands.includes(item.brand)) &&
      item.price >= price[0] &&
      item.price <= price[1] &&
      toSearchKey(item.title).includes(key)
  ).sort((a, b) =>
    sort === "cheap" ? a.price - b.price : sort === "expensive" ? b.price - a.price : sort === "new" ? Number(b.id) - Number(a.id) : b.rating - a.rating
  )
  const pageCount = Math.max(1, Math.ceil(items.length / PAGE_SIZE))
  const currentPage = Math.min(page, pageCount)
  const visible = items.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE)

  const clear = () => {
    setCategory("all")
    setBrands([])
    setPrice([PRICE.min, PRICE.max])
    setQuery("")
  }

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-6xl flex-col gap-10 p-4 sm:p-6">
        {/* سرصفحهٔ دسته: مسیر، عنوانِ درشت، تعداد و جست‌وجوی داخلِ دسته روی سطحِ رنگی. */}
        <header className="relative flex flex-col gap-6 overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--muted),color-mix(in_oklab,var(--brand-secondary)_12%,var(--muted)))] px-6 py-8 sm:px-10 sm:py-12">
          <Breadcrumb>
            <BreadcrumbList>
              <BreadcrumbItem>
                <BreadcrumbLink href="#">خانه</BreadcrumbLink>
              </BreadcrumbItem>
              <BreadcrumbSeparator />
              <BreadcrumbItem>
                <BreadcrumbLink href="#">کالای دیجیتال</BreadcrumbLink>
              </BreadcrumbItem>
              <BreadcrumbSeparator />
              <BreadcrumbItem>
                <BreadcrumbPage>صوتی</BreadcrumbPage>
              </BreadcrumbItem>
            </BreadcrumbList>
          </Breadcrumb>
          <div className="flex flex-col gap-2">
            <h1 className="text-4xl leading-snug font-bold sm:text-5xl">صوتی و هدفون</h1>
            <p className="text-muted-foreground">{toFaDigits(48)} محصول از برندهای محبوب، با ارسالِ رایگانِ بالای ۵ میلیون</p>
          </div>
          <SearchField
            aria-label="جست‌وجو در این دسته"
            placeholder="جست‌وجو در صوتی و هدفون…"
            value={query}
            onValueChange={(value) => {
              setQuery(value)
              setPage(1)
            }}
            className="w-full max-w-md [&_input]:bg-card [&>div]:bg-card"
          />
          <span aria-hidden className="pointer-events-none absolute -bottom-6 end-4 text-[9rem] leading-none font-black text-[color-mix(in_oklab,var(--foreground)_5%,transparent)] select-none max-sm:hidden">
            صوتی
          </span>
        </header>

        <div className="grid grid-cols-1 gap-8 md:grid-cols-[15rem_minmax(0,1fr)]">
          <aside aria-label="فیلترها" className="flex flex-col gap-6">
            {/* دسته‌ها: فهرستِ ساده با تعداد؛ فعال با پس‌زمینه و نوارِ شروع. */}
            <nav aria-label="دسته‌ها" className="flex flex-col gap-1">
              <h2 className="mb-2 text-sm font-semibold">دسته‌ها</h2>
              {CATEGORIES.map((item) => {
                const active = item.id === category
                return (
                  <Button
                    key={item.id}
                    variant="ghost"
                    aria-current={active ? "page" : undefined}
                    onClick={() => {
                      setCategory(item.id)
                      setPage(1)
                    }}
                    className={active ? "justify-between bg-muted font-semibold" : "justify-between font-normal text-muted-foreground"}
                  >
                    {item.title}
                    <span className="text-xs tabular-nums text-muted-foreground">{toFaDigits(item.count)}</span>
                  </Button>
                )
              })}
            </nav>

            <Accordion type="multiple" defaultValue={["brand", "price"]}>
              <AccordionItem value="brand">
                <AccordionTrigger>برند</AccordionTrigger>
                <AccordionContent className="flex flex-col gap-3 pb-4">
                  {BRANDS.map((brand) => (
                    <div key={brand} className="flex items-center gap-2">
                      <Checkbox
                        id={`brand-${brand}`}
                        checked={brands.includes(brand)}
                        onCheckedChange={(checked) => {
                          setBrands((current) => (checked === true ? [...current, brand] : current.filter((b) => b !== brand)))
                          setPage(1)
                        }}
                      />
                      <Label htmlFor={`brand-${brand}`}>{brand}</Label>
                    </div>
                  ))}
                </AccordionContent>
              </AccordionItem>
              <AccordionItem value="price">
                <AccordionTrigger>محدودهٔ قیمت</AccordionTrigger>
                {/* فاصلهٔ افقی برای دستگیره‌ها: لبهٔ محتوای آکاردئون آن‌ها را نیمه می‌برید. */}
                <AccordionContent className="flex flex-col gap-3 px-3 pt-2 pb-4">
                  <Slider
                    value={price}
                    onValueChange={(value) => {
                      setPrice(value)
                      setPage(1)
                    }}
                    min={PRICE.min}
                    max={PRICE.max}
                    step={250_000}
                    thumbLabel={["کمترین قیمت", "بیشترین قیمت"]}
                  />
                  <div className="flex justify-between text-xs text-muted-foreground">
                    <span>{formatCurrency(price[0])}</span>
                    <span>{formatCurrency(price[1])}</span>
                  </div>
                </AccordionContent>
              </AccordionItem>
            </Accordion>
          </aside>

          <section className="flex min-w-0 flex-col gap-8" aria-label="محصولات">
            <ControlSizeProvider size="xl">
              <div className="flex flex-col gap-3">
                <div className="flex flex-wrap items-center justify-between gap-3">
                  <span className="text-sm text-muted-foreground">
                    {toFaDigits(items.length)} محصول
                  </span>
                  <div className="max-w-full overflow-x-auto">
                  <ToggleGroup type="single" variant="segmented" value={sort} onValueChange={(value) => value && setSort(value as string)} aria-label="مرتب‌سازی">
                    <ToggleGroupItem value="popular">محبوب‌ترین</ToggleGroupItem>
                    <ToggleGroupItem value="new">جدیدترین</ToggleGroupItem>
                    <ToggleGroupItem value="cheap">ارزان‌ترین</ToggleGroupItem>
                    <ToggleGroupItem value="expensive">گران‌ترین</ToggleGroupItem>
                  </ToggleGroup>
                  </div>
                </div>
                {brands.length > 0 && (
                  <div className="flex flex-wrap gap-2">
                    {brands.map((brand) => (
                      <Chip key={brand} variant="soft" color="primary">
                        <ChipLabel>{brand}</ChipLabel>
                        <ChipCloseButton
                          aria-label={`حذف فیلتر ${brand}`}
                          onClick={() => setBrands((current) => current.filter((b) => b !== brand))}
                        />
                      </Chip>
                    ))}
                  </div>
                )}
              </div>
            </ControlSizeProvider>

            {visible.length === 0 ? (
              <EmptyState variant="bordered">
                <SearchIcon aria-hidden className="size-8 text-placeholder" />
                <EmptyStateTitle>محصولی با این فیلترها نیست</EmptyStateTitle>
                <EmptyStateDescription>محدودهٔ قیمت را بازتر کنید یا برندها را بردارید.</EmptyStateDescription>
                <EmptyStateActions>
                  <Button variant="outline" onClick={clear}>
                    پاک‌کردن فیلترها
                  </Button>
                </EmptyStateActions>
              </EmptyState>
            ) : (
              <ProductCardGrid>
                {visible.map((item) => (
                  <ProductCard key={item.id}>
                    <ProductCardMedia>
                      {item.tag && (
                        <ProductCardBadge>
                          <Badge size="sm" color={item.tag === "جدید" ? "success" : "warning"} variant="soft">
                            {item.tag}
                          </Badge>
                        </ProductCardBadge>
                      )}
                    </ProductCardMedia>
                    <ProductCardBody>
                      {/* برند در شروع و درصدِ تخفیف در انتهای همان ردیف؛ خطِ قیمت خلوت می‌ماند. */}
                      <div className="flex items-center justify-between gap-2">
                        <ProductCardCategory>{item.brand}</ProductCardCategory>
                        {item.oldPrice && (
                          <Badge color="destructive" size="sm">
                            ٪{toFaDigits(Math.round((1 - item.price / item.oldPrice) * 100))}
                          </Badge>
                        )}
                      </div>
                      <ProductCardTitle>{item.title}</ProductCardTitle>
                      <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
                        <Rating readOnly defaultValue={item.rating} size="sm" aria-label={`امتیاز ${toFaDigits(item.rating)}`} />
                        ({toFaDigits(item.reviews)})
                      </span>
                      <ProductCardPrice
                        amount={formatCurrency(item.price)}
                        originalAmount={item.oldPrice ? formatCurrency(item.oldPrice) : undefined}
                      />
                    </ProductCardBody>
                    <ProductCardFooter>
                      <AddToCart fullWidth label="افزودن" itemName={item.title} />
                    </ProductCardFooter>
                  </ProductCard>
                ))}
              </ProductCardGrid>
            )}

            {pageCount > 1 && (
              <Pagination>
                <PaginationContent>
                  <PaginationItem>
                    <PaginationPrevious
                      href="#"
                      aria-disabled={currentPage === 1}
                      onClick={(event) => {
                        event.preventDefault()
                        setPage(Math.max(1, currentPage - 1))
                      }}
                    />
                  </PaginationItem>
                  {Array.from({ length: pageCount }, (_, index) => index + 1).map((number) => (
                    <PaginationItem key={number}>
                      <PaginationLink
                        href="#"
                        isActive={number === currentPage}
                        onClick={(event) => {
                          event.preventDefault()
                          setPage(number)
                        }}
                      >
                        {toFaDigits(number)}
                      </PaginationLink>
                    </PaginationItem>
                  ))}
                  <PaginationItem>
                    <PaginationNext
                      href="#"
                      aria-disabled={currentPage === pageCount}
                      onClick={(event) => {
                        event.preventDefault()
                        setPage(Math.min(pageCount, currentPage + 1))
                      }}
                    />
                  </PaginationItem>
                </PaginationContent>
              </Pagination>
            )}
          </section>
        </div>

        <section aria-labelledby="recommended-title" className="flex flex-col gap-4">
          <h2 id="recommended-title" className="text-xl font-bold">
            پیشنهادهای ما برای شما
          </h2>
          <div className="flex gap-3 overflow-x-auto pb-2">
            {RECOMMENDED.map((item) => (
              <ProductCard key={item.id} size="sm">
                <ProductCardMedia />
                <ProductCardBody>
                  <ProductCardTitle>{item.title}</ProductCardTitle>
                  <ProductCardPrice amount={formatCurrency(item.price)} />
                </ProductCardBody>
              </ProductCard>
            ))}
          </div>
        </section>

        {/* خبرنامه: کارتِ تیره با ورودی و دکمه؛ متغیرهای تم روی خودِ کارت وارونه می‌شوند. */}
        <section
          aria-labelledby="newsletter-title"
          className="flex flex-col gap-6 rounded-xl bg-inverse px-6 py-8 text-[var(--dig-white)] [--background:var(--inverse)] [--border:var(--inverse-border)] [--foreground:var(--dig-white)] [--input:var(--inverse-border)] [--muted-foreground:var(--dig-graphite-300)] [--primary-foreground:var(--dig-graphite-950)] [--primary:var(--dig-white)] sm:flex-row sm:items-center sm:justify-between sm:px-10"
        >
          <div className="flex flex-col gap-2">
            <h2 id="newsletter-title" className="text-2xl font-bold">
              اولین نفر باخبر شو
            </h2>
            <p className="text-sm text-muted-foreground">محصولاتِ تازه و تخفیف‌های ویژه، هفته‌ای یک ایمیل؛ هر وقت خواستی لغوش کن.</p>
          </div>
          <form
            className="flex w-full gap-2 sm:max-w-sm"
            onSubmit={(event) => {
              event.preventDefault()
              toast.success("عضو خبرنامه شدی.")
              event.currentTarget.reset()
            }}
          >
            <Input type="email" dir="ltr" required aria-label="ایمیل" placeholder="name@example.com" className="min-w-0 flex-1" />
            <Button type="submit">عضویت</Button>
          </form>
        </section>
      </main>
    </ControlSizeProvider>
  )
}