diff --git a/client/src/components/CharacterStats.jsx b/client/src/components/CharacterStats.jsx
new file mode 100644
index 0000000..0f7fe3b
--- /dev/null
+++ b/client/src/components/CharacterStats.jsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react'
+
+// A small stat-tile row for a "My Characters" page: total characters, how many
+// are online right now, and how many game accounts are linked. `scope` is the
+// shard api object (admin or player self-service). Renders nothing until an
+// account is linked, so the empty/link-prompt state below it stands alone.
+//
+// It fetches the same rosters GameAccounts loads; for a personal page that's at
+// most a couple of extra live round-trips, and keeps this presentational bit
+// decoupled from GameAccounts' per-account roster loading.
+
+function Tile({ value, label }) {
+ return (
+
+
{value}
+
+ {label}
+
+
+ )
+}
+
+export default function CharacterStats({ scope }) {
+ const [stats, setStats] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const accounts = await scope.accounts()
+ const linked = accounts.length
+ if (linked === 0) {
+ if (!cancelled) setStats({ linked: 0 })
+ return
+ }
+ // Roster is a live round-trip and can be unavailable (503); tolerate a
+ // partial result so a restarting shard doesn't blank the whole row.
+ const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
+ let chars = 0
+ let online = 0
+ let complete = true
+ for (const r of rosters) {
+ if (r.status === 'fulfilled') {
+ const cs = r.value.chars || []
+ chars += cs.length
+ online += cs.filter((c) => c.online).length
+ } else {
+ complete = false
+ }
+ }
+ if (!cancelled) setStats({ linked, chars, online, complete })
+ } catch {
+ if (!cancelled) setStats({ error: true })
+ }
+ })()
+ return () => { cancelled = true }
+ }, [scope])
+
+ // Hidden until we know an account is linked (or while first loading).
+ if (!stats || stats.error || stats.linked === 0) return null
+
+ // Counts depend on live rosters; show a dash if none came back.
+ const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx
index 7167603..8a33933 100644
--- a/client/src/routes/admin/views/AdminCharacters.jsx
+++ b/client/src/routes/admin/views/AdminCharacters.jsx
@@ -1,15 +1,16 @@
+import CharacterStats from '../../../components/CharacterStats.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
import { api } from '../../../api/client.js'
// Staff link their OWN in-game account and view their characters — the same
// shared component players use, pointed at the staff self-service endpoints.
+// Sits inside the Admin shell, which supplies the "My Characters" page header;
+// stat tiles bring it to parity with the Player Portal's Characters page.
export default function AdminCharacters() {
return (
-
- Link your own game account to view your characters, stats, skills and vendors.
-