@shoppexio/storefront-react 0.0.0 → 0.1.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -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 +75 -1
@@ -0,0 +1,1365 @@
1
+ /**
2
+ * The wholesale account.
3
+ *
4
+ * The producer's relationship has FIVE states and this panel answers each of
5
+ * them separately: ACTIVE gets the working surface, APPLIED gets "your
6
+ * application is with the shop", SUSPENDED and REJECTED each get their own
7
+ * sentence, and INVITED — like having no relationship at all — goes to the entry
8
+ * form. There is no PENDING. Folding two of those together tells a suspended
9
+ * reseller they were rejected, or hands a rejected one a catalog.
10
+ *
11
+ * Three controls a wholesale surface would normally carry are ABSENT rather
12
+ * than disabled: placing an order, minting an API key and creating a wallet
13
+ * top-up. The storefront edge allowlists none of those mutations
14
+ * (`workers/storefront/src/customer-api.ts`).
15
+ *
16
+ * Pricing a basket stays, because it writes nothing. The quoted total is the
17
+ * only total shown, and a basket that has moved since its quote shows no total
18
+ * at all rather than the one that no longer applies.
19
+ */
20
+
21
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
22
+ import {
23
+ RESELLER_QUANTITY_MAX_UNITS,
24
+ RESELLER_QUANTITY_MIN_UNITS,
25
+ narrowResellerApiKeys,
26
+ narrowResellerCatalog,
27
+ narrowResellerMutation,
28
+ narrowResellerOrderDetail,
29
+ narrowResellerOrders,
30
+ narrowResellerProgram,
31
+ narrowResellerQuote,
32
+ narrowResellerWallet,
33
+ quoteMatchesBasket,
34
+ resellerBasketKey,
35
+ resellerEntryMode,
36
+ type ResellerCatalogItem,
37
+ type ResellerCatalogVariant,
38
+ type ResellerOrderItemInput,
39
+ type ResellerOrderLine,
40
+ type ResellerOrderListItem,
41
+ type ResellerProgramState,
42
+ type ResellerQuote,
43
+ type ResellerRelationship,
44
+ } from './account-reseller';
45
+ import { callCustomerApi } from './customer-account';
46
+ import {
47
+ clearAccountResellerInvite,
48
+ peekAccountResellerInvite,
49
+ } from './account-reseller-invite';
50
+ import type { SdkResponse, ShoppexSdk } from '@shoppexio/storefront-react';
51
+ import { t } from './i18n';
52
+ import { SearchIcon } from './icons';
53
+ import {
54
+ AccountBackButton,
55
+ AccountCard,
56
+ AccountEmpty,
57
+ AccountError,
58
+ AccountFieldError,
59
+ AccountRowsSkeleton,
60
+ AccountSectionTitle,
61
+ BusySpinner,
62
+ CopyButton,
63
+ StatusChip,
64
+ formatAccountAmount,
65
+ formatAccountDate,
66
+ formatAccountDateTime,
67
+ useAccountResource,
68
+ } from './shared';
69
+ import { useAccountResellerProgram } from './dashboard';
70
+
71
+ /** The contact form's control skin — see `components/contact/contact-form.tsx`. */
72
+ const FIELD =
73
+ 'sx-account-field sxa:w-full sxa:rounded-card sxa:border sxa:bg-[color-mix(in_oklab,_var(--sx-bg)_40%,_transparent)] sxa:px-3 sxa:py-2.5 sxa:text-small sxa:shadow-card sxa:placeholder:text-muted sxa:focus-visible:border-accent sxa:focus-visible:ring-[3px] sxa:focus-visible:ring-[color-mix(in_oklab,_var(--sx-accent)_50%,_transparent)] sxa:focus-visible:outline-none';
74
+
75
+ const PAGE_SIZE = 20;
76
+
77
+ /** One basket row: what the producer needs, plus the words to show it by. */
78
+ interface BasketLine extends ResellerOrderItemInput {
79
+ title: string;
80
+ variant_title: string | null;
81
+ }
82
+
83
+ /** A quote, pinned to the exact basket it priced. */
84
+ interface PricedBasket {
85
+ basketKey: string;
86
+ quote: ResellerQuote;
87
+ }
88
+
89
+ export function AccountReseller() {
90
+ /*
91
+ * The SAME response the navigation gate reads. Two separate reads could
92
+ * disagree — the gate's failing while this one succeeded — and the buyer
93
+ * would be shown an active wholesale account by a panel the navigation had
94
+ * just dropped the tab for. Retrying here reopens the gate with it.
95
+ */
96
+ const { state, reload } = useAccountResellerProgram();
97
+
98
+ if (state.status === 'loading') {
99
+ return <AccountRowsSkeleton rows={4} label={t('overview.loading')} />;
100
+ }
101
+ if (state.status === 'error') {
102
+ return <AccountError message={state.message} onRetry={reload} />;
103
+ }
104
+
105
+ const narrowed = narrowResellerProgram(state.data);
106
+ if (!narrowed.ok) {
107
+ return <AccountError message={t('common.genericError')} onRetry={reload} />;
108
+ }
109
+ const program = narrowed.data;
110
+
111
+ if (!program.program_enabled) {
112
+ return (
113
+ <AccountEmpty
114
+ title={t('reseller.disabledTitle')}
115
+ body={t('reseller.disabledBody')}
116
+ />
117
+ );
118
+ }
119
+
120
+ const reseller = program.reseller;
121
+ const status = reseller?.status;
122
+
123
+ if (reseller && status === 'ACTIVE') {
124
+ return <ActiveReseller relationship={reseller} />;
125
+ }
126
+
127
+ if (status === 'APPLIED') {
128
+ return (
129
+ <AccountEmpty
130
+ title={t('reseller.pendingTitle')}
131
+ body={t('reseller.pendingBody')}
132
+ />
133
+ );
134
+ }
135
+
136
+ if (status === 'SUSPENDED') {
137
+ return (
138
+ <AccountEmpty
139
+ title={t('reseller.suspendedTitle')}
140
+ body={t('reseller.suspendedBody')}
141
+ />
142
+ );
143
+ }
144
+
145
+ if (status === 'REJECTED') {
146
+ return (
147
+ <AccountEmpty
148
+ title={t('reseller.rejectedTitle')}
149
+ body={t('reseller.rejectedBody')}
150
+ />
151
+ );
152
+ }
153
+
154
+ // INVITED and "no relationship at all" are the two ways into the program, and
155
+ // `resellerEntryMode` is what tells them apart from the shop's own mode.
156
+ return <ResellerEntry program={program} onDone={reload} />;
157
+ }
158
+
159
+ function ResellerEntry({
160
+ program,
161
+ onDone,
162
+ }: {
163
+ program: ResellerProgramState;
164
+ onDone: () => void;
165
+ }) {
166
+ const fieldId = useId();
167
+ const [note, setNote] = useState('');
168
+ const [token, setToken] = useState('');
169
+ const [busy, setBusy] = useState(false);
170
+ const [error, setError] = useState<string | null>(null);
171
+ const attemptedInviteToken = useRef<string | null>(null);
172
+
173
+ useEffect(() => {
174
+ // The cookie belongs to the invited email. Another authenticated buyer must
175
+ // be able to switch accounts while the cookie lives.
176
+ if (program.reseller?.status !== 'INVITED') return;
177
+
178
+ const inviteToken = peekAccountResellerInvite(document);
179
+ if (!inviteToken || attemptedInviteToken.current === inviteToken) return;
180
+ attemptedInviteToken.current = inviteToken;
181
+
182
+ setBusy(true);
183
+ setError(null);
184
+ void (async () => {
185
+ const result = await callCustomerApi((sdk) => sdk.acceptResellerInvite(inviteToken));
186
+ setBusy(false);
187
+
188
+ // Only success spends the cookie: a failure can mean this browser is
189
+ // signed into another invited customer, and the actually invited one
190
+ // still needs the token after switching accounts.
191
+ if (!result.success) {
192
+ setToken(inviteToken);
193
+ setError(result.message?.trim() || t('common.genericError'));
194
+ return;
195
+ }
196
+ if (!narrowResellerMutation(result.data).ok) {
197
+ setToken(inviteToken);
198
+ setError(t('common.genericError'));
199
+ return;
200
+ }
201
+ clearAccountResellerInvite(document);
202
+ onDone();
203
+ })();
204
+ }, [program.reseller?.status, onDone]);
205
+
206
+ const run = async (operation: (sdk: ShoppexSdk) => Promise<SdkResponse<unknown>>) => {
207
+ setBusy(true);
208
+ setError(null);
209
+ const result = await callCustomerApi(operation);
210
+ setBusy(false);
211
+
212
+ if (!result.success) {
213
+ setError(result.message?.trim() || t('common.genericError'));
214
+ return;
215
+ }
216
+ // The enrolment routes answer with the relationship they just wrote. A body
217
+ // that is not one is not proof of anything, so it is not celebrated.
218
+ if (!narrowResellerMutation(result.data).ok) {
219
+ setError(t('common.genericError'));
220
+ return;
221
+ }
222
+ onDone();
223
+ };
224
+
225
+ /*
226
+ * Not `program.enrollment_mode`. An invitation outranks the shop's mode:
227
+ * `ResellerEnrollmentService.invite` only requires an ENABLED program, so a
228
+ * shop taking open sign-ups can still have invited this buyer. Offering them
229
+ * the join button offers the one call the producer answers 409 to, while the
230
+ * token in their mail has nowhere to go.
231
+ */
232
+ const entry = resellerEntryMode(program);
233
+
234
+ if (entry === 'OPEN') {
235
+ return (
236
+ <AccountEmpty
237
+ title={t('reseller.openTitle')}
238
+ body={program.application_message ?? t('reseller.openBody')}
239
+ action={(
240
+ <button
241
+ type="button"
242
+ disabled={busy}
243
+ aria-busy={busy || undefined}
244
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
245
+ onClick={() => void run((sdk) => sdk.enrollAsReseller())}
246
+ >
247
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
248
+ {t('reseller.join')}
249
+ </button>
250
+ )}
251
+ />
252
+ );
253
+ }
254
+
255
+ if (entry === 'MANUAL') {
256
+ return (
257
+ <AccountCard className="sxa:mx-auto sxa:max-w-xl sxa:p-6">
258
+ <h2 className="sxa:font-heading sxa:text-body sxa:font-semibold">{t('reseller.manualTitle')}</h2>
259
+ <p className="sxa:mt-2 sxa:text-small sxa:leading-relaxed sxa:text-muted">
260
+ {program.application_message ?? t('reseller.manualBody')}
261
+ </p>
262
+ <form
263
+ noValidate
264
+ className="sxa:mt-5"
265
+ onSubmit={(event) => {
266
+ event.preventDefault();
267
+ void run((sdk) => sdk.acceptResellerInvite(token.trim()));
268
+ }}
269
+ >
270
+ <label htmlFor={`${fieldId}-token`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
271
+ {t('reseller.inviteToken')}
272
+ </label>
273
+ <input
274
+ id={`${fieldId}-token`}
275
+ type="text"
276
+ value={token}
277
+ disabled={busy}
278
+ aria-invalid={error ? true : undefined}
279
+ onChange={(event) => {
280
+ setToken(event.target.value);
281
+ setError(null);
282
+ }}
283
+ className={FIELD}
284
+ />
285
+ {error ? <AccountFieldError message={error} /> : null}
286
+ <button
287
+ type="submit"
288
+ disabled={busy || token.trim().length === 0}
289
+ aria-busy={busy || undefined}
290
+ className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
291
+ >
292
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
293
+ {t('reseller.acceptInvite')}
294
+ </button>
295
+ </form>
296
+ </AccountCard>
297
+ );
298
+ }
299
+
300
+ const apply = () => {
301
+ if (program.require_application_note && note.trim().length === 0) {
302
+ setError(t('reseller.noteRequired'));
303
+ return;
304
+ }
305
+ void run((sdk) => sdk.applyForReseller(note.trim() || undefined));
306
+ };
307
+
308
+ return (
309
+ <AccountCard className="sxa:mx-auto sxa:max-w-xl sxa:p-6">
310
+ <h2 className="sxa:font-heading sxa:text-body sxa:font-semibold">{t('reseller.applicationTitle')}</h2>
311
+ <p className="sxa:mt-2 sxa:text-small sxa:leading-relaxed sxa:text-muted">
312
+ {program.application_message ?? t('reseller.applicationBody')}
313
+ </p>
314
+ <form
315
+ noValidate
316
+ className="sxa:mt-5"
317
+ onSubmit={(event) => {
318
+ event.preventDefault();
319
+ apply();
320
+ }}
321
+ >
322
+ <label htmlFor={`${fieldId}-note`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
323
+ {t('reseller.applicationNote')}
324
+ </label>
325
+ <textarea
326
+ id={`${fieldId}-note`}
327
+ rows={4}
328
+ value={note}
329
+ disabled={busy}
330
+ aria-invalid={error ? true : undefined}
331
+ onChange={(event) => {
332
+ setNote(event.target.value);
333
+ setError(null);
334
+ }}
335
+ className={`${FIELD} sxa:resize-none`}
336
+ />
337
+ {error ? <AccountFieldError message={error} /> : null}
338
+ <button
339
+ type="submit"
340
+ disabled={busy}
341
+ aria-busy={busy || undefined}
342
+ className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
343
+ >
344
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
345
+ {t('reseller.apply')}
346
+ </button>
347
+ </form>
348
+ </AccountCard>
349
+ );
350
+ }
351
+
352
+ function ActiveReseller({ relationship }: { relationship: ResellerRelationship }) {
353
+ /*
354
+ * The basket lives HERE, above the catalog, because the catalog paginates and
355
+ * a buyer builds one order across several pages. A basket owned by the list
356
+ * would empty itself every time the buyer asked for more products.
357
+ */
358
+ const [basket, setBasket] = useState<BasketLine[]>([]);
359
+ const [priced, setPriced] = useState<PricedBasket | null>(null);
360
+
361
+ /** Any change to the basket retires the quote that priced the old one. */
362
+ const replaceBasket = (next: BasketLine[]) => {
363
+ setBasket(next);
364
+ setPriced(null);
365
+ };
366
+
367
+ const add = (line: BasketLine) => {
368
+ const existing = basket.findIndex(
369
+ (entry) => entry.product_id === line.product_id && entry.variant_id === line.variant_id,
370
+ );
371
+ if (existing === -1) {
372
+ replaceBasket([...basket, line]);
373
+ return;
374
+ }
375
+ replaceBasket(basket.map((entry, index) => (
376
+ index === existing
377
+ ? { ...entry, quantity: Math.min(RESELLER_QUANTITY_MAX_UNITS, entry.quantity + 1) }
378
+ : entry
379
+ )));
380
+ };
381
+
382
+ const discount = relationship.tier?.discount_percent;
383
+
384
+ return (
385
+ <div className="sxa:space-y-8">
386
+ <section>
387
+ <AccountSectionTitle>{t('reseller.activeTitle')}</AccountSectionTitle>
388
+ <div className="sxa:mt-4 sxa:grid sxa:gap-3 sxa:sm:grid-cols-2">
389
+ <Metric
390
+ label={t('reseller.tier')}
391
+ value={relationship.tier?.name ?? t('common.unavailable')}
392
+ />
393
+ <Metric
394
+ label={t('orders.discount')}
395
+ value={discount === null || discount === undefined
396
+ ? t('common.unavailable')
397
+ : `${discount}%`}
398
+ />
399
+ {/*
400
+ The relationship carries a lifetime figure and NO currency to read
401
+ it in, so it is printed exactly as the producer spelled it. Running
402
+ it through a formatter would mean picking a currency nobody sent.
403
+ */}
404
+ <Metric label={t('referrals.revenue')} value={relationship.lifetime_revenue} />
405
+ <Metric label={t('overview.statOrders')} value={relationship.lifetime_orders} />
406
+ </div>
407
+ </section>
408
+
409
+ <WholesaleWallet />
410
+ <WholesaleCatalog onAdd={add} />
411
+ <WholesaleBasket
412
+ basket={basket}
413
+ priced={priced}
414
+ onBasketChange={replaceBasket}
415
+ onPriced={setPriced}
416
+ />
417
+ <WholesaleOrders />
418
+ <ResellerApiKeys />
419
+ </div>
420
+ );
421
+ }
422
+
423
+ function Metric({ label, value }: { label: string; value: string | number }) {
424
+ return (
425
+ <div className="sxa:rounded-input sxa:border sxa:bg-bg sxa:p-5">
426
+ <p className="sxa:truncate sxa:text-h3 sxa:font-semibold sxa:tabular-nums">{value}</p>
427
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{label}</p>
428
+ </div>
429
+ );
430
+ }
431
+
432
+ /* --- Wholesale balance ---------------------------------------------------- */
433
+
434
+ function WholesaleWallet() {
435
+ const { state, reload } = useAccountResource('reseller-wallet', () =>
436
+ callCustomerApi((sdk) => sdk.resellerWallet()));
437
+
438
+ const heading = <AccountSectionTitle>{t('reseller.walletTitle')}</AccountSectionTitle>;
439
+
440
+ if (state.status === 'loading') {
441
+ return (
442
+ <section>
443
+ {heading}
444
+ <div className="sxa:mt-4">
445
+ <AccountRowsSkeleton rows={1} label={t('overview.loading')} />
446
+ </div>
447
+ </section>
448
+ );
449
+ }
450
+ if (state.status === 'error') {
451
+ return (
452
+ <section>
453
+ {heading}
454
+ <div className="sxa:mt-4">
455
+ <AccountError message={state.message} onRetry={reload} />
456
+ </div>
457
+ </section>
458
+ );
459
+ }
460
+
461
+ const narrowed = narrowResellerWallet(state.data);
462
+ if (!narrowed.ok) {
463
+ return (
464
+ <section>
465
+ {heading}
466
+ <div className="sxa:mt-4">
467
+ <AccountError message={t('common.genericError')} onRetry={reload} />
468
+ </div>
469
+ </section>
470
+ );
471
+ }
472
+ const wallet = narrowed.data;
473
+
474
+ return (
475
+ <section>
476
+ {heading}
477
+ <AccountCard className="sxa:mt-4 sxa:p-6">
478
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
479
+ <p className="sxa:text-small sxa:font-medium">{t('rewards.available')}</p>
480
+ <StatusChip status={wallet.is_active ? 'ACTIVE' : 'INACTIVE'} />
481
+ </div>
482
+ <p className="sxa:mt-1 sxa:text-h2 sxa:font-semibold sxa:tabular-nums">
483
+ {formatAccountAmount(wallet.available, wallet.currency)}
484
+ </p>
485
+
486
+ <div className="sxa:mt-4 sxa:grid sxa:gap-3 sxa:sm:grid-cols-2">
487
+ <Metric
488
+ label={t('reseller.balance')}
489
+ value={formatAccountAmount(wallet.balance, wallet.currency)}
490
+ />
491
+ <Metric
492
+ label={t('reseller.held')}
493
+ value={formatAccountAmount(wallet.hold_balance, wallet.currency)}
494
+ />
495
+ </div>
496
+
497
+ </AccountCard>
498
+ </section>
499
+ );
500
+ }
501
+
502
+ /* --- Paging --------------------------------------------------------------- */
503
+
504
+ interface PageSlice<T> {
505
+ items: T[];
506
+ page: number;
507
+ perPage: number;
508
+ total: number;
509
+ }
510
+
511
+ type PageRead<T> = Promise<
512
+ { ok: true; slice: PageSlice<T> } | { ok: false; message: string }
513
+ >;
514
+
515
+ type PagedState<T> =
516
+ | { status: 'loading' }
517
+ | { status: 'error'; message: string }
518
+ | { status: 'ready'; slice: PageSlice<T> };
519
+
520
+ /**
521
+ * A list that GROWS a page at a time.
522
+ *
523
+ * Both wholesale lists append rather than replace. The catalog has to: a basket
524
+ * is built across pages, and swapping page one for page two would take the
525
+ * products the buyer had just been looking at off the screen while their basket
526
+ * still names them. The order history follows the same shape so the two read
527
+ * alike.
528
+ *
529
+ * The page number advances only on a SUCCESSFUL read, and it advances to the
530
+ * page the producer says it answered. A failed "next" leaves the list exactly
531
+ * where it was, so pressing it again asks for the page that failed rather than
532
+ * skipping it.
533
+ */
534
+ function usePagedList<T>(resetKey: string, read: (page: number) => PageRead<T>) {
535
+ const [state, setState] = useState<PagedState<T>>({ status: 'loading' });
536
+ const [attempt, setAttempt] = useState(0);
537
+ const [moreBusy, setMoreBusy] = useState(false);
538
+ const [moreError, setMoreError] = useState<string | null>(null);
539
+ const readRef = useRef(read);
540
+ readRef.current = read;
541
+ const stateRef = useRef(state);
542
+ stateRef.current = state;
543
+ /** Bumped on every reset, so a page that arrives late cannot land in a list
544
+ * the buyer has since re-filtered. */
545
+ const generation = useRef(0);
546
+
547
+ useEffect(() => {
548
+ generation.current += 1;
549
+ const mine = generation.current;
550
+ setState({ status: 'loading' });
551
+ setMoreBusy(false);
552
+ setMoreError(null);
553
+
554
+ void readRef.current(1).then((result) => {
555
+ if (generation.current !== mine) return;
556
+ setState(result.ok
557
+ ? { status: 'ready', slice: result.slice }
558
+ : { status: 'error', message: result.message });
559
+ });
560
+ }, [resetKey, attempt]);
561
+
562
+ const loadMore = useCallback(() => {
563
+ const current = stateRef.current;
564
+ if (current.status !== 'ready') return;
565
+ const mine = generation.current;
566
+ setMoreBusy(true);
567
+ setMoreError(null);
568
+
569
+ void readRef.current(current.slice.page + 1).then((result) => {
570
+ if (generation.current !== mine) return;
571
+ setMoreBusy(false);
572
+ if (!result.ok) {
573
+ setMoreError(result.message);
574
+ return;
575
+ }
576
+ setState((previous) => (previous.status === 'ready'
577
+ ? {
578
+ status: 'ready',
579
+ slice: {
580
+ items: [...previous.slice.items, ...result.slice.items],
581
+ page: result.slice.page,
582
+ perPage: result.slice.perPage,
583
+ total: result.slice.total,
584
+ },
585
+ }
586
+ : previous));
587
+ });
588
+ }, []);
589
+
590
+ const reload = useCallback(() => setAttempt((value) => value + 1), []);
591
+
592
+ return { state, moreBusy, moreError, loadMore, reload };
593
+ }
594
+
595
+ function hasMorePages<T>(slice: PageSlice<T>): boolean {
596
+ return slice.page * slice.perPage < slice.total;
597
+ }
598
+
599
+ function MorePages({
600
+ slice,
601
+ busy,
602
+ error,
603
+ onMore,
604
+ }: {
605
+ slice: PageSlice<unknown>;
606
+ busy: boolean;
607
+ error: string | null;
608
+ onMore: () => void;
609
+ }) {
610
+ const more = hasMorePages(slice);
611
+ if (slice.page <= 1 && !more) return null;
612
+
613
+ return (
614
+ <div>
615
+ <nav
616
+ className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3"
617
+ aria-label={t('orders.pagesLabel')}
618
+ >
619
+ <span className="sxa:text-micro sxa:text-muted">{t('orders.page', { page: slice.page })}</span>
620
+ <button
621
+ type="button"
622
+ disabled={!more || busy}
623
+ aria-busy={busy || undefined}
624
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-40"
625
+ onClick={onMore}
626
+ >
627
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
628
+ {t('orders.next')}
629
+ </button>
630
+ </nav>
631
+ {error ? <AccountFieldError message={error} /> : null}
632
+ </div>
633
+ );
634
+ }
635
+
636
+ /* --- Catalog -------------------------------------------------------------- */
637
+
638
+ /** One catalog row, carrying the currency the producer sent WITH its page. */
639
+ interface CatalogRow {
640
+ item: ResellerCatalogItem;
641
+ currency: string;
642
+ }
643
+
644
+ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
645
+ const searchId = useId();
646
+ const [search, setSearch] = useState('');
647
+ const [query, setQuery] = useState('');
648
+
649
+ const { state, moreBusy, moreError, loadMore, reload } = usePagedList<CatalogRow>(
650
+ `reseller-catalog:${query}`,
651
+ async (page) => {
652
+ const response = await callCustomerApi((sdk) => sdk.resellerCatalog({
653
+ ...(query ? { search: query } : {}),
654
+ page,
655
+ per_page: PAGE_SIZE,
656
+ }));
657
+ if (!response.success) {
658
+ return { ok: false, message: response.message?.trim() || t('common.genericError') };
659
+ }
660
+ const narrowed = narrowResellerCatalog(response.data);
661
+ if (!narrowed.ok) return { ok: false, message: t('common.genericError') };
662
+
663
+ const { catalog } = narrowed.data;
664
+ return {
665
+ ok: true,
666
+ slice: {
667
+ items: catalog.items.map((item) => ({ item, currency: catalog.currency })),
668
+ page: catalog.page,
669
+ perPage: catalog.per_page,
670
+ total: catalog.total,
671
+ },
672
+ };
673
+ },
674
+ );
675
+
676
+ const heading = (
677
+ <div className="sxa:flex sxa:flex-wrap sxa:items-end sxa:justify-between sxa:gap-3">
678
+ <AccountSectionTitle>{t('reseller.catalogTitle')}</AccountSectionTitle>
679
+ <form
680
+ noValidate
681
+ className="sxa:flex sxa:items-center sxa:gap-2"
682
+ onSubmit={(event) => {
683
+ event.preventDefault();
684
+ // A new search restarts the list at page one. The basket is held
685
+ // above this component, so it survives the reset intact.
686
+ setQuery(search.trim());
687
+ }}
688
+ >
689
+ <label htmlFor={searchId} className="sxa:sr-only">
690
+ {t('reseller.search')}
691
+ </label>
692
+ <input
693
+ id={searchId}
694
+ type="search"
695
+ value={search}
696
+ onChange={(event) => setSearch(event.target.value)}
697
+ className={`${FIELD} sxa:w-36`}
698
+ />
699
+ <button
700
+ type="submit"
701
+ className="sx-account-button sxa:card-button sxa:card-button--add"
702
+ aria-label={t('reseller.search')}
703
+ >
704
+ <SearchIcon className="sxa:h-3 sxa:w-3" />
705
+ </button>
706
+ </form>
707
+ </div>
708
+ );
709
+
710
+ if (state.status === 'loading') {
711
+ return (
712
+ <section>
713
+ {heading}
714
+ <div className="sxa:mt-4">
715
+ <AccountRowsSkeleton label={t('overview.loading')} />
716
+ </div>
717
+ </section>
718
+ );
719
+ }
720
+ if (state.status === 'error') {
721
+ return (
722
+ <section>
723
+ {heading}
724
+ <div className="sxa:mt-4">
725
+ <AccountError message={state.message} onRetry={reload} />
726
+ </div>
727
+ </section>
728
+ );
729
+ }
730
+
731
+ const { slice } = state;
732
+
733
+ return (
734
+ <section>
735
+ {heading}
736
+ {slice.items.length === 0 ? (
737
+ <div className="sxa:mt-4">
738
+ <AccountEmpty
739
+ title={t('reseller.catalogEmptyTitle')}
740
+ body={t('reseller.catalogEmptyBody')}
741
+ />
742
+ </div>
743
+ ) : (
744
+ <ul className="sxa:mt-4 sxa:space-y-3">
745
+ {slice.items.map((row) => (
746
+ <li key={row.item.product_id}>
747
+ <CatalogProduct item={row.item} currency={row.currency} onAdd={onAdd} />
748
+ </li>
749
+ ))}
750
+ </ul>
751
+ )}
752
+ <MorePages slice={slice} busy={moreBusy} error={moreError} onMore={loadMore} />
753
+ </section>
754
+ );
755
+ }
756
+
757
+ function CatalogProduct({
758
+ item,
759
+ currency,
760
+ onAdd,
761
+ }: {
762
+ item: ResellerCatalogItem;
763
+ currency: string;
764
+ onAdd: (line: BasketLine) => void;
765
+ }) {
766
+ return (
767
+ <AccountCard className="sxa:p-5">
768
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-3">
769
+ <div className="sxa:min-w-0 sxa:flex-1">
770
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">{item.title}</p>
771
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
772
+ {formatAccountAmount(item.unit_price, currency)}
773
+ {item.volume_discount_enabled ? ` · ${t('reseller.volumePricing')}` : ''}
774
+ {item.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
775
+ </p>
776
+ </div>
777
+ {item.variants.length === 0 ? (
778
+ item.orderable ? (
779
+ <button
780
+ type="button"
781
+ className="sx-account-button sxa:card-button sxa:card-button--add"
782
+ onClick={() => onAdd({
783
+ product_id: item.product_id,
784
+ quantity: RESELLER_QUANTITY_MIN_UNITS,
785
+ title: item.title,
786
+ variant_title: null,
787
+ })}
788
+ >
789
+ {t('reseller.add')}
790
+ </button>
791
+ ) : (
792
+ <span className="sxa:text-micro sxa:text-muted">{t('common.unavailable')}</span>
793
+ )
794
+ ) : null}
795
+ </div>
796
+
797
+ {item.variants.length > 0 ? (
798
+ <ul className="sxa:mt-4 sxa:space-y-2 sxa:border-t sxa:pt-4">
799
+ {item.variants.map((variant) => (
800
+ <CatalogVariant
801
+ key={variant.variant_id}
802
+ item={item}
803
+ variant={variant}
804
+ currency={currency}
805
+ onAdd={onAdd}
806
+ />
807
+ ))}
808
+ </ul>
809
+ ) : null}
810
+ </AccountCard>
811
+ );
812
+ }
813
+
814
+ function CatalogVariant({
815
+ item,
816
+ variant,
817
+ currency,
818
+ onAdd,
819
+ }: {
820
+ item: ResellerCatalogItem;
821
+ variant: ResellerCatalogVariant;
822
+ currency: string;
823
+ onAdd: (line: BasketLine) => void;
824
+ }) {
825
+ return (
826
+ <li className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-3 sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-3">
827
+ <div className="sxa:min-w-0 sxa:flex-1">
828
+ <p className="sxa:truncate sxa:text-small">{variant.title}</p>
829
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
830
+ {formatAccountAmount(variant.unit_price, currency)}
831
+ {variant.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
832
+ </p>
833
+ </div>
834
+ {/* Both flags have to say yes: an orderable variant of an unorderable
835
+ product is still not orderable. */}
836
+ {item.orderable && variant.orderable ? (
837
+ <button
838
+ type="button"
839
+ className="sx-account-button sxa:card-button sxa:card-button--add"
840
+ onClick={() => onAdd({
841
+ product_id: item.product_id,
842
+ variant_id: variant.variant_id,
843
+ quantity: RESELLER_QUANTITY_MIN_UNITS,
844
+ title: item.title,
845
+ variant_title: variant.title,
846
+ })}
847
+ >
848
+ {t('reseller.add')}
849
+ </button>
850
+ ) : (
851
+ <span className="sxa:text-micro sxa:text-muted">{t('common.unavailable')}</span>
852
+ )}
853
+ </li>
854
+ );
855
+ }
856
+
857
+ /* --- Basket and quote ----------------------------------------------------- */
858
+
859
+ /**
860
+ * A typed quantity, as whole units.
861
+ *
862
+ * `ResellerOrderItemSchema` is `.int()`, and it rejects the WHOLE basket over
863
+ * one fractional line rather than that line alone. The quote button is not a
864
+ * form submit either, so the number input's implicit step never runs and a
865
+ * typed `1.5` would travel all the way to that refusal. `Math.trunc`, never a
866
+ * bare `Number`.
867
+ */
868
+ function wholesaleQuantity(raw: string): number {
869
+ const units = Math.trunc(Number(raw));
870
+ if (!Number.isFinite(units) || units < RESELLER_QUANTITY_MIN_UNITS) {
871
+ return RESELLER_QUANTITY_MIN_UNITS;
872
+ }
873
+ return Math.min(RESELLER_QUANTITY_MAX_UNITS, units);
874
+ }
875
+
876
+ function WholesaleBasket({
877
+ basket,
878
+ priced,
879
+ onBasketChange,
880
+ onPriced,
881
+ }: {
882
+ basket: BasketLine[];
883
+ priced: PricedBasket | null;
884
+ onBasketChange: (basket: BasketLine[]) => void;
885
+ onPriced: (priced: PricedBasket) => void;
886
+ }) {
887
+ const [busy, setBusy] = useState(false);
888
+ const [error, setError] = useState<string | null>(null);
889
+
890
+ const items: ResellerOrderItemInput[] = basket.map(({ product_id, variant_id, quantity }) => ({
891
+ product_id,
892
+ variant_id,
893
+ quantity,
894
+ }));
895
+ const quoteIsCurrent = priced !== null && quoteMatchesBasket(priced.basketKey, items);
896
+
897
+ const price = async () => {
898
+ setBusy(true);
899
+ setError(null);
900
+ const result = await callCustomerApi((sdk) => sdk.quoteResellerOrder(items));
901
+ setBusy(false);
902
+
903
+ if (!result.success) {
904
+ setError(result.message?.trim() || t('common.genericError'));
905
+ return;
906
+ }
907
+ const narrowed = narrowResellerQuote(result.data);
908
+ if (!narrowed.ok) {
909
+ setError(t('common.genericError'));
910
+ return;
911
+ }
912
+ onPriced({ basketKey: resellerBasketKey(items), quote: narrowed.data });
913
+ };
914
+
915
+ return (
916
+ <section>
917
+ <AccountSectionTitle>{t('reseller.basketTitle')}</AccountSectionTitle>
918
+ <AccountCard className="sxa:mt-4 sxa:p-6">
919
+ {basket.length === 0 ? (
920
+ <p className="sxa:text-small sxa:text-muted">{t('reseller.basketEmpty')}</p>
921
+ ) : (
922
+ <>
923
+ <ul className="sxa:space-y-3">
924
+ {basket.map((line) => (
925
+ <li
926
+ key={`${line.product_id}:${line.variant_id ?? ''}`}
927
+ className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-3"
928
+ >
929
+ <div className="sxa:min-w-0 sxa:flex-1">
930
+ <p className="sxa:truncate sxa:text-small sxa:font-medium">{line.title}</p>
931
+ {line.variant_title ? (
932
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{line.variant_title}</p>
933
+ ) : null}
934
+ </div>
935
+ <label className="sxa:flex sxa:items-center sxa:gap-2 sxa:text-micro sxa:text-muted">
936
+ {t('reseller.quantity')}
937
+ <input
938
+ type="number"
939
+ min={RESELLER_QUANTITY_MIN_UNITS}
940
+ max={RESELLER_QUANTITY_MAX_UNITS}
941
+ step={1}
942
+ value={line.quantity}
943
+ disabled={busy}
944
+ className={`${FIELD} sxa:w-24 sxa:tabular-nums`}
945
+ onChange={(event) => onBasketChange(basket.map((entry) => (
946
+ entry === line
947
+ ? { ...entry, quantity: wholesaleQuantity(event.target.value) }
948
+ : entry
949
+ )))}
950
+ />
951
+ </label>
952
+ <button
953
+ type="button"
954
+ disabled={busy}
955
+ className="sxa:text-micro sxa:font-medium sxa:text-muted sxa:transition-colors sxa:hover:text-error sxa:disabled:opacity-50"
956
+ onClick={() => onBasketChange(basket.filter((entry) => entry !== line))}
957
+ >
958
+ {t('favorites.remove')}
959
+ </button>
960
+ </li>
961
+ ))}
962
+ </ul>
963
+
964
+ <div className="sxa:mt-5 sxa:border-t sxa:pt-4">
965
+ {/*
966
+ The quoted total is the only total this panel knows. A basket
967
+ that has moved since it was priced shows NONE — the previous
968
+ total belongs to a basket that no longer exists, and printing it
969
+ beside the new one is how a buyer plans against a price the
970
+ producer will not honour.
971
+ */}
972
+ {quoteIsCurrent && priced ? (
973
+ <dl className="sxa:space-y-2">
974
+ <div className="sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
975
+ <dt className="sxa:text-muted">{t('orders.subtotal')}</dt>
976
+ <dd className="sxa:font-medium sxa:tabular-nums">
977
+ {formatAccountAmount(priced.quote.subtotal, priced.quote.currency)}
978
+ </dd>
979
+ </div>
980
+ {Number(priced.quote.volume_discount_total) > 0 ? (
981
+ <div className="sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
982
+ <dt className="sxa:text-success">{t('orders.discount')}</dt>
983
+ <dd className="sxa:font-medium sxa:tabular-nums sxa:text-success">
984
+ {formatAccountAmount(
985
+ priced.quote.volume_discount_total,
986
+ priced.quote.currency,
987
+ )}
988
+ </dd>
989
+ </div>
990
+ ) : null}
991
+ <div className="sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:border-t sxa:pt-3">
992
+ <dt className="sxa:text-small sxa:font-semibold">{t('orders.total')}</dt>
993
+ <dd className="sxa:text-h2 sxa:font-semibold sxa:tabular-nums">
994
+ {formatAccountAmount(priced.quote.total, priced.quote.currency)}
995
+ </dd>
996
+ </div>
997
+ </dl>
998
+ ) : (
999
+ <p className="sxa:text-small sxa:text-muted">{t('reseller.basketNeedsQuote')}</p>
1000
+ )}
1001
+
1002
+ <button
1003
+ type="button"
1004
+ disabled={busy}
1005
+ aria-busy={busy || undefined}
1006
+ className="sx-account-cta sxa:cta-button sxa:mt-4 sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
1007
+ onClick={() => void price()}
1008
+ >
1009
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
1010
+ {t('reseller.quote')}
1011
+ </button>
1012
+
1013
+ {/*
1014
+ Where "Place order" would have been. The edge allowlists no
1015
+ wholesale order-placement route, so this says where the order is
1016
+ actually placed instead of offering a button that 404s.
1017
+ */}
1018
+ <p className="sxa:mt-4 sxa:rounded-input sxa:border sxa:bg-bg sxa:p-5 sxa:text-small sxa:leading-relaxed sxa:text-muted">
1019
+ {t('reseller.orderElsewhere')}
1020
+ </p>
1021
+ </div>
1022
+ </>
1023
+ )}
1024
+ {error ? <AccountFieldError message={error} /> : null}
1025
+ </AccountCard>
1026
+ </section>
1027
+ );
1028
+ }
1029
+
1030
+ /* --- Order history -------------------------------------------------------- */
1031
+
1032
+ function WholesaleOrders() {
1033
+ const [selected, setSelected] = useState<string | null>(null);
1034
+ const { state, moreBusy, moreError, loadMore, reload } = usePagedList<ResellerOrderListItem>(
1035
+ 'reseller-orders',
1036
+ async (page) => {
1037
+ const response = await callCustomerApi((sdk) => sdk.resellerOrders({
1038
+ page,
1039
+ per_page: PAGE_SIZE,
1040
+ }));
1041
+ if (!response.success) {
1042
+ return { ok: false, message: response.message?.trim() || t('common.genericError') };
1043
+ }
1044
+ const narrowed = narrowResellerOrders(response.data);
1045
+ if (!narrowed.ok) return { ok: false, message: t('common.genericError') };
1046
+
1047
+ return {
1048
+ ok: true,
1049
+ slice: {
1050
+ items: narrowed.data.items,
1051
+ page: narrowed.data.page,
1052
+ perPage: narrowed.data.per_page,
1053
+ total: narrowed.data.total,
1054
+ },
1055
+ };
1056
+ },
1057
+ );
1058
+
1059
+ if (selected !== null) {
1060
+ return <WholesaleOrderDetail uniqid={selected} onBack={() => setSelected(null)} />;
1061
+ }
1062
+
1063
+ const heading = <AccountSectionTitle>{t('reseller.ordersTitle')}</AccountSectionTitle>;
1064
+
1065
+ if (state.status === 'loading') {
1066
+ return (
1067
+ <section>
1068
+ {heading}
1069
+ <div className="sxa:mt-4">
1070
+ <AccountRowsSkeleton label={t('orders.loading')} />
1071
+ </div>
1072
+ </section>
1073
+ );
1074
+ }
1075
+ if (state.status === 'error') {
1076
+ return (
1077
+ <section>
1078
+ {heading}
1079
+ <div className="sxa:mt-4">
1080
+ <AccountError message={state.message} onRetry={reload} />
1081
+ </div>
1082
+ </section>
1083
+ );
1084
+ }
1085
+
1086
+ const { slice } = state;
1087
+
1088
+ return (
1089
+ <section>
1090
+ {heading}
1091
+ {slice.items.length === 0 ? (
1092
+ <div className="sxa:mt-4">
1093
+ <AccountEmpty
1094
+ title={t('reseller.ordersEmptyTitle')}
1095
+ body={t('reseller.ordersEmptyBody')}
1096
+ />
1097
+ </div>
1098
+ ) : (
1099
+ <ul className="sxa:mt-4 sxa:space-y-3">
1100
+ {slice.items.map((order) => (
1101
+ <li key={order.uniqid}>
1102
+ <button
1103
+ type="button"
1104
+ onClick={() => setSelected(order.uniqid)}
1105
+ className="sxa:flex sxa:w-full sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:text-left sxa:shadow-card sxa:transition-colors sxa:hover:bg-raised"
1106
+ >
1107
+ <div className="sxa:min-w-0 sxa:flex-1">
1108
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">
1109
+ {t('orders.fallbackTitle', { id: order.uniqid })}
1110
+ </p>
1111
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
1112
+ {formatAccountDate(order.created_at)}
1113
+ {' · '}
1114
+ {t('reseller.lineQuantity', { count: order.item_count })}
1115
+ </p>
1116
+ </div>
1117
+ <span className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
1118
+ {formatAccountAmount(order.total, order.currency)}
1119
+ </span>
1120
+ <StatusChip status={order.status} />
1121
+ </button>
1122
+ </li>
1123
+ ))}
1124
+ </ul>
1125
+ )}
1126
+ <MorePages slice={slice} busy={moreBusy} error={moreError} onMore={loadMore} />
1127
+ </section>
1128
+ );
1129
+ }
1130
+
1131
+ function WholesaleOrderDetail({ uniqid, onBack }: { uniqid: string; onBack: () => void }) {
1132
+ const { state, reload } = useAccountResource(`reseller-order:${uniqid}`, () =>
1133
+ callCustomerApi((sdk) => sdk.resellerOrder(uniqid)));
1134
+
1135
+ const back = <AccountBackButton onClick={onBack} label={t('orders.allOrders')} />;
1136
+
1137
+ if (state.status === 'loading') {
1138
+ return (
1139
+ <section>
1140
+ {back}
1141
+ <div className="sxa:mt-5">
1142
+ <AccountRowsSkeleton label={t('orders.loadingOne')} />
1143
+ </div>
1144
+ </section>
1145
+ );
1146
+ }
1147
+ if (state.status === 'error') {
1148
+ return (
1149
+ <section>
1150
+ {back}
1151
+ <div className="sxa:mt-5">
1152
+ <AccountError message={state.message} onRetry={reload} />
1153
+ </div>
1154
+ </section>
1155
+ );
1156
+ }
1157
+
1158
+ const narrowed = narrowResellerOrderDetail(state.data);
1159
+ if (!narrowed.ok) {
1160
+ return (
1161
+ <section>
1162
+ {back}
1163
+ <div className="sxa:mt-5">
1164
+ <AccountError message={t('common.genericError')} onRetry={reload} />
1165
+ </div>
1166
+ </section>
1167
+ );
1168
+ }
1169
+ const order = narrowed.data;
1170
+
1171
+ return (
1172
+ <section>
1173
+ {back}
1174
+ <div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
1175
+ <div className="sxa:min-w-0">
1176
+ <h2 className="sxa:font-heading sxa:truncate sxa:text-h3 sxa:font-semibold">
1177
+ {t('orders.detailTitle', { id: order.uniqid })}
1178
+ </h2>
1179
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">
1180
+ {formatAccountDateTime(order.created_at)}
1181
+ </p>
1182
+ </div>
1183
+ <StatusChip status={order.status} />
1184
+ </div>
1185
+
1186
+ <AccountCard className="sxa:mt-5 sxa:p-6">
1187
+ <div className="sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
1188
+ <span className="sxa:text-muted">{t('orders.total')}</span>
1189
+ <span className="sxa:font-semibold sxa:tabular-nums">
1190
+ {formatAccountAmount(order.total, order.currency)}
1191
+ </span>
1192
+ </div>
1193
+ {order.refunded_amount !== null ? (
1194
+ <div className="sxa:mt-2 sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
1195
+ <span className="sxa:text-muted">{t('reseller.refunded')}</span>
1196
+ <span className="sxa:font-semibold sxa:tabular-nums">
1197
+ {formatAccountAmount(order.refunded_amount, order.currency)}
1198
+ </span>
1199
+ </div>
1200
+ ) : null}
1201
+ <div className="sxa:mt-2 sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
1202
+ <span className="sxa:text-muted">{t('reseller.tier')}</span>
1203
+ <span className="sxa:font-medium">
1204
+ {order.tier_name}
1205
+ {' · '}
1206
+ {`${order.tier_discount_percent}%`}
1207
+ </span>
1208
+ </div>
1209
+ </AccountCard>
1210
+
1211
+ <ul className="sxa:mt-4 sxa:space-y-3">
1212
+ {order.lines.map((line, index) => (
1213
+ <li key={`${line.product_id ?? ''}:${line.variant_id ?? ''}:${index}`}>
1214
+ <OrderLine line={line} currency={order.currency} />
1215
+ </li>
1216
+ ))}
1217
+ </ul>
1218
+ </section>
1219
+ );
1220
+ }
1221
+
1222
+ function OrderLine({ line, currency }: { line: ResellerOrderLine; currency: string }) {
1223
+ return (
1224
+ <AccountCard className="sxa:p-5">
1225
+ <div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
1226
+ <div className="sxa:min-w-0">
1227
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">{line.product_title}</p>
1228
+ {line.variant_title ? (
1229
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{line.variant_title}</p>
1230
+ ) : null}
1231
+ </div>
1232
+ <p className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
1233
+ {formatAccountAmount(line.total, currency)}
1234
+ </p>
1235
+ </div>
1236
+ <p className="sxa:mt-2 sxa:text-micro sxa:text-muted">
1237
+ {t('reseller.lineQuantity', { count: line.quantity })}
1238
+ {' · '}
1239
+ {line.delivered ? t('delivery.DELIVERED') : t('delivery.PENDING')}
1240
+ {line.delivered_at !== null ? ` · ${formatAccountDateTime(line.delivered_at)}` : ''}
1241
+ </p>
1242
+
1243
+ {line.serials.length > 0 ? (
1244
+ <div className="sxa:mt-4 sxa:space-y-2 sxa:border-t sxa:pt-4">
1245
+ <p className="sxa:text-micro sxa:font-semibold sxa:text-muted">{t('reseller.serials')}</p>
1246
+ {line.serials.map((serial) => (
1247
+ <div key={serial} className="sxa:flex sxa:items-center sxa:gap-3">
1248
+ <p className="sxa:min-w-0 sxa:flex-1 sxa:truncate sxa:font-mono sxa:text-micro">{serial}</p>
1249
+ <CopyButton value={serial} />
1250
+ </div>
1251
+ ))}
1252
+ </div>
1253
+ ) : null}
1254
+ </AccountCard>
1255
+ );
1256
+ }
1257
+
1258
+ /* --- API keys ------------------------------------------------------------- */
1259
+
1260
+ function ResellerApiKeys() {
1261
+ const { state, reload } = useAccountResource('reseller-api-keys', () =>
1262
+ callCustomerApi((sdk) => sdk.resellerApiKeys()));
1263
+ const [error, setError] = useState<string | null>(null);
1264
+
1265
+ const heading = <AccountSectionTitle>{t('reseller.apiKeysTitle')}</AccountSectionTitle>;
1266
+
1267
+ /*
1268
+ * Where "Create key" would have been. Issuing a key is not an allowlisted
1269
+ * route at the edge, and a key the buyer cannot mint is not a control that
1270
+ * belongs on the page — so this names where they come from instead.
1271
+ */
1272
+ const elsewhere = (
1273
+ <p className="sxa:mt-4 sxa:rounded-input sxa:border sxa:bg-bg sxa:p-5 sxa:text-small sxa:leading-relaxed sxa:text-muted">
1274
+ {t('reseller.keysElsewhere')}
1275
+ </p>
1276
+ );
1277
+
1278
+ if (state.status === 'loading') {
1279
+ return (
1280
+ <section>
1281
+ {heading}
1282
+ <div className="sxa:mt-4">
1283
+ <AccountRowsSkeleton rows={2} label={t('overview.loading')} />
1284
+ </div>
1285
+ {elsewhere}
1286
+ </section>
1287
+ );
1288
+ }
1289
+ if (state.status === 'error') {
1290
+ return (
1291
+ <section>
1292
+ {heading}
1293
+ <div className="sxa:mt-4">
1294
+ <AccountError message={state.message} onRetry={reload} />
1295
+ </div>
1296
+ {elsewhere}
1297
+ </section>
1298
+ );
1299
+ }
1300
+
1301
+ const narrowed = narrowResellerApiKeys(state.data);
1302
+ if (!narrowed.ok) {
1303
+ return (
1304
+ <section>
1305
+ {heading}
1306
+ <div className="sxa:mt-4">
1307
+ <AccountError message={t('common.genericError')} onRetry={reload} />
1308
+ </div>
1309
+ {elsewhere}
1310
+ </section>
1311
+ );
1312
+ }
1313
+ const keys = narrowed.data;
1314
+
1315
+ return (
1316
+ <section>
1317
+ {heading}
1318
+ {error ? (
1319
+ <div className="sxa:mt-4">
1320
+ <AccountError message={error} />
1321
+ </div>
1322
+ ) : null}
1323
+
1324
+ {keys.length === 0 ? (
1325
+ <p className="sxa:mt-4 sxa:text-small sxa:text-muted">{t('reseller.apiKeysEmpty')}</p>
1326
+ ) : (
1327
+ <ul className="sxa:mt-4 sxa:space-y-3">
1328
+ {keys.map((key) => (
1329
+ <li
1330
+ key={key.id}
1331
+ className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
1332
+ >
1333
+ <div className="sxa:min-w-0 sxa:flex-1">
1334
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">
1335
+ {key.name}
1336
+ {' · '}
1337
+ <span className="sxa:font-mono">{key.key_last4}</span>
1338
+ </p>
1339
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
1340
+ {key.last_used_at
1341
+ ? t('reseller.lastUsed', {
1342
+ date: formatAccountDateTime(key.last_used_at),
1343
+ })
1344
+ : t('reseller.neverUsed')}
1345
+ </p>
1346
+ </div>
1347
+
1348
+ {/* A revoked key stays on the list, stamped. Removing the row
1349
+ would hide the fact that the key ever existed.
1350
+
1351
+ Read-only: revoking is irreversible and breaks the buyer's
1352
+ external integrations at once, and this page is
1353
+ merchant-authored JavaScript — a hostile theme could revoke
1354
+ every key silently, past a confirmation it renders itself.
1355
+ The route is off the worker's allowlist for that reason. */}
1356
+ {key.revoked_at !== null ? <StatusChip status="REVOKED" /> : null}
1357
+ </li>
1358
+ ))}
1359
+ </ul>
1360
+ )}
1361
+
1362
+ {elsewhere}
1363
+ </section>
1364
+ );
1365
+ }