{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "locations-1",
  "title": "Locations 1: finder on a world map",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A locations finder on a dotted world map: pressable pins, a searchable list, filters by type, and a card for the chosen place with address, contacts, directions and whether it is open now in its own time zone.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/dotted-map.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/locations-1/locations-1.tsx",
      "content": "// Ballmac UI: Locations 1. https://ui.ballmac.com/blocks/locations-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Clock, Mail, MapPin, Phone, Search, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { DottedMap } from \"@/components/ballmac/dotted-map\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Locations1Place = {\n  id: string\n  name: string\n  kind: string\n  city: string\n  country: string\n  address: string\n  lat: number\n  lng: number\n  /** Hours from UTC, such as 1 or -5 or 5.5, used for \"open now\". */\n  utcOffset: number\n  /** Opening hours as 24-hour numbers, 9 and 17.5 for 09:00 to 17:30. Days not listed in `days` are closed. */\n  hours: { open: number; close: number; /** 0 is Sunday to 6 is Saturday. Defaults to Monday to Friday. */ days?: number[] }\n  phone?: string\n  email?: string\n}\n\ntype Locations1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    places?: Locations1Place[]\n    title?: string\n    description?: string\n    /** An ISO date and time in UTC for \"now\". The block never reads the clock. */\n    now?: string\n    defaultSelectedId?: string\n    onSelect?: (place: Locations1Place) => void\n  }\n\nconst sample: Locations1Place[] = [\n  { id: \"lis\", name: \"Lisbon HQ\", kind: \"Office\", city: \"Lisbon\", country: \"Portugal\", address: \"Rua da Prata 120, 1100-420 Lisboa\", lat: 38.71, lng: -9.14, utcOffset: 1, hours: { open: 9, close: 18 }, phone: \"+351 21 000 0100\", email: \"lisbon@acme.com\" },\n  { id: \"nyc\", name: \"New York studio\", kind: \"Office\", city: \"New York\", country: \"United States\", address: \"88 Orchard St, New York, NY 10002\", lat: 40.72, lng: -73.99, utcOffset: -4, hours: { open: 9, close: 17.5 }, phone: \"+1 212 555 0148\", email: \"nyc@acme.com\" },\n  { id: \"sao\", name: \"São Paulo hub\", kind: \"Office\", city: \"São Paulo\", country: \"Brazil\", address: \"Av. Paulista 1578, São Paulo\", lat: -23.56, lng: -46.65, utcOffset: -3, hours: { open: 9, close: 18 }, email: \"saopaulo@acme.com\" },\n  { id: \"lon\", name: \"London shop\", kind: \"Store\", city: \"London\", country: \"United Kingdom\", address: \"14 Carnaby St, London W1F 9PS\", lat: 51.51, lng: -0.14, utcOffset: 1, hours: { open: 10, close: 19, days: [0, 1, 2, 3, 4, 5, 6] }, phone: \"+44 20 7946 0958\" },\n  { id: \"ber\", name: \"Berlin shop\", kind: \"Store\", city: \"Berlin\", country: \"Germany\", address: \"Torstraße 72, 10119 Berlin\", lat: 52.53, lng: 13.4, utcOffset: 2, hours: { open: 10, close: 20, days: [1, 2, 3, 4, 5, 6] }, phone: \"+49 30 555 0123\" },\n  { id: \"lag\", name: \"Lagos office\", kind: \"Office\", city: \"Lagos\", country: \"Nigeria\", address: \"12 Adeola Odeku St, Victoria Island\", lat: 6.43, lng: 3.42, utcOffset: 1, hours: { open: 8, close: 17 }, email: \"lagos@acme.com\" },\n  { id: \"dxb\", name: \"Dubai showroom\", kind: \"Store\", city: \"Dubai\", country: \"United Arab Emirates\", address: \"Al Wasl Rd, Jumeirah 1\", lat: 25.2, lng: 55.27, utcOffset: 4, hours: { open: 10, close: 22, days: [0, 1, 2, 3, 4, 5, 6] }, phone: \"+971 4 555 0190\" },\n  { id: \"sin\", name: \"Singapore office\", kind: \"Office\", city: \"Singapore\", country: \"Singapore\", address: \"1 Fusionopolis Way, #12-01\", lat: 1.3, lng: 103.79, utcOffset: 8, hours: { open: 9, close: 18 }, email: \"singapore@acme.com\" },\n  { id: \"tyo\", name: \"Tokyo shop\", kind: \"Store\", city: \"Tokyo\", country: \"Japan\", address: \"3-18-5 Jingumae, Shibuya\", lat: 35.67, lng: 139.71, utcOffset: 9, hours: { open: 11, close: 20, days: [0, 1, 2, 3, 4, 5, 6] }, phone: \"+81 3 5550 0177\" },\n  { id: \"syd\", name: \"Sydney office\", kind: \"Office\", city: \"Sydney\", country: \"Australia\", address: \"200 George St, Sydney NSW 2000\", lat: -33.87, lng: 151.21, utcOffset: 11, hours: { open: 9, close: 17 }, email: \"sydney@acme.com\" },\n]\n\nconst LAT: [number, number] = [-56, 73]\nconst hhmm = (h: number) => `${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.round((h % 1) * 60)).padStart(2, \"0\")}`\n\nfunction openState(p: Locations1Place, nowMs: number) {\n  const local = new Date(nowMs + p.utcOffset * 3_600_000)\n  const day = local.getUTCDay()\n  const h = local.getUTCHours() + local.getUTCMinutes() / 60\n  const days = p.hours.days ?? [1, 2, 3, 4, 5]\n  const isOpen = days.includes(day) && h >= p.hours.open && h < p.hours.close\n  return { isOpen, text: isOpen ? `Open now · until ${hhmm(p.hours.close)}` : days.includes(day) && h < p.hours.open ? `Closed · opens ${hhmm(p.hours.open)}` : \"Closed now\", local: hhmm(h) }\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6 lg:grid-cols-[22rem_minmax(0,1fr)]\">\n      <div className=\"grid gap-3\">\n        {[0, 1, 2, 3].map((i) => (\n          <Skeleton key={i} className=\"h-24 rounded-2xl\" />\n        ))}\n      </div>\n      <Skeleton className=\"aspect-[2/1] rounded-3xl\" />\n    </div>\n  )\n}\n\n/**\n * A locations finder on a dotted world map: pins you can press (each is a real button), a list that searches names, cities and countries, a\n * card for the chosen place with its address, contacts and whether it is open right now in its own time zone. Open-now is worked out from the\n * moment you pass and each place's offset and hours; the block never reads the clock.\n */\nfunction Locations1({\n  places = sample,\n  title = \"Find us\",\n  description = \"Visit a shop, or come by one of our offices. Each one shows whether it is open right now.\",\n  now = \"2026-10-19T10:42:00Z\",\n  defaultSelectedId,\n  onSelect,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Locations1Props) {\n  const id = React.useId()\n  const all = state === \"empty\" ? [] : places\n  const [query, setQuery] = React.useState(\"\")\n  const [kind, setKind] = React.useState<string | null>(null)\n  const [selectedId, setSelectedId] = React.useState<string | undefined>(defaultSelectedId ?? all[0]?.id)\n  const tokens = queryTokens(query)\n  const nowMs = Date.parse(now)\n  const kinds = [...new Set(all.map((p) => p.kind))]\n  const shown = all.filter((p) => (!kind || p.kind === kind) && (!tokens.length || matchesAll(`${p.name} ${p.city} ${p.country} ${p.kind}`, tokens)))\n  const selected = all.find((p) => p.id === selectedId)\n  const [south, north] = LAT\n  const span = north - south\n  const choose = (p: Locations1Place) => {\n    setSelectedId(p.id)\n    onSelect?.(p)\n  }\n\n  const content = !all.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <MapPin className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No locations yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"When a shop or office opens, it appears here with its hours.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid items-start gap-6 lg:grid-cols-[22rem_minmax(0,1fr)]\">\n      <div className=\"order-2 min-w-0 lg:order-1\">\n        <div className=\"bg-background focus-within:ring-ring/50 flex h-10 items-center gap-2 rounded-lg border px-3 focus-within:ring-[3px]\">\n          <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n          <input type=\"search\" aria-label=\"Search locations\" placeholder=\"Search by city, country or name\" value={query} onChange={(e) => setQuery(e.target.value)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n        </div>\n        {kinds.length > 1 ? (\n          <div role=\"group\" aria-label=\"Filter by type\" className=\"mt-3 flex flex-wrap gap-2\">\n            {[null, ...kinds].map((k) => (\n              <button key={k ?? \"all\"} type=\"button\" aria-pressed={kind === k} onClick={() => setKind(k)} className={cn(\"focus-visible:ring-ring/50 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", kind === k ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n                {k ?? \"All\"}\n              </button>\n            ))}\n          </div>\n        ) : null}\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-4 text-sm\">\n          {shown.length} {shown.length === 1 ? \"place\" : \"places\"}\n        </p>\n        {shown.length ? (\n          <ul className=\"mt-3 grid max-h-[34rem] gap-2.5 overflow-y-auto pe-1\">\n            {shown.map((p) => {\n              const o = openState(p, nowMs)\n              const on = p.id === selectedId\n              return (\n                <li key={p.id}>\n                  <button\n                    type=\"button\"\n                    data-place={p.id}\n                    aria-pressed={on}\n                    onClick={() => choose(p)}\n                    className={cn(\"bg-card focus-visible:ring-ring/50 hover:border-foreground/30 w-full rounded-2xl border p-4 text-start outline-none transition-[border-color,box-shadow] focus-visible:ring-[3px] motion-reduce:transition-none\", on && \"border-foreground ring-foreground ring-1\")}\n                  >\n                    <span className=\"flex items-center justify-between gap-3\">\n                      <span className=\"font-semibold tracking-tight\">\n                        <MatchHighlight text={p.name} query={tokens} />\n                      </span>\n                      <span className=\"bg-muted rounded-full px-2.5 py-0.5 text-xs font-medium\">{p.kind}</span>\n                    </span>\n                    <span className=\"text-muted-foreground mt-1 block text-sm\">\n                      <MatchHighlight text={`${p.city}, ${p.country}`} query={tokens} />\n                    </span>\n                    <span className=\"mt-2.5 flex items-center gap-2 text-sm\">\n                      <span aria-hidden=\"true\" className={cn(\"size-2 rounded-full\", o.isOpen ? \"bg-chart-2\" : \"bg-muted-foreground/50\")} />\n                      {o.text}\n                    </span>\n                  </button>\n                </li>\n              )\n            })}\n          </ul>\n        ) : (\n          <div className=\"mt-3 rounded-2xl border border-dashed px-5 py-10 text-center\">\n            <p className=\"font-medium\">Nothing matches</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">Try a city or a country.</p>\n            <Button variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={() => { setQuery(\"\"); setKind(null) }}>\n              <X aria-hidden=\"true\" />\n              Clear\n            </Button>\n          </div>\n        )}\n      </div>\n\n      <div className=\"order-1 min-w-0 lg:sticky lg:top-6 lg:order-2\">\n        <div className=\"bg-card relative overflow-hidden rounded-3xl border p-3 sm:p-5\">\n          <div dir=\"ltr\" className=\"relative\">\n            <DottedMap dots={110} latRange={LAT} markers={selected ? [{ lat: selected.lat, lng: selected.lng, tone: \"chart-1\" }] : []} label=\"Map of our locations\" />\n            {shown.map((p) => {\n              const on = p.id === selectedId\n              return (\n                <button\n                  key={p.id}\n                  type=\"button\"\n                  aria-label={`${p.name}, ${p.city}`}\n                  aria-pressed={on}\n                  onClick={() => choose(p)}\n                  className=\"focus-visible:ring-ring/60 absolute -translate-x-1/2 -translate-y-1/2 rounded-full p-2 outline-none focus-visible:ring-[3px]\"\n                  style={{ left: `${((p.lng + 180) / 360) * 100}%`, top: `${((north - p.lat) / span) * 100}%` }}\n                >\n                  <span aria-hidden=\"true\" className={cn(\"block rounded-full border-2 border-white shadow transition-all duration-150 motion-reduce:transition-none\", on ? \"bg-chart-1 size-4 ring-4 ring-[color-mix(in_oklab,var(--chart-1)_35%,transparent)]\" : \"bg-foreground hover:bg-chart-1 size-2.5 hover:scale-125\")} />\n                </button>\n              )\n            })}\n          </div>\n        </div>\n\n        {selected ? (\n          <article aria-labelledby={`${id}-sel`} className=\"bg-card mt-4 rounded-3xl border p-5 sm:p-7\">\n            <div className=\"flex flex-wrap items-start justify-between gap-3\">\n              <div>\n                <p className=\"text-muted-foreground text-sm\">{selected.kind}</p>\n                <h2 id={`${id}-sel`} className=\"text-2xl font-semibold tracking-tight\">\n                  {selected.name}\n                </h2>\n              </div>\n              {(() => {\n                const o = openState(selected, nowMs)\n                return (\n                  <p className={cn(\"flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium\", o.isOpen && \"bg-chart-2/10 border-chart-2/30\")}>\n                    <span aria-hidden=\"true\" className={cn(\"size-2 rounded-full\", o.isOpen ? \"bg-chart-2\" : \"bg-muted-foreground/50\")} />\n                    {o.text}\n                  </p>\n                )\n              })()}\n            </div>\n            <dl className=\"mt-5 grid gap-3 text-sm sm:grid-cols-2\">\n              <div className=\"flex items-start gap-3\">\n                <dt className=\"sr-only\">Address</dt>\n                <MapPin aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n                <dd className=\"text-pretty\">{selected.address}</dd>\n              </div>\n              <div className=\"flex items-start gap-3\">\n                <dt className=\"sr-only\">Local time</dt>\n                <Clock aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n                <dd className=\"tabular-nums\">\n                  {openState(selected, nowMs).local} there · {hhmm(selected.hours.open)} to {hhmm(selected.hours.close)}\n                </dd>\n              </div>\n              {selected.phone ? (\n                <div className=\"flex items-start gap-3\">\n                  <dt className=\"sr-only\">Phone</dt>\n                  <Phone aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n                  <dd dir=\"ltr\">\n                    <a href={`tel:${selected.phone.replace(/[^+\\d]/g, \"\")}`} className=\"focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]\">\n                      {selected.phone}\n                    </a>\n                  </dd>\n                </div>\n              ) : null}\n              {selected.email ? (\n                <div className=\"flex items-start gap-3\">\n                  <dt className=\"sr-only\">Email</dt>\n                  <Mail aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n                  <dd>\n                    <a href={`mailto:${selected.email}`} className=\"focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]\">\n                      {selected.email}\n                    </a>\n                  </dd>\n                </div>\n              ) : null}\n            </dl>\n            <a href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${selected.address}`)}`} target=\"_blank\" rel=\"noreferrer\" className=\"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring/50 mt-6 inline-flex h-10 items-center gap-2 rounded-md px-5 text-sm font-medium outline-none focus-visible:ring-[3px]\">\n              Get directions <span className=\"sr-only\">to {selected.name} (opens in a new tab)</span>\n            </a>\n          </article>\n        ) : null}\n      </div>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"locations-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-14 sm:px-6 sm:py-20\", className)} {...frameProps(props)}>\n      <header className=\"mb-10 max-w-2xl\">\n        <h1 id={`${id}-title`} className=\"text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-4 text-lg text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading locations\" loading={<Skel />} error={{ title: errorState?.title ?? \"Locations could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Locations1, type Locations1Props, type Locations1Place }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/locations-1/locations-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "locations",
      "store locator",
      "offices",
      "map",
      "find us",
      "stores",
      "branches",
      "opening hours",
      "contact"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A locations finder. <Locations1 places={[{ id, name, kind: 'Office' | 'Store', city, country, address, lat, lng, utcOffset: 1, hours: { open: 9, close: 18, days: [1,2,3,4,5] }, phone, email }]} now='2026-10-19T10:42:00Z' defaultSelectedId onSelect />. Open now, closing and opening times are worked out from now, each utcOffset and hours (days default to Monday to Friday). Pass now yourself; the block never reads the clock. Pins are real buttons placed from lat and lng.",
      "whenToUse": [
        "A contact or find-us page for a business with several offices or shops",
        "A store locator on a marketing site"
      ],
      "whenNotToUse": [
        "One address with a map embed",
        "Turn-by-turn routes or live traffic"
      ],
      "composesWith": [
        "contact-1",
        "footer-1",
        "about-1",
        "directory-1"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Search, type filter, each place in the list as one button (pressed when chosen), then the pins, which are the same places as buttons with their name and city"
        },
        {
          "keys": "Open now",
          "action": "Written as 'Open now · until 18:00' or 'Closed · opens 09:00', beside a dot, never by color alone"
        },
        {
          "keys": "Links",
          "action": "Phone, email and directions are real links; directions says it opens in a new tab"
        }
      ],
      "customization": [
        "Hours can be half hours (17.5) and any set of days",
        "Phone numbers are shown left to right in right-to-left layouts, like any number",
        "The map and its pins keep their geography in every language"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "locations-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton list and map under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No locations",
        "example": "locations-1-empty",
        "trigger": "state=\"empty\" (or places={[]})",
        "note": "Says places appear when a shop or office opens; emptyState sets the copy. A search with no match says so with Clear."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "locations-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "locations-1-demo",
      "locations-1-night",
      "locations-1-loading",
      "locations-1-empty",
      "locations-1-error"
    ],
    "url": "https://ui.ballmac.com/components/locations-1"
  },
  "categories": [
    "blocks",
    "locations"
  ],
  "type": "registry:block"
}