@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,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store credit and coupons the shop has granted this buyer.
|
|
3
|
+
*
|
|
4
|
+
* Every figure on screen is the server's own. The summary comes out of the
|
|
5
|
+
* wallet and the grant ledger already summed; adding up the activity list here
|
|
6
|
+
* would put a second number for the same money on the page, and the two would
|
|
7
|
+
* disagree the moment a grant is pending or expired.
|
|
8
|
+
*
|
|
9
|
+
* A coupon is copied and pasted at checkout and the store credit a grant paid
|
|
10
|
+
* out is applied there — the wallet routes are not open to storefront code, so
|
|
11
|
+
* there is no balance to move from this page.
|
|
12
|
+
*
|
|
13
|
+
* Points are the exception, and the only one: the loyalty route offers a
|
|
14
|
+
* conversion INTO store credit, which is why this panel has a control that
|
|
15
|
+
* writes at all. It is a separate read from the dashboard payload — a shop
|
|
16
|
+
* running no points programme answers `enabled: false`, and the section that
|
|
17
|
+
* depends on that answer must not hold up the coupons above it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState, type ReactNode } from 'react';
|
|
21
|
+
import { Link } from 'react-router-dom';
|
|
22
|
+
import {
|
|
23
|
+
loyaltyDate,
|
|
24
|
+
loyaltyLifetimePoints,
|
|
25
|
+
loyaltyPointsBalance,
|
|
26
|
+
loyaltyTransactionKey,
|
|
27
|
+
redeemOffer,
|
|
28
|
+
redeemPreview,
|
|
29
|
+
redeemSubmissionKey,
|
|
30
|
+
resolveRedeemKey,
|
|
31
|
+
signedPoints,
|
|
32
|
+
type RedeemOffer,
|
|
33
|
+
} from './account-loyalty';
|
|
34
|
+
import {
|
|
35
|
+
epochSecondsToDate,
|
|
36
|
+
optionalEpochSecondsToDate,
|
|
37
|
+
rewardReasonKey,
|
|
38
|
+
rewardStatusKey,
|
|
39
|
+
rewardTitle,
|
|
40
|
+
usableCoupons,
|
|
41
|
+
type UsableCoupon,
|
|
42
|
+
} from './account-rewards';
|
|
43
|
+
import { callCustomerApi, type AccountPanelState } from './customer-account';
|
|
44
|
+
import type { SdkCustomerLoyalty, SdkCustomerRewardActivity } from '@shoppexio/storefront-react';
|
|
45
|
+
import { t } from './i18n';
|
|
46
|
+
import { GiftIcon, TagIcon, ZapIcon } from './icons';
|
|
47
|
+
import { useAccountDashboard } from './dashboard';
|
|
48
|
+
import {
|
|
49
|
+
AccountCard,
|
|
50
|
+
AccountEmpty,
|
|
51
|
+
AccountError,
|
|
52
|
+
AccountFieldError,
|
|
53
|
+
AccountNotice,
|
|
54
|
+
AccountRowsSkeleton,
|
|
55
|
+
AccountSectionTitle,
|
|
56
|
+
BusySpinner,
|
|
57
|
+
CopyButton,
|
|
58
|
+
formatAccountAmount,
|
|
59
|
+
formatAccountDate,
|
|
60
|
+
statusLabel,
|
|
61
|
+
useAccountResource,
|
|
62
|
+
} from './shared';
|
|
63
|
+
|
|
64
|
+
/** The contact form's control skin — see `components/contact/contact-form.tsx`. */
|
|
65
|
+
const FIELD =
|
|
66
|
+
'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';
|
|
67
|
+
|
|
68
|
+
export function AccountRewards() {
|
|
69
|
+
const { state, reload } = useAccountDashboard();
|
|
70
|
+
const loyalty = useAccountResource('loyalty', () =>
|
|
71
|
+
callCustomerApi((sdk) => sdk.loyalty()), { keepStale: true });
|
|
72
|
+
|
|
73
|
+
if (state.status === 'loading') {
|
|
74
|
+
return <AccountRowsSkeleton rows={3} label={t('rewards.loading')} />;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (state.status === 'error') {
|
|
78
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { summary, activity } = state.data.rewards;
|
|
82
|
+
const coupons = usableCoupons(activity);
|
|
83
|
+
// A background refresh of the shell payload that failed: the tiles below
|
|
84
|
+
// kept their figures instead of the page falling to an error, but the buyer
|
|
85
|
+
// is told they may be out of date, with the same retry the error shows.
|
|
86
|
+
const shellStale = state.staleMessage;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="sxa:space-y-8">
|
|
90
|
+
{shellStale ? <AccountError message={shellStale} onRetry={reload} /> : null}
|
|
91
|
+
<div className="sxa:grid sxa:grid-cols-1 sxa:gap-4 sxa:sm:grid-cols-3">
|
|
92
|
+
<RewardTile
|
|
93
|
+
label={t('rewards.available')}
|
|
94
|
+
amount={summary.available}
|
|
95
|
+
currency={summary.currency}
|
|
96
|
+
/>
|
|
97
|
+
<RewardTile
|
|
98
|
+
label={t('rewards.pending')}
|
|
99
|
+
amount={summary.pending}
|
|
100
|
+
currency={summary.currency}
|
|
101
|
+
/>
|
|
102
|
+
<RewardTile
|
|
103
|
+
label={t('rewards.lifetime')}
|
|
104
|
+
amount={summary.lifetime_earned}
|
|
105
|
+
currency={summary.currency}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/*
|
|
110
|
+
The shop's currency, not the wallet's and not the rewards summary's.
|
|
111
|
+
`redeem_value_per_point` is quoted in the currency the SHOP prices in,
|
|
112
|
+
so it is the only one the preview may be multiplied into; the credited
|
|
113
|
+
amount afterwards carries the currency the redeem response names.
|
|
114
|
+
|
|
115
|
+
`reload` is the SHELL's, and it is handed down for one reason: a
|
|
116
|
+
conversion credits the wallet, and the wallet is what the three tiles
|
|
117
|
+
directly above are read from. Without it this panel says "€5.00 added
|
|
118
|
+
to your store credit" under an Available tile still showing the balance
|
|
119
|
+
before it — two numbers for the same money, disagreeing until a reload.
|
|
120
|
+
*/}
|
|
121
|
+
<LoyaltyPoints
|
|
122
|
+
resource={loyalty}
|
|
123
|
+
currency={state.data.shop.currency}
|
|
124
|
+
onCredited={reload}
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
{/*
|
|
128
|
+
Only coupons that would actually be accepted at checkout: a pending
|
|
129
|
+
grant has no code yet, a revoked one has a code the till will refuse,
|
|
130
|
+
and wallet credit is not something to paste. The narrowing decides;
|
|
131
|
+
this section simply disappears when it returns nothing.
|
|
132
|
+
*/}
|
|
133
|
+
{coupons.length ? (
|
|
134
|
+
<section>
|
|
135
|
+
<AccountSectionTitle>{t('rewards.couponsTitle')}</AccountSectionTitle>
|
|
136
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">{t('rewards.couponsBody')}</p>
|
|
137
|
+
<ul className="sxa:mt-4 sxa:space-y-4">
|
|
138
|
+
{coupons.map((coupon) => (
|
|
139
|
+
<li key={coupon.id}>
|
|
140
|
+
<CouponCard coupon={coupon} />
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
</section>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
<section>
|
|
148
|
+
<AccountSectionTitle>{t('rewards.activityTitle')}</AccountSectionTitle>
|
|
149
|
+
<div className="sxa:mt-4">
|
|
150
|
+
{activity.length === 0 ? (
|
|
151
|
+
<AccountEmpty
|
|
152
|
+
title={t('rewards.emptyTitle')}
|
|
153
|
+
body={t('rewards.emptyBody')}
|
|
154
|
+
action={(
|
|
155
|
+
<Link
|
|
156
|
+
to="/all-products"
|
|
157
|
+
className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small"
|
|
158
|
+
>
|
|
159
|
+
{t('common.browseStore')}
|
|
160
|
+
</Link>
|
|
161
|
+
)}
|
|
162
|
+
/>
|
|
163
|
+
) : (
|
|
164
|
+
<ul className="sxa:space-y-4">
|
|
165
|
+
{activity.map((item) => (
|
|
166
|
+
<li
|
|
167
|
+
key={item.id}
|
|
168
|
+
className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
|
|
169
|
+
>
|
|
170
|
+
<span className="sxa:flex sxa:h-10 sxa:w-10 sxa:shrink-0 sxa:items-center sxa:justify-center sxa:rounded-input sxa:border sxa:bg-bg">
|
|
171
|
+
<GiftIcon className="sxa:h-4 sxa:w-4 sxa:text-muted" />
|
|
172
|
+
</span>
|
|
173
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
174
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{rewardTitleText(item)}</p>
|
|
175
|
+
<p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">
|
|
176
|
+
{t(rewardReasonKey(item.reason))}
|
|
177
|
+
{' · '}
|
|
178
|
+
{formatAccountDate(epochSecondsToDate(item.created_at))}
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
<span className="sxa:shrink-0 sxa:text-micro sxa:font-medium sxa:text-muted">
|
|
182
|
+
{grantStatusLabel(item.status)}
|
|
183
|
+
</span>
|
|
184
|
+
</li>
|
|
185
|
+
))}
|
|
186
|
+
</ul>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The points a buyer holds, what they are worth, and how they were earned.
|
|
196
|
+
*
|
|
197
|
+
* Three things decide whether this renders at all, and none of them is known
|
|
198
|
+
* before the loyalty route answers: a shop may run no programme (`enabled`
|
|
199
|
+
* false), a buyer may have no account on one that exists (`account` null), and
|
|
200
|
+
* a shop may offer no conversion (`settings` null). Nothing is sketched in
|
|
201
|
+
* while that answer is in flight — a heading that appears and then vanishes
|
|
202
|
+
* once the shop turns out to have no points is worse than a section that
|
|
203
|
+
* arrives a moment late.
|
|
204
|
+
*
|
|
205
|
+
* A FAILED read is shown, though. It cannot be told apart from "no programme
|
|
206
|
+
* here", and staying silent would strand exactly the buyers this section
|
|
207
|
+
* exists for: the ones holding points they now cannot see.
|
|
208
|
+
*/
|
|
209
|
+
function LoyaltyPoints({
|
|
210
|
+
resource,
|
|
211
|
+
currency,
|
|
212
|
+
onCredited,
|
|
213
|
+
}: {
|
|
214
|
+
resource: { state: AccountPanelState<SdkCustomerLoyalty>; reload: () => void };
|
|
215
|
+
currency: string;
|
|
216
|
+
/** Re-read the shell's dashboard: a conversion moved the store credit it holds. */
|
|
217
|
+
onCredited: () => void;
|
|
218
|
+
}) {
|
|
219
|
+
const { state, reload } = resource;
|
|
220
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
* A conversion re-reads the route so the balance and the activity list come
|
|
224
|
+
* back from the producer rather than being patched together here — the
|
|
225
|
+
* response carries a new balance but no new activity row, and inventing one
|
|
226
|
+
* would put a made-up timestamp on the page. `useAccountResource` holds the
|
|
227
|
+
* last answer across a reload of the SAME key, so that re-read no longer
|
|
228
|
+
* empties the section and takes the "credited" notice with it one frame after
|
|
229
|
+
* the buyer pressed the button. Nothing is kept a second time here.
|
|
230
|
+
*/
|
|
231
|
+
const loyalty = state.status === 'ready' ? state.data : null;
|
|
232
|
+
|
|
233
|
+
if (loyalty === null) {
|
|
234
|
+
return state.status === 'error' ? (
|
|
235
|
+
<PointsSection>
|
|
236
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
237
|
+
</PointsSection>
|
|
238
|
+
) : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!loyalty.enabled) return null;
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* A re-read that failed kept these figures on screen — better than tearing
|
|
245
|
+
* the section down under the buyer — but they may be out of date, and the
|
|
246
|
+
* credited notice above a stale balance would read as the server confirming
|
|
247
|
+
* it. The same error the first read would show, with the same retry.
|
|
248
|
+
*/
|
|
249
|
+
const stale = state.status === 'ready' ? state.staleMessage : undefined;
|
|
250
|
+
|
|
251
|
+
const offer = redeemOffer(loyalty);
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<PointsSection>
|
|
255
|
+
<div className={`sxa:grid sxa:grid-cols-1 sxa:gap-4 ${loyalty.account ? 'sxa:sm:grid-cols-2' : ''}`}>
|
|
256
|
+
<PointsTile label={t('rewards.pointsBalance')} points={loyaltyPointsBalance(loyalty.account)} />
|
|
257
|
+
{loyalty.account ? (
|
|
258
|
+
<PointsTile
|
|
259
|
+
label={t('rewards.pointsLifetime')}
|
|
260
|
+
points={loyaltyLifetimePoints(loyalty.account)}
|
|
261
|
+
/>
|
|
262
|
+
) : null}
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{/* A programme this buyer has not started. Zero points is the true
|
|
266
|
+
balance, and this is the sentence that makes it read as a beginning
|
|
267
|
+
rather than as something that went missing. */}
|
|
268
|
+
{loyalty.account === null ? (
|
|
269
|
+
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">{t('rewards.pointsEarnHint')}</p>
|
|
270
|
+
) : null}
|
|
271
|
+
|
|
272
|
+
{stale ? <AccountError message={stale} onRetry={reload} /> : null}
|
|
273
|
+
|
|
274
|
+
{notice ? <AccountNotice message={notice} /> : null}
|
|
275
|
+
|
|
276
|
+
{/* The minimum, for a balance that cannot clear it yet. The form is gone
|
|
277
|
+
because the route would refuse it, but the figure it refuses below is
|
|
278
|
+
what the buyer is collecting towards. */}
|
|
279
|
+
{offer.kind === 'below-minimum' && loyalty.account !== null ? (
|
|
280
|
+
<p className="sxa:mt-3 sxa:text-small sxa:text-muted">
|
|
281
|
+
{t('rewards.redeemMinimum', { points: offer.minimumPoints })}
|
|
282
|
+
</p>
|
|
283
|
+
) : null}
|
|
284
|
+
|
|
285
|
+
{offer.kind === 'offered' ? (
|
|
286
|
+
<RedeemPoints
|
|
287
|
+
offer={offer}
|
|
288
|
+
currency={currency}
|
|
289
|
+
onRedeemed={(message) => {
|
|
290
|
+
setNotice(message);
|
|
291
|
+
reload();
|
|
292
|
+
onCredited();
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
) : null}
|
|
296
|
+
|
|
297
|
+
{loyalty.transactions.length ? (
|
|
298
|
+
<PointsActivity transactions={loyalty.transactions} />
|
|
299
|
+
) : null}
|
|
300
|
+
</PointsSection>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function PointsSection({ children }: { children: ReactNode }) {
|
|
305
|
+
return (
|
|
306
|
+
<section>
|
|
307
|
+
<AccountSectionTitle>{t('rewards.pointsTitle')}</AccountSectionTitle>
|
|
308
|
+
<div className="sxa:mt-4">{children}</div>
|
|
309
|
+
</section>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function PointsTile({ label, points }: { label: string; points: number }) {
|
|
314
|
+
return (
|
|
315
|
+
<AccountCard className="sxa:p-6">
|
|
316
|
+
<p className="sxa:text-h2 sxa:font-bold sxa:tabular-nums">{points}</p>
|
|
317
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{label}</p>
|
|
318
|
+
</AccountCard>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Converting points into store credit — the one control on this panel that
|
|
324
|
+
* moves anything.
|
|
325
|
+
*
|
|
326
|
+
* It asks before it acts, and the question names both figures: the points that
|
|
327
|
+
* leave the balance and roughly what arrives as credit. "Roughly" is honest —
|
|
328
|
+
* the server holds the authoritative rounding — and the amount the buyer is
|
|
329
|
+
* told afterwards is the one it actually credited, not this estimate.
|
|
330
|
+
*/
|
|
331
|
+
function RedeemPoints({
|
|
332
|
+
offer,
|
|
333
|
+
currency,
|
|
334
|
+
onRedeemed,
|
|
335
|
+
}: {
|
|
336
|
+
offer: Extract<RedeemOffer, { kind: 'offered' }>;
|
|
337
|
+
currency: string;
|
|
338
|
+
onRedeemed: (message: string) => void;
|
|
339
|
+
}) {
|
|
340
|
+
const [typed, setTyped] = useState('');
|
|
341
|
+
const [confirming, setConfirming] = useState(false);
|
|
342
|
+
const [submitting, setSubmitting] = useState(false);
|
|
343
|
+
const [error, setError] = useState<string | null>(null);
|
|
344
|
+
|
|
345
|
+
/*
|
|
346
|
+
* The seed of the conversion in hand, or '' while there is none. The
|
|
347
|
+
* submission key is DERIVED from (seed, amount) at send time, never minted
|
|
348
|
+
* per keystroke: a request that never reached the server and one the server
|
|
349
|
+
* committed before the connection dropped look identical from here, and with
|
|
350
|
+
* a derived key every path back to the same amount — retype,
|
|
351
|
+
* clear-and-repeat — is the same submission, so the retry replays instead of
|
|
352
|
+
* spending the points twice. A different amount derives a different key on
|
|
353
|
+
* its own. Only a success clears the seed, and the next intent mints a new
|
|
354
|
+
* one.
|
|
355
|
+
*
|
|
356
|
+
* A ref rather than state, minted on the first send rather than while
|
|
357
|
+
* rendering: a buyer who never converts anything makes the browser produce
|
|
358
|
+
* nothing, and minting cannot throw in the middle of a render.
|
|
359
|
+
*/
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
const preview = redeemPreview(typed, offer);
|
|
363
|
+
|
|
364
|
+
const retype = (value: string) => {
|
|
365
|
+
setTyped(value);
|
|
366
|
+
setConfirming(false);
|
|
367
|
+
setError(null);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const submit = async () => {
|
|
371
|
+
if (preview.kind !== 'ready') return;
|
|
372
|
+
|
|
373
|
+
setSubmitting(true);
|
|
374
|
+
setError(null);
|
|
375
|
+
try {
|
|
376
|
+
// Remembered per amount at MODULE level until that amount answers
|
|
377
|
+
// success (see account-loyalty.ts): the memory has to survive this
|
|
378
|
+
// component unmounting on a tab switch, or a committed-but-unanswered
|
|
379
|
+
// conversion would debit twice on the retry after coming back.
|
|
380
|
+
const idempotencyKey = await redeemSubmissionKey(preview.points);
|
|
381
|
+
const response = await callCustomerApi((sdk) => sdk.redeemLoyaltyPoints({
|
|
382
|
+
points: preview.points,
|
|
383
|
+
idempotencyKey,
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
if (!response.success || response.data === undefined) {
|
|
387
|
+
// The server's own sentence: "below the minimum", "not enough points"
|
|
388
|
+
// and "the programme was switched off" are all 400s, and only it knows
|
|
389
|
+
// which one happened.
|
|
390
|
+
setError(response.message?.trim() || t('rewards.redeemFailed'));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// `replayed` is this conversion answered a second time, not a refusal —
|
|
395
|
+
// the points were converted and the credit exists either way.
|
|
396
|
+
const credited = response.data;
|
|
397
|
+
setTyped('');
|
|
398
|
+
setConfirming(false);
|
|
399
|
+
resolveRedeemKey(preview.points);
|
|
400
|
+
onRedeemed(t('rewards.redeemDone', {
|
|
401
|
+
amount: formatAccountAmount(credited.wallet_amount, credited.currency),
|
|
402
|
+
}));
|
|
403
|
+
} catch {
|
|
404
|
+
// A browser that cannot derive the key — no Web Crypto, no secure
|
|
405
|
+
// context — is a conversion that cannot be sent, not a button stuck on
|
|
406
|
+
// "Converting…" for the rest of the session.
|
|
407
|
+
setError(t('rewards.redeemFailed'));
|
|
408
|
+
} finally {
|
|
409
|
+
setSubmitting(false);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<AccountCard className="sxa:mt-4 sxa:p-6">
|
|
415
|
+
<p className="sxa:font-heading sxa:font-semibold">{t('rewards.redeemTitle')}</p>
|
|
416
|
+
<p className="sxa:mt-2 sxa:text-small sxa:text-muted">{t('rewards.redeemBody')}</p>
|
|
417
|
+
|
|
418
|
+
<div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
419
|
+
<label htmlFor="loyalty-points" className="sxa:sr-only">{t('rewards.redeemLabel')}</label>
|
|
420
|
+
<input
|
|
421
|
+
id="loyalty-points"
|
|
422
|
+
type="number"
|
|
423
|
+
inputMode="numeric"
|
|
424
|
+
min={offer.minimumPoints}
|
|
425
|
+
max={offer.balance}
|
|
426
|
+
step={1}
|
|
427
|
+
value={typed}
|
|
428
|
+
disabled={submitting}
|
|
429
|
+
placeholder={String(offer.minimumPoints)}
|
|
430
|
+
aria-invalid={preview.kind === 'not-a-number' || preview.kind === 'zero-value' ? true : undefined}
|
|
431
|
+
onChange={(event) => retype(event.target.value)}
|
|
432
|
+
className={`${FIELD} sxa:w-36 sxa:tabular-nums`}
|
|
433
|
+
/>
|
|
434
|
+
{!confirming ? (
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
disabled={preview.kind !== 'ready' || submitting}
|
|
438
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
439
|
+
onClick={() => setConfirming(true)}
|
|
440
|
+
>
|
|
441
|
+
{t('rewards.redeemContinue')}
|
|
442
|
+
</button>
|
|
443
|
+
) : null}
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
<RedeemPreviewLine preview={preview} currency={currency} />
|
|
447
|
+
|
|
448
|
+
{/*
|
|
449
|
+
The step between typing an amount and spending it. The question repeats
|
|
450
|
+
both figures because the field above it is a number without a unit, and
|
|
451
|
+
"convert 500" and "convert 500 into €25" are different decisions.
|
|
452
|
+
*/}
|
|
453
|
+
{confirming && preview.kind === 'ready' ? (
|
|
454
|
+
<div className="sxa:mt-4 sxa:border-t sxa:pt-4">
|
|
455
|
+
<p className="sxa:text-small sxa:font-semibold">
|
|
456
|
+
{t('rewards.redeemConfirm', {
|
|
457
|
+
points: preview.points,
|
|
458
|
+
amount: formatAccountAmount(preview.amount, currency),
|
|
459
|
+
})}
|
|
460
|
+
</p>
|
|
461
|
+
<div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
462
|
+
<button
|
|
463
|
+
type="button"
|
|
464
|
+
disabled={submitting}
|
|
465
|
+
aria-busy={submitting || undefined}
|
|
466
|
+
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"
|
|
467
|
+
onClick={() => void submit()}
|
|
468
|
+
>
|
|
469
|
+
{submitting ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
470
|
+
{submitting ? t('rewards.redeemWorking') : t('rewards.redeemConfirmAction')}
|
|
471
|
+
</button>
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
disabled={submitting}
|
|
475
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
476
|
+
onClick={() => setConfirming(false)}
|
|
477
|
+
>
|
|
478
|
+
{t('common.cancel')}
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
) : null}
|
|
483
|
+
|
|
484
|
+
{error ? <AccountFieldError message={error} /> : null}
|
|
485
|
+
</AccountCard>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** What the typed amount is worth, or the one thing wrong with it. */
|
|
490
|
+
function RedeemPreviewLine({
|
|
491
|
+
preview,
|
|
492
|
+
currency,
|
|
493
|
+
}: {
|
|
494
|
+
preview: ReturnType<typeof redeemPreview>;
|
|
495
|
+
currency: string;
|
|
496
|
+
}) {
|
|
497
|
+
if (preview.kind === 'empty') return null;
|
|
498
|
+
|
|
499
|
+
if (preview.kind === 'ready') {
|
|
500
|
+
return (
|
|
501
|
+
<p className="sxa:mt-2.5 sxa:text-small sxa:text-muted">
|
|
502
|
+
{t('rewards.redeemPreview', { amount: formatAccountAmount(preview.amount, currency) })}
|
|
503
|
+
</p>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// An amount worth nothing is an amount the field cannot use, and it is told
|
|
508
|
+
// apart from a mistyped one by nobody: both say "this number will not do".
|
|
509
|
+
const message = preview.kind === 'not-a-number' || preview.kind === 'zero-value'
|
|
510
|
+
? t('rewards.redeemInvalid')
|
|
511
|
+
: preview.kind === 'below-minimum'
|
|
512
|
+
? t('rewards.redeemMinimum', { points: preview.minimumPoints })
|
|
513
|
+
: t('rewards.redeemOverBalance', { points: preview.balance });
|
|
514
|
+
|
|
515
|
+
return <p role="alert" className="sxa:mt-2.5 sxa:text-small sxa:text-error">{message}</p>;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function PointsActivity({ transactions }: { transactions: SdkCustomerLoyalty['transactions'] }) {
|
|
519
|
+
return (
|
|
520
|
+
<div className="sxa:mt-6">
|
|
521
|
+
<h3 className="sxa:text-micro sxa:font-semibold sxa:uppercase sxa:tracking-wider sxa:text-muted">
|
|
522
|
+
{t('rewards.pointsActivityTitle')}
|
|
523
|
+
</h3>
|
|
524
|
+
<ul className="sxa:mt-3 sxa:space-y-4">
|
|
525
|
+
{/*
|
|
526
|
+
The wire carries no id for a points row, so the position in a list the
|
|
527
|
+
server sends whole is the key. It is stable for as long as the list
|
|
528
|
+
is: a reload replaces every row at once.
|
|
529
|
+
*/}
|
|
530
|
+
{transactions.map((entry, index) => (
|
|
531
|
+
<li
|
|
532
|
+
key={`${entry.created_at}:${index}`}
|
|
533
|
+
className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
|
|
534
|
+
>
|
|
535
|
+
<span className="sxa:flex sxa:h-10 sxa:w-10 sxa:shrink-0 sxa:items-center sxa:justify-center sxa:rounded-input sxa:border sxa:bg-bg">
|
|
536
|
+
<ZapIcon className="sxa:h-4 sxa:w-4 sxa:text-muted" />
|
|
537
|
+
</span>
|
|
538
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
539
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{t(loyaltyTransactionKey(entry.type))}</p>
|
|
540
|
+
<p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">
|
|
541
|
+
{/* The shop's own note on the row, where it wrote one — the only
|
|
542
|
+
place a manual adjustment ever explains itself. */}
|
|
543
|
+
{entry.description ? `${entry.description} · ` : ''}
|
|
544
|
+
{formatAccountDate(loyaltyDate(entry.created_at))}
|
|
545
|
+
</p>
|
|
546
|
+
</div>
|
|
547
|
+
<span
|
|
548
|
+
className={`sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums ${
|
|
549
|
+
entry.points > 0 ? 'sxa:text-success' : 'sxa:text-muted'
|
|
550
|
+
}`}
|
|
551
|
+
>
|
|
552
|
+
{signedPoints(entry.points)}
|
|
553
|
+
</span>
|
|
554
|
+
</li>
|
|
555
|
+
))}
|
|
556
|
+
</ul>
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function RewardTile({
|
|
562
|
+
label,
|
|
563
|
+
amount,
|
|
564
|
+
currency,
|
|
565
|
+
}: {
|
|
566
|
+
label: string;
|
|
567
|
+
amount: string;
|
|
568
|
+
currency: string;
|
|
569
|
+
}) {
|
|
570
|
+
return (
|
|
571
|
+
<AccountCard className="sxa:p-6">
|
|
572
|
+
<p className="sxa:text-h2 sxa:font-bold sxa:tabular-nums">{formatAccountAmount(amount, currency)}</p>
|
|
573
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{label}</p>
|
|
574
|
+
</AccountCard>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function CouponCard({ coupon }: { coupon: UsableCoupon }) {
|
|
579
|
+
const expiry = optionalEpochSecondsToDate(coupon.expires_at);
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<AccountCard className="sxa:p-5">
|
|
583
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
584
|
+
<div className="sxa:flex sxa:min-w-0 sxa:items-start sxa:gap-3">
|
|
585
|
+
<TagIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
586
|
+
<div className="sxa:min-w-0">
|
|
587
|
+
<p className="sxa:text-small sxa:font-semibold">
|
|
588
|
+
{/* The producer sends a percentage only sometimes; without one
|
|
589
|
+
the coupon is named as a coupon rather than as "0% off". */}
|
|
590
|
+
{coupon.amount
|
|
591
|
+
? t('rewards.percentOff', { percent: coupon.amount })
|
|
592
|
+
: t('rewards.couponGeneric')}
|
|
593
|
+
</p>
|
|
594
|
+
{expiry ? (
|
|
595
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
596
|
+
{t('rewards.expiresOn', { date: formatAccountDate(expiry) })}
|
|
597
|
+
</p>
|
|
598
|
+
) : null}
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
<CopyButton value={coupon.coupon_code} />
|
|
602
|
+
</div>
|
|
603
|
+
<code className="sxa:mt-3 sxa:block sxa:truncate sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro">
|
|
604
|
+
{coupon.coupon_code}
|
|
605
|
+
</code>
|
|
606
|
+
</AccountCard>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** One reward as a sentence. The pure half decided WHAT it is; this spells it. */
|
|
611
|
+
function rewardTitleText(item: SdkCustomerRewardActivity): string {
|
|
612
|
+
const title = rewardTitle(item);
|
|
613
|
+
switch (title.kind) {
|
|
614
|
+
case 'store-credit-amount':
|
|
615
|
+
return t('rewards.storeCredit', {
|
|
616
|
+
amount: formatAccountAmount(title.amount, title.currency),
|
|
617
|
+
});
|
|
618
|
+
case 'store-credit':
|
|
619
|
+
return t('rewards.storeCreditGeneric');
|
|
620
|
+
case 'coupon-code':
|
|
621
|
+
return t('rewards.couponNamed', { code: title.code });
|
|
622
|
+
case 'coupon-percent':
|
|
623
|
+
return t('rewards.percentOff', { percent: title.percent });
|
|
624
|
+
case 'coupon':
|
|
625
|
+
return t('rewards.couponUnnamed');
|
|
626
|
+
case 'other':
|
|
627
|
+
return t('rewards.otherReward');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A grant state this catalog names, or the producer's own word for it.
|
|
633
|
+
*
|
|
634
|
+
* Grant status is an open set — the server may ship a state before this
|
|
635
|
+
* template has heard of it — so an unknown one is shown as the server spelled
|
|
636
|
+
* it rather than mapped onto the nearest state we do know.
|
|
637
|
+
*/
|
|
638
|
+
function grantStatusLabel(status: string): string {
|
|
639
|
+
const key = rewardStatusKey(status);
|
|
640
|
+
return key ? t(key) : statusLabel(status);
|
|
641
|
+
}
|