@shoppexio/storefront-react 0.2.1 → 0.3.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.
@@ -1,25 +1,35 @@
1
1
  /**
2
2
  * What the buyer has earned by sending other buyers to this shop.
3
3
  *
4
- * Read-only, and deliberately so. The hosted portal let an affiliate turn a
5
- * balance into store credit and request a crypto payout; both move money toward
6
- * the buyer, and ADR-0064 keeps money paths off the account API — theme code
7
- * runs on the same origin as the session cookie, so an allowlisted payout would
8
- * be a transaction primitive standing next to untrusted markup. This panel
9
- * shows the standing and says where the payout is arranged. Issuing a link is
10
- * the same story: `POST /affiliate/links` is not allowlisted either, so the
11
- * links below are the ones the shop issued.
4
+ * One write: a link with a code of the buyer's own, when the producer says the
5
+ * shop allows it. It moves no money — the shop caps the links, approves the
6
+ * ones that ask for their own rates, and manages all of them itself.
7
+ *
8
+ * Everything else stays read-only, and deliberately so. The hosted portal let
9
+ * an affiliate turn a balance into store credit and request a crypto payout;
10
+ * both move money toward the buyer, and ADR-0064 keeps money paths off the
11
+ * account API — theme code runs on the same origin as the session cookie, so an
12
+ * allowlisted payout would be a transaction primitive standing next to
13
+ * untrusted markup. This panel shows the standing and says where the payout is
14
+ * arranged.
12
15
  */
13
16
 
17
+ import { useId, useState } from 'react';
14
18
  import {
15
19
  claimTypeKey,
20
+ commissionAmounts,
21
+ commissionLedgerKey,
22
+ commissionLedgerTone,
16
23
  isAwaitingApproval,
17
24
  isEnrolledInReferrals,
18
25
  isForeignBalanceCurrency,
26
+ isShareableLink,
27
+ offersReferralLinks,
28
+ referralLinkInput,
19
29
  referralShareUrl,
20
30
  } from './account-referrals';
21
- import { safeExternalHref } from './customer-account';
22
- import type { SdkCustomerAffiliateLink } from '@shoppexio/storefront-react';
31
+ import { callCustomerApi, safeExternalHref } from './customer-account';
32
+ import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
23
33
  import { t } from './i18n';
24
34
  import { ExternalLinkIcon } from './icons';
25
35
  import { useAccountDashboard } from './dashboard';
@@ -27,8 +37,11 @@ import {
27
37
  AccountCard,
28
38
  AccountEmpty,
29
39
  AccountError,
40
+ AccountFieldError,
41
+ AccountNotice,
30
42
  AccountRowsSkeleton,
31
43
  AccountSectionTitle,
44
+ BusySpinner,
32
45
  CopyButton,
33
46
  StatusChip,
34
47
  formatAccountAmount,
@@ -36,8 +49,15 @@ import {
36
49
  statusLabel,
37
50
  } from './shared';
38
51
 
52
+ /** The settings form's control skin, so the two forms on this account match. */
53
+ const FIELD =
54
+ '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';
55
+
39
56
  export function AccountReferrals() {
40
57
  const { state, reload } = useAccountDashboard();
58
+ const [creating, setCreating] = useState(false);
59
+ /** What the last made link was told to be: live, or waiting for the shop. */
60
+ const [created, setCreated] = useState<'ready' | 'pending' | null>(null);
41
61
 
42
62
  if (state.status === 'loading') {
43
63
  return <AccountRowsSkeleton rows={3} label={t('referrals.loading')} />;
@@ -54,73 +74,119 @@ export function AccountReferrals() {
54
74
  * hid the whole tab on it; this template keeps the tab reachable — the link
55
75
  * may already be sitting in a sent email — and says what the state is instead
56
76
  * of showing a balance sheet full of zeroes.
77
+ *
78
+ * A shop with auto-join off reports `not_enrolled` and still lets the buyer
79
+ * make a first link, which is how they join. That buyer gets the links
80
+ * section; the balance and history belong to enrolled buyers only.
57
81
  */
58
- if (!isEnrolledInReferrals(summary)) {
82
+ if (!offersReferralLinks(summary)) {
59
83
  return (
60
84
  <AccountEmpty title={t('referrals.notEnrolledTitle')} body={t('referrals.notEnrolledBody')} />
61
85
  );
62
86
  }
63
87
 
88
+ const enrolled = isEnrolledInReferrals(summary);
64
89
  const { balances, balance_currency: currency } = summary;
65
90
 
66
91
  return (
67
92
  <div className="sxa:space-y-8">
68
- <div>
69
- <AccountCard className="sxa:p-6 sxa:md:p-7">
70
- <p className="sxa:text-small sxa:text-muted">{t('referrals.availableLabel')}</p>
71
- <p className="sxa:mt-1 sxa:text-h1 sxa:font-bold sxa:tabular-nums">
72
- {formatAccountAmount(balances.available, currency)}
73
- </p>
74
- <p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('referrals.payoutsElsewhere')}</p>
75
- </AccountCard>
76
-
77
- {/*
78
- Four separate figures, exactly as the server sends them. Folding
79
- "converted" and "paid" into one friendlier "withdrawn" total would put
80
- a number on screen that no other Shoppex surface reports.
81
- */}
82
- <div className="sxa:mt-4 sxa:grid sxa:grid-cols-2 sxa:gap-4 sxa:sm:grid-cols-4">
83
- <BalanceTile
84
- label={t('referrals.pendingLabel')}
85
- amount={balances.pending}
86
- currency={currency}
87
- />
88
- <BalanceTile
89
- label={t('referrals.requestedLabel')}
90
- amount={balances.requested}
91
- currency={currency}
92
- />
93
- <BalanceTile
94
- label={t('referrals.convertedLabel')}
95
- amount={balances.converted}
96
- currency={currency}
97
- />
98
- <BalanceTile
99
- label={t('referrals.paidLabel')}
100
- amount={balances.paid}
101
- currency={currency}
102
- />
103
- </div>
93
+ {enrolled ? (
94
+ <div>
95
+ <AccountCard className="sxa:p-6 sxa:md:p-7">
96
+ <p className="sxa:text-small sxa:text-muted">{t('referrals.availableLabel')}</p>
97
+ <p className="sxa:mt-1 sxa:text-h1 sxa:font-bold sxa:tabular-nums">
98
+ {formatAccountAmount(balances.available, currency)}
99
+ </p>
100
+ <p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('referrals.payoutsElsewhere')}</p>
101
+ </AccountCard>
104
102
 
105
- {isForeignBalanceCurrency(summary, state.data.shop.currency) ? (
106
- <p className="sxa:mt-3 sxa:text-small sxa:text-muted">
107
- {t('referrals.differentCurrency', { currency })}
108
- </p>
109
- ) : null}
110
- </div>
103
+ {/*
104
+ Four separate figures, exactly as the server sends them. Folding
105
+ "converted" and "paid" into one friendlier "withdrawn" total would put
106
+ a number on screen that no other Shoppex surface reports.
107
+ */}
108
+ <div className="sxa:mt-4 sxa:grid sxa:grid-cols-2 sxa:gap-4 sxa:sm:grid-cols-4">
109
+ <BalanceTile
110
+ label={t('referrals.pendingLabel')}
111
+ amount={balances.pending}
112
+ currency={currency}
113
+ />
114
+ <BalanceTile
115
+ label={t('referrals.requestedLabel')}
116
+ amount={balances.requested}
117
+ currency={currency}
118
+ />
119
+ <BalanceTile
120
+ label={t('referrals.convertedLabel')}
121
+ amount={balances.converted}
122
+ currency={currency}
123
+ />
124
+ <BalanceTile
125
+ label={t('referrals.paidLabel')}
126
+ amount={balances.paid}
127
+ currency={currency}
128
+ />
129
+ </div>
130
+
131
+ {isForeignBalanceCurrency(summary, state.data.shop.currency) ? (
132
+ <p className="sxa:mt-3 sxa:text-small sxa:text-muted">
133
+ {t('referrals.differentCurrency', { currency })}
134
+ </p>
135
+ ) : null}
136
+ </div>
137
+ ) : null}
111
138
 
112
139
  <section>
113
- <AccountSectionTitle>{t('referrals.linksTitle')}</AccountSectionTitle>
140
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
141
+ <AccountSectionTitle>{t('referrals.linksTitle')}</AccountSectionTitle>
142
+ {/* Offered only when the producer says this buyer may make a link.
143
+ A shop that refuses gets its own sentence below instead of a
144
+ control that answers 400. */}
145
+ {summary.can_create_custom_links && !creating ? (
146
+ <button
147
+ type="button"
148
+ className="sx-account-button sxa:card-button sxa:card-button--add"
149
+ onClick={() => {
150
+ setCreating(true);
151
+ setCreated(null);
152
+ }}
153
+ >
154
+ {t('referrals.newLink')}
155
+ </button>
156
+ ) : null}
157
+ </div>
114
158
  <p className="sxa:mt-1 sxa:text-small sxa:text-muted">
115
159
  {summary.affiliate_codes_accepted_at_checkout
116
160
  ? t('referrals.codesAccepted')
117
161
  : t('referrals.codesNotAccepted')}
118
162
  </p>
163
+ {!summary.can_create_custom_links && summary.custom_link_request_disabled_reason ? (
164
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{summary.custom_link_request_disabled_reason}</p>
165
+ ) : null}
166
+
167
+ {created ? (
168
+ <AccountNotice message={created === 'pending' ? t('referrals.linkRequested') : t('referrals.linkCreated')} />
169
+ ) : null}
170
+
171
+ {creating && summary.can_create_custom_links ? (
172
+ <NewReferralLinkForm
173
+ onCancel={() => setCreating(false)}
174
+ onCreated={(pending) => {
175
+ setCreating(false);
176
+ setCreated(pending ? 'pending' : 'ready');
177
+ // The new row comes from the summary, which only the dashboard
178
+ // read carries.
179
+ reload();
180
+ }}
181
+ onUnknown={reload}
182
+ />
183
+ ) : null}
184
+
119
185
  <div className="sxa:mt-4">
120
186
  {summary.links.length === 0 ? (
121
187
  <AccountEmpty
122
188
  title={t('referrals.linksEmptyTitle')}
123
- body={t('referrals.linksEmptyBody')}
189
+ body={summary.can_create_custom_links ? t('referrals.linksEmptyCanCreate') : t('referrals.linksEmptyBody')}
124
190
  />
125
191
  ) : (
126
192
  <ul className="sxa:space-y-4">
@@ -134,87 +200,246 @@ export function AccountReferrals() {
134
200
  </div>
135
201
  </section>
136
202
 
137
- <section>
138
- <AccountSectionTitle>{t('referrals.commissionsTitle')}</AccountSectionTitle>
139
- <div className="sxa:mt-4">
140
- {summary.commissions.length === 0 ? (
141
- <AccountEmpty
142
- title={t('referrals.commissionsEmptyTitle')}
143
- body={t('referrals.commissionsEmptyBody')}
203
+ {enrolled ? (
204
+ <>
205
+ <section>
206
+ <AccountSectionTitle>{t('referrals.commissionsTitle')}</AccountSectionTitle>
207
+ <div className="sxa:mt-4">
208
+ {summary.commissions.length === 0 ? (
209
+ <AccountEmpty
210
+ title={t('referrals.commissionsEmptyTitle')}
211
+ body={t('referrals.commissionsEmptyBody')}
212
+ />
213
+ ) : (
214
+ <ul className="sxa:space-y-4">
215
+ {summary.commissions.map((commission) => (
216
+ <li
217
+ key={commission.id}
218
+ className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
219
+ >
220
+ <div className="sxa:min-w-0 sxa:flex-1">
221
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">
222
+ {t('referrals.commissionFor', { id: commission.invoice_id })}
223
+ </p>
224
+ {/* `created_at` is nullable on this row. No date is printed
225
+ when the producer sent none. */}
226
+ {commission.created_at ? (
227
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
228
+ {formatAccountDate(commission.created_at)}
229
+ </p>
230
+ ) : null}
231
+ </div>
232
+ <CommissionAmount commission={commission} currency={currency} />
233
+ <StatusChip
234
+ status={commissionLedgerTone(commission.ledger_status)}
235
+ label={t(commissionLedgerKey(commission.ledger_status))}
236
+ />
237
+ </li>
238
+ ))}
239
+ </ul>
240
+ )}
241
+ </div>
242
+ </section>
243
+
244
+ <section>
245
+ <AccountSectionTitle>{t('referrals.balanceTitle')}</AccountSectionTitle>
246
+ <div className="sxa:mt-4">
247
+ {summary.claims.length === 0 ? (
248
+ <AccountEmpty
249
+ title={t('referrals.balanceEmptyTitle')}
250
+ body={t('referrals.balanceEmptyBody')}
251
+ />
252
+ ) : (
253
+ <ul className="sxa:space-y-4">
254
+ {summary.claims.map((claim) => {
255
+ const typeKey = claimTypeKey(claim.type);
256
+ return (
257
+ <li
258
+ key={claim.id}
259
+ className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
260
+ >
261
+ <div className="sxa:min-w-0 sxa:flex-1">
262
+ {/* An unlisted claim type keeps the producer's own word
263
+ rather than being renamed into one we recognise. */}
264
+ <p className="sxa:truncate sxa:text-small sxa:font-semibold">
265
+ {typeKey ? t(typeKey) : statusLabel(claim.type)}
266
+ </p>
267
+ {claim.created_at ? (
268
+ <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
269
+ {formatAccountDate(claim.created_at)}
270
+ </p>
271
+ ) : null}
272
+ </div>
273
+ <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">
274
+ {/* The claim carries its own currency; the balance
275
+ currency above is not necessarily the same one. */}
276
+ {formatAccountAmount(claim.amount, claim.currency)}
277
+ </span>
278
+ <StatusChip status={claim.status} />
279
+ </li>
280
+ );
281
+ })}
282
+ </ul>
283
+ )}
284
+ </div>
285
+ </section>
286
+ </>
287
+ ) : null}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ /**
293
+ * A link with a code of the buyer's own.
294
+ *
295
+ * The producer's sentence is shown as it arrives: it knows whether the code is
296
+ * taken, too short, or one link too many. `onUnknown` is for the answers that
297
+ * are not the producer refusing — a timeout, a 5xx — which say nothing about
298
+ * whether the link exists, so the list is read again rather than the link
299
+ * being asked for twice.
300
+ */
301
+ function NewReferralLinkForm({
302
+ onCancel,
303
+ onCreated,
304
+ onUnknown,
305
+ }: {
306
+ onCancel: () => void;
307
+ onCreated: (pending: boolean) => void;
308
+ onUnknown: () => void;
309
+ }) {
310
+ const id = useId();
311
+ const [draft, setDraft] = useState({ label: '', code: '', commission: '', discount: '' });
312
+ const [busy, setBusy] = useState(false);
313
+ const [error, setError] = useState<string | null>(null);
314
+
315
+ const set = (field: keyof typeof draft) => (event: { target: { value: string } }) => {
316
+ setDraft((current) => ({ ...current, [field]: event.target.value }));
317
+ setError(null);
318
+ };
319
+
320
+ const submit = async () => {
321
+ const parsed = referralLinkInput(draft);
322
+ if (!parsed.ok) {
323
+ setError(parsed.reason === 'required' ? t('referrals.linkRequired') : t('referrals.linkRateInvalid'));
324
+ return;
325
+ }
326
+
327
+ setBusy(true);
328
+ setError(null);
329
+ const response = await callCustomerApi((sdk) => sdk.createAffiliateLink(parsed.input));
330
+ setBusy(false);
331
+
332
+ if (!response.success || response.data === undefined) {
333
+ setError(response.message?.trim() || t('referrals.linkFailed'));
334
+ if (response.status === undefined || response.status >= 500) onUnknown();
335
+ return;
336
+ }
337
+
338
+ onCreated(response.data.affiliate_link.status === 'pending');
339
+ };
340
+
341
+ return (
342
+ <AccountCard className="sxa:mt-4 sxa:p-5">
343
+ <form
344
+ noValidate
345
+ onSubmit={(event) => {
346
+ event.preventDefault();
347
+ void submit();
348
+ }}
349
+ >
350
+ <div className="sxa:grid sxa:gap-4 sxa:sm:grid-cols-2">
351
+ <div>
352
+ <label htmlFor={`${id}-label`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
353
+ {t('referrals.linkLabel')}
354
+ </label>
355
+ <input
356
+ id={`${id}-label`}
357
+ type="text"
358
+ maxLength={100}
359
+ autoComplete="off"
360
+ value={draft.label}
361
+ disabled={busy}
362
+ placeholder={t('referrals.linkLabelPlaceholder')}
363
+ onChange={set('label')}
364
+ className={FIELD}
144
365
  />
145
- ) : (
146
- <ul className="sxa:space-y-4">
147
- {summary.commissions.map((commission) => (
148
- <li
149
- key={commission.id}
150
- className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
151
- >
152
- <div className="sxa:min-w-0 sxa:flex-1">
153
- <p className="sxa:truncate sxa:text-small sxa:font-semibold">
154
- {t('referrals.commissionFor', { id: commission.invoice_id })}
155
- </p>
156
- {/* `created_at` is nullable on this row. No date is printed
157
- when the producer sent none. */}
158
- {commission.created_at ? (
159
- <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
160
- {formatAccountDate(commission.created_at)}
161
- </p>
162
- ) : null}
163
- </div>
164
- <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">
165
- {formatAccountAmount(commission.commission_amount, currency)}
166
- </span>
167
- <StatusChip status={commission.status} />
168
- </li>
169
- ))}
170
- </ul>
171
- )}
366
+ </div>
367
+ <div>
368
+ <label htmlFor={`${id}-code`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
369
+ {t('referrals.linkCode')}
370
+ </label>
371
+ <input
372
+ id={`${id}-code`}
373
+ type="text"
374
+ maxLength={32}
375
+ autoComplete="off"
376
+ autoCapitalize="none"
377
+ spellCheck={false}
378
+ value={draft.code}
379
+ disabled={busy}
380
+ placeholder="creator-2026"
381
+ onChange={set('code')}
382
+ className={FIELD}
383
+ />
384
+ </div>
385
+ <div>
386
+ <label htmlFor={`${id}-commission`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
387
+ {t('referrals.linkCommission')}
388
+ </label>
389
+ <input
390
+ id={`${id}-commission`}
391
+ type="text"
392
+ inputMode="decimal"
393
+ autoComplete="off"
394
+ value={draft.commission}
395
+ disabled={busy}
396
+ placeholder={t('referrals.linkRatePlaceholder')}
397
+ onChange={set('commission')}
398
+ className={FIELD}
399
+ />
400
+ </div>
401
+ <div>
402
+ <label htmlFor={`${id}-discount`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
403
+ {t('referrals.linkDiscount')}
404
+ </label>
405
+ <input
406
+ id={`${id}-discount`}
407
+ type="text"
408
+ inputMode="decimal"
409
+ autoComplete="off"
410
+ value={draft.discount}
411
+ disabled={busy}
412
+ placeholder={t('referrals.linkRatePlaceholder')}
413
+ onChange={set('discount')}
414
+ className={FIELD}
415
+ />
416
+ </div>
172
417
  </div>
173
- </section>
418
+ <p className="sxa:mt-3 sxa:text-micro sxa:leading-relaxed sxa:text-muted">{t('referrals.linkRatesHint')}</p>
174
419
 
175
- <section>
176
- <AccountSectionTitle>{t('referrals.balanceTitle')}</AccountSectionTitle>
177
- <div className="sxa:mt-4">
178
- {summary.claims.length === 0 ? (
179
- <AccountEmpty
180
- title={t('referrals.balanceEmptyTitle')}
181
- body={t('referrals.balanceEmptyBody')}
182
- />
183
- ) : (
184
- <ul className="sxa:space-y-4">
185
- {summary.claims.map((claim) => {
186
- const typeKey = claimTypeKey(claim.type);
187
- return (
188
- <li
189
- key={claim.id}
190
- className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
191
- >
192
- <div className="sxa:min-w-0 sxa:flex-1">
193
- {/* An unlisted claim type keeps the producer's own word
194
- rather than being renamed into one we recognise. */}
195
- <p className="sxa:truncate sxa:text-small sxa:font-semibold">
196
- {typeKey ? t(typeKey) : statusLabel(claim.type)}
197
- </p>
198
- {claim.created_at ? (
199
- <p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
200
- {formatAccountDate(claim.created_at)}
201
- </p>
202
- ) : null}
203
- </div>
204
- <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">
205
- {/* The claim carries its own currency; the balance
206
- currency above is not necessarily the same one. */}
207
- {formatAccountAmount(claim.amount, claim.currency)}
208
- </span>
209
- <StatusChip status={claim.status} />
210
- </li>
211
- );
212
- })}
213
- </ul>
214
- )}
420
+ {error ? <AccountFieldError message={error} /> : null}
421
+
422
+ <div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
423
+ <button
424
+ type="submit"
425
+ disabled={busy}
426
+ aria-busy={busy || undefined}
427
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-9 sxa:items-center sxa:px-4 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
428
+ >
429
+ {busy ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
430
+ {busy ? t('referrals.creatingLink') : t('referrals.createLink')}
431
+ </button>
432
+ <button
433
+ type="button"
434
+ disabled={busy}
435
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
436
+ onClick={onCancel}
437
+ >
438
+ {t('common.cancel')}
439
+ </button>
215
440
  </div>
216
- </section>
217
- </div>
441
+ </form>
442
+ </AccountCard>
218
443
  );
219
444
  }
220
445
 
@@ -282,28 +507,31 @@ function ReferralLinkCard({
282
507
  <p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('referrals.awaitingApproval')}</p>
283
508
  ) : null}
284
509
 
285
- <div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
286
- <code className="sxa:min-w-0 sxa:flex-1 sxa:truncate sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro">
287
- {shareValue}
288
- </code>
289
- <div className="sxa:flex sxa:shrink-0 sxa:items-center sxa:gap-2">
290
- {shareUrl ? (
291
- <a
292
- href={shareUrl}
293
- target="_blank"
294
- rel="noopener noreferrer"
295
- className="sx-account-button sxa:card-button sxa:card-button--add"
296
- >
297
- <ExternalLinkIcon className="sxa:h-3.5 sxa:w-3.5" />
298
- {/* `downloads.openLink` is the catalog's one label for "this
299
- anchor leaves the storefront", and it is written generically
300
- enough to be it here too. */}
301
- <span className="sxa:ml-1.5">{t('downloads.openLink')}</span>
302
- </a>
303
- ) : null}
304
- <CopyButton value={shareValue} />
510
+ {/* Only a live link is handed out; the chip above names any other state. */}
511
+ {isShareableLink(link) ? (
512
+ <div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
513
+ <code className="sxa:min-w-0 sxa:flex-1 sxa:truncate sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro">
514
+ {shareValue}
515
+ </code>
516
+ <div className="sxa:flex sxa:shrink-0 sxa:items-center sxa:gap-2">
517
+ {shareUrl ? (
518
+ <a
519
+ href={shareUrl}
520
+ target="_blank"
521
+ rel="noopener noreferrer"
522
+ className="sx-account-button sxa:card-button sxa:card-button--add"
523
+ >
524
+ <ExternalLinkIcon className="sxa:h-3.5 sxa:w-3.5" />
525
+ {/* `downloads.openLink` is the catalog's one label for "this
526
+ anchor leaves the storefront", and it is written generically
527
+ enough to be it here too. */}
528
+ <span className="sxa:ml-1.5">{t('downloads.openLink')}</span>
529
+ </a>
530
+ ) : null}
531
+ <CopyButton value={shareValue} />
532
+ </div>
305
533
  </div>
306
- </div>
534
+ ) : null}
307
535
 
308
536
  <dl className="sxa:mt-4 sxa:grid sxa:grid-cols-3 sxa:gap-4 sxa:border-t sxa:pt-4">
309
537
  <StatCell label={t('referrals.clicks')} value={String(link.total_clicks)} />
@@ -325,3 +553,21 @@ function StatCell({ label, value }: { label: string; value: string }) {
325
553
  </div>
326
554
  );
327
555
  }
556
+
557
+ type SdkAffiliateCommission = SdkCustomerAffiliateSummary['commissions'][number];
558
+
559
+ /** The commission's lead figure, with the total and each other destination named under it. */
560
+ function CommissionAmount({ commission, currency }: { commission: SdkAffiliateCommission; currency: string }) {
561
+ const { amount, total, paidOut, storeCredit } = commissionAmounts(commission);
562
+ const note = [
563
+ total !== null ? t('referrals.ofTotal', { total: formatAccountAmount(total, currency) }) : null,
564
+ paidOut !== null ? t('referrals.paidOutPart', { amount: formatAccountAmount(paidOut, currency) }) : null,
565
+ storeCredit !== null ? t('referrals.storeCreditPart', { amount: formatAccountAmount(storeCredit, currency) }) : null,
566
+ ].filter((part) => part !== null).join(' · ');
567
+ return (
568
+ <span className="sxa:shrink-0 sxa:text-right sxa:text-small sxa:font-semibold sxa:tabular-nums">
569
+ <span className="sxa:block">{formatAccountAmount(amount, currency)}</span>
570
+ {note ? <span className="sxa:mt-1 sxa:block sxa:text-micro sxa:font-normal sxa:text-muted">{note}</span> : null}
571
+ </span>
572
+ );
573
+ }