@shoppexio/storefront-react 0.4.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.
@@ -14,6 +14,12 @@
14
14
  * its own email code before it mints the portal session; the producer's
15
15
  * `can_manage_payment_method` decides whether it shows.
16
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.
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>`
19
25
  * URL would be this template inventing platform route shape; the selection
@@ -22,16 +28,33 @@
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
50
  import { callCustomerApi, safeExternalHref } from './customer-account';
33
51
  import { hostedPageHref } from './hosted-page';
34
- import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
52
+ import type {
53
+ SdkCustomerOpenRenewal,
54
+ SdkCustomerPauseOptions,
55
+ SdkCustomerPauseReason,
56
+ SdkCustomerSubscription,
57
+ } from '@shoppexio/storefront-react';
35
58
  import { t } from './i18n';
36
59
  import { CircleAlertIcon } from './icons';
37
60
  import { useAccountDashboard } from './dashboard';
@@ -45,6 +68,7 @@ import {
45
68
  AccountSectionTitle,
46
69
  BusySpinner,
47
70
  HostedPageLink,
71
+ InvoiceStatusChip,
48
72
  StatusChip,
49
73
  formatAccountAmount,
50
74
  formatAccountDate,
@@ -127,7 +151,7 @@ export function AccountSubscriptions() {
127
151
  {formatAccountAmount(subscription.plan.price, subscription.plan.currency)}
128
152
  </span>
129
153
  ) : null}
130
- <StatusChip status={subscription.status} />
154
+ <SubscriptionStatus subscription={subscription} />
131
155
  </button>
132
156
  <RenewalDue subscription={subscription} className="sxa:mt-2" />
133
157
  </li>
@@ -160,14 +184,19 @@ function SubscriptionDetail({
160
184
  onBack: () => void;
161
185
  onChanged: (message: string) => void;
162
186
  }) {
163
- const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'cancel' | null>(null);
187
+ const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'unpause' | 'cancel' | null>(null);
164
188
  const [confirmingCancel, setConfirmingCancel] = useState(false);
189
+ const [pausing, setPausing] = useState(false);
165
190
  const [actionError, setActionError] = useState<string | null>(null);
166
191
 
167
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';
168
197
 
169
198
  const run = async (
170
- action: 'pause' | 'resume' | 'cancel',
199
+ action: 'resume' | 'unpause' | 'cancel',
171
200
  call: () => Promise<{ success: boolean; message?: string }>,
172
201
  doneMessage: string,
173
202
  ) => {
@@ -200,13 +229,14 @@ function SubscriptionDetail({
200
229
  {t('subscriptions.startedOn', { date: formatAccountDate(subscription.created_at) })}
201
230
  </p>
202
231
  </div>
203
- <StatusChip status={subscription.status} />
232
+ <SubscriptionStatus subscription={subscription} />
204
233
  </div>
205
234
 
206
235
  {notice ? <AccountNotice message={notice} /> : null}
207
236
  {actionError ? <div className="sxa:mt-3"><AccountError message={actionError} /></div> : null}
208
237
 
209
238
  <RenewalDue subscription={subscription} className="sxa:mt-3" />
239
+ <PauseState subscription={subscription} />
210
240
 
211
241
  {subscription.status === 'PAST_DUE' && subscription.actions.can_manage_payment_method ? (
212
242
  <PastDueCard subscription={subscription} />
@@ -256,37 +286,36 @@ function SubscriptionDetail({
256
286
  control still promises that pressing it would have worked.
257
287
  */}
258
288
  <div className="sxa:mt-6 sxa:flex sxa:flex-wrap sxa:gap-2">
259
- {subscription.actions.can_pause ? (
289
+ {pauseOptions && !pausing ? (
260
290
  <button
261
291
  type="button"
262
292
  disabled={pendingAction !== null}
263
- aria-busy={pendingAction === 'pause' || undefined}
264
293
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
265
- onClick={() => void run(
266
- 'pause',
267
- () => callCustomerApi((sdk) => sdk.pauseSubscription(subscription.uniqid)),
268
- t('subscriptions.paused'),
269
- )}
294
+ onClick={() => {
295
+ setConfirmingCancel(false);
296
+ setActionError(null);
297
+ setPausing(true);
298
+ }}
270
299
  >
271
- {pendingAction === 'pause' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
272
- {pendingAction === 'pause' ? t('subscriptions.pausing') : t('subscriptions.pause')}
300
+ {t('subscriptions.pause')}
273
301
  </button>
274
302
  ) : null}
275
303
 
304
+ {/* `can_resume` is "Resume now" on a paused plan and "Don't pause" on a scheduled pause. */}
276
305
  {subscription.actions.can_resume ? (
277
306
  <button
278
307
  type="button"
279
308
  disabled={pendingAction !== null}
280
- aria-busy={pendingAction === 'resume' || undefined}
309
+ aria-busy={pendingAction === 'resume' || pendingAction === 'unpause' || undefined}
281
310
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
282
311
  onClick={() => void run(
283
- 'resume',
312
+ scheduledPause ? 'unpause' : 'resume',
284
313
  () => callCustomerApi((sdk) => sdk.resumeSubscription(subscription.uniqid)),
285
- t('subscriptions.resumed'),
314
+ scheduledPause ? t('subscriptions.pauseCalledOff') : t('subscriptions.resumed'),
286
315
  )}
287
316
  >
288
- {pendingAction === 'resume' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
289
- {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')}
290
319
  </button>
291
320
  ) : null}
292
321
 
@@ -295,13 +324,42 @@ function SubscriptionDetail({
295
324
  type="button"
296
325
  disabled={pendingAction !== null}
297
326
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
298
- onClick={() => setConfirmingCancel(true)}
327
+ onClick={() => {
328
+ setPausing(false);
329
+ setConfirmingCancel(true);
330
+ }}
299
331
  >
300
332
  {t('subscriptions.cancel')}
301
333
  </button>
302
334
  ) : null}
303
335
  </div>
304
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
+
305
363
  {/*
306
364
  Cancelling asks first, and the question names what cancelling does on
307
365
  THIS gateway. PayPal cannot stop a billing agreement at the period
@@ -353,34 +411,185 @@ function SubscriptionDetail({
353
411
  }
354
412
 
355
413
  /**
356
- * A renewal payment that is due, and the way to pay it.
357
- *
358
- * `renewal_checkout_url` is the hosted checkout of the renewal invoice the
359
- * server already opened, so this is a plain link: following it creates
360
- * nothing, and no call is made from here. No URL means nothing is due and
361
- * nothing is shown; a URL that fails the scheme check gets no anchor, only the
362
- * sentence, because the payment is still due.
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.
363
420
  */
364
421
  function RenewalDue({ subscription, className }: { subscription: SdkCustomerSubscription; className: string }) {
365
- const url = subscription.renewal_checkout_url ?? null;
366
- if (url === null) return null;
367
- const href = safeExternalHref(url);
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);
368
435
 
369
436
  return (
370
437
  <AccountCard className={`${className} sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-3 sxa:px-5 sxa:py-3`}>
371
- <p className="sxa:min-w-0 sxa:text-small">{t('subscriptions.renewalDue')}</p>
438
+ <p className="sxa:min-w-0 sxa:text-small">{note}</p>
372
439
  {href === null ? null : (
373
440
  <a
374
441
  href={href}
375
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"
376
443
  >
377
- {t('subscriptions.payNow')}
444
+ {payLabel}
378
445
  </a>
379
446
  )}
380
447
  </AccountCard>
381
448
  );
382
449
  }
383
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
+
384
593
  /**
385
594
  * A past-due Stripe plan: the last charge failed, and the card is what to
386
595
  * change before the plan ends. The same hosted billing link as "Update card".
@@ -406,6 +615,63 @@ function PastDueCard({ subscription }: { subscription: SdkCustomerSubscription }
406
615
  );
407
616
  }
408
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
+
409
675
  function DetailRow({ label, children }: { label: string; children: ReactNode }) {
410
676
  return (
411
677
  <div className="sxa:flex sxa:items-center sxa:justify-between sxa:gap-4">
@@ -439,7 +705,7 @@ function BillingHistory({ uniqid }: { uniqid: string }) {
439
705
  );
440
706
  }
441
707
 
442
- function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
708
+ export function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
443
709
  const history = narrowBillingHistory(payload);
444
710
 
445
711
  /*
@@ -460,21 +726,33 @@ function BillingHistoryList({ payload, reload }: { payload: unknown; reload: ()
460
726
  );
461
727
  }
462
728
 
729
+ // Each row says what it billed and opens its order in the account, where the receipt is (Design Batch 9, C3).
463
730
  return (
464
731
  <ul className="sxa:space-y-4">
465
- {history.invoices.map((invoice) => (
466
- <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">
467
- <div className="sxa:min-w-0 sxa:flex-1">
468
- <p className="sxa:truncate sxa:text-small sxa:font-semibold sxa:tabular-nums">
469
- {formatAccountAmount(invoice.total, invoice.currency)}
470
- </p>
471
- <p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">
472
- {formatAccountDate(invoice.created_at)}
473
- </p>
474
- </div>
475
- <StatusChip status={invoice.status} />
476
- </li>
477
- ))}
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
+ })}
478
756
  </ul>
479
757
  );
480
758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoppexio/storefront-react",
3
- "version": "0.4.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",