225 lines
8.0 KiB
TypeScript
225 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState, Suspense } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { trackSignup } from '@/lib/analytics';
|
|
import Wordmark from '@/components/Wordmark';
|
|
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
|
|
|
|
function SignupInner() {
|
|
const router = useRouter();
|
|
const search = useSearchParams();
|
|
const next = search.get('next') || '/dashboard';
|
|
const { signUp, signInWithProvider } = useAuth();
|
|
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [confirm, setConfirm] = useState('');
|
|
const [ageOk, setAgeOk] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [done, setDone] = useState(false);
|
|
|
|
const handleSignup = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
if (password.length < 8) return setError('Password must be at least 8 characters.');
|
|
if (password !== confirm) return setError('Passwords do not match.');
|
|
if (!ageOk) return setError('You must confirm you are 21 or older.');
|
|
|
|
setBusy(true);
|
|
const { error: err } = await signUp(email, password, ageOk);
|
|
setBusy(false);
|
|
if (err) {
|
|
setError(err);
|
|
return;
|
|
}
|
|
trackSignup({ method: 'password' });
|
|
setDone(true);
|
|
// Supabase confirms via email by default. If session is immediate, redirect.
|
|
setTimeout(() => router.replace(next), 1500);
|
|
};
|
|
|
|
// Session 13 — generic OAuth dispatch. Same provider buttons as
|
|
// the login page; same graceful-error contract for unconfigured
|
|
// providers (Apple/X).
|
|
const handleOAuth = async (provider: 'google' | 'apple' | 'twitter') => {
|
|
setBusy(true);
|
|
setError('');
|
|
const { error: err } = await signInWithProvider(provider);
|
|
if (err) {
|
|
setError(err);
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (done) {
|
|
return (
|
|
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
|
<div className="surface diagonal-cut" style={{ maxWidth: 420, padding: 32, textAlign: 'center' }}>
|
|
<div className="mono" style={{ fontSize: 48, color: 'var(--grade-a)', marginBottom: 16 }}>✓</div>
|
|
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 12 }}>You're in.</h2>
|
|
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
|
Check your email for a confirmation link, then come back and grade something.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
|
|
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
|
|
<a
|
|
href="/"
|
|
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
|
|
aria-label="VYNDR — home"
|
|
>
|
|
<Wordmark size={26} />
|
|
</a>
|
|
|
|
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Get started — free</h1>
|
|
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}>
|
|
5 free reads every month. Your first read is fully unlocked. No credit card.
|
|
</p>
|
|
|
|
{/* Session 14 — OAuth buttons with provider icons. Same
|
|
ProviderButton helper as /login (inlined here to avoid a
|
|
new shared module for two callsites). */}
|
|
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
|
|
{(['google', 'apple', 'twitter'] as const).map((provider) => {
|
|
const Icon = provider === 'google' ? GoogleIcon : provider === 'apple' ? AppleIcon : XIcon;
|
|
const label = provider === 'google' ? 'Google' : provider === 'apple' ? 'Apple' : 'X';
|
|
return (
|
|
<button
|
|
key={provider}
|
|
onClick={() => handleOAuth(provider)}
|
|
disabled={busy}
|
|
aria-label={`Continue with ${label}`}
|
|
className="btn-ghost"
|
|
style={{
|
|
width: '100%',
|
|
padding: 12,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 10,
|
|
}}
|
|
data-provider={provider}
|
|
>
|
|
<Icon />
|
|
<span>Continue with {label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div style={dividerStyle}>
|
|
<span style={dividerLine} />
|
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>OR</span>
|
|
<span style={dividerLine} />
|
|
</div>
|
|
|
|
<form onSubmit={handleSignup} style={{ display: 'grid', gap: 12 }}>
|
|
<div>
|
|
<label className="mono" style={labelStyle}>Email</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
autoComplete="email"
|
|
className="input-field"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mono" style={labelStyle}>Password</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
minLength={8}
|
|
autoComplete="new-password"
|
|
className="input-field"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mono" style={labelStyle}>Confirm password</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
minLength={8}
|
|
autoComplete="new-password"
|
|
className="input-field"
|
|
value={confirm}
|
|
onChange={(e) => setConfirm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: 13, color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={ageOk}
|
|
onChange={(e) => setAgeOk(e.target.checked)}
|
|
style={{ marginTop: 2 }}
|
|
/>
|
|
<span>I confirm I am 21 years of age or older.</span>
|
|
</label>
|
|
|
|
{error && <p style={{ color: 'var(--grade-d)', fontSize: 13, margin: 0 }}>{error}</p>}
|
|
|
|
<button type="submit" disabled={busy} className="btn-primary" style={{ padding: 14, marginTop: 4 }}>
|
|
{busy ? 'Creating account…' : 'Get started — free'}
|
|
</button>
|
|
</form>
|
|
|
|
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.6 }}>
|
|
By signing up you agree to our{' '}
|
|
<a href="/terms" style={{ color: 'var(--text-secondary)' }}>Terms</a> and{' '}
|
|
<a href="/privacy" style={{ color: 'var(--text-secondary)' }}>Privacy Policy</a>.
|
|
</p>
|
|
|
|
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-secondary)', marginTop: 12 }}>
|
|
Already have an account?{' '}
|
|
<a href={`/login${next ? `?next=${encodeURIComponent(next)}` : ''}`} style={{ color: 'var(--grade-a)' }}>
|
|
Log in
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default function SignupPage() {
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<SignupInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
display: 'block',
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
letterSpacing: '0.08em',
|
|
textTransform: 'uppercase',
|
|
color: 'var(--text-tertiary)',
|
|
marginBottom: 6,
|
|
};
|
|
|
|
const dividerStyle: React.CSSProperties = {
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr auto 1fr',
|
|
gap: 12,
|
|
alignItems: 'center',
|
|
margin: '16px 0',
|
|
};
|
|
|
|
const dividerLine: React.CSSProperties = {
|
|
height: 1,
|
|
background: 'var(--border)',
|
|
};
|