@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
package/eject/account/orders.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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 [
|
|
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({
|
|
119
|
+
const [applied, setApplied] = useState<{ statusGroup: BuyerStatusFilter | ''; search: string }>({ statusGroup: '', search: '' });
|
|
138
120
|
|
|
139
121
|
const { state, reload } = useAccountResource(
|
|
140
|
-
`orders:${page}:${applied.
|
|
122
|
+
`orders:${page}:${applied.statusGroup}:${applied.search}`,
|
|
141
123
|
() => callCustomerApi((sdk) => sdk.orders({
|
|
142
124
|
page,
|
|
143
125
|
limit: ORDERS_PER_PAGE,
|
|
144
|
-
|
|
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: {
|
|
152
|
-
const merged = {
|
|
153
|
-
|
|
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.
|
|
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={
|
|
162
|
+
value={statusGroup}
|
|
180
163
|
aria-label={t('orders.status')}
|
|
181
|
-
onChange={(event) => applyFilters({
|
|
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
|
-
{
|
|
186
|
-
|
|
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">{
|
|
390
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">{orderDateLine(order)}</p>
|
|
345
391
|
</div>
|
|
346
|
-
<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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={
|
|
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
|
-
{
|
|
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>
|