"use client"; import type { FacilityFilters, FacilityStatus, FacilityType, SearchResponse } from "@dci/core"; import { FACILITY_TYPES } from "@dci/core"; import { SlidersHorizontal } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { FilterChips, ToggleChip, type Chip } from "@/components/ui/filter-chips"; import { SearchInput } from "@/components/ui/search-input"; import { Sheet } from "@/components/ui/sheet"; import { StatusDot } from "@/components/ui/status-dot"; import { apiGet, clientApiBase } from "@/lib/api"; import { useDebounced } from "@/lib/hooks"; import { STATUS_LABEL, STATUS_ORDER, TYPE_LABEL } from "@/lib/labels"; import { countActiveFilters, csv, describeFilters, filtersToQuery, parseFilters, removeFilter } from "@/lib/query"; /* ------------------------------------------------------------------------------------------ Shared facility filter set (map, /datacenters, entity pages). Two pieces: - : the controls, driven by a (filters, onChange) pair (used inline in the map's desktop panel and inside the sheet). - : URL-synced chips + "Filters" button opening a Sheet (bottom on mobile). ------------------------------------------------------------------------------------------ */ export const MW_MAX = 500; const YEAR_MIN = 1990; const YEAR_MAX = new Date().getUTCFullYear() + 8; export interface FilterFieldsProps { value: FacilityFilters; onChange: (next: FacilityFilters) => void; /** hide some groups (e.g. cloud toggle) */ hide?: Array<"status" | "type" | "operator" | "country" | "mw" | "years" | "flags" | "cloud" | "q">; className?: string; /** extra non-facility toggles (map: cloud regions layer) */ extra?: ReactNode; } function Group({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { return (
{label}
{children} {hint &&

{hint}

}
); } const TYPE_CHOICES: FacilityType[] = FACILITY_TYPES.filter((t) => t !== "unknown"); function RangeInputs({ lo, hi, min, max, step = 1, unit, onChange, labels }: { lo: number | undefined; hi: number | undefined; min: number; max: number; step?: number; unit?: string; onChange: (lo: number | undefined, hi: number | undefined) => void; labels: [string, string] }) { const l = lo ?? min; const h = hi ?? max; return (
– {unit && {unit}}
onChange(Math.min(Number(e.target.value), h), hi)} className="dual-range absolute inset-x-0 top-0 h-6 w-full appearance-none bg-transparent" /> onChange(lo, Math.max(Number(e.target.value), l))} className="dual-range absolute inset-x-0 top-0 h-6 w-full appearance-none bg-transparent" />
); } /** Async operator search-select backed by /search (type=operator). */ function OperatorSelect({ value, onChange }: { value: string | undefined; onChange: (slug: string | undefined, name?: string) => void }) { const [q, setQ] = useState(""); const dq = useDebounced(q.trim(), 180); const [hits, setHits] = useState>([]); useEffect(() => { if (dq.length < 2) return; const ctrl = new AbortController(); apiGet("/search", { q: dq, type: "operator" }, { base: clientApiBase(), signal: ctrl.signal, timeoutMs: 5000 }).then((r) => { if (!ctrl.signal.aborted && r.ok) setHits(r.data.hits.filter((h) => h.type === "operator").map((h) => ({ slug: h.slug, title: h.title, subtitle: h.subtitle }))); }); return () => ctrl.abort(); }, [dq]); return (
{value ? (
{value}
) : ( <> {dq.length >= 2 && hits.length > 0 && (
    {hits.map((h) => (
  • ))}
)} )}
); } export function FacilityFilterFields({ value, onChange, hide = [], className, extra }: FilterFieldsProps) { const statuses = csv(value.status); const types = csv(value.type); const toggleCsv = (key: "status" | "type", member: string) => { const cur = csv(value[key]); const next = cur.includes(member) ? cur.filter((x) => x !== member) : [...cur, member]; onChange({ ...value, [key]: next.length ? next.join(",") : undefined, page: undefined }); }; const set = (patch: Partial) => onChange({ ...value, ...patch, page: undefined }); const show = (g: NonNullable[number]) => !hide.includes(g); return (
{show("q") && ( set({ q: v || undefined })} placeholder="Name, city, alias…" aria-label="Keyword" /> )} {show("status") && (
{STATUS_ORDER.filter((s) => s !== "unknown").map((s) => ( toggleCsv("status", s)} dot={}> {STATUS_LABEL[s]} ))}
)} {show("type") && (
{TYPE_CHOICES.map((t) => ( toggleCsv("type", t)}> {TYPE_LABEL[t]} ))}
)} {show("flags") && (
set({ ai: value.ai ? undefined : true })}> AI infrastructure set({ hyperscale: value.hyperscale ? undefined : true })}> Hyperscale set({ colocation: value.colocation ? undefined : true })}> Colocation set({ renewable: value.renewable ? undefined : true })}> Renewable claim set({ has_mw: value.has_mw ? undefined : true })}> Known MW only
{extra}
)} {show("operator") && ( set({ operator: slug })} /> )} {show("country") && ( set({ country: e.target.value.toUpperCase().replace(/[^A-Z,]/g, "") || undefined })} placeholder="US, CA, IE…" aria-label="Country codes" maxLength={29} className="figure h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] uppercase outline-none placeholder:normal-case placeholder:text-ink-4 focus:border-ink" /> )} {show("mw") && ( set({ min_mw: lo, max_mw: hi === MW_MAX ? undefined : hi })} /> )} {show("years") && ( set({ opened_from: lo === YEAR_MIN ? undefined : lo, opened_to: hi === YEAR_MAX ? undefined : hi })} /> )} {show("cloud") && ( set({ cloud: e.target.value || undefined })} placeholder="aws" aria-label="Cloud provider slug" className="h-9 w-full rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] outline-none placeholder:text-ink-4 focus:border-ink" /> )}
); } /* ---------------------------------------------------------------- URL-synced bar + sheet */ export interface FacilityFilterBarProps { /** path the chips/sheet write to (default: current) */ basePath?: string; total?: number | null; hide?: FilterFieldsProps["hide"]; className?: string; /** preserve these params when rewriting the URL (e.g. map view) */ keep?: string[]; children?: ReactNode; } export function useUrlFilters(basePath?: string, keep: string[] = []) { const router = useRouter(); const pathname = usePathname(); const sp = useSearchParams(); const filters = useMemo(() => parseFilters(sp), [sp]); const push = useCallback( (next: FacilityFilters) => { const q = new URLSearchParams(filtersToQuery(next)); for (const k of keep) { const v = sp.get(k); if (v !== null) q.set(k, v); } const s = q.toString(); router.replace(`${basePath ?? pathname}${s ? `?${s}` : ""}`, { scroll: false }); }, [router, pathname, basePath, sp, keep], ); return { filters, push, sp }; } export function FacilityFilterBar({ basePath, total, hide, className, keep, children }: FacilityFilterBarProps) { const { filters, push } = useUrlFilters(basePath, keep); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(filters); const [synced, setSynced] = useState(filters); if (synced !== filters) { setSynced(filters); setDraft(filters); } const active = countActiveFilters(filters); const chips: Chip[] = describeFilters(filters, { status: STATUS_LABEL, type: TYPE_LABEL }).map((c) => ({ key: `${c.key}:${c.member ?? c.value}`, label: c.label, value: c.value, onRemove: () => push(removeFilter(filters, c)) })); return (
{children} {total !== undefined && total !== null && ( {new Intl.NumberFormat("en-US").format(total)} {total === 1 ? "facility" : "facilities"} )}
push({ sort: filters.sort, order: filters.order })} /> setOpen(false)} title="Filter facilities" description={total !== undefined && total !== null ? `${new Intl.NumberFormat("en-US").format(total)} matching` : undefined} heightVh={82} footer={
} >
); }