@kahitsan/ksui 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,36 +1,71 @@
1
- // fetchUrl prop threading the default stays the vouchers plugin's own API so
2
- // existing consumers are unaffected; a consumer without vouchers.view can
3
- // point the picker at a peer proxy route with the same response shape.
1
+ // The picker pages the list in from the server (page/limit) and delegates the
2
+ // search to it, so these assert the request contract as well as the rendering.
4
3
  import { describe, expect, it, vi } from "vitest";
5
4
  import { fireEvent, render, waitFor } from "@solidjs/testing-library";
6
- import VoucherPicker from "./VoucherPicker";
5
+ import VoucherPicker, { type VoucherOption } from "./VoucherPicker";
7
6
 
8
- function mockFetchOnce(): typeof fetch {
9
- const impl = vi.fn(async () => ({
10
- ok: true,
11
- json: async () => ({ data: [] }),
12
- })) as unknown as typeof fetch;
7
+ /** Captures every requested URL and serves pages out of `rows`. */
8
+ function mockPagedFetch(rows: VoucherOption[]) {
9
+ const calls: string[] = [];
10
+ const impl = vi.fn(async (url: string) => {
11
+ calls.push(url);
12
+ const parsed = new URL(url, "http://localhost");
13
+ const page = Number(parsed.searchParams.get("page") ?? "1");
14
+ const limit = Number(parsed.searchParams.get("limit") ?? "25");
15
+ const search = (parsed.searchParams.get("search") ?? "").toLowerCase();
16
+ const matched = search
17
+ ? rows.filter((r) => r.code.toLowerCase().includes(search))
18
+ : rows;
19
+ const start = (page - 1) * limit;
20
+ return {
21
+ ok: true,
22
+ json: async () => ({
23
+ data: matched.slice(start, start + limit),
24
+ total: matched.length,
25
+ page,
26
+ limit,
27
+ }),
28
+ };
29
+ }) as unknown as typeof fetch;
13
30
  vi.stubGlobal("fetch", impl);
14
- return impl;
31
+ return { impl, calls };
32
+ }
33
+
34
+ function voucher(over: Partial<VoucherOption> & Pick<VoucherOption, "id" | "code">): VoucherOption {
35
+ return {
36
+ type: "percentage",
37
+ value: 20,
38
+ max_discount_amount: null,
39
+ applicable_packages: null,
40
+ minimum_purchase: 0,
41
+ valid_from: null,
42
+ valid_until: null,
43
+ is_active: true,
44
+ ...over,
45
+ };
46
+ }
47
+
48
+ function manyVouchers(n: number): VoucherOption[] {
49
+ return Array.from({ length: n }, (_, i) =>
50
+ voucher({ id: i + 1, code: `BULK_${String(i + 1).padStart(3, "0")}` }),
51
+ );
15
52
  }
16
53
 
17
54
  describe("VoucherPicker fetchUrl", () => {
18
55
  it("defaults to the vouchers plugin's own API when fetchUrl is omitted", async () => {
19
- const fetchMock = mockFetchOnce();
56
+ const { calls } = mockPagedFetch([]);
20
57
  const { getByTestId } = render(() => (
21
58
  <VoucherPicker selected={null} onChange={vi.fn()} subtotal={100} packageIds={[]} />
22
59
  ));
23
60
  fireEvent.click(getByTestId("voucher-picker-trigger"));
24
61
 
25
- await waitFor(() => expect(fetchMock).toHaveBeenCalled());
26
- expect(fetchMock).toHaveBeenCalledWith(
27
- "/api/vouchers?status=active&limit=200",
28
- expect.objectContaining({ credentials: "include" }),
29
- );
62
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0));
63
+ expect(calls[0]).toContain("/api/vouchers");
64
+ expect(calls[0]).toContain("status=active");
30
65
  });
31
66
 
32
67
  it("fetches the overridden URL when fetchUrl is provided", async () => {
33
- const fetchMock = mockFetchOnce();
68
+ const { calls } = mockPagedFetch([]);
34
69
  const { getByTestId } = render(() => (
35
70
  <VoucherPicker
36
71
  selected={null}
@@ -42,10 +77,295 @@ describe("VoucherPicker fetchUrl", () => {
42
77
  ));
43
78
  fireEvent.click(getByTestId("voucher-picker-trigger"));
44
79
 
45
- await waitFor(() => expect(fetchMock).toHaveBeenCalled());
46
- expect(fetchMock).toHaveBeenCalledWith(
47
- "/api/counter/vouchers",
48
- expect.objectContaining({ credentials: "include" }),
80
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0));
81
+ expect(calls[0]).toContain("/api/counter/vouchers");
82
+ });
83
+
84
+ it("requests only the first page up front, not the whole table", async () => {
85
+ const { calls } = mockPagedFetch(manyVouchers(120));
86
+ const { getByTestId, getAllByTestId } = render(() => (
87
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
88
+ ));
89
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
90
+
91
+ await waitFor(() =>
92
+ expect(getAllByTestId(/^voucher-picker-result-/).length).toBeGreaterThan(0),
93
+ );
94
+ expect(calls[0]).toContain("page=1");
95
+ expect(calls[0]).toContain("limit=25");
96
+ // 120 rows exist but only the first page is mounted.
97
+ expect(getAllByTestId(/^voucher-picker-result-/).length).toBe(25);
98
+ });
99
+ });
100
+
101
+ describe("VoucherPicker dialog", () => {
102
+ it("opens a dialog and only commits the pick on Confirm", async () => {
103
+ mockPagedFetch([voucher({ id: 1, code: "SAVE20" })]);
104
+ const onChange = vi.fn();
105
+ const { getByTestId, queryByTestId } = render(() => (
106
+ <VoucherPicker selected={null} onChange={onChange} subtotal={1000} packageIds={[]} />
107
+ ));
108
+
109
+ expect(queryByTestId("voucher-picker-popup")).toBeNull();
110
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
111
+
112
+ await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
113
+ expect(getByTestId("voucher-picker-popup").closest("dialog")).not.toBeNull();
114
+
115
+ // Staging a row must not reach the consumer yet.
116
+ fireEvent.click(getByTestId("voucher-picker-result-1"));
117
+ expect(onChange).not.toHaveBeenCalled();
118
+ expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("SAVE20");
119
+
120
+ fireEvent.click(getByTestId("voucher-picker-confirm"));
121
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ id: 1, code: "SAVE20" }));
122
+ await waitFor(() => expect(queryByTestId("voucher-picker-popup")).toBeNull());
123
+ });
124
+
125
+ it("discards the staged pick on Cancel", async () => {
126
+ mockPagedFetch([voucher({ id: 1, code: "SAVE20" })]);
127
+ const onChange = vi.fn();
128
+ const { getByTestId, queryByTestId } = render(() => (
129
+ <VoucherPicker selected={null} onChange={onChange} subtotal={1000} packageIds={[]} />
130
+ ));
131
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
132
+ await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
133
+
134
+ fireEvent.click(getByTestId("voucher-picker-result-1"));
135
+ fireEvent.click(getByTestId("voucher-picker-cancel"));
136
+
137
+ expect(onChange).not.toHaveBeenCalled();
138
+ await waitFor(() => expect(queryByTestId("voucher-picker-popup")).toBeNull());
139
+ });
140
+
141
+ it("re-tapping the staged row unstages it", async () => {
142
+ mockPagedFetch([voucher({ id: 1, code: "SAVE20" })]);
143
+ const { getByTestId } = render(() => (
144
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
145
+ ));
146
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
147
+ await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
148
+
149
+ fireEvent.click(getByTestId("voucher-picker-result-1"));
150
+ expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("SAVE20");
151
+
152
+ fireEvent.click(getByTestId("voucher-picker-result-1"));
153
+ expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("No voucher selected");
154
+ });
155
+
156
+ it("delegates the search to the server and highlights the match", async () => {
157
+ const { calls } = mockPagedFetch([
158
+ voucher({ id: 1, code: "SAVE20" }),
159
+ voucher({ id: 2, code: "PARTNER_ACES" }),
160
+ ]);
161
+ const { getByTestId, queryByTestId } = render(() => (
162
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
163
+ ));
164
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
165
+ await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
166
+
167
+ fireEvent.input(getByTestId("voucher-picker-search"), { target: { value: "partner" } });
168
+
169
+ await waitFor(() => expect(calls.some((u) => u.includes("search=partner"))).toBe(true));
170
+ await waitFor(() => expect(queryByTestId("voucher-picker-result-1")).toBeNull());
171
+
172
+ const row = getByTestId("voucher-picker-result-2");
173
+ const mark = row.querySelector("mark");
174
+ expect(mark).not.toBeNull();
175
+ expect(mark!.textContent?.toLowerCase()).toBe("partner");
176
+ });
177
+
178
+ it("shows when a voucher expires", async () => {
179
+ const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
180
+ mockPagedFetch([voucher({ id: 9, code: "ENDINGSOON", valid_until: soon })]);
181
+ const { getByTestId } = render(() => (
182
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
183
+ ));
184
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
185
+
186
+ await waitFor(() => expect(getByTestId("voucher-picker-result-9")).toBeTruthy());
187
+ expect(getByTestId("voucher-picker-result-9").textContent).toContain("Expires in 3 days");
188
+ });
189
+
190
+ it("reads a full timestamp date the same as a bare date", async () => {
191
+ const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
192
+ mockPagedFetch([
193
+ voucher({ id: 10, code: "TIMESTAMPED", valid_until: `${soon}T16:00:00.000Z` }),
194
+ ]);
195
+ const { getByTestId } = render(() => (
196
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
197
+ ));
198
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
199
+
200
+ await waitFor(() => expect(getByTestId("voucher-picker-result-10")).toBeTruthy());
201
+ const text = getByTestId("voucher-picker-result-10").textContent ?? "";
202
+ expect(text).toContain("Expires in 3 days");
203
+ expect(text).not.toContain("T16:00:00");
204
+ });
205
+
206
+ it("shows how many redemptions are used against the limit", async () => {
207
+ mockPagedFetch([
208
+ voucher({ id: 20, code: "LIMITED", usage_count: 3, usage_limit_total: 10 }),
209
+ ]);
210
+ const { getByTestId } = render(() => (
211
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
212
+ ));
213
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
214
+
215
+ await waitFor(() => expect(getByTestId("voucher-picker-result-20")).toBeTruthy());
216
+ expect(getByTestId("voucher-picker-result-20").textContent).toContain("3/10 used");
217
+ });
218
+
219
+ it("omits the usage line for an unlimited voucher", async () => {
220
+ mockPagedFetch([voucher({ id: 21, code: "UNLIMITED", usage_count: 42 })]);
221
+ const { getByTestId } = render(() => (
222
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
223
+ ));
224
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
225
+
226
+ await waitFor(() => expect(getByTestId("voucher-picker-result-21")).toBeTruthy());
227
+ expect(getByTestId("voucher-picker-result-21").textContent).not.toContain("used");
228
+ });
229
+
230
+ it("blocks a fully-redeemed voucher the same way the server does", async () => {
231
+ mockPagedFetch([
232
+ voucher({ id: 22, code: "SPENT", usage_count: 10, usage_limit_total: 10 }),
233
+ ]);
234
+ const { getByTestId, queryByTestId } = render(() => (
235
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
236
+ ));
237
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
238
+
239
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-22")).toBeTruthy());
240
+ // Never selectable — it would be rejected at charge time.
241
+ expect(queryByTestId("voucher-picker-result-22")).toBeNull();
242
+ expect(getByTestId("voucher-picker-inapplicable-22").textContent).toContain("Fully redeemed");
243
+ });
244
+
245
+ it("explains why an ineligible voucher can't be used", async () => {
246
+ mockPagedFetch([voucher({ id: 7, code: "BIGSPEND", minimum_purchase: 5000 })]);
247
+ const { getByTestId } = render(() => (
248
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
249
+ ));
250
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
251
+
252
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-7")).toBeTruthy());
253
+ expect(getByTestId("voucher-picker-inapplicable-7").textContent).toContain("minimum");
254
+ });
255
+
256
+ it("previews a discount range while nothing is priced yet", async () => {
257
+ mockPagedFetch([voucher({ id: 3, code: "SAVE20" })]);
258
+ const { getByTestId } = render(() => (
259
+ <VoucherPicker
260
+ selected={null}
261
+ onChange={vi.fn()}
262
+ subtotal={0}
263
+ subtotalRange={{ min: 99, max: 118 }}
264
+ packageIds={[]}
265
+ />
266
+ ));
267
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
268
+
269
+ await waitFor(() => expect(getByTestId("voucher-picker-result-3")).toBeTruthy());
270
+ // 20% of 99 and of 118, both rounded the same way the server rounds.
271
+ expect(getByTestId("voucher-picker-result-3").textContent).toContain("₱20.00 to ₱24.00");
272
+ });
273
+
274
+ it("shows a single amount once the cart has a real subtotal", async () => {
275
+ mockPagedFetch([voucher({ id: 4, code: "SAVE20" })]);
276
+ const { getByTestId } = render(() => (
277
+ <VoucherPicker
278
+ selected={null}
279
+ onChange={vi.fn()}
280
+ subtotal={99}
281
+ subtotalRange={{ min: 99, max: 118 }}
282
+ packageIds={[]}
283
+ />
284
+ ));
285
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
286
+
287
+ await waitFor(() => expect(getByTestId("voucher-picker-result-4")).toBeTruthy());
288
+ const text = getByTestId("voucher-picker-result-4").textContent ?? "";
289
+ expect(text).toContain("₱20.00");
290
+ expect(text).not.toContain("₱24.00");
291
+ });
292
+
293
+ it("names the specific reason each ineligible voucher can't be used", async () => {
294
+ const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
295
+ const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
296
+ mockPagedFetch([
297
+ voucher({ id: 30, code: "TOO_SMALL", minimum_purchase: 5000 }),
298
+ voucher({ id: 31, code: "GONE", valid_until: yesterday }),
299
+ voucher({ id: 32, code: "NOT_YET", valid_from: nextWeek }),
300
+ voucher({ id: 33, code: "OFF", is_active: false }),
301
+ voucher({ id: 34, code: "OTHER_ITEMS", applicable_packages: [99] }),
302
+ ]);
303
+ const { getByTestId } = render(() => (
304
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[1]} />
305
+ ));
306
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
307
+
308
+ await waitFor(() => expect(getByTestId("voucher-picker-inapplicable-30")).toBeTruthy());
309
+ expect(getByTestId("voucher-picker-inapplicable-30").textContent).toContain("minimum");
310
+ expect(getByTestId("voucher-picker-inapplicable-31").textContent).toContain("Expired");
311
+ expect(getByTestId("voucher-picker-inapplicable-32").textContent).toContain("Starts");
312
+ expect(getByTestId("voucher-picker-inapplicable-33").textContent).toContain("Inactive");
313
+ expect(getByTestId("voucher-picker-inapplicable-34").textContent).toContain(
314
+ "Doesn't cover every item",
315
+ );
316
+ });
317
+
318
+ it("surfaces a load failure instead of rendering an empty list", async () => {
319
+ vi.stubGlobal(
320
+ "fetch",
321
+ vi.fn(async () => ({ ok: false, status: 403, json: async () => ({}) })) as unknown as typeof fetch,
322
+ );
323
+ const { getByTestId, queryByTestId } = render(() => (
324
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
325
+ ));
326
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
327
+
328
+ await waitFor(() =>
329
+ expect(getByTestId("voucher-picker-popup").textContent).toContain("Permission denied"),
49
330
  );
331
+ // The misleading "nothing here" copy must not stand in for a real failure.
332
+ expect(queryByTestId("voucher-picker-popup")!.textContent).not.toContain(
333
+ "No vouchers available.",
334
+ );
335
+ });
336
+
337
+ it("reopening discards a pick that was staged but never confirmed", async () => {
338
+ mockPagedFetch([voucher({ id: 40, code: "STAGED" }), voucher({ id: 41, code: "OTHER" })]);
339
+ const onChange = vi.fn();
340
+ const { getByTestId } = render(() => (
341
+ <VoucherPicker selected={null} onChange={onChange} subtotal={1000} packageIds={[]} />
342
+ ));
343
+
344
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
345
+ await waitFor(() => expect(getByTestId("voucher-picker-result-40")).toBeTruthy());
346
+ fireEvent.click(getByTestId("voucher-picker-result-40"));
347
+ fireEvent.click(getByTestId("voucher-picker-cancel"));
348
+
349
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
350
+ await waitFor(() => expect(getByTestId("voucher-picker-result-40")).toBeTruthy());
351
+ expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("No voucher selected");
352
+ expect(onChange).not.toHaveBeenCalled();
353
+ });
354
+
355
+ it("keeps Escape from reaching an ancestor's document-level dismiss handler", async () => {
356
+ mockPagedFetch([]);
357
+ const ancestorEsc = vi.fn();
358
+ document.addEventListener("keydown", ancestorEsc);
359
+
360
+ const { getByTestId } = render(() => (
361
+ <VoucherPicker selected={null} onChange={vi.fn()} subtotal={100} packageIds={[]} />
362
+ ));
363
+ fireEvent.click(getByTestId("voucher-picker-trigger"));
364
+ await waitFor(() => expect(getByTestId("voucher-picker-popup")).toBeTruthy());
365
+
366
+ fireEvent.keyDown(getByTestId("voucher-picker-search"), { key: "Escape" });
367
+ expect(ancestorEsc).not.toHaveBeenCalled();
368
+
369
+ document.removeEventListener("keydown", ancestorEsc);
50
370
  });
51
371
  });
@@ -1,19 +1,20 @@
1
1
  // Vendored into plugin remotes.
2
2
  //
3
3
  // Cross-plugin picker: fetches vouchers over HTTP and degrades gracefully:
4
- // when the endpoint isn't reachable the popup shows a "couldn't load" notice
4
+ // when the endpoint isn't reachable the dialog shows a "couldn't load" notice
5
5
  // and the sale records with no voucher (the manual-discount field stays
6
6
  // available). Defaults to the vouchers plugin's own public API
7
7
  // (/api/vouchers); `fetchUrl` overrides it for a consumer that reaches
8
8
  // vouchers through a peer proxy route instead (same response shape required).
9
9
 
10
- import { Portal } from "solid-js/web";
11
- import { useTopLayer } from "../../utils/top-layer";
12
- import { usePopoverMount } from "../../utils/modal-layer";
10
+ import { Modal } from "../base/Modal";
11
+ import { highlightMatch } from "../../utils/highlight";
13
12
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
14
13
  import Ticket from "lucide-solid/icons/ticket";
15
14
  import X from "lucide-solid/icons/x";
15
+ import Search from "lucide-solid/icons/search";
16
16
  import Loader2 from "lucide-solid/icons/loader-2";
17
+ import CalendarClock from "lucide-solid/icons/calendar-clock";
17
18
 
18
19
  export interface VoucherOption {
19
20
  id: number;
@@ -26,6 +27,10 @@ export interface VoucherOption {
26
27
  valid_from: string | null;
27
28
  valid_until: string | null;
28
29
  is_active: boolean;
30
+ /** Redemptions so far. Absent on endpoints that don't expose usage. */
31
+ usage_count?: number | null;
32
+ /** Total redemptions allowed; null/absent means unlimited. */
33
+ usage_limit_total?: number | null;
29
34
  }
30
35
 
31
36
  const DEFAULT_FETCH_URL = "/api/vouchers?status=active&limit=200";
@@ -41,11 +46,12 @@ interface VoucherPickerProps {
41
46
  * a consumer with no `vouchers.view` grant can point this at a peer proxy
42
47
  * route instead. */
43
48
  fetchUrl?: string;
49
+ /** Cheapest/priciest total still reachable from what the cart offers. Used to
50
+ * preview a discount RANGE while `subtotal` is still 0, instead of a
51
+ * meaningless zero. */
52
+ subtotalRange?: { min: number; max: number };
44
53
  }
45
54
 
46
- const POPUP_MAX_HEIGHT = 360;
47
- const POPUP_MIN_WIDTH = 320;
48
-
49
55
  function asNumber(v: string | number | null | undefined): number {
50
56
  if (v == null) return 0;
51
57
  return typeof v === "string" ? parseFloat(v) : v;
@@ -69,22 +75,61 @@ function formatCurrency(amount: number): string {
69
75
  return new Intl.NumberFormat("en-PH", { style: "currency", currency: "PHP" }).format(amount);
70
76
  }
71
77
 
72
- function isApplicable(
78
+ /** True once every allowed redemption is spent. Unlimited codes never exhaust. */
79
+ function usageExhausted(v: VoucherOption): boolean {
80
+ const limit = v.usage_limit_total;
81
+ if (limit == null || limit <= 0) return false;
82
+ return (v.usage_count ?? 0) >= limit;
83
+ }
84
+
85
+ /** "3/10 used" — null when the endpoint omits usage or the code is unlimited. */
86
+ function formatUsage(v: VoucherOption): string | null {
87
+ const limit = v.usage_limit_total;
88
+ if (limit == null || limit <= 0) return null;
89
+ return `${v.usage_count ?? 0}/${limit} used`;
90
+ }
91
+
92
+ /** Few enough redemptions left that the cashier should notice. */
93
+ function nearlyUsedUp(v: VoucherOption): boolean {
94
+ const limit = v.usage_limit_total;
95
+ if (limit == null || limit <= 0) return false;
96
+ const left = limit - (v.usage_count ?? 0);
97
+ return left > 0 && left <= Math.max(1, Math.ceil(limit * 0.2));
98
+ }
99
+
100
+ /** Null when the voucher can be applied; otherwise the shopper-facing reason it can't. */
101
+ function ineligibilityReason(
73
102
  voucher: VoucherOption,
74
103
  subtotal: number,
75
104
  packageIds: number[],
76
105
  todayIso: string,
77
- ): boolean {
78
- if (!voucher.is_active) return false;
79
- if (voucher.valid_from && todayIso < voucher.valid_from) return false;
80
- if (voucher.valid_until && todayIso > voucher.valid_until) return false;
81
- if (asNumber(voucher.minimum_purchase) > subtotal) return false;
106
+ ): string | null {
107
+ if (!voucher.is_active) return "Inactive";
108
+ if (voucher.valid_from && todayIso < toDay(voucher.valid_from))
109
+ return `Starts ${toDay(voucher.valid_from)}`;
110
+ if (voucher.valid_until && todayIso > toDay(voucher.valid_until))
111
+ return `Expired ${toDay(voucher.valid_until)}`;
112
+ // Mirrors the server's usage gate — without it an exhausted code looks
113
+ // selectable here and is only rejected at charge time.
114
+ if (usageExhausted(voucher)) return "Fully redeemed";
115
+ if (asNumber(voucher.minimum_purchase) > subtotal)
116
+ return `Needs ${formatCurrency(asNumber(voucher.minimum_purchase))} minimum`;
82
117
  if (voucher.applicable_packages && voucher.applicable_packages.length > 0) {
83
- if (packageIds.length === 0) return false;
118
+ if (packageIds.length === 0) return "Only for specific items";
84
119
  const allowed = new Set(voucher.applicable_packages);
85
- if (!packageIds.every((id) => allowed.has(id))) return false;
120
+ if (!packageIds.every((id) => allowed.has(id))) return "Doesn't cover every item";
86
121
  }
87
- return true;
122
+ return null;
123
+ }
124
+
125
+ // Single source of truth with the reason list above, so the two can't drift.
126
+ function isApplicable(
127
+ voucher: VoucherOption,
128
+ subtotal: number,
129
+ packageIds: number[],
130
+ todayIso: string,
131
+ ): boolean {
132
+ return ineligibilityReason(voucher, subtotal, packageIds, todayIso) === null;
88
133
  }
89
134
 
90
135
  function formatVoucherDescription(v: VoucherOption): string {
@@ -99,24 +144,72 @@ function formatVoucherDescription(v: VoucherOption): string {
99
144
  return "";
100
145
  }
101
146
 
147
+ /** A date column may serialize as a bare date or a full timestamp; keep the day. */
148
+ function toDay(value: string): string {
149
+ return value.slice(0, 10);
150
+ }
151
+
152
+ /** Whole days from today to `day`; negative once it's in the past. */
153
+ function daysUntil(day: string, todayIso: string): number {
154
+ return Math.round((Date.parse(`${day}T00:00:00Z`) - Date.parse(`${todayIso}T00:00:00Z`)) / 86400000);
155
+ }
156
+
157
+ /** Short human expiry for the row's meta line. Null when the voucher never expires. */
158
+ function formatExpiry(validUntil: string | null, todayIso: string): string | null {
159
+ if (!validUntil) return null;
160
+ const day = toDay(validUntil);
161
+ if (day < todayIso) return `Expired ${day}`;
162
+ const days = daysUntil(day, todayIso);
163
+ if (days === 0) return "Expires today";
164
+ if (days === 1) return "Expires tomorrow";
165
+ if (days <= 30) return `Expires in ${days} days`;
166
+ return `Expires ${day}`;
167
+ }
168
+
169
+ // Server page size. The list is paged in on scroll so an account with hundreds
170
+ // of codes doesn't ship (or mount) all of them just to open the picker.
171
+ const PAGE_SIZE = 25;
172
+ const SEARCH_DEBOUNCE_MS = 200;
173
+
102
174
  export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
103
- const popoverMount = usePopoverMount();
104
175
  const [open, setOpen] = createSignal(false);
105
176
  const [vouchers, setVouchers] = createSignal<VoucherOption[]>([]);
106
177
  const [loading, setLoading] = createSignal(false);
178
+ const [loadingMore, setLoadingMore] = createSignal(false);
107
179
  const [error, setError] = createSignal<string | null>(null);
108
- const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
180
+ const [query, setQuery] = createSignal("");
181
+ const [debouncedQuery, setDebouncedQuery] = createSignal("");
182
+ const [page, setPage] = createSignal(1);
183
+ const [total, setTotal] = createSignal(0);
184
+ // Staged choice. `props.selected` only changes on Confirm.
185
+ const [draft, setDraft] = createSignal<VoucherOption | null>(null);
186
+
187
+ // Signal, not a plain ref: the sentinel mounts only once the first page
188
+ // reveals there are more, which is after the observer effect first runs.
189
+ const [sentinel, setSentinel] = createSignal<HTMLDivElement | undefined>();
109
190
 
110
- let triggerRef: HTMLButtonElement | undefined;
111
- let popupRef: HTMLDivElement | undefined;
112
191
  let activeFetchToken = 0;
192
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
113
193
 
114
- createEffect(() => {
115
- if (!open()) return;
194
+ const baseUrl = () => props.fetchUrl ?? DEFAULT_FETCH_URL;
195
+
196
+ // The endpoint may already carry query params; append rather than assume "?".
197
+ const pageUrl = (p: number, search: string): string => {
198
+ const [path, existing] = baseUrl().split("?");
199
+ const qs = new URLSearchParams(existing ?? "");
200
+ qs.set("page", String(p));
201
+ qs.set("limit", String(PAGE_SIZE));
202
+ if (search) qs.set("search", search);
203
+ else qs.delete("search");
204
+ return `${path}?${qs.toString()}`;
205
+ };
206
+
207
+ const loadPage = (p: number, search: string, append: boolean) => {
116
208
  const token = ++activeFetchToken;
117
- setLoading(true);
209
+ if (append) setLoadingMore(true);
210
+ else setLoading(true);
118
211
  setError(null);
119
- fetch(props.fetchUrl ?? DEFAULT_FETCH_URL, { credentials: "include" })
212
+ fetch(pageUrl(p, search), { credentials: "include" })
120
213
  .then((r) => {
121
214
  if (!r.ok)
122
215
  throw new Error(
@@ -130,17 +223,64 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
130
223
  })
131
224
  .then((json) => {
132
225
  if (token !== activeFetchToken) return;
133
- setVouchers((json.data || []) as VoucherOption[]);
226
+ const rows = (json.data || []) as VoucherOption[];
227
+ setTotal(typeof json.total === "number" ? json.total : rows.length);
228
+ setPage(p);
229
+ setVouchers((prev) => (append ? [...prev, ...rows] : rows));
134
230
  })
135
231
  .catch((e) => {
136
232
  if (token !== activeFetchToken) return;
137
233
  setError(e instanceof Error ? e.message : "Failed to load");
138
- setVouchers([]);
234
+ if (!append) setVouchers([]);
139
235
  })
140
236
  .finally(() => {
141
237
  if (token !== activeFetchToken) return;
142
238
  setLoading(false);
239
+ setLoadingMore(false);
143
240
  });
241
+ };
242
+
243
+ // Debounce the keystrokes into a server-side search, so filtering spans the
244
+ // whole table rather than only the pages already pulled down.
245
+ createEffect(() => {
246
+ const q = query();
247
+ if (!open()) return;
248
+ clearTimeout(debounceTimer);
249
+ debounceTimer = setTimeout(() => setDebouncedQuery(q.trim()), SEARCH_DEBOUNCE_MS);
250
+ });
251
+ onCleanup(() => clearTimeout(debounceTimer));
252
+
253
+ // Refetch from page 1 whenever the dialog opens or the search term settles.
254
+ createEffect(() => {
255
+ if (!open()) return;
256
+ const search = debouncedQuery();
257
+ loadPage(1, search, false);
258
+ });
259
+
260
+ const hasMore = createMemo(() => vouchers().length < total());
261
+
262
+ const loadNext = () => {
263
+ if (loading() || loadingMore() || !hasMore()) return;
264
+ loadPage(page() + 1, debouncedQuery(), true);
265
+ };
266
+
267
+ // Scroll sentinel: pull the next page when the end of the list comes into view.
268
+ // Re-created after every append — an observer only reports a CHANGE in
269
+ // intersection, so a sentinel that stays on screen (list still shorter than
270
+ // the viewport) would never fire again and paging would stall.
271
+ createEffect(() => {
272
+ const loadedCount = vouchers().length;
273
+ if (!open() || loadedCount === 0) return;
274
+ const el = sentinel();
275
+ if (!el || typeof IntersectionObserver === "undefined") return;
276
+ const io = new IntersectionObserver(
277
+ (entries) => {
278
+ if (entries.some((e) => e.isIntersecting)) loadNext();
279
+ },
280
+ { rootMargin: "120px" },
281
+ );
282
+ io.observe(el);
283
+ onCleanup(() => io.disconnect());
144
284
  });
145
285
 
146
286
  const today = () => new Date().toISOString().slice(0, 10);
@@ -152,75 +292,36 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
152
292
 
153
293
  const inapplicable = createMemo(() => {
154
294
  const today_ = today();
155
- return vouchers().filter((v) => !isApplicable(v, props.subtotal, props.packageIds, today_));
295
+ return vouchers()
296
+ .filter((v) => !isApplicable(v, props.subtotal, props.packageIds, today_))
297
+ .map((v) => ({
298
+ voucher: v,
299
+ reason: ineligibilityReason(v, props.subtotal, props.packageIds, today_) ?? "",
300
+ }));
156
301
  });
157
302
 
158
- const updatePosition = () => {
159
- if (!triggerRef) return;
160
- const rect = triggerRef.getBoundingClientRect();
161
- const vpHeight = window.innerHeight;
162
- const vpWidth = window.innerWidth;
163
- const width = Math.max(POPUP_MIN_WIDTH, rect.width);
164
- const spaceBelow = vpHeight - rect.bottom;
165
- const spaceAbove = rect.top;
166
- const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
167
- const maxHeight = Math.max(
168
- 200,
169
- Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
170
- );
171
- const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
172
- if (flipUp) {
173
- setPopupStyle({
174
- position: "fixed",
175
- bottom: `${vpHeight - rect.top + 4}px`,
176
- left: `${left}px`,
177
- width: `${width}px`,
178
- "max-height": `${maxHeight}px`,
179
- });
180
- } else {
181
- setPopupStyle({
182
- position: "fixed",
183
- top: `${rect.bottom + 4}px`,
184
- left: `${left}px`,
185
- width: `${width}px`,
186
- "max-height": `${maxHeight}px`,
187
- });
188
- }
303
+ const openPicker = () => {
304
+ if (props.disabled) return;
305
+ setQuery("");
306
+ setDebouncedQuery("");
307
+ setVouchers([]);
308
+ setPage(1);
309
+ setTotal(0);
310
+ setDraft(props.selected);
311
+ setOpen(true);
189
312
  };
190
313
 
191
- createEffect(() => {
192
- if (!open()) return;
193
- updatePosition();
194
-
195
- const onDocClick = (e: MouseEvent) => {
196
- const t = e.target as Node;
197
- if (triggerRef?.contains(t)) return;
198
- if (popupRef?.contains(t)) return;
199
- setOpen(false);
200
- };
201
- const onEsc = (e: KeyboardEvent) => {
202
- if (e.key === "Escape") {
203
- e.stopPropagation();
204
- setOpen(false);
205
- }
206
- };
207
- const onReflow = () => updatePosition();
208
-
209
- document.addEventListener("mousedown", onDocClick);
210
- document.addEventListener("keydown", onEsc, true);
211
- window.addEventListener("resize", onReflow);
212
- window.addEventListener("scroll", onReflow, true);
213
- onCleanup(() => {
214
- document.removeEventListener("mousedown", onDocClick);
215
- document.removeEventListener("keydown", onEsc, true);
216
- window.removeEventListener("resize", onReflow);
217
- window.removeEventListener("scroll", onReflow, true);
218
- });
219
- });
314
+ const close = () => setOpen(false);
315
+
316
+ // Picking a row only stages it; nothing reaches the cart until Confirm, so a
317
+ // mis-tap on a touch screen can be corrected without re-opening the dialog.
318
+ const stage = (v: VoucherOption) => {
319
+ setDraft((current) => (current?.id === v.id ? null : v));
320
+ };
220
321
 
221
- const select = (v: VoucherOption | null) => {
222
- props.onChange(v);
223
- setOpen(false);
322
+ const confirm = () => {
323
+ props.onChange(draft());
324
+ close();
224
325
  };
225
326
 
226
327
  const clear = (e: MouseEvent) => {
@@ -230,18 +331,67 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
230
331
 
231
332
  const previewDiscount = createMemo(() => calculateDiscount(props.selected, props.subtotal));
232
333
 
334
+ // A fixed_amount description already reads "₱X off"; repeating the computed
335
+ // figure beside it just says the same thing twice.
336
+ const showTriggerAmount = createMemo(
337
+ () => previewDiscount() > 0 && props.selected?.type !== "fixed_amount",
338
+ );
339
+
340
+ // Nothing priced yet: preview against the cheapest/priciest total the cart
341
+ // could still reach, so the row shows a real range instead of a bare zero.
342
+ const rangePreview = createMemo(() => {
343
+ const r = props.subtotalRange;
344
+ if (props.subtotal > 0 || !r || r.max <= 0) return null;
345
+ return r;
346
+ });
347
+
348
+ const discountLabel = (v: VoucherOption): string => {
349
+ const r = rangePreview();
350
+ if (!r) return `−${formatCurrency(calculateDiscount(v, props.subtotal))}`;
351
+ const lo = calculateDiscount(v, r.min);
352
+ const hi = calculateDiscount(v, r.max);
353
+ return lo === hi
354
+ ? `−${formatCurrency(hi)}`
355
+ : `−${formatCurrency(lo)} to ${formatCurrency(hi)}`;
356
+ };
357
+
358
+ // An ancestor may close itself on a document-level Escape; the dialog handles
359
+ // its own dismissal, so keep the key from reaching that listener.
360
+ const swallowEscape = (e: KeyboardEvent) => {
361
+ if (e.key === "Escape") e.stopPropagation();
362
+ };
363
+
364
+ createEffect(() => {
365
+ if (!open()) return;
366
+ document.addEventListener("keydown", swallowEscape, true);
367
+ onCleanup(() => document.removeEventListener("keydown", swallowEscape, true));
368
+ });
369
+
370
+ const metaLine = (v: VoucherOption): string =>
371
+ [formatVoucherDescription(v), formatExpiry(v.valid_until, today()), formatUsage(v)]
372
+ .filter(Boolean)
373
+ .join(" · ");
374
+
375
+ const expiresSoon = (v: VoucherOption): boolean => {
376
+ if (!v.valid_until) return false;
377
+ const days = daysUntil(toDay(v.valid_until), today());
378
+ return days >= 0 && days <= 7;
379
+ };
380
+
381
+ // Either scarcity signal warrants the amber treatment on the meta line.
382
+ const runningOut = (v: VoucherOption): boolean => expiresSoon(v) || nearlyUsedUp(v);
383
+
233
384
  return (
234
385
  <>
235
386
  <button
236
- ref={triggerRef}
237
387
  type="button"
238
388
  data-testid="voucher-picker-trigger"
239
389
  disabled={props.disabled}
240
- onClick={() => !props.disabled && setOpen((o) => !o)}
390
+ onClick={openPicker}
241
391
  class={`${props.compact ? "inline-flex" : "w-full flex"} items-center gap-2 ${
242
392
  props.compact ? "px-2.5 py-2" : "px-3 py-2.5"
243
393
  } rounded-lg bg-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_30%,transparent)] border border-[color-mix(in_srgb,var(--ks-border-strong,#3f3f46)_50%,transparent)] hover:border-[color-mix(in_srgb,var(--ks-primary,#c9a961)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_5%,transparent)] transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60`}
244
- aria-haspopup="listbox"
394
+ aria-haspopup="dialog"
245
395
  aria-expanded={open()}
246
396
  >
247
397
  <Ticket size={16} class="shrink-0 text-[var(--ks-fg-muted,#a1a1aa)]" />
@@ -250,7 +400,9 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
250
400
  <span class="block truncate text-[var(--ks-fg,#ffffff)] font-medium">{props.selected!.code}</span>
251
401
  <span class="block truncate text-[11px] text-[var(--ks-success-fg,#34d399)]">
252
402
  {formatVoucherDescription(props.selected!)}
253
- <Show when={previewDiscount() > 0}> · {formatCurrency(previewDiscount())} off</Show>
403
+ {/* A fixed-amount voucher already names the peso figure — appending
404
+ the computed one would just repeat it. */}
405
+ <Show when={showTriggerAmount()}> · {formatCurrency(previewDiscount())} off</Show>
254
406
  </span>
255
407
  </span>
256
408
  <button
@@ -267,31 +419,62 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
267
419
  </button>
268
420
 
269
421
  <Show when={open()}>
270
- <Portal mount={popoverMount()}>
271
- <div
272
- ref={(el) => {
273
- popupRef = el;
274
- onCleanup(useTopLayer(el));
275
- }}
276
- data-testid="voucher-picker-popup"
277
- class="z-[100] rounded-md border border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
278
- style={popupStyle()}
279
- >
280
- <div class="px-3 py-2 border-b border-[var(--ks-border-strong,#3f3f46)] flex items-center gap-2">
281
- <Ticket size={14} class="text-[var(--ks-fg-subtle,#71717a)] shrink-0" />
282
- <span class="text-xs uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-bold">Vouchers</span>
283
- <Show when={loading()}>
284
- <Loader2 size={14} class="animate-spin text-[var(--ks-fg-subtle,#71717a)] ml-auto shrink-0" />
422
+ <Modal onClose={close} size="xl" ariaLabel="Select a voucher">
423
+ <div data-testid="voucher-picker-popup" class="flex flex-col max-h-[70vh]">
424
+ {/* Bled to the card edges (the card owns the padding) so the rule
425
+ under the title spans the full width. */}
426
+ <div class="-mx-6 -mt-6 px-5 sm:px-6 py-3 border-b border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] flex items-center justify-between gap-3 shrink-0">
427
+ <div class="flex items-center gap-2 min-w-0">
428
+ <Ticket size={16} class="text-[var(--ks-fg-muted,#a1a1aa)] shrink-0" aria-hidden="true" />
429
+ <h2 class="m-0 text-base font-semibold text-[var(--ks-fg,#ffffff)] truncate">
430
+ Select a voucher
431
+ </h2>
432
+ <Show when={loading()}>
433
+ <Loader2 size={14} class="animate-spin text-[var(--ks-fg-subtle,#71717a)] shrink-0" />
434
+ </Show>
435
+ </div>
436
+ <button
437
+ type="button"
438
+ data-testid="voucher-picker-close"
439
+ onClick={close}
440
+ class="w-8 h-8 flex items-center justify-center rounded text-[var(--ks-fg-muted,#a1a1aa)] hover:text-[var(--ks-fg,#ffffff)] hover:bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] transition-colors cursor-pointer shrink-0"
441
+ aria-label="Close"
442
+ >
443
+ <X size={16} />
444
+ </button>
445
+ </div>
446
+
447
+ <div class="mt-4 shrink-0 flex items-center gap-2 px-3 py-2 rounded-lg border border-[color-mix(in_srgb,var(--ks-border-strong,#3f3f46)_60%,transparent)] bg-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_25%,transparent)] focus-within:border-[color-mix(in_srgb,var(--ks-primary,#c9a961)_50%,transparent)] transition-colors">
448
+ <Search size={16} class="shrink-0 text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
449
+ <input
450
+ type="text"
451
+ data-testid="voucher-picker-search"
452
+ value={query()}
453
+ onInput={(e) => setQuery(e.currentTarget.value)}
454
+ placeholder="Search voucher code…"
455
+ aria-label="Search voucher code"
456
+ class="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-[var(--ks-fg,#ffffff)] placeholder:text-[var(--ks-fg-subtle,#71717a)]"
457
+ />
458
+ <Show when={query() !== ""}>
459
+ <button
460
+ type="button"
461
+ onClick={() => setQuery("")}
462
+ class="shrink-0 p-0.5 rounded text-[var(--ks-fg-subtle,#71717a)] hover:text-[var(--ks-fg,#ffffff)] transition-colors cursor-pointer"
463
+ aria-label="Clear search"
464
+ >
465
+ <X size={14} />
466
+ </button>
285
467
  </Show>
286
468
  </div>
469
+
287
470
  <ul
288
471
  role="listbox"
289
472
  aria-label="Available vouchers"
290
- class="m-0 p-0 list-none flex-1 overflow-y-auto"
473
+ class="m-0 mt-3 p-0 list-none flex-1 overflow-y-auto -mx-1 px-1"
291
474
  >
292
475
  <Show when={error()}>
293
476
  <li>
294
- <div role="status" class="px-3 py-2 text-xs text-[var(--ks-danger-fg,#f87171)]">
477
+ <div role="status" class="px-3 py-3 text-sm text-[var(--ks-danger-fg,#f87171)]">
295
478
  {error()}
296
479
  </div>
297
480
  </li>
@@ -300,35 +483,51 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
300
483
  when={!loading() && !error() && applicable().length === 0 && inapplicable().length === 0}
301
484
  >
302
485
  <li>
303
- <div role="status" class="px-3 py-4 text-xs text-[var(--ks-fg-subtle,#71717a)] text-center">
304
- No vouchers available.
486
+ <div role="status" class="px-3 py-8 text-sm text-[var(--ks-fg-subtle,#71717a)] text-center">
487
+ <Show when={debouncedQuery() !== ""} fallback="No vouchers available.">
488
+ No voucher matches “{debouncedQuery()}”.
489
+ </Show>
305
490
  </div>
306
491
  </li>
307
492
  </Show>
308
493
  <For each={applicable()}>
309
494
  {(v) => {
310
- const discount = () => calculateDiscount(v, props.subtotal);
311
- const selected = () => props.selected?.id === v.id;
495
+ const selected = () => draft()?.id === v.id;
312
496
  return (
313
497
  <li role="option" aria-selected={selected()}>
314
498
  <button
315
499
  type="button"
316
500
  data-testid={`voucher-picker-result-${v.id}`}
317
- onClick={() => select(v)}
318
- class="w-full text-left px-3 py-2 hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_10%,transparent)] transition-colors flex items-start gap-2 cursor-pointer"
501
+ onClick={() => stage(v)}
502
+ class="w-full text-left px-3 py-3 mb-1 rounded-lg border transition-colors flex items-center gap-3 cursor-pointer border-[color-mix(in_srgb,var(--ks-border-strong,#3f3f46)_40%,transparent)] hover:border-[color-mix(in_srgb,var(--ks-primary,#c9a961)_50%,transparent)] hover:bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_8%,transparent)]"
503
+ classList={{
504
+ "border-[color-mix(in_srgb,var(--ks-primary,#c9a961)_60%,transparent)] bg-[color-mix(in_srgb,var(--ks-primary,#c9a961)_12%,transparent)]":
505
+ selected(),
506
+ }}
319
507
  >
320
- <Ticket size={14} class="shrink-0 mt-0.5 text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
508
+ <Ticket size={18} class="shrink-0 text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
321
509
  <span class="flex-1 min-w-0">
322
- <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
323
- <span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
324
- {formatVoucherDescription(v)}
510
+ <span class="block text-sm font-medium text-[var(--ks-fg,#ffffff)] truncate">
511
+ {highlightMatch(v.code, debouncedQuery())}
512
+ </span>
513
+ <span class="flex items-center gap-1 text-xs text-[var(--ks-fg-subtle,#71717a)] min-w-0">
514
+ <Show when={runningOut(v)}>
515
+ <CalendarClock
516
+ size={12}
517
+ class="shrink-0 text-[var(--ks-warning-fg,#fbbf24)]"
518
+ aria-hidden="true"
519
+ />
520
+ </Show>
521
+ <span class="truncate" classList={{ "text-[var(--ks-warning-fg,#fbbf24)]": runningOut(v) }}>
522
+ {metaLine(v)}
523
+ </span>
325
524
  </span>
326
525
  </span>
327
- <span class="text-xs text-[var(--ks-success-fg,#34d399)] shrink-0 mt-0.5 font-mono">
328
- {formatCurrency(discount())}
526
+ <span class="text-sm text-[var(--ks-success-fg,#34d399)] shrink-0 font-mono">
527
+ {discountLabel(v)}
329
528
  </span>
330
529
  <Show when={selected()}>
331
- <span class="text-[var(--ks-accent,#fbbf24)] shrink-0 mt-0.5">✓</span>
530
+ <span class="text-[var(--ks-accent,#fbbf24)] shrink-0" aria-hidden="true">✓</span>
332
531
  </Show>
333
532
  </button>
334
533
  </li>
@@ -337,46 +536,102 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
337
536
  </For>
338
537
  <Show when={inapplicable().length > 0}>
339
538
  <li>
340
- <div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-semibold border-t border-[var(--ks-border-strong,#3f3f46)] mt-1">
539
+ <div class="px-1 pt-3 pb-2 text-[11px] uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-semibold border-t border-[var(--ks-border-strong,#3f3f46)] mt-2">
341
540
  Not applicable to this cart
342
541
  </div>
343
542
  </li>
344
543
  <For each={inapplicable()}>
345
- {(v) => (
544
+ {(entry) => (
346
545
  <li>
347
546
  <div
348
- data-testid={`voucher-picker-inapplicable-${v.id}`}
349
- class="w-full text-left px-3 py-2 flex items-start gap-2 opacity-50 cursor-not-allowed"
547
+ data-testid={`voucher-picker-inapplicable-${entry.voucher.id}`}
548
+ class="w-full text-left px-3 py-3 mb-1 rounded-lg border border-transparent flex items-center gap-3 opacity-60 cursor-not-allowed"
350
549
  aria-disabled="true"
351
550
  >
352
- <Ticket size={14} class="shrink-0 mt-0.5 text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
551
+ <Ticket size={18} class="shrink-0 text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
353
552
  <span class="flex-1 min-w-0">
354
- <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
355
- <span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
356
- {formatVoucherDescription(v)}
553
+ <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">
554
+ {highlightMatch(entry.voucher.code, debouncedQuery())}
357
555
  </span>
556
+ <span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
557
+ {metaLine(entry.voucher)}
558
+ </span>
559
+ </span>
560
+ <span class="text-xs text-[var(--ks-warning-fg,#fbbf24)] shrink-0 text-right max-w-[45%] truncate">
561
+ {entry.reason}
358
562
  </span>
359
563
  </div>
360
564
  </li>
361
565
  )}
362
566
  </For>
363
567
  </Show>
568
+
569
+ {/* Sentinel: intersecting pulls the next page. */}
570
+ <Show when={hasMore()}>
571
+ <li>
572
+ <div
573
+ ref={setSentinel}
574
+ data-testid="voucher-picker-sentinel"
575
+ class="px-3 py-3 flex items-center justify-center gap-2 text-xs text-[var(--ks-fg-subtle,#71717a)]"
576
+ >
577
+ <Show when={loadingMore()} fallback={<span>Scroll for more</span>}>
578
+ <Loader2 size={14} class="animate-spin shrink-0" />
579
+ <span>Loading more…</span>
580
+ </Show>
581
+ </div>
582
+ </li>
583
+ </Show>
364
584
  </ul>
365
- <Show when={props.selected}>
366
- <div class="border-t border-[var(--ks-border-strong,#3f3f46)]">
585
+
586
+ {/* Bled to the card edges so the rule spans the full width, matching
587
+ the header. */}
588
+ <div class="-mx-6 -mb-6 mt-4 px-5 sm:px-6 py-3 border-t border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] flex items-center justify-between gap-3 shrink-0">
589
+ <span
590
+ data-testid="voucher-picker-draft-summary"
591
+ class="text-xs text-[var(--ks-fg-subtle,#71717a)] min-w-0 truncate"
592
+ >
593
+ <Show when={draft()} fallback="No voucher selected">
594
+ <span class="text-[var(--ks-fg,#ffffff)]">{draft()!.code}</span>
595
+ {" · "}
596
+ {discountLabel(draft()!).replace("−", "")} off
597
+ </Show>
598
+ </span>
599
+ <div class="flex items-center gap-2 shrink-0">
600
+ <Show when={props.selected && draft()}>
601
+ <button
602
+ type="button"
603
+ data-testid="voucher-picker-clear-from-list"
604
+ onClick={() => setDraft(null)}
605
+ class="px-3 py-2 rounded-lg text-sm text-[var(--ks-danger-fg,#f87171)] hover:bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] transition-colors cursor-pointer"
606
+ >
607
+ Remove
608
+ </button>
609
+ </Show>
367
610
  <button
368
611
  type="button"
369
- data-testid="voucher-picker-clear-from-list"
370
- onClick={() => select(null)}
371
- class="w-full text-left px-3 py-2 text-xs text-[var(--ks-danger-fg,#f87171)] hover:bg-[color-mix(in_srgb,var(--ks-danger,#ef4444)_10%,transparent)] transition-colors flex items-center gap-2 cursor-pointer"
612
+ data-testid="voucher-picker-cancel"
613
+ onClick={close}
614
+ class="px-3 py-2 rounded-lg text-sm text-[var(--ks-fg-muted,#a1a1aa)] hover:text-[var(--ks-fg,#ffffff)] hover:bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] transition-colors cursor-pointer"
372
615
  >
373
- <X size={12} />
374
- <span>Remove voucher</span>
616
+ Cancel
617
+ </button>
618
+ <button
619
+ type="button"
620
+ data-testid="voucher-picker-confirm"
621
+ onClick={confirm}
622
+ disabled={draft()?.id === props.selected?.id}
623
+ class="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--ks-primary,#c9a961)] text-[var(--ks-fg-on-accent,#0a0a0a)] hover:opacity-90 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
624
+ >
625
+ <Show when={props.selected} fallback="Apply voucher">
626
+ <Show when={draft()} fallback="Remove voucher">
627
+ Change voucher
628
+ </Show>
629
+ </Show>
375
630
  </button>
376
631
  </div>
377
- </Show>
632
+ </div>
378
633
  </div>
379
- </Portal>
634
+ </Modal>
380
635
  </Show>
381
636
  </>
382
637
  );