@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. package/package.json +75 -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
+ }