@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.
- package/CHANGELOG.md +8 -0
- package/dist/account.css +22 -0
- package/dist/account.js +2031 -294
- package/dist/account.js.map +1 -1
- package/dist/index.d.ts +108 -18
- package/dist/index.js +29 -5
- package/dist/index.js.map +1 -1
- package/eject/account/account-payment-method.ts +1 -0
- package/eject/account/account-subscriptions.ts +158 -6
- package/eject/account/buyer-invoice-status.ts +100 -0
- package/eject/account/downloads.tsx +45 -13
- package/eject/account/messages/ar.json +99 -14
- package/eject/account/messages/de.json +99 -14
- package/eject/account/messages/en.json +99 -14
- package/eject/account/messages/es.json +99 -14
- package/eject/account/messages/fi.json +99 -14
- package/eject/account/messages/fr.json +99 -14
- package/eject/account/messages/hi.json +99 -14
- package/eject/account/messages/it.json +99 -14
- package/eject/account/messages/lt.json +99 -14
- package/eject/account/messages/nl.json +99 -14
- package/eject/account/messages/pl.json +99 -14
- package/eject/account/messages/pt.json +99 -14
- package/eject/account/messages/ru.json +99 -14
- package/eject/account/messages/tr.json +99 -14
- package/eject/account/messages/uk.json +99 -14
- package/eject/account/messages/zh.json +99 -14
- package/eject/account/orders.tsx +115 -41
- package/eject/account/reseller.tsx +3 -2
- package/eject/account/shared.tsx +21 -3
- package/eject/account/subscriptions.tsx +324 -46
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
<
|
|
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: '
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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={() =>
|
|
266
|
-
|
|
267
|
-
()
|
|
268
|
-
|
|
269
|
-
|
|
294
|
+
onClick={() => {
|
|
295
|
+
setConfirmingCancel(false);
|
|
296
|
+
setActionError(null);
|
|
297
|
+
setPausing(true);
|
|
298
|
+
}}
|
|
270
299
|
>
|
|
271
|
-
{
|
|
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.
|
|
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={() =>
|
|
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
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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
|
|
366
|
-
if (
|
|
367
|
-
|
|
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">{
|
|
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
|
-
{
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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.
|
|
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",
|