@slyxup/ui 0.2.8 → 0.2.9

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