Files
elysium/apps/web/src/components/game/editProfileModal.tsx
T
hikari 29c817230d
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s
feat: initial prototype — core game systems (#30)
## Summary

This PR represents the full v1 prototype, implementing the core game systems for Elysium.

- Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests
- Adventurer hiring with batch size selector and progressive tier cost scaling
- Prestige, transcendence, and apotheosis systems with auto-prestige support
- Character sheet, titles, leaderboards, companion system, and daily login bonuses
- Auto-quest and auto-boss toggles
- Discord webhook notifications on prestige/transcendence/apotheosis
- Discord role awarded on apotheosis
- Responsive design and overarching story/lore system
- In-game sound effects and browser notifications for key events
- Support link button in the resource bar
- Full test coverage (100% on `apps/api` and `packages/types`)
- CI pipeline: lint → build → test

## Closes

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
Closes #12
Closes #13
Closes #14
Closes #16
Closes #19
Closes #20
Closes #21
Closes #22
Closes #23
Closes #24
Closes #25
Closes #26
Closes #27
Closes #29

 This issue was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #30
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-08 15:53:39 -07:00

483 lines
15 KiB
TypeScript

/**
* @file Edit profile modal component for updating player profile settings.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex form with many fields */
/* eslint-disable complexity -- Many conditional render paths for toggles */
/* eslint-disable max-lines -- Large modal with profile and settings forms */
/* eslint-disable max-statements -- Many state initialisations and handlers */
import {
DEFAULT_PROFILE_SETTINGS,
type NumberFormat,
type ProfileSettings,
} from "@elysium/types";
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
import { updateProfile } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
import {
requestNotificationPermission,
} from "../../utils/notification.js";
interface EditProfileModalProperties {
readonly onClose: ()=> void;
}
interface StatToggle {
key: keyof ProfileSettings;
label: string;
icon: string;
}
const currentRunToggles: Array<StatToggle> = [
{ icon: "🪙", key: "showCurrentGold", label: "Gold Earned This Run" },
{ icon: "👆", key: "showCurrentClicks", label: "Clicks This Run" },
{ icon: "✨", key: "showApotheosis", label: "Apotheosis Badge" },
{ icon: "🌌", key: "showTranscendence", label: "Transcendence Badge" },
{ icon: "⭐", key: "showPrestige", label: "Prestige Level" },
{ icon: "💀", key: "showBossesDefeated", label: "Bosses Defeated" },
{ icon: "📜", key: "showQuestsCompleted", label: "Quests Completed" },
{
icon: "⚔️",
key: "showAdventurersRecruited",
label: "Adventurers Recruited",
},
{
icon: "🏆",
key: "showAchievementsUnlocked",
label: "Achievements Unlocked",
},
];
const allTimeToggles: Array<StatToggle> = [
{ icon: "🪙", key: "showTotalGold", label: "Total Gold Earned" },
{ icon: "👆", key: "showTotalClicks", label: "Total Clicks" },
{
icon: "💀",
key: "showLifetimeBossesDefeated",
label: "Bosses Defeated",
},
{
icon: "📜",
key: "showLifetimeQuestsCompleted",
label: "Quests Completed",
},
{
icon: "⚔️",
key: "showLifetimeAdventurersRecruited",
label: "Adventurers Recruited",
},
{
icon: "🏆",
key: "showLifetimeAchievementsUnlocked",
label: "Achievements Unlocked",
},
{ icon: "📅", key: "showGuildFounded", label: "Guild Founded Date" },
];
const numberFormatOptions: Array<{
value: NumberFormat;
label: string;
example: string;
}> = [
{ example: "1.23Qa", label: "Suffix", value: "suffix" },
{ example: "1.23e15", label: "Scientific", value: "scientific" },
{ example: "1.23E15", label: "Engineering", value: "engineering" },
];
/**
* Renders the edit profile modal for updating player display settings.
* @param props - The modal properties.
* @param props.onClose - Callback to close the modal.
* @returns The JSX element.
*/
const EditProfileModal = ({
onClose,
}: EditProfileModalProperties): JSX.Element => {
const {
state,
numberFormat: currentNumberFormat,
setNumberFormat,
setEnableSounds,
setEnableNotifications,
} = useGame();
const player = state?.player;
const [ characterName, setCharacterName ] = useState(
player?.characterName ?? "",
);
const [ bio, setBio ] = useState("");
const [ profileSettings, setProfileSettings ] = useState<ProfileSettings>({
...DEFAULT_PROFILE_SETTINGS,
numberFormat: currentNumberFormat,
});
const [ loadingProfile, setLoadingProfile ] = useState(true);
const [ saving, setSaving ] = useState(false);
const [ error, setError ] = useState<string | null>(null);
const [ saved, setSaved ] = useState(false);
useEffect(() => {
if (player?.discordId === undefined || player.discordId === "") {
return;
}
fetch(`/api/profile/${player.discordId}`).
then(async(response) => {
if (!response.ok) {
return;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
const data = (await response.json()) as {
bio: string;
profileSettings: ProfileSettings;
characterName: string;
};
setBio(data.bio);
setProfileSettings({
...DEFAULT_PROFILE_SETTINGS,
...data.profileSettings,
});
setCharacterName(
data.characterName === ""
? player.characterName
: data.characterName,
);
}).
catch(() => {
/* Fall back to local state if fetch fails — not a blocking error */
}).
finally(() => {
setLoadingProfile(false);
});
}, [ player?.discordId, player?.characterName ]);
async function handleSave(): Promise<void> {
setSaving(true);
setError(null);
try {
await updateProfile({
bio,
characterName,
profileSettings,
});
setNumberFormat(profileSettings.numberFormat);
setEnableSounds(profileSettings.enableSounds);
setEnableNotifications(profileSettings.enableNotifications);
setSaved(true);
setTimeout(onClose, 900);
} catch (error_: unknown) {
setError(error_ instanceof Error
? error_.message
: "Failed to save");
} finally {
setSaving(false);
}
}
function handleSaveClick(): void {
void handleSave();
}
function toggleSetting(key: keyof ProfileSettings): void {
setProfileSettings((previous) => {
const current = previous[key];
const toggled = typeof current === "boolean"
? !current
: current;
return { ...previous, [key]: toggled };
});
}
function handleLeaderboardToggle(): void {
toggleSetting("showOnLeaderboards");
}
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
setCharacterName(event.target.value);
}
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
setBio(event.target.value);
}
function handleSoundsToggle(): void {
toggleSetting("enableSounds");
}
async function handleNotificationsEnable(): Promise<void> {
if (profileSettings.enableNotifications) {
toggleSetting("enableNotifications");
return;
}
const granted = await requestNotificationPermission();
if (granted) {
toggleSetting("enableNotifications");
} else {
setError(
"Browser notification permission was denied."
+ " Please enable it in your browser settings.",
);
}
}
function handleNotificationsToggle(): void {
void handleNotificationsEnable();
}
const isSaveDisabled = saving || characterName.trim() === "";
let saveLabel = "Save Profile";
if (saving) {
saveLabel = "Saving…";
}
if (saved) {
saveLabel = "✓ Saved!";
}
return (
<div aria-modal="true" className="modal-overlay" role="dialog">
<div className="modal edit-profile-modal">
<div className="modal-header">
<h2>{"Edit Profile"}</h2>
<button
aria-label="Close"
className="modal-close"
onClick={onClose}
type="button"
>
{"✕"}
</button>
</div>
{loadingProfile
? <p className="edit-profile-loading">{"Loading your profile…"}</p>
: <div className="edit-profile-form">
<label className="edit-profile-label" htmlFor="edit-char-name">
{"Display Name"}
</label>
<input
className="edit-profile-input"
id="edit-char-name"
maxLength={32}
onChange={handleNameChange}
placeholder="Your character's name"
type="text"
value={characterName}
/>
<span className="edit-profile-hint">
{characterName.length}
{" / 32"}
</span>
<label className="edit-profile-label" htmlFor="edit-bio">
{"Bio"}
</label>
<textarea
className="edit-profile-textarea"
id="edit-bio"
maxLength={200}
onChange={handleBioChange}
placeholder="Tell the world about your guild… (optional)"
rows={3}
value={bio}
/>
<span className="edit-profile-hint">
{bio.length}
{" / 200"}
</span>
<div className="edit-profile-section">
<p className="edit-profile-label">{"Visible Stats"}</p>
<p className="edit-profile-sublabel">
{"Choose which stats appear on your public profile."}
</p>
<p className="edit-profile-stat-group-heading">{"Current Run"}</p>
<div className="stat-toggles">
{currentRunToggles.map(({ key, label, icon }) => {
const isOn = profileSettings[key] === true;
const toggleClass = isOn
? "stat-toggle-on"
: "stat-toggle-off";
const toggleIndicator = isOn
? "✓ Shown"
: "Hidden";
function handleToggle(): void {
toggleSetting(key);
}
return (
<button
className={`stat-toggle-btn ${toggleClass}`}
key={key}
onClick={handleToggle}
type="button"
>
<span>
{icon} {label}
</span>
<span className="stat-toggle-indicator">
{toggleIndicator}
</span>
</button>
);
})}
</div>
<p className="edit-profile-stat-group-heading">{"All Time"}</p>
<div className="stat-toggles">
{allTimeToggles.map(({ key, label, icon }) => {
const isOn = profileSettings[key] === true;
const toggleClass = isOn
? "stat-toggle-on"
: "stat-toggle-off";
const toggleIndicator = isOn
? "✓ Shown"
: "Hidden";
function handleToggle(): void {
toggleSetting(key);
}
return (
<button
className={`stat-toggle-btn ${toggleClass}`}
key={key}
onClick={handleToggle}
type="button"
>
<span>
{icon} {label}
</span>
<span className="stat-toggle-indicator">
{toggleIndicator}
</span>
</button>
);
})}
</div>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">{"Privacy"}</p>
<p className="edit-profile-sublabel">
{"Control your visibility on public leaderboards."}
</p>
<button
className={`stat-toggle-btn ${
profileSettings.showOnLeaderboards
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleLeaderboardToggle}
type="button"
>
<span>{"🏆 Appear on Leaderboards"}</span>
<span className="stat-toggle-indicator">
{profileSettings.showOnLeaderboards
? "✓ Shown"
: "Hidden"}
</span>
</button>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">{"Sounds & Notifications"}</p>
<p className="edit-profile-sublabel">
{"Control in-game sound effects and browser notifications."}
</p>
<button
className={`stat-toggle-btn ${
profileSettings.enableSounds
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleSoundsToggle}
type="button"
>
<span>{"🔊 Sound Effects"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enableSounds
? "✓ On"
: "Off"}
</span>
</button>
<button
className={`stat-toggle-btn ${
profileSettings.enableNotifications
? "stat-toggle-on"
: "stat-toggle-off"
}`}
onClick={handleNotificationsToggle}
type="button"
>
<span>{"🔔 Browser Notifications"}</span>
<span className="stat-toggle-indicator">
{profileSettings.enableNotifications
? "✓ On"
: "Off"
}
</span>
</button>
</div>
<div className="edit-profile-section">
<p className="edit-profile-label">{"Number Format"}</p>
<p className="edit-profile-sublabel">
{"How large numbers appear across the game."}
</p>
<div className="number-format-picker">
{numberFormatOptions.map(({ value, label, example }) => {
function handleFormatSelect(): void {
setProfileSettings((previous) => {
return { ...previous, numberFormat: value };
});
}
return (
<button
className={`number-format-btn ${
profileSettings.numberFormat === value
? "number-format-active"
: ""
}`}
key={value}
onClick={handleFormatSelect}
type="button"
>
<span className="number-format-label">{label}</span>
<span className="number-format-example">{example}</span>
</button>
);
})}
</div>
</div>
{error === null
? null
: <p className="edit-profile-error">{error}</p>
}
<div className="edit-profile-actions">
<button
className="edit-profile-cancel"
onClick={onClose}
type="button"
>
{"Cancel"}
</button>
<button
className="edit-profile-save"
disabled={isSaveDisabled}
onClick={handleSaveClick}
type="button"
>
{saveLabel}
</button>
</div>
</div>
}
</div>
</div>
);
};
export { EditProfileModal };