@slyxup/ui 0.2.8 → 0.2.9
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/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
- package/dist/components/UserProfile/UserProfile.js +213 -54
- package/dist/components/UserProfile/UserProfile.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +111 -5
- package/dist/styles.js.map +1 -1
- package/package.json +9 -9
- package/src/components/UserProfile/UserProfile.tsx +423 -119
- package/src/styles.ts +111 -5
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
|
@@ -13,12 +13,29 @@ export interface UserProfileProps {
|
|
|
13
13
|
|
|
14
14
|
type Tab = 'profile' | 'security' | 'billing';
|
|
15
15
|
|
|
16
|
-
function initials(user: {
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
function initials(user: {
|
|
17
|
+
firstName: string | null;
|
|
18
|
+
lastName?: string | null;
|
|
19
|
+
email: string;
|
|
20
|
+
}): string {
|
|
21
|
+
const f = user.firstName?.trim();
|
|
22
|
+
const l = user.lastName?.trim();
|
|
23
|
+
if (f && l) return (f[0] + l[0]).toUpperCase();
|
|
24
|
+
if (f) return f.slice(0, 1).toUpperCase();
|
|
25
|
+
if (l) return l.slice(0, 1).toUpperCase();
|
|
19
26
|
return user.email.slice(0, 1).toUpperCase();
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
function displayName(user: {
|
|
30
|
+
firstName: string | null;
|
|
31
|
+
lastName: string | null;
|
|
32
|
+
email: string;
|
|
33
|
+
}): string {
|
|
34
|
+
const parts = [user.firstName?.trim(), user.lastName?.trim()].filter(Boolean);
|
|
35
|
+
const name = parts.join(' ');
|
|
36
|
+
return name || user.email;
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
function deviceLabel(ua: string | null): string {
|
|
23
40
|
if (!ua) return 'Unknown device';
|
|
24
41
|
const os = /Windows/i.test(ua)
|
|
@@ -56,10 +73,25 @@ function formatDate(iso: string): string {
|
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
function formatCurrency(amount: number, currency: string): string {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
try {
|
|
77
|
+
return new Intl.NumberFormat(undefined, {
|
|
78
|
+
style: 'currency',
|
|
79
|
+
currency: currency.toUpperCase(),
|
|
80
|
+
}).format(amount / 100);
|
|
81
|
+
} catch {
|
|
82
|
+
return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Plan {
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
amount: number;
|
|
90
|
+
currency: string;
|
|
91
|
+
interval: string;
|
|
92
|
+
trialDays: number | null;
|
|
93
|
+
features: string[] | null;
|
|
94
|
+
isPopular: boolean;
|
|
63
95
|
}
|
|
64
96
|
|
|
65
97
|
export function UserProfile({
|
|
@@ -101,6 +133,7 @@ export function UserProfile({
|
|
|
101
133
|
const [subscription, setSubscription] = useState<{
|
|
102
134
|
id: string;
|
|
103
135
|
status: string;
|
|
136
|
+
planId?: string | null;
|
|
104
137
|
planName: string | null;
|
|
105
138
|
currentPeriodEnd: string | null;
|
|
106
139
|
cancelAtPeriodEnd: boolean;
|
|
@@ -114,19 +147,24 @@ export function UserProfile({
|
|
|
114
147
|
billedAt: string | null;
|
|
115
148
|
}[]
|
|
116
149
|
>([]);
|
|
150
|
+
const [plans, setPlans] = useState<Plan[]>([]);
|
|
151
|
+
const [plansLoading, setPlansLoading] = useState(false);
|
|
152
|
+
const [checkoutId, setCheckoutId] = useState<string | null>(null);
|
|
117
153
|
|
|
118
154
|
// ── Danger zone state ──
|
|
119
155
|
const [confirmText, setConfirmText] = useState('');
|
|
120
156
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
121
157
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
122
158
|
|
|
159
|
+
// Sync form fields when user loads/updates (ensures firstName/lastName show correctly
|
|
160
|
+
// after SlyxUpProvider fetches client.users.me()). Guard against stale overwrites.
|
|
123
161
|
useEffect(() => {
|
|
124
162
|
if (user) {
|
|
125
163
|
setFirstName(user.firstName ?? '');
|
|
126
164
|
setLastName(user.lastName ?? '');
|
|
127
165
|
setAvatarUrl(user.avatarUrl ?? '');
|
|
128
166
|
}
|
|
129
|
-
}, [user]);
|
|
167
|
+
}, [user?.firstName, user?.lastName, user?.avatarUrl]);
|
|
130
168
|
|
|
131
169
|
useEffect(() => {
|
|
132
170
|
function onKey(e: KeyboardEvent) {
|
|
@@ -150,40 +188,123 @@ export function UserProfile({
|
|
|
150
188
|
|
|
151
189
|
const loadBilling = useCallback(async () => {
|
|
152
190
|
setBillingLoading(true);
|
|
191
|
+
setPlansLoading(true);
|
|
153
192
|
try {
|
|
154
|
-
const
|
|
155
|
-
(client as unknown as { apiUrl: string }).apiUrl
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
const token = (client as unknown as { _token?: string })?._token;
|
|
193
|
+
const rawApiUrl =
|
|
194
|
+
(client as unknown as { apiUrl: string }).apiUrl ?? 'https://auth.slyxup.online';
|
|
195
|
+
const billingUrl = rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
|
|
196
|
+
const token = (client as unknown as { _token?: string; getToken?: () => string | undefined })?._token
|
|
197
|
+
?? (client as unknown as { getToken?: () => string | undefined })?.getToken?.();
|
|
160
198
|
const headers: Record<string, string> = {
|
|
161
199
|
'Content-Type': 'application/json',
|
|
162
200
|
};
|
|
163
201
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
202
|
+
// Also forward publishable key if available (helps billing resolve project)
|
|
203
|
+
const pubKey = (client as unknown as { publishableKey?: string })?.publishableKey;
|
|
204
|
+
if (pubKey && pubKey !== 'pk_test_missing') headers['X-Publishable-Key'] = pubKey;
|
|
205
|
+
|
|
206
|
+
// Derive projectId for plans: prefer user.projectId, then try subscription's projectId, fallback none
|
|
207
|
+
const projectId = (user as unknown as { projectId?: string | null })?.projectId ?? null;
|
|
164
208
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
fetch(`${billingUrl}/v1/invoices`, { headers, credentials: 'include' }),
|
|
171
|
-
]);
|
|
172
|
-
|
|
173
|
-
if (subRes.status === 'fulfilled' && subRes.value.ok) {
|
|
174
|
-
const sub = await subRes.value.json();
|
|
175
|
-
if (sub.ok) setSubscription(sub.subscription ?? null);
|
|
209
|
+
// Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
|
|
210
|
+
async function fetchJson(url: string) {
|
|
211
|
+
const res = await fetch(url, { headers, credentials: 'include' });
|
|
212
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
213
|
+
return res.json();
|
|
176
214
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
215
|
+
|
|
216
|
+
// Subscription
|
|
217
|
+
let subData: unknown = null;
|
|
218
|
+
for (const path of [
|
|
219
|
+
`${billingUrl}/v1/billing/subscription${projectId ? `?projectId=${projectId}` : ''}`,
|
|
220
|
+
`${billingUrl}/v1/subscription`,
|
|
221
|
+
]) {
|
|
222
|
+
try {
|
|
223
|
+
const j = await fetchJson(path) as Record<string, unknown>;
|
|
224
|
+
if (j && (j as { ok?: boolean }).ok !== false) {
|
|
225
|
+
subData = j;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// try next path
|
|
230
|
+
}
|
|
180
231
|
}
|
|
232
|
+
if (subData) {
|
|
233
|
+
const sd = subData as {
|
|
234
|
+
ok?: boolean;
|
|
235
|
+
subscription?: Record<string, unknown> | null;
|
|
236
|
+
subscriptions?: Record<string, unknown>[];
|
|
237
|
+
};
|
|
238
|
+
let sub: Record<string, unknown> | null = null;
|
|
239
|
+
if (sd.subscription !== undefined) sub = sd.subscription as Record<string, unknown> | null;
|
|
240
|
+
else if (Array.isArray(sd.subscriptions) && sd.subscriptions.length > 0) sub = sd.subscriptions[0] as Record<string, unknown>;
|
|
241
|
+
|
|
242
|
+
if (sub) {
|
|
243
|
+
setSubscription({
|
|
244
|
+
id: String(sub.id ?? ''),
|
|
245
|
+
status: String(sub.status ?? 'active'),
|
|
246
|
+
planId: (sub.planId as string | null) ?? (sub.plan_id as string | null) ?? null,
|
|
247
|
+
planName: (sub.planName as string | null) ?? (sub.plan_name as string | null) ?? (sub.name as string | null) ?? null,
|
|
248
|
+
currentPeriodEnd: (sub.currentPeriodEnd as string | null) ?? (sub.current_period_end as string | null) ?? (sub.currentPeriod_end as string | null) ?? null,
|
|
249
|
+
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd ?? sub.cancel_at_period_end ?? false),
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
setSubscription(null);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
setSubscription(null);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Invoices
|
|
259
|
+
for (const path of [`${billingUrl}/v1/billing/invoices`, `${billingUrl}/v1/invoices`]) {
|
|
260
|
+
try {
|
|
261
|
+
const invJ = (await fetchJson(path)) as { ok?: boolean; invoices?: unknown[] };
|
|
262
|
+
if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
|
|
263
|
+
setInvoices(
|
|
264
|
+
(invJ.invoices as Record<string, unknown>[]).map((inv) => ({
|
|
265
|
+
id: String(inv.id),
|
|
266
|
+
amount: Number(inv.amount ?? 0),
|
|
267
|
+
currency: String(inv.currency ?? 'USD'),
|
|
268
|
+
status: String(inv.status ?? 'pending'),
|
|
269
|
+
billedAt: (inv.billedAt as string | null) ?? (inv.billed_at as string | null) ?? null,
|
|
270
|
+
}))
|
|
271
|
+
);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// try next
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Plans (needs projectId — if missing, try without and degrade gracefully)
|
|
280
|
+
const planPaths: string[] = [];
|
|
281
|
+
if (projectId) planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
|
|
282
|
+
// Also try without projectId as last resort (will 400 but we catch)
|
|
283
|
+
planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId ?? ''}`);
|
|
284
|
+
planPaths.push(`${billingUrl}/v1/plans`);
|
|
285
|
+
|
|
286
|
+
let gotPlans = false;
|
|
287
|
+
for (const p of planPaths) {
|
|
288
|
+
try {
|
|
289
|
+
const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
|
|
290
|
+
if (pj?.ok !== false && Array.isArray(pj.plans)) {
|
|
291
|
+
setPlans(pj.plans);
|
|
292
|
+
gotPlans = true;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
} catch {
|
|
296
|
+
// continue
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!gotPlans) setPlans([]);
|
|
181
300
|
} catch {
|
|
182
|
-
// Billing unavailable — show empty state
|
|
301
|
+
// Billing unavailable — show empty state, keep plans empty
|
|
302
|
+
setPlans([]);
|
|
183
303
|
} finally {
|
|
184
304
|
setBillingLoading(false);
|
|
305
|
+
setPlansLoading(false);
|
|
185
306
|
}
|
|
186
|
-
}, [client]);
|
|
307
|
+
}, [client, user]);
|
|
187
308
|
|
|
188
309
|
useEffect(() => {
|
|
189
310
|
if (tab === 'security') void loadSessions();
|
|
@@ -237,9 +358,7 @@ export function UserProfile({
|
|
|
237
358
|
setConfirmPassword('');
|
|
238
359
|
setTimeout(() => setPwSaved(false), 3000);
|
|
239
360
|
} catch (err) {
|
|
240
|
-
setPwError(
|
|
241
|
-
err instanceof Error ? err.message : 'Failed to change password'
|
|
242
|
-
);
|
|
361
|
+
setPwError(err instanceof Error ? err.message : 'Failed to change password');
|
|
243
362
|
} finally {
|
|
244
363
|
setPwBusy(false);
|
|
245
364
|
}
|
|
@@ -273,33 +392,69 @@ export function UserProfile({
|
|
|
273
392
|
await client.auth.signOut().catch(() => undefined);
|
|
274
393
|
onDeleted?.();
|
|
275
394
|
} catch (err) {
|
|
276
|
-
setDeleteError(
|
|
277
|
-
err instanceof Error ? err.message : 'Failed to delete account'
|
|
278
|
-
);
|
|
395
|
+
setDeleteError(err instanceof Error ? err.message : 'Failed to delete account');
|
|
279
396
|
} finally {
|
|
280
397
|
setDeleteBusy(false);
|
|
281
398
|
}
|
|
282
399
|
}
|
|
283
400
|
|
|
401
|
+
async function handleCheckout(planId: string) {
|
|
402
|
+
setCheckoutId(planId);
|
|
403
|
+
try {
|
|
404
|
+
const rawApiUrl =
|
|
405
|
+
(client as unknown as { apiUrl: string }).apiUrl ?? 'https://auth.slyxup.online';
|
|
406
|
+
const billingUrl = rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
|
|
407
|
+
const token = (client as unknown as { _token?: string; getToken?: () => string | undefined })?._token
|
|
408
|
+
?? (client as unknown as { getToken?: () => string | undefined })?.getToken?.();
|
|
409
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
410
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
411
|
+
const pubKey = (client as unknown as { publishableKey?: string })?.publishableKey;
|
|
412
|
+
if (pubKey && pubKey !== 'pk_test_missing') headers['X-Publishable-Key'] = pubKey;
|
|
413
|
+
|
|
414
|
+
const res = await fetch(`${billingUrl}/v1/billing/checkout`, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers,
|
|
417
|
+
credentials: 'include',
|
|
418
|
+
body: JSON.stringify({ planId }),
|
|
419
|
+
});
|
|
420
|
+
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
421
|
+
if (res.ok && typeof data.checkoutUrl === 'string' && data.checkoutUrl) {
|
|
422
|
+
window.location.href = data.checkoutUrl as string;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : `Checkout failed (${res.status})`);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
// Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
|
|
428
|
+
console.error('[SlyxUp] checkout failed', err);
|
|
429
|
+
} finally {
|
|
430
|
+
setCheckoutId(null);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
284
434
|
if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
|
|
285
435
|
if (!user) return null;
|
|
286
436
|
|
|
287
437
|
const emailVerified = user.emailVerified;
|
|
438
|
+
const name = displayName(user as { firstName: string | null; lastName: string | null; email: string });
|
|
288
439
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
440
|
+
// Resolve current plan name via plans lookup if subscription has planId but no planName
|
|
441
|
+
const currentPlan = subscription
|
|
442
|
+
? plans.find((p) => p.id === subscription.planId) ?? null
|
|
443
|
+
: null;
|
|
444
|
+
const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
|
|
445
|
+
|
|
446
|
+
const bodyInner = (
|
|
447
|
+
<>
|
|
296
448
|
<div className="slx-profile-head">
|
|
297
449
|
<h2 className="slx-profile-title">Account settings</h2>
|
|
298
450
|
{modal && (
|
|
299
451
|
<button
|
|
300
452
|
type="button"
|
|
301
453
|
className="slx-profile-close"
|
|
302
|
-
onClick={
|
|
454
|
+
onClick={(e) => {
|
|
455
|
+
e.stopPropagation();
|
|
456
|
+
onClose?.();
|
|
457
|
+
}}
|
|
303
458
|
aria-label="Close account settings"
|
|
304
459
|
>
|
|
305
460
|
✕
|
|
@@ -342,27 +497,20 @@ export function UserProfile({
|
|
|
342
497
|
<section className="slx-profile-sec">
|
|
343
498
|
<div className="slx-avatar-row">
|
|
344
499
|
<div className="slx-avatar-lg" aria-hidden="true">
|
|
345
|
-
{user.avatarUrl ? (
|
|
346
|
-
<img src={user.avatarUrl} alt="" />
|
|
347
|
-
) : (
|
|
348
|
-
initials(user)
|
|
349
|
-
)}
|
|
500
|
+
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user as { firstName: string | null; lastName: string | null; email: string })}
|
|
350
501
|
</div>
|
|
351
|
-
<div>
|
|
352
|
-
<p className="slx-row-value" style={{ margin: 0 }}>
|
|
353
|
-
{
|
|
354
|
-
|
|
355
|
-
|
|
502
|
+
<div style={{ minWidth: 0 }}>
|
|
503
|
+
<p className="slx-row-value" style={{ margin: 0, wordBreak: 'break-word' }}>
|
|
504
|
+
{name}
|
|
505
|
+
</p>
|
|
506
|
+
<p className="slx-row-label" style={{ wordBreak: 'break-all' }}>
|
|
507
|
+
{user.email}
|
|
356
508
|
</p>
|
|
357
|
-
<p className="slx-row-label">{user.email}</p>
|
|
358
509
|
</div>
|
|
359
510
|
</div>
|
|
360
511
|
|
|
361
512
|
{saved && (
|
|
362
|
-
<p
|
|
363
|
-
className="slx-error-text"
|
|
364
|
-
style={{ color: 'var(--slx-success)' }}
|
|
365
|
-
>
|
|
513
|
+
<p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
|
|
366
514
|
Profile saved.
|
|
367
515
|
</p>
|
|
368
516
|
)}
|
|
@@ -376,6 +524,7 @@ export function UserProfile({
|
|
|
376
524
|
id="slx-up-first"
|
|
377
525
|
className="slx-input"
|
|
378
526
|
type="text"
|
|
527
|
+
autoComplete="given-name"
|
|
379
528
|
value={firstName}
|
|
380
529
|
onChange={(e) => setFirstName(e.target.value)}
|
|
381
530
|
/>
|
|
@@ -388,6 +537,7 @@ export function UserProfile({
|
|
|
388
537
|
id="slx-up-last"
|
|
389
538
|
className="slx-input"
|
|
390
539
|
type="text"
|
|
540
|
+
autoComplete="family-name"
|
|
391
541
|
value={lastName}
|
|
392
542
|
onChange={(e) => setLastName(e.target.value)}
|
|
393
543
|
/>
|
|
@@ -404,9 +554,7 @@ export function UserProfile({
|
|
|
404
554
|
value={avatarUrl}
|
|
405
555
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
406
556
|
/>
|
|
407
|
-
<p className="slx-hint">
|
|
408
|
-
Paste a public image URL for your avatar.
|
|
409
|
-
</p>
|
|
557
|
+
<p className="slx-hint">Paste a public image URL for your avatar.</p>
|
|
410
558
|
</div>
|
|
411
559
|
<button className="slx-btn" type="submit" disabled={busy}>
|
|
412
560
|
{busy ? 'Saving…' : 'Save changes'}
|
|
@@ -416,9 +564,11 @@ export function UserProfile({
|
|
|
416
564
|
|
|
417
565
|
<section className="slx-profile-sec">
|
|
418
566
|
<h3 className="slx-sec-title">Email</h3>
|
|
419
|
-
<div className="slx-row">
|
|
420
|
-
<div>
|
|
421
|
-
<p className="slx-row-value"
|
|
567
|
+
<div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
|
|
568
|
+
<div style={{ minWidth: 0 }}>
|
|
569
|
+
<p className="slx-row-value" style={{ wordBreak: 'break-all' }}>
|
|
570
|
+
{user.email}
|
|
571
|
+
</p>
|
|
422
572
|
<p className="slx-row-label">Primary email</p>
|
|
423
573
|
</div>
|
|
424
574
|
{emailVerified ? (
|
|
@@ -429,11 +579,10 @@ export function UserProfile({
|
|
|
429
579
|
display: 'inline-flex',
|
|
430
580
|
alignItems: 'center',
|
|
431
581
|
gap: 8,
|
|
582
|
+
flexWrap: 'wrap',
|
|
432
583
|
}}
|
|
433
584
|
>
|
|
434
|
-
<span className="slx-badge slx-badge-warn">
|
|
435
|
-
Unverified
|
|
436
|
-
</span>
|
|
585
|
+
<span className="slx-badge slx-badge-warn">Unverified</span>
|
|
437
586
|
<button
|
|
438
587
|
type="button"
|
|
439
588
|
className="slx-link"
|
|
@@ -455,10 +604,7 @@ export function UserProfile({
|
|
|
455
604
|
<section className="slx-profile-sec">
|
|
456
605
|
<h3 className="slx-sec-title">Change password</h3>
|
|
457
606
|
{pwSaved && (
|
|
458
|
-
<p
|
|
459
|
-
className="slx-error-text"
|
|
460
|
-
style={{ color: 'var(--slx-success)' }}
|
|
461
|
-
>
|
|
607
|
+
<p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
|
|
462
608
|
Password updated.
|
|
463
609
|
</p>
|
|
464
610
|
)}
|
|
@@ -527,18 +673,10 @@ export function UserProfile({
|
|
|
527
673
|
<div className="slx-session-meta">
|
|
528
674
|
<p className="slx-session-device">
|
|
529
675
|
{deviceLabel(s.userAgent)}
|
|
530
|
-
{s.isCurrent &&
|
|
531
|
-
<span className="slx-badge slx-badge-accent">
|
|
532
|
-
This device
|
|
533
|
-
</span>
|
|
534
|
-
)}
|
|
676
|
+
{s.isCurrent && <span className="slx-badge slx-badge-accent">This device</span>}
|
|
535
677
|
</p>
|
|
536
678
|
<p className="slx-session-sub">
|
|
537
|
-
{[
|
|
538
|
-
s.ipAddress,
|
|
539
|
-
`created ${formatDate(s.createdAt)}`,
|
|
540
|
-
`expires ${formatDate(s.expiresAt)}`,
|
|
541
|
-
]
|
|
679
|
+
{[s.ipAddress, `created ${formatDate(s.createdAt)}`, `expires ${formatDate(s.expiresAt)}`]
|
|
542
680
|
.filter(Boolean)
|
|
543
681
|
.join(' · ')}
|
|
544
682
|
</p>
|
|
@@ -563,9 +701,7 @@ export function UserProfile({
|
|
|
563
701
|
onClick={onRevokeOthers}
|
|
564
702
|
disabled={othersRevoking}
|
|
565
703
|
>
|
|
566
|
-
{othersRevoking
|
|
567
|
-
? 'Signing out…'
|
|
568
|
-
: 'Sign out other devices'}
|
|
704
|
+
{othersRevoking ? 'Signing out…' : 'Sign out other devices'}
|
|
569
705
|
</button>
|
|
570
706
|
)}
|
|
571
707
|
</>
|
|
@@ -575,9 +711,8 @@ export function UserProfile({
|
|
|
575
711
|
<section className="slx-danger-zone">
|
|
576
712
|
<p className="slx-danger-title">Danger zone</p>
|
|
577
713
|
<p className="slx-danger-desc">
|
|
578
|
-
Permanently deletes your account and all associated data.
|
|
579
|
-
|
|
580
|
-
undone.
|
|
714
|
+
Permanently deletes your account and all associated data. Active sessions are revoked immediately. This
|
|
715
|
+
cannot be undone.
|
|
581
716
|
</p>
|
|
582
717
|
{deleteError && <p className="slx-error-text">{deleteError}</p>}
|
|
583
718
|
<div className="slx-field">
|
|
@@ -616,16 +751,83 @@ export function UserProfile({
|
|
|
616
751
|
<p className="slx-hint">Loading billing information…</p>
|
|
617
752
|
</section>
|
|
618
753
|
) : !subscription ? (
|
|
619
|
-
|
|
620
|
-
<
|
|
621
|
-
|
|
622
|
-
<
|
|
623
|
-
|
|
624
|
-
You don't have a subscription yet. Choose a plan to get
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
754
|
+
<>
|
|
755
|
+
<section className="slx-profile-sec">
|
|
756
|
+
<h3 className="slx-sec-title">Subscription</h3>
|
|
757
|
+
<div className="slx-billing-card">
|
|
758
|
+
<p className="slx-billing-plan">No active subscription</p>
|
|
759
|
+
<p className="slx-billing-detail">You don't have a subscription yet. Choose a plan to get started.</p>
|
|
760
|
+
</div>
|
|
761
|
+
</section>
|
|
762
|
+
|
|
763
|
+
<section className="slx-profile-sec">
|
|
764
|
+
<h3 className="slx-sec-title">Available plans</h3>
|
|
765
|
+
{plansLoading ? (
|
|
766
|
+
<p className="slx-hint">Loading plans…</p>
|
|
767
|
+
) : plans.length === 0 ? (
|
|
768
|
+
<div className="slx-billing-card" style={{ textAlign: 'center' }}>
|
|
769
|
+
<p className="slx-billing-detail">No plans configured for this project yet.</p>
|
|
770
|
+
<p className="slx-hint" style={{ marginTop: 6 }}>
|
|
771
|
+
Ask your admin to create a plan in billing.
|
|
772
|
+
</p>
|
|
773
|
+
</div>
|
|
774
|
+
) : (
|
|
775
|
+
<div className="slx-billing-plans">
|
|
776
|
+
{plans.map((plan) => (
|
|
777
|
+
<div key={plan.id} className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}>
|
|
778
|
+
{plan.isPopular && <span className="slx-plan-badge">POPULAR</span>}
|
|
779
|
+
<p className="slx-plan-name">{plan.name}</p>
|
|
780
|
+
<p style={{ margin: '2px 0 0' }}>
|
|
781
|
+
<span className="slx-plan-price">{formatCurrency(plan.amount, plan.currency)}</span>
|
|
782
|
+
<span className="slx-plan-interval">/{plan.interval}</span>
|
|
783
|
+
</p>
|
|
784
|
+
{plan.trialDays ? (
|
|
785
|
+
<p className="slx-billing-detail" style={{ color: 'var(--slx-accent)', marginTop: 4 }}>
|
|
786
|
+
{plan.trialDays} day free trial
|
|
787
|
+
</p>
|
|
788
|
+
) : (
|
|
789
|
+
<p className="slx-billing-detail" style={{ visibility: 'hidden', marginTop: 4 }}>
|
|
790
|
+
|
|
791
|
+
</p>
|
|
792
|
+
)}
|
|
793
|
+
<ul className="slx-plan-features">
|
|
794
|
+
{(plan.features ?? []).map((f) => (
|
|
795
|
+
<li key={f}>{f}</li>
|
|
796
|
+
))}
|
|
797
|
+
</ul>
|
|
798
|
+
<button
|
|
799
|
+
type="button"
|
|
800
|
+
className="slx-btn slx-plan-cta"
|
|
801
|
+
onClick={() => handleCheckout(plan.id)}
|
|
802
|
+
disabled={checkoutId === plan.id}
|
|
803
|
+
>
|
|
804
|
+
{checkoutId === plan.id ? 'Redirecting…' : 'Choose plan'}
|
|
805
|
+
</button>
|
|
806
|
+
</div>
|
|
807
|
+
))}
|
|
808
|
+
</div>
|
|
809
|
+
)}
|
|
810
|
+
</section>
|
|
811
|
+
|
|
812
|
+
{invoices.length > 0 && (
|
|
813
|
+
<section className="slx-profile-sec">
|
|
814
|
+
<h3 className="slx-sec-title">Invoices</h3>
|
|
815
|
+
{invoices.map((inv) => (
|
|
816
|
+
<div key={inv.id} className="slx-invoice-row">
|
|
817
|
+
<span className="slx-invoice-date">{inv.billedAt ? formatDate(inv.billedAt) : '—'}</span>
|
|
818
|
+
<span className="slx-invoice-amount">{formatCurrency(inv.amount, inv.currency)}</span>
|
|
819
|
+
<span
|
|
820
|
+
className={`slx-badge ${
|
|
821
|
+
inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
|
|
822
|
+
}`}
|
|
823
|
+
>
|
|
824
|
+
{inv.status}
|
|
825
|
+
</span>
|
|
826
|
+
</div>
|
|
827
|
+
))}
|
|
828
|
+
</section>
|
|
829
|
+
)}
|
|
830
|
+
</>
|
|
629
831
|
) : (
|
|
630
832
|
<>
|
|
631
833
|
<section className="slx-profile-sec">
|
|
@@ -636,17 +838,15 @@ export function UserProfile({
|
|
|
636
838
|
display: 'flex',
|
|
637
839
|
justifyContent: 'space-between',
|
|
638
840
|
alignItems: 'flex-start',
|
|
841
|
+
gap: 12,
|
|
842
|
+
flexWrap: 'wrap',
|
|
639
843
|
}}
|
|
640
844
|
>
|
|
641
|
-
<div>
|
|
642
|
-
<p className="slx-billing-plan">
|
|
643
|
-
{subscription.planName ?? 'Subscription'}
|
|
644
|
-
</p>
|
|
845
|
+
<div style={{ minWidth: 0 }}>
|
|
846
|
+
<p className="slx-billing-plan">{resolvedPlanName ?? 'Subscription'}</p>
|
|
645
847
|
<p className="slx-billing-detail">
|
|
646
848
|
Status:{' '}
|
|
647
|
-
<span
|
|
648
|
-
className={`slx-billing-status slx-billing-status-${subscription.status}`}
|
|
649
|
-
>
|
|
849
|
+
<span className={`slx-billing-status slx-billing-status-${subscription.status}`}>
|
|
650
850
|
{subscription.status}
|
|
651
851
|
</span>
|
|
652
852
|
</p>
|
|
@@ -657,29 +857,112 @@ export function UserProfile({
|
|
|
657
857
|
: `Renews ${formatDate(subscription.currentPeriodEnd)}`}
|
|
658
858
|
</p>
|
|
659
859
|
)}
|
|
860
|
+
{subscription.cancelAtPeriodEnd && (
|
|
861
|
+
<p className="slx-billing-detail" style={{ color: 'var(--slx-danger)', fontWeight: 600 }}>
|
|
862
|
+
Scheduled to cancel at period end
|
|
863
|
+
</p>
|
|
864
|
+
)}
|
|
660
865
|
</div>
|
|
866
|
+
{currentPlan && (
|
|
867
|
+
<span className="slx-badge slx-badge-accent" style={{ flexShrink: 0 }}>
|
|
868
|
+
{formatCurrency(currentPlan.amount, currentPlan.currency)}/{currentPlan.interval}
|
|
869
|
+
</span>
|
|
870
|
+
)}
|
|
871
|
+
</div>
|
|
872
|
+
{currentPlan?.features && currentPlan.features.length > 0 && (
|
|
873
|
+
<ul className="slx-plan-features" style={{ margin: '12px 0 0' }}>
|
|
874
|
+
{currentPlan.features.map((f) => (
|
|
875
|
+
<li key={f}>{f}</li>
|
|
876
|
+
))}
|
|
877
|
+
</ul>
|
|
878
|
+
)}
|
|
879
|
+
<div className="slx-billing-actions">
|
|
880
|
+
<button type="button" className="slx-btn-secondary" onClick={() => void loadBilling()}>
|
|
881
|
+
Refresh
|
|
882
|
+
</button>
|
|
661
883
|
</div>
|
|
662
884
|
</div>
|
|
663
885
|
</section>
|
|
664
886
|
|
|
887
|
+
{/* Upgrade / Downgrade plans */}
|
|
888
|
+
{plansLoading ? (
|
|
889
|
+
<section className="slx-profile-sec">
|
|
890
|
+
<p className="slx-hint">Loading available plans…</p>
|
|
891
|
+
</section>
|
|
892
|
+
) : plans.length > 0 ? (
|
|
893
|
+
<section className="slx-profile-sec">
|
|
894
|
+
<h3 className="slx-sec-title">Available plans</h3>
|
|
895
|
+
<p className="slx-hint" style={{ marginBottom: 8 }}>
|
|
896
|
+
Switch plans anytime. Changes apply at the next billing cycle.
|
|
897
|
+
</p>
|
|
898
|
+
<div className="slx-billing-plans">
|
|
899
|
+
{plans.map((plan) => {
|
|
900
|
+
const isCurrent = subscription.planId
|
|
901
|
+
? subscription.planId === plan.id
|
|
902
|
+
: resolvedPlanName === plan.name;
|
|
903
|
+
const currentAmount = currentPlan?.amount ?? 0;
|
|
904
|
+
const isUpgrade = plan.amount > currentAmount;
|
|
905
|
+
const isDowngrade = plan.amount < currentAmount && !isCurrent;
|
|
906
|
+
return (
|
|
907
|
+
<div
|
|
908
|
+
key={plan.id}
|
|
909
|
+
className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
|
|
910
|
+
style={isCurrent ? { opacity: 0.92 } : undefined}
|
|
911
|
+
>
|
|
912
|
+
{plan.isPopular && !isCurrent && <span className="slx-plan-badge">POPULAR</span>}
|
|
913
|
+
{isCurrent && <span className="slx-plan-badge" style={{ background: 'var(--slx-success)' }}>CURRENT</span>}
|
|
914
|
+
<p className="slx-plan-name">{plan.name}</p>
|
|
915
|
+
<p style={{ margin: '2px 0 0' }}>
|
|
916
|
+
<span className="slx-plan-price">{formatCurrency(plan.amount, plan.currency)}</span>
|
|
917
|
+
<span className="slx-plan-interval">/{plan.interval}</span>
|
|
918
|
+
</p>
|
|
919
|
+
{plan.trialDays ? (
|
|
920
|
+
<p className="slx-billing-detail" style={{ color: 'var(--slx-accent)', marginTop: 4 }}>
|
|
921
|
+
{plan.trialDays} day trial
|
|
922
|
+
</p>
|
|
923
|
+
) : (
|
|
924
|
+
<p className="slx-billing-detail" style={{ visibility: 'hidden', marginTop: 4 }}>
|
|
925
|
+
|
|
926
|
+
</p>
|
|
927
|
+
)}
|
|
928
|
+
<ul className="slx-plan-features">
|
|
929
|
+
{(plan.features ?? []).map((f) => (
|
|
930
|
+
<li key={f}>{f}</li>
|
|
931
|
+
))}
|
|
932
|
+
</ul>
|
|
933
|
+
<button
|
|
934
|
+
type="button"
|
|
935
|
+
className={isCurrent ? 'slx-btn-secondary slx-plan-cta' : 'slx-btn slx-plan-cta'}
|
|
936
|
+
disabled={isCurrent || checkoutId === plan.id}
|
|
937
|
+
onClick={() => handleCheckout(plan.id)}
|
|
938
|
+
>
|
|
939
|
+
{isCurrent
|
|
940
|
+
? 'Current plan'
|
|
941
|
+
: checkoutId === plan.id
|
|
942
|
+
? 'Redirecting…'
|
|
943
|
+
: isUpgrade
|
|
944
|
+
? 'Upgrade'
|
|
945
|
+
: isDowngrade
|
|
946
|
+
? 'Downgrade'
|
|
947
|
+
: 'Switch plan'}
|
|
948
|
+
</button>
|
|
949
|
+
</div>
|
|
950
|
+
);
|
|
951
|
+
})}
|
|
952
|
+
</div>
|
|
953
|
+
</section>
|
|
954
|
+
) : null}
|
|
955
|
+
|
|
665
956
|
{invoices.length > 0 && (
|
|
666
957
|
<section className="slx-profile-sec">
|
|
667
958
|
<h3 className="slx-sec-title">Invoices</h3>
|
|
668
959
|
{invoices.map((inv) => (
|
|
669
960
|
<div key={inv.id} className="slx-invoice-row">
|
|
670
|
-
<span className="slx-invoice-date">
|
|
671
|
-
|
|
672
|
-
</span>
|
|
673
|
-
<span className="slx-invoice-amount">
|
|
674
|
-
{formatCurrency(inv.amount, inv.currency)}
|
|
675
|
-
</span>
|
|
961
|
+
<span className="slx-invoice-date">{inv.billedAt ? formatDate(inv.billedAt) : '—'}</span>
|
|
962
|
+
<span className="slx-invoice-amount">{formatCurrency(inv.amount, inv.currency)}</span>
|
|
676
963
|
<span
|
|
677
964
|
className={`slx-badge ${
|
|
678
|
-
inv.status === 'paid'
|
|
679
|
-
? 'slx-badge-ok'
|
|
680
|
-
: inv.status === 'overdue'
|
|
681
|
-
? 'slx-badge-warn'
|
|
682
|
-
: 'slx-badge-accent'
|
|
965
|
+
inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
|
|
683
966
|
}`}
|
|
684
967
|
>
|
|
685
968
|
{inv.status}
|
|
@@ -692,19 +975,40 @@ export function UserProfile({
|
|
|
692
975
|
))}
|
|
693
976
|
</div>
|
|
694
977
|
</div>
|
|
695
|
-
|
|
978
|
+
</>
|
|
696
979
|
);
|
|
697
980
|
|
|
698
|
-
if (!modal)
|
|
981
|
+
if (!modal) {
|
|
982
|
+
return (
|
|
983
|
+
<div className="slx-profile-modal" style={{ maxHeight: 'none' }}>
|
|
984
|
+
{bodyInner}
|
|
985
|
+
</div>
|
|
986
|
+
);
|
|
987
|
+
}
|
|
699
988
|
|
|
989
|
+
// Modal overlay — click on backdrop closes, click inside modal does not.
|
|
990
|
+
// Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
|
|
700
991
|
return (
|
|
701
992
|
<div
|
|
702
993
|
className="slx-overlay"
|
|
703
994
|
onMouseDown={(e) => {
|
|
704
995
|
if (e.target === e.currentTarget) onClose?.();
|
|
705
996
|
}}
|
|
997
|
+
onClick={(e) => {
|
|
998
|
+
if (e.target === e.currentTarget) onClose?.();
|
|
999
|
+
}}
|
|
1000
|
+
role="presentation"
|
|
706
1001
|
>
|
|
707
|
-
|
|
1002
|
+
<div
|
|
1003
|
+
role="dialog"
|
|
1004
|
+
aria-modal="true"
|
|
1005
|
+
aria-label="Account settings"
|
|
1006
|
+
className="slx-profile-modal"
|
|
1007
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1008
|
+
onClick={(e) => e.stopPropagation()}
|
|
1009
|
+
>
|
|
1010
|
+
{bodyInner}
|
|
1011
|
+
</div>
|
|
708
1012
|
</div>
|
|
709
1013
|
);
|
|
710
1014
|
}
|