@shoppexio/storefront-react 0.0.0 → 0.1.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 +20 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/account.css +1171 -0
- package/dist/account.d.ts +23 -0
- package/dist/account.js +12685 -0
- package/dist/account.js.map +1 -0
- package/dist/chunk-D6XKDVAV.js +181 -0
- package/dist/chunk-D6XKDVAV.js.map +1 -0
- package/dist/index.d.ts +3087 -0
- package/dist/index.js +2801 -0
- package/dist/index.js.map +1 -0
- package/eject/account/account-availability.ts +171 -0
- package/eject/account/account-downloads.ts +118 -0
- package/eject/account/account-favorites.ts +99 -0
- package/eject/account/account-loyalty.ts +241 -0
- package/eject/account/account-page.tsx +410 -0
- package/eject/account/account-payment-method.ts +99 -0
- package/eject/account/account-referrals.ts +68 -0
- package/eject/account/account-reseller-invite.ts +88 -0
- package/eject/account/account-reseller.ts +685 -0
- package/eject/account/account-rewards.ts +104 -0
- package/eject/account/account-settings.ts +181 -0
- package/eject/account/account-signin-link.ts +122 -0
- package/eject/account/account-subscriptions.ts +130 -0
- package/eject/account/account-support.ts +155 -0
- package/eject/account/account-warranties.ts +118 -0
- package/eject/account/customer-account.ts +93 -0
- package/eject/account/dashboard.tsx +139 -0
- package/eject/account/downloads.tsx +343 -0
- package/eject/account/favorites.tsx +201 -0
- package/eject/account/i18n.ts +175 -0
- package/eject/account/icons.tsx +218 -0
- package/eject/account/login.tsx +253 -0
- package/eject/account/messages/ar.json +464 -0
- package/eject/account/messages/de.json +464 -0
- package/eject/account/messages/en.json +464 -0
- package/eject/account/messages/es.json +464 -0
- package/eject/account/messages/fi.json +464 -0
- package/eject/account/messages/fr.json +464 -0
- package/eject/account/messages/hi.json +464 -0
- package/eject/account/messages/it.json +464 -0
- package/eject/account/messages/lt.json +464 -0
- package/eject/account/messages/nl.json +464 -0
- package/eject/account/messages/pl.json +464 -0
- package/eject/account/messages/pt.json +464 -0
- package/eject/account/messages/ru.json +464 -0
- package/eject/account/messages/tr.json +464 -0
- package/eject/account/messages/uk.json +464 -0
- package/eject/account/messages/zh.json +464 -0
- package/eject/account/orders.tsx +791 -0
- package/eject/account/overview.tsx +147 -0
- package/eject/account/referrals.tsx +327 -0
- package/eject/account/reseller.tsx +1365 -0
- package/eject/account/rewards.tsx +641 -0
- package/eject/account/settings.tsx +699 -0
- package/eject/account/shared.tsx +398 -0
- package/eject/account/subscriptions.tsx +421 -0
- package/eject/account/support.tsx +453 -0
- package/package.json +75 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The buyer's recurring purchases.
|
|
3
|
+
*
|
|
4
|
+
* Subscriptions have no list route of their own — they ride along in the
|
|
5
|
+
* dashboard payload — and the only per-item routes this panel calls are the
|
|
6
|
+
* three the edge admits: pause, resume, cancel. All three END an obligation
|
|
7
|
+
* instead of creating one, which is the reason storefront code may call them at
|
|
8
|
+
* all.
|
|
9
|
+
*
|
|
10
|
+
* The payment-method portal is NOT one of them. That route answers with a
|
|
11
|
+
* signed redirect into the provider's own portal, and any script sharing this
|
|
12
|
+
* origin could fetch the link and forward it, so the lane renders no control
|
|
13
|
+
* for it whatsoever — not a disabled one, which would promise that pressing it
|
|
14
|
+
* would work. The buyer reaches the portal from the store's own emails, and the
|
|
15
|
+
* panel says so rather than pretending the capability does not exist.
|
|
16
|
+
*
|
|
17
|
+
* List and detail are two views of one panel. The platform defines a detail
|
|
18
|
+
* path for orders and for nothing else, so a `/dashboard/subscriptions/<id>`
|
|
19
|
+
* URL would be this template inventing platform route shape; the selection
|
|
20
|
+
* lives in component state instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useState, type ReactNode } from 'react';
|
|
24
|
+
import { Link } from 'react-router-dom';
|
|
25
|
+
import {
|
|
26
|
+
billingMessage,
|
|
27
|
+
cancelsAtPeriodEnd,
|
|
28
|
+
narrowBillingHistory,
|
|
29
|
+
subscriptionTiming,
|
|
30
|
+
subscriptionTimingKey,
|
|
31
|
+
} from './account-subscriptions';
|
|
32
|
+
import { callCustomerApi } from './customer-account';
|
|
33
|
+
import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
|
|
34
|
+
import { t } from './i18n';
|
|
35
|
+
import { LockIcon } from './icons';
|
|
36
|
+
import { useAccountDashboard } from './dashboard';
|
|
37
|
+
import {
|
|
38
|
+
AccountBackButton,
|
|
39
|
+
AccountCard,
|
|
40
|
+
AccountEmpty,
|
|
41
|
+
AccountError,
|
|
42
|
+
AccountNotice,
|
|
43
|
+
AccountRowsSkeleton,
|
|
44
|
+
AccountSectionTitle,
|
|
45
|
+
BusySpinner,
|
|
46
|
+
StatusChip,
|
|
47
|
+
formatAccountAmount,
|
|
48
|
+
formatAccountDate,
|
|
49
|
+
useAccountResource,
|
|
50
|
+
} from './shared';
|
|
51
|
+
|
|
52
|
+
export function AccountSubscriptions() {
|
|
53
|
+
const { state, reload } = useAccountDashboard();
|
|
54
|
+
const [selectedUniqid, setSelectedUniqid] = useState<string | null>(null);
|
|
55
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
56
|
+
|
|
57
|
+
if (state.status === 'loading') {
|
|
58
|
+
return <AccountRowsSkeleton rows={3} label={t('subscriptions.loading')} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (state.status === 'error') {
|
|
62
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const subscriptions = state.data.subscriptions;
|
|
66
|
+
|
|
67
|
+
if (subscriptions.length === 0) {
|
|
68
|
+
return (
|
|
69
|
+
<AccountEmpty
|
|
70
|
+
title={t('subscriptions.emptyTitle')}
|
|
71
|
+
body={t('subscriptions.emptyBody')}
|
|
72
|
+
action={<BrowseStoreLink />}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* The selection is an id, never a copy of the row. Reloading after a cancel
|
|
79
|
+
* replaces the payload, and holding the object would leave the detail view
|
|
80
|
+
* describing the plan as it stood before the buyer changed it. An id that no
|
|
81
|
+
* longer matches anything falls back to the list — which is exactly what a
|
|
82
|
+
* subscription leaving the payload should look like.
|
|
83
|
+
*/
|
|
84
|
+
const selected = subscriptions.find((entry) => entry.uniqid === selectedUniqid);
|
|
85
|
+
|
|
86
|
+
if (selected) {
|
|
87
|
+
return (
|
|
88
|
+
<SubscriptionDetail
|
|
89
|
+
subscription={selected}
|
|
90
|
+
notice={notice}
|
|
91
|
+
onBack={() => {
|
|
92
|
+
setSelectedUniqid(null);
|
|
93
|
+
setNotice(null);
|
|
94
|
+
}}
|
|
95
|
+
onChanged={(message) => {
|
|
96
|
+
setNotice(message);
|
|
97
|
+
reload();
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<ul className="sxa:space-y-4">
|
|
105
|
+
{subscriptions.map((subscription) => (
|
|
106
|
+
<li key={subscription.uniqid}>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={() => {
|
|
110
|
+
setNotice(null);
|
|
111
|
+
setSelectedUniqid(subscription.uniqid);
|
|
112
|
+
}}
|
|
113
|
+
className="sxa:flex sxa:w-full sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:text-left sxa:shadow-card sxa:transition-opacity sxa:hover:opacity-80"
|
|
114
|
+
>
|
|
115
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
116
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">
|
|
117
|
+
{subscription.plan?.name ?? t('subscriptions.unnamedPlan')}
|
|
118
|
+
</p>
|
|
119
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">{timingLabel(subscription)}</p>
|
|
120
|
+
</div>
|
|
121
|
+
{/* A plan the producer did not send has no price to print, and a
|
|
122
|
+
dash in its place would read as "free". */}
|
|
123
|
+
{subscription.plan ? (
|
|
124
|
+
<span className="sxa:shrink-0 sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
125
|
+
{formatAccountAmount(subscription.plan.price, subscription.plan.currency)}
|
|
126
|
+
</span>
|
|
127
|
+
) : null}
|
|
128
|
+
<StatusChip status={subscription.status} />
|
|
129
|
+
</button>
|
|
130
|
+
</li>
|
|
131
|
+
))}
|
|
132
|
+
</ul>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function BrowseStoreLink() {
|
|
137
|
+
return (
|
|
138
|
+
<Link to="/all-products" className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small">
|
|
139
|
+
{t('common.browseStore')}
|
|
140
|
+
</Link>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function timingLabel(subscription: SdkCustomerSubscription): string {
|
|
145
|
+
const timing = subscriptionTiming(subscription);
|
|
146
|
+
return t(subscriptionTimingKey(timing), { date: formatAccountDate(timing.date) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function SubscriptionDetail({
|
|
150
|
+
subscription,
|
|
151
|
+
notice,
|
|
152
|
+
onBack,
|
|
153
|
+
onChanged,
|
|
154
|
+
}: {
|
|
155
|
+
subscription: SdkCustomerSubscription;
|
|
156
|
+
notice: string | null;
|
|
157
|
+
onBack: () => void;
|
|
158
|
+
onChanged: (message: string) => void;
|
|
159
|
+
}) {
|
|
160
|
+
const [pendingAction, setPendingAction] = useState<'pause' | 'resume' | 'cancel' | null>(null);
|
|
161
|
+
const [confirmingCancel, setConfirmingCancel] = useState(false);
|
|
162
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
163
|
+
|
|
164
|
+
const atPeriodEnd = cancelsAtPeriodEnd(subscription.gateway);
|
|
165
|
+
|
|
166
|
+
const run = async (
|
|
167
|
+
action: 'pause' | 'resume' | 'cancel',
|
|
168
|
+
call: () => Promise<{ success: boolean; message?: string }>,
|
|
169
|
+
doneMessage: string,
|
|
170
|
+
) => {
|
|
171
|
+
setPendingAction(action);
|
|
172
|
+
setActionError(null);
|
|
173
|
+
const result = await call();
|
|
174
|
+
setPendingAction(null);
|
|
175
|
+
|
|
176
|
+
if (!result.success) {
|
|
177
|
+
setActionError(result.message?.trim() || t('subscriptions.actionFailed'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
setConfirmingCancel(false);
|
|
182
|
+
onChanged(doneMessage);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const billing = subscription.plan ? billingMessage(subscription.plan) : null;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div>
|
|
189
|
+
<AccountBackButton onClick={onBack} label={t('subscriptions.allSubscriptions')} />
|
|
190
|
+
|
|
191
|
+
<div className="sxa:mt-5 sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-4">
|
|
192
|
+
<div className="sxa:min-w-0">
|
|
193
|
+
<h2 className="sxa:font-heading sxa:text-h3 sxa:font-semibold">
|
|
194
|
+
{subscription.plan?.name ?? t('subscriptions.unnamedPlan')}
|
|
195
|
+
</h2>
|
|
196
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">
|
|
197
|
+
{t('subscriptions.startedOn', { date: formatAccountDate(subscription.created_at) })}
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
<StatusChip status={subscription.status} />
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{notice ? <AccountNotice message={notice} /> : null}
|
|
204
|
+
{actionError ? <div className="sxa:mt-3"><AccountError message={actionError} /></div> : null}
|
|
205
|
+
|
|
206
|
+
<AccountCard className="sxa:mt-5 sxa:p-6">
|
|
207
|
+
{subscription.plan ? (
|
|
208
|
+
<>
|
|
209
|
+
<p className="sxa:text-h2 sxa:font-bold sxa:tabular-nums">
|
|
210
|
+
{formatAccountAmount(subscription.plan.price, subscription.plan.currency)}
|
|
211
|
+
</p>
|
|
212
|
+
{billing ? (
|
|
213
|
+
<p className="sxa:mt-1 sxa:text-small sxa:text-muted">{t(billing.key, billing.params)}</p>
|
|
214
|
+
) : null}
|
|
215
|
+
</>
|
|
216
|
+
) : null}
|
|
217
|
+
|
|
218
|
+
<p className="sxa:mt-2 sxa:text-small sxa:text-muted">{timingLabel(subscription)}</p>
|
|
219
|
+
|
|
220
|
+
<dl className="sxa:mt-5 sxa:space-y-2.5 sxa:border-t sxa:pt-4 sxa:text-small">
|
|
221
|
+
<DetailRow label={t('subscriptions.gateway')}>{subscription.gateway}</DetailRow>
|
|
222
|
+
<DetailRow label={t('subscriptions.paymentMethod')}>
|
|
223
|
+
{/* Brand AND last four, or the producer's own word for the method.
|
|
224
|
+
Half a card ("Visa ending —") is not a payment method. */}
|
|
225
|
+
{subscription.payment_method?.card_brand && subscription.payment_method.card_last4
|
|
226
|
+
? t('subscriptions.cardOnFile', {
|
|
227
|
+
brand: subscription.payment_method.card_brand,
|
|
228
|
+
last4: subscription.payment_method.card_last4,
|
|
229
|
+
})
|
|
230
|
+
: subscription.payment_method?.type ?? t('subscriptions.noPaymentMethod')}
|
|
231
|
+
</DetailRow>
|
|
232
|
+
</dl>
|
|
233
|
+
</AccountCard>
|
|
234
|
+
|
|
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
|
+
{subscription.actions.can_manage_payment_method ? (
|
|
242
|
+
<p className="sxa:mt-4 sxa:flex sxa:items-start sxa:gap-2 sxa:text-small sxa:text-muted">
|
|
243
|
+
<LockIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0" />
|
|
244
|
+
{t('subscriptions.paymentMethodElsewhere')}
|
|
245
|
+
</p>
|
|
246
|
+
) : null}
|
|
247
|
+
|
|
248
|
+
{/*
|
|
249
|
+
The producer's `actions` decide which controls exist. A flag that is
|
|
250
|
+
false removes the button; it never disables one, because a disabled
|
|
251
|
+
control still promises that pressing it would have worked.
|
|
252
|
+
*/}
|
|
253
|
+
<div className="sxa:mt-6 sxa:flex sxa:flex-wrap sxa:gap-2">
|
|
254
|
+
{subscription.actions.can_pause ? (
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
disabled={pendingAction !== null}
|
|
258
|
+
aria-busy={pendingAction === 'pause' || undefined}
|
|
259
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
260
|
+
onClick={() => void run(
|
|
261
|
+
'pause',
|
|
262
|
+
() => callCustomerApi((sdk) => sdk.pauseSubscription(subscription.uniqid)),
|
|
263
|
+
t('subscriptions.paused'),
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
{pendingAction === 'pause' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
267
|
+
{pendingAction === 'pause' ? t('subscriptions.pausing') : t('subscriptions.pause')}
|
|
268
|
+
</button>
|
|
269
|
+
) : null}
|
|
270
|
+
|
|
271
|
+
{subscription.actions.can_resume ? (
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
disabled={pendingAction !== null}
|
|
275
|
+
aria-busy={pendingAction === 'resume' || undefined}
|
|
276
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
277
|
+
onClick={() => void run(
|
|
278
|
+
'resume',
|
|
279
|
+
() => callCustomerApi((sdk) => sdk.resumeSubscription(subscription.uniqid)),
|
|
280
|
+
t('subscriptions.resumed'),
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
{pendingAction === 'resume' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
284
|
+
{pendingAction === 'resume' ? t('subscriptions.resuming') : t('subscriptions.resume')}
|
|
285
|
+
</button>
|
|
286
|
+
) : null}
|
|
287
|
+
|
|
288
|
+
{subscription.actions.can_cancel && !confirmingCancel ? (
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
disabled={pendingAction !== null}
|
|
292
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
293
|
+
onClick={() => setConfirmingCancel(true)}
|
|
294
|
+
>
|
|
295
|
+
{t('subscriptions.cancel')}
|
|
296
|
+
</button>
|
|
297
|
+
) : null}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/*
|
|
301
|
+
Cancelling asks first, and the question names what cancelling does on
|
|
302
|
+
THIS gateway. PayPal cannot stop a billing agreement at the period
|
|
303
|
+
boundary, so telling that buyer they keep the days they already paid for
|
|
304
|
+
would be a promise this template cannot keep.
|
|
305
|
+
*/}
|
|
306
|
+
{confirmingCancel ? (
|
|
307
|
+
<AccountCard className="sxa:mt-4 sxa:p-6">
|
|
308
|
+
<p className="sxa:font-heading sxa:font-semibold">{t('subscriptions.confirmCancelTitle')}</p>
|
|
309
|
+
<p className="sxa:mt-2 sxa:text-small sxa:text-muted">
|
|
310
|
+
{atPeriodEnd
|
|
311
|
+
? t('subscriptions.confirmCancelAtPeriodEnd', {
|
|
312
|
+
date: formatAccountDate(subscription.current_period_end),
|
|
313
|
+
})
|
|
314
|
+
: t('subscriptions.confirmCancelNow')}
|
|
315
|
+
</p>
|
|
316
|
+
<div className="sxa:mt-4 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
disabled={pendingAction !== null}
|
|
320
|
+
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"
|
|
321
|
+
onClick={() => setConfirmingCancel(false)}
|
|
322
|
+
>
|
|
323
|
+
{t('subscriptions.keep')}
|
|
324
|
+
</button>
|
|
325
|
+
<button
|
|
326
|
+
type="button"
|
|
327
|
+
disabled={pendingAction !== null}
|
|
328
|
+
aria-busy={pendingAction === 'cancel' || undefined}
|
|
329
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
330
|
+
onClick={() => void run(
|
|
331
|
+
'cancel',
|
|
332
|
+
() => callCustomerApi((sdk) => sdk.cancelSubscription(subscription.uniqid, {
|
|
333
|
+
cancel_at_period_end: atPeriodEnd,
|
|
334
|
+
})),
|
|
335
|
+
t('subscriptions.cancelled'),
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
338
|
+
{pendingAction === 'cancel' ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
339
|
+
{pendingAction === 'cancel' ? t('subscriptions.cancelling') : t('subscriptions.cancel')}
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</AccountCard>
|
|
343
|
+
) : null}
|
|
344
|
+
|
|
345
|
+
<BillingHistory uniqid={subscription.uniqid} />
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
|
351
|
+
return (
|
|
352
|
+
<div className="sxa:flex sxa:items-center sxa:justify-between sxa:gap-4">
|
|
353
|
+
<dt className="sxa:text-muted">{label}</dt>
|
|
354
|
+
<dd className="sxa:min-w-0 sxa:truncate sxa:text-right sxa:font-medium">{children}</dd>
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function BillingHistory({ uniqid }: { uniqid: string }) {
|
|
360
|
+
const { state, reload } = useAccountResource(`billing:${uniqid}`, () =>
|
|
361
|
+
callCustomerApi((sdk) => sdk.subscriptionBillingHistory(uniqid)));
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<section className="sxa:mt-8">
|
|
365
|
+
<AccountSectionTitle>{t('subscriptions.historyTitle')}</AccountSectionTitle>
|
|
366
|
+
<div className="sxa:mt-4">
|
|
367
|
+
{state.status === 'loading' ? (
|
|
368
|
+
<AccountRowsSkeleton rows={2} label={t('subscriptions.historyLoading')} />
|
|
369
|
+
) : null}
|
|
370
|
+
|
|
371
|
+
{state.status === 'error' ? (
|
|
372
|
+
<AccountError message={state.message} onRetry={reload} />
|
|
373
|
+
) : null}
|
|
374
|
+
|
|
375
|
+
{state.status === 'ready' ? (
|
|
376
|
+
<BillingHistoryList payload={state.data} reload={reload} />
|
|
377
|
+
) : null}
|
|
378
|
+
</div>
|
|
379
|
+
</section>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function BillingHistoryList({ payload, reload }: { payload: unknown; reload: () => void }) {
|
|
384
|
+
const history = narrowBillingHistory(payload);
|
|
385
|
+
|
|
386
|
+
/*
|
|
387
|
+
* A payload that does not match the producer is not drawn as an empty
|
|
388
|
+
* history. "You were never billed" and "we could not read your invoices" are
|
|
389
|
+
* different answers, and only one of them is about the buyer's money.
|
|
390
|
+
*/
|
|
391
|
+
if (!history.ok) {
|
|
392
|
+
return <AccountError message={t('common.genericError')} onRetry={reload} />;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (history.invoices.length === 0) {
|
|
396
|
+
return (
|
|
397
|
+
<AccountEmpty
|
|
398
|
+
title={t('subscriptions.historyEmptyTitle')}
|
|
399
|
+
body={t('subscriptions.historyEmptyBody')}
|
|
400
|
+
/>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<ul className="sxa:space-y-4">
|
|
406
|
+
{history.invoices.map((invoice) => (
|
|
407
|
+
<li key={invoice.uniqid} className="sxa:flex sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card">
|
|
408
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
409
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold sxa:tabular-nums">
|
|
410
|
+
{formatAccountAmount(invoice.total, invoice.currency)}
|
|
411
|
+
</p>
|
|
412
|
+
<p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">
|
|
413
|
+
{formatAccountDate(invoice.created_at)}
|
|
414
|
+
</p>
|
|
415
|
+
</div>
|
|
416
|
+
<StatusChip status={invoice.status} />
|
|
417
|
+
</li>
|
|
418
|
+
))}
|
|
419
|
+
</ul>
|
|
420
|
+
);
|
|
421
|
+
}
|