SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
5 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
14.2 KB · 309 lines tsx
Raw Blame History
1"use client";23import type { ExploreQuery, ExploreResponse } from "@dci/core";4import { CONFIDENCE_LEVELS, FACILITY_STATUSES, FACILITY_TYPES, GEO_PRECISIONS, PIPELINE_STATUSES, PROJECT_CLASSES } from "@dci/core";5import { SlidersHorizontal } from "lucide-react";6import { useRouter } from "next/navigation";7import { useState, type ReactNode } from "react";8import { Button } from "@/components/ui/button";9import { cn } from "@/components/ui/cn";10import { ToggleChip } from "@/components/ui/filter-chips";11import { SegmentedControl } from "@/components/ui/segmented";12import { Sheet } from "@/components/ui/sheet";13import { AI_FILTERS, countExploreFilters, exploreHref, toggleCsv } from "@/lib/explore-query";14import { fmtInt } from "@/lib/format";15import { CONFIDENCE_SHORT, PRECISION_SHORT, STATUS_LABEL, TYPE_LABEL } from "@/lib/labels";1617const PROJECT_STATUSES = [...PIPELINE_STATUSES, "operational", "expansion", "cancelled"] as const;1819const CLASS_LABEL: Record<string, string> = {20  NEW_BUILD: "New build",21  EXPANSION: "Expansion",22  CONSTRUCTION_START: "Construction start",23  PERMIT: "Permit",24  LAND_ACQUISITION: "Land acquisition",25  GRID_CONNECTION: "Grid connection",26  POWER_AGREEMENT: "Power agreement",27  FINANCING: "Financing",28  ACQUISITION: "Acquisition",29  PARTNERSHIP: "Partnership",30  CUSTOMER_AGREEMENT: "Customer agreement",31  EXECUTIVE_APPOINTMENT: "Executive appointment",32  SUSTAINABILITY: "Sustainability",33  PRODUCT_NEWS: "Product news",34  GENERAL_COMPANY_NEWS: "Company news",35  UNKNOWN: "Unknown",36};3738const AI_LABEL: Record<(typeof AI_FILTERS)[number], string> = { any: "Any", confirmed: "Confirmed", likely: "≥ Likely", associated: "≥ Associated" };3940const inputCls = "h-10 w-full min-w-0 rounded-sm border border-hair-2 bg-surface px-2.5 text-[13px] text-ink outline-none focus:border-ink sm:h-9";4142function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {43  return (44    <label className="block min-w-0">45      <span className="label-caps mb-1 block">{label}</span>46      {children}47      {hint && <span className="mt-0.5 block text-[11px] text-ink-4">{hint}</span>}48    </label>49  );50}5152function Group({ legend, children }: { legend: string; children: ReactNode }) {53  return (54    <fieldset className="border-t border-hair py-3 first:border-t-0 first:pt-0">55      <legend className="label-caps mb-2">{legend}</legend>56      {children}57    </fieldset>58  );59}6061interface Props {62  query: ExploreQuery;63  facets: ExploreResponse["facets"] | null;64}6566/**67 * Structured query builder (URL-as-state). Desktop: inline left column; mobile: a Filters button opening a sheet.68 * Chips apply immediately; text/number inputs apply on blur / Enter (and via the sheet's Apply).69 */70export function QueryBuilder({ query, facets }: Props) {71  return (72    <>73      <div className="hidden lg:block">74        <Form query={query} facets={facets} />75      </div>76      <MobileFilters query={query} facets={facets} />77    </>78  );79}8081function MobileFilters({ query, facets }: Props) {82  const [open, setOpen] = useState(false);83  const active = countExploreFilters(query);84  return (85    <div className="lg:hidden">86      <Button size="sm" variant={active ? "primary" : "secondary"} onClick={() => setOpen(true)} aria-haspopup="dialog">87        <SlidersHorizontal className="size-3.5" strokeWidth={1.75} /> Filters{active > 0 && <span className="figure ml-0.5 rounded-xs bg-paper/20 px-1 text-[11px]">{active}</span>}88      </Button>89      <Sheet open={open} onClose={() => setOpen(false)} title="Build a query" description="Every field maps to a URL parameter — the address bar is the saved view." heightVh={86}>90        <Form query={query} facets={facets} onNavigate={() => setOpen(false)} />91      </Sheet>92    </div>93  );94}9596function Form({ query, facets, onNavigate }: Props & { onNavigate?: () => void }) {97  const router = useRouter();98  const isProjects = query.entity === "projects";99  const [draft, setDraft] = useState<ExploreQuery>(query);100  const [synced, setSynced] = useState(query);101  if (synced !== query) {102    setSynced(query);103    setDraft(query);104  }105106  const go = (patch: Partial<ExploreQuery> & Record<string, unknown>) => {107    router.replace(exploreHref(query, { ...patch, page: undefined }), { scroll: false });108    onNavigate?.();109  };110  const commit = () => go({ ...draft });111  const csvOn = (key: keyof ExploreQuery, member: string) => ((draft[key] as string | undefined) ?? "").split(",").includes(member);112  const flip = (key: keyof ExploreQuery, member: string) => go({ [key]: toggleCsv(query[key] as string | undefined, member) });113  const onKey = (e: React.KeyboardEvent) => {114    if (e.key === "Enter") {115      e.preventDefault();116      commit();117    }118  };119  const setNum = (key: keyof ExploreQuery) => (e: React.ChangeEvent<HTMLInputElement>) => {120    const v = e.target.value;121    setDraft({ ...draft, [key]: v === "" ? undefined : Number(v) });122  };123  const statuses = isProjects ? PROJECT_STATUSES : FACILITY_STATUSES;124  const countryFacet = (facets?.country ?? []).slice(0, 12);125  const operatorFacet = (facets?.operator ?? []).slice(0, 8);126127  return (128    <form129      className="flex flex-col"130      onSubmit={(e) => {131        e.preventDefault();132        commit();133      }}134    >135      <Group legend="Entity">136        <SegmentedControl137          label="Entity"138          value={isProjects ? "projects" : "facilities"}139          onChange={(v) => {140            router.replace(exploreHref({ entity: v as ExploreQuery["entity"], view: query.view, q: query.q, country: query.country, operator: query.operator, metro: query.metro }), { scroll: false });141            onNavigate?.();142          }}143          options={[144            { value: "facilities", label: "Facilities" },145            { value: "projects", label: "Projects" },146          ]}147          className="w-full [&>*]:flex-1"148        />149      </Group>150151      <Group legend="Search">152        <input value={draft.q ?? ""} onChange={(e) => setDraft({ ...draft, q: e.target.value || undefined })} onBlur={commit} onKeyDown={onKey} placeholder={isProjects ? "Project, operator or city" : "Facility, operator or city"} className={inputCls} aria-label="Free text" />153      </Group>154155      <Group legend="Status">156        <div className="flex flex-wrap gap-1.5">157          {statuses.map((s) => (158            <ToggleChip key={s} active={csvOn("status", s)} onClick={() => flip("status", s)} className="h-9 text-[12px] sm:h-7">159              {STATUS_LABEL[s]}160            </ToggleChip>161          ))}162        </div>163      </Group>164165      <Group legend="Place">166        <div className="grid gap-2 sm:grid-cols-2">167          <Field label="Country (ISO2, comma list)">168            <input value={draft.country ?? ""} onChange={(e) => setDraft({ ...draft, country: e.target.value.toUpperCase().replace(/[^A-Z,]/g, "") || undefined })} onBlur={commit} onKeyDown={onKey} placeholder="US,CA" className={cn(inputCls, "figure")} />169          </Field>170          <Field label="Market slug">171            <input value={draft.metro ?? ""} onChange={(e) => setDraft({ ...draft, metro: e.target.value.trim() || undefined })} onBlur={commit} onKeyDown={onKey} placeholder="northern-virginia" className={cn(inputCls, "figure")} />172          </Field>173        </div>174        {countryFacet.length > 0 && (175          <div className="mt-2 flex flex-wrap gap-1.5" aria-label="Countries in the current result">176            {countryFacet.map((c) => (177              <ToggleChip key={c.key} active={csvOn("country", c.key)} onClick={() => flip("country", c.key)} className="h-9 text-[12px] sm:h-7">178                {c.name} <span className="figure text-[11px] opacity-70">{fmtInt(c.count)}</span>179              </ToggleChip>180            ))}181          </div>182        )}183      </Group>184185      <Group legend="Operator">186        <input value={draft.operator ?? ""} onChange={(e) => setDraft({ ...draft, operator: e.target.value.trim() || undefined })} onBlur={commit} onKeyDown={onKey} placeholder="equinix" className={cn(inputCls, "figure")} aria-label="Operator slug" />187        {operatorFacet.length > 0 && (188          <div className="mt-2 flex flex-wrap gap-1.5" aria-label="Operators in the current result">189            {operatorFacet.map((o) => (190              <ToggleChip key={o.key} active={query.operator === o.key} onClick={() => go({ operator: query.operator === o.key ? undefined : o.key })} className="h-9 text-[12px] sm:h-7">191                {o.name} <span className="figure text-[11px] opacity-70">{fmtInt(o.count)}</span>192              </ToggleChip>193            ))}194          </div>195        )}196      </Group>197198      {!isProjects && (199        <Group legend="Facility type">200          <div className="flex flex-wrap gap-1.5">201            {FACILITY_TYPES.map((t) => (202              <ToggleChip key={t} active={csvOn("type", t)} onClick={() => flip("type", t)} className="h-9 text-[12px] sm:h-7">203                {TYPE_LABEL[t]}204              </ToggleChip>205            ))}206          </div>207        </Group>208      )}209210      <Group legend="Capacity (published MW)">211        <div className="grid grid-cols-2 gap-2">212          <Field label="Min MW">213            <input type="number" min={0} inputMode="decimal" value={draft.min_mw ?? ""} onChange={setNum("min_mw")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />214          </Field>215          <Field label="Max MW">216            <input type="number" min={0} inputMode="decimal" value={draft.max_mw ?? ""} onChange={setNum("max_mw")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />217          </Field>218        </div>219        <div className="mt-2 flex flex-wrap gap-1.5">220          <ToggleChip active={query.has_mw === true} onClick={() => go({ has_mw: query.has_mw ? undefined : true })} className="h-9 text-[12px] sm:h-7">221            Known MW only222          </ToggleChip>223          {!isProjects && (224            <ToggleChip active={query.hyperscale === true} onClick={() => go({ hyperscale: query.hyperscale ? undefined : true })} className="h-9 text-[12px] sm:h-7">225              Hyperscale226            </ToggleChip>227          )}228        </div>229      </Group>230231      <Group legend="AI evidence">232        <SegmentedControl label="AI evidence" value={query.ai ?? "any"} onChange={(v) => go({ ai: v === "any" ? undefined : (v as ExploreQuery["ai"]) })} options={AI_FILTERS.map((a) => ({ value: a, label: AI_LABEL[a] }))} className="w-full [&>*]:flex-1" size="xs" />233        <p className="mt-1 text-[11px] text-ink-4">Confirmed ⊂ likely ⊂ associated; “associated” alone is a tenant / customer mention, not AI infrastructure.</p>234      </Group>235236      {isProjects ? (237        <>238          <Group legend="Timing">239            <div className="grid grid-cols-2 gap-2">240              <Field label="Expected after (year)">241                <input type="number" min={2000} max={2100} inputMode="numeric" value={draft.expected_after ?? ""} onChange={setNum("expected_after")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />242              </Field>243              <Field label="Expected before (year)">244                <input type="number" min={2000} max={2100} inputMode="numeric" value={draft.expected_before ?? ""} onChange={setNum("expected_before")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />245              </Field>246            </div>247            <div className="mt-2">248              <Field label="Announced since">249                <input type="date" value={draft.announced_since ?? ""} onChange={(e) => setDraft({ ...draft, announced_since: e.target.value || undefined })} onBlur={commit} className={cn(inputCls, "figure")} />250              </Field>251            </div>252          </Group>253          <Group legend="Announcement class">254            <div className="flex flex-wrap gap-1.5">255              {PROJECT_CLASSES.map((c) => (256                <ToggleChip key={c} active={csvOn("project_class", c)} onClick={() => flip("project_class", c)} className="h-9 text-[12px] sm:h-7">257                  {CLASS_LABEL[c] ?? c}258                </ToggleChip>259              ))}260            </div>261          </Group>262        </>263      ) : (264        <>265          <Group legend="Opened">266            <div className="grid grid-cols-2 gap-2">267              <Field label="From (year)">268                <input type="number" min={1950} max={2100} inputMode="numeric" value={draft.opened_from ?? ""} onChange={setNum("opened_from")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />269              </Field>270              <Field label="To (year)">271                <input type="number" min={1950} max={2100} inputMode="numeric" value={draft.opened_to ?? ""} onChange={setNum("opened_to")} onBlur={commit} onKeyDown={onKey} className={cn(inputCls, "figure")} />272              </Field>273            </div>274            <p className="mt-1 text-[11px] text-ink-4">Facilities without a published opening date never match a year range.</p>275          </Group>276          <Group legend="Location precision">277            <div className="flex flex-wrap gap-1.5">278              {GEO_PRECISIONS.map((p) => (279                <ToggleChip key={p} active={csvOn("location_precision", p)} onClick={() => flip("location_precision", p)} className="h-9 text-[12px] sm:h-7">280                  {PRECISION_SHORT[p]}281                </ToggleChip>282              ))}283            </div>284          </Group>285        </>286      )}287288      <Group legend="Confidence">289        <div className="flex flex-wrap gap-1.5">290          {CONFIDENCE_LEVELS.map((c) => (291            <ToggleChip key={c} active={csvOn("confidence", c)} onClick={() => flip("confidence", c)} className="h-9 text-[12px] sm:h-7">292              {CONFIDENCE_SHORT[c]}293            </ToggleChip>294          ))}295        </div>296      </Group>297298      <div className="flex items-center justify-between gap-2 border-t border-hair pt-3">299        <Button variant="ghost" size="sm" type="button" onClick={() => go({ status: undefined, country: undefined, metro: undefined, operator: undefined, type: undefined, min_mw: undefined, max_mw: undefined, ai: undefined, hyperscale: undefined, expected_before: undefined, expected_after: undefined, opened_from: undefined, opened_to: undefined, announced_since: undefined, confidence: undefined, location_precision: undefined, has_mw: undefined, project_class: undefined, q: undefined })}>300          Reset301        </Button>302        <Button variant="primary" size="sm" type="submit">303          Apply304        </Button>305      </div>306    </form>307  );308}309