generated from nhcarrigan/template
a36c8e72a5
## 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>
299 lines
8.5 KiB
TypeScript
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 };
|