{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "store-locator",
  "title": "Store Locator",
  "description": "A page that finds stores: search a place and the map pulls in to it and its nearest stores, a list that follows the view and answers the map on hover, a drive-time halo round the store you pick, and directions to it. Calls /geocode/autocomplete (and /geocode/reverse for typed coordinates), /isochrone and /route. Ships with Ottawa Public Library branches (City of Ottawa open data).",
  "dependencies": [
    "@unmap/routing"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map.json",
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/geocoder.json",
    "https://registry.unmap.dev/r/cluster-layer.json",
    "https://registry.unmap.dev/r/geojson-layer.json",
    "https://registry.unmap.dev/r/route-line.json",
    "https://registry.unmap.dev/r/marker-popup.json",
    "card",
    "button",
    "alert",
    "spinner"
  ],
  "files": [
    {
      "path": "registry/blocks/store-locator/page.tsx",
      "content": "/**\n * /store-locator: the store-locator block as a Next.js App Router page, full-bleed (the block keeps\n * its own gutter). The key comes from NEXT_PUBLIC_UNMAP_KEY in .env.local; installing the block\n * added the empty line, paste your key.\n */\nimport { StoreLocator } from \"@/components/ui/store-locator\";\n\nexport const metadata = { title: \"Find a branch\" };\n\nexport default function StoreLocatorPage() {\n  return (\n    <main className=\"h-dvh\">\n      <StoreLocator apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/store-locator/page.tsx"
    },
    {
      "path": "registry/blocks/store-locator/store-locator.tsx",
      "content": "\"use client\";\n\n/**\n * <StoreLocator> - a store-locator page in one component. A full-bleed map with a floating panel\n * (a bottom sheet on a phone): search a place and the map pulls in to it and its nearest stores;\n * the list is the map's legend, listing the stores in view as clusters split on zoom; hover either\n * and the other answers; pick a store for its hours, a drive-time halo round it, and directions.\n *\n * Composes the registry's map, geocoder, cluster-layer, geojson-layer, route-line and\n * marker-popup items on your shadcn Card, Button, Alert and Spinner, in your tokens. Each action is\n * one request to the unmap API: the search box calls /geocode/autocomplete (and /geocode/reverse for\n * typed coordinates), the drive area calls /isochrone, \"Directions\" calls /route. Nothing is\n * requested until the reader acts. Routes and drive areas are OpenStreetMap data (ODbL), credited\n * in the panel.\n *\n * Ships with Ottawa Public Library branches (lib/store-locator-data) and credits them. Pass\n * `stores` for your own points. Installed by `npx shadcn add` with its page at\n * app/store-locator/page.tsx. Edit freely.\n */\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Router, type LngLat } from \"@unmap/routing\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { ClusterLayer } from \"@/components/ui/cluster-layer\";\nimport { Geocoder } from \"@/components/ui/geocoder\";\nimport { GeoJSONLayer } from \"@/components/ui/geojson-layer\";\nimport { Map } from \"@/components/ui/map\";\nimport { Marker, MarkerLabel } from \"@/components/ui/marker-popup\";\nimport { RouteLine } from \"@/components/ui/route-line\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { useMap } from \"@/lib/map-context\";\nimport { STORES, STORES_SOURCE, type StoreCollection, type StoreFeature } from \"@/lib/store-locator-data\";\n\n// --- store-locator helpers (kept identical across both lanes) ---\nexport type LocatorLang = \"en\" | \"fr\";\n\n/** [west, south, east, north], as the map's visible bounds. */\nexport type LocatorBounds = [number, number, number, number];\n\n/** The place the reader searched for. */\nexport interface LocatorOrigin {\n  name: string;\n  lng: number;\n  lat: number;\n}\n\n/** What /isochrone returns: GeoJSON polygons, largest band first. */\nexport interface LocatorReach {\n  type: \"FeatureCollection\";\n  features: Array<{ geometry: { type: string; coordinates: unknown } | null }>;\n}\n\nexport interface StoreRow {\n  feature: StoreFeature;\n  /** Metres from the searched place; null before a search. */\n  distance: number | null;\n}\n\n/** A box on screen, as getBoundingClientRect() gives it. */\nexport interface LocatorRect {\n  left: number;\n  top: number;\n  right: number;\n  bottom: number;\n  width: number;\n  height: number;\n}\n\n/** Camera padding in pixels, as fitBounds takes it. */\nexport interface LocatorPadding {\n  top: number;\n  right: number;\n  bottom: number;\n  left: number;\n}\n\n/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\nexport const LOCATOR_COPY = {\n  en: {\n    title: \"Find a branch\",\n    intro: \"Search a place, or pick a branch on the map.\",\n    search: \"Search a place\",\n    list: \"Branches\",\n    count: (n: number) => (n === 1 ? \"branch in view\" : \"branches in view\"),\n    empty: \"No branches here. Zoom out, move the map, or show them all.\",\n    allStores: \"Show all branches\",\n    announceNearest: (place: string, store: string, distance: string) => `Nearest to ${place}: ${store}, ${distance}.`,\n    announcePick: (store: string) => `${store} selected.`,\n    capped: (n: number) => `Showing the first ${n}. Zoom in to see the rest.`,\n    showList: \"Show the list\",\n    hideList: \"Hide the list\",\n    close: \"Close\",\n    directions: \"Directions\",\n    needOrigin: \"Search a place to get directions from it.\",\n    reach: (minutes: number) => `${minutes} min drive area`,\n    reachArea: (minutes: number, store: string) => `The shaded area is within a ${minutes} min drive of ${store}.`,\n    reachInside: (place: string, minutes: number, store: string) => `${place} is within a ${minutes} min drive of ${store}.`,\n    reachOutside: (place: string, minutes: number, store: string) =>\n      `${place} is more than a ${minutes} min drive from ${store}.`,\n    byCar: \"by car\",\n    loadingRoute: \"Finding the route...\",\n    loadingReach: \"Drawing the drive area...\",\n    retry: \"Try again\",\n    openToday: \"Open today\",\n    closedToday: \"Closed today\",\n    closed: \"Closed\",\n    source: \"Data:\",\n    routesCredit: \"Routes and drive areas:\",\n    osm: \"OpenStreetMap contributors\",\n    odbl: \"ODbL\",\n    noKeyTitle: \"Add your unmap key to load the map.\",\n    getKey: \"Get a key\",\n    noKey:\n      'Pass apiKey. On Next.js, set NEXT_PUBLIC_UNMAP_KEY in .env.local. On Nuxt, set NUXT_PUBLIC_UNMAP_KEY in .env. If you installed with unmap add, nuxt.config already declares runtimeConfig: { public: { unmapKey: \"\" } }; otherwise add it there. Then restart the dev server.',\n    outside: \"That place is outside Canada. Routing covers Canada.\",\n    noRoute: \"No road connects those places.\",\n    busy: \"Too many requests. Try again in a moment.\",\n    keyRefused: \"The API key was refused. Check it and try again.\",\n    unavailable: \"The service didn't answer. Try again in a moment.\",\n    failed: \"The request failed. Try again.\",\n  },\n  fr: {\n    title: \"Trouver une succursale\",\n    intro: \"Cherchez un lieu ou choisissez une succursale sur la carte.\",\n    search: \"Chercher un lieu\",\n    list: \"Succursales\",\n    count: (n: number) => (n === 1 ? \"succursale visible\" : \"succursales visibles\"),\n    empty: \"Aucune succursale ici. Dézoomez, déplacez la carte ou affichez-les toutes.\",\n    allStores: \"Afficher toutes les succursales\",\n    announceNearest: (place: string, store: string, distance: string) => `Plus proche de ${place} : ${store}, ${distance}.`,\n    announcePick: (store: string) => `${store} sélectionnée.`,\n    capped: (n: number) => `Affichage des ${n} premières. Zoomez pour voir les autres.`,\n    showList: \"Afficher la liste\",\n    hideList: \"Masquer la liste\",\n    close: \"Fermer\",\n    directions: \"Itinéraire\",\n    needOrigin: \"Cherchez un lieu pour obtenir un itinéraire à partir de celui-ci.\",\n    reach: (minutes: number) => `Zone à ${minutes} min en voiture`,\n    reachArea: (minutes: number, store: string) => `La zone colorée est à moins de ${minutes} min en voiture de ${store}.`,\n    reachInside: (place: string, minutes: number, store: string) => `${place} est à moins de ${minutes} min en voiture de ${store}.`,\n    reachOutside: (place: string, minutes: number, store: string) => `${place} est à plus de ${minutes} min en voiture de ${store}.`,\n    byCar: \"en voiture\",\n    loadingRoute: \"Calcul de l'itinéraire...\",\n    loadingReach: \"Tracé de la zone...\",\n    retry: \"Réessayer\",\n    openToday: \"Ouvert aujourd'hui\",\n    closedToday: \"Fermé aujourd'hui\",\n    closed: \"Fermé\",\n    source: \"Données :\",\n    routesCredit: \"Itinéraires et zones de trajet :\",\n    osm: \"contributeurs d'OpenStreetMap\",\n    odbl: \"ODbL\",\n    noKeyTitle: \"Ajoutez votre clé unmap pour charger la carte.\",\n    getKey: \"Obtenir une clé\",\n    noKey:\n      'Passez apiKey. Avec Next.js, définissez NEXT_PUBLIC_UNMAP_KEY dans .env.local. Avec Nuxt, définissez NUXT_PUBLIC_UNMAP_KEY dans .env. Si vous avez installé le bloc avec unmap add, nuxt.config déclare déjà runtimeConfig: { public: { unmapKey: \"\" } } ; sinon, ajoutez-le. Redémarrez ensuite le serveur de développement.',\n    outside: \"Ce lieu est hors du Canada. Les itinéraires couvrent le Canada.\",\n    noRoute: \"Aucune route ne relie ces lieux.\",\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    keyRefused: \"La clé API a été refusée. Vérifiez-la et réessayez.\",\n    unavailable: \"Le service n'a pas répondu. Réessayez dans un instant.\",\n    failed: \"La requête a échoué. Réessayez.\",\n  },\n} satisfies Record<LocatorLang, Record<string, string | ((...values: never[]) => string)>>;\n\n/** Downtown Ottawa, where the sample branches are. */\nexport const OTTAWA: [number, number] = [-75.69, 45.42];\n\n/** The block's own map id, so a map elsewhere on the page never answers for it. */\nexport const LOCATOR_MAP_ID = \"store-locator\";\n/** Source ids (and layer-id prefixes) of what the block draws. */\nexport const LOCATOR_LAYERS = {\n  stores: \"store-locator-stores\",\n  points: \"store-locator-stores-points\",\n  reach: \"store-locator-reach\",\n  route: \"store-locator-route\",\n  focus: \"store-locator-focus\",\n};\n\n/** Who the routes and drive areas come from, and the licence they carry. */\nexport const ROUTING_CREDIT = {\n  sourceUrl: \"https://www.openstreetmap.org/copyright\",\n  licenceUrl: \"https://opendatacommons.org/licenses/odbl/\",\n};\n\n/** The drive-time halo is a light wash of the fill colour, so the stores under it stay readable. */\nexport const REACH_FILL = { \"fill-opacity\": 0.15 };\n/** The ring on the store under the pointer or picked: larger than a point, on a thick halo of the page ground, below the labels. */\nexport const FOCUS_CIRCLE = { \"circle-radius\": 10, \"circle-stroke-width\": 3 };\n\n/** The most rows the list renders; the headline still counts them all. */\nexport const LIST_LIMIT = 50;\n/** How many of the nearest stores a search frames with the searched place. */\nexport const NEAREST_FRAMED = 3;\n/** The panel's inset from the map edge, and the gutter the camera keeps. */\nexport const GUTTER = 24;\n/** Camera moves and the route draw-in, in milliseconds. Reduced motion jumps. */\nexport const CAMERA_MS = 800;\nexport const DRAW_MS = 900;\n\nconst EARTH_RADIUS_M = 6_371_008.8;\nconst DAYS = [\"sun\", \"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\"] as const;\n/** The least map, in pixels, a padded camera must have before it tightens its gutter. */\nconst MIN_FRAME = 120;\n/** The tightened gutter, and the least strip the camera keeps to frame in beside a tall panel. */\nconst TIGHT = 8;\nconst MIN_STRIP = 32;\n\n/** Great-circle distance in metres. */\nexport function distanceMetres(a: [number, number], b: [number, number]): number {\n  const toRadians = (degrees: number) => (degrees * Math.PI) / 180;\n  const dLat = toRadians(b[1] - a[1]);\n  const dLng = toRadians(b[0] - a[0]);\n  const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRadians(a[1])) * Math.cos(toRadians(b[1])) * Math.sin(dLng / 2) ** 2;\n  return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));\n}\n\n/** The map's visible bounds as [west, south, east, north]. */\nexport function boundsOf(map: { getBounds(): { toArray(): number[][] } }): LocatorBounds {\n  const [southWest, northEast] = map.getBounds().toArray();\n  return [southWest![0]!, southWest![1]!, northEast![0]!, northEast![1]!];\n}\n\nexport function inBounds([lng, lat]: [number, number], [west, south, east, north]: LocatorBounds): boolean {\n  if (lat < south || lat > north) return false;\n  // Across the antimeridian `west` is greater than `east`.\n  return west <= east ? lng >= west && lng <= east : lng >= west || lng <= east;\n}\n\nfunction inRing([x, y]: [number, number], ring: [number, number][]): boolean {\n  let inside = false;\n  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {\n    const [xi, yi] = ring[i]!;\n    const [xj, yj] = ring[j]!;\n    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;\n  }\n  return inside;\n}\n\nfunction inPolygon(point: [number, number], rings: [number, number][][]): boolean {\n  const [outer, ...holes] = rings;\n  return !!outer && inRing(point, outer) && !holes.some((hole) => inRing(point, hole));\n}\n\n/** The polygons of a drive-time area, each as its rings. */\nfunction polygonsOf(reach: LocatorReach): [number, number][][][] {\n  return reach.features.flatMap(({ geometry }) => {\n    if (geometry?.type === \"Polygon\") return [geometry.coordinates as [number, number][][]];\n    if (geometry?.type === \"MultiPolygon\") return geometry.coordinates as [number, number][][][];\n    return [];\n  });\n}\n\n/** True when the point is inside any polygon of the drive-time area. */\nexport function inReach(point: [number, number], reach: LocatorReach): boolean {\n  return polygonsOf(reach).some((polygon) => inPolygon(point, polygon));\n}\n\n/** [[west, south], [east, north]] around some points, for fitBounds. A loop: spreading a long route into Math.min overflows the stack. */\nexport function pointsBounds(points: [number, number][]): [[number, number], [number, number]] {\n  let west = Infinity;\n  let south = Infinity;\n  let east = -Infinity;\n  let north = -Infinity;\n  for (const [lng, lat] of points) {\n    if (lng < west) west = lng;\n    if (lng > east) east = lng;\n    if (lat < south) south = lat;\n    if (lat > north) north = lat;\n  }\n  return [\n    [west, south],\n    [east, north],\n  ];\n}\n\n/** The drive-time area's outer bounds, or null when it holds no polygon. */\nexport function reachBounds(reach: LocatorReach): [[number, number], [number, number]] | null {\n  const points = polygonsOf(reach).flatMap((polygon) => polygon[0] ?? []);\n  return points.length > 0 ? pointsBounds(points) : null;\n}\n\nexport function storeName(feature: StoreFeature, lang: LocatorLang): string {\n  return lang === \"fr\" ? feature.properties.name_fr : feature.properties.name;\n}\n\n/**\n * The list beside the map: every store inside the view, nearest first after a search, by name\n * before one. The panel renders the first LIST_LIMIT and counts the rest.\n */\nexport function visibleStores(\n  stores: StoreCollection,\n  options: { bounds: LocatorBounds | null; origin: LocatorOrigin | null; lang: LocatorLang },\n): StoreRow[] {\n  const { bounds, origin, lang } = options;\n  const locale = lang === \"fr\" ? \"fr-CA\" : \"en-CA\";\n  return stores.features\n    .filter((feature) => !bounds || inBounds(feature.geometry.coordinates, bounds))\n    .map((feature) => ({\n      feature,\n      distance: origin ? distanceMetres([origin.lng, origin.lat], feature.geometry.coordinates) : null,\n    }))\n    .sort((a, b) =>\n      a.distance !== null && b.distance !== null\n        ? a.distance - b.distance\n        : storeName(a.feature, lang).localeCompare(storeName(b.feature, lang), locale),\n    );\n}\n\n/** The stores to ring on the map, in order, each once: the one under the pointer and the one picked. */\nexport function focusCollection(stores: StoreCollection, ids: Array<string | null | undefined>): StoreCollection {\n  const wanted = [...new Set(ids.filter((id): id is string => !!id))];\n  return {\n    type: \"FeatureCollection\",\n    features: wanted\n      .map((id) => stores.features.find((feature) => feature.properties.id === id))\n      .filter((feature): feature is StoreFeature => !!feature),\n  };\n}\n\n/**\n * Camera padding that keeps what the camera frames clear of the floating panel: its width on the\n * left when it floats beside the map, its height at the bottom when it is a sheet across it. An\n * even gutter when nothing is measured. When the full gutter would leave too little map (a tall\n * sheet on a small phone), the gutter tightens and the frame stays in the strip the panel leaves,\n * never behind the panel.\n */\nexport function panelPadding(map: LocatorRect, panel: LocatorRect | null, gutter = GUTTER): LocatorPadding {\n  const even = { top: gutter, right: gutter, bottom: gutter, left: gutter };\n  if (!panel || panel.width <= 0 || panel.height <= 0 || map.width <= 0 || map.height <= 0) return even;\n  const sheet = panel.width >= map.width * 0.6;\n  const cover = Math.round(sheet ? map.bottom - panel.top : panel.right - map.left);\n  const size = sheet ? map.height : map.width;\n  if (size - 2 * gutter - cover >= MIN_FRAME) return sheet ? { ...even, bottom: cover + gutter } : { ...even, left: cover + gutter };\n  const near = Math.min(cover + TIGHT, size - TIGHT - MIN_STRIP);\n  return sheet ? { ...even, top: TIGHT, bottom: near } : { ...even, left: near, right: TIGHT };\n}\n\n/**\n * The list's scrollTop that shows a row whole, moving as little as it can; unchanged when the row\n * shows already. Scrolling the list itself, not scrollIntoView, keeps the page and the panel still.\n */\nexport function listScrollTop(\n  view: { scrollTop: number; clientHeight: number },\n  row: { offsetTop: number; offsetHeight: number },\n): number {\n  if (row.offsetTop < view.scrollTop) return row.offsetTop;\n  const bottom = row.offsetTop + row.offsetHeight;\n  return bottom > view.scrollTop + view.clientHeight ? bottom - view.clientHeight : view.scrollTop;\n}\n\n/** The pixel offset that centres a point in the padded area: easeTo's `offset`, which, unlike its `padding`, does not stick. */\nexport function paddingOffset(padding: LocatorPadding): [number, number] {\n  return [(padding.left - padding.right) / 2, (padding.top - padding.bottom) / 2];\n}\n\nexport function formatDistance(metres: number, lang: LocatorLang): string {\n  if (metres < 1000) return `${Math.round(metres)} m`;\n  const km = new Intl.NumberFormat(lang === \"fr\" ? \"fr-CA\" : \"en-CA\", {\n    minimumFractionDigits: 1,\n    maximumFractionDigits: 1,\n  });\n  return `${km.format(metres / 1000)} km`;\n}\n\n/** \"18 min\", \"1 h 15 min\": a drive time, never under a minute. */\nexport function formatDuration(seconds: number, _lang: LocatorLang): string {\n  const minutes = Math.max(1, Math.round(seconds / 60));\n  if (minutes < 60) return `${minutes} min`;\n  const rest = minutes % 60;\n  return rest ? `${Math.floor(minutes / 60)} h ${rest} min` : `${minutes / 60} h`;\n}\n\n/** Today's opening hours as the data gives them (\"10:00-20:30\"), or \"Closed\". `day` is Date#getDay(). */\nexport function hoursToday(feature: StoreFeature, day: number, lang: LocatorLang): string {\n  return feature.properties.hours[DAYS[day] ?? \"sun\"] ?? LOCATOR_COPY[lang].closed;\n}\n\n/** \"Open today 10:00-20:30\" or \"Closed today\". */\nexport function todayLine(feature: StoreFeature, day: number, lang: LocatorLang): string {\n  const hours = feature.properties.hours[DAYS[day] ?? \"sun\"];\n  const copy = LOCATOR_COPY[lang];\n  return hours ? `${copy.openToday} ${hours}` : copy.closedToday;\n}\n\n/**\n * What to tell the reader about a failed request: the place, the key or the rate when the gateway\n * says so; a service that did not answer for a 5xx or a request that never got an HTTP answer.\n */\nexport function requestError(caught: unknown, lang: LocatorLang): string {\n  const copy = LOCATOR_COPY[lang];\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { code?: unknown; status?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : null;\n  if (failure.code === \"outside_coverage\") return copy.outside;\n  if (failure.code === \"no_route\") return copy.noRoute;\n  if (status === 429) return copy.busy;\n  if (status === 401 || status === 403) return copy.keyRefused;\n  if (status === null || status >= 500) return copy.unavailable;\n  return copy.failed;\n}\n\nexport function isAbort(caught: unknown): boolean {\n  return typeof caught === \"object\" && caught !== null && (caught as { name?: unknown }).name === \"AbortError\";\n}\n\n/** True when the reader asked for less motion: the camera jumps and the route appears whole. */\nexport function prefersReducedMotion(): boolean {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")?.matches === true;\n}\n\n/** Fast start, soft landing: the route's draw-in. */\nexport function easeOut(t: number): number {\n  return 1 - (1 - t) ** 3;\n}\n// --- end store-locator helpers ---\n\nexport interface StoreLocatorProps {\n  /** unmap API key. The page passes NEXT_PUBLIC_UNMAP_KEY. With none, the block asks for one and loads nothing. */\n  apiKey: string;\n  /** Gateway base URL. Defaults to the unmap production gateway. */\n  gateway?: string;\n  /** Your stores: a FeatureCollection of points with StoreProperties. Defaults to the sample Ottawa Public Library branches. */\n  stores?: StoreCollection;\n  /** The drive time the halo round a store covers, in minutes (1 to 120). Defaults to 10. */\n  driveMinutes?: number;\n  /** Where the map opens, [longitude, latitude]. Defaults to downtown Ottawa. */\n  center?: [number, number];\n  /** Opening zoom. Defaults to 11. */\n  zoom?: number;\n  /** Language of the copy, the numbers and the store names. Defaults to \"en\". */\n  lang?: LocatorLang;\n  className?: string;\n}\n\nexport function StoreLocator({\n  apiKey,\n  gateway,\n  stores = STORES,\n  driveMinutes = 10,\n  center = OTTAWA,\n  zoom = 11,\n  lang = \"en\",\n  className,\n}: StoreLocatorProps) {\n  if (!apiKey.trim()) {\n    const copy = LOCATOR_COPY[lang];\n    // Where the map would be: the block's gutter, the Alert at a readable width, the first action first.\n    return (\n      <div\n        data-unmap-block=\"store-locator\"\n        data-state=\"no-key\"\n        className={[\"flex h-full min-h-[480px] items-start justify-center bg-muted/40 p-4 sm:items-center sm:p-6\", className].filter(Boolean).join(\" \")}\n      >\n        <Alert role=\"alert\" className=\"w-full max-w-md motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          <AlertTitle className=\"line-clamp-none\">{copy.noKeyTitle}</AlertTitle>\n          <AlertDescription className=\"flex flex-col items-start gap-3\">\n            <a className=\"font-medium text-primary underline underline-offset-4\" href={KEY_URL} target=\"_blank\" rel=\"noreferrer\">\n              {copy.getKey}\n            </a>\n            <p>{copy.noKey}</p>\n          </AlertDescription>\n        </Alert>\n      </div>\n    );\n  }\n  return (\n    <div data-unmap-block=\"store-locator\" className={[\"relative h-full min-h-[480px]\", className].filter(Boolean).join(\" \")}>\n      <Map id={LOCATOR_MAP_ID} apiKey={apiKey} gateway={gateway} center={center} zoom={zoom} lang={lang} className=\"h-full\">\n        <Locator apiKey={apiKey} gateway={gateway} stores={stores} driveMinutes={driveMinutes} lang={lang} />\n      </Map>\n    </div>\n  );\n}\n\ntype Trip = { coordinates: LngLat[]; distanceMeters: number; durationSeconds: number };\ntype Hover = { id: string; from: \"map\" | \"list\" };\ntype Pending = \"route\" | \"reach\";\ntype MapPointerEvent = { features?: Array<{ properties?: Record<string, unknown> | null }> };\n\n/** 0 to 1 as a new line draws in; 1 at once under reduced motion. */\nfunction useDrawIn(line: unknown): number {\n  const [drawn, setDrawn] = useState<{ line: unknown; progress: number }>({ line: null, progress: 1 });\n  useEffect(() => {\n    if (!line || prefersReducedMotion()) return;\n    if (typeof requestAnimationFrame !== \"function\") {\n      setDrawn({ line, progress: 1 });\n      return;\n    }\n    const start = performance.now();\n    let frame = 0;\n    const step = () => {\n      const t = Math.min(1, (performance.now() - start) / DRAW_MS);\n      setDrawn({ line, progress: easeOut(t) });\n      if (t < 1) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [line]);\n  if (prefersReducedMotion()) return 1;\n  return drawn.line === line ? drawn.progress : 0;\n}\n\n/** The route on the map, drawing in. Its own component so the draw-in's frames re-render the line alone, not the panel and its list. */\nfunction DrawnRoute({ line }: { line: Trip }) {\n  const progress = useDrawIn(line);\n  return <RouteLine mapId={LOCATOR_MAP_ID} id={LOCATOR_LAYERS.route} coordinates={line.coordinates} progress={progress} />;\n}\n\n/** Everything that needs the map. It renders inside <Map>, where useMap() can see it. */\nfunction Locator({\n  apiKey,\n  gateway,\n  stores,\n  driveMinutes,\n  lang,\n}: {\n  apiKey: string;\n  gateway?: string;\n  stores: StoreCollection;\n  driveMinutes: number;\n  lang: LocatorLang;\n}) {\n  const copy = LOCATOR_COPY[lang];\n  const map = useMap(LOCATOR_MAP_ID);\n  const router = useMemo(() => new Router({ key: apiKey, gateway }), [apiKey, gateway]);\n  const [bounds, setBounds] = useState<LocatorBounds | null>(null);\n  const [origin, setOrigin] = useState<LocatorOrigin | null>(null);\n  const [selected, setSelected] = useState<string | null>(null);\n  const [hover, setHover] = useState<Hover | null>(null);\n  const [reach, setReach] = useState<LocatorReach | null>(null);\n  const [trip, setTrip] = useState<Trip | null>(null);\n  const [pending, setPending] = useState<Pending | null>(null);\n  const [error, setError] = useState<{ message: string; retry: Pending } | null>(null);\n  const [expanded, setExpanded] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  // The reader's weekday, read after mount: a server in another timezone (or on another day) would\n  // render different hours than the browser hydrates, so the first pass shows none.\n  const [day, setDay] = useState<number | null>(null);\n  const abortRef = useRef<{ controller: AbortController; kind: Pending } | null>(null);\n  const panelRef = useRef<HTMLDivElement | null>(null);\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n  const listId = useId();\n  const hintId = useId();\n\n  useEffect(() => setDay(new Date().getDay()), []);\n\n  // The list follows the view: read the bounds now and after every move.\n  useEffect(() => {\n    if (!map) return;\n    const update = () => setBounds(boundsOf(map));\n    update();\n    map.on(\"moveend\", update);\n    return () => {\n      map.off(\"moveend\", update);\n    };\n  }, [map]);\n\n  // The store under the pointer lights its row.\n  useEffect(() => {\n    if (!map) return;\n    const move = (event: MapPointerEvent) => {\n      const id = event.features?.[0]?.properties?.id;\n      if (id === undefined || id === null) return;\n      setHover((current) => (current?.from === \"map\" && current.id === String(id) ? current : { id: String(id), from: \"map\" }));\n    };\n    const leave = () => setHover((current) => (current?.from === \"map\" ? null : current));\n    map.on(\"mousemove\", LOCATOR_LAYERS.points, move as never);\n    map.on(\"mouseleave\", LOCATOR_LAYERS.points, leave as never);\n    return () => {\n      map.off(\"mousemove\", LOCATOR_LAYERS.points, move as never);\n      map.off(\"mouseleave\", LOCATOR_LAYERS.points, leave as never);\n    };\n  }, [map]);\n\n  // A row lit from the map scrolls into the list's view. Only the list scrolls: the panel and the\n  // page stay where the reader has them.\n  useEffect(() => {\n    const scroller = scrollRef.current;\n    if (hover?.from !== \"map\" || !scroller) return;\n    const row = [...scroller.querySelectorAll<HTMLElement>(\"[data-store-id]\")].find(\n      (element) => element.getAttribute(\"data-store-id\") === hover.id,\n    );\n    if (!row) return;\n    const top = listScrollTop(scroller, row);\n    if (top !== scroller.scrollTop) scroller.scrollTop = top;\n  }, [hover]);\n\n  useEffect(\n    () => () => {\n      abortRef.current?.controller.abort();\n      abortRef.current = null;\n    },\n    [],\n  );\n\n  function padding(): LocatorPadding {\n    const container = map?.getContainer();\n    if (!container) return panelPadding({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }, null);\n    return panelPadding(container.getBoundingClientRect(), panelRef.current?.getBoundingClientRect() ?? null);\n  }\n  const duration = () => (prefersReducedMotion() ? 0 : CAMERA_MS);\n\n  // Search pulls the map: the searched place and its nearest stores, clear of the panel. This runs\n  // after the geocoder's own fly to the result, and takes over from it.\n  useEffect(() => {\n    if (!map || !origin) return;\n    const nearest = visibleStores(stores, { bounds: null, origin, lang }).slice(0, NEAREST_FRAMED);\n    const points: [number, number][] = [[origin.lng, origin.lat], ...nearest.map((row) => row.feature.geometry.coordinates)];\n    map.fitBounds(pointsBounds(points), { padding: padding(), maxZoom: 14, duration: duration() });\n    // Only a new search moves the camera; `stores` and `lang` changing under it do not.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [map, origin]);\n\n  const rows = useMemo(() => visibleStores(stores, { bounds, origin, lang }), [stores, bounds, origin, lang]);\n  const store = stores.features.find((feature) => feature.properties.id === selected) ?? null;\n  const focus = useMemo(() => focusCollection(stores, [hover?.id, selected]), [stores, hover, selected]);\n  const originLngLat = useMemo<[number, number] | null>(() => (origin ? [origin.lng, origin.lat] : null), [origin]);\n\n  function cancel() {\n    abortRef.current?.controller.abort();\n    abortRef.current = null;\n    setPending(null);\n  }\n\n  /** One request at a time: a new one aborts the last, and only the latest may set state. */\n  async function request<T>(kind: Pending, call: (signal: AbortSignal) => Promise<T>, done: (result: T) => void) {\n    cancel();\n    const current = { controller: new AbortController(), kind };\n    abortRef.current = current;\n    setPending(kind);\n    setError(null);\n    try {\n      const result = await call(current.controller.signal);\n      if (abortRef.current === current) done(result);\n    } catch (caught) {\n      if (abortRef.current === current && !isAbort(caught)) setError({ message: requestError(caught, lang), retry: kind });\n    } finally {\n      if (abortRef.current === current) {\n        abortRef.current = null;\n        setPending(null);\n      }\n    }\n  }\n\n  function choose(result: { name: string; lng: number; lat: number }) {\n    // A new start makes a route stale; a halo round the store still holds.\n    if (abortRef.current?.kind === \"route\") cancel();\n    const next = { name: result.name, lng: result.lng, lat: result.lat };\n    setOrigin(next);\n    setTrip(null);\n    setError(null);\n    const nearest = visibleStores(stores, { bounds: null, origin: next, lang })[0];\n    setAnnouncement(nearest ? copy.announceNearest(next.name, storeName(nearest.feature, lang), formatDistance(nearest.distance!, lang)) : \"\");\n  }\n\n  function select(id: string | null) {\n    // Picking the store already picked keeps its route, halo and answers.\n    if (id === selected) return;\n    cancel();\n    setSelected(id);\n    setTrip(null);\n    setReach(null);\n    setError(null);\n    const picked = id ? stores.features.find((feature) => feature.properties.id === id) : null;\n    setAnnouncement(picked ? copy.announcePick(storeName(picked, lang)) : \"\");\n  }\n\n  function pick(feature: StoreFeature) {\n    select(feature.properties.id);\n    map?.easeTo({\n      center: feature.geometry.coordinates,\n      zoom: Math.max(map.getZoom(), 13),\n      offset: paddingOffset(padding()),\n      duration: duration(),\n    });\n  }\n\n  function showAll() {\n    if (stores.features.length === 0) return;\n    map?.fitBounds(pointsBounds(stores.features.map((feature) => feature.geometry.coordinates)), {\n      padding: padding(),\n      duration: duration(),\n    });\n  }\n\n  function drawReach() {\n    if (!store) return;\n    void request(\n      \"reach\",\n      (signal) => router.isochrone(store.geometry.coordinates, { minutes: [driveMinutes], mode: \"auto\", signal }),\n      (collection) => {\n        const area = collection as LocatorReach;\n        setReach(area);\n        const frame = reachBounds(area);\n        if (frame) map?.fitBounds(frame, { padding: padding(), duration: duration() });\n      },\n    );\n  }\n\n  function toggleReach() {\n    if (reach) setReach(null);\n    else drawReach();\n  }\n\n  function directions() {\n    if (!origin || !store) return;\n    void request(\n      \"route\",\n      (signal) => router.route([origin.lng, origin.lat], store.geometry.coordinates, { mode: \"auto\", signal }),\n      (result) => {\n        setTrip({ coordinates: result.geometry.coordinates, distanceMeters: result.distanceMeters, durationSeconds: result.durationSeconds });\n        map?.fitBounds(pointsBounds(result.geometry.coordinates), { padding: padding(), duration: duration() });\n      },\n    );\n  }\n\n  function leaveRow() {\n    setHover((current) => (current?.from === \"list\" ? null : current));\n  }\n\n  const name = store ? storeName(store, lang) : \"\";\n  const answer =\n    store && reach\n      ? origin\n        ? inReach([origin.lng, origin.lat], reach)\n          ? copy.reachInside(origin.name, driveMinutes, name)\n          : copy.reachOutside(origin.name, driveMinutes, name)\n        : copy.reachArea(driveMinutes, name)\n      : null;\n  const french = lang === \"fr\";\n\n  return (\n    <>\n      <ClusterLayer\n        mapId={LOCATOR_MAP_ID}\n        id={LOCATOR_LAYERS.stores}\n        data={stores}\n        onClick={(event) => select(String(event.feature.properties.id))}\n      />\n      {reach ? <GeoJSONLayer mapId={LOCATOR_MAP_ID} id={LOCATOR_LAYERS.reach} data={reach} fillPaint={REACH_FILL} circlePaint={false} /> : null}\n      {trip ? <DrawnRoute line={trip} /> : null}\n      <GeoJSONLayer mapId={LOCATOR_MAP_ID} id={LOCATOR_LAYERS.focus} data={focus} fillPaint={false} linePaint={false} circlePaint={FOCUS_CIRCLE} />\n      {/* The picked store: your pin (in --primary) with its name. */}\n      {store ? (\n        <Marker key={store.properties.id} mapId={LOCATOR_MAP_ID} lngLat={store.geometry.coordinates}>\n          <MarkerLabel>{name}</MarkerLabel>\n        </Marker>\n      ) : null}\n      {/* The searched place: a \"you are here\" dot in --foreground, so it never reads as a store. */}\n      {origin && originLngLat ? (\n        <Marker\n          mapId={LOCATOR_MAP_ID}\n          lngLat={originLngLat}\n          markerContent={<span className=\"block size-4 rounded-full border-[3px] border-background bg-foreground\" />}\n        >\n          <MarkerLabel>{origin.name}</MarkerLabel>\n        </Marker>\n      ) : null}\n      <p data-testid=\"store-locator-announce\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </p>\n      {/* A card floating on the map at 24 px; below `sm`, a sheet across the bottom (at most half the map) that clears the attribution. */}\n      <div\n        ref={panelRef}\n        className=\"absolute inset-x-4 bottom-10 z-10 flex max-h-[50%] flex-col sm:inset-x-auto sm:top-6 sm:bottom-auto sm:left-6 sm:max-h-[calc(100%-3rem)] sm:w-[22rem] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-4 motion-safe:duration-300 sm:motion-safe:slide-in-from-bottom-0 sm:motion-safe:slide-in-from-left-4\"\n      >\n        <Card className=\"flex min-h-0 flex-1 flex-col\">\n          <CardHeader>\n            <CardTitle role=\"heading\" aria-level={2}>\n              {copy.title}\n            </CardTitle>\n            <CardDescription className=\"max-sm:hidden\">{copy.intro}</CardDescription>\n          </CardHeader>\n          {/* The search, the picked store and the count stay put; only the list below them scrolls. On a phone the whole sheet scrolls. */}\n          <CardContent className=\"flex min-h-0 flex-1 flex-col gap-4 max-sm:overflow-y-auto max-sm:overscroll-contain\">\n            <Geocoder\n              mapId={LOCATOR_MAP_ID}\n              apiKey={apiKey}\n              gateway={gateway}\n              lang={lang}\n              placeholder={copy.search}\n              proximity=\"map\"\n              flyToZoom={13}\n              onSelect={choose}\n            />\n            {store ? (\n              <section\n                key={store.properties.id}\n                aria-label={name}\n                className=\"flex shrink-0 flex-col gap-3 border-y py-4 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200\"\n              >\n                <div className=\"flex items-start justify-between gap-2\">\n                  <div className=\"flex min-w-0 flex-col gap-1\">\n                    <p className=\"text-lg leading-tight font-semibold\">{name}</p>\n                    <p className=\"text-sm text-muted-foreground\">{store.properties.address}</p>\n                  </div>\n                  <Button type=\"button\" variant=\"ghost\" size=\"icon\" aria-label={copy.close} onClick={() => select(null)}>\n                    <span aria-hidden=\"true\">×</span>\n                  </Button>\n                </div>\n                {day === null ? null : <p className=\"text-sm\">{todayLine(store, day, lang)}</p>}\n                <div className=\"flex flex-wrap gap-2\">\n                  <Button\n                    type=\"button\"\n                    disabled={!origin || pending !== null}\n                    aria-describedby={origin ? undefined : hintId}\n                    onClick={directions}\n                  >\n                    {copy.directions}\n                  </Button>\n                  <Button type=\"button\" variant=\"outline\" aria-pressed={reach !== null} disabled={pending !== null} onClick={toggleReach}>\n                    {copy.reach(driveMinutes)}\n                  </Button>\n                </div>\n                {/* Where the answers land, always there, so nothing jumps when one arrives. */}\n                <div data-testid=\"store-locator-answer\" aria-live=\"polite\" className=\"flex min-h-10 flex-col justify-center gap-2\">\n                  {origin ? null : (\n                    <p id={hintId} className=\"text-xs text-muted-foreground\">\n                      {copy.needOrigin}\n                    </p>\n                  )}\n                  {pending ? (\n                    <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                      <Spinner /> {pending === \"route\" ? copy.loadingRoute : copy.loadingReach}\n                    </p>\n                  ) : null}\n                  {trip ? (\n                    <p data-testid=\"store-locator-trip\" className=\"flex items-baseline gap-2\">\n                      <span className=\"text-2xl font-semibold tabular-nums\">{formatDuration(trip.durationSeconds, lang)}</span>{\" \"}\n                      <span className=\"text-sm text-muted-foreground tabular-nums\">{`${formatDistance(trip.distanceMeters, lang)} ${copy.byCar}`}</span>\n                    </p>\n                  ) : null}\n                  {answer ? <p className=\"text-sm\">{answer}</p> : null}\n                  {error ? (\n                    <Alert variant=\"destructive\" role=\"alert\">\n                      <AlertDescription className=\"flex flex-col items-start gap-2\">\n                        <span>{error.message}</span>\n                        <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={error.retry === \"route\" ? directions : drawReach}>\n                          {copy.retry}\n                        </Button>\n                      </AlertDescription>\n                    </Alert>\n                  ) : null}\n                </div>\n              </section>\n            ) : null}\n            <div className=\"flex shrink-0 items-center gap-2\">\n              <p data-testid=\"store-locator-count\" className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                <span className={store ? \"text-base font-semibold tabular-nums\" : \"text-2xl font-semibold tabular-nums\"}>{rows.length}</span>{\" \"}\n                <span className=\"text-sm text-muted-foreground\">{copy.count(rows.length)}</span>\n              </p>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"sm:hidden\"\n                aria-expanded={expanded}\n                aria-controls={listId}\n                onClick={() => setExpanded((open) => !open)}\n              >\n                {expanded ? copy.hideList : copy.showList}\n              </Button>\n            </div>\n            <div id={listId} data-expanded={expanded} className=\"flex min-h-0 flex-1 flex-col max-sm:flex-none max-sm:data-[expanded=false]:hidden\">\n              <div ref={scrollRef} data-store-locator-scroll=\"\" className=\"relative -mx-2 min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 max-sm:flex-none max-sm:overflow-visible\">\n                {rows.length === 0 ? (\n                  <div className=\"flex flex-col items-start gap-2\">\n                    <p className=\"text-sm text-muted-foreground\">{copy.empty}</p>\n                    <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={showAll}>\n                      {copy.allStores}\n                    </Button>\n                  </div>\n                ) : (\n                  <ul aria-label={copy.list} className=\"flex flex-col gap-0.5\">\n                    {rows.slice(0, LIST_LIMIT).map(({ feature, distance }) => {\n                      const id = feature.properties.id;\n                      const picked = id === selected;\n                      return (\n                        <li key={id}>\n                          <Button\n                            type=\"button\"\n                            variant={picked ? \"secondary\" : \"ghost\"}\n                            aria-pressed={picked}\n                            data-store-id={id}\n                            data-hovered={hover?.id === id ? \"true\" : undefined}\n                            className=\"h-auto w-full justify-between gap-3 py-2 text-left whitespace-normal transition-colors hover:bg-muted focus-visible:bg-muted data-[hovered=true]:bg-muted\"\n                            onClick={() => pick(feature)}\n                            onMouseEnter={() => setHover({ id, from: \"list\" })}\n                            onMouseLeave={leaveRow}\n                            onFocus={() => setHover({ id, from: \"list\" })}\n                            onBlur={leaveRow}\n                          >\n                            <span className=\"flex min-w-0 flex-col items-start gap-0.5\">\n                              <span className={picked ? \"flex items-center gap-1.5 font-semibold\" : \"flex items-center gap-1.5 font-medium\"}>\n                                {/* Picked is shown by weight and a mark, not by colour alone. */}\n                                {picked ? (\n                                  <span aria-hidden=\"true\" data-selected-mark=\"\">\n                                    ✓\n                                  </span>\n                                ) : null}\n                                {storeName(feature, lang)}\n                              </span>\n                              <span className=\"min-h-4 text-xs font-normal text-muted-foreground\">\n                                {day === null ? \"\" : todayLine(feature, day, lang)}\n                              </span>\n                            </span>\n                            {distance === null ? null : (\n                              <span className=\"shrink-0 text-xs font-normal text-muted-foreground tabular-nums\">{formatDistance(distance, lang)}</span>\n                            )}\n                          </Button>\n                        </li>\n                      );\n                    })}\n                  </ul>\n                )}\n                {rows.length > LIST_LIMIT ? <p className=\"py-2 text-xs text-muted-foreground\">{copy.capped(LIST_LIMIT)}</p> : null}\n              </div>\n            </div>\n            <div data-testid=\"store-locator-credits\" className=\"flex shrink-0 flex-col gap-1 text-xs text-muted-foreground\">\n              {stores === STORES ? (\n                <p>\n                  {copy.source}{\" \"}\n                  <a className=\"underline underline-offset-2\" href={STORES_SOURCE.url} target=\"_blank\" rel=\"noreferrer\">\n                    {french ? STORES_SOURCE.name_fr : STORES_SOURCE.name}\n                  </a>\n                  .{\" \"}\n                  <a className=\"underline underline-offset-2\" href={STORES_SOURCE.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                    {french ? STORES_SOURCE.licence_fr : STORES_SOURCE.licence}\n                  </a>\n                </p>\n              ) : null}\n              <p>\n                {copy.routesCredit}{\" \"}\n                <a className=\"underline underline-offset-2\" href={ROUTING_CREDIT.sourceUrl} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.osm}\n                </a>\n                ,{\" \"}\n                <a className=\"underline underline-offset-2\" href={ROUTING_CREDIT.licenceUrl} target=\"_blank\" rel=\"noreferrer\">\n                  {copy.odbl}\n                </a>\n                .\n              </p>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/store-locator-data.ts",
      "content": "/**\n * Ottawa Public Library branches: the sample locations of the store-locator block.\n *\n * From the City of Ottawa open data item \"Library Locations and Hours (Ottawa Public Libraries)\n * API\" (d64cd889fa7d4852845d9d9e208fe1ea): the English and French branch feeds, retrieved\n * 2026-09-29, joined on postal code. Hours are the regular weekly hours on that date (null is\n * closed); holiday exceptions are not included and the hours will go stale. Licensed under the\n * Open Government Licence, City of Ottawa, version 2.0.\n *\n * Replace STORES with your own points: any FeatureCollection with these properties works.\n */\n\nexport type StoreDay = \"sun\" | \"mon\" | \"tue\" | \"wed\" | \"thu\" | \"fri\" | \"sat\";\n\nexport interface StoreProperties {\n  id: string;\n  name: string;\n  name_fr: string;\n  address: string;\n  hours: Record<StoreDay, string | null>;\n}\n\nexport interface StoreFeature {\n  type: \"Feature\";\n  geometry: { type: \"Point\"; coordinates: [number, number] };\n  properties: StoreProperties;\n}\n\nexport interface StoreCollection {\n  type: \"FeatureCollection\";\n  features: StoreFeature[];\n}\n\n/**\n * Credit line the block prints under the list. The licence asks for this wording; the French line\n * keeps the licence's official name.\n */\nexport const STORES_SOURCE = {\n  name: \"Ottawa Public Library branches, City of Ottawa open data\",\n  name_fr: \"Succursales de la Bibliothèque publique d'Ottawa, données ouvertes de la Ville d'Ottawa\",\n  url: \"https://open.ottawa.ca/datasets/d64cd889fa7d4852845d9d9e208fe1ea\",\n  licence: \"Contains information licensed under the Open Government Licence – City of Ottawa.\",\n  licence_fr: \"Contient de l'information visée par l'Open Government Licence – City of Ottawa.\",\n  licenceUrl: \"https://ottawa.ca/en/city-hall/open-transparent-and-accountable-government/open-data/open-data-licence-version-20\",\n};\n\nexport const STORES: StoreCollection = {\n  type: \"FeatureCollection\",\n  features: [\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.66172,45.378111]},\"properties\":{\"id\":\"alta-vista\",\"name\":\"Alta Vista\",\"name_fr\":\"Alta Vista\",\"address\":\"2516 Alta Vista, Ottawa, ON K1V 7T1\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.895303,45.321871]},\"properties\":{\"id\":\"beaverbrook\",\"name\":\"Beaverbrook\",\"name_fr\":\"Beaverbrook\",\"address\":\"2500 Campeau, Ottawa, ON K2K 2W3\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-21:00\",\"tue\":\"10:00-21:00\",\"wed\":\"10:00-21:00\",\"thu\":\"10:00-21:00\",\"fri\":\"10:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.564533,45.43091]},\"properties\":{\"id\":\"blackburn-hamlet\",\"name\":\"Blackburn Hamlet\",\"name_fr\":\"Blackburn Hamlet\",\"address\":\"199 Glen Park, Ottawa, ON K1B 5B8\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.77204,45.3732]},\"properties\":{\"id\":\"carlingwood\",\"name\":\"Carlingwood\",\"name_fr\":\"Carlingwood\",\"address\":\"281 Woodroffe, Ottawa, ON K2A 3W4\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-76.047203,45.351002]},\"properties\":{\"id\":\"carp\",\"name\":\"Carp\",\"name_fr\":\"Carp\",\"address\":\"3911 Carp, Ottawa, ON K0A 1L0\",\"hours\":{\"sun\":null,\"mon\":\"10:00-18:00\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.826202,45.320702]},\"properties\":{\"id\":\"centennial\",\"name\":\"Centennial\",\"name_fr\":\"Centennial\",\"address\":\"3870 Old Richmond, Ottawa, ON K2H 5C4\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-76.093498,45.499001]},\"properties\":{\"id\":\"constance-bay\",\"name\":\"Constance Bay\",\"name_fr\":\"Constance Bay\",\"address\":\"262 Len Purcell, Ottawa, ON K0A 3M0\",\"hours\":{\"sun\":null,\"mon\":null,\"tue\":\"10:00-17:00\",\"wed\":\"16:00-20:00\",\"thu\":\"16:00-20:00\",\"fri\":\"15:00-18:00\",\"sat\":\"10:00-14:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.493202,45.471199]},\"properties\":{\"id\":\"cumberland\",\"name\":\"Cumberland\",\"name_fr\":\"Cumberland\",\"address\":\"1599 Tenth Line, Ottawa, ON K1E 3E8\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-21:00\",\"tue\":\"10:00-21:00\",\"wed\":\"10:00-21:00\",\"thu\":\"10:00-21:00\",\"fri\":\"10:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.624702,45.399899]},\"properties\":{\"id\":\"elmvale-acres\",\"name\":\"Elmvale Acres\",\"name_fr\":\"Elmvale Acres\",\"address\":\"1910 St-Laurent, Ottawa, ON K1G 1A4\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.733398,45.354698]},\"properties\":{\"id\":\"emerald-plaza\",\"name\":\"Emerald Plaza\",\"name_fr\":\"Emerald Plaza\",\"address\":\"1547 Merivale, Ottawa, ON K2G 4V3\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-76.206299,45.4701]},\"properties\":{\"id\":\"fitzroy-harbour\",\"name\":\"Fitzroy Harbour\",\"name_fr\":\"Fitzroy Harbour\",\"address\":\"100 Clifford Campbell, Ottawa, ON K0A 1X0\",\"hours\":{\"sun\":null,\"mon\":\"17:00-20:00\",\"tue\":null,\"wed\":\"17:00-20:00\",\"thu\":\"10:00-13:00\",\"fri\":null,\"sat\":\"10:00-13:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.557198,45.2612]},\"properties\":{\"id\":\"greely\",\"name\":\"Greely\",\"name_fr\":\"Greely\",\"address\":\"1448 Meadow Drive, Ottawa, ON K4P 1B1\",\"hours\":{\"sun\":null,\"mon\":\"10:00-18:00\",\"tue\":\"10:00-20:00\",\"wed\":\"10:00-20:00\",\"thu\":\"10:00-18:00\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.635399,45.3634]},\"properties\":{\"id\":\"greenboro\",\"name\":\"Greenboro\",\"name_fr\":\"Greenboro\",\"address\":\"363 Lorry Greenberg, Ottawa, ON K1T 3P8\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-21:00\",\"tue\":\"10:00-21:00\",\"wed\":\"10:00-21:00\",\"thu\":\"10:00-21:00\",\"fri\":\"10:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.885902,45.295399]},\"properties\":{\"id\":\"hazeldean\",\"name\":\"Hazeldean\",\"name_fr\":\"Hazeldean\",\"address\":\"50 Castlefrank, Ottawa, ON K2L 2N5\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.695396,45.4203]},\"properties\":{\"id\":\"main\",\"name\":\"Main\",\"name_fr\":\"Centrale\",\"address\":\"120 Metcalfe, Ottawa, ON K1P 5M2\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-21:00\",\"tue\":\"10:00-21:00\",\"wed\":\"10:00-21:00\",\"thu\":\"10:00-21:00\",\"fri\":\"10:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.679604,45.230301]},\"properties\":{\"id\":\"manotick\",\"name\":\"Manotick\",\"name_fr\":\"Manotick\",\"address\":\"5499 South River, Ottawa, ON K4M 1J3\",\"hours\":{\"sun\":null,\"mon\":null,\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.471778,45.236744]},\"properties\":{\"id\":\"metcalfe-village\",\"name\":\"Metcalfe village\",\"name_fr\":\"Metcalfe\",\"address\":\"8243 Victoria St, Ottawa, ON K0A 2P0\",\"hours\":{\"sun\":null,\"mon\":null,\"tue\":\"10:00-20:00\",\"wed\":\"10:00-14:00\",\"thu\":\"10:00-20:00\",\"fri\":\"13:00-17:00\",\"sat\":\"10:00-14:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.939102,45.165199]},\"properties\":{\"id\":\"munster\",\"name\":\"Munster\",\"name_fr\":\"Munster\",\"address\":\"7749 Bleeks, Ottawa, ON K0A 3P0\",\"hours\":{\"sun\":null,\"mon\":\"10:00-14:00\",\"tue\":\"15:00-20:00\",\"wed\":\"10:00-14:00\",\"thu\":\"15:00-20:00\",\"fri\":null,\"sat\":\"10:00-14:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.762703,45.3442]},\"properties\":{\"id\":\"nepean-centrepointe\",\"name\":\"Nepean Centrepointe\",\"name_fr\":\"Nepean Centrepointe\",\"address\":\"101 Centrepointe, Ottawa, ON K2G 5K7\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-21:00\",\"tue\":\"10:00-21:00\",\"wed\":\"10:00-21:00\",\"thu\":\"10:00-21:00\",\"fri\":\"10:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.600899,45.438301]},\"properties\":{\"id\":\"north-gloucester\",\"name\":\"North Gloucester\",\"name_fr\":\"Gloucester Nord\",\"address\":\"2036 Ogilvie, Ottawa, ON K1J 7N8\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.717598,45.132801]},\"properties\":{\"id\":\"north-gower\",\"name\":\"North Gower\",\"name_fr\":\"North Gower\",\"address\":\"6579 Fourth Line, Ottawa, ON K0A 2T0\",\"hours\":{\"sun\":null,\"mon\":\"10:00-16:00\",\"tue\":\"13:00-20:00\",\"wed\":\"10:00-18:00\",\"thu\":\"13:00-20:00\",\"fri\":null,\"sat\":\"10:00-14:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.5242,45.4566]},\"properties\":{\"id\":\"orleans\",\"name\":\"Orléans\",\"name_fr\":\"Orléans\",\"address\":\"1705 Orléans, Ottawa, ON K1C 4W2\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.603203,45.1474]},\"properties\":{\"id\":\"osgoode\",\"name\":\"Osgoode\",\"name_fr\":\"Osgoode\",\"address\":\"5630 Osgoode Main, Ottawa, ON K0A 2W0\",\"hours\":{\"sun\":null,\"mon\":\"10:00-17:00\",\"tue\":\"10:00-20:00\",\"wed\":\"13:00-17:00\",\"thu\":\"13:00-20:00\",\"fri\":null,\"sat\":\"10:00-14:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.843903,45.190899]},\"properties\":{\"id\":\"richmond\",\"name\":\"Richmond\",\"name_fr\":\"Richmond\",\"address\":\"6240 Perth Street, Ottawa, ON K0A 2Z0\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:00\",\"tue\":\"10:00-18:00\",\"wed\":\"10:00-20:00\",\"thu\":\"10:00-18:00\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.683098,45.430099]},\"properties\":{\"id\":\"rideau\",\"name\":\"Rideau\",\"name_fr\":\"Rideau\",\"address\":\"377 Rideau, Ottawa, ON K1N 5Y6\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.6791,45.451]},\"properties\":{\"id\":\"rockcliffe-park\",\"name\":\"Rockcliffe Park\",\"name_fr\":\"Rockcliffe Park\",\"address\":\"380 Springfield, Ottawa, ON K1M 0L7\",\"hours\":{\"sun\":null,\"mon\":\"10:00-17:00\",\"tue\":\"13:00-20:00\",\"wed\":\"10:00-17:00\",\"thu\":\"13:00-20:00\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-14:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.726315,45.401637]},\"properties\":{\"id\":\"rosemount\",\"name\":\"Rosemount\",\"name_fr\":\"Rosemount\",\"address\":\"18 Rosemount, Ottawa, ON K1Y 1P4\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.761497,45.2799]},\"properties\":{\"id\":\"ruth-e-dickinson\",\"name\":\"Ruth E. Dickinson\",\"name_fr\":\"Ruth E. Dickinson\",\"address\":\"100 Malvern, Ottawa, ON K2J 2G5\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-21:00\",\"tue\":\"10:00-21:00\",\"wed\":\"10:00-21:00\",\"thu\":\"10:00-21:00\",\"fri\":\"10:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.647903,45.436199]},\"properties\":{\"id\":\"st-laurent\",\"name\":\"St-Laurent\",\"name_fr\":\"St-Laurent\",\"address\":\"515 Côté, Ottawa, ON K1K 0Z8\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.915497,45.2542]},\"properties\":{\"id\":\"stittsville\",\"name\":\"Stittsville\",\"name_fr\":\"Stittsville\",\"address\":\"1637 Stittsville Main, Ottawa, ON K2S 1A9\",\"hours\":{\"sun\":\"10:00-17:00\",\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.683899,45.3955]},\"properties\":{\"id\":\"sunnyside\",\"name\":\"Sunnyside\",\"name_fr\":\"Sunnyside\",\"address\":\"1049 Bank, Ottawa, ON K1S 3W9\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"10:00-20:30\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.6604,45.444302]},\"properties\":{\"id\":\"vanier\",\"name\":\"Vanier\",\"name_fr\":\"Vanier\",\"address\":\"310 Pères Blancs, Ottawa, ON K1L 7L5\",\"hours\":{\"sun\":null,\"mon\":\"10:00-20:30\",\"tue\":\"10:00-20:30\",\"wed\":\"10:00-20:30\",\"thu\":\"13:00-18:00\",\"fri\":\"13:00-18:00\",\"sat\":\"10:00-17:00\"}}},\n    {\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[-75.463799,45.1633]},\"properties\":{\"id\":\"vernon\",\"name\":\"Vernon\",\"name_fr\":\"Vernon\",\"address\":\"8682 Bank, Ottawa, ON K0A 3J0\",\"hours\":{\"sun\":null,\"mon\":null,\"tue\":\"10:00-17:00\",\"wed\":\"16:00-20:00\",\"thu\":\"13:00-20:00\",\"fri\":null,\"sat\":\"10:00-14:00\"}}},\n  ],\n};\n",
      "type": "registry:lib"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /store-locator. Guide: https://unmap.dev/docs/frontend/blocks#find-a-branch",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}