@shoppexio/storefront-react 0.4.0 → 0.6.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,34 @@
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';
50
+ import { gatewayLabel } from './account-payment-method';
32
51
  import { callCustomerApi, safeExternalHref } from './customer-account';
33
52
  import { hostedPageHref } from './hosted-page';
34
- import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
53
+ import type {
54
+ SdkCustomerOpenRenewal,
55
+ SdkCustomerPauseOptions,
56
+ SdkCustomerPauseReason,
57
+ SdkCustomerSubscription,
58
+ } from '@shoppexio/storefront-react';
35
59
  import { t } from './i18n';
36
60
  import { CircleAlertIcon } from './icons';
37
61
  import { useAccountDashboard } from './dashboard';
@@ -45,6 +69,7 @@ import {
45
69
  AccountSectionTitle,
46
70
  BusySpinner,
47
71
  HostedPageLink,
72
+ InvoiceStatusChip,
48
73
  StatusChip,
49
74
  formatAccountAmount,
50
75
  formatAccountDate,
@@ -124,10 +149,10 @@ export function AccountSubscriptions() {
124
149
  dash in its place would read as "free". */}
125
150
  {subscription.plan ? (
126
151
  <span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">
127
- {formatAccountAmount(subscription.plan.price, subscription.plan.currency)}
152
+ {nextPaymentAmount(subscription)}
128
153
  </span>
129
154
  ) : null}
130
- <StatusChip status={subscription.status} />
155
+ <SubscriptionStatus subscription={subscription} />
131
156
  </button>
132
157
  <RenewalDue subscription={subscription} className="sxa:mt-2" />
133
158
  </li>
@@ -160,14 +185,19 @@ function SubscriptionDetail({
160
185
  onBack: () => void;
161
186
  onChanged: (message: string) => void;
162
187
  }) {
163
- const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'cancel' | null>(null);
188
+ const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'unpause' | 'cancel' | null>(null);
164
189
  const [confirmingCancel, setConfirmingCancel] = useState(false);
190
+ const [pausing, setPausing] = useState(false);
165
191
  const [actionError, setActionError] = useState<string | null>(null);
166
192
 
167
193
  const atPeriodEnd = cancelsAtPeriodEnd(subscription.gateway);
194
+ // Pause opens the dialog, so it needs the lengths the producer offers; an SDK
195
+ // older than the pause policy strips them, and then nothing is offered.
196
+ const pauseOptions = subscription.actions.can_pause ? subscription.pause_options ?? null : null;
197
+ const scheduledPause = subscription.pause?.state === 'scheduled';
168
198
 
169
199
  const run = async (
170
- action: 'pause' | 'resume' | 'cancel',
200
+ action: 'resume' | 'unpause' | 'cancel',
171
201
  call: () => Promise<{ success: boolean; message?: string }>,
172
202
  doneMessage: string,
173
203
  ) => {
@@ -186,6 +216,7 @@ function SubscriptionDetail({
186
216
  };
187
217
 
188
218
  const billing = subscription.plan ? billingMessage(subscription.plan) : null;
219
+ const discount = discountLine(subscription);
189
220
 
190
221
  return (
191
222
  <div>
@@ -200,13 +231,14 @@ function SubscriptionDetail({
200
231
  {t('subscriptions.startedOn', { date: formatAccountDate(subscription.created_at) })}
201
232
  </p>
202
233
  </div>
203
- <StatusChip status={subscription.status} />
234
+ <SubscriptionStatus subscription={subscription} />
204
235
  </div>
205
236
 
206
237
  {notice ? <AccountNotice message={notice} /> : null}
207
238
  {actionError ? <div className="sxa:mt-3"><AccountError message={actionError} /></div> : null}
208
239
 
209
240
  <RenewalDue subscription={subscription} className="sxa:mt-3" />
241
+ <PauseState subscription={subscription} />
210
242
 
211
243
  {subscription.status === 'PAST_DUE' && subscription.actions.can_manage_payment_method ? (
212
244
  <PastDueCard subscription={subscription} />
@@ -216,7 +248,7 @@ function SubscriptionDetail({
216
248
  {subscription.plan ? (
217
249
  <>
218
250
  <p className="sxa:text-h2 sxa:font-bold sxa:tabular-nums">
219
- {formatAccountAmount(subscription.plan.price, subscription.plan.currency)}
251
+ {nextPaymentAmount(subscription)}
220
252
  </p>
221
253
  {billing ? (
222
254
  <p className="sxa:mt-1 sxa:text-small sxa:text-muted">{t(billing.key, billing.params)}</p>
@@ -227,7 +259,7 @@ function SubscriptionDetail({
227
259
  <p className="sxa:mt-2 sxa:text-small sxa:text-muted">{timingLabel(subscription)}</p>
228
260
 
229
261
  <dl className="sxa:mt-5 sxa:space-y-2.5 sxa:border-t sxa:pt-4 sxa:text-small">
230
- <DetailRow label={t('subscriptions.gateway')}>{subscription.gateway}</DetailRow>
262
+ <DetailRow label={t('subscriptions.gateway')}>{gatewayLabel(subscription.gateway)}</DetailRow>
231
263
  <DetailRow label={t('subscriptions.paymentMethod')}>
232
264
  {/* Brand AND last four, or the producer's own word for the method.
233
265
  Half a card ("Visa ending —") is not a payment method. */}
@@ -238,6 +270,13 @@ function SubscriptionDetail({
238
270
  })
239
271
  : subscription.payment_method?.type ?? t('subscriptions.noPaymentMethod')}
240
272
  </DetailRow>
273
+ {discount ? (
274
+ // Wraps instead of truncating: the line ends with the price after the term.
275
+ <div className="sxa:flex sxa:items-start sxa:justify-between sxa:gap-4">
276
+ <dt className="sxa:shrink-0 sxa:text-muted">{t('subscriptions.discount')}</dt>
277
+ <dd className="sxa:min-w-0 sxa:text-right sxa:font-medium">{discount}</dd>
278
+ </div>
279
+ ) : null}
241
280
  </dl>
242
281
  </AccountCard>
243
282
 
@@ -256,37 +295,36 @@ function SubscriptionDetail({
256
295
  control still promises that pressing it would have worked.
257
296
  */}
258
297
  <div className="sxa:mt-6 sxa:flex sxa:flex-wrap sxa:gap-2">
259
- {subscription.actions.can_pause ? (
298
+ {pauseOptions && !pausing ? (
260
299
  <button
261
300
  type="button"
262
301
  disabled={pendingAction !== null}
263
- aria-busy={pendingAction === 'pause' || undefined}
264
302
  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
- )}
303
+ onClick={() => {
304
+ setConfirmingCancel(false);
305
+ setActionError(null);
306
+ setPausing(true);
307
+ }}
270
308
  >
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')}
309
+ {t('subscriptions.pause')}
273
310
  </button>
274
311
  ) : null}
275
312
 
313
+ {/* `can_resume` is "Resume now" on a paused plan and "Don't pause" on a scheduled pause. */}
276
314
  {subscription.actions.can_resume ? (
277
315
  <button
278
316
  type="button"
279
317
  disabled={pendingAction !== null}
280
- aria-busy={pendingAction === 'resume' || undefined}
318
+ aria-busy={pendingAction === 'resume' || pendingAction === 'unpause' || undefined}
281
319
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
282
320
  onClick={() => void run(
283
- 'resume',
321
+ scheduledPause ? 'unpause' : 'resume',
284
322
  () => callCustomerApi((sdk) => sdk.resumeSubscription(subscription.uniqid)),
285
- t('subscriptions.resumed'),
323
+ scheduledPause ? t('subscriptions.pauseCalledOff') : t('subscriptions.resumed'),
286
324
  )}
287
325
  >
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')}
326
+ {pendingAction === 'resume' || pendingAction === 'unpause' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
327
+ {pendingAction === 'resume' ? t('subscriptions.resuming') : scheduledPause ? t('subscriptions.dontPause') : t('subscriptions.resumeNow')}
290
328
  </button>
291
329
  ) : null}
292
330
 
@@ -295,13 +333,42 @@ function SubscriptionDetail({
295
333
  type="button"
296
334
  disabled={pendingAction !== null}
297
335
  className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
298
- onClick={() => setConfirmingCancel(true)}
336
+ onClick={() => {
337
+ setPausing(false);
338
+ setConfirmingCancel(true);
339
+ }}
299
340
  >
300
341
  {t('subscriptions.cancel')}
301
342
  </button>
302
343
  ) : null}
303
344
  </div>
304
345
 
346
+ {subscription.pause_limit ? (
347
+ <p className="sxa:mt-3 sxa:text-small sxa:text-muted">
348
+ {subscription.pause_limit.pauses_per_year === 1
349
+ ? t('subscriptions.pauseLimitOne', { date: formatAccountDate(subscription.pause_limit.available_again_at) })
350
+ : t('subscriptions.pauseLimit', {
351
+ count: subscription.pause_limit.pauses_per_year,
352
+ date: formatAccountDate(subscription.pause_limit.available_again_at),
353
+ })}
354
+ </p>
355
+ ) : null}
356
+
357
+ {pausing && pauseOptions ? (
358
+ <PauseDialog
359
+ key={subscription.uniqid}
360
+ subscription={subscription}
361
+ options={pauseOptions}
362
+ busy={pendingAction !== null}
363
+ onBusy={(busy) => setPendingAction(busy ? 'pause' : null)}
364
+ onClose={() => setPausing(false)}
365
+ onPaused={() => {
366
+ setPausing(false);
367
+ onChanged(t('subscriptions.pauseSavedNotice'));
368
+ }}
369
+ />
370
+ ) : null}
371
+
305
372
  {/*
306
373
  Cancelling asks first, and the question names what cancelling does on
307
374
  THIS gateway. PayPal cannot stop a billing agreement at the period
@@ -353,34 +420,185 @@ function SubscriptionDetail({
353
420
  }
354
421
 
355
422
  /**
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.
423
+ * An open renewal, and the way to pay it: on hold, due, or invoiced ahead.
424
+ * The producer names the invoice's hosted checkout in `open_renewal`, so this
425
+ * is a plain link, never a call: paying is a purchase, and purchases carry
426
+ * their authorisation in checkout (ADR-0064). Nothing open (or a backend that
427
+ * does not send the field yet), nothing shown. A URL that fails the scheme check
428
+ * gets no anchor, only the sentence, because the payment is still due.
363
429
  */
364
430
  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);
431
+ const ended = endedAfterHold(subscription);
432
+ if (ended) {
433
+ return (
434
+ <AccountCard className={`${className} sxa:px-5 sxa:py-3`}>
435
+ <p className="sxa:text-small">{t(endedNoteKey(ended))}</p>
436
+ </AccountCard>
437
+ );
438
+ }
439
+ const state = openRenewalState(subscription);
440
+ const open = subscription.open_renewal;
441
+ if (state === null || !open) return null;
442
+ const href = safeExternalHref(open.pay_url);
443
+ const { note, payLabel } = renewalCopy(subscription, state, open);
368
444
 
369
445
  return (
370
446
  <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>
447
+ <p className="sxa:min-w-0 sxa:text-small">{note}</p>
372
448
  {href === null ? null : (
373
449
  <a
374
450
  href={href}
375
451
  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
452
  >
377
- {t('subscriptions.payNow')}
453
+ {payLabel}
378
454
  </a>
379
455
  )}
380
456
  </AccountCard>
381
457
  );
382
458
  }
383
459
 
460
+ const PAUSE_LENGTH_KEYS: Readonly<Record<SdkCustomerPauseOptions['lengths'][number]['days'], string>> = {
461
+ 7: 'subscriptions.pauseLength7',
462
+ 14: 'subscriptions.pauseLength14',
463
+ 30: 'subscriptions.pauseLength30',
464
+ 60: 'subscriptions.pauseLength60',
465
+ 90: 'subscriptions.pauseLength90',
466
+ };
467
+
468
+ /** `SUBSCRIPTION_PAUSE_REASONS`, in the producer's order, with their buyer labels. */
469
+ const PAUSE_REASONS: ReadonlyArray<readonly [SdkCustomerPauseReason, string]> = [
470
+ ['taking_a_break', 'subscriptions.pauseReasonTakingABreak'],
471
+ ['too_expensive', 'subscriptions.pauseReasonTooExpensive'],
472
+ ['not_using', 'subscriptions.pauseReasonNotUsing'],
473
+ ['not_working', 'subscriptions.pauseReasonNotWorking'],
474
+ ['other', 'subscriptions.pauseReasonOther'],
475
+ ];
476
+
477
+ /**
478
+ * The pause dialog: the lengths the producer offers, each with its resume date, a reason
479
+ * when the shop asks for one, and what the pause means. A failure keeps the dialog and
480
+ * says nothing changed; the buyer tries again from here.
481
+ */
482
+ function PauseDialog({
483
+ subscription,
484
+ options,
485
+ busy,
486
+ onBusy,
487
+ onClose,
488
+ onPaused,
489
+ }: {
490
+ subscription: SdkCustomerSubscription;
491
+ options: SdkCustomerPauseOptions;
492
+ busy: boolean;
493
+ onBusy: (busy: boolean) => void;
494
+ onClose: () => void;
495
+ onPaused: () => void;
496
+ }) {
497
+ const [days, setDays] = useState(() => defaultPauseLength(options));
498
+ const [reason, setReason] = useState<SdkCustomerPauseReason | ''>('');
499
+ const [failed, setFailed] = useState(false);
500
+ const length = options.lengths.find((entry) => entry.days === days) ?? null;
501
+ const start = formatAccountDate(options.starts_at);
502
+ const startsLater = Date.parse(options.starts_at) > Date.now();
503
+ const left = pausesLeftMessage(options.pauses_left_after);
504
+
505
+ const confirm = async () => {
506
+ if (!length) return;
507
+ onBusy(true);
508
+ setFailed(false);
509
+ const result = await callCustomerApi((sdk) => sdk.pauseSubscription(subscription.uniqid, {
510
+ length_days: length.days,
511
+ ...(options.reason_enabled && reason !== '' ? { reason } : {}),
512
+ }));
513
+ onBusy(false);
514
+ if (!result.success) {
515
+ setFailed(true);
516
+ return;
517
+ }
518
+ onPaused();
519
+ };
520
+
521
+ return (
522
+ <AccountCard className="sxa:mt-4 sxa:p-6">
523
+ <p className="sxa:font-heading sxa:font-semibold">
524
+ {t('subscriptions.pauseTitle', { plan: subscription.plan?.name ?? t('subscriptions.unnamedPlan') })}
525
+ </p>
526
+ {startsLater ? (
527
+ <p className="sxa:mt-2 sxa:text-small sxa:text-muted">{t('subscriptions.pauseStartsAfter', { date: start })}</p>
528
+ ) : null}
529
+
530
+ <p className="sxa:mt-4 sxa:text-small sxa:font-semibold">{t('subscriptions.pauseHowLong')}</p>
531
+ <div className="sxa:mt-2 sxa:flex sxa:flex-wrap sxa:gap-2" role="radiogroup" aria-label={t('subscriptions.pauseHowLong')}>
532
+ {options.lengths.map((entry) => (
533
+ <button
534
+ key={entry.days}
535
+ type="button"
536
+ role="radio"
537
+ aria-checked={entry.days === days}
538
+ disabled={busy}
539
+ className={`sx-account-button sxa:card-button ${entry.days === days ? 'sxa:card-button--add' : ''} sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50`}
540
+ onClick={() => setDays(entry.days)}
541
+ >
542
+ {t(PAUSE_LENGTH_KEYS[entry.days])}
543
+ </button>
544
+ ))}
545
+ </div>
546
+
547
+ {options.reason_enabled ? (
548
+ <label className="sxa:mt-4 sxa:block">
549
+ <span className="sxa:text-small sxa:font-semibold">{t('subscriptions.pauseReasonLabel')}</span>
550
+ <span className="sxa:text-small sxa:text-muted"> · {t('subscriptions.pauseOptional')}</span>
551
+ <select
552
+ value={reason}
553
+ disabled={busy}
554
+ onChange={(event) => setReason(PAUSE_REASONS.find(([value]) => value === event.target.value)?.[0] ?? '')}
555
+ 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"
556
+ >
557
+ <option value="">{t('subscriptions.pauseReasonNone')}</option>
558
+ {PAUSE_REASONS.map(([value, key]) => <option key={value} value={value}>{t(key)}</option>)}
559
+ </select>
560
+ </label>
561
+ ) : null}
562
+
563
+ {length ? (
564
+ <ul className="sxa:mt-4 sxa:list-disc sxa:space-y-1 sxa:pl-5 sxa:text-small sxa:text-muted">
565
+ {[
566
+ t('subscriptions.pauseSummary', { start, end: formatAccountDate(length.resume_at) }),
567
+ pauseAccessLine(subscription),
568
+ t('subscriptions.pauseResumeAnyTime'),
569
+ t(left.key, left.params),
570
+ ].map((line) => <li key={line}>{line}</li>)}
571
+ </ul>
572
+ ) : null}
573
+
574
+ {failed ? <div className="sxa:mt-3"><AccountError message={t('subscriptions.pauseError')} /></div> : null}
575
+
576
+ <div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
577
+ {length ? (
578
+ <button
579
+ type="button"
580
+ disabled={busy}
581
+ aria-busy={busy || undefined}
582
+ 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"
583
+ onClick={() => void confirm()}
584
+ >
585
+ {busy ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
586
+ {busy ? t('subscriptions.pausing') : t('subscriptions.pauseConfirm', { date: formatAccountDate(length.resume_at) })}
587
+ </button>
588
+ ) : null}
589
+ <button
590
+ type="button"
591
+ disabled={busy}
592
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
593
+ onClick={onClose}
594
+ >
595
+ {t('subscriptions.pauseKeep')}
596
+ </button>
597
+ </div>
598
+ </AccountCard>
599
+ );
600
+ }
601
+
384
602
  /**
385
603
  * A past-due Stripe plan: the last charge failed, and the card is what to
386
604
  * change before the plan ends. The same hosted billing link as "Update card".
@@ -406,6 +624,98 @@ function PastDueCard({ subscription }: { subscription: SdkCustomerSubscription }
406
624
  );
407
625
  }
408
626
 
627
+ /** What the pause takes: the Discord roles by name when the producer knows them, else "your access". */
628
+ function pauseAccessLine(subscription: SdkCustomerSubscription): string {
629
+ const roles = subscription.pause_access_roles ?? [];
630
+ if (roles.length === 0) return t('subscriptions.pauseAccess');
631
+ return t(roles.length === 1 ? 'subscriptions.pauseAccessRole' : 'subscriptions.pauseAccessRoles', {
632
+ roles: roles.map((role) => `@${role}`).join(', '),
633
+ });
634
+ }
635
+
636
+ /** A running or scheduled pause, read from the producer's `pause`. */
637
+ function PauseState({ subscription }: { subscription: SdkCustomerSubscription }) {
638
+ const pause = subscription.pause;
639
+ if (!pause || subscription.status.toUpperCase() === 'CANCELED') return null;
640
+ let title: string;
641
+ let body: string;
642
+ if (pause.state === 'scheduled') {
643
+ title = t('subscriptions.pauseScheduled', { date: formatAccountDate(pause.starts_at) });
644
+ body = pause.resume_at === null
645
+ ? t('subscriptions.pauseScheduledBodyOpenEnded')
646
+ : t('subscriptions.pauseScheduledBody', { date: formatAccountDate(pause.resume_at) });
647
+ } else {
648
+ title = pause.resume_at === null
649
+ ? t('subscriptions.pausedOpenEnded')
650
+ : t('subscriptions.pausedUntil', { date: formatAccountDate(pause.resume_at) });
651
+ const paused = `${t('subscriptions.pausedBody')} ${pauseAccessLine(subscription)}`;
652
+ body = subscription.plan && resumeBillsToday(subscription)
653
+ ? `${paused} ${t('subscriptions.pausedPayToday', {
654
+ price: formatAccountAmount(subscription.plan.price, subscription.plan.currency),
655
+ })}`
656
+ : paused;
657
+ }
658
+ return (
659
+ <AccountCard className="sxa:mt-3 sxa:px-5 sxa:py-3">
660
+ <p className="sxa:text-small sxa:font-semibold">{title}</p>
661
+ <p className="sxa:mt-0.5 sxa:text-small sxa:text-muted">{body}</p>
662
+ </AccountCard>
663
+ );
664
+ }
665
+
666
+ /** The subscription's status, or what its open renewal makes it: on hold, or payment due. */
667
+ function SubscriptionStatus({ subscription }: { subscription: SdkCustomerSubscription }) {
668
+ const state = openRenewalState(subscription);
669
+ if (state === 'held') return <StatusChip status="PAST_DUE" label={t('subscriptions.statusOnHold')} />;
670
+ if (state === 'due') return <StatusChip status="PENDING" label={t('subscriptions.statusPaymentDue')} />;
671
+ return <StatusChip status={subscription.status} />;
672
+ }
673
+
674
+ /** The renewal note and the pay label for an open renewal. */
675
+ function renewalCopy(subscription: SdkCustomerSubscription, state: OpenRenewalState, open: SdkCustomerOpenRenewal) {
676
+ const amount = formatAccountAmount(open.amount, open.currency);
677
+ const date = formatAccountDate(open.pay_by);
678
+ const renewal = formatAccountDate(renewalDate(subscription.current_period_end));
679
+ if (state === 'held') return { note: t('subscriptions.holdNote', { amount, date, interval: t(noteIntervalKey(open, 'hold')) }), payLabel: t('subscriptions.payAndResume', { amount }) };
680
+ const note = state === 'ready' ? t('subscriptions.readyNote', { renewal, interval: t(noteIntervalKey(open, 'ready')) }) : t('subscriptions.dueNote', { renewal, date });
681
+ return { note, payLabel: t('subscriptions.payAmount', { amount }) };
682
+ }
683
+
684
+ /**
685
+ * The plan's coupon term as the buyer reads it (Design Batch 4, feature 1), the portal's line:
686
+ * "LAUNCH20 · 20 % off · 2 more payments, then $9.99 a month", or "TENOFF · 10 % off every
687
+ * payment", with ", up to $3.00 each" when the coupon's cap bounds each payment. Every amount is
688
+ * the producer's `discount`. Null without a running term.
689
+ */
690
+ export function discountLine(subscription: SdkCustomerSubscription): string | null {
691
+ const { discount, plan } = subscription;
692
+ // The producer reads a term only for the plan it serves.
693
+ if (!discount || !plan) return null;
694
+ const off = discount.discount_type === 'PERCENTAGE'
695
+ ? t('subscriptions.discountPercent', { percent: Number(discount.discount_value) })
696
+ : formatAccountAmount(discount.discount_amount, discount.currency);
697
+ const cap = discount.max_discount === null
698
+ ? ''
699
+ : t('subscriptions.discountCap', { amount: formatAccountAmount(discount.max_discount, discount.currency) });
700
+ if (discount.payments_total === null) return t('subscriptions.discountEvery', { code: discount.code, off, cap });
701
+ const left = discount.payments_total - discount.payments_used;
702
+ const period = billingMessage(plan, 'period');
703
+ return t(left === 1 ? 'subscriptions.discountLeftOne' : 'subscriptions.discountLeft', {
704
+ code: discount.code,
705
+ off,
706
+ cap,
707
+ count: left,
708
+ price: formatAccountAmount(discount.full_amount, discount.currency),
709
+ period: t(period.key, period.params),
710
+ });
711
+ }
712
+
713
+ /** What the next payment bills: the term's `next_amount` while it runs, else the plan price. */
714
+ export function nextPaymentAmount(subscription: SdkCustomerSubscription): string | null {
715
+ if (subscription.discount) return formatAccountAmount(subscription.discount.next_amount, subscription.discount.currency);
716
+ return subscription.plan ? formatAccountAmount(subscription.plan.price, subscription.plan.currency) : null;
717
+ }
718
+
409
719
  function DetailRow({ label, children }: { label: string; children: ReactNode }) {
410
720
  return (
411
721
  <div className="sxa:flex sxa:items-center sxa:justify-between sxa:gap-4">
@@ -439,7 +749,7 @@ function BillingHistory({ uniqid }: { uniqid: string }) {
439
749
  );
440
750
  }
441
751
 
442
- function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
752
+ export function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
443
753
  const history = narrowBillingHistory(payload);
444
754
 
445
755
  /*
@@ -460,21 +770,33 @@ function BillingHistoryList({ payload, reload }: { payload: unknown; reload: ()
460
770
  );
461
771
  }
462
772
 
773
+ // Each row says what it billed and opens its order in the account, where the receipt is (Design Batch 9, C3).
463
774
  return (
464
775
  <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
- ))}
776
+ {history.invoices.map((invoice) => {
777
+ const title = billingEntryTitle(invoice);
778
+ const days = billingEntryDays(invoice);
779
+ return (
780
+ <li key={invoice.uniqid}>
781
+ <Link
782
+ to={accountOrderHref(invoice.uniqid)}
783
+ 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"
784
+ >
785
+ <div className="sxa:min-w-0 sxa:flex-1">
786
+ <p className="sxa:text-small sxa:font-semibold">{t(title.key, title.params)}</p>
787
+ <p className="sxa:mt-1 sxa:text-micro sxa:tabular-nums sxa:text-muted">
788
+ {formatAccountDate(invoice.created_at)}
789
+ {days ? ` · ${t(days.key, days.params)}` : null}
790
+ </p>
791
+ </div>
792
+ <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">
793
+ <span className="sxa:text-small sxa:font-semibold sxa:tabular-nums">{formatAccountAmount(invoice.total, invoice.currency)}</span>
794
+ <InvoiceStatusChip status={invoice.status} />
795
+ </div>
796
+ </Link>
797
+ </li>
798
+ );
799
+ })}
478
800
  </ul>
479
801
  );
480
802
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoppexio/storefront-react",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",