Files
morsequest/client/src/components/MnemonicBuilder.tsx
T
kitadmin 8232e75fa9 feat(onboarding): curated meaningful mnemonic phrases
Replace random word banks with hand-crafted mnemonic suggestions
for each letter. All phrases are meaningful (e.g. "he has his hat"
for H, "sun set sky" for S, "over only once" for O) and every word
starts with the target letter. Placeholder shows first suggestion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 03:58:06 +00:00

200 lines
6.1 KiB
TypeScript

import { useState, useEffect } from 'react'
import { NOVICE_LETTERS, MORSE, validatePhrase, suggestPhrases } 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 [suggestions, setSuggestions] = useState<string[]>([])
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
useEffect(() => {
setSuggestions(suggestPhrases(letter, 5))
setPhrase('')
setError(null)
}, [letter])
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)
}
}
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' : ''}
{' '}3 letters for dots ·, 4+ letters for dashes .
{' '}Start a word with "{letter}" to anchor it!
</p>
<input
type="text"
value={phrase}
onChange={e => { setPhrase(e.target.value); setError(null) }}
placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''}
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>
)}
{/* Suggestions */}
<div style={{ marginTop: '0.75rem', marginBottom: '1rem' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.4rem',
}}>
<span style={{ fontSize: '0.85rem', color: 'var(--color-brown)' }}>
Suggestions click to use:
</span>
<button
type="button"
onClick={() => setSuggestions(suggestPhrases(letter, 5))}
disabled={saving}
style={{
padding: '0.1rem 0.4rem',
fontSize: '0.8rem',
background: 'none',
border: '1px solid var(--color-brown)',
borderRadius: '0.25rem',
cursor: 'pointer',
color: 'var(--color-brown)',
lineHeight: 1.4,
}}
>
new
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{suggestions.map(s => (
<button
key={s}
type="button"
onClick={() => { setPhrase(s); setError(null) }}
disabled={saving}
style={{
padding: '0.35rem 0.7rem',
fontSize: '0.9rem',
fontFamily: 'var(--font-mono)',
background: phrase === s ? 'var(--color-gold)' : 'var(--color-parchment)',
border: `1px solid ${phrase === s ? 'var(--color-gold)' : '#c8a96e'}`,
borderRadius: '0.25rem',
cursor: 'pointer',
color: 'var(--color-forest)',
textAlign: 'left',
transition: 'background 0.1s',
}}
>
{s}
</button>
))}
</div>
</div>
{/* 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>
)
}