@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.
- package/dist/components/UserProfile/UserProfile.d.ts.map +1 -1
- package/dist/components/UserProfile/UserProfile.js +252 -42
- package/dist/components/UserProfile/UserProfile.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +111 -5
- package/dist/styles.js.map +1 -1
- package/package.json +9 -9
- package/src/components/UserProfile/UserProfile.tsx +578 -62
- package/src/styles.ts +111 -5
- package/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
|
@@ -13,12 +13,29 @@ export interface UserProfileProps {
|
|
|
13
13
|
|
|
14
14
|
type Tab = 'profile' | 'security' | 'billing';
|
|
15
15
|
|
|
16
|
-
function initials(user: {
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
155
|
-
(client as unknown as { apiUrl: string }).apiUrl
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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={
|
|
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(
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
620
|
-
<
|
|
621
|
-
|
|
622
|
-
<
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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'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
|
+
|
|
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
|
-
{
|
|
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
|
+
|
|
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
|
-
|
|
1187
|
+
</>
|
|
696
1188
|
);
|
|
697
1189
|
|
|
698
|
-
if (!modal)
|
|
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
|
-
{
|
|
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
|
}
|