@slyxup/ui 0.2.0 → 0.2.1
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/dist/components/BillingPortal/BillingPortal.d.ts +22 -0
- package/dist/components/BillingPortal/BillingPortal.d.ts.map +1 -0
- package/dist/components/BillingPortal/BillingPortal.js +59 -0
- package/dist/components/BillingPortal/BillingPortal.js.map +1 -0
- package/dist/components/PricingTable/PricingTable.d.ts +19 -0
- package/dist/components/PricingTable/PricingTable.d.ts.map +1 -0
- package/dist/components/PricingTable/PricingTable.js +52 -0
- package/dist/components/PricingTable/PricingTable.js.map +1 -0
- package/dist/components/SignIn/SignIn.d.ts.map +1 -1
- package/dist/components/SignIn/SignIn.js +13 -2
- package/dist/components/SignIn/SignIn.js.map +1 -1
- package/dist/components/SignUp/SignUp.d.ts.map +1 -1
- package/dist/components/SignUp/SignUp.js +13 -2
- package/dist/components/SignUp/SignUp.js.map +1 -1
- package/dist/styles.d.ts +3 -2
- package/dist/styles.d.ts.map +1 -1
- package/dist/styles.js +37 -16
- package/dist/styles.js.map +1 -1
- package/package.json +13 -9
- package/src/components/BillingPortal/BillingPortal.tsx +182 -0
- package/src/components/PricingTable/PricingTable.tsx +155 -0
- package/src/components/SignIn/SignIn.tsx +26 -1
- package/src/components/SignUp/SignUp.tsx +26 -1
- package/src/styles.ts +38 -16
- package/test/components.test.tsx +135 -0
- package/test/icons.test.tsx +32 -0
- package/test/styles.test.ts +47 -0
- package/vitest.config.ts +2 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Subscription {
|
|
6
|
+
id: string;
|
|
7
|
+
status: string;
|
|
8
|
+
currentPeriodEnd: string | null;
|
|
9
|
+
cancelAtPeriodEnd: boolean;
|
|
10
|
+
}
|
|
11
|
+
interface Invoice {
|
|
12
|
+
id: string;
|
|
13
|
+
amount: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
status: 'paid' | 'pending' | 'overdue' | 'refunded';
|
|
16
|
+
billedAt: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BillingPortalProps {
|
|
20
|
+
subscription: Subscription | null;
|
|
21
|
+
invoices: Invoice[];
|
|
22
|
+
onCancel?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Current plan + invoices table */
|
|
26
|
+
export function BillingPortal({
|
|
27
|
+
subscription,
|
|
28
|
+
invoices,
|
|
29
|
+
onCancel,
|
|
30
|
+
}: BillingPortalProps) {
|
|
31
|
+
if (!subscription) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="slx-card">
|
|
34
|
+
<h3 style={{ fontSize: 16, fontWeight: 600 }}>
|
|
35
|
+
No active subscription
|
|
36
|
+
</h3>
|
|
37
|
+
<p style={{ fontSize: 13.5, color: 'var(--slx-muted)', marginTop: 6 }}>
|
|
38
|
+
You don't have a subscription yet. Choose a plan to get started.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
46
|
+
{/* Plan card */}
|
|
47
|
+
<div className="slx-card">
|
|
48
|
+
<div
|
|
49
|
+
style={{
|
|
50
|
+
display: 'flex',
|
|
51
|
+
justifyContent: 'space-between',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<div>
|
|
56
|
+
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Current plan</h3>
|
|
57
|
+
<p
|
|
58
|
+
style={{ fontSize: 13, color: 'var(--slx-muted)', marginTop: 4 }}
|
|
59
|
+
>
|
|
60
|
+
Status:{' '}
|
|
61
|
+
<span
|
|
62
|
+
style={{
|
|
63
|
+
fontWeight: 650,
|
|
64
|
+
color:
|
|
65
|
+
subscription.status === 'active' ||
|
|
66
|
+
subscription.status === 'trialing'
|
|
67
|
+
? '#34d399'
|
|
68
|
+
: subscription.status === 'past_due'
|
|
69
|
+
? 'var(--slx-danger)'
|
|
70
|
+
: 'var(--slx-muted)',
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{subscription.status}
|
|
74
|
+
</span>
|
|
75
|
+
</p>
|
|
76
|
+
{subscription.currentPeriodEnd && (
|
|
77
|
+
<p
|
|
78
|
+
style={{
|
|
79
|
+
fontSize: 12.5,
|
|
80
|
+
color: 'var(--slx-muted)',
|
|
81
|
+
marginTop: 4,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
Renews{' '}
|
|
85
|
+
{new Date(subscription.currentPeriodEnd).toLocaleDateString()}
|
|
86
|
+
{subscription.cancelAtPeriodEnd
|
|
87
|
+
? ' (cancels at period end)'
|
|
88
|
+
: ''}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
{subscription.status !== 'canceled' &&
|
|
93
|
+
!subscription.cancelAtPeriodEnd &&
|
|
94
|
+
onCancel && (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onCancel}
|
|
98
|
+
style={{
|
|
99
|
+
fontSize: 13,
|
|
100
|
+
fontWeight: 550,
|
|
101
|
+
background: 'transparent',
|
|
102
|
+
border: '1px solid var(--slx-border)',
|
|
103
|
+
borderRadius: 'var(--slx-radius)',
|
|
104
|
+
padding: '8px 14px',
|
|
105
|
+
cursor: 'pointer',
|
|
106
|
+
color: 'var(--slx-danger)',
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
Cancel
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Invoices */}
|
|
116
|
+
{invoices.length > 0 && (
|
|
117
|
+
<div className="slx-card">
|
|
118
|
+
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>
|
|
119
|
+
Invoices
|
|
120
|
+
</h3>
|
|
121
|
+
<table
|
|
122
|
+
style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}
|
|
123
|
+
>
|
|
124
|
+
<thead>
|
|
125
|
+
<tr
|
|
126
|
+
style={{
|
|
127
|
+
borderBottom: '1px solid var(--slx-border)',
|
|
128
|
+
textAlign: 'left',
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<th style={{ padding: '6px 0', fontWeight: 550 }}>Date</th>
|
|
132
|
+
<th style={{ padding: '6px 0', fontWeight: 550 }}>Amount</th>
|
|
133
|
+
<th style={{ padding: '6px 0', fontWeight: 550 }}>Status</th>
|
|
134
|
+
</tr>
|
|
135
|
+
</thead>
|
|
136
|
+
<tbody>
|
|
137
|
+
{invoices.map((inv) => (
|
|
138
|
+
<tr
|
|
139
|
+
key={inv.id}
|
|
140
|
+
style={{ borderBottom: '1px solid var(--slx-border)' }}
|
|
141
|
+
>
|
|
142
|
+
<td style={{ padding: '8px 0' }}>
|
|
143
|
+
{inv.billedAt
|
|
144
|
+
? new Date(inv.billedAt).toLocaleDateString()
|
|
145
|
+
: '—'}
|
|
146
|
+
</td>
|
|
147
|
+
<td style={{ padding: '8px 0' }}>
|
|
148
|
+
${(inv.amount / 100).toFixed(2)} {inv.currency}
|
|
149
|
+
</td>
|
|
150
|
+
<td style={{ padding: '8px 0' }}>
|
|
151
|
+
<span
|
|
152
|
+
style={{
|
|
153
|
+
fontSize: 11.5,
|
|
154
|
+
fontWeight: 600,
|
|
155
|
+
padding: '2px 8px',
|
|
156
|
+
borderRadius: 999,
|
|
157
|
+
background:
|
|
158
|
+
inv.status === 'paid'
|
|
159
|
+
? 'rgba(52,211,153,.1)'
|
|
160
|
+
: inv.status === 'overdue'
|
|
161
|
+
? 'rgba(214,69,80,.1)'
|
|
162
|
+
: 'rgba(108,108,232,.08)',
|
|
163
|
+
color:
|
|
164
|
+
inv.status === 'paid'
|
|
165
|
+
? '#34d399'
|
|
166
|
+
: inv.status === 'overdue'
|
|
167
|
+
? 'var(--slx-danger)'
|
|
168
|
+
: 'var(--slx-accent)',
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{inv.status}
|
|
172
|
+
</span>
|
|
173
|
+
</td>
|
|
174
|
+
</tr>
|
|
175
|
+
))}
|
|
176
|
+
</tbody>
|
|
177
|
+
</table>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface Plan {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
amount: number;
|
|
9
|
+
currency: string;
|
|
10
|
+
interval: 'month' | 'year';
|
|
11
|
+
trialDays: number | null;
|
|
12
|
+
features: string[] | null;
|
|
13
|
+
isPopular: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PricingTableProps {
|
|
17
|
+
plans: Plan[];
|
|
18
|
+
onSelect?: (plan: Plan) => void;
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Clean pricing grid with popular badge */
|
|
23
|
+
export function PricingTable({ plans, onSelect, loading }: PricingTableProps) {
|
|
24
|
+
if (loading) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="slx-pricing"
|
|
28
|
+
style={{
|
|
29
|
+
display: 'grid',
|
|
30
|
+
gridTemplateColumns: `repeat(${Math.min(plans.length, 3)}, 1fr)`,
|
|
31
|
+
gap: 16,
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{[1, 2, 3].map((i) => (
|
|
35
|
+
<div
|
|
36
|
+
key={i}
|
|
37
|
+
className="slx-card"
|
|
38
|
+
style={{ minHeight: 320 }}
|
|
39
|
+
aria-busy="true"
|
|
40
|
+
/>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
style={{
|
|
49
|
+
display: 'grid',
|
|
50
|
+
gridTemplateColumns: `repeat(${Math.min(plans.length, 3)}, 1fr)`,
|
|
51
|
+
gap: 16,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{plans.map((plan) => (
|
|
55
|
+
<div
|
|
56
|
+
key={plan.id}
|
|
57
|
+
className={`slx-card${plan.isPopular ? ' slx-card-popular' : ''}`}
|
|
58
|
+
style={{
|
|
59
|
+
position: plan.isPopular ? 'relative' : undefined,
|
|
60
|
+
borderColor: plan.isPopular ? 'var(--slx-accent)' : undefined,
|
|
61
|
+
display: 'flex',
|
|
62
|
+
flexDirection: 'column',
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{plan.isPopular && (
|
|
66
|
+
<span
|
|
67
|
+
style={{
|
|
68
|
+
position: 'absolute',
|
|
69
|
+
top: -11,
|
|
70
|
+
right: 20,
|
|
71
|
+
fontSize: 10.5,
|
|
72
|
+
fontFamily: 'var(--slx-mono)',
|
|
73
|
+
background:
|
|
74
|
+
'linear-gradient(135deg, var(--slx-accent), #8b5cf6)',
|
|
75
|
+
color: '#fff',
|
|
76
|
+
padding: '3px 10px',
|
|
77
|
+
borderRadius: 999,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
POPULAR
|
|
81
|
+
</span>
|
|
82
|
+
)}
|
|
83
|
+
<h3
|
|
84
|
+
style={{ fontSize: 15, fontWeight: 550, color: 'var(--slx-muted)' }}
|
|
85
|
+
>
|
|
86
|
+
{plan.name}
|
|
87
|
+
</h3>
|
|
88
|
+
<div style={{ margin: '8px 0 2px' }}>
|
|
89
|
+
<span
|
|
90
|
+
style={{
|
|
91
|
+
fontSize: 38,
|
|
92
|
+
fontWeight: 750,
|
|
93
|
+
letterSpacing: '-0.03em',
|
|
94
|
+
fontFamily: '"Space Grotesk",sans-serif',
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
${(plan.amount / 100).toFixed(0)}
|
|
98
|
+
</span>
|
|
99
|
+
<span style={{ fontSize: 14, color: 'var(--slx-muted)' }}>
|
|
100
|
+
/{plan.interval}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
{plan.trialDays ? (
|
|
104
|
+
<p
|
|
105
|
+
style={{
|
|
106
|
+
fontSize: 12,
|
|
107
|
+
color: 'var(--slx-accent)',
|
|
108
|
+
marginBottom: 4,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{plan.trialDays} day free trial
|
|
112
|
+
</p>
|
|
113
|
+
) : (
|
|
114
|
+
<p style={{ fontSize: 12, color: 'transparent', marginBottom: 4 }}>
|
|
115
|
+
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
<ul style={{ listStyle: 'none', margin: '14px 0 22px', flex: 1 }}>
|
|
119
|
+
{((plan.features ?? []) as string[]).map((f: string) => (
|
|
120
|
+
<li
|
|
121
|
+
key={f}
|
|
122
|
+
style={{
|
|
123
|
+
fontSize: 13.5,
|
|
124
|
+
color: 'var(--slx-ink)',
|
|
125
|
+
padding: '5px 0 5px 24px',
|
|
126
|
+
position: 'relative',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<span
|
|
130
|
+
style={{
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
left: 0,
|
|
133
|
+
color: '#34d399',
|
|
134
|
+
fontWeight: 700,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
✓
|
|
138
|
+
</span>{' '}
|
|
139
|
+
{f}
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="slx-btn"
|
|
146
|
+
onClick={() => onSelect?.(plan)}
|
|
147
|
+
disabled={loading}
|
|
148
|
+
>
|
|
149
|
+
Get started
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -18,7 +18,10 @@ export function SignIn({
|
|
|
18
18
|
onSuccess,
|
|
19
19
|
onSignUpClick,
|
|
20
20
|
}: SignInProps) {
|
|
21
|
-
const { signIn } = useAuth()
|
|
21
|
+
const { signIn, client } = useAuth() as unknown as {
|
|
22
|
+
signIn: ReturnType<typeof useAuth>['signIn'];
|
|
23
|
+
client: { publishableKey?: string };
|
|
24
|
+
};
|
|
22
25
|
const [email, setEmail] = useState('');
|
|
23
26
|
const [password, setPassword] = useState('');
|
|
24
27
|
const [busy, setBusy] = useState(false);
|
|
@@ -54,8 +57,30 @@ export function SignIn({
|
|
|
54
57
|
window.location.href = `/v1/oauth/${provider}`;
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
const missingKey =
|
|
61
|
+
!client.publishableKey ||
|
|
62
|
+
client.publishableKey === 'pk_test_missing' ||
|
|
63
|
+
client.publishableKey.includes('REPLACE');
|
|
64
|
+
|
|
57
65
|
return (
|
|
58
66
|
<div ref={cardRef} className={`slx-card${error ? ' slx-card-error' : ''}`}>
|
|
67
|
+
{missingKey && (
|
|
68
|
+
<p
|
|
69
|
+
style={{
|
|
70
|
+
fontSize: 12,
|
|
71
|
+
background: '#fff3cd',
|
|
72
|
+
border: '1px solid #ffe69c',
|
|
73
|
+
borderRadius: 8,
|
|
74
|
+
padding: '8px 10px',
|
|
75
|
+
marginBottom: 14,
|
|
76
|
+
lineHeight: 1.4,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<strong>Setup:</strong> Add{' '}
|
|
80
|
+
<code>NEXT_PUBLIC_SLYXUP_PUBLISHABLE_KEY</code> to{' '}
|
|
81
|
+
<code>.env.local</code> — run <code>npx @slyxup/cli keys create</code>
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
59
84
|
<div className="slx-mark">
|
|
60
85
|
<KeyholeMark />
|
|
61
86
|
</div>
|
|
@@ -15,7 +15,10 @@ export function SignUp({
|
|
|
15
15
|
onSuccess,
|
|
16
16
|
onSignInClick,
|
|
17
17
|
}: SignUpProps) {
|
|
18
|
-
const { signUp } = useAuth()
|
|
18
|
+
const { signUp, client } = useAuth() as unknown as {
|
|
19
|
+
signUp: ReturnType<typeof useAuth>['signUp'];
|
|
20
|
+
client: { publishableKey?: string };
|
|
21
|
+
};
|
|
19
22
|
const [firstName, setFirstName] = useState('');
|
|
20
23
|
const [email, setEmail] = useState('');
|
|
21
24
|
const [password, setPassword] = useState('');
|
|
@@ -52,8 +55,30 @@ export function SignUp({
|
|
|
52
55
|
window.location.href = `/v1/oauth/${provider}`;
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
const missingKey =
|
|
59
|
+
!client.publishableKey ||
|
|
60
|
+
client.publishableKey === 'pk_test_missing' ||
|
|
61
|
+
client.publishableKey.includes('REPLACE');
|
|
62
|
+
|
|
55
63
|
return (
|
|
56
64
|
<div ref={cardRef} className={`slx-card${error ? ' slx-card-error' : ''}`}>
|
|
65
|
+
{missingKey && (
|
|
66
|
+
<p
|
|
67
|
+
style={{
|
|
68
|
+
fontSize: 12,
|
|
69
|
+
background: '#fff3cd',
|
|
70
|
+
border: '1px solid #ffe69c',
|
|
71
|
+
borderRadius: 8,
|
|
72
|
+
padding: '8px 10px',
|
|
73
|
+
marginBottom: 14,
|
|
74
|
+
lineHeight: 1.4,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<strong>Setup:</strong> Add{' '}
|
|
78
|
+
<code>NEXT_PUBLIC_SLYXUP_PUBLISHABLE_KEY</code> — run{' '}
|
|
79
|
+
<code>npx @slyxup/cli keys create</code>
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
57
82
|
<div className="slx-mark">
|
|
58
83
|
<KeyholeMark />
|
|
59
84
|
</div>
|
package/src/styles.ts
CHANGED
|
@@ -3,19 +3,24 @@
|
|
|
3
3
|
* Injected once via <SlyxUpStyles />. Theme with CSS variables on :root or .slyxup-scope.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
export const FONT_LINK = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">`;
|
|
7
|
+
|
|
6
8
|
export const CSS = `
|
|
9
|
+
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
|
10
|
+
|
|
7
11
|
.slyxup-root {
|
|
8
12
|
--slx-accent: #5b5bd6;
|
|
9
13
|
--slx-accent-hover: #4c4cc4;
|
|
10
|
-
--slx-accent-soft: rgba(91, 91, 214, 0.
|
|
14
|
+
--slx-accent-soft: rgba(91, 91, 214, 0.14);
|
|
11
15
|
--slx-bg: #ffffff;
|
|
12
|
-
--slx-bg-page: #
|
|
16
|
+
--slx-bg-page: #e9eaf6;
|
|
13
17
|
--slx-ink: #16161d;
|
|
14
18
|
--slx-muted: #6f6f7b;
|
|
15
|
-
--slx-border: #
|
|
19
|
+
--slx-border: #d8d8e8;
|
|
16
20
|
--slx-danger: #d64550;
|
|
17
21
|
--slx-radius: 12px;
|
|
18
|
-
--slx-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
22
|
+
--slx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
23
|
+
--slx-display: "Space Grotesk", "DM Sans", sans-serif;
|
|
19
24
|
--slx-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
20
25
|
|
|
21
26
|
font-family: var(--slx-font);
|
|
@@ -55,9 +60,10 @@ export const CSS = `
|
|
|
55
60
|
border: 1px solid var(--slx-border);
|
|
56
61
|
border-radius: calc(var(--slx-radius) + 4px);
|
|
57
62
|
padding: 32px;
|
|
58
|
-
box-shadow: 0
|
|
63
|
+
box-shadow: 0 4px 12px rgba(16,16,29,.08), 0 16px 40px rgba(16,16,29,.12), 0 0 0 1px rgba(16,16,29,.04);
|
|
59
64
|
box-sizing: border-box;
|
|
60
65
|
}
|
|
66
|
+
.slyxup-root .slx-card { background: #ffffff; }
|
|
61
67
|
.slx-card-error { animation: slx-shake .45s cubic-bezier(.36,.07,.19,.97) both; }
|
|
62
68
|
|
|
63
69
|
/* ── Header / keyhole mark ── */
|
|
@@ -68,7 +74,7 @@ export const CSS = `
|
|
|
68
74
|
margin-bottom: 18px;
|
|
69
75
|
}
|
|
70
76
|
.slx-mark svg { display: block; }
|
|
71
|
-
.slx-title { font-size:
|
|
77
|
+
.slx-title { font-family: var(--slx-display); font-size: 20px; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 6px; }
|
|
72
78
|
.slx-subtitle { font-size: 13.5px; color: var(--slx-muted); margin: 0 0 22px; line-height: 1.5; }
|
|
73
79
|
|
|
74
80
|
/* ── Fields ── */
|
|
@@ -99,17 +105,21 @@ export const CSS = `
|
|
|
99
105
|
/* ── Button ── */
|
|
100
106
|
.slx-btn {
|
|
101
107
|
width: 100%; box-sizing: border-box;
|
|
102
|
-
font:
|
|
103
|
-
color: #fff; background:
|
|
104
|
-
border:
|
|
108
|
+
font-family: var(--slx-font); font-size: 14px; font-weight: 600; letter-spacing: 0.01em;
|
|
109
|
+
color: #fff; background: #0a0a0f;
|
|
110
|
+
border: 1px solid #0a0a0f; border-radius: var(--slx-radius);
|
|
105
111
|
padding: 11px 14px; cursor: pointer;
|
|
106
112
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
107
|
-
transition: background .15s, transform .06s;
|
|
113
|
+
transition: background .15s, transform .06s, border-color .15s;
|
|
108
114
|
}
|
|
109
|
-
.slx-btn:hover { background:
|
|
110
|
-
.slx-btn:active { transform: scale(.985); }
|
|
111
|
-
.slx-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px
|
|
115
|
+
.slx-btn:hover { background: #1a1a23; border-color: #1a1a23; }
|
|
116
|
+
.slx-btn:active { transform: scale(.985); background: #000; }
|
|
117
|
+
.slx-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(10,10,15,.14), 0 0 0 1px #0a0a0f; }
|
|
112
118
|
.slx-btn[disabled] { opacity: .6; cursor: not-allowed; }
|
|
119
|
+
@media (prefers-color-scheme: dark) {
|
|
120
|
+
.slyxup-root:not(.slyxup-light) .slx-btn { background: #f2f2f5; color: #0a0a0f; border-color: #f2f2f5; }
|
|
121
|
+
.slyxup-root:not(.slyxup-light) .slx-btn:hover { background: #e6e6eb; border-color: #e6e6eb; }
|
|
122
|
+
}
|
|
113
123
|
.slx-spinner {
|
|
114
124
|
width: 15px; height: 15px; flex: none;
|
|
115
125
|
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
|
@@ -140,8 +150,12 @@ export const CSS = `
|
|
|
140
150
|
content: ""; height: 1px; flex: 1; background: var(--slx-border);
|
|
141
151
|
}
|
|
142
152
|
.slx-footer { font-size: 13px; color: var(--slx-muted); margin-top: 20px; text-align: center; }
|
|
143
|
-
.slx-link {
|
|
144
|
-
|
|
153
|
+
.slx-link {
|
|
154
|
+
color: var(--slx-accent); font-weight: 600; text-decoration: none; cursor: pointer;
|
|
155
|
+
background: none; border: none; font: inherit; font-size: inherit;
|
|
156
|
+
padding: 0; margin: 0;
|
|
157
|
+
}
|
|
158
|
+
.slx-link:hover { text-decoration: underline; color: var(--slx-accent-hover); }
|
|
145
159
|
.slx-link:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft); border-radius: 4px; }
|
|
146
160
|
|
|
147
161
|
/* ── Success state ── */
|
|
@@ -188,9 +202,17 @@ export const CSS = `
|
|
|
188
202
|
|
|
189
203
|
let injected = false;
|
|
190
204
|
|
|
191
|
-
/** Inject the SlyxUp stylesheet once per document. */
|
|
205
|
+
/** Inject the SlyxUp stylesheet + DM Sans font once per document. */
|
|
192
206
|
export function injectStyles(): void {
|
|
193
207
|
if (injected || typeof document === 'undefined') return;
|
|
208
|
+
// DM Sans font link
|
|
209
|
+
if (!document.querySelector('link[href*="DM+Sans"]')) {
|
|
210
|
+
const fontLink = document.createElement('link');
|
|
211
|
+
fontLink.rel = 'stylesheet';
|
|
212
|
+
fontLink.href =
|
|
213
|
+
'https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap';
|
|
214
|
+
document.head.appendChild(fontLink);
|
|
215
|
+
}
|
|
194
216
|
const style = document.createElement('style');
|
|
195
217
|
style.setAttribute('data-slyxup', 'styles');
|
|
196
218
|
style.textContent = CSS;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
const mockSignIn = vi.fn().mockResolvedValue({ ok: true });
|
|
6
|
+
const mockSignUp = vi.fn().mockResolvedValue({ ok: true });
|
|
7
|
+
const mockSignOut = vi.fn().mockResolvedValue({ ok: true });
|
|
8
|
+
|
|
9
|
+
vi.mock('@slyxup/react', () => ({
|
|
10
|
+
useAuth: () => ({
|
|
11
|
+
isLoaded: true,
|
|
12
|
+
isSignedIn: false,
|
|
13
|
+
userId: null,
|
|
14
|
+
client: {},
|
|
15
|
+
signIn: mockSignIn,
|
|
16
|
+
signUp: mockSignUp,
|
|
17
|
+
signOut: mockSignOut,
|
|
18
|
+
}),
|
|
19
|
+
useUser: () => ({
|
|
20
|
+
isLoaded: true,
|
|
21
|
+
isSignedIn: false,
|
|
22
|
+
user: null,
|
|
23
|
+
isSignedOut: true,
|
|
24
|
+
reload: vi.fn(),
|
|
25
|
+
}),
|
|
26
|
+
useSession: () => ({
|
|
27
|
+
isLoaded: true,
|
|
28
|
+
isSignedIn: false,
|
|
29
|
+
session: null,
|
|
30
|
+
reload: vi.fn(),
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { SignIn } from '../src/components/SignIn/SignIn';
|
|
35
|
+
import { SignUp } from '../src/components/SignUp/SignUp';
|
|
36
|
+
import { SocialButtons } from '../src/components/SocialButtons/SocialButtons';
|
|
37
|
+
import { UserButton } from '../src/components/UserButton/UserButton';
|
|
38
|
+
|
|
39
|
+
describe('SignIn', () => {
|
|
40
|
+
beforeEach(() => vi.clearAllMocks());
|
|
41
|
+
|
|
42
|
+
it('renders sign in heading', () => {
|
|
43
|
+
render(<SignIn />);
|
|
44
|
+
expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders email and password inputs', () => {
|
|
48
|
+
render(<SignIn />);
|
|
49
|
+
expect(screen.getByLabelText(/email/i)).toBeTruthy();
|
|
50
|
+
expect(screen.getByLabelText(/password/i)).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders submit button', () => {
|
|
54
|
+
render(<SignIn />);
|
|
55
|
+
expect(screen.getByRole('button', { name: /sign in/i })).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shows social buttons by default', () => {
|
|
59
|
+
render(<SignIn />);
|
|
60
|
+
expect(screen.getByText(/continue with google/i)).toBeTruthy();
|
|
61
|
+
expect(screen.getByText(/continue with github/i)).toBeTruthy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('hides social buttons when social=false', () => {
|
|
65
|
+
render(<SignIn social={false} />);
|
|
66
|
+
expect(screen.queryByText(/continue with google/i)).toBeNull();
|
|
67
|
+
expect(screen.queryByText(/continue with github/i)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('calls signIn on form submit', async () => {
|
|
71
|
+
render(<SignIn />);
|
|
72
|
+
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.com' } });
|
|
73
|
+
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: '12345678' } });
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));
|
|
75
|
+
await waitFor(() => expect(mockSignIn).toHaveBeenCalledWith({ email: 'a@b.com', password: '12345678' }));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('shows sign up link when onSignUpClick provided', () => {
|
|
79
|
+
const onSignUp = vi.fn();
|
|
80
|
+
render(<SignIn onSignUpClick={onSignUp} />);
|
|
81
|
+
expect(screen.getByText(/sign up/i)).toBeTruthy();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('SignUp', () => {
|
|
86
|
+
beforeEach(() => vi.clearAllMocks());
|
|
87
|
+
|
|
88
|
+
it('renders create your account heading', () => {
|
|
89
|
+
render(<SignUp />);
|
|
90
|
+
expect(screen.getByRole('heading', { name: /create your account/i })).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('renders first name, email, password inputs', () => {
|
|
94
|
+
render(<SignUp />);
|
|
95
|
+
expect(screen.getByLabelText(/first name/i)).toBeTruthy();
|
|
96
|
+
expect(screen.getByLabelText(/email/i)).toBeTruthy();
|
|
97
|
+
expect(screen.getByLabelText(/password/i)).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('shows social buttons by default', () => {
|
|
101
|
+
render(<SignUp />);
|
|
102
|
+
expect(screen.getByText(/continue with google/i)).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('SocialButtons', () => {
|
|
107
|
+
beforeEach(() => vi.clearAllMocks());
|
|
108
|
+
|
|
109
|
+
it('renders both providers by default', () => {
|
|
110
|
+
render(<SocialButtons />);
|
|
111
|
+
expect(screen.getByText(/continue with google/i)).toBeTruthy();
|
|
112
|
+
expect(screen.getByText(/continue with github/i)).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renders only specified providers', () => {
|
|
116
|
+
render(<SocialButtons providers={['google']} />);
|
|
117
|
+
expect(screen.getByText(/continue with google/i)).toBeTruthy();
|
|
118
|
+
expect(screen.queryByText(/continue with github/i)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('UserButton', () => {
|
|
123
|
+
beforeEach(() => vi.clearAllMocks());
|
|
124
|
+
|
|
125
|
+
it('renders avatar', () => {
|
|
126
|
+
render(<UserButton />);
|
|
127
|
+
expect(screen.getByRole('button', { name: /account menu/i })).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('opens dropdown on click', async () => {
|
|
131
|
+
render(<UserButton />);
|
|
132
|
+
fireEvent.click(screen.getByRole('button', { name: /account menu/i }));
|
|
133
|
+
await waitFor(() => expect(screen.getByText(/sign out/i)).toBeTruthy());
|
|
134
|
+
});
|
|
135
|
+
});
|