/* ============================================================================ LISTING STATE - URL search parameters ---------------------------------------------------------------------------- Every filter, the sort order and the page number live in the query string, so a filtered listing is a real URL: shareable, bookmarkable, back-button correct, and reachable by a crawler that only follows links. Pure and client-safe - the same helpers parse in the loader and build hrefs in the component, which is what keeps the two in step. PARAMETER NAMES (shared by /en and /ar - the locale lives in the path): resources ?type= &topic= &author= &partner= &year= &sort= &page= knowledge-hub ?type= &topic= &q= &page= news-and-updates ?tab= &page= ========================================================================= */ import { data } from "react-router"; import { DEFAULT_LOCALE, isLocale, localizedPath, translate, type Locale, type TranslationKey, } from "~/i18n"; /** Entries per page. 12 divides cleanly into the 1 / 2 / 3 column grids. */ export const PAGE_SIZE = 12; /** Longest search term we will forward to the CMS. */ const MAX_QUERY_LENGTH = 100; /** Slugs and enum values are `[a-z0-9-]` - anything else is dropped, never sent. */ const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export type SortOrder = "newest" | "oldest"; export const SORT_ORDERS = ["newest", "oldest"] as const satisfies readonly SortOrder[]; /* --------------------------------------------------------------------------- PARSING ------------------------------------------------------------------------ */ /** A 1-based page number. Anything unparseable, zero or negative becomes 1. */ export function parsePage(params: URLSearchParams): number { const raw = Number.parseInt(params.get("page") ?? "", 10); return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 10_000) : 1; } /** One of `allowed`, or `fallback`. Used for every enum-backed filter. */ export function parseEnum( params: URLSearchParams, key: string, allowed: readonly T[], fallback: T, ): T { const value = params.get(key); return allowed.find((option) => option === value) ?? fallback; } /** One of `allowed`, or undefined when the parameter is absent or unknown. */ export function parseOptionalEnum( params: URLSearchParams, key: string, allowed: readonly T[], ): T | undefined { const value = params.get(key); return allowed.find((option) => option === value); } /** * A taxonomy slug. Validated rather than trusted: the value is interpolated * into a Strapi filter, so only the shape a slug can actually have is passed on. */ export function parseSlug(params: URLSearchParams, key: string): string | undefined { const value = params.get(key)?.trim().toLowerCase(); if (!value || value.length > 80 || !SLUG_PATTERN.test(value)) return undefined; return value; } /** * An exact value for a filter on a plain STRING field - `resource.authors` is * free text, not a taxonomy, so its filter cannot be a slug. Trimmed, length * capped and stripped of control characters before it is interpolated into a * Strapi `$eq`, so only a value a reader could have picked out of the select * is ever forwarded. */ export function parseExactText( params: URLSearchParams, key: string, maxLength = 200, ): string | undefined { const raw = params.get(key); if (typeof raw !== "string") return undefined; // eslint-disable-next-line no-control-regex const value = raw.replace(/[\u0000-\u001F\u007F]/g, "").trim(); if (value.length === 0 || value.length > maxLength) return undefined; return value; } /** A four-digit year inside a plausible range, or undefined. */ export function parseYear(params: URLSearchParams, key = "year"): number | undefined { const raw = Number.parseInt(params.get(key) ?? "", 10); if (!Number.isFinite(raw) || raw < 1900 || raw > 2200) return undefined; return raw; } /** A free-text search term, trimmed and length-capped. */ export function parseQuery(params: URLSearchParams, key = "q"): string | undefined { const value = params.get(key)?.trim(); if (!value) return undefined; return value.slice(0, MAX_QUERY_LENGTH); } export function parseSort(params: URLSearchParams): SortOrder { return parseEnum(params, "sort", SORT_ORDERS, "newest"); } /* --------------------------------------------------------------------------- STRAPI QUERY FRAGMENTS ------------------------------------------------------------------------ */ /** `"publicationDate:desc"` / `":asc"`, with a stable tiebreaker. */ export function sortFor(field: string, order: SortOrder): string[] { return order === "oldest" ? [`${field}:asc`, "title:asc"] : [`${field}:desc`, "title:asc"]; } /** A whole calendar year as a half-open range on a date field. */ export function yearFilter(year: number): { $gte: string; $lt: string } { return { $gte: `${year}-01-01`, $lt: `${year + 1}-01-01`, }; } /** Drop the keys whose value is undefined, so an empty filter is not sent. */ export function compactFilters( filters: Record, ): Record { return Object.fromEntries( Object.entries(filters).filter(([, value]) => value !== undefined), ); } /* --------------------------------------------------------------------------- HREFS ------------------------------------------------------------------------ */ /** * The same listing with one parameter changed. `undefined` removes it, and * changing anything other than the page resets back to page 1 - a filter that * kept you on page 7 of a shorter result set is the classic empty page. */ export function withParam( params: URLSearchParams, key: string, value: string | number | undefined, ): string { const next = new URLSearchParams(params); if (value === undefined || value === "") next.delete(key); else next.set(key, String(value)); if (key !== "page") next.delete("page"); if (key === "page" && String(value) === "1") next.delete("page"); const search = next.toString(); return search ? `?${search}` : ""; } /** The listing with every filter cleared. */ export function clearedHref(pathname: string): string { return pathname; } /* --------------------------------------------------------------------------- PAGINATION MODEL ------------------------------------------------------------------------ */ export interface PageLink { readonly page: number; readonly isCurrent: boolean; } export interface PaginationModel { readonly page: number; readonly pageCount: number; readonly total: number; readonly from: number; readonly to: number; readonly previous: number | undefined; readonly next: number | undefined; readonly pages: readonly PageLink[]; } /** * A windowed page list: first, last, and up to two neighbours either side of * the current page. Gaps are rendered as an ellipsis by the component. */ export function buildPagination( page: number, pageSize: number, total: number, ): PaginationModel { const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); const current = Math.min(Math.max(1, page), pageCount); const pageWindow = new Set([1, pageCount, current]); for (let offset = 1; offset <= 2; offset += 1) { if (current - offset >= 1) pageWindow.add(current - offset); if (current + offset <= pageCount) pageWindow.add(current + offset); } const pages = [...pageWindow] .sort((a, b) => a - b) .map((value) => ({ page: value, isCurrent: value === current })); return { page: current, pageCount, total, from: total === 0 ? 0 : (current - 1) * pageSize + 1, to: Math.min(current * pageSize, total), previous: current > 1 ? current - 1 : undefined, next: current < pageCount ? current + 1 : undefined, pages, }; } /* --------------------------------------------------------------------------- OPTION LISTS ------------------------------------------------------------------------ */ export interface SelectOption { readonly value: string; readonly label: string; } /** Turn an enum plus its catalogue keys into `