SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
9.6 KB · 196 lines tsx
Raw Blame History
1import type { MetroSummary } from "@dci/core";2import type { Metadata } from "next";3import Link from "next/link";4import { Breadcrumbs } from "@/components/entity/breadcrumbs";5import { EstimateMark } from "@/components/ui/badge";6import { Button } from "@/components/ui/button";7import { Page, PageHeader } from "@/components/ui/card";8import { CountryFlag } from "@/components/ui/country-flag";9import { FilterChips, type Chip } from "@/components/ui/filter-chips";10import { Pagination } from "@/components/ui/pagination";11import { EmptyState } from "@/components/ui/states";12import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";13import { api } from "@/lib/api";14import { fmtInt, fmtMw } from "@/lib/format";15import { routes } from "@/lib/routes";16import { pageMetadata } from "@/lib/seo";17import { compareBy, hrefWith, paginate, parseSort, sortTh, spGet, spInt, type SearchParams } from "@/lib/sort";18import { countryHref, countrySlugMap, pipelineMw } from "../country-links";1920export const revalidate = 120;2122export const metadata: Metadata = pageMetadata({23  title: "Data center markets",24  description: "Metro markets ranked by facilities, known capacity and pipeline — operators, cloud regions and internet exchanges per market.",25  path: routes.markets(),26});2728const SORT_KEYS = ["facilities", "mw", "pipeline", "operators", "cloud", "name"] as const;29type SortKey = (typeof SORT_KEYS)[number];30const PER_PAGE = 50;31const BASE = routes.markets();3233const getter: Record<SortKey, (m: MetroSummary) => number | string | null> = {34  facilities: (m) => m.facilityCount,35  mw: (m) => m.knownMw,36  pipeline: (m) => pipelineMw(m),37  operators: (m) => m.operatorCount,38  cloud: (m) => m.cloudRegionCount,39  name: (m) => m.name,40};4142export default async function MarketsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {43  const sp = await searchParams;44  const q = spGet(sp, "q");45  const country = spGet(sp, "country")?.toUpperCase();46  const sort = parseSort<SortKey>(sp, SORT_KEYS, { key: "facilities", order: "desc" });47  const [res, { map, countries }] = await Promise.all([api.metros({ q, country }), countrySlugMap()]);48  const ok = res.items.length > 0;4950  const sorted = [...res.items].sort((a, b) => compareBy(getter[sort.key], sort.order)(a, b) || a.name.localeCompare(b.name));51  const { items, page } = paginate(sorted, spInt(sp, "page"), PER_PAGE);52  const th = (key: SortKey) => sortTh(BASE, sp, sort, key);5354  const chips: Chip[] = [];55  if (q) chips.push({ key: "q", label: "Search", value: q, removeHref: hrefWith(BASE, sp, { q: undefined }) });56  if (country) chips.push({ key: "country", label: "Country", value: countries.find((c) => c.iso2 === country)?.name ?? country, removeHref: hrefWith(BASE, sp, { country: undefined }) });5758  return (59    <Page>60      <Breadcrumbs items={[{ name: "Data centers", href: routes.datacenters() }, { name: "Markets", href: BASE }]} className="pt-3" />61      <PageHeader62        eyebrow="Index"63        title="Data center markets"64        description={ok ? `${fmtInt(sorted.length)} metro markets. Ranked by facilities, known capacity and pipeline; MW figures are lower bounds from published values only.` : "Metro markets ranked by facilities, known capacity and pipeline."}65        aside={66          <Button href={routes.map()} size="sm">67            Open map68          </Button>69        }70      />71      <form method="get" action={BASE} className="flex flex-col gap-2 border-y border-hair py-3 sm:flex-row sm:items-center">72        <input type="search" name="q" defaultValue={q ?? ""} placeholder="Search markets or sub-markets" aria-label="Search markets" className="h-9 min-w-0 flex-1 rounded-sm border border-hair-2 bg-surface px-3 text-[13px] text-ink outline-none placeholder:text-ink-4 focus-visible:ring-2 focus-visible:ring-accent" />73        <div className="flex gap-2">74          <select name="country" defaultValue={country ?? ""} aria-label="Country" className="h-9 min-w-0 flex-1 rounded-sm border border-hair-2 bg-surface px-2 text-[13px] text-ink outline-none focus-visible:ring-2 focus-visible:ring-accent sm:w-[200px] sm:flex-none">75            <option value="">All countries</option>76            {countries.map((c) => (77              <option key={c.iso2} value={c.iso2}>78                {c.name}79              </option>80            ))}81          </select>82          {sort.key !== "facilities" && <input type="hidden" name="sort" value={sort.key} />}83          {sort.key !== "facilities" && <input type="hidden" name="order" value={sort.order} />}84          <Button type="submit" variant="primary" size="md">85            Filter86          </Button>87        </div>88      </form>89      <FilterChips chips={chips} resetHref={BASE} className="py-2" />9091      <div className="pt-1">92        {items.length === 0 ? (93          <EmptyState compact title={ok ? "No markets match" : "Markets unavailable"} description={ok ? "Try a different search or country." : "The index API is not reachable right now."} />94        ) : (95          <Table>96            <THead>97              <tr>98                <Th {...th("name")}>Market</Th>99                <Th className="hidden sm:table-cell">Country</Th>100                <Th numeric {...th("facilities")}>101                  Facilities102                </Th>103                <Th className="hidden lg:table-cell" title="operational · construction · planned">104                  Op · Con · Plan105                </Th>106                <Th numeric {...th("mw")}>107                  Known MW108                </Th>109                <Th numeric {...th("pipeline")}>110                  Pipeline111                </Th>112                <Th numeric className="hidden md:table-cell" {...th("operators")}>113                  Operators114                </Th>115                <Th numeric className="hidden md:table-cell" {...th("cloud")}>116                  Cloud regions117                </Th>118                <Th numeric className="hidden lg:table-cell">119                  IXPs120                </Th>121                <Th numeric className="hidden lg:table-cell">122                  Projects123                </Th>124              </tr>125            </THead>126            <TBody>127              {items.map((m) => {128                const pipe = pipelineMw(m);129                const cHref = countryHref(map, m.countryIso2);130                return (131                  <Tr key={m.id}>132                    <Td primary>133                      <RowLink href={routes.market(m.slug)}>{m.name}</RowLink>134                      <div className="text-[11.5px] text-ink-3 sm:hidden">135                        {m.countryName ?? m.countryIso2}136                        {m.regionName && m.regionName !== m.name ? ` · ${m.regionName}` : ""}137                      </div>138                    </Td>139                    <Td label="Country" className="hidden sm:table-cell">140                      {cHref ? (141                        <Link href={cHref} className="text-ink-2 hover:text-ink hover:underline">142                          <CountryFlag iso2={m.countryIso2} withName className="max-w-[180px]" />143                        </Link>144                      ) : (145                        <CountryFlag iso2={m.countryIso2} withName />146                      )}147                    </Td>148                    <Td label="Facilities" numeric lead>149                      {fmtInt(m.facilityCount)}150                    </Td>151                    <Td label="Op · Con · Plan" className="figure hidden text-[12px] text-ink-3 lg:table-cell" title="operational · under construction · planned">152                      {fmtInt(m.operationalCount)} · {fmtInt(m.constructionCount)} · {fmtInt(m.plannedCount)}153                    </Td>154                    <Td label="Known MW" numeric>155                      {m.knownMw === null ? <span className="text-ink-4">—</span> : fmtMw(m.knownMw)}156                    </Td>157                    <Td label="Pipeline" numeric>158                      {pipe === null ? (159                        <span className="text-ink-4">—</span>160                      ) : (161                        <span title="Under construction + planned power (published figures only)">162                          <EstimateMark className="mr-0.5" />163                          {fmtMw(pipe)}164                        </span>165                      )}166                    </Td>167                    <Td label="Operators" numeric className="hidden md:table-cell">168                      {fmtInt(m.operatorCount)}169                    </Td>170                    <Td label="Cloud regions" numeric className="hidden md:table-cell">171                      {m.cloudRegionCount ? fmtInt(m.cloudRegionCount) : <span className="text-ink-4">—</span>}172                    </Td>173                    <Td label="IXPs" numeric className="hidden lg:table-cell">174                      {m.ixpCount ? fmtInt(m.ixpCount) : <span className="text-ink-4">—</span>}175                    </Td>176                    <Td label="Projects" numeric className="hidden lg:table-cell">177                      {m.projectCount ? fmtInt(m.projectCount) : <span className="text-ink-4">—</span>}178                    </Td>179                  </Tr>180                );181              })}182            </TBody>183          </Table>184        )}185      </div>186      <Pagination page={page} perPage={PER_PAGE} total={sorted.length} hrefFor={(p) => hrefWith(BASE, sp, { page: p > 1 ? p : undefined })} label="markets" className="mt-3" />187      <p className="mt-6 text-[11.5px] text-ink-4">188        Facilities are assigned to a market when they lie within its radius or their city matches one of its sub-markets; markets never span borders.{" "}189        <Link href={routes.methodology()} className="underline decoration-hair-2 hover:text-ink">190          Methodology191        </Link>192      </p>193    </Page>194  );195}196