diff --git a/src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx b/src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx
deleted file mode 100644
index 911715fd..00000000
--- a/src/components/campaign/shared/CampaignCollapsibleFilterPanel.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-'use client'
-
-import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
-import { useState, type ReactNode } from 'react'
-
-import { Button } from '@/components/ui/button'
-import { cn } from '@/lib/utils'
-
-/**
- * The collapsible filter panel of the list filter shells (P3-F — extracted
- * from ActivityFilters/SupporterFilters, ~30 near-verbatim lines each):
- * always-open on desktop, a disclosure card with 44 px targets on mobile,
- * and the "Limpar filtros" footer when any filter is active.
- */
-export const CampaignCollapsibleFilterPanel = ({
- panelId,
- hasFilters,
- onClear,
- children,
-}: {
- panelId: string
- hasFilters: boolean
- onClear: () => void
- children: ReactNode
-}) => {
- const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
-
- return (
-
-
-
- {children}
- {hasFilters ? (
-
-
-
- ) : null}
-
-
- )
-}
diff --git a/src/components/campaign/shared/CampaignFilterChips.tsx b/src/components/campaign/shared/CampaignFilterChips.tsx
deleted file mode 100644
index e25baa4d..00000000
--- a/src/components/campaign/shared/CampaignFilterChips.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-'use client'
-
-import Link from 'next/link'
-import { useRouter } from 'next/navigation'
-import { useState, type MouseEvent } from 'react'
-
-import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
-import { Button } from '@/components/ui/button'
-
-export type CampaignFilterChip = {
- href: string
- label: string
- active: boolean
-}
-
-/**
- * Status/kind chips that navigate via URL. Optimistic on the chip (the tapped
- * one activates at once), honest pending on the results (shared boundary
- * transition dims them) — "Feel the action".
- */
-export const CampaignFilterChips = ({
- ariaLabel,
- chips,
-}: {
- ariaLabel: string
- chips: CampaignFilterChip[]
-}) => {
- const router = useRouter()
- const { isPending, startTransition } = useCampaignListTransition()
- const [optimisticHref, setOptimisticHref] = useState(null)
-
- const activeHref =
- isPending && optimisticHref !== null
- ? optimisticHref
- : (chips.find((chip) => chip.active)?.href ?? null)
-
- const interceptNavigation = (event: MouseEvent, href: string) => {
- if (
- event.defaultPrevented ||
- event.button !== 0 ||
- event.metaKey ||
- event.ctrlKey ||
- event.shiftKey ||
- event.altKey
- ) {
- return
- }
- event.preventDefault()
- setOptimisticHref(href)
- startTransition(() => {
- router.push(href)
- })
- }
-
- return (
-
- )
-}
diff --git a/src/components/campaign/shared/CampaignMobileMultiFilterField.tsx b/src/components/campaign/shared/CampaignMobileMultiFilterField.tsx
deleted file mode 100644
index db8bc440..00000000
--- a/src/components/campaign/shared/CampaignMobileMultiFilterField.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-'use client'
-
-import { Field, FieldLabel } from '@/components/ui/field'
-import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
-import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
-
-type CampaignMobileMultiFilterOption = {
- value: string
- label: string
-}
-
-export const CampaignMobileMultiFilterField = ({
- id,
- label,
- emptyLabel,
- options,
- selected,
- onToggle,
-}: {
- id: string
- label: string
- emptyLabel: string
- options: CampaignMobileMultiFilterOption[]
- selected: readonly string[]
- onToggle: (value: string) => void
-}) => {
- // No snapshot: the NativeSelect closes on every choice, so the next open
- // already wants the just-toggled value at the top.
- const { ordered } = orderFilterOptionsSelectedFirst(options, selected)
- const selectedSet = new Set(selected)
-
- return (
-
- {label}
- {
- if (event.target.value) onToggle(event.target.value)
- }}
- className="min-h-11 w-full"
- >
-
- {selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
-
- {ordered.map((option) => (
-
- {selectedSet.has(option.value) ? `✓ ${option.label}` : option.label}
-
- ))}
-
-
- )
-}
diff --git a/src/components/campaign/shared/CampaignSearchForm.tsx b/src/components/campaign/shared/CampaignSearchForm.tsx
deleted file mode 100644
index e1d2175c..00000000
--- a/src/components/campaign/shared/CampaignSearchForm.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-'use client'
-
-import { useRouter } from 'next/navigation'
-import { useEffect, useRef, useState } from 'react'
-
-import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
-import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation'
-import { Button } from '@/components/ui/button'
-
-/**
- * Name-search form for simple list pages (lideranças, organizações,
- * dobradinhas): keeps the input value locally (optimistic) and navigates in a
- * transition so the results region dims instead of a dead full-page wait.
- */
-export const CampaignSearchForm = ({
- ariaLabel,
- placeholder,
- initialQuery,
- basePath,
- filterParams = '',
-}: {
- ariaLabel: string
- placeholder: string
- initialQuery: string
- /** List route; the query lands as `?q=` (serializable — no function props across the RSC boundary). */
- basePath: string
- /**
- * The ACTIVE filters' canonical serialization (minus `q`/`page`), produced
- * server-side by the domain's own serializer (P3-F): submitting the search
- * must not silently drop the other filters, which the hand-rolled `?q=`
- * used to do.
- */
- filterParams?: string
-}) => {
- const router = useRouter()
- const { isPending, startTransition } = useCampaignListTransition()
- const [query, setQuery] = useState(initialQuery)
-
- const hrefForQuery = (value: string) => {
- const params = new URLSearchParams(filterParams)
- if (value) params.set('q', value)
- const queryString = params.toString()
- return queryString ? `${basePath}?${queryString}` : basePath
- }
-
- const navigateIfChanged = (value: string) => {
- // No-op guard (same policy as `useCampaignListFilterNavigation`): the URL
- // already carries exactly this query — don't pay an RSC round-trip.
- if (value === initialQuery) return
- startTransition(() => {
- router.replace(hrefForQuery(value), { scroll: false })
- })
- }
-
- /**
- * Search-as-you-type (P3-F unified idiom — this form used to navigate only
- * on explicit submit): the debounce commits what the user settled on; the
- * Buscar button remains as the immediate path for keyboard users in a hurry.
- */
- const debounceRef = useRef | null>(null)
- useEffect(
- () => () => {
- if (debounceRef.current) clearTimeout(debounceRef.current)
- },
- [],
- )
- const onQueryChange = (value: string) => {
- setQuery(value)
- if (debounceRef.current) clearTimeout(debounceRef.current)
- debounceRef.current = setTimeout(() => {
- navigateIfChanged(value)
- }, SEARCH_DEBOUNCE_MS)
- }
-
- return (
-
- )
-}
diff --git a/src/components/campaign/shared/useCampaignFilterValues.ts b/src/components/campaign/shared/useCampaignFilterValues.ts
deleted file mode 100644
index 653e9352..00000000
--- a/src/components/campaign/shared/useCampaignFilterValues.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-'use client'
-
-import { useRouter } from 'next/navigation'
-import { useEffect, useRef, useState } from 'react'
-
-import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
-
-/**
- * The filter-values state machine of the list filter shells (P3-F — extracted
- * from ActivityFilters/SupporterFilters, where it was spelled twice WITHOUT
- * the no-op guard): local mirror for instant feedback, committed-href
- * deduplication so re-selecting the already-active value never pays an RSC
- * round-trip, and the pending transition the results region dims with.
- *
- * `toHref` comes from the domain's own serializer — the hook never decides
- * what is canonical.
- */
-export const useCampaignFilterValues = ({
- committedValues,
- toHref,
-}: {
- /** Values parsed from the URL on the server — fresh on every render. */
- committedValues: Values
- /** Domain serializer; must canonicalize and pin the page reset it wants. */
- toHref: (values: Values) => string
-}) => {
- const router = useRouter()
- const { isPending, startTransition } = useCampaignListTransition()
- const valuesRef = useRef(committedValues)
- const [values, setValues] = useState(committedValues)
-
- // The committed URL's href, written in an effect: an interrupted transition
- // render is abandoned before commit, and a later navigation must not be
- // deduplicated against state that never landed (same lesson as
- // `useCampaignListFilterNavigation`'s stateRef).
- const committedHrefRef = useRef(toHref(committedValues))
- useEffect(() => {
- committedHrefRef.current = toHref(committedValues)
- })
-
- const replaceValues = (next: Values) => {
- valuesRef.current = next
- setValues(next)
- const href = toHref(next)
- if (href === committedHrefRef.current) return
- startTransition(() => {
- router.replace(href, { scroll: false })
- })
- }
-
- /** Functional update against the LATEST values, not the render that scheduled it. */
- const updateValues = (updater: (current: Values) => Values) => {
- replaceValues(updater(valuesRef.current))
- }
-
- /** Mirror a keystroke locally WITHOUT navigating (the debounce commits later). */
- const setLocalValues = (updater: (current: Values) => Values) => {
- const next = updater(valuesRef.current)
- valuesRef.current = next
- setValues(next)
- }
-
- return { values, isPending, replaceValues, updateValues, setLocalValues }
-}
diff --git a/src/components/campaign/shared/useCampaignListFilterNavigation.ts b/src/components/campaign/shared/useCampaignListFilterNavigation.ts
index a5224cf4..238f4fca 100644
--- a/src/components/campaign/shared/useCampaignListFilterNavigation.ts
+++ b/src/components/campaign/shared/useCampaignListFilterNavigation.ts
@@ -1,35 +1,14 @@
'use client'
import { useRouter } from 'next/navigation'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
-import { normalizedText } from '@/utilities/campaignListUrl'
/**
- * Long for search-as-you-type on purpose: every commit is an RSC round trip
- * over a mobile connection, and the field team types município names slowly.
- */
-export const SEARCH_DEBOUNCE_MS = 1000
-
-type StateWithOptionalQ = { q?: string }
-
-const readCommittedQ = (state: object): string | undefined =>
- 'q' in state ? (state as StateWithOptionalQ).q : undefined
-
-const withDraftQ = (state: State, q: string): State =>
- ({ ...state, q }) as State
-
-/**
- * Navigation scaffold shared by the list filter shells that still use a
- * debounced search box (territórios, dobradinhas). Municípios moved to the
- * omnibox in B127 but still calls `navigateWithSearch` from the same hook.
- *
- * The hook never decides what is canonical — `toHref` comes from the domain's
- * own URL module (`buildXListHref`, which already canonicalizes), so the three
- * URL contracts stay independent instead of collapsing into one DSL. Raw
- * keystrokes are handed to `toHref` untrimmed for the same reason: trimming is
- * the serializer's job, and doing it here too would be a second policy.
+ * Navigation scaffold for list filter shells (B127/B128 omnibox). The hook never
+ * decides what is canonical — `toHref` comes from the domain's own URL module
+ * (`buildXListHref`, which already canonicalizes).
*/
export const useCampaignListFilterNavigation = ({
state,
@@ -42,52 +21,13 @@ export const useCampaignListFilterNavigation = ({
}) => {
const router = useRouter()
const { isPending, startTransition } = useCampaignListTransition()
- const debounceRef = useRef | null>(null)
- const [search, setSearch] = useState(readCommittedQ(state) ?? '')
- /**
- * Follow the URL when the committed query changes under us — back/forward and
- * the empty state's "Limpar busca e filtros" navigate from outside this shell.
- * Without this the box would keep showing a query the URL no longer has, and
- * the next filter touch would put it back (`navigateWithSearch` carries the
- * box). A pending debounce wins: that text is the user's, not the URL's.
- */
- const [committedQ, setCommittedQ] = useState(readCommittedQ(state))
- const stateQ = readCommittedQ(state)
- if (stateQ !== committedQ) {
- setCommittedQ(stateQ)
- if (!debounceRef.current) setSearch(stateQ ?? '')
- }
-
- /**
- * The controls that navigate from *outside* this shell — sortable heads,
- * pagination — change `state` without touching the pending timer. Reading the
- * committed state through a ref keeps a timer scheduled before such a
- * navigation from reverting it a second later. Written in an effect, not
- * during render: an interrupted transition render is abandoned before commit,
- * and the timer must not resolve against state that never landed.
- */
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
})
- const clearDebounce = () => {
- if (!debounceRef.current) return
- clearTimeout(debounceRef.current)
- debounceRef.current = null
- }
-
- useEffect(() => clearDebounce, [])
-
- /**
- * Cancels any pending search, then navigates — unless `next` serializes to
- * what is already committed. Both sides pin the same page, so the guard asks
- * "do the filters and sort change?", not "is this the current URL?": a
- * re-submitted identical search on page 3 leaves the user on page 3.
- */
- const navigateTo = (next: State) => {
- clearDebounce()
+ const navigate = (next: State) => {
const nextHref = toHref(next)
if (nextHref === toHref(stateRef.current)) return
startTransition(() => {
@@ -95,58 +35,8 @@ export const useCampaignListFilterNavigation = ({
})
}
- /**
- * Navigates carrying the search the user has typed but not yet committed —
- * otherwise touching a filter mid-typing would drop it while the input keeps
- * showing the text.
- */
- const navigateWithSearch = (next: State) => {
- navigateTo(withDraftQ(next, search))
- }
-
- /**
- * The "Limpar" buttons: empties the box and drops `q` in the same gesture.
- * Exists so no caller has to know that `navigateWithSearch` would re-commit
- * the search being cleared, because `setSearch('')` has not flushed yet.
- */
- const clearSearchAndNavigate = (next: State) => {
- setSearch('')
- navigateTo(next)
- }
-
- /**
- * The search input's only handler: mirrors the keystroke and (re)arms the
- * debounce in one call, so no caller can wire up half of it.
- *
- * Typing back to the committed value still arms a timer — it cancels the
- * pending navigation rather than merely skipping a new one, because the guard
- * lives in `navigateTo` and the timer is free to resolve to a no-op.
- */
- const onSearchChange = (value: string) => {
- setSearch(value)
- clearDebounce()
- debounceRef.current = setTimeout(() => {
- navigateTo(withDraftQ(stateRef.current, value))
- }, SEARCH_DEBOUNCE_MS)
- }
-
return {
- search,
- onSearchChange,
- /**
- * What the active-filters summary should describe. The draft wins while the
- * box is non-empty; an emptied box falls back to the committed `q` so the
- * summary does not flicker off during the debounce, before the results it
- * describes have actually changed.
- */
- draftQ: normalizedText(search) || readCommittedQ(state),
isPending,
- /**
- * Navigate with the caller's state as-is (no draft-search merge). Used by
- * shells that own committed `q` themselves (B127 omnibox).
- */
- navigate: navigateTo,
- navigateWithSearch,
- clearSearchAndNavigate,
+ navigate,
}
}
diff --git a/src/lib/campaignListOmnibox.ts b/src/lib/campaignListOmnibox.ts
index bf9c925a..d0601e8d 100644
--- a/src/lib/campaignListOmnibox.ts
+++ b/src/lib/campaignListOmnibox.ts
@@ -32,7 +32,7 @@ const omniboxQueryMatches = (
export const omniboxGroupMatches = (group: string, needle: string): boolean =>
omniboxQueryMatches(group, needle)
-export const SUGGESTION_CAP_PER_GROUP = 8
+const SUGGESTION_CAP_PER_GROUP = 8
export type OmniboxSuggestionSeed = CampaignListOmniboxSuggestion & {
/** When true, show even with an empty query (dimension shortcuts). */
diff --git a/src/utilities/territory/territoryListUrl.ts b/src/utilities/territory/territoryListUrl.ts
index c9506809..61aa5e8b 100644
--- a/src/utilities/territory/territoryListUrl.ts
+++ b/src/utilities/territory/territoryListUrl.ts
@@ -165,17 +165,3 @@ const sortOptionLabel = (key: TerritoryListSortKey, dir: TerritoryListSortDirect
export const territoryListSortOptions = territoryListSortKeys.flatMap((key) =>
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })),
)
-
-export const serializeTerritorySortValue = (
- key: TerritoryListSortKey,
- dir: TerritoryListSortDirection,
-): string => `${key}|${dir}`
-
-export const parseTerritorySortValue = (
- value: string,
-): { key: TerritoryListSortKey; dir: TerritoryListSortDirection } | null => {
- const [key, dir] = value.split('|')
- if (!territoryListSortKeySet.has(key)) return null
- if (!territoryListSortDirSet.has(dir as TerritoryListSortDirection)) return null
- return { key: key as TerritoryListSortKey, dir: dir as TerritoryListSortDirection }
-}