بلاک‌ها · پشتیبانی و محتوا

نتایجِ جست‌وجو

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

صفحهٔ نتایج: جست‌وجو با تعدادِ نتیجه، زبانه‌های نوع با شمار، نتایجِ ترکیبی (کالا، مقاله، راهنما) با برجسته‌شدنِ واژه، جست‌وجوهای مرتبط و حالتِ بی‌نتیجه.

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

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