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