@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +215 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. 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
+ }