@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.
- package/CHANGELOG.md +31 -0
- package/dist/account.css +26 -8
- package/dist/account.js +3217 -1001
- package/dist/account.js.map +1 -1
- package/dist/index.d.ts +188 -14
- package/dist/index.js +43 -20
- package/dist/index.js.map +1 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-payment-method.ts +1 -0
- package/eject/account/account-referrals.ts +70 -1
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/account-subscriptions.ts +158 -6
- package/eject/account/buyer-invoice-status.ts +100 -0
- package/eject/account/downloads.tsx +118 -17
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +150 -39
- package/eject/account/messages/de.json +150 -39
- package/eject/account/messages/en.json +150 -39
- package/eject/account/messages/es.json +150 -39
- package/eject/account/messages/fi.json +150 -39
- package/eject/account/messages/fr.json +150 -39
- package/eject/account/messages/hi.json +150 -39
- package/eject/account/messages/it.json +150 -39
- package/eject/account/messages/lt.json +150 -39
- package/eject/account/messages/nl.json +150 -39
- package/eject/account/messages/pl.json +150 -39
- package/eject/account/messages/pt.json +150 -39
- package/eject/account/messages/ru.json +150 -39
- package/eject/account/messages/tr.json +150 -39
- package/eject/account/messages/uk.json +150 -39
- package/eject/account/messages/zh.json +150 -39
- package/eject/account/orders.tsx +203 -41
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +47 -5
- package/eject/account/reseller.tsx +62 -308
- package/eject/account/rewards.tsx +5 -0
- package/eject/account/shared.tsx +60 -4
- package/eject/account/subscriptions.tsx +387 -50
- 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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
<
|
|
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: '
|
|
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
|
-
<
|
|
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
|
-
<
|
|
243
|
-
|
|
244
|
-
{
|
|
245
|
-
|
|
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
|
-
{
|
|
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={() =>
|
|
261
|
-
|
|
262
|
-
()
|
|
263
|
-
|
|
264
|
-
|
|
294
|
+
onClick={() => {
|
|
295
|
+
setConfirmingCancel(false);
|
|
296
|
+
setActionError(null);
|
|
297
|
+
setPausing(true);
|
|
298
|
+
}}
|
|
265
299
|
>
|
|
266
|
-
{
|
|
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.
|
|
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={() =>
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
"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": "
|
|
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.
|
|
76
|
-
"react-dom": "19.
|
|
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",
|