Mapcn
blockstore-locatorsource: ok · hit
Store Locator
Searchable store list synced with map markers.
npx shadcn add "https://mapcn.dev/r/store-locator.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
@mapcn/mapinputsidebar
Files4 · source cached 10/8/2026, 1:43:42 PM
src/registry/blocks/store-locator/page.tsx
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { StoreList } from "./components/store-list";
import { LocatorMap } from "./components/locator-map";
import { MAP_CENTER, stores } from "./data";
export default function Page() {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(stores[0].id);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return stores;
return stores.filter(
(store) =>
store.name.toLowerCase().includes(q) ||
store.address.toLowerCase().includes(q) ||
store.neighborhood.toLowerCase().includes(q),
);
}, [query]);
return (
<SidebarProvider style={{ "--sidebar-width": "20rem" } as CSSProperties}>
<StoreList
stores={filtered}
query={query}
onQueryChange={setQuery}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<SidebarInset>
<LocatorMap
stores={filtered}
selectedId={selectedId}
onSelect={setSelectedId}
onClearSelection={() => setSelectedId(null)}
center={MAP_CENTER}
/>
</SidebarInset>
</SidebarProvider>
);
}
src/registry/blocks/store-locator/data.ts
export interface Store {
id: string;
name: string;
address: string;
neighborhood: string;
phone: string;
hours: string;
openNow: boolean;
lat: number;
lng: number;
}
export const MAP_CENTER: [number, number] = [-122.4194, 37.7749];
export const stores: Store[] = [
{
id: "ferry-building",
name: "Ferry Building",
address: "1 Ferry Building",
neighborhood: "Embarcadero",
phone: "(415) 555-0142",
hours: "7:00 AM – 8:00 PM",
openNow: true,
lat: 37.7955,
lng: -122.3933,
},
{
id: "union-square",
name: "Union Square",
address: "333 Post St",
neighborhood: "Downtown",
phone: "(415) 555-0188",
hours: "6:30 AM – 9:00 PM",
openNow: true,
lat: 37.788,
lng: -122.4075,
},
{
id: "north-beach",
name: "North Beach",
address: "601 Columbus Ave",
neighborhood: "North Beach",
phone: "(415) 555-0119",
hours: "7:00 AM – 7:00 PM",
openNow: true,
lat: 37.8003,
lng: -122.4097,
},
{
id: "hayes-valley",
name: "Hayes Valley",
address: "450 Hayes St",
neighborhood: "Hayes Valley",
phone: "(415) 555-0163",
hours: "7:30 AM – 6:00 PM",
openNow: false,
lat: 37.7765,
lng: -122.4256,
},
{
id: "mission",
name: "Mission District",
address: "2901 Mission St",
neighborhood: "Mission",
phone: "(415) 555-0177",
hours: "6:00 AM – 8:00 PM",
openNow: true,
lat: 37.7509,
lng: -122.4181,
},
{
id: "marina",
name: "Marina",
address: "2156 Chestnut St",
neighborhood: "Marina",
phone: "(415) 555-0125",
hours: "7:00 AM – 7:00 PM",
openNow: false,
lat: 37.8003,
lng: -122.4366,
},
{
id: "castro",
name: "Castro",
address: "489 Castro St",
neighborhood: "Castro",
phone: "(415) 555-0151",
hours: "7:00 AM – 8:00 PM",
openNow: true,
lat: 37.7609,
lng: -122.435,
},
{
id: "soma",
name: "SoMa",
address: "680 Folsom St",
neighborhood: "SoMa",
phone: "(415) 555-0134",
hours: "6:30 AM – 6:30 PM",
openNow: true,
lat: 37.7852,
lng: -122.3982,
},
];
src/registry/blocks/store-locator/components/store-list.tsx
"use client";
import { useEffect, useRef } from "react";
import { Clock, MapPin, Phone, Search } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from "@/components/ui/sidebar";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { Store } from "../data";
interface StoreListProps {
stores: Store[];
query: string;
onQueryChange: (value: string) => void;
selectedId: string | null;
onSelect: (id: string) => void;
}
export function StoreList({
stores,
query,
onQueryChange,
selectedId,
onSelect,
}: StoreListProps) {
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
useEffect(() => {
if (!selectedId) return;
itemRefs.current
.get(selectedId)
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedId]);
return (
<Sidebar collapsible="offcanvas">
<SidebarHeader className="gap-3 p-4">
<div>
<h2 className="text-foreground text-lg font-semibold tracking-tight">
Find a store
</h2>
<p className="text-muted-foreground text-sm">
{stores.length} {stores.length === 1 ? "location" : "locations"}{" "}
near you
</p>
</div>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search name or address"
className="bg-background pl-8"
aria-label="Search stores"
/>
</div>
</SidebarHeader>
<SidebarSeparator className="mx-0" />
<SidebarContent>
{stores.length === 0 ? (
<div className="text-muted-foreground p-6 text-center text-sm">
No stores match your search.
</div>
) : (
<SidebarGroup className="p-2">
<SidebarMenu className="gap-1">
{stores.map((store) => {
const active = store.id === selectedId;
return (
<SidebarMenuItem key={store.id}>
<SidebarMenuButton
asChild
isActive={active}
className="h-auto flex-col items-stretch gap-0 p-3"
>
<button
type="button"
ref={(el) => {
if (el) itemRefs.current.set(store.id, el);
else itemRefs.current.delete(store.id);
}}
onClick={() => onSelect(store.id)}
aria-current={active}
>
<div className="flex items-center justify-between gap-2">
<span className="text-foreground font-medium">
{store.name}
</span>
<span
className={cn(
"flex items-center gap-1.5 text-xs font-medium",
store.openNow
? "text-foreground"
: "text-muted-foreground",
)}
>
<span
className={cn(
"size-1.5 rounded-full",
store.openNow
? "bg-emerald-500"
: "bg-neutral-500",
)}
/>
{store.openNow ? "Open" : "Closed"}
</span>
</div>
<div className="text-muted-foreground mt-2 space-y-1.5 text-xs font-normal tabular-nums">
<p className="flex items-center gap-1.5">
<MapPin className="size-3.5 shrink-0" />
{store.address}, {store.neighborhood}
</p>
<p className="flex items-center gap-1.5">
<Clock className="size-3.5 shrink-0" />
{store.hours}
</p>
<p className="flex items-center gap-1.5">
<Phone className="size-3.5 shrink-0" />
{store.phone}
</p>
</div>
</button>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
)}
</SidebarContent>
</Sidebar>
);
}
src/registry/blocks/store-locator/components/locator-map.tsx
"use client";
import { useEffect } from "react";
import { Clock, MapPin, Phone } from "lucide-react";
import {
Map,
MapControls,
MapMarker,
MapPopup,
MarkerContent,
MarkerTooltip,
useMap,
} from "@/components/ui/map";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import type { Store } from "../data";
interface LocatorMapProps {
stores: Store[];
selectedId: string | null;
onSelect: (id: string) => void;
onClearSelection: () => void;
center: [number, number];
}
function FlyToSelected({ store }: { store?: Store }) {
const { map } = useMap();
useEffect(() => {
if (!map || !store) return;
map.flyTo({
center: [store.lng, store.lat],
zoom: 14,
duration: 800,
essential: true,
});
}, [map, store]);
return null;
}
function StorePin({ active }: { active: boolean }) {
return (
<div
className={cn(
"text-background flex items-center justify-center rounded-full shadow-md transition-all",
active
? "bg-foreground/80 size-9 scale-110"
: "bg-foreground hover:bg-muted-foreground size-7",
)}
>
<MapPin className={cn(active ? "size-4.5" : "size-3.5")} />
</div>
);
}
export function LocatorMap({
stores,
selectedId,
onSelect,
onClearSelection,
center,
}: LocatorMapProps) {
const selected = stores.find((store) => store.id === selectedId);
return (
<div className="relative h-full">
<SidebarTrigger className="bg-background absolute top-3 left-3 z-10 border shadow-sm md:hidden" />
<Map center={center} zoom={12} minZoom={10} maxZoom={17}>
<MapControls showFullscreen showCompass />
<FlyToSelected store={selected} />
{stores.map((store) => (
<MapMarker
key={store.id}
longitude={store.lng}
latitude={store.lat}
onClick={() => onSelect(store.id)}
>
<MarkerContent>
<StorePin active={store.id === selectedId} />
</MarkerContent>
<MarkerTooltip
offset={24}
className="bg-foreground text-background"
>
{store.name}
</MarkerTooltip>
</MapMarker>
))}
{selected && (
<MapPopup
longitude={selected.lng}
latitude={selected.lat}
offset={26}
closeButton
closeOnClick={false}
onClose={onClearSelection}
className="min-w-56"
focusAfterOpen={false}
>
<p className="text-popover-foreground pr-5 font-medium">
{selected.name}
</p>
<span
className={cn(
"mt-1 flex items-center gap-1.5 text-xs font-medium",
selected.openNow ? "text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"size-1.5 rounded-full",
selected.openNow ? "bg-emerald-500" : "bg-neutral-500",
)}
/>
{selected.openNow ? "Open now" : "Closed"}
</span>
<div className="text-muted-foreground mt-2.5 space-y-1.5 text-xs tabular-nums">
<p className="flex items-center gap-1.5">
<MapPin className="size-3.5 shrink-0" />
{selected.address}, {selected.neighborhood}
</p>
<p className="flex items-center gap-1.5">
<Clock className="size-3.5 shrink-0" />
{selected.hours}
</p>
<p className="flex items-center gap-1.5">
<Phone className="size-3.5 shrink-0" />
{selected.phone}
</p>
</div>
</MapPopup>
)}
</Map>
</div>
);
}