@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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +215 -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 +79 -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
|
+
}
|