feat: onboarding mnemonic builder with step-through UI (Task 10)
- MnemonicBuilder: 12 NOVICE_LETTERS, real-time validatePhrase, save/skip - OnboardingPage: wraps builder, passes token and onComplete - App.tsx: post-auth fetch /api/mnemonics, route to onboarding if <12 saved - Fix: validatePhrase called with letter (not Morse pattern) as second arg Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+28
-15
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth } from './hooks/useAuth'
|
import { useAuth } from './hooks/useAuth'
|
||||||
import { LoginPage } from './pages/LoginPage'
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
import { OnboardingPage } from './pages/OnboardingPage'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
||||||
@@ -8,8 +9,27 @@ type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { profile, token, loading, logout } = useAuth()
|
const { profile, token, loading, logout } = useAuth()
|
||||||
const [route, setRoute] = useState<AppRoute>('login')
|
const [route, setRoute] = useState<AppRoute>('login')
|
||||||
|
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
|
||||||
|
|
||||||
if (loading) {
|
// After auth resolves, check onboarding status
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile || !token) return
|
||||||
|
if (route !== 'login' && route !== 'sent') return
|
||||||
|
|
||||||
|
setCheckingOnboarding(true)
|
||||||
|
fetch('/api/mnemonics', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((mnemonics: Record<string, string>) => {
|
||||||
|
const count = Object.keys(mnemonics).length
|
||||||
|
setRoute(count >= 12 ? 'game' : 'onboarding')
|
||||||
|
})
|
||||||
|
.catch(() => setRoute('game'))
|
||||||
|
.finally(() => setCheckingOnboarding(false))
|
||||||
|
}, [profile, token])
|
||||||
|
|
||||||
|
if (loading || checkingOnboarding) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
||||||
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
|
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
|
||||||
@@ -17,28 +37,21 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If authenticated and still on an unauthenticated route, go to game
|
|
||||||
// (Task 10 will refine this to check onboarding completion)
|
|
||||||
const activeRoute: AppRoute = profile && (route === 'login' || route === 'sent') ? 'game' : route
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
{activeRoute === 'login' && (
|
{route === 'login' && (
|
||||||
<LoginPage onLoginSent={() => setRoute('sent')} />
|
<LoginPage onLoginSent={() => setRoute('sent')} />
|
||||||
)}
|
)}
|
||||||
{activeRoute === 'sent' && (
|
{route === 'sent' && (
|
||||||
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
|
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
|
||||||
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>Check your email</h2>
|
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>Check your email</h2>
|
||||||
<p>We sent a magic link to your inbox. Click it to begin your quest!</p>
|
<p>We sent a magic link to your inbox. Click it to begin your quest!</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeRoute === 'onboarding' && (
|
{route === 'onboarding' && token && (
|
||||||
<div>
|
<OnboardingPage token={token} onComplete={() => setRoute('game')} />
|
||||||
<h1 style={{ fontFamily: 'var(--font-header)' }}>Welcome, {profile?.display_name || 'Adventurer'}!</h1>
|
|
||||||
<p>Mnemonic builder coming in Task 10.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{activeRoute === 'game' && (
|
{route === 'game' && (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontFamily: 'var(--font-header)' }}>Practice</h1>
|
<h1 style={{ fontFamily: 'var(--font-header)' }}>Practice</h1>
|
||||||
<p>Hello, {profile?.display_name || profile?.email || 'Adventurer'}!</p>
|
<p>Hello, {profile?.display_name || profile?.email || 'Adventurer'}!</p>
|
||||||
@@ -48,7 +61,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeRoute === 'admin' && (
|
{route === 'admin' && (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontFamily: 'var(--font-header)' }}>Admin</h1>
|
<h1 style={{ fontFamily: 'var(--font-header)' }}>Admin</h1>
|
||||||
<p>Admin panel coming in Task 12.</p>
|
<p>Admin panel coming in Task 12.</p>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { NOVICE_LETTERS, MORSE, validatePhrase } from '../lib/morse'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
token: string
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LETTERS = NOVICE_LETTERS // ['A','E','T','I','N','S','H','R','D','L','U','O']
|
||||||
|
|
||||||
|
export function MnemonicBuilder({ token, onComplete }: Props) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [phrase, setPhrase] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const letter = LETTERS[index]
|
||||||
|
const pattern = MORSE[letter] ?? ''
|
||||||
|
// Display pattern as dots/dashes: '.' → '·', '-' → '—'
|
||||||
|
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
||||||
|
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!validation?.valid) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/mnemonics', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ letter, phrase: phrase.trim() }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error('Failed to save')
|
||||||
|
advance()
|
||||||
|
} catch {
|
||||||
|
setError('Failed to save. Try again.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
if (index + 1 >= LETTERS.length) {
|
||||||
|
onComplete()
|
||||||
|
} else {
|
||||||
|
setIndex(i => i + 1)
|
||||||
|
setPhrase('')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 520, margin: '0 auto' }}>
|
||||||
|
{/* Progress */}
|
||||||
|
<p style={{ color: 'var(--color-brown)', marginBottom: '1.5rem' }}>
|
||||||
|
Letter {index + 1} of {LETTERS.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Letter display */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '4rem',
|
||||||
|
fontFamily: 'var(--font-header)',
|
||||||
|
color: 'var(--color-forest)',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '1.8rem',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--color-gold)',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
letterSpacing: '0.3em',
|
||||||
|
}}>
|
||||||
|
{display}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginTop: '0.25rem' }}>
|
||||||
|
{pattern.length} {pattern.length === 1 ? 'word' : 'words'} needed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phrase input */}
|
||||||
|
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.4rem' }}>
|
||||||
|
Create your mnemonic phrase:
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
|
||||||
|
{pattern.length} word{pattern.length !== 1 ? 's' : ''} — short words (1-2 letters) for dots,
|
||||||
|
long words (3+ letters) for dashes.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={phrase}
|
||||||
|
onChange={e => { setPhrase(e.target.value); setError(null) }}
|
||||||
|
placeholder={`${pattern.length} word${pattern.length !== 1 ? 's' : ''}, e.g. "${examplePhrase(pattern)}"`}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ marginBottom: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation feedback */}
|
||||||
|
{validation && !validation.valid && (
|
||||||
|
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
|
||||||
|
{validation.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{validation?.valid && (
|
||||||
|
<p style={{ color: 'green', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
|
||||||
|
Looks good!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving || !validation?.valid}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save (+50 pts)'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={advance}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple example phrase for the placeholder
|
||||||
|
function examplePhrase(pattern: string): string {
|
||||||
|
return pattern.split('').map(c => c === '.' ? 'go' : 'WALKING').join(' ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MnemonicBuilder } from '../components/MnemonicBuilder'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
token: string
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingPage({ token, onComplete }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
|
||||||
|
Build Your Mnemonics
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
|
||||||
|
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
|
||||||
|
</p>
|
||||||
|
<MnemonicBuilder token={token} onComplete={onComplete} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user