@slyxup/ui 0.2.8 → 0.2.10

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,12 +147,17 @@ 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 ?? '');
@@ -150,40 +188,165 @@ 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 ??
195
+ 'https://auth.slyxup.online';
196
+ const billingUrl = rawApiUrl.replace(
197
+ 'auth.slyxup.online',
198
+ 'billing.slyxup.online'
199
+ );
200
+ const token =
201
+ (
202
+ client as unknown as {
203
+ _token?: string;
204
+ getToken?: () => string | undefined;
205
+ }
206
+ )?._token ??
207
+ (
208
+ client as unknown as { getToken?: () => string | undefined }
209
+ )?.getToken?.();
160
210
  const headers: Record<string, string> = {
161
211
  'Content-Type': 'application/json',
162
212
  };
163
213
  if (token) headers.Authorization = `Bearer ${token}`;
214
+ // Also forward publishable key if available (helps billing resolve project)
215
+ const pubKey = (client as unknown as { publishableKey?: string })
216
+ ?.publishableKey;
217
+ if (pubKey && pubKey !== 'pk_test_missing')
218
+ headers['X-Publishable-Key'] = pubKey;
219
+
220
+ // Derive projectId for plans: prefer user.projectId, then try subscription's projectId, fallback none
221
+ const projectId =
222
+ (user as unknown as { projectId?: string | null })?.projectId ?? null;
223
+
224
+ // Fetch subscription + invoices (new /v1/billing/* with fallback to legacy /v1/*)
225
+ async function fetchJson(url: string) {
226
+ const res = await fetch(url, { headers, credentials: 'include' });
227
+ if (!res.ok) throw new Error(String(res.status));
228
+ return res.json();
229
+ }
230
+
231
+ // Subscription
232
+ let subData: unknown = null;
233
+ for (const path of [
234
+ `${billingUrl}/v1/billing/subscription${projectId ? `?projectId=${projectId}` : ''}`,
235
+ `${billingUrl}/v1/subscription`,
236
+ ]) {
237
+ try {
238
+ const j = (await fetchJson(path)) as Record<string, unknown>;
239
+ if (j && (j as { ok?: boolean }).ok !== false) {
240
+ subData = j;
241
+ break;
242
+ }
243
+ } catch {
244
+ // try next path
245
+ }
246
+ }
247
+ if (subData) {
248
+ const sd = subData as {
249
+ ok?: boolean;
250
+ subscription?: Record<string, unknown> | null;
251
+ subscriptions?: Record<string, unknown>[];
252
+ };
253
+ let sub: Record<string, unknown> | null = null;
254
+ if (sd.subscription !== undefined)
255
+ sub = sd.subscription as Record<string, unknown> | null;
256
+ else if (Array.isArray(sd.subscriptions) && sd.subscriptions.length > 0)
257
+ sub = sd.subscriptions[0] as Record<string, unknown>;
258
+
259
+ if (sub) {
260
+ setSubscription({
261
+ id: String(sub.id ?? ''),
262
+ status: String(sub.status ?? 'active'),
263
+ planId:
264
+ (sub.planId as string | null) ??
265
+ (sub.plan_id as string | null) ??
266
+ null,
267
+ planName:
268
+ (sub.planName as string | null) ??
269
+ (sub.plan_name as string | null) ??
270
+ (sub.name as string | null) ??
271
+ null,
272
+ currentPeriodEnd:
273
+ (sub.currentPeriodEnd as string | null) ??
274
+ (sub.current_period_end as string | null) ??
275
+ (sub.currentPeriod_end as string | null) ??
276
+ null,
277
+ cancelAtPeriodEnd: Boolean(
278
+ sub.cancelAtPeriodEnd ?? sub.cancel_at_period_end ?? false
279
+ ),
280
+ });
281
+ } else {
282
+ setSubscription(null);
283
+ }
284
+ } else {
285
+ setSubscription(null);
286
+ }
164
287
 
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);
288
+ // Invoices
289
+ for (const path of [
290
+ `${billingUrl}/v1/billing/invoices`,
291
+ `${billingUrl}/v1/invoices`,
292
+ ]) {
293
+ try {
294
+ const invJ = (await fetchJson(path)) as {
295
+ ok?: boolean;
296
+ invoices?: unknown[];
297
+ };
298
+ if (invJ?.ok !== false && Array.isArray(invJ.invoices)) {
299
+ setInvoices(
300
+ (invJ.invoices as Record<string, unknown>[]).map((inv) => ({
301
+ id: String(inv.id),
302
+ amount: Number(inv.amount ?? 0),
303
+ currency: String(inv.currency ?? 'USD'),
304
+ status: String(inv.status ?? 'pending'),
305
+ billedAt:
306
+ (inv.billedAt as string | null) ??
307
+ (inv.billed_at as string | null) ??
308
+ null,
309
+ }))
310
+ );
311
+ break;
312
+ }
313
+ } catch {
314
+ // try next
315
+ }
176
316
  }
177
- if (invRes.status === 'fulfilled' && invRes.value.ok) {
178
- const inv = await invRes.value.json();
179
- if (inv.ok) setInvoices(inv.invoices ?? []);
317
+
318
+ // Plans (needs projectId — if missing, try without and degrade gracefully)
319
+ const planPaths: string[] = [];
320
+ if (projectId)
321
+ planPaths.push(`${billingUrl}/v1/billing/plans?projectId=${projectId}`);
322
+ // Also try without projectId as last resort (will 400 but we catch)
323
+ planPaths.push(
324
+ `${billingUrl}/v1/billing/plans?projectId=${projectId ?? ''}`
325
+ );
326
+ planPaths.push(`${billingUrl}/v1/plans`);
327
+
328
+ let gotPlans = false;
329
+ for (const p of planPaths) {
330
+ try {
331
+ const pj = (await fetchJson(p)) as { ok?: boolean; plans?: Plan[] };
332
+ if (pj?.ok !== false && Array.isArray(pj.plans)) {
333
+ setPlans(pj.plans);
334
+ gotPlans = true;
335
+ break;
336
+ }
337
+ } catch {
338
+ // continue
339
+ }
180
340
  }
341
+ if (!gotPlans) setPlans([]);
181
342
  } catch {
182
- // Billing unavailable — show empty state
343
+ // Billing unavailable — show empty state, keep plans empty
344
+ setPlans([]);
183
345
  } finally {
184
346
  setBillingLoading(false);
347
+ setPlansLoading(false);
185
348
  }
186
- }, [client]);
349
+ }, [client, user]);
187
350
 
188
351
  useEffect(() => {
189
352
  if (tab === 'security') void loadSessions();
@@ -281,25 +444,89 @@ export function UserProfile({
281
444
  }
282
445
  }
283
446
 
447
+ async function handleCheckout(planId: string) {
448
+ setCheckoutId(planId);
449
+ try {
450
+ const rawApiUrl =
451
+ (client as unknown as { apiUrl: string }).apiUrl ??
452
+ 'https://auth.slyxup.online';
453
+ const billingUrl = rawApiUrl.replace(
454
+ 'auth.slyxup.online',
455
+ 'billing.slyxup.online'
456
+ );
457
+ const token =
458
+ (
459
+ client as unknown as {
460
+ _token?: string;
461
+ getToken?: () => string | undefined;
462
+ }
463
+ )?._token ??
464
+ (
465
+ client as unknown as { getToken?: () => string | undefined }
466
+ )?.getToken?.();
467
+ const headers: Record<string, string> = {
468
+ 'Content-Type': 'application/json',
469
+ };
470
+ if (token) headers.Authorization = `Bearer ${token}`;
471
+ const pubKey = (client as unknown as { publishableKey?: string })
472
+ ?.publishableKey;
473
+ if (pubKey && pubKey !== 'pk_test_missing')
474
+ headers['X-Publishable-Key'] = pubKey;
475
+
476
+ const res = await fetch(`${billingUrl}/v1/billing/checkout`, {
477
+ method: 'POST',
478
+ headers,
479
+ credentials: 'include',
480
+ body: JSON.stringify({ planId }),
481
+ });
482
+ const data = (await res.json().catch(() => ({}))) as Record<
483
+ string,
484
+ unknown
485
+ >;
486
+ if (res.ok && typeof data.checkoutUrl === 'string' && data.checkoutUrl) {
487
+ window.location.href = data.checkoutUrl as string;
488
+ return;
489
+ }
490
+ if (!res.ok)
491
+ throw new Error(
492
+ typeof data.error === 'string'
493
+ ? data.error
494
+ : `Checkout failed (${res.status})`
495
+ );
496
+ } catch (err) {
497
+ // Fallback: if billing checkout isn't configured, just log. Parent can handle via window redirect.
498
+ console.error('[SlyxUp] checkout failed', err);
499
+ } finally {
500
+ setCheckoutId(null);
501
+ }
502
+ }
503
+
284
504
  if (!isLoaded) return <div className="slx-card" aria-busy="true" />;
285
505
  if (!user) return null;
286
506
 
287
507
  const emailVerified = user.emailVerified;
508
+ const name = displayName(
509
+ user as { firstName: string | null; lastName: string | null; email: string }
510
+ );
288
511
 
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
- >
512
+ // Resolve current plan name via plans lookup if subscription has planId but no planName
513
+ const currentPlan = subscription
514
+ ? (plans.find((p) => p.id === subscription.planId) ?? null)
515
+ : null;
516
+ const resolvedPlanName = subscription?.planName ?? currentPlan?.name ?? null;
517
+
518
+ const bodyInner = (
519
+ <>
296
520
  <div className="slx-profile-head">
297
521
  <h2 className="slx-profile-title">Account settings</h2>
298
522
  {modal && (
299
523
  <button
300
524
  type="button"
301
525
  className="slx-profile-close"
302
- onClick={onClose}
526
+ onClick={(e) => {
527
+ e.stopPropagation();
528
+ onClose?.();
529
+ }}
303
530
  aria-label="Close account settings"
304
531
  >
305
532
 
@@ -345,16 +572,28 @@ export function UserProfile({
345
572
  {user.avatarUrl ? (
346
573
  <img src={user.avatarUrl} alt="" />
347
574
  ) : (
348
- initials(user)
575
+ initials(
576
+ user as {
577
+ firstName: string | null;
578
+ lastName: string | null;
579
+ email: string;
580
+ }
581
+ )
349
582
  )}
350
583
  </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}
584
+ <div style={{ minWidth: 0 }}>
585
+ <p
586
+ className="slx-row-value"
587
+ style={{ margin: 0, wordBreak: 'break-word' }}
588
+ >
589
+ {name}
590
+ </p>
591
+ <p
592
+ className="slx-row-label"
593
+ style={{ wordBreak: 'break-all' }}
594
+ >
595
+ {user.email}
356
596
  </p>
357
- <p className="slx-row-label">{user.email}</p>
358
597
  </div>
359
598
  </div>
360
599
 
@@ -376,6 +615,7 @@ export function UserProfile({
376
615
  id="slx-up-first"
377
616
  className="slx-input"
378
617
  type="text"
618
+ autoComplete="given-name"
379
619
  value={firstName}
380
620
  onChange={(e) => setFirstName(e.target.value)}
381
621
  />
@@ -388,6 +628,7 @@ export function UserProfile({
388
628
  id="slx-up-last"
389
629
  className="slx-input"
390
630
  type="text"
631
+ autoComplete="family-name"
391
632
  value={lastName}
392
633
  onChange={(e) => setLastName(e.target.value)}
393
634
  />
@@ -416,9 +657,14 @@ export function UserProfile({
416
657
 
417
658
  <section className="slx-profile-sec">
418
659
  <h3 className="slx-sec-title">Email</h3>
419
- <div className="slx-row">
420
- <div>
421
- <p className="slx-row-value">{user.email}</p>
660
+ <div className="slx-row" style={{ flexWrap: 'wrap', gap: 8 }}>
661
+ <div style={{ minWidth: 0 }}>
662
+ <p
663
+ className="slx-row-value"
664
+ style={{ wordBreak: 'break-all' }}
665
+ >
666
+ {user.email}
667
+ </p>
422
668
  <p className="slx-row-label">Primary email</p>
423
669
  </div>
424
670
  {emailVerified ? (
@@ -429,6 +675,7 @@ export function UserProfile({
429
675
  display: 'inline-flex',
430
676
  alignItems: 'center',
431
677
  gap: 8,
678
+ flexWrap: 'wrap',
432
679
  }}
433
680
  >
434
681
  <span className="slx-badge slx-badge-warn">
@@ -616,16 +863,119 @@ export function UserProfile({
616
863
  <p className="slx-hint">Loading billing information…</p>
617
864
  </section>
618
865
  ) : !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>
866
+ <>
867
+ <section className="slx-profile-sec">
868
+ <h3 className="slx-sec-title">Subscription</h3>
869
+ <div className="slx-billing-card">
870
+ <p className="slx-billing-plan">No active subscription</p>
871
+ <p className="slx-billing-detail">
872
+ You don&apos;t have a subscription yet. Choose a plan to
873
+ get started.
874
+ </p>
875
+ </div>
876
+ </section>
877
+
878
+ <section className="slx-profile-sec">
879
+ <h3 className="slx-sec-title">Available plans</h3>
880
+ {plansLoading ? (
881
+ <p className="slx-hint">Loading plans…</p>
882
+ ) : plans.length === 0 ? (
883
+ <div
884
+ className="slx-billing-card"
885
+ style={{ textAlign: 'center' }}
886
+ >
887
+ <p className="slx-billing-detail">
888
+ No plans configured for this project yet.
889
+ </p>
890
+ <p className="slx-hint" style={{ marginTop: 6 }}>
891
+ Ask your admin to create a plan in billing.
892
+ </p>
893
+ </div>
894
+ ) : (
895
+ <div className="slx-billing-plans">
896
+ {plans.map((plan) => (
897
+ <div
898
+ key={plan.id}
899
+ className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
900
+ >
901
+ {plan.isPopular && (
902
+ <span className="slx-plan-badge">POPULAR</span>
903
+ )}
904
+ <p className="slx-plan-name">{plan.name}</p>
905
+ <p style={{ margin: '2px 0 0' }}>
906
+ <span className="slx-plan-price">
907
+ {formatCurrency(plan.amount, plan.currency)}
908
+ </span>
909
+ <span className="slx-plan-interval">
910
+ /{plan.interval}
911
+ </span>
912
+ </p>
913
+ {plan.trialDays ? (
914
+ <p
915
+ className="slx-billing-detail"
916
+ style={{
917
+ color: 'var(--slx-accent)',
918
+ marginTop: 4,
919
+ }}
920
+ >
921
+ {plan.trialDays} day free trial
922
+ </p>
923
+ ) : (
924
+ <p
925
+ className="slx-billing-detail"
926
+ style={{ visibility: 'hidden', marginTop: 4 }}
927
+ >
928
+ &nbsp;
929
+ </p>
930
+ )}
931
+ <ul className="slx-plan-features">
932
+ {(plan.features ?? []).map((f) => (
933
+ <li key={f}>{f}</li>
934
+ ))}
935
+ </ul>
936
+ <button
937
+ type="button"
938
+ className="slx-btn slx-plan-cta"
939
+ onClick={() => handleCheckout(plan.id)}
940
+ disabled={checkoutId === plan.id}
941
+ >
942
+ {checkoutId === plan.id
943
+ ? 'Redirecting…'
944
+ : 'Choose plan'}
945
+ </button>
946
+ </div>
947
+ ))}
948
+ </div>
949
+ )}
950
+ </section>
951
+
952
+ {invoices.length > 0 && (
953
+ <section className="slx-profile-sec">
954
+ <h3 className="slx-sec-title">Invoices</h3>
955
+ {invoices.map((inv) => (
956
+ <div key={inv.id} className="slx-invoice-row">
957
+ <span className="slx-invoice-date">
958
+ {inv.billedAt ? formatDate(inv.billedAt) : '—'}
959
+ </span>
960
+ <span className="slx-invoice-amount">
961
+ {formatCurrency(inv.amount, inv.currency)}
962
+ </span>
963
+ <span
964
+ className={`slx-badge ${
965
+ inv.status === 'paid'
966
+ ? 'slx-badge-ok'
967
+ : inv.status === 'overdue'
968
+ ? 'slx-badge-warn'
969
+ : 'slx-badge-accent'
970
+ }`}
971
+ >
972
+ {inv.status}
973
+ </span>
974
+ </div>
975
+ ))}
976
+ </section>
977
+ )}
978
+ </>
629
979
  ) : (
630
980
  <>
631
981
  <section className="slx-profile-sec">
@@ -636,11 +986,13 @@ export function UserProfile({
636
986
  display: 'flex',
637
987
  justifyContent: 'space-between',
638
988
  alignItems: 'flex-start',
989
+ gap: 12,
990
+ flexWrap: 'wrap',
639
991
  }}
640
992
  >
641
- <div>
993
+ <div style={{ minWidth: 0 }}>
642
994
  <p className="slx-billing-plan">
643
- {subscription.planName ?? 'Subscription'}
995
+ {resolvedPlanName ?? 'Subscription'}
644
996
  </p>
645
997
  <p className="slx-billing-detail">
646
998
  Status:{' '}
@@ -657,11 +1009,151 @@ export function UserProfile({
657
1009
  : `Renews ${formatDate(subscription.currentPeriodEnd)}`}
658
1010
  </p>
659
1011
  )}
1012
+ {subscription.cancelAtPeriodEnd && (
1013
+ <p
1014
+ className="slx-billing-detail"
1015
+ style={{
1016
+ color: 'var(--slx-danger)',
1017
+ fontWeight: 600,
1018
+ }}
1019
+ >
1020
+ Scheduled to cancel at period end
1021
+ </p>
1022
+ )}
660
1023
  </div>
1024
+ {currentPlan && (
1025
+ <span
1026
+ className="slx-badge slx-badge-accent"
1027
+ style={{ flexShrink: 0 }}
1028
+ >
1029
+ {formatCurrency(
1030
+ currentPlan.amount,
1031
+ currentPlan.currency
1032
+ )}
1033
+ /{currentPlan.interval}
1034
+ </span>
1035
+ )}
1036
+ </div>
1037
+ {currentPlan?.features &&
1038
+ currentPlan.features.length > 0 && (
1039
+ <ul
1040
+ className="slx-plan-features"
1041
+ style={{ margin: '12px 0 0' }}
1042
+ >
1043
+ {currentPlan.features.map((f) => (
1044
+ <li key={f}>{f}</li>
1045
+ ))}
1046
+ </ul>
1047
+ )}
1048
+ <div className="slx-billing-actions">
1049
+ <button
1050
+ type="button"
1051
+ className="slx-btn-secondary"
1052
+ onClick={() => void loadBilling()}
1053
+ >
1054
+ Refresh
1055
+ </button>
661
1056
  </div>
662
1057
  </div>
663
1058
  </section>
664
1059
 
1060
+ {/* Upgrade / Downgrade plans */}
1061
+ {plansLoading ? (
1062
+ <section className="slx-profile-sec">
1063
+ <p className="slx-hint">Loading available plans…</p>
1064
+ </section>
1065
+ ) : plans.length > 0 ? (
1066
+ <section className="slx-profile-sec">
1067
+ <h3 className="slx-sec-title">Available plans</h3>
1068
+ <p className="slx-hint" style={{ marginBottom: 8 }}>
1069
+ Switch plans anytime. Changes apply at the next billing
1070
+ cycle.
1071
+ </p>
1072
+ <div className="slx-billing-plans">
1073
+ {plans.map((plan) => {
1074
+ const isCurrent = subscription.planId
1075
+ ? subscription.planId === plan.id
1076
+ : resolvedPlanName === plan.name;
1077
+ const currentAmount = currentPlan?.amount ?? 0;
1078
+ const isUpgrade = plan.amount > currentAmount;
1079
+ const isDowngrade =
1080
+ plan.amount < currentAmount && !isCurrent;
1081
+ return (
1082
+ <div
1083
+ key={plan.id}
1084
+ className={`slx-plan-card${plan.isPopular ? ' popular' : ''}`}
1085
+ style={isCurrent ? { opacity: 0.92 } : undefined}
1086
+ >
1087
+ {plan.isPopular && !isCurrent && (
1088
+ <span className="slx-plan-badge">POPULAR</span>
1089
+ )}
1090
+ {isCurrent && (
1091
+ <span
1092
+ className="slx-plan-badge"
1093
+ style={{ background: 'var(--slx-success)' }}
1094
+ >
1095
+ CURRENT
1096
+ </span>
1097
+ )}
1098
+ <p className="slx-plan-name">{plan.name}</p>
1099
+ <p style={{ margin: '2px 0 0' }}>
1100
+ <span className="slx-plan-price">
1101
+ {formatCurrency(plan.amount, plan.currency)}
1102
+ </span>
1103
+ <span className="slx-plan-interval">
1104
+ /{plan.interval}
1105
+ </span>
1106
+ </p>
1107
+ {plan.trialDays ? (
1108
+ <p
1109
+ className="slx-billing-detail"
1110
+ style={{
1111
+ color: 'var(--slx-accent)',
1112
+ marginTop: 4,
1113
+ }}
1114
+ >
1115
+ {plan.trialDays} day trial
1116
+ </p>
1117
+ ) : (
1118
+ <p
1119
+ className="slx-billing-detail"
1120
+ style={{ visibility: 'hidden', marginTop: 4 }}
1121
+ >
1122
+ &nbsp;
1123
+ </p>
1124
+ )}
1125
+ <ul className="slx-plan-features">
1126
+ {(plan.features ?? []).map((f) => (
1127
+ <li key={f}>{f}</li>
1128
+ ))}
1129
+ </ul>
1130
+ <button
1131
+ type="button"
1132
+ className={
1133
+ isCurrent
1134
+ ? 'slx-btn-secondary slx-plan-cta'
1135
+ : 'slx-btn slx-plan-cta'
1136
+ }
1137
+ disabled={isCurrent || checkoutId === plan.id}
1138
+ onClick={() => handleCheckout(plan.id)}
1139
+ >
1140
+ {isCurrent
1141
+ ? 'Current plan'
1142
+ : checkoutId === plan.id
1143
+ ? 'Redirecting…'
1144
+ : isUpgrade
1145
+ ? 'Upgrade'
1146
+ : isDowngrade
1147
+ ? 'Downgrade'
1148
+ : 'Switch plan'}
1149
+ </button>
1150
+ </div>
1151
+ );
1152
+ })}
1153
+ </div>
1154
+ </section>
1155
+ ) : null}
1156
+
665
1157
  {invoices.length > 0 && (
666
1158
  <section className="slx-profile-sec">
667
1159
  <h3 className="slx-sec-title">Invoices</h3>
@@ -692,19 +1184,43 @@ export function UserProfile({
692
1184
  ))}
693
1185
  </div>
694
1186
  </div>
695
- </div>
1187
+ </>
696
1188
  );
697
1189
 
698
- if (!modal) return body;
1190
+ if (!modal) {
1191
+ return (
1192
+ <div className="slx-profile-modal" style={{ maxHeight: 'none' }}>
1193
+ {bodyInner}
1194
+ </div>
1195
+ );
1196
+ }
699
1197
 
1198
+ // Modal overlay — click on backdrop closes, click inside modal does not.
1199
+ // Use onMouseDown + onClick for desktop + mobile reliability; close button also works via stopPropagation.
700
1200
  return (
1201
+ // biome-ignore lint/a11y/useKeyWithClickEvents: overlay click is for mouse; keyboard Escape handled in effect
701
1202
  <div
702
1203
  className="slx-overlay"
703
1204
  onMouseDown={(e) => {
704
1205
  if (e.target === e.currentTarget) onClose?.();
705
1206
  }}
1207
+ onClick={(e) => {
1208
+ if (e.target === e.currentTarget) onClose?.();
1209
+ }}
1210
+ role="presentation"
706
1211
  >
707
- {body}
1212
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation only, no keyboard action needed */}
1213
+ {/* biome-ignore lint/a11y/useSemanticElements: dialog is correct for modal */}
1214
+ <div
1215
+ role="dialog"
1216
+ aria-modal="true"
1217
+ aria-label="Account settings"
1218
+ className="slx-profile-modal"
1219
+ onMouseDown={(e) => e.stopPropagation()}
1220
+ onClick={(e) => e.stopPropagation()}
1221
+ >
1222
+ {bodyInner}
1223
+ </div>
708
1224
  </div>
709
1225
  );
710
1226
  }