Zum Inhalt springen
UI Kit
Accent

Filialfinder

Den Laden finden. Eine gezeichnete Karte mit Liste, die gemeinsam aufleuchten, die Seite eines Ladens mit Woche und Angebot, Ladenkarten mit Viertel-Chips und ein Finder für den nächsten Laden.

Eine von 4 Components in Versand und Filialen

4 Variants

Variants

Karte und Liste

Eine gezeichnete Stadtteilkarte mit Pins, die nacheinander fallen, daneben eine Liste mit Suche. Hover oder Wahl eines Ladens lässt seinen Pin mit Schein und Namen aufleuchten, ein Tipp auf einen Pin scrollt die Liste dorthin. Auf dem Telefon sind Liste und Karte zwei Ansichten hinter einem Schalter.

Im Studio öffnen

Ladenseite

Ein Laden, jetzt geöffnet mit atmendem Punkt, ein Foto, das beim Scrollen treibt, Angebote, die nacheinander aufsteigen, der Pin auf der Karte, Route und Anruf, und die Woche mit markiertem Heute.

Im Studio öffnen

Ladenkarten

Ladenkarten mit Foto, Viertel, Entfernung und Öffnungsstand. Viertel-Chips und ein Schalter „nur geöffnete“ filtern die Karten, die bei jeder Änderung neu einlaufen. Karten heben sich beim Hover, das Foto zoomt innen.

Im Studio öffnen

Nächster Laden

Standort nutzen oder Postleitzahl. Während der Suche pulsiert ein Ring über der Karte, dann läuft der nächste Laden mit Entfernung, Wegzeit und Öffnungsstand ein, dazu zwei weitere in der Nähe. „Als meinen Laden wählen“ bestätigt mit Haken.

Im Studio öffnen

Details

Was Filialfinder mitbringt, aus Registry und Quellcode gelesen.

Bereich
E-Commerce
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/store-locator.tsx
Bringt mit
utilskitbuttoniconrevealmotionsectionmapsproduct-image
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 30 eigene Texte
In Sammlungen
Lokales Geschäft

Props

Die Schnittstelle StoreLocatorProps aus components/ui/store-locator.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    StoreLocatorVariant

    map-liststore-detailcardsnearest

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • storesoptional
    Store[]
  • storeIdoptional
    string

    store-detail: the store to show, default the first

  • mapoptional
    React.ReactNode

    The map under the pins, default a drawn district map

  • defaultLocatedoptional
    boolean

    nearest: show the result right away (previews)

  • onLocateoptional
    () => void

Eingebaute Texte

Diese Texte schreibt Filialfinder selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 30 Texte anzeigen
searchEnglisch City or postcodeDeutsch Stadt oder Postleitzahl
searchLabelEnglisch Search storesDeutsch Läden suchen
storesEnglisch storesDeutsch Läden
storeEnglisch storeDeutsch Laden
listEnglisch ListDeutsch Liste
mapEnglisch MapDeutsch Karte
closedEnglisch ClosedDeutsch Geschlossen
openEnglisch OpenDeutsch Geöffnet
directionsEnglisch Get directionsDeutsch Route planen
callEnglisch CallDeutsch Anrufen
detailsEnglisch Store detailsDeutsch Zum Laden
hoursEnglisch Opening hoursDeutsch Öffnungszeiten
servicesEnglisch In this storeDeutsch In diesem Laden
noMatchEnglisch No store matches that yet. Try a city or show all stores.Deutsch Dazu passt noch kein Laden. Versuch eine Stadt oder zeig alle Läden.
showAllEnglisch Show all storesDeutsch Alle Läden zeigen
allEnglisch AllDeutsch Alle
onlyOpenEnglisch Open now onlyDeutsch Nur geöffnete
todayEnglisch TodayDeutsch Heute
locateEnglisch Use my locationDeutsch Meinen Standort nutzen
locatingEnglisch Finding youDeutsch Standort wird gesucht
orPostcodeEnglisch or enter a postcodeDeutsch oder Postleitzahl eingeben
postcodeEnglisch PostcodeDeutsch Postleitzahl
findEnglisch FindDeutsch Suchen
nearestEnglisch Nearest to youDeutsch Am nächsten bei dir
alsoNearEnglisch Also near youDeutsch Auch in deiner Nähe
chooseEnglisch Choose this storeDeutsch Diesen Laden wählen
chosenEnglisch Your storeDeutsch Dein Laden
phoneEnglisch PhoneDeutsch Telefon
addressEnglisch AddressDeutsch Adresse
pinLabelEnglisch Show on mapDeutsch Auf der Karte zeigen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-checked, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio, radiogroup und switch.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 6 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 648 Zeilen
components/ui/store-locator.tsx
'use client';

import { useId, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { DistrictMap } from '@/components/ui/maps';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';

/*
 * Finding a store: a map with a list, the page of one store, store cards
 * with filters and a small "nearest store" finder.
 *
 *   map-list    a drawn map with pins and a list; picking one lights up the other
 *   store-detail  one store: open now, the week, services, how to get there
 *   cards       store cards with region chips and an "open now" switch
 *   nearest     use my location or a postcode, the nearest store and two more
 *
 * The map is drawn (maps.tsx). Pin positions are percent of the map, x from
 * the left, y from the top. Pass your own map node to use a real one.
 */

export type StoreLocatorVariant = 'map-list' | 'store-detail' | 'cards' | 'nearest';

export interface StoreHours {
  /** e.g. "Monday" */
  day: string;
  /** e.g. "10:00 to 20:00" or "Closed" */
  hours: string;
  today?: boolean;
}

export interface Store {
  id: string;
  name: string;
  address: string;
  /** City or district, used by the region chips */
  region?: string;
  /** Pin position on the map, percent */
  x?: number;
  y?: number;
  /** Already formatted, e.g. "1.2 km" */
  distance?: string;
  /** e.g. "8 min on foot" */
  travel?: string;
  open?: boolean;
  /** e.g. "Closes at 8 pm" or "Opens at 10 am" */
  status?: string;
  phone?: string;
  hours?: StoreHours[];
  services?: { icon?: IconName; label: string }[];
  image?: React.ReactNode;
  href?: string;
  /** store-detail: a line about the store */
  note?: string;
}

export interface StoreLocatorProps {
  variant?: StoreLocatorVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  stores?: Store[];
  /** store-detail: the store to show, default the first */
  storeId?: string;
  /** The map under the pins, default a drawn district map */
  map?: React.ReactNode;
  /** nearest: show the result right away (previews) */
  defaultLocated?: boolean;
  onLocate?: () => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'City or postcode',
    searchLabel: 'Search stores',
    stores: 'stores',
    store: 'store',
    list: 'List',
    map: 'Map',
    closed: 'Closed',
    open: 'Open',
    directions: 'Get directions',
    call: 'Call',
    details: 'Store details',
    hours: 'Opening hours',
    services: 'In this store',
    noMatch: 'No store matches that yet. Try a city or show all stores.',
    showAll: 'Show all stores',
    all: 'All',
    onlyOpen: 'Open now only',
    today: 'Today',
    locate: 'Use my location',
    locating: 'Finding you',
    orPostcode: 'or enter a postcode',
    postcode: 'Postcode',
    find: 'Find',
    nearest: 'Nearest to you',
    alsoNear: 'Also near you',
    choose: 'Choose this store',
    chosen: 'Your store',
    phone: 'Phone',
    address: 'Address',
    pinLabel: 'Show on map',
  },
  de: {
    search: 'Stadt oder Postleitzahl',
    searchLabel: 'Läden suchen',
    stores: 'Läden',
    store: 'Laden',
    list: 'Liste',
    map: 'Karte',
    closed: 'Geschlossen',
    open: 'Geöffnet',
    directions: 'Route planen',
    call: 'Anrufen',
    details: 'Zum Laden',
    hours: 'Öffnungszeiten',
    services: 'In diesem Laden',
    noMatch: 'Dazu passt noch kein Laden. Versuch eine Stadt oder zeig alle Läden.',
    showAll: 'Alle Läden zeigen',
    all: 'Alle',
    onlyOpen: 'Nur geöffnete',
    today: 'Heute',
    locate: 'Meinen Standort nutzen',
    locating: 'Standort wird gesucht',
    orPostcode: 'oder Postleitzahl eingeben',
    postcode: 'Postleitzahl',
    find: 'Suchen',
    nearest: 'Am nächsten bei dir',
    alsoNear: 'Auch in deiner Nähe',
    choose: 'Diesen Laden wählen',
    chosen: 'Dein Laden',
    phone: 'Telefon',
    address: 'Adresse',
    pinLabel: 'Auf der Karte zeigen',
  },
};

type Texts = (typeof TEXTS)['en'];

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';

function Head({ eyebrow, title, description }: { eyebrow?: string; title?: string; description?: string }) {
  if (!title) return null;
  return (
    <div className="mb-10 max-w-[40rem] sm:mb-12">
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
          {description}
        </p>
      )}
    </div>
  );
}

/** Open or closed with a breathing dot while open */
function OpenState({ store, t, className }: { store: Store; t: Texts; className?: string }) {
  if (store.open === undefined && !store.status) return null;
  return (
    <span className={cn('inline-flex items-center gap-1.5 text-[13px]', className)}>
      <span className="relative flex size-2" aria-hidden>
        {store.open && <span className="absolute inset-0 animate-kit-pulse rounded-full bg-success" />}
        <span className={cn('relative size-2 rounded-full', store.open ? 'bg-success' : 'bg-subtle-foreground')} />
      </span>
      <span className={cn('font-medium', store.open ? 'text-success-foreground' : 'text-muted-foreground')}>{store.open ? t.open : t.closed}</span>
      {store.status && <span className="text-muted-foreground">· {store.status}</span>}
    </span>
  );
}

/** A pin on the map. The active one grows, gets a label and a soft halo. */
function Pin({ store, active, onPick, index, label }: { store: Store; active: boolean; onPick: () => void; index: number; label: string }) {
  return (
    <button
      type="button"
      onClick={onPick}
      aria-label={`${label}, ${store.name}`}
      aria-pressed={active}
      className={cn('group/pin absolute z-10 after:absolute after:-inset-2 after:content-[""] -translate-x-1/2 -translate-y-full rounded-full', active && 'z-20', FOCUS)}
      style={{ left: `${store.x ?? 50}%`, top: `${store.y ?? 50}%` }}
    >
      <span className={cn('absolute left-1/2 top-full size-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] transition-[opacity,transform] duration-500', active ? 'scale-100 animate-kit-pulse opacity-100' : 'scale-50 opacity-0')} aria-hidden />
      <span className={cn('relative block origin-bottom transition-transform duration-300 ease-[var(--ease-kit)] group-hover/pin:-translate-y-1 animate-[kitshop-pop_0.5s_var(--ease-kit)_both]', active ? 'scale-125' : 'scale-100')} style={{ animationDelay: `${200 + index * 90}ms` }}>
        <svg viewBox="0 0 24 30" className="h-9 w-[29px] drop-shadow-[0_4px_6px_rgb(0_0_0/0.25)]" aria-hidden>
          <path d="M12 29c5.6-6 10-11 10-16.5A10 10 0 0 0 2 12.5C2 18 6.4 23 12 29Z" className={cn('transition-colors duration-300', active ? 'fill-primary' : 'fill-foreground')} />
          <circle cx="12" cy="12" r="4" className={cn(active ? 'fill-primary-foreground' : 'fill-background')} />
        </svg>
      </span>
      {active && (
        <span className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 animate-[kitshop-in_0.35s_var(--ease-kit)] whitespace-nowrap rounded-lg border border-border bg-popover px-2.5 py-1.5 text-[12.5px] font-medium text-foreground shadow-[var(--shadow-md)]">
          {store.name}
        </span>
      )}
    </button>
  );
}

function MapPanel({ stores, active, onPick, map, t, className }: { stores: Store[]; active: string; onPick: (id: string) => void; map?: React.ReactNode; t: Texts; className?: string }) {
  const ref = useScrollProgress<HTMLDivElement>();
  return (
    <div ref={ref} className={cn('relative isolate aspect-[10/7] overflow-hidden rounded-2xl border border-border bg-surface-2', className)}>
      {/* The map drifts a little while scrolling, the pins stay on their spots */}
      <div data-clip-ok className="absolute inset-0 scale-[1.06] motion-reduce:!transform-none" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 3%), 0) scale(1.06)' }}>
        {map ?? <DistrictMap className="size-full" />}
      </div>
      <div className="absolute inset-0">
        {stores.map((s, i) => (
          <Pin key={s.id} store={s} index={i} active={s.id === active} onPick={() => onPick(s.id)} label={t.pinLabel} />
        ))}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* map-list                                                            */
/* ------------------------------------------------------------------ */

function MapList(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const stores = p.stores ?? [];
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(stores[0]?.id ?? '');
  const [view, setView] = useState<'list' | 'map'>('list');
  const listRef = useRef<HTMLUListElement>(null);
  const id = useId();
  const q = query.trim().toLowerCase();
  const shown = useMemo(() => (q ? stores.filter((s) => `${s.name} ${s.address} ${s.region ?? ''}`.toLowerCase().includes(q)) : stores), [stores, q]);
  const pick = (sid: string, fromMap = false) => {
    setActive(sid);
    if (fromMap) {
      const el = listRef.current?.querySelector<HTMLElement>(`[data-store="${sid}"]`);
      el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
    }
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,24rem)_minmax(0,1fr)] lg:gap-6">
        <div className="flex min-h-0 flex-col gap-3 lg:relative">
          <div className="flex gap-2">
            <label htmlFor={id} className="relative block flex-1">
              <span className="sr-only">{t.searchLabel}</span>
              <Icon name="search" size={16} className="pointer-events-none absolute start-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
              <input
                id={id}
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder={t.search}
                className="h-11 w-full rounded-lg border border-input bg-background ps-10 pe-3 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]"
              />
            </label>
            {/* Phones: list and map as two views of the same stores */}
            <div role="radiogroup" aria-label={`${t.list}, ${t.map}`} className="relative grid h-11 shrink-0 grid-cols-2 rounded-lg border border-border-strong bg-surface p-1 lg:hidden">
              <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]" style={{ transform: `translateX(${view === 'map' ? 100 : 0}%)` }} />
              {(['list', 'map'] as const).map((v) => (
                <button key={v} type="button" role="radio" aria-checked={view === v} onClick={() => setView(v)} className={cn('relative z-10 grid w-12 place-items-center after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] rounded-md transition-colors', view === v ? 'text-foreground' : 'text-muted-foreground', FOCUS)} aria-label={v === 'list' ? t.list : t.map}>
                  <Icon name={v === 'list' ? 'list' : 'map'} size={17} />
                </button>
              ))}
            </div>
          </div>
          <p className="text-[13px] text-muted-foreground" aria-live="polite">
            <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, shown.length)}</span> {shown.length === 1 ? t.store : t.stores}
          </p>
          <div className={cn('min-h-0 lg:relative lg:flex-1', view === 'map' && 'max-lg:hidden')}>
            {shown.length ? (
              <ul ref={listRef} key={q} className="kit-scrollbar space-y-2 lg:absolute lg:inset-0 lg:overflow-y-auto lg:pb-10 lg:pe-1 lg:[mask-image:linear-gradient(to_bottom,black_86%,transparent)]">
                {shown.map((s, i) => {
                  const on = s.id === active;
                  return (
                    <li key={s.id} data-store={s.id} className="animate-[kitshop-in_0.4s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 6) * 50}ms` }}>
                      <button
                        type="button"
                        aria-pressed={on}
                        onClick={() => pick(s.id)}
                        onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(s.id)}
                        className={cn('w-full rounded-xl border p-4 text-left transition-[border-color,background-color,box-shadow] duration-300', on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong', FOCUS)}
                      >
                        <span className="flex items-baseline justify-between gap-3">
                          <span className="text-[15px] font-semibold text-foreground">{s.name}</span>
                          {s.distance && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                        </span>
                        <span className="mt-0.5 block text-[13.5px] text-muted-foreground">{s.address}</span>
                        <OpenState store={s} t={t} className="mt-2" />
                      </button>
                    </li>
                  );
                })}
              </ul>
            ) : (
              <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border-strong p-8 text-center">
                <p className="text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
                <Button variant="outline" size="sm" onClick={() => setQuery('')}>
                  {t.showAll}
                </Button>
              </div>
            )}
          </div>
        </div>
        <Reveal effect="scale" className={cn(view === 'list' && 'max-lg:hidden')}>
          <MapPanel stores={shown} active={active} onPick={(sid) => pick(sid, true)} map={p.map} t={t} />
          {/* Phones: the picked store under the map, so the map view stands on its own */}
          {(() => {
            const s = shown.find((x) => x.id === active);
            return s ? (
              <div key={s.id} className={cn(CARD, 'mt-3 animate-[kitshop-in_0.4s_var(--ease-kit)] p-4 lg:hidden')} aria-live="polite">
                <div className="flex items-baseline justify-between gap-3">
                  <p className="text-[15px] font-semibold text-foreground">{s.name}</p>
                  {s.distance && <span className="text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                </div>
                <p className="mt-0.5 text-[13.5px] text-muted-foreground">{s.address}</p>
                <div className="mt-3 flex items-center justify-between gap-3">
                  <OpenState store={s} t={t} />
                  <Button href={s.href ?? '#'} size="sm" variant="outline" icon="arrow-right" className="h-11">
                    {t.details}
                  </Button>
                </div>
              </div>
            ) : null;
          })()}
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* store-detail                                                        */
/* ------------------------------------------------------------------ */

function StoreDetail(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const stores = p.stores ?? [];
  const s = stores.find((x) => x.id === p.storeId) ?? stores[0];
  const parallax = useScrollProgress<HTMLDivElement>();
  if (!s) return null;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-14">
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? s.name}</h2>
          <OpenState store={s} t={t} className="kit-rise mt-4 text-[14px]" />
          {s.note && (
            <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              {s.note}
            </p>
          )}
          {s.image && (
            <div ref={parallax} className="relative mt-8 aspect-[16/10] overflow-hidden rounded-2xl bg-surface-2">
              <div className="absolute inset-x-0 -inset-y-[6%] motion-reduce:!transform-none [&>*]:size-full" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 8%), 0)' }}>
                {s.image}
              </div>
            </div>
          )}
          {s.services?.length ? (
            <div className="mt-8">
              <h3 className="text-[15px] font-semibold text-foreground">{t.services}</h3>
              <Stagger step={60} as="ul" className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
                {s.services.map((sv) => (
                  <li key={sv.label} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-[14px] text-foreground">
                    <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
                      <Icon name={sv.icon ?? 'check'} size={17} />
                    </span>
                    {sv.label}
                  </li>
                ))}
              </Stagger>
            </div>
          ) : null}
        </div>

        <div className="lg:pt-2">
          <Reveal effect="rise" className={cn(CARD, 'overflow-hidden')}>
            <MapPanel stores={[{ ...s, x: 50, y: 62 }]} active={s.id} onPick={() => {}} map={p.map} t={t} className="rounded-none border-0 border-b" />
            <div className="space-y-5 p-6">
              <div>
                <p className="text-[12.5px] text-muted-foreground">{t.address}</p>
                <p className="mt-0.5 text-[15px] font-medium text-foreground">{s.address}</p>
              </div>
              {s.phone && (
                <div>
                  <p className="text-[12.5px] text-muted-foreground">{t.phone}</p>
                  <a href={`tel:${s.phone.replace(/\s/g, '')}`} className={cn('relative mt-0.5 inline-block rounded-sm after:absolute after:inset-x-0 after:-inset-y-[11px] after:content-[""] text-[15px] font-medium tabular-nums text-foreground hover:underline', FOCUS)}>
                    {s.phone}
                  </a>
                </div>
              )}
              <div className="grid grid-cols-2 gap-2">
                <Button href={s.href ?? '#'} iconLeft="navigation">
                  {t.directions}
                </Button>
                {s.phone && (
                  <Button href={`tel:${s.phone.replace(/\s/g, '')}`} variant="outline" iconLeft="phone">
                    {t.call}
                  </Button>
                )}
              </div>
            </div>
            {s.hours?.length ? (
              <div className="border-t border-border p-6">
                <h3 className="text-[15px] font-semibold text-foreground">{t.hours}</h3>
                <dl className="mt-3 divide-y divide-border">
                  {s.hours.map((h) => (
                    <div key={h.day} className={cn('-mx-3 flex justify-between gap-4 rounded-lg px-3 py-2.5 text-[14px]', h.today && 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]')}>
                      <dt className={cn(h.today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
                        {h.day}
                        {h.today && <span className="ms-2 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.today}</span>}
                      </dt>
                      <dd className={cn('tabular-nums', h.today ? 'font-semibold text-foreground' : 'text-foreground')}>{h.hours}</dd>
                    </div>
                  ))}
                </dl>
              </div>
            ) : null}
          </Reveal>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* cards                                                               */
/* ------------------------------------------------------------------ */

function Cards(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const stores = p.stores ?? [];
  const regions = Array.from(new Set(stores.map((s) => s.region).filter(Boolean))) as string[];
  const [region, setRegion] = useState<string | null>(null);
  const [onlyOpen, setOnlyOpen] = useState(false);
  const shown = stores.filter((s) => (region ? s.region === region : true)).filter((s) => (onlyOpen ? s.open : true));
  const key = `${region}-${onlyOpen}`;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <div className="kit-no-scrollbar kit-fade-end -mx-6 flex gap-2 overflow-x-auto px-6 py-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:[mask-image:none]" role="group" aria-label={t.searchLabel}>
          {[null, ...regions].map((r) => (
            <button
              key={r ?? 'all'}
              type="button"
              aria-pressed={region === r}
              onClick={() => setRegion(r)}
              className={cn('inline-flex h-11 shrink-0 items-center rounded-full border px-4 text-[13.5px] font-medium transition-colors sm:h-10', region === r ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}
            >
              {r ?? t.all}
            </button>
          ))}
        </div>
        <button type="button" role="switch" aria-checked={onlyOpen} onClick={() => setOnlyOpen(!onlyOpen)} className={cn('flex min-h-11 shrink-0 items-center gap-3 rounded-lg text-[14px] font-medium text-foreground', FOCUS)}>
          <span aria-hidden className={cn('relative h-6 w-11 rounded-full transition-colors duration-300', onlyOpen ? 'bg-success' : 'bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]')}>
            <span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-background shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', onlyOpen && 'translate-x-5')} />
          </span>
          {t.onlyOpen}
        </button>
      </div>
      <p className="sr-only" aria-live="polite">
        {formatNumber(locale, shown.length)} {t.stores}
      </p>
      {shown.length ? (
        <ul key={key} className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
          {shown.map((s, i) => (
            <li key={s.id} className="animate-[kitshop-in_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 6) * 70}ms` }}>
              <a href={s.href ?? '#'} className={cn('group/card flex h-full flex-col rounded-2xl border border-border bg-card p-2.5 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0', FOCUS)}>
                <span className="relative block aspect-[16/10] overflow-hidden rounded-xl bg-surface-2">
                  <span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{s.image}</span>
                  {s.region && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12px] font-medium text-foreground backdrop-blur">{s.region}</span>}
                </span>
                <span className="flex flex-1 flex-col px-2.5 pb-2 pt-4">
                  <span className="flex items-baseline justify-between gap-3">
                    <span className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">{s.name}</span>
                    {s.distance && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                  </span>
                  <span className="mt-0.5 text-[14px] text-muted-foreground">{s.address}</span>
                  <OpenState store={s} t={t} className="mt-3" />
                  <span className="mt-auto inline-flex items-center gap-1.5 pt-4 text-[14px] font-medium text-foreground">
                    {t.details}
                    <Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/card:translate-x-1" />
                  </span>
                </span>
              </a>
            </li>
          ))}
        </ul>
      ) : (
        <div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border-strong p-10 text-center">
          <p className="max-w-[24rem] text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
          <Button variant="outline" onClick={() => { setRegion(null); setOnlyOpen(false); }}>
            {t.showAll}
          </Button>
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* nearest                                                             */
/* ------------------------------------------------------------------ */

function Nearest(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const stores = p.stores ?? [];
  const [state, setState] = useState<'idle' | 'locating' | 'done'>(p.defaultLocated ? 'done' : 'idle');
  const [code, setCode] = useState('');
  const [mine, setMine] = useState<string | null>(null);
  const id = useId();
  const find = () => {
    setState('locating');
    p.onLocate?.();
    window.setTimeout(() => setState('done'), 900);
  };
  const [first, ...rest] = stores;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className={cn(CARD, 'mx-auto grid max-w-[62rem] grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]')}>
        <div className="flex flex-col p-6 sm:p-8">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="text-balance text-h3 font-semibold text-foreground">{p.title}</h2>
          {p.description && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{p.description}</p>}
          <Button className="mt-6 w-full" size="lg" iconLeft={state === 'locating' ? 'loader' : 'locate'} disabled={state === 'locating'} onClick={find}>
            <Swap value={state === 'locating'}>{state === 'locating' ? t.locating : t.locate}</Swap>
          </Button>
          <p className="my-4 flex items-center gap-3 text-[12.5px] text-subtle-foreground">
            <span className="h-px flex-1 bg-border" />
            {t.orPostcode}
            <span className="h-px flex-1 bg-border" />
          </p>
          <form
            className="flex gap-2"
            onSubmit={(e) => {
              e.preventDefault();
              if (code.trim()) find();
            }}
          >
            <label htmlFor={id} className="sr-only">
              {t.postcode}
            </label>
            <input id={id} value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" autoComplete="postal-code" placeholder={t.postcode} className="h-11 w-0 min-w-0 flex-1 rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none placeholder:text-subtle-foreground focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]" />
            <Button type="submit" variant="outline">
              {t.find}
            </Button>
          </form>
        </div>

        <div className="relative min-h-[22rem] border-t border-border bg-surface md:border-l md:border-t-0">
          {state !== 'done' ? (
            <div className="absolute inset-0">
              <DistrictMap className={cn('size-full transition-[filter,opacity] duration-700', state === 'locating' ? 'opacity-80' : 'opacity-60 blur-[1px]')} />
              <span className="absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-background/90 text-foreground shadow-[var(--shadow-md)] backdrop-blur">
                <Icon name="locate" size={24} className={cn(state === 'locating' && 'animate-kit-pulse')} />
                {state === 'locating' && <span aria-hidden className="absolute inset-0 animate-ping rounded-full border-2 border-primary motion-reduce:animate-none" />}
              </span>
            </div>
          ) : (
            first && (
              <div className="flex h-full flex-col p-6 sm:p-8" aria-live="polite">
                <p className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]">{t.nearest}</p>
                <div className="mt-2 animate-[kitshop-in_0.5s_var(--ease-kit)_both]">
                  <div className="flex items-baseline justify-between gap-3">
                    <h3 className="text-[20px] font-semibold tracking-[-0.015em] text-foreground">{first.name}</h3>
                    {first.distance && <span className="text-[14px] font-medium tabular-nums text-foreground">{first.distance}</span>}
                  </div>
                  <p className="mt-0.5 text-[14px] text-muted-foreground">{first.address}</p>
                  <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
                    <OpenState store={first} t={t} />
                    {first.travel && (
                      <span className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
                        <Icon name="navigation" size={13} />
                        {first.travel}
                      </span>
                    )}
                  </div>
                  <div className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
                    <Button size="sm" className="h-11 sm:h-10" variant={mine === first.id ? 'secondary' : 'primary'} iconLeft={mine === first.id ? 'check' : 'store'} onClick={() => setMine(first.id)}>
                      <Swap value={mine === first.id}>{mine === first.id ? t.chosen : t.choose}</Swap>
                    </Button>
                    <Button size="sm" className="h-11 sm:h-10" variant="outline" href={first.href ?? '#'} iconLeft="navigation">
                      {t.directions}
                    </Button>
                  </div>
                </div>
                {rest.length > 0 && (
                  <div className="mt-6 border-t border-border pt-5">
                    <p className="text-[12.5px] text-muted-foreground">{t.alsoNear}</p>
                    <ul className="mt-2 divide-y divide-border">
                      {rest.slice(0, 2).map((s, i) => (
                        <li key={s.id} className="animate-[kitshop-in_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${150 + i * 80}ms` }}>
                          <button type="button" onClick={() => setMine(s.id)} className={cn('flex w-full items-center justify-between gap-3 py-3 text-left', FOCUS)}>
                            <span className="min-w-0">
                              <span className="block truncate text-[14.5px] font-medium text-foreground">{s.name}</span>
                              <OpenState store={s} t={t} className="mt-0.5" />
                            </span>
                            <span className="flex shrink-0 items-center gap-2 text-[13px] tabular-nums text-muted-foreground">
                              {s.distance}
                              <Icon name={mine === s.id ? 'check-circle' : 'chevron-right'} size={16} className={cn(mine === s.id && 'text-success-foreground')} />
                            </span>
                          </button>
                        </li>
                      ))}
                    </ul>
                  </div>
                )}
              </div>
            )
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * Store finders in four layouts. map-list: map and list in sync.
 * store-detail: one store with hours and services. cards: filterable cards.
 * nearest: the nearest store from a location or postcode.
 */
export function StoreLocator(props: StoreLocatorProps) {
  const { variant = 'map-list' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'store-detail') return <StoreDetail {...props} t={t} />;
  if (variant === 'cards') return <Cards {...props} t={t} />;
  if (variant === 'nearest') return <Nearest {...props} t={t} />;
  return <MapList {...props} t={t} />;
}

Häufige Fragen

Wie installiere ich Filialfinder?

Mit aiw add @ai-website/store-locator. Der Befehl legt die Datei components/ui/store-locator.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, motion, section, maps und product-image. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Filialfinder?

4 Varianten, Karte und Liste, Ladenseite, Ladenkarten und Nächster Laden. Du wählst sie mit der Prop variant, zum Beispiel variant="store-detail".

Kann ich die Texte von Filialfinder übersetzen?

Ja. Filialfinder bringt 30 eigene Texte auf Deutsch und Englisch mit, etwa „Stadt oder Postleitzahl“, „Läden suchen“ und „Läden“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Filialfinder einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Filialfinder mit aiw_components und installieren sie mit aiw_add @ai-website/store-locator. Nenn im Prompt die Variante, etwa variant="map-list", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Filialfinder ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden StoreLocator-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert