# Store Locator with Map, Postcode Search, Open Now and Schema

> Free store locator code: postcode search, distance sort with the haversine formula, open now from opening hours, directions links and LocalBusiness JSON-LD.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/store-locator-collection-with-svg-map-postcode-search-distance-sort-open-now-and-localbusiness-schema).

- Page, with a live preview: https://shadcn.exchange/r/weinc/store-locator-collection-with-svg-map-postcode-search-distance-sort-open-now-and-localbusiness-schema
- Screenshot: https://shadcn.exchange/shot/weinc/store-locator-collection-with-svg-map-postcode-search-distance-sort-open-now-and-localbusiness-schema/light.png
- Registry item: https://we.inc/r/store-locator-collection-with-svg-map-postcode-search-distance-sort-open-now-and-localbusiness-schema.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/store-locator-collection-with-svg-map-postcode-search-distance-sort-open-now-and-localbusiness-schema.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/store-locator-collection-with-svg-map-postcode-search-distance-sort-open-now-and-localbusiness-schema.tsx`

```tsx
import { useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";

/*
  Store locator with no map library and no plugin.
  The map is an inline SVG of a made-up town; stores are placed on it by projecting their latitude and
  longitude into the SVG's box. Everything else (postcode search, haversine distance sort, "open now"
  from opening hours, directions links, JSON-LD) is plain code you can reuse with a real map.
  Directions links are href="#" in this demo. On a real site build them from the store's coordinates:
    Google Maps: www.google.com/maps/dir/?api=1&destination=LAT,LNG   (prefix with the https scheme)
    Apple Maps:  maps.apple.com/?daddr=LAT,LNG
  All names, postcodes and coordinates below are invented.
*/

type Day = "su" | "mo" | "tu" | "we" | "th" | "fr" | "sa";
type Store = {
  id: string;
  name: string;
  street: string;
  town: string;
  postcode: string;
  phone: string;
  lat: number;
  lng: number;
  hours: Record<Day, string>;
};

/* ---------- data: edit this list ---------- */
const WEEKDAY = "08:00-18:00";
const STORES: Store[] = [
  { id: "quay", name: "Fieldstone Hardware, Kestrel Quay", street: "4 Ropewalk Lane", town: "Kestrel Quay", postcode: "KQ1 4RL", phone: "01632 960 114", lat: 52.0412, lng: -1.0388, hours: { mo: WEEKDAY, tu: WEEKDAY, we: WEEKDAY, th: WEEKDAY, fr: "08:00-20:00", sa: "09:00-17:00", su: "10:00-16:00" } },
  { id: "market", name: "Fieldstone Hardware, Market Square", street: "21 Corn Exchange Row", town: "Ashby Centre", postcode: "AC2 1CX", phone: "01632 960 227", lat: 52.0335, lng: -1.0021, hours: { mo: "07:30-19:00", tu: "07:30-19:00", we: "07:30-19:00", th: "07:30-19:00", fr: "07:30-19:00", sa: "08:00-18:00", su: "closed" } },
  { id: "millbrook", name: "Fieldstone Hardware, Millbrook", street: "88 Weir Road", town: "Millbrook", postcode: "MB4 8WR", phone: "01632 960 381", lat: 52.0558, lng: -0.9702, hours: { mo: WEEKDAY, tu: WEEKDAY, we: WEEKDAY, th: WEEKDAY, fr: WEEKDAY, sa: "09:00-16:00", su: "closed" } },
  { id: "rise", name: "Fieldstone Trade Counter, Ashby Rise", street: "Unit 6, Larkspur Park", town: "Ashby Rise", postcode: "AR3 6LP", phone: "01632 960 402", lat: 52.0178, lng: -0.9563, hours: { mo: "06:30-17:00", tu: "06:30-17:00", we: "06:30-17:00", th: "06:30-17:00", fr: "06:30-16:00", sa: "07:00-12:00", su: "closed" } },
  { id: "green", name: "Fieldstone Hardware, Harrow Green", street: "3 Chapel Parade", town: "Harrow Green", postcode: "HG5 3CP", phone: "01632 960 519", lat: 52.0121, lng: -1.0296, hours: { mo: "09:00-17:30", tu: "09:00-17:30", we: "closed", th: "09:00-17:30", fr: "09:00-17:30", sa: "09:00-17:00", su: "closed" } },
  { id: "docks", name: "Fieldstone Garden Centre, Old Docks", street: "Basin Wharf", town: "Old Docks", postcode: "OD7 2BW", phone: "01632 960 640", lat: 52.0467, lng: -1.0119, hours: { mo: "09:00-18:00", tu: "09:00-18:00", we: "09:00-18:00", th: "09:00-21:00", fr: "09:00-18:00", sa: "08:30-18:00", su: "10:00-17:00" } },
];

/* Places a visitor can search for. A real site would call a geocoder here. */
const PLACES: { label: string; keys: string[]; lat: number; lng: number }[] = [
  { label: "Ashby Centre", keys: ["ashby centre", "ac2", "town hall"], lat: 52.0312, lng: -1.0055 },
  { label: "Kestrel Quay", keys: ["kestrel quay", "kq1"], lat: 52.0431, lng: -1.0432 },
  { label: "Millbrook", keys: ["millbrook", "mb4"], lat: 52.0581, lng: -0.9741 },
  { label: "Ashby Rise", keys: ["ashby rise", "ar3"], lat: 52.0159, lng: -0.9601 },
  { label: "Harrow Green", keys: ["harrow green", "hg5"], lat: 52.0102, lng: -1.0331 },
  { label: "Old Docks", keys: ["old docks", "od7"], lat: 52.0489, lng: -1.0151 },
  { label: "Fenwick Hill", keys: ["fenwick hill", "fh6"], lat: 52.0247, lng: -0.9858 },
];

/* ---------- logic (the same functions appear in the copyable code) ---------- */
const DAYS: Day[] = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];

function distanceKm(aLat: number, aLng: number, bLat: number, bLng: number): number {
  const R = 6371;
  const rad = (d: number) => (d * Math.PI) / 180;
  const dLat = rad(bLat - aLat);
  const dLng = rad(bLng - aLng);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(aLat)) * Math.cos(rad(bLat)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h));
}

const toMin = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5));

function openState(store: Store, at: Date): { open: boolean; text: string } {
  const day = at.getDay();
  const now = at.getHours() * 60 + at.getMinutes();
  const today = store.hours[DAYS[day]];
  if (today !== "closed") {
    const [o, c] = today.split("-");
    if (now >= toMin(o) && now < toMin(c)) return { open: true, text: `Open now, closes ${c}` };
    if (now < toMin(o)) return { open: false, text: `Closed, opens ${o} today` };
  }
  for (let i = 1; i <= 7; i++) {
    const next = store.hours[DAYS[(day + i) % 7]];
    if (next !== "closed") return { open: false, text: `Closed, opens ${next.split("-")[0]} ${i === 1 ? "tomorrow" : DAY_NAMES[(day + i) % 7]}` };
  }
  return { open: false, text: "Closed" };
}

const SCHEME = "https:" + "//";
const SCHEMA_DAYS: Record<Day, string> = { mo: "Monday", tu: "Tuesday", we: "Wednesday", th: "Thursday", fr: "Friday", sa: "Saturday", su: "Sunday" };

function jsonLd(s: Store): string {
  const spec = (Object.keys(SCHEMA_DAYS) as Day[])
    .filter((d) => s.hours[d] !== "closed")
    .map((d) => ({ "@type": "OpeningHoursSpecification", dayOfWeek: SCHEMA_DAYS[d], opens: s.hours[d].split("-")[0], closes: s.hours[d].split("-")[1] }));
  const data = {
    "@context": `${SCHEME}schema.org`,
    "@type": "HardwareStore",
    "@id": `${SCHEME}example.com/stores/${s.id}#store`,
    name: s.name,
    telephone: s.phone,
    address: { "@type": "PostalAddress", streetAddress: s.street, addressLocality: s.town, postalCode: s.postcode, addressCountry: "GB" },
    geo: { "@type": "GeoCoordinates", latitude: s.lat, longitude: s.lng },
    openingHoursSpecification: spec,
  };
  return `<script type="application/ld+json">\n${JSON.stringify(data, null, 2)}\n</script>\n`;
}

const VANILLA = `// store-locator.js: no libraries. stores is the JSON array shown in the Data tab.
function distanceKm(aLat, aLng, bLat, bLng) {
  const R = 6371; // mean Earth radius in km (use 3959 for miles)
  const rad = (d) => (d * Math.PI) / 180;
  const dLat = rad(bLat - aLat);
  const dLng = rad(bLng - aLng);
  const h = Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(aLat)) * Math.cos(rad(bLat)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.sqrt(h)); // haversine formula
}

const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const toMin = (t) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5));

function isOpen(store, at = new Date()) {
  // Uses the visitor's clock. If stores span time zones, convert "at" first.
  const spec = store.hours[DAYS[at.getDay()]];
  if (spec === "closed") return false;
  const [open, close] = spec.split("-");
  const now = at.getHours() * 60 + at.getMinutes();
  return now >= toMin(open) && now < toMin(close);
}

function directionsUrl(store) {
  // Opens turn-by-turn directions from wherever the visitor is.
  return "${SCHEME}www.google.com/maps/dir/?api=1&destination=" + store.lat + "," + store.lng;
  // Apple Maps: "${SCHEME}maps.apple.com/?daddr=" + store.lat + "," + store.lng
}

function nearest(stores, origin, openOnly = false) {
  return stores
    .map((s) => ({ ...s, km: distanceKm(origin.lat, origin.lng, s.lat, s.lng) }))
    .filter((s) => !openOnly || isOpen(s))
    .sort((a, b) => a.km - b.km);
}

function render(list, el) {
  el.replaceChildren(...list.map((s) => {
    const li = document.createElement("li");
    const a = document.createElement("a");
    a.href = directionsUrl(s);
    a.textContent = "Directions";
    li.append(s.name + ", " + s.km.toFixed(1) + " km, " + (isOpen(s) ? "open now" : "closed") + " ");
    li.append(a);
    return li;
  }));
}
`;

const REACT = `import { useMemo, useState } from "react";
// distanceKm, isOpen and directionsUrl: paste them from the vanilla tab.

export function useStoreLocator(stores, start) {
  const [origin, setOrigin] = useState(start); // { lat, lng, label }
  const [openOnly, setOpenOnly] = useState(false);
  const [selected, setSelected] = useState(null);

  const results = useMemo(() => stores
    .map((s) => ({ ...s, km: distanceKm(origin.lat, origin.lng, s.lat, s.lng), open: isOpen(s) }))
    .filter((s) => !openOnly || s.open)
    .sort((a, b) => a.km - b.km), [stores, origin, openOnly]);

  return { results, origin, setOrigin, openOnly, setOpenOnly, selected, setSelected };
}

// Map pins and list rows both call setSelected(store.id); both read selected
// to highlight, which keeps the list and the map in step.
`;

/* ---------- SVG map projection ---------- */
const W = 640;
const H = 440;
const BOUNDS = { minLat: 52.004, maxLat: 52.064, minLng: -1.052, maxLng: -0.948 };
const px = (lat: number, lng: number) => ({
  x: ((lng - BOUNDS.minLng) / (BOUNDS.maxLng - BOUNDS.minLng)) * W,
  y: ((BOUNDS.maxLat - lat) / (BOUNDS.maxLat - BOUNDS.minLat)) * H,
});

type Tab = "data" | "jsonld" | "vanilla" | "react";
const TABS: { k: Tab; label: string }[] = [
  { k: "data", label: "stores.json" },
  { k: "jsonld", label: "LocalBusiness JSON-LD" },
  { k: "vanilla", label: "Vanilla JS" },
  { k: "react", label: "React hook" },
];

export default function StoreLocatorCollection() {
  const [query, setQuery] = useState("");
  const [origin, setOrigin] = useState(PLACES[0]);
  const [message, setMessage] = useState(`Showing stores nearest ${PLACES[0].label}.`);
  const [openOnly, setOpenOnly] = useState(false);
  const [selected, setSelected] = useState<string>("market");
  const [tab, setTab] = useState<Tab>("jsonld");
  const [copied, setCopied] = useState(false);
  const [showAll, setShowAll] = useState(false);
  const listRef = useRef<HTMLUListElement | null>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const now = useMemo(() => new Date(), []);

  const results = useMemo(
    () =>
      STORES.map((s) => ({ s, km: distanceKm(origin.lat, origin.lng, s.lat, s.lng), st: openState(s, now) }))
        .filter((r) => !openOnly || r.st.open)
        .sort((a, b) => a.km - b.km),
    [origin, openOnly, now],
  );
  const sel = STORES.find((s) => s.id === selected) ?? STORES[0];

  function search(e: FormEvent) {
    e.preventDefault();
    const q = query.trim().toLowerCase().replace(/\s+/g, " ");
    if (!q) return;
    const hit = PLACES.find((p) => p.keys.some((k) => k.startsWith(q) || q.startsWith(k)));
    if (!hit) {
      setMessage(`No match for "${query}". Try a town such as Millbrook or a postcode start such as KQ1.`);
      return;
    }
    setOrigin(hit);
    const first = STORES.map((s) => ({ s, km: distanceKm(hit.lat, hit.lng, s.lat, s.lng) })).sort((a, b) => a.km - b.km)[0];
    setSelected(first.s.id);
    setMessage(`Showing stores nearest ${hit.label}. Closest is ${first.s.name.split(", ")[1]}, ${first.km.toFixed(1)} km away.`);
  }

  function pick(id: string, fromMap: boolean) {
    setSelected(id);
    if (fromMap && listRef.current) {
      const row = listRef.current.querySelector<HTMLElement>(`[data-id="${id}"]`);
      if (row) listRef.current.scrollTo({ top: row.offsetTop - listRef.current.offsetTop - 8, behavior: "smooth" });
    }
  }

  function pinKey(e: KeyboardEvent<SVGGElement>, id: string) {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      pick(id, true);
    }
  }

  function tabKey(e: KeyboardEvent<HTMLButtonElement>, i: number) {
    const n = e.key === "ArrowRight" ? (i + 1) % TABS.length : e.key === "ArrowLeft" ? (i + TABS.length - 1) % TABS.length : -1;
    if (n < 0) return;
    e.preventDefault();
    setTab(TABS[n].k);
    tabRefs.current[n]?.focus();
  }

  const code =
    tab === "data" ? JSON.stringify(STORES, null, 2) + "\n" : tab === "jsonld" ? jsonLd(sel) : tab === "vanilla" ? VANILLA : REACT;

  async function copy() {
    try {
      await navigator.clipboard.writeText(code);
      setCopied(true);
      setTimeout(() => setCopied(false), 1600);
    } catch {
      setShowAll(true);
      setMessage("Copy was blocked, the full code is shown so you can select it.");
    }
  }

  const o = px(origin.lat, origin.lng);
  const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2";

  return (
    <section className="bg-[#f6f5f1] px-4 py-12 text-stone-900 sm:px-8 sm:py-14" style={{ fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif' }}>
      <style>{`@media (prefers-reduced-motion: reduce) { .sl-pin { transition: none !important; } }`}</style>
      <div className="mx-auto max-w-6xl">
        <header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
          <div className="max-w-2xl">
            <p className="font-mono text-[12px] uppercase tracking-[0.18em] text-stone-600">Store locator · no plugin · no map library</p>
            <h2 className="mt-3 text-[30px] font-semibold leading-[1.08] tracking-[-0.03em] sm:text-[40px]">Find a Fieldstone store</h2>
            <p className="mt-3 text-[15px] leading-relaxed text-stone-600">Search a town or postcode. Results sort by straight-line distance and show which stores are open right now.</p>
          </div>
        </header>

        <form onSubmit={search} className="mt-6 flex flex-col gap-3 rounded-2xl bg-white p-3 ring-1 ring-stone-200 sm:flex-row sm:items-center">
          <label htmlFor="sl-q" className="sr-only">Town or postcode</label>
          <div className="relative flex-1">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone-500">
              <path d="M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4" />
            </svg>
            <input id="sl-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Town or postcode, e.g. Millbrook or KQ1" autoComplete="postal-code" className="h-11 w-full rounded-xl border border-stone-300 pl-9 pr-3 text-[15px] placeholder:text-stone-500 focus:border-emerald-700 focus:outline-none focus:ring-4 focus:ring-emerald-700/15" />
          </div>
          <div className="flex items-center gap-3">
            <label htmlFor="sl-open" className="inline-flex min-h-[44px] cursor-pointer items-center gap-2 text-[14px] text-stone-700">
              <input id="sl-open" type="checkbox" checked={openOnly} onChange={(e) => setOpenOnly(e.target.checked)} className="h-[18px] w-[18px] accent-emerald-700" />
              Open now only
            </label>
            <button type="submit" className={`h-11 rounded-xl bg-emerald-800 px-5 text-[14px] font-semibold text-white hover:bg-emerald-900 ${ring}`}>Search</button>
          </div>
        </form>
        <p className="mt-2 text-[13px] text-stone-600" role="status" aria-live="polite">{message}</p>

        <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)]">
          <ul ref={listRef} className="relative order-2 max-h-[460px] space-y-2 overflow-y-auto pr-1 lg:order-1" aria-label="Stores, nearest first">
            {results.length === 0 && <li className="rounded-xl border border-dashed border-stone-300 p-6 text-[14px] text-stone-600">No store is open at this hour. Clear "Open now only" to see them all.</li>}
            {results.map(({ s, km, st }, i) => {
              const on = s.id === selected;
              return (
                <li key={s.id} data-id={s.id} className={`rounded-xl bg-white p-4 ring-1 transition-shadow ${on ? "ring-2 ring-emerald-700 shadow-[0_6px_18px_-10px_rgba(6,78,59,0.5)]" : "ring-stone-200"}`}>
                  <button type="button" aria-pressed={on} onClick={() => pick(s.id, false)} className={`flex w-full items-start gap-3 rounded-lg text-left ${ring}`}>
                    <span className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full text-[12px] font-bold ${on ? "bg-emerald-800 text-white" : "bg-stone-100 text-stone-700"}`}>{i + 1}</span>
                    <span className="min-w-0 flex-1">
                      <span className="block text-[15px] font-semibold leading-snug">{s.name.split(", ")[1]}</span>
                      <span className="block text-[13px] text-stone-600">{s.street}, {s.postcode}</span>
                      <span className={`mt-1 inline-flex items-center gap-1.5 text-[13px] font-medium ${st.open ? "text-emerald-800" : "text-stone-600"}`}>
                        <span className={`h-2 w-2 rounded-full ${st.open ? "bg-emerald-600" : "bg-stone-400"}`} aria-hidden="true" />
                        {st.text}
                      </span>
                    </span>
                    <span className="shrink-0 font-mono text-[13px] tabular-nums text-stone-700">{km.toFixed(1)} km</span>
                  </button>
                  {on && (
                    <div className="mt-3 flex flex-wrap gap-2 border-t border-stone-100 pt-3">
                      {/* Demo link. Real one: Google Maps dir/?api=1&destination=LAT,LNG or Apple Maps ?daddr=LAT,LNG */}
                      <a href="#" className={`inline-flex min-h-[40px] items-center rounded-lg bg-emerald-800 px-3.5 text-[13px] font-semibold text-white hover:bg-emerald-900 ${ring}`}>Directions</a>
                      <a href="#" className={`inline-flex min-h-[40px] items-center rounded-lg px-3.5 text-[13px] font-semibold text-stone-800 ring-1 ring-inset ring-stone-300 hover:bg-stone-50 ${ring}`}>Call {s.phone}</a>
                    </div>
                  )}
                </li>
              );
            })}
          </ul>

          <figure className="order-1 overflow-hidden rounded-2xl bg-[#e9ebe4] ring-1 ring-stone-200 lg:order-2">
            <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full" role="group" aria-label="Map of stores. Use the pins or the list to choose a store.">
              <rect width={W} height={H} fill="#eceee7" />
              {/* parks */}
              <path d="M410 250 L520 232 L548 300 L470 338 L404 312 Z" fill="#d5e3cc" />
              <path d="M70 330 C110 300 170 312 190 350 C170 392 98 400 70 370 Z" fill="#d5e3cc" />
              <path d="M300 40 L370 34 L380 92 L310 98 Z" fill="#d5e3cc" />
              {/* river and docks */}
              <path d="M-10 128 C80 120 140 168 230 150 S360 92 430 120 S560 190 650 170" fill="none" stroke="#b9d3df" strokeWidth={26} strokeLinecap="round" />
              <rect x={232} y={118} width={40} height={22} rx={3} fill="#b9d3df" />
              {/* minor streets */}
              <g stroke="#fbfbf8" strokeWidth={5} fill="none">
                {[60, 120, 180, 240, 300, 360, 420, 480, 540, 600].map((x) => <path key={`v${x}`} d={`M${x} 170 V${H}`} opacity={0.9} />)}
                {[200, 250, 300, 350, 400].map((y) => <path key={`h${y}`} d={`M0 ${y} H${W}`} />)}
                {[20, 60, 100].map((y) => <path key={`n${y}`} d={`M0 ${y} H${W}`} />)}
              </g>
              {/* main roads */}
              <g stroke="#ffffff" strokeWidth={11} fill="none" strokeLinecap="round">
                <path d="M0 236 C160 228 280 250 400 226 S560 200 650 214" />
                <path d="M330 -10 C320 120 350 260 318 450" />
                <path d="M120 450 C180 330 260 250 330 230" />
              </g>
              <g stroke="#f2c97a" strokeWidth={5} fill="none" strokeLinecap="round">
                <path d="M0 236 C160 228 280 250 400 226 S560 200 650 214" />
              </g>
              {/* labels */}
              <g fill="#6b6f66" fontSize={11} fontWeight={600} letterSpacing="0.08em">
                <text x={30} y={86}>KESTREL QUAY</text>
                <text x={470} y={74}>MILLBROOK</text>
                <text x={250} y={300}>ASHBY CENTRE</text>
                <text x={470} y={372}>ASHBY RISE</text>
                <text x={92} y={420}>HARROW GREEN</text>
                <text x={282} y={98}>OLD DOCKS</text>
              </g>
              <text x={500} y={150} fill="#6f8f9c" fontSize={11} fontStyle="italic">River Lune</text>
              {/* search origin */}
              <g aria-hidden="true">
                <circle cx={o.x} cy={o.y} r={16} fill="#2563eb" opacity={0.15} />
                <circle cx={o.x} cy={o.y} r={6} fill="#2563eb" stroke="#fff" strokeWidth={2.5} />
              </g>
              {/* pins */}
              {results.map(({ s }, i) => {
                const p = px(s.lat, s.lng);
                const on = s.id === selected;
                return (
                  <g
                    key={s.id}
                    role="button"
                    tabIndex={0}
                    aria-label={`${s.name}, result ${i + 1}`}
                    aria-pressed={on}
                    onClick={() => pick(s.id, true)}
                    onKeyDown={(e) => pinKey(e, s.id)}
                    className="sl-pin cursor-pointer outline-none [&:focus-visible>circle.sl-focus]:opacity-100"
                    style={{ transform: `translate(${p.x}px, ${p.y}px)` }}
                  >
                    <circle className="sl-focus" r={24} fill="none" stroke="#047857" strokeWidth={2.5} opacity={0} />
                    <ellipse cx={0} cy={2} rx={7} ry={2.5} fill="#000" opacity={0.18} />
                    <path d="M0 0 C-3 -8 -13 -14 -13 -24 A13 13 0 1 1 13 -24 C13 -14 3 -8 0 0 Z" fill={on ? "#065f46" : "#ffffff"} stroke={on ? "#022c22" : "#57534e"} strokeWidth={1.5} />
                    <text y={-20} textAnchor="middle" fontSize={12} fontWeight={700} fill={on ? "#ffffff" : "#1c1917"}>{i + 1}</text>
                  </g>
                );
              })}
            </svg>
            <figcaption className="flex flex-wrap items-center justify-between gap-2 border-t border-stone-200 bg-white px-4 py-2.5 text-[12.5px] text-stone-600">
              <span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-full bg-blue-600" aria-hidden="true" />Searched from {origin.label}</span>
              <span>Illustrated map of an invented town, drawn in SVG</span>
            </figcaption>
          </figure>
        </div>

        <div className="mt-6 rounded-2xl bg-white ring-1 ring-stone-200">
          <div className="flex flex-wrap items-center justify-between gap-2 border-b border-stone-200 px-3 pt-3">
            <div role="tablist" aria-label="Code" className="flex gap-1 overflow-x-auto">
              {TABS.map((t, i) => (
                <button
                  key={t.k}
                  ref={(el) => { tabRefs.current[i] = el; }}
                  type="button"
                  role="tab"
                  id={`sl-tab-${t.k}`}
                  aria-selected={tab === t.k}
                  aria-controls="sl-code"
                  tabIndex={tab === t.k ? 0 : -1}
                  onClick={() => setTab(t.k)}
                  onKeyDown={(e) => tabKey(e, i)}
                  className={`min-h-[40px] shrink-0 border-b-2 px-3 text-[13px] font-medium ${ring} ${tab === t.k ? "border-emerald-700 text-stone-900" : "border-transparent text-stone-600 hover:text-stone-900"}`}
                >
                  {t.label}
                </button>
              ))}
            </div>
            <div className="mb-2 flex gap-2">
              <button type="button" aria-expanded={showAll} aria-controls="sl-code" onClick={() => setShowAll(!showAll)} className={`min-h-[40px] rounded-lg px-3 text-[13px] font-medium text-stone-700 ring-1 ring-inset ring-stone-300 hover:bg-stone-50 ${ring}`}>{showAll ? "Shorter" : "Show all"}</button>
              <button type="button" onClick={copy} className={`min-h-[40px] rounded-lg px-3 text-[13px] font-semibold text-white ${ring} ${copied ? "bg-emerald-700" : "bg-stone-900 hover:bg-stone-700"}`}>{copied ? "Copied" : "Copy"}</button>
            </div>
          </div>
          <div role="tabpanel" id="sl-code" aria-labelledby={`sl-tab-${tab}`} className="p-3">
            {tab === "jsonld" && <p className="mb-2 px-1 text-[13px] text-stone-600">Structured data for the selected store, {sel.name.split(", ")[1]}. Put one block on each store's own page.</p>}
            <pre tabIndex={0} className={`overflow-auto rounded-xl bg-[#14161a] p-4 font-mono text-[11.5px] leading-[1.6] text-stone-100 ${showAll ? "max-h-[640px]" : "max-h-[260px]"}`}>
              <code>{code}</code>
            </pre>
          </div>
        </div>
      </div>
    </section>
  );
}
```
