@shoppexio/storefront-react 0.3.0 → 0.5.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 (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/account.css +26 -8
  3. package/dist/account.js +3217 -1001
  4. package/dist/account.js.map +1 -1
  5. package/dist/index.d.ts +188 -14
  6. package/dist/index.js +43 -20
  7. package/dist/index.js.map +1 -1
  8. package/eject/account/account-downloads.ts +89 -1
  9. package/eject/account/account-payment-method.ts +1 -0
  10. package/eject/account/account-referrals.ts +70 -1
  11. package/eject/account/account-reseller.ts +6 -140
  12. package/eject/account/account-subscriptions.ts +158 -6
  13. package/eject/account/buyer-invoice-status.ts +100 -0
  14. package/eject/account/downloads.tsx +118 -17
  15. package/eject/account/hosted-page.ts +45 -0
  16. package/eject/account/messages/ar.json +150 -39
  17. package/eject/account/messages/de.json +150 -39
  18. package/eject/account/messages/en.json +150 -39
  19. package/eject/account/messages/es.json +150 -39
  20. package/eject/account/messages/fi.json +150 -39
  21. package/eject/account/messages/fr.json +150 -39
  22. package/eject/account/messages/hi.json +150 -39
  23. package/eject/account/messages/it.json +150 -39
  24. package/eject/account/messages/lt.json +150 -39
  25. package/eject/account/messages/nl.json +150 -39
  26. package/eject/account/messages/pl.json +150 -39
  27. package/eject/account/messages/pt.json +150 -39
  28. package/eject/account/messages/ru.json +150 -39
  29. package/eject/account/messages/tr.json +150 -39
  30. package/eject/account/messages/uk.json +150 -39
  31. package/eject/account/messages/zh.json +150 -39
  32. package/eject/account/orders.tsx +203 -41
  33. package/eject/account/overview.tsx +6 -0
  34. package/eject/account/referrals.tsx +47 -5
  35. package/eject/account/reseller.tsx +62 -308
  36. package/eject/account/rewards.tsx +5 -0
  37. package/eject/account/shared.tsx +60 -4
  38. package/eject/account/subscriptions.tsx +387 -50
  39. package/package.json +4 -4
@@ -13,6 +13,7 @@
13
13
  * `account-route.ts`. Order detail is not a page of its own.
14
14
  */
15
15
 
16
+ import { BUYER_STATUS_FILTERS, buyerInvoiceStatus, type BuyerStatusFilter } from './buyer-invoice-status';
16
17
  import { useState, type ReactNode } from 'react';
17
18
  import { Link } from 'react-router-dom';
18
19
  import { paymentMethodLabel } from './account-payment-method';
@@ -29,7 +30,7 @@ import {
29
30
  warrantyDate,
30
31
  } from './account-warranties';
31
32
  import { callCustomerApi, safeExternalHref } from './customer-account';
32
- import type { SdkCustomerDeliverySummary, SdkCustomerWarranty } from '@shoppexio/storefront-react';
33
+ import type { SdkCustomerDeliverySummary, SdkCustomerOrderDetail, SdkCustomerWarranty } from '@shoppexio/storefront-react';
33
34
  import { t, tOptional } from './i18n';
34
35
  import {
35
36
  ArrowLeftIcon,
@@ -48,7 +49,7 @@ import {
48
49
  AccountRowsSkeleton,
49
50
  AccountSectionTitle,
50
51
  BusySpinner,
51
- StatusChip,
52
+ InvoiceStatusChip,
52
53
  formatAccountAmount,
53
54
  formatAccountDate,
54
55
  formatInvoiceAmount,
@@ -57,25 +58,6 @@ import {
57
58
 
58
59
  const ORDERS_PER_PAGE = 10;
59
60
 
60
- /** The producer's invoice-status enum, in the order the hosted portal lists it. */
61
- const STATUS_FILTERS = [
62
- 'COMPLETED',
63
- 'PENDING',
64
- 'PROCESSING',
65
- 'REFUNDED',
66
- 'DISPUTED',
67
- 'EXPIRED',
68
- ] as const;
69
-
70
- const STATUS_LABEL_KEYS: Record<(typeof STATUS_FILTERS)[number], string> = {
71
- COMPLETED: 'orders.statusCompleted',
72
- PENDING: 'orders.statusPending',
73
- PROCESSING: 'orders.statusProcessing',
74
- REFUNDED: 'orders.statusRefunded',
75
- DISPUTED: 'orders.statusDisputed',
76
- EXPIRED: 'orders.statusExpired',
77
- };
78
-
79
61
  /** The template's field skin, shared with the checkout and contact forms. */
80
62
  const FIELD =
81
63
  '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';
@@ -123,7 +105,7 @@ export function OrderRow({
123
105
  <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{formatAccountDate(createdAt)}</p>
124
106
  </div>
125
107
  <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">{amount}</span>
126
- <StatusChip status={status} />
108
+ <InvoiceStatusChip status={status} />
127
109
  </AccountCard>
128
110
  </Link>
129
111
  );
@@ -131,32 +113,33 @@ export function OrderRow({
131
113
 
132
114
  export function AccountOrders() {
133
115
  const [page, setPage] = useState(1);
134
- const [status, setStatus] = useState('');
116
+ const [statusGroup, setStatusGroup] = useState<BuyerStatusFilter | ''>('');
135
117
  const [search, setSearch] = useState('');
136
118
  /** What was last SENT, as against what is currently being typed. */
137
- const [applied, setApplied] = useState({ status: '', search: '' });
119
+ const [applied, setApplied] = useState<{ statusGroup: BuyerStatusFilter | ''; search: string }>({ statusGroup: '', search: '' });
138
120
 
139
121
  const { state, reload } = useAccountResource(
140
- `orders:${page}:${applied.status}:${applied.search}`,
122
+ `orders:${page}:${applied.statusGroup}:${applied.search}`,
141
123
  () => callCustomerApi((sdk) => sdk.orders({
142
124
  page,
143
125
  limit: ORDERS_PER_PAGE,
144
- ...(applied.status ? { status: applied.status } : {}),
126
+ // One group of the buyer status table, filtered by the producer: "Paid" is two statuses.
127
+ ...(applied.statusGroup ? { statusGroup: applied.statusGroup } : {}),
145
128
  ...(applied.search ? { search: applied.search } : {}),
146
129
  })),
147
130
  );
148
131
 
149
132
  // A changed filter is a different list. Staying on page four of the old one
150
133
  // would ask for a page the new list may not have and answer with nothing.
151
- const applyFilters = (next: { status?: string; search?: string }) => {
152
- const merged = { status: next.status ?? status, search: next.search ?? search };
153
- setStatus(merged.status);
134
+ const applyFilters = (next: { statusGroup?: BuyerStatusFilter | ''; search?: string }) => {
135
+ const merged = { statusGroup: next.statusGroup ?? statusGroup, search: next.search ?? search };
136
+ setStatusGroup(merged.statusGroup);
154
137
  setSearch(merged.search);
155
138
  setApplied(merged);
156
139
  setPage(1);
157
140
  };
158
141
 
159
- const filtering = applied.status !== '' || applied.search !== '';
142
+ const filtering = applied.statusGroup !== '' || applied.search !== '';
160
143
 
161
144
  const filterBar = (
162
145
  <form
@@ -176,14 +159,15 @@ export function AccountOrders() {
176
159
  />
177
160
  <div className="sxa:relative">
178
161
  <select
179
- value={status}
162
+ value={statusGroup}
180
163
  aria-label={t('orders.status')}
181
- onChange={(event) => applyFilters({ status: event.target.value })}
164
+ onChange={(event) => applyFilters({ statusGroup: BUYER_STATUS_FILTERS.find((filter) => filter.id === event.target.value)?.id ?? '' })}
182
165
  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"
183
166
  >
184
167
  <option value="">{t('orders.statusAll')}</option>
185
- {STATUS_FILTERS.map((value) => (
186
- <option key={value} value={value}>{t(STATUS_LABEL_KEYS[value])}</option>
168
+ {/* The table's groups, in the words of the status chips (Design Batch 9, C2). */}
169
+ {BUYER_STATUS_FILTERS.map((filter) => (
170
+ <option key={filter.id} value={filter.id}>{t(`invoiceStatus.${filter.labelKey}`)}</option>
187
171
  ))}
188
172
  </select>
189
173
  <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">
@@ -304,6 +288,63 @@ export function AccountOrders() {
304
288
  );
305
289
  }
306
290
 
291
+ /**
292
+ * Why an order offers no receipt, when the buyer status table gives it none
293
+ * (Design Batch 9, C7). Only where the sentence is true: an expired order that
294
+ * took no payment, and one the shop cancelled. An unpaid order still waits for
295
+ * payment, and a disputed one says nothing more.
296
+ */
297
+ export function noReceiptKey(order: Pick<SdkCustomerOrderDetail, 'status' | 'expiredPayment'>): string | null {
298
+ if (order.status === 'VOIDED') return 'orders.noReceiptVoided';
299
+ if (order.status === 'EXPIRED' && order.expiredPayment === 'NONE') return 'orders.noReceiptUnpaid';
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * When the order was paid, for one the buyer status table counts as paid (a
305
+ * held order waiting for stock is; a disputed one is not), else when it was
306
+ * placed (Design Batch 9, C1).
307
+ */
308
+ export function orderDateLine(order: Pick<SdkCustomerOrderDetail, 'status' | 'createdAt' | 'paidAt'>): string {
309
+ return order.paidAt && buyerInvoiceStatus(order.status)?.paid
310
+ ? t('orders.paidOn', { date: formatAccountDate(order.paidAt) })
311
+ : formatAccountDate(order.createdAt);
312
+ }
313
+
314
+ /**
315
+ * What a paid order held for a restock says (C1). The restock delivers the
316
+ * whole order at once and the wire does not say which line is short, so only
317
+ * a single item is named.
318
+ */
319
+ export function waitingStockNotice(order: Pick<SdkCustomerOrderDetail, 'status' | 'lineItems'>): string | null {
320
+ if (order.status !== 'AWAITING_STOCK') return null;
321
+ const [only] = order.lineItems;
322
+ const body = order.lineItems.length === 1 && only!.quantity === 1
323
+ ? t('orders.waitingStock.body', { item: only!.productTitle })
324
+ : t('orders.waitingStock.bodyOrder');
325
+ return `${t('orders.waitingStock.title')} ${body}`;
326
+ }
327
+
328
+ /** A line of a held order waits for the restock; "Pending" would read as unpaid (C1). */
329
+ export function lineDeliveryChipStatus(
330
+ orderStatus: string,
331
+ line: Pick<SdkCustomerOrderDetail['lineItems'][number], 'deliveryStatus'>,
332
+ ): string {
333
+ return orderStatus === 'AWAITING_STOCK' && line.deliveryStatus === 'PENDING' ? 'WAITING' : line.deliveryStatus;
334
+ }
335
+
336
+ /**
337
+ * The sentence under a line the seller delivered without anything to hand
338
+ * over (C7). Withheld goods (a gift, a reveal not taken) are not "nothing",
339
+ * and neither is a backend that did not say.
340
+ */
341
+ export function nothingToDownloadDate(
342
+ line: Pick<SdkCustomerOrderDetail['lineItems'][number], 'deliveredAt' | 'deliverySummary'>,
343
+ deliveryWithheld: boolean | undefined,
344
+ ): string | null {
345
+ return line.deliveredAt !== null && line.deliverySummary === null && deliveryWithheld === false ? line.deliveredAt : null;
346
+ }
347
+
307
348
  export function AccountOrderDetail({ orderId }: { orderId: string }) {
308
349
  const { state, reload } = useAccountResource(`order:${orderId}`, () =>
309
350
  callCustomerApi((sdk) => sdk.order(orderId)));
@@ -326,6 +367,9 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
326
367
  <div>
327
368
  {backLink}
328
369
  <div className="sxa:mt-5">
370
+ <AccountSectionTitle>{t('orders.detailError', { id: orderId })}</AccountSectionTitle>
371
+ </div>
372
+ <div className="sxa:mt-4">
329
373
  <AccountError message={state.message} onRetry={reload} />
330
374
  </div>
331
375
  </div>
@@ -333,6 +377,8 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
333
377
  }
334
378
 
335
379
  const order = state.data;
380
+ const noReceipt = noReceiptKey(order);
381
+ const waiting = waitingStockNotice(order);
336
382
 
337
383
  return (
338
384
  <div>
@@ -341,9 +387,9 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
341
387
  <div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
342
388
  <div>
343
389
  <AccountSectionTitle>{t('orders.detailTitle', { id: order.uniqid })}</AccountSectionTitle>
344
- <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{formatAccountDate(order.createdAt)}</p>
390
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{orderDateLine(order)}</p>
345
391
  </div>
346
- <StatusChip status={order.status} />
392
+ <InvoiceStatusChip status={order.status} />
347
393
  </div>
348
394
 
349
395
  {/*
@@ -356,7 +402,18 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
356
402
  <p className="sxa:mt-3 sxa:text-small sxa:leading-relaxed sxa:text-muted">{order.statusDetails}</p>
357
403
  ) : null}
358
404
 
359
- <InvoicePdfButton orderUniqid={order.uniqid} />
405
+ {waiting ? (
406
+ <AccountCard className="sxa:mt-4 sxa:px-5 sxa:py-4">
407
+ <p role="status" className="sxa:text-small sxa:leading-relaxed">{waiting}</p>
408
+ </AccountCard>
409
+ ) : null}
410
+
411
+ {/* The receipt follows the buyer status table: unpaid, expired, cancelled and disputed orders have none. */}
412
+ {buyerInvoiceStatus(order.status)?.receipt ? (
413
+ <InvoicePdfButton orderUniqid={order.uniqid} />
414
+ ) : noReceipt ? (
415
+ <p className="sxa:mt-4 sxa:text-small sxa:text-muted">{t(noReceipt)}</p>
416
+ ) : null}
360
417
 
361
418
  <ul className="sxa:mt-6 sxa:space-y-3">
362
419
  {order.lineItems.map((item) => (
@@ -384,7 +441,7 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
384
441
  rendered for every line rather than only for the unhappy ones.
385
442
  */}
386
443
  <p className="sxa:mt-1.5">
387
- <DeliveryChip status={item.deliveryStatus} />
444
+ <DeliveryChip status={lineDeliveryChipStatus(order.status, item)} />
388
445
  </p>
389
446
  </div>
390
447
  </div>
@@ -396,6 +453,7 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
396
453
  payment status.
397
454
  */}
398
455
  {item.deliverySummary ? <DeliveredItems summary={item.deliverySummary} /> : null}
456
+ {nothingToDownload(item, order.deliveryWithheld)}
399
457
  </AccountCard>
400
458
  </li>
401
459
  ))}
@@ -445,6 +503,9 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
445
503
  order AFTER it was paid for.
446
504
  */}
447
505
  <OrderWarranties orderUniqid={order.uniqid} />
506
+
507
+ {/* Keyed: a review posted on one order must not follow the buyer to the next. */}
508
+ <OrderReview key={order.uniqid} orderUniqid={order.uniqid} />
448
509
  </div>
449
510
  );
450
511
  }
@@ -458,9 +519,16 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
458
519
  * a temporary object URL on an anchor the buyer never sees; the name is the
459
520
  * producer's, and when it sent none the browser picks one.
460
521
  */
522
+ function nothingToDownload(line: SdkCustomerOrderDetail['lineItems'][number], deliveryWithheld: boolean | undefined): ReactNode {
523
+ const deliveredAt = nothingToDownloadDate(line, deliveryWithheld);
524
+ if (deliveredAt === null) return null;
525
+ return <p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('orders.nothingToDownload', { date: formatAccountDate(deliveredAt) })}</p>;
526
+ }
527
+
461
528
  function InvoicePdfButton({ orderUniqid }: { orderUniqid: string }) {
462
529
  const [busy, setBusy] = useState(false);
463
- const [error, setError] = useState<string | null>(null);
530
+ /** Why the last try failed: the session ended (sign in again) or the receipt could not be made (try later). */
531
+ const [error, setError] = useState<'signed-out' | 'failed' | null>(null);
464
532
 
465
533
  const download = async () => {
466
534
  setBusy(true);
@@ -469,7 +537,7 @@ function InvoicePdfButton({ orderUniqid }: { orderUniqid: string }) {
469
537
  setBusy(false);
470
538
 
471
539
  if (!response.success || response.data === undefined) {
472
- setError(response.message?.trim() || t('orders.invoicePdfFailed'));
540
+ setError(response.status === 401 || response.status === 403 ? 'signed-out' : 'failed');
473
541
  return;
474
542
  }
475
543
 
@@ -495,7 +563,16 @@ function InvoicePdfButton({ orderUniqid }: { orderUniqid: string }) {
495
563
  : <ReceiptIcon className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" />}
496
564
  {busy ? t('orders.invoicePdfLoading') : t('orders.invoicePdf')}
497
565
  </button>
498
- {error ? <AccountFieldError message={error} /> : null}
566
+ {error === 'failed' ? <AccountFieldError message={t('orders.pdfFailed')} /> : null}
567
+ {error === 'signed-out' ? (
568
+ <div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-3">
569
+ <AccountFieldError message={t('orders.pdfSignedOut')} />
570
+ {/* A fresh load asks who is signed in and shows the sign-in form. */}
571
+ <button type="button" onClick={() => window.location.reload()} className="sx-account-button sxa:card-button sxa:card-button--add sxa:mt-3">
572
+ {t('login.submit')}
573
+ </button>
574
+ </div>
575
+ ) : null}
499
576
  </div>
500
577
  );
501
578
  }
@@ -686,6 +763,91 @@ function WarrantyRow({
686
763
  );
687
764
  }
688
765
 
766
+ /** The three scores the server keeps, as the buyer reads them. */
767
+ const REVIEW_SCORE_KEYS: Readonly<Record<number, string>> = {
768
+ 5: 'orders.reviewPositive',
769
+ 3: 'orders.reviewNeutral',
770
+ 1: 'orders.reviewNegative',
771
+ };
772
+
773
+ /**
774
+ * The buyer's review of this order.
775
+ *
776
+ * The server decides whether the order can be reviewed (`can_review`: paid,
777
+ * reviews on, inside the edit window) and names the hosted review page for it
778
+ * (`review_url`, null otherwise); this only reads that state and never guesses
779
+ * it. The review is written on that page, never posted from here: this page
780
+ * runs beside merchant theme code that holds the buyer's session, and a review
781
+ * it could post would not be the buyer's own (#1152). Nothing is drawn while
782
+ * the read is in flight, or when there is neither a review page to open nor a
783
+ * review of the buyer's own to show. A failed read is shown with a retry,
784
+ * because it cannot be told apart from an order waiting for its review.
785
+ */
786
+ export function OrderReview({ orderUniqid }: { orderUniqid: string }) {
787
+ const { state, reload } = useAccountResource(`review:${orderUniqid}`, () =>
788
+ callCustomerApi((sdk) => sdk.orderReview(orderUniqid)));
789
+
790
+ if (state.status === 'loading') return null;
791
+
792
+ if (state.status === 'error') {
793
+ return (
794
+ <ReviewSection>
795
+ <AccountError message={state.message} onRetry={reload} />
796
+ </ReviewSection>
797
+ );
798
+ }
799
+
800
+ const review = state.data;
801
+ const ownReview = review.already_submitted && !review.is_automated;
802
+ // Absent on a backend that predates the link; a URL that fails the scheme check gets no anchor.
803
+ const href = safeExternalHref(review.review_url);
804
+ if (href === null && !ownReview) return null;
805
+
806
+ return (
807
+ <ReviewSection
808
+ description={review.product_title
809
+ ? t('orders.reviewPrompt', { product: review.product_title })
810
+ : t('orders.reviewPromptGeneric')}
811
+ >
812
+ <AccountCard className="sxa:flex sxa:flex-col sxa:items-start sxa:gap-3 sxa:px-5 sxa:py-4">
813
+ {ownReview ? (
814
+ <div>
815
+ <p className="sxa:text-small sxa:font-semibold">
816
+ {t((review.score === null ? undefined : REVIEW_SCORE_KEYS[review.score]) ?? 'orders.reviewFallbackLabel')}
817
+ {review.submitted_at ? (
818
+ <span className="sxa:font-normal sxa:text-muted"> · {formatAccountDate(review.submitted_at)}</span>
819
+ ) : null}
820
+ </p>
821
+ {review.message ? (
822
+ <p className="sxa:mt-1.5 sxa:text-small sxa:whitespace-pre-line sxa:text-muted">{review.message}</p>
823
+ ) : null}
824
+ </div>
825
+ ) : review.is_automated ? (
826
+ <p className="sxa:text-small sxa:text-muted">{t('orders.reviewAutomated')}</p>
827
+ ) : null}
828
+ {href === null ? null : (
829
+ <a
830
+ href={href}
831
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-9 sxa:shrink-0 sxa:items-center sxa:px-4 sxa:text-small sxa:font-semibold"
832
+ >
833
+ {t(ownReview ? 'orders.reviewEdit' : 'orders.reviewWrite')}
834
+ </a>
835
+ )}
836
+ </AccountCard>
837
+ </ReviewSection>
838
+ );
839
+ }
840
+
841
+ function ReviewSection({ description, children }: { description?: string; children: ReactNode }) {
842
+ return (
843
+ <section className="sxa:mt-8">
844
+ <AccountSectionTitle>{t('orders.reviewTitle')}</AccountSectionTitle>
845
+ {description ? <p className="sxa:mt-1.5 sxa:text-small sxa:text-muted">{description}</p> : null}
846
+ <div className="sxa:mt-4">{children}</div>
847
+ </section>
848
+ );
849
+ }
850
+
689
851
  /** Cover as a sentence. Cancelled cover names no date, because it has none. */
690
852
  function coverageLabel(coverage: WarrantyCoverage): string {
691
853
  switch (coverage.kind) {
@@ -21,6 +21,7 @@ import {
21
21
  AccountError,
22
22
  AccountRowsSkeleton,
23
23
  AccountSectionTitle,
24
+ HostedPageLink,
24
25
  formatAccountAmount,
25
26
  formatInvoiceAmount,
26
27
  } from './shared';
@@ -78,6 +79,11 @@ export function AccountOverview() {
78
79
  </div>
79
80
  <p className="sxa:text-small sxa:text-muted">{t('overview.walletNote')}</p>
80
81
  </div>
82
+ {/* Adding funds is a payment, so it happens on the hosted top-up page,
83
+ and only when the shop takes top-ups (decision T4). */}
84
+ {account.wallet.topups_allowed ? (
85
+ <HostedPageLink page="topup" hints={{}} label={t('overview.topUp')} className="sxa:mt-4 sxa:border-t sxa:pt-4" />
86
+ ) : null}
81
87
  </AccountCard>
82
88
  ) : null}
83
89
 
@@ -24,13 +24,15 @@ import {
24
24
  isEnrolledInReferrals,
25
25
  isForeignBalanceCurrency,
26
26
  isShareableLink,
27
+ linkCommissionDiffers,
27
28
  offersReferralLinks,
28
29
  referralLinkInput,
30
+ referralRateRows,
29
31
  referralShareUrl,
30
32
  } from './account-referrals';
31
33
  import { callCustomerApi, safeExternalHref } from './customer-account';
32
- import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
33
- import { t } from './i18n';
34
+ import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateRates, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
35
+ import { getActiveLocale, t } from './i18n';
34
36
  import { ExternalLinkIcon } from './icons';
35
37
  import { useAccountDashboard } from './dashboard';
36
38
  import {
@@ -136,6 +138,8 @@ export function AccountReferrals() {
136
138
  </div>
137
139
  ) : null}
138
140
 
141
+ {summary.rates ? <YourRates rates={summary.rates} /> : null}
142
+
139
143
  <section>
140
144
  <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
141
145
  <AccountSectionTitle>{t('referrals.linksTitle')}</AccountSectionTitle>
@@ -192,7 +196,7 @@ export function AccountReferrals() {
192
196
  <ul className="sxa:space-y-4">
193
197
  {summary.links.map((link) => (
194
198
  <li key={link.id}>
195
- <ReferralLinkCard link={link} currency={currency} />
199
+ <ReferralLinkCard link={link} currency={currency} rates={summary.rates} />
196
200
  </li>
197
201
  ))}
198
202
  </ul>
@@ -460,12 +464,45 @@ function BalanceTile({
460
464
  );
461
465
  }
462
466
 
467
+ /** "Your rates": what a referral earns, in the rows the Easy-lane account block shows. */
468
+ function YourRates({ rates }: { rates: SdkCustomerAffiliateRates }) {
469
+ const id = useId();
470
+ const locale = getActiveLocale();
471
+ const rows = referralRateRows(rates, {
472
+ // The cap and the minimum are kept in US dollars, like the affiliate ledger.
473
+ money: (usd) => formatAccountAmount(usd, 'USD'),
474
+ list: (names) => new Intl.ListFormat(locale, { type: 'conjunction' }).format(names),
475
+ more: (count) => t('referrals.ratesMoreProducts', { count: String(count) }),
476
+ });
477
+ return (
478
+ <section aria-labelledby={id}>
479
+ <h2 id={id} className="sx-account-section-title sxa:font-heading sxa:text-body sxa:font-semibold">
480
+ {t('referrals.yourRates')}
481
+ </h2>
482
+ <AccountCard className="sxa:mt-4 sxa:p-5">
483
+ <ul className="sxa:space-y-3">
484
+ {rows.map((row) => (
485
+ <li key={row.id}>
486
+ <p className="sxa:text-small sxa:font-semibold">{t(row.title.key, row.title.params)}</p>
487
+ {row.detail ? (
488
+ <p className="sxa:mt-0.5 sxa:text-micro sxa:text-muted">{t(row.detail.key, row.detail.params)}</p>
489
+ ) : null}
490
+ </li>
491
+ ))}
492
+ </ul>
493
+ </AccountCard>
494
+ </section>
495
+ );
496
+ }
497
+
463
498
  function ReferralLinkCard({
464
499
  link,
465
500
  currency,
501
+ rates,
466
502
  }: {
467
503
  link: SdkCustomerAffiliateLink;
468
504
  currency: string;
505
+ rates: SdkCustomerAffiliateRates | null;
469
506
  }) {
470
507
  /*
471
508
  * The shareable URL is the SERVER's — it knows the shop's own storefront
@@ -490,8 +527,13 @@ function ReferralLinkCard({
490
527
  <div className="sxa:min-w-0">
491
528
  <p className="sxa:truncate sxa:text-small sxa:font-semibold">{link.label ?? link.code}</p>
492
529
  <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
493
- {t('referrals.commission', { percent: link.effective_commission_percent })}
494
- {' · '}
530
+ {/* "Your rates" names the rate; a link says it only when it earns another one. */}
531
+ {linkCommissionDiffers(link, rates) ? (
532
+ <>
533
+ {t('referrals.commission', { percent: link.effective_commission_percent })}
534
+ {' · '}
535
+ </>
536
+ ) : null}
495
537
  {t('referrals.buyerDiscount', { percent: link.effective_customer_discount_percent })}
496
538
  </p>
497
539
  </div>