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