فهرست محصولات
پیشنمایش تمامصفحهکارتهای محصول با فیلتر دسته، بازهٔ قیمت و امتیاز، فیلترهای فعال و مرتبسازی.
۱. نصب کامپوننتها
این بلاک از ۲۰ آیتم دیگ استفاده میکند: 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.jsonpnpm 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.jsonyarn 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.jsonbunx --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>
)
}