@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -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 +75 -1
@@ -0,0 +1,699 @@
1
+ /**
2
+ * The buyer's own details, their marketing consent, and their signed-in devices.
3
+ *
4
+ * Three producers, three sections, and nothing pretending to be a fourth. The
5
+ * email address is not editable — changing it is gated behind a fresh code and
6
+ * has no allowlisted path — there is no password field, because this account
7
+ * signs in with a code, and there is no "delete account", because no endpoint
8
+ * exists. A control for any of them would be a control that cannot work.
9
+ *
10
+ * Signing a device out cannot be undone from here, so it is asked before it is
11
+ * done: the row arms, then commits. `window.confirm` is not used — it is a
12
+ * modal the theme cannot style, cannot translate and cannot place next to the
13
+ * row it is about.
14
+ */
15
+
16
+ import { useEffect, useId, useRef, useState } from 'react';
17
+ import {
18
+ describeUserAgent,
19
+ hasOtherSessions,
20
+ isAcceptableAvatarSize,
21
+ isSaveableName,
22
+ narrowCustomerSessions,
23
+ narrowEmailPreferences,
24
+ profileInitials,
25
+ type CustomerSession,
26
+ type EmailPreferences,
27
+ } from './account-settings';
28
+ import {
29
+ callCustomerApi,
30
+ safeExternalHref,
31
+ type AccountPanelState,
32
+ } from './customer-account';
33
+ import { t } from './i18n';
34
+ import { useAccountDashboard } from './dashboard';
35
+ import {
36
+ AccountCard,
37
+ AccountError,
38
+ AccountFieldError,
39
+ AccountNotice,
40
+ AccountRowsSkeleton,
41
+ AccountSectionTitle,
42
+ BusySpinner,
43
+ formatAccountDateTime,
44
+ useAccountResource,
45
+ } from './shared';
46
+
47
+ /** The contact form's control skin — see `components/contact/contact-form.tsx`. */
48
+ const FIELD =
49
+ 'sx-account-field sxa:w-full sxa:rounded-card sxa:border sxa:bg-[color-mix(in_oklab,_var(--sx-bg)_40%,_transparent)] sxa:px-3 sxa:py-2.5 sxa:text-small sxa:shadow-card sxa:placeholder:text-muted sxa:focus-visible:border-accent sxa:focus-visible:ring-[3px] sxa:focus-visible:ring-[color-mix(in_oklab,_var(--sx-accent)_50%,_transparent)] sxa:focus-visible:outline-none';
50
+
51
+ export function AccountSettings() {
52
+ const { state, reload } = useAccountDashboard();
53
+
54
+ if (state.status === 'loading') {
55
+ return <AccountRowsSkeleton rows={4} label={t('settings.loading')} />;
56
+ }
57
+
58
+ if (state.status === 'error') {
59
+ return <AccountError message={state.message} onRetry={reload} />;
60
+ }
61
+
62
+ return (
63
+ <div className="sxa:space-y-8">
64
+ <ProfileSection
65
+ name={state.data.customer.name}
66
+ email={state.data.customer.email}
67
+ onSaved={reload}
68
+ />
69
+ <EmailPreferencesSection />
70
+ <DevicesSection />
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Keeps the last COMPLETE read on screen while the next one is in flight.
77
+ *
78
+ * These two sections re-read after every write, because the producer decides
79
+ * what a change to one list does to the rest of the record. Without this the
80
+ * list a buyer just ticked would blank out on each tick and come back a moment
81
+ * later, which reads as the setting having been lost.
82
+ *
83
+ * A read that cannot be narrowed drops the held copy rather than keeping it
84
+ * beside the error: a consent state the server may no longer hold is worse than
85
+ * no consent state at all.
86
+ */
87
+ function useLastCompleteRead<T>(
88
+ state: AccountPanelState<unknown>,
89
+ narrow: (payload: unknown) => { ok: true; value: T } | { ok: false },
90
+ ): { shown: T | null; broken: boolean } {
91
+ const [shown, setShown] = useState<T | null>(null);
92
+ const [broken, setBroken] = useState(false);
93
+ const narrowRef = useRef(narrow);
94
+ narrowRef.current = narrow;
95
+
96
+ useEffect(() => {
97
+ if (state.status !== 'ready') return;
98
+ const result = narrowRef.current(state.data);
99
+ setBroken(!result.ok);
100
+ setShown(result.ok ? result.value : null);
101
+ }, [state]);
102
+
103
+ return { shown, broken };
104
+ }
105
+
106
+ /**
107
+ * What is known about the buyer's photo.
108
+ *
109
+ * `unread` is a third answer, not a spelling of "no photo". A `/me` that failed
110
+ * leaves this panel without an answer, and drawing initials there would tell a
111
+ * buyer who HAS a photo that they have none — while hiding the control that
112
+ * removes it. The absence of an answer is not evidence of absence.
113
+ */
114
+ type AvatarState =
115
+ | { status: 'reading' }
116
+ | { status: 'known'; url: string | null }
117
+ | { status: 'unread' };
118
+
119
+ function ProfileSection({
120
+ name,
121
+ email,
122
+ onSaved,
123
+ }: {
124
+ name: string;
125
+ email: string;
126
+ onSaved: () => void;
127
+ }) {
128
+ const nameId = useId();
129
+ const [value, setValue] = useState(name);
130
+ const [busy, setBusy] = useState(false);
131
+ const [notice, setNotice] = useState<string | null>(null);
132
+ const [error, setError] = useState<string | null>(null);
133
+ const [avatar, setAvatar] = useState<AvatarState>({ status: 'reading' });
134
+ const [avatarAttempt, setAvatarAttempt] = useState(0);
135
+ /** Set once a write has landed, so the mount read can no longer speak. */
136
+ const avatarSettled = useRef(false);
137
+ const [avatarBusy, setAvatarBusy] = useState<'upload' | 'remove' | null>(null);
138
+ const avatarUrl = avatar.status === 'known' ? avatar.url : null;
139
+ const fileInput = useRef<HTMLInputElement>(null);
140
+
141
+ /*
142
+ * The avatar lives on `/me`, NOT on the dashboard payload this panel is
143
+ * built from — the dashboard's customer object carries a name and an email
144
+ * and nothing else. Reading only the dashboard left every buyer who has a
145
+ * photo looking at their initials, with no way to remove the picture they
146
+ * had uploaded.
147
+ *
148
+ * Asked separately from the dashboard so that a slow or failed `/me` costs
149
+ * the avatar and nothing else: the name, the email, the preferences and the
150
+ * devices all still render.
151
+ */
152
+ useEffect(() => {
153
+ let live = true;
154
+ void callCustomerApi((sdk) => sdk.me()).then((result) => {
155
+ if (!live) return;
156
+
157
+ /*
158
+ * A slow `/me` must not undo a change the buyer already made. The file
159
+ * picker gives this read all the time it needs: the buyer opens it,
160
+ * chooses a photo, the upload lands — and this response, in flight since
161
+ * mount, would then put the OLD picture back on a screen that had
162
+ * already shown the new one. The mutation is the newer truth, so once
163
+ * one has landed this read has nothing left to say.
164
+ */
165
+ if (avatarSettled.current) return;
166
+
167
+ if (!result.success) {
168
+ // Said, not swallowed. `null` here would draw initials, which tells a
169
+ // buyer who HAS a photo that they have none — and hides the control
170
+ // that would let them remove it.
171
+ setAvatar({ status: 'unread' });
172
+ return;
173
+ }
174
+ setAvatar({ status: 'known', url: readAvatarUrl(result.data) });
175
+ });
176
+ return () => {
177
+ live = false;
178
+ };
179
+ }, [avatarAttempt]);
180
+
181
+ const save = async () => {
182
+ if (!isSaveableName(value)) {
183
+ setError(t('settings.nameTooShort'));
184
+ return;
185
+ }
186
+
187
+ setBusy(true);
188
+ setError(null);
189
+ setNotice(null);
190
+ const result = await callCustomerApi((sdk) => sdk.updateProfile({ name: value.trim() }));
191
+ setBusy(false);
192
+
193
+ if (!result.success) {
194
+ setError(result.message?.trim() || t('settings.profileFailed'));
195
+ return;
196
+ }
197
+
198
+ setNotice(t('settings.profileSaved'));
199
+ onSaved();
200
+ };
201
+
202
+ const uploadAvatar = async (file: File) => {
203
+ if (!isAcceptableAvatarSize(file.size)) {
204
+ // The producer refuses anything larger, but only after buffering it.
205
+ setError(t('settings.avatarTooLarge'));
206
+ return;
207
+ }
208
+
209
+ setAvatarBusy('upload');
210
+ setError(null);
211
+ setNotice(null);
212
+ const result = await callCustomerApi((sdk) => sdk.updateAvatar(file));
213
+ setAvatarBusy(null);
214
+
215
+ if (!result.success) {
216
+ setError(result.message?.trim() || t('settings.avatarFailed'));
217
+ return;
218
+ }
219
+
220
+ avatarSettled.current = true;
221
+ setAvatar({ status: 'known', url: readAvatarUrl(result.data) });
222
+ };
223
+
224
+ const removeAvatar = async () => {
225
+ setAvatarBusy('remove');
226
+ setError(null);
227
+ setNotice(null);
228
+ const result = await callCustomerApi((sdk) => sdk.removeAvatar());
229
+ setAvatarBusy(null);
230
+
231
+ if (!result.success) {
232
+ setError(result.message?.trim() || t('settings.avatarRemoveFailed'));
233
+ return;
234
+ }
235
+
236
+ avatarSettled.current = true;
237
+ setAvatar({ status: 'known', url: null });
238
+ };
239
+
240
+ return (
241
+ <section>
242
+ <AccountSectionTitle>{t('settings.profileTitle')}</AccountSectionTitle>
243
+
244
+ <AccountCard className="sxa:mt-4 sxa:p-6">
245
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-4">
246
+ {avatarUrl ? (
247
+ <img
248
+ src={avatarUrl}
249
+ alt=""
250
+ className="sxa:h-16 sxa:w-16 sxa:rounded-full sxa:border sxa:object-cover"
251
+ />
252
+ ) : (
253
+ <div
254
+ aria-hidden="true"
255
+ className="sxa:flex sxa:h-16 sxa:w-16 sxa:items-center sxa:justify-center sxa:rounded-full sxa:border sxa:bg-bg sxa:text-h3 sxa:font-semibold"
256
+ >
257
+ {profileInitials(value, email)}
258
+ </div>
259
+ )}
260
+
261
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
262
+ {/*
263
+ The input is cleared before every upload so that re-picking the
264
+ same file still fires a change event — otherwise a failed upload
265
+ can never be retried with the same photo.
266
+ */}
267
+ <input
268
+ ref={fileInput}
269
+ type="file"
270
+ accept="image/*"
271
+ className="sxa:hidden"
272
+ onChange={(event) => {
273
+ const file = event.target.files?.[0];
274
+ event.target.value = '';
275
+ if (file) void uploadAvatar(file);
276
+ }}
277
+ />
278
+ <button
279
+ type="button"
280
+ disabled={avatarBusy !== null}
281
+ aria-busy={avatarBusy === 'upload' || undefined}
282
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
283
+ onClick={() => fileInput.current?.click()}
284
+ >
285
+ {avatarBusy === 'upload' ? t('settings.uploadingAvatar') : t('settings.uploadAvatar')}
286
+ </button>
287
+ {avatarUrl ? (
288
+ <button
289
+ type="button"
290
+ disabled={avatarBusy !== null}
291
+ aria-busy={avatarBusy === 'remove' || undefined}
292
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
293
+ onClick={() => void removeAvatar()}
294
+ >
295
+ {avatarBusy === 'remove' ? t('settings.removingAvatar') : t('settings.removeAvatar')}
296
+ </button>
297
+ ) : null}
298
+ </div>
299
+ </div>
300
+ {avatar.status === 'unread' ? (
301
+ <p role="alert" className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2 sxa:text-micro sxa:text-muted">
302
+ {t('common.notLoaded')}
303
+ <button
304
+ type="button"
305
+ onClick={() => {
306
+ setAvatar({ status: 'reading' });
307
+ setAvatarAttempt((attempt) => attempt + 1);
308
+ }}
309
+ className="sxa:underline sxa:underline-offset-2 sxa:transition-opacity sxa:hover:opacity-80"
310
+ >
311
+ {t('common.retry')}
312
+ </button>
313
+ </p>
314
+ ) : null}
315
+ <p className="sxa:mt-2 sxa:text-micro sxa:text-muted">{t('settings.avatarHint')}</p>
316
+
317
+ <form
318
+ noValidate
319
+ className="sxa:mt-6 sxa:border-t sxa:pt-6"
320
+ onSubmit={(event) => {
321
+ event.preventDefault();
322
+ void save();
323
+ }}
324
+ >
325
+ <p className="sxa:text-small sxa:font-medium">{t('settings.emailLabel')}</p>
326
+ <p className="sxa:mt-1 sxa:truncate sxa:text-small sxa:text-muted">{email}</p>
327
+ <p className="sxa:mt-1 sxa:text-micro sxa:leading-relaxed sxa:text-muted">
328
+ {t('settings.emailNote')}
329
+ </p>
330
+
331
+ <label htmlFor={nameId} className="sxa:mt-5 sxa:mb-1.5 sxa:block sxa:text-small sxa:font-medium">
332
+ {t('settings.nameLabel')}
333
+ </label>
334
+ <input
335
+ id={nameId}
336
+ type="text"
337
+ value={value}
338
+ autoComplete="name"
339
+ disabled={busy}
340
+ aria-invalid={error ? true : undefined}
341
+ onChange={(event) => {
342
+ setValue(event.target.value);
343
+ setError(null);
344
+ }}
345
+ className={`${FIELD} sxa:max-w-md`}
346
+ />
347
+
348
+ {notice ? <AccountNotice message={notice} /> : null}
349
+ {error ? <AccountFieldError message={error} /> : null}
350
+
351
+ <button
352
+ type="submit"
353
+ disabled={busy}
354
+ aria-busy={busy || undefined}
355
+ className="sx-account-cta sxa:cta-button sxa:mt-5 sxa:inline-flex sxa:h-10 sxa:items-center sxa:px-5 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
356
+ >
357
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
358
+ {busy ? t('settings.savingProfile') : t('settings.saveProfile')}
359
+ </button>
360
+ </form>
361
+ </AccountCard>
362
+ </section>
363
+ );
364
+ }
365
+
366
+ function EmailPreferencesSection() {
367
+ const { state, reload } = useAccountResource('email-preferences', () =>
368
+ callCustomerApi((sdk) => sdk.emailPreferences()));
369
+ const { shown, broken } = useLastCompleteRead<EmailPreferences>(state, (payload) => {
370
+ const narrowed = narrowEmailPreferences(payload);
371
+ return narrowed.ok ? { ok: true, value: narrowed.preferences } : { ok: false };
372
+ });
373
+ const [pendingKey, setPendingKey] = useState<string | null>(null);
374
+ const [error, setError] = useState<string | null>(null);
375
+
376
+ const heading = <AccountSectionTitle>{t('settings.emailsTitle')}</AccountSectionTitle>;
377
+
378
+ if (state.status === 'error' || broken) {
379
+ return (
380
+ <section>
381
+ {heading}
382
+ <div className="sxa:mt-4">
383
+ <AccountError
384
+ message={state.status === 'error' ? state.message : t('settings.emailsFailed')}
385
+ onRetry={reload}
386
+ />
387
+ </div>
388
+ </section>
389
+ );
390
+ }
391
+
392
+ if (shown === null) {
393
+ return (
394
+ <section>
395
+ {heading}
396
+ <p role="status" className="sxa:mt-4 sxa:text-small sxa:text-muted">
397
+ {t('settings.emailsLoading')}
398
+ </p>
399
+ </section>
400
+ );
401
+ }
402
+
403
+ const write = async (
404
+ key: string,
405
+ call: () => Promise<{ success: boolean; message?: string }>,
406
+ ) => {
407
+ setPendingKey(key);
408
+ setError(null);
409
+ const result = await call();
410
+ setPendingKey(null);
411
+
412
+ if (!result.success) {
413
+ setError(result.message?.trim() || t('settings.emailsSaveFailed'));
414
+ return;
415
+ }
416
+
417
+ // Re-read rather than flip the box locally: the producer decides what a
418
+ // change to one list does to the suppression record, and guessing that here
419
+ // would show a consent state the server does not hold.
420
+ reload();
421
+ };
422
+
423
+ return (
424
+ <section>
425
+ {heading}
426
+ <AccountCard className="sxa:mt-4 sxa:p-6">
427
+ {error ? <AccountFieldError message={error} /> : null}
428
+
429
+ <label className="sxa:flex sxa:items-start sxa:gap-3">
430
+ <input
431
+ type="checkbox"
432
+ checked={shown.global_unsubscribed}
433
+ disabled={pendingKey !== null}
434
+ className="sxa:mt-1 sxa:h-4 sxa:w-4 sxa:shrink-0"
435
+ onChange={(event) => void write('global', () => callCustomerApi((sdk) =>
436
+ sdk.updateEmailPreferences({ global_unsubscribed: event.target.checked })))}
437
+ />
438
+ <span className="sxa:min-w-0">
439
+ <span className="sxa:block sxa:text-small sxa:font-medium">{t('settings.unsubscribeAll')}</span>
440
+ <span className="sxa:mt-1 sxa:block sxa:text-micro sxa:text-muted">
441
+ {t('settings.unsubscribeAllNote')}
442
+ </span>
443
+ </span>
444
+ </label>
445
+
446
+ <div className="sxa:mt-5 sxa:space-y-4 sxa:border-t sxa:pt-4">
447
+ {shown.lists.length === 0 ? (
448
+ <p className="sxa:text-small sxa:text-muted">{t('settings.listsEmpty')}</p>
449
+ ) : (
450
+ shown.lists.map((list) => (
451
+ <label key={list.id} className="sxa:flex sxa:items-start sxa:gap-3">
452
+ <input
453
+ type="checkbox"
454
+ checked={list.subscribed}
455
+ disabled={pendingKey !== null}
456
+ className="sxa:mt-1 sxa:h-4 sxa:w-4 sxa:shrink-0"
457
+ onChange={(event) => void write(list.id, () => callCustomerApi((sdk) =>
458
+ sdk.updateEmailPreferences({
459
+ list_subscriptions: [{ list_id: list.id, subscribed: event.target.checked }],
460
+ })))}
461
+ />
462
+ <span className="sxa:min-w-0">
463
+ <span className="sxa:block sxa:text-small sxa:font-medium">{list.name}</span>
464
+ {list.description ? (
465
+ <span className="sxa:mt-1 sxa:block sxa:text-micro sxa:text-muted">{list.description}</span>
466
+ ) : null}
467
+ </span>
468
+ </label>
469
+ ))
470
+ )}
471
+ </div>
472
+ </AccountCard>
473
+ </section>
474
+ );
475
+ }
476
+
477
+ function DevicesSection() {
478
+ const { state, reload } = useAccountResource('sessions', () =>
479
+ callCustomerApi((sdk) => sdk.sessions()));
480
+ const { shown, broken } = useLastCompleteRead<CustomerSession[]>(state, (payload) => {
481
+ const narrowed = narrowCustomerSessions(payload);
482
+ return narrowed.ok ? { ok: true, value: narrowed.sessions } : { ok: false };
483
+ });
484
+ /**
485
+ * Which row is currently asking. One value for the whole section, so opening
486
+ * a second question closes the first — two armed rows beside each other is
487
+ * how the wrong device gets signed out.
488
+ */
489
+ const [armed, setArmed] = useState<string | null>(null);
490
+ const [pendingId, setPendingId] = useState<string | null>(null);
491
+ const [error, setError] = useState<string | null>(null);
492
+ const [notice, setNotice] = useState<string | null>(null);
493
+
494
+ const heading = <AccountSectionTitle>{t('settings.devicesTitle')}</AccountSectionTitle>;
495
+
496
+ if (state.status === 'error' || broken) {
497
+ return (
498
+ <section>
499
+ {heading}
500
+ <div className="sxa:mt-4">
501
+ <AccountError
502
+ message={state.status === 'error' ? state.message : t('settings.devicesFailed')}
503
+ onRetry={reload}
504
+ />
505
+ </div>
506
+ </section>
507
+ );
508
+ }
509
+
510
+ if (shown === null) {
511
+ return (
512
+ <section>
513
+ {heading}
514
+ <p role="status" className="sxa:mt-4 sxa:text-small sxa:text-muted">
515
+ {t('settings.devicesLoading')}
516
+ </p>
517
+ </section>
518
+ );
519
+ }
520
+
521
+ const revoke = async (
522
+ id: string,
523
+ call: () => Promise<{ success: boolean; message?: string }>,
524
+ done: string | null,
525
+ ) => {
526
+ setPendingId(id);
527
+ setError(null);
528
+ setNotice(null);
529
+ const result = await call();
530
+ setPendingId(null);
531
+
532
+ if (!result.success) {
533
+ setError(result.message?.trim() || t('settings.signOutDeviceFailed'));
534
+ return;
535
+ }
536
+
537
+ setArmed(null);
538
+ if (done) setNotice(done);
539
+ reload();
540
+ };
541
+
542
+ return (
543
+ <section>
544
+ {heading}
545
+ <div className="sxa:mt-4 sxa:space-y-3">
546
+ {error ? <AccountError message={error} /> : null}
547
+ {notice ? <AccountNotice message={notice} /> : null}
548
+
549
+ {shown.length === 0 ? (
550
+ <p className="sxa:text-small sxa:text-muted">{t('settings.devicesEmpty')}</p>
551
+ ) : (
552
+ <ul className="sxa:space-y-3">
553
+ {shown.map((session) => (
554
+ <li
555
+ key={session.id}
556
+ className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:rounded-input sxa:border sxa:bg-surface sxa:p-5 sxa:shadow-card"
557
+ >
558
+ <div className="sxa:min-w-0 sxa:flex-1">
559
+ <p className="sxa:truncate sxa:text-small sxa:font-medium">
560
+ {deviceLabel(session)}
561
+ {session.current ? (
562
+ <span className="sxa:ml-2 sxa:text-micro sxa:font-normal sxa:text-muted">
563
+ {t('settings.thisDevice')}
564
+ </span>
565
+ ) : null}
566
+ </p>
567
+ <p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">
568
+ {session.ip_address ?? t('common.unavailable')}
569
+ {' · '}
570
+ {t('settings.signedInOn', {
571
+ date: formatAccountDateTime(session.created_at),
572
+ })}
573
+ </p>
574
+ </div>
575
+
576
+ {/* The session making this request is never offered a sign-out
577
+ of its own: the account shell's own control does that. */}
578
+ {session.current ? null : armed === session.id ? (
579
+ <ConfirmPair
580
+ busy={pendingId === session.id}
581
+ disabled={pendingId !== null}
582
+ confirmLabel={t('settings.signOutDevice')}
583
+ busyLabel={t('settings.signingOutDevice')}
584
+ onConfirm={() => void revoke(
585
+ session.id,
586
+ () => callCustomerApi((sdk) => sdk.revokeSession(session.id)),
587
+ null,
588
+ )}
589
+ onCancel={() => setArmed(null)}
590
+ />
591
+ ) : (
592
+ <button
593
+ type="button"
594
+ disabled={pendingId !== null}
595
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
596
+ onClick={() => setArmed(session.id)}
597
+ >
598
+ {t('settings.signOutDevice')}
599
+ </button>
600
+ )}
601
+ </li>
602
+ ))}
603
+ </ul>
604
+ )}
605
+
606
+ {hasOtherSessions(shown) ? (
607
+ armed === 'others' ? (
608
+ <ConfirmPair
609
+ busy={pendingId === 'others'}
610
+ disabled={pendingId !== null}
611
+ confirmLabel={t('settings.signOutOthers')}
612
+ busyLabel={t('settings.signingOutDevice')}
613
+ onConfirm={() => void revoke(
614
+ 'others',
615
+ () => callCustomerApi((sdk) => sdk.revokeAllSessions()),
616
+ t('settings.signOutOthersDone'),
617
+ )}
618
+ onCancel={() => setArmed(null)}
619
+ />
620
+ ) : (
621
+ <button
622
+ type="button"
623
+ disabled={pendingId !== null}
624
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
625
+ onClick={() => setArmed('others')}
626
+ >
627
+ {t('settings.signOutOthers')}
628
+ </button>
629
+ )
630
+ ) : null}
631
+ </div>
632
+ </section>
633
+ );
634
+ }
635
+
636
+ /**
637
+ * The second half of an armed control: commit, or step back.
638
+ *
639
+ * Signing a device out cannot be undone by the buyer — the device has to sign
640
+ * in again — so the plain button is only ever the question, and this pair is
641
+ * the answer.
642
+ */
643
+ export function ConfirmPair({
644
+ busy,
645
+ disabled,
646
+ confirmLabel,
647
+ busyLabel,
648
+ onConfirm,
649
+ onCancel,
650
+ }: {
651
+ busy: boolean;
652
+ disabled: boolean;
653
+ confirmLabel: string;
654
+ busyLabel: string;
655
+ onConfirm: () => void;
656
+ onCancel: () => void;
657
+ }) {
658
+ return (
659
+ <div className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
660
+ <button
661
+ type="button"
662
+ disabled={disabled}
663
+ aria-busy={busy || undefined}
664
+ className="sx-account-cta sxa:cta-button sxa:inline-flex sxa:h-9 sxa:items-center sxa:px-4 sxa:text-small sxa:font-semibold sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
665
+ onClick={onConfirm}
666
+ >
667
+ {busy ? <BusySpinner className="sxa:mr-1.5" /> : null}
668
+ {busy ? busyLabel : confirmLabel}
669
+ </button>
670
+ <button
671
+ type="button"
672
+ disabled={disabled}
673
+ className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
674
+ onClick={onCancel}
675
+ >
676
+ {t('subscriptions.keep')}
677
+ </button>
678
+ </div>
679
+ );
680
+ }
681
+
682
+ function deviceLabel(session: CustomerSession): string {
683
+ const described = describeUserAgent(session.user_agent);
684
+ if (!described) return t('settings.unknownDevice');
685
+ if (!described.os) return described.browser;
686
+ return t('settings.deviceOn', { browser: described.browser, os: described.os });
687
+ }
688
+
689
+ /**
690
+ * The avatar URL the producer answered with, or null when it sent none.
691
+ *
692
+ * Checked before it is rendered for the same reason a delivery link is: it is
693
+ * a URL this template did not write, and the check is one call away.
694
+ */
695
+ function readAvatarUrl(data: unknown): string | null {
696
+ if (typeof data !== 'object' || data === null) return null;
697
+ const url = (data as { avatar_url?: unknown }).avatar_url;
698
+ return typeof url === 'string' ? safeExternalHref(url) : null;
699
+ }