@shoppexio/storefront-react 0.3.0 → 0.4.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 (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/account.css +4 -8
  3. package/dist/account.js +1257 -778
  4. package/dist/account.js.map +1 -1
  5. package/dist/index.d.ts +92 -8
  6. package/dist/index.js +15 -16
  7. package/dist/index.js.map +1 -1
  8. package/eject/account/account-downloads.ts +89 -1
  9. package/eject/account/account-referrals.ts +70 -1
  10. package/eject/account/account-reseller.ts +6 -140
  11. package/eject/account/downloads.tsx +73 -4
  12. package/eject/account/hosted-page.ts +45 -0
  13. package/eject/account/messages/ar.json +55 -29
  14. package/eject/account/messages/de.json +55 -29
  15. package/eject/account/messages/en.json +55 -29
  16. package/eject/account/messages/es.json +55 -29
  17. package/eject/account/messages/fi.json +55 -29
  18. package/eject/account/messages/fr.json +55 -29
  19. package/eject/account/messages/hi.json +55 -29
  20. package/eject/account/messages/it.json +55 -29
  21. package/eject/account/messages/lt.json +55 -29
  22. package/eject/account/messages/nl.json +55 -29
  23. package/eject/account/messages/pl.json +55 -29
  24. package/eject/account/messages/pt.json +55 -29
  25. package/eject/account/messages/ru.json +55 -29
  26. package/eject/account/messages/tr.json +55 -29
  27. package/eject/account/messages/uk.json +55 -29
  28. package/eject/account/messages/zh.json +55 -29
  29. package/eject/account/orders.tsx +88 -0
  30. package/eject/account/overview.tsx +6 -0
  31. package/eject/account/referrals.tsx +47 -5
  32. package/eject/account/reseller.tsx +59 -306
  33. package/eject/account/rewards.tsx +5 -0
  34. package/eject/account/shared.tsx +39 -1
  35. package/eject/account/subscriptions.tsx +75 -16
  36. package/package.json +4 -4
@@ -8,38 +8,31 @@
8
8
  * form. There is no PENDING. Folding two of those together tells a suspended
9
9
  * reseller they were rejected, or hands a rejected one a catalog.
10
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.
11
+ * Nothing here spends, mints or revokes (design Batch 7, 5b; #1167). Placing
12
+ * an order, adding funds and handing out a key or the delivery URL are not
13
+ * controls on this page: on a code storefront the JavaScript here is the
14
+ * merchant's, and the storefront edge allowlists none of those mutations
15
+ * (`workers/storefront/src/customer-api.ts`). "Add funds", "Manage keys" and
16
+ * "Delivery URL" are links to the hosted top-up and reseller pages on the
17
+ * checkout host, which ask for their own email code. The catalog is a price
18
+ * list; there is no basket and no quote.
19
19
  */
20
20
 
21
21
  import { useCallback, useEffect, useId, useRef, useState } from 'react';
22
22
  import {
23
- RESELLER_QUANTITY_MAX_UNITS,
24
- RESELLER_QUANTITY_MIN_UNITS,
25
23
  narrowResellerApiKeys,
26
24
  narrowResellerCatalog,
27
25
  narrowResellerMutation,
28
26
  narrowResellerOrderDetail,
29
27
  narrowResellerOrders,
30
28
  narrowResellerProgram,
31
- narrowResellerQuote,
32
29
  narrowResellerWallet,
33
- quoteMatchesBasket,
34
- resellerBasketKey,
35
30
  resellerEntryMode,
36
31
  type ResellerCatalogItem,
37
32
  type ResellerCatalogVariant,
38
- type ResellerOrderItemInput,
39
33
  type ResellerOrderLine,
40
34
  type ResellerOrderListItem,
41
35
  type ResellerProgramState,
42
- type ResellerQuote,
43
36
  type ResellerRelationship,
44
37
  } from './account-reseller';
45
38
  import { callCustomerApi } from './customer-account';
@@ -60,6 +53,7 @@ import {
60
53
  AccountSectionTitle,
61
54
  BusySpinner,
62
55
  CopyButton,
56
+ HostedPageLink,
63
57
  StatusChip,
64
58
  formatAccountAmount,
65
59
  formatAccountDate,
@@ -74,18 +68,6 @@ const FIELD =
74
68
 
75
69
  const PAGE_SIZE = 20;
76
70
 
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
71
  export function AccountReseller() {
90
72
  /*
91
73
  * The SAME response the navigation gate reads. Two separate reads could
@@ -121,7 +103,7 @@ export function AccountReseller() {
121
103
  const status = reseller?.status;
122
104
 
123
105
  if (reseller && status === 'ACTIVE') {
124
- return <ActiveReseller relationship={reseller} />;
106
+ return <ActiveReseller relationship={reseller} program={program} />;
125
107
  }
126
108
 
127
109
  if (status === 'APPLIED') {
@@ -134,10 +116,13 @@ export function AccountReseller() {
134
116
  }
135
117
 
136
118
  if (status === 'SUSPENDED') {
119
+ // A suspended reseller can still revoke a key or rotate the delivery URL,
120
+ // so the two credential links stay; adding funds does not.
137
121
  return (
138
122
  <AccountEmpty
139
123
  title={t('reseller.suspendedTitle')}
140
124
  body={t('reseller.suspendedBody')}
125
+ action={<CredentialLinks program={program} />}
141
126
  />
142
127
  );
143
128
  }
@@ -349,36 +334,13 @@ function ResellerEntry({
349
334
  );
350
335
  }
351
336
 
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
-
337
+ function ActiveReseller({
338
+ relationship,
339
+ program,
340
+ }: {
341
+ relationship: ResellerRelationship;
342
+ program: ResellerProgramState;
343
+ }) {
382
344
  const discount = relationship.tier?.discount_percent;
383
345
 
384
346
  return (
@@ -407,15 +369,9 @@ function ActiveReseller({ relationship }: { relationship: ResellerRelationship }
407
369
  </section>
408
370
 
409
371
  <WholesaleWallet />
410
- <WholesaleCatalog onAdd={add} />
411
- <WholesaleBasket
412
- basket={basket}
413
- priced={priced}
414
- onBasketChange={replaceBasket}
415
- onPriced={setPriced}
416
- />
372
+ <WholesaleCatalog />
417
373
  <WholesaleOrders />
418
- <ResellerApiKeys />
374
+ <ResellerApiKeys program={program} />
419
375
  </div>
420
376
  );
421
377
  }
@@ -494,6 +450,12 @@ function WholesaleWallet() {
494
450
  />
495
451
  </div>
496
452
 
453
+ <HostedPageLink
454
+ page="topup"
455
+ hints={{ for: 'reseller' }}
456
+ label={t('reseller.topUp')}
457
+ className="sxa:mt-4 sxa:border-t sxa:pt-4"
458
+ />
497
459
  </AccountCard>
498
460
  </section>
499
461
  );
@@ -520,11 +482,8 @@ type PagedState<T> =
520
482
  /**
521
483
  * A list that GROWS a page at a time.
522
484
  *
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.
485
+ * Both wholesale lists append rather than replace, like the buyer's own order
486
+ * history, so the two read alike.
528
487
  *
529
488
  * The page number advances only on a SUCCESSFUL read, and it advances to the
530
489
  * page the producer says it answered. A failed "next" leaves the list exactly
@@ -641,7 +600,7 @@ interface CatalogRow {
641
600
  currency: string;
642
601
  }
643
602
 
644
- function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
603
+ function WholesaleCatalog() {
645
604
  const searchId = useId();
646
605
  const [search, setSearch] = useState('');
647
606
  const [query, setQuery] = useState('');
@@ -681,8 +640,7 @@ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
681
640
  className="sxa:flex sxa:items-center sxa:gap-2"
682
641
  onSubmit={(event) => {
683
642
  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.
643
+ // A new search restarts the list at page one.
686
644
  setQuery(search.trim());
687
645
  }}
688
646
  >
@@ -744,7 +702,7 @@ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
744
702
  <ul className="sxa:mt-4 sxa:space-y-3">
745
703
  {slice.items.map((row) => (
746
704
  <li key={row.item.product_id}>
747
- <CatalogProduct item={row.item} currency={row.currency} onAdd={onAdd} />
705
+ <CatalogProduct item={row.item} currency={row.currency} />
748
706
  </li>
749
707
  ))}
750
708
  </ul>
@@ -757,11 +715,9 @@ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
757
715
  function CatalogProduct({
758
716
  item,
759
717
  currency,
760
- onAdd,
761
718
  }: {
762
719
  item: ResellerCatalogItem;
763
720
  currency: string;
764
- onAdd: (line: BasketLine) => void;
765
721
  }) {
766
722
  return (
767
723
  <AccountCard className="sxa:p-5">
@@ -771,27 +727,8 @@ function CatalogProduct({
771
727
  <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
772
728
  {formatAccountAmount(item.unit_price, currency)}
773
729
  {item.volume_discount_enabled ? ` · ${t('reseller.volumePricing')}` : ''}
774
- {item.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
775
730
  </p>
776
731
  </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
732
  </div>
796
733
 
797
734
  {item.variants.length > 0 ? (
@@ -799,10 +736,8 @@ function CatalogProduct({
799
736
  {item.variants.map((variant) => (
800
737
  <CatalogVariant
801
738
  key={variant.variant_id}
802
- item={item}
803
739
  variant={variant}
804
740
  currency={currency}
805
- onAdd={onAdd}
806
741
  />
807
742
  ))}
808
743
  </ul>
@@ -812,15 +747,11 @@ function CatalogProduct({
812
747
  }
813
748
 
814
749
  function CatalogVariant({
815
- item,
816
750
  variant,
817
751
  currency,
818
- onAdd,
819
752
  }: {
820
- item: ResellerCatalogItem;
821
753
  variant: ResellerCatalogVariant;
822
754
  currency: string;
823
- onAdd: (line: BasketLine) => void;
824
755
  }) {
825
756
  return (
826
757
  <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">
@@ -828,207 +759,12 @@ function CatalogVariant({
828
759
  <p className="sxa:truncate sxa:text-small">{variant.title}</p>
829
760
  <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
830
761
  {formatAccountAmount(variant.unit_price, currency)}
831
- {variant.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
832
762
  </p>
833
763
  </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
764
  </li>
854
765
  );
855
766
  }
856
767
 
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
768
  function WholesaleOrders() {
1033
769
  const [selected, setSelected] = useState<string | null>(null);
1034
770
  const { state, moreBusy, moreError, loadMore, reload } = usePagedList<ResellerOrderListItem>(
@@ -1257,22 +993,39 @@ function OrderLine({ line, currency }: { line: ResellerOrderLine; currency: stri
1257
993
 
1258
994
  /* --- API keys ------------------------------------------------------------- */
1259
995
 
1260
- function ResellerApiKeys() {
996
+ /**
997
+ * Keys and the delivery URL are credentials, so they are created, shown and
998
+ * revoked on the hosted reseller page. The delivery row only when the shop has
999
+ * delivery URLs on.
1000
+ */
1001
+ function CredentialLinks({ program }: { program: ResellerProgramState }) {
1002
+ return (
1003
+ <div className="sxa:space-y-3 sxa:text-left">
1004
+ <HostedPageLink page="reseller" hints={{ tab: 'keys' }} label={t('reseller.manageKeys')} />
1005
+ {program.delivery_url_enabled ? (
1006
+ <HostedPageLink
1007
+ page="reseller"
1008
+ hints={{ tab: 'delivery' }}
1009
+ label={t('reseller.delivery')}
1010
+ hint={t('reseller.deliveryHint')}
1011
+ />
1012
+ ) : null}
1013
+ </div>
1014
+ );
1015
+ }
1016
+
1017
+ function ResellerApiKeys({ program }: { program: ResellerProgramState }) {
1261
1018
  const { state, reload } = useAccountResource('reseller-api-keys', () =>
1262
1019
  callCustomerApi((sdk) => sdk.resellerApiKeys()));
1263
1020
  const [error, setError] = useState<string | null>(null);
1264
1021
 
1265
1022
  const heading = <AccountSectionTitle>{t('reseller.apiKeysTitle')}</AccountSectionTitle>;
1266
1023
 
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
- */
1024
+ /* Where "Create key" would have been: the hosted reseller page mints and revokes. */
1272
1025
  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>
1026
+ <div className="sxa:mt-4 sxa:rounded-input sxa:border sxa:bg-bg sxa:p-5">
1027
+ <CredentialLinks program={program} />
1028
+ </div>
1276
1029
  );
1277
1030
 
1278
1031
  if (state.status === 'loading') {
@@ -55,6 +55,7 @@ import {
55
55
  AccountSectionTitle,
56
56
  BusySpinner,
57
57
  CopyButton,
58
+ HostedPageLink,
58
59
  formatAccountAmount,
59
60
  formatAccountDate,
60
61
  statusLabel,
@@ -105,6 +106,10 @@ export function AccountRewards() {
105
106
  currency={summary.currency}
106
107
  />
107
108
  </div>
109
+ {/* Store credit comes in through the hosted top-up page, when the shop takes top-ups. */}
110
+ {state.data.wallet?.topups_allowed ? (
111
+ <HostedPageLink page="topup" hints={{}} label={t('overview.topUp')} />
112
+ ) : null}
108
113
 
109
114
  {/*
110
115
  The shop's currency, not the wallet's and not the rewards summary's.
@@ -14,14 +14,16 @@
14
14
 
15
15
  import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
16
16
  import { Link } from 'react-router-dom';
17
- import { toPanelState, type AccountPanelState } from './customer-account';
17
+ import { safeExternalHref, toPanelState, type AccountPanelState } from './customer-account';
18
18
  import type { SdkResponse } from '@shoppexio/storefront-react';
19
+ import { hostedPageHref, type HostedPage } from './hosted-page';
19
20
  import { getActiveLocale, t, tOptional } from './i18n';
20
21
  import {
21
22
  ArrowLeftIcon,
22
23
  CheckIcon,
23
24
  CircleAlertIcon,
24
25
  CopyIcon,
26
+ ExternalLinkIcon,
25
27
  LoaderIcon,
26
28
  } from './icons';
27
29
 
@@ -393,6 +395,42 @@ export function CopyButton({ value, className = '' }: { value: string; className
393
395
  );
394
396
  }
395
397
 
398
+ /**
399
+ * A way out to a hosted buyer page on the checkout host (design Batch 7, A1):
400
+ * a plate with the external-link glyph, never a solid, because the account
401
+ * itself moves no money and mints no credential. Same tab, so the hosted page
402
+ * can send the buyer back. Renders nothing when the page has no shop or
403
+ * checkout host to link to (`hostedPageHref`).
404
+ */
405
+ export function HostedPageLink({
406
+ page,
407
+ hints,
408
+ label,
409
+ hint = t('account.secureLink'),
410
+ className = '',
411
+ }: {
412
+ page: HostedPage;
413
+ hints: Record<string, string>;
414
+ label: string;
415
+ /** The line beside the link; null where the card has no room for it. */
416
+ hint?: string | null;
417
+ className?: string;
418
+ }) {
419
+ // The checkout host is a page global, so it is held to the same http(s)
420
+ // rule as any other href this account renders.
421
+ const href = safeExternalHref(hostedPageHref(page, hints));
422
+ if (!href) return null;
423
+ return (
424
+ <div className={`sx-account-hosted-link sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-2 ${className}`}>
425
+ {hint ? <p className="sxa:text-small sxa:text-muted">{hint}</p> : null}
426
+ <a href={href} className="sx-account-button sxa:card-button sxa:card-button--add">
427
+ {label}
428
+ <ExternalLinkIcon className="sxa:ml-1.5 sxa:h-3.5 sxa:w-3.5 sxa:shrink-0" />
429
+ </a>
430
+ </div>
431
+ );
432
+ }
433
+
396
434
  /** The spinner a control shows while its own request is in flight. */
397
435
  export function BusySpinner({ className = '' }: { className?: string }) {
398
436
  return <LoaderIcon className={`sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:animate-spin ${className}`} />;