'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} />;
}