@kahitsan/ksui 0.37.0 → 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.
@@ -1,17 +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";
10
+ import { Modal } from "../base/Modal";
11
+ import { highlightMatch } from "../../utils/highlight";
11
12
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
12
13
  import Ticket from "lucide-solid/icons/ticket";
13
14
  import X from "lucide-solid/icons/x";
15
+ import Search from "lucide-solid/icons/search";
14
16
  import Loader2 from "lucide-solid/icons/loader-2";
17
+ import CalendarClock from "lucide-solid/icons/calendar-clock";
15
18
 
16
19
  export interface VoucherOption {
17
20
  id: number;
@@ -24,6 +27,10 @@ export interface VoucherOption {
24
27
  valid_from: string | null;
25
28
  valid_until: string | null;
26
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;
27
34
  }
28
35
 
29
36
  const DEFAULT_FETCH_URL = "/api/vouchers?status=active&limit=200";
@@ -39,11 +46,12 @@ interface VoucherPickerProps {
39
46
  * a consumer with no `vouchers.view` grant can point this at a peer proxy
40
47
  * route instead. */
41
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 };
42
53
  }
43
54
 
44
- const POPUP_MAX_HEIGHT = 360;
45
- const POPUP_MIN_WIDTH = 320;
46
-
47
55
  function asNumber(v: string | number | null | undefined): number {
48
56
  if (v == null) return 0;
49
57
  return typeof v === "string" ? parseFloat(v) : v;
@@ -67,22 +75,61 @@ function formatCurrency(amount: number): string {
67
75
  return new Intl.NumberFormat("en-PH", { style: "currency", currency: "PHP" }).format(amount);
68
76
  }
69
77
 
70
- 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(
71
102
  voucher: VoucherOption,
72
103
  subtotal: number,
73
104
  packageIds: number[],
74
105
  todayIso: string,
75
- ): boolean {
76
- if (!voucher.is_active) return false;
77
- if (voucher.valid_from && todayIso < voucher.valid_from) return false;
78
- if (voucher.valid_until && todayIso > voucher.valid_until) return false;
79
- 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`;
80
117
  if (voucher.applicable_packages && voucher.applicable_packages.length > 0) {
81
- if (packageIds.length === 0) return false;
118
+ if (packageIds.length === 0) return "Only for specific items";
82
119
  const allowed = new Set(voucher.applicable_packages);
83
- if (!packageIds.every((id) => allowed.has(id))) return false;
120
+ if (!packageIds.every((id) => allowed.has(id))) return "Doesn't cover every item";
84
121
  }
85
- 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;
86
133
  }
87
134
 
88
135
  function formatVoucherDescription(v: VoucherOption): string {
@@ -97,23 +144,72 @@ function formatVoucherDescription(v: VoucherOption): string {
97
144
  return "";
98
145
  }
99
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
+
100
174
  export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
101
175
  const [open, setOpen] = createSignal(false);
102
176
  const [vouchers, setVouchers] = createSignal<VoucherOption[]>([]);
103
177
  const [loading, setLoading] = createSignal(false);
178
+ const [loadingMore, setLoadingMore] = createSignal(false);
104
179
  const [error, setError] = createSignal<string | null>(null);
105
- 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>();
106
190
 
107
- let triggerRef: HTMLButtonElement | undefined;
108
- let popupRef: HTMLDivElement | undefined;
109
191
  let activeFetchToken = 0;
192
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
110
193
 
111
- createEffect(() => {
112
- 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) => {
113
208
  const token = ++activeFetchToken;
114
- setLoading(true);
209
+ if (append) setLoadingMore(true);
210
+ else setLoading(true);
115
211
  setError(null);
116
- fetch(props.fetchUrl ?? DEFAULT_FETCH_URL, { credentials: "include" })
212
+ fetch(pageUrl(p, search), { credentials: "include" })
117
213
  .then((r) => {
118
214
  if (!r.ok)
119
215
  throw new Error(
@@ -127,17 +223,64 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
127
223
  })
128
224
  .then((json) => {
129
225
  if (token !== activeFetchToken) return;
130
- 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));
131
230
  })
132
231
  .catch((e) => {
133
232
  if (token !== activeFetchToken) return;
134
233
  setError(e instanceof Error ? e.message : "Failed to load");
135
- setVouchers([]);
234
+ if (!append) setVouchers([]);
136
235
  })
137
236
  .finally(() => {
138
237
  if (token !== activeFetchToken) return;
139
238
  setLoading(false);
239
+ setLoadingMore(false);
140
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());
141
284
  });
142
285
 
143
286
  const today = () => new Date().toISOString().slice(0, 10);
@@ -149,75 +292,36 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
149
292
 
150
293
  const inapplicable = createMemo(() => {
151
294
  const today_ = today();
152
- 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
+ }));
153
301
  });
154
302
 
155
- const updatePosition = () => {
156
- if (!triggerRef) return;
157
- const rect = triggerRef.getBoundingClientRect();
158
- const vpHeight = window.innerHeight;
159
- const vpWidth = window.innerWidth;
160
- const width = Math.max(POPUP_MIN_WIDTH, rect.width);
161
- const spaceBelow = vpHeight - rect.bottom;
162
- const spaceAbove = rect.top;
163
- const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
164
- const maxHeight = Math.max(
165
- 200,
166
- Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
167
- );
168
- const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
169
- if (flipUp) {
170
- setPopupStyle({
171
- position: "fixed",
172
- bottom: `${vpHeight - rect.top + 4}px`,
173
- left: `${left}px`,
174
- width: `${width}px`,
175
- "max-height": `${maxHeight}px`,
176
- });
177
- } else {
178
- setPopupStyle({
179
- position: "fixed",
180
- top: `${rect.bottom + 4}px`,
181
- left: `${left}px`,
182
- width: `${width}px`,
183
- "max-height": `${maxHeight}px`,
184
- });
185
- }
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);
186
312
  };
187
313
 
188
- createEffect(() => {
189
- if (!open()) return;
190
- updatePosition();
191
-
192
- const onDocClick = (e: MouseEvent) => {
193
- const t = e.target as Node;
194
- if (triggerRef?.contains(t)) return;
195
- if (popupRef?.contains(t)) return;
196
- setOpen(false);
197
- };
198
- const onEsc = (e: KeyboardEvent) => {
199
- if (e.key === "Escape") {
200
- e.stopPropagation();
201
- setOpen(false);
202
- }
203
- };
204
- const onReflow = () => updatePosition();
205
-
206
- document.addEventListener("mousedown", onDocClick);
207
- document.addEventListener("keydown", onEsc, true);
208
- window.addEventListener("resize", onReflow);
209
- window.addEventListener("scroll", onReflow, true);
210
- onCleanup(() => {
211
- document.removeEventListener("mousedown", onDocClick);
212
- document.removeEventListener("keydown", onEsc, true);
213
- window.removeEventListener("resize", onReflow);
214
- window.removeEventListener("scroll", onReflow, true);
215
- });
216
- });
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
+ };
217
321
 
218
- const select = (v: VoucherOption | null) => {
219
- props.onChange(v);
220
- setOpen(false);
322
+ const confirm = () => {
323
+ props.onChange(draft());
324
+ close();
221
325
  };
222
326
 
223
327
  const clear = (e: MouseEvent) => {
@@ -227,18 +331,67 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
227
331
 
228
332
  const previewDiscount = createMemo(() => calculateDiscount(props.selected, props.subtotal));
229
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
+
230
384
  return (
231
385
  <>
232
386
  <button
233
- ref={triggerRef}
234
387
  type="button"
235
388
  data-testid="voucher-picker-trigger"
236
389
  disabled={props.disabled}
237
- onClick={() => !props.disabled && setOpen((o) => !o)}
390
+ onClick={openPicker}
238
391
  class={`${props.compact ? "inline-flex" : "w-full flex"} items-center gap-2 ${
239
392
  props.compact ? "px-2.5 py-2" : "px-3 py-2.5"
240
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`}
241
- aria-haspopup="listbox"
394
+ aria-haspopup="dialog"
242
395
  aria-expanded={open()}
243
396
  >
244
397
  <Ticket size={16} class="shrink-0 text-[var(--ks-fg-muted,#a1a1aa)]" />
@@ -247,7 +400,9 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
247
400
  <span class="block truncate text-[var(--ks-fg,#ffffff)] font-medium">{props.selected!.code}</span>
248
401
  <span class="block truncate text-[11px] text-[var(--ks-success-fg,#34d399)]">
249
402
  {formatVoucherDescription(props.selected!)}
250
- <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>
251
406
  </span>
252
407
  </span>
253
408
  <button
@@ -264,28 +419,62 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
264
419
  </button>
265
420
 
266
421
  <Show when={open()}>
267
- <Portal>
268
- <div
269
- ref={popupRef}
270
- data-testid="voucher-picker-popup"
271
- 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"
272
- style={popupStyle()}
273
- >
274
- <div class="px-3 py-2 border-b border-[var(--ks-border-strong,#3f3f46)] flex items-center gap-2">
275
- <Ticket size={14} class="text-[var(--ks-fg-subtle,#71717a)] shrink-0" />
276
- <span class="text-xs uppercase tracking-widest text-[var(--ks-fg-subtle,#71717a)] font-bold">Vouchers</span>
277
- <Show when={loading()}>
278
- <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>
279
467
  </Show>
280
468
  </div>
469
+
281
470
  <ul
282
471
  role="listbox"
283
472
  aria-label="Available vouchers"
284
- 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"
285
474
  >
286
475
  <Show when={error()}>
287
476
  <li>
288
- <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)]">
289
478
  {error()}
290
479
  </div>
291
480
  </li>
@@ -294,35 +483,51 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
294
483
  when={!loading() && !error() && applicable().length === 0 && inapplicable().length === 0}
295
484
  >
296
485
  <li>
297
- <div role="status" class="px-3 py-4 text-xs text-[var(--ks-fg-subtle,#71717a)] text-center">
298
- 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>
299
490
  </div>
300
491
  </li>
301
492
  </Show>
302
493
  <For each={applicable()}>
303
494
  {(v) => {
304
- const discount = () => calculateDiscount(v, props.subtotal);
305
- const selected = () => props.selected?.id === v.id;
495
+ const selected = () => draft()?.id === v.id;
306
496
  return (
307
497
  <li role="option" aria-selected={selected()}>
308
498
  <button
309
499
  type="button"
310
500
  data-testid={`voucher-picker-result-${v.id}`}
311
- onClick={() => select(v)}
312
- 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
+ }}
313
507
  >
314
- <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" />
315
509
  <span class="flex-1 min-w-0">
316
- <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
317
- <span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
318
- {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>
319
524
  </span>
320
525
  </span>
321
- <span class="text-xs text-[var(--ks-success-fg,#34d399)] shrink-0 mt-0.5 font-mono">
322
- {formatCurrency(discount())}
526
+ <span class="text-sm text-[var(--ks-success-fg,#34d399)] shrink-0 font-mono">
527
+ {discountLabel(v)}
323
528
  </span>
324
529
  <Show when={selected()}>
325
- <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>
326
531
  </Show>
327
532
  </button>
328
533
  </li>
@@ -331,46 +536,102 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
331
536
  </For>
332
537
  <Show when={inapplicable().length > 0}>
333
538
  <li>
334
- <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">
335
540
  Not applicable to this cart
336
541
  </div>
337
542
  </li>
338
543
  <For each={inapplicable()}>
339
- {(v) => (
544
+ {(entry) => (
340
545
  <li>
341
546
  <div
342
- data-testid={`voucher-picker-inapplicable-${v.id}`}
343
- 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"
344
549
  aria-disabled="true"
345
550
  >
346
- <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" />
347
552
  <span class="flex-1 min-w-0">
348
- <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">{v.code}</span>
349
- <span class="block text-[11px] text-[var(--ks-fg-subtle,#71717a)] truncate">
350
- {formatVoucherDescription(v)}
553
+ <span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">
554
+ {highlightMatch(entry.voucher.code, debouncedQuery())}
351
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}
352
562
  </span>
353
563
  </div>
354
564
  </li>
355
565
  )}
356
566
  </For>
357
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>
358
584
  </ul>
359
- <Show when={props.selected}>
360
- <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>
361
610
  <button
362
611
  type="button"
363
- data-testid="voucher-picker-clear-from-list"
364
- onClick={() => select(null)}
365
- 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"
366
615
  >
367
- <X size={12} />
368
- <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>
369
630
  </button>
370
631
  </div>
371
- </Show>
632
+ </div>
372
633
  </div>
373
- </Portal>
634
+ </Modal>
374
635
  </Show>
375
636
  </>
376
637
  );