@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,791 @@
1
+ /**
2
+ * Order history, and one order in full.
3
+ *
4
+ * Pagination is PAGE based because the producer is: it reads `page` and `limit`
5
+ * and answers with `has_more`. There is no cursor to carry, and inventing one
6
+ * would be ignored upstream and quietly return page one for ever.
7
+ *
8
+ * The invoice search and the status filter travel WITH the request. The list is
9
+ * paged, so filtering the rows already in hand would search only the ten rows
10
+ * the buyer happens to be looking at and call the rest of their history missing.
11
+ *
12
+ * List and detail are two views of the same panel, chosen by the route — see
13
+ * `account-route.ts`. Order detail is not a page of its own.
14
+ */
15
+
16
+ import { useState, type ReactNode } from 'react';
17
+ import { Link } from 'react-router-dom';
18
+ import { paymentMethodLabel } from './account-payment-method';
19
+ import { accountOrderHref, accountTabHref } from '@shoppexio/storefront-react';
20
+ import {
21
+ WARRANTY_CLAIM_MESSAGE_MAX,
22
+ canClaimWarranty,
23
+ warrantiesForOrder,
24
+ warrantyClaimKey,
25
+ warrantyClaimMessage,
26
+ warrantyCoverage,
27
+ type WarrantyCoverage,
28
+ warrantyDate,
29
+ } from './account-warranties';
30
+ import { callCustomerApi, safeExternalHref } from './customer-account';
31
+ import type { SdkCustomerDeliverySummary, SdkCustomerWarranty } from '@shoppexio/storefront-react';
32
+ import { t, tOptional } from './i18n';
33
+ import {
34
+ ArrowLeftIcon,
35
+ ArrowRightIcon,
36
+ ChevronDownIcon,
37
+ ReceiptIcon,
38
+ ShieldIcon,
39
+ } from './icons';
40
+ import {
41
+ AccountBackLink,
42
+ AccountCard,
43
+ AccountEmpty,
44
+ AccountError,
45
+ AccountFieldError,
46
+ AccountNotice,
47
+ AccountRowsSkeleton,
48
+ AccountSectionTitle,
49
+ BusySpinner,
50
+ StatusChip,
51
+ formatAccountAmount,
52
+ formatAccountDate,
53
+ formatInvoiceAmount,
54
+ useAccountResource,
55
+ } from './shared';
56
+
57
+ const ORDERS_PER_PAGE = 10;
58
+
59
+ /** The producer's invoice-status enum, in the order the hosted portal lists it. */
60
+ const STATUS_FILTERS = [
61
+ 'COMPLETED',
62
+ 'PENDING',
63
+ 'PROCESSING',
64
+ 'REFUNDED',
65
+ 'DISPUTED',
66
+ 'EXPIRED',
67
+ ] as const;
68
+
69
+ const STATUS_LABEL_KEYS: Record<(typeof STATUS_FILTERS)[number], string> = {
70
+ COMPLETED: 'orders.statusCompleted',
71
+ PENDING: 'orders.statusPending',
72
+ PROCESSING: 'orders.statusProcessing',
73
+ REFUNDED: 'orders.statusRefunded',
74
+ DISPUTED: 'orders.statusDisputed',
75
+ EXPIRED: 'orders.statusExpired',
76
+ };
77
+
78
+ /** The template's field skin, shared with the checkout and contact forms. */
79
+ const FIELD =
80
+ 'sx-account-field sxa:w-full 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';
81
+
82
+ export function moreItemsLabel(count: number): string {
83
+ return count === 1 ? t('orders.andMoreOne') : t('orders.andMoreOther', { count });
84
+ }
85
+
86
+ /**
87
+ * What to call an order in a list.
88
+ *
89
+ * The first line's product title, and the count of the lines behind it. A line
90
+ * whose title the producer sent as null is named by the order's own id rather
91
+ * than by a product this template guessed at.
92
+ */
93
+ export function orderRowTitle(
94
+ firstTitle: string | null | undefined,
95
+ lineCount: number,
96
+ uniqid: string,
97
+ ): string {
98
+ const head = firstTitle ?? t('orders.fallbackTitle', { id: uniqid });
99
+ return lineCount > 1 ? `${head} ${moreItemsLabel(lineCount - 1)}` : head;
100
+ }
101
+
102
+ /** One order, as a row that opens it. Both this panel and the overview use it. */
103
+ export function OrderRow({
104
+ uniqid,
105
+ title,
106
+ createdAt,
107
+ amount,
108
+ status,
109
+ }: {
110
+ uniqid: string;
111
+ title: string;
112
+ createdAt: string;
113
+ amount: string;
114
+ status: string;
115
+ }) {
116
+ return (
117
+ <Link to={accountOrderHref(uniqid)} className="sxa:block sxa:transition-opacity sxa:hover:opacity-80">
118
+ <AccountCard className="sxa:flex sxa:items-center sxa:gap-4 sxa:px-5 sxa:py-4">
119
+ <ReceiptIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
120
+ <div className="sxa:min-w-0 sxa:flex-1">
121
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">{title}</p>
122
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{formatAccountDate(createdAt)}</p>
123
+ </div>
124
+ <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">{amount}</span>
125
+ <StatusChip status={status} />
126
+ </AccountCard>
127
+ </Link>
128
+ );
129
+ }
130
+
131
+ export function AccountOrders() {
132
+ const [page, setPage] = useState(1);
133
+ const [status, setStatus] = useState('');
134
+ const [search, setSearch] = useState('');
135
+ /** What was last SENT, as against what is currently being typed. */
136
+ const [applied, setApplied] = useState({ status: '', search: '' });
137
+
138
+ const { state, reload } = useAccountResource(
139
+ `orders:${page}:${applied.status}:${applied.search}`,
140
+ () => callCustomerApi((sdk) => sdk.orders({
141
+ page,
142
+ limit: ORDERS_PER_PAGE,
143
+ ...(applied.status ? { status: applied.status } : {}),
144
+ ...(applied.search ? { search: applied.search } : {}),
145
+ })),
146
+ );
147
+
148
+ // A changed filter is a different list. Staying on page four of the old one
149
+ // would ask for a page the new list may not have and answer with nothing.
150
+ const applyFilters = (next: { status?: string; search?: string }) => {
151
+ const merged = { status: next.status ?? status, search: next.search ?? search };
152
+ setStatus(merged.status);
153
+ setSearch(merged.search);
154
+ setApplied(merged);
155
+ setPage(1);
156
+ };
157
+
158
+ const filtering = applied.status !== '' || applied.search !== '';
159
+
160
+ const filterBar = (
161
+ <form
162
+ className="sxa:mb-5 sxa:flex sxa:flex-col sxa:gap-2 sxa:sm:flex-row"
163
+ onSubmit={(event) => {
164
+ event.preventDefault();
165
+ applyFilters({});
166
+ }}
167
+ >
168
+ <input
169
+ type="search"
170
+ value={search}
171
+ placeholder={t('orders.searchPlaceholder')}
172
+ aria-label={t('orders.search')}
173
+ onChange={(event) => setSearch(event.target.value)}
174
+ className={`sxa:min-w-0 sxa:flex-1 ${FIELD} sxa:h-10 sxa:rounded-input`}
175
+ />
176
+ <div className="sxa:relative">
177
+ <select
178
+ value={status}
179
+ aria-label={t('orders.status')}
180
+ onChange={(event) => applyFilters({ status: event.target.value })}
181
+ className="sxa:h-10 sxa:w-full sxa:appearance-none sxa:border sxa:bg-surface sxa:pr-9 sxa:pl-4 sxa:text-small sxa:focus:outline-none sxa:sm:w-auto sxa:rounded-input"
182
+ >
183
+ <option value="">{t('orders.statusAll')}</option>
184
+ {STATUS_FILTERS.map((value) => (
185
+ <option key={value} value={value}>{t(STATUS_LABEL_KEYS[value])}</option>
186
+ ))}
187
+ </select>
188
+ <div className="sxa:pointer-events-none sxa:absolute sxa:inset-y-0 sxa:right-0 sxa:flex sxa:items-center sxa:px-3 sxa:text-muted">
189
+ <ChevronDownIcon className="sxa:h-4 sxa:w-4" />
190
+ </div>
191
+ </div>
192
+ <button type="submit" className="sx-account-button sxa:card-button sxa:card-button--add sxa:shrink-0">
193
+ {t('orders.search')}
194
+ </button>
195
+ </form>
196
+ );
197
+
198
+ if (state.status === 'loading') {
199
+ return (
200
+ <div>
201
+ {filterBar}
202
+ <AccountRowsSkeleton rows={4} label={t('orders.loading')} />
203
+ </div>
204
+ );
205
+ }
206
+
207
+ if (state.status === 'error') {
208
+ return <AccountError message={state.message} onRetry={reload} />;
209
+ }
210
+
211
+ const { invoices, pagination } = state.data;
212
+
213
+ if (invoices.length === 0) {
214
+ // A filter that matched nothing is not an empty account. Telling a buyer
215
+ // they have never ordered anything because they mistyped an invoice id is a
216
+ // lie the control they just used caused.
217
+ if (filtering && pagination.page === 1) {
218
+ return (
219
+ <div>
220
+ {filterBar}
221
+ <AccountEmpty title={t('orders.movedTitle')} body={t('orders.emptyFiltered')} />
222
+ </div>
223
+ );
224
+ }
225
+
226
+ // An empty page one means the account has no orders. A later page coming
227
+ // back empty means the list moved underneath the buyer, and the way out of
228
+ // that is page one — not a message that pretends they never bought anything.
229
+ return pagination.page > 1 ? (
230
+ <AccountEmpty
231
+ title={t('orders.movedTitle')}
232
+ body={t('orders.movedBody')}
233
+ action={(
234
+ <button
235
+ type="button"
236
+ className="sx-account-button sxa:card-button sxa:card-button--add"
237
+ onClick={() => setPage(1)}
238
+ >
239
+ {t('orders.backToFirstPage')}
240
+ </button>
241
+ )}
242
+ />
243
+ ) : (
244
+ <AccountEmpty
245
+ title={t('orders.emptyTitle')}
246
+ body={t('orders.emptyBody')}
247
+ action={(
248
+ <Link to="/all-products" className="sx-account-button sxa:card-button sxa:card-button--add">
249
+ {t('common.browseStore')}
250
+ </Link>
251
+ )}
252
+ />
253
+ );
254
+ }
255
+
256
+ return (
257
+ <div>
258
+ {filterBar}
259
+ <ul className="sxa:space-y-3">
260
+ {invoices.map((order) => (
261
+ <li key={order.uniqid}>
262
+ <OrderRow
263
+ uniqid={order.uniqid}
264
+ title={orderRowTitle(
265
+ order.lineItems[0]?.productTitle,
266
+ order.lineItems.length,
267
+ order.uniqid,
268
+ )}
269
+ createdAt={order.createdAt}
270
+ amount={formatInvoiceAmount(order.totalDisplay, order.total, order.currency)}
271
+ status={order.status}
272
+ />
273
+ </li>
274
+ ))}
275
+ </ul>
276
+
277
+ {pagination.page > 1 || pagination.has_more ? (
278
+ <nav className="sxa:mt-6 sxa:flex sxa:items-center sxa:justify-between sxa:gap-4" aria-label={t('orders.pagesLabel')}>
279
+ <button
280
+ type="button"
281
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-40"
282
+ disabled={pagination.page <= 1}
283
+ onClick={() => setPage((current) => Math.max(1, current - 1))}
284
+ >
285
+ <ArrowLeftIcon className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" />
286
+ {t('orders.previous')}
287
+ </button>
288
+ <p className="sxa:text-small sxa:tabular-nums sxa:text-muted">
289
+ {t('orders.page', { page: pagination.page })}
290
+ </p>
291
+ <button
292
+ type="button"
293
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-40"
294
+ disabled={!pagination.has_more}
295
+ onClick={() => setPage((current) => current + 1)}
296
+ >
297
+ {t('orders.next')}
298
+ <ArrowRightIcon className="sxa:ml-1.5 sxa:h-3.5 sxa:w-3.5" />
299
+ </button>
300
+ </nav>
301
+ ) : null}
302
+ </div>
303
+ );
304
+ }
305
+
306
+ export function AccountOrderDetail({ orderId }: { orderId: string }) {
307
+ const { state, reload } = useAccountResource(`order:${orderId}`, () =>
308
+ callCustomerApi((sdk) => sdk.order(orderId)));
309
+
310
+ const backLink = <AccountBackLink to={accountTabHref('orders')} label={t('orders.allOrders')} />;
311
+
312
+ if (state.status === 'loading') {
313
+ return (
314
+ <div>
315
+ {backLink}
316
+ <div className="sxa:mt-5">
317
+ <AccountRowsSkeleton rows={3} label={t('orders.loadingOne')} />
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ if (state.status === 'error') {
324
+ return (
325
+ <div>
326
+ {backLink}
327
+ <div className="sxa:mt-5">
328
+ <AccountError message={state.message} onRetry={reload} />
329
+ </div>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ const order = state.data;
335
+
336
+ return (
337
+ <div>
338
+ {backLink}
339
+
340
+ <div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
341
+ <div>
342
+ <AccountSectionTitle>{t('orders.detailTitle', { id: order.uniqid })}</AccountSectionTitle>
343
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{formatAccountDate(order.createdAt)}</p>
344
+ </div>
345
+ <StatusChip status={order.status} />
346
+ </div>
347
+
348
+ {/*
349
+ The server's own sentence about this status, printed verbatim and only
350
+ when it sent one. It is the single place a buyer learns that a paid
351
+ order did not deliver, or why a refund is still moving — no storefront
352
+ can reconstruct that from the status word alone.
353
+ */}
354
+ {order.statusDetails ? (
355
+ <p className="sxa:mt-3 sxa:text-small sxa:leading-relaxed sxa:text-muted">{order.statusDetails}</p>
356
+ ) : null}
357
+
358
+ <ul className="sxa:mt-6 sxa:space-y-3">
359
+ {order.lineItems.map((item) => (
360
+ <li key={item.id}>
361
+ <AccountCard className="sxa:px-5 sxa:py-4">
362
+ <div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
363
+ <div className="sxa:min-w-0">
364
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">{item.productTitle}</p>
365
+ {item.variantTitle ? (
366
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{item.variantTitle}</p>
367
+ ) : null}
368
+ <p className="sxa:mt-1.5 sxa:text-micro sxa:tabular-nums sxa:text-muted">
369
+ {item.quantity} × {formatAccountAmount(item.unitPrice, order.currency)}
370
+ </p>
371
+ </div>
372
+ <div className="sxa:text-right">
373
+ <p className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
374
+ {formatAccountAmount(item.total, order.currency)}
375
+ </p>
376
+ {/*
377
+ Fulfillment is decided per LINE and server-side, and it is
378
+ independent of payment: a completed, fully paid invoice can
379
+ still carry a line that never reached the buyer. This chip is
380
+ the only thing on the page that says so, which is why it is
381
+ rendered for every line rather than only for the unhappy ones.
382
+ */}
383
+ <p className="sxa:mt-1.5">
384
+ <DeliveryChip status={item.deliveryStatus} />
385
+ </p>
386
+ </div>
387
+ </div>
388
+
389
+ {/*
390
+ What the shop actually handed over for this line, when it handed
391
+ over anything. The presence of a summary is NOT a delivery state
392
+ — the chip above owns that — and nothing here is derived from the
393
+ payment status.
394
+ */}
395
+ {item.deliverySummary ? <DeliveredItems summary={item.deliverySummary} /> : null}
396
+ </AccountCard>
397
+ </li>
398
+ ))}
399
+ </ul>
400
+
401
+ <AccountCard className="sxa:mt-6 sxa:px-5 sxa:py-5">
402
+ <dl className="sxa:space-y-2.5">
403
+ <SummaryRow label={t('orders.subtotal')}>
404
+ {formatAccountAmount(order.subtotal, order.currency)}
405
+ </SummaryRow>
406
+ {/*
407
+ `discount` is the USD-normalized figure and `discountDisplay` the
408
+ same amount in the buyer's currency, exactly like the total. Display
409
+ first is the producer's rule; the row itself only appears when the
410
+ order carried a discount at all.
411
+ */}
412
+ {Number(order.discountDisplay ?? order.discount) > 0 ? (
413
+ <SummaryRow label={t('orders.discount')}>
414
+ −{formatInvoiceAmount(order.discountDisplay, order.discount, order.currency)}
415
+ </SummaryRow>
416
+ ) : null}
417
+ {Number(order.tax) > 0 ? (
418
+ <SummaryRow label={t('orders.tax')}>
419
+ {formatAccountAmount(order.tax, order.currency)}
420
+ </SummaryRow>
421
+ ) : null}
422
+ <SummaryRow label={t('orders.total')} emphasis>
423
+ {formatInvoiceAmount(order.totalDisplay, order.total, order.currency)}
424
+ </SummaryRow>
425
+ {/*
426
+ The coin or the alternative method the buyer picked — never the
427
+ internal rail. `gateway` says NATIVE_CRYPTO where the buyer chose
428
+ USDT, which names a thing they never dealt with.
429
+ */}
430
+ {order.currentPayment ? (
431
+ <SummaryRow label={t('orders.paidWith')}>
432
+ {paymentMethodLabel(order.currentPayment)}
433
+ </SummaryRow>
434
+ ) : null}
435
+ </dl>
436
+ </AccountCard>
437
+
438
+ {/*
439
+ Below the receipt, not inside it. The lines and the totals are one
440
+ block — a warranty list between a row and the sum it belongs to breaks
441
+ a reading order buyers already know — and cover is what happens to this
442
+ order AFTER it was paid for.
443
+ */}
444
+ <OrderWarranties orderUniqid={order.uniqid} />
445
+ </div>
446
+ );
447
+ }
448
+
449
+ /**
450
+ * What the shop still covers on this order.
451
+ *
452
+ * The route filters on `invoices.uniqid` when it is handed one, and that is the
453
+ * id this order carries, so the read asks for this order's cover by name. The
454
+ * answer is still filtered here: there is no line-item id on that wire, a
455
+ * warranty belongs to the order rather than to the row that sold it, and a
456
+ * client that renders whatever a list contains cannot be wrong about whose
457
+ * order it is showing.
458
+ *
459
+ * Most orders carry none, which is why nothing is drawn while the read is in
460
+ * flight — a "Warranty" heading that appears and then disappears on every
461
+ * order without cover is worse than one that arrives a moment late. A failed
462
+ * read IS shown: it cannot be told apart from "no cover on this order", and
463
+ * the buyer who has cover is the one who would be left without it.
464
+ */
465
+ function OrderWarranties({ orderUniqid }: { orderUniqid: string }) {
466
+ const { state, reload } = useAccountResource(`warranties:${orderUniqid}`, () =>
467
+ callCustomerApi((sdk) => sdk.warranties(orderUniqid)));
468
+ /** Rows the server returned from a claim, replacing the ones it was read with. */
469
+ const [claimed, setClaimed] = useState<Record<string, SdkCustomerWarranty>>({});
470
+
471
+ if (state.status === 'loading') return null;
472
+
473
+ if (state.status === 'error') {
474
+ return (
475
+ <WarrantySection>
476
+ <AccountError message={state.message} onRetry={reload} />
477
+ </WarrantySection>
478
+ );
479
+ }
480
+
481
+ const warranties = warrantiesForOrder(state.data.warranties, orderUniqid)
482
+ .map((warranty) => claimed[warranty.uniqid] ?? warranty);
483
+
484
+ if (warranties.length === 0) return null;
485
+
486
+ return (
487
+ <WarrantySection>
488
+ <ul className="sxa:space-y-3">
489
+ {warranties.map((warranty) => (
490
+ <li key={warranty.uniqid}>
491
+ <WarrantyRow
492
+ warranty={warranty}
493
+ onClaimed={(updated) => setClaimed((rows) => ({ ...rows, [updated.uniqid]: updated }))}
494
+ />
495
+ </li>
496
+ ))}
497
+ </ul>
498
+ </WarrantySection>
499
+ );
500
+ }
501
+
502
+ function WarrantySection({ children }: { children: ReactNode }) {
503
+ return (
504
+ <section className="sxa:mt-8">
505
+ <AccountSectionTitle>{t('orders.warrantyTitle')}</AccountSectionTitle>
506
+ <div className="sxa:mt-4">{children}</div>
507
+ </section>
508
+ );
509
+ }
510
+
511
+ function WarrantyRow({
512
+ warranty,
513
+ onClaimed,
514
+ }: {
515
+ warranty: SdkCustomerWarranty;
516
+ onClaimed: (warranty: SdkCustomerWarranty) => void;
517
+ }) {
518
+ const [writing, setWriting] = useState(false);
519
+ const [message, setMessage] = useState('');
520
+ const [sending, setSending] = useState(false);
521
+ const [sent, setSent] = useState(false);
522
+ const [error, setError] = useState<string | null>(null);
523
+
524
+ const claimKey = warrantyClaimKey(warranty.claim_status);
525
+ const fieldId = `warranty-claim-${warranty.uniqid}`;
526
+
527
+ const send = async () => {
528
+ setSending(true);
529
+ setError(null);
530
+ const response = await callCustomerApi((sdk) => sdk.claimWarranty(
531
+ warranty.uniqid,
532
+ warrantyClaimMessage(message),
533
+ ));
534
+ setSending(false);
535
+
536
+ if (!response.success || response.data === undefined) {
537
+ setError(response.message?.trim() || t('orders.warrantyClaimFailed'));
538
+ return;
539
+ }
540
+
541
+ // The row is replaced by the one the server answered with, rather than
542
+ // edited in place: it is the only party that knows what the claim did.
543
+ setWriting(false);
544
+ setMessage('');
545
+ setSent(true);
546
+ onClaimed(response.data.warranty);
547
+ };
548
+
549
+ return (
550
+ <AccountCard className="sxa:px-5 sxa:py-4">
551
+ <div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
552
+ <div className="sxa:flex sxa:min-w-0 sxa:items-start sxa:gap-3">
553
+ <ShieldIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
554
+ <div className="sxa:min-w-0">
555
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">{warranty.product_title}</p>
556
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
557
+ {coverageLabel(warrantyCoverage(warranty))}
558
+ {claimKey ? ` · ${t(claimKey)}` : ''}
559
+ </p>
560
+ </div>
561
+ </div>
562
+
563
+ {/* Offered only while there is cover left and nothing has been claimed
564
+ against it. Both are the producer's facts, and neither is a button
565
+ that gets disabled instead of removed. */}
566
+ {canClaimWarranty(warranty) && !writing ? (
567
+ <button
568
+ type="button"
569
+ className="sx-account-button sxa:card-button sxa:card-button--add"
570
+ onClick={() => setWriting(true)}
571
+ >
572
+ {t('orders.warrantyClaimStart')}
573
+ </button>
574
+ ) : null}
575
+ </div>
576
+
577
+ {sent ? <AccountNotice message={t('orders.warrantyClaimDone')} /> : null}
578
+
579
+ {writing ? (
580
+ <div className="sxa:mt-4 sxa:border-t sxa:pt-4">
581
+ <label htmlFor={fieldId} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
582
+ {t('orders.warrantyClaimLabel')}
583
+ <span className="sxa:ml-2 sxa:text-micro sxa:font-normal sxa:text-muted">
584
+ {t('orders.warrantyClaimOptional')}
585
+ </span>
586
+ </label>
587
+ <textarea
588
+ id={fieldId}
589
+ rows={3}
590
+ value={message}
591
+ disabled={sending}
592
+ maxLength={WARRANTY_CLAIM_MESSAGE_MAX}
593
+ onChange={(event) => {
594
+ setMessage(event.target.value);
595
+ setError(null);
596
+ }}
597
+ className={`${FIELD} sxa:resize-none`}
598
+ />
599
+
600
+ <div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
601
+ <button
602
+ type="button"
603
+ disabled={sending}
604
+ aria-busy={sending || undefined}
605
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-9 sxa:items-center sxa:px-4 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
606
+ onClick={() => void send()}
607
+ >
608
+ {sending ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
609
+ {sending ? t('orders.warrantyClaimSending') : t('orders.warrantyClaimSend')}
610
+ </button>
611
+ <button
612
+ type="button"
613
+ disabled={sending}
614
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
615
+ onClick={() => {
616
+ setWriting(false);
617
+ setError(null);
618
+ }}
619
+ >
620
+ {t('common.cancel')}
621
+ </button>
622
+ </div>
623
+
624
+ {error ? <AccountFieldError message={error} /> : null}
625
+ </div>
626
+ ) : null}
627
+ </AccountCard>
628
+ );
629
+ }
630
+
631
+ /** Cover as a sentence. Cancelled cover names no date, because it has none. */
632
+ function coverageLabel(coverage: WarrantyCoverage): string {
633
+ switch (coverage.kind) {
634
+ case 'covered':
635
+ return t('orders.warrantyCoveredUntil', { date: formatAccountDate(warrantyDate(coverage.expiresAt)) });
636
+ case 'ended':
637
+ return t('orders.warrantyEnded', { date: formatAccountDate(warrantyDate(coverage.expiresAt)) });
638
+ case 'cancelled':
639
+ return t('orders.warrantyCancelled');
640
+ }
641
+ }
642
+
643
+ function SummaryRow({
644
+ label,
645
+ emphasis = false,
646
+ children,
647
+ }: {
648
+ label: string;
649
+ emphasis?: boolean;
650
+ children: ReactNode;
651
+ }) {
652
+ return (
653
+ <div
654
+ className={`sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-4 ${
655
+ emphasis ? 'sxa:border-t sxa:pt-3 sxa:text-body' : 'sxa:text-small'
656
+ }`}
657
+ >
658
+ <dt className={emphasis ? 'sxa:text-small sxa:font-semibold' : 'sxa:text-muted'}>{label}</dt>
659
+ <dd className={`sxa:tabular-nums ${emphasis ? 'sxa:text-h3 sxa:font-bold' : 'sxa:font-medium'}`}>
660
+ {children}
661
+ </dd>
662
+ </div>
663
+ );
664
+ }
665
+
666
+ /**
667
+ * The per-line fulfillment state, in a word.
668
+ *
669
+ * `tOptional` first: delivery status is an open wire enum, so a state this
670
+ * catalog has not heard of is printed as the producer spelled it rather than as
671
+ * a blank or a guess. `FAILED` reads "Delivery failed" — a buyer looking at a
672
+ * completed invoice has to be able to see that one of its lines never arrived.
673
+ */
674
+ function DeliveryChip({ status }: { status: string }) {
675
+ const normalized = status.trim().toUpperCase();
676
+ const tone = normalized === 'DELIVERED'
677
+ ? 'sxa:meta-chip--in_stock'
678
+ : normalized === 'FAILED'
679
+ ? 'sxa:meta-chip--sold_out'
680
+ : 'sxa:meta-chip--neutral';
681
+
682
+ return (
683
+ <span className={`sx-account-chip sxa:meta-chip ${tone}`}>
684
+ {tOptional(`delivery.${normalized}`) ?? status}
685
+ </span>
686
+ );
687
+ }
688
+
689
+ function DeliveredItems({ summary }: { summary: SdkCustomerDeliverySummary }) {
690
+ const hasContent =
691
+ summary.codes.length > 0
692
+ || summary.links.length > 0
693
+ || summary.notes.length > 0
694
+ || summary.serials.length > 0;
695
+
696
+ if (!hasContent) return null;
697
+
698
+ return (
699
+ <div className="sxa:mt-4 sxa:border-t sxa:pt-4">
700
+ <p className="sxa:text-micro sxa:font-semibold sxa:tracking-[0.12em] sxa:text-muted sxa:uppercase">
701
+ {t('orders.deliveryTitle')}
702
+ </p>
703
+
704
+ {summary.codes.length ? (
705
+ <ul className="sxa:mt-3 sxa:space-y-1.5">
706
+ {summary.codes.map((code) => (
707
+ <li
708
+ key={code}
709
+ className="sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro sxa:break-all"
710
+ >
711
+ {code}
712
+ </li>
713
+ ))}
714
+ </ul>
715
+ ) : null}
716
+
717
+ {summary.serials.length ? (
718
+ <ul className="sxa:mt-3 sxa:space-y-1.5">
719
+ {summary.serials.map((serial) => (
720
+ <li
721
+ key={serial.value}
722
+ className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2 sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro sxa:break-all"
723
+ >
724
+ {/* A replaced serial is struck through rather than dropped: it is
725
+ the one the buyer may already have installed somewhere. */}
726
+ <span className={serial.is_replaced ? 'sxa:line-through sxa:opacity-60' : ''}>
727
+ {serial.value}
728
+ </span>
729
+ {serial.is_replacement ? (
730
+ <span className="sx-account-chip sxa:meta-chip sxa:meta-chip--neutral">{t('orders.replacement')}</span>
731
+ ) : null}
732
+ </li>
733
+ ))}
734
+ </ul>
735
+ ) : null}
736
+
737
+ {summary.links.length ? (
738
+ <ul className="sxa:mt-3 sxa:space-y-1.5">
739
+ {summary.links.map((link) => (
740
+ <li key={`${link.label}-${link.value}`} className="sxa:text-small">
741
+ <DeliveryLink label={link.label} value={link.value} href={link.href} />
742
+ </li>
743
+ ))}
744
+ </ul>
745
+ ) : null}
746
+
747
+ {summary.notes.length ? (
748
+ <div className="sxa:mt-3 sxa:space-y-1.5">
749
+ {summary.notes.map((note) => (
750
+ <p key={note} className="sxa:text-small sxa:leading-relaxed sxa:text-muted">{note}</p>
751
+ ))}
752
+ </div>
753
+ ) : null}
754
+ </div>
755
+ );
756
+ }
757
+
758
+ /**
759
+ * A delivery link the MERCHANT typed into their own product.
760
+ *
761
+ * `javascript:` is a URL, and an anchor carrying one runs on the origin the
762
+ * buyer is standing on with the session cookie they are signed in with. So the
763
+ * scheme is checked before an anchor exists at all: a link that does not survive
764
+ * `safeExternalHref` is shown as the text it is, never as something to click.
765
+ */
766
+ function DeliveryLink({
767
+ label,
768
+ value,
769
+ href,
770
+ }: {
771
+ label: string;
772
+ value: string;
773
+ href?: string | null;
774
+ }) {
775
+ const safeHref = safeExternalHref(href);
776
+
777
+ if (safeHref === null) {
778
+ return <span className="sxa:break-all sxa:text-muted">{label}: {value}</span>;
779
+ }
780
+
781
+ return (
782
+ <a
783
+ href={safeHref}
784
+ target="_blank"
785
+ rel="noreferrer noopener"
786
+ className="sxa:font-medium sxa:text-accent sxa:underline sxa:underline-offset-2 sxa:transition-opacity sxa:hover:opacity-80"
787
+ >
788
+ {label}
789
+ </a>
790
+ );
791
+ }