Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/front/src/components/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { UserCircleIcon } from '@heroicons/react/24/outline'
import { type PlayerType } from '@knucklebones/common'
import { useLocalizedPath } from '../hooks/useLocalizedPath'
import KnucklebonesLogo from '../svgs/logo.svg'
Expand Down Expand Up @@ -60,6 +61,15 @@ export function HomePage() {
<Button as={Link} size='large' to={localizedPath('/how-to-play')}>
{t('guide.label')}
</Button>
<Button
as={Link}
size='large'
to={localizedPath('/profile')}
aria-label={t('profile.label')}
title={t('profile.label')}
>
<UserCircleIcon className='h-8 w-8 md:h-10 md:w-10' />
</Button>
</div>
<div className='absolute bottom-0 flex flex-col gap-2 p-2'>
<Footer />
Expand Down
82 changes: 82 additions & 0 deletions apps/front/src/components/Profile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { getRankedProfile } from '../utils/api'
import { ensurePlayerIdentity } from '../utils/playerIdentity'
import { ProfilePage } from './Profile'

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' }
})
}))
vi.mock('../utils/api', () => ({ getRankedProfile: vi.fn() }))
vi.mock('../utils/playerIdentity', () => ({
ensurePlayerIdentity: vi.fn()
}))

describe('ProfilePage', () => {
beforeEach(() => {
vi.mocked(ensurePlayerIdentity).mockReset().mockResolvedValue({
playerId: '22222222-2222-4222-8222-222222222222',
credential: 'credential'
})
vi.mocked(getRankedProfile).mockReset().mockResolvedValue({
playerId: '22222222-2222-4222-8222-222222222222',
ratingPool: 'classic',
rating: 1248,
gamesPlayed: 14,
wins: 8,
losses: 4,
draws: 2
})
localStorage.setItem('knucklebones.identity.v1.displayName', 'Current Name')
})

it('shows ranked statistics and saves a new display name', async () => {
const user = userEvent.setup()
render(<ProfilePage />)

expect(await screen.findByText('1,248')).toBeVisible()
expect(screen.getByText('8')).toBeVisible()
expect(screen.getByText('4')).toBeVisible()
expect(screen.getByText('2')).toBeVisible()

const nameInput = screen.getByRole('textbox', {
name: 'profile.name.label'
})
await user.clear(nameInput)
await user.type(nameInput, 'New Name')
await user.click(screen.getByRole('button', { name: 'profile.name.save' }))

expect(localStorage.getItem('knucklebones.identity.v1.displayName')).toBe(
'New Name'
)
expect(
screen.getByRole('button', { name: 'profile.name.save' })
).toBeDisabled()
})

it('offers a retry when the profile cannot be loaded', async () => {
vi.mocked(getRankedProfile)
.mockRejectedValueOnce(new Error('Unavailable'))
.mockResolvedValueOnce({
playerId: '22222222-2222-4222-8222-222222222222',
ratingPool: 'classic',
rating: 1200,
gamesPlayed: 0,
wins: 0,
losses: 0,
draws: 0
})
const user = userEvent.setup()
render(<ProfilePage />)

expect(await screen.findByRole('alert')).toHaveTextContent('profile.error')
await user.click(screen.getByRole('button', { name: 'profile.retry' }))

expect(await screen.findByText('1,200')).toBeVisible()
expect(getRankedProfile).toHaveBeenCalledTimes(2)
})
})
149 changes: 149 additions & 0 deletions apps/front/src/components/Profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { type RankedProfile } from '@knucklebones/common'
import { useNoIndex } from '../hooks/useNoIndex'
import { getRankedProfile } from '../utils/api'
import {
getStoredDisplayName,
storeDisplayName
} from '../utils/identityStorage'
import { MAX_NAME_LENGTH } from '../utils/name'
import { ensurePlayerIdentity } from '../utils/playerIdentity'
import { Button } from './Button'

interface StatisticProps {
label: string
value: string
}

function Statistic({ label, value }: StatisticProps) {
return (
<div className='rounded-2xl border border-slate-900/10 bg-white/70 p-5 text-center shadow-sm dark:border-slate-50/10 dark:bg-slate-800/70'>
<dt className='text-sm font-medium text-slate-500 dark:text-slate-400'>
{label}
</dt>
<dd className='mt-2 text-3xl font-bold tracking-tight'>{value}</dd>
</div>
)
}

export function ProfilePage() {
const { t, i18n } = useTranslation()
const [profile, setProfile] = React.useState<RankedProfile>()
const [displayName, setDisplayName] = React.useState('')
const [savedDisplayName, setSavedDisplayName] = React.useState('')
const [hasError, setHasError] = React.useState(false)
const [loadAttempt, setLoadAttempt] = React.useState(0)
useNoIndex()

React.useEffect(() => {
let disposed = false

async function loadProfile() {
setHasError(false)
try {
await ensurePlayerIdentity()
const nextProfile = await getRankedProfile()
if (!disposed) {
const storedDisplayName = getStoredDisplayName() ?? ''
setProfile(nextProfile)
setDisplayName(storedDisplayName)
setSavedDisplayName(storedDisplayName)
}
} catch {
if (!disposed) {
setHasError(true)
}
}
}

void loadProfile()
return () => {
disposed = true
}
}, [loadAttempt])

function saveName(event: React.FormEvent) {
event.preventDefault()
const nextDisplayName = displayName.trim()
if (nextDisplayName.length === 0) {
return
}

storeDisplayName(nextDisplayName)
setDisplayName(nextDisplayName)
setSavedDisplayName(nextDisplayName)
}

if (profile === undefined) {
return (
<main className='flex min-h-96 items-center justify-center px-4'>
{hasError ? (
<div className='flex flex-col items-center gap-4 text-center'>
<p className='text-xl font-medium' role='alert'>
{t('profile.error')}
</p>
<Button onClick={() => setLoadAttempt((attempt) => attempt + 1)}>
{t('profile.retry')}
</Button>
</div>
) : (
<p className='text-xl font-medium' aria-live='polite'>
{t('profile.loading')}
</p>
)}
</main>
)
}

const numberFormatter = new Intl.NumberFormat(i18n.language)
const statistics = [
['elo', profile.rating],
['wins', profile.wins],
['losses', profile.losses],
['draws', profile.draws]
] as const
const isNameEmpty = displayName.trim().length === 0
const isNameSaved = displayName === savedDisplayName

return (
<main className='container mx-auto max-w-3xl px-4 py-8 md:px-6'>
<h1 className='font-mona text-center text-4xl font-bold tracking-tight md:text-6xl'>
{t('profile.title')}
</h1>

<form
className='mx-auto mt-8 max-w-xl rounded-2xl border border-slate-900/10 bg-white/70 p-5 shadow-sm dark:border-slate-50/10 dark:bg-slate-800/70'
onSubmit={saveName}
>
<label htmlFor='profile-display-name' className='font-medium'>
{t('profile.name.label')}
</label>
<div className='mt-2 flex flex-col gap-3 sm:flex-row'>
<input
id='profile-display-name'
type='text'
value={displayName}
maxLength={MAX_NAME_LENGTH}
autoComplete='nickname'
className='min-w-0 flex-1 rounded-md border-2 border-slate-300 bg-white px-3 py-2 dark:border-slate-600 dark:bg-slate-900'
onChange={(event) => setDisplayName(event.target.value)}
/>
<Button type='submit' disabled={isNameEmpty || isNameSaved}>
{t('profile.name.save')}
</Button>
</div>
</form>

<dl className='mt-8 grid grid-cols-2 gap-4'>
{statistics.map(([key, value]) => (
<Statistic
key={key}
label={t(`profile.statistics.${key}`)}
value={numberFormatter.format(value)}
/>
))}
</dl>
</main>
)
}
3 changes: 3 additions & 0 deletions apps/front/src/components/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Game } from './Game'
import { GameProvider } from './GameContext'
import { HomePage } from './HomePage'
import { HowToPlayPage } from './HowToPlay'
import { ProfilePage } from './Profile'
import { RankedMatchmaking } from './RankedMatchmaking'

const RankedStatsPage = React.lazy(() =>
Expand Down Expand Up @@ -44,12 +45,14 @@ export function Router() {
<Route path='/' element={<HomePage />} />
<Route path='/room/:roomKey' element={<GameRoute />} />
<Route path='/how-to-play' element={<HowToPlayPage />} />
<Route path='/profile' element={<ProfilePage />} />
<Route path='/ranked' element={<RankedMatchmaking />} />
<Route path='/ranked-stats' element={<RankedStatsRoute />} />
<Route path='/:language' element={<SupportedLanguageRoute />}>
<Route index element={<HomePage />} />
<Route path='room/:roomKey' element={<GameRoute />} />
<Route path='how-to-play' element={<HowToPlayPage />} />
<Route path='profile' element={<ProfilePage />} />
<Route path='ranked' element={<RankedMatchmaking />} />
<Route path='ranked-stats' element={<RankedStatsRoute />} />
</Route>
Expand Down
17 changes: 17 additions & 0 deletions apps/front/src/translations/resources/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
},
"footer": "The original Knucklebones game in Cult of the Lamb was created by Massive Monster.\nThis is a fan-site and not an official implementation by Massive Monster.\nYou can find the original game on the <0>Cult of the Lamb</0> website."
},
"profile": {
"label": "Player profile",
"title": "Your profile",
"loading": "Loading your profile…",
"error": "Your profile could not be loaded.",
"retry": "Try again",
"name": {
"label": "Display name",
"save": "Save"
},
"statistics": {
"elo": "Elo",
"wins": "Wins",
"losses": "Losses",
"draws": "Draws"
}
},
"ranked": {
"identity-warning": "Ranked progress belongs to this browser identity. Save your recovery phrase before clearing browser data.",
"rating": "Rating: {{rating}}",
Expand Down
17 changes: 17 additions & 0 deletions apps/front/src/translations/resources/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
},
"footer": "Le jeu Knucklebones est une création originale de l'entité Massive Monster.\nLe présent site est une réalisation de fans et ne constitue en aucun cas une implémentation officielle de la part de Massive Monster.\nPour accéder au jeu original, nous vous invitons à consulter le site internet officiel de <0>Cult of the Lamb</0>."
},
"profile": {
"label": "Profil du joueur",
"title": "Votre profil",
"loading": "Chargement de votre profil…",
"error": "Impossible de charger votre profil.",
"retry": "Réessayer",
"name": {
"label": "Nom d'affichage",
"save": "Enregistrer"
},
"statistics": {
"elo": "Elo",
"wins": "Victoires",
"losses": "Défaites",
"draws": "Égalités"
}
},
"ranked": {
"identity-warning": "Votre progression classée appartient à l'identité de ce navigateur. Sauvegardez votre phrase de récupération avant d'effacer ses données.",
"rating": "Classement : {{rating}}",
Expand Down
17 changes: 17 additions & 0 deletions apps/front/src/translations/resources/zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@
},
"footer": "《進擊羔羊傳說》中的原版 Knucklebones 遊戲由 Massive Monster 創作。\n這是一個粉絲網站,並非 Massive Monster 的官方實現。\n您可以在 <0>《進擊羔羊傳說》</0> 網站上找到原版遊戲。"
},
"profile": {
"label": "玩家個人檔案",
"title": "您的個人檔案",
"loading": "正在載入您的個人檔案…",
"error": "無法載入您的個人檔案。",
"retry": "再試一次",
"name": {
"label": "顯示名稱",
"save": "儲存"
},
"statistics": {
"elo": "Elo",
"wins": "勝場",
"losses": "敗場",
"draws": "平手"
}
},
"ranked": {
"identity-warning": "排名進度綁定於此瀏覽器的玩家身分。清除瀏覽器資料前,請先保存復原短語。",
"rating": "評分:{{rating}}",
Expand Down
Loading