@shoppexio/storefront-react 0.0.0 → 0.1.1

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +215 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. package/package.json +79 -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
+ }