@slyxup/ui 0.2.15 → 0.3.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.
@@ -1,1776 +0,0 @@
1
- import type { SlyxupSessionInfo } from '@slyxup/core';
2
- import { useAuth, useUser } from '@slyxup/react';
3
- import {
4
- type FormEvent,
5
- useCallback,
6
- useEffect,
7
- useRef,
8
- useState,
9
- } from 'react';
10
- import { initPaddle, openPaddleCheckout } from '../../lib/paddle';
11
- import { injectStyles } from '../../styles';
12
-
13
- export interface UserProfileProps {
14
- /** Render as a centered modal overlay (default). Set false for inline usage. */
15
- modal?: boolean;
16
- onClose?: () => void;
17
- /** Called after the account is deleted — redirect or reset app state here. */
18
- onDeleted?: () => void;
19
- }
20
-
21
- type Tab = 'profile' | 'security' | 'billing';
22
-
23
- function initials(user: {
24
- firstName: string | null;
25
- lastName?: string | null;
26
- email: string;
27
- }): string {
28
- const f = user.firstName?.trim();
29
- const l = user.lastName?.trim();
30
- if (f && l) return (f[0] + l[0]).toUpperCase();
31
- if (f) return f.slice(0, 1).toUpperCase();
32
- if (l) return l.slice(0, 1).toUpperCase();
33
- return user.email.slice(0, 1).toUpperCase();
34
- }
35
-
36
- function displayName(user: {
37
- firstName: string | null;
38
- lastName: string | null;
39
- email: string;
40
- }): string {
41
- const parts = [user.firstName?.trim(), user.lastName?.trim()].filter(Boolean);
42
- const name = parts.join(' ');
43
- return name || user.email;
44
- }
45
-
46
- function deviceLabel(ua: string | null): string {
47
- if (!ua) return 'Unknown device';
48
- const os = /Windows/i.test(ua)
49
- ? 'Windows'
50
- : /Mac OS X|Macintosh/i.test(ua)
51
- ? 'macOS'
52
- : /Android/i.test(ua)
53
- ? 'Android'
54
- : /iPhone|iPad|iPod/i.test(ua)
55
- ? 'iOS'
56
- : /CrOS/i.test(ua)
57
- ? 'Chrome OS'
58
- : /Linux/i.test(ua)
59
- ? 'Linux'
60
- : 'Unknown OS';
61
- // Order matters: check Edge before Chrome (Edg/ appears in Chrome UA)
62
- const browser = /Edg\//i.test(ua)
63
- ? 'Edge'
64
- : /OPR|Opera/i.test(ua)
65
- ? 'Opera'
66
- : /Vivaldi/i.test(ua)
67
- ? 'Vivaldi'
68
- : /Brave/i.test(ua)
69
- ? 'Brave'
70
- : /Chrome\//i.test(ua) && !/CriOS/i.test(ua)
71
- ? 'Chrome'
72
- : /Safari\//i.test(ua) && !/Chrome\//i.test(ua)
73
- ? 'Safari'
74
- : /Firefox\//i.test(ua) || /FxiOS/i.test(ua)
75
- ? 'Firefox'
76
- : /SamsungBrowser/i.test(ua)
77
- ? 'Samsung Browser'
78
- : /Mobile/i.test(ua) || /Android/i.test(ua)
79
- ? 'Mobile Browser'
80
- : 'Browser';
81
- return `${browser} · ${os}`;
82
- }
83
-
84
- function formatDate(iso: string): string {
85
- const d = new Date(iso);
86
- return Number.isNaN(d.getTime())
87
- ? iso
88
- : d.toLocaleDateString(undefined, {
89
- month: 'short',
90
- day: 'numeric',
91
- year: 'numeric',
92
- });
93
- }
94
-
95
- function formatCurrency(amount: number, currency: string): string {
96
- try {
97
- return new Intl.NumberFormat(undefined, {
98
- style: 'currency',
99
- currency: currency.toUpperCase(),
100
- }).format(amount / 100);
101
- } catch {
102
- return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
103
- }
104
- }
105
-
106
- interface Plan {
107
- id: string;
108
- name: string;
109
- paddlePriceId: string;
110
- amount: number;
111
- currency: string;
112
- interval: string;
113
- trialDays: number | null;
114
- features: string[] | null;
115
- isPopular: boolean;
116
- }
117
-
118
- export function UserProfile({
119
- modal = true,
120
- onClose,
121
- onDeleted,
122
- }: UserProfileProps) {
123
- injectStyles();
124
- const { isLoaded, user, reload } = useUser();
125
- const { client } = useAuth();
126
-
127
- const [tab, setTab] = useState<Tab>('profile');
128
-
129
- // ── Profile form state ──
130
- const [firstName, setFirstName] = useState('');
131
- const [lastName, setLastName] = useState('');
132
- const [username, setUsername] = useState('');
133
- const [avatarUrl, setAvatarUrl] = useState('');
134
- const [busy, setBusy] = useState(false);
135
- const [saved, setSaved] = useState(false);
136
- const [resending, setResending] = useState(false);
137
- const [resent, setResent] = useState(false);
138
-
139
- // ── Password form state ──
140
- const [currentPassword, setCurrentPassword] = useState('');
141
- const [newPassword, setNewPassword] = useState('');
142
- const [confirmPassword, setConfirmPassword] = useState('');
143
- const [pwBusy, setPwBusy] = useState(false);
144
- const [pwError, setPwError] = useState<string | null>(null);
145
- const [pwSaved, setPwSaved] = useState(false);
146
-
147
- // ── Sessions state ──
148
- const [sessions, setSessions] = useState<SlyxupSessionInfo[]>([]);
149
- const [sessionsLoading, setSessionsLoading] = useState(true);
150
- const [revokingId, setRevokingId] = useState<string | null>(null);
151
- const [othersRevoking, setOthersRevoking] = useState(false);
152
- const [sessionsTotal, setSessionsTotal] = useState(0);
153
- const [sessionsPage, setSessionsPage] = useState(0);
154
- const SESSIONS_PER_PAGE = 10;
155
-
156
- // ── Billing state ──
157
- const [billingLoading, setBillingLoading] = useState(true);
158
- const [subscription, setSubscription] = useState<{
159
- id: string;
160
- status: string;
161
- planId?: string | null;
162
- planName: string | null;
163
- currentPeriodEnd: string | null;
164
- cancelAtPeriodEnd: boolean;
165
- } | null>(null);
166
- const [invoices, setInvoices] = useState<
167
- {
168
- id: string;
169
- amount: number;
170
- currency: string;
171
- status: string;
172
- billedAt: string | null;
173
- }[]
174
- >([]);
175
- const [plans, setPlans] = useState<Plan[]>([]);
176
- const [plansLoading, setPlansLoading] = useState(false);
177
- // Project whose plans/subscription are shown — used to attribute checkout customData.
178
- const [projectId, setProjectId] = useState<string | null>(null);
179
- const [checkoutId, setCheckoutId] = useState<string | null>(null);
180
- const [checkoutDone, setCheckoutDone] = useState(false);
181
- const [checkoutSuccess, setCheckoutSuccess] = useState(false);
182
- // Keep the latest subscription available to the async polling loop (avoids
183
- // stale-closure reads of state inside setTimeout callbacks).
184
- const subscriptionRef = useRef(subscription);
185
- useEffect(() => {
186
- subscriptionRef.current = subscription;
187
- }, [subscription]);
188
-
189
- // ── Danger zone state ──
190
- const [confirmText, setConfirmText] = useState('');
191
- const [deleteBusy, setDeleteBusy] = useState(false);
192
- const [deleteError, setDeleteError] = useState<string | null>(null);
193
-
194
- // ── Two-factor (TOTP) state ──
195
- const [tfaStage, setTfaStage] = useState<'idle' | 'setup' | 'codes'>('idle');
196
- const [tfaSetup, setTfaSetup] = useState<{
197
- secret: string;
198
- provisioningUri: string;
199
- accountName: string;
200
- } | null>(null);
201
- const [tfaCode, setTfaCode] = useState('');
202
- const [tfaRecoveryCodes, setTfaRecoveryCodes] = useState<string[]>([]);
203
- const [tfaBusy, setTfaBusy] = useState(false);
204
- const [tfaError, setTfaError] = useState<string | null>(null);
205
- const [tfaVerifyCode, setTfaVerifyCode] = useState('');
206
-
207
- // ── Connected accounts state ──
208
- const [accounts, setAccounts] = useState<
209
- { id: string; provider: 'google' | 'github'; createdAt: string }[]
210
- >([]);
211
- const [accountsLoading, setAccountsLoading] = useState(false);
212
- const [accountsError, setAccountsError] = useState<string | null>(null);
213
- const [unlinkingId, setUnlinkingId] = useState<string | null>(null);
214
-
215
- // Sync form fields when user loads/updates. Use individual primitives as
216
- // deps so the effect fires even when the user object reference stays the
217
- // same (e.g. after a silent reload that returns identical data).
218
- // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — see comment above
219
- useEffect(() => {
220
- if (user) {
221
- setFirstName(user.firstName ?? '');
222
- setLastName(user.lastName ?? '');
223
- setUsername(user.username ?? '');
224
- setAvatarUrl(user.avatarUrl ?? '');
225
- }
226
- }, [
227
- user?.firstName,
228
- user?.lastName,
229
- user?.username,
230
- user?.avatarUrl,
231
- user?.id,
232
- ]);
233
-
234
- useEffect(() => {
235
- function onKey(e: KeyboardEvent) {
236
- if (e.key === 'Escape' && modal) onClose?.();
237
- }
238
- document.addEventListener('keydown', onKey);
239
- return () => document.removeEventListener('keydown', onKey);
240
- }, [modal, onClose]);
241
-
242
- const loadSessions = useCallback(
243
- async (page = 0) => {
244
- setSessionsLoading(true);
245
- try {
246
- const res = await client.sessions.list({
247
- limit: SESSIONS_PER_PAGE,
248
- offset: page * SESSIONS_PER_PAGE,
249
- });
250
- setSessions(res.sessions);
251
- setSessionsTotal(res.total);
252
- setSessionsPage(page);
253
- } catch {
254
- setSessions([]);
255
- setSessionsTotal(0);
256
- } finally {
257
- setSessionsLoading(false);
258
- }
259
- },
260
- [client]
261
- );
262
-
263
- const loadBilling = useCallback(async () => {
264
- setBillingLoading(true);
265
- setPlansLoading(true);
266
- try {
267
- const rawApiUrl =
268
- (client as unknown as { apiUrl: string }).apiUrl ??
269
- 'https://auth.slyxup.online';
270
- const billingUrl = (() => {
271
- // Localhost: swap port 8787 → 8788 (auth → billing)
272
- if (/^https?:\/\/localhost(:\d+)?$/.test(rawApiUrl)) {
273
- return rawApiUrl.replace(/:(\d+)$/, ':8788');
274
- }
275
- return rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
276
- })();
277
- const token =
278
- (
279
- client as unknown as { getToken?: () => string | undefined }
280
- ).getToken?.() ??
281
- (
282
- client as unknown as {
283
- _token?: string;
284
- }
285
- )?._token;
286
- const headers: Record<string, string> = {
287
- 'Content-Type': 'application/json',
288
- };
289
- if (token) headers.Authorization = `Bearer ${token}`;
290
- // Also forward publishable key if available (helps billing resolve project)
291
- const pubKey = (client as unknown as { publishableKey?: string })
292
- ?.publishableKey;
293
- if (pubKey && pubKey !== 'pk_test_missing')
294
- headers['X-Publishable-Key'] = pubKey;
295
-
296
- // Derive projectId for plans: prefer user.projectId, then try to resolve from publishableKey's project (for examples)
297
- // For the example apps, the publishableKey is for the example project, not the user's projectId
298
- const projectId: string | null =
299
- (user as unknown as { projectId?: string | null })?.projectId ?? null;
300
- setProjectId(projectId);
301
-
302
- // Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
303
- async function fetchJson(url: string) {
304
- const res = await fetch(url, { headers, credentials: 'include' });
305
- if (!res.ok) throw new Error(String(res.status));
306
- return res.json();
307
- }
308
-
309
- // Subscription
310
- let subData: unknown = null;
311
- for (const path of [
312
- `${billingUrl}/v1/billing/subscription${projectId ? `?projectId=${projectId}` : ''}`,
313
- `${billingUrl}/v1/subscription`,
314
- ]) {
315
- try {
316
- const j = (await fetchJson(path)) as Record<string, unknown>;
317
- if (j && (j as { ok?: boolean }).ok !== false) {
318
- subData = j;
319
- break;
320
- }
321
- } catch {
322
- // try next path
323
- }
324
- }
325
- if (subData) {
326
- const sd = subData as {
327
- ok?: boolean;
328
- subscription?: Record<string, unknown> | null;
329
- subscriptions?: Record<string, unknown>[];
330
- };
331
- let sub: Record<string, unknown> | null = null;
332
- if (sd.subscription !== undefined)
333
- sub = sd.subscription as Record<string, unknown> | null;
334
- else if (Array.isArray(sd.subscriptions) && sd.subscriptions.length > 0)
335
- sub = sd.subscriptions[0] as Record<string, unknown>;
336
-
337
- if (sub) {
338
- setSubscription({
339
- id: String(sub.id ?? ''),
340
- status: String(sub.status ?? 'active'),
341
- planId:
342
- (sub.planId as string | null) ??
343
- (sub.plan_id as string | null) ??
344
- null,
345
- planName:
346
- (sub.planName as string | null) ??
347
- (sub.plan_name as string | null) ??
348
- (sub.name as string | null) ??
349
- null,
350
- currentPeriodEnd:
351
- (sub.currentPeriodEnd as string | null) ??
352
- (sub.current_period_end as string | null) ??
353
- (sub.currentPeriod_end as string | null) ??
354
- null,
355
- cancelAtPeriodEnd: Boolean(
356
- sub.cancelAtPeriodEnd ?? sub.cancel_at_period_end ?? false
357
- ),
358
- });
359
- } else {
360
- setSubscription(null);
361
- }
362
- } else {
363
- setSubscription(null);
364
- }
365
-
366
- // Invoices
367
- for (const path of [
368
- `${billingUrl}/v1/billing/invoices`,
369
- `${billingUrl}/v1/invoices`,
370
- ]) {
371
- try {
372
- const invJ = (await fetchJson(path)) as {
373
- ok?: boolean;
374
- invoices?: unknown[];
375
- };
376
- if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
377
- setInvoices(
378
- (invJ.invoices as Record<string, unknown>[]).map((inv) => ({
379
- id: String(inv.id),
380
- amount: Number(inv.amount ?? 0),
381
- currency: String(inv.currency ?? 'USD'),
382
- status: String(inv.status ?? 'pending'),
383
- billedAt:
384
- (inv.billedAt as string | null) ??
385
- (inv.billed_at as string | null) ??
386
- null,
387
- }))
388
- );
389
- break;
390
- }
391
- } catch {
392
- // try next
393
- }
394
- }
395
-
396
- // Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
397
- let gotPlans = false;
398
- const planPaths: string[] = [];
399
- if (projectId) {
400
- planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
401
- } else if (pubKey && pubKey !== 'pk_test_missing') {
402
- // No projectId but have publishableKey — let billing resolve project via X-Publishable-Key header.
403
- // Billing plans route returns [] in test/localhost when projectId is missing, which is fine.
404
- planPaths.push(`${billingUrl}/v1/billing/plans`);
405
- } else {
406
- // No projectId and no publishableKey — don't make the request, just show empty
407
- setPlans([]);
408
- gotPlans = true;
409
- }
410
- for (const p of planPaths) {
411
- try {
412
- const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
413
- if (pj?.ok !== false && Array.isArray(pj.plans)) {
414
- setPlans(pj.plans);
415
- gotPlans = true;
416
- break;
417
- }
418
- } catch {
419
- // continue
420
- }
421
- }
422
- if (!gotPlans) setPlans([]);
423
- } catch {
424
- // Billing unavailable — show empty state, keep plans empty
425
- setPlans([]);
426
- } finally {
427
- setBillingLoading(false);
428
- setPlansLoading(false);
429
- }
430
- }, [client, user]);
431
-
432
- useEffect(() => {
433
- if (tab === 'security') void loadSessions(0);
434
- if (tab === 'billing') void loadBilling();
435
- }, [tab, loadSessions, loadBilling]);
436
-
437
- // React to a completed Paddle checkout: reload billing immediately and show
438
- // a success confirmation (fallback for when the webhook is fast / polling lags).
439
- useEffect(() => {
440
- function onCheckoutCompleted() {
441
- setCheckoutSuccess(true);
442
- void loadBilling();
443
- setTimeout(() => setCheckoutSuccess(false), 6000);
444
- }
445
- window.addEventListener('slyxup:checkout-completed', onCheckoutCompleted);
446
- return () =>
447
- window.removeEventListener(
448
- 'slyxup:checkout-completed',
449
- onCheckoutCompleted
450
- );
451
- }, [loadBilling]);
452
-
453
- async function onProfileSubmit(e: FormEvent) {
454
- e.preventDefault();
455
- setBusy(true);
456
- setSaved(false);
457
- try {
458
- await client.users.update({ firstName, lastName, username, avatarUrl });
459
- await reload();
460
- setSaved(true);
461
- setTimeout(() => setSaved(false), 2500);
462
- } finally {
463
- setBusy(false);
464
- }
465
- }
466
-
467
- async function onResendVerification() {
468
- if (!user) return;
469
- setResending(true);
470
- try {
471
- await client.auth.resendVerification(user.email);
472
- setResent(true);
473
- setTimeout(() => setResent(false), 4000);
474
- } finally {
475
- setResending(false);
476
- }
477
- }
478
-
479
- const loadAccounts = useCallback(async () => {
480
- setAccountsLoading(true);
481
- setAccountsError(null);
482
- try {
483
- const res = await client.accounts.list();
484
- setAccounts(res.accounts);
485
- } catch {
486
- setAccounts([]);
487
- setAccountsError('Could not load connected accounts.');
488
- } finally {
489
- setAccountsLoading(false);
490
- }
491
- }, [client]);
492
-
493
- useEffect(() => {
494
- if (tab === 'security') void loadAccounts();
495
- }, [tab, loadAccounts]);
496
-
497
- async function startTfaSetup() {
498
- setTfaError(null);
499
- setTfaBusy(true);
500
- try {
501
- const res = await client.twoFactor.setup();
502
- setTfaSetup(res);
503
- setTfaStage('setup');
504
- setTfaCode('');
505
- } catch (err) {
506
- setTfaError(err instanceof Error ? err.message : 'Failed to start setup');
507
- } finally {
508
- setTfaBusy(false);
509
- }
510
- }
511
-
512
- async function submitTfa(e: FormEvent) {
513
- e.preventDefault();
514
- if (!tfaSetup) return;
515
- setTfaError(null);
516
- setTfaBusy(true);
517
- try {
518
- const res = await client.twoFactor.enable(
519
- tfaSetup.secret,
520
- tfaCode.trim()
521
- );
522
- setTfaRecoveryCodes(res.recoveryCodes);
523
- setTfaStage('codes');
524
- await reload();
525
- } catch (err) {
526
- setTfaError(
527
- err instanceof Error ? err.message : 'Invalid code — try again.'
528
- );
529
- } finally {
530
- setTfaBusy(false);
531
- }
532
- }
533
-
534
- async function submitTfaDisable(e: FormEvent) {
535
- e.preventDefault();
536
- setTfaError(null);
537
- setTfaBusy(true);
538
- try {
539
- await client.twoFactor.disable(tfaVerifyCode.trim());
540
- setTfaVerifyCode('');
541
- setTfaStage('idle');
542
- setTfaSetup(null);
543
- setTfaRecoveryCodes([]);
544
- await reload();
545
- } catch (err) {
546
- setTfaError(
547
- err instanceof Error
548
- ? err.message
549
- : 'Invalid code — could not disable 2FA.'
550
- );
551
- } finally {
552
- setTfaBusy(false);
553
- }
554
- }
555
-
556
- async function onUnlink(accountId: string, provider: 'google' | 'github') {
557
- setUnlinkingId(accountId);
558
- setAccountsError(null);
559
- try {
560
- await client.accounts.unlink(accountId, provider);
561
- setAccounts((prev) => prev.filter((a) => a.id !== accountId));
562
- } catch (err) {
563
- setAccountsError(
564
- err instanceof Error ? err.message : 'Could not unlink account.'
565
- );
566
- } finally {
567
- setUnlinkingId(null);
568
- }
569
- }
570
-
571
- async function onPasswordSubmit(e: FormEvent) {
572
- e.preventDefault();
573
- setPwError(null);
574
- setPwSaved(false);
575
- if (newPassword.length < 8) {
576
- setPwError('New password must be at least 8 characters.');
577
- return;
578
- }
579
- if (newPassword !== confirmPassword) {
580
- setPwError('Passwords do not match.');
581
- return;
582
- }
583
- setPwBusy(true);
584
- try {
585
- await client.password.change({ currentPassword, newPassword });
586
- setPwSaved(true);
587
- setCurrentPassword('');
588
- setNewPassword('');
589
- setConfirmPassword('');
590
- setTimeout(() => setPwSaved(false), 3000);
591
- } catch (err) {
592
- setPwError(
593
- err instanceof Error ? err.message : 'Failed to change password'
594
- );
595
- } finally {
596
- setPwBusy(false);
597
- }
598
- }
599
-
600
- async function onRevoke(id: string) {
601
- setRevokingId(id);
602
- try {
603
- await client.sessions.revoke(id);
604
- await loadSessions(sessionsPage);
605
- } finally {
606
- setRevokingId(null);
607
- }
608
- }
609
-
610
- async function onRevokeOthers() {
611
- setOthersRevoking(true);
612
- try {
613
- await client.sessions.revokeOthers();
614
- await loadSessions(0);
615
- } finally {
616
- setOthersRevoking(false);
617
- }
618
- }
619
-
620
- async function onDeleteAccount() {
621
- setDeleteError(null);
622
- setDeleteBusy(true);
623
- try {
624
- await client.users.delete();
625
- await client.auth.signOut().catch(() => undefined);
626
- onDeleted?.();
627
- } catch (err) {
628
- setDeleteError(
629
- err instanceof Error ? err.message : 'Failed to delete account'
630
- );
631
- } finally {
632
- setDeleteBusy(false);
633
- }
634
- }
635
-
636
- async function handleCheckout(plan: Plan) {
637
- setCheckoutId(plan.id);
638
- setCheckoutDone(false);
639
- try {
640
- // Always use Paddle.js overlay checkout
641
- // authApiUrl is available via client apiUrl
642
- const rawApiUrl =
643
- (client as unknown as { apiUrl: string }).apiUrl ??
644
- 'https://auth.slyxup.online';
645
- await initPaddle(rawApiUrl);
646
- // Pass custom data so the billing webhook can attribute the created
647
- // subscription to this user + project + plan (Paddle copies custom_data
648
- // from the transaction to the subscription for recurring items).
649
- const customData: Record<string, string> = {
650
- userId: user?.id ?? '',
651
- planId: plan.id,
652
- };
653
- if (projectId) customData.projectId = projectId;
654
- openPaddleCheckout(plan.paddlePriceId, user?.email, customData);
655
- setCheckoutDone(true);
656
-
657
- // Poll for subscription updates after checkout (webhook may take a few seconds)
658
- let attempts = 0;
659
- const maxAttempts = 10;
660
- const pollInterval = 3000; // 3 seconds
661
- const poll = async () => {
662
- attempts++;
663
- try {
664
- await loadBilling();
665
- if (subscriptionRef.current && attempts < maxAttempts) {
666
- // Found subscription, stop polling
667
- return;
668
- }
669
- } catch {
670
- // Ignore polling errors
671
- }
672
- if (attempts < maxAttempts) {
673
- setTimeout(poll, pollInterval);
674
- }
675
- };
676
- // Start polling after a short delay to allow webhook to process
677
- setTimeout(poll, 2000);
678
- return;
679
- } catch (err) {
680
- console.error('[SlyxUp] checkout failed', err);
681
- } finally {
682
- setCheckoutId(null);
683
- }
684
- }
685
-
686
- if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
687
- if (!user) return null;
688
-
689
- const emailVerified = user.emailVerified;
690
- const name = displayName(
691
- user as { firstName: string | null; lastName: string | null; email: string }
692
- );
693
-
694
- // Resolve current plan name via plans lookup if subscription has planId but no planName
695
- const currentPlan = subscription
696
- ? (plans.find((p) => p.id === subscription.planId) ?? null)
697
- : null;
698
- const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
699
-
700
- const bodyInner = (
701
- <>
702
- <div className="slx-profile-head">
703
- <h2 className="slx-profile-title">Account settings</h2>
704
- {modal && (
705
- <button
706
- type="button"
707
- className="slx-profile-close"
708
- onClick={(e) => {
709
- e.stopPropagation();
710
- onClose?.();
711
- }}
712
- aria-label="Close account settings"
713
- >
714
-
715
- </button>
716
- )}
717
- </div>
718
-
719
- <div className="slx-profile-body">
720
- <nav className="slx-profile-nav" aria-label="Settings sections">
721
- <button
722
- type="button"
723
- className={`slx-profile-nav-btn${tab === 'profile' ? ' on' : ''}`}
724
- onClick={() => setTab('profile')}
725
- aria-current={tab === 'profile' ? 'page' : undefined}
726
- >
727
- <ProfileIcon /> Profile
728
- </button>
729
- <button
730
- type="button"
731
- className={`slx-profile-nav-btn${tab === 'security' ? ' on' : ''}`}
732
- onClick={() => setTab('security')}
733
- aria-current={tab === 'security' ? 'page' : undefined}
734
- >
735
- <ShieldIcon /> Security
736
- </button>
737
- <button
738
- type="button"
739
- className={`slx-profile-nav-btn${tab === 'billing' ? ' on' : ''}`}
740
- onClick={() => setTab('billing')}
741
- aria-current={tab === 'billing' ? 'page' : undefined}
742
- >
743
- <CreditCardIcon /> Billing
744
- </button>
745
- </nav>
746
-
747
- <div className="slx-profile-content">
748
- {/* ── Profile Tab ── */}
749
- {tab === 'profile' && (
750
- <>
751
- <section className="slx-profile-sec">
752
- <div className="slx-avatar-row">
753
- <div className="slx-avatar-lg" aria-hidden="true">
754
- {user.avatarUrl ? (
755
- <img src={user.avatarUrl} alt="" />
756
- ) : (
757
- initials(
758
- user as {
759
- firstName: string | null;
760
- lastName: string | null;
761
- email: string;
762
- }
763
- )
764
- )}
765
- </div>
766
- <div style={{ minWidth: 0 }}>
767
- <p
768
- className="slx-row-value"
769
- style={{ margin: 0, wordBreak: 'break-word' }}
770
- >
771
- {name}
772
- </p>
773
- <p
774
- className="slx-row-label"
775
- style={{ wordBreak: 'break-all' }}
776
- >
777
- {user.email}
778
- </p>
779
- </div>
780
- </div>
781
-
782
- {saved && (
783
- <p
784
- className="slx-error-text"
785
- style={{ color: 'var(--slx-success)' }}
786
- >
787
- Profile saved.
788
- </p>
789
- )}
790
-
791
- <form onSubmit={onProfileSubmit}>
792
- <div className="slx-field">
793
- <label className="slx-label" htmlFor="slx-up-first">
794
- First name
795
- </label>
796
- <input
797
- id="slx-up-first"
798
- className="slx-input"
799
- type="text"
800
- autoComplete="given-name"
801
- value={firstName}
802
- onChange={(e) => setFirstName(e.target.value)}
803
- />
804
- </div>
805
- <div className="slx-field">
806
- <label className="slx-label" htmlFor="slx-up-last">
807
- Last name
808
- </label>
809
- <input
810
- id="slx-up-last"
811
- className="slx-input"
812
- type="text"
813
- autoComplete="family-name"
814
- value={lastName}
815
- onChange={(e) => setLastName(e.target.value)}
816
- />
817
- </div>
818
- <div className="slx-field">
819
- <label className="slx-label" htmlFor="slx-up-avatar">
820
- Avatar URL
821
- </label>
822
- <input
823
- id="slx-up-avatar"
824
- className="slx-input"
825
- type="url"
826
- placeholder="https://…"
827
- value={avatarUrl}
828
- onChange={(e) => setAvatarUrl(e.target.value)}
829
- />
830
- <p className="slx-hint">
831
- Paste a public image URL for your avatar.
832
- </p>
833
- </div>
834
- <div className="slx-field">
835
- <label className="slx-label" htmlFor="slx-up-username">
836
- Username
837
- </label>
838
- <input
839
- id="slx-up-username"
840
- className="slx-input"
841
- type="text"
842
- autoComplete="username"
843
- placeholder="yourname"
844
- value={username}
845
- onChange={(e) => setUsername(e.target.value)}
846
- />
847
- <p className="slx-hint">
848
- Used for password sign-in as an alternative to email. Must
849
- be unique within your project.
850
- </p>
851
- </div>
852
- <button className="slx-btn" type="submit" disabled={busy}>
853
- {busy ? 'Saving…' : 'Save changes'}
854
- </button>
855
- </form>
856
- </section>
857
-
858
- <section className="slx-profile-sec">
859
- <h3 className="slx-sec-title">Email</h3>
860
- <div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
861
- <div style={{ minWidth: 0 }}>
862
- <p
863
- className="slx-row-value"
864
- style={{ wordBreak: 'break-all' }}
865
- >
866
- {user.email}
867
- </p>
868
- <p className="slx-row-label">Primary email</p>
869
- </div>
870
- {emailVerified ? (
871
- <span className="slx-badge slx-badge-ok">Verified</span>
872
- ) : (
873
- <span
874
- style={{
875
- display: 'inline-flex',
876
- alignItems: 'center',
877
- gap: 8,
878
- flexWrap: 'wrap',
879
- }}
880
- >
881
- <span className="slx-badge slx-badge-warn">
882
- Unverified
883
- </span>
884
- <button
885
- type="button"
886
- className="slx-link"
887
- onClick={onResendVerification}
888
- disabled={resending}
889
- >
890
- {resending ? 'Sending…' : resent ? 'Sent ✓' : 'Resend'}
891
- </button>
892
- </span>
893
- )}
894
- </div>
895
- </section>
896
- </>
897
- )}
898
-
899
- {/* ── Security Tab ── */}
900
- {tab === 'security' && (
901
- <>
902
- <section className="slx-profile-sec">
903
- <h3 className="slx-sec-title">Change password</h3>
904
- {pwSaved && (
905
- <p
906
- className="slx-error-text"
907
- style={{ color: 'var(--slx-success)' }}
908
- >
909
- Password updated.
910
- </p>
911
- )}
912
- {pwError && <p className="slx-error-text">{pwError}</p>}
913
- <form onSubmit={onPasswordSubmit}>
914
- <div className="slx-field">
915
- <label className="slx-label" htmlFor="slx-pw-current">
916
- Current password
917
- </label>
918
- <input
919
- id="slx-pw-current"
920
- className="slx-input"
921
- type="password"
922
- autoComplete="current-password"
923
- value={currentPassword}
924
- onChange={(e) => setCurrentPassword(e.target.value)}
925
- required
926
- />
927
- </div>
928
- <div className="slx-field">
929
- <label className="slx-label" htmlFor="slx-pw-new">
930
- New password
931
- </label>
932
- <input
933
- id="slx-pw-new"
934
- className="slx-input"
935
- type="password"
936
- autoComplete="new-password"
937
- minLength={8}
938
- value={newPassword}
939
- onChange={(e) => setNewPassword(e.target.value)}
940
- required
941
- />
942
- <p className="slx-hint">At least 8 characters.</p>
943
- </div>
944
- <div className="slx-field">
945
- <label className="slx-label" htmlFor="slx-pw-confirm">
946
- Confirm new password
947
- </label>
948
- <input
949
- id="slx-pw-confirm"
950
- className="slx-input"
951
- type="password"
952
- autoComplete="new-password"
953
- value={confirmPassword}
954
- onChange={(e) => setConfirmPassword(e.target.value)}
955
- required
956
- />
957
- </div>
958
- <button className="slx-btn" type="submit" disabled={pwBusy}>
959
- {pwBusy ? 'Updating…' : 'Update password'}
960
- </button>
961
- </form>
962
- </section>
963
-
964
- <section className="slx-profile-sec">
965
- <h3 className="slx-sec-title">Active sessions</h3>
966
- {sessionsLoading ? (
967
- <p className="slx-hint">Loading sessions…</p>
968
- ) : sessions.length === 0 ? (
969
- <p className="slx-hint">No active sessions.</p>
970
- ) : (
971
- <>
972
- {sessions.map((s) => (
973
- <div key={s.id} className="slx-session">
974
- <div className="slx-session-meta">
975
- <p className="slx-session-device">
976
- {deviceLabel(s.userAgent)}
977
- {s.isCurrent && (
978
- <span className="slx-badge slx-badge-accent">
979
- This device
980
- </span>
981
- )}
982
- </p>
983
- <p className="slx-session-sub">
984
- {[
985
- s.ipAddress,
986
- `created ${formatDate(s.createdAt)}`,
987
- `expires ${formatDate(s.expiresAt)}`,
988
- ]
989
- .filter(Boolean)
990
- .join(' · ')}
991
- </p>
992
- </div>
993
- {!s.isCurrent && (
994
- <button
995
- type="button"
996
- className="slx-btn-danger-outline"
997
- onClick={() => onRevoke(s.id)}
998
- disabled={revokingId === s.id}
999
- >
1000
- {revokingId === s.id ? '…' : 'Revoke'}
1001
- </button>
1002
- )}
1003
- </div>
1004
- ))}
1005
- {sessions.some((s) => !s.isCurrent) && (
1006
- <button
1007
- type="button"
1008
- className="slx-btn-danger-outline"
1009
- style={{ width: '100%', marginTop: 4 }}
1010
- onClick={onRevokeOthers}
1011
- disabled={othersRevoking}
1012
- >
1013
- {othersRevoking
1014
- ? 'Signing out…'
1015
- : 'Sign out other devices'}
1016
- </button>
1017
- )}
1018
- {sessionsTotal > SESSIONS_PER_PAGE && (
1019
- <div className="slx-pagination">
1020
- <button
1021
- type="button"
1022
- className="slx-btn-secondary"
1023
- disabled={sessionsPage === 0 || sessionsLoading}
1024
- onClick={() => void loadSessions(sessionsPage - 1)}
1025
- >
1026
- Previous
1027
- </button>
1028
- <span className="slx-pagination-info">
1029
- {sessionsPage * SESSIONS_PER_PAGE + 1}–
1030
- {Math.min(
1031
- (sessionsPage + 1) * SESSIONS_PER_PAGE,
1032
- sessionsTotal
1033
- )}{' '}
1034
- of {sessionsTotal}
1035
- </span>
1036
- <button
1037
- type="button"
1038
- className="slx-btn-secondary"
1039
- disabled={
1040
- (sessionsPage + 1) * SESSIONS_PER_PAGE >=
1041
- sessionsTotal || sessionsLoading
1042
- }
1043
- onClick={() => void loadSessions(sessionsPage + 1)}
1044
- >
1045
- Next
1046
- </button>
1047
- </div>
1048
- )}
1049
- </>
1050
- )}
1051
- </section>
1052
-
1053
- {/* ── Two-factor authentication ── */}
1054
- <section className="slx-profile-sec">
1055
- <h3 className="slx-sec-title">Two-factor authentication</h3>
1056
- {tfaError && <p className="slx-error-text">{tfaError}</p>}
1057
-
1058
- {!user.twoFactorEnabled && tfaStage === 'idle' && (
1059
- <>
1060
- <p className="slx-hint">
1061
- Add an authenticator app (Google Authenticator, Authy,
1062
- 1Password, etc.) to protect your account with a time-based
1063
- one-time password.
1064
- </p>
1065
- <button
1066
- type="button"
1067
- className="slx-btn"
1068
- onClick={() => void startTfaSetup()}
1069
- disabled={tfaBusy}
1070
- >
1071
- {tfaBusy ? 'Starting…' : 'Set up 2FA'}
1072
- </button>
1073
- </>
1074
- )}
1075
-
1076
- {!user.twoFactorEnabled && tfaStage === 'setup' && (
1077
- <form onSubmit={submitTfa}>
1078
- {tfaSetup && (
1079
- <>
1080
- <p className="slx-hint">
1081
- Scan this QR code with your authenticator app:
1082
- </p>
1083
- <div
1084
- style={{
1085
- display: 'flex',
1086
- alignItems: 'center',
1087
- gap: 14,
1088
- margin: '8px 0',
1089
- flexWrap: 'wrap',
1090
- }}
1091
- >
1092
- <img
1093
- src={`https://api.qrserver.com/v1/create-qr-code/?size=132x132&data=${encodeURIComponent(tfaSetup.provisioningUri)}`}
1094
- alt="QR code to scan with your authenticator app"
1095
- width={132}
1096
- height={132}
1097
- style={{ borderRadius: 6 }}
1098
- />
1099
- <div style={{ minWidth: 0 }}>
1100
- <p className="slx-row-label">
1101
- Or enter this code manually:
1102
- </p>
1103
- <p
1104
- className="slx-row-value"
1105
- style={{
1106
- userSelect: 'all',
1107
- letterSpacing: 2,
1108
- fontFamily: 'monospace',
1109
- }}
1110
- >
1111
- {tfaSetup.secret.replace(/(.{4})/g, '$1 ').trim()}
1112
- </p>
1113
- <p className="slx-hint">
1114
- Account: {tfaSetup.accountName}
1115
- </p>
1116
- </div>
1117
- </div>
1118
- </>
1119
- )}
1120
- <div className="slx-field">
1121
- <label className="slx-label" htmlFor="slx-tfa-code">
1122
- Enter the 6-digit code
1123
- </label>
1124
- <input
1125
- id="slx-tfa-code"
1126
- className="slx-input"
1127
- type="text"
1128
- inputMode="numeric"
1129
- autoComplete="one-time-code"
1130
- maxLength={6}
1131
- pattern="[0-9]*"
1132
- placeholder="000000"
1133
- value={tfaCode}
1134
- onChange={(e) =>
1135
- setTfaCode(e.target.value.replace(/\D/g, ''))
1136
- }
1137
- required
1138
- />
1139
- </div>
1140
- <button
1141
- className="slx-btn"
1142
- type="submit"
1143
- disabled={tfaBusy}
1144
- >
1145
- {tfaBusy ? 'Verifying…' : 'Enable 2FA'}
1146
- </button>
1147
- </form>
1148
- )}
1149
-
1150
- {!user.twoFactorEnabled && tfaStage === 'codes' && (
1151
- <>
1152
- <div
1153
- className="slx-billing-card"
1154
- style={{ margin: '4px 0 8px' }}
1155
- >
1156
- <p className="slx-billing-plan">
1157
- 2FA enabled — save recovery codes
1158
- </p>
1159
- <p className="slx-billing-detail">
1160
- Each code can be used once to sign in if you lose access
1161
- to your authenticator. Store them somewhere safe.
1162
- </p>
1163
- <ul
1164
- style={{
1165
- display: 'grid',
1166
- gridTemplateColumns: 'repeat(2, auto)',
1167
- gap: '4px 24px',
1168
- justifyContent: 'start',
1169
- margin: '10px 0',
1170
- paddingLeft: 0,
1171
- listStyle: 'none',
1172
- fontFamily: 'monospace',
1173
- }}
1174
- >
1175
- {tfaRecoveryCodes.map((c) => (
1176
- <li key={c}>{c}</li>
1177
- ))}
1178
- </ul>
1179
- <button
1180
- type="button"
1181
- className="slx-btn-secondary"
1182
- onClick={() =>
1183
- void navigator.clipboard
1184
- ?.writeText(tfaRecoveryCodes.join('\n'))
1185
- .catch(() => undefined)
1186
- }
1187
- >
1188
- Copy codes
1189
- </button>
1190
- </div>
1191
- <button
1192
- type="button"
1193
- className="slx-btn-secondary"
1194
- onClick={() => void reload()}
1195
- >
1196
- Done
1197
- </button>
1198
- </>
1199
- )}
1200
-
1201
- {user.twoFactorEnabled && (
1202
- <>
1203
- <p className="slx-hint">2FA is enabled for this account.</p>
1204
- <form
1205
- onSubmit={submitTfaDisable}
1206
- style={{
1207
- display: 'flex',
1208
- gap: 10,
1209
- alignItems: 'flex-end',
1210
- flexWrap: 'wrap',
1211
- marginTop: 8,
1212
- }}
1213
- >
1214
- <div className="slx-field" style={{ flex: '1 1 160px' }}>
1215
- <label className="slx-label" htmlFor="slx-tfa-disable">
1216
- Authenticator code
1217
- </label>
1218
- <input
1219
- id="slx-tfa-disable"
1220
- className="slx-input"
1221
- type="text"
1222
- inputMode="numeric"
1223
- autoComplete="one-time-code"
1224
- maxLength={6}
1225
- pattern="[0-9]*"
1226
- placeholder="000000"
1227
- value={tfaVerifyCode}
1228
- onChange={(e) =>
1229
- setTfaVerifyCode(e.target.value.replace(/\D/g, ''))
1230
- }
1231
- required
1232
- />
1233
- </div>
1234
- <button
1235
- type="submit"
1236
- className="slx-btn-danger-outline"
1237
- disabled={tfaBusy}
1238
- >
1239
- {tfaBusy ? 'Disabling…' : 'Disable 2FA'}
1240
- </button>
1241
- </form>
1242
- </>
1243
- )}
1244
- </section>
1245
-
1246
- {/* ── Connected accounts ── */}
1247
- <section className="slx-profile-sec">
1248
- <h3 className="slx-sec-title">Connected accounts</h3>
1249
- {accountsError && (
1250
- <p className="slx-error-text">{accountsError}</p>
1251
- )}
1252
- {accountsLoading ? (
1253
- <p className="slx-hint">Loading…</p>
1254
- ) : accounts.length === 0 ? (
1255
- <>
1256
- <p className="slx-hint">
1257
- No social accounts connected. You can sign in with Google
1258
- or GitHub and link them here later.
1259
- </p>
1260
- <button
1261
- type="button"
1262
- className="slx-btn-secondary"
1263
- onClick={() => void loadAccounts()}
1264
- >
1265
- Refresh
1266
- </button>
1267
- </>
1268
- ) : (
1269
- <>
1270
- <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
1271
- {accounts.map((acc) => (
1272
- <li
1273
- key={acc.id}
1274
- className="slx-session"
1275
- style={{ alignItems: 'center' }}
1276
- >
1277
- <div className="slx-session-meta">
1278
- <p className="slx-session-device">
1279
- {acc.provider === 'google' ? 'Google' : 'GitHub'}
1280
- </p>
1281
- <p className="slx-session-sub">
1282
- Connected {formatDate(acc.createdAt)}
1283
- </p>
1284
- </div>
1285
- <button
1286
- type="button"
1287
- className="slx-btn-danger-outline"
1288
- onClick={() => void onUnlink(acc.id, acc.provider)}
1289
- disabled={unlinkingId === acc.id}
1290
- >
1291
- {unlinkingId === acc.id ? '…' : 'Unlink'}
1292
- </button>
1293
- </li>
1294
- ))}
1295
- </ul>
1296
- </>
1297
- )}
1298
- </section>
1299
-
1300
- <section className="slx-danger-zone">
1301
- <p className="slx-danger-title">Danger zone</p>
1302
- <p className="slx-danger-desc">
1303
- Permanently deletes your account and all associated data.
1304
- Active sessions are revoked immediately. This cannot be
1305
- undone.
1306
- </p>
1307
- {deleteError && <p className="slx-error-text">{deleteError}</p>}
1308
- <div className="slx-field">
1309
- <label className="slx-label" htmlFor="slx-del-confirm">
1310
- Type <strong>DELETE</strong> to confirm
1311
- </label>
1312
- <input
1313
- id="slx-del-confirm"
1314
- className="slx-input"
1315
- type="text"
1316
- value={confirmText}
1317
- onChange={(e) => setConfirmText(e.target.value)}
1318
- placeholder="DELETE"
1319
- />
1320
- </div>
1321
- <button
1322
- type="button"
1323
- className="slx-btn"
1324
- style={{
1325
- background: 'var(--slx-danger)',
1326
- borderColor: 'var(--slx-danger)',
1327
- }}
1328
- onClick={onDeleteAccount}
1329
- disabled={confirmText !== 'DELETE' || deleteBusy}
1330
- >
1331
- {deleteBusy ? 'Deleting…' : 'Delete my account forever'}
1332
- </button>
1333
- </section>
1334
- </>
1335
- )}
1336
-
1337
- {/* ── Billing Tab ── */}
1338
- {tab === 'billing' &&
1339
- (billingLoading ? (
1340
- <section className="slx-profile-sec">
1341
- <p className="slx-hint">Loading billing information…</p>
1342
- </section>
1343
- ) : !subscription ? (
1344
- <>
1345
- <section className="slx-profile-sec">
1346
- <h3 className="slx-sec-title">Subscription</h3>
1347
- {checkoutDone && (
1348
- <p
1349
- className="slx-error-text"
1350
- style={{ color: 'var(--slx-success)', marginBottom: 8 }}
1351
- >
1352
- Checkout opened — complete your payment in the overlay.
1353
- </p>
1354
- )}
1355
- {checkoutSuccess && (
1356
- <p
1357
- className="slx-error-text"
1358
- style={{ color: 'var(--slx-success)', marginBottom: 8 }}
1359
- >
1360
- Payment successful — your subscription is being set up.
1361
- </p>
1362
- )}
1363
- <div className="slx-billing-card">
1364
- <p className="slx-billing-plan">No active subscription</p>
1365
- <p className="slx-billing-detail">
1366
- You don&apos;t have a subscription yet. Choose a plan to
1367
- get started.
1368
- </p>
1369
- </div>
1370
- </section>
1371
-
1372
- <section className="slx-profile-sec">
1373
- <h3 className="slx-sec-title">Available plans</h3>
1374
- {plansLoading ? (
1375
- <p className="slx-hint">Loading plans…</p>
1376
- ) : plans.length === 0 ? (
1377
- <div
1378
- className="slx-billing-card"
1379
- style={{ textAlign: 'center' }}
1380
- >
1381
- <p className="slx-billing-detail">
1382
- No plans configured for this project yet.
1383
- </p>
1384
- <p className="slx-hint" style={{ marginTop: 6 }}>
1385
- Ask your admin to create a plan in billing.
1386
- </p>
1387
- </div>
1388
- ) : (
1389
- <div className="slx-billing-plans">
1390
- {plans.map((plan) => (
1391
- <div
1392
- key={plan.id}
1393
- className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
1394
- >
1395
- {plan.isPopular && (
1396
- <span className="slx-plan-badge">POPULAR</span>
1397
- )}
1398
- <p className="slx-plan-name">{plan.name}</p>
1399
- <p style={{ margin: '2px 0 0' }}>
1400
- <span className="slx-plan-price">
1401
- {formatCurrency(plan.amount, plan.currency)}
1402
- </span>
1403
- <span className="slx-plan-interval">
1404
- /{plan.interval}
1405
- </span>
1406
- </p>
1407
- {plan.trialDays ? (
1408
- <p
1409
- className="slx-billing-detail"
1410
- style={{
1411
- color: 'var(--slx-accent)',
1412
- marginTop: 4,
1413
- }}
1414
- >
1415
- {plan.trialDays} day free trial
1416
- </p>
1417
- ) : (
1418
- <p
1419
- className="slx-billing-detail"
1420
- style={{ visibility: 'hidden', marginTop: 4 }}
1421
- >
1422
- &nbsp;
1423
- </p>
1424
- )}
1425
- <ul className="slx-plan-features">
1426
- {(plan.features ?? []).map((f) => (
1427
- <li key={f}>{f}</li>
1428
- ))}
1429
- </ul>
1430
- <button
1431
- type="button"
1432
- className="slx-btn slx-plan-cta"
1433
- onClick={() => handleCheckout(plan)}
1434
- disabled={checkoutId === plan.id}
1435
- >
1436
- {checkoutId === plan.id
1437
- ? 'Redirecting…'
1438
- : 'Choose plan'}
1439
- </button>
1440
- </div>
1441
- ))}
1442
- </div>
1443
- )}
1444
- </section>
1445
-
1446
- {invoices.length > 0 && (
1447
- <section className="slx-profile-sec">
1448
- <h3 className="slx-sec-title">Invoices</h3>
1449
- {invoices.map((inv) => (
1450
- <div key={inv.id} className="slx-invoice-row">
1451
- <span className="slx-invoice-date">
1452
- {inv.billedAt ? formatDate(inv.billedAt) : '—'}
1453
- </span>
1454
- <span className="slx-invoice-amount">
1455
- {formatCurrency(inv.amount, inv.currency)}
1456
- </span>
1457
- <span
1458
- className={`slx-badge ${
1459
- inv.status === 'paid'
1460
- ? 'slx-badge-ok'
1461
- : inv.status === 'overdue'
1462
- ? 'slx-badge-warn'
1463
- : 'slx-badge-accent'
1464
- }`}
1465
- >
1466
- {inv.status}
1467
- </span>
1468
- </div>
1469
- ))}
1470
- </section>
1471
- )}
1472
- </>
1473
- ) : (
1474
- <>
1475
- <section className="slx-profile-sec">
1476
- <h3 className="slx-sec-title">Current plan</h3>
1477
- <div className="slx-billing-card">
1478
- <div
1479
- style={{
1480
- display: 'flex',
1481
- justifyContent: 'space-between',
1482
- alignItems: 'flex-start',
1483
- gap: 12,
1484
- flexWrap: 'wrap',
1485
- }}
1486
- >
1487
- <div style={{ minWidth: 0 }}>
1488
- <p className="slx-billing-plan">
1489
- {resolvedPlanName ?? 'Subscription'}
1490
- </p>
1491
- <p className="slx-billing-detail">
1492
- Status:{' '}
1493
- <span
1494
- className={`slx-billing-status slx-billing-status-${subscription.status}`}
1495
- >
1496
- {subscription.status}
1497
- </span>
1498
- </p>
1499
- {subscription.currentPeriodEnd && (
1500
- <p className="slx-billing-detail">
1501
- {subscription.cancelAtPeriodEnd
1502
- ? `Cancels ${formatDate(subscription.currentPeriodEnd)}`
1503
- : `Renews ${formatDate(subscription.currentPeriodEnd)}`}
1504
- </p>
1505
- )}
1506
- {subscription.cancelAtPeriodEnd && (
1507
- <p
1508
- className="slx-billing-detail"
1509
- style={{
1510
- color: 'var(--slx-danger)',
1511
- fontWeight: 600,
1512
- }}
1513
- >
1514
- Scheduled to cancel at period end
1515
- </p>
1516
- )}
1517
- </div>
1518
- {currentPlan && (
1519
- <span
1520
- className="slx-badge slx-badge-accent"
1521
- style={{ flexShrink: 0 }}
1522
- >
1523
- {formatCurrency(
1524
- currentPlan.amount,
1525
- currentPlan.currency
1526
- )}
1527
- /{currentPlan.interval}
1528
- </span>
1529
- )}
1530
- </div>
1531
- {currentPlan?.features &&
1532
- currentPlan.features.length > 0 && (
1533
- <ul
1534
- className="slx-plan-features"
1535
- style={{ margin: '12px 0 0' }}
1536
- >
1537
- {currentPlan.features.map((f) => (
1538
- <li key={f}>{f}</li>
1539
- ))}
1540
- </ul>
1541
- )}
1542
- <div className="slx-billing-actions">
1543
- <button
1544
- type="button"
1545
- className="slx-btn-secondary"
1546
- onClick={() => void loadBilling()}
1547
- >
1548
- Refresh
1549
- </button>
1550
- </div>
1551
- </div>
1552
- </section>
1553
-
1554
- {/* Upgrade / Downgrade plans */}
1555
- {plansLoading ? (
1556
- <section className="slx-profile-sec">
1557
- <p className="slx-hint">Loading available plans…</p>
1558
- </section>
1559
- ) : plans.length > 0 ? (
1560
- <section className="slx-profile-sec">
1561
- <h3 className="slx-sec-title">Available plans</h3>
1562
- <p className="slx-hint" style={{ marginBottom: 8 }}>
1563
- Switch plans anytime. Changes apply at the next billing
1564
- cycle.
1565
- </p>
1566
- <div className="slx-billing-plans">
1567
- {plans.map((plan) => {
1568
- const isCurrent = subscription.planId
1569
- ? subscription.planId === plan.id
1570
- : resolvedPlanName === plan.name;
1571
- const currentAmount = currentPlan?.amount ?? 0;
1572
- const isUpgrade = plan.amount > currentAmount;
1573
- const isDowngrade =
1574
- plan.amount < currentAmount && !isCurrent;
1575
- return (
1576
- <div
1577
- key={plan.id}
1578
- className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
1579
- style={isCurrent ? { opacity: 0.92 } : undefined}
1580
- >
1581
- {plan.isPopular && !isCurrent && (
1582
- <span className="slx-plan-badge">POPULAR</span>
1583
- )}
1584
- {isCurrent && (
1585
- <span
1586
- className="slx-plan-badge"
1587
- style={{ background: 'var(--slx-success)' }}
1588
- >
1589
- CURRENT
1590
- </span>
1591
- )}
1592
- <p className="slx-plan-name">{plan.name}</p>
1593
- <p style={{ margin: '2px 0 0' }}>
1594
- <span className="slx-plan-price">
1595
- {formatCurrency(plan.amount, plan.currency)}
1596
- </span>
1597
- <span className="slx-plan-interval">
1598
- /{plan.interval}
1599
- </span>
1600
- </p>
1601
- {plan.trialDays ? (
1602
- <p
1603
- className="slx-billing-detail"
1604
- style={{
1605
- color: 'var(--slx-accent)',
1606
- marginTop: 4,
1607
- }}
1608
- >
1609
- {plan.trialDays} day trial
1610
- </p>
1611
- ) : (
1612
- <p
1613
- className="slx-billing-detail"
1614
- style={{ visibility: 'hidden', marginTop: 4 }}
1615
- >
1616
- &nbsp;
1617
- </p>
1618
- )}
1619
- <ul className="slx-plan-features">
1620
- {(plan.features ?? []).map((f) => (
1621
- <li key={f}>{f}</li>
1622
- ))}
1623
- </ul>
1624
- <button
1625
- type="button"
1626
- className={
1627
- isCurrent
1628
- ? 'slx-btn-secondary slx-plan-cta'
1629
- : 'slx-btn slx-plan-cta'
1630
- }
1631
- disabled={isCurrent || checkoutId === plan.id}
1632
- onClick={() => handleCheckout(plan)}
1633
- >
1634
- {isCurrent
1635
- ? 'Current plan'
1636
- : checkoutId === plan.id
1637
- ? 'Redirecting…'
1638
- : isUpgrade
1639
- ? 'Upgrade'
1640
- : isDowngrade
1641
- ? 'Downgrade'
1642
- : 'Switch plan'}
1643
- </button>
1644
- </div>
1645
- );
1646
- })}
1647
- </div>
1648
- </section>
1649
- ) : null}
1650
-
1651
- {invoices.length > 0 && (
1652
- <section className="slx-profile-sec">
1653
- <h3 className="slx-sec-title">Invoices</h3>
1654
- {invoices.map((inv) => (
1655
- <div key={inv.id} className="slx-invoice-row">
1656
- <span className="slx-invoice-date">
1657
- {inv.billedAt ? formatDate(inv.billedAt) : '—'}
1658
- </span>
1659
- <span className="slx-invoice-amount">
1660
- {formatCurrency(inv.amount, inv.currency)}
1661
- </span>
1662
- <span
1663
- className={`slx-badge ${
1664
- inv.status === 'paid'
1665
- ? 'slx-badge-ok'
1666
- : inv.status === 'overdue'
1667
- ? 'slx-badge-warn'
1668
- : 'slx-badge-accent'
1669
- }`}
1670
- >
1671
- {inv.status}
1672
- </span>
1673
- </div>
1674
- ))}
1675
- </section>
1676
- )}
1677
- </>
1678
- ))}
1679
- </div>
1680
- </div>
1681
- </>
1682
- );
1683
-
1684
- if (!modal) {
1685
- return (
1686
- <div className="slx-profile-modal" style={{ maxHeight: 'none' }}>
1687
- {bodyInner}
1688
- </div>
1689
- );
1690
- }
1691
-
1692
- // Modal overlay — click on backdrop closes, click inside modal does not.
1693
- // Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
1694
- return (
1695
- // biome-ignore lint/a11y/useKeyWithClickEvents: overlay click is for mouse; keyboard Escape handled in effect
1696
- <div
1697
- className="slx-overlay"
1698
- onMouseDown={(e) => {
1699
- if (e.target === e.currentTarget) onClose?.();
1700
- }}
1701
- onClick={(e) => {
1702
- if (e.target === e.currentTarget) onClose?.();
1703
- }}
1704
- role="presentation"
1705
- >
1706
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only, no keyboard action needed */}
1707
- {/* biome-ignore lint/a11y/useSemanticElements: dialog is correct for modal */}
1708
- <div
1709
- role="dialog"
1710
- aria-modal="true"
1711
- aria-label="Account settings"
1712
- className="slx-profile-modal"
1713
- onMouseDown={(e) => e.stopPropagation()}
1714
- onClick={(e) => e.stopPropagation()}
1715
- >
1716
- {bodyInner}
1717
- </div>
1718
- </div>
1719
- );
1720
- }
1721
-
1722
- function ProfileIcon() {
1723
- return (
1724
- <svg
1725
- width="15"
1726
- height="15"
1727
- viewBox="0 0 24 24"
1728
- fill="none"
1729
- stroke="currentColor"
1730
- strokeWidth="2"
1731
- strokeLinecap="round"
1732
- aria-hidden="true"
1733
- >
1734
- <circle cx="12" cy="8" r="4" />
1735
- <path d="M5 21c0-3.9 3.1-7 7-7s7 3.1 7 7" />
1736
- </svg>
1737
- );
1738
- }
1739
-
1740
- function ShieldIcon() {
1741
- return (
1742
- <svg
1743
- width="15"
1744
- height="15"
1745
- viewBox="0 0 24 24"
1746
- fill="none"
1747
- stroke="currentColor"
1748
- strokeWidth="2"
1749
- strokeLinecap="round"
1750
- strokeLinejoin="round"
1751
- aria-hidden="true"
1752
- >
1753
- <path d="M12 22s8-3.6 8-10V5l-8-3-8 3v7c0 6.4 8 10 8 10z" />
1754
- <path d="m9 12 2 2 4-4" />
1755
- </svg>
1756
- );
1757
- }
1758
-
1759
- function CreditCardIcon() {
1760
- return (
1761
- <svg
1762
- width="15"
1763
- height="15"
1764
- viewBox="0 0 24 24"
1765
- fill="none"
1766
- stroke="currentColor"
1767
- strokeWidth="2"
1768
- strokeLinecap="round"
1769
- strokeLinejoin="round"
1770
- aria-hidden="true"
1771
- >
1772
- <rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
1773
- <line x1="1" y1="10" x2="23" y2="10" />
1774
- </svg>
1775
- );
1776
- }