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