@slyxup/ui 0.2.9 → 0.2.11

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,6 +1,13 @@
1
1
  import type { SlyxupSessionInfo } from '@slyxup/core';
2
2
  import { useAuth, useUser } from '@slyxup/react';
3
- import { type FormEvent, useCallback, useEffect, useState } from 'react';
3
+ import {
4
+ type FormEvent,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import { initPaddle, openPaddleCheckout } from '../../lib/paddle';
4
11
  import { injectStyles } from '../../styles';
5
12
 
6
13
  export interface UserProfileProps {
@@ -44,20 +51,33 @@ function deviceLabel(ua: string | null): string {
44
51
  ? 'macOS'
45
52
  : /Android/i.test(ua)
46
53
  ? 'Android'
47
- : /iPhone|iPad|iOS/i.test(ua)
54
+ : /iPhone|iPad|iPod/i.test(ua)
48
55
  ? 'iOS'
49
- : /Linux/i.test(ua)
50
- ? 'Linux'
51
- : 'Unknown OS';
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)
52
62
  const browser = /Edg\//i.test(ua)
53
63
  ? 'Edge'
54
- : /Chrome\//i.test(ua)
55
- ? 'Chrome'
56
- : /Safari\//i.test(ua)
57
- ? 'Safari'
58
- : /Firefox\//i.test(ua)
59
- ? 'Firefox'
60
- : 'Browser';
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';
61
81
  return `${browser} · ${os}`;
62
82
  }
63
83
 
@@ -86,6 +106,7 @@ function formatCurrency(amount: number, currency: string): string {
86
106
  interface Plan {
87
107
  id: string;
88
108
  name: string;
109
+ paddlePriceId: string;
89
110
  amount: number;
90
111
  currency: string;
91
112
  interval: string;
@@ -108,6 +129,7 @@ export function UserProfile({
108
129
  // ── Profile form state ──
109
130
  const [firstName, setFirstName] = useState('');
110
131
  const [lastName, setLastName] = useState('');
132
+ const [username, setUsername] = useState('');
111
133
  const [avatarUrl, setAvatarUrl] = useState('');
112
134
  const [busy, setBusy] = useState(false);
113
135
  const [saved, setSaved] = useState(false);
@@ -127,6 +149,9 @@ export function UserProfile({
127
149
  const [sessionsLoading, setSessionsLoading] = useState(true);
128
150
  const [revokingId, setRevokingId] = useState<string | null>(null);
129
151
  const [othersRevoking, setOthersRevoking] = useState(false);
152
+ const [sessionsTotal, setSessionsTotal] = useState(0);
153
+ const [sessionsPage, setSessionsPage] = useState(0);
154
+ const SESSIONS_PER_PAGE = 10;
130
155
 
131
156
  // ── Billing state ──
132
157
  const [billingLoading, setBillingLoading] = useState(true);
@@ -149,22 +174,62 @@ export function UserProfile({
149
174
  >([]);
150
175
  const [plans, setPlans] = useState<Plan[]>([]);
151
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);
152
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]);
153
188
 
154
189
  // ── Danger zone state ──
155
190
  const [confirmText, setConfirmText] = useState('');
156
191
  const [deleteBusy, setDeleteBusy] = useState(false);
157
192
  const [deleteError, setDeleteError] = useState<string | null>(null);
158
193
 
159
- // Sync form fields when user loads/updates (ensures firstName/lastName show correctly
160
- // after SlyxUpProvider fetches client.users.me()). Guard against stale overwrites.
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
161
219
  useEffect(() => {
162
220
  if (user) {
163
221
  setFirstName(user.firstName ?? '');
164
222
  setLastName(user.lastName ?? '');
223
+ setUsername(user.username ?? '');
165
224
  setAvatarUrl(user.avatarUrl ?? '');
166
225
  }
167
- }, [user?.firstName, user?.lastName, user?.avatarUrl]);
226
+ }, [
227
+ user?.firstName,
228
+ user?.lastName,
229
+ user?.username,
230
+ user?.avatarUrl,
231
+ user?.id,
232
+ ]);
168
233
 
169
234
  useEffect(() => {
170
235
  function onKey(e: KeyboardEvent) {
@@ -174,37 +239,65 @@ export function UserProfile({
174
239
  return () => document.removeEventListener('keydown', onKey);
175
240
  }, [modal, onClose]);
176
241
 
177
- const loadSessions = useCallback(async () => {
178
- setSessionsLoading(true);
179
- try {
180
- const res = await client.sessions.list();
181
- setSessions(res.sessions);
182
- } catch {
183
- setSessions([]);
184
- } finally {
185
- setSessionsLoading(false);
186
- }
187
- }, [client]);
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
+ );
188
262
 
189
263
  const loadBilling = useCallback(async () => {
190
264
  setBillingLoading(true);
191
265
  setPlansLoading(true);
192
266
  try {
193
267
  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?.();
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;
198
286
  const headers: Record<string, string> = {
199
287
  'Content-Type': 'application/json',
200
288
  };
201
289
  if (token) headers.Authorization = `Bearer ${token}`;
202
290
  // 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;
291
+ const pubKey = (client as unknown as { publishableKey?: string })
292
+ ?.publishableKey;
293
+ if (pubKey && pubKey !== 'pk_test_missing')
294
+ headers['X-Publishable-Key'] = pubKey;
205
295
 
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;
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);
208
301
 
209
302
  // Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
210
303
  async function fetchJson(url: string) {
@@ -220,7 +313,7 @@ export function UserProfile({
220
313
  `${billingUrl}/v1/subscription`,
221
314
  ]) {
222
315
  try {
223
- const j = await fetchJson(path) as Record<string, unknown>;
316
+ const j = (await fetchJson(path)) as Record<string, unknown>;
224
317
  if (j && (j as { ok?: boolean }).ok !== false) {
225
318
  subData = j;
226
319
  break;
@@ -236,17 +329,32 @@ export function UserProfile({
236
329
  subscriptions?: Record<string, unknown>[];
237
330
  };
238
331
  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>;
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>;
241
336
 
242
337
  if (sub) {
243
338
  setSubscription({
244
339
  id: String(sub.id ?? ''),
245
340
  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),
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
+ ),
250
358
  });
251
359
  } else {
252
360
  setSubscription(null);
@@ -256,9 +364,15 @@ export function UserProfile({
256
364
  }
257
365
 
258
366
  // Invoices
259
- for (const path of [`${billingUrl}/v1/billing/invoices`, `${billingUrl}/v1/invoices`]) {
367
+ for (const path of [
368
+ `${billingUrl}/v1/billing/invoices`,
369
+ `${billingUrl}/v1/invoices`,
370
+ ]) {
260
371
  try {
261
- const invJ = (await fetchJson(path)) as { ok?: boolean; invoices?: unknown[] };
372
+ const invJ = (await fetchJson(path)) as {
373
+ ok?: boolean;
374
+ invoices?: unknown[];
375
+ };
262
376
  if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
263
377
  setInvoices(
264
378
  (invJ.invoices as Record<string, unknown>[]).map((inv) => ({
@@ -266,7 +380,10 @@ export function UserProfile({
266
380
  amount: Number(inv.amount ?? 0),
267
381
  currency: String(inv.currency ?? 'USD'),
268
382
  status: String(inv.status ?? 'pending'),
269
- billedAt: (inv.billedAt as string | null) ?? (inv.billed_at as string | null) ?? null,
383
+ billedAt:
384
+ (inv.billedAt as string | null) ??
385
+ (inv.billed_at as string | null) ??
386
+ null,
270
387
  }))
271
388
  );
272
389
  break;
@@ -276,14 +393,20 @@ export function UserProfile({
276
393
  }
277
394
  }
278
395
 
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
-
396
+ // Plans (needs projectId — if missing, try with publishableKey header instead of empty projectId)
286
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
+ }
287
410
  for (const p of planPaths) {
288
411
  try {
289
412
  const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
@@ -307,16 +430,32 @@ export function UserProfile({
307
430
  }, [client, user]);
308
431
 
309
432
  useEffect(() => {
310
- if (tab === 'security') void loadSessions();
433
+ if (tab === 'security') void loadSessions(0);
311
434
  if (tab === 'billing') void loadBilling();
312
435
  }, [tab, loadSessions, loadBilling]);
313
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
+
314
453
  async function onProfileSubmit(e: FormEvent) {
315
454
  e.preventDefault();
316
455
  setBusy(true);
317
456
  setSaved(false);
318
457
  try {
319
- await client.users.update({ firstName, lastName, avatarUrl });
458
+ await client.users.update({ firstName, lastName, username, avatarUrl });
320
459
  await reload();
321
460
  setSaved(true);
322
461
  setTimeout(() => setSaved(false), 2500);
@@ -337,6 +476,98 @@ export function UserProfile({
337
476
  }
338
477
  }
339
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
+
340
571
  async function onPasswordSubmit(e: FormEvent) {
341
572
  e.preventDefault();
342
573
  setPwError(null);
@@ -358,7 +589,9 @@ export function UserProfile({
358
589
  setConfirmPassword('');
359
590
  setTimeout(() => setPwSaved(false), 3000);
360
591
  } catch (err) {
361
- setPwError(err instanceof Error ? err.message : 'Failed to change password');
592
+ setPwError(
593
+ err instanceof Error ? err.message : 'Failed to change password'
594
+ );
362
595
  } finally {
363
596
  setPwBusy(false);
364
597
  }
@@ -368,7 +601,7 @@ export function UserProfile({
368
601
  setRevokingId(id);
369
602
  try {
370
603
  await client.sessions.revoke(id);
371
- await loadSessions();
604
+ await loadSessions(sessionsPage);
372
605
  } finally {
373
606
  setRevokingId(null);
374
607
  }
@@ -378,7 +611,7 @@ export function UserProfile({
378
611
  setOthersRevoking(true);
379
612
  try {
380
613
  await client.sessions.revokeOthers();
381
- await loadSessions();
614
+ await loadSessions(0);
382
615
  } finally {
383
616
  setOthersRevoking(false);
384
617
  }
@@ -392,39 +625,58 @@ export function UserProfile({
392
625
  await client.auth.signOut().catch(() => undefined);
393
626
  onDeleted?.();
394
627
  } catch (err) {
395
- setDeleteError(err instanceof Error ? err.message : 'Failed to delete account');
628
+ setDeleteError(
629
+ err instanceof Error ? err.message : 'Failed to delete account'
630
+ );
396
631
  } finally {
397
632
  setDeleteBusy(false);
398
633
  }
399
634
  }
400
635
 
401
- async function handleCheckout(planId: string) {
402
- setCheckoutId(planId);
636
+ async function handleCheckout(plan: Plan) {
637
+ setCheckoutId(plan.id);
638
+ setCheckoutDone(false);
403
639
  try {
640
+ // Always use Paddle.js overlay checkout
641
+ // authApiUrl is available via client apiUrl
404
642
  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})`);
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;
426
679
  } catch (err) {
427
- // Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
428
680
  console.error('[SlyxUp] checkout failed', err);
429
681
  } finally {
430
682
  setCheckoutId(null);
@@ -435,11 +687,13 @@ export function UserProfile({
435
687
  if (!user) return null;
436
688
 
437
689
  const emailVerified = user.emailVerified;
438
- const name = displayName(user as { firstName: string | null; lastName: string | null; email: string });
690
+ const name = displayName(
691
+ user as { firstName: string | null; lastName: string | null; email: string }
692
+ );
439
693
 
440
694
  // Resolve current plan name via plans lookup if subscription has planId but no planName
441
695
  const currentPlan = subscription
442
- ? plans.find((p) => p.id === subscription.planId) ?? null
696
+ ? (plans.find((p) => p.id === subscription.planId) ?? null)
443
697
  : null;
444
698
  const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
445
699
 
@@ -497,20 +751,39 @@ export function UserProfile({
497
751
  <section className="slx-profile-sec">
498
752
  <div className="slx-avatar-row">
499
753
  <div className="slx-avatar-lg" aria-hidden="true">
500
- {user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user as { firstName: string | null; lastName: string | null; email: string })}
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
+ )}
501
765
  </div>
502
766
  <div style={{ minWidth: 0 }}>
503
- <p className="slx-row-value" style={{ margin: 0, wordBreak: 'break-word' }}>
767
+ <p
768
+ className="slx-row-value"
769
+ style={{ margin: 0, wordBreak: 'break-word' }}
770
+ >
504
771
  {name}
505
772
  </p>
506
- <p className="slx-row-label" style={{ wordBreak: 'break-all' }}>
773
+ <p
774
+ className="slx-row-label"
775
+ style={{ wordBreak: 'break-all' }}
776
+ >
507
777
  {user.email}
508
778
  </p>
509
779
  </div>
510
780
  </div>
511
781
 
512
782
  {saved && (
513
- <p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
783
+ <p
784
+ className="slx-error-text"
785
+ style={{ color: 'var(--slx-success)' }}
786
+ >
514
787
  Profile saved.
515
788
  </p>
516
789
  )}
@@ -554,7 +827,27 @@ export function UserProfile({
554
827
  value={avatarUrl}
555
828
  onChange={(e) => setAvatarUrl(e.target.value)}
556
829
  />
557
- <p className="slx-hint">Paste a public image URL for your avatar.</p>
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>
558
851
  </div>
559
852
  <button className="slx-btn" type="submit" disabled={busy}>
560
853
  {busy ? 'Saving…' : 'Save changes'}
@@ -566,7 +859,10 @@ export function UserProfile({
566
859
  <h3 className="slx-sec-title">Email</h3>
567
860
  <div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
568
861
  <div style={{ minWidth: 0 }}>
569
- <p className="slx-row-value" style={{ wordBreak: 'break-all' }}>
862
+ <p
863
+ className="slx-row-value"
864
+ style={{ wordBreak: 'break-all' }}
865
+ >
570
866
  {user.email}
571
867
  </p>
572
868
  <p className="slx-row-label">Primary email</p>
@@ -582,7 +878,9 @@ export function UserProfile({
582
878
  flexWrap: 'wrap',
583
879
  }}
584
880
  >
585
- <span className="slx-badge slx-badge-warn">Unverified</span>
881
+ <span className="slx-badge slx-badge-warn">
882
+ Unverified
883
+ </span>
586
884
  <button
587
885
  type="button"
588
886
  className="slx-link"
@@ -604,7 +902,10 @@ export function UserProfile({
604
902
  <section className="slx-profile-sec">
605
903
  <h3 className="slx-sec-title">Change password</h3>
606
904
  {pwSaved && (
607
- <p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
905
+ <p
906
+ className="slx-error-text"
907
+ style={{ color: 'var(--slx-success)' }}
908
+ >
608
909
  Password updated.
609
910
  </p>
610
911
  )}
@@ -673,10 +974,18 @@ export function UserProfile({
673
974
  <div className="slx-session-meta">
674
975
  <p className="slx-session-device">
675
976
  {deviceLabel(s.userAgent)}
676
- {s.isCurrent && <span className="slx-badge slx-badge-accent">This device</span>}
977
+ {s.isCurrent && (
978
+ <span className="slx-badge slx-badge-accent">
979
+ This device
980
+ </span>
981
+ )}
677
982
  </p>
678
983
  <p className="slx-session-sub">
679
- {[s.ipAddress, `created ${formatDate(s.createdAt)}`, `expires ${formatDate(s.expiresAt)}`]
984
+ {[
985
+ s.ipAddress,
986
+ `created ${formatDate(s.createdAt)}`,
987
+ `expires ${formatDate(s.expiresAt)}`,
988
+ ]
680
989
  .filter(Boolean)
681
990
  .join(' · ')}
682
991
  </p>
@@ -701,9 +1010,289 @@ export function UserProfile({
701
1010
  onClick={onRevokeOthers}
702
1011
  disabled={othersRevoking}
703
1012
  >
704
- {othersRevoking ? 'Signing out…' : 'Sign out other devices'}
1013
+ {othersRevoking
1014
+ ? 'Signing out…'
1015
+ : 'Sign out other devices'}
705
1016
  </button>
706
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>
707
1296
  </>
708
1297
  )}
709
1298
  </section>
@@ -711,8 +1300,9 @@ export function UserProfile({
711
1300
  <section className="slx-danger-zone">
712
1301
  <p className="slx-danger-title">Danger zone</p>
713
1302
  <p className="slx-danger-desc">
714
- Permanently deletes your account and all associated data. Active sessions are revoked immediately. This
715
- cannot be undone.
1303
+ Permanently deletes your account and all associated data.
1304
+ Active sessions are revoked immediately. This cannot be
1305
+ undone.
716
1306
  </p>
717
1307
  {deleteError && <p className="slx-error-text">{deleteError}</p>}
718
1308
  <div className="slx-field">
@@ -754,9 +1344,28 @@ export function UserProfile({
754
1344
  <>
755
1345
  <section className="slx-profile-sec">
756
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
+ )}
757
1363
  <div className="slx-billing-card">
758
1364
  <p className="slx-billing-plan">No active subscription</p>
759
- <p className="slx-billing-detail">You don&apos;t have a subscription yet. Choose a plan to get started.</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>
760
1369
  </div>
761
1370
  </section>
762
1371
 
@@ -765,8 +1374,13 @@ export function UserProfile({
765
1374
  {plansLoading ? (
766
1375
  <p className="slx-hint">Loading plans…</p>
767
1376
  ) : 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>
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>
770
1384
  <p className="slx-hint" style={{ marginTop: 6 }}>
771
1385
  Ask your admin to create a plan in billing.
772
1386
  </p>
@@ -774,19 +1388,37 @@ export function UserProfile({
774
1388
  ) : (
775
1389
  <div className="slx-billing-plans">
776
1390
  {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>}
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
+ )}
779
1398
  <p className="slx-plan-name">{plan.name}</p>
780
1399
  <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>
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>
783
1406
  </p>
784
1407
  {plan.trialDays ? (
785
- <p className="slx-billing-detail" style={{ color: 'var(--slx-accent)', marginTop: 4 }}>
1408
+ <p
1409
+ className="slx-billing-detail"
1410
+ style={{
1411
+ color: 'var(--slx-accent)',
1412
+ marginTop: 4,
1413
+ }}
1414
+ >
786
1415
  {plan.trialDays} day free trial
787
1416
  </p>
788
1417
  ) : (
789
- <p className="slx-billing-detail" style={{ visibility: 'hidden', marginTop: 4 }}>
1418
+ <p
1419
+ className="slx-billing-detail"
1420
+ style={{ visibility: 'hidden', marginTop: 4 }}
1421
+ >
790
1422
  &nbsp;
791
1423
  </p>
792
1424
  )}
@@ -798,10 +1430,12 @@ export function UserProfile({
798
1430
  <button
799
1431
  type="button"
800
1432
  className="slx-btn slx-plan-cta"
801
- onClick={() => handleCheckout(plan.id)}
1433
+ onClick={() => handleCheckout(plan)}
802
1434
  disabled={checkoutId === plan.id}
803
1435
  >
804
- {checkoutId === plan.id ? 'Redirecting…' : 'Choose plan'}
1436
+ {checkoutId === plan.id
1437
+ ? 'Redirecting…'
1438
+ : 'Choose plan'}
805
1439
  </button>
806
1440
  </div>
807
1441
  ))}
@@ -814,11 +1448,19 @@ export function UserProfile({
814
1448
  <h3 className="slx-sec-title">Invoices</h3>
815
1449
  {invoices.map((inv) => (
816
1450
  <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>
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>
819
1457
  <span
820
1458
  className={`slx-badge ${
821
- inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
1459
+ inv.status === 'paid'
1460
+ ? 'slx-badge-ok'
1461
+ : inv.status === 'overdue'
1462
+ ? 'slx-badge-warn'
1463
+ : 'slx-badge-accent'
822
1464
  }`}
823
1465
  >
824
1466
  {inv.status}
@@ -843,10 +1485,14 @@ export function UserProfile({
843
1485
  }}
844
1486
  >
845
1487
  <div style={{ minWidth: 0 }}>
846
- <p className="slx-billing-plan">{resolvedPlanName ?? 'Subscription'}</p>
1488
+ <p className="slx-billing-plan">
1489
+ {resolvedPlanName ?? 'Subscription'}
1490
+ </p>
847
1491
  <p className="slx-billing-detail">
848
1492
  Status:{' '}
849
- <span className={`slx-billing-status slx-billing-status-${subscription.status}`}>
1493
+ <span
1494
+ className={`slx-billing-status slx-billing-status-${subscription.status}`}
1495
+ >
850
1496
  {subscription.status}
851
1497
  </span>
852
1498
  </p>
@@ -858,26 +1504,47 @@ export function UserProfile({
858
1504
  </p>
859
1505
  )}
860
1506
  {subscription.cancelAtPeriodEnd && (
861
- <p className="slx-billing-detail" style={{ color: 'var(--slx-danger)', fontWeight: 600 }}>
1507
+ <p
1508
+ className="slx-billing-detail"
1509
+ style={{
1510
+ color: 'var(--slx-danger)',
1511
+ fontWeight: 600,
1512
+ }}
1513
+ >
862
1514
  Scheduled to cancel at period end
863
1515
  </p>
864
1516
  )}
865
1517
  </div>
866
1518
  {currentPlan && (
867
- <span className="slx-badge slx-badge-accent" style={{ flexShrink: 0 }}>
868
- {formatCurrency(currentPlan.amount, currentPlan.currency)}/{currentPlan.interval}
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}
869
1528
  </span>
870
1529
  )}
871
1530
  </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
- )}
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
+ )}
879
1542
  <div className="slx-billing-actions">
880
- <button type="button" className="slx-btn-secondary" onClick={() => void loadBilling()}>
1543
+ <button
1544
+ type="button"
1545
+ className="slx-btn-secondary"
1546
+ onClick={() => void loadBilling()}
1547
+ >
881
1548
  Refresh
882
1549
  </button>
883
1550
  </div>
@@ -893,7 +1560,8 @@ export function UserProfile({
893
1560
  <section className="slx-profile-sec">
894
1561
  <h3 className="slx-sec-title">Available plans</h3>
895
1562
  <p className="slx-hint" style={{ marginBottom: 8 }}>
896
- Switch plans anytime. Changes apply at the next billing cycle.
1563
+ Switch plans anytime. Changes apply at the next billing
1564
+ cycle.
897
1565
  </p>
898
1566
  <div className="slx-billing-plans">
899
1567
  {plans.map((plan) => {
@@ -902,26 +1570,49 @@ export function UserProfile({
902
1570
  : resolvedPlanName === plan.name;
903
1571
  const currentAmount = currentPlan?.amount ?? 0;
904
1572
  const isUpgrade = plan.amount > currentAmount;
905
- const isDowngrade = plan.amount < currentAmount && !isCurrent;
1573
+ const isDowngrade =
1574
+ plan.amount < currentAmount && !isCurrent;
906
1575
  return (
907
1576
  <div
908
1577
  key={plan.id}
909
1578
  className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
910
1579
  style={isCurrent ? { opacity: 0.92 } : undefined}
911
1580
  >
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>}
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
+ )}
914
1592
  <p className="slx-plan-name">{plan.name}</p>
915
1593
  <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>
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>
918
1600
  </p>
919
1601
  {plan.trialDays ? (
920
- <p className="slx-billing-detail" style={{ color: 'var(--slx-accent)', marginTop: 4 }}>
1602
+ <p
1603
+ className="slx-billing-detail"
1604
+ style={{
1605
+ color: 'var(--slx-accent)',
1606
+ marginTop: 4,
1607
+ }}
1608
+ >
921
1609
  {plan.trialDays} day trial
922
1610
  </p>
923
1611
  ) : (
924
- <p className="slx-billing-detail" style={{ visibility: 'hidden', marginTop: 4 }}>
1612
+ <p
1613
+ className="slx-billing-detail"
1614
+ style={{ visibility: 'hidden', marginTop: 4 }}
1615
+ >
925
1616
  &nbsp;
926
1617
  </p>
927
1618
  )}
@@ -932,9 +1623,13 @@ export function UserProfile({
932
1623
  </ul>
933
1624
  <button
934
1625
  type="button"
935
- className={isCurrent ? 'slx-btn-secondary slx-plan-cta' : 'slx-btn slx-plan-cta'}
1626
+ className={
1627
+ isCurrent
1628
+ ? 'slx-btn-secondary slx-plan-cta'
1629
+ : 'slx-btn slx-plan-cta'
1630
+ }
936
1631
  disabled={isCurrent || checkoutId === plan.id}
937
- onClick={() => handleCheckout(plan.id)}
1632
+ onClick={() => handleCheckout(plan)}
938
1633
  >
939
1634
  {isCurrent
940
1635
  ? 'Current plan'
@@ -958,11 +1653,19 @@ export function UserProfile({
958
1653
  <h3 className="slx-sec-title">Invoices</h3>
959
1654
  {invoices.map((inv) => (
960
1655
  <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>
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>
963
1662
  <span
964
1663
  className={`slx-badge ${
965
- inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
1664
+ inv.status === 'paid'
1665
+ ? 'slx-badge-ok'
1666
+ : inv.status === 'overdue'
1667
+ ? 'slx-badge-warn'
1668
+ : 'slx-badge-accent'
966
1669
  }`}
967
1670
  >
968
1671
  {inv.status}
@@ -989,6 +1692,7 @@ export function UserProfile({
989
1692
  // Modal overlay — click on backdrop closes, click inside modal does not.
990
1693
  // Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
991
1694
  return (
1695
+ // biome-ignore lint/a11y/useKeyWithClickEvents: overlay click is for mouse; keyboard Escape handled in effect
992
1696
  <div
993
1697
  className="slx-overlay"
994
1698
  onMouseDown={(e) => {
@@ -999,6 +1703,8 @@ export function UserProfile({
999
1703
  }}
1000
1704
  role="presentation"
1001
1705
  >
1706
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only, no keyboard action needed */}
1707
+ {/* biome-ignore lint/a11y/useSemanticElements: dialog is correct for modal */}
1002
1708
  <div
1003
1709
  role="dialog"
1004
1710
  aria-modal="true"