{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "business-hours",
  "title": "Business Hours",
  "description": "A premium opening-hours card, live open/closed status with a pulsing dot, a day-by-day schedule, and a Get Directions link built from an address or custom URL.",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "registry/new-york/business-hours/business-hours.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nfunction cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport interface BusinessHoursDay {\n  day: string;\n  hours: string;\n}\n\nexport interface BusinessHoursProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  title?: string;\n  subtitle?: string;\n  headerTitle?: string;\n  days?: BusinessHoursDay[];\n  /** IANA time zone of the business, e.g. \"Europe/Istanbul\", so \"Open now\" is right for visitors anywhere. Defaults to the visitor's clock. */\n  timeZone?: string;\n  showStatus?: boolean;\n  openText?: string;\n  closedText?: string;\n  theme?: \"dark\" | \"light\";\n  showThemeToggle?: boolean;\n  address?: string;\n  phone?: string;\n  directionsUrl?: string;\n  showDirectionsButton?: boolean;\n  backgroundImage?: string;\n  backgroundImageAlt?: string;\n  backgroundVideo?: string;\n  borderRadius?: number;\n  titleFont?: React.CSSProperties;\n  uiFont?: React.CSSProperties;\n}\n\nconst DEFAULT_DAYS: BusinessHoursDay[] = [\n  { day: \"Monday\", hours: \"09:00 AM - 05:00 PM\" },\n  { day: \"Tuesday\", hours: \"09:00 AM - 05:00 PM\" },\n  { day: \"Wednesday\", hours: \"09:00 AM - 05:00 PM\" },\n  { day: \"Thursday\", hours: \"09:00 AM - 05:00 PM\" },\n  { day: \"Friday\", hours: \"09:00 AM - 05:00 PM\" },\n  { day: \"Saturday\", hours: \"09:00 AM - 05:00 PM\" },\n  { day: \"Sunday\", hours: \"Closed\" },\n];\n\nfunction parseTimeToMinutes(value: string): number | null {\n  const match = value.trim().match(/^(\\d{1,2}):(\\d{2})\\s*(AM|PM)$/i);\n  if (!match) return null;\n  let hours = parseInt(match[1], 10) % 12;\n  const minutes = parseInt(match[2], 10);\n  if (match[3].toUpperCase() === \"PM\") hours += 12;\n  return hours * 60 + minutes;\n}\n\nfunction getOpenRange(hours: string): [number, number] | null {\n  if (hours.toLowerCase().includes(\"closed\")) return null;\n  const [openPart, closePart] = hours.split(\" - \");\n  if (!openPart || !closePart) return null;\n  const start = parseTimeToMinutes(openPart);\n  const end = parseTimeToMinutes(closePart);\n  if (start === null || end === null) return null;\n  return [start, end];\n}\n\nfunction minutesTo24Hour(minutes: number): string {\n  const h = Math.floor(minutes / 60) % 24;\n  const m = minutes % 60;\n  return `${String(h).padStart(2, \"0\")}:${String(m).padStart(2, \"0\")}`;\n}\n\n/** Weekday index (0 = Sunday) and minutes past midnight, in `timeZone` or the visitor's own clock. */\nfunction clockNow(timeZone?: string): { day: number; minutes: number } {\n  const now = new Date();\n  if (!timeZone) return { day: now.getDay(), minutes: now.getHours() * 60 + now.getMinutes() };\n  const parts = new Intl.DateTimeFormat(\"en-US\", { timeZone, weekday: \"short\", hour: \"2-digit\", minute: \"2-digit\", hourCycle: \"h23\" }).formatToParts(now);\n  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? \"\";\n  return { day: [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"].indexOf(get(\"weekday\")), minutes: Number(get(\"hour\")) * 60 + Number(get(\"minute\")) };\n}\n\nconst DAY_NAMES = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\n\nexport function BusinessHours({\n  title = \"Visit Our Office\",\n  subtitle = \"Drop by our workspace for a meeting, a tour, or just to say hello. Check today's status before you head over.\",\n  headerTitle = \"Office Hours\",\n  days = DEFAULT_DAYS,\n  timeZone,\n  showStatus = true,\n  openText = \"Open Now\",\n  closedText = \"Closed\",\n  theme = \"dark\",\n  showThemeToggle = true,\n  address = \"\",\n  phone = \"\",\n  directionsUrl = \"\",\n  showDirectionsButton = true,\n  backgroundImage,\n  backgroundImageAlt = \"\",\n  backgroundVideo,\n  borderRadius = 20,\n  titleFont,\n  uiFont,\n  className,\n  style,\n  ...props\n}: BusinessHoursProps) {\n  const [liveTheme, setLiveTheme] = React.useState<\"dark\" | \"light\">(theme);\n  React.useEffect(() => {\n    setLiveTheme(theme);\n  }, [theme]);\n  const isDark = liveTheme === \"dark\";\n  const hasMedia = Boolean(backgroundImage || backgroundVideo);\n  const videoRef = React.useRef<HTMLVideoElement>(null);\n\n  const toggleTheme = () => setLiveTheme((t) => (t === \"dark\" ? \"light\" : \"dark\"));\n\n  // Computed only on the client, after mount — \"now\" differs between the\n  // server-render pass and hydration, so deriving open/closed during render\n  // would risk a hydration mismatch.\n  const [isOpenNow, setIsOpenNow] = React.useState(false);\n  const [pulseOn, setPulseOn] = React.useState(false);\n\n  React.useEffect(() => {\n    const compute = () => {\n      const now = clockNow(timeZone);\n      const todayEntry = days.find((d) => d.day === DAY_NAMES[now.day]);\n      const range = todayEntry ? getOpenRange(todayEntry.hours) : null;\n      if (!range) {\n        setIsOpenNow(false);\n        return;\n      }\n      const nowMinutes = now.minutes;\n      setIsOpenNow(range[1] > range[0] ? nowMinutes >= range[0] && nowMinutes < range[1] : nowMinutes >= range[0] || nowMinutes < range[1]);\n    };\n    compute();\n    const id = setInterval(compute, 60000);\n    return () => clearInterval(id);\n  }, [days, timeZone]);\n\n  React.useEffect(() => {\n    const id = setInterval(() => setPulseOn((p) => !p), 1000);\n    return () => clearInterval(id);\n  }, []);\n\n  React.useEffect(() => {\n    if (!backgroundVideo) return;\n    const el = videoRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          el.play().catch(() => {});\n        } else {\n          el.pause();\n        }\n      },\n      { threshold: 0 }\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [backgroundVideo]);\n\n  const directionsHref = directionsUrl || (address ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` : \"\");\n\n  // JSON-LD (schema.org LocalBusiness — opening hours must be 24h \"HH:MM\" per spec)\n  const jsonLd = {\n    \"@context\": \"https://schema.org\",\n    \"@type\": \"LocalBusiness\",\n    name: title,\n    description: subtitle,\n    ...(address && { address }),\n    ...(phone && { telephone: phone }),\n    ...(backgroundImage && { image: backgroundImage }),\n    openingHoursSpecification: days\n      .map((d) => ({ day: d.day, range: getOpenRange(d.hours) }))\n      .filter((d): d is { day: string; range: [number, number] } => d.range !== null)\n      .map((d) => ({\n        \"@type\": \"OpeningHoursSpecification\",\n        dayOfWeek: d.day,\n        opens: minutesTo24Hour(d.range[0]),\n        closes: minutesTo24Hour(d.range[1]),\n      })),\n  };\n\n  const colors = {\n    pageBg: isDark ? \"#080808\" : \"#ffffff\",\n    overlay: isDark ? \"rgba(0,0,0,0.55)\" : \"rgba(255,255,255,0.7)\",\n    cardBg: isDark ? \"rgba(24,24,24,0.9)\" : \"#ffffff\",\n    cardBorder: isDark ? \"rgba(255,255,255,0.08)\" : \"rgba(0,0,0,0.06)\",\n    headerBg: isDark ? \"rgba(255,255,255,0.05)\" : \"rgba(0,0,0,0.02)\",\n    headerBorder: isDark ? \"rgba(255,255,255,0.08)\" : \"rgba(0,0,0,0.06)\",\n    title: isDark ? \"#ffffff\" : \"#111111\",\n    subtitle: isDark ? \"rgba(255,255,255,0.55)\" : \"rgba(0,0,0,0.55)\",\n    day: isDark ? \"rgba(255,255,255,0.75)\" : \"rgba(0,0,0,0.7)\",\n    hours: isDark ? \"rgba(255,255,255,0.9)\" : \"rgba(0,0,0,0.85)\",\n    hoursClosed: isDark ? \"rgba(255,255,255,0.5)\" : \"rgba(0,0,0,0.6)\",\n    rowAlt: isDark ? \"rgba(255,255,255,0.025)\" : \"rgba(0,0,0,0.015)\",\n    rowBorder: isDark ? \"rgba(255,255,255,0.05)\" : \"rgba(0,0,0,0.05)\",\n    toggleBg: isDark ? \"rgba(255,255,255,0.08)\" : \"rgba(0,0,0,0.04)\",\n    toggleBorder: isDark ? \"rgba(255,255,255,0.12)\" : \"rgba(0,0,0,0.08)\",\n    toggleIcon: isDark ? \"#ffffff\" : \"#111111\",\n    statusOpenDot: \"#3ddc84\",\n    statusOpenText: isDark ? \"#78ffb4\" : \"#166534\",\n    statusClosedDot: isDark ? \"#ff7878\" : \"#d14343\",\n    statusClosedText: isDark ? \"#ff9494\" : \"#d14343\",\n    directionsBg: isDark ? \"rgba(255,255,255,0.06)\" : \"rgba(255,255,255,0.9)\",\n    directionsBorder: isDark ? \"rgba(255,255,255,0.16)\" : \"rgba(0,0,0,0.1)\",\n    directionsText: isDark ? \"#ffffff\" : \"#111111\",\n  };\n\n  return (\n    <section\n      role=\"region\"\n      aria-label={`${title}, opening hours`}\n      className={cn(\"relative box-border flex w-full flex-col items-center justify-center overflow-hidden px-10 py-10\", className)}\n      style={{ background: hasMedia ? (isDark ? \"#0a0a0a\" : \"#f5f5f5\") : colors.pageBg, transition: \"background 0.4s ease\", ...style }}\n      {...props}\n    >\n      {hasMedia && (\n        <div className=\"absolute inset-0 z-0\">\n          {backgroundVideo ? (\n            <video ref={videoRef} muted loop playsInline poster={backgroundImage} aria-hidden=\"true\" className=\"block h-full w-full object-cover\">\n              <source src={backgroundVideo} />\n            </video>\n          ) : (\n            backgroundImage && <img src={backgroundImage} alt={backgroundImageAlt} className=\"block h-full w-full object-cover\" />\n          )}\n          <div className=\"absolute inset-0 transition-colors duration-500\" style={{ background: colors.overlay }} />\n        </div>\n      )}\n\n      <script type=\"application/ld+json\" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />\n\n      {showThemeToggle && (\n        <button\n          type=\"button\"\n          onClick={toggleTheme}\n          aria-pressed={isDark}\n          aria-label={isDark ? \"Switch to light mode\" : \"Switch to dark mode\"}\n          className=\"absolute top-5 left-5 z-50 flex h-10 w-10 items-center justify-center rounded-xl border p-0 backdrop-blur-md transition-all duration-300 outline-none focus-visible:ring-2\"\n          style={{ background: colors.toggleBg, borderColor: colors.toggleBorder }}\n        >\n          {isDark ? (\n            <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n              <circle cx=\"12\" cy=\"12\" r=\"4\" stroke={colors.toggleIcon} strokeWidth=\"1.8\" />\n              <path\n                d=\"M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41\"\n                stroke={colors.toggleIcon}\n                strokeWidth=\"1.8\"\n                strokeLinecap=\"round\"\n              />\n            </svg>\n          ) : (\n            <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M21 14.5A8.5 8.5 0 0 1 9.5 3 7 7 0 1 0 21 14.5z\" stroke={colors.toggleIcon} strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          )}\n        </button>\n      )}\n\n      <div className=\"relative z-[2] flex w-full flex-col items-center\">\n        <header className=\"mb-10 max-w-[480px] text-center\">\n          <h2 className=\"mt-0 mb-3.5 text-[32px] leading-[1.2] tracking-tight\" style={{ color: colors.title, transition: \"color 0.4s ease\", ...titleFont }}>\n            {title}\n          </h2>\n          <p className=\"mx-auto mt-0 mb-0 max-w-[360px] text-sm leading-relaxed\" style={{ color: colors.subtitle, transition: \"color 0.4s ease\", ...uiFont, fontSize: uiFont?.fontSize ?? 14 }}>\n            {subtitle}\n          </p>\n        </header>\n\n        <div\n          className=\"w-full max-w-[360px] overflow-hidden border transition-all duration-300\"\n          style={{\n            background: colors.cardBg,\n            borderColor: colors.cardBorder,\n            borderRadius,\n            backdropFilter: isDark ? \"blur(20px)\" : \"none\",\n            boxShadow: isDark ? \"0 25px 50px -12px rgba(0,0,0,0.55)\" : \"0 20px 40px -12px rgba(0,0,0,0.08)\",\n          }}\n        >\n          <div className=\"flex items-center justify-between border-b px-6 py-4.5\" style={{ background: colors.headerBg, borderColor: colors.headerBorder }}>\n            <h3 className=\"m-0 text-lg\" style={{ color: colors.title, transition: \"color 0.4s ease\", ...titleFont, fontSize: 18 }}>\n              {headerTitle}\n            </h3>\n\n            {showStatus && (\n              <div\n                role=\"status\"\n                aria-live=\"polite\"\n                className=\"flex items-center gap-2 text-[11px] tracking-[0.12em] uppercase\"\n                style={{ color: isOpenNow ? colors.statusOpenText : colors.statusClosedText, ...uiFont, fontSize: 11 }}\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"h-1.5 w-1.5 rounded-full transition-shadow duration-1000\"\n                  style={{\n                    background: isOpenNow ? colors.statusOpenDot : colors.statusClosedDot,\n                    boxShadow: isOpenNow && pulseOn ? \"0 0 0 5px rgba(61,220,132,0.25)\" : \"0 0 8px rgba(0,0,0,0)\",\n                  }}\n                />\n                {isOpenNow ? openText : closedText}\n              </div>\n            )}\n          </div>\n\n          <dl className=\"m-0 p-0\">\n            {days.map((item, i) => {\n              const isClosed = item.hours.toLowerCase().includes(\"closed\");\n              return (\n                <div\n                  key={item.day}\n                  className=\"flex items-center justify-between gap-3 px-6 py-4\"\n                  style={{ background: i % 2 === 0 ? colors.rowAlt : \"transparent\", borderBottom: i < days.length - 1 ? `1px solid ${colors.rowBorder}` : \"none\" }}\n                >\n                  <dt className=\"m-0 shrink-0 text-sm font-normal\" style={{ color: colors.day, transition: \"color 0.4s ease\", ...uiFont, fontSize: uiFont?.fontSize ?? 14 }}>\n                    {item.day}\n                  </dt>\n                  <dd\n                    className=\"m-0 min-w-0 flex-1 text-right text-sm tabular-nums\"\n                    style={{ color: isClosed ? colors.hoursClosed : colors.hours, transition: \"color 0.4s ease\", ...uiFont, fontSize: uiFont?.fontSize ?? 14 }}\n                  >\n                    {item.hours}\n                  </dd>\n                </div>\n              );\n            })}\n          </dl>\n        </div>\n\n        {showDirectionsButton && directionsHref && (\n          <a\n            href={directionsHref}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            aria-label={address ? `Get directions to ${address} (opens in new tab)` : \"Get directions (opens in new tab)\"}\n            className=\"mt-5 inline-flex items-center gap-2 rounded-full border px-6 py-3 text-sm no-underline backdrop-blur-md transition-colors\"\n            style={{ background: colors.directionsBg, borderColor: colors.directionsBorder, color: colors.directionsText, ...uiFont, fontSize: uiFont?.fontSize ?? 14 }}\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M12 21s-7-6.13-7-11a7 7 0 1 1 14 0c0 4.87-7 11-7 11z\" stroke={colors.directionsText} strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n              <circle cx=\"12\" cy=\"10\" r=\"2.5\" stroke={colors.directionsText} strokeWidth=\"1.8\" />\n            </svg>\n            Get Directions\n          </a>\n        )}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}