@kahitsan/ksui 0.37.1 → 0.38.1

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