SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
12.4 KB · 214 lines tsx
Raw Blame History
1'use client';2import { ArrowRight, ChevronDown, ChevronUp, GripVertical, Plus, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useCallback, useMemo, useState } from 'react';6import { t } from '@/i18n';7import { cn } from '@/lib/cn';8import { COMPARE_GROUP_PRESETS } from '@/lib/compare-presets';9import { MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES } from '@/lib/compare-state';10import { routes } from '@/lib/site';11import type { CountryLite } from '@/lib/types-compare';12import { seriesVar } from '@/components/charts/palette';13import { COMPARE_PRESETS } from '@/components/home/compare-teaser';14import { CountryPickerSheet, CountryTypeahead } from './country-picker';1516/**17 * /compare landing: typeahead + ordered chips (drag on desktop, up/down buttons everywhere), presets18 * (G7, Nordics, BRICS, USMCA…, "Canada vs peers" from /similar) and the Compare button → /compare/a/b/c.19 * On phones the chip row is sticky under the header and the picker opens as a bottom sheet.20 */21export function CompareBuilder({ countries, peers, initial = [] }: { countries: CountryLite[]; peers: CountryLite[]; initial?: string[] }) {22  const router = useRouter();23  const bySlug = useMemo(() => new Map(countries.map((c) => [c.slug, c])), [countries]);24  const [selected, setSelected] = useState<string[]>(() => initial.filter((s) => bySlug.has(s)).slice(0, MAX_COMPARE_COUNTRIES));25  const [sheet, setSheet] = useState(false);26  const [dragFrom, setDragFrom] = useState<number | null>(null);27  const exclude = useMemo(() => new Set(selected), [selected]);28  const chosen = selected.map((s) => bySlug.get(s)!).filter(Boolean);29  const canGo = selected.length >= MIN_COMPARE_COUNTRIES;30  const full = selected.length >= MAX_COMPARE_COUNTRIES;3132  const add = useCallback((c: CountryLite) => setSelected((s) => (s.includes(c.slug) || s.length >= MAX_COMPARE_COUNTRIES ? s : [...s, c.slug])), []);33  const remove = (slug: string) => setSelected((s) => s.filter((x) => x !== slug));34  const move = (from: number, to: number) =>35    setSelected((s) => {36      if (to < 0 || to >= s.length || from === to) return s;37      const next = [...s];38      const [item] = next.splice(from, 1);39      next.splice(to, 0, item!);40      return next;41    });42  const go = () => {43    if (canGo) router.push(routes.compare(...selected));44  };4546  const peerPreset = peers.length >= 2 ? ['canada', ...peers.filter((p) => p.slug !== 'canada').slice(0, 5).map((p) => p.slug)] : null;4748  return (49    <div>50      {/* Selected chips — sticky on phones */}51      <div className="sticky top-[52px] z-20 -mx-4 border-b border-rule bg-paper/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-paper/85 sm:-mx-6 sm:px-6 md:static md:mx-0 md:border-0 md:bg-transparent md:px-0 md:py-0 md:backdrop-blur-none">52        <div className="flex items-center gap-2 md:hidden">53          <button type="button" onClick={() => setSheet(true)} disabled={full} className={cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-sm border px-3 text-sm', full ? 'border-rule text-ink-3' : 'border-ink text-ink')}>54            <Plus size={15} aria-hidden />55            {t('compare.add')}56          </button>57          <ul className="scrollbar-none flex min-w-0 flex-1 gap-1.5 overflow-x-auto" aria-label={t('compare.builder.selected')}>58            {chosen.map((c, i) => (59              <li key={c.slug} className="shrink-0">60                <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm">61                  <span aria-hidden className="h-2 w-2 rounded-full" style={{ background: seriesVar(i) }} />62                  <span aria-hidden>{c.flag}</span>63                  <span className="max-w-[9rem] truncate">{c.name}</span>64                  <button type="button" onClick={() => remove(c.slug)} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-ink" aria-label={t('compare.builder.remove', { name: c.name })}>65                    <X size={14} aria-hidden />66                  </button>67                </span>68              </li>69            ))}70          </ul>71          <button type="button" onClick={go} disabled={!canGo} className={cn('inline-flex h-11 shrink-0 items-center gap-1 rounded-sm px-3 text-sm font-medium', canGo ? 'bg-ink text-paper' : 'bg-surface-2 text-ink-3')}>72            {t('compare.builder.go')}73            <ArrowRight size={15} aria-hidden />74          </button>75        </div>7677        <div className="hidden md:block">78          <CountryTypeahead countries={countries} exclude={exclude} onPick={add} disabled={full} placeholder={full ? t('compare.builder.full', { max: MAX_COMPARE_COUNTRIES }) : undefined} className="max-w-xl" />79        </div>80      </div>8182      {/* Ordered list with reorder controls */}83      <div className="mt-4">84        <div className="eyebrow mb-2">85          {t('compare.builder.selected')} · <span className="tnum">{selected.length}/{MAX_COMPARE_COUNTRIES}</span>86        </div>87        {chosen.length === 0 ? (88          <p className="border-t border-dashed border-rule-strong py-5 text-sm text-ink-3">{t('compare.builder.empty')}</p>89        ) : (90          <ol className="divide-y divide-rule border-y border-rule">91            {chosen.map((c, i) => (92              <li93                key={c.slug}94                draggable95                onDragStart={() => setDragFrom(i)}96                onDragOver={(e) => e.preventDefault()}97                onDrop={() => {98                  if (dragFrom != null) move(dragFrom, i);99                  setDragFrom(null);100                }}101                onDragEnd={() => setDragFrom(null)}102                className={cn('flex min-h-[52px] items-center gap-2 py-1', dragFrom === i && 'opacity-50')}103              >104                <span className="hidden cursor-grab text-ink-3 md:inline" title={t('compare.builder.drag')} aria-hidden>105                  <GripVertical size={16} />106                </span>107                <span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />108                <span aria-hidden className="text-xl leading-none">109                  {c.flag}110                </span>111                <span className="min-w-0 flex-1 truncate text-sm font-medium text-ink">{c.name}</span>112                <div className="flex items-center">113                  <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0} className="tap grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30" aria-label={t('compare.builder.moveUp', { name: c.name })}>114                    <ChevronUp size={16} aria-hidden />115                  </button>116                  <button type="button" onClick={() => move(i, i + 1)} disabled={i === chosen.length - 1} className="tap grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30" aria-label={t('compare.builder.moveDown', { name: c.name })}>117                    <ChevronDown size={16} aria-hidden />118                  </button>119                  <button type="button" onClick={() => remove(c.slug)} className="tap grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-down" aria-label={t('compare.builder.remove', { name: c.name })}>120                    <X size={16} aria-hidden />121                  </button>122                </div>123              </li>124            ))}125          </ol>126        )}127        <div className="mt-4 hidden flex-wrap items-center gap-3 md:flex">128          <button type="button" onClick={go} disabled={!canGo} className={cn('inline-flex h-10 items-center gap-2 rounded-sm px-4 text-sm font-medium', canGo ? 'bg-ink text-paper hover:bg-accent hover:text-accent-ink' : 'bg-surface-2 text-ink-3')}>129            {canGo ? t('compare.builder.goN', { n: selected.length }) : t('compare.builder.go')}130            <ArrowRight size={15} aria-hidden />131          </button>132          {!canGo ? <span className="text-sm text-ink-3">{t('compare.builder.needMore', { n: MIN_COMPARE_COUNTRIES - selected.length })}</span> : null}133          {selected.length ? (134            <button type="button" onClick={() => setSelected([])} className="inline-flex h-10 items-center rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2">135              {t('compare.builder.clear')}136            </button>137          ) : null}138        </div>139        {!canGo && selected.length ? <p className="mt-2 text-sm text-ink-3 md:hidden">{t('compare.builder.needMore', { n: MIN_COMPARE_COUNTRIES - selected.length })}</p> : null}140      </div>141142      {/* Presets */}143      <section className="hairline mt-8 pt-6" aria-labelledby="presets-h">144        <h2 id="presets-h" className="display text-xl text-ink md:text-2xl">145          {t('compare.presets.title')}146        </h2>147        <p className="mt-1 text-sm text-ink-2">{t('compare.presets.sub')}</p>148        <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">149          {[...COMPARE_GROUP_PRESETS.map((p) => ({ id: p.id, label: t(`compare.preset.${p.id}` as 'compare.preset.g7'), hint: null as string | null, slugs: p.slugs })), ...(peerPreset ? [{ id: 'peers', label: t('compare.preset.peers', { name: 'Canada' }), hint: t('compare.preset.peersHint', { name: 'Canada' }), slugs: peerPreset }] : [])].map((p) => {150            const members = p.slugs.map((s) => bySlug.get(s)).filter((c): c is CountryLite => !!c);151            if (members.length < 2) return null;152            return (153              <li key={p.id} className="border-t border-rule">154                <div className="flex min-h-[64px] items-center gap-3 py-2.5">155                  <Link href={routes.compare(...members.map((m) => m.slug))} className="group min-w-0 flex-1">156                    <span className="flex items-baseline gap-2">157                      <span className="text-sm font-medium text-ink group-hover:text-accent">{p.label}</span>158                      <span className="tnum text-2xs text-ink-3" aria-label={t('compare.presets.count', { n: members.length })}>159                        <span aria-hidden className="mr-1.5">·</span>160                        {t('compare.presets.count', { n: members.length })}161                      </span>162                    </span>163                    <span className="mt-0.5 flex flex-wrap gap-x-1 text-base leading-tight" aria-hidden>164                      {members.map((m) => (165                        <span key={m.slug}>{m.flag}</span>166                      ))}167                    </span>168                    {p.hint ? <span className="block text-xs text-ink-3">{p.hint}</span> : null}169                  </Link>170                  <button171                    type="button"172                    onClick={() => setSelected(members.map((m) => m.slug))}173                    className="tap shrink-0 rounded-sm border border-rule px-2.5 text-xs text-ink-2 hover:border-accent hover:text-accent md:min-h-[32px]"174                    aria-label={`${p.label}: ${t('compare.builder.open')}`}175                  >176                    {t('compare.builder.open')}177                  </button>178                </div>179              </li>180            );181          })}182        </ul>183184        <h3 className="eyebrow mt-8">{t('compare.presets.pairs')}</h3>185        <ul className="mt-2 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-4">186          {COMPARE_PRESETS.map(([a, b]) => {187            const ca = bySlug.get(a);188            const cb = bySlug.get(b);189            if (!ca || !cb) return null;190            return (191              <li key={`${a}-${b}`} className="border-t border-rule">192                <Link href={routes.compare(a, b)} className="group flex min-h-[52px] items-center gap-2 py-2 text-sm">193                  <span aria-hidden className="text-lg leading-none">194                    {ca.flag}195                  </span>196                  <span className="truncate text-ink group-hover:text-accent">{ca.name}</span>197                  <span className="text-ink-3">{t('compare.vs')}</span>198                  <span aria-hidden className="text-lg leading-none">199                    {cb.flag}200                  </span>201                  <span className="truncate text-ink group-hover:text-accent">{cb.name}</span>202                  <ArrowRight size={14} aria-hidden className="ml-auto shrink-0 text-ink-3 group-hover:text-accent" />203                </Link>204              </li>205            );206          })}207        </ul>208      </section>209210      <CountryPickerSheet open={sheet} onClose={() => setSheet(false)} countries={countries} exclude={exclude} onPick={add} title={t('compare.addCountry')} closeOnPick={false} />211    </div>212  );213}214