@shoppexio/storefront-react 0.2.1 → 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.
- package/CHANGELOG.md +120 -0
- package/dist/account.css +4 -8
- package/dist/account.js +2387 -942
- package/dist/account.js.map +1 -1
- package/dist/{chunk-ZEFZPUAW.js → chunk-NBOIS53R.js} +2 -1
- package/dist/chunk-NBOIS53R.js.map +1 -0
- package/dist/index.d.ts +243 -29
- package/dist/index.js +221 -77
- package/dist/index.js.map +1 -1
- package/eject/account/account-availability.ts +4 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-referrals.ts +249 -7
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/dashboard.tsx +1 -0
- package/eject/account/downloads.tsx +73 -4
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +94 -31
- package/eject/account/messages/de.json +94 -31
- package/eject/account/messages/en.json +94 -31
- package/eject/account/messages/es.json +94 -31
- package/eject/account/messages/fi.json +94 -31
- package/eject/account/messages/fr.json +94 -31
- package/eject/account/messages/hi.json +94 -31
- package/eject/account/messages/it.json +94 -31
- package/eject/account/messages/lt.json +94 -31
- package/eject/account/messages/nl.json +94 -31
- package/eject/account/messages/pl.json +94 -31
- package/eject/account/messages/pt.json +94 -31
- package/eject/account/messages/ru.json +94 -31
- package/eject/account/messages/tr.json +94 -31
- package/eject/account/messages/uk.json +94 -31
- package/eject/account/messages/zh.json +94 -31
- package/eject/account/orders.tsx +141 -0
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +444 -156
- package/eject/account/reseller.tsx +59 -306
- package/eject/account/rewards.tsx +43 -6
- package/eject/account/settings.tsx +98 -2
- package/eject/account/shared.tsx +42 -3
- package/eject/account/subscriptions.tsx +75 -16
- package/package.json +7 -4
- package/dist/chunk-ZEFZPUAW.js.map +0 -1
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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({
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
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
|
|
524
|
-
*
|
|
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(
|
|
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.
|
|
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}
|
|
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
|
-
|
|
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
|
-
<
|
|
1274
|
-
{
|
|
1275
|
-
</
|
|
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') {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* depends on that answer must not hold up the coupons above it.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { useState, type ReactNode } from 'react';
|
|
20
|
+
import { useRef, useState, type ReactNode } from 'react';
|
|
21
21
|
import { Link } from 'react-router-dom';
|
|
22
22
|
import {
|
|
23
23
|
loyaltyDate,
|
|
@@ -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.
|
|
@@ -218,6 +223,14 @@ function LoyaltyPoints({
|
|
|
218
223
|
}) {
|
|
219
224
|
const { state, reload } = resource;
|
|
220
225
|
const [notice, setNotice] = useState<string | null>(null);
|
|
226
|
+
/*
|
|
227
|
+
* The loyalty answer on screen when a conversion's outcome became unknown.
|
|
228
|
+
* Held by identity: only a re-read that ANSWERS replaces `state.data`, and
|
|
229
|
+
* the reload discards any read started before it. So the form stays locked
|
|
230
|
+
* until a fresh read succeeded; a failed one keeps this object and the lock,
|
|
231
|
+
* and offers the read again through the stale notice below.
|
|
232
|
+
*/
|
|
233
|
+
const [unconfirmedOn, setUnconfirmedOn] = useState<SdkCustomerLoyalty | null>(null);
|
|
221
234
|
|
|
222
235
|
/*
|
|
223
236
|
* A conversion re-reads the route so the balance and the activity list come
|
|
@@ -229,6 +242,10 @@ function LoyaltyPoints({
|
|
|
229
242
|
* the buyer pressed the button. Nothing is kept a second time here.
|
|
230
243
|
*/
|
|
231
244
|
const loyalty = state.status === 'ready' ? state.data : null;
|
|
245
|
+
// Read at the moment the answer arrives: the callback below was created by
|
|
246
|
+
// the render that started the request, and a re-read may have landed since.
|
|
247
|
+
const onScreen = useRef(loyalty);
|
|
248
|
+
onScreen.current = loyalty;
|
|
232
249
|
|
|
233
250
|
if (loyalty === null) {
|
|
234
251
|
return state.status === 'error' ? (
|
|
@@ -249,6 +266,7 @@ function LoyaltyPoints({
|
|
|
249
266
|
const stale = state.status === 'ready' ? state.staleMessage : undefined;
|
|
250
267
|
|
|
251
268
|
const offer = redeemOffer(loyalty);
|
|
269
|
+
const unconfirmed = unconfirmedOn === loyalty;
|
|
252
270
|
|
|
253
271
|
return (
|
|
254
272
|
<PointsSection>
|
|
@@ -271,7 +289,9 @@ function LoyaltyPoints({
|
|
|
271
289
|
|
|
272
290
|
{stale ? <AccountError message={stale} onRetry={reload} /> : null}
|
|
273
291
|
|
|
274
|
-
{
|
|
292
|
+
{unconfirmed ? (
|
|
293
|
+
<p role="status" className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('rewards.loading')}</p>
|
|
294
|
+
) : notice ? <AccountNotice message={notice} /> : null}
|
|
275
295
|
|
|
276
296
|
{/* The minimum, for a balance that cannot clear it yet. The form is gone
|
|
277
297
|
because the route would refuse it, but the figure it refuses below is
|
|
@@ -286,8 +306,11 @@ function LoyaltyPoints({
|
|
|
286
306
|
<RedeemPoints
|
|
287
307
|
offer={offer}
|
|
288
308
|
currency={currency}
|
|
309
|
+
locked={unconfirmed}
|
|
289
310
|
onRedeemed={(message) => {
|
|
290
311
|
setNotice(message);
|
|
312
|
+
// No message is the unknown outcome; the two re-reads answer it.
|
|
313
|
+
setUnconfirmedOn(message === null ? onScreen.current : null);
|
|
291
314
|
reload();
|
|
292
315
|
onCredited();
|
|
293
316
|
}}
|
|
@@ -331,11 +354,15 @@ function PointsTile({ label, points }: { label: string; points: number }) {
|
|
|
331
354
|
function RedeemPoints({
|
|
332
355
|
offer,
|
|
333
356
|
currency,
|
|
357
|
+
locked,
|
|
334
358
|
onRedeemed,
|
|
335
359
|
}: {
|
|
336
360
|
offer: Extract<RedeemOffer, { kind: 'offered' }>;
|
|
337
361
|
currency: string;
|
|
338
|
-
|
|
362
|
+
/** An earlier conversion is unconfirmed until the points were re-read: nothing may be sent. */
|
|
363
|
+
locked: boolean;
|
|
364
|
+
/** The credited notice, or null when the server withheld its answer. */
|
|
365
|
+
onRedeemed: (message: string | null) => void;
|
|
339
366
|
}) {
|
|
340
367
|
const [typed, setTyped] = useState('');
|
|
341
368
|
const [confirming, setConfirming] = useState(false);
|
|
@@ -368,7 +395,7 @@ function RedeemPoints({
|
|
|
368
395
|
};
|
|
369
396
|
|
|
370
397
|
const submit = async () => {
|
|
371
|
-
if (preview.kind !== 'ready') return;
|
|
398
|
+
if (preview.kind !== 'ready' || locked) return;
|
|
372
399
|
|
|
373
400
|
setSubmitting(true);
|
|
374
401
|
setError(null);
|
|
@@ -383,6 +410,16 @@ function RedeemPoints({
|
|
|
383
410
|
idempotencyKey,
|
|
384
411
|
}));
|
|
385
412
|
|
|
413
|
+
if (response.code === 'RESPONSE_CONTRACT_VIOLATION') {
|
|
414
|
+
// The server ran the conversion and withheld only its answer: there is
|
|
415
|
+
// no failure to show and nothing to send again. The amount's key stays
|
|
416
|
+
// remembered, so a later send of it replays instead of debiting twice.
|
|
417
|
+
setTyped('');
|
|
418
|
+
setConfirming(false);
|
|
419
|
+
onRedeemed(null);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
386
423
|
if (!response.success || response.data === undefined) {
|
|
387
424
|
// The server's own sentence: "below the minimum", "not enough points"
|
|
388
425
|
// and "the programme was switched off" are all 400s, and only it knows
|
|
@@ -425,7 +462,7 @@ function RedeemPoints({
|
|
|
425
462
|
max={offer.balance}
|
|
426
463
|
step={1}
|
|
427
464
|
value={typed}
|
|
428
|
-
disabled={submitting}
|
|
465
|
+
disabled={submitting || locked}
|
|
429
466
|
placeholder={String(offer.minimumPoints)}
|
|
430
467
|
aria-invalid={preview.kind === 'not-a-number' || preview.kind === 'zero-value' ? true : undefined}
|
|
431
468
|
onChange={(event) => retype(event.target.value)}
|
|
@@ -434,7 +471,7 @@ function RedeemPoints({
|
|
|
434
471
|
{!confirming ? (
|
|
435
472
|
<button
|
|
436
473
|
type="button"
|
|
437
|
-
disabled={preview.kind !== 'ready' || submitting}
|
|
474
|
+
disabled={preview.kind !== 'ready' || submitting || locked}
|
|
438
475
|
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
439
476
|
onClick={() => setConfirming(true)}
|
|
440
477
|
>
|