@shoppexio/storefront-react 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/dist/account.css +4 -8
- package/dist/account.js +2387 -942
- package/dist/account.js.map +1 -1
- package/dist/{chunk-ZEFZPUAW.js → chunk-NBOIS53R.js} +2 -1
- package/dist/chunk-NBOIS53R.js.map +1 -0
- package/dist/index.d.ts +243 -29
- package/dist/index.js +221 -77
- package/dist/index.js.map +1 -1
- package/eject/account/account-availability.ts +4 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-referrals.ts +249 -7
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/dashboard.tsx +1 -0
- package/eject/account/downloads.tsx +73 -4
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +94 -31
- package/eject/account/messages/de.json +94 -31
- package/eject/account/messages/en.json +94 -31
- package/eject/account/messages/es.json +94 -31
- package/eject/account/messages/fi.json +94 -31
- package/eject/account/messages/fr.json +94 -31
- package/eject/account/messages/hi.json +94 -31
- package/eject/account/messages/it.json +94 -31
- package/eject/account/messages/lt.json +94 -31
- package/eject/account/messages/nl.json +94 -31
- package/eject/account/messages/pl.json +94 -31
- package/eject/account/messages/pt.json +94 -31
- package/eject/account/messages/ru.json +94 -31
- package/eject/account/messages/tr.json +94 -31
- package/eject/account/messages/uk.json +94 -31
- package/eject/account/messages/zh.json +94 -31
- package/eject/account/orders.tsx +141 -0
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +444 -156
- package/eject/account/reseller.tsx +59 -306
- package/eject/account/rewards.tsx +43 -6
- package/eject/account/settings.tsx +98 -2
- package/eject/account/shared.tsx +42 -3
- package/eject/account/subscriptions.tsx +75 -16
- package/package.json +7 -4
- package/dist/chunk-ZEFZPUAW.js.map +0 -1
|
@@ -1,34 +1,49 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What the buyer has earned by sending other buyers to this shop.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
+
linkCommissionDiffers,
|
|
28
|
+
offersReferralLinks,
|
|
29
|
+
referralLinkInput,
|
|
30
|
+
referralRateRows,
|
|
19
31
|
referralShareUrl,
|
|
20
32
|
} from './account-referrals';
|
|
21
|
-
import { safeExternalHref } from './customer-account';
|
|
22
|
-
import type { SdkCustomerAffiliateLink } from '@shoppexio/storefront-react';
|
|
23
|
-
import { t } from './i18n';
|
|
33
|
+
import { callCustomerApi, safeExternalHref } from './customer-account';
|
|
34
|
+
import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateRates, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
|
|
35
|
+
import { getActiveLocale, t } from './i18n';
|
|
24
36
|
import { ExternalLinkIcon } from './icons';
|
|
25
37
|
import { useAccountDashboard } from './dashboard';
|
|
26
38
|
import {
|
|
27
39
|
AccountCard,
|
|
28
40
|
AccountEmpty,
|
|
29
41
|
AccountError,
|
|
42
|
+
AccountFieldError,
|
|
43
|
+
AccountNotice,
|
|
30
44
|
AccountRowsSkeleton,
|
|
31
45
|
AccountSectionTitle,
|
|
46
|
+
BusySpinner,
|
|
32
47
|
CopyButton,
|
|
33
48
|
StatusChip,
|
|
34
49
|
formatAccountAmount,
|
|
@@ -36,8 +51,15 @@ import {
|
|
|
36
51
|
statusLabel,
|
|
37
52
|
} from './shared';
|
|
38
53
|
|
|
54
|
+
/** The settings form's control skin, so the two forms on this account match. */
|
|
55
|
+
const FIELD =
|
|
56
|
+
'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';
|
|
57
|
+
|
|
39
58
|
export function AccountReferrals() {
|
|
40
59
|
const { state, reload } = useAccountDashboard();
|
|
60
|
+
const [creating, setCreating] = useState(false);
|
|
61
|
+
/** What the last made link was told to be: live, or waiting for the shop. */
|
|
62
|
+
const [created, setCreated] = useState<'ready' | 'pending' | null>(null);
|
|
41
63
|
|
|
42
64
|
if (state.status === 'loading') {
|
|
43
65
|
return <AccountRowsSkeleton rows={3} label={t('referrals.loading')} />;
|
|
@@ -54,79 +76,127 @@ export function AccountReferrals() {
|
|
|
54
76
|
* hid the whole tab on it; this template keeps the tab reachable — the link
|
|
55
77
|
* may already be sitting in a sent email — and says what the state is instead
|
|
56
78
|
* of showing a balance sheet full of zeroes.
|
|
79
|
+
*
|
|
80
|
+
* A shop with auto-join off reports `not_enrolled` and still lets the buyer
|
|
81
|
+
* make a first link, which is how they join. That buyer gets the links
|
|
82
|
+
* section; the balance and history belong to enrolled buyers only.
|
|
57
83
|
*/
|
|
58
|
-
if (!
|
|
84
|
+
if (!offersReferralLinks(summary)) {
|
|
59
85
|
return (
|
|
60
86
|
<AccountEmpty title={t('referrals.notEnrolledTitle')} body={t('referrals.notEnrolledBody')} />
|
|
61
87
|
);
|
|
62
88
|
}
|
|
63
89
|
|
|
90
|
+
const enrolled = isEnrolledInReferrals(summary);
|
|
64
91
|
const { balances, balance_currency: currency } = summary;
|
|
65
92
|
|
|
66
93
|
return (
|
|
67
94
|
<div className="sxa:space-y-8">
|
|
68
|
-
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
95
|
+
{enrolled ? (
|
|
96
|
+
<div>
|
|
97
|
+
<AccountCard className="sxa:p-6 sxa:md:p-7">
|
|
98
|
+
<p className="sxa:text-small sxa:text-muted">{t('referrals.availableLabel')}</p>
|
|
99
|
+
<p className="sxa:mt-1 sxa:text-h1 sxa:font-bold sxa:tabular-nums">
|
|
100
|
+
{formatAccountAmount(balances.available, currency)}
|
|
101
|
+
</p>
|
|
102
|
+
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('referrals.payoutsElsewhere')}</p>
|
|
103
|
+
</AccountCard>
|
|
104
|
+
|
|
105
|
+
{/*
|
|
106
|
+
Four separate figures, exactly as the server sends them. Folding
|
|
107
|
+
"converted" and "paid" into one friendlier "withdrawn" total would put
|
|
108
|
+
a number on screen that no other Shoppex surface reports.
|
|
109
|
+
*/}
|
|
110
|
+
<div className="sxa:mt-4 sxa:grid sxa:grid-cols-2 sxa:gap-4 sxa:sm:grid-cols-4">
|
|
111
|
+
<BalanceTile
|
|
112
|
+
label={t('referrals.pendingLabel')}
|
|
113
|
+
amount={balances.pending}
|
|
114
|
+
currency={currency}
|
|
115
|
+
/>
|
|
116
|
+
<BalanceTile
|
|
117
|
+
label={t('referrals.requestedLabel')}
|
|
118
|
+
amount={balances.requested}
|
|
119
|
+
currency={currency}
|
|
120
|
+
/>
|
|
121
|
+
<BalanceTile
|
|
122
|
+
label={t('referrals.convertedLabel')}
|
|
123
|
+
amount={balances.converted}
|
|
124
|
+
currency={currency}
|
|
125
|
+
/>
|
|
126
|
+
<BalanceTile
|
|
127
|
+
label={t('referrals.paidLabel')}
|
|
128
|
+
amount={balances.paid}
|
|
129
|
+
currency={currency}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{isForeignBalanceCurrency(summary, state.data.shop.currency) ? (
|
|
134
|
+
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">
|
|
135
|
+
{t('referrals.differentCurrency', { currency })}
|
|
136
|
+
</p>
|
|
137
|
+
) : null}
|
|
103
138
|
</div>
|
|
139
|
+
) : null}
|
|
104
140
|
|
|
105
|
-
|
|
106
|
-
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">
|
|
107
|
-
{t('referrals.differentCurrency', { currency })}
|
|
108
|
-
</p>
|
|
109
|
-
) : null}
|
|
110
|
-
</div>
|
|
141
|
+
{summary.rates ? <YourRates rates={summary.rates} /> : null}
|
|
111
142
|
|
|
112
143
|
<section>
|
|
113
|
-
<
|
|
144
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3">
|
|
145
|
+
<AccountSectionTitle>{t('referrals.linksTitle')}</AccountSectionTitle>
|
|
146
|
+
{/* Offered only when the producer says this buyer may make a link.
|
|
147
|
+
A shop that refuses gets its own sentence below instead of a
|
|
148
|
+
control that answers 400. */}
|
|
149
|
+
{summary.can_create_custom_links && !creating ? (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className="sx-account-button sxa:card-button sxa:card-button--add"
|
|
153
|
+
onClick={() => {
|
|
154
|
+
setCreating(true);
|
|
155
|
+
setCreated(null);
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{t('referrals.newLink')}
|
|
159
|
+
</button>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
114
162
|
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">
|
|
115
163
|
{summary.affiliate_codes_accepted_at_checkout
|
|
116
164
|
? t('referrals.codesAccepted')
|
|
117
165
|
: t('referrals.codesNotAccepted')}
|
|
118
166
|
</p>
|
|
167
|
+
{!summary.can_create_custom_links && summary.custom_link_request_disabled_reason ? (
|
|
168
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">{summary.custom_link_request_disabled_reason}</p>
|
|
169
|
+
) : null}
|
|
170
|
+
|
|
171
|
+
{created ? (
|
|
172
|
+
<AccountNotice message={created === 'pending' ? t('referrals.linkRequested') : t('referrals.linkCreated')} />
|
|
173
|
+
) : null}
|
|
174
|
+
|
|
175
|
+
{creating && summary.can_create_custom_links ? (
|
|
176
|
+
<NewReferralLinkForm
|
|
177
|
+
onCancel={() => setCreating(false)}
|
|
178
|
+
onCreated={(pending) => {
|
|
179
|
+
setCreating(false);
|
|
180
|
+
setCreated(pending ? 'pending' : 'ready');
|
|
181
|
+
// The new row comes from the summary, which only the dashboard
|
|
182
|
+
// read carries.
|
|
183
|
+
reload();
|
|
184
|
+
}}
|
|
185
|
+
onUnknown={reload}
|
|
186
|
+
/>
|
|
187
|
+
) : null}
|
|
188
|
+
|
|
119
189
|
<div className="sxa:mt-4">
|
|
120
190
|
{summary.links.length === 0 ? (
|
|
121
191
|
<AccountEmpty
|
|
122
192
|
title={t('referrals.linksEmptyTitle')}
|
|
123
|
-
body={t('referrals.linksEmptyBody')}
|
|
193
|
+
body={summary.can_create_custom_links ? t('referrals.linksEmptyCanCreate') : t('referrals.linksEmptyBody')}
|
|
124
194
|
/>
|
|
125
195
|
) : (
|
|
126
196
|
<ul className="sxa:space-y-4">
|
|
127
197
|
{summary.links.map((link) => (
|
|
128
198
|
<li key={link.id}>
|
|
129
|
-
<ReferralLinkCard link={link} currency={currency} />
|
|
199
|
+
<ReferralLinkCard link={link} currency={currency} rates={summary.rates} />
|
|
130
200
|
</li>
|
|
131
201
|
))}
|
|
132
202
|
</ul>
|
|
@@ -134,87 +204,246 @@ export function AccountReferrals() {
|
|
|
134
204
|
</div>
|
|
135
205
|
</section>
|
|
136
206
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
207
|
+
{enrolled ? (
|
|
208
|
+
<>
|
|
209
|
+
<section>
|
|
210
|
+
<AccountSectionTitle>{t('referrals.commissionsTitle')}</AccountSectionTitle>
|
|
211
|
+
<div className="sxa:mt-4">
|
|
212
|
+
{summary.commissions.length === 0 ? (
|
|
213
|
+
<AccountEmpty
|
|
214
|
+
title={t('referrals.commissionsEmptyTitle')}
|
|
215
|
+
body={t('referrals.commissionsEmptyBody')}
|
|
216
|
+
/>
|
|
217
|
+
) : (
|
|
218
|
+
<ul className="sxa:space-y-4">
|
|
219
|
+
{summary.commissions.map((commission) => (
|
|
220
|
+
<li
|
|
221
|
+
key={commission.id}
|
|
222
|
+
className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
|
|
223
|
+
>
|
|
224
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
225
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">
|
|
226
|
+
{t('referrals.commissionFor', { id: commission.invoice_id })}
|
|
227
|
+
</p>
|
|
228
|
+
{/* `created_at` is nullable on this row. No date is printed
|
|
229
|
+
when the producer sent none. */}
|
|
230
|
+
{commission.created_at ? (
|
|
231
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
232
|
+
{formatAccountDate(commission.created_at)}
|
|
233
|
+
</p>
|
|
234
|
+
) : null}
|
|
235
|
+
</div>
|
|
236
|
+
<CommissionAmount commission={commission} currency={currency} />
|
|
237
|
+
<StatusChip
|
|
238
|
+
status={commissionLedgerTone(commission.ledger_status)}
|
|
239
|
+
label={t(commissionLedgerKey(commission.ledger_status))}
|
|
240
|
+
/>
|
|
241
|
+
</li>
|
|
242
|
+
))}
|
|
243
|
+
</ul>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
</section>
|
|
247
|
+
|
|
248
|
+
<section>
|
|
249
|
+
<AccountSectionTitle>{t('referrals.balanceTitle')}</AccountSectionTitle>
|
|
250
|
+
<div className="sxa:mt-4">
|
|
251
|
+
{summary.claims.length === 0 ? (
|
|
252
|
+
<AccountEmpty
|
|
253
|
+
title={t('referrals.balanceEmptyTitle')}
|
|
254
|
+
body={t('referrals.balanceEmptyBody')}
|
|
255
|
+
/>
|
|
256
|
+
) : (
|
|
257
|
+
<ul className="sxa:space-y-4">
|
|
258
|
+
{summary.claims.map((claim) => {
|
|
259
|
+
const typeKey = claimTypeKey(claim.type);
|
|
260
|
+
return (
|
|
261
|
+
<li
|
|
262
|
+
key={claim.id}
|
|
263
|
+
className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
|
|
264
|
+
>
|
|
265
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
266
|
+
{/* An unlisted claim type keeps the producer's own word
|
|
267
|
+
rather than being renamed into one we recognise. */}
|
|
268
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">
|
|
269
|
+
{typeKey ? t(typeKey) : statusLabel(claim.type)}
|
|
270
|
+
</p>
|
|
271
|
+
{claim.created_at ? (
|
|
272
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
273
|
+
{formatAccountDate(claim.created_at)}
|
|
274
|
+
</p>
|
|
275
|
+
) : null}
|
|
276
|
+
</div>
|
|
277
|
+
<span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
278
|
+
{/* The claim carries its own currency; the balance
|
|
279
|
+
currency above is not necessarily the same one. */}
|
|
280
|
+
{formatAccountAmount(claim.amount, claim.currency)}
|
|
281
|
+
</span>
|
|
282
|
+
<StatusChip status={claim.status} />
|
|
283
|
+
</li>
|
|
284
|
+
);
|
|
285
|
+
})}
|
|
286
|
+
</ul>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
</section>
|
|
290
|
+
</>
|
|
291
|
+
) : null}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A link with a code of the buyer's own.
|
|
298
|
+
*
|
|
299
|
+
* The producer's sentence is shown as it arrives: it knows whether the code is
|
|
300
|
+
* taken, too short, or one link too many. `onUnknown` is for the answers that
|
|
301
|
+
* are not the producer refusing — a timeout, a 5xx — which say nothing about
|
|
302
|
+
* whether the link exists, so the list is read again rather than the link
|
|
303
|
+
* being asked for twice.
|
|
304
|
+
*/
|
|
305
|
+
function NewReferralLinkForm({
|
|
306
|
+
onCancel,
|
|
307
|
+
onCreated,
|
|
308
|
+
onUnknown,
|
|
309
|
+
}: {
|
|
310
|
+
onCancel: () => void;
|
|
311
|
+
onCreated: (pending: boolean) => void;
|
|
312
|
+
onUnknown: () => void;
|
|
313
|
+
}) {
|
|
314
|
+
const id = useId();
|
|
315
|
+
const [draft, setDraft] = useState({ label: '', code: '', commission: '', discount: '' });
|
|
316
|
+
const [busy, setBusy] = useState(false);
|
|
317
|
+
const [error, setError] = useState<string | null>(null);
|
|
318
|
+
|
|
319
|
+
const set = (field: keyof typeof draft) => (event: { target: { value: string } }) => {
|
|
320
|
+
setDraft((current) => ({ ...current, [field]: event.target.value }));
|
|
321
|
+
setError(null);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const submit = async () => {
|
|
325
|
+
const parsed = referralLinkInput(draft);
|
|
326
|
+
if (!parsed.ok) {
|
|
327
|
+
setError(parsed.reason === 'required' ? t('referrals.linkRequired') : t('referrals.linkRateInvalid'));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
setBusy(true);
|
|
332
|
+
setError(null);
|
|
333
|
+
const response = await callCustomerApi((sdk) => sdk.createAffiliateLink(parsed.input));
|
|
334
|
+
setBusy(false);
|
|
335
|
+
|
|
336
|
+
if (!response.success || response.data === undefined) {
|
|
337
|
+
setError(response.message?.trim() || t('referrals.linkFailed'));
|
|
338
|
+
if (response.status === undefined || response.status >= 500) onUnknown();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
onCreated(response.data.affiliate_link.status === 'pending');
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<AccountCard className="sxa:mt-4 sxa:p-5">
|
|
347
|
+
<form
|
|
348
|
+
noValidate
|
|
349
|
+
onSubmit={(event) => {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
void submit();
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
<div className="sxa:grid sxa:gap-4 sxa:sm:grid-cols-2">
|
|
355
|
+
<div>
|
|
356
|
+
<label htmlFor={`${id}-label`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
357
|
+
{t('referrals.linkLabel')}
|
|
358
|
+
</label>
|
|
359
|
+
<input
|
|
360
|
+
id={`${id}-label`}
|
|
361
|
+
type="text"
|
|
362
|
+
maxLength={100}
|
|
363
|
+
autoComplete="off"
|
|
364
|
+
value={draft.label}
|
|
365
|
+
disabled={busy}
|
|
366
|
+
placeholder={t('referrals.linkLabelPlaceholder')}
|
|
367
|
+
onChange={set('label')}
|
|
368
|
+
className={FIELD}
|
|
144
369
|
/>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
370
|
+
</div>
|
|
371
|
+
<div>
|
|
372
|
+
<label htmlFor={`${id}-code`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
373
|
+
{t('referrals.linkCode')}
|
|
374
|
+
</label>
|
|
375
|
+
<input
|
|
376
|
+
id={`${id}-code`}
|
|
377
|
+
type="text"
|
|
378
|
+
maxLength={32}
|
|
379
|
+
autoComplete="off"
|
|
380
|
+
autoCapitalize="none"
|
|
381
|
+
spellCheck={false}
|
|
382
|
+
value={draft.code}
|
|
383
|
+
disabled={busy}
|
|
384
|
+
placeholder="creator-2026"
|
|
385
|
+
onChange={set('code')}
|
|
386
|
+
className={FIELD}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
<div>
|
|
390
|
+
<label htmlFor={`${id}-commission`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
391
|
+
{t('referrals.linkCommission')}
|
|
392
|
+
</label>
|
|
393
|
+
<input
|
|
394
|
+
id={`${id}-commission`}
|
|
395
|
+
type="text"
|
|
396
|
+
inputMode="decimal"
|
|
397
|
+
autoComplete="off"
|
|
398
|
+
value={draft.commission}
|
|
399
|
+
disabled={busy}
|
|
400
|
+
placeholder={t('referrals.linkRatePlaceholder')}
|
|
401
|
+
onChange={set('commission')}
|
|
402
|
+
className={FIELD}
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
<div>
|
|
406
|
+
<label htmlFor={`${id}-discount`} className="sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
|
|
407
|
+
{t('referrals.linkDiscount')}
|
|
408
|
+
</label>
|
|
409
|
+
<input
|
|
410
|
+
id={`${id}-discount`}
|
|
411
|
+
type="text"
|
|
412
|
+
inputMode="decimal"
|
|
413
|
+
autoComplete="off"
|
|
414
|
+
value={draft.discount}
|
|
415
|
+
disabled={busy}
|
|
416
|
+
placeholder={t('referrals.linkRatePlaceholder')}
|
|
417
|
+
onChange={set('discount')}
|
|
418
|
+
className={FIELD}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
172
421
|
</div>
|
|
173
|
-
|
|
422
|
+
<p className="sxa:mt-3 sxa:text-micro sxa:leading-relaxed sxa:text-muted">{t('referrals.linkRatesHint')}</p>
|
|
174
423
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
<div className="sxa:mt-4">
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
)}
|
|
424
|
+
{error ? <AccountFieldError message={error} /> : null}
|
|
425
|
+
|
|
426
|
+
<div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
427
|
+
<button
|
|
428
|
+
type="submit"
|
|
429
|
+
disabled={busy}
|
|
430
|
+
aria-busy={busy || undefined}
|
|
431
|
+
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"
|
|
432
|
+
>
|
|
433
|
+
{busy ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
434
|
+
{busy ? t('referrals.creatingLink') : t('referrals.createLink')}
|
|
435
|
+
</button>
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
disabled={busy}
|
|
439
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
440
|
+
onClick={onCancel}
|
|
441
|
+
>
|
|
442
|
+
{t('common.cancel')}
|
|
443
|
+
</button>
|
|
215
444
|
</div>
|
|
216
|
-
</
|
|
217
|
-
</
|
|
445
|
+
</form>
|
|
446
|
+
</AccountCard>
|
|
218
447
|
);
|
|
219
448
|
}
|
|
220
449
|
|
|
@@ -235,12 +464,45 @@ function BalanceTile({
|
|
|
235
464
|
);
|
|
236
465
|
}
|
|
237
466
|
|
|
467
|
+
/** "Your rates": what a referral earns, in the rows the Easy-lane account block shows. */
|
|
468
|
+
function YourRates({ rates }: { rates: SdkCustomerAffiliateRates }) {
|
|
469
|
+
const id = useId();
|
|
470
|
+
const locale = getActiveLocale();
|
|
471
|
+
const rows = referralRateRows(rates, {
|
|
472
|
+
// The cap and the minimum are kept in US dollars, like the affiliate ledger.
|
|
473
|
+
money: (usd) => formatAccountAmount(usd, 'USD'),
|
|
474
|
+
list: (names) => new Intl.ListFormat(locale, { type: 'conjunction' }).format(names),
|
|
475
|
+
more: (count) => t('referrals.ratesMoreProducts', { count: String(count) }),
|
|
476
|
+
});
|
|
477
|
+
return (
|
|
478
|
+
<section aria-labelledby={id}>
|
|
479
|
+
<h2 id={id} className="sx-account-section-title sxa:font-heading sxa:text-body sxa:font-semibold">
|
|
480
|
+
{t('referrals.yourRates')}
|
|
481
|
+
</h2>
|
|
482
|
+
<AccountCard className="sxa:mt-4 sxa:p-5">
|
|
483
|
+
<ul className="sxa:space-y-3">
|
|
484
|
+
{rows.map((row) => (
|
|
485
|
+
<li key={row.id}>
|
|
486
|
+
<p className="sxa:text-small sxa:font-semibold">{t(row.title.key, row.title.params)}</p>
|
|
487
|
+
{row.detail ? (
|
|
488
|
+
<p className="sxa:mt-0.5 sxa:text-micro sxa:text-muted">{t(row.detail.key, row.detail.params)}</p>
|
|
489
|
+
) : null}
|
|
490
|
+
</li>
|
|
491
|
+
))}
|
|
492
|
+
</ul>
|
|
493
|
+
</AccountCard>
|
|
494
|
+
</section>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
238
498
|
function ReferralLinkCard({
|
|
239
499
|
link,
|
|
240
500
|
currency,
|
|
501
|
+
rates,
|
|
241
502
|
}: {
|
|
242
503
|
link: SdkCustomerAffiliateLink;
|
|
243
504
|
currency: string;
|
|
505
|
+
rates: SdkCustomerAffiliateRates | null;
|
|
244
506
|
}) {
|
|
245
507
|
/*
|
|
246
508
|
* The shareable URL is the SERVER's — it knows the shop's own storefront
|
|
@@ -265,8 +527,13 @@ function ReferralLinkCard({
|
|
|
265
527
|
<div className="sxa:min-w-0">
|
|
266
528
|
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{link.label ?? link.code}</p>
|
|
267
529
|
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
268
|
-
{
|
|
269
|
-
{
|
|
530
|
+
{/* "Your rates" names the rate; a link says it only when it earns another one. */}
|
|
531
|
+
{linkCommissionDiffers(link, rates) ? (
|
|
532
|
+
<>
|
|
533
|
+
{t('referrals.commission', { percent: link.effective_commission_percent })}
|
|
534
|
+
{' · '}
|
|
535
|
+
</>
|
|
536
|
+
) : null}
|
|
270
537
|
{t('referrals.buyerDiscount', { percent: link.effective_customer_discount_percent })}
|
|
271
538
|
</p>
|
|
272
539
|
</div>
|
|
@@ -282,28 +549,31 @@ function ReferralLinkCard({
|
|
|
282
549
|
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('referrals.awaitingApproval')}</p>
|
|
283
550
|
) : null}
|
|
284
551
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
552
|
+
{/* Only a live link is handed out; the chip above names any other state. */}
|
|
553
|
+
{isShareableLink(link) ? (
|
|
554
|
+
<div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
555
|
+
<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">
|
|
556
|
+
{shareValue}
|
|
557
|
+
</code>
|
|
558
|
+
<div className="sxa:flex sxa:shrink-0 sxa:items-center sxa:gap-2">
|
|
559
|
+
{shareUrl ? (
|
|
560
|
+
<a
|
|
561
|
+
href={shareUrl}
|
|
562
|
+
target="_blank"
|
|
563
|
+
rel="noopener noreferrer"
|
|
564
|
+
className="sx-account-button sxa:card-button sxa:card-button--add"
|
|
565
|
+
>
|
|
566
|
+
<ExternalLinkIcon className="sxa:h-3.5 sxa:w-3.5" />
|
|
567
|
+
{/* `downloads.openLink` is the catalog's one label for "this
|
|
568
|
+
anchor leaves the storefront", and it is written generically
|
|
569
|
+
enough to be it here too. */}
|
|
570
|
+
<span className="sxa:ml-1.5">{t('downloads.openLink')}</span>
|
|
571
|
+
</a>
|
|
572
|
+
) : null}
|
|
573
|
+
<CopyButton value={shareValue} />
|
|
574
|
+
</div>
|
|
305
575
|
</div>
|
|
306
|
-
|
|
576
|
+
) : null}
|
|
307
577
|
|
|
308
578
|
<dl className="sxa:mt-4 sxa:grid sxa:grid-cols-3 sxa:gap-4 sxa:border-t sxa:pt-4">
|
|
309
579
|
<StatCell label={t('referrals.clicks')} value={String(link.total_clicks)} />
|
|
@@ -325,3 +595,21 @@ function StatCell({ label, value }: { label: string; value: string }) {
|
|
|
325
595
|
</div>
|
|
326
596
|
);
|
|
327
597
|
}
|
|
598
|
+
|
|
599
|
+
type SdkAffiliateCommission = SdkCustomerAffiliateSummary['commissions'][number];
|
|
600
|
+
|
|
601
|
+
/** The commission's lead figure, with the total and each other destination named under it. */
|
|
602
|
+
function CommissionAmount({ commission, currency }: { commission: SdkAffiliateCommission; currency: string }) {
|
|
603
|
+
const { amount, total, paidOut, storeCredit } = commissionAmounts(commission);
|
|
604
|
+
const note = [
|
|
605
|
+
total !== null ? t('referrals.ofTotal', { total: formatAccountAmount(total, currency) }) : null,
|
|
606
|
+
paidOut !== null ? t('referrals.paidOutPart', { amount: formatAccountAmount(paidOut, currency) }) : null,
|
|
607
|
+
storeCredit !== null ? t('referrals.storeCreditPart', { amount: formatAccountAmount(storeCredit, currency) }) : null,
|
|
608
|
+
].filter((part) => part !== null).join(' · ');
|
|
609
|
+
return (
|
|
610
|
+
<span className="sxa:shrink-0 sxa:text-right sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
611
|
+
<span className="sxa:block">{formatAccountAmount(amount, currency)}</span>
|
|
612
|
+
{note ? <span className="sxa:mt-1 sxa:block sxa:text-micro sxa:font-normal sxa:text-muted">{note}</span> : null}
|
|
613
|
+
</span>
|
|
614
|
+
);
|
|
615
|
+
}
|