diff --git a/apps/front/src/components/HomePage.tsx b/apps/front/src/components/HomePage.tsx
index 2866f497..d54a7354 100644
--- a/apps/front/src/components/HomePage.tsx
+++ b/apps/front/src/components/HomePage.tsx
@@ -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'
@@ -60,6 +61,15 @@ export function HomePage() {
+
diff --git a/apps/front/src/components/Profile.test.tsx b/apps/front/src/components/Profile.test.tsx
new file mode 100644
index 00000000..66568fb5
--- /dev/null
+++ b/apps/front/src/components/Profile.test.tsx
@@ -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(
)
+
+ 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(
)
+
+ 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)
+ })
+})
diff --git a/apps/front/src/components/Profile.tsx b/apps/front/src/components/Profile.tsx
new file mode 100644
index 00000000..236e3e15
--- /dev/null
+++ b/apps/front/src/components/Profile.tsx
@@ -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 (
+
+
+ {label}
+
+ {value}
+
+ )
+}
+
+export function ProfilePage() {
+ const { t, i18n } = useTranslation()
+ const [profile, setProfile] = React.useState
()
+ 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 (
+
+ {hasError ? (
+
+
+ {t('profile.error')}
+
+
+
+ ) : (
+
+ {t('profile.loading')}
+
+ )}
+
+ )
+ }
+
+ 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 (
+
+
+ {t('profile.title')}
+
+
+
+
+
+ {statistics.map(([key, value]) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/front/src/components/Router.tsx b/apps/front/src/components/Router.tsx
index 06b890a0..cb798bef 100644
--- a/apps/front/src/components/Router.tsx
+++ b/apps/front/src/components/Router.tsx
@@ -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(() =>
@@ -44,12 +45,14 @@ export function Router() {
} />
} />
} />
+ } />
} />
} />
}>
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/front/src/translations/resources/en.json b/apps/front/src/translations/resources/en.json
index 88199da7..49ff37fb 100644
--- a/apps/front/src/translations/resources/en.json
+++ b/apps/front/src/translations/resources/en.json
@@ -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 Lamb0> 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}}",
diff --git a/apps/front/src/translations/resources/fr.json b/apps/front/src/translations/resources/fr.json
index 4ca83264..020fdd7a 100644
--- a/apps/front/src/translations/resources/fr.json
+++ b/apps/front/src/translations/resources/fr.json
@@ -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 Lamb0>."
},
+ "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}}",
diff --git a/apps/front/src/translations/resources/zh-tw.json b/apps/front/src/translations/resources/zh-tw.json
index 74685705..b7405102 100644
--- a/apps/front/src/translations/resources/zh-tw.json
+++ b/apps/front/src/translations/resources/zh-tw.json
@@ -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}}",