feat: add community role grant and join prompt for players

- Grant Elysian Discord role to players on OAuth login (new and returning)
- Add inGuild flag to Player schema, seeded from role grant response
- Connect Discord Gateway WebSocket to keep inGuild in sync on join/leave
- Return inGuild from load endpoint; expose in game context
- Show join community modal once per session when inGuild is false
This commit is contained in:
2026-03-24 18:10:22 -07:00
committed by Naomi Carrigan
parent b48beef474
commit 5025948530
13 changed files with 549 additions and 3 deletions
@@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js";
import { EditProfileModal } from "./editProfileModal.js";
import { EquipmentPanel } from "./equipmentPanel.js";
import { ExplorationPanel } from "./explorationPanel.js";
import { JoinCommunityModal } from "./joinCommunityModal.js";
import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js";
@@ -164,6 +165,7 @@ const GameLayout = (): JSX.Element => {
transcendenceCount={state.transcendence?.count ?? 0}
/>
<OfflineModal />
<JoinCommunityModal />
{schemaOutdated && !dismissedOutdatedWarning
? <OutdatedSchemaModal onDismiss={handleDismissOutdated} />
: null}
@@ -0,0 +1,70 @@
/**
* @file Modal prompting players to join the NHCarrigan Discord community.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { useCallback, useState, type JSX } from "react";
import { useGame } from "../../context/gameContext.js";
const sessionKey = "elysium_join_community_dismissed";
/**
* Renders a modal prompting the player to join the NHCarrigan Discord server.
* Shown once per session when the player is not already in the guild.
* @returns The JSX element or null if the player is in the guild or dismissed.
*/
const JoinCommunityModal = (): JSX.Element | null => {
const { inGuild } = useGame();
const [ dismissed, setDismissed ] = useState(
() => {
return sessionStorage.getItem(sessionKey) === "true";
},
);
const handleDismiss = useCallback((): void => {
sessionStorage.setItem(sessionKey, "true");
setDismissed(true);
}, []);
if (inGuild || dismissed) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal">
<h2>{"Join Our Community!"}</h2>
<p>
{"Did you know Elysium has an active Discord community? "}
{"Join to chat with other players, get updates, and earn "}
{"the exclusive Elysian role!"}
</p>
<p className="modal-note">
{"You already earn the Elysian role just by playing — "}
{"joining lets us show it off in the server!"}
</p>
<div className="modal-actions">
<a
className="modal-close-button"
href="https://discord.gg/nhcarrigan"
onClick={handleDismiss}
rel="noreferrer"
target="_blank"
>
{"Join Discord"}
</a>
<button
className="modal-close-button"
onClick={handleDismiss}
type="button"
>
{"Maybe later"}
</button>
</div>
</div>
</div>
);
};
export { JoinCommunityModal };
+9
View File
@@ -243,6 +243,11 @@ interface GameContextValue {
isLoading: boolean;
error: string | null;
/**
* Whether the player is currently a member of the NHCarrigan Discord server.
*/
inGuild: boolean;
/**
* Click the crystal to earn gold.
*/
@@ -694,6 +699,7 @@ export const GameProvider = ({
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
const [ inGuild, setInGuild ] = useState(false);
const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState<
Array<string>
>([]);
@@ -731,6 +737,7 @@ export const GameProvider = ({
setSchemaOutdated(data.schemaOutdated);
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
setCurrentSchemaVersion(data.currentSchemaVersion);
setInGuild(data.inGuild === true);
// Fetch number format preference from profile (fire-and-forget, non-blocking)
void fetch(`/api/profile/${data.state.player.discordId}`).
@@ -2303,6 +2310,7 @@ export const GameProvider = ({
forceUnlocks,
formatNumber,
handleClick,
inGuild,
isLoading,
isSyncing,
lastSavedAt,
@@ -2374,6 +2382,7 @@ export const GameProvider = ({
error,
flushBossLoreToasts,
forceSync,
inGuild,
forceUnlocks,
handleClick,
isLoading,