fe5cbaec2b
No email required — players pick male/female/mystery, get assigned a humorous period-appropriate operator name (e.g. "Dusty Clicksworth", "Ada Dottsworth", "The Phantom Sender"), can reroll, then hit "Begin Transmitting!" for instant play. Email/magic link moved to a collapsible section for cross-device access only. - server: POST /api/auth/guest creates guest profile + session - server/db: profiles.email now nullable, is_guest column added, migration recreates table for existing installs - client: names.ts with 15 names per gender category - client: LoginPage redesigned — gender picker → name display → play - client: useAuth gains setAuth() for direct session injection - 55 server + 44 client tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
import { useState } from 'react'
|
|
import type { Profile } from '../hooks/useAuth'
|
|
import { type Gender, randomName } from '../lib/names'
|
|
|
|
type Props = {
|
|
onLoginSent: () => void
|
|
onGuestLogin: (token: string, profile: Profile) => void
|
|
}
|
|
|
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
|
{ value: 'male', label: 'Male', icon: '♂' },
|
|
{ value: 'female', label: 'Female', icon: '♀' },
|
|
{ value: 'mystery', label: 'Mystery', icon: '✦' },
|
|
]
|
|
|
|
export function LoginPage({ onLoginSent, onGuestLogin }: Props) {
|
|
const [gender, setGender] = useState<Gender | null>(null)
|
|
const [name, setName] = useState('')
|
|
const [joining, setJoining] = useState(false)
|
|
const [showEmail, setShowEmail] = useState(false)
|
|
const [email, setEmail] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
function pickGender(g: Gender) {
|
|
setGender(g)
|
|
setName(randomName(g))
|
|
setError(null)
|
|
}
|
|
|
|
function reroll() {
|
|
if (gender) setName(randomName(gender))
|
|
}
|
|
|
|
async function startPlaying() {
|
|
if (!name) return
|
|
setJoining(true)
|
|
setError(null)
|
|
try {
|
|
const r = await fetch('/api/auth/guest', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ display_name: name }),
|
|
})
|
|
if (!r.ok) throw new Error('Failed to create session')
|
|
const { token, profile } = await r.json()
|
|
onGuestLogin(token, profile)
|
|
} catch {
|
|
setError('Something went wrong. Try again.')
|
|
setJoining(false)
|
|
}
|
|
}
|
|
|
|
async function sendMagicLink(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!email.includes('@') || !email.includes('.')) {
|
|
setError('Enter a valid email address.')
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
setError(null)
|
|
try {
|
|
const r = await fetch('/api/auth/request', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
})
|
|
if (!r.ok) throw new Error('Failed to send link')
|
|
onLoginSent()
|
|
} catch {
|
|
setError('Failed to send magic link. Try again.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 440, margin: '3rem auto 0', padding: '0 1rem' }}>
|
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
|
<h1 style={{ fontFamily: 'var(--font-header)', fontSize: '2rem', color: 'var(--color-forest)', marginBottom: '0.25rem' }}>
|
|
Join the Telegraph Corps
|
|
</h1>
|
|
<p style={{ color: 'var(--color-brown)', fontSize: '0.95rem' }}>
|
|
No sign-up needed — pick your operator and start transmitting!
|
|
</p>
|
|
</div>
|
|
|
|
{/* Step 1: Gender picker */}
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<p style={{ fontWeight: 600, marginBottom: '0.6rem', textAlign: 'center' }}>
|
|
Choose your operator type:
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
|
{GENDER_OPTIONS.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => pickGender(opt.value)}
|
|
style={{
|
|
flex: 1,
|
|
padding: '0.7rem 0.5rem',
|
|
fontFamily: 'var(--font-header)',
|
|
fontSize: '1rem',
|
|
background: gender === opt.value ? 'var(--color-gold)' : 'var(--color-parchment)',
|
|
border: `2px solid ${gender === opt.value ? 'var(--color-gold)' : '#c8a96e'}`,
|
|
borderRadius: '0.4rem',
|
|
cursor: 'pointer',
|
|
color: 'var(--color-forest)',
|
|
fontWeight: gender === opt.value ? 700 : 400,
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
<div style={{ fontSize: '1.4rem' }}>{opt.icon}</div>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step 2: Name + reroll + start */}
|
|
{gender && (
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<p style={{ fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
|
|
Your operator name:
|
|
</p>
|
|
<div style={{
|
|
background: 'var(--color-parchment)',
|
|
border: '2px solid var(--color-gold)',
|
|
borderRadius: '0.4rem',
|
|
padding: '0.9rem 1rem',
|
|
textAlign: 'center',
|
|
marginBottom: '0.75rem',
|
|
}}>
|
|
<span style={{
|
|
fontFamily: 'var(--font-header)',
|
|
fontSize: '1.3rem',
|
|
color: 'var(--color-forest)',
|
|
fontWeight: 700,
|
|
}}>
|
|
{name}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.6rem' }}>
|
|
<button
|
|
type="button"
|
|
onClick={reroll}
|
|
disabled={joining}
|
|
className="btn btn-ghost"
|
|
style={{ whiteSpace: 'nowrap' }}
|
|
>
|
|
↻ Different name
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={startPlaying}
|
|
disabled={joining}
|
|
className="btn"
|
|
style={{ flex: 1, fontSize: '1.05rem' }}
|
|
>
|
|
{joining ? 'Joining...' : 'Begin Transmitting!'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<p style={{ color: 'crimson', fontSize: '0.9rem', textAlign: 'center', marginBottom: '1rem' }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* Optional email section */}
|
|
<div style={{ borderTop: '1px solid #c8a96e', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setShowEmail(v => !v); setError(null) }}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
color: 'var(--color-brown)',
|
|
fontSize: '0.9rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.3rem',
|
|
margin: '0 auto',
|
|
}}
|
|
>
|
|
<span>{showEmail ? '▾' : '▸'}</span>
|
|
Already have an account or want cross-device access?
|
|
</button>
|
|
|
|
{showEmail && (
|
|
<form onSubmit={sendMagicLink} style={{ marginTop: '0.75rem' }}>
|
|
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem', textAlign: 'center' }}>
|
|
Enter your email to receive a magic link.
|
|
</p>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={e => { setEmail(e.target.value); setError(null) }}
|
|
placeholder="telegraph@example.com"
|
|
disabled={submitting}
|
|
style={{ marginBottom: '0.5rem' }}
|
|
/>
|
|
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
|
|
{submitting ? 'Sending...' : 'Send Magic Link'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|