{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "footer-tiles",
  "title": "Footer Tiles",
  "description": "A footer as four tiles: address, opening hours with an open-now signal in the place's own time zone, links and social links.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/data-tile"
  ],
  "files": [
    {
      "path": "registry/manniche/footer-tiles/footer-tiles.tsx",
      "content": "// The footer as a small bento of four tiles: address, opening hours, links and social links, with a legal row under\n// them. Signature: the open-now dot is the only --primary on the page. Whether the place is open is computed from the\n// weekly schedule, the time zone and `now`, so a visitor in another zone still sees the shop's own time. The dot is\n// never the only signal: the words \"Open now\" or \"Closed, opens at 09:00\" sit beside it, and a closed place shows a\n// hollow ring. Screen readers get a <footer> landmark, an h2 (hidden), one h3 per tile, <address>, real lists, and\n// opening hours as a list of day ranges with today marked in text. The ©-year and the status are computed after mount\n// unless `now` is passed; until then the tile shows a neutral \"Opening hours\" line. When `now` is absent the status\n// refreshes every 30 seconds. Under reduced motion nothing animates (the only motion is colour-free opacity on hover).\nimport { ArrowUpRight } from 'lucide-react'\nimport { useId, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactNode } from 'react'\nimport { cn } from '@/lib/utils'\nimport { DataTile } from '@/registry/manniche/data-tile/data-tile'\n\nexport type FooterTilesLink = {\n  label: string\n  href: string\n  /** Opens in a new tab with rel=\"noopener noreferrer\" and a hidden \"(opens in a new tab)\". */\n  external?: boolean\n}\n\n/** 0 is Sunday, 1 Monday, up to 6 Saturday. */\nexport type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6\n\nexport type OpeningRange = {\n  day: Weekday\n  /** 24-hour \"HH:MM\" in the place's own time zone. */\n  open: string\n  /** 24-hour \"HH:MM\", later than `open` on the same day. A day may have several ranges (a lunch break). */\n  close: string\n}\n\nexport type FooterTilesAddress = {\n  /** The place's name, e.g. \"Halden Studio, Munich\". */\n  name: string\n  /** Street, postcode and city, one entry per line. */\n  lines: string[]\n  /** Link for the \"Directions\" action, e.g. a map URL. */\n  directionsHref: string\n}\n\nexport type FooterTilesHours = {\n  /** An IANA time zone, e.g. \"Europe/Berlin\". The schedule is read in this zone. */\n  timeZone: string\n  schedule: OpeningRange[]\n}\n\nexport type FooterTilesSocial = {\n  label: string\n  href: string\n  /** A small icon, decorative: the label carries the meaning. */\n  icon: ReactNode\n}\n\nexport type FooterTilesLabels = {\n  heading?: string\n  navigation?: string\n  address?: string\n  directions?: string\n  hours?: string\n  links?: string\n  social?: string\n  openNow?: string\n  closed?: string\n  /** `{time}` is replaced with the closing time. */\n  closesAt?: string\n  /** `{time}` is replaced with the opening time (later today). */\n  opensAt?: string\n  /** `{day}` and `{time}` are replaced with the next opening. */\n  opensOn?: string\n  today?: string\n  /** `{zone}` is replaced with the time zone. */\n  zoneNote?: string\n  /** Shown before the status is known (server render, first paint). */\n  unknown?: string\n  /** Seven short day names, Sunday first. */\n  days?: [string, string, string, string, string, string, string]\n  shut?: string\n  newTab?: string\n}\n\nexport type FooterTilesProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {\n  address: FooterTilesAddress\n  hours: FooterTilesHours\n  /** Links tile: a short list of pages. */\n  links: FooterTilesLink[]\n  /** Social tile: each with an icon and text. */\n  social: FooterTilesSocial[]\n  /** The name after the © sign. */\n  owner: string\n  /** Legal links under the tiles. */\n  legalLinks?: FooterTilesLink[]\n  /** The moment to judge \"open now\" by. When absent the real time is used after mount and refreshed every 30 s. */\n  now?: Date\n  /** UI strings, for translation. */\n  labels?: FooterTilesLabels\n}\n\nconst DEFAULT_LABELS: Required<FooterTilesLabels> = {\n  heading: 'Footer',\n  navigation: 'Footer links',\n  address: 'Visit',\n  directions: 'Directions',\n  hours: 'Opening hours',\n  links: 'Explore',\n  social: 'Follow',\n  openNow: 'Open now',\n  closed: 'Closed',\n  closesAt: 'closes {time}',\n  opensAt: 'opens at {time}',\n  opensOn: 'opens {day} {time}',\n  today: 'Today',\n  zoneNote: 'Times in {zone}.',\n  unknown: 'Opening hours',\n  days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],\n  shut: 'Closed',\n  newTab: '(opens in a new tab)',\n}\n\n// Time logic ----------------------------------------------------------------------------------------------------\n\nconst WEEKDAYS: Record<string, Weekday> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }\n\nconst toMinutes = (hhmm: string) => {\n  const [h, m] = hhmm.split(':').map(Number)\n  return (h || 0) * 60 + (m || 0)\n}\n\n/** The weekday and minute of the day in the given zone, or null for a zone the browser does not know. */\nfunction zonedParts(date: Date, timeZone: string): { day: Weekday; minutes: number } | null {\n  try {\n    const parts = new Intl.DateTimeFormat('en-US', { timeZone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(date)\n    const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ''\n    const day = WEEKDAYS[get('weekday')]\n    if (day === undefined) return null\n    return { day, minutes: (Number(get('hour')) % 24) * 60 + Number(get('minute')) }\n  } catch {\n    return null\n  }\n}\n\ntype Status =\n  | { open: true; day: Weekday; closes: string }\n  | { open: false; day: Weekday; next?: { day: Weekday; time: string; today: boolean } }\n\n/** Is the place open at `date`, and if not, when does it open next? Null when the zone is unknown. */\nfunction openStatus(hours: FooterTilesHours, date: Date): Status | null {\n  const at = zonedParts(date, hours.timeZone)\n  if (!at) return null\n  const forDay = (d: Weekday) => hours.schedule.filter((r) => r.day === d).sort((a, b) => toMinutes(a.open) - toMinutes(b.open))\n  const current = forDay(at.day).find((r) => toMinutes(r.open) <= at.minutes && at.minutes < toMinutes(r.close))\n  if (current) return { open: true, day: at.day, closes: current.close }\n  for (let offset = 0; offset <= 7; offset++) {\n    const day = ((at.day + offset) % 7) as Weekday\n    const later = forDay(day).find((r) => offset > 0 || toMinutes(r.open) > at.minutes)\n    if (later) return { open: false, day: at.day, next: { day, time: later.open, today: offset === 0 } }\n  }\n  return { open: false, day: at.day }\n}\n\nconst noopSubscribe = () => () => {}\nconst subscribeMinute = (onChange: () => void) => {\n  const id = setInterval(onChange, 30_000)\n  return () => clearInterval(id)\n}\n// A whole-minute timestamp, so the snapshot only changes when the minute does. Null on the server and first render.\nconst minuteNow = () => Math.floor(Date.now() / 60_000) * 60_000\n\nfunction useNow(now?: Date): Date | null {\n  const live = useSyncExternalStore(now ? noopSubscribe : subscribeMinute, minuteNow, () => null)\n  if (now) return now\n  return live === null ? null : new Date(live)\n}\n\nconst noopYearSubscribe = () => () => {}\nfunction useYear(now: Date | null) {\n  const clientYear = useSyncExternalStore(noopYearSubscribe, () => new Date().getFullYear(), () => null)\n  return now ? now.getFullYear() : clientYear\n}\n\n/** Merges consecutive days with the same hours into one line: \"Mon–Fri 09:00–18:00\". Monday first. */\nfunction dayGroups(schedule: OpeningRange[], days: string[], shut: string) {\n  const order: Weekday[] = [1, 2, 3, 4, 5, 6, 0]\n  const text = (d: Weekday) =>\n    schedule\n      .filter((r) => r.day === d)\n      .sort((a, b) => toMinutes(a.open) - toMinutes(b.open))\n      .map((r) => `${r.open}–${r.close}`)\n      .join(', ') || shut\n  const groups: { first: Weekday; last: Weekday; days: Weekday[]; text: string }[] = []\n  for (const d of order) {\n    const t = text(d)\n    const prev = groups[groups.length - 1]\n    if (prev && prev.text === t) {\n      prev.last = d\n      prev.days.push(d)\n    } else groups.push({ first: d, last: d, days: [d], text: t })\n  }\n  return groups.map((g) => ({\n    key: `${g.first}-${g.last}`,\n    label: g.first === g.last ? days[g.first] : `${days[g.first]}–${days[g.last]}`,\n    days: g.days,\n    text: g.text,\n    shut: g.text === shut,\n  }))\n}\n\n// Pieces --------------------------------------------------------------------------------------------------------\n\nconst ROW =\n  'relative flex min-h-11 items-center justify-between gap-3 rounded-md text-sm text-foreground transition-[opacity] duration-200 ease-out-quint hover:opacity-70 focus-visible:opacity-100 focus-visible:outline-offset-0'\n\nfunction ext(external?: boolean) {\n  return external ? { target: '_blank', rel: 'noopener noreferrer' } : {}\n}\n\nfunction fill(template: string, values: Record<string, string>) {\n  return template.replace(/\\{(\\w+)\\}/g, (_, k: string) => values[k] ?? '')\n}\n\nfunction HoursTile({ hours, now, labels }: { hours: FooterTilesHours; now: Date | null; labels: Required<FooterTilesLabels> }) {\n  const status = now ? openStatus(hours, now) : null\n  const groups = dayGroups(hours.schedule, labels.days, labels.shut)\n\n  // Closed with no next opening only happens for an empty schedule: then \"Closed\" stands alone.\n  let line: string = status && !status.open && !status.next ? '' : labels.unknown\n  if (status?.open) line = fill(labels.closesAt, { time: status.closes })\n  else if (status?.next) {\n    line = status.next.today\n      ? fill(labels.opensAt, { time: status.next.time })\n      : fill(labels.opensOn, { day: labels.days[status.next.day], time: status.next.time })\n  }\n\n  return (\n    <DataTile\n      title={labels.hours}\n      footer={<span className=\"font-mono text-xs tabular-nums\">{fill(labels.zoneNote, { zone: hours.timeZone })}</span>}\n    >\n      <p className=\"flex min-h-6 items-center gap-2.5 text-sm font-medium\">\n        {status ? (\n          <>\n            <span\n              aria-hidden\n              className={cn('size-2.5 flex-none rounded-full', status.open ? 'bg-primary' : 'border-[1.5px] border-muted-foreground')}\n            />\n            <span>\n              {status.open ? labels.openNow : labels.closed}\n              {line && (\n                <span className=\"font-normal text-muted-foreground\">\n                  {status.open ? ' · ' : ', '}\n                  <span className=\"font-mono text-[13px] tabular-nums\">{line}</span>\n                </span>\n              )}\n            </span>\n          </>\n        ) : (\n          <span className=\"text-muted-foreground\">{line}</span>\n        )}\n      </p>\n      <ul className=\"mt-4 grid gap-1.5 border-t border-border pt-4\">\n        {groups.map((g) => {\n          const isToday = status !== null && g.days.includes(status.day)\n          return (\n            <li key={g.key} className=\"flex items-baseline justify-between gap-3 text-[13.5px]\">\n              <span className={cn('flex items-baseline gap-2 whitespace-nowrap', isToday ? 'font-medium text-foreground' : 'text-muted-foreground')}>\n                {g.label}\n                {isToday && <span className=\"rounded-full bg-muted px-2 py-px text-[11px] font-medium text-foreground\">{labels.today}</span>}\n              </span>\n              <span className={cn('font-mono text-xs tabular-nums', isToday ? 'text-foreground' : 'text-muted-foreground', g.shut && 'font-sans text-[13.5px]')}>\n                {g.text}\n              </span>\n            </li>\n          )\n        })}\n      </ul>\n    </DataTile>\n  )\n}\n\n/** A four-tile footer: address, opening hours with an open-now signal, links and social links. */\nexport function FooterTiles({ address, hours, links, social, owner, legalLinks = [], now, labels, className, ...rest }: FooterTilesProps) {\n  const l = { ...DEFAULT_LABELS, ...labels }\n  const clock = useNow(now)\n  const year = useYear(clock)\n  const headingId = useId()\n\n  return (\n    <footer {...rest} aria-labelledby={headingId} className={cn('@container w-full bg-background text-foreground', className)}>\n      <div className=\"mx-auto w-full max-w-6xl px-4 py-12 @min-[40rem]:py-16 sm:px-6\">\n        <h2 id={headingId} className=\"sr-only\">\n          {l.heading}\n        </h2>\n        <div className=\"grid gap-3 @min-[36rem]:grid-cols-2 @min-[64rem]:grid-cols-4 @min-[64rem]:gap-4\">\n          <DataTile\n            title={l.address}\n            footer={\n              <a\n                href={address.directionsHref}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"-my-1 inline-flex min-h-11 items-center gap-1.5 rounded-md font-medium text-foreground transition-[opacity] duration-200 hover:opacity-70 focus-visible:outline-offset-0\"\n              >\n                {l.directions}\n                <span className=\"sr-only\">\n                  {' '}\n                  {l.newTab}\n                </span>\n                <ArrowUpRight className=\"size-4\" aria-hidden />\n              </a>\n            }\n          >\n            <address className=\"text-sm leading-relaxed not-italic\">\n              <span className=\"block font-medium\">{address.name}</span>\n              {address.lines.map((line) => (\n                <span key={line} className=\"block text-muted-foreground\">\n                  {line}\n                </span>\n              ))}\n            </address>\n          </DataTile>\n\n          <HoursTile hours={hours} now={clock} labels={l} />\n\n          <DataTile title={l.links}>\n            <nav aria-label={l.navigation}>\n              <ul className=\"-my-1.5\">\n                {links.map((link) => (\n                  <li key={link.href + link.label}>\n                    <a href={link.href} {...ext(link.external)} className={ROW}>\n                      {link.label}\n                      {link.external && <span className=\"sr-only\"> {l.newTab}</span>}\n                    </a>\n                  </li>\n                ))}\n              </ul>\n            </nav>\n          </DataTile>\n\n          <DataTile title={l.social}>\n            <ul className=\"-my-1.5\">\n              {social.map((s) => (\n                <li key={s.href + s.label}>\n                  <a href={s.href} target=\"_blank\" rel=\"noopener noreferrer\" className={ROW}>\n                    <span className=\"flex items-center gap-3\">\n                      <span aria-hidden className=\"grid size-5 flex-none place-items-center text-muted-foreground [&>svg]:size-[18px]\">\n                        {s.icon}\n                      </span>\n                      {s.label}\n                      <span className=\"sr-only\"> {l.newTab}</span>\n                    </span>\n                    <ArrowUpRight className=\"size-4 flex-none text-muted-foreground\" aria-hidden />\n                  </a>\n                </li>\n              ))}\n            </ul>\n          </DataTile>\n        </div>\n\n        <div className=\"mt-4 flex flex-col gap-x-6 gap-y-0 px-1 @min-[40rem]:flex-row @min-[40rem]:items-center @min-[40rem]:justify-between @min-[40rem]:px-2\">\n          <p className=\"min-h-11 content-center font-mono text-xs text-muted-foreground tabular-nums\">\n            &copy; {year ?? ''}\n            {year ? ' ' : ''}\n            {owner}\n          </p>\n          {legalLinks.length > 0 && (\n            <ul className=\"flex flex-wrap gap-x-5\">\n              {legalLinks.map((link) => (\n                <li key={link.href + link.label}>\n                  <a\n                    href={link.href}\n                    {...ext(link.external)}\n                    className=\"relative inline-flex min-h-11 items-center rounded-md text-[13px] text-muted-foreground transition-[color] duration-200 ease-out-quint hover:text-foreground focus-visible:text-foreground focus-visible:outline-offset-0\"\n                  >\n                    {link.label}\n                    {link.external && <span className=\"sr-only\"> {l.newTab}</span>}\n                  </a>\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      </div>\n    </footer>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}