بلاک‌ها · رزرو و خدمات

یافتنِ شعبه: جست‌وجو و فیلترِ «الان باز»، فهرستِ شعبه‌ها با فاصله، ساعت، وضعیت و تماس، و نقشه با نشانگرهایی که با فهرست هماهنگ‌اند.

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

این بلاک از ۸ آیتم دیگ استفاده می‌کند: badgebuttoncontrol-sizeempty-stateiconspersiansearch-fieldtoggle-group

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add 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/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/badge.json 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/badge.json 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/badge.json 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 بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

store-locator.tsx
"use client";

import * as React from "react";

import { ClockIcon, PhoneIcon } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ControlSizeProvider } from "@/components/ui/control-size";
import {
  EmptyState,
  EmptyStateDescription,
  EmptyStateTitle,
} from "@/components/ui/empty-state";
import { SearchField } from "@/components/ui/search-field";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { toFaDigits, toSearchKey } from "@/lib/persian";

const BRANCHES = [
  {
    id: "1",
    name: "شعبهٔ ونک",
    address: "تهران، میدان ونک، خیابان ملاصدرا، پلاک ۴",
    hours: "۹ تا ۲۱",
    open: true,
    distance: 1.2,
    phone: "02188001234",
    x: 58,
    y: 34,
  },
  {
    id: "2",
    name: "شعبهٔ تجریش",
    address: "تهران، میدان تجریش، بازارچهٔ قدس",
    hours: "۱۰ تا ۲۲",
    open: true,
    distance: 4.8,
    phone: "02122004567",
    x: 72,
    y: 16,
  },
  {
    id: "3",
    name: "شعبهٔ ولیعصر",
    address: "تهران، خیابان ولیعصر، بالاتر از پارک ساعی",
    hours: "۹ تا ۱۸",
    open: false,
    distance: 3.1,
    phone: "02188707070",
    x: 46,
    y: 52,
  },
  {
    id: "4",
    name: "شعبهٔ صادقیه",
    address: "تهران، فلکهٔ دوم صادقیه، مجتمعِ نگین",
    hours: "۱۰ تا ۲۱",
    open: true,
    distance: 9.4,
    phone: "02144001122",
    x: 20,
    y: 44,
  },
];

/**
 * شعبه‌یاب: جست‌وجو و فیلترِ «فقط باز» با سگمنت، فهرستِ شعبه‌ها با فاصله، ساعت و وضعیت،
 * و نقشه با نشانگرِ هر شعبه که با فهرست هماهنگ است (انتخاب در هر کدام، دیگری را برجسته می‌کند).
 */
export default function StoreLocatorBlock() {
  const [query, setQuery] = React.useState("");
  const [filter, setFilter] = React.useState("all");
  const [active, setActive] = React.useState("1");
  const visible = BRANCHES.filter(
    (branch) =>
      (filter === "all" || branch.open) &&
      toSearchKey(branch.name + branch.address).includes(toSearchKey(query)),
  );

  return (
    <ControlSizeProvider size="xl" asChild>
      <main className="mx-auto flex w-full max-w-6xl 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-muted-foreground">
            نزدیک‌ترین شعبه را پیدا کن و قبل از رفتن ساعتِ کارش را ببین.
          </p>
        </header>
        <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[24rem_minmax(0,1fr)]">
          <div className="flex flex-col gap-4">
            <SearchField
              aria-label="جست‌وجوی شعبه"
              placeholder="محله یا خیابان"
              value={query}
              onValueChange={setQuery}
            />
            <ToggleGroup
              type="single"
              variant="segmented"
              value={filter}
              onValueChange={(value) => value && setFilter(value as string)}
              aria-label="فیلترِ شعبه‌ها"
              className="w-full"
            >
              <ToggleGroupItem value="all" className="flex-1">
                همه
              </ToggleGroupItem>
              <ToggleGroupItem value="open" className="flex-1">
                الان باز
              </ToggleGroupItem>
            </ToggleGroup>
            {visible.length === 0 ? (
              <EmptyState className="rounded-xl bg-subtle py-10">
                <EmptyStateTitle>شعبه‌ای پیدا نشد</EmptyStateTitle>
                <EmptyStateDescription>
                  نامِ محله را کوتاه‌تر بنویس یا فیلتر را روی «همه» بگذار.
                </EmptyStateDescription>
              </EmptyState>
            ) : (
              <ControlSizeProvider size="lg" asChild>
                <ul className="flex flex-col gap-2">
                  {visible.map((branch) => (
                    <li key={branch.id}>
                      <div
                        className={`flex flex-col gap-2 rounded-xl border p-3.5 transition-colors ${active === branch.id ? "border-primary bg-primary-subtle" : "border-border bg-card"}`}
                      >
                        <Button
                          variant="ghost"
                          onClick={() => setActive(branch.id)}
                          aria-pressed={active === branch.id}
                          className="h-auto w-full flex-col items-start gap-1 p-0 text-start font-normal whitespace-normal hover:bg-[inherit]"
                        >
                          <span className="flex w-full items-center justify-between gap-2">
                            <span className="font-semibold">{branch.name}</span>
                            <span className="text-sm text-muted-foreground tabular-nums">
                              {toFaDigits(
                                String(branch.distance).replace(".", "٫"),
                              )}{" "}
                              کیلومتر
                            </span>
                          </span>
                          <span className="text-sm leading-6 text-muted-foreground">
                            {branch.address}
                          </span>
                        </Button>
                        <div className="flex flex-wrap items-center justify-between gap-2">
                          <span className="flex items-center gap-2 text-sm">
                            <ClockIcon
                              aria-hidden
                              className="size-4 text-muted-foreground"
                            />
                            {branch.hours}
                            <Badge
                              variant="soft"
                              color={branch.open ? "success" : "default"}
                            >
                              {branch.open ? "باز" : "بسته"}
                            </Badge>
                          </span>
                          <Button variant="ghost" asChild>
                            <a href={`tel:${branch.phone}`}>
                              <PhoneIcon />
                              تماس
                            </a>
                          </Button>
                        </div>
                      </div>
                    </li>
                  ))}
                </ul>
              </ControlSizeProvider>
            )}
          </div>
          {/* در موبایل نقشه بالای فهرست و کوتاه‌تر است؛ در دارک کمی تیره‌تر تا روی صفحهٔ تیره نتابد. */}
          <div className="relative order-first overflow-hidden rounded-xl border border-border lg:sticky lg:top-6 lg:order-none">
            {/* eslint-disable-next-line @next/next/no-img-element -- نقشهٔ تزئینی؛ در محصول نقشهٔ واقعی می‌آید */}
            <img
              src="/blocks/contact-map.svg"
              alt="نقشهٔ شعبه‌ها"
              className="aspect-[16/9] w-full object-cover dark:brightness-[0.8] lg:aspect-[4/3]"
            />
            {visible.map((branch) => (
              <Button
                key={branch.id}
                size="icon"
                aria-label={branch.name}
                aria-pressed={active === branch.id}
                onClick={() => setActive(branch.id)}
                className={`absolute -translate-x-1/2 -translate-y-1/2 rounded-full ring-4 ring-card ${active === branch.id ? "scale-125 bg-[var(--dig-orange-500)] text-[var(--dig-white)]" : "bg-foreground text-background"}`}
                style={{ left: `${branch.x}%`, top: `${branch.y}%` }}
              >
                {toFaDigits(branch.id)}
              </Button>
            ))}
          </div>
        </div>
      </main>
    </ControlSizeProvider>
  );
}