Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

Contact Offices

Én flise pr. kontor med adresse, telefon, ugens åbningstider, lokal tid og åben eller lukket i ord.

Forhåndsvisning

React

Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/contact-offices.json
npx shadcn@latest add @manniche/contact-offices
contact-offices.tsx
// Contact offices: one tile per office with address, phone, weekly hours, local time and an open/closed signal.
// Signature: the open office carries the one --primary dot; the open office closest to closing says "Closes in
// 40 min" in mono; closed offices say when they open next ("Opens Mon 09:00").
// Screen readers: a list of offices with an h3 each; the status is text, never colour alone; hours are a table.
// Time: pass `now` for a fixed time; otherwise the section renders a neutral placeholder on the server and reads
// the clock in an effect after mount, ticking once a minute. Reduced motion: nothing animates.
import { useEffect, useId, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
import { Check, Copy, MapPin } from 'lucide-react'
import { cn } from '@/lib/utils'

export type OfficeHours = {
  /** Day of week, 0 = Sunday ... 6 = Saturday. */
  day: number
  /** "09:00" in the office's local time. */
  open: string
  /** "17:00" in the office's local time, later than `open` on the same day ("24:00" for midnight). For hours that run
   * past midnight, give two rows: one to "24:00" and one from "00:00" on the next day. */
  close: string
}

export type ContactOffice = {
  id: string
  city: string
  address: string
  phone?: string
  /** IANA zone, e.g. "Europe/Berlin". */
  timeZone: string
  hours: OfficeHours[]
  /** Link to a map or directions. */
  directionsHref?: string
}

export type ContactOfficesProps = Omit<HTMLAttributes<HTMLElement>, 'title'> & {
  heading: ReactNode
  intro?: ReactNode
  offices: ContactOffice[]
  /** A fixed time; when absent the clock runs after mount. */
  now?: Date
  labels?: {
    open?: string; closed?: string; localTime?: string; copyAddress?: string; copied?: string; failed?: string
    directions?: string; newTab?: string; hours?: string; day?: string; closesIn?: (min: number) => string; opensAt?: (day: string, time: string) => string
    days?: string[]
  }
}

const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const toMin = (t: string) => {
  const [h, m] = t.split(':').map(Number)
  return h * 60 + (m || 0)
}

function localParts(now: Date, timeZone: string) {
  const f = new Intl.DateTimeFormat('en-GB', { timeZone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
  const p = Object.fromEntries(f.formatToParts(now).map((x) => [x.type, x.value]))
  return { day: DAYS.indexOf(p.weekday), minutes: Number(p.hour) * 60 + Number(p.minute), clock: `${p.hour}:${p.minute}` }
}

type OfficeStatus = { open: boolean; clock: string; closesIn?: number; next?: { day: number; time: string } }

/** Whether an office is open at `now`, how long until it closes, or when it opens next. */
function officeStatus(o: ContactOffice, now: Date): OfficeStatus {
  const { day, minutes, clock } = localParts(now, o.timeZone)
  const today = o.hours.find((h) => h.day === day && minutes >= toMin(h.open) && minutes < toMin(h.close))
  if (today) return { open: true, clock, closesIn: toMin(today.close) - minutes }
  for (let i = 0; i < 8; i++) {
    const d = (day + i) % 7
    const h = o.hours.filter((x) => x.day === d).sort((a, b) => toMin(a.open) - toMin(b.open)).find((x) => i > 0 || toMin(x.open) > minutes)
    if (h) return { open: false, clock, next: { day: d, time: h.open } }
  }
  return { open: false, clock }
}

function CopyAddress({ text, L }: { text: string; L: { copyAddress: string; copied: string; failed: string } }) {
  const [s, setS] = useState<'idle' | 'copied' | 'failed'>('idle')
  const t = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(t.current), [])
  const run = async () => {
    try {
      await navigator.clipboard.writeText(text)
      setS('copied')
    } catch {
      setS('failed')
    }
    clearTimeout(t.current)
    t.current = setTimeout(() => setS('idle'), 1800)
  }
  return (
    <>
      <button type="button" onClick={run} className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-muted px-3.5 text-sm font-medium outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring">
        {s === 'copied' ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
        {s === 'copied' ? L.copied : s === 'failed' ? L.failed : L.copyAddress}
      </button>
      <span role="status" aria-live="polite" className="sr-only">{s === 'copied' ? L.copied : s === 'failed' ? L.failed : ''}</span>
    </>
  )
}

export function ContactOffices({ heading, intro, offices, now, labels = {}, className, ...rest }: ContactOfficesProps) {
  const L = {
    open: 'Open now', closed: 'Closed', localTime: 'Local time', copyAddress: 'Copy address', copied: 'Copied', failed: 'Not copied',
    directions: 'Directions', newTab: '(opens in a new tab)', hours: 'Opening hours',
    closesIn: (m: number) => (m >= 60 ? `Closes in ${Math.floor(m / 60)} h ${m % 60 ? `${m % 60} min` : ''}`.trim() : `Closes in ${m} min`),
    opensAt: (d: string, t: string) => `Opens ${d} ${t}`,
    days: DAYS,
    ...labels,
  }
  const uid = useId()
  const [clock, setClock] = useState<Date | null>(null)
  useEffect(() => {
    if (now) return
    // Read after mount, then again on each new minute (not on a timer from mount, which would lag up to 59 s).
    let timer = setTimeout(function tick() {
      setClock(new Date())
      timer = setTimeout(tick, 60_000 - (Date.now() % 60_000) + 20)
    }, 0)
    return () => clearTimeout(timer)
  }, [now])
  const at = now ?? clock
  const statuses = at ? offices.map((o) => officeStatus(o, at)) : null
  const soonest = statuses
    ? statuses.reduce<number>((best, s, i) => (s.open && (best < 0 || (s.closesIn ?? 1e9) < (statuses[best].closesIn ?? 1e9)) ? i : best), -1)
    : -1

  return (
    <section aria-labelledby={`${uid}-h`} className={cn('@container mx-auto w-full max-w-6xl px-4 py-12 sm:px-6', className)} {...rest}>
      <header className="max-w-2xl">
        <h2 id={`${uid}-h`} className="font-[family-name:var(--font-display,inherit)] text-3xl font-semibold tracking-tight text-balance @min-[40rem]:text-4xl">{heading}</h2>
        {intro && <p className="mt-3 text-muted-foreground">{intro}</p>}
      </header>
      <ul className="mt-8 grid list-none gap-3 p-0 @min-[44rem]:grid-cols-2">
        {offices.map((o, i) => {
          const st = statuses?.[i]
          return (
            <li key={o.id} className="min-w-0">
              <article className="flex h-full flex-col rounded-[26px] bg-card p-6 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]">
                <div className="flex items-start justify-between gap-3">
                  <h3 className="font-[family-name:var(--font-display,inherit)] text-2xl font-semibold">{o.city}</h3>
                  <span className="flex items-center gap-2 rounded-full bg-muted px-3 py-1 text-xs font-medium" aria-live="polite">
                    <span aria-hidden className={cn('size-2 rounded-full', st?.open ? 'bg-primary' : 'bg-muted-foreground/50')} />
                    {st ? (st.open ? L.open : L.closed) : <span className="inline-block h-3 w-14" aria-hidden />}
                  </span>
                </div>
                <p className="mt-1 font-mono text-xs tabular-nums text-muted-foreground">
                  {L.localTime} {st ? st.clock : '--:--'} · {o.timeZone}
                </p>
                {st && (
                  <p className="mt-3 min-h-5 font-mono text-xs tabular-nums">
                    {st.open && i === soonest && st.closesIn !== undefined && st.closesIn <= 120 && L.closesIn(st.closesIn)}
                    {!st.open && st.next && L.opensAt(L.days[st.next.day], st.next.time)}
                  </p>
                )}
                <address className="mt-3 text-sm not-italic">
                  {o.address}
                  {o.phone && (<><br /><a className="inline-flex min-h-11 items-center underline underline-offset-4 outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring tabular-nums" href={`tel:${o.phone.replace(/[^\d+]/g, '')}`}>{o.phone}</a></>)}
                </address>
                <table className="mt-4 w-full text-left font-mono text-xs tabular-nums text-muted-foreground">
                  <caption className="sr-only">{L.hours}</caption>
                  <tbody>
                    {[...o.hours].sort((a, b) => ((a.day + 6) % 7) - ((b.day + 6) % 7)).map((h) => (
                      <tr key={h.day + h.open}>
                        <th scope="row" className="py-0.5 pr-3 font-normal">{L.days[h.day]}</th>
                        <td className="py-0.5">{h.open} to {h.close}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
                <div className="mt-auto flex flex-wrap gap-2 pt-5">
                  <CopyAddress text={`${o.city}, ${o.address}`} L={L} />
                  {o.directionsHref && (
                    <a href={o.directionsHref} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring">
                      <MapPin className="size-4" aria-hidden />{L.directions}<span className="sr-only"> {o.city} {L.newTab}</span>
                    </a>
                  )}
                </div>
              </article>
            </li>
          )
        })}
      </ul>
    </section>
  )
}
contact-offices-demo.tsx
import { ContactOffices } from '@/components/contact-offices'

const WEEK = [1, 2, 3, 4, 5].map((day) => ({ day, open: '09:00', close: '17:00' }))

export default function ContactOfficesDemo() {
  return (
    <ContactOffices
      heading="Two offices, one team"
      intro="Invented addresses and numbers."
      offices={[
        { id: 'muc', city: 'Munich', address: 'Example Street 1, 80331 Munich', phone: '+49 89 0000 0000', timeZone: 'Europe/Berlin', hours: WEEK, directionsHref: 'https://example.com/map/munich' },
        { id: 'aal', city: 'Aalborg', address: 'Eksempelvej 2, 9000 Aalborg', phone: '+45 00 00 00 00', timeZone: 'Europe/Copenhagen', hours: WEEK, directionsHref: 'https://example.com/map/aalborg' },
      ]}
    />
  )
}

HTML

Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.

Åbn i ny fane

Farven i din egen app

Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .dark.

:root {
  --primary: oklch(0.52 0.2 262);
  --primary-foreground: oklch(0.99 0 0);
  --accent: oklch(0.95 0.034 262);
  --accent-foreground: oklch(0.36 0.140 262);
  --ring: oklch(0.52 0.2 262);
}

.dark {
  --primary: oklch(0.74 0.150 262);
  --primary-foreground: oklch(0.18 0.024 262);
  --accent: oklch(0.29 0.050 262);
  --accent-foreground: oklch(0.9 0.050 262);
  --ring: oklch(0.74 0.150 262);
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/contact-offices.json

Afhængigheder

Filer

  • contact-offices.tsx
  • contact-offices.html

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.