@shoppexio/storefront-react 0.0.0 → 0.1.1

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