@shoppexio/storefront-react 0.4.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.
@@ -13,6 +13,7 @@
13
13
  * `account-route.ts`. Order detail is not a page of its own.
14
14
  */
15
15
 
16
+ import { BUYER_STATUS_FILTERS, buyerInvoiceStatus, type BuyerStatusFilter } from './buyer-invoice-status';
16
17
  import { useState, type ReactNode } from 'react';
17
18
  import { Link } from 'react-router-dom';
18
19
  import { paymentMethodLabel } from './account-payment-method';
@@ -29,7 +30,7 @@ import {
29
30
  warrantyDate,
30
31
  } from './account-warranties';
31
32
  import { callCustomerApi, safeExternalHref } from './customer-account';
32
- import type { SdkCustomerDeliverySummary, SdkCustomerWarranty } from '@shoppexio/storefront-react';
33
+ import type { SdkCustomerDeliverySummary, SdkCustomerOrderDetail, SdkCustomerWarranty } from '@shoppexio/storefront-react';
33
34
  import { t, tOptional } from './i18n';
34
35
  import {
35
36
  ArrowLeftIcon,
@@ -48,7 +49,7 @@ import {
48
49
  AccountRowsSkeleton,
49
50
  AccountSectionTitle,
50
51
  BusySpinner,
51
- StatusChip,
52
+ InvoiceStatusChip,
52
53
  formatAccountAmount,
53
54
  formatAccountDate,
54
55
  formatInvoiceAmount,
@@ -57,25 +58,6 @@ import {
57
58
 
58
59
  const ORDERS_PER_PAGE = 10;
59
60
 
60
- /** The producer's invoice-status enum, in the order the hosted portal lists it. */
61
- const STATUS_FILTERS = [
62
- 'COMPLETED',
63
- 'PENDING',
64
- 'PROCESSING',
65
- 'REFUNDED',
66
- 'DISPUTED',
67
- 'EXPIRED',
68
- ] as const;
69
-
70
- const STATUS_LABEL_KEYS: Record<(typeof STATUS_FILTERS)[number], string> = {
71
- COMPLETED: 'orders.statusCompleted',
72
- PENDING: 'orders.statusPending',
73
- PROCESSING: 'orders.statusProcessing',
74
- REFUNDED: 'orders.statusRefunded',
75
- DISPUTED: 'orders.statusDisputed',
76
- EXPIRED: 'orders.statusExpired',
77
- };
78
-
79
61
  /** The template's field skin, shared with the checkout and contact forms. */
80
62
  const FIELD =
81
63
  'sx-account-field sxa:w-full sxa:border sxa:bg-[color-mix(in_oklab,_var(--sx-bg)_40%,_transparent)] sxa:px-3 sxa:py-2.5 sxa:text-small sxa:shadow-card sxa:placeholder:text-muted sxa:focus-visible:border-accent sxa:focus-visible:ring-[3px] sxa:focus-visible:ring-[color-mix(in_oklab,_var(--sx-accent)_50%,_transparent)] sxa:focus-visible:outline-none';
@@ -123,7 +105,7 @@ export function OrderRow({
123
105
  <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{formatAccountDate(createdAt)}</p>
124
106
  </div>
125
107
  <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">{amount}</span>
126
- <StatusChip status={status} />
108
+ <InvoiceStatusChip status={status} />
127
109
  </AccountCard>
128
110
  </Link>
129
111
  );
@@ -131,32 +113,33 @@ export function OrderRow({
131
113
 
132
114
  export function AccountOrders() {
133
115
  const [page, setPage] = useState(1);
134
- const [status, setStatus] = useState('');
116
+ const [statusGroup, setStatusGroup] = useState<BuyerStatusFilter | ''>('');
135
117
  const [search, setSearch] = useState('');
136
118
  /** What was last SENT, as against what is currently being typed. */
137
- const [applied, setApplied] = useState({ status: '', search: '' });
119
+ const [applied, setApplied] = useState<{ statusGroup: BuyerStatusFilter | ''; search: string }>({ statusGroup: '', search: '' });
138
120
 
139
121
  const { state, reload } = useAccountResource(
140
- `orders:${page}:${applied.status}:${applied.search}`,
122
+ `orders:${page}:${applied.statusGroup}:${applied.search}`,
141
123
  () => callCustomerApi((sdk) => sdk.orders({
142
124
  page,
143
125
  limit: ORDERS_PER_PAGE,
144
- ...(applied.status ? { status: applied.status } : {}),
126
+ // One group of the buyer status table, filtered by the producer: "Paid" is two statuses.
127
+ ...(applied.statusGroup ? { statusGroup: applied.statusGroup } : {}),
145
128
  ...(applied.search ? { search: applied.search } : {}),
146
129
  })),
147
130
  );
148
131
 
149
132
  // A changed filter is a different list. Staying on page four of the old one
150
133
  // would ask for a page the new list may not have and answer with nothing.
151
- const applyFilters = (next: { status?: string; search?: string }) => {
152
- const merged = { status: next.status ?? status, search: next.search ?? search };
153
- setStatus(merged.status);
134
+ const applyFilters = (next: { statusGroup?: BuyerStatusFilter | ''; search?: string }) => {
135
+ const merged = { statusGroup: next.statusGroup ?? statusGroup, search: next.search ?? search };
136
+ setStatusGroup(merged.statusGroup);
154
137
  setSearch(merged.search);
155
138
  setApplied(merged);
156
139
  setPage(1);
157
140
  };
158
141
 
159
- const filtering = applied.status !== '' || applied.search !== '';
142
+ const filtering = applied.statusGroup !== '' || applied.search !== '';
160
143
 
161
144
  const filterBar = (
162
145
  <form
@@ -176,14 +159,15 @@ export function AccountOrders() {
176
159
  />
177
160
  <div className="sxa:relative">
178
161
  <select
179
- value={status}
162
+ value={statusGroup}
180
163
  aria-label={t('orders.status')}
181
- onChange={(event) => applyFilters({ status: event.target.value })}
164
+ onChange={(event) => applyFilters({ statusGroup: BUYER_STATUS_FILTERS.find((filter) => filter.id === event.target.value)?.id ?? '' })}
182
165
  className="sxa:h-10 sxa:w-full sxa:appearance-none sxa:border sxa:bg-surface sxa:pr-9 sxa:pl-4 sxa:text-small sxa:focus:outline-none sxa:sm:w-auto sxa:rounded-input"
183
166
  >
184
167
  <option value="">{t('orders.statusAll')}</option>
185
- {STATUS_FILTERS.map((value) => (
186
- <option key={value} value={value}>{t(STATUS_LABEL_KEYS[value])}</option>
168
+ {/* The table's groups, in the words of the status chips (Design Batch 9, C2). */}
169
+ {BUYER_STATUS_FILTERS.map((filter) => (
170
+ <option key={filter.id} value={filter.id}>{t(`invoiceStatus.${filter.labelKey}`)}</option>
187
171
  ))}
188
172
  </select>
189
173
  <div className="sxa:pointer-events-none sxa:absolute sxa:inset-y-0 sxa:right-0 sxa:flex sxa:items-center sxa:px-3 sxa:text-muted">
@@ -304,6 +288,63 @@ export function AccountOrders() {
304
288
  );
305
289
  }
306
290
 
291
+ /**
292
+ * Why an order offers no receipt, when the buyer status table gives it none
293
+ * (Design Batch 9, C7). Only where the sentence is true: an expired order that
294
+ * took no payment, and one the shop cancelled. An unpaid order still waits for
295
+ * payment, and a disputed one says nothing more.
296
+ */
297
+ export function noReceiptKey(order: Pick<SdkCustomerOrderDetail, 'status' | 'expiredPayment'>): string | null {
298
+ if (order.status === 'VOIDED') return 'orders.noReceiptVoided';
299
+ if (order.status === 'EXPIRED' && order.expiredPayment === 'NONE') return 'orders.noReceiptUnpaid';
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * When the order was paid, for one the buyer status table counts as paid (a
305
+ * held order waiting for stock is; a disputed one is not), else when it was
306
+ * placed (Design Batch 9, C1).
307
+ */
308
+ export function orderDateLine(order: Pick<SdkCustomerOrderDetail, 'status' | 'createdAt' | 'paidAt'>): string {
309
+ return order.paidAt && buyerInvoiceStatus(order.status)?.paid
310
+ ? t('orders.paidOn', { date: formatAccountDate(order.paidAt) })
311
+ : formatAccountDate(order.createdAt);
312
+ }
313
+
314
+ /**
315
+ * What a paid order held for a restock says (C1). The restock delivers the
316
+ * whole order at once and the wire does not say which line is short, so only
317
+ * a single item is named.
318
+ */
319
+ export function waitingStockNotice(order: Pick<SdkCustomerOrderDetail, 'status' | 'lineItems'>): string | null {
320
+ if (order.status !== 'AWAITING_STOCK') return null;
321
+ const [only] = order.lineItems;
322
+ const body = order.lineItems.length === 1 && only!.quantity === 1
323
+ ? t('orders.waitingStock.body', { item: only!.productTitle })
324
+ : t('orders.waitingStock.bodyOrder');
325
+ return `${t('orders.waitingStock.title')} ${body}`;
326
+ }
327
+
328
+ /** A line of a held order waits for the restock; "Pending" would read as unpaid (C1). */
329
+ export function lineDeliveryChipStatus(
330
+ orderStatus: string,
331
+ line: Pick<SdkCustomerOrderDetail['lineItems'][number], 'deliveryStatus'>,
332
+ ): string {
333
+ return orderStatus === 'AWAITING_STOCK' && line.deliveryStatus === 'PENDING' ? 'WAITING' : line.deliveryStatus;
334
+ }
335
+
336
+ /**
337
+ * The sentence under a line the seller delivered without anything to hand
338
+ * over (C7). Withheld goods (a gift, a reveal not taken) are not "nothing",
339
+ * and neither is a backend that did not say.
340
+ */
341
+ export function nothingToDownloadDate(
342
+ line: Pick<SdkCustomerOrderDetail['lineItems'][number], 'deliveredAt' | 'deliverySummary'>,
343
+ deliveryWithheld: boolean | undefined,
344
+ ): string | null {
345
+ return line.deliveredAt !== null && line.deliverySummary === null && deliveryWithheld === false ? line.deliveredAt : null;
346
+ }
347
+
307
348
  export function AccountOrderDetail({ orderId }: { orderId: string }) {
308
349
  const { state, reload } = useAccountResource(`order:${orderId}`, () =>
309
350
  callCustomerApi((sdk) => sdk.order(orderId)));
@@ -326,6 +367,9 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
326
367
  <div>
327
368
  {backLink}
328
369
  <div className="sxa:mt-5">
370
+ <AccountSectionTitle>{t('orders.detailError', { id: orderId })}</AccountSectionTitle>
371
+ </div>
372
+ <div className="sxa:mt-4">
329
373
  <AccountError message={state.message} onRetry={reload} />
330
374
  </div>
331
375
  </div>
@@ -333,6 +377,8 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
333
377
  }
334
378
 
335
379
  const order = state.data;
380
+ const noReceipt = noReceiptKey(order);
381
+ const waiting = waitingStockNotice(order);
336
382
 
337
383
  return (
338
384
  <div>
@@ -341,9 +387,9 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
341
387
  <div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
342
388
  <div>
343
389
  <AccountSectionTitle>{t('orders.detailTitle', { id: order.uniqid })}</AccountSectionTitle>
344
- <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{formatAccountDate(order.createdAt)}</p>
390
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{orderDateLine(order)}</p>
345
391
  </div>
346
- <StatusChip status={order.status} />
392
+ <InvoiceStatusChip status={order.status} />
347
393
  </div>
348
394
 
349
395
  {/*
@@ -356,7 +402,18 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
356
402
  <p className="sxa:mt-3 sxa:text-small sxa:leading-relaxed sxa:text-muted">{order.statusDetails}</p>
357
403
  ) : null}
358
404
 
359
- <InvoicePdfButton orderUniqid={order.uniqid} />
405
+ {waiting ? (
406
+ <AccountCard className="sxa:mt-4 sxa:px-5 sxa:py-4">
407
+ <p role="status" className="sxa:text-small sxa:leading-relaxed">{waiting}</p>
408
+ </AccountCard>
409
+ ) : null}
410
+
411
+ {/* The receipt follows the buyer status table: unpaid, expired, cancelled and disputed orders have none. */}
412
+ {buyerInvoiceStatus(order.status)?.receipt ? (
413
+ <InvoicePdfButton orderUniqid={order.uniqid} />
414
+ ) : noReceipt ? (
415
+ <p className="sxa:mt-4 sxa:text-small sxa:text-muted">{t(noReceipt)}</p>
416
+ ) : null}
360
417
 
361
418
  <ul className="sxa:mt-6 sxa:space-y-3">
362
419
  {order.lineItems.map((item) => (
@@ -384,7 +441,7 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
384
441
  rendered for every line rather than only for the unhappy ones.
385
442
  */}
386
443
  <p className="sxa:mt-1.5">
387
- <DeliveryChip status={item.deliveryStatus} />
444
+ <DeliveryChip status={lineDeliveryChipStatus(order.status, item)} />
388
445
  </p>
389
446
  </div>
390
447
  </div>
@@ -396,6 +453,7 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
396
453
  payment status.
397
454
  */}
398
455
  {item.deliverySummary ? <DeliveredItems summary={item.deliverySummary} /> : null}
456
+ {nothingToDownload(item, order.deliveryWithheld)}
399
457
  </AccountCard>
400
458
  </li>
401
459
  ))}
@@ -461,9 +519,16 @@ export function AccountOrderDetail({ orderId }: { orderId: string }) {
461
519
  * a temporary object URL on an anchor the buyer never sees; the name is the
462
520
  * producer's, and when it sent none the browser picks one.
463
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
+
464
528
  function InvoicePdfButton({ orderUniqid }: { orderUniqid: string }) {
465
529
  const [busy, setBusy] = useState(false);
466
- const [error, setError] = useState<string | null>(null);
530
+ /** Why the last try failed: the session ended (sign in again) or the receipt could not be made (try later). */
531
+ const [error, setError] = useState<'signed-out' | 'failed' | null>(null);
467
532
 
468
533
  const download = async () => {
469
534
  setBusy(true);
@@ -472,7 +537,7 @@ function InvoicePdfButton({ orderUniqid }: { orderUniqid: string }) {
472
537
  setBusy(false);
473
538
 
474
539
  if (!response.success || response.data === undefined) {
475
- setError(response.message?.trim() || t('orders.invoicePdfFailed'));
540
+ setError(response.status === 401 || response.status === 403 ? 'signed-out' : 'failed');
476
541
  return;
477
542
  }
478
543
 
@@ -498,7 +563,16 @@ function InvoicePdfButton({ orderUniqid }: { orderUniqid: string }) {
498
563
  : <ReceiptIcon className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" />}
499
564
  {busy ? t('orders.invoicePdfLoading') : t('orders.invoicePdf')}
500
565
  </button>
501
- {error ? <AccountFieldError message={error} /> : null}
566
+ {error === 'failed' ? <AccountFieldError message={t('orders.pdfFailed')} /> : null}
567
+ {error === 'signed-out' ? (
568
+ <div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-3">
569
+ <AccountFieldError message={t('orders.pdfSignedOut')} />
570
+ {/* A fresh load asks who is signed in and shows the sign-in form. */}
571
+ <button type="button" onClick={() => window.location.reload()} className="sx-account-button sxa:card-button sxa:card-button--add sxa:mt-3">
572
+ {t('login.submit')}
573
+ </button>
574
+ </div>
575
+ ) : null}
502
576
  </div>
503
577
  );
504
578
  }
@@ -54,6 +54,7 @@ import {
54
54
  BusySpinner,
55
55
  CopyButton,
56
56
  HostedPageLink,
57
+ InvoiceStatusChip,
57
58
  StatusChip,
58
59
  formatAccountAmount,
59
60
  formatAccountDate,
@@ -853,7 +854,7 @@ function WholesaleOrders() {
853
854
  <span className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
854
855
  {formatAccountAmount(order.total, order.currency)}
855
856
  </span>
856
- <StatusChip status={order.status} />
857
+ <InvoiceStatusChip status={order.status} />
857
858
  </button>
858
859
  </li>
859
860
  ))}
@@ -916,7 +917,7 @@ function WholesaleOrderDetail({ uniqid, onBack }: { uniqid: string; onBack: () =
916
917
  {formatAccountDateTime(order.created_at)}
917
918
  </p>
918
919
  </div>
919
- <StatusChip status={order.status} />
920
+ <InvoiceStatusChip status={order.status} />
920
921
  </div>
921
922
 
922
923
  <AccountCard className="sxa:mt-5 sxa:p-6">
@@ -12,6 +12,7 @@
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
18
  import { safeExternalHref, toPanelState, type AccountPanelState } from './customer-account';
@@ -213,11 +214,8 @@ const STATUS_TONES: Record<string, string> = {
213
214
  CONVERTED: TONE_GOOD,
214
215
  // In flight. The buyer is waiting on someone, or someone is waiting on them.
215
216
  PENDING: TONE_WAITING,
216
- PENDING_PAYMENT: TONE_WAITING,
217
217
  PROCESSING: TONE_WAITING,
218
- AWAITING_STOCK: TONE_WAITING,
219
218
  AWAITING_REPLY: TONE_WAITING,
220
- UNDERPAID: TONE_WAITING,
221
219
  TRIALING: TONE_WAITING,
222
220
  PAUSED: TONE_WAITING,
223
221
  INCOMPLETE: TONE_WAITING,
@@ -247,6 +245,26 @@ const STATUS_TONES: Record<string, string> = {
247
245
  * the tone never applied.
248
246
  */
249
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
+
250
268
  /** `label` replaces the status word when the caller has a better one; the tone still follows `status`. */
251
269
  export function StatusChip({ status, label }: { status: string; label?: string }) {
252
270
  const tone = STATUS_TONES[status.trim().toUpperCase()] ?? 'sxa:meta-chip--neutral';