Files
elysium/apps/web/src/components/game/profilePage.tsx
T
hikari a36c8e72a5
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m8s
feat: error handling, logging, analytics, OG tags, and sticky sidebar (#44)
## Summary

- Add comprehensive try/catch error handling across all API routes, middleware, and the Hono global error handler, piping every unhandled error to the `@nhcarrigan/logger` service to prevent silent crashes and unhandled Promise rejections
- Add a `logError` utility on the frontend that forwards errors through the overridden `console.error` to the backend telemetry endpoint; apply it to every silent `catch {}` block in the game context, sound, notification, and clipboard utilities, and wrap the React tree in an `ErrorBoundary`
- Add Plausible analytics, Open Graph + Twitter Card meta tags, Tree-Nation widget, and Google Ads to `index.html`
- Make the game sidebar sticky with a `--resource-bar-height` CSS custom property offset so it stays viewport-height without overlapping the resource bar; reset sticky behaviour in the mobile responsive override

## Test plan

- [ ] Lint passes: `pnpm lint`
- [ ] Build passes: `pnpm build`
- [ ] Verify errors thrown in API routes appear in the logger service rather than crashing the process
- [ ] Verify frontend errors appear in the `/api/fe/error` backend log
- [ ] Verify Open Graph tags render correctly when sharing the URL
- [ ] Verify Plausible analytics fires on page load
- [ ] Verify Tree-Nation badge renders in the sidebar
- [ ] Verify sidebar stays fixed while the main content scrolls on desktop
- [ ] Verify mobile layout is unaffected

✨ This issue was created with help from Hikari~ 🌸

Reviewed-on: #44
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 19:54:42 -07:00

299 lines
8.5 KiB
TypeScript

/**
* @file Profile page component displaying a player's public profile.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional stat visibility checks */
import { useEffect, useState, type JSX } from "react";
import { formatNumber } from "../../utils/format.js";
import { logError } from "../../utils/logError.js";
import type { PublicProfileResponse } from "@elysium/types";
interface ProfilePageProperties {
readonly discordId: string;
}
interface StatEntry {
icon: string;
value: string;
label: string;
date: boolean;
}
/**
* Renders the public profile page for a given player.
* @param props - The profile page properties.
* @param props.discordId - The Discord ID of the player to display.
* @returns The JSX element.
*/
const ProfilePage = ({ discordId }: ProfilePageProperties): JSX.Element => {
const [ profile, setProfile ] = useState<PublicProfileResponse | null>(null);
const [ error, setError ] = useState<string | null>(null);
const [ copied, setCopied ] = useState(false);
useEffect(() => {
fetch(`/api/profile/${discordId}`).
then(async(response) => {
if (!response.ok) {
throw new Error("Player not found");
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
return await (response.json() as Promise<PublicProfileResponse>);
}).
then(setProfile).
catch((error_: unknown) => {
setError(
error_ instanceof Error
? error_.message
: "Failed to load profile",
);
});
}, [ discordId ]);
function handleCopy(): void {
void navigator.clipboard.writeText(window.location.href).
then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}).
catch((error_: unknown) => {
logError("clipboard_copy", error_);
});
}
if (error !== null) {
return (
<div className="profile-page">
<div className="profile-error">
<p>
{"⚠️ "}
{error}
</p>
<a className="profile-play-link" href="/">
{"← Play Elysium"}
</a>
</div>
</div>
);
}
if (profile === null) {
return (
<div className="profile-page">
<div className="profile-loading">{"Loading profile…"}</div>
</div>
);
}
const settings = profile.profileSettings;
function fmt(value: number): string {
return formatNumber(value, settings.numberFormat);
}
const avatarUrl
= profile.avatar === null
? `https://cdn.discordapp.com/embed/avatars/${String(Number.parseInt(discordId, 10) % 5)}.png`
: `https://cdn.discordapp.com/avatars/${discordId}/${profile.avatar}.png?size=128`;
const memberSince = new Date(profile.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
});
const currentRunStatsRaw: Array<StatEntry | false> = [
settings.showCurrentGold && {
date: false,
icon: "πŸͺ™",
label: "Gold Earned",
value: fmt(profile.currentRunGold),
},
settings.showCurrentClicks && {
date: false,
icon: "πŸ‘†",
label: "Clicks",
value: fmt(profile.currentRunClicks),
},
settings.showBossesDefeated && {
date: false,
icon: "πŸ’€",
label: "Bosses Defeated",
value: String(profile.bossesDefeated),
},
settings.showQuestsCompleted && {
date: false,
icon: "πŸ“œ",
label: "Quests Completed",
value: String(profile.questsCompleted),
},
settings.showAdventurersRecruited && {
date: false,
icon: "βš”οΈ",
label: "Adventurers Recruited",
value: fmt(profile.adventurersRecruited),
},
settings.showAchievementsUnlocked && {
date: false,
icon: "πŸ†",
label: "Achievements Unlocked",
value: String(profile.achievementsUnlocked),
},
];
const currentRunStats = currentRunStatsRaw.filter(
(entry): entry is StatEntry => {
return entry !== false;
},
);
const allTimeStatsRaw: Array<StatEntry | false> = [
settings.showTotalGold && {
date: false,
icon: "πŸͺ™",
label: "Total Gold Earned",
value: fmt(profile.totalGoldEarned),
},
settings.showTotalClicks && {
date: false,
icon: "πŸ‘†",
label: "Total Clicks",
value: fmt(profile.totalClicks),
},
settings.showLifetimeBossesDefeated && {
date: false,
icon: "πŸ’€",
label: "Bosses Defeated",
value: String(profile.lifetimeBossesDefeated),
},
settings.showLifetimeQuestsCompleted && {
date: false,
icon: "πŸ“œ",
label: "Quests Completed",
value: String(profile.lifetimeQuestsCompleted),
},
settings.showLifetimeAdventurersRecruited && {
date: false,
icon: "βš”οΈ",
label: "Adventurers Recruited",
value: fmt(profile.lifetimeAdventurersRecruited),
},
settings.showLifetimeAchievementsUnlocked && {
date: false,
icon: "πŸ†",
label: "Achievements Unlocked",
value: String(profile.lifetimeAchievementsUnlocked),
},
settings.showGuildFounded && {
date: true,
icon: "πŸ“…",
label: "Guild Founded",
value: memberSince,
},
];
const allTimeStats = allTimeStatsRaw.filter((entry): entry is StatEntry => {
return entry !== false;
});
function renderStats(stats: Array<StatEntry>): JSX.Element {
return (
<div className="profile-stats">
{stats.map((stat) => {
return (
<div className="profile-stat" key={stat.label}>
<span className="profile-stat-icon">{stat.icon}</span>
<span
className={`profile-stat-value ${
stat.date
? "profile-stat-date"
: ""
}`}
>
{stat.value}
</span>
<span className="profile-stat-label">{stat.label}</span>
</div>
);
})}
</div>
);
}
return (
<div className="profile-page">
<div className="profile-card">
<div className="profile-header">
<img
alt={`${profile.username}'s avatar`}
className="profile-avatar"
src={avatarUrl}
/>
<div className="profile-identity">
<h1 className="profile-character-name">{profile.characterName}</h1>
<p className="profile-username">
{"@"}
{profile.username}
</p>
{settings.showApotheosis && profile.apotheosisCount > 0
? <span className="profile-apotheosis-badge">
{"✨ Apotheosis "}
{profile.apotheosisCount}
</span>
: null}
{settings.showTranscendence && profile.transcendenceCount > 0
? <span className="profile-transcendence-badge">
{"🌌 Transcendence "}
{profile.transcendenceCount}
</span>
: null}
{settings.showPrestige && profile.prestigeCount > 0
? <span className="profile-prestige-badge">
{"⭐ Prestige "}
{profile.prestigeCount}
</span>
: null}
</div>
</div>
{profile.bio === ""
? null
: <p className="profile-bio">{profile.bio}</p>
}
{currentRunStats.length > 0
&& <div className="profile-stats-section">
<h3 className="profile-stats-heading">{"Current Run"}</h3>
{renderStats(currentRunStats)}
</div>
}
{allTimeStats.length > 0
&& <div className="profile-stats-section">
<h3 className="profile-stats-heading">{"All Time"}</h3>
{renderStats(allTimeStats)}
</div>
}
<div className="profile-actions">
<button
className="profile-share-button"
onClick={handleCopy}
type="button"
>
{copied
? "βœ“ Copied!"
: "πŸ”— Copy Profile Link"}
</button>
<a className="profile-play-link" href="/">
{"βš”οΈ Play Elysium"}
</a>
</div>
</div>
</div>
);
};
export { ProfilePage };