@shoppexio/storefront-react 0.0.0 → 0.1.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 +20 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/account.css +1171 -0
- package/dist/account.d.ts +23 -0
- package/dist/account.js +12685 -0
- package/dist/account.js.map +1 -0
- package/dist/chunk-D6XKDVAV.js +181 -0
- package/dist/chunk-D6XKDVAV.js.map +1 -0
- package/dist/index.d.ts +3087 -0
- package/dist/index.js +2801 -0
- package/dist/index.js.map +1 -0
- package/eject/account/account-availability.ts +171 -0
- package/eject/account/account-downloads.ts +118 -0
- package/eject/account/account-favorites.ts +99 -0
- package/eject/account/account-loyalty.ts +241 -0
- package/eject/account/account-page.tsx +410 -0
- package/eject/account/account-payment-method.ts +99 -0
- package/eject/account/account-referrals.ts +68 -0
- package/eject/account/account-reseller-invite.ts +88 -0
- package/eject/account/account-reseller.ts +685 -0
- package/eject/account/account-rewards.ts +104 -0
- package/eject/account/account-settings.ts +181 -0
- package/eject/account/account-signin-link.ts +122 -0
- package/eject/account/account-subscriptions.ts +130 -0
- package/eject/account/account-support.ts +155 -0
- package/eject/account/account-warranties.ts +118 -0
- package/eject/account/customer-account.ts +93 -0
- package/eject/account/dashboard.tsx +139 -0
- package/eject/account/downloads.tsx +343 -0
- package/eject/account/favorites.tsx +201 -0
- package/eject/account/i18n.ts +175 -0
- package/eject/account/icons.tsx +218 -0
- package/eject/account/login.tsx +253 -0
- package/eject/account/messages/ar.json +464 -0
- package/eject/account/messages/de.json +464 -0
- package/eject/account/messages/en.json +464 -0
- package/eject/account/messages/es.json +464 -0
- package/eject/account/messages/fi.json +464 -0
- package/eject/account/messages/fr.json +464 -0
- package/eject/account/messages/hi.json +464 -0
- package/eject/account/messages/it.json +464 -0
- package/eject/account/messages/lt.json +464 -0
- package/eject/account/messages/nl.json +464 -0
- package/eject/account/messages/pl.json +464 -0
- package/eject/account/messages/pt.json +464 -0
- package/eject/account/messages/ru.json +464 -0
- package/eject/account/messages/tr.json +464 -0
- package/eject/account/messages/uk.json +464 -0
- package/eject/account/messages/zh.json +464 -0
- package/eject/account/orders.tsx +791 -0
- package/eject/account/overview.tsx +147 -0
- package/eject/account/referrals.tsx +327 -0
- package/eject/account/reseller.tsx +1365 -0
- package/eject/account/rewards.tsx +641 -0
- package/eject/account/settings.tsx +699 -0
- package/eject/account/shared.tsx +398 -0
- package/eject/account/subscriptions.tsx +421 -0
- package/eject/account/support.tsx +453 -0
- package/package.json +75 -1
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Order history, and one order in full.
|
|
3
|
+
*
|
|
4
|
+
* Pagination is PAGE based because the producer is: it reads `page` and `limit`
|
|
5
|
+
* and answers with `has_more`. There is no cursor to carry, and inventing one
|
|
6
|
+
* would be ignored upstream and quietly return page one for ever.
|
|
7
|
+
*
|
|
8
|
+
* The invoice search and the status filter travel WITH the request. The list is
|
|
9
|
+
* paged, so filtering the rows already in hand would search only the ten rows
|
|
10
|
+
* the buyer happens to be looking at and call the rest of their history missing.
|
|
11
|
+
*
|
|
12
|
+
* List and detail are two views of the same panel, chosen by the route — see
|
|
13
|
+
* `account-route.ts`. Order detail is not a page of its own.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState, type ReactNode } from 'react';
|
|
17
|
+
import { Link } from 'react-router-dom';
|
|
18
|
+
import { paymentMethodLabel } from './account-payment-method';
|
|
19
|
+
import { accountOrderHref, accountTabHref } from '@shoppexio/storefront-react';
|
|
20
|
+
import {
|
|
21
|
+
WARRANTY_CLAIM_MESSAGE_MAX,
|
|
22
|
+
canClaimWarranty,
|
|
23
|
+
warrantiesForOrder,
|
|
24
|
+
warrantyClaimKey,
|
|
25
|
+
warrantyClaimMessage,
|
|
26
|
+
warrantyCoverage,
|
|
27
|
+
type WarrantyCoverage,
|
|
28
|
+
warrantyDate,
|
|
29
|
+
} from './account-warranties';
|
|
30
|
+
import { callCustomerApi, safeExternalHref } from './customer-account';
|
|
31
|
+
import type { SdkCustomerDeliverySummary, SdkCustomerWarranty } from '@shoppexio/storefront-react';
|
|
32
|
+
import { t, tOptional } from './i18n';
|
|
33
|
+
import {
|
|
34
|
+
ArrowLeftIcon,
|
|
35
|
+
ArrowRightIcon,
|
|
36
|
+
ChevronDownIcon,
|
|
37
|
+
ReceiptIcon,
|
|
38
|
+
ShieldIcon,
|
|
39
|
+
} from './icons';
|
|
40
|
+
import {
|
|
41
|
+
AccountBackLink,
|
|
42
|
+
AccountCard,
|
|
43
|
+
AccountEmpty,
|
|
44
|
+
AccountError,
|
|
45
|
+
AccountFieldError,
|
|
46
|
+
AccountNotice,
|
|
47
|
+
AccountRowsSkeleton,
|
|
48
|
+
AccountSectionTitle,
|
|
49
|
+
BusySpinner,
|
|
50
|
+
StatusChip,
|
|
51
|
+
formatAccountAmount,
|
|
52
|
+
formatAccountDate,
|
|
53
|
+
formatInvoiceAmount,
|
|
54
|
+
useAccountResource,
|
|
55
|
+
} from './shared';
|
|
56
|
+
|
|
57
|
+
const ORDERS_PER_PAGE = 10;
|
|
58
|
+
|
|
59
|
+
/** The producer's invoice-status enum, in the order the hosted portal lists it. */
|
|
60
|
+
const STATUS_FILTERS = [
|
|
61
|
+
'COMPLETED',
|
|
62
|
+
'PENDING',
|
|
63
|
+
'PROCESSING',
|
|
64
|
+
'REFUNDED',
|
|
65
|
+
'DISPUTED',
|
|
66
|
+
'EXPIRED',
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
const STATUS_LABEL_KEYS: Record<(typeof STATUS_FILTERS)[number], string> = {
|
|
70
|
+
COMPLETED: 'orders.statusCompleted',
|
|
71
|
+
PENDING: 'orders.statusPending',
|
|
72
|
+
PROCESSING: 'orders.statusProcessing',
|
|
73
|
+
REFUNDED: 'orders.statusRefunded',
|
|
74
|
+
DISPUTED: 'orders.statusDisputed',
|
|
75
|
+
EXPIRED: 'orders.statusExpired',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The template's field skin, shared with the checkout and contact forms. */
|
|
79
|
+
const FIELD =
|
|
80
|
+
'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';
|
|
81
|
+
|
|
82
|
+
export function moreItemsLabel(count: number): string {
|
|
83
|
+
return count === 1 ? t('orders.andMoreOne') : t('orders.andMoreOther', { count });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What to call an order in a list.
|
|
88
|
+
*
|
|
89
|
+
* The first line's product title, and the count of the lines behind it. A line
|
|
90
|
+
* whose title the producer sent as null is named by the order's own id rather
|
|
91
|
+
* than by a product this template guessed at.
|
|
92
|
+
*/
|
|
93
|
+
export function orderRowTitle(
|
|
94
|
+
firstTitle: string | null | undefined,
|
|
95
|
+
lineCount: number,
|
|
96
|
+
uniqid: string,
|
|
97
|
+
): string {
|
|
98
|
+
const head = firstTitle ?? t('orders.fallbackTitle', { id: uniqid });
|
|
99
|
+
return lineCount > 1 ? `${head} ${moreItemsLabel(lineCount - 1)}` : head;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One order, as a row that opens it. Both this panel and the overview use it. */
|
|
103
|
+
export function OrderRow({
|
|
104
|
+
uniqid,
|
|
105
|
+
title,
|
|
106
|
+
createdAt,
|
|
107
|
+
amount,
|
|
108
|
+
status,
|
|
109
|
+
}: {
|
|
110
|
+
uniqid: string;
|
|
111
|
+
title: string;
|
|
112
|
+
createdAt: string;
|
|
113
|
+
amount: string;
|
|
114
|
+
status: string;
|
|
115
|
+
}) {
|
|
116
|
+
return (
|
|
117
|
+
<Link to={accountOrderHref(uniqid)} className="sxa:block sxa:transition-opacity sxa:hover:opacity-80">
|
|
118
|
+
<AccountCard className="sxa:flex sxa:items-center sxa:gap-4 sxa:px-5 sxa:py-4">
|
|
119
|
+
<ReceiptIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
120
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
121
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{title}</p>
|
|
122
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{formatAccountDate(createdAt)}</p>
|
|
123
|
+
</div>
|
|
124
|
+
<span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">{amount}</span>
|
|
125
|
+
<StatusChip status={status} />
|
|
126
|
+
</AccountCard>
|
|
127
|
+
</Link>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function AccountOrders() {
|
|
132
|
+
const [page, setPage] = useState(1);
|
|
133
|
+
const [status, setStatus] = useState('');
|
|
134
|
+
const [search, setSearch] = useState('');
|
|
135
|
+
/** What was last SENT, as against what is currently being typed. */
|
|
136
|
+
const [applied, setApplied] = useState({ status: '', search: '' });
|
|
137
|
+
|
|
138
|
+
const { state, reload } = useAccountResource(
|
|
139
|
+
`orders:${page}:${applied.status}:${applied.search}`,
|
|
140
|
+
() => callCustomerApi((sdk) => sdk.orders({
|
|
141
|
+
page,
|
|
142
|
+
limit: ORDERS_PER_PAGE,
|
|
143
|
+
...(applied.status ? { status: applied.status } : {}),
|
|
144
|
+
...(applied.search ? { search: applied.search } : {}),
|
|
145
|
+
})),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// A changed filter is a different list. Staying on page four of the old one
|
|
149
|
+
// would ask for a page the new list may not have and answer with nothing.
|
|
150
|
+
const applyFilters = (next: { status?: string; search?: string }) => {
|
|
151
|
+
const merged = { status: next.status ?? status, search: next.search ?? search };
|
|
152
|
+
setStatus(merged.status);
|
|
153
|
+
setSearch(merged.search);
|
|
154
|
+
setApplied(merged);
|
|
155
|
+
setPage(1);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const filtering = applied.status !== '' || applied.search !== '';
|
|
159
|
+
|
|
160
|
+
const filterBar = (
|
|
161
|
+
<form
|
|
162
|
+
className="sxa:mb-5 sxa:flex sxa:flex-col sxa:gap-2 sxa:sm:flex-row"
|
|
163
|
+
onSubmit={(event) => {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
applyFilters({});
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<input
|
|
169
|
+
type="search"
|
|
170
|
+
value={search}
|
|
171
|
+
placeholder={t('orders.searchPlaceholder')}
|
|
172
|
+
aria-label={t('orders.search')}
|
|
173
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
174
|
+
className={`sxa:min-w-0 sxa:flex-1 ${FIELD} sxa:h-10 sxa:rounded-input`}
|
|
175
|
+
/>
|
|
176
|
+
<div className="sxa:relative">
|
|
177
|
+
<select
|
|
178
|
+
value={status}
|
|
179
|
+
aria-label={t('orders.status')}
|
|
180
|
+
onChange={(event) => applyFilters({ status: event.target.value })}
|
|
181
|
+
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"
|
|
182
|
+
>
|
|
183
|
+
<option value="">{t('orders.statusAll')}</option>
|
|
184
|
+
{STATUS_FILTERS.map((value) => (
|
|
185
|
+
<option key={value} value={value}>{t(STATUS_LABEL_KEYS[value])}</option>
|
|
186
|
+
))}
|
|
187
|
+
</select>
|
|
188
|
+
<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">
|
|
189
|
+
<ChevronDownIcon className="sxa:h-4 sxa:w-4" />
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<button type="submit" className="sx-account-button sxa:card-button sxa:card-button--add sxa:shrink-0">
|
|
193
|
+
{t('orders.search')}
|
|
194
|
+
</button>
|
|
195
|
+
</form>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (state.status === 'loading') {
|
|
199
|
+
return (
|
|
200
|
+
<div>
|
|
201
|
+
{filterBar}
|
|
202
|
+
<AccountRowsSkeleton rows={4} label={t('orders.loading')} />
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (state.status === 'error') {
|
|
208
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const { invoices, pagination } = state.data;
|
|
212
|
+
|
|
213
|
+
if (invoices.length === 0) {
|
|
214
|
+
// A filter that matched nothing is not an empty account. Telling a buyer
|
|
215
|
+
// they have never ordered anything because they mistyped an invoice id is a
|
|
216
|
+
// lie the control they just used caused.
|
|
217
|
+
if (filtering && pagination.page === 1) {
|
|
218
|
+
return (
|
|
219
|
+
<div>
|
|
220
|
+
{filterBar}
|
|
221
|
+
<AccountEmpty title={t('orders.movedTitle')} body={t('orders.emptyFiltered')} />
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// An empty page one means the account has no orders. A later page coming
|
|
227
|
+
// back empty means the list moved underneath the buyer, and the way out of
|
|
228
|
+
// that is page one — not a message that pretends they never bought anything.
|
|
229
|
+
return pagination.page > 1 ? (
|
|
230
|
+
<AccountEmpty
|
|
231
|
+
title={t('orders.movedTitle')}
|
|
232
|
+
body={t('orders.movedBody')}
|
|
233
|
+
action={(
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className="sx-account-button sxa:card-button sxa:card-button--add"
|
|
237
|
+
onClick={() => setPage(1)}
|
|
238
|
+
>
|
|
239
|
+
{t('orders.backToFirstPage')}
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
243
|
+
) : (
|
|
244
|
+
<AccountEmpty
|
|
245
|
+
title={t('orders.emptyTitle')}
|
|
246
|
+
body={t('orders.emptyBody')}
|
|
247
|
+
action={(
|
|
248
|
+
<Link to="/all-products" className="sx-account-button sxa:card-button sxa:card-button--add">
|
|
249
|
+
{t('common.browseStore')}
|
|
250
|
+
</Link>
|
|
251
|
+
)}
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div>
|
|
258
|
+
{filterBar}
|
|
259
|
+
<ul className="sxa:space-y-3">
|
|
260
|
+
{invoices.map((order) => (
|
|
261
|
+
<li key={order.uniqid}>
|
|
262
|
+
<OrderRow
|
|
263
|
+
uniqid={order.uniqid}
|
|
264
|
+
title={orderRowTitle(
|
|
265
|
+
order.lineItems[0]?.productTitle,
|
|
266
|
+
order.lineItems.length,
|
|
267
|
+
order.uniqid,
|
|
268
|
+
)}
|
|
269
|
+
createdAt={order.createdAt}
|
|
270
|
+
amount={formatInvoiceAmount(order.totalDisplay, order.total, order.currency)}
|
|
271
|
+
status={order.status}
|
|
272
|
+
/>
|
|
273
|
+
</li>
|
|
274
|
+
))}
|
|
275
|
+
</ul>
|
|
276
|
+
|
|
277
|
+
{pagination.page > 1 || pagination.has_more ? (
|
|
278
|
+
<nav className="sxa:mt-6 sxa:flex sxa:items-center sxa:justify-between sxa:gap-4" aria-label={t('orders.pagesLabel')}>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-40"
|
|
282
|
+
disabled={pagination.page <= 1}
|
|
283
|
+
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
284
|
+
>
|
|
285
|
+
<ArrowLeftIcon className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" />
|
|
286
|
+
{t('orders.previous')}
|
|
287
|
+
</button>
|
|
288
|
+
<p className="sxa:text-small sxa:tabular-nums sxa:text-muted">
|
|
289
|
+
{t('orders.page', { page: pagination.page })}
|
|
290
|
+
</p>
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-40"
|
|
294
|
+
disabled={!pagination.has_more}
|
|
295
|
+
onClick={() => setPage((current) => current + 1)}
|
|
296
|
+
>
|
|
297
|
+
{t('orders.next')}
|
|
298
|
+
<ArrowRightIcon className="sxa:ml-1.5 sxa:h-3.5 sxa:w-3.5" />
|
|
299
|
+
</button>
|
|
300
|
+
</nav>
|
|
301
|
+
) : null}
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function AccountOrderDetail({ orderId }: { orderId: string }) {
|
|
307
|
+
const { state, reload } = useAccountResource(`order:${orderId}`, () =>
|
|
308
|
+
callCustomerApi((sdk) => sdk.order(orderId)));
|
|
309
|
+
|
|
310
|
+
const backLink = <AccountBackLink to={accountTabHref('orders')} label={t('orders.allOrders')} />;
|
|
311
|
+
|
|
312
|
+
if (state.status === 'loading') {
|
|
313
|
+
return (
|
|
314
|
+
<div>
|
|
315
|
+
{backLink}
|
|
316
|
+
<div className="sxa:mt-5">
|
|
317
|
+
<AccountRowsSkeleton rows={3} label={t('orders.loadingOne')} />
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (state.status === 'error') {
|
|
324
|
+
return (
|
|
325
|
+
<div>
|
|
326
|
+
{backLink}
|
|
327
|
+
<div className="sxa:mt-5">
|
|
328
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const order = state.data;
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<div>
|
|
338
|
+
{backLink}
|
|
339
|
+
|
|
340
|
+
<div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
|
|
341
|
+
<div>
|
|
342
|
+
<AccountSectionTitle>{t('orders.detailTitle', { id: order.uniqid })}</AccountSectionTitle>
|
|
343
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">{formatAccountDate(order.createdAt)}</p>
|
|
344
|
+
</div>
|
|
345
|
+
<StatusChip status={order.status} />
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{/*
|
|
349
|
+
The server's own sentence about this status, printed verbatim and only
|
|
350
|
+
when it sent one. It is the single place a buyer learns that a paid
|
|
351
|
+
order did not deliver, or why a refund is still moving — no storefront
|
|
352
|
+
can reconstruct that from the status word alone.
|
|
353
|
+
*/}
|
|
354
|
+
{order.statusDetails ? (
|
|
355
|
+
<p className="sxa:mt-3 sxa:text-small sxa:leading-relaxed sxa:text-muted">{order.statusDetails}</p>
|
|
356
|
+
) : null}
|
|
357
|
+
|
|
358
|
+
<ul className="sxa:mt-6 sxa:space-y-3">
|
|
359
|
+
{order.lineItems.map((item) => (
|
|
360
|
+
<li key={item.id}>
|
|
361
|
+
<AccountCard className="sxa:px-5 sxa:py-4">
|
|
362
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
363
|
+
<div className="sxa:min-w-0">
|
|
364
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{item.productTitle}</p>
|
|
365
|
+
{item.variantTitle ? (
|
|
366
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{item.variantTitle}</p>
|
|
367
|
+
) : null}
|
|
368
|
+
<p className="sxa:mt-1.5 sxa:text-micro sxa:tabular-nums sxa:text-muted">
|
|
369
|
+
{item.quantity} × {formatAccountAmount(item.unitPrice, order.currency)}
|
|
370
|
+
</p>
|
|
371
|
+
</div>
|
|
372
|
+
<div className="sxa:text-right">
|
|
373
|
+
<p className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
374
|
+
{formatAccountAmount(item.total, order.currency)}
|
|
375
|
+
</p>
|
|
376
|
+
{/*
|
|
377
|
+
Fulfillment is decided per LINE and server-side, and it is
|
|
378
|
+
independent of payment: a completed, fully paid invoice can
|
|
379
|
+
still carry a line that never reached the buyer. This chip is
|
|
380
|
+
the only thing on the page that says so, which is why it is
|
|
381
|
+
rendered for every line rather than only for the unhappy ones.
|
|
382
|
+
*/}
|
|
383
|
+
<p className="sxa:mt-1.5">
|
|
384
|
+
<DeliveryChip status={item.deliveryStatus} />
|
|
385
|
+
</p>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
{/*
|
|
390
|
+
What the shop actually handed over for this line, when it handed
|
|
391
|
+
over anything. The presence of a summary is NOT a delivery state
|
|
392
|
+
— the chip above owns that — and nothing here is derived from the
|
|
393
|
+
payment status.
|
|
394
|
+
*/}
|
|
395
|
+
{item.deliverySummary ? <DeliveredItems summary={item.deliverySummary} /> : null}
|
|
396
|
+
</AccountCard>
|
|
397
|
+
</li>
|
|
398
|
+
))}
|
|
399
|
+
</ul>
|
|
400
|
+
|
|
401
|
+
<AccountCard className="sxa:mt-6 sxa:px-5 sxa:py-5">
|
|
402
|
+
<dl className="sxa:space-y-2.5">
|
|
403
|
+
<SummaryRow label={t('orders.subtotal')}>
|
|
404
|
+
{formatAccountAmount(order.subtotal, order.currency)}
|
|
405
|
+
</SummaryRow>
|
|
406
|
+
{/*
|
|
407
|
+
`discount` is the USD-normalized figure and `discountDisplay` the
|
|
408
|
+
same amount in the buyer's currency, exactly like the total. Display
|
|
409
|
+
first is the producer's rule; the row itself only appears when the
|
|
410
|
+
order carried a discount at all.
|
|
411
|
+
*/}
|
|
412
|
+
{Number(order.discountDisplay ?? order.discount) > 0 ? (
|
|
413
|
+
<SummaryRow label={t('orders.discount')}>
|
|
414
|
+
−{formatInvoiceAmount(order.discountDisplay, order.discount, order.currency)}
|
|
415
|
+
</SummaryRow>
|
|
416
|
+
) : null}
|
|
417
|
+
{Number(order.tax) > 0 ? (
|
|
418
|
+
<SummaryRow label={t('orders.tax')}>
|
|
419
|
+
{formatAccountAmount(order.tax, order.currency)}
|
|
420
|
+
</SummaryRow>
|
|
421
|
+
) : null}
|
|
422
|
+
<SummaryRow label={t('orders.total')} emphasis>
|
|
423
|
+
{formatInvoiceAmount(order.totalDisplay, order.total, order.currency)}
|
|
424
|
+
</SummaryRow>
|
|
425
|
+
{/*
|
|
426
|
+
The coin or the alternative method the buyer picked — never the
|
|
427
|
+
internal rail. `gateway` says NATIVE_CRYPTO where the buyer chose
|
|
428
|
+
USDT, which names a thing they never dealt with.
|
|
429
|
+
*/}
|
|
430
|
+
{order.currentPayment ? (
|
|
431
|
+
<SummaryRow label={t('orders.paidWith')}>
|
|
432
|
+
{paymentMethodLabel(order.currentPayment)}
|
|
433
|
+
</SummaryRow>
|
|
434
|
+
) : null}
|
|
435
|
+
</dl>
|
|
436
|
+
</AccountCard>
|
|
437
|
+
|
|
438
|
+
{/*
|
|
439
|
+
Below the receipt, not inside it. The lines and the totals are one
|
|
440
|
+
block — a warranty list between a row and the sum it belongs to breaks
|
|
441
|
+
a reading order buyers already know — and cover is what happens to this
|
|
442
|
+
order AFTER it was paid for.
|
|
443
|
+
*/}
|
|
444
|
+
<OrderWarranties orderUniqid={order.uniqid} />
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* What the shop still covers on this order.
|
|
451
|
+
*
|
|
452
|
+
* The route filters on `invoices.uniqid` when it is handed one, and that is the
|
|
453
|
+
* id this order carries, so the read asks for this order's cover by name. The
|
|
454
|
+
* answer is still filtered here: there is no line-item id on that wire, a
|
|
455
|
+
* warranty belongs to the order rather than to the row that sold it, and a
|
|
456
|
+
* client that renders whatever a list contains cannot be wrong about whose
|
|
457
|
+
* order it is showing.
|
|
458
|
+
*
|
|
459
|
+
* Most orders carry none, which is why nothing is drawn while the read is in
|
|
460
|
+
* flight — a "Warranty" heading that appears and then disappears on every
|
|
461
|
+
* order without cover is worse than one that arrives a moment late. A failed
|
|
462
|
+
* read IS shown: it cannot be told apart from "no cover on this order", and
|
|
463
|
+
* the buyer who has cover is the one who would be left without it.
|
|
464
|
+
*/
|
|
465
|
+
function OrderWarranties({ orderUniqid }: { orderUniqid: string }) {
|
|
466
|
+
const { state, reload } = useAccountResource(`warranties:${orderUniqid}`, () =>
|
|
467
|
+
callCustomerApi((sdk) => sdk.warranties(orderUniqid)));
|
|
468
|
+
/** Rows the server returned from a claim, replacing the ones it was read with. */
|
|
469
|
+
const [claimed, setClaimed] = useState<Record<string, SdkCustomerWarranty>>({});
|
|
470
|
+
|
|
471
|
+
if (state.status === 'loading') return null;
|
|
472
|
+
|
|
473
|
+
if (state.status === 'error') {
|
|
474
|
+
return (
|
|
475
|
+
<WarrantySection>
|
|
476
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
477
|
+
</WarrantySection>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const warranties = warrantiesForOrder(state.data.warranties, orderUniqid)
|
|
482
|
+
.map((warranty) => claimed[warranty.uniqid] ?? warranty);
|
|
483
|
+
|
|
484
|
+
if (warranties.length === 0) return null;
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<WarrantySection>
|
|
488
|
+
<ul className="sxa:space-y-3">
|
|
489
|
+
{warranties.map((warranty) => (
|
|
490
|
+
<li key={warranty.uniqid}>
|
|
491
|
+
<WarrantyRow
|
|
492
|
+
warranty={warranty}
|
|
493
|
+
onClaimed={(updated) => setClaimed((rows) => ({ ...rows, [updated.uniqid]: updated }))}
|
|
494
|
+
/>
|
|
495
|
+
</li>
|
|
496
|
+
))}
|
|
497
|
+
</ul>
|
|
498
|
+
</WarrantySection>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function WarrantySection({ children }: { children: ReactNode }) {
|
|
503
|
+
return (
|
|
504
|
+
<section className="sxa:mt-8">
|
|
505
|
+
<AccountSectionTitle>{t('orders.warrantyTitle')}</AccountSectionTitle>
|
|
506
|
+
<div className="sxa:mt-4">{children}</div>
|
|
507
|
+
</section>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function WarrantyRow({
|
|
512
|
+
warranty,
|
|
513
|
+
onClaimed,
|
|
514
|
+
}: {
|
|
515
|
+
warranty: SdkCustomerWarranty;
|
|
516
|
+
onClaimed: (warranty: SdkCustomerWarranty) => void;
|
|
517
|
+
}) {
|
|
518
|
+
const [writing, setWriting] = useState(false);
|
|
519
|
+
const [message, setMessage] = useState('');
|
|
520
|
+
const [sending, setSending] = useState(false);
|
|
521
|
+
const [sent, setSent] = useState(false);
|
|
522
|
+
const [error, setError] = useState<string | null>(null);
|
|
523
|
+
|
|
524
|
+
const claimKey = warrantyClaimKey(warranty.claim_status);
|
|
525
|
+
const fieldId = `warranty-claim-${warranty.uniqid}`;
|
|
526
|
+
|
|
527
|
+
const send = async () => {
|
|
528
|
+
setSending(true);
|
|
529
|
+
setError(null);
|
|
530
|
+
const response = await callCustomerApi((sdk) => sdk.claimWarranty(
|
|
531
|
+
warranty.uniqid,
|
|
532
|
+
warrantyClaimMessage(message),
|
|
533
|
+
));
|
|
534
|
+
setSending(false);
|
|
535
|
+
|
|
536
|
+
if (!response.success || response.data === undefined) {
|
|
537
|
+
setError(response.message?.trim() || t('orders.warrantyClaimFailed'));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The row is replaced by the one the server answered with, rather than
|
|
542
|
+
// edited in place: it is the only party that knows what the claim did.
|
|
543
|
+
setWriting(false);
|
|
544
|
+
setMessage('');
|
|
545
|
+
setSent(true);
|
|
546
|
+
onClaimed(response.data.warranty);
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<AccountCard className="sxa:px-5 sxa:py-4">
|
|
551
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
552
|
+
<div className="sxa:flex sxa:min-w-0 sxa:items-start sxa:gap-3">
|
|
553
|
+
<ShieldIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
554
|
+
<div className="sxa:min-w-0">
|
|
555
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{warranty.product_title}</p>
|
|
556
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
557
|
+
{coverageLabel(warrantyCoverage(warranty))}
|
|
558
|
+
{claimKey ? ` · ${t(claimKey)}` : ''}
|
|
559
|
+
</p>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
{/* Offered only while there is cover left and nothing has been claimed
|
|
564
|
+
against it. Both are the producer's facts, and neither is a button
|
|
565
|
+
that gets disabled instead of removed. */}
|
|
566
|
+
{canClaimWarranty(warranty) && !writing ? (
|
|
567
|
+
<button
|
|
568
|
+
type="button"
|
|
569
|
+
className="sx-account-button sxa:card-button sxa:card-button--add"
|
|
570
|
+
onClick={() => setWriting(true)}
|
|
571
|
+
>
|
|
572
|
+
{t('orders.warrantyClaimStart')}
|
|
573
|
+
</button>
|
|
574
|
+
) : null}
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
{sent ? <AccountNotice message={t('orders.warrantyClaimDone')} /> : null}
|
|
578
|
+
|
|
579
|
+
{writing ? (
|
|
580
|
+
<div className="sxa:mt-4 sxa:border-t sxa:pt-4">
|
|
581
|
+
<label htmlFor={fieldId} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
582
|
+
{t('orders.warrantyClaimLabel')}
|
|
583
|
+
<span className="sxa:ml-2 sxa:text-micro sxa:font-normal sxa:text-muted">
|
|
584
|
+
{t('orders.warrantyClaimOptional')}
|
|
585
|
+
</span>
|
|
586
|
+
</label>
|
|
587
|
+
<textarea
|
|
588
|
+
id={fieldId}
|
|
589
|
+
rows={3}
|
|
590
|
+
value={message}
|
|
591
|
+
disabled={sending}
|
|
592
|
+
maxLength={WARRANTY_CLAIM_MESSAGE_MAX}
|
|
593
|
+
onChange={(event) => {
|
|
594
|
+
setMessage(event.target.value);
|
|
595
|
+
setError(null);
|
|
596
|
+
}}
|
|
597
|
+
className={`${FIELD} sxa:resize-none`}
|
|
598
|
+
/>
|
|
599
|
+
|
|
600
|
+
<div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
disabled={sending}
|
|
604
|
+
aria-busy={sending || undefined}
|
|
605
|
+
className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-9 sxa:items-center sxa:px-4 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
606
|
+
onClick={() => void send()}
|
|
607
|
+
>
|
|
608
|
+
{sending ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
609
|
+
{sending ? t('orders.warrantyClaimSending') : t('orders.warrantyClaimSend')}
|
|
610
|
+
</button>
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
disabled={sending}
|
|
614
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
615
|
+
onClick={() => {
|
|
616
|
+
setWriting(false);
|
|
617
|
+
setError(null);
|
|
618
|
+
}}
|
|
619
|
+
>
|
|
620
|
+
{t('common.cancel')}
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
|
|
624
|
+
{error ? <AccountFieldError message={error} /> : null}
|
|
625
|
+
</div>
|
|
626
|
+
) : null}
|
|
627
|
+
</AccountCard>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Cover as a sentence. Cancelled cover names no date, because it has none. */
|
|
632
|
+
function coverageLabel(coverage: WarrantyCoverage): string {
|
|
633
|
+
switch (coverage.kind) {
|
|
634
|
+
case 'covered':
|
|
635
|
+
return t('orders.warrantyCoveredUntil', { date: formatAccountDate(warrantyDate(coverage.expiresAt)) });
|
|
636
|
+
case 'ended':
|
|
637
|
+
return t('orders.warrantyEnded', { date: formatAccountDate(warrantyDate(coverage.expiresAt)) });
|
|
638
|
+
case 'cancelled':
|
|
639
|
+
return t('orders.warrantyCancelled');
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function SummaryRow({
|
|
644
|
+
label,
|
|
645
|
+
emphasis = false,
|
|
646
|
+
children,
|
|
647
|
+
}: {
|
|
648
|
+
label: string;
|
|
649
|
+
emphasis?: boolean;
|
|
650
|
+
children: ReactNode;
|
|
651
|
+
}) {
|
|
652
|
+
return (
|
|
653
|
+
<div
|
|
654
|
+
className={`sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-4 ${
|
|
655
|
+
emphasis ? 'sxa:border-t sxa:pt-3 sxa:text-body' : 'sxa:text-small'
|
|
656
|
+
}`}
|
|
657
|
+
>
|
|
658
|
+
<dt className={emphasis ? 'sxa:text-small sxa:font-semibold' : 'sxa:text-muted'}>{label}</dt>
|
|
659
|
+
<dd className={`sxa:tabular-nums ${emphasis ? 'sxa:text-h3 sxa:font-bold' : 'sxa:font-medium'}`}>
|
|
660
|
+
{children}
|
|
661
|
+
</dd>
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The per-line fulfillment state, in a word.
|
|
668
|
+
*
|
|
669
|
+
* `tOptional` first: delivery status is an open wire enum, so a state this
|
|
670
|
+
* catalog has not heard of is printed as the producer spelled it rather than as
|
|
671
|
+
* a blank or a guess. `FAILED` reads "Delivery failed" — a buyer looking at a
|
|
672
|
+
* completed invoice has to be able to see that one of its lines never arrived.
|
|
673
|
+
*/
|
|
674
|
+
function DeliveryChip({ status }: { status: string }) {
|
|
675
|
+
const normalized = status.trim().toUpperCase();
|
|
676
|
+
const tone = normalized === 'DELIVERED'
|
|
677
|
+
? 'sxa:meta-chip--in_stock'
|
|
678
|
+
: normalized === 'FAILED'
|
|
679
|
+
? 'sxa:meta-chip--sold_out'
|
|
680
|
+
: 'sxa:meta-chip--neutral';
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<span className={`sx-account-chip sxa:meta-chip ${tone}`}>
|
|
684
|
+
{tOptional(`delivery.${normalized}`) ?? status}
|
|
685
|
+
</span>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function DeliveredItems({ summary }: { summary: SdkCustomerDeliverySummary }) {
|
|
690
|
+
const hasContent =
|
|
691
|
+
summary.codes.length > 0
|
|
692
|
+
|| summary.links.length > 0
|
|
693
|
+
|| summary.notes.length > 0
|
|
694
|
+
|| summary.serials.length > 0;
|
|
695
|
+
|
|
696
|
+
if (!hasContent) return null;
|
|
697
|
+
|
|
698
|
+
return (
|
|
699
|
+
<div className="sxa:mt-4 sxa:border-t sxa:pt-4">
|
|
700
|
+
<p className="sxa:text-micro sxa:font-semibold sxa:tracking-[0.12em] sxa:text-muted sxa:uppercase">
|
|
701
|
+
{t('orders.deliveryTitle')}
|
|
702
|
+
</p>
|
|
703
|
+
|
|
704
|
+
{summary.codes.length ? (
|
|
705
|
+
<ul className="sxa:mt-3 sxa:space-y-1.5">
|
|
706
|
+
{summary.codes.map((code) => (
|
|
707
|
+
<li
|
|
708
|
+
key={code}
|
|
709
|
+
className="sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro sxa:break-all"
|
|
710
|
+
>
|
|
711
|
+
{code}
|
|
712
|
+
</li>
|
|
713
|
+
))}
|
|
714
|
+
</ul>
|
|
715
|
+
) : null}
|
|
716
|
+
|
|
717
|
+
{summary.serials.length ? (
|
|
718
|
+
<ul className="sxa:mt-3 sxa:space-y-1.5">
|
|
719
|
+
{summary.serials.map((serial) => (
|
|
720
|
+
<li
|
|
721
|
+
key={serial.value}
|
|
722
|
+
className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2 sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro sxa:break-all"
|
|
723
|
+
>
|
|
724
|
+
{/* A replaced serial is struck through rather than dropped: it is
|
|
725
|
+
the one the buyer may already have installed somewhere. */}
|
|
726
|
+
<span className={serial.is_replaced ? 'sxa:line-through sxa:opacity-60' : ''}>
|
|
727
|
+
{serial.value}
|
|
728
|
+
</span>
|
|
729
|
+
{serial.is_replacement ? (
|
|
730
|
+
<span className="sx-account-chip sxa:meta-chip sxa:meta-chip--neutral">{t('orders.replacement')}</span>
|
|
731
|
+
) : null}
|
|
732
|
+
</li>
|
|
733
|
+
))}
|
|
734
|
+
</ul>
|
|
735
|
+
) : null}
|
|
736
|
+
|
|
737
|
+
{summary.links.length ? (
|
|
738
|
+
<ul className="sxa:mt-3 sxa:space-y-1.5">
|
|
739
|
+
{summary.links.map((link) => (
|
|
740
|
+
<li key={`${link.label}-${link.value}`} className="sxa:text-small">
|
|
741
|
+
<DeliveryLink label={link.label} value={link.value} href={link.href} />
|
|
742
|
+
</li>
|
|
743
|
+
))}
|
|
744
|
+
</ul>
|
|
745
|
+
) : null}
|
|
746
|
+
|
|
747
|
+
{summary.notes.length ? (
|
|
748
|
+
<div className="sxa:mt-3 sxa:space-y-1.5">
|
|
749
|
+
{summary.notes.map((note) => (
|
|
750
|
+
<p key={note} className="sxa:text-small sxa:leading-relaxed sxa:text-muted">{note}</p>
|
|
751
|
+
))}
|
|
752
|
+
</div>
|
|
753
|
+
) : null}
|
|
754
|
+
</div>
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* A delivery link the MERCHANT typed into their own product.
|
|
760
|
+
*
|
|
761
|
+
* `javascript:` is a URL, and an anchor carrying one runs on the origin the
|
|
762
|
+
* buyer is standing on with the session cookie they are signed in with. So the
|
|
763
|
+
* scheme is checked before an anchor exists at all: a link that does not survive
|
|
764
|
+
* `safeExternalHref` is shown as the text it is, never as something to click.
|
|
765
|
+
*/
|
|
766
|
+
function DeliveryLink({
|
|
767
|
+
label,
|
|
768
|
+
value,
|
|
769
|
+
href,
|
|
770
|
+
}: {
|
|
771
|
+
label: string;
|
|
772
|
+
value: string;
|
|
773
|
+
href?: string | null;
|
|
774
|
+
}) {
|
|
775
|
+
const safeHref = safeExternalHref(href);
|
|
776
|
+
|
|
777
|
+
if (safeHref === null) {
|
|
778
|
+
return <span className="sxa:break-all sxa:text-muted">{label}: {value}</span>;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<a
|
|
783
|
+
href={safeHref}
|
|
784
|
+
target="_blank"
|
|
785
|
+
rel="noreferrer noopener"
|
|
786
|
+
className="sxa:font-medium sxa:text-accent sxa:underline sxa:underline-offset-2 sxa:transition-opacity sxa:hover:opacity-80"
|
|
787
|
+
>
|
|
788
|
+
{label}
|
|
789
|
+
</a>
|
|
790
|
+
);
|
|
791
|
+
}
|