spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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