@slyxup/ui 0.2.7 → 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/README.md +4 -1
- package/dist/components/SignIn/SignIn.d.ts.map +1 -1
- package/dist/components/SignIn/SignIn.js +2 -1
- package/dist/components/SignIn/SignIn.js.map +1 -1
- package/dist/components/SignUp/SignUp.d.ts.map +1 -1
- package/dist/components/SignUp/SignUp.js +2 -1
- package/dist/components/SignUp/SignUp.js.map +1 -1
- package/dist/components/SocialButtons/SocialButtons.d.ts.map +1 -1
- package/dist/components/SocialButtons/SocialButtons.js +2 -1
- package/dist/components/SocialButtons/SocialButtons.js.map +1 -1
- package/dist/components/UserButton/UserButton.d.ts +5 -1
- package/dist/components/UserButton/UserButton.d.ts.map +1 -1
- package/dist/components/UserButton/UserButton.js +6 -2
- package/dist/components/UserButton/UserButton.js.map +1 -1
- package/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
- package/dist/components/UserProfile/UserProfile.js +248 -27
- package/dist/components/UserProfile/UserProfile.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +269 -0
- package/dist/styles.js.map +1 -1
- package/package.json +9 -9
- package/src/components/SignIn/SignIn.tsx +2 -1
- package/src/components/SignUp/SignUp.tsx +2 -1
- package/src/components/SocialButtons/SocialButtons.tsx +2 -1
- package/src/components/UserButton/UserButton.tsx +12 -2
- package/src/components/UserProfile/UserProfile.tsx +556 -61
- package/src/index.ts +12 -1
- package/src/styles.ts +269 -0
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
|
@@ -11,15 +11,31 @@ export interface UserProfileProps {
|
|
|
11
11
|
onDeleted?: () => void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
type Tab = 'profile' | 'security';
|
|
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
|
|
|
22
|
-
|
|
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
|
+
|
|
23
39
|
function deviceLabel(ua: string | null): string {
|
|
24
40
|
if (!ua) return 'Unknown device';
|
|
25
41
|
const os = /Windows/i.test(ua)
|
|
@@ -56,6 +72,28 @@ function formatDate(iso: string): string {
|
|
|
56
72
|
});
|
|
57
73
|
}
|
|
58
74
|
|
|
75
|
+
function formatCurrency(amount: number, currency: string): string {
|
|
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;
|
|
95
|
+
}
|
|
96
|
+
|
|
59
97
|
export function UserProfile({
|
|
60
98
|
modal = true,
|
|
61
99
|
onClose,
|
|
@@ -90,18 +128,43 @@ export function UserProfile({
|
|
|
90
128
|
const [revokingId, setRevokingId] = useState<string | null>(null);
|
|
91
129
|
const [othersRevoking, setOthersRevoking] = useState(false);
|
|
92
130
|
|
|
131
|
+
// ── Billing state ──
|
|
132
|
+
const [billingLoading, setBillingLoading] = useState(true);
|
|
133
|
+
const [subscription, setSubscription] = useState<{
|
|
134
|
+
id: string;
|
|
135
|
+
status: string;
|
|
136
|
+
planId?: string | null;
|
|
137
|
+
planName: string | null;
|
|
138
|
+
currentPeriodEnd: string | null;
|
|
139
|
+
cancelAtPeriodEnd: boolean;
|
|
140
|
+
} | null>(null);
|
|
141
|
+
const [invoices, setInvoices] = useState<
|
|
142
|
+
{
|
|
143
|
+
id: string;
|
|
144
|
+
amount: number;
|
|
145
|
+
currency: string;
|
|
146
|
+
status: string;
|
|
147
|
+
billedAt: string | null;
|
|
148
|
+
}[]
|
|
149
|
+
>([]);
|
|
150
|
+
const [plans, setPlans] = useState<Plan[]>([]);
|
|
151
|
+
const [plansLoading, setPlansLoading] = useState(false);
|
|
152
|
+
const [checkoutId, setCheckoutId] = useState<string | null>(null);
|
|
153
|
+
|
|
93
154
|
// ── Danger zone state ──
|
|
94
155
|
const [confirmText, setConfirmText] = useState('');
|
|
95
156
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
96
157
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
97
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.
|
|
98
161
|
useEffect(() => {
|
|
99
162
|
if (user) {
|
|
100
163
|
setFirstName(user.firstName ?? '');
|
|
101
164
|
setLastName(user.lastName ?? '');
|
|
102
165
|
setAvatarUrl(user.avatarUrl ?? '');
|
|
103
166
|
}
|
|
104
|
-
}, [user]);
|
|
167
|
+
}, [user?.firstName, user?.lastName, user?.avatarUrl]);
|
|
105
168
|
|
|
106
169
|
useEffect(() => {
|
|
107
170
|
function onKey(e: KeyboardEvent) {
|
|
@@ -123,9 +186,130 @@ export function UserProfile({
|
|
|
123
186
|
}
|
|
124
187
|
}, [client]);
|
|
125
188
|
|
|
189
|
+
const loadBilling = useCallback(async () => {
|
|
190
|
+
setBillingLoading(true);
|
|
191
|
+
setPlansLoading(true);
|
|
192
|
+
try {
|
|
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?.();
|
|
198
|
+
const headers: Record<string, string> = {
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
};
|
|
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;
|
|
208
|
+
|
|
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();
|
|
214
|
+
}
|
|
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
|
+
}
|
|
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([]);
|
|
300
|
+
} catch {
|
|
301
|
+
// Billing unavailable — show empty state, keep plans empty
|
|
302
|
+
setPlans([]);
|
|
303
|
+
} finally {
|
|
304
|
+
setBillingLoading(false);
|
|
305
|
+
setPlansLoading(false);
|
|
306
|
+
}
|
|
307
|
+
}, [client, user]);
|
|
308
|
+
|
|
126
309
|
useEffect(() => {
|
|
127
310
|
if (tab === 'security') void loadSessions();
|
|
128
|
-
|
|
311
|
+
if (tab === 'billing') void loadBilling();
|
|
312
|
+
}, [tab, loadSessions, loadBilling]);
|
|
129
313
|
|
|
130
314
|
async function onProfileSubmit(e: FormEvent) {
|
|
131
315
|
e.preventDefault();
|
|
@@ -174,9 +358,7 @@ export function UserProfile({
|
|
|
174
358
|
setConfirmPassword('');
|
|
175
359
|
setTimeout(() => setPwSaved(false), 3000);
|
|
176
360
|
} catch (err) {
|
|
177
|
-
setPwError(
|
|
178
|
-
err instanceof Error ? err.message : 'Failed to change password'
|
|
179
|
-
);
|
|
361
|
+
setPwError(err instanceof Error ? err.message : 'Failed to change password');
|
|
180
362
|
} finally {
|
|
181
363
|
setPwBusy(false);
|
|
182
364
|
}
|
|
@@ -210,33 +392,69 @@ export function UserProfile({
|
|
|
210
392
|
await client.auth.signOut().catch(() => undefined);
|
|
211
393
|
onDeleted?.();
|
|
212
394
|
} catch (err) {
|
|
213
|
-
setDeleteError(
|
|
214
|
-
err instanceof Error ? err.message : 'Failed to delete account'
|
|
215
|
-
);
|
|
395
|
+
setDeleteError(err instanceof Error ? err.message : 'Failed to delete account');
|
|
216
396
|
} finally {
|
|
217
397
|
setDeleteBusy(false);
|
|
218
398
|
}
|
|
219
399
|
}
|
|
220
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
|
+
|
|
221
434
|
if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
|
|
222
435
|
if (!user) return null;
|
|
223
436
|
|
|
224
437
|
const emailVerified = user.emailVerified;
|
|
438
|
+
const name = displayName(user as { firstName: string | null; lastName: string | null; email: string });
|
|
225
439
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
<>
|
|
233
448
|
<div className="slx-profile-head">
|
|
234
449
|
<h2 className="slx-profile-title">Account settings</h2>
|
|
235
450
|
{modal && (
|
|
236
451
|
<button
|
|
237
452
|
type="button"
|
|
238
453
|
className="slx-profile-close"
|
|
239
|
-
onClick={
|
|
454
|
+
onClick={(e) => {
|
|
455
|
+
e.stopPropagation();
|
|
456
|
+
onClose?.();
|
|
457
|
+
}}
|
|
240
458
|
aria-label="Close account settings"
|
|
241
459
|
>
|
|
242
460
|
✕
|
|
@@ -262,28 +480,37 @@ export function UserProfile({
|
|
|
262
480
|
>
|
|
263
481
|
<ShieldIcon /> Security
|
|
264
482
|
</button>
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
className={`slx-profile-nav-btn${tab === 'billing' ? ' on' : ''}`}
|
|
486
|
+
onClick={() => setTab('billing')}
|
|
487
|
+
aria-current={tab === 'billing' ? 'page' : undefined}
|
|
488
|
+
>
|
|
489
|
+
<CreditCardIcon /> Billing
|
|
490
|
+
</button>
|
|
265
491
|
</nav>
|
|
266
492
|
|
|
267
493
|
<div className="slx-profile-content">
|
|
268
|
-
{
|
|
494
|
+
{/* ── Profile Tab ── */}
|
|
495
|
+
{tab === 'profile' && (
|
|
269
496
|
<>
|
|
270
497
|
<section className="slx-profile-sec">
|
|
271
498
|
<div className="slx-avatar-row">
|
|
272
499
|
<div className="slx-avatar-lg" aria-hidden="true">
|
|
273
|
-
{user.avatarUrl ? (
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
500
|
+
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user as { firstName: string | null; lastName: string | null; email: string })}
|
|
501
|
+
</div>
|
|
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}
|
|
508
|
+
</p>
|
|
278
509
|
</div>
|
|
279
|
-
<p className="slx-hint" style={{ margin: 0 }}>
|
|
280
|
-
Your avatar is loaded from its URL. Paste any public image
|
|
281
|
-
link below.
|
|
282
|
-
</p>
|
|
283
510
|
</div>
|
|
284
511
|
|
|
285
512
|
{saved && (
|
|
286
|
-
<p className="slx-error-text" style={{ color: '
|
|
513
|
+
<p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
|
|
287
514
|
Profile saved.
|
|
288
515
|
</p>
|
|
289
516
|
)}
|
|
@@ -297,6 +524,7 @@ export function UserProfile({
|
|
|
297
524
|
id="slx-up-first"
|
|
298
525
|
className="slx-input"
|
|
299
526
|
type="text"
|
|
527
|
+
autoComplete="given-name"
|
|
300
528
|
value={firstName}
|
|
301
529
|
onChange={(e) => setFirstName(e.target.value)}
|
|
302
530
|
/>
|
|
@@ -309,6 +537,7 @@ export function UserProfile({
|
|
|
309
537
|
id="slx-up-last"
|
|
310
538
|
className="slx-input"
|
|
311
539
|
type="text"
|
|
540
|
+
autoComplete="family-name"
|
|
312
541
|
value={lastName}
|
|
313
542
|
onChange={(e) => setLastName(e.target.value)}
|
|
314
543
|
/>
|
|
@@ -325,6 +554,7 @@ export function UserProfile({
|
|
|
325
554
|
value={avatarUrl}
|
|
326
555
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
|
327
556
|
/>
|
|
557
|
+
<p className="slx-hint">Paste a public image URL for your avatar.</p>
|
|
328
558
|
</div>
|
|
329
559
|
<button className="slx-btn" type="submit" disabled={busy}>
|
|
330
560
|
{busy ? 'Saving…' : 'Save changes'}
|
|
@@ -334,24 +564,25 @@ export function UserProfile({
|
|
|
334
564
|
|
|
335
565
|
<section className="slx-profile-sec">
|
|
336
566
|
<h3 className="slx-sec-title">Email</h3>
|
|
337
|
-
<div className="slx-row">
|
|
338
|
-
<div>
|
|
339
|
-
<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>
|
|
340
572
|
<p className="slx-row-label">Primary email</p>
|
|
341
573
|
</div>
|
|
342
574
|
{emailVerified ? (
|
|
343
|
-
<span className="slx-badge slx-badge-ok"
|
|
575
|
+
<span className="slx-badge slx-badge-ok">Verified</span>
|
|
344
576
|
) : (
|
|
345
577
|
<span
|
|
346
578
|
style={{
|
|
347
579
|
display: 'inline-flex',
|
|
348
580
|
alignItems: 'center',
|
|
349
581
|
gap: 8,
|
|
582
|
+
flexWrap: 'wrap',
|
|
350
583
|
}}
|
|
351
584
|
>
|
|
352
|
-
<span className="slx-badge slx-badge-warn">
|
|
353
|
-
Unverified
|
|
354
|
-
</span>
|
|
585
|
+
<span className="slx-badge slx-badge-warn">Unverified</span>
|
|
355
586
|
<button
|
|
356
587
|
type="button"
|
|
357
588
|
className="slx-link"
|
|
@@ -365,12 +596,15 @@ export function UserProfile({
|
|
|
365
596
|
</div>
|
|
366
597
|
</section>
|
|
367
598
|
</>
|
|
368
|
-
)
|
|
599
|
+
)}
|
|
600
|
+
|
|
601
|
+
{/* ── Security Tab ── */}
|
|
602
|
+
{tab === 'security' && (
|
|
369
603
|
<>
|
|
370
604
|
<section className="slx-profile-sec">
|
|
371
605
|
<h3 className="slx-sec-title">Change password</h3>
|
|
372
606
|
{pwSaved && (
|
|
373
|
-
<p className="slx-error-text" style={{ color: '
|
|
607
|
+
<p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
|
|
374
608
|
Password updated.
|
|
375
609
|
</p>
|
|
376
610
|
)}
|
|
@@ -430,6 +664,8 @@ export function UserProfile({
|
|
|
430
664
|
<h3 className="slx-sec-title">Active sessions</h3>
|
|
431
665
|
{sessionsLoading ? (
|
|
432
666
|
<p className="slx-hint">Loading sessions…</p>
|
|
667
|
+
) : sessions.length === 0 ? (
|
|
668
|
+
<p className="slx-hint">No active sessions.</p>
|
|
433
669
|
) : (
|
|
434
670
|
<>
|
|
435
671
|
{sessions.map((s) => (
|
|
@@ -437,18 +673,10 @@ export function UserProfile({
|
|
|
437
673
|
<div className="slx-session-meta">
|
|
438
674
|
<p className="slx-session-device">
|
|
439
675
|
{deviceLabel(s.userAgent)}
|
|
440
|
-
{s.isCurrent &&
|
|
441
|
-
<span className="slx-badge slx-badge-accent">
|
|
442
|
-
This device
|
|
443
|
-
</span>
|
|
444
|
-
)}
|
|
676
|
+
{s.isCurrent && <span className="slx-badge slx-badge-accent">This device</span>}
|
|
445
677
|
</p>
|
|
446
678
|
<p className="slx-session-sub">
|
|
447
|
-
{[
|
|
448
|
-
s.ipAddress,
|
|
449
|
-
`created ${formatDate(s.createdAt)}`,
|
|
450
|
-
`expires ${formatDate(s.expiresAt)}`,
|
|
451
|
-
]
|
|
679
|
+
{[s.ipAddress, `created ${formatDate(s.createdAt)}`, `expires ${formatDate(s.expiresAt)}`]
|
|
452
680
|
.filter(Boolean)
|
|
453
681
|
.join(' · ')}
|
|
454
682
|
</p>
|
|
@@ -469,13 +697,11 @@ export function UserProfile({
|
|
|
469
697
|
<button
|
|
470
698
|
type="button"
|
|
471
699
|
className="slx-btn-danger-outline"
|
|
472
|
-
style={{ width: '100%',
|
|
700
|
+
style={{ width: '100%', marginTop: 4 }}
|
|
473
701
|
onClick={onRevokeOthers}
|
|
474
702
|
disabled={othersRevoking}
|
|
475
703
|
>
|
|
476
|
-
{othersRevoking
|
|
477
|
-
? 'Signing out…'
|
|
478
|
-
: 'Sign out other devices'}
|
|
704
|
+
{othersRevoking ? 'Signing out…' : 'Sign out other devices'}
|
|
479
705
|
</button>
|
|
480
706
|
)}
|
|
481
707
|
</>
|
|
@@ -485,9 +711,8 @@ export function UserProfile({
|
|
|
485
711
|
<section className="slx-danger-zone">
|
|
486
712
|
<p className="slx-danger-title">Danger zone</p>
|
|
487
713
|
<p className="slx-danger-desc">
|
|
488
|
-
Permanently deletes your account and all associated data.
|
|
489
|
-
|
|
490
|
-
undone.
|
|
714
|
+
Permanently deletes your account and all associated data. Active sessions are revoked immediately. This
|
|
715
|
+
cannot be undone.
|
|
491
716
|
</p>
|
|
492
717
|
{deleteError && <p className="slx-error-text">{deleteError}</p>}
|
|
493
718
|
<div className="slx-field">
|
|
@@ -518,21 +743,272 @@ export function UserProfile({
|
|
|
518
743
|
</section>
|
|
519
744
|
</>
|
|
520
745
|
)}
|
|
746
|
+
|
|
747
|
+
{/* ── Billing Tab ── */}
|
|
748
|
+
{tab === 'billing' &&
|
|
749
|
+
(billingLoading ? (
|
|
750
|
+
<section className="slx-profile-sec">
|
|
751
|
+
<p className="slx-hint">Loading billing information…</p>
|
|
752
|
+
</section>
|
|
753
|
+
) : !subscription ? (
|
|
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
|
+
</>
|
|
831
|
+
) : (
|
|
832
|
+
<>
|
|
833
|
+
<section className="slx-profile-sec">
|
|
834
|
+
<h3 className="slx-sec-title">Current plan</h3>
|
|
835
|
+
<div className="slx-billing-card">
|
|
836
|
+
<div
|
|
837
|
+
style={{
|
|
838
|
+
display: 'flex',
|
|
839
|
+
justifyContent: 'space-between',
|
|
840
|
+
alignItems: 'flex-start',
|
|
841
|
+
gap: 12,
|
|
842
|
+
flexWrap: 'wrap',
|
|
843
|
+
}}
|
|
844
|
+
>
|
|
845
|
+
<div style={{ minWidth: 0 }}>
|
|
846
|
+
<p className="slx-billing-plan">{resolvedPlanName ?? 'Subscription'}</p>
|
|
847
|
+
<p className="slx-billing-detail">
|
|
848
|
+
Status:{' '}
|
|
849
|
+
<span className={`slx-billing-status slx-billing-status-${subscription.status}`}>
|
|
850
|
+
{subscription.status}
|
|
851
|
+
</span>
|
|
852
|
+
</p>
|
|
853
|
+
{subscription.currentPeriodEnd && (
|
|
854
|
+
<p className="slx-billing-detail">
|
|
855
|
+
{subscription.cancelAtPeriodEnd
|
|
856
|
+
? `Cancels ${formatDate(subscription.currentPeriodEnd)}`
|
|
857
|
+
: `Renews ${formatDate(subscription.currentPeriodEnd)}`}
|
|
858
|
+
</p>
|
|
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
|
+
)}
|
|
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>
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
</section>
|
|
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
|
+
|
|
956
|
+
{invoices.length > 0 && (
|
|
957
|
+
<section className="slx-profile-sec">
|
|
958
|
+
<h3 className="slx-sec-title">Invoices</h3>
|
|
959
|
+
{invoices.map((inv) => (
|
|
960
|
+
<div key={inv.id} className="slx-invoice-row">
|
|
961
|
+
<span className="slx-invoice-date">{inv.billedAt ? formatDate(inv.billedAt) : '—'}</span>
|
|
962
|
+
<span className="slx-invoice-amount">{formatCurrency(inv.amount, inv.currency)}</span>
|
|
963
|
+
<span
|
|
964
|
+
className={`slx-badge ${
|
|
965
|
+
inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
|
|
966
|
+
}`}
|
|
967
|
+
>
|
|
968
|
+
{inv.status}
|
|
969
|
+
</span>
|
|
970
|
+
</div>
|
|
971
|
+
))}
|
|
972
|
+
</section>
|
|
973
|
+
)}
|
|
974
|
+
</>
|
|
975
|
+
))}
|
|
521
976
|
</div>
|
|
522
977
|
</div>
|
|
523
|
-
|
|
978
|
+
</>
|
|
524
979
|
);
|
|
525
980
|
|
|
526
|
-
if (!modal)
|
|
981
|
+
if (!modal) {
|
|
982
|
+
return (
|
|
983
|
+
<div className="slx-profile-modal" style={{ maxHeight: 'none' }}>
|
|
984
|
+
{bodyInner}
|
|
985
|
+
</div>
|
|
986
|
+
);
|
|
987
|
+
}
|
|
527
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.
|
|
528
991
|
return (
|
|
529
992
|
<div
|
|
530
993
|
className="slx-overlay"
|
|
531
994
|
onMouseDown={(e) => {
|
|
532
995
|
if (e.target === e.currentTarget) onClose?.();
|
|
533
996
|
}}
|
|
997
|
+
onClick={(e) => {
|
|
998
|
+
if (e.target === e.currentTarget) onClose?.();
|
|
999
|
+
}}
|
|
1000
|
+
role="presentation"
|
|
534
1001
|
>
|
|
535
|
-
|
|
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>
|
|
536
1012
|
</div>
|
|
537
1013
|
);
|
|
538
1014
|
}
|
|
@@ -573,3 +1049,22 @@ function ShieldIcon() {
|
|
|
573
1049
|
</svg>
|
|
574
1050
|
);
|
|
575
1051
|
}
|
|
1052
|
+
|
|
1053
|
+
function CreditCardIcon() {
|
|
1054
|
+
return (
|
|
1055
|
+
<svg
|
|
1056
|
+
width="15"
|
|
1057
|
+
height="15"
|
|
1058
|
+
viewBox="0 0 24 24"
|
|
1059
|
+
fill="none"
|
|
1060
|
+
stroke="currentColor"
|
|
1061
|
+
strokeWidth="2"
|
|
1062
|
+
strokeLinecap="round"
|
|
1063
|
+
strokeLinejoin="round"
|
|
1064
|
+
aria-hidden="true"
|
|
1065
|
+
>
|
|
1066
|
+
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
|
1067
|
+
<line x1="1" y1="10" x2="23" y2="10" />
|
|
1068
|
+
</svg>
|
|
1069
|
+
);
|
|
1070
|
+
}
|