@kahitsan/ksui 0.3.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.
@@ -0,0 +1,312 @@
1
+ import { Portal } from "solid-js/web";
2
+ import {
3
+ createEffect,
4
+ createMemo,
5
+ createSignal,
6
+ For,
7
+ onCleanup,
8
+ onMount,
9
+ Show,
10
+ type JSX,
11
+ } from "solid-js";
12
+ import Banknote from "lucide-solid/icons/banknote";
13
+ import Loader2 from "lucide-solid/icons/loader-2";
14
+ import AccountAvatar from "./AccountAvatar";
15
+
16
+ export interface PaymentAccountOption {
17
+ id: number;
18
+ name: string;
19
+ type: string;
20
+ icon?: string | null;
21
+ color?: string | null;
22
+ s3_link?: string | null;
23
+ }
24
+
25
+ interface PaymentAccountPickerProps {
26
+ selected: PaymentAccountOption | null;
27
+ onChange: (next: PaymentAccountOption | null) => void;
28
+ /** Reports the loaded count back up so the modal can disable Charge when zero. */
29
+ onCountChange?: (count: number) => void;
30
+ disabled?: boolean;
31
+ }
32
+
33
+ const POPUP_MAX_HEIGHT = 360;
34
+ const POPUP_MIN_WIDTH = 320;
35
+
36
+ const TYPE_LABELS: Record<string, string> = {
37
+ bank: "Bank",
38
+ e_wallet: "E-Wallet",
39
+ cash: "Cash",
40
+ external: "External",
41
+ };
42
+
43
+ const TYPE_ORDER = ["cash", "e_wallet", "bank", "external"];
44
+
45
+ function groupAndSort(accounts: PaymentAccountOption[]): Array<[string, PaymentAccountOption[]]> {
46
+ const buckets = new Map<string, PaymentAccountOption[]>();
47
+ for (const a of accounts) {
48
+ const key = a.type ?? "external";
49
+ if (!buckets.has(key)) buckets.set(key, []);
50
+ buckets.get(key)!.push(a);
51
+ }
52
+ for (const list of buckets.values()) list.sort((a, b) => a.name.localeCompare(b.name));
53
+ // Preserve a deterministic order for the type sections so the picker
54
+ // doesn't shuffle between renders or between orgs with different account
55
+ // mixes. Unknown types append at the end.
56
+ const sections: Array<[string, PaymentAccountOption[]]> = [];
57
+ for (const t of TYPE_ORDER) {
58
+ const list = buckets.get(t);
59
+ if (list && list.length > 0) sections.push([t, list]);
60
+ }
61
+ for (const [t, list] of buckets.entries()) {
62
+ if (!TYPE_ORDER.includes(t)) sections.push([t, list]);
63
+ }
64
+ return sections;
65
+ }
66
+
67
+ export default function PaymentAccountPicker(props: PaymentAccountPickerProps): JSX.Element {
68
+ const [open, setOpen] = createSignal(false);
69
+ const [accounts, setAccounts] = createSignal<PaymentAccountOption[]>([]);
70
+ const [loading, setLoading] = createSignal(true);
71
+ const [error, setError] = createSignal<string | null>(null);
72
+ const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
73
+
74
+ let triggerRef: HTMLButtonElement | undefined;
75
+ let popupRef: HTMLDivElement | undefined;
76
+ let activeFetchToken = 0;
77
+
78
+ // Fetch once on mount and report the count to the parent. We don't refetch
79
+ // on every open since the account list rarely changes mid-cart and the
80
+ // count is also what governs whether Charge is allowed at all (zero
81
+ // accounts means the user can't pay into anything).
82
+ const reportCount = (n: number) => props.onCountChange?.(n);
83
+ const fetchAccounts = async () => {
84
+ const token = ++activeFetchToken;
85
+ setLoading(true);
86
+ setError(null);
87
+ try {
88
+ const r = await fetch("/api/financial-accounts?status=active&limit=200");
89
+ if (!r.ok) throw new Error(r.status === 403 ? "Permission denied" : "Failed to load");
90
+ const json = await r.json();
91
+ if (token !== activeFetchToken) return;
92
+ const list = (json.data || []) as PaymentAccountOption[];
93
+ setAccounts(list);
94
+ reportCount(list.length);
95
+ } catch (e) {
96
+ if (token !== activeFetchToken) return;
97
+ setError(e instanceof Error ? e.message : "Failed to load");
98
+ setAccounts([]);
99
+ reportCount(0);
100
+ } finally {
101
+ if (token === activeFetchToken) setLoading(false);
102
+ }
103
+ };
104
+
105
+ // Single fetch at mount — list rarely changes within a cart session,
106
+ // and Charge gating already depends on the count it reports up via
107
+ // onCountChange. If the picker ever needs to refetch on org switch,
108
+ // swap this for a createEffect tracking the active-org signal.
109
+ onMount(() => {
110
+ void fetchAccounts();
111
+ });
112
+
113
+ // Auto-select the first account once the list loads, so a single-account
114
+ // org doesn't have to tap to charge. Skips when something is already
115
+ // selected (preserves an explicit reset to null).
116
+ createEffect(() => {
117
+ if (props.selected) return;
118
+ if (loading()) return;
119
+ const first = accounts()[0];
120
+ if (first) props.onChange(first);
121
+ });
122
+
123
+ const sections = createMemo(() => groupAndSort(accounts()));
124
+ const noAccounts = () => !loading() && accounts().length === 0;
125
+
126
+ const updatePosition = () => {
127
+ if (!triggerRef) return;
128
+ const rect = triggerRef.getBoundingClientRect();
129
+ const vpHeight = window.innerHeight;
130
+ const vpWidth = window.innerWidth;
131
+ const width = Math.max(POPUP_MIN_WIDTH, rect.width);
132
+ const spaceBelow = vpHeight - rect.bottom;
133
+ const spaceAbove = rect.top;
134
+ const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
135
+ const top = flipUp ? Math.max(8, rect.top - POPUP_MAX_HEIGHT - 4) : rect.bottom + 4;
136
+ const maxHeight = Math.max(
137
+ 200,
138
+ Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
139
+ );
140
+ const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
141
+ setPopupStyle({
142
+ position: "fixed",
143
+ top: `${top}px`,
144
+ left: `${left}px`,
145
+ width: `${width}px`,
146
+ "max-height": `${maxHeight}px`,
147
+ });
148
+ };
149
+
150
+ createEffect(() => {
151
+ if (!open()) return;
152
+ updatePosition();
153
+
154
+ const onDocClick = (e: MouseEvent) => {
155
+ const t = e.target as Node;
156
+ if (triggerRef?.contains(t)) return;
157
+ if (popupRef?.contains(t)) return;
158
+ setOpen(false);
159
+ };
160
+ const onEsc = (e: KeyboardEvent) => {
161
+ if (e.key === "Escape") {
162
+ e.stopPropagation();
163
+ setOpen(false);
164
+ }
165
+ };
166
+ const onReflow = () => updatePosition();
167
+
168
+ document.addEventListener("mousedown", onDocClick);
169
+ document.addEventListener("keydown", onEsc, true);
170
+ window.addEventListener("resize", onReflow);
171
+ window.addEventListener("scroll", onReflow, true);
172
+ onCleanup(() => {
173
+ document.removeEventListener("mousedown", onDocClick);
174
+ document.removeEventListener("keydown", onEsc, true);
175
+ window.removeEventListener("resize", onReflow);
176
+ window.removeEventListener("scroll", onReflow, true);
177
+ });
178
+ });
179
+
180
+ const select = (a: PaymentAccountOption) => {
181
+ props.onChange(a);
182
+ setOpen(false);
183
+ };
184
+
185
+ const triggerDisabled = () => props.disabled || noAccounts();
186
+
187
+ return (
188
+ <>
189
+ <button
190
+ ref={triggerRef}
191
+ type="button"
192
+ data-testid="payment-account-picker-trigger"
193
+ disabled={triggerDisabled()}
194
+ onClick={() => !triggerDisabled() && setOpen((o) => !o)}
195
+ title={
196
+ noAccounts()
197
+ ? "Ask an admin to grant access to a financial account before charging."
198
+ : undefined
199
+ }
200
+ class="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-zinc-800/30 border border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5 transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
201
+ aria-haspopup="listbox"
202
+ aria-expanded={open()}
203
+ >
204
+ <Show when={loading()} fallback={null}>
205
+ <Loader2 size={14} class="animate-spin text-zinc-500 shrink-0" aria-hidden="true" />
206
+ </Show>
207
+ <Show when={!loading()}>
208
+ <Show
209
+ when={props.selected}
210
+ fallback={
211
+ <Show
212
+ when={noAccounts()}
213
+ fallback={
214
+ <>
215
+ <Banknote size={16} class="shrink-0 text-zinc-400" aria-hidden="true" />
216
+ <span class="text-zinc-500 italic">Choose payment account</span>
217
+ </>
218
+ }
219
+ >
220
+ <Banknote size={16} class="shrink-0 text-zinc-500" aria-hidden="true" />
221
+ <span class="text-zinc-500 italic">No accessible accounts</span>
222
+ </Show>
223
+ }
224
+ >
225
+ {(acct) => (
226
+ <>
227
+ <AccountAvatar account={acct()} size={28} />
228
+ <span class="flex-1 min-w-0">
229
+ <span class="block truncate text-zinc-100 font-medium">{acct().name}</span>
230
+ <span class="block truncate text-[11px] text-zinc-500">
231
+ {TYPE_LABELS[acct().type] ?? acct().type}
232
+ </span>
233
+ </span>
234
+ </>
235
+ )}
236
+ </Show>
237
+ </Show>
238
+ </button>
239
+
240
+ <Show when={open()}>
241
+ <Portal>
242
+ <div
243
+ ref={popupRef}
244
+ data-testid="payment-account-picker-popup"
245
+ class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
246
+ style={popupStyle()}
247
+ >
248
+ <div class="px-3 py-2 border-b border-zinc-800 flex items-center gap-2">
249
+ <Banknote size={14} class="text-zinc-500 shrink-0" aria-hidden="true" />
250
+ <span class="text-xs uppercase tracking-widest text-zinc-500 font-bold">
251
+ Payment account
252
+ </span>
253
+ <Show when={loading()}>
254
+ <Loader2 size={14} class="animate-spin text-zinc-500 ml-auto shrink-0" />
255
+ </Show>
256
+ </div>
257
+ <div class="flex-1 overflow-y-auto">
258
+ <Show when={error()}>
259
+ <div role="status" class="px-3 py-2 text-xs text-red-400">
260
+ {error()}
261
+ </div>
262
+ </Show>
263
+ <Show when={!loading() && !error() && accounts().length === 0}>
264
+ <div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
265
+ No accessible accounts. Ask an admin to grant access.
266
+ </div>
267
+ </Show>
268
+ <For each={sections()}>
269
+ {([type, list]) => (
270
+ <div>
271
+ <div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-zinc-600 font-semibold">
272
+ {TYPE_LABELS[type] ?? type}
273
+ </div>
274
+ <ul
275
+ role="listbox"
276
+ aria-label={`${TYPE_LABELS[type] ?? type} accounts`}
277
+ class="m-0 p-0 list-none"
278
+ >
279
+ <For each={list}>
280
+ {(a) => {
281
+ const selected = () => props.selected?.id === a.id;
282
+ return (
283
+ <li role="option" aria-selected={selected()}>
284
+ <button
285
+ type="button"
286
+ data-testid={`payment-account-picker-result-${a.id}`}
287
+ onClick={() => select(a)}
288
+ class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-center gap-2 cursor-pointer"
289
+ >
290
+ <AccountAvatar account={a} size={28} />
291
+ <span class="flex-1 min-w-0 text-sm text-zinc-100 truncate">
292
+ {a.name}
293
+ </span>
294
+ <Show when={selected()}>
295
+ <span class="text-amber-400 shrink-0">✓</span>
296
+ </Show>
297
+ </button>
298
+ </li>
299
+ );
300
+ }}
301
+ </For>
302
+ </ul>
303
+ </div>
304
+ )}
305
+ </For>
306
+ </div>
307
+ </div>
308
+ </Portal>
309
+ </Show>
310
+ </>
311
+ );
312
+ }
@@ -0,0 +1,369 @@
1
+ // Source: KahitSan/kserp src/components/VoucherPicker.tsx (vendored into the plugin remote).
2
+ //
3
+ // Cross-plugin picker: fetches the SIBLING vouchers plugin's public API at
4
+ // /api/vouchers and degrades gracefully — when the vouchers plugin isn't
5
+ // deployed the popup shows a "couldn't load" notice and the sale records with
6
+ // no voucher (the manual-discount field stays available).
7
+
8
+ import { Portal } from "solid-js/web";
9
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
10
+ import Ticket from "lucide-solid/icons/ticket";
11
+ import X from "lucide-solid/icons/x";
12
+ import Loader2 from "lucide-solid/icons/loader-2";
13
+
14
+ export interface VoucherOption {
15
+ id: number;
16
+ code: string;
17
+ type: "percentage" | "fixed_amount" | "free";
18
+ value: string | number | null;
19
+ max_discount_amount: string | number | null;
20
+ applicable_packages: number[] | null;
21
+ minimum_purchase: string | number;
22
+ valid_from: string | null;
23
+ valid_until: string | null;
24
+ is_active: boolean;
25
+ }
26
+
27
+ interface VoucherPickerProps {
28
+ selected: VoucherOption | null;
29
+ onChange: (next: VoucherOption | null) => void;
30
+ subtotal: number;
31
+ packageIds: number[];
32
+ disabled?: boolean;
33
+ compact?: boolean;
34
+ }
35
+
36
+ const POPUP_MAX_HEIGHT = 360;
37
+ const POPUP_MIN_WIDTH = 320;
38
+
39
+ function asNumber(v: string | number | null | undefined): number {
40
+ if (v == null) return 0;
41
+ return typeof v === "string" ? parseFloat(v) : v;
42
+ }
43
+
44
+ export function calculateDiscount(voucher: VoucherOption | null, subtotal: number): number {
45
+ if (!voucher || subtotal <= 0) return 0;
46
+ if (voucher.type === "free") return subtotal;
47
+ if (voucher.type === "fixed_amount") {
48
+ return Math.min(asNumber(voucher.value), subtotal);
49
+ }
50
+ if (voucher.type === "percentage") {
51
+ const raw = Math.round((subtotal * asNumber(voucher.value)) / 100);
52
+ const cap = voucher.max_discount_amount != null ? asNumber(voucher.max_discount_amount) : raw;
53
+ return Math.min(raw, cap);
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ function formatCurrency(amount: number): string {
59
+ return new Intl.NumberFormat("en-PH", { style: "currency", currency: "PHP" }).format(amount);
60
+ }
61
+
62
+ function isApplicable(
63
+ voucher: VoucherOption,
64
+ subtotal: number,
65
+ packageIds: number[],
66
+ todayIso: string,
67
+ ): boolean {
68
+ if (!voucher.is_active) return false;
69
+ if (voucher.valid_from && todayIso < voucher.valid_from) return false;
70
+ if (voucher.valid_until && todayIso > voucher.valid_until) return false;
71
+ if (asNumber(voucher.minimum_purchase) > subtotal) return false;
72
+ if (voucher.applicable_packages && voucher.applicable_packages.length > 0) {
73
+ if (packageIds.length === 0) return false;
74
+ const allowed = new Set(voucher.applicable_packages);
75
+ if (!packageIds.every((id) => allowed.has(id))) return false;
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function formatVoucherDescription(v: VoucherOption): string {
81
+ if (v.type === "free") return "Free of charge";
82
+ if (v.type === "fixed_amount") return `${formatCurrency(asNumber(v.value))} off`;
83
+ if (v.type === "percentage") {
84
+ const cap = v.max_discount_amount != null ? asNumber(v.max_discount_amount) : 0;
85
+ return cap > 0
86
+ ? `${asNumber(v.value)}% off (up to ${formatCurrency(cap)})`
87
+ : `${asNumber(v.value)}% off`;
88
+ }
89
+ return "";
90
+ }
91
+
92
+ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
93
+ const [open, setOpen] = createSignal(false);
94
+ const [vouchers, setVouchers] = createSignal<VoucherOption[]>([]);
95
+ const [loading, setLoading] = createSignal(false);
96
+ const [error, setError] = createSignal<string | null>(null);
97
+ const [popupStyle, setPopupStyle] = createSignal<JSX.CSSProperties>({});
98
+
99
+ let triggerRef: HTMLButtonElement | undefined;
100
+ let popupRef: HTMLDivElement | undefined;
101
+ let activeFetchToken = 0;
102
+
103
+ createEffect(() => {
104
+ if (!open()) return;
105
+ const token = ++activeFetchToken;
106
+ setLoading(true);
107
+ setError(null);
108
+ fetch("/api/vouchers?status=active&limit=200", { credentials: "include" })
109
+ .then((r) => {
110
+ if (!r.ok)
111
+ throw new Error(
112
+ r.status === 403
113
+ ? "Permission denied"
114
+ : r.status === 404
115
+ ? "Vouchers module isn't available"
116
+ : "Failed to load",
117
+ );
118
+ return r.json();
119
+ })
120
+ .then((json) => {
121
+ if (token !== activeFetchToken) return;
122
+ setVouchers((json.data || []) as VoucherOption[]);
123
+ })
124
+ .catch((e) => {
125
+ if (token !== activeFetchToken) return;
126
+ setError(e instanceof Error ? e.message : "Failed to load");
127
+ setVouchers([]);
128
+ })
129
+ .finally(() => {
130
+ if (token !== activeFetchToken) return;
131
+ setLoading(false);
132
+ });
133
+ });
134
+
135
+ const today = () => new Date().toISOString().slice(0, 10);
136
+
137
+ const applicable = createMemo(() => {
138
+ const today_ = today();
139
+ return vouchers().filter((v) => isApplicable(v, props.subtotal, props.packageIds, today_));
140
+ });
141
+
142
+ const inapplicable = createMemo(() => {
143
+ const today_ = today();
144
+ return vouchers().filter((v) => !isApplicable(v, props.subtotal, props.packageIds, today_));
145
+ });
146
+
147
+ const updatePosition = () => {
148
+ if (!triggerRef) return;
149
+ const rect = triggerRef.getBoundingClientRect();
150
+ const vpHeight = window.innerHeight;
151
+ const vpWidth = window.innerWidth;
152
+ const width = Math.max(POPUP_MIN_WIDTH, rect.width);
153
+ const spaceBelow = vpHeight - rect.bottom;
154
+ const spaceAbove = rect.top;
155
+ const flipUp = spaceBelow < POPUP_MAX_HEIGHT && spaceAbove > spaceBelow;
156
+ const maxHeight = Math.max(
157
+ 200,
158
+ Math.min(POPUP_MAX_HEIGHT, flipUp ? spaceAbove - 12 : spaceBelow - 12),
159
+ );
160
+ const left = Math.min(Math.max(8, rect.left), vpWidth - width - 8);
161
+ if (flipUp) {
162
+ setPopupStyle({
163
+ position: "fixed",
164
+ bottom: `${vpHeight - rect.top + 4}px`,
165
+ left: `${left}px`,
166
+ width: `${width}px`,
167
+ "max-height": `${maxHeight}px`,
168
+ });
169
+ } else {
170
+ setPopupStyle({
171
+ position: "fixed",
172
+ top: `${rect.bottom + 4}px`,
173
+ left: `${left}px`,
174
+ width: `${width}px`,
175
+ "max-height": `${maxHeight}px`,
176
+ });
177
+ }
178
+ };
179
+
180
+ createEffect(() => {
181
+ if (!open()) return;
182
+ updatePosition();
183
+
184
+ const onDocClick = (e: MouseEvent) => {
185
+ const t = e.target as Node;
186
+ if (triggerRef?.contains(t)) return;
187
+ if (popupRef?.contains(t)) return;
188
+ setOpen(false);
189
+ };
190
+ const onEsc = (e: KeyboardEvent) => {
191
+ if (e.key === "Escape") {
192
+ e.stopPropagation();
193
+ setOpen(false);
194
+ }
195
+ };
196
+ const onReflow = () => updatePosition();
197
+
198
+ document.addEventListener("mousedown", onDocClick);
199
+ document.addEventListener("keydown", onEsc, true);
200
+ window.addEventListener("resize", onReflow);
201
+ window.addEventListener("scroll", onReflow, true);
202
+ onCleanup(() => {
203
+ document.removeEventListener("mousedown", onDocClick);
204
+ document.removeEventListener("keydown", onEsc, true);
205
+ window.removeEventListener("resize", onReflow);
206
+ window.removeEventListener("scroll", onReflow, true);
207
+ });
208
+ });
209
+
210
+ const select = (v: VoucherOption | null) => {
211
+ props.onChange(v);
212
+ setOpen(false);
213
+ };
214
+
215
+ const clear = (e: MouseEvent) => {
216
+ e.stopPropagation();
217
+ props.onChange(null);
218
+ };
219
+
220
+ const previewDiscount = createMemo(() => calculateDiscount(props.selected, props.subtotal));
221
+
222
+ return (
223
+ <>
224
+ <button
225
+ ref={triggerRef}
226
+ type="button"
227
+ data-testid="voucher-picker-trigger"
228
+ disabled={props.disabled}
229
+ onClick={() => !props.disabled && setOpen((o) => !o)}
230
+ class={`${props.compact ? "inline-flex" : "w-full flex"} items-center gap-2 ${
231
+ props.compact ? "px-2.5 py-2" : "px-3 py-2.5"
232
+ } rounded-lg bg-zinc-800/30 border border-zinc-700/50 hover:border-amber-500/40 hover:bg-amber-500/5 transition-colors text-sm text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-60`}
233
+ aria-haspopup="listbox"
234
+ aria-expanded={open()}
235
+ >
236
+ <Ticket size={16} class="shrink-0 text-zinc-400" />
237
+ <Show when={props.selected} fallback={<span class="text-zinc-500 italic">No voucher</span>}>
238
+ <span class="flex-1 min-w-0">
239
+ <span class="block truncate text-zinc-100 font-medium">{props.selected!.code}</span>
240
+ <span class="block truncate text-[11px] text-emerald-400">
241
+ {formatVoucherDescription(props.selected!)}
242
+ <Show when={previewDiscount() > 0}> · {formatCurrency(previewDiscount())} off</Show>
243
+ </span>
244
+ </span>
245
+ <button
246
+ type="button"
247
+ data-testid="voucher-picker-clear"
248
+ onClick={clear}
249
+ class="shrink-0 p-1 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
250
+ title="Remove voucher"
251
+ aria-label="Remove voucher"
252
+ >
253
+ <X size={14} />
254
+ </button>
255
+ </Show>
256
+ </button>
257
+
258
+ <Show when={open()}>
259
+ <Portal>
260
+ <div
261
+ ref={popupRef}
262
+ data-testid="voucher-picker-popup"
263
+ class="z-[100] rounded-md border border-zinc-700 bg-zinc-900/95 backdrop-blur shadow-xl overflow-hidden flex flex-col"
264
+ style={popupStyle()}
265
+ >
266
+ <div class="px-3 py-2 border-b border-zinc-800 flex items-center gap-2">
267
+ <Ticket size={14} class="text-zinc-500 shrink-0" />
268
+ <span class="text-xs uppercase tracking-widest text-zinc-500 font-bold">Vouchers</span>
269
+ <Show when={loading()}>
270
+ <Loader2 size={14} class="animate-spin text-zinc-500 ml-auto shrink-0" />
271
+ </Show>
272
+ </div>
273
+ <ul
274
+ role="listbox"
275
+ aria-label="Available vouchers"
276
+ class="m-0 p-0 list-none flex-1 overflow-y-auto"
277
+ >
278
+ <Show when={error()}>
279
+ <li>
280
+ <div role="status" class="px-3 py-2 text-xs text-red-400">
281
+ {error()}
282
+ </div>
283
+ </li>
284
+ </Show>
285
+ <Show
286
+ when={!loading() && !error() && applicable().length === 0 && inapplicable().length === 0}
287
+ >
288
+ <li>
289
+ <div role="status" class="px-3 py-4 text-xs text-zinc-500 text-center">
290
+ No vouchers available.
291
+ </div>
292
+ </li>
293
+ </Show>
294
+ <For each={applicable()}>
295
+ {(v) => {
296
+ const discount = () => calculateDiscount(v, props.subtotal);
297
+ const selected = () => props.selected?.id === v.id;
298
+ return (
299
+ <li role="option" aria-selected={selected()}>
300
+ <button
301
+ type="button"
302
+ data-testid={`voucher-picker-result-${v.id}`}
303
+ onClick={() => select(v)}
304
+ class="w-full text-left px-3 py-2 hover:bg-amber-500/10 transition-colors flex items-start gap-2 cursor-pointer"
305
+ >
306
+ <Ticket size={14} class="shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
307
+ <span class="flex-1 min-w-0">
308
+ <span class="block text-sm text-zinc-100 truncate">{v.code}</span>
309
+ <span class="block text-[11px] text-zinc-500 truncate">
310
+ {formatVoucherDescription(v)}
311
+ </span>
312
+ </span>
313
+ <span class="text-xs text-emerald-300 shrink-0 mt-0.5 font-mono">
314
+ {formatCurrency(discount())}
315
+ </span>
316
+ <Show when={selected()}>
317
+ <span class="text-amber-400 shrink-0 mt-0.5">✓</span>
318
+ </Show>
319
+ </button>
320
+ </li>
321
+ );
322
+ }}
323
+ </For>
324
+ <Show when={inapplicable().length > 0}>
325
+ <li>
326
+ <div class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-widest text-zinc-600 font-semibold border-t border-zinc-800 mt-1">
327
+ Not applicable to this cart
328
+ </div>
329
+ </li>
330
+ <For each={inapplicable()}>
331
+ {(v) => (
332
+ <li>
333
+ <div
334
+ data-testid={`voucher-picker-inapplicable-${v.id}`}
335
+ class="w-full text-left px-3 py-2 flex items-start gap-2 opacity-50 cursor-not-allowed"
336
+ aria-disabled="true"
337
+ >
338
+ <Ticket size={14} class="shrink-0 mt-0.5 text-zinc-500" aria-hidden="true" />
339
+ <span class="flex-1 min-w-0">
340
+ <span class="block text-sm text-zinc-300 truncate">{v.code}</span>
341
+ <span class="block text-[11px] text-zinc-500 truncate">
342
+ {formatVoucherDescription(v)}
343
+ </span>
344
+ </span>
345
+ </div>
346
+ </li>
347
+ )}
348
+ </For>
349
+ </Show>
350
+ </ul>
351
+ <Show when={props.selected}>
352
+ <div class="border-t border-zinc-800">
353
+ <button
354
+ type="button"
355
+ data-testid="voucher-picker-clear-from-list"
356
+ onClick={() => select(null)}
357
+ class="w-full text-left px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors flex items-center gap-2 cursor-pointer"
358
+ >
359
+ <X size={12} />
360
+ <span>Remove voucher</span>
361
+ </button>
362
+ </div>
363
+ </Show>
364
+ </div>
365
+ </Portal>
366
+ </Show>
367
+ </>
368
+ );
369
+ }