@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,410 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentType, type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
import { Link, Navigate, useLocation } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
isAccountTabOffered,
|
|
5
|
+
type AccountAvailability,
|
|
6
|
+
} from './account-availability';
|
|
7
|
+
import {
|
|
8
|
+
IMPLEMENTED_ACCOUNT_TABS,
|
|
9
|
+
accountTabHref,
|
|
10
|
+
accountTabLabelKey,
|
|
11
|
+
canonicalAccountPath,
|
|
12
|
+
resolveAccountView,
|
|
13
|
+
type AccountTab,
|
|
14
|
+
type AccountView,
|
|
15
|
+
type ImplementedAccountTab,
|
|
16
|
+
} from '@shoppexio/storefront-react';
|
|
17
|
+
import { captureAccountResellerInvite } from './account-reseller-invite';
|
|
18
|
+
import {
|
|
19
|
+
takeAccountSignInPrefill,
|
|
20
|
+
type AccountSignInPrefill,
|
|
21
|
+
} from './account-signin-link';
|
|
22
|
+
import { callCustomerApi } from './customer-account';
|
|
23
|
+
import { t } from './i18n';
|
|
24
|
+
import {
|
|
25
|
+
AccountShellProvider,
|
|
26
|
+
useAccountAvailability,
|
|
27
|
+
useAccountShellReads,
|
|
28
|
+
} from './dashboard';
|
|
29
|
+
import { AccountDownloads } from './downloads';
|
|
30
|
+
import { AccountFavorites } from './favorites';
|
|
31
|
+
import { AccountLogin } from './login';
|
|
32
|
+
import { AccountOrderDetail, AccountOrders } from './orders';
|
|
33
|
+
import { AccountOverview } from './overview';
|
|
34
|
+
import { AccountReferrals } from './referrals';
|
|
35
|
+
import { AccountReseller } from './reseller';
|
|
36
|
+
import { AccountRewards } from './rewards';
|
|
37
|
+
import { AccountSettings } from './settings';
|
|
38
|
+
import { AccountSubscriptions } from './subscriptions';
|
|
39
|
+
import { AccountSupport } from './support';
|
|
40
|
+
import { AccountEmpty, AccountError, AccountRowsSkeleton, BusySpinner } from './shared';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The customer account, served from this storefront's own build.
|
|
44
|
+
*
|
|
45
|
+
* ONE shell under `/dashboard`, with the panel as a path segment below it —
|
|
46
|
+
* `/dashboard/orders`, `/dashboard/orders/<uniqid>`. That is the platform's
|
|
47
|
+
* route shape and it is the same on every lane, so a link in an order email
|
|
48
|
+
* works whether the shop runs a document theme or either code storefront. The
|
|
49
|
+
* retired `?tab=`/`?order=` spelling is accepted and canonicalized here;
|
|
50
|
+
* nothing in this template writes it.
|
|
51
|
+
*
|
|
52
|
+
* The session is an HttpOnly cookie the Shoppex worker sets on the merchant's
|
|
53
|
+
* own host. This component cannot read it and does not try: it asks the API who
|
|
54
|
+
* is signed in, and renders the sign-in form when the answer is nobody.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
type SessionState =
|
|
58
|
+
| { status: 'checking' }
|
|
59
|
+
| { status: 'signed-out' }
|
|
60
|
+
| { status: 'signed-in' }
|
|
61
|
+
| { status: 'unknown'; message: string };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The prefill for this page load, taken from the cookie the first time it is
|
|
65
|
+
* asked for.
|
|
66
|
+
*
|
|
67
|
+
* Module scope rather than a hook, because the cookie can be read exactly once:
|
|
68
|
+
* reading it deletes it, and React invokes a `useState` initializer twice under
|
|
69
|
+
* StrictMode. The second read would find an empty jar and null out a prefill the
|
|
70
|
+
* first read had already spent.
|
|
71
|
+
*/
|
|
72
|
+
let takenPrefill: AccountSignInPrefill | null | undefined;
|
|
73
|
+
|
|
74
|
+
export function takeSignInPrefillOnce(): AccountSignInPrefill | null {
|
|
75
|
+
if (takenPrefill === undefined) takenPrefill = takeAccountSignInPrefill(document);
|
|
76
|
+
return takenPrefill;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spend the prefill for good — component state AND the module memo.
|
|
81
|
+
*
|
|
82
|
+
* Clearing only the component's copy leaves the pair here for the next mount.
|
|
83
|
+
* This is a single-page app: the buyer walks off to a product, their session
|
|
84
|
+
* expires, they navigate back to `/dashboard`, and the page remounts from this
|
|
85
|
+
* cache — putting the old address and a long-dead code back in the form for
|
|
86
|
+
* whoever is at the browser.
|
|
87
|
+
*/
|
|
88
|
+
export function spendSignInPrefill(): null {
|
|
89
|
+
takenPrefill = null;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `?tab=` and `?order=` are this route's to drop; everything else is not. */
|
|
94
|
+
function remainingSearch(search: URLSearchParams): string {
|
|
95
|
+
const kept = new URLSearchParams(search);
|
|
96
|
+
kept.delete('tab');
|
|
97
|
+
kept.delete('order');
|
|
98
|
+
// `invoice` is the portal's spelling for `order` and `canonicalAccountPath`
|
|
99
|
+
// counts it as legacy too, so leaving it here would re-trigger this very
|
|
100
|
+
// redirect on the next render — the loop the paragraph above warns about.
|
|
101
|
+
kept.delete('invoice');
|
|
102
|
+
const query = kept.toString();
|
|
103
|
+
return query ? `?${query}` : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the host storefront hands the account. */
|
|
107
|
+
export interface AccountPageProps {
|
|
108
|
+
/**
|
|
109
|
+
* The storefront's own page chrome around the account: the same column,
|
|
110
|
+
* gutter and rhythm every other page renders into. The account draws
|
|
111
|
+
* everything inside it and nothing around it.
|
|
112
|
+
*/
|
|
113
|
+
frame: ComponentType<{ children: ReactNode }>;
|
|
114
|
+
/**
|
|
115
|
+
* What a path under the account that names no account surface renders —
|
|
116
|
+
* the storefront's own not-found page, so a mistyped link looks like every
|
|
117
|
+
* other missing page on the shop.
|
|
118
|
+
*/
|
|
119
|
+
notFound: ReactNode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function AccountPage({ frame: Frame, notFound }: AccountPageProps) {
|
|
123
|
+
const location = useLocation();
|
|
124
|
+
const search = new URLSearchParams(location.search);
|
|
125
|
+
const canonical = canonicalAccountPath(location.pathname, search);
|
|
126
|
+
const view = resolveAccountView(location.pathname, search);
|
|
127
|
+
const [session, setSession] = useState<SessionState>({ status: 'checking' });
|
|
128
|
+
const [signOutError, setSignOutError] = useState<string | null>(null);
|
|
129
|
+
const [signingOut, setSigningOut] = useState(false);
|
|
130
|
+
const [probeToken, setProbeToken] = useState(0);
|
|
131
|
+
const [prefill, setPrefill] = useState(takeSignInPrefillOnce);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
let active = true;
|
|
135
|
+
|
|
136
|
+
// An artifact-owned account receives the reseller invitation in the URL —
|
|
137
|
+
// the worker only mints the cookie for platform-rendered surfaces. Move it
|
|
138
|
+
// into the cookie before anything else, so the sign-in navigation that may
|
|
139
|
+
// follow (a new tab included) cannot lose it. Idempotent under StrictMode.
|
|
140
|
+
captureAccountResellerInvite(document, window);
|
|
141
|
+
|
|
142
|
+
/*
|
|
143
|
+
* The session probe.
|
|
144
|
+
*
|
|
145
|
+
* Only a 401 means "not signed in". A 403, a 5xx, a contract mismatch or a
|
|
146
|
+
* network error with no status at all means the answer is UNKNOWN, and
|
|
147
|
+
* showing the sign-in form there tells a buyer whose session is still live
|
|
148
|
+
* that they have been signed out. Those get an error and a retry.
|
|
149
|
+
*
|
|
150
|
+
* The opposite must never happen either: a failed probe treated as a
|
|
151
|
+
* signed-in customer would render an account shell around data that was
|
|
152
|
+
* never fetched.
|
|
153
|
+
*/
|
|
154
|
+
void callCustomerApi((sdk) => sdk.me()).then((result) => {
|
|
155
|
+
if (!active) return;
|
|
156
|
+
|
|
157
|
+
if (result.success) {
|
|
158
|
+
// Already signed in, so the link's code was never needed — and neither
|
|
159
|
+
// a later sign-out nor a remount may bring it back.
|
|
160
|
+
setPrefill(spendSignInPrefill());
|
|
161
|
+
setSession({ status: 'signed-in' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (result.status === 401) {
|
|
166
|
+
setSession({ status: 'signed-out' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setSession({
|
|
171
|
+
status: 'unknown',
|
|
172
|
+
message: result.message?.trim() || t('account.sessionUnknown'),
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
active = false;
|
|
178
|
+
};
|
|
179
|
+
}, [probeToken]);
|
|
180
|
+
|
|
181
|
+
const signOut = async () => {
|
|
182
|
+
setSigningOut(true);
|
|
183
|
+
setSignOutError(null);
|
|
184
|
+
const result = await callCustomerApi((sdk) => sdk.logout());
|
|
185
|
+
setSigningOut(false);
|
|
186
|
+
|
|
187
|
+
// The worker clears the session cookie on every response it produces, even
|
|
188
|
+
// a 500 from the backend — so an ANSWERED request means the buyer really is
|
|
189
|
+
// signed out, whatever the status says. Only a result with no status at all
|
|
190
|
+
// means nothing reached the edge: the cookie is still there, the session is
|
|
191
|
+
// still live, and a signed-out page would be a lie the next reload exposes.
|
|
192
|
+
if (!result.success && result.status === undefined) {
|
|
193
|
+
setSignOutError(result.message?.trim() || t('account.signOutFailed'));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setSession({ status: 'signed-out' });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/*
|
|
200
|
+
* A retired spelling is rewritten before anything renders — replace, not
|
|
201
|
+
* push, so the back button leaves the account rather than bouncing between
|
|
202
|
+
* two spellings of one panel. Returning the redirect here rather than running
|
|
203
|
+
* it from an effect also means the panel below mounts once, on the canonical
|
|
204
|
+
* URL, instead of loading its data twice.
|
|
205
|
+
*/
|
|
206
|
+
if (canonical !== null) return <Navigate to={`${canonical}${remainingSearch(search)}`} replace />;
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* A path naming no account surface is NOT this route (ADR-0064), and it is
|
|
210
|
+
* answered before anything asks who is signed in. Answering it inside the
|
|
211
|
+
* account meant a buyer who followed a mistyped link was shown a sign-in form
|
|
212
|
+
* first and only told the link was broken after they had used it — a password
|
|
213
|
+
* prompt standing in front of a 404.
|
|
214
|
+
*/
|
|
215
|
+
if (view.kind === 'unknown-tab') return <>{notFound}</>;
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<Frame>
|
|
219
|
+
<section className="sx-account sxa:space-y-8 sxa:py-6 sxa:md:py-8" data-sx-account-session={session.status}>
|
|
220
|
+
<div className="sx-account-header sxa:flex sxa:flex-wrap sxa:items-end sxa:justify-between sxa:gap-4">
|
|
221
|
+
<h1 className="sx-account-title sxa:font-heading sxa:text-h1">
|
|
222
|
+
{t('account.title')}
|
|
223
|
+
</h1>
|
|
224
|
+
{session.status === 'signed-in' ? (
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => void signOut()}
|
|
228
|
+
disabled={signingOut}
|
|
229
|
+
aria-busy={signingOut || undefined}
|
|
230
|
+
className="sx-account-signout sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
231
|
+
>
|
|
232
|
+
{signingOut ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
233
|
+
{signingOut ? t('account.signingOut') : t('account.signOut')}
|
|
234
|
+
</button>
|
|
235
|
+
) : null}
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{session.status === 'checking' ? (
|
|
239
|
+
<AccountRowsSkeleton rows={3} label={t('account.checkingSession')} />
|
|
240
|
+
) : null}
|
|
241
|
+
|
|
242
|
+
{session.status === 'signed-out' ? (
|
|
243
|
+
<AccountLogin
|
|
244
|
+
prefill={prefill}
|
|
245
|
+
onSignedIn={() => {
|
|
246
|
+
// Spent. Without this the pair outlives the sign-in — here and in
|
|
247
|
+
// the module memo behind it — so signing out, or navigating away
|
|
248
|
+
// and back, remounts the form with the previous buyer's address
|
|
249
|
+
// and their already-used code.
|
|
250
|
+
setPrefill(spendSignInPrefill());
|
|
251
|
+
setSession({ status: 'signed-in' });
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
) : null}
|
|
255
|
+
|
|
256
|
+
{session.status === 'unknown' ? (
|
|
257
|
+
<div className="sxa:mx-auto sxa:w-full sxa:max-w-md">
|
|
258
|
+
<AccountError
|
|
259
|
+
message={session.message}
|
|
260
|
+
onRetry={() => {
|
|
261
|
+
setSession({ status: 'checking' });
|
|
262
|
+
setProbeToken((token) => token + 1);
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
) : null}
|
|
267
|
+
|
|
268
|
+
{session.status === 'signed-in' ? (
|
|
269
|
+
<SignedInAccount view={view} signOutError={signOutError} />
|
|
270
|
+
) : null}
|
|
271
|
+
</section>
|
|
272
|
+
</Frame>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Everything below the sign-in: the shell's two reads, the gated navigation,
|
|
278
|
+
* and the panel.
|
|
279
|
+
*
|
|
280
|
+
* Its own component because the reads have to sit ABOVE both the navigation and
|
|
281
|
+
* the panel — the navigation gates on them and seven panels are built from the
|
|
282
|
+
* dashboard payload. They used to be loaded by each panel separately, which is
|
|
283
|
+
* why the shell had nothing to gate with.
|
|
284
|
+
*/
|
|
285
|
+
function SignedInAccount({
|
|
286
|
+
view,
|
|
287
|
+
signOutError,
|
|
288
|
+
}: {
|
|
289
|
+
view: AccountView;
|
|
290
|
+
signOutError: string | null;
|
|
291
|
+
}) {
|
|
292
|
+
/*
|
|
293
|
+
* The surface being shown, and therefore what the shell's reads are keyed on.
|
|
294
|
+
* An order detail rides on the orders key: it loads its own order and needs
|
|
295
|
+
* nothing re-read to show it.
|
|
296
|
+
*/
|
|
297
|
+
const panelKey = view.kind === 'panel' ? view.tab : 'orders';
|
|
298
|
+
const reads = useAccountShellReads(panelKey);
|
|
299
|
+
const availability = useAccountAvailability(reads);
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<AccountShellProvider value={reads}>
|
|
303
|
+
{signOutError ? <AccountError message={signOutError} /> : null}
|
|
304
|
+
<AccountNav view={view} availability={availability} />
|
|
305
|
+
<div className="sx-account-panel" data-sx-account-view={view.kind === 'panel' ? view.tab : view.kind}>
|
|
306
|
+
<AccountPanel view={view} />
|
|
307
|
+
</div>
|
|
308
|
+
</AccountShellProvider>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The tabs this storefront offers this customer.
|
|
314
|
+
*
|
|
315
|
+
* Gated, because ADR-0064 names the portal's `visibleSections()` model as
|
|
316
|
+
* contract and requires it reproduced rather than simplified away — the
|
|
317
|
+
* document lane reproduces it, so a code storefront that did not would show a
|
|
318
|
+
* Referrals tab to a buyer whose shop runs no programme while the very same
|
|
319
|
+
* shop on the other lane hides it. The rules live in `account-availability.ts`;
|
|
320
|
+
* this only draws what they admit.
|
|
321
|
+
*/
|
|
322
|
+
function AccountNav({
|
|
323
|
+
view,
|
|
324
|
+
availability,
|
|
325
|
+
}: {
|
|
326
|
+
view: AccountView;
|
|
327
|
+
availability: AccountAvailability | null;
|
|
328
|
+
}) {
|
|
329
|
+
const activeTab: AccountTab | null = view.kind === 'panel'
|
|
330
|
+
? view.tab
|
|
331
|
+
: view.kind === 'order'
|
|
332
|
+
? 'orders'
|
|
333
|
+
: null;
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<nav className="sx-account-nav sxa:flex sxa:flex-wrap sxa:gap-1" aria-label={t('account.sections')}>
|
|
337
|
+
{IMPLEMENTED_ACCOUNT_TABS
|
|
338
|
+
.filter((tab) => isAccountTabOffered(tab, availability, activeTab))
|
|
339
|
+
.map((tab) => (
|
|
340
|
+
<Link
|
|
341
|
+
key={tab}
|
|
342
|
+
to={accountTabHref(tab)}
|
|
343
|
+
aria-current={activeTab === tab ? 'page' : undefined}
|
|
344
|
+
className={`sx-account-nav-link sxa:nav-link sxa:rounded-input sxa:px-3 sxa:py-1.5 sxa:text-small ${activeTab === tab ? 'sxa:nav-link--active' : ''}`}
|
|
345
|
+
>
|
|
346
|
+
{t(accountTabLabelKey(tab))}
|
|
347
|
+
</Link>
|
|
348
|
+
))}
|
|
349
|
+
</nav>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Every implemented tab, and its panel.
|
|
355
|
+
*
|
|
356
|
+
* A record rather than a switch with a default: the key type is
|
|
357
|
+
* `ImplementedAccountTab`, so adding a tab to that list without building its
|
|
358
|
+
* panel stops the build. A default branch would instead quietly answer the new
|
|
359
|
+
* tab with the overview, which is the "looks like the link worked" failure the
|
|
360
|
+
* route module exists to prevent.
|
|
361
|
+
*/
|
|
362
|
+
const ACCOUNT_PANELS: Readonly<Record<ImplementedAccountTab, () => ReactElement>> = {
|
|
363
|
+
overview: () => <AccountOverview />,
|
|
364
|
+
orders: () => <AccountOrders />,
|
|
365
|
+
downloads: () => <AccountDownloads />,
|
|
366
|
+
subscriptions: () => <AccountSubscriptions />,
|
|
367
|
+
rewards: () => <AccountRewards />,
|
|
368
|
+
referrals: () => <AccountReferrals />,
|
|
369
|
+
favorites: () => <AccountFavorites />,
|
|
370
|
+
support: () => <AccountSupport />,
|
|
371
|
+
settings: () => <AccountSettings />,
|
|
372
|
+
reseller: () => <AccountReseller />,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
function AccountPanel({ view }: { view: AccountView }) {
|
|
376
|
+
switch (view.kind) {
|
|
377
|
+
case 'panel':
|
|
378
|
+
return ACCOUNT_PANELS[view.tab]();
|
|
379
|
+
case 'order':
|
|
380
|
+
return <AccountOrderDetail orderId={view.orderId} />;
|
|
381
|
+
case 'not-built':
|
|
382
|
+
/*
|
|
383
|
+
* A real platform tab this storefront does not render. Said plainly
|
|
384
|
+
* rather than answered with the overview, which would look like the link
|
|
385
|
+
* worked and leave the buyer hunting for something that is not there.
|
|
386
|
+
*/
|
|
387
|
+
return (
|
|
388
|
+
<AccountEmpty
|
|
389
|
+
title={t('account.notBuilt.title', { tab: t(accountTabLabelKey(view.tab)) })}
|
|
390
|
+
body={t('account.notBuilt.body')}
|
|
391
|
+
action={<OverviewLink />}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
case 'unknown-tab':
|
|
395
|
+
// Answered above, before the session is even read. Unreachable here, and
|
|
396
|
+
// the compiler holds the switch exhaustive so it cannot be dropped.
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function OverviewLink() {
|
|
402
|
+
return (
|
|
403
|
+
<Link
|
|
404
|
+
to={accountTabHref('overview')}
|
|
405
|
+
className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small"
|
|
406
|
+
>
|
|
407
|
+
{t('account.goToOverview')}
|
|
408
|
+
</Link>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the buyer paid with, in the buyer's vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* The producer sends two different things: the internal rail (`gateway` —
|
|
5
|
+
* NATIVE_CRYPTO, NOWPAYMENTS, CRYPTOMUS…) and the concrete method the buyer
|
|
6
|
+
* actually chose (`apmMethod`, `cryptoGateway`). Someone who paid in USDT
|
|
7
|
+
* never picked "NOWPAYMENTS" and does not recognise the word — showing the
|
|
8
|
+
* rail names a company they never dealt with.
|
|
9
|
+
*
|
|
10
|
+
* The precedence is the customer portal's, kept identical on purpose: this
|
|
11
|
+
* account page replaces the portal, and a buyer's order history must not
|
|
12
|
+
* change wording underneath them at cutover.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const GATEWAY_LABELS: Record<string, string> = {
|
|
16
|
+
STRIPE: 'Stripe',
|
|
17
|
+
PAYPAL: 'PayPal',
|
|
18
|
+
CASHAPP: 'Cash App',
|
|
19
|
+
SQUARE: 'Square',
|
|
20
|
+
BITCOIN: 'Bitcoin',
|
|
21
|
+
LITECOIN: 'Litecoin',
|
|
22
|
+
ETHEREUM: 'Ethereum',
|
|
23
|
+
SOLANA: 'Solana',
|
|
24
|
+
CONCORDIUM: 'Concordium',
|
|
25
|
+
NANO: 'Nano',
|
|
26
|
+
MONERO: 'Monero',
|
|
27
|
+
BALANCE: 'Store Balance',
|
|
28
|
+
MOLLIE: 'Mollie',
|
|
29
|
+
LEX_PAYMENTS: 'Lex Payments',
|
|
30
|
+
SELLIX_PAY: 'Sellix Pay',
|
|
31
|
+
// Internal crypto rails — buyers see the coin (via cryptoGateway) or the
|
|
32
|
+
// generic "Crypto", never rail or provider vocabulary.
|
|
33
|
+
NATIVE_CRYPTO: 'Crypto',
|
|
34
|
+
NOWPAYMENTS: 'Crypto',
|
|
35
|
+
NOWPAYMENTS_WHITELABEL: 'Crypto',
|
|
36
|
+
OXAPAY: 'Crypto',
|
|
37
|
+
CRYPTOMUS: 'Crypto',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const CRYPTO_COIN_LABELS: Record<string, string> = {
|
|
41
|
+
BITCOIN: 'Bitcoin',
|
|
42
|
+
BITCOIN_CASH: 'Bitcoin Cash',
|
|
43
|
+
BITCOIN_LN: 'Lightning',
|
|
44
|
+
LITECOIN: 'Litecoin',
|
|
45
|
+
ETHEREUM: 'Ethereum',
|
|
46
|
+
SOLANA: 'Solana',
|
|
47
|
+
MONERO: 'Monero',
|
|
48
|
+
TRON: 'Tron',
|
|
49
|
+
POLYGON: 'Polygon',
|
|
50
|
+
BINANCE_COIN: 'BNB',
|
|
51
|
+
RIPPLE: 'Ripple',
|
|
52
|
+
DOGECOIN: 'Dogecoin',
|
|
53
|
+
DASH: 'Dash',
|
|
54
|
+
CRONOS: 'Cronos',
|
|
55
|
+
NANO: 'Nano',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const NOWPAYMENTS_DYNAMIC_PREFIX = 'NOWPAYMENTS:';
|
|
59
|
+
const NOWPAYMENTS_CURRENCY_CODE = /^[A-Z0-9]{2,12}$/;
|
|
60
|
+
|
|
61
|
+
/** `NOWPAYMENTS:ton` → `TON`. The prefix is the provider, never buyer-facing. */
|
|
62
|
+
function parseDynamicNowPaymentsCurrency(gateway: string): string | null {
|
|
63
|
+
const normalized = gateway.trim().toUpperCase();
|
|
64
|
+
if (!normalized.startsWith(NOWPAYMENTS_DYNAMIC_PREFIX)) return null;
|
|
65
|
+
const code = normalized.slice(NOWPAYMENTS_DYNAMIC_PREFIX.length);
|
|
66
|
+
return NOWPAYMENTS_CURRENCY_CODE.test(code) ? code : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `USDT_TRC20` → `USDT (TRC20)`; known chains map to their display names. */
|
|
70
|
+
export function cryptoCoinLabel(coinKey: string): string {
|
|
71
|
+
const dynamic = parseDynamicNowPaymentsCurrency(coinKey);
|
|
72
|
+
if (dynamic) return dynamic;
|
|
73
|
+
const upper = coinKey.trim().toUpperCase();
|
|
74
|
+
if (CRYPTO_COIN_LABELS[upper]) return CRYPTO_COIN_LABELS[upper];
|
|
75
|
+
const [symbol, ...network] = upper.split(/[_:]+/);
|
|
76
|
+
return network.length > 0 ? `${symbol} (${network.join(' ')})` : symbol;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type PaymentMethodSource = {
|
|
80
|
+
/** The internal rail. Only ever shown when nothing more concrete exists. */
|
|
81
|
+
gateway: string;
|
|
82
|
+
displayName: string | null;
|
|
83
|
+
cryptoGateway: string | null;
|
|
84
|
+
blockchain: string | null;
|
|
85
|
+
apmMethod: string | null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* displayName → apmMethod → coin → rail label. Each step is more specific
|
|
90
|
+
* about what the buyer chose than the one after it.
|
|
91
|
+
*/
|
|
92
|
+
export function paymentMethodLabel(payment: PaymentMethodSource): string {
|
|
93
|
+
if (payment.displayName) return payment.displayName;
|
|
94
|
+
if (payment.apmMethod) return payment.apmMethod;
|
|
95
|
+
// Crypto payments show the concrete coin; the rail stays internal.
|
|
96
|
+
if (payment.cryptoGateway) return cryptoCoinLabel(payment.cryptoGateway);
|
|
97
|
+
const base = GATEWAY_LABELS[payment.gateway] ?? payment.gateway;
|
|
98
|
+
return payment.blockchain ? `${base} (${payment.blockchain})` : base;
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The referrals panel's pure half.
|
|
5
|
+
*
|
|
6
|
+
* Read-only by design. Converting a referral balance into store credit and
|
|
7
|
+
* requesting a payout both move money toward the buyer, and ADR-0064 keeps
|
|
8
|
+
* money paths off the account API for good — theme code shares the origin with
|
|
9
|
+
* the session cookie, so an allowlisted payout endpoint would be a transaction
|
|
10
|
+
* primitive sitting next to untrusted markup. The panel therefore shows what
|
|
11
|
+
* has been earned and says where the payout is arranged.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here adds two balances together either. The server sends five
|
|
14
|
+
* figures; summing them into a friendlier one produces a number no other
|
|
15
|
+
* surface agrees with.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the buyer is in the programme at all.
|
|
20
|
+
*
|
|
21
|
+
* `not_enrolled` is the availability gate the hosted portal used to hide the
|
|
22
|
+
* whole tab. This template keeps the tab reachable — its link may already be in
|
|
23
|
+
* a sent email — and answers with the state instead, which tells the buyer
|
|
24
|
+
* something true rather than showing an empty balance sheet.
|
|
25
|
+
*/
|
|
26
|
+
export function isEnrolledInReferrals(summary: SdkCustomerAffiliateSummary): boolean {
|
|
27
|
+
return summary.state !== 'not_enrolled';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The link a buyer shares, or null when the server built none.
|
|
32
|
+
*
|
|
33
|
+
* The URL is the server's: it knows the shop's own storefront host. Assembling
|
|
34
|
+
* one here out of the current origin would hand out a link that works from
|
|
35
|
+
* wherever the buyer happens to be standing, including a preview host.
|
|
36
|
+
*/
|
|
37
|
+
export function referralShareUrl(link: SdkCustomerAffiliateLink): string | null {
|
|
38
|
+
const url = link.url?.trim();
|
|
39
|
+
return url ? url : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A link the shop has not approved yet earns nothing if it is shared today. */
|
|
43
|
+
export function isAwaitingApproval(link: SdkCustomerAffiliateLink): boolean {
|
|
44
|
+
return link.status === 'pending';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CLAIM_TYPE_KEYS: Readonly<Record<string, string>> = {
|
|
48
|
+
STORE_CREDIT: 'referrals.claimStoreCredit',
|
|
49
|
+
CRYPTO_PAYOUT_REQUEST: 'referrals.claimCryptoPayout',
|
|
50
|
+
MIGRATION_ADJUSTMENT: 'referrals.claimAdjustment',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Null for a claim type this catalog does not know — the raw value is shown. */
|
|
54
|
+
export function claimTypeKey(type: string): string | null {
|
|
55
|
+
return CLAIM_TYPE_KEYS[type] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether the referral balance is held in a different currency than the shop
|
|
60
|
+
* prices in, which is worth saying before a buyer compares the two numbers.
|
|
61
|
+
*/
|
|
62
|
+
export function isForeignBalanceCurrency(
|
|
63
|
+
summary: SdkCustomerAffiliateSummary,
|
|
64
|
+
shopCurrency: string,
|
|
65
|
+
): boolean {
|
|
66
|
+
return summary.balance_currency.toUpperCase() !== shopCurrency.toUpperCase();
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reseller invitation token, carried across sign-in as a cookie.
|
|
3
|
+
*
|
|
4
|
+
* On an artifact-owned account the edge worker deliberately leaves
|
|
5
|
+
* `reseller_invite` in the URL — this bundle is immutable merchant code, and an
|
|
6
|
+
* older build would otherwise watch the worker strip a token nobody reads.
|
|
7
|
+
* `captureAccountResellerInvite` is therefore this template's own handoff: it
|
|
8
|
+
* moves the token from the URL into the same `__Host-` cookie the worker mints
|
|
9
|
+
* for platform-rendered accounts, and scrubs the parameter before it can linger
|
|
10
|
+
* in history or a copied link.
|
|
11
|
+
*
|
|
12
|
+
* Reading is deliberately not consumption. A reload, a sign-in link opened in a
|
|
13
|
+
* new tab, or a session for another email must leave the token available while
|
|
14
|
+
* its one-hour cookie lives. Callers clear it explicitly after an accept
|
|
15
|
+
* attempt.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Pinned to `workers/storefront/src/reseller-invite-link.ts`. */
|
|
19
|
+
export const ACCOUNT_RESELLER_INVITE_COOKIE = '__Host-sx_reseller_invite';
|
|
20
|
+
|
|
21
|
+
/** Pinned to the email producer (`ResellerEnrollmentEmailService`). */
|
|
22
|
+
export const ACCOUNT_RESELLER_INVITE_PARAM = 'reseller_invite';
|
|
23
|
+
|
|
24
|
+
const INVITE_TTL_SECONDS = 3600;
|
|
25
|
+
|
|
26
|
+
const CLEAR_RESELLER_INVITE_COOKIE = `${ACCOUNT_RESELLER_INVITE_COOKIE}=; Path=/; Secure; SameSite=Lax; Max-Age=0`;
|
|
27
|
+
|
|
28
|
+
export interface AccountResellerInviteCookieJar {
|
|
29
|
+
cookie: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The slice of `window` the capture needs; a seam for tests. */
|
|
33
|
+
export interface AccountResellerInviteLocation {
|
|
34
|
+
location: { search: string; pathname: string; hash: string };
|
|
35
|
+
history: { replaceState(data: unknown, unused: string, url: string): void };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cookieValue(jar: string, name: string): string | null {
|
|
39
|
+
for (const entry of jar.split(';')) {
|
|
40
|
+
const separator = entry.indexOf('=');
|
|
41
|
+
if (separator < 0) continue;
|
|
42
|
+
if (entry.slice(0, separator).trim() !== name) continue;
|
|
43
|
+
return entry.slice(separator + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function usableInviteToken(raw: string | null): string | null {
|
|
49
|
+
if (!raw || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Move a URL-borne invitation into the cookie, scrubbing the parameter.
|
|
55
|
+
*
|
|
56
|
+
* The scrub happens even for an unusable value — a malformed token has no
|
|
57
|
+
* business staying in the address bar either. Idempotent: a second call finds
|
|
58
|
+
* no parameter and does nothing, so a StrictMode double-mount is harmless.
|
|
59
|
+
*/
|
|
60
|
+
export function captureAccountResellerInvite(
|
|
61
|
+
jar: AccountResellerInviteCookieJar,
|
|
62
|
+
win: AccountResellerInviteLocation,
|
|
63
|
+
): void {
|
|
64
|
+
const params = new URLSearchParams(win.location.search);
|
|
65
|
+
if (!params.has(ACCOUNT_RESELLER_INVITE_PARAM)) return;
|
|
66
|
+
|
|
67
|
+
const token = usableInviteToken(params.get(ACCOUNT_RESELLER_INVITE_PARAM));
|
|
68
|
+
params.delete(ACCOUNT_RESELLER_INVITE_PARAM);
|
|
69
|
+
const query = params.toString();
|
|
70
|
+
win.history.replaceState(
|
|
71
|
+
null,
|
|
72
|
+
'',
|
|
73
|
+
`${win.location.pathname}${query ? `?${query}` : ''}${win.location.hash}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!token) return;
|
|
77
|
+
jar.cookie = `${ACCOUNT_RESELLER_INVITE_COOKIE}=${token}; Path=/; Secure; SameSite=Lax; Max-Age=${INVITE_TTL_SECONDS}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read without consuming; the producer's base64url token is already cookie-safe. */
|
|
81
|
+
export function peekAccountResellerInvite(jar: AccountResellerInviteCookieJar): string | null {
|
|
82
|
+
return usableInviteToken(cookieValue(jar.cookie, ACCOUNT_RESELLER_INVITE_COOKIE));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Clear after an accept attempt, with the same `__Host-` attributes as the producer. */
|
|
86
|
+
export function clearAccountResellerInvite(jar: AccountResellerInviteCookieJar): void {
|
|
87
|
+
jar.cookie = CLEAR_RESELLER_INVITE_COOKIE;
|
|
88
|
+
}
|