diff --git a/client/src/App.tsx b/client/src/App.tsx index 06a6ec4..3896413 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useAuth } from './hooks/useAuth' import { LoginPage } from './pages/LoginPage' +import { OnboardingPage } from './pages/OnboardingPage' import './index.css' type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' @@ -8,8 +9,27 @@ type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' export default function App() { const { profile, token, loading, logout } = useAuth() const [route, setRoute] = useState('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) => { + const count = Object.keys(mnemonics).length + setRoute(count >= 12 ? 'game' : 'onboarding') + }) + .catch(() => setRoute('game')) + .finally(() => setCheckingOnboarding(false)) + }, [profile, token]) + + if (loading || checkingOnboarding) { return (

Loading quest...

@@ -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 (
- {activeRoute === 'login' && ( + {route === 'login' && ( setRoute('sent')} /> )} - {activeRoute === 'sent' && ( + {route === 'sent' && (

Check your email

We sent a magic link to your inbox. Click it to begin your quest!

)} - {activeRoute === 'onboarding' && ( -
-

Welcome, {profile?.display_name || 'Adventurer'}!

-

Mnemonic builder coming in Task 10.

-
+ {route === 'onboarding' && token && ( + setRoute('game')} /> )} - {activeRoute === 'game' && ( + {route === 'game' && (

Practice

Hello, {profile?.display_name || profile?.email || 'Adventurer'}!

@@ -48,7 +61,7 @@ export default function App() {
)} - {activeRoute === 'admin' && ( + {route === 'admin' && (

Admin

Admin panel coming in Task 12.

diff --git a/client/src/components/MnemonicBuilder.tsx b/client/src/components/MnemonicBuilder.tsx new file mode 100644 index 0000000..cc31eff --- /dev/null +++ b/client/src/components/MnemonicBuilder.tsx @@ -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(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 ( +
+ {/* Progress */} +

+ Letter {index + 1} of {LETTERS.length} +

+ + {/* Letter display */} +
+
+ {letter} +
+
+ {display} +
+
+ {pattern.length} {pattern.length === 1 ? 'word' : 'words'} needed +
+
+ + {/* Phrase input */} + +

+ {pattern.length} word{pattern.length !== 1 ? 's' : ''} — short words (1-2 letters) for dots, + long words (3+ letters) for dashes. +

+ { 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 && ( +

+ {validation.message} +

+ )} + {validation?.valid && ( +

+ Looks good! +

+ )} + {error && ( +

{error}

+ )} + + {/* Actions */} +
+ + +
+
+ ) +} + +// Generate a simple example phrase for the placeholder +function examplePhrase(pattern: string): string { + return pattern.split('').map(c => c === '.' ? 'go' : 'WALKING').join(' ') +} diff --git a/client/src/pages/OnboardingPage.tsx b/client/src/pages/OnboardingPage.tsx new file mode 100644 index 0000000..5bf4357 --- /dev/null +++ b/client/src/pages/OnboardingPage.tsx @@ -0,0 +1,20 @@ +import { MnemonicBuilder } from '../components/MnemonicBuilder' + +type Props = { + token: string + onComplete: () => void +} + +export function OnboardingPage({ token, onComplete }: Props) { + return ( +
+

+ Build Your Mnemonics +

+

+ Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes. +

+ +
+ ) +}