11"use client" ;
22
33import { usePathname , useRouter } from "next/navigation" ;
4- import { useMemo , useState } from "react" ;
4+ import { useOptimistic , useState , useTransition } from "react" ;
55import { Pagination } from "@/components/ui/Pagination" ;
66import { Container } from "@/components/ui/SectionHeading" ;
77import { StoryCard } from "@/components/ui/StoryCard" ;
88import {
9- DEFAULT_FILTERS ,
10- FILTERS ,
11- type FilterDimension ,
12- } from "@/lib/fact-checks-content" ;
9+ EMPTY_FILTERS ,
10+ type FilterSelection ,
11+ filtersToQuery ,
12+ } from "@/lib/data/fact-check-filters" ;
13+ import { FILTERS , type FilterDimension } from "@/lib/fact-checks-content" ;
1314import type { Story } from "@/lib/home-content" ;
14- import { FilterBar , type Selection } from "./FilterBar" ;
15+ import { FilterBar } from "./FilterBar" ;
1516
16- const EMPTY : Selection = { region : [ ] , language : [ ] , topic : [ ] } ;
17-
18- function clone ( sel : Selection ) : Selection {
17+ function clone ( sel : FilterSelection ) : FilterSelection {
1918 return {
2019 region : [ ...sel . region ] ,
2120 language : [ ...sel . language ] ,
2221 topic : [ ...sel . topic ] ,
2322 } ;
2423}
2524
26- function matches ( story : Story , applied : Selection ) : boolean {
27- return FILTERS . every ( ( { dimension } ) => {
28- const wanted = applied [ dimension ] ;
29- if ( wanted . length === 0 ) return true ;
30- const value = story [ dimension ] ;
31- return value != null && wanted . includes ( value ) ;
32- } ) ;
33- }
34-
25+ /**
26+ * The fact-checks grid + filter bar. Filtering and pagination are both
27+ * **server-side and URL-driven**: `stories` arrive already filtered/paged, and
28+ * every control navigates by mutating the query string (`?region=…&topic=…&page=N`),
29+ * which re-runs the server fetch.
30+ *
31+ * Filters **auto-apply** — there's no "Apply" step. The URL (`filters` prop) is
32+ * the single source of truth; toggling an option, removing a chip, or clearing
33+ * navigates straight away. `useOptimistic` mirrors the change so the checkboxes
34+ * and chips update instantly while the server re-fetches the grid in a
35+ * transition. Filter changes use `replace` (so a burst of toggles doesn't spam
36+ * history); pagination uses `push`.
37+ */
3538export function FactChecksExplorer ( {
3639 stories,
3740 page,
3841 totalPages,
42+ filters,
3943} : {
4044 stories : Story [ ] ;
4145 page : number ;
4246 totalPages : number ;
47+ filters : FilterSelection ;
4348} ) {
4449 const router = useRouter ( ) ;
4550 const pathname = usePathname ( ) ;
51+ const [ , startTransition ] = useTransition ( ) ;
4652
47- // `selected` is the staged set reflected by the dropdowns + chips; `applied`
48- // is what actually filters the listing (committed via "Apply Filters"). The
49- // page loads showing the design's chips staged but the full listing visible —
50- // filtering kicks in once the reader clicks "Apply Filters".
51- const [ selected , setSelected ] = useState < Selection > ( ( ) =>
52- clone ( DEFAULT_FILTERS ) ,
53- ) ;
54- const [ applied , setApplied ] = useState < Selection > ( ( ) => clone ( EMPTY ) ) ;
53+ // Optimistic mirror of the applied (URL) filters — instant checkbox/chip
54+ // feedback; rebases to `filters` once the navigation transition resolves.
55+ const [ optimistic , setOptimistic ] = useOptimistic ( filters ) ;
5556 const [ openDropdown , setOpenDropdown ] = useState < FilterDimension | null > (
5657 null ,
5758 ) ;
5859
59- const chips = useMemo (
60- ( ) =>
61- FILTERS . flatMap ( ( { dimension } ) =>
62- selected [ dimension ] . map ( ( value ) => ( { dimension, value } ) ) ,
63- ) ,
64- [ selected ] ,
65- ) ;
66-
67- const results = useMemo (
68- ( ) => stories . filter ( ( s ) => matches ( s , applied ) ) ,
69- [ stories , applied ] ,
60+ const chips = FILTERS . flatMap ( ( { dimension } ) =>
61+ optimistic [ dimension ] . map ( ( value ) => ( { dimension, value } ) ) ,
7062 ) ;
7163
72- const toggleOption = ( dimension : FilterDimension , value : string ) => {
73- setSelected ( ( prev ) => {
74- const next = clone ( prev ) ;
75- next [ dimension ] = next [ dimension ] . includes ( value )
76- ? next [ dimension ] . filter ( ( v ) => v !== value )
77- : [ ...next [ dimension ] , value ] ;
78- return next ;
79- } ) ;
80- } ;
81-
82- const removeChip = ( dimension : FilterDimension , value : string ) => {
83- setSelected ( ( prev ) => {
84- const next = clone ( prev ) ;
85- next [ dimension ] = next [ dimension ] . filter ( ( v ) => v !== value ) ;
86- return next ;
64+ // Commit a filter/page change to the URL; the server re-fetches that slice.
65+ const navigate = (
66+ next : FilterSelection ,
67+ nextPage : number ,
68+ mode : "push" | "replace" ,
69+ ) => {
70+ const params = new URLSearchParams ( filtersToQuery ( next ) ) ;
71+ if ( nextPage > 1 ) params . set ( "page" , String ( nextPage ) ) ;
72+ const qs = params . toString ( ) ;
73+ const url = qs ? `${ pathname } ?${ qs } ` : pathname ;
74+ startTransition ( ( ) => {
75+ setOptimistic ( next ) ;
76+ router [ mode ] ( url ) ;
8777 } ) ;
8878 } ;
8979
90- const toggleDropdown = ( dimension : FilterDimension ) =>
91- setOpenDropdown ( ( cur ) => ( cur === dimension ? null : dimension ) ) ;
80+ // Any filter change auto-applies and resets to page 1.
81+ const commitFilters = ( next : FilterSelection ) => navigate ( next , 1 , "replace" ) ;
9282
93- const goToPage = ( next : number ) => {
94- router . push ( next <= 1 ? pathname : `${ pathname } ?page=${ next } ` ) ;
83+ const toggleOption = ( dimension : FilterDimension , value : string ) => {
84+ const next = clone ( optimistic ) ;
85+ next [ dimension ] = next [ dimension ] . includes ( value )
86+ ? next [ dimension ] . filter ( ( v ) => v !== value )
87+ : [ ...next [ dimension ] , value ] ;
88+ commitFilters ( next ) ;
9589 } ;
9690
97- const apply = ( ) => {
98- setApplied ( clone ( selected ) ) ;
99- setOpenDropdown ( null ) ;
100- goToPage ( 1 ) ;
91+ const removeChip = ( dimension : FilterDimension , value : string ) => {
92+ const next = clone ( optimistic ) ;
93+ next [ dimension ] = next [ dimension ] . filter ( ( v ) => v !== value ) ;
94+ commitFilters ( next ) ;
10195 } ;
10296
10397 const clear = ( ) => {
104- setSelected ( clone ( EMPTY ) ) ;
105- setApplied ( clone ( EMPTY ) ) ;
10698 setOpenDropdown ( null ) ;
107- goToPage ( 1 ) ;
99+ commitFilters ( EMPTY_FILTERS ) ;
108100 } ;
109101
110- const [ feature , secondary , ...grid ] = results ;
102+ const toggleDropdown = ( dimension : FilterDimension ) =>
103+ setOpenDropdown ( ( cur ) => ( cur === dimension ? null : dimension ) ) ;
104+
105+ // Pagination keeps the applied filters; only the page changes.
106+ const goToPage = ( next : number ) => navigate ( filters , next , "push" ) ;
107+
108+ const [ feature , secondary , ...grid ] = stories ;
111109
112110 return (
113111 < >
114112 { /* Filter panel — its own band below the hero, not overlapping it. */ }
115113 < section className = "bg-neutral-50" >
116114 < Container className = "py-14 lg:py-16" >
117115 < FilterBar
118- selected = { selected }
116+ selected = { optimistic }
119117 openDropdown = { openDropdown }
120118 chips = { chips }
121119 onToggleDropdown = { toggleDropdown }
122120 onToggleOption = { toggleOption }
123121 onRemoveChip = { removeChip }
124- onApply = { apply }
125122 onClear = { clear }
126123 />
127124 </ Container >
@@ -130,7 +127,7 @@ export function FactChecksExplorer({
130127 { /* Listing */ }
131128 < section className = "py-14 lg:py-20" >
132129 < Container >
133- { results . length === 0 ? (
130+ { stories . length === 0 ? (
134131 < p className = "py-16 text-center text-base font-medium text-neutral-500" >
135132 No fact-checks match your filters. Try removing some.
136133 </ p >
0 commit comments