@slyxup/ui 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +4 -1
  2. package/dist/components/SignIn/SignIn.d.ts.map +1 -1
  3. package/dist/components/SignIn/SignIn.js +2 -1
  4. package/dist/components/SignIn/SignIn.js.map +1 -1
  5. package/dist/components/SignUp/SignUp.d.ts.map +1 -1
  6. package/dist/components/SignUp/SignUp.js +2 -1
  7. package/dist/components/SignUp/SignUp.js.map +1 -1
  8. package/dist/components/SocialButtons/SocialButtons.d.ts.map +1 -1
  9. package/dist/components/SocialButtons/SocialButtons.js +2 -1
  10. package/dist/components/SocialButtons/SocialButtons.js.map +1 -1
  11. package/dist/components/UserButton/UserButton.d.ts +5 -1
  12. package/dist/components/UserButton/UserButton.d.ts.map +1 -1
  13. package/dist/components/UserButton/UserButton.js +6 -2
  14. package/dist/components/UserButton/UserButton.js.map +1 -1
  15. package/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
  16. package/dist/components/UserProfile/UserProfile.js +248 -27
  17. package/dist/components/UserProfile/UserProfile.js.map +1 -1
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/styles.d.ts +1 -1
  23. package/dist/styles.d.ts.map +1 -1
  24. package/dist/styles.js +269 -0
  25. package/dist/styles.js.map +1 -1
  26. package/package.json +9 -9
  27. package/src/components/SignIn/SignIn.tsx +2 -1
  28. package/src/components/SignUp/SignUp.tsx +2 -1
  29. package/src/components/SocialButtons/SocialButtons.tsx +2 -1
  30. package/src/components/UserButton/UserButton.tsx +12 -2
  31. package/src/components/UserProfile/UserProfile.tsx +556 -61
  32. package/src/index.ts +12 -1
  33. package/src/styles.ts +269 -0
  34. package/.turbo/turbo-build.log +0 -4
  35. package/LICENSE +0 -21
@@ -11,15 +11,31 @@ export interface UserProfileProps {
11
11
  onDeleted?: () => void;
12
12
  }
13
13
 
14
- type Tab = 'profile' | 'security';
14
+ type Tab = 'profile' | 'security' | 'billing';
15
15
 
16
- function initials(user: { firstName: string | null; email: string }): string {
17
- const n = user.firstName?.trim();
18
- if (n) return n.slice(0, 1).toUpperCase();
16
+ function initials(user: {
17
+ firstName: string | null;
18
+ lastName?: string | null;
19
+ email: string;
20
+ }): string {
21
+ const f = user.firstName?.trim();
22
+ const l = user.lastName?.trim();
23
+ if (f && l) return (f[0] + l[0]).toUpperCase();
24
+ if (f) return f.slice(0, 1).toUpperCase();
25
+ if (l) return l.slice(0, 1).toUpperCase();
19
26
  return user.email.slice(0, 1).toUpperCase();
20
27
  }
21
28
 
22
- /** Best-effort device label from a user agent string. */
29
+ function displayName(user: {
30
+ firstName: string | null;
31
+ lastName: string | null;
32
+ email: string;
33
+ }): string {
34
+ const parts = [user.firstName?.trim(), user.lastName?.trim()].filter(Boolean);
35
+ const name = parts.join(' ');
36
+ return name || user.email;
37
+ }
38
+
23
39
  function deviceLabel(ua: string | null): string {
24
40
  if (!ua) return 'Unknown device';
25
41
  const os = /Windows/i.test(ua)
@@ -56,6 +72,28 @@ function formatDate(iso: string): string {
56
72
  });
57
73
  }
58
74
 
75
+ function formatCurrency(amount: number, currency: string): string {
76
+ try {
77
+ return new Intl.NumberFormat(undefined, {
78
+ style: 'currency',
79
+ currency: currency.toUpperCase(),
80
+ }).format(amount / 100);
81
+ } catch {
82
+ return `${(amount / 100).toFixed(2)} ${currency.toUpperCase()}`;
83
+ }
84
+ }
85
+
86
+ interface Plan {
87
+ id: string;
88
+ name: string;
89
+ amount: number;
90
+ currency: string;
91
+ interval: string;
92
+ trialDays: number | null;
93
+ features: string[] | null;
94
+ isPopular: boolean;
95
+ }
96
+
59
97
  export function UserProfile({
60
98
  modal = true,
61
99
  onClose,
@@ -90,18 +128,43 @@ export function UserProfile({
90
128
  const [revokingId, setRevokingId] = useState<string | null>(null);
91
129
  const [othersRevoking, setOthersRevoking] = useState(false);
92
130
 
131
+ // ── Billing state ──
132
+ const [billingLoading, setBillingLoading] = useState(true);
133
+ const [subscription, setSubscription] = useState<{
134
+ id: string;
135
+ status: string;
136
+ planId?: string | null;
137
+ planName: string | null;
138
+ currentPeriodEnd: string | null;
139
+ cancelAtPeriodEnd: boolean;
140
+ } | null>(null);
141
+ const [invoices, setInvoices] = useState<
142
+ {
143
+ id: string;
144
+ amount: number;
145
+ currency: string;
146
+ status: string;
147
+ billedAt: string | null;
148
+ }[]
149
+ >([]);
150
+ const [plans, setPlans] = useState<Plan[]>([]);
151
+ const [plansLoading, setPlansLoading] = useState(false);
152
+ const [checkoutId, setCheckoutId] = useState<string | null>(null);
153
+
93
154
  // ── Danger zone state ──
94
155
  const [confirmText, setConfirmText] = useState('');
95
156
  const [deleteBusy, setDeleteBusy] = useState(false);
96
157
  const [deleteError, setDeleteError] = useState<string | null>(null);
97
158
 
159
+ // Sync form fields when user loads/updates (ensures firstName/lastName show correctly
160
+ // after SlyxUpProvider fetches client.users.me()). Guard against stale overwrites.
98
161
  useEffect(() => {
99
162
  if (user) {
100
163
  setFirstName(user.firstName ?? '');
101
164
  setLastName(user.lastName ?? '');
102
165
  setAvatarUrl(user.avatarUrl ?? '');
103
166
  }
104
- }, [user]);
167
+ }, [user?.firstName, user?.lastName, user?.avatarUrl]);
105
168
 
106
169
  useEffect(() => {
107
170
  function onKey(e: KeyboardEvent) {
@@ -123,9 +186,130 @@ export function UserProfile({
123
186
  }
124
187
  }, [client]);
125
188
 
189
+ const loadBilling = useCallback(async () => {
190
+ setBillingLoading(true);
191
+ setPlansLoading(true);
192
+ try {
193
+ const rawApiUrl =
194
+ (client as unknown as { apiUrl: string }).apiUrl ?? 'https://auth.slyxup.online';
195
+ const billingUrl = rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
196
+ const token = (client as unknown as { _token?: string; getToken?: () => string | undefined })?._token
197
+ ?? (client as unknown as { getToken?: () => string | undefined })?.getToken?.();
198
+ const headers: Record<string, string> = {
199
+ 'Content-Type': 'application/json',
200
+ };
201
+ if (token) headers.Authorization = `Bearer ${token}`;
202
+ // Also forward publishable key if available (helps billing resolve project)
203
+ const pubKey = (client as unknown as { publishableKey?: string })?.publishableKey;
204
+ if (pubKey && pubKey !== 'pk_test_missing') headers['X-Publishable-Key'] = pubKey;
205
+
206
+ // Derive projectId for plans: prefer user.projectId, then try subscription's projectId, fallback none
207
+ const projectId = (user as unknown as { projectId?: string | null })?.projectId ?? null;
208
+
209
+ // Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
210
+ async function fetchJson(url: string) {
211
+ const res = await fetch(url, { headers, credentials: 'include' });
212
+ if (!res.ok) throw new Error(String(res.status));
213
+ return res.json();
214
+ }
215
+
216
+ // Subscription
217
+ let subData: unknown = null;
218
+ for (const path of [
219
+ `${billingUrl}/v1/billing/subscription${projectId ? `?projectId=${projectId}` : ''}`,
220
+ `${billingUrl}/v1/subscription`,
221
+ ]) {
222
+ try {
223
+ const j = await fetchJson(path) as Record<string, unknown>;
224
+ if (j && (j as { ok?: boolean }).ok !== false) {
225
+ subData = j;
226
+ break;
227
+ }
228
+ } catch {
229
+ // try next path
230
+ }
231
+ }
232
+ if (subData) {
233
+ const sd = subData as {
234
+ ok?: boolean;
235
+ subscription?: Record<string, unknown> | null;
236
+ subscriptions?: Record<string, unknown>[];
237
+ };
238
+ let sub: Record<string, unknown> | null = null;
239
+ if (sd.subscription !== undefined) sub = sd.subscription as Record<string, unknown> | null;
240
+ else if (Array.isArray(sd.subscriptions) && sd.subscriptions.length > 0) sub = sd.subscriptions[0] as Record<string, unknown>;
241
+
242
+ if (sub) {
243
+ setSubscription({
244
+ id: String(sub.id ?? ''),
245
+ status: String(sub.status ?? 'active'),
246
+ planId: (sub.planId as string | null) ?? (sub.plan_id as string | null) ?? null,
247
+ planName: (sub.planName as string | null) ?? (sub.plan_name as string | null) ?? (sub.name as string | null) ?? null,
248
+ currentPeriodEnd: (sub.currentPeriodEnd as string | null) ?? (sub.current_period_end as string | null) ?? (sub.currentPeriod_end as string | null) ?? null,
249
+ cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd ?? sub.cancel_at_period_end ?? false),
250
+ });
251
+ } else {
252
+ setSubscription(null);
253
+ }
254
+ } else {
255
+ setSubscription(null);
256
+ }
257
+
258
+ // Invoices
259
+ for (const path of [`${billingUrl}/v1/billing/invoices`, `${billingUrl}/v1/invoices`]) {
260
+ try {
261
+ const invJ = (await fetchJson(path)) as { ok?: boolean; invoices?: unknown[] };
262
+ if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
263
+ setInvoices(
264
+ (invJ.invoices as Record<string, unknown>[]).map((inv) => ({
265
+ id: String(inv.id),
266
+ amount: Number(inv.amount ?? 0),
267
+ currency: String(inv.currency ?? 'USD'),
268
+ status: String(inv.status ?? 'pending'),
269
+ billedAt: (inv.billedAt as string | null) ?? (inv.billed_at as string | null) ?? null,
270
+ }))
271
+ );
272
+ break;
273
+ }
274
+ } catch {
275
+ // try next
276
+ }
277
+ }
278
+
279
+ // Plans (needs projectId — if missing, try without and degrade gracefully)
280
+ const planPaths: string[] = [];
281
+ if (projectId) planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
282
+ // Also try without projectId as last resort (will 400 but we catch)
283
+ planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId ?? ''}`);
284
+ planPaths.push(`${billingUrl}/v1/plans`);
285
+
286
+ let gotPlans = false;
287
+ for (const p of planPaths) {
288
+ try {
289
+ const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
290
+ if (pj?.ok !== false && Array.isArray(pj.plans)) {
291
+ setPlans(pj.plans);
292
+ gotPlans = true;
293
+ break;
294
+ }
295
+ } catch {
296
+ // continue
297
+ }
298
+ }
299
+ if (!gotPlans) setPlans([]);
300
+ } catch {
301
+ // Billing unavailable — show empty state, keep plans empty
302
+ setPlans([]);
303
+ } finally {
304
+ setBillingLoading(false);
305
+ setPlansLoading(false);
306
+ }
307
+ }, [client, user]);
308
+
126
309
  useEffect(() => {
127
310
  if (tab === 'security') void loadSessions();
128
- }, [tab, loadSessions]);
311
+ if (tab === 'billing') void loadBilling();
312
+ }, [tab, loadSessions, loadBilling]);
129
313
 
130
314
  async function onProfileSubmit(e: FormEvent) {
131
315
  e.preventDefault();
@@ -174,9 +358,7 @@ export function UserProfile({
174
358
  setConfirmPassword('');
175
359
  setTimeout(() => setPwSaved(false), 3000);
176
360
  } catch (err) {
177
- setPwError(
178
- err instanceof Error ? err.message : 'Failed to change password'
179
- );
361
+ setPwError(err instanceof Error ? err.message : 'Failed to change password');
180
362
  } finally {
181
363
  setPwBusy(false);
182
364
  }
@@ -210,33 +392,69 @@ export function UserProfile({
210
392
  await client.auth.signOut().catch(() => undefined);
211
393
  onDeleted?.();
212
394
  } catch (err) {
213
- setDeleteError(
214
- err instanceof Error ? err.message : 'Failed to delete account'
215
- );
395
+ setDeleteError(err instanceof Error ? err.message : 'Failed to delete account');
216
396
  } finally {
217
397
  setDeleteBusy(false);
218
398
  }
219
399
  }
220
400
 
401
+ async function handleCheckout(planId: string) {
402
+ setCheckoutId(planId);
403
+ try {
404
+ const rawApiUrl =
405
+ (client as unknown as { apiUrl: string }).apiUrl ?? 'https://auth.slyxup.online';
406
+ const billingUrl = rawApiUrl.replace('auth.slyxup.online', 'billing.slyxup.online');
407
+ const token = (client as unknown as { _token?: string; getToken?: () => string | undefined })?._token
408
+ ?? (client as unknown as { getToken?: () => string | undefined })?.getToken?.();
409
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
410
+ if (token) headers.Authorization = `Bearer ${token}`;
411
+ const pubKey = (client as unknown as { publishableKey?: string })?.publishableKey;
412
+ if (pubKey && pubKey !== 'pk_test_missing') headers['X-Publishable-Key'] = pubKey;
413
+
414
+ const res = await fetch(`${billingUrl}/v1/billing/checkout`, {
415
+ method: 'POST',
416
+ headers,
417
+ credentials: 'include',
418
+ body: JSON.stringify({ planId }),
419
+ });
420
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
421
+ if (res.ok && typeof data.checkoutUrl === 'string' && data.checkoutUrl) {
422
+ window.location.href = data.checkoutUrl as string;
423
+ return;
424
+ }
425
+ if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : `Checkout failed (${res.status})`);
426
+ } catch (err) {
427
+ // Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
428
+ console.error('[SlyxUp] checkout failed', err);
429
+ } finally {
430
+ setCheckoutId(null);
431
+ }
432
+ }
433
+
221
434
  if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
222
435
  if (!user) return null;
223
436
 
224
437
  const emailVerified = user.emailVerified;
438
+ const name = displayName(user as { firstName: string | null; lastName: string | null; email: string });
225
439
 
226
- const body = (
227
- <div
228
- // biome-ignore lint/a11y/useSemanticElements: rendered inside a popover, not a top-level dialog
229
- role="dialog"
230
- aria-modal={modal || undefined}
231
- aria-label="Account settings"
232
- >
440
+ // Resolve current plan name via plans lookup if subscription has planId but no planName
441
+ const currentPlan = subscription
442
+ ? plans.find((p) => p.id === subscription.planId) ?? null
443
+ : null;
444
+ const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
445
+
446
+ const bodyInner = (
447
+ <>
233
448
  <div className="slx-profile-head">
234
449
  <h2 className="slx-profile-title">Account settings</h2>
235
450
  {modal && (
236
451
  <button
237
452
  type="button"
238
453
  className="slx-profile-close"
239
- onClick={onClose}
454
+ onClick={(e) => {
455
+ e.stopPropagation();
456
+ onClose?.();
457
+ }}
240
458
  aria-label="Close account settings"
241
459
  >
242
460
 
@@ -262,28 +480,37 @@ export function UserProfile({
262
480
  >
263
481
  <ShieldIcon /> Security
264
482
  </button>
483
+ <button
484
+ type="button"
485
+ className={`slx-profile-nav-btn${tab === 'billing' ? ' on' : ''}`}
486
+ onClick={() => setTab('billing')}
487
+ aria-current={tab === 'billing' ? 'page' : undefined}
488
+ >
489
+ <CreditCardIcon /> Billing
490
+ </button>
265
491
  </nav>
266
492
 
267
493
  <div className="slx-profile-content">
268
- {tab === 'profile' ? (
494
+ {/* ── Profile Tab ── */}
495
+ {tab === 'profile' && (
269
496
  <>
270
497
  <section className="slx-profile-sec">
271
498
  <div className="slx-avatar-row">
272
499
  <div className="slx-avatar-lg" aria-hidden="true">
273
- {user.avatarUrl ? (
274
- <img src={user.avatarUrl} alt="" />
275
- ) : (
276
- initials(user)
277
- )}
500
+ {user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user as { firstName: string | null; lastName: string | null; email: string })}
501
+ </div>
502
+ <div style={{ minWidth: 0 }}>
503
+ <p className="slx-row-value" style={{ margin: 0, wordBreak: 'break-word' }}>
504
+ {name}
505
+ </p>
506
+ <p className="slx-row-label" style={{ wordBreak: 'break-all' }}>
507
+ {user.email}
508
+ </p>
278
509
  </div>
279
- <p className="slx-hint" style={{ margin: 0 }}>
280
- Your avatar is loaded from its URL. Paste any public image
281
- link below.
282
- </p>
283
510
  </div>
284
511
 
285
512
  {saved && (
286
- <p className="slx-error-text" style={{ color: '#34a853' }}>
513
+ <p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
287
514
  Profile saved.
288
515
  </p>
289
516
  )}
@@ -297,6 +524,7 @@ export function UserProfile({
297
524
  id="slx-up-first"
298
525
  className="slx-input"
299
526
  type="text"
527
+ autoComplete="given-name"
300
528
  value={firstName}
301
529
  onChange={(e) => setFirstName(e.target.value)}
302
530
  />
@@ -309,6 +537,7 @@ export function UserProfile({
309
537
  id="slx-up-last"
310
538
  className="slx-input"
311
539
  type="text"
540
+ autoComplete="family-name"
312
541
  value={lastName}
313
542
  onChange={(e) => setLastName(e.target.value)}
314
543
  />
@@ -325,6 +554,7 @@ export function UserProfile({
325
554
  value={avatarUrl}
326
555
  onChange={(e) => setAvatarUrl(e.target.value)}
327
556
  />
557
+ <p className="slx-hint">Paste a public image URL for your avatar.</p>
328
558
  </div>
329
559
  <button className="slx-btn" type="submit" disabled={busy}>
330
560
  {busy ? 'Saving…' : 'Save changes'}
@@ -334,24 +564,25 @@ export function UserProfile({
334
564
 
335
565
  <section className="slx-profile-sec">
336
566
  <h3 className="slx-sec-title">Email</h3>
337
- <div className="slx-row">
338
- <div>
339
- <p className="slx-row-value">{user.email}</p>
567
+ <div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
568
+ <div style={{ minWidth: 0 }}>
569
+ <p className="slx-row-value" style={{ wordBreak: 'break-all' }}>
570
+ {user.email}
571
+ </p>
340
572
  <p className="slx-row-label">Primary email</p>
341
573
  </div>
342
574
  {emailVerified ? (
343
- <span className="slx-badge slx-badge-ok">✓ Verified</span>
575
+ <span className="slx-badge slx-badge-ok">Verified</span>
344
576
  ) : (
345
577
  <span
346
578
  style={{
347
579
  display: 'inline-flex',
348
580
  alignItems: 'center',
349
581
  gap: 8,
582
+ flexWrap: 'wrap',
350
583
  }}
351
584
  >
352
- <span className="slx-badge slx-badge-warn">
353
- Unverified
354
- </span>
585
+ <span className="slx-badge slx-badge-warn">Unverified</span>
355
586
  <button
356
587
  type="button"
357
588
  className="slx-link"
@@ -365,12 +596,15 @@ export function UserProfile({
365
596
  </div>
366
597
  </section>
367
598
  </>
368
- ) : (
599
+ )}
600
+
601
+ {/* ── Security Tab ── */}
602
+ {tab === 'security' && (
369
603
  <>
370
604
  <section className="slx-profile-sec">
371
605
  <h3 className="slx-sec-title">Change password</h3>
372
606
  {pwSaved && (
373
- <p className="slx-error-text" style={{ color: '#34a853' }}>
607
+ <p className="slx-error-text" style={{ color: 'var(--slx-success)' }}>
374
608
  Password updated.
375
609
  </p>
376
610
  )}
@@ -430,6 +664,8 @@ export function UserProfile({
430
664
  <h3 className="slx-sec-title">Active sessions</h3>
431
665
  {sessionsLoading ? (
432
666
  <p className="slx-hint">Loading sessions…</p>
667
+ ) : sessions.length === 0 ? (
668
+ <p className="slx-hint">No active sessions.</p>
433
669
  ) : (
434
670
  <>
435
671
  {sessions.map((s) => (
@@ -437,18 +673,10 @@ export function UserProfile({
437
673
  <div className="slx-session-meta">
438
674
  <p className="slx-session-device">
439
675
  {deviceLabel(s.userAgent)}
440
- {s.isCurrent && (
441
- <span className="slx-badge slx-badge-accent">
442
- This device
443
- </span>
444
- )}
676
+ {s.isCurrent && <span className="slx-badge slx-badge-accent">This device</span>}
445
677
  </p>
446
678
  <p className="slx-session-sub">
447
- {[
448
- s.ipAddress,
449
- `created ${formatDate(s.createdAt)}`,
450
- `expires ${formatDate(s.expiresAt)}`,
451
- ]
679
+ {[s.ipAddress, `created ${formatDate(s.createdAt)}`, `expires ${formatDate(s.expiresAt)}`]
452
680
  .filter(Boolean)
453
681
  .join(' · ')}
454
682
  </p>
@@ -469,13 +697,11 @@ export function UserProfile({
469
697
  <button
470
698
  type="button"
471
699
  className="slx-btn-danger-outline"
472
- style={{ width: '100%', padding: '9px 11px' }}
700
+ style={{ width: '100%', marginTop: 4 }}
473
701
  onClick={onRevokeOthers}
474
702
  disabled={othersRevoking}
475
703
  >
476
- {othersRevoking
477
- ? 'Signing out…'
478
- : 'Sign out other devices'}
704
+ {othersRevoking ? 'Signing out…' : 'Sign out other devices'}
479
705
  </button>
480
706
  )}
481
707
  </>
@@ -485,9 +711,8 @@ export function UserProfile({
485
711
  <section className="slx-danger-zone">
486
712
  <p className="slx-danger-title">Danger zone</p>
487
713
  <p className="slx-danger-desc">
488
- Permanently deletes your account and all associated data.
489
- Active sessions are revoked immediately. This cannot be
490
- undone.
714
+ Permanently deletes your account and all associated data. Active sessions are revoked immediately. This
715
+ cannot be undone.
491
716
  </p>
492
717
  {deleteError && <p className="slx-error-text">{deleteError}</p>}
493
718
  <div className="slx-field">
@@ -518,21 +743,272 @@ export function UserProfile({
518
743
  </section>
519
744
  </>
520
745
  )}
746
+
747
+ {/* ── Billing Tab ── */}
748
+ {tab === 'billing' &&
749
+ (billingLoading ? (
750
+ <section className="slx-profile-sec">
751
+ <p className="slx-hint">Loading billing information…</p>
752
+ </section>
753
+ ) : !subscription ? (
754
+ <>
755
+ <section className="slx-profile-sec">
756
+ <h3 className="slx-sec-title">Subscription</h3>
757
+ <div className="slx-billing-card">
758
+ <p className="slx-billing-plan">No active subscription</p>
759
+ <p className="slx-billing-detail">You don&apos;t have a subscription yet. Choose a plan to get started.</p>
760
+ </div>
761
+ </section>
762
+
763
+ <section className="slx-profile-sec">
764
+ <h3 className="slx-sec-title">Available plans</h3>
765
+ {plansLoading ? (
766
+ <p className="slx-hint">Loading plans…</p>
767
+ ) : plans.length === 0 ? (
768
+ <div className="slx-billing-card" style={{ textAlign: 'center' }}>
769
+ <p className="slx-billing-detail">No plans configured for this project yet.</p>
770
+ <p className="slx-hint" style={{ marginTop: 6 }}>
771
+ Ask your admin to create a plan in billing.
772
+ </p>
773
+ </div>
774
+ ) : (
775
+ <div className="slx-billing-plans">
776
+ {plans.map((plan) => (
777
+ <div key={plan.id} className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}>
778
+ {plan.isPopular && <span className="slx-plan-badge">POPULAR</span>}
779
+ <p className="slx-plan-name">{plan.name}</p>
780
+ <p style={{ margin: '2px 0 0' }}>
781
+ <span className="slx-plan-price">{formatCurrency(plan.amount, plan.currency)}</span>
782
+ <span className="slx-plan-interval">/{plan.interval}</span>
783
+ </p>
784
+ {plan.trialDays ? (
785
+ <p className="slx-billing-detail" style={{ color: 'var(--slx-accent)', marginTop: 4 }}>
786
+ {plan.trialDays} day free trial
787
+ </p>
788
+ ) : (
789
+ <p className="slx-billing-detail" style={{ visibility: 'hidden', marginTop: 4 }}>
790
+ &nbsp;
791
+ </p>
792
+ )}
793
+ <ul className="slx-plan-features">
794
+ {(plan.features ?? []).map((f) => (
795
+ <li key={f}>{f}</li>
796
+ ))}
797
+ </ul>
798
+ <button
799
+ type="button"
800
+ className="slx-btn slx-plan-cta"
801
+ onClick={() => handleCheckout(plan.id)}
802
+ disabled={checkoutId === plan.id}
803
+ >
804
+ {checkoutId === plan.id ? 'Redirecting…' : 'Choose plan'}
805
+ </button>
806
+ </div>
807
+ ))}
808
+ </div>
809
+ )}
810
+ </section>
811
+
812
+ {invoices.length > 0 && (
813
+ <section className="slx-profile-sec">
814
+ <h3 className="slx-sec-title">Invoices</h3>
815
+ {invoices.map((inv) => (
816
+ <div key={inv.id} className="slx-invoice-row">
817
+ <span className="slx-invoice-date">{inv.billedAt ? formatDate(inv.billedAt) : '—'}</span>
818
+ <span className="slx-invoice-amount">{formatCurrency(inv.amount, inv.currency)}</span>
819
+ <span
820
+ className={`slx-badge ${
821
+ inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
822
+ }`}
823
+ >
824
+ {inv.status}
825
+ </span>
826
+ </div>
827
+ ))}
828
+ </section>
829
+ )}
830
+ </>
831
+ ) : (
832
+ <>
833
+ <section className="slx-profile-sec">
834
+ <h3 className="slx-sec-title">Current plan</h3>
835
+ <div className="slx-billing-card">
836
+ <div
837
+ style={{
838
+ display: 'flex',
839
+ justifyContent: 'space-between',
840
+ alignItems: 'flex-start',
841
+ gap: 12,
842
+ flexWrap: 'wrap',
843
+ }}
844
+ >
845
+ <div style={{ minWidth: 0 }}>
846
+ <p className="slx-billing-plan">{resolvedPlanName ?? 'Subscription'}</p>
847
+ <p className="slx-billing-detail">
848
+ Status:{' '}
849
+ <span className={`slx-billing-status slx-billing-status-${subscription.status}`}>
850
+ {subscription.status}
851
+ </span>
852
+ </p>
853
+ {subscription.currentPeriodEnd && (
854
+ <p className="slx-billing-detail">
855
+ {subscription.cancelAtPeriodEnd
856
+ ? `Cancels ${formatDate(subscription.currentPeriodEnd)}`
857
+ : `Renews ${formatDate(subscription.currentPeriodEnd)}`}
858
+ </p>
859
+ )}
860
+ {subscription.cancelAtPeriodEnd && (
861
+ <p className="slx-billing-detail" style={{ color: 'var(--slx-danger)', fontWeight: 600 }}>
862
+ Scheduled to cancel at period end
863
+ </p>
864
+ )}
865
+ </div>
866
+ {currentPlan && (
867
+ <span className="slx-badge slx-badge-accent" style={{ flexShrink: 0 }}>
868
+ {formatCurrency(currentPlan.amount, currentPlan.currency)}/{currentPlan.interval}
869
+ </span>
870
+ )}
871
+ </div>
872
+ {currentPlan?.features && currentPlan.features.length > 0 && (
873
+ <ul className="slx-plan-features" style={{ margin: '12px 0 0' }}>
874
+ {currentPlan.features.map((f) => (
875
+ <li key={f}>{f}</li>
876
+ ))}
877
+ </ul>
878
+ )}
879
+ <div className="slx-billing-actions">
880
+ <button type="button" className="slx-btn-secondary" onClick={() => void loadBilling()}>
881
+ Refresh
882
+ </button>
883
+ </div>
884
+ </div>
885
+ </section>
886
+
887
+ {/* Upgrade / Downgrade plans */}
888
+ {plansLoading ? (
889
+ <section className="slx-profile-sec">
890
+ <p className="slx-hint">Loading available plans…</p>
891
+ </section>
892
+ ) : plans.length > 0 ? (
893
+ <section className="slx-profile-sec">
894
+ <h3 className="slx-sec-title">Available plans</h3>
895
+ <p className="slx-hint" style={{ marginBottom: 8 }}>
896
+ Switch plans anytime. Changes apply at the next billing cycle.
897
+ </p>
898
+ <div className="slx-billing-plans">
899
+ {plans.map((plan) => {
900
+ const isCurrent = subscription.planId
901
+ ? subscription.planId === plan.id
902
+ : resolvedPlanName === plan.name;
903
+ const currentAmount = currentPlan?.amount ?? 0;
904
+ const isUpgrade = plan.amount > currentAmount;
905
+ const isDowngrade = plan.amount < currentAmount && !isCurrent;
906
+ return (
907
+ <div
908
+ key={plan.id}
909
+ className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
910
+ style={isCurrent ? { opacity: 0.92 } : undefined}
911
+ >
912
+ {plan.isPopular && !isCurrent && <span className="slx-plan-badge">POPULAR</span>}
913
+ {isCurrent && <span className="slx-plan-badge" style={{ background: 'var(--slx-success)' }}>CURRENT</span>}
914
+ <p className="slx-plan-name">{plan.name}</p>
915
+ <p style={{ margin: '2px 0 0' }}>
916
+ <span className="slx-plan-price">{formatCurrency(plan.amount, plan.currency)}</span>
917
+ <span className="slx-plan-interval">/{plan.interval}</span>
918
+ </p>
919
+ {plan.trialDays ? (
920
+ <p className="slx-billing-detail" style={{ color: 'var(--slx-accent)', marginTop: 4 }}>
921
+ {plan.trialDays} day trial
922
+ </p>
923
+ ) : (
924
+ <p className="slx-billing-detail" style={{ visibility: 'hidden', marginTop: 4 }}>
925
+ &nbsp;
926
+ </p>
927
+ )}
928
+ <ul className="slx-plan-features">
929
+ {(plan.features ?? []).map((f) => (
930
+ <li key={f}>{f}</li>
931
+ ))}
932
+ </ul>
933
+ <button
934
+ type="button"
935
+ className={isCurrent ? 'slx-btn-secondary slx-plan-cta' : 'slx-btn slx-plan-cta'}
936
+ disabled={isCurrent || checkoutId === plan.id}
937
+ onClick={() => handleCheckout(plan.id)}
938
+ >
939
+ {isCurrent
940
+ ? 'Current plan'
941
+ : checkoutId === plan.id
942
+ ? 'Redirecting…'
943
+ : isUpgrade
944
+ ? 'Upgrade'
945
+ : isDowngrade
946
+ ? 'Downgrade'
947
+ : 'Switch plan'}
948
+ </button>
949
+ </div>
950
+ );
951
+ })}
952
+ </div>
953
+ </section>
954
+ ) : null}
955
+
956
+ {invoices.length > 0 && (
957
+ <section className="slx-profile-sec">
958
+ <h3 className="slx-sec-title">Invoices</h3>
959
+ {invoices.map((inv) => (
960
+ <div key={inv.id} className="slx-invoice-row">
961
+ <span className="slx-invoice-date">{inv.billedAt ? formatDate(inv.billedAt) : '—'}</span>
962
+ <span className="slx-invoice-amount">{formatCurrency(inv.amount, inv.currency)}</span>
963
+ <span
964
+ className={`slx-badge ${
965
+ inv.status === 'paid' ? 'slx-badge-ok' : inv.status === 'overdue' ? 'slx-badge-warn' : 'slx-badge-accent'
966
+ }`}
967
+ >
968
+ {inv.status}
969
+ </span>
970
+ </div>
971
+ ))}
972
+ </section>
973
+ )}
974
+ </>
975
+ ))}
521
976
  </div>
522
977
  </div>
523
- </div>
978
+ </>
524
979
  );
525
980
 
526
- if (!modal) return body;
981
+ if (!modal) {
982
+ return (
983
+ <div className="slx-profile-modal" style={{ maxHeight: 'none' }}>
984
+ {bodyInner}
985
+ </div>
986
+ );
987
+ }
527
988
 
989
+ // Modal overlay — click on backdrop closes, click inside modal does not.
990
+ // Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
528
991
  return (
529
992
  <div
530
993
  className="slx-overlay"
531
994
  onMouseDown={(e) => {
532
995
  if (e.target === e.currentTarget) onClose?.();
533
996
  }}
997
+ onClick={(e) => {
998
+ if (e.target === e.currentTarget) onClose?.();
999
+ }}
1000
+ role="presentation"
534
1001
  >
535
- {body}
1002
+ <div
1003
+ role="dialog"
1004
+ aria-modal="true"
1005
+ aria-label="Account settings"
1006
+ className="slx-profile-modal"
1007
+ onMouseDown={(e) => e.stopPropagation()}
1008
+ onClick={(e) => e.stopPropagation()}
1009
+ >
1010
+ {bodyInner}
1011
+ </div>
536
1012
  </div>
537
1013
  );
538
1014
  }
@@ -573,3 +1049,22 @@ function ShieldIcon() {
573
1049
  </svg>
574
1050
  );
575
1051
  }
1052
+
1053
+ function CreditCardIcon() {
1054
+ return (
1055
+ <svg
1056
+ width="15"
1057
+ height="15"
1058
+ viewBox="0 0 24 24"
1059
+ fill="none"
1060
+ stroke="currentColor"
1061
+ strokeWidth="2"
1062
+ strokeLinecap="round"
1063
+ strokeLinejoin="round"
1064
+ aria-hidden="true"
1065
+ >
1066
+ <rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
1067
+ <line x1="1" y1="10" x2="23" y2="10" />
1068
+ </svg>
1069
+ );
1070
+ }