export const SEARCH_SCOPE = Object.freeze({ SINGLE: "single", MULTI: "multi", ALL: "all", }); // Backend constraint (app/api/search/route.js): limit must be 50..200. // We expose a strict allowed set in the UI to keep URLs predictable and avoid 400s. export const SEARCH_LIMITS = Object.freeze([50, 100, 200]); export const DEFAULT_SEARCH_LIMIT = 100; /** * Read a query parameter from either: * - URLSearchParams (client-side `useSearchParams()`) * - Next.js server `searchParams` object (plain object with string or string[]) * * @param {any} searchParams * @param {string} key * @returns {string|null} */ function readParam(searchParams, key) { if (!searchParams) return null; // URLSearchParams-like object: has .get() if (typeof searchParams.get === "function") { const value = searchParams.get(key); return typeof value === "string" ? value : null; } // Next.js server searchParams: plain object const raw = searchParams[key]; if (Array.isArray(raw)) { return typeof raw[0] === "string" ? raw[0] : null; } return typeof raw === "string" ? raw : null; } function normalizeTrimmedOrNull(value) { if (typeof value !== "string") return null; const s = value.trim(); return s ? s : null; } function normalizeBranchId(value) { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed) return null; // Keep it lenient (fail-open): uppercase for consistency. // Validation is enforced later (backend + error mapping). return trimmed.toUpperCase(); } function toBranchNumber(branchId) { const m = /^NL(\d+)$/i.exec(String(branchId || "").trim()); if (!m) return null; const n = Number(m[1]); return Number.isInteger(n) ? n : null; } function compareBranchIds(a, b) { const aa = String(a || ""); const bb = String(b || ""); const na = toBranchNumber(aa); const nb = toBranchNumber(bb); // Prefer numeric ordering for NLxx patterns when possible. if (na !== null && nb !== null) return na - nb; // Stable fallback: // - valid NL come before unknown shapes // - otherwise lexicographic if (na !== null && nb === null) return -1; if (na === null && nb !== null) return 1; return aa.localeCompare(bb, "en"); } function uniqueSorted(items) { const cleaned = []; for (const it of Array.isArray(items) ? items : []) { const id = normalizeBranchId(String(it)); if (!id) continue; cleaned.push(id); } const unique = Array.from(new Set(cleaned)); unique.sort(compareBranchIds); return unique; } /** * Parse comma-separated branches param (branches=NL01,NL02). * * @param {string|null} raw * @returns {string[]} */ export function parseBranchesCsv(raw) { const s = typeof raw === "string" ? raw.trim() : ""; if (!s) return []; return uniqueSorted( s .split(",") .map((x) => String(x).trim()) .filter(Boolean) ); } /** * Serialize branches as comma-separated string. * * @param {string[]|null|undefined} branches * @returns {string|null} */ export function serializeBranchesCsv(branches) { if (!Array.isArray(branches) || branches.length === 0) return null; const cleaned = uniqueSorted(branches); return cleaned.length > 0 ? cleaned.join(",") : null; } function normalizeLimit(raw) { const s = normalizeTrimmedOrNull(raw); if (!s) return DEFAULT_SEARCH_LIMIT; if (!/^\d+$/.test(s)) return DEFAULT_SEARCH_LIMIT; const n = Number(s); if (!Number.isInteger(n)) return DEFAULT_SEARCH_LIMIT; return SEARCH_LIMITS.includes(n) ? n : DEFAULT_SEARCH_LIMIT; } /** * Parse search URL state from query params. * * Precedence: * 1) scope=all -> ALL * 2) scope=multi OR branches=... -> MULTI * 3) otherwise -> SINGLE (routeBranch is the source of truth) * * @param {URLSearchParams|Record|any} searchParams * @param {{ routeBranch?: string|null }=} options * @returns {{ * q: string|null, * scope: "single"|"multi"|"all", * branch: string|null, * branches: string[], * limit: number, * from: string|null, * to: string|null * }} */ export function parseSearchUrlState(searchParams, { routeBranch = null } = {}) { const q = normalizeTrimmedOrNull(readParam(searchParams, "q")); const scopeRaw = normalizeTrimmedOrNull(readParam(searchParams, "scope")); const branchRaw = normalizeBranchId(readParam(searchParams, "branch")); const branches = parseBranchesCsv(readParam(searchParams, "branches")); const limit = normalizeLimit(readParam(searchParams, "limit")); const from = normalizeTrimmedOrNull(readParam(searchParams, "from")); const to = normalizeTrimmedOrNull(readParam(searchParams, "to")); let scope = SEARCH_SCOPE.SINGLE; if (scopeRaw === "all") { scope = SEARCH_SCOPE.ALL; } else if (scopeRaw === "multi" || branches.length > 0) { scope = SEARCH_SCOPE.MULTI; } if (scope === SEARCH_SCOPE.SINGLE) { const fallbackRouteBranch = normalizeBranchId(routeBranch); const branch = branchRaw || fallbackRouteBranch || null; return { q, scope, branch, branches: [], limit, from, to, }; } if (scope === SEARCH_SCOPE.MULTI) { return { q, scope, branch: null, branches, limit, from, to, }; } // ALL return { q, scope: SEARCH_SCOPE.ALL, branch: null, branches: [], limit, from, to, }; } /** * Serialize search URL state into a stable query string (no leading "?"). * * Stable param ordering: * - q * - scope * - branches * - limit * - from * - to * * SINGLE policy: * - We do NOT emit `branch=` because the path segment `/:branch/search` is the source of truth. * * @param {{ * q?: string|null, * scope?: "single"|"multi"|"all"|string|null, * branch?: string|null, * branches?: string[]|null, * limit?: number|null, * from?: string|null, * to?: string|null * }} state * @returns {string} */ export function serializeSearchUrlState(state) { const s = state || {}; const params = new URLSearchParams(); const q = normalizeTrimmedOrNull(s.q); const from = normalizeTrimmedOrNull(s.from); const to = normalizeTrimmedOrNull(s.to); const limit = Number.isInteger(s.limit) && SEARCH_LIMITS.includes(s.limit) ? s.limit : DEFAULT_SEARCH_LIMIT; if (q) params.set("q", q); // Scope: we only emit what the UI needs for shareability. if (s.scope === SEARCH_SCOPE.ALL) { params.set("scope", "all"); } else if (s.scope === SEARCH_SCOPE.MULTI) { params.set("scope", "multi"); const csv = serializeBranchesCsv(s.branches); if (csv) params.set("branches", csv); } else { // SINGLE: no `branch=` in URL (path is SoT) } // Only include non-default limit to keep URLs shorter. if (limit !== DEFAULT_SEARCH_LIMIT) { params.set("limit", String(limit)); } // from/to are additive for RHL-025. We allow carrying them already. if (from) params.set("from", from); if (to) params.set("to", to); return params.toString(); }