Mikkel Manniche
Dansk English Deutsch

Vorlage · React · HTML

Contact Offices

Eine Kachel pro Büro mit Adresse, Telefon, Wochenzeiten, Ortszeit und geöffnet oder geschlossen in Worten.

Vorschau

React

Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.

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

Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.

In neuem Tab öffnen

Die Farbe in deiner eigenen App

Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel in .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);
}

Installieren

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

Abhängigkeiten

Dateien

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

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.