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:
2026-04-30 02:14:46 +00:00
parent 7c9386f125
commit 8415e569dd
3 changed files with 191 additions and 15 deletions
+143
View File
@@ -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(' ')
}