@shoppexio/storefront-react 0.0.0 → 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/account.css +1171 -0
- package/dist/account.d.ts +23 -0
- package/dist/account.js +12685 -0
- package/dist/account.js.map +1 -0
- package/dist/chunk-D6XKDVAV.js +181 -0
- package/dist/chunk-D6XKDVAV.js.map +1 -0
- package/dist/index.d.ts +3087 -0
- package/dist/index.js +2801 -0
- package/dist/index.js.map +1 -0
- package/eject/account/account-availability.ts +171 -0
- package/eject/account/account-downloads.ts +118 -0
- package/eject/account/account-favorites.ts +99 -0
- package/eject/account/account-loyalty.ts +241 -0
- package/eject/account/account-page.tsx +410 -0
- package/eject/account/account-payment-method.ts +99 -0
- package/eject/account/account-referrals.ts +68 -0
- package/eject/account/account-reseller-invite.ts +88 -0
- package/eject/account/account-reseller.ts +685 -0
- package/eject/account/account-rewards.ts +104 -0
- package/eject/account/account-settings.ts +181 -0
- package/eject/account/account-signin-link.ts +122 -0
- package/eject/account/account-subscriptions.ts +130 -0
- package/eject/account/account-support.ts +155 -0
- package/eject/account/account-warranties.ts +118 -0
- package/eject/account/customer-account.ts +93 -0
- package/eject/account/dashboard.tsx +139 -0
- package/eject/account/downloads.tsx +343 -0
- package/eject/account/favorites.tsx +201 -0
- package/eject/account/i18n.ts +175 -0
- package/eject/account/icons.tsx +218 -0
- package/eject/account/login.tsx +253 -0
- package/eject/account/messages/ar.json +464 -0
- package/eject/account/messages/de.json +464 -0
- package/eject/account/messages/en.json +464 -0
- package/eject/account/messages/es.json +464 -0
- package/eject/account/messages/fi.json +464 -0
- package/eject/account/messages/fr.json +464 -0
- package/eject/account/messages/hi.json +464 -0
- package/eject/account/messages/it.json +464 -0
- package/eject/account/messages/lt.json +464 -0
- package/eject/account/messages/nl.json +464 -0
- package/eject/account/messages/pl.json +464 -0
- package/eject/account/messages/pt.json +464 -0
- package/eject/account/messages/ru.json +464 -0
- package/eject/account/messages/tr.json +464 -0
- package/eject/account/messages/uk.json +464 -0
- package/eject/account/messages/zh.json +464 -0
- package/eject/account/orders.tsx +791 -0
- package/eject/account/overview.tsx +147 -0
- package/eject/account/referrals.tsx +327 -0
- package/eject/account/reseller.tsx +1365 -0
- package/eject/account/rewards.tsx +641 -0
- package/eject/account/settings.tsx +699 -0
- package/eject/account/shared.tsx +398 -0
- package/eject/account/subscriptions.tsx +421 -0
- package/eject/account/support.tsx +453 -0
- package/package.json +79 -1
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pieces every account panel needs.
|
|
3
|
+
*
|
|
4
|
+
* Every class here is an `sxa:` utility compiled into `account.css`, and every
|
|
5
|
+
* value those utilities read is one of the host theme's `--sx-*` custom
|
|
6
|
+
* properties: cards are `rounded-input border bg-surface shadow-card`, inner
|
|
7
|
+
* tiles `bg-bg`, secondary text `text-muted`. The account has no palette of its
|
|
8
|
+
* own. The `sx-account-*` classes carry no styles; they are the hooks a theme
|
|
9
|
+
* restyles through.
|
|
10
|
+
*
|
|
11
|
+
* Loading, empty and failed all render something a buyer can act on, and none
|
|
12
|
+
* of them invents a value the producer did not send.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
16
|
+
import { Link } from 'react-router-dom';
|
|
17
|
+
import { toPanelState, type AccountPanelState } from './customer-account';
|
|
18
|
+
import type { SdkResponse } from '@shoppexio/storefront-react';
|
|
19
|
+
import { getActiveLocale, t, tOptional } from './i18n';
|
|
20
|
+
import {
|
|
21
|
+
ArrowLeftIcon,
|
|
22
|
+
CheckIcon,
|
|
23
|
+
CircleAlertIcon,
|
|
24
|
+
CopyIcon,
|
|
25
|
+
LoaderIcon,
|
|
26
|
+
} from './icons';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Loads one panel's data and keeps the three states apart.
|
|
30
|
+
*
|
|
31
|
+
* `key` is what the load depends on: the page number for the order list, the
|
|
32
|
+
* order id for a detail view. A response that arrives after the key changed is
|
|
33
|
+
* dropped, so a slow request cannot overwrite the panel the buyer is now on.
|
|
34
|
+
*/
|
|
35
|
+
export function useAccountResource<T>(
|
|
36
|
+
key: string,
|
|
37
|
+
load: () => Promise<SdkResponse<T>>,
|
|
38
|
+
options: {
|
|
39
|
+
/**
|
|
40
|
+
* Keep ready data when a same-key re-read fails, carrying the error as
|
|
41
|
+
* `staleMessage` — ONLY for consumers that render that field (the points
|
|
42
|
+
* section, and the shell read it revalidates). Everything else keeps the
|
|
43
|
+
* plain contract: a failed re-read is an error state with a retry, shown
|
|
44
|
+
* where ready data was.
|
|
45
|
+
*/
|
|
46
|
+
keepStale?: boolean;
|
|
47
|
+
} = {},
|
|
48
|
+
): { state: AccountPanelState<T>; reload: () => void } {
|
|
49
|
+
const [state, setState] = useState<AccountPanelState<T>>({ status: 'loading' });
|
|
50
|
+
const [attempt, setAttempt] = useState(0);
|
|
51
|
+
const loadRef = useRef(load);
|
|
52
|
+
loadRef.current = load;
|
|
53
|
+
const keepStaleRef = useRef(options.keepStale === true);
|
|
54
|
+
keepStaleRef.current = options.keepStale === true;
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Back to loading DURING the render that changed the key, not in the effect
|
|
58
|
+
* afterwards.
|
|
59
|
+
*
|
|
60
|
+
* An effect runs after the render commits, so for one render this hook still
|
|
61
|
+
* handed out the PREVIOUS key's finished data. A panel mounted on it, started
|
|
62
|
+
* its own nested reads, was torn down when the effect finally set loading,
|
|
63
|
+
* and started every one of those reads again when the new data arrived — five
|
|
64
|
+
* duplicated requests on the wholesale panel. React's documented way to reset
|
|
65
|
+
* state when an input changes is to do it while rendering, which is what this
|
|
66
|
+
* is; the extra render it schedules happens before anything is shown.
|
|
67
|
+
*
|
|
68
|
+
* A bumped ATTEMPT on the same key is a different thing and is not reset: it
|
|
69
|
+
* re-reads the subject already on screen. Blanking finished data for the
|
|
70
|
+
* length of that read empties the panel one frame after the buyer acted on
|
|
71
|
+
* it — the points section taking its own "credited" notice down with it — so
|
|
72
|
+
* ready data stays until the new answer replaces it. Only a key change, or a
|
|
73
|
+
* re-read of something that never finished, goes back to loading.
|
|
74
|
+
*/
|
|
75
|
+
const token = `${key}::${attempt}`;
|
|
76
|
+
const [rendered, setRendered] = useState({ token, key });
|
|
77
|
+
if (token !== rendered.token) {
|
|
78
|
+
setRendered({ token, key });
|
|
79
|
+
if (key !== rendered.key || state.status !== 'ready') setState({ status: 'loading' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let active = true;
|
|
84
|
+
|
|
85
|
+
loadRef.current()
|
|
86
|
+
.then((response) => {
|
|
87
|
+
if (!active) return;
|
|
88
|
+
// A failed RE-read keeps what the buyer is looking at — ready data
|
|
89
|
+
// only exists here for a same-key revalidation (a key change went back
|
|
90
|
+
// to loading above), and replacing it with an error tears down the
|
|
91
|
+
// panel that just acted. But it does not stay silent: the retained
|
|
92
|
+
// figures are marked stale, so the panel can say they may be out of
|
|
93
|
+
// date. The first read of a key still surfaces its error normally.
|
|
94
|
+
setState((previous) => {
|
|
95
|
+
const next = toPanelState(response);
|
|
96
|
+
if (next.status === 'error' && previous.status === 'ready') {
|
|
97
|
+
return keepStaleRef.current ? { ...previous, staleMessage: next.message } : next;
|
|
98
|
+
}
|
|
99
|
+
return next;
|
|
100
|
+
});
|
|
101
|
+
})
|
|
102
|
+
.catch((error: unknown) => {
|
|
103
|
+
if (!active) return;
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
setState((previous) => (previous.status === 'ready' && keepStaleRef.current
|
|
106
|
+
? { ...previous, staleMessage: message }
|
|
107
|
+
: { status: 'error', message }));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
active = false;
|
|
112
|
+
};
|
|
113
|
+
}, [token]);
|
|
114
|
+
|
|
115
|
+
return { state, reload: useCallback(() => setAttempt((value) => value + 1), []) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* An invoice amount, in the currency the buyer sees.
|
|
120
|
+
*
|
|
121
|
+
* `currency` is the BUYER display currency and `total` is the USD-normalized
|
|
122
|
+
* aggregate, so the two are not a pair. `totalDisplay` is the same total in
|
|
123
|
+
* that currency and is what to render; the producer documents
|
|
124
|
+
* `total_display ?? total` as the rule, and this is the only place the template
|
|
125
|
+
* applies it.
|
|
126
|
+
*/
|
|
127
|
+
export function formatInvoiceAmount(
|
|
128
|
+
displayAmount: string | null,
|
|
129
|
+
normalizedAmount: string,
|
|
130
|
+
currency: string,
|
|
131
|
+
): string {
|
|
132
|
+
return formatAccountAmount(displayAmount ?? normalizedAmount, currency);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatAccountAmount(amount: string, currency: string): string {
|
|
136
|
+
const value = Number(amount);
|
|
137
|
+
if (!Number.isFinite(value)) return amount;
|
|
138
|
+
try {
|
|
139
|
+
return new Intl.NumberFormat(getActiveLocale(), {
|
|
140
|
+
style: 'currency',
|
|
141
|
+
currency: currency || 'USD',
|
|
142
|
+
}).format(value);
|
|
143
|
+
} catch {
|
|
144
|
+
// An unknown currency code is the shop's, not ours to correct.
|
|
145
|
+
return `${amount} ${currency}`.trim();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatAccountDate(value: string | Date): string {
|
|
150
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
151
|
+
if (Number.isNaN(date.getTime())) return typeof value === 'string' ? value : '';
|
|
152
|
+
return new Intl.DateTimeFormat(getActiveLocale(), {
|
|
153
|
+
year: 'numeric',
|
|
154
|
+
month: 'short',
|
|
155
|
+
day: 'numeric',
|
|
156
|
+
}).format(date);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatAccountDateTime(value: string | Date): string {
|
|
160
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
161
|
+
if (Number.isNaN(date.getTime())) return typeof value === 'string' ? value : '';
|
|
162
|
+
return new Intl.DateTimeFormat(getActiveLocale(), {
|
|
163
|
+
dateStyle: 'medium',
|
|
164
|
+
timeStyle: 'short',
|
|
165
|
+
}).format(date);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A producer status as a word.
|
|
170
|
+
*
|
|
171
|
+
* `tOptional` first: a status the catalog names is shown in the buyer's
|
|
172
|
+
* language, and one it does not is shown as the producer spelled it rather than
|
|
173
|
+
* as a guess. New statuses appear upstream before they appear here.
|
|
174
|
+
*
|
|
175
|
+
* The catalog is keyed by the producer's own spelling — `status.COMPLETED`,
|
|
176
|
+
* uppercase — so the lookup normalizes UP. Lowercasing it missed every key in
|
|
177
|
+
* every one of the 16 locales and quietly served `humanizeStatus`, i.e. English
|
|
178
|
+
* word-splitting, to buyers reading the shop in another language.
|
|
179
|
+
*/
|
|
180
|
+
export function statusLabel(status: string): string {
|
|
181
|
+
return tOptional(`status.${status.trim().toUpperCase()}`) ?? humanizeStatus(status);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function humanizeStatus(status: string): string {
|
|
185
|
+
return status
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
.split('_')
|
|
188
|
+
.map((word) => (word ? word[0]!.toUpperCase() + word.slice(1) : word))
|
|
189
|
+
.join(' ');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The tone table. Anything unlisted is neutral rather than green.
|
|
194
|
+
*
|
|
195
|
+
* The chip tones are the catalog's stock-level tones. They read as
|
|
196
|
+
* stock levels because the catalog's chips are where they were introduced;
|
|
197
|
+
* there is no separate success/warning/danger trio to reach for, and inventing
|
|
198
|
+
* one would leave the storefront with two chip systems.
|
|
199
|
+
*/
|
|
200
|
+
const TONE_GOOD = 'sxa:meta-chip--in_stock';
|
|
201
|
+
const TONE_WAITING = 'sxa:meta-chip--low_stock';
|
|
202
|
+
const TONE_BAD = 'sxa:meta-chip--sold_out';
|
|
203
|
+
|
|
204
|
+
const STATUS_TONES: Record<string, string> = {
|
|
205
|
+
// Done, and nothing is owed either way.
|
|
206
|
+
COMPLETED: TONE_GOOD,
|
|
207
|
+
PAID: TONE_GOOD,
|
|
208
|
+
ACTIVE: TONE_GOOD,
|
|
209
|
+
APPROVED: TONE_GOOD,
|
|
210
|
+
RESOLVED: TONE_GOOD,
|
|
211
|
+
CONVERTED: TONE_GOOD,
|
|
212
|
+
// In flight. The buyer is waiting on someone, or someone is waiting on them.
|
|
213
|
+
PENDING: TONE_WAITING,
|
|
214
|
+
PENDING_PAYMENT: TONE_WAITING,
|
|
215
|
+
PROCESSING: TONE_WAITING,
|
|
216
|
+
AWAITING_STOCK: TONE_WAITING,
|
|
217
|
+
AWAITING_REPLY: TONE_WAITING,
|
|
218
|
+
UNDERPAID: TONE_WAITING,
|
|
219
|
+
TRIALING: TONE_WAITING,
|
|
220
|
+
PAUSED: TONE_WAITING,
|
|
221
|
+
INCOMPLETE: TONE_WAITING,
|
|
222
|
+
REQUESTED: TONE_WAITING,
|
|
223
|
+
// Something the buyer would want to notice.
|
|
224
|
+
PAST_DUE: TONE_BAD,
|
|
225
|
+
UNPAID: TONE_BAD,
|
|
226
|
+
DISPUTED: TONE_BAD,
|
|
227
|
+
EXPIRED: TONE_BAD,
|
|
228
|
+
VOIDED: TONE_BAD,
|
|
229
|
+
REVOKED: TONE_BAD,
|
|
230
|
+
REJECTED: TONE_BAD,
|
|
231
|
+
SUSPENDED: TONE_BAD,
|
|
232
|
+
FAILED: TONE_BAD,
|
|
233
|
+
CANCELED: TONE_BAD,
|
|
234
|
+
CANCELLED: TONE_BAD,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/*
|
|
238
|
+
* Deliberately untoned, and neutral is the honest answer for each:
|
|
239
|
+
* REFUNDED / PARTIAL_REFUND (settled, and the buyer has their money),
|
|
240
|
+
* INACTIVE, OPEN / CLOSED (a support thread's two normal ends).
|
|
241
|
+
*
|
|
242
|
+
* `delivery.*` statuses — DELIVERED, AWAITING_FULFILLMENT — are a DIFFERENT
|
|
243
|
+
* namespace and belong to the per-line delivery chip, not here. They sat in
|
|
244
|
+
* this table until the guard test caught them: the lookup could never hit, so
|
|
245
|
+
* the tone never applied.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
export function StatusChip({ status }: { status: string }) {
|
|
249
|
+
const tone = STATUS_TONES[status.trim().toUpperCase()] ?? 'sxa:meta-chip--neutral';
|
|
250
|
+
return (
|
|
251
|
+
<span className={`sx-account-status sx-account-chip sxa:meta-chip sxa:meta-chip--status ${tone} sxa:shrink-0`}>{statusLabel(status)}</span>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The card surface every panel section sits on. */
|
|
256
|
+
export function AccountCard({
|
|
257
|
+
children,
|
|
258
|
+
className = '',
|
|
259
|
+
}: {
|
|
260
|
+
children: ReactNode;
|
|
261
|
+
className?: string;
|
|
262
|
+
}) {
|
|
263
|
+
return (
|
|
264
|
+
<div className={`sx-account-card sxa:rounded-input sxa:border sxa:bg-surface sxa:shadow-card ${className}`}>{children}</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function AccountSectionTitle({ children }: { children: ReactNode }) {
|
|
269
|
+
return <h2 className="sx-account-section-title sxa:font-heading sxa:text-body sxa:font-semibold">{children}</h2>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function AccountRowsSkeleton({ rows = 3, label }: { rows?: number; label: string }) {
|
|
273
|
+
return (
|
|
274
|
+
<div role="status" aria-label={label} className="sx-account-skeleton sxa:space-y-3">
|
|
275
|
+
{Array.from({ length: rows }, (_, index) => (
|
|
276
|
+
<div key={index} className="sxa:h-16 sxa:animate-pulse sxa:rounded-input sxa:border sxa:bg-bg" />
|
|
277
|
+
))}
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A failure, in the producer's own words where it gave any.
|
|
284
|
+
*
|
|
285
|
+
* The retry is the point: a buyer who cannot see their orders wants to try
|
|
286
|
+
* again, not read an apology.
|
|
287
|
+
*/
|
|
288
|
+
export function AccountError({
|
|
289
|
+
message,
|
|
290
|
+
onRetry,
|
|
291
|
+
}: {
|
|
292
|
+
message: string;
|
|
293
|
+
onRetry?: () => void;
|
|
294
|
+
}) {
|
|
295
|
+
return (
|
|
296
|
+
<AccountCard className="sx-account-error sxa:px-6 sxa:py-10 sxa:text-center">
|
|
297
|
+
<div className="sxa:mx-auto sxa:mb-4 sxa:flex sxa:h-12 sxa:w-12 sxa:items-center sxa:justify-center sxa:rounded-input sxa:border sxa:bg-bg">
|
|
298
|
+
<CircleAlertIcon className="sxa:h-5 sxa:w-5 sxa:text-muted" />
|
|
299
|
+
</div>
|
|
300
|
+
<p role="alert" className="sxa:text-small sxa:text-muted">{message}</p>
|
|
301
|
+
{onRetry ? (
|
|
302
|
+
<button type="button" onClick={onRetry} className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:h-9 sxa:px-4 sxa:text-small">
|
|
303
|
+
{t('common.retry')}
|
|
304
|
+
</button>
|
|
305
|
+
) : null}
|
|
306
|
+
</AccountCard>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function AccountNotice({ message }: { message: string }) {
|
|
311
|
+
return (
|
|
312
|
+
<p role="status" className="sx-account-notice sxa:mt-3 sxa:inline-flex sxa:items-center sxa:gap-1.5 sxa:text-small sxa:text-success">
|
|
313
|
+
<CheckIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0" />
|
|
314
|
+
{message}
|
|
315
|
+
</p>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function AccountFieldError({ message }: { message: string }) {
|
|
320
|
+
return <p role="alert" className="sx-account-field-error sxa:mt-3 sxa:text-small sxa:text-error">{message}</p>;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function AccountEmpty({
|
|
324
|
+
title,
|
|
325
|
+
body,
|
|
326
|
+
action,
|
|
327
|
+
}: {
|
|
328
|
+
title: string;
|
|
329
|
+
body: string;
|
|
330
|
+
action?: ReactNode;
|
|
331
|
+
}) {
|
|
332
|
+
return (
|
|
333
|
+
<AccountCard className="sx-account-empty sxa:px-8 sxa:py-16 sxa:text-center">
|
|
334
|
+
<p className="sxa:font-heading sxa:font-semibold">{title}</p>
|
|
335
|
+
<p className="sxa:mx-auto sxa:mt-2 sxa:max-w-md sxa:text-small sxa:text-muted">{body}</p>
|
|
336
|
+
{action ? <div className="sxa:mt-5">{action}</div> : null}
|
|
337
|
+
</AccountCard>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function AccountBackLink({ to, label }: { to: string; label: string }) {
|
|
342
|
+
return (
|
|
343
|
+
<Link to={to} className="sx-account-back sxa:inline-flex sxa:items-center sxa:gap-1.5 sxa:text-small sxa:transition-opacity sxa:hover:opacity-80">
|
|
344
|
+
<ArrowLeftIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0" />
|
|
345
|
+
{label}
|
|
346
|
+
</Link>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function AccountBackButton({ onClick, label }: { onClick: () => void; label: string }) {
|
|
351
|
+
return (
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
onClick={onClick}
|
|
355
|
+
className="sx-account-back sxa:inline-flex sxa:items-center sxa:gap-1.5 sxa:text-small sxa:transition-opacity sxa:hover:opacity-80"
|
|
356
|
+
>
|
|
357
|
+
<ArrowLeftIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0" />
|
|
358
|
+
{label}
|
|
359
|
+
</button>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Copies what the row actually shows.
|
|
365
|
+
*
|
|
366
|
+
* The confirmation is time-boxed rather than sticky: a "Copied" that never
|
|
367
|
+
* clears is a claim about the clipboard the page cannot keep checking.
|
|
368
|
+
*/
|
|
369
|
+
export function CopyButton({ value, className = '' }: { value: string; className?: string }) {
|
|
370
|
+
const [copied, setCopied] = useState(false);
|
|
371
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
372
|
+
|
|
373
|
+
useEffect(() => () => {
|
|
374
|
+
if (timer.current) clearTimeout(timer.current);
|
|
375
|
+
}, []);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
className={`sx-account-copy sx-account-button sxa:card-button sxa:card-button--add ${className}`}
|
|
381
|
+
onClick={() => {
|
|
382
|
+
void navigator.clipboard?.writeText(value).then(() => {
|
|
383
|
+
setCopied(true);
|
|
384
|
+
if (timer.current) clearTimeout(timer.current);
|
|
385
|
+
timer.current = setTimeout(() => setCopied(false), 1500);
|
|
386
|
+
});
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
{copied ? <CheckIcon className="sxa:h-3.5 sxa:w-3.5" /> : <CopyIcon className="sxa:h-3.5 sxa:w-3.5" />}
|
|
390
|
+
<span className="sxa:ml-1.5">{copied ? t('common.copied') : t('common.copy')}</span>
|
|
391
|
+
</button>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The spinner a control shows while its own request is in flight. */
|
|
396
|
+
export function BusySpinner({ className = '' }: { className?: string }) {
|
|
397
|
+
return <LoaderIcon className={`sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:animate-spin ${className}`} />;
|
|
398
|
+
}
|