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