Web InnoventixFreeCode

Map Store Locator

Original · free

store locator list plus map

byWeb InnoventixReact + Tailwind
mapstorelocatormaps
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/map-store-locator.json
map-store-locator.tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactElement,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type FeatureKey = "roastery" | "parking" | "stepFree" | "outdoor";

type DayHours = [number, number] | null;

type Store = {
  id: string;
  name: string;
  area: string;
  address: string;
  postcode: string;
  phone: string;
  miles: number;
  x: number;
  y: number;
  features: FeatureKey[];
  hours: [DayHours, DayHours, DayHours, DayHours, DayHours, DayHours, DayHours];
  note: string;
};

type SortKey = "nearest" | "name" | "open";

const MAP_W = 100;
const MAP_H = 80;
const ZOOM = 1.6;

const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;

const WEEKDAY: DayHours = [420, 1080];
const SAT: DayHours = [480, 1080];
const SUN: DayHours = [540, 960];

const STORES: Store[] = [
  {
    id: "ancoats",
    name: "Northline Ancoats Roastery",
    area: "Ancoats",
    address: "Unit 4, Cutting Room Square, Blossom Street",
    postcode: "M4 6BF",
    phone: "0161 232 4180",
    miles: 0.6,
    x: 62,
    y: 29,
    features: ["roastery", "stepFree", "outdoor"],
    hours: [SUN, [420, 1140], [420, 1140], [420, 1140], [420, 1140], [420, 1140], SAT],
    note: "The production floor is glazed end to end — you can watch the 25kg Loring run from the counter. Free cupping table every Saturday at 11am, first come first served.",
  },
  {
    id: "northern-quarter",
    name: "Northline Edge Street",
    area: "Northern Quarter",
    address: "18 Edge Street",
    postcode: "M4 1HN",
    phone: "0161 232 4181",
    miles: 0.4,
    x: 47,
    y: 31,
    features: ["stepFree", "outdoor"],
    hours: [SUN, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, [420, 1140], SAT],
    note: "Our smallest bar and the busiest between 8am and 9.30am. Two seats at the window, six on the pavement, and a filter batch brewed every 20 minutes.",
  },
  {
    id: "deansgate",
    name: "Northline St John's",
    area: "Deansgate",
    address: "St John's, 2 Water Street",
    postcode: "M3 4JQ",
    phone: "0161 232 4182",
    miles: 0.9,
    x: 38,
    y: 43,
    features: ["stepFree", "parking", "outdoor"],
    hours: [SUN, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, [420, 1140], SAT],
    note: "Ground floor of the old goods yard. Long benches, plenty of sockets, and a 90-minute laptop limit after midday on weekends.",
  },
  {
    id: "salford-quays",
    name: "Northline Dock Office",
    area: "Salford Quays",
    address: "The Dock Office, Trafford Road",
    postcode: "M50 3XB",
    phone: "0161 232 4183",
    miles: 2.8,
    x: 16,
    y: 35,
    features: ["parking", "stepFree"],
    hours: [null, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, WEEKDAY, [480, 960]],
    note: "Closed Sundays. Ninety free parking spaces behind the building, validated at the till for any order over £4.",
  },
  {
    id: "chorlton",
    name: "Northline Beech Road",
    area: "Chorlton",
    address: "72 Beech Road",
    postcode: "M21 9EG",
    phone: "0161 232 4184",
    miles: 3.6,
    x: 25,
    y: 61,
    features: ["outdoor", "stepFree"],
    hours: [[480, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1080], [480, 1080]],
    note: "Nine tables in the back garden, heated until the end of October. Dogs welcome inside and out; water bowls by the door.",
  },
  {
    id: "didsbury",
    name: "Northline Old Bank",
    area: "Didsbury",
    address: "The Old Bank, 726 Wilmslow Road",
    postcode: "M20 2DW",
    phone: "0161 232 4185",
    miles: 5.2,
    x: 40,
    y: 71,
    features: ["parking", "outdoor"],
    hours: [[540, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1020], [450, 1020], [480, 1080]],
    note: "A 1904 banking hall with the vault door still in place. Step-free entry is via the side lane on Warburton Street — ring the bell and we will open it.",
  },
  {
    id: "stockport",
    name: "Northline Produce Hall",
    area: "Stockport",
    address: "Produce Hall, Market Place",
    postcode: "SK1 1EU",
    phone: "0161 232 4186",
    miles: 6.1,
    x: 82,
    y: 65,
    features: ["roastery", "stepFree", "parking"],
    hours: [[600, 960], null, [540, 1020], [540, 1020], [540, 1020], [540, 1080], [480, 1080]],
    note: "Closed Mondays. Our sample roaster lives here — Wednesday evenings are open to anyone who wants to learn to roast on 200g batches.",
  },
];

const FEATURE_LABEL: Record<FeatureKey, string> = {
  roastery: "Roastery tours",
  parking: "Free parking",
  stepFree: "Step-free access",
  outdoor: "Outdoor seating",
};

const BLOCKS: { x: number; y: number; w: number; h: number }[] = [
  { x: 5, y: 4, w: 10, h: 8 },
  { x: 20, y: 5, w: 12, h: 7 },
  { x: 37, y: 3, w: 10, h: 9 },
  { x: 52, y: 5, w: 9, h: 7 },
  { x: 66, y: 3, w: 11, h: 8 },
  { x: 82, y: 6, w: 12, h: 7 },
  { x: 20, y: 22, w: 9, h: 7 },
  { x: 35, y: 20, w: 8, h: 8 },
  { x: 50, y: 22, w: 10, h: 6 },
  { x: 66, y: 21, w: 9, h: 8 },
  { x: 80, y: 34, w: 13, h: 9 },
  { x: 63, y: 38, w: 10, h: 7 },
  { x: 48, y: 36, w: 8, h: 6 },
  { x: 8, y: 46, w: 9, h: 7 },
  { x: 34, y: 54, w: 10, h: 8 },
  { x: 50, y: 62, w: 9, h: 7 },
  { x: 64, y: 56, w: 11, h: 8 },
  { x: 14, y: 68, w: 12, h: 8 },
  { x: 78, y: 70, w: 14, h: 7 },
];

const ROADS: string[] = [
  "M -2 17 L 102 13",
  "M -2 33 L 102 29",
  "M -2 51 L 102 49",
  "M -2 67 L 102 70",
  "M 16 -2 L 21 82",
  "M 32 -2 L 35 82",
  "M 45 -2 L 43 82",
  "M 59 -2 L 56 82",
  "M 73 -2 L 77 82",
  "M 87 -2 L 90 82",
];

const RING =
  "M 50 7 C 77 7, 93 25, 91 43 C 89 61, 69 73, 47 71 C 25 69, 9 55, 11 37 C 13 19, 27 7, 50 7 Z";

const RIVER =
  "M -6 12 C 14 22, 22 30, 32 38 C 42 46, 44 50, 56 55 C 70 61, 82 63, 106 62";

const CANAL = "M 96 4 C 80 16, 74 26, 76 40 C 78 52, 72 60, 60 66";

function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max);
}

function fmtTime(mins: number): string {
  const h = Math.floor(mins / 60);
  const m = mins % 60;
  const suffix = h >= 12 ? "pm" : "am";
  const h12 = h % 12 === 0 ? 12 : h % 12;
  return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, "0")}${suffix}`;
}

function isOpenAt(store: Store, nowMin: number): boolean {
  const day = Math.floor(nowMin / 1440) % 7;
  const minute = nowMin % 1440;
  const slot = store.hours[day];
  if (!slot) return false;
  return minute >= slot[0] && minute < slot[1];
}

function directionsHref(store: Store): string {
  const q = encodeURIComponent(`${store.name}, ${store.address}, ${store.postcode}, UK`);
  return `https://www.google.com/maps/search/?api=1&query=${q}`;
}

function IconSearch({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="1.7" />
      <path d="m16 16 4.5 4.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </svg>
  );
}

function IconClose({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path d="m7 7 10 10M17 7 7 17" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
    </svg>
  );
}

function IconClock({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
      <path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function IconPhone({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M6.2 4h3l1.4 3.5-2 1.4a11 11 0 0 0 5.5 5.5l1.4-2 3.5 1.4v3a1.6 1.6 0 0 1-1.8 1.6C10.8 18 6 13.2 4.6 5.8A1.6 1.6 0 0 1 6.2 4Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconArrow({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path d="M7 17 17 7M9 7h8v8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconFlame({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M12 3s4.2 4.4 4.2 8.4a4.2 4.2 0 0 1-8.4 0c0-1.6.8-2.9 1.7-4 .3 1 .9 1.7 1.6 2 .3-2.4-.2-4.3.9-6.4Z"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinejoin="round"
      />
      <path d="M12 20.8a4.9 4.9 0 0 0 4.9-4.9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function IconParking({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <rect x="4" y="4" width="16" height="16" rx="4" stroke="currentColor" strokeWidth="1.6" />
      <path d="M10 17V8h3a2.6 2.6 0 0 1 0 5.2h-3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconAccess({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="13" cy="4.6" r="1.9" stroke="currentColor" strokeWidth="1.5" />
      <path d="M10.6 8.6v4.2h4l2.6 5.4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
      <path d="M14.9 15.7a4.8 4.8 0 1 1-5.1-5.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function IconSun({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="M12 3.4v2M12 18.6v2M3.4 12h2M18.6 12h2M6 6l1.4 1.4M16.6 16.6 18 18M18 6l-1.4 1.4M7.4 16.6 6 18"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

const FEATURE_ICON: Record<FeatureKey, (p: { className?: string }) => ReactElement> = {
  roastery: IconFlame,
  parking: IconParking,
  stepFree: IconAccess,
  outdoor: IconSun,
};

function FeatureIcon({ id, className }: { id: FeatureKey; className?: string }) {
  const Cmp = FEATURE_ICON[id];
  return <Cmp className={className} />;
}

export default function MapStoreLocator() {
  const reduce = useReducedMotion();

  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<SortKey>("nearest");
  const [filters, setFilters] = useState<FeatureKey[]>([]);
  const [openOnly, setOpenOnly] = useState(false);
  const [selectedId, setSelectedId] = useState<string | null>("ancoats");
  const [activeId, setActiveId] = useState<string>("ancoats");
  const [nowMin, setNowMin] = useState<number | null>(null);

  const optionRefs = useRef<Record<string, HTMLLIElement | null>>({});

  useEffect(() => {
    const d = new Date();
    setNowMin(d.getDay() * 1440 + d.getHours() * 60 + d.getMinutes());
  }, []);

  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    const filtered = STORES.filter((s) => {
      if (q) {
        const hay = `${s.name} ${s.area} ${s.address} ${s.postcode}`.toLowerCase();
        if (!hay.includes(q)) return false;
      }
      if (openOnly && nowMin !== null && !isOpenAt(s, nowMin)) return false;
      for (const f of filters) {
        if (!s.features.includes(f)) return false;
      }
      return true;
    });

    const sorted = [...filtered];
    if (sort === "name") {
      sorted.sort((a, b) => a.name.localeCompare(b.name));
    } else if (sort === "open" && nowMin !== null) {
      sorted.sort((a, b) => {
        const oa = isOpenAt(a, nowMin) ? 0 : 1;
        const ob = isOpenAt(b, nowMin) ? 0 : 1;
        return oa - ob || a.miles - b.miles;
      });
    } else {
      sorted.sort((a, b) => a.miles - b.miles);
    }
    return sorted;
  }, [query, sort, filters, openOnly, nowMin]);

  useEffect(() => {
    if (results.length === 0) return;
    if (!results.some((s) => s.id === activeId)) {
      setActiveId(results[0].id);
    }
  }, [results, activeId]);

  useEffect(() => {
    if (selectedId && !results.some((s) => s.id === selectedId)) {
      setSelectedId(results.length > 0 ? results[0].id : null);
    }
  }, [results, selectedId]);

  useEffect(() => {
    const el = optionRefs.current[activeId];
    if (el) el.scrollIntoView({ block: "nearest" });
  }, [activeId]);

  const selected = useMemo(
    () => STORES.find((s) => s.id === selectedId) ?? null,
    [selectedId],
  );

  const zoom = selected ? ZOOM : 1;
  const fx = selected ? selected.x / MAP_W : 0.5;
  const fy = selected ? selected.y / MAP_H : 0.5;
  const tx = clamp((0.5 - zoom * fx) * 100, -(zoom - 1) * 100, 0);
  const ty = clamp((0.5 - zoom * fy) * 100, -(zoom - 1) * 100, 0);

  const mapTransition = reduce
    ? { duration: 0 }
    : ({ type: "spring", stiffness: 130, damping: 24, mass: 0.9 } as const);

  const toggleFilter = useCallback((key: FeatureKey) => {
    setFilters((prev) =>
      prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
    );
  }, []);

  const pick = useCallback((id: string) => {
    setActiveId(id);
    setSelectedId(id);
  }, []);

  const onListKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
    if (results.length === 0) return;
    const i = results.findIndex((s) => s.id === activeId);
    const current = i < 0 ? 0 : i;
    let next = -1;

    if (e.key === "ArrowDown") next = Math.min(current + 1, results.length - 1);
    else if (e.key === "ArrowUp") next = Math.max(current - 1, 0);
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = results.length - 1;
    else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      pick(results[current].id);
      return;
    } else {
      return;
    }

    e.preventDefault();
    pick(results[next].id);
  };

  const clearAll = () => {
    setQuery("");
    setFilters([]);
    setOpenOnly(false);
    setSort("nearest");
  };

  const hasFilters = query.trim() !== "" || filters.length > 0 || openOnly;
  const todayIndex = nowMin === null ? -1 : Math.floor(nowMin / 1440) % 7;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 sm:py-28 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
@keyframes mslx-pulse {
  0%   { transform: scale(0.5); opacity: 0.5; }
  70%  { transform: scale(2.4); opacity: 0; }
  100% { transform: scale(2.4); opacity: 0; }
}
@keyframes mslx-beacon {
  0%, 100% { opacity: 0.35; transform: scale(1); }
  50%      { opacity: 0.8;  transform: scale(1.4); }
}
.mslx-pulse-ring { animation: mslx-pulse 2.4s cubic-bezier(0.3, 0, 0.2, 1) infinite; }
.mslx-beacon {
  transform-box: fill-box;
  transform-origin: center;
  animation: mslx-beacon 2.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .mslx-pulse-ring, .mslx-beacon { animation: none !important; }
}
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <header className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Northline Coffee Roasters
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
            Seven bars across Greater Manchester
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
            Search by area or postcode, filter for what you need, and pick a pin to see the
            address, today&rsquo;s hours and how to get there. Distances are measured from
            Manchester Piccadilly.
          </p>
        </header>

        <div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
          {/* ---------------- Controls + list ---------------- */}
          <div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm sm:p-5 dark:border-zinc-800 dark:bg-zinc-900">
            <label htmlFor="mslx-search" className="sr-only">
              Search Northline locations by name, area or postcode
            </label>
            <div className="relative">
              <IconSearch className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-zinc-500" />
              <input
                id="mslx-search"
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Ancoats, M21, Beech Road…"
                autoComplete="off"
                className="w-full rounded-xl border border-slate-300 bg-slate-50 py-2.5 pl-10 pr-9 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:ring-offset-zinc-900"
              />
              {query !== "" ? (
                <button
                  type="button"
                  onClick={() => setQuery("")}
                  className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
                >
                  <IconClose className="h-3.5 w-3.5" />
                  <span className="sr-only">Clear search</span>
                </button>
              ) : null}
            </div>

            <div className="mt-3 flex flex-wrap gap-2">
              <button
                type="button"
                aria-pressed={openOnly}
                onClick={() => setOpenOnly((v) => !v)}
                disabled={nowMin === null}
                className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-50 dark:focus-visible:ring-offset-zinc-900 ${
                  openOnly
                    ? "border-emerald-600 bg-emerald-600 text-white dark:border-emerald-500 dark:bg-emerald-500 dark:text-zinc-950"
                    : "border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
                }`}
              >
                <IconClock className="h-3.5 w-3.5" />
                Open now
              </button>

              {(Object.keys(FEATURE_LABEL) as FeatureKey[]).map((key) => {
                const on = filters.includes(key);
                return (
                  <button
                    key={key}
                    type="button"
                    aria-pressed={on}
                    onClick={() => toggleFilter(key)}
                    className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
                      on
                        ? "border-indigo-600 bg-indigo-600 text-white dark:border-indigo-500 dark:bg-indigo-500 dark:text-zinc-950"
                        : "border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
                    }`}
                  >
                    <FeatureIcon id={key} className="h-3.5 w-3.5" />
                    {FEATURE_LABEL[key]}
                  </button>
                );
              })}
            </div>

            <div className="mt-4 flex items-center justify-between gap-3 border-t border-slate-200 pt-3 dark:border-zinc-800">
              <p className="text-xs text-slate-600 dark:text-zinc-400">
                <span className="font-semibold text-slate-900 dark:text-zinc-100">
                  {results.length}
                </span>{" "}
                of {STORES.length} locations
              </p>
              <div className="flex items-center gap-2">
                <label
                  htmlFor="mslx-sort"
                  className="text-xs font-medium text-slate-600 dark:text-zinc-400"
                >
                  Sort
                </label>
                <select
                  id="mslx-sort"
                  value={sort}
                  onChange={(e) => setSort(e.target.value as SortKey)}
                  className="rounded-lg border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200 dark:focus-visible:ring-offset-zinc-900"
                >
                  <option value="nearest">Nearest</option>
                  <option value="name">Name A–Z</option>
                  <option value="open">Open first</option>
                </select>
              </div>
            </div>

            <p aria-live="polite" className="sr-only">
              {results.length === 0
                ? "No locations match your filters."
                : `${results.length} location${results.length === 1 ? "" : "s"} listed.`}
            </p>

            {results.length === 0 ? (
              <div className="mt-4 rounded-xl border border-dashed border-slate-300 px-4 py-10 text-center dark:border-zinc-700">
                <p className="text-sm font-medium text-slate-800 dark:text-zinc-200">
                  Nothing matches that yet
                </p>
                <p className="mx-auto mt-1 max-w-xs text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
                  Try a wider search — every bar sits within six miles of Piccadilly.
                </p>
                <button
                  type="button"
                  onClick={clearAll}
                  className="mt-4 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
                >
                  Reset filters
                </button>
              </div>
            ) : (
              <ul
                role="listbox"
                aria-label="Northline locations"
                tabIndex={0}
                aria-activedescendant={
                  results.some((s) => s.id === activeId) ? `mslx-opt-${activeId}` : undefined
                }
                onKeyDown={onListKeyDown}
                className="mt-3 max-h-[24rem] space-y-1.5 overflow-y-auto rounded-xl p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
              >
                {results.map((s) => {
                  const isSelected = s.id === selectedId;
                  const isActive = s.id === activeId;
                  const open = nowMin !== null && isOpenAt(s, nowMin);
                  const today = todayIndex >= 0 ? s.hours[todayIndex] : null;
                  return (
                    <li
                      key={s.id}
                      id={`mslx-opt-${s.id}`}
                      role="option"
                      aria-selected={isSelected}
                      ref={(el) => {
                        optionRefs.current[s.id] = el;
                      }}
                      onClick={() => pick(s.id)}
                      className={`cursor-pointer rounded-xl border px-3 py-2.5 transition-colors ${
                        isSelected
                          ? "border-indigo-500 bg-indigo-50 dark:border-indigo-400/70 dark:bg-indigo-500/10"
                          : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700 dark:hover:bg-zinc-800/60"
                      } ${isActive ? "ring-2 ring-indigo-400/70 ring-offset-1 ring-offset-white dark:ring-indigo-400/60 dark:ring-offset-zinc-900" : ""}`}
                    >
                      <div className="flex items-start justify-between gap-2">
                        <div className="min-w-0">
                          <p className="truncate text-sm font-semibold text-slate-900 dark:text-zinc-50">
                            {s.area}
                          </p>
                          <p className="mt-0.5 truncate text-xs text-slate-600 dark:text-zinc-400">
                            {s.address}
                          </p>
                        </div>
                        <span className="shrink-0 rounded-md bg-slate-100 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-slate-700 dark:bg-zinc-800 dark:text-zinc-300">
                          {s.miles.toFixed(1)} mi
                        </span>
                      </div>
                      <div className="mt-2 flex items-center gap-2 text-[11px]">
                        {nowMin === null ? (
                          <span className="text-slate-500 dark:text-zinc-500">
                            {s.postcode}
                          </span>
                        ) : (
                          <>
                            <span
                              className={`inline-flex items-center gap-1 font-semibold ${
                                open
                                  ? "text-emerald-700 dark:text-emerald-400"
                                  : "text-rose-700 dark:text-rose-400"
                              }`}
                            >
                              <span
                                aria-hidden="true"
                                className={`h-1.5 w-1.5 rounded-full ${
                                  open ? "bg-emerald-500" : "bg-rose-500"
                                }`}
                              />
                              {open ? "Open" : "Closed"}
                            </span>
                            <span className="text-slate-500 dark:text-zinc-500">
                              {today
                                ? `${fmtTime(today[0])} – ${fmtTime(today[1])} today`
                                : "Closed today"}
                            </span>
                          </>
                        )}
                      </div>
                    </li>
                  );
                })}
              </ul>
            )}

            {hasFilters ? (
              <button
                type="button"
                onClick={clearAll}
                className="mt-3 text-xs font-semibold text-indigo-700 underline underline-offset-4 hover:text-indigo-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-900"
              >
                Clear search and filters
              </button>
            ) : null}
          </div>

          {/* ---------------- Map + detail ---------------- */}
          <div className="space-y-4">
            <div
              role="group"
              aria-label="Map of Northline locations. Each pin is a button."
              className="relative aspect-[5/4] w-full overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 shadow-sm sm:aspect-[16/10] dark:border-zinc-800 dark:bg-zinc-900"
            >
              <motion.div
                className="absolute inset-0"
                style={{ transformOrigin: "0% 0%" }}
                animate={{ x: `${tx}%`, y: `${ty}%`, scale: zoom }}
                transition={mapTransition}
              >
                <svg
                  viewBox={`0 0 ${MAP_W} ${MAP_H}`}
                  preserveAspectRatio="xMidYMid meet"
                  aria-hidden="true"
                  className="h-full w-full"
                >
                  <rect
                    x="0"
                    y="0"
                    width={MAP_W}
                    height={MAP_H}
                    className="fill-slate-100 dark:fill-zinc-900"
                  />
                  {BLOCKS.map((b) => (
                    <rect
                      key={`${b.x}-${b.y}`}
                      x={b.x}
                      y={b.y}
                      width={b.w}
                      height={b.h}
                      rx="1.2"
                      className="fill-slate-200/80 dark:fill-zinc-800/80"
                    />
                  ))}
                  <ellipse
                    cx="26"
                    cy="59"
                    rx="10"
                    ry="6"
                    className="fill-emerald-200/70 dark:fill-emerald-900/40"
                  />
                  <ellipse
                    cx="76"
                    cy="20"
                    rx="11"
                    ry="7"
                    className="fill-emerald-200/70 dark:fill-emerald-900/40"
                  />
                  <path
                    d={RIVER}
                    fill="none"
                    strokeWidth="5.5"
                    strokeLinecap="round"
                    className="stroke-sky-200 dark:stroke-sky-950"
                  />
                  <path
                    d={CANAL}
                    fill="none"
                    strokeWidth="1.6"
                    strokeLinecap="round"
                    className="stroke-sky-300/80 dark:stroke-sky-900"
                  />
                  {ROADS.map((d) => (
                    <path
                      key={d}
                      d={d}
                      fill="none"
                      strokeWidth="1.4"
                      strokeLinecap="round"
                      className="stroke-white dark:stroke-zinc-700"
                    />
                  ))}
                  <path
                    d={RING}
                    fill="none"
                    strokeWidth="2.2"
                    strokeLinecap="round"
                    className="stroke-amber-300/70 dark:stroke-amber-800/70"
                  />
                  <circle cx="53" cy="37" r="4.5" className="mslx-beacon fill-indigo-500/30" />
                  <circle cx="53" cy="37" r="1.5" className="fill-indigo-600 dark:fill-indigo-400" />
                </svg>

                {STORES.map((s) => {
                  const isSelected = s.id === selectedId;
                  const visible = results.some((r) => r.id === s.id);
                  return (
                    <div
                      key={s.id}
                      className="absolute -translate-x-1/2 -translate-y-full"
                      style={{
                        left: `${(s.x / MAP_W) * 100}%`,
                        top: `${(s.y / MAP_H) * 100}%`,
                      }}
                    >
                      <motion.button
                        type="button"
                        onClick={() => pick(s.id)}
                        aria-pressed={isSelected}
                        aria-label={`${s.area} — ${s.name}, ${s.postcode}${
                          visible ? "" : " (filtered out)"
                        }`}
                        animate={{ scale: 1 / zoom, opacity: visible ? 1 : 0.3 }}
                        transition={mapTransition}
                        className="relative block origin-bottom rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                      >
                        {isSelected ? (
                          <span
                            aria-hidden="true"
                            className="mslx-pulse-ring absolute bottom-0 left-1/2 h-7 w-7 -translate-x-1/2 translate-y-1/2 rounded-full bg-indigo-500/45"
                          />
                        ) : null}
                        <svg
                          viewBox="0 0 24 24"
                          aria-hidden="true"
                          className={`relative h-8 w-8 drop-shadow-sm transition-transform ${
                            isSelected ? "scale-110" : "hover:scale-105"
                          }`}
                        >
                          <path
                            d="M12 22.5S19 15.6 19 10.4A7 7 0 1 0 5 10.4C5 15.6 12 22.5 12 22.5Z"
                            className={
                              isSelected
                                ? "fill-indigo-600 stroke-indigo-800 dark:fill-indigo-400 dark:stroke-indigo-200"
                                : "fill-white stroke-slate-400 dark:fill-zinc-800 dark:stroke-zinc-500"
                            }
                            strokeWidth="1.1"
                          />
                          <circle
                            cx="12"
                            cy="10.2"
                            r="2.7"
                            className={
                              isSelected
                                ? "fill-white dark:fill-zinc-950"
                                : "fill-indigo-500 dark:fill-indigo-400"
                            }
                          />
                        </svg>
                        {isSelected ? (
                          <span className="absolute left-1/2 top-full mt-0.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold text-white shadow dark:bg-zinc-100 dark:text-zinc-900">
                            {s.area}
                          </span>
                        ) : null}
                      </motion.button>
                    </div>
                  );
                })}
              </motion.div>

              <div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between p-3">
                <span className="pointer-events-auto rounded-lg bg-white/90 px-2.5 py-1 text-[11px] font-medium text-slate-700 shadow-sm backdrop-blur dark:bg-zinc-950/85 dark:text-zinc-300">
                  Illustrative map — not to scale
                </span>
                {selected ? (
                  <button
                    type="button"
                    onClick={() => setSelectedId(null)}
                    className="pointer-events-auto inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white/95 px-2.5 py-1 text-[11px] font-semibold text-slate-800 shadow-sm backdrop-blur hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:border-zinc-700 dark:bg-zinc-950/90 dark:text-zinc-200 dark:hover:bg-zinc-950 dark:focus-visible:ring-offset-zinc-900"
                  >
                    <IconClose className="h-3 w-3" />
                    Show all pins
                  </button>
                ) : null}
              </div>

              <div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-3 rounded-lg bg-white/90 px-2.5 py-1.5 text-[11px] text-slate-600 shadow-sm backdrop-blur dark:bg-zinc-950/85 dark:text-zinc-400">
                <span className="inline-flex items-center gap-1.5">
                  <span aria-hidden="true" className="h-2 w-2 rounded-full bg-indigo-600 dark:bg-indigo-400" />
                  Piccadilly
                </span>
                <span className="inline-flex items-center gap-1.5">
                  <span aria-hidden="true" className="h-2 w-3.5 rounded-sm bg-amber-300 dark:bg-amber-800" />
                  Ring road
                </span>
                <span className="inline-flex items-center gap-1.5">
                  <span aria-hidden="true" className="h-2 w-3.5 rounded-sm bg-sky-200 dark:bg-sky-950" />
                  Irwell
                </span>
              </div>
            </div>

            {/* ---------------- Detail ---------------- */}
            {selected ? (
              <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
                <div className="flex flex-wrap items-start justify-between gap-4">
                  <div>
                    <h3 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-zinc-50">
                      {selected.name}
                    </h3>
                    <p className="mt-1 text-sm text-slate-600 dark:text-zinc-400">
                      {selected.address}, {selected.postcode} ·{" "}
                      <span className="tabular-nums">{selected.miles.toFixed(1)} mi</span> from
                      Piccadilly
                    </p>
                  </div>
                  <a
                    href={directionsHref(selected)}
                    target="_blank"
                    rel="noopener"
                    className="inline-flex items-center gap-1.5 rounded-xl bg-indigo-600 px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:text-zinc-950 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                  >
                    Get directions
                    <IconArrow className="h-4 w-4" />
                  </a>
                </div>

                <p className="mt-4 text-sm leading-relaxed text-slate-700 dark:text-zinc-300">
                  {selected.note}
                </p>

                <div className="mt-5 grid gap-5 sm:grid-cols-2">
                  <div>
                    <h4 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-500">
                      <IconClock className="h-3.5 w-3.5" />
                      Opening hours
                    </h4>
                    <dl className="mt-2 space-y-1">
                      {DAY_LABELS.map((day, i) => {
                        const slot = selected.hours[i];
                        const isToday = i === todayIndex;
                        return (
                          <div
                            key={day}
                            className={`flex items-center justify-between rounded-md px-2 py-0.5 text-xs ${
                              isToday
                                ? "bg-indigo-50 font-semibold text-slate-900 dark:bg-indigo-500/10 dark:text-zinc-50"
                                : "text-slate-600 dark:text-zinc-400"
                            }`}
                          >
                            <dt>
                              {day}
                              {isToday ? <span className="sr-only"> (today)</span> : null}
                            </dt>
                            <dd className="tabular-nums">
                              {slot ? `${fmtTime(slot[0])} – ${fmtTime(slot[1])}` : "Closed"}
                            </dd>
                          </div>
                        );
                      })}
                    </dl>
                  </div>

                  <div>
                    <h4 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-500">
                      At this bar
                    </h4>
                    <ul className="mt-2 space-y-1.5">
                      {selected.features.map((f) => (
                        <li
                          key={f}
                          className="flex items-center gap-2 text-xs text-slate-700 dark:text-zinc-300"
                        >
                          <FeatureIcon
                            id={f}
                            className="h-4 w-4 text-indigo-600 dark:text-indigo-400"
                          />
                          {FEATURE_LABEL[f]}
                        </li>
                      ))}
                    </ul>
                    <a
                      href={`tel:${selected.phone.replace(/\s/g, "")}`}
                      className="mt-4 inline-flex items-center gap-1.5 rounded-lg text-xs font-semibold text-slate-800 underline underline-offset-4 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-200 dark:hover:text-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                    >
                      <IconPhone className="h-3.5 w-3.5" />
                      {selected.phone}
                    </a>
                  </div>
                </div>
              </div>
            ) : (
              <div className="rounded-2xl border border-dashed border-slate-300 bg-white/60 p-6 text-center dark:border-zinc-700 dark:bg-zinc-900/50">
                <p className="text-sm font-semibold text-slate-800 dark:text-zinc-200">
                  Showing all {STORES.length} locations
                </p>
                <p className="mx-auto mt-1 max-w-sm text-xs leading-relaxed text-slate-500 dark:text-zinc-400">
                  Choose a pin or a row from the list to zoom in and see hours, phone and
                  directions. Arrow keys work in the list.
                </p>
              </div>
            )}
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →