@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.
- package/package.json +1 -1
- package/src/components/base/AddAttachmentTile.tsx +8 -2
- package/src/components/base/BadgeSelect.tsx +8 -2
- package/src/components/base/DatePicker.test.tsx +58 -1
- package/src/components/base/DatePicker.tsx +36 -3
- package/src/components/base/Modal.tsx +9 -1
- package/src/components/composite/ComboBox.tsx +15 -5
- package/src/components/composite/MentionTextarea.tsx +8 -2
- package/src/components/composite/PaymentAccountPicker.tsx +8 -2
- package/src/components/composite/SearchableSelect.tsx +8 -2
- package/src/components/composite/VoucherPicker.test.tsx +341 -21
- package/src/components/composite/VoucherPicker.tsx +396 -135
- package/src/utils/modal-layer.ts +28 -0
- package/src/utils/top-layer.ts +66 -0
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
):
|
|
76
|
-
if (!voucher.is_active) return
|
|
77
|
-
if (voucher.valid_from && todayIso < voucher.valid_from)
|
|
78
|
-
|
|
79
|
-
if (
|
|
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
|
|
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
|
|
120
|
+
if (!packageIds.every((id) => allowed.has(id))) return "Doesn't cover every item";
|
|
84
121
|
}
|
|
85
|
-
return
|
|
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 [
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
209
|
+
if (append) setLoadingMore(true);
|
|
210
|
+
else setLoading(true);
|
|
115
211
|
setError(null);
|
|
116
|
-
fetch(
|
|
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
|
-
|
|
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()
|
|
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
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
219
|
-
props.onChange(
|
|
220
|
-
|
|
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={
|
|
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="
|
|
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
|
-
|
|
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
|
-
<
|
|
268
|
-
<div
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
class="
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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={() =>
|
|
312
|
-
class="w-full text-left px-3 py-
|
|
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={
|
|
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">
|
|
317
|
-
|
|
318
|
-
|
|
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-
|
|
322
|
-
{
|
|
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
|
|
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-
|
|
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
|
-
{(
|
|
544
|
+
{(entry) => (
|
|
340
545
|
<li>
|
|
341
546
|
<div
|
|
342
|
-
data-testid={`voucher-picker-inapplicable-${
|
|
343
|
-
class="w-full text-left px-3 py-
|
|
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={
|
|
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">
|
|
349
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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-
|
|
364
|
-
onClick={
|
|
365
|
-
class="
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
</
|
|
632
|
+
</div>
|
|
372
633
|
</div>
|
|
373
|
-
</
|
|
634
|
+
</Modal>
|
|
374
635
|
</Show>
|
|
375
636
|
</>
|
|
376
637
|
);
|