نتایجِ جستوجو
پیشنمایش تمامصفحهصفحهٔ نتایج: جستوجو با تعدادِ نتیجه، زبانههای نوع با شمار، نتایجِ ترکیبی (کالا، مقاله، راهنما) با برجستهشدنِ واژه، جستوجوهای مرتبط و حالتِ بینتیجه.
۱. نصب کامپوننتها
این بلاک از ۷ آیتم دیگ استفاده میکند: buttoncontrol-sizeempty-stateiconspersiansearch-fieldtoggle-group
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/button.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/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/button.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/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/button.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/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/button.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/persian.json https://docs.digdesign.ir/r/search-field.json https://docs.digdesign.ir/r/toggle-group.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
search-results.tsx
"use client"
import * as React from "react"
import { ChevronLeftIcon, FileTextIcon, GuideIcon, ShoppingCartIcon } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { EmptyState, EmptyStateActions, EmptyStateDescription, EmptyStateTitle } from "@/components/ui/empty-state"
import { SearchField } from "@/components/ui/search-field"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { formatCurrency, toFaDigits, toSearchKey } from "@/lib/persian"
import { stockImages, type StockImageName } from "@/lib/stock-images"
type Result =
| { type: "product"; title: string; text: string; price: number; image: StockImageName }
| { type: "article"; title: string; text: string; minutes: number; image: StockImageName }
| { type: "help"; title: string; text: string; path: string }
const RESULTS: Result[] = [
{ type: "product", title: "گلدانِ سرامیکی دوقلو", text: "دستسازِ لالجین، لعابِ مات، ارتفاعِ ۳۲ سانتیمتر.", price: 1_290_000, image: "productVase" },
{ type: "article", title: "نگهداری گلدانِ سرامیکی در زمستان", text: "سه اشتباهِ رایج که باعثِ ترکخوردنِ گلدان میشود.", minutes: 5, image: "coverMinimalRoom" },
{ type: "help", title: "مرجوعی گلدانِ آسیبدیده", text: "اگر گلدان شکسته رسید، تا ۷ روز با عکس درخواست بده.", path: "راهنما › سفارش و ارسال" },
{ type: "product", title: "گلدانِ سنگی کوچک", text: "مناسبِ گیاهِ آپارتمانی و میزِ کار.", price: 640_000, image: "coverNotebook" },
{ type: "help", title: "بستهبندی کالاهای شکستنی", text: "همهٔ سفالها با بستهبندی ضدضربه ارسال میشوند.", path: "راهنما › ارسال" },
]
const TABS = [
{ value: "all", label: "همه" },
{ value: "product", label: "کالا" },
{ value: "article", label: "مقاله" },
{ value: "help", label: "راهنما" },
] as const
const ICON = { product: <ShoppingCartIcon />, article: <FileTextIcon />, help: <GuideIcon /> }
/** واژهٔ جستوجو را در متن برجسته میکند (با یکسانسازی ی/ک عربی و فارسی). */
function Highlight({ text, query }: { text: string; query: string }) {
const key = toSearchKey(query.trim())
if (!key) return <>{text}</>
// کلیدِ جستوجو فاصله و نیمفاصله را حذف میکند؛ برای هر نویسهٔ کلید جایش در متنِ اصلی را
// نگه میداریم تا هایلایت روی همان حروف بنشیند، نه چند حرف جلوتر.
const positions: number[] = []
let normalized = ""
for (let i = 0; i < text.length; i++) {
const piece = toSearchKey(text[i])
for (const char of piece) {
normalized += char
positions.push(i)
}
}
const index = normalized.indexOf(key)
if (index < 0) return <>{text}</>
const start = positions[index]
const end = positions[index + key.length - 1] + 1
return (
<>
{text.slice(0, start)}
<mark className="rounded-sm bg-[var(--dig-amber-100)] text-foreground dark:bg-[color-mix(in_oklab,var(--dig-amber-400)_28%,var(--card))]">{text.slice(start, end)}</mark>
{text.slice(end)}
</>
)
}
/**
* نتایجِ جستوجو: نوارِ جستوجو با تعدادِ نتیجه، زبانههای نوع با شمار، نتایجِ ترکیبی
* (کالا با قیمت، مقاله با زمانِ خواندن، راهنما با مسیر) با برجستهکردنِ واژه، جستوجوهای
* مرتبط و حالتِ بینتیجه با پیشنهاد.
*/
export default function SearchResultsBlock() {
const [query, setQuery] = React.useState("گلدان")
const [tab, setTab] = React.useState<(typeof TABS)[number]["value"]>("all")
const matches = RESULTS.filter((result) => toSearchKey(result.title + result.text).includes(toSearchKey(query.trim())))
const visible = matches.filter((result) => tab === "all" || result.type === tab)
const count = (value: string) => (value === "all" ? matches.length : matches.filter((result) => result.type === value).length)
return (
<ControlSizeProvider size="xl" asChild>
<main className="mx-auto flex w-full max-w-4xl flex-col gap-8 p-4 sm:p-6">
<h1 className="sr-only">نتایجِ جستوجو</h1>
<div className="flex flex-col gap-3">
<SearchField aria-label="جستوجو" value={query} onValueChange={setQuery} placeholder="جستوجوی کالا، مقاله یا راهنما" />
<p className="text-sm text-muted-foreground">
{toFaDigits(matches.length)} نتیجه برای «<span className="font-medium text-foreground">{query}</span>»
</p>
</div>
<ToggleGroup type="single" variant="segmented" value={tab} onValueChange={(value) => value && setTab(value as typeof tab)} aria-label="نوعِ نتیجه" className="self-start">
{TABS.map((item) => (
<ToggleGroupItem key={item.value} value={item.value} className="gap-2">
{item.label}
<span className="text-xs text-muted-foreground tabular-nums">{toFaDigits(count(item.value))}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
{visible.length === 0 ? (
<EmptyState variant="bordered" className="rounded-xl py-12">
<EmptyStateTitle>نتیجهای پیدا نشد</EmptyStateTitle>
<EmptyStateDescription>کلمهٔ کوتاهتر یا هممعنی امتحان کن؛ مثلاً «سفال» بهجای «سفالینه».</EmptyStateDescription>
<EmptyStateActions>
<Button variant="outline" onClick={() => setQuery("گلدان")}>
جستوجوی «گلدان»
</Button>
</EmptyStateActions>
</EmptyState>
) : (
<ul className="flex flex-col divide-y divide-border">
{visible.map((result) => (
<li key={result.title}>
<a href="#" className="group flex items-start gap-4 py-5 first:pt-0">
{"image" in result ? (
// eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ
<img src={stockImages[result.image].src} alt="" className="size-20 shrink-0 rounded-lg object-cover" />
) : (
<span aria-hidden className="flex size-20 shrink-0 items-center justify-center rounded-lg bg-subtle text-brand-secondary [&>svg]:size-6">
{ICON[result.type]}
</span>
)}
<span className="flex flex-1 flex-col gap-1.5">
<span className="flex items-center gap-2 text-xs text-muted-foreground [&>svg]:size-3.5">
{ICON[result.type]}
{result.type === "product" ? "کالا" : result.type === "article" ? `مقاله · ${toFaDigits(result.minutes)} دقیقه` : result.path}
</span>
<span className="text-lg font-bold group-hover:underline">
<Highlight text={result.title} query={query} />
</span>
<span className="leading-7 text-muted-foreground">
<Highlight text={result.text} query={query} />
</span>
{result.type === "product" && <span className="font-bold tabular-nums">{formatCurrency(result.price)}</span>}
</span>
<ChevronLeftIcon aria-hidden className="mt-2 size-5 shrink-0 text-muted-foreground ltr:rotate-180" />
</a>
</li>
))}
</ul>
)}
<section aria-labelledby="related-searches" className="flex flex-col gap-3 rounded-xl bg-subtle p-5">
<h2 id="related-searches" className="text-sm font-semibold">
جستوجوهای مرتبط
</h2>
<div className="flex flex-wrap gap-2">
{["گلدانِ بزرگ", "سفالِ لالجین", "گلدانِ آویز", "کودِ گیاه"].map((item) => (
<Button key={item} variant="outline" onClick={() => setQuery(item)} className="bg-card">
{item}
</Button>
))}
</div>
</section>
</main>
</ControlSizeProvider>
)
}