@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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +137 -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 +75 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account overview: who is signed in, what the shop is holding for them,
|
|
3
|
+
* and the last few orders.
|
|
4
|
+
*
|
|
5
|
+
* One request carries all of it. `dashboard()` is the overview payload and the
|
|
6
|
+
* collection route for five other panels, so everything on this screen comes out
|
|
7
|
+
* of a single response — nothing is counted in the browser and nothing is filled
|
|
8
|
+
* in where the server sent nothing. A buyer never reads a figure this storefront
|
|
9
|
+
* made up.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Link } from 'react-router-dom';
|
|
13
|
+
import { accountTabHref } from '@shoppexio/storefront-react';
|
|
14
|
+
import { t } from './i18n';
|
|
15
|
+
import { ArrowRightIcon } from './icons';
|
|
16
|
+
import { OrderRow, orderRowTitle } from './orders';
|
|
17
|
+
import { useAccountDashboard } from './dashboard';
|
|
18
|
+
import {
|
|
19
|
+
AccountCard,
|
|
20
|
+
AccountEmpty,
|
|
21
|
+
AccountError,
|
|
22
|
+
AccountRowsSkeleton,
|
|
23
|
+
AccountSectionTitle,
|
|
24
|
+
formatAccountAmount,
|
|
25
|
+
formatInvoiceAmount,
|
|
26
|
+
} from './shared';
|
|
27
|
+
|
|
28
|
+
/** As many as the panel can show without becoming the orders page. */
|
|
29
|
+
const RECENT_ORDER_COUNT = 5;
|
|
30
|
+
|
|
31
|
+
export function AccountOverview() {
|
|
32
|
+
const { state, reload } = useAccountDashboard();
|
|
33
|
+
|
|
34
|
+
if (state.status === 'loading') {
|
|
35
|
+
return <AccountRowsSkeleton rows={4} label={t('overview.loading')} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (state.status === 'error') {
|
|
39
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const account = state.data;
|
|
43
|
+
const recentOrders = account.invoices.slice(0, RECENT_ORDER_COUNT);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="sxa:space-y-6">
|
|
47
|
+
<AccountCard className="sxa:px-6 sxa:py-6">
|
|
48
|
+
<p className="sxa:text-small sxa:text-muted">{t('overview.signedInAs')}</p>
|
|
49
|
+
<p className="sxa:font-heading sxa:mt-1 sxa:truncate sxa:text-h3 sxa:font-semibold">{account.customer.name}</p>
|
|
50
|
+
<p className="sxa:mt-0.5 sxa:truncate sxa:text-small sxa:text-muted">{account.customer.email}</p>
|
|
51
|
+
</AccountCard>
|
|
52
|
+
|
|
53
|
+
{/* Four counters the server keeps. Each is printed as it arrived — none of
|
|
54
|
+
them is the length of a list this page happens to be holding. */}
|
|
55
|
+
<div className="sxa:grid sxa:grid-cols-2 sxa:gap-3 sxa:sm:grid-cols-4">
|
|
56
|
+
<StatTile label={t('overview.statOrders')} value={account.stats.invoices_count} />
|
|
57
|
+
<StatTile label={t('overview.statLicenses')} value={account.stats.licenses_count} />
|
|
58
|
+
<StatTile label={t('overview.statDownloads')} value={account.stats.deliverables_count} />
|
|
59
|
+
<StatTile label={t('overview.statTickets')} value={account.stats.tickets_count} />
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/*
|
|
63
|
+
Store credit is a section, not a tab, and it exists only for shops that
|
|
64
|
+
run one: `wallet` is null otherwise, and that null IS the gate. Printing
|
|
65
|
+
a zero balance for a shop with no wallet would advertise a feature the
|
|
66
|
+
merchant never switched on.
|
|
67
|
+
|
|
68
|
+
The balance is read from this payload and never adjusted here.
|
|
69
|
+
*/}
|
|
70
|
+
{account.wallet && account.wallet.enabled ? (
|
|
71
|
+
<AccountCard className="sxa:px-6 sxa:py-6">
|
|
72
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-baseline sxa:justify-between sxa:gap-3">
|
|
73
|
+
<div>
|
|
74
|
+
<p className="sxa:text-small sxa:text-muted">{t('overview.walletLabel')}</p>
|
|
75
|
+
<p className="sxa:mt-1 sxa:text-h2 sxa:font-bold sxa:tabular-nums">
|
|
76
|
+
{formatAccountAmount(account.wallet.available, account.wallet.currency)}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
<p className="sxa:text-small sxa:text-muted">{t('overview.walletNote')}</p>
|
|
80
|
+
</div>
|
|
81
|
+
</AccountCard>
|
|
82
|
+
) : null}
|
|
83
|
+
|
|
84
|
+
<section>
|
|
85
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-4">
|
|
86
|
+
<AccountSectionTitle>{t('overview.recentOrders')}</AccountSectionTitle>
|
|
87
|
+
{recentOrders.length ? (
|
|
88
|
+
<Link
|
|
89
|
+
to={accountTabHref('orders')}
|
|
90
|
+
className="sxa:inline-flex sxa:items-center sxa:gap-1.5 sxa:text-small sxa:transition-opacity sxa:hover:opacity-80"
|
|
91
|
+
>
|
|
92
|
+
{t('overview.allOrders')}
|
|
93
|
+
<ArrowRightIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0" />
|
|
94
|
+
</Link>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="sxa:mt-4">
|
|
99
|
+
{recentOrders.length === 0 ? (
|
|
100
|
+
<AccountEmpty
|
|
101
|
+
title={t('orders.emptyTitle')}
|
|
102
|
+
body={t('orders.emptyBody')}
|
|
103
|
+
action={(
|
|
104
|
+
<Link to="/all-products" className="sx-account-button sxa:card-button sxa:card-button--add">
|
|
105
|
+
{t('common.browseStore')}
|
|
106
|
+
</Link>
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
) : (
|
|
110
|
+
<ul className="sxa:space-y-3">
|
|
111
|
+
{recentOrders.map((order) => (
|
|
112
|
+
<li key={order.uniqid}>
|
|
113
|
+
{/*
|
|
114
|
+
The dashboard spells its order rows in snake_case while the
|
|
115
|
+
orders list spells the same fields in camelCase. Both are the
|
|
116
|
+
producer's own naming, so neither is renamed on the way in —
|
|
117
|
+
the mapping happens here, at the row.
|
|
118
|
+
*/}
|
|
119
|
+
<OrderRow
|
|
120
|
+
uniqid={order.uniqid}
|
|
121
|
+
title={orderRowTitle(
|
|
122
|
+
order.line_items[0]?.product_title,
|
|
123
|
+
order.line_items.length,
|
|
124
|
+
order.uniqid,
|
|
125
|
+
)}
|
|
126
|
+
createdAt={order.created_at}
|
|
127
|
+
amount={formatInvoiceAmount(order.total_display, order.total, order.currency)}
|
|
128
|
+
status={order.status}
|
|
129
|
+
/>
|
|
130
|
+
</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</section>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function StatTile({ label, value }: { label: string; value: number }) {
|
|
141
|
+
return (
|
|
142
|
+
<div className="sxa:rounded-input sxa:border sxa:bg-bg sxa:px-4 sxa:py-4">
|
|
143
|
+
<p className="sxa:text-h2 sxa:font-bold sxa:tabular-nums">{value}</p>
|
|
144
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{label}</p>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the buyer has earned by sending other buyers to this shop.
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
claimTypeKey,
|
|
16
|
+
isAwaitingApproval,
|
|
17
|
+
isEnrolledInReferrals,
|
|
18
|
+
isForeignBalanceCurrency,
|
|
19
|
+
referralShareUrl,
|
|
20
|
+
} from './account-referrals';
|
|
21
|
+
import { safeExternalHref } from './customer-account';
|
|
22
|
+
import type { SdkCustomerAffiliateLink } from '@shoppexio/storefront-react';
|
|
23
|
+
import { t } from './i18n';
|
|
24
|
+
import { ExternalLinkIcon } from './icons';
|
|
25
|
+
import { useAccountDashboard } from './dashboard';
|
|
26
|
+
import {
|
|
27
|
+
AccountCard,
|
|
28
|
+
AccountEmpty,
|
|
29
|
+
AccountError,
|
|
30
|
+
AccountRowsSkeleton,
|
|
31
|
+
AccountSectionTitle,
|
|
32
|
+
CopyButton,
|
|
33
|
+
StatusChip,
|
|
34
|
+
formatAccountAmount,
|
|
35
|
+
formatAccountDate,
|
|
36
|
+
statusLabel,
|
|
37
|
+
} from './shared';
|
|
38
|
+
|
|
39
|
+
export function AccountReferrals() {
|
|
40
|
+
const { state, reload } = useAccountDashboard();
|
|
41
|
+
|
|
42
|
+
if (state.status === 'loading') {
|
|
43
|
+
return <AccountRowsSkeleton rows={3} label={t('referrals.loading')} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (state.status === 'error') {
|
|
47
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const summary = state.data.affiliate;
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* `not_enrolled` is the shop's answer, not an empty list. The hosted portal
|
|
54
|
+
* hid the whole tab on it; this template keeps the tab reachable — the link
|
|
55
|
+
* may already be sitting in a sent email — and says what the state is instead
|
|
56
|
+
* of showing a balance sheet full of zeroes.
|
|
57
|
+
*/
|
|
58
|
+
if (!isEnrolledInReferrals(summary)) {
|
|
59
|
+
return (
|
|
60
|
+
<AccountEmpty title={t('referrals.notEnrolledTitle')} body={t('referrals.notEnrolledBody')} />
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { balances, balance_currency: currency } = summary;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<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>
|
|
104
|
+
|
|
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>
|
|
111
|
+
|
|
112
|
+
<section>
|
|
113
|
+
<AccountSectionTitle>{t('referrals.linksTitle')}</AccountSectionTitle>
|
|
114
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">
|
|
115
|
+
{summary.affiliate_codes_accepted_at_checkout
|
|
116
|
+
? t('referrals.codesAccepted')
|
|
117
|
+
: t('referrals.codesNotAccepted')}
|
|
118
|
+
</p>
|
|
119
|
+
<div className="sxa:mt-4">
|
|
120
|
+
{summary.links.length === 0 ? (
|
|
121
|
+
<AccountEmpty
|
|
122
|
+
title={t('referrals.linksEmptyTitle')}
|
|
123
|
+
body={t('referrals.linksEmptyBody')}
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<ul className="sxa:space-y-4">
|
|
127
|
+
{summary.links.map((link) => (
|
|
128
|
+
<li key={link.id}>
|
|
129
|
+
<ReferralLinkCard link={link} currency={currency} />
|
|
130
|
+
</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</section>
|
|
136
|
+
|
|
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')}
|
|
144
|
+
/>
|
|
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
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
|
|
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
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</section>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function BalanceTile({
|
|
222
|
+
label,
|
|
223
|
+
amount,
|
|
224
|
+
currency,
|
|
225
|
+
}: {
|
|
226
|
+
label: string;
|
|
227
|
+
amount: string;
|
|
228
|
+
currency: string;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<AccountCard className="sxa:p-5">
|
|
232
|
+
<p className="sxa:text-h3 sxa:font-semibold sxa:tabular-nums">{formatAccountAmount(amount, currency)}</p>
|
|
233
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{label}</p>
|
|
234
|
+
</AccountCard>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ReferralLinkCard({
|
|
239
|
+
link,
|
|
240
|
+
currency,
|
|
241
|
+
}: {
|
|
242
|
+
link: SdkCustomerAffiliateLink;
|
|
243
|
+
currency: string;
|
|
244
|
+
}) {
|
|
245
|
+
/*
|
|
246
|
+
* The shareable URL is the SERVER's — it knows the shop's own storefront
|
|
247
|
+
* host. Assembling one from whatever origin this bundle happens to be loaded
|
|
248
|
+
* from would hand out a link that works from a preview domain and nowhere
|
|
249
|
+
* else. It is also nullable: a shop with no storefront URL to build one from
|
|
250
|
+
* gets `null`, and then there is no link row to draw — but the code beside it
|
|
251
|
+
* is still the buyer's, so that is what the row carries.
|
|
252
|
+
*
|
|
253
|
+
* `safeExternalHref` runs before anything is rendered as an anchor. The URL
|
|
254
|
+
* ultimately comes from merchant-controlled data, and `javascript:` is a URL:
|
|
255
|
+
* an anchor carrying one executes on the buyer's own origin, with their
|
|
256
|
+
* session. A scheme that does not survive the check is dropped rather than
|
|
257
|
+
* shown, so nothing invites the buyer to copy it either.
|
|
258
|
+
*/
|
|
259
|
+
const shareUrl = safeExternalHref(referralShareUrl(link));
|
|
260
|
+
const shareValue = shareUrl ?? link.code;
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<AccountCard className="sxa:p-5">
|
|
264
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
265
|
+
<div className="sxa:min-w-0">
|
|
266
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{link.label ?? link.code}</p>
|
|
267
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
268
|
+
{t('referrals.commission', { percent: link.effective_commission_percent })}
|
|
269
|
+
{' · '}
|
|
270
|
+
{t('referrals.buyerDiscount', { percent: link.effective_customer_discount_percent })}
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="sxa:flex sxa:shrink-0 sxa:items-center sxa:gap-2">
|
|
274
|
+
{link.is_primary ? (
|
|
275
|
+
<span className="sx-account-chip sxa:meta-chip sxa:meta-chip--neutral sxa:shrink-0">{t('referrals.primary')}</span>
|
|
276
|
+
) : null}
|
|
277
|
+
<StatusChip status={link.status} />
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{isAwaitingApproval(link) ? (
|
|
282
|
+
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('referrals.awaitingApproval')}</p>
|
|
283
|
+
) : null}
|
|
284
|
+
|
|
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} />
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<dl className="sxa:mt-4 sxa:grid sxa:grid-cols-3 sxa:gap-4 sxa:border-t sxa:pt-4">
|
|
309
|
+
<StatCell label={t('referrals.clicks')} value={String(link.total_clicks)} />
|
|
310
|
+
<StatCell label={t('referrals.sales')} value={String(link.total_sales)} />
|
|
311
|
+
<StatCell
|
|
312
|
+
label={t('referrals.revenue')}
|
|
313
|
+
value={formatAccountAmount(link.total_revenue, currency)}
|
|
314
|
+
/>
|
|
315
|
+
</dl>
|
|
316
|
+
</AccountCard>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function StatCell({ label, value }: { label: string; value: string }) {
|
|
321
|
+
return (
|
|
322
|
+
<div>
|
|
323
|
+
<dt className="sxa:text-micro sxa:text-muted">{label}</dt>
|
|
324
|
+
<dd className="sxa:mt-1 sxa:text-small sxa:font-semibold sxa:tabular-nums">{value}</dd>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|