@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.
- package/CHANGELOG.md +31 -0
- package/dist/account.css +26 -8
- package/dist/account.js +3217 -1001
- package/dist/account.js.map +1 -1
- package/dist/index.d.ts +188 -14
- package/dist/index.js +43 -20
- package/dist/index.js.map +1 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-payment-method.ts +1 -0
- package/eject/account/account-referrals.ts +70 -1
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/account-subscriptions.ts +158 -6
- package/eject/account/buyer-invoice-status.ts +100 -0
- package/eject/account/downloads.tsx +118 -17
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +150 -39
- package/eject/account/messages/de.json +150 -39
- package/eject/account/messages/en.json +150 -39
- package/eject/account/messages/es.json +150 -39
- package/eject/account/messages/fi.json +150 -39
- package/eject/account/messages/fr.json +150 -39
- package/eject/account/messages/hi.json +150 -39
- package/eject/account/messages/it.json +150 -39
- package/eject/account/messages/lt.json +150 -39
- package/eject/account/messages/nl.json +150 -39
- package/eject/account/messages/pl.json +150 -39
- package/eject/account/messages/pt.json +150 -39
- package/eject/account/messages/ru.json +150 -39
- package/eject/account/messages/tr.json +150 -39
- package/eject/account/messages/uk.json +150 -39
- package/eject/account/messages/zh.json +150 -39
- package/eject/account/orders.tsx +203 -41
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +47 -5
- package/eject/account/reseller.tsx +62 -308
- package/eject/account/rewards.tsx +5 -0
- package/eject/account/shared.tsx +60 -4
- package/eject/account/subscriptions.tsx +387 -50
- 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
|
-
*
|
|
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,8 @@ import {
|
|
|
60
53
|
AccountSectionTitle,
|
|
61
54
|
BusySpinner,
|
|
62
55
|
CopyButton,
|
|
56
|
+
HostedPageLink,
|
|
57
|
+
InvoiceStatusChip,
|
|
63
58
|
StatusChip,
|
|
64
59
|
formatAccountAmount,
|
|
65
60
|
formatAccountDate,
|
|
@@ -74,18 +69,6 @@ const FIELD =
|
|
|
74
69
|
|
|
75
70
|
const PAGE_SIZE = 20;
|
|
76
71
|
|
|
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
72
|
export function AccountReseller() {
|
|
90
73
|
/*
|
|
91
74
|
* The SAME response the navigation gate reads. Two separate reads could
|
|
@@ -121,7 +104,7 @@ export function AccountReseller() {
|
|
|
121
104
|
const status = reseller?.status;
|
|
122
105
|
|
|
123
106
|
if (reseller && status === 'ACTIVE') {
|
|
124
|
-
return <ActiveReseller relationship={reseller} />;
|
|
107
|
+
return <ActiveReseller relationship={reseller} program={program} />;
|
|
125
108
|
}
|
|
126
109
|
|
|
127
110
|
if (status === 'APPLIED') {
|
|
@@ -134,10 +117,13 @@ export function AccountReseller() {
|
|
|
134
117
|
}
|
|
135
118
|
|
|
136
119
|
if (status === 'SUSPENDED') {
|
|
120
|
+
// A suspended reseller can still revoke a key or rotate the delivery URL,
|
|
121
|
+
// so the two credential links stay; adding funds does not.
|
|
137
122
|
return (
|
|
138
123
|
<AccountEmpty
|
|
139
124
|
title={t('reseller.suspendedTitle')}
|
|
140
125
|
body={t('reseller.suspendedBody')}
|
|
126
|
+
action={<CredentialLinks program={program} />}
|
|
141
127
|
/>
|
|
142
128
|
);
|
|
143
129
|
}
|
|
@@ -349,36 +335,13 @@ function ResellerEntry({
|
|
|
349
335
|
);
|
|
350
336
|
}
|
|
351
337
|
|
|
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
|
-
|
|
338
|
+
function ActiveReseller({
|
|
339
|
+
relationship,
|
|
340
|
+
program,
|
|
341
|
+
}: {
|
|
342
|
+
relationship: ResellerRelationship;
|
|
343
|
+
program: ResellerProgramState;
|
|
344
|
+
}) {
|
|
382
345
|
const discount = relationship.tier?.discount_percent;
|
|
383
346
|
|
|
384
347
|
return (
|
|
@@ -407,15 +370,9 @@ function ActiveReseller({ relationship }: { relationship: ResellerRelationship }
|
|
|
407
370
|
</section>
|
|
408
371
|
|
|
409
372
|
<WholesaleWallet />
|
|
410
|
-
<WholesaleCatalog
|
|
411
|
-
<WholesaleBasket
|
|
412
|
-
basket={basket}
|
|
413
|
-
priced={priced}
|
|
414
|
-
onBasketChange={replaceBasket}
|
|
415
|
-
onPriced={setPriced}
|
|
416
|
-
/>
|
|
373
|
+
<WholesaleCatalog />
|
|
417
374
|
<WholesaleOrders />
|
|
418
|
-
<ResellerApiKeys />
|
|
375
|
+
<ResellerApiKeys program={program} />
|
|
419
376
|
</div>
|
|
420
377
|
);
|
|
421
378
|
}
|
|
@@ -494,6 +451,12 @@ function WholesaleWallet() {
|
|
|
494
451
|
/>
|
|
495
452
|
</div>
|
|
496
453
|
|
|
454
|
+
<HostedPageLink
|
|
455
|
+
page="topup"
|
|
456
|
+
hints={{ for: 'reseller' }}
|
|
457
|
+
label={t('reseller.topUp')}
|
|
458
|
+
className="sxa:mt-4 sxa:border-t sxa:pt-4"
|
|
459
|
+
/>
|
|
497
460
|
</AccountCard>
|
|
498
461
|
</section>
|
|
499
462
|
);
|
|
@@ -520,11 +483,8 @@ type PagedState<T> =
|
|
|
520
483
|
/**
|
|
521
484
|
* A list that GROWS a page at a time.
|
|
522
485
|
*
|
|
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.
|
|
486
|
+
* Both wholesale lists append rather than replace, like the buyer's own order
|
|
487
|
+
* history, so the two read alike.
|
|
528
488
|
*
|
|
529
489
|
* The page number advances only on a SUCCESSFUL read, and it advances to the
|
|
530
490
|
* page the producer says it answered. A failed "next" leaves the list exactly
|
|
@@ -641,7 +601,7 @@ interface CatalogRow {
|
|
|
641
601
|
currency: string;
|
|
642
602
|
}
|
|
643
603
|
|
|
644
|
-
function WholesaleCatalog(
|
|
604
|
+
function WholesaleCatalog() {
|
|
645
605
|
const searchId = useId();
|
|
646
606
|
const [search, setSearch] = useState('');
|
|
647
607
|
const [query, setQuery] = useState('');
|
|
@@ -681,8 +641,7 @@ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
|
|
|
681
641
|
className="sxa:flex sxa:items-center sxa:gap-2"
|
|
682
642
|
onSubmit={(event) => {
|
|
683
643
|
event.preventDefault();
|
|
684
|
-
// A new search restarts the list at page one.
|
|
685
|
-
// above this component, so it survives the reset intact.
|
|
644
|
+
// A new search restarts the list at page one.
|
|
686
645
|
setQuery(search.trim());
|
|
687
646
|
}}
|
|
688
647
|
>
|
|
@@ -744,7 +703,7 @@ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
|
|
|
744
703
|
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
745
704
|
{slice.items.map((row) => (
|
|
746
705
|
<li key={row.item.product_id}>
|
|
747
|
-
<CatalogProduct item={row.item} currency={row.currency}
|
|
706
|
+
<CatalogProduct item={row.item} currency={row.currency} />
|
|
748
707
|
</li>
|
|
749
708
|
))}
|
|
750
709
|
</ul>
|
|
@@ -757,11 +716,9 @@ function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
|
|
|
757
716
|
function CatalogProduct({
|
|
758
717
|
item,
|
|
759
718
|
currency,
|
|
760
|
-
onAdd,
|
|
761
719
|
}: {
|
|
762
720
|
item: ResellerCatalogItem;
|
|
763
721
|
currency: string;
|
|
764
|
-
onAdd: (line: BasketLine) => void;
|
|
765
722
|
}) {
|
|
766
723
|
return (
|
|
767
724
|
<AccountCard className="sxa:p-5">
|
|
@@ -771,27 +728,8 @@ function CatalogProduct({
|
|
|
771
728
|
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
772
729
|
{formatAccountAmount(item.unit_price, currency)}
|
|
773
730
|
{item.volume_discount_enabled ? ` · ${t('reseller.volumePricing')}` : ''}
|
|
774
|
-
{item.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
|
|
775
731
|
</p>
|
|
776
732
|
</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
733
|
</div>
|
|
796
734
|
|
|
797
735
|
{item.variants.length > 0 ? (
|
|
@@ -799,10 +737,8 @@ function CatalogProduct({
|
|
|
799
737
|
{item.variants.map((variant) => (
|
|
800
738
|
<CatalogVariant
|
|
801
739
|
key={variant.variant_id}
|
|
802
|
-
item={item}
|
|
803
740
|
variant={variant}
|
|
804
741
|
currency={currency}
|
|
805
|
-
onAdd={onAdd}
|
|
806
742
|
/>
|
|
807
743
|
))}
|
|
808
744
|
</ul>
|
|
@@ -812,15 +748,11 @@ function CatalogProduct({
|
|
|
812
748
|
}
|
|
813
749
|
|
|
814
750
|
function CatalogVariant({
|
|
815
|
-
item,
|
|
816
751
|
variant,
|
|
817
752
|
currency,
|
|
818
|
-
onAdd,
|
|
819
753
|
}: {
|
|
820
|
-
item: ResellerCatalogItem;
|
|
821
754
|
variant: ResellerCatalogVariant;
|
|
822
755
|
currency: string;
|
|
823
|
-
onAdd: (line: BasketLine) => void;
|
|
824
756
|
}) {
|
|
825
757
|
return (
|
|
826
758
|
<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 +760,12 @@ function CatalogVariant({
|
|
|
828
760
|
<p className="sxa:truncate sxa:text-small">{variant.title}</p>
|
|
829
761
|
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
830
762
|
{formatAccountAmount(variant.unit_price, currency)}
|
|
831
|
-
{variant.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
|
|
832
763
|
</p>
|
|
833
764
|
</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
765
|
</li>
|
|
854
766
|
);
|
|
855
767
|
}
|
|
856
768
|
|
|
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
769
|
function WholesaleOrders() {
|
|
1033
770
|
const [selected, setSelected] = useState<string | null>(null);
|
|
1034
771
|
const { state, moreBusy, moreError, loadMore, reload } = usePagedList<ResellerOrderListItem>(
|
|
@@ -1117,7 +854,7 @@ function WholesaleOrders() {
|
|
|
1117
854
|
<span className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
1118
855
|
{formatAccountAmount(order.total, order.currency)}
|
|
1119
856
|
</span>
|
|
1120
|
-
<
|
|
857
|
+
<InvoiceStatusChip status={order.status} />
|
|
1121
858
|
</button>
|
|
1122
859
|
</li>
|
|
1123
860
|
))}
|
|
@@ -1180,7 +917,7 @@ function WholesaleOrderDetail({ uniqid, onBack }: { uniqid: string; onBack: () =
|
|
|
1180
917
|
{formatAccountDateTime(order.created_at)}
|
|
1181
918
|
</p>
|
|
1182
919
|
</div>
|
|
1183
|
-
<
|
|
920
|
+
<InvoiceStatusChip status={order.status} />
|
|
1184
921
|
</div>
|
|
1185
922
|
|
|
1186
923
|
<AccountCard className="sxa:mt-5 sxa:p-6">
|
|
@@ -1257,22 +994,39 @@ function OrderLine({ line, currency }: { line: ResellerOrderLine; currency: stri
|
|
|
1257
994
|
|
|
1258
995
|
/* --- API keys ------------------------------------------------------------- */
|
|
1259
996
|
|
|
1260
|
-
|
|
997
|
+
/**
|
|
998
|
+
* Keys and the delivery URL are credentials, so they are created, shown and
|
|
999
|
+
* revoked on the hosted reseller page. The delivery row only when the shop has
|
|
1000
|
+
* delivery URLs on.
|
|
1001
|
+
*/
|
|
1002
|
+
function CredentialLinks({ program }: { program: ResellerProgramState }) {
|
|
1003
|
+
return (
|
|
1004
|
+
<div className="sxa:space-y-3 sxa:text-left">
|
|
1005
|
+
<HostedPageLink page="reseller" hints={{ tab: 'keys' }} label={t('reseller.manageKeys')} />
|
|
1006
|
+
{program.delivery_url_enabled ? (
|
|
1007
|
+
<HostedPageLink
|
|
1008
|
+
page="reseller"
|
|
1009
|
+
hints={{ tab: 'delivery' }}
|
|
1010
|
+
label={t('reseller.delivery')}
|
|
1011
|
+
hint={t('reseller.deliveryHint')}
|
|
1012
|
+
/>
|
|
1013
|
+
) : null}
|
|
1014
|
+
</div>
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function ResellerApiKeys({ program }: { program: ResellerProgramState }) {
|
|
1261
1019
|
const { state, reload } = useAccountResource('reseller-api-keys', () =>
|
|
1262
1020
|
callCustomerApi((sdk) => sdk.resellerApiKeys()));
|
|
1263
1021
|
const [error, setError] = useState<string | null>(null);
|
|
1264
1022
|
|
|
1265
1023
|
const heading = <AccountSectionTitle>{t('reseller.apiKeysTitle')}</AccountSectionTitle>;
|
|
1266
1024
|
|
|
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
|
-
*/
|
|
1025
|
+
/* Where "Create key" would have been: the hosted reseller page mints and revokes. */
|
|
1272
1026
|
const elsewhere = (
|
|
1273
|
-
<
|
|
1274
|
-
{
|
|
1275
|
-
</
|
|
1027
|
+
<div className="sxa:mt-4 sxa:rounded-input sxa:border sxa:bg-bg sxa:p-5">
|
|
1028
|
+
<CredentialLinks program={program} />
|
|
1029
|
+
</div>
|
|
1276
1030
|
);
|
|
1277
1031
|
|
|
1278
1032
|
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.
|
package/eject/account/shared.tsx
CHANGED
|
@@ -12,16 +12,19 @@
|
|
|
12
12
|
* of them invents a value the producer did not send.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { buyerInvoiceStatus, type BuyerStatusTone } from './buyer-invoice-status';
|
|
15
16
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
16
17
|
import { Link } from 'react-router-dom';
|
|
17
|
-
import { toPanelState, type AccountPanelState } from './customer-account';
|
|
18
|
+
import { safeExternalHref, toPanelState, type AccountPanelState } from './customer-account';
|
|
18
19
|
import type { SdkResponse } from '@shoppexio/storefront-react';
|
|
20
|
+
import { hostedPageHref, type HostedPage } from './hosted-page';
|
|
19
21
|
import { getActiveLocale, t, tOptional } from './i18n';
|
|
20
22
|
import {
|
|
21
23
|
ArrowLeftIcon,
|
|
22
24
|
CheckIcon,
|
|
23
25
|
CircleAlertIcon,
|
|
24
26
|
CopyIcon,
|
|
27
|
+
ExternalLinkIcon,
|
|
25
28
|
LoaderIcon,
|
|
26
29
|
} from './icons';
|
|
27
30
|
|
|
@@ -211,11 +214,8 @@ const STATUS_TONES: Record<string, string> = {
|
|
|
211
214
|
CONVERTED: TONE_GOOD,
|
|
212
215
|
// In flight. The buyer is waiting on someone, or someone is waiting on them.
|
|
213
216
|
PENDING: TONE_WAITING,
|
|
214
|
-
PENDING_PAYMENT: TONE_WAITING,
|
|
215
217
|
PROCESSING: TONE_WAITING,
|
|
216
|
-
AWAITING_STOCK: TONE_WAITING,
|
|
217
218
|
AWAITING_REPLY: TONE_WAITING,
|
|
218
|
-
UNDERPAID: TONE_WAITING,
|
|
219
219
|
TRIALING: TONE_WAITING,
|
|
220
220
|
PAUSED: TONE_WAITING,
|
|
221
221
|
INCOMPLETE: TONE_WAITING,
|
|
@@ -245,6 +245,26 @@ const STATUS_TONES: Record<string, string> = {
|
|
|
245
245
|
* the tone never applied.
|
|
246
246
|
*/
|
|
247
247
|
|
|
248
|
+
const INVOICE_TONES: Record<BuyerStatusTone, string> = {
|
|
249
|
+
positive: TONE_GOOD,
|
|
250
|
+
notice: TONE_WAITING,
|
|
251
|
+
danger: TONE_BAD,
|
|
252
|
+
neutral: 'sxa:meta-chip--neutral',
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* An order's status: the word (`invoiceStatus.*`) and the tone from the one
|
|
257
|
+
* buyer status table (Design Batch 9, C2), the rows the checkout and the theme
|
|
258
|
+
* account read too. A status the table does not know falls to `StatusChip`.
|
|
259
|
+
*/
|
|
260
|
+
export function InvoiceStatusChip({ status }: { status: string }) {
|
|
261
|
+
const row = buyerInvoiceStatus(status.trim().toUpperCase());
|
|
262
|
+
if (!row) return <StatusChip status={status} />;
|
|
263
|
+
return (
|
|
264
|
+
<span className={`sx-account-status sx-account-chip sxa:meta-chip sxa:meta-chip--status ${INVOICE_TONES[row.tone]} sxa:shrink-0`}>{t(`invoiceStatus.${row.labelKey}`)}</span>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
248
268
|
/** `label` replaces the status word when the caller has a better one; the tone still follows `status`. */
|
|
249
269
|
export function StatusChip({ status, label }: { status: string; label?: string }) {
|
|
250
270
|
const tone = STATUS_TONES[status.trim().toUpperCase()] ?? 'sxa:meta-chip--neutral';
|
|
@@ -393,6 +413,42 @@ export function CopyButton({ value, className = '' }: { value: string; className
|
|
|
393
413
|
);
|
|
394
414
|
}
|
|
395
415
|
|
|
416
|
+
/**
|
|
417
|
+
* A way out to a hosted buyer page on the checkout host (design Batch 7, A1):
|
|
418
|
+
* a plate with the external-link glyph, never a solid, because the account
|
|
419
|
+
* itself moves no money and mints no credential. Same tab, so the hosted page
|
|
420
|
+
* can send the buyer back. Renders nothing when the page has no shop or
|
|
421
|
+
* checkout host to link to (`hostedPageHref`).
|
|
422
|
+
*/
|
|
423
|
+
export function HostedPageLink({
|
|
424
|
+
page,
|
|
425
|
+
hints,
|
|
426
|
+
label,
|
|
427
|
+
hint = t('account.secureLink'),
|
|
428
|
+
className = '',
|
|
429
|
+
}: {
|
|
430
|
+
page: HostedPage;
|
|
431
|
+
hints: Record<string, string>;
|
|
432
|
+
label: string;
|
|
433
|
+
/** The line beside the link; null where the card has no room for it. */
|
|
434
|
+
hint?: string | null;
|
|
435
|
+
className?: string;
|
|
436
|
+
}) {
|
|
437
|
+
// The checkout host is a page global, so it is held to the same http(s)
|
|
438
|
+
// rule as any other href this account renders.
|
|
439
|
+
const href = safeExternalHref(hostedPageHref(page, hints));
|
|
440
|
+
if (!href) return null;
|
|
441
|
+
return (
|
|
442
|
+
<div className={`sx-account-hosted-link sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-2 ${className}`}>
|
|
443
|
+
{hint ? <p className="sxa:text-small sxa:text-muted">{hint}</p> : null}
|
|
444
|
+
<a href={href} className="sx-account-button sxa:card-button sxa:card-button--add">
|
|
445
|
+
{label}
|
|
446
|
+
<ExternalLinkIcon className="sxa:ml-1.5 sxa:h-3.5 sxa:w-3.5 sxa:shrink-0" />
|
|
447
|
+
</a>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
396
452
|
/** The spinner a control shows while its own request is in flight. */
|
|
397
453
|
export function BusySpinner({ className = '' }: { className?: string }) {
|
|
398
454
|
return <LoaderIcon className={`sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:animate-spin ${className}`} />;
|