@shoppexio/storefront-react 0.3.0 → 0.5.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/account.css +26 -8
  3. package/dist/account.js +3217 -1001
  4. package/dist/account.js.map +1 -1
  5. package/dist/index.d.ts +188 -14
  6. package/dist/index.js +43 -20
  7. package/dist/index.js.map +1 -1
  8. package/eject/account/account-downloads.ts +89 -1
  9. package/eject/account/account-payment-method.ts +1 -0
  10. package/eject/account/account-referrals.ts +70 -1
  11. package/eject/account/account-reseller.ts +6 -140
  12. package/eject/account/account-subscriptions.ts +158 -6
  13. package/eject/account/buyer-invoice-status.ts +100 -0
  14. package/eject/account/downloads.tsx +118 -17
  15. package/eject/account/hosted-page.ts +45 -0
  16. package/eject/account/messages/ar.json +150 -39
  17. package/eject/account/messages/de.json +150 -39
  18. package/eject/account/messages/en.json +150 -39
  19. package/eject/account/messages/es.json +150 -39
  20. package/eject/account/messages/fi.json +150 -39
  21. package/eject/account/messages/fr.json +150 -39
  22. package/eject/account/messages/hi.json +150 -39
  23. package/eject/account/messages/it.json +150 -39
  24. package/eject/account/messages/lt.json +150 -39
  25. package/eject/account/messages/nl.json +150 -39
  26. package/eject/account/messages/pl.json +150 -39
  27. package/eject/account/messages/pt.json +150 -39
  28. package/eject/account/messages/ru.json +150 -39
  29. package/eject/account/messages/tr.json +150 -39
  30. package/eject/account/messages/uk.json +150 -39
  31. package/eject/account/messages/zh.json +150 -39
  32. package/eject/account/orders.tsx +203 -41
  33. package/eject/account/overview.tsx +6 -0
  34. package/eject/account/referrals.tsx +47 -5
  35. package/eject/account/reseller.tsx +62 -308
  36. package/eject/account/rewards.tsx +5 -0
  37. package/eject/account/shared.tsx +60 -4
  38. package/eject/account/subscriptions.tsx +387 -50
  39. package/package.json +4 -4
@@ -9,10 +9,16 @@
9
9
  *
10
10
  * The payment-method portal is NOT one of them. That route answers with a
11
11
  * signed redirect into the provider's own portal, and any script sharing this
12
- * origin could fetch the link and forward it, so the lane renders no control
13
- * for it whatsoever — not a disabled one, which would promise that pressing it
14
- * would work. The buyer reaches the portal from the store's own emails, and the
15
- * panel says so rather than pretending the capability does not exist.
12
+ * origin could fetch the link and forward it. So "Update card" is a link to the
13
+ * hosted billing page on the checkout host (design Batch 7, 5b), which asks for
14
+ * its own email code before it mints the portal session; the producer's
15
+ * `can_manage_payment_method` decides whether it shows.
16
+ *
17
+ * Pausing asks first (Design Batch 4, feature 6): a small card with the lengths
18
+ * and resume dates the producer offers in `pause_options`, a reason only when
19
+ * the shop asks for one, and "Pause until {date}". A running or scheduled pause
20
+ * and the yearly limit are read from `pause` and `pause_limit`; "Don't pause"
21
+ * calls off a scheduled pause through the resume route.
16
22
  *
17
23
  * List and detail are two views of one panel. The platform defines a detail
18
24
  * path for orders and for nothing else, so a `/dashboard/subscriptions/<id>`
@@ -22,17 +28,35 @@
22
28
 
23
29
  import { useState, type ReactNode } from 'react';
24
30
  import { Link } from 'react-router-dom';
31
+ import { accountOrderHref } from '@shoppexio/storefront-react';
25
32
  import {
33
+ billingEntryDays,
34
+ billingEntryTitle,
26
35
  billingMessage,
27
36
  cancelsAtPeriodEnd,
37
+ defaultPauseLength,
38
+ endedAfterHold,
39
+ endedNoteKey,
28
40
  narrowBillingHistory,
41
+ noteIntervalKey,
42
+ openRenewalState,
43
+ pausesLeftMessage,
44
+ renewalDate,
45
+ resumeBillsToday,
46
+ type OpenRenewalState,
29
47
  subscriptionTiming,
30
48
  subscriptionTimingKey,
31
49
  } from './account-subscriptions';
32
- import { callCustomerApi } from './customer-account';
33
- import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
50
+ import { callCustomerApi, safeExternalHref } from './customer-account';
51
+ import { hostedPageHref } from './hosted-page';
52
+ import type {
53
+ SdkCustomerOpenRenewal,
54
+ SdkCustomerPauseOptions,
55
+ SdkCustomerPauseReason,
56
+ SdkCustomerSubscription,
57
+ } from '@shoppexio/storefront-react';
34
58
  import { t } from './i18n';
35
- import { LockIcon } from './icons';
59
+ import { CircleAlertIcon } from './icons';
36
60
  import { useAccountDashboard } from './dashboard';
37
61
  import {
38
62
  AccountBackButton,
@@ -43,6 +67,8 @@ import {
43
67
  AccountRowsSkeleton,
44
68
  AccountSectionTitle,
45
69
  BusySpinner,
70
+ HostedPageLink,
71
+ InvoiceStatusChip,
46
72
  StatusChip,
47
73
  formatAccountAmount,
48
74
  formatAccountDate,
@@ -125,8 +151,9 @@ export function AccountSubscriptions() {
125
151
  {formatAccountAmount(subscription.plan.price, subscription.plan.currency)}
126
152
  </span>
127
153
  ) : null}
128
- <StatusChip status={subscription.status} />
154
+ <SubscriptionStatus subscription={subscription} />
129
155
  </button>
156
+ <RenewalDue subscription={subscription} className="sxa:mt-2" />
130
157
  </li>
131
158
  ))}
132
159
  </ul>
@@ -157,14 +184,19 @@ function SubscriptionDetail({
157
184
  onBack: () => void;
158
185
  onChanged: (message: string) => void;
159
186
  }) {
160
- const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'cancel' | null>(null);
187
+ const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'unpause' | 'cancel' | null>(null);
161
188
  const [confirmingCancel, setConfirmingCancel] = useState(false);
189
+ const [pausing, setPausing] = useState(false);
162
190
  const [actionError, setActionError] = useState<string | null>(null);
163
191
 
164
192
  const atPeriodEnd = cancelsAtPeriodEnd(subscription.gateway);
193
+ // Pause opens the dialog, so it needs the lengths the producer offers; an SDK
194
+ // older than the pause policy strips them, and then nothing is offered.
195
+ const pauseOptions = subscription.actions.can_pause ? subscription.pause_options ?? null : null;
196
+ const scheduledPause = subscription.pause?.state === 'scheduled';
165
197
 
166
198
  const run = async (
167
- action: 'pause' | 'resume' | 'cancel',
199
+ action: 'resume' | 'unpause' | 'cancel',
168
200
  call: () => Promise<{ success: boolean; message?: string }>,
169
201
  doneMessage: string,
170
202
  ) => {
@@ -197,12 +229,19 @@ function SubscriptionDetail({
197
229
  {t('subscriptions.startedOn', { date: formatAccountDate(subscription.created_at) })}
198
230
  </p>
199
231
  </div>
200
- <StatusChip status={subscription.status} />
232
+ <SubscriptionStatus subscription={subscription} />
201
233
  </div>
202
234
 
203
235
  {notice ? <AccountNotice message={notice} /> : null}
204
236
  {actionError ? <div className="sxa:mt-3"><AccountError message={actionError} /></div> : null}
205
237
 
238
+ <RenewalDue subscription={subscription} className="sxa:mt-3" />
239
+ <PauseState subscription={subscription} />
240
+
241
+ {subscription.status === 'PAST_DUE' && subscription.actions.can_manage_payment_method ? (
242
+ <PastDueCard subscription={subscription} />
243
+ ) : null}
244
+
206
245
  <AccountCard className="sxa:mt-5 sxa:p-6">
207
246
  {subscription.plan ? (
208
247
  <>
@@ -232,17 +271,13 @@ function SubscriptionDetail({
232
271
  </dl>
233
272
  </AccountCard>
234
273
 
235
- {/*
236
- Where a "Manage payment method" button would have gone. The flag is true
237
- for every Stripe plan, cancelled ones included, and the route behind it
238
- answers with a signed provider URL — so this lane renders the sentence
239
- and no control at all.
240
- */}
241
274
  {subscription.actions.can_manage_payment_method ? (
242
- <p className="sxa:mt-4 sxa:flex sxa:items-start sxa:gap-2 sxa:text-small sxa:text-muted">
243
- <LockIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0" />
244
- {t('subscriptions.paymentMethodElsewhere')}
245
- </p>
275
+ <HostedPageLink
276
+ page="billing"
277
+ hints={{ subscription: subscription.uniqid }}
278
+ label={t('subscriptions.updateCard')}
279
+ className="sxa:mt-4"
280
+ />
246
281
  ) : null}
247
282
 
248
283
  {/*
@@ -251,37 +286,36 @@ function SubscriptionDetail({
251
286
  control still promises that pressing it would have worked.
252
287
  */}
253
288
  <div className="sxa:mt-6 sxa:flex sxa:flex-wrap sxa:gap-2">
254
- {subscription.actions.can_pause ? (
289
+ {pauseOptions && !pausing ? (
255
290
  <button
256
291
  type="button"
257
292
  disabled={pendingAction !== null}
258
- aria-busy={pendingAction === 'pause' || undefined}
259
293
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
260
- onClick={() => void run(
261
- 'pause',
262
- () => callCustomerApi((sdk) => sdk.pauseSubscription(subscription.uniqid)),
263
- t('subscriptions.paused'),
264
- )}
294
+ onClick={() => {
295
+ setConfirmingCancel(false);
296
+ setActionError(null);
297
+ setPausing(true);
298
+ }}
265
299
  >
266
- {pendingAction === 'pause' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
267
- {pendingAction === 'pause' ? t('subscriptions.pausing') : t('subscriptions.pause')}
300
+ {t('subscriptions.pause')}
268
301
  </button>
269
302
  ) : null}
270
303
 
304
+ {/* `can_resume` is "Resume now" on a paused plan and "Don't pause" on a scheduled pause. */}
271
305
  {subscription.actions.can_resume ? (
272
306
  <button
273
307
  type="button"
274
308
  disabled={pendingAction !== null}
275
- aria-busy={pendingAction === 'resume' || undefined}
309
+ aria-busy={pendingAction === 'resume' || pendingAction === 'unpause' || undefined}
276
310
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
277
311
  onClick={() => void run(
278
- 'resume',
312
+ scheduledPause ? 'unpause' : 'resume',
279
313
  () => callCustomerApi((sdk) => sdk.resumeSubscription(subscription.uniqid)),
280
- t('subscriptions.resumed'),
314
+ scheduledPause ? t('subscriptions.pauseCalledOff') : t('subscriptions.resumed'),
281
315
  )}
282
316
  >
283
- {pendingAction === 'resume' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
284
- {pendingAction === 'resume' ? t('subscriptions.resuming') : t('subscriptions.resume')}
317
+ {pendingAction === 'resume' || pendingAction === 'unpause' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
318
+ {pendingAction === 'resume' ? t('subscriptions.resuming') : scheduledPause ? t('subscriptions.dontPause') : t('subscriptions.resumeNow')}
285
319
  </button>
286
320
  ) : null}
287
321
 
@@ -290,13 +324,42 @@ function SubscriptionDetail({
290
324
  type="button"
291
325
  disabled={pendingAction !== null}
292
326
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
293
- onClick={() => setConfirmingCancel(true)}
327
+ onClick={() => {
328
+ setPausing(false);
329
+ setConfirmingCancel(true);
330
+ }}
294
331
  >
295
332
  {t('subscriptions.cancel')}
296
333
  </button>
297
334
  ) : null}
298
335
  </div>
299
336
 
337
+ {subscription.pause_limit ? (
338
+ <p className="sxa:mt-3 sxa:text-small sxa:text-muted">
339
+ {subscription.pause_limit.pauses_per_year === 1
340
+ ? t('subscriptions.pauseLimitOne', { date: formatAccountDate(subscription.pause_limit.available_again_at) })
341
+ : t('subscriptions.pauseLimit', {
342
+ count: subscription.pause_limit.pauses_per_year,
343
+ date: formatAccountDate(subscription.pause_limit.available_again_at),
344
+ })}
345
+ </p>
346
+ ) : null}
347
+
348
+ {pausing && pauseOptions ? (
349
+ <PauseDialog
350
+ key={subscription.uniqid}
351
+ subscription={subscription}
352
+ options={pauseOptions}
353
+ busy={pendingAction !== null}
354
+ onBusy={(busy) => setPendingAction(busy ? 'pause' : null)}
355
+ onClose={() => setPausing(false)}
356
+ onPaused={() => {
357
+ setPausing(false);
358
+ onChanged(t('subscriptions.pauseSavedNotice'));
359
+ }}
360
+ />
361
+ ) : null}
362
+
300
363
  {/*
301
364
  Cancelling asks first, and the question names what cancelling does on
302
365
  THIS gateway. PayPal cannot stop a billing agreement at the period
@@ -347,6 +410,268 @@ function SubscriptionDetail({
347
410
  );
348
411
  }
349
412
 
413
+ /**
414
+ * An open renewal, and the way to pay it: on hold, due, or invoiced ahead.
415
+ * The producer names the invoice's hosted checkout in `open_renewal`, so this
416
+ * is a plain link, never a call: paying is a purchase, and purchases carry
417
+ * their authorisation in checkout (ADR-0064). Nothing open (or a backend that
418
+ * does not send the field yet), nothing shown. A URL that fails the scheme check
419
+ * gets no anchor, only the sentence, because the payment is still due.
420
+ */
421
+ function RenewalDue({ subscription, className }: { subscription: SdkCustomerSubscription; className: string }) {
422
+ const ended = endedAfterHold(subscription);
423
+ if (ended) {
424
+ return (
425
+ <AccountCard className={`${className} sxa:px-5 sxa:py-3`}>
426
+ <p className="sxa:text-small">{t(endedNoteKey(ended))}</p>
427
+ </AccountCard>
428
+ );
429
+ }
430
+ const state = openRenewalState(subscription);
431
+ const open = subscription.open_renewal;
432
+ if (state === null || !open) return null;
433
+ const href = safeExternalHref(open.pay_url);
434
+ const { note, payLabel } = renewalCopy(subscription, state, open);
435
+
436
+ return (
437
+ <AccountCard className={`${className} sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3 sxa:px-5 sxa:py-3`}>
438
+ <p className="sxa:min-w-0 sxa:text-small">{note}</p>
439
+ {href === null ? null : (
440
+ <a
441
+ href={href}
442
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-9 sxa:shrink-0 sxa:items-center sxa:px-4 sxa:text-small sxa:font-semibold"
443
+ >
444
+ {payLabel}
445
+ </a>
446
+ )}
447
+ </AccountCard>
448
+ );
449
+ }
450
+
451
+ const PAUSE_LENGTH_KEYS: Readonly<Record<SdkCustomerPauseOptions['lengths'][number]['days'], string>> = {
452
+ 7: 'subscriptions.pauseLength7',
453
+ 14: 'subscriptions.pauseLength14',
454
+ 30: 'subscriptions.pauseLength30',
455
+ 60: 'subscriptions.pauseLength60',
456
+ 90: 'subscriptions.pauseLength90',
457
+ };
458
+
459
+ /** `SUBSCRIPTION_PAUSE_REASONS`, in the producer's order, with their buyer labels. */
460
+ const PAUSE_REASONS: ReadonlyArray<readonly [SdkCustomerPauseReason, string]> = [
461
+ ['taking_a_break', 'subscriptions.pauseReasonTakingABreak'],
462
+ ['too_expensive', 'subscriptions.pauseReasonTooExpensive'],
463
+ ['not_using', 'subscriptions.pauseReasonNotUsing'],
464
+ ['not_working', 'subscriptions.pauseReasonNotWorking'],
465
+ ['other', 'subscriptions.pauseReasonOther'],
466
+ ];
467
+
468
+ /**
469
+ * The pause dialog: the lengths the producer offers, each with its resume date, a reason
470
+ * when the shop asks for one, and what the pause means. A failure keeps the dialog and
471
+ * says nothing changed; the buyer tries again from here.
472
+ */
473
+ function PauseDialog({
474
+ subscription,
475
+ options,
476
+ busy,
477
+ onBusy,
478
+ onClose,
479
+ onPaused,
480
+ }: {
481
+ subscription: SdkCustomerSubscription;
482
+ options: SdkCustomerPauseOptions;
483
+ busy: boolean;
484
+ onBusy: (busy: boolean) => void;
485
+ onClose: () => void;
486
+ onPaused: () => void;
487
+ }) {
488
+ const [days, setDays] = useState(() => defaultPauseLength(options));
489
+ const [reason, setReason] = useState<SdkCustomerPauseReason | ''>('');
490
+ const [failed, setFailed] = useState(false);
491
+ const length = options.lengths.find((entry) => entry.days === days) ?? null;
492
+ const start = formatAccountDate(options.starts_at);
493
+ const startsLater = Date.parse(options.starts_at) > Date.now();
494
+ const left = pausesLeftMessage(options.pauses_left_after);
495
+
496
+ const confirm = async () => {
497
+ if (!length) return;
498
+ onBusy(true);
499
+ setFailed(false);
500
+ const result = await callCustomerApi((sdk) => sdk.pauseSubscription(subscription.uniqid, {
501
+ length_days: length.days,
502
+ ...(options.reason_enabled && reason !== '' ? { reason } : {}),
503
+ }));
504
+ onBusy(false);
505
+ if (!result.success) {
506
+ setFailed(true);
507
+ return;
508
+ }
509
+ onPaused();
510
+ };
511
+
512
+ return (
513
+ <AccountCard className="sxa:mt-4 sxa:p-6">
514
+ <p className="sxa:font-heading sxa:font-semibold">
515
+ {t('subscriptions.pauseTitle', { plan: subscription.plan?.name ?? t('subscriptions.unnamedPlan') })}
516
+ </p>
517
+ {startsLater ? (
518
+ <p className="sxa:mt-2 sxa:text-small sxa:text-muted">{t('subscriptions.pauseStartsAfter', { date: start })}</p>
519
+ ) : null}
520
+
521
+ <p className="sxa:mt-4 sxa:text-small sxa:font-semibold">{t('subscriptions.pauseHowLong')}</p>
522
+ <div className="sxa:mt-2 sxa:flex sxa:flex-wrap sxa:gap-2" role="radiogroup" aria-label={t('subscriptions.pauseHowLong')}>
523
+ {options.lengths.map((entry) => (
524
+ <button
525
+ key={entry.days}
526
+ type="button"
527
+ role="radio"
528
+ aria-checked={entry.days === days}
529
+ disabled={busy}
530
+ className={`sx-account-button sxa:card-button ${entry.days === days ? 'sxa:card-button--add' : ''} sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50`}
531
+ onClick={() => setDays(entry.days)}
532
+ >
533
+ {t(PAUSE_LENGTH_KEYS[entry.days])}
534
+ </button>
535
+ ))}
536
+ </div>
537
+
538
+ {options.reason_enabled ? (
539
+ <label className="sxa:mt-4 sxa:block">
540
+ <span className="sxa:text-small sxa:font-semibold">{t('subscriptions.pauseReasonLabel')}</span>
541
+ <span className="sxa:text-small sxa:text-muted"> · {t('subscriptions.pauseOptional')}</span>
542
+ <select
543
+ value={reason}
544
+ disabled={busy}
545
+ onChange={(event) => setReason(PAUSE_REASONS.find(([value]) => value === event.target.value)?.[0] ?? '')}
546
+ className="sx-account-input sxa:mt-2 sxa:block sxa:w-full sxa:rounded-input sxa:border sxa:bg-surface sxa:px-3 sxa:py-2 sxa:text-small"
547
+ >
548
+ <option value="">{t('subscriptions.pauseReasonNone')}</option>
549
+ {PAUSE_REASONS.map(([value, key]) => <option key={value} value={value}>{t(key)}</option>)}
550
+ </select>
551
+ </label>
552
+ ) : null}
553
+
554
+ {length ? (
555
+ <ul className="sxa:mt-4 sxa:list-disc sxa:space-y-1 sxa:pl-5 sxa:text-small sxa:text-muted">
556
+ {[
557
+ t('subscriptions.pauseSummary', { start, end: formatAccountDate(length.resume_at) }),
558
+ pauseAccessLine(subscription),
559
+ t('subscriptions.pauseResumeAnyTime'),
560
+ t(left.key, left.params),
561
+ ].map((line) => <li key={line}>{line}</li>)}
562
+ </ul>
563
+ ) : null}
564
+
565
+ {failed ? <div className="sxa:mt-3"><AccountError message={t('subscriptions.pauseError')} /></div> : null}
566
+
567
+ <div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
568
+ {length ? (
569
+ <button
570
+ type="button"
571
+ disabled={busy}
572
+ aria-busy={busy || undefined}
573
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-8 sxa:items-center sxa:px-4 sxa:text-micro sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
574
+ onClick={() => void confirm()}
575
+ >
576
+ {busy ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
577
+ {busy ? t('subscriptions.pausing') : t('subscriptions.pauseConfirm', { date: formatAccountDate(length.resume_at) })}
578
+ </button>
579
+ ) : null}
580
+ <button
581
+ type="button"
582
+ disabled={busy}
583
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
584
+ onClick={onClose}
585
+ >
586
+ {t('subscriptions.pauseKeep')}
587
+ </button>
588
+ </div>
589
+ </AccountCard>
590
+ );
591
+ }
592
+
593
+ /**
594
+ * A past-due Stripe plan: the last charge failed, and the card is what to
595
+ * change before the plan ends. The same hosted billing link as "Update card".
596
+ */
597
+ function PastDueCard({ subscription }: { subscription: SdkCustomerSubscription }) {
598
+ // No shop or checkout host to link to: the notice would point at nothing.
599
+ if (hostedPageHref('billing', { subscription: subscription.uniqid }) === null) return null;
600
+ return (
601
+ <AccountCard className="sx-account-past-due sxa:mt-3 sxa:flex sxa:items-start sxa:gap-3 sxa:px-5 sxa:py-4">
602
+ <CircleAlertIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-error" />
603
+ <div className="sxa:min-w-0 sxa:flex-1">
604
+ <p className="sxa:text-small sxa:font-semibold">{t('subscriptions.pastDueCardTitle')}</p>
605
+ <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{t('subscriptions.pastDueCardBody')}</p>
606
+ <HostedPageLink
607
+ page="billing"
608
+ hints={{ subscription: subscription.uniqid }}
609
+ label={t('subscriptions.updateCard')}
610
+ hint={null}
611
+ className="sxa:mt-3"
612
+ />
613
+ </div>
614
+ </AccountCard>
615
+ );
616
+ }
617
+
618
+ /** What the pause takes: the Discord roles by name when the producer knows them, else "your access". */
619
+ function pauseAccessLine(subscription: SdkCustomerSubscription): string {
620
+ const roles = subscription.pause_access_roles ?? [];
621
+ if (roles.length === 0) return t('subscriptions.pauseAccess');
622
+ return t(roles.length === 1 ? 'subscriptions.pauseAccessRole' : 'subscriptions.pauseAccessRoles', {
623
+ roles: roles.map((role) => `@${role}`).join(', '),
624
+ });
625
+ }
626
+
627
+ /** A running or scheduled pause, read from the producer's `pause`. */
628
+ function PauseState({ subscription }: { subscription: SdkCustomerSubscription }) {
629
+ const pause = subscription.pause;
630
+ if (!pause || subscription.status.toUpperCase() === 'CANCELED') return null;
631
+ let title: string;
632
+ let body: string;
633
+ if (pause.state === 'scheduled') {
634
+ title = t('subscriptions.pauseScheduled', { date: formatAccountDate(pause.starts_at) });
635
+ body = pause.resume_at === null
636
+ ? t('subscriptions.pauseScheduledBodyOpenEnded')
637
+ : t('subscriptions.pauseScheduledBody', { date: formatAccountDate(pause.resume_at) });
638
+ } else {
639
+ title = pause.resume_at === null
640
+ ? t('subscriptions.pausedOpenEnded')
641
+ : t('subscriptions.pausedUntil', { date: formatAccountDate(pause.resume_at) });
642
+ const paused = `${t('subscriptions.pausedBody')} ${pauseAccessLine(subscription)}`;
643
+ body = subscription.plan && resumeBillsToday(subscription)
644
+ ? `${paused} ${t('subscriptions.pausedPayToday', {
645
+ price: formatAccountAmount(subscription.plan.price, subscription.plan.currency),
646
+ })}`
647
+ : paused;
648
+ }
649
+ return (
650
+ <AccountCard className="sxa:mt-3 sxa:px-5 sxa:py-3">
651
+ <p className="sxa:text-small sxa:font-semibold">{title}</p>
652
+ <p className="sxa:mt-0.5 sxa:text-small sxa:text-muted">{body}</p>
653
+ </AccountCard>
654
+ );
655
+ }
656
+
657
+ /** The subscription's status, or what its open renewal makes it: on hold, or payment due. */
658
+ function SubscriptionStatus({ subscription }: { subscription: SdkCustomerSubscription }) {
659
+ const state = openRenewalState(subscription);
660
+ if (state === 'held') return <StatusChip status="PAST_DUE" label={t('subscriptions.statusOnHold')} />;
661
+ if (state === 'due') return <StatusChip status="PENDING" label={t('subscriptions.statusPaymentDue')} />;
662
+ return <StatusChip status={subscription.status} />;
663
+ }
664
+
665
+ /** The renewal note and the pay label for an open renewal. */
666
+ function renewalCopy(subscription: SdkCustomerSubscription, state: OpenRenewalState, open: SdkCustomerOpenRenewal) {
667
+ const amount = formatAccountAmount(open.amount, open.currency);
668
+ const date = formatAccountDate(open.pay_by);
669
+ const renewal = formatAccountDate(renewalDate(subscription.current_period_end));
670
+ if (state === 'held') return { note: t('subscriptions.holdNote', { amount, date, interval: t(noteIntervalKey(open, 'hold')) }), payLabel: t('subscriptions.payAndResume', { amount }) };
671
+ const note = state === 'ready' ? t('subscriptions.readyNote', { renewal, interval: t(noteIntervalKey(open, 'ready')) }) : t('subscriptions.dueNote', { renewal, date });
672
+ return { note, payLabel: t('subscriptions.payAmount', { amount }) };
673
+ }
674
+
350
675
  function DetailRow({ label, children }: { label: string; children: ReactNode }) {
351
676
  return (
352
677
  <div className="sxa:flex sxa:items-center sxa:justify-between sxa:gap-4">
@@ -380,7 +705,7 @@ function BillingHistory({ uniqid }: { uniqid: string }) {
380
705
  );
381
706
  }
382
707
 
383
- function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
708
+ export function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
384
709
  const history = narrowBillingHistory(payload);
385
710
 
386
711
  /*
@@ -401,21 +726,33 @@ function BillingHistoryList({ payload, reload }: { payload: unknown; reload: ()
401
726
  );
402
727
  }
403
728
 
729
+ // Each row says what it billed and opens its order in the account, where the receipt is (Design Batch 9, C3).
404
730
  return (
405
731
  <ul className="sxa:space-y-4">
406
- {history.invoices.map((invoice) => (
407
- <li key={invoice.uniqid} className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card">
408
- <div className="sxa:min-w-0 sxa:flex-1">
409
- <p className="sxa:truncate sxa:text-small sxa:font-semibold sxa:tabular-nums">
410
- {formatAccountAmount(invoice.total, invoice.currency)}
411
- </p>
412
- <p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">
413
- {formatAccountDate(invoice.created_at)}
414
- </p>
415
- </div>
416
- <StatusChip status={invoice.status} />
417
- </li>
418
- ))}
732
+ {history.invoices.map((invoice) => {
733
+ const title = billingEntryTitle(invoice);
734
+ const days = billingEntryDays(invoice);
735
+ return (
736
+ <li key={invoice.uniqid}>
737
+ <Link
738
+ to={accountOrderHref(invoice.uniqid)}
739
+ className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card sxa:transition-opacity sxa:hover:opacity-80"
740
+ >
741
+ <div className="sxa:min-w-0 sxa:flex-1">
742
+ <p className="sxa:text-small sxa:font-semibold">{t(title.key, title.params)}</p>
743
+ <p className="sxa:mt-1 sxa:text-micro sxa:tabular-nums sxa:text-muted">
744
+ {formatAccountDate(invoice.created_at)}
745
+ {days ? ` · ${t(days.key, days.params)}` : null}
746
+ </p>
747
+ </div>
748
+ <div className="sxa:flex sxa:shrink-0 sxa:flex-col sxa:items-end sxa:gap-1.5 sxa:sm:flex-row sxa:sm:items-center sxa:sm:gap-3">
749
+ <span className="sxa:text-small sxa:font-semibold sxa:tabular-nums">{formatAccountAmount(invoice.total, invoice.currency)}</span>
750
+ <InvoiceStatusChip status={invoice.status} />
751
+ </div>
752
+ </Link>
753
+ </li>
754
+ );
755
+ })}
419
756
  </ul>
420
757
  );
421
758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoppexio/storefront-react",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "React hooks, models and a customer account for Shoppex code storefronts",
5
5
  "type": "module",
6
6
  "homepage": "https://docs.shoppex.io/storefront/code-storefront-development",
@@ -37,7 +37,7 @@
37
37
  "build": "tsup && bun run build:css && bun run build:eject",
38
38
  "build:css": "tailwindcss --input src/account/account.css --output dist/account.css --optimize",
39
39
  "lint": "biome lint src tests scripts",
40
- "typecheck": "tsc --noEmit",
40
+ "typecheck": "tsrs --noEmit",
41
41
  "test": "bun run build:css && bun test ./tests",
42
42
  "build:eject": "bun scripts/build-eject.ts"
43
43
  },
@@ -72,8 +72,8 @@
72
72
  "@types/react": "19.2.14",
73
73
  "@types/react-dom": "19.2.3",
74
74
  "jsdom-cjs": "npm:jsdom@26.1.0",
75
- "react": "19.2.7",
76
- "react-dom": "19.2.7",
75
+ "react": "19.3.1",
76
+ "react-dom": "19.3.1",
77
77
  "react-router-dom": "7.18.1",
78
78
  "tailwindcss": "4.2.1",
79
79
  "tsup": "^8.5.0",