@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.
@@ -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&apos;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
+ &nbsp;
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
+ &#10003;
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.1);
14
+ --slx-accent-soft: rgba(91, 91, 214, 0.14);
11
15
  --slx-bg: #ffffff;
12
- --slx-bg-page: #f7f7fa;
16
+ --slx-bg-page: #e9eaf6;
13
17
  --slx-ink: #16161d;
14
18
  --slx-muted: #6f6f7b;
15
- --slx-border: #e6e6ec;
19
+ --slx-border: #d8d8e8;
16
20
  --slx-danger: #d64550;
17
21
  --slx-radius: 12px;
18
- --slx-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
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 1px 2px rgba(10,10,20,.04), 0 8px 24px rgba(10,10,20,.06);
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: 19px; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 6px; }
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: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.01em;
103
- color: #fff; background: var(--slx-accent);
104
- border: none; border-radius: var(--slx-radius);
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: var(--slx-accent-hover); }
110
- .slx-btn:active { transform: scale(.985); }
111
- .slx-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft), 0 0 0 1px var(--slx-accent); }
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 { color: var(--slx-accent); font-weight: 550; text-decoration: none; cursor: pointer; }
144
- .slx-link:hover { text-decoration: underline; }
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
+ });