@shoppexio/storefront-react 0.0.0 → 0.1.1
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 +25 -0
- package/LICENSE +21 -0
- package/README.md +215 -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 +79 -1
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wholesale account.
|
|
3
|
+
*
|
|
4
|
+
* The producer's relationship has FIVE states and this panel answers each of
|
|
5
|
+
* them separately: ACTIVE gets the working surface, APPLIED gets "your
|
|
6
|
+
* application is with the shop", SUSPENDED and REJECTED each get their own
|
|
7
|
+
* sentence, and INVITED — like having no relationship at all — goes to the entry
|
|
8
|
+
* form. There is no PENDING. Folding two of those together tells a suspended
|
|
9
|
+
* reseller they were rejected, or hands a rejected one a catalog.
|
|
10
|
+
*
|
|
11
|
+
* Three controls a wholesale surface would normally carry are ABSENT rather
|
|
12
|
+
* than disabled: placing an order, minting an API key and creating a wallet
|
|
13
|
+
* top-up. The storefront edge allowlists none of those mutations
|
|
14
|
+
* (`workers/storefront/src/customer-api.ts`).
|
|
15
|
+
*
|
|
16
|
+
* Pricing a basket stays, because it writes nothing. The quoted total is the
|
|
17
|
+
* only total shown, and a basket that has moved since its quote shows no total
|
|
18
|
+
* at all rather than the one that no longer applies.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
22
|
+
import {
|
|
23
|
+
RESELLER_QUANTITY_MAX_UNITS,
|
|
24
|
+
RESELLER_QUANTITY_MIN_UNITS,
|
|
25
|
+
narrowResellerApiKeys,
|
|
26
|
+
narrowResellerCatalog,
|
|
27
|
+
narrowResellerMutation,
|
|
28
|
+
narrowResellerOrderDetail,
|
|
29
|
+
narrowResellerOrders,
|
|
30
|
+
narrowResellerProgram,
|
|
31
|
+
narrowResellerQuote,
|
|
32
|
+
narrowResellerWallet,
|
|
33
|
+
quoteMatchesBasket,
|
|
34
|
+
resellerBasketKey,
|
|
35
|
+
resellerEntryMode,
|
|
36
|
+
type ResellerCatalogItem,
|
|
37
|
+
type ResellerCatalogVariant,
|
|
38
|
+
type ResellerOrderItemInput,
|
|
39
|
+
type ResellerOrderLine,
|
|
40
|
+
type ResellerOrderListItem,
|
|
41
|
+
type ResellerProgramState,
|
|
42
|
+
type ResellerQuote,
|
|
43
|
+
type ResellerRelationship,
|
|
44
|
+
} from './account-reseller';
|
|
45
|
+
import { callCustomerApi } from './customer-account';
|
|
46
|
+
import {
|
|
47
|
+
clearAccountResellerInvite,
|
|
48
|
+
peekAccountResellerInvite,
|
|
49
|
+
} from './account-reseller-invite';
|
|
50
|
+
import type { SdkResponse, ShoppexSdk } from '@shoppexio/storefront-react';
|
|
51
|
+
import { t } from './i18n';
|
|
52
|
+
import { SearchIcon } from './icons';
|
|
53
|
+
import {
|
|
54
|
+
AccountBackButton,
|
|
55
|
+
AccountCard,
|
|
56
|
+
AccountEmpty,
|
|
57
|
+
AccountError,
|
|
58
|
+
AccountFieldError,
|
|
59
|
+
AccountRowsSkeleton,
|
|
60
|
+
AccountSectionTitle,
|
|
61
|
+
BusySpinner,
|
|
62
|
+
CopyButton,
|
|
63
|
+
StatusChip,
|
|
64
|
+
formatAccountAmount,
|
|
65
|
+
formatAccountDate,
|
|
66
|
+
formatAccountDateTime,
|
|
67
|
+
useAccountResource,
|
|
68
|
+
} from './shared';
|
|
69
|
+
import { useAccountResellerProgram } from './dashboard';
|
|
70
|
+
|
|
71
|
+
/** The contact form's control skin — see `components/contact/contact-form.tsx`. */
|
|
72
|
+
const FIELD =
|
|
73
|
+
'sx-account-field sxa:w-full sxa:rounded-card 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';
|
|
74
|
+
|
|
75
|
+
const PAGE_SIZE = 20;
|
|
76
|
+
|
|
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
|
+
export function AccountReseller() {
|
|
90
|
+
/*
|
|
91
|
+
* The SAME response the navigation gate reads. Two separate reads could
|
|
92
|
+
* disagree — the gate's failing while this one succeeded — and the buyer
|
|
93
|
+
* would be shown an active wholesale account by a panel the navigation had
|
|
94
|
+
* just dropped the tab for. Retrying here reopens the gate with it.
|
|
95
|
+
*/
|
|
96
|
+
const { state, reload } = useAccountResellerProgram();
|
|
97
|
+
|
|
98
|
+
if (state.status === 'loading') {
|
|
99
|
+
return <AccountRowsSkeleton rows={4} label={t('overview.loading')} />;
|
|
100
|
+
}
|
|
101
|
+
if (state.status === 'error') {
|
|
102
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const narrowed = narrowResellerProgram(state.data);
|
|
106
|
+
if (!narrowed.ok) {
|
|
107
|
+
return <AccountError message={t('common.genericError')} onRetry={reload} />;
|
|
108
|
+
}
|
|
109
|
+
const program = narrowed.data;
|
|
110
|
+
|
|
111
|
+
if (!program.program_enabled) {
|
|
112
|
+
return (
|
|
113
|
+
<AccountEmpty
|
|
114
|
+
title={t('reseller.disabledTitle')}
|
|
115
|
+
body={t('reseller.disabledBody')}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const reseller = program.reseller;
|
|
121
|
+
const status = reseller?.status;
|
|
122
|
+
|
|
123
|
+
if (reseller && status === 'ACTIVE') {
|
|
124
|
+
return <ActiveReseller relationship={reseller} />;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (status === 'APPLIED') {
|
|
128
|
+
return (
|
|
129
|
+
<AccountEmpty
|
|
130
|
+
title={t('reseller.pendingTitle')}
|
|
131
|
+
body={t('reseller.pendingBody')}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (status === 'SUSPENDED') {
|
|
137
|
+
return (
|
|
138
|
+
<AccountEmpty
|
|
139
|
+
title={t('reseller.suspendedTitle')}
|
|
140
|
+
body={t('reseller.suspendedBody')}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (status === 'REJECTED') {
|
|
146
|
+
return (
|
|
147
|
+
<AccountEmpty
|
|
148
|
+
title={t('reseller.rejectedTitle')}
|
|
149
|
+
body={t('reseller.rejectedBody')}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// INVITED and "no relationship at all" are the two ways into the program, and
|
|
155
|
+
// `resellerEntryMode` is what tells them apart from the shop's own mode.
|
|
156
|
+
return <ResellerEntry program={program} onDone={reload} />;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function ResellerEntry({
|
|
160
|
+
program,
|
|
161
|
+
onDone,
|
|
162
|
+
}: {
|
|
163
|
+
program: ResellerProgramState;
|
|
164
|
+
onDone: () => void;
|
|
165
|
+
}) {
|
|
166
|
+
const fieldId = useId();
|
|
167
|
+
const [note, setNote] = useState('');
|
|
168
|
+
const [token, setToken] = useState('');
|
|
169
|
+
const [busy, setBusy] = useState(false);
|
|
170
|
+
const [error, setError] = useState<string | null>(null);
|
|
171
|
+
const attemptedInviteToken = useRef<string | null>(null);
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
// The cookie belongs to the invited email. Another authenticated buyer must
|
|
175
|
+
// be able to switch accounts while the cookie lives.
|
|
176
|
+
if (program.reseller?.status !== 'INVITED') return;
|
|
177
|
+
|
|
178
|
+
const inviteToken = peekAccountResellerInvite(document);
|
|
179
|
+
if (!inviteToken || attemptedInviteToken.current === inviteToken) return;
|
|
180
|
+
attemptedInviteToken.current = inviteToken;
|
|
181
|
+
|
|
182
|
+
setBusy(true);
|
|
183
|
+
setError(null);
|
|
184
|
+
void (async () => {
|
|
185
|
+
const result = await callCustomerApi((sdk) => sdk.acceptResellerInvite(inviteToken));
|
|
186
|
+
setBusy(false);
|
|
187
|
+
|
|
188
|
+
// Only success spends the cookie: a failure can mean this browser is
|
|
189
|
+
// signed into another invited customer, and the actually invited one
|
|
190
|
+
// still needs the token after switching accounts.
|
|
191
|
+
if (!result.success) {
|
|
192
|
+
setToken(inviteToken);
|
|
193
|
+
setError(result.message?.trim() || t('common.genericError'));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!narrowResellerMutation(result.data).ok) {
|
|
197
|
+
setToken(inviteToken);
|
|
198
|
+
setError(t('common.genericError'));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
clearAccountResellerInvite(document);
|
|
202
|
+
onDone();
|
|
203
|
+
})();
|
|
204
|
+
}, [program.reseller?.status, onDone]);
|
|
205
|
+
|
|
206
|
+
const run = async (operation: (sdk: ShoppexSdk) => Promise<SdkResponse<unknown>>) => {
|
|
207
|
+
setBusy(true);
|
|
208
|
+
setError(null);
|
|
209
|
+
const result = await callCustomerApi(operation);
|
|
210
|
+
setBusy(false);
|
|
211
|
+
|
|
212
|
+
if (!result.success) {
|
|
213
|
+
setError(result.message?.trim() || t('common.genericError'));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// The enrolment routes answer with the relationship they just wrote. A body
|
|
217
|
+
// that is not one is not proof of anything, so it is not celebrated.
|
|
218
|
+
if (!narrowResellerMutation(result.data).ok) {
|
|
219
|
+
setError(t('common.genericError'));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
onDone();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/*
|
|
226
|
+
* Not `program.enrollment_mode`. An invitation outranks the shop's mode:
|
|
227
|
+
* `ResellerEnrollmentService.invite` only requires an ENABLED program, so a
|
|
228
|
+
* shop taking open sign-ups can still have invited this buyer. Offering them
|
|
229
|
+
* the join button offers the one call the producer answers 409 to, while the
|
|
230
|
+
* token in their mail has nowhere to go.
|
|
231
|
+
*/
|
|
232
|
+
const entry = resellerEntryMode(program);
|
|
233
|
+
|
|
234
|
+
if (entry === 'OPEN') {
|
|
235
|
+
return (
|
|
236
|
+
<AccountEmpty
|
|
237
|
+
title={t('reseller.openTitle')}
|
|
238
|
+
body={program.application_message ?? t('reseller.openBody')}
|
|
239
|
+
action={(
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
disabled={busy}
|
|
243
|
+
aria-busy={busy || undefined}
|
|
244
|
+
className="sx-account-cta sxa:cta-button 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"
|
|
245
|
+
onClick={() => void run((sdk) => sdk.enrollAsReseller())}
|
|
246
|
+
>
|
|
247
|
+
{busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
|
|
248
|
+
{t('reseller.join')}
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
/>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (entry === 'MANUAL') {
|
|
256
|
+
return (
|
|
257
|
+
<AccountCard className="sxa:mx-auto sxa:max-w-xl sxa:p-6">
|
|
258
|
+
<h2 className="sxa:font-heading sxa:text-body sxa:font-semibold">{t('reseller.manualTitle')}</h2>
|
|
259
|
+
<p className="sxa:mt-2 sxa:text-small sxa:leading-relaxed sxa:text-muted">
|
|
260
|
+
{program.application_message ?? t('reseller.manualBody')}
|
|
261
|
+
</p>
|
|
262
|
+
<form
|
|
263
|
+
noValidate
|
|
264
|
+
className="sxa:mt-5"
|
|
265
|
+
onSubmit={(event) => {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
void run((sdk) => sdk.acceptResellerInvite(token.trim()));
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<label htmlFor={`${fieldId}-token`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
271
|
+
{t('reseller.inviteToken')}
|
|
272
|
+
</label>
|
|
273
|
+
<input
|
|
274
|
+
id={`${fieldId}-token`}
|
|
275
|
+
type="text"
|
|
276
|
+
value={token}
|
|
277
|
+
disabled={busy}
|
|
278
|
+
aria-invalid={error ? true : undefined}
|
|
279
|
+
onChange={(event) => {
|
|
280
|
+
setToken(event.target.value);
|
|
281
|
+
setError(null);
|
|
282
|
+
}}
|
|
283
|
+
className={FIELD}
|
|
284
|
+
/>
|
|
285
|
+
{error ? <AccountFieldError message={error} /> : null}
|
|
286
|
+
<button
|
|
287
|
+
type="submit"
|
|
288
|
+
disabled={busy || token.trim().length === 0}
|
|
289
|
+
aria-busy={busy || undefined}
|
|
290
|
+
className="sx-account-cta sxa:cta-button sxa:mt-5 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"
|
|
291
|
+
>
|
|
292
|
+
{busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
|
|
293
|
+
{t('reseller.acceptInvite')}
|
|
294
|
+
</button>
|
|
295
|
+
</form>
|
|
296
|
+
</AccountCard>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const apply = () => {
|
|
301
|
+
if (program.require_application_note && note.trim().length === 0) {
|
|
302
|
+
setError(t('reseller.noteRequired'));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
void run((sdk) => sdk.applyForReseller(note.trim() || undefined));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<AccountCard className="sxa:mx-auto sxa:max-w-xl sxa:p-6">
|
|
310
|
+
<h2 className="sxa:font-heading sxa:text-body sxa:font-semibold">{t('reseller.applicationTitle')}</h2>
|
|
311
|
+
<p className="sxa:mt-2 sxa:text-small sxa:leading-relaxed sxa:text-muted">
|
|
312
|
+
{program.application_message ?? t('reseller.applicationBody')}
|
|
313
|
+
</p>
|
|
314
|
+
<form
|
|
315
|
+
noValidate
|
|
316
|
+
className="sxa:mt-5"
|
|
317
|
+
onSubmit={(event) => {
|
|
318
|
+
event.preventDefault();
|
|
319
|
+
apply();
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
<label htmlFor={`${fieldId}-note`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
323
|
+
{t('reseller.applicationNote')}
|
|
324
|
+
</label>
|
|
325
|
+
<textarea
|
|
326
|
+
id={`${fieldId}-note`}
|
|
327
|
+
rows={4}
|
|
328
|
+
value={note}
|
|
329
|
+
disabled={busy}
|
|
330
|
+
aria-invalid={error ? true : undefined}
|
|
331
|
+
onChange={(event) => {
|
|
332
|
+
setNote(event.target.value);
|
|
333
|
+
setError(null);
|
|
334
|
+
}}
|
|
335
|
+
className={`${FIELD} sxa:resize-none`}
|
|
336
|
+
/>
|
|
337
|
+
{error ? <AccountFieldError message={error} /> : null}
|
|
338
|
+
<button
|
|
339
|
+
type="submit"
|
|
340
|
+
disabled={busy}
|
|
341
|
+
aria-busy={busy || undefined}
|
|
342
|
+
className="sx-account-cta sxa:cta-button sxa:mt-5 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"
|
|
343
|
+
>
|
|
344
|
+
{busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
|
|
345
|
+
{t('reseller.apply')}
|
|
346
|
+
</button>
|
|
347
|
+
</form>
|
|
348
|
+
</AccountCard>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function ActiveReseller({ relationship }: { relationship: ResellerRelationship }) {
|
|
353
|
+
/*
|
|
354
|
+
* The basket lives HERE, above the catalog, because the catalog paginates and
|
|
355
|
+
* a buyer builds one order across several pages. A basket owned by the list
|
|
356
|
+
* would empty itself every time the buyer asked for more products.
|
|
357
|
+
*/
|
|
358
|
+
const [basket, setBasket] = useState<BasketLine[]>([]);
|
|
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
|
+
|
|
382
|
+
const discount = relationship.tier?.discount_percent;
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<div className="sxa:space-y-8">
|
|
386
|
+
<section>
|
|
387
|
+
<AccountSectionTitle>{t('reseller.activeTitle')}</AccountSectionTitle>
|
|
388
|
+
<div className="sxa:mt-4 sxa:grid sxa:gap-3 sxa:sm:grid-cols-2">
|
|
389
|
+
<Metric
|
|
390
|
+
label={t('reseller.tier')}
|
|
391
|
+
value={relationship.tier?.name ?? t('common.unavailable')}
|
|
392
|
+
/>
|
|
393
|
+
<Metric
|
|
394
|
+
label={t('orders.discount')}
|
|
395
|
+
value={discount === null || discount === undefined
|
|
396
|
+
? t('common.unavailable')
|
|
397
|
+
: `${discount}%`}
|
|
398
|
+
/>
|
|
399
|
+
{/*
|
|
400
|
+
The relationship carries a lifetime figure and NO currency to read
|
|
401
|
+
it in, so it is printed exactly as the producer spelled it. Running
|
|
402
|
+
it through a formatter would mean picking a currency nobody sent.
|
|
403
|
+
*/}
|
|
404
|
+
<Metric label={t('referrals.revenue')} value={relationship.lifetime_revenue} />
|
|
405
|
+
<Metric label={t('overview.statOrders')} value={relationship.lifetime_orders} />
|
|
406
|
+
</div>
|
|
407
|
+
</section>
|
|
408
|
+
|
|
409
|
+
<WholesaleWallet />
|
|
410
|
+
<WholesaleCatalog onAdd={add} />
|
|
411
|
+
<WholesaleBasket
|
|
412
|
+
basket={basket}
|
|
413
|
+
priced={priced}
|
|
414
|
+
onBasketChange={replaceBasket}
|
|
415
|
+
onPriced={setPriced}
|
|
416
|
+
/>
|
|
417
|
+
<WholesaleOrders />
|
|
418
|
+
<ResellerApiKeys />
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function Metric({ label, value }: { label: string; value: string | number }) {
|
|
424
|
+
return (
|
|
425
|
+
<div className="sxa:rounded-input sxa:border sxa:bg-bg sxa:p-5">
|
|
426
|
+
<p className="sxa:truncate sxa:text-h3 sxa:font-semibold sxa:tabular-nums">{value}</p>
|
|
427
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{label}</p>
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* --- Wholesale balance ---------------------------------------------------- */
|
|
433
|
+
|
|
434
|
+
function WholesaleWallet() {
|
|
435
|
+
const { state, reload } = useAccountResource('reseller-wallet', () =>
|
|
436
|
+
callCustomerApi((sdk) => sdk.resellerWallet()));
|
|
437
|
+
|
|
438
|
+
const heading = <AccountSectionTitle>{t('reseller.walletTitle')}</AccountSectionTitle>;
|
|
439
|
+
|
|
440
|
+
if (state.status === 'loading') {
|
|
441
|
+
return (
|
|
442
|
+
<section>
|
|
443
|
+
{heading}
|
|
444
|
+
<div className="sxa:mt-4">
|
|
445
|
+
<AccountRowsSkeleton rows={1} label={t('overview.loading')} />
|
|
446
|
+
</div>
|
|
447
|
+
</section>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (state.status === 'error') {
|
|
451
|
+
return (
|
|
452
|
+
<section>
|
|
453
|
+
{heading}
|
|
454
|
+
<div className="sxa:mt-4">
|
|
455
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
456
|
+
</div>
|
|
457
|
+
</section>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const narrowed = narrowResellerWallet(state.data);
|
|
462
|
+
if (!narrowed.ok) {
|
|
463
|
+
return (
|
|
464
|
+
<section>
|
|
465
|
+
{heading}
|
|
466
|
+
<div className="sxa:mt-4">
|
|
467
|
+
<AccountError message={t('common.genericError')} onRetry={reload} />
|
|
468
|
+
</div>
|
|
469
|
+
</section>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const wallet = narrowed.data;
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
<section>
|
|
476
|
+
{heading}
|
|
477
|
+
<AccountCard className="sxa:mt-4 sxa:p-6">
|
|
478
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
|
|
479
|
+
<p className="sxa:text-small sxa:font-medium">{t('rewards.available')}</p>
|
|
480
|
+
<StatusChip status={wallet.is_active ? 'ACTIVE' : 'INACTIVE'} />
|
|
481
|
+
</div>
|
|
482
|
+
<p className="sxa:mt-1 sxa:text-h2 sxa:font-semibold sxa:tabular-nums">
|
|
483
|
+
{formatAccountAmount(wallet.available, wallet.currency)}
|
|
484
|
+
</p>
|
|
485
|
+
|
|
486
|
+
<div className="sxa:mt-4 sxa:grid sxa:gap-3 sxa:sm:grid-cols-2">
|
|
487
|
+
<Metric
|
|
488
|
+
label={t('reseller.balance')}
|
|
489
|
+
value={formatAccountAmount(wallet.balance, wallet.currency)}
|
|
490
|
+
/>
|
|
491
|
+
<Metric
|
|
492
|
+
label={t('reseller.held')}
|
|
493
|
+
value={formatAccountAmount(wallet.hold_balance, wallet.currency)}
|
|
494
|
+
/>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
</AccountCard>
|
|
498
|
+
</section>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* --- Paging --------------------------------------------------------------- */
|
|
503
|
+
|
|
504
|
+
interface PageSlice<T> {
|
|
505
|
+
items: T[];
|
|
506
|
+
page: number;
|
|
507
|
+
perPage: number;
|
|
508
|
+
total: number;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
type PageRead<T> = Promise<
|
|
512
|
+
{ ok: true; slice: PageSlice<T> } | { ok: false; message: string }
|
|
513
|
+
>;
|
|
514
|
+
|
|
515
|
+
type PagedState<T> =
|
|
516
|
+
| { status: 'loading' }
|
|
517
|
+
| { status: 'error'; message: string }
|
|
518
|
+
| { status: 'ready'; slice: PageSlice<T> };
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* A list that GROWS a page at a time.
|
|
522
|
+
*
|
|
523
|
+
* Both wholesale lists append rather than replace. The catalog has to: a basket
|
|
524
|
+
* is built across pages, and swapping page one for page two would take the
|
|
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.
|
|
528
|
+
*
|
|
529
|
+
* The page number advances only on a SUCCESSFUL read, and it advances to the
|
|
530
|
+
* page the producer says it answered. A failed "next" leaves the list exactly
|
|
531
|
+
* where it was, so pressing it again asks for the page that failed rather than
|
|
532
|
+
* skipping it.
|
|
533
|
+
*/
|
|
534
|
+
function usePagedList<T>(resetKey: string, read: (page: number) => PageRead<T>) {
|
|
535
|
+
const [state, setState] = useState<PagedState<T>>({ status: 'loading' });
|
|
536
|
+
const [attempt, setAttempt] = useState(0);
|
|
537
|
+
const [moreBusy, setMoreBusy] = useState(false);
|
|
538
|
+
const [moreError, setMoreError] = useState<string | null>(null);
|
|
539
|
+
const readRef = useRef(read);
|
|
540
|
+
readRef.current = read;
|
|
541
|
+
const stateRef = useRef(state);
|
|
542
|
+
stateRef.current = state;
|
|
543
|
+
/** Bumped on every reset, so a page that arrives late cannot land in a list
|
|
544
|
+
* the buyer has since re-filtered. */
|
|
545
|
+
const generation = useRef(0);
|
|
546
|
+
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
generation.current += 1;
|
|
549
|
+
const mine = generation.current;
|
|
550
|
+
setState({ status: 'loading' });
|
|
551
|
+
setMoreBusy(false);
|
|
552
|
+
setMoreError(null);
|
|
553
|
+
|
|
554
|
+
void readRef.current(1).then((result) => {
|
|
555
|
+
if (generation.current !== mine) return;
|
|
556
|
+
setState(result.ok
|
|
557
|
+
? { status: 'ready', slice: result.slice }
|
|
558
|
+
: { status: 'error', message: result.message });
|
|
559
|
+
});
|
|
560
|
+
}, [resetKey, attempt]);
|
|
561
|
+
|
|
562
|
+
const loadMore = useCallback(() => {
|
|
563
|
+
const current = stateRef.current;
|
|
564
|
+
if (current.status !== 'ready') return;
|
|
565
|
+
const mine = generation.current;
|
|
566
|
+
setMoreBusy(true);
|
|
567
|
+
setMoreError(null);
|
|
568
|
+
|
|
569
|
+
void readRef.current(current.slice.page + 1).then((result) => {
|
|
570
|
+
if (generation.current !== mine) return;
|
|
571
|
+
setMoreBusy(false);
|
|
572
|
+
if (!result.ok) {
|
|
573
|
+
setMoreError(result.message);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
setState((previous) => (previous.status === 'ready'
|
|
577
|
+
? {
|
|
578
|
+
status: 'ready',
|
|
579
|
+
slice: {
|
|
580
|
+
items: [...previous.slice.items, ...result.slice.items],
|
|
581
|
+
page: result.slice.page,
|
|
582
|
+
perPage: result.slice.perPage,
|
|
583
|
+
total: result.slice.total,
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
: previous));
|
|
587
|
+
});
|
|
588
|
+
}, []);
|
|
589
|
+
|
|
590
|
+
const reload = useCallback(() => setAttempt((value) => value + 1), []);
|
|
591
|
+
|
|
592
|
+
return { state, moreBusy, moreError, loadMore, reload };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function hasMorePages<T>(slice: PageSlice<T>): boolean {
|
|
596
|
+
return slice.page * slice.perPage < slice.total;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function MorePages({
|
|
600
|
+
slice,
|
|
601
|
+
busy,
|
|
602
|
+
error,
|
|
603
|
+
onMore,
|
|
604
|
+
}: {
|
|
605
|
+
slice: PageSlice<unknown>;
|
|
606
|
+
busy: boolean;
|
|
607
|
+
error: string | null;
|
|
608
|
+
onMore: () => void;
|
|
609
|
+
}) {
|
|
610
|
+
const more = hasMorePages(slice);
|
|
611
|
+
if (slice.page <= 1 && !more) return null;
|
|
612
|
+
|
|
613
|
+
return (
|
|
614
|
+
<div>
|
|
615
|
+
<nav
|
|
616
|
+
className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3"
|
|
617
|
+
aria-label={t('orders.pagesLabel')}
|
|
618
|
+
>
|
|
619
|
+
<span className="sxa:text-micro sxa:text-muted">{t('orders.page', { page: slice.page })}</span>
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
disabled={!more || busy}
|
|
623
|
+
aria-busy={busy || undefined}
|
|
624
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-40"
|
|
625
|
+
onClick={onMore}
|
|
626
|
+
>
|
|
627
|
+
{busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
|
|
628
|
+
{t('orders.next')}
|
|
629
|
+
</button>
|
|
630
|
+
</nav>
|
|
631
|
+
{error ? <AccountFieldError message={error} /> : null}
|
|
632
|
+
</div>
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* --- Catalog -------------------------------------------------------------- */
|
|
637
|
+
|
|
638
|
+
/** One catalog row, carrying the currency the producer sent WITH its page. */
|
|
639
|
+
interface CatalogRow {
|
|
640
|
+
item: ResellerCatalogItem;
|
|
641
|
+
currency: string;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function WholesaleCatalog({ onAdd }: { onAdd: (line: BasketLine) => void }) {
|
|
645
|
+
const searchId = useId();
|
|
646
|
+
const [search, setSearch] = useState('');
|
|
647
|
+
const [query, setQuery] = useState('');
|
|
648
|
+
|
|
649
|
+
const { state, moreBusy, moreError, loadMore, reload } = usePagedList<CatalogRow>(
|
|
650
|
+
`reseller-catalog:${query}`,
|
|
651
|
+
async (page) => {
|
|
652
|
+
const response = await callCustomerApi((sdk) => sdk.resellerCatalog({
|
|
653
|
+
...(query ? { search: query } : {}),
|
|
654
|
+
page,
|
|
655
|
+
per_page: PAGE_SIZE,
|
|
656
|
+
}));
|
|
657
|
+
if (!response.success) {
|
|
658
|
+
return { ok: false, message: response.message?.trim() || t('common.genericError') };
|
|
659
|
+
}
|
|
660
|
+
const narrowed = narrowResellerCatalog(response.data);
|
|
661
|
+
if (!narrowed.ok) return { ok: false, message: t('common.genericError') };
|
|
662
|
+
|
|
663
|
+
const { catalog } = narrowed.data;
|
|
664
|
+
return {
|
|
665
|
+
ok: true,
|
|
666
|
+
slice: {
|
|
667
|
+
items: catalog.items.map((item) => ({ item, currency: catalog.currency })),
|
|
668
|
+
page: catalog.page,
|
|
669
|
+
perPage: catalog.per_page,
|
|
670
|
+
total: catalog.total,
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const heading = (
|
|
677
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-end sxa:justify-between sxa:gap-3">
|
|
678
|
+
<AccountSectionTitle>{t('reseller.catalogTitle')}</AccountSectionTitle>
|
|
679
|
+
<form
|
|
680
|
+
noValidate
|
|
681
|
+
className="sxa:flex sxa:items-center sxa:gap-2"
|
|
682
|
+
onSubmit={(event) => {
|
|
683
|
+
event.preventDefault();
|
|
684
|
+
// A new search restarts the list at page one. The basket is held
|
|
685
|
+
// above this component, so it survives the reset intact.
|
|
686
|
+
setQuery(search.trim());
|
|
687
|
+
}}
|
|
688
|
+
>
|
|
689
|
+
<label htmlFor={searchId} className="sxa:sr-only">
|
|
690
|
+
{t('reseller.search')}
|
|
691
|
+
</label>
|
|
692
|
+
<input
|
|
693
|
+
id={searchId}
|
|
694
|
+
type="search"
|
|
695
|
+
value={search}
|
|
696
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
697
|
+
className={`${FIELD} sxa:w-36`}
|
|
698
|
+
/>
|
|
699
|
+
<button
|
|
700
|
+
type="submit"
|
|
701
|
+
className="sx-account-button sxa:card-button sxa:card-button--add"
|
|
702
|
+
aria-label={t('reseller.search')}
|
|
703
|
+
>
|
|
704
|
+
<SearchIcon className="sxa:h-3 sxa:w-3" />
|
|
705
|
+
</button>
|
|
706
|
+
</form>
|
|
707
|
+
</div>
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
if (state.status === 'loading') {
|
|
711
|
+
return (
|
|
712
|
+
<section>
|
|
713
|
+
{heading}
|
|
714
|
+
<div className="sxa:mt-4">
|
|
715
|
+
<AccountRowsSkeleton label={t('overview.loading')} />
|
|
716
|
+
</div>
|
|
717
|
+
</section>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (state.status === 'error') {
|
|
721
|
+
return (
|
|
722
|
+
<section>
|
|
723
|
+
{heading}
|
|
724
|
+
<div className="sxa:mt-4">
|
|
725
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
726
|
+
</div>
|
|
727
|
+
</section>
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const { slice } = state;
|
|
732
|
+
|
|
733
|
+
return (
|
|
734
|
+
<section>
|
|
735
|
+
{heading}
|
|
736
|
+
{slice.items.length === 0 ? (
|
|
737
|
+
<div className="sxa:mt-4">
|
|
738
|
+
<AccountEmpty
|
|
739
|
+
title={t('reseller.catalogEmptyTitle')}
|
|
740
|
+
body={t('reseller.catalogEmptyBody')}
|
|
741
|
+
/>
|
|
742
|
+
</div>
|
|
743
|
+
) : (
|
|
744
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
745
|
+
{slice.items.map((row) => (
|
|
746
|
+
<li key={row.item.product_id}>
|
|
747
|
+
<CatalogProduct item={row.item} currency={row.currency} onAdd={onAdd} />
|
|
748
|
+
</li>
|
|
749
|
+
))}
|
|
750
|
+
</ul>
|
|
751
|
+
)}
|
|
752
|
+
<MorePages slice={slice} busy={moreBusy} error={moreError} onMore={loadMore} />
|
|
753
|
+
</section>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function CatalogProduct({
|
|
758
|
+
item,
|
|
759
|
+
currency,
|
|
760
|
+
onAdd,
|
|
761
|
+
}: {
|
|
762
|
+
item: ResellerCatalogItem;
|
|
763
|
+
currency: string;
|
|
764
|
+
onAdd: (line: BasketLine) => void;
|
|
765
|
+
}) {
|
|
766
|
+
return (
|
|
767
|
+
<AccountCard className="sxa:p-5">
|
|
768
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-3">
|
|
769
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
770
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{item.title}</p>
|
|
771
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
772
|
+
{formatAccountAmount(item.unit_price, currency)}
|
|
773
|
+
{item.volume_discount_enabled ? ` · ${t('reseller.volumePricing')}` : ''}
|
|
774
|
+
{item.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
|
|
775
|
+
</p>
|
|
776
|
+
</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
|
+
</div>
|
|
796
|
+
|
|
797
|
+
{item.variants.length > 0 ? (
|
|
798
|
+
<ul className="sxa:mt-4 sxa:space-y-2 sxa:border-t sxa:pt-4">
|
|
799
|
+
{item.variants.map((variant) => (
|
|
800
|
+
<CatalogVariant
|
|
801
|
+
key={variant.variant_id}
|
|
802
|
+
item={item}
|
|
803
|
+
variant={variant}
|
|
804
|
+
currency={currency}
|
|
805
|
+
onAdd={onAdd}
|
|
806
|
+
/>
|
|
807
|
+
))}
|
|
808
|
+
</ul>
|
|
809
|
+
) : null}
|
|
810
|
+
</AccountCard>
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function CatalogVariant({
|
|
815
|
+
item,
|
|
816
|
+
variant,
|
|
817
|
+
currency,
|
|
818
|
+
onAdd,
|
|
819
|
+
}: {
|
|
820
|
+
item: ResellerCatalogItem;
|
|
821
|
+
variant: ResellerCatalogVariant;
|
|
822
|
+
currency: string;
|
|
823
|
+
onAdd: (line: BasketLine) => void;
|
|
824
|
+
}) {
|
|
825
|
+
return (
|
|
826
|
+
<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">
|
|
827
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
828
|
+
<p className="sxa:truncate sxa:text-small">{variant.title}</p>
|
|
829
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
830
|
+
{formatAccountAmount(variant.unit_price, currency)}
|
|
831
|
+
{variant.supplier_backed ? ` · ${t('reseller.supplierBacked')}` : ''}
|
|
832
|
+
</p>
|
|
833
|
+
</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
|
+
</li>
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
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
|
+
function WholesaleOrders() {
|
|
1033
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
1034
|
+
const { state, moreBusy, moreError, loadMore, reload } = usePagedList<ResellerOrderListItem>(
|
|
1035
|
+
'reseller-orders',
|
|
1036
|
+
async (page) => {
|
|
1037
|
+
const response = await callCustomerApi((sdk) => sdk.resellerOrders({
|
|
1038
|
+
page,
|
|
1039
|
+
per_page: PAGE_SIZE,
|
|
1040
|
+
}));
|
|
1041
|
+
if (!response.success) {
|
|
1042
|
+
return { ok: false, message: response.message?.trim() || t('common.genericError') };
|
|
1043
|
+
}
|
|
1044
|
+
const narrowed = narrowResellerOrders(response.data);
|
|
1045
|
+
if (!narrowed.ok) return { ok: false, message: t('common.genericError') };
|
|
1046
|
+
|
|
1047
|
+
return {
|
|
1048
|
+
ok: true,
|
|
1049
|
+
slice: {
|
|
1050
|
+
items: narrowed.data.items,
|
|
1051
|
+
page: narrowed.data.page,
|
|
1052
|
+
perPage: narrowed.data.per_page,
|
|
1053
|
+
total: narrowed.data.total,
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
},
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
if (selected !== null) {
|
|
1060
|
+
return <WholesaleOrderDetail uniqid={selected} onBack={() => setSelected(null)} />;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const heading = <AccountSectionTitle>{t('reseller.ordersTitle')}</AccountSectionTitle>;
|
|
1064
|
+
|
|
1065
|
+
if (state.status === 'loading') {
|
|
1066
|
+
return (
|
|
1067
|
+
<section>
|
|
1068
|
+
{heading}
|
|
1069
|
+
<div className="sxa:mt-4">
|
|
1070
|
+
<AccountRowsSkeleton label={t('orders.loading')} />
|
|
1071
|
+
</div>
|
|
1072
|
+
</section>
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
if (state.status === 'error') {
|
|
1076
|
+
return (
|
|
1077
|
+
<section>
|
|
1078
|
+
{heading}
|
|
1079
|
+
<div className="sxa:mt-4">
|
|
1080
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
1081
|
+
</div>
|
|
1082
|
+
</section>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const { slice } = state;
|
|
1087
|
+
|
|
1088
|
+
return (
|
|
1089
|
+
<section>
|
|
1090
|
+
{heading}
|
|
1091
|
+
{slice.items.length === 0 ? (
|
|
1092
|
+
<div className="sxa:mt-4">
|
|
1093
|
+
<AccountEmpty
|
|
1094
|
+
title={t('reseller.ordersEmptyTitle')}
|
|
1095
|
+
body={t('reseller.ordersEmptyBody')}
|
|
1096
|
+
/>
|
|
1097
|
+
</div>
|
|
1098
|
+
) : (
|
|
1099
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
1100
|
+
{slice.items.map((order) => (
|
|
1101
|
+
<li key={order.uniqid}>
|
|
1102
|
+
<button
|
|
1103
|
+
type="button"
|
|
1104
|
+
onClick={() => setSelected(order.uniqid)}
|
|
1105
|
+
className="sxa:flex sxa:w-full sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:text-left sxa:shadow-card sxa:transition-colors sxa:hover:bg-raised"
|
|
1106
|
+
>
|
|
1107
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
1108
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">
|
|
1109
|
+
{t('orders.fallbackTitle', { id: order.uniqid })}
|
|
1110
|
+
</p>
|
|
1111
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
1112
|
+
{formatAccountDate(order.created_at)}
|
|
1113
|
+
{' · '}
|
|
1114
|
+
{t('reseller.lineQuantity', { count: order.item_count })}
|
|
1115
|
+
</p>
|
|
1116
|
+
</div>
|
|
1117
|
+
<span className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
1118
|
+
{formatAccountAmount(order.total, order.currency)}
|
|
1119
|
+
</span>
|
|
1120
|
+
<StatusChip status={order.status} />
|
|
1121
|
+
</button>
|
|
1122
|
+
</li>
|
|
1123
|
+
))}
|
|
1124
|
+
</ul>
|
|
1125
|
+
)}
|
|
1126
|
+
<MorePages slice={slice} busy={moreBusy} error={moreError} onMore={loadMore} />
|
|
1127
|
+
</section>
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function WholesaleOrderDetail({ uniqid, onBack }: { uniqid: string; onBack: () => void }) {
|
|
1132
|
+
const { state, reload } = useAccountResource(`reseller-order:${uniqid}`, () =>
|
|
1133
|
+
callCustomerApi((sdk) => sdk.resellerOrder(uniqid)));
|
|
1134
|
+
|
|
1135
|
+
const back = <AccountBackButton onClick={onBack} label={t('orders.allOrders')} />;
|
|
1136
|
+
|
|
1137
|
+
if (state.status === 'loading') {
|
|
1138
|
+
return (
|
|
1139
|
+
<section>
|
|
1140
|
+
{back}
|
|
1141
|
+
<div className="sxa:mt-5">
|
|
1142
|
+
<AccountRowsSkeleton label={t('orders.loadingOne')} />
|
|
1143
|
+
</div>
|
|
1144
|
+
</section>
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
if (state.status === 'error') {
|
|
1148
|
+
return (
|
|
1149
|
+
<section>
|
|
1150
|
+
{back}
|
|
1151
|
+
<div className="sxa:mt-5">
|
|
1152
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
1153
|
+
</div>
|
|
1154
|
+
</section>
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const narrowed = narrowResellerOrderDetail(state.data);
|
|
1159
|
+
if (!narrowed.ok) {
|
|
1160
|
+
return (
|
|
1161
|
+
<section>
|
|
1162
|
+
{back}
|
|
1163
|
+
<div className="sxa:mt-5">
|
|
1164
|
+
<AccountError message={t('common.genericError')} onRetry={reload} />
|
|
1165
|
+
</div>
|
|
1166
|
+
</section>
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
const order = narrowed.data;
|
|
1170
|
+
|
|
1171
|
+
return (
|
|
1172
|
+
<section>
|
|
1173
|
+
{back}
|
|
1174
|
+
<div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
|
|
1175
|
+
<div className="sxa:min-w-0">
|
|
1176
|
+
<h2 className="sxa:font-heading sxa:truncate sxa:text-h3 sxa:font-semibold">
|
|
1177
|
+
{t('orders.detailTitle', { id: order.uniqid })}
|
|
1178
|
+
</h2>
|
|
1179
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">
|
|
1180
|
+
{formatAccountDateTime(order.created_at)}
|
|
1181
|
+
</p>
|
|
1182
|
+
</div>
|
|
1183
|
+
<StatusChip status={order.status} />
|
|
1184
|
+
</div>
|
|
1185
|
+
|
|
1186
|
+
<AccountCard className="sxa:mt-5 sxa:p-6">
|
|
1187
|
+
<div className="sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
|
|
1188
|
+
<span className="sxa:text-muted">{t('orders.total')}</span>
|
|
1189
|
+
<span className="sxa:font-semibold sxa:tabular-nums">
|
|
1190
|
+
{formatAccountAmount(order.total, order.currency)}
|
|
1191
|
+
</span>
|
|
1192
|
+
</div>
|
|
1193
|
+
{order.refunded_amount !== null ? (
|
|
1194
|
+
<div className="sxa:mt-2 sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
|
|
1195
|
+
<span className="sxa:text-muted">{t('reseller.refunded')}</span>
|
|
1196
|
+
<span className="sxa:font-semibold sxa:tabular-nums">
|
|
1197
|
+
{formatAccountAmount(order.refunded_amount, order.currency)}
|
|
1198
|
+
</span>
|
|
1199
|
+
</div>
|
|
1200
|
+
) : null}
|
|
1201
|
+
<div className="sxa:mt-2 sxa:flex sxa:items-baseline sxa:justify-between sxa:gap-3 sxa:text-small">
|
|
1202
|
+
<span className="sxa:text-muted">{t('reseller.tier')}</span>
|
|
1203
|
+
<span className="sxa:font-medium">
|
|
1204
|
+
{order.tier_name}
|
|
1205
|
+
{' · '}
|
|
1206
|
+
{`${order.tier_discount_percent}%`}
|
|
1207
|
+
</span>
|
|
1208
|
+
</div>
|
|
1209
|
+
</AccountCard>
|
|
1210
|
+
|
|
1211
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
1212
|
+
{order.lines.map((line, index) => (
|
|
1213
|
+
<li key={`${line.product_id ?? ''}:${line.variant_id ?? ''}:${index}`}>
|
|
1214
|
+
<OrderLine line={line} currency={order.currency} />
|
|
1215
|
+
</li>
|
|
1216
|
+
))}
|
|
1217
|
+
</ul>
|
|
1218
|
+
</section>
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function OrderLine({ line, currency }: { line: ResellerOrderLine; currency: string }) {
|
|
1223
|
+
return (
|
|
1224
|
+
<AccountCard className="sxa:p-5">
|
|
1225
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
1226
|
+
<div className="sxa:min-w-0">
|
|
1227
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{line.product_title}</p>
|
|
1228
|
+
{line.variant_title ? (
|
|
1229
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{line.variant_title}</p>
|
|
1230
|
+
) : null}
|
|
1231
|
+
</div>
|
|
1232
|
+
<p className="sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
1233
|
+
{formatAccountAmount(line.total, currency)}
|
|
1234
|
+
</p>
|
|
1235
|
+
</div>
|
|
1236
|
+
<p className="sxa:mt-2 sxa:text-micro sxa:text-muted">
|
|
1237
|
+
{t('reseller.lineQuantity', { count: line.quantity })}
|
|
1238
|
+
{' · '}
|
|
1239
|
+
{line.delivered ? t('delivery.DELIVERED') : t('delivery.PENDING')}
|
|
1240
|
+
{line.delivered_at !== null ? ` · ${formatAccountDateTime(line.delivered_at)}` : ''}
|
|
1241
|
+
</p>
|
|
1242
|
+
|
|
1243
|
+
{line.serials.length > 0 ? (
|
|
1244
|
+
<div className="sxa:mt-4 sxa:space-y-2 sxa:border-t sxa:pt-4">
|
|
1245
|
+
<p className="sxa:text-micro sxa:font-semibold sxa:text-muted">{t('reseller.serials')}</p>
|
|
1246
|
+
{line.serials.map((serial) => (
|
|
1247
|
+
<div key={serial} className="sxa:flex sxa:items-center sxa:gap-3">
|
|
1248
|
+
<p className="sxa:min-w-0 sxa:flex-1 sxa:truncate sxa:font-mono sxa:text-micro">{serial}</p>
|
|
1249
|
+
<CopyButton value={serial} />
|
|
1250
|
+
</div>
|
|
1251
|
+
))}
|
|
1252
|
+
</div>
|
|
1253
|
+
) : null}
|
|
1254
|
+
</AccountCard>
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/* --- API keys ------------------------------------------------------------- */
|
|
1259
|
+
|
|
1260
|
+
function ResellerApiKeys() {
|
|
1261
|
+
const { state, reload } = useAccountResource('reseller-api-keys', () =>
|
|
1262
|
+
callCustomerApi((sdk) => sdk.resellerApiKeys()));
|
|
1263
|
+
const [error, setError] = useState<string | null>(null);
|
|
1264
|
+
|
|
1265
|
+
const heading = <AccountSectionTitle>{t('reseller.apiKeysTitle')}</AccountSectionTitle>;
|
|
1266
|
+
|
|
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
|
+
*/
|
|
1272
|
+
const elsewhere = (
|
|
1273
|
+
<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">
|
|
1274
|
+
{t('reseller.keysElsewhere')}
|
|
1275
|
+
</p>
|
|
1276
|
+
);
|
|
1277
|
+
|
|
1278
|
+
if (state.status === 'loading') {
|
|
1279
|
+
return (
|
|
1280
|
+
<section>
|
|
1281
|
+
{heading}
|
|
1282
|
+
<div className="sxa:mt-4">
|
|
1283
|
+
<AccountRowsSkeleton rows={2} label={t('overview.loading')} />
|
|
1284
|
+
</div>
|
|
1285
|
+
{elsewhere}
|
|
1286
|
+
</section>
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
if (state.status === 'error') {
|
|
1290
|
+
return (
|
|
1291
|
+
<section>
|
|
1292
|
+
{heading}
|
|
1293
|
+
<div className="sxa:mt-4">
|
|
1294
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
1295
|
+
</div>
|
|
1296
|
+
{elsewhere}
|
|
1297
|
+
</section>
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const narrowed = narrowResellerApiKeys(state.data);
|
|
1302
|
+
if (!narrowed.ok) {
|
|
1303
|
+
return (
|
|
1304
|
+
<section>
|
|
1305
|
+
{heading}
|
|
1306
|
+
<div className="sxa:mt-4">
|
|
1307
|
+
<AccountError message={t('common.genericError')} onRetry={reload} />
|
|
1308
|
+
</div>
|
|
1309
|
+
{elsewhere}
|
|
1310
|
+
</section>
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
const keys = narrowed.data;
|
|
1314
|
+
|
|
1315
|
+
return (
|
|
1316
|
+
<section>
|
|
1317
|
+
{heading}
|
|
1318
|
+
{error ? (
|
|
1319
|
+
<div className="sxa:mt-4">
|
|
1320
|
+
<AccountError message={error} />
|
|
1321
|
+
</div>
|
|
1322
|
+
) : null}
|
|
1323
|
+
|
|
1324
|
+
{keys.length === 0 ? (
|
|
1325
|
+
<p className="sxa:mt-4 sxa:text-small sxa:text-muted">{t('reseller.apiKeysEmpty')}</p>
|
|
1326
|
+
) : (
|
|
1327
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
1328
|
+
{keys.map((key) => (
|
|
1329
|
+
<li
|
|
1330
|
+
key={key.id}
|
|
1331
|
+
className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
|
|
1332
|
+
>
|
|
1333
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
1334
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">
|
|
1335
|
+
{key.name}
|
|
1336
|
+
{' · '}
|
|
1337
|
+
<span className="sxa:font-mono">{key.key_last4}</span>
|
|
1338
|
+
</p>
|
|
1339
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
1340
|
+
{key.last_used_at
|
|
1341
|
+
? t('reseller.lastUsed', {
|
|
1342
|
+
date: formatAccountDateTime(key.last_used_at),
|
|
1343
|
+
})
|
|
1344
|
+
: t('reseller.neverUsed')}
|
|
1345
|
+
</p>
|
|
1346
|
+
</div>
|
|
1347
|
+
|
|
1348
|
+
{/* A revoked key stays on the list, stamped. Removing the row
|
|
1349
|
+
would hide the fact that the key ever existed.
|
|
1350
|
+
|
|
1351
|
+
Read-only: revoking is irreversible and breaks the buyer's
|
|
1352
|
+
external integrations at once, and this page is
|
|
1353
|
+
merchant-authored JavaScript — a hostile theme could revoke
|
|
1354
|
+
every key silently, past a confirmation it renders itself.
|
|
1355
|
+
The route is off the worker's allowlist for that reason. */}
|
|
1356
|
+
{key.revoked_at !== null ? <StatusChip status="REVOKED" /> : null}
|
|
1357
|
+
</li>
|
|
1358
|
+
))}
|
|
1359
|
+
</ul>
|
|
1360
|
+
)}
|
|
1361
|
+
|
|
1362
|
+
{elsewhere}
|
|
1363
|
+
</section>
|
|
1364
|
+
);
|
|
1365
|
+
}
|