Files
vyndr/web/src/app/signup/page.tsx
T

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&apos;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)',
};