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>
697 lines
22 KiB
TypeScript
697 lines
22 KiB
TypeScript
/**
|
|
* @file Character sheet panel for viewing and editing the player's character.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable max-lines-per-function -- Complex component with many fields */
|
|
/* eslint-disable complexity -- Many conditional render paths for optional fields */
|
|
/* eslint-disable max-statements -- Component requires many state declarations */
|
|
/* eslint-disable max-lines -- Large component with editing and view modes */
|
|
import {
|
|
DEFAULT_PROFILE_SETTINGS,
|
|
STORY_CHAPTERS,
|
|
type EquipmentBonus,
|
|
type EquipmentRarity,
|
|
type EquipmentType,
|
|
type ProfileSettings,
|
|
} from "@elysium/types";
|
|
import { type ChangeEvent, type JSX, useEffect, useRef, useState } from "react";
|
|
import { updateProfile } from "../../api/client.js";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import { logError } from "../../utils/logError.js";
|
|
|
|
interface EquippedItem {
|
|
name: string;
|
|
type: EquipmentType;
|
|
rarity: EquipmentRarity;
|
|
bonus: EquipmentBonus;
|
|
}
|
|
|
|
interface CharacterSheetData {
|
|
characterName: string;
|
|
pronouns: string;
|
|
characterRace: string;
|
|
characterClass: string;
|
|
bio: string;
|
|
guildName: string;
|
|
guildDescription: string;
|
|
activeTitle: string;
|
|
unlockedTitles: Array<{ id: string; name: string }>;
|
|
equippedItems: Array<EquippedItem>;
|
|
}
|
|
|
|
const emptySheet: CharacterSheetData = {
|
|
activeTitle: "",
|
|
bio: "",
|
|
characterClass: "",
|
|
characterName: "",
|
|
characterRace: "",
|
|
equippedItems: [],
|
|
guildDescription: "",
|
|
guildName: "",
|
|
pronouns: "",
|
|
unlockedTitles: [],
|
|
};
|
|
|
|
const slotIcons: Record<EquipmentType, string> = {
|
|
armour: "๐ก๏ธ",
|
|
trinket: "๐",
|
|
weapon: "โ๏ธ",
|
|
};
|
|
|
|
/**
|
|
* Formats an equipment bonus as a human-readable string.
|
|
* @param bonus - The equipment bonus to format.
|
|
* @returns The formatted bonus string.
|
|
*/
|
|
const formatBonus = (bonus: EquipmentBonus): string => {
|
|
const parts: Array<string> = [];
|
|
if (bonus.goldMultiplier !== undefined) {
|
|
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
|
parts.push(`+${String(pct)}% Gold Income`);
|
|
}
|
|
if (bonus.combatMultiplier !== undefined) {
|
|
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
|
parts.push(`+${String(pct)}% Combat Power`);
|
|
}
|
|
if (bonus.clickMultiplier !== undefined) {
|
|
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
|
parts.push(`+${String(pct)}% Click Power`);
|
|
}
|
|
return parts.join(" ยท ");
|
|
};
|
|
|
|
/**
|
|
* Renders the character sheet panel for viewing and editing player profile.
|
|
* @returns The JSX element.
|
|
*/
|
|
const CharacterSheetPanel = (): JSX.Element => {
|
|
const { state, loginStreak } = useGame();
|
|
const player = state?.player;
|
|
|
|
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet);
|
|
const [ draft, setDraft ] = useState<CharacterSheetData>(emptySheet);
|
|
const [ editing, setEditing ] = useState(false);
|
|
const [ loading, setLoading ] = useState(true);
|
|
const [ saving, setSaving ] = useState(false);
|
|
const [ error, setError ] = useState<string | null>(null);
|
|
const [ saved, setSaved ] = useState(false);
|
|
const [ copied, setCopied ] = useState(false);
|
|
const savedSettingsReference = useRef<ProfileSettings>({
|
|
...DEFAULT_PROFILE_SETTINGS,
|
|
});
|
|
|
|
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 {
|
|
characterName: string;
|
|
pronouns: string;
|
|
characterRace: string;
|
|
characterClass: string;
|
|
bio: string;
|
|
guildName: string;
|
|
guildDescription: string;
|
|
profileSettings: ProfileSettings;
|
|
activeTitle: string;
|
|
unlockedTitles: Array<{ id: string; name: string }>;
|
|
equippedItems: Array<EquippedItem>;
|
|
};
|
|
const loaded: CharacterSheetData = {
|
|
activeTitle: data.activeTitle,
|
|
bio: data.bio,
|
|
characterClass: data.characterClass,
|
|
characterName: data.characterName,
|
|
characterRace: data.characterRace,
|
|
equippedItems: data.equippedItems,
|
|
guildDescription: data.guildDescription,
|
|
guildName: data.guildName,
|
|
pronouns: data.pronouns,
|
|
unlockedTitles: data.unlockedTitles,
|
|
};
|
|
setSheet(loaded);
|
|
setDraft(loaded);
|
|
savedSettingsReference.current = {
|
|
...DEFAULT_PROFILE_SETTINGS,
|
|
...data.profileSettings,
|
|
};
|
|
}).
|
|
catch(() => {
|
|
|
|
/* Fall back to empty */
|
|
}).
|
|
finally(() => {
|
|
setLoading(false);
|
|
});
|
|
}, [ player?.discordId ]);
|
|
|
|
function handleEdit(): void {
|
|
setDraft({ ...sheet });
|
|
setEditing(true);
|
|
setError(null);
|
|
setSaved(false);
|
|
}
|
|
|
|
function handleCancel(): void {
|
|
setEditing(false);
|
|
setError(null);
|
|
}
|
|
|
|
async function handleSave(): Promise<void> {
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const characterName
|
|
= draft.characterName === ""
|
|
? player?.characterName ?? ""
|
|
: draft.characterName;
|
|
await updateProfile({
|
|
activeTitle: draft.activeTitle,
|
|
bio: draft.bio,
|
|
characterClass: draft.characterClass,
|
|
characterName: characterName,
|
|
characterRace: draft.characterRace,
|
|
guildDescription: draft.guildDescription,
|
|
guildName: draft.guildName,
|
|
profileSettings: savedSettingsReference.current,
|
|
pronouns: draft.pronouns,
|
|
});
|
|
setSheet({ ...draft });
|
|
setSaved(true);
|
|
setTimeout(() => {
|
|
setEditing(false);
|
|
setSaved(false);
|
|
}, 900);
|
|
} catch (error_) {
|
|
setError(error_ instanceof Error
|
|
? error_.message
|
|
: "Failed to save");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
function handleSaveClick(): void {
|
|
void handleSave();
|
|
}
|
|
|
|
function handleShareClick(): void {
|
|
const discordId = player?.discordId ?? "";
|
|
const url = `${window.location.origin}/character/${discordId}`;
|
|
void navigator.clipboard.writeText(url).
|
|
then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => {
|
|
setCopied(false);
|
|
}, 2000);
|
|
}).
|
|
catch((error_: unknown) => {
|
|
logError("clipboard_copy", error_);
|
|
});
|
|
}
|
|
|
|
function handleNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, characterName: value };
|
|
});
|
|
}
|
|
|
|
function handlePronounsChange(event: ChangeEvent<HTMLInputElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, pronouns: value };
|
|
});
|
|
}
|
|
|
|
function handleRaceChange(event: ChangeEvent<HTMLInputElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, characterRace: value };
|
|
});
|
|
}
|
|
|
|
function handleClassChange(event: ChangeEvent<HTMLInputElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, characterClass: value };
|
|
});
|
|
}
|
|
|
|
function handleBioChange(event: ChangeEvent<HTMLTextAreaElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, bio: value };
|
|
});
|
|
}
|
|
|
|
function handleTitleChange(event: ChangeEvent<HTMLSelectElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, activeTitle: value };
|
|
});
|
|
}
|
|
|
|
function handleGuildNameChange(event: ChangeEvent<HTMLInputElement>): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, guildName: value };
|
|
});
|
|
}
|
|
|
|
function handleGuildDescChange(
|
|
event: ChangeEvent<HTMLTextAreaElement>,
|
|
): void {
|
|
const { value } = event.target;
|
|
setDraft((current) => {
|
|
return { ...current, guildDescription: value };
|
|
});
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading character sheetโฆ"}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (editing) {
|
|
const isSaveDisabled = saving || draft.characterName.trim() === "";
|
|
let saveLabel = "Save";
|
|
if (saving) {
|
|
saveLabel = "Savingโฆ";
|
|
}
|
|
if (saved) {
|
|
saveLabel = "โ Saved!";
|
|
}
|
|
return (
|
|
<section className="panel character-sheet-panel">
|
|
<div className="panel-header">
|
|
<h2>{"๐ Character Sheet"}</h2>
|
|
</div>
|
|
|
|
<div className="character-sheet-form">
|
|
<div className="character-sheet-section">
|
|
<h3 className="character-sheet-section-title">{"โ๏ธ Character"}</h3>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-name">
|
|
{"Character Name"}
|
|
</label>
|
|
<input
|
|
className="character-sheet-input"
|
|
id="cs-name"
|
|
maxLength={32}
|
|
onChange={handleNameChange}
|
|
placeholder="Your character's name"
|
|
type="text"
|
|
value={draft.characterName}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.characterName.length}
|
|
{" / 32"}
|
|
</span>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-pronouns">
|
|
{"Pronouns"}
|
|
</label>
|
|
<input
|
|
className="character-sheet-input"
|
|
id="cs-pronouns"
|
|
maxLength={20}
|
|
onChange={handlePronounsChange}
|
|
placeholder="e.g. she/her, he/him, they/them"
|
|
type="text"
|
|
value={draft.pronouns}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.pronouns.length}
|
|
{" / 20"}
|
|
</span>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-race">
|
|
{"Race"}
|
|
</label>
|
|
<input
|
|
className="character-sheet-input"
|
|
id="cs-race"
|
|
maxLength={32}
|
|
onChange={handleRaceChange}
|
|
placeholder="e.g. Elf, Dwarf, Human, Tieflingโฆ"
|
|
type="text"
|
|
value={draft.characterRace}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.characterRace.length}
|
|
{" / 32"}
|
|
</span>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-class">
|
|
{"Class"}
|
|
</label>
|
|
<input
|
|
className="character-sheet-input"
|
|
id="cs-class"
|
|
maxLength={32}
|
|
onChange={handleClassChange}
|
|
placeholder="e.g. Paladin, Archmage, Shadow Rogueโฆ"
|
|
type="text"
|
|
value={draft.characterClass}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.characterClass.length}
|
|
{" / 32"}
|
|
</span>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-bio">
|
|
{"About Your Character"}
|
|
</label>
|
|
<textarea
|
|
className="character-sheet-textarea"
|
|
id="cs-bio"
|
|
maxLength={200}
|
|
onChange={handleBioChange}
|
|
placeholder={
|
|
"Describe your character's story, personality, or appearanceโฆ"
|
|
}
|
|
rows={4}
|
|
value={draft.bio}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.bio.length}
|
|
{" / 200"}
|
|
</span>
|
|
|
|
{draft.unlockedTitles.length > 0
|
|
&& <>
|
|
<label className="character-sheet-label" htmlFor="cs-title">
|
|
{"Active Title"}
|
|
</label>
|
|
<select
|
|
className="character-sheet-input"
|
|
id="cs-title"
|
|
onChange={handleTitleChange}
|
|
value={draft.activeTitle}
|
|
>
|
|
<option value="">{"โ None โ"}</option>
|
|
{draft.unlockedTitles.map((title) => {
|
|
return (
|
|
<option key={title.id} value={title.id}>
|
|
{title.name}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</>
|
|
}
|
|
</div>
|
|
|
|
<div className="character-sheet-section">
|
|
<h3 className="character-sheet-section-title">{"๐ฐ Guild"}</h3>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-guild-name">
|
|
{"Guild Name"}
|
|
</label>
|
|
<input
|
|
className="character-sheet-input"
|
|
id="cs-guild-name"
|
|
maxLength={64}
|
|
onChange={handleGuildNameChange}
|
|
placeholder="Name your guild"
|
|
type="text"
|
|
value={draft.guildName}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.guildName.length}
|
|
{" / 64"}
|
|
</span>
|
|
|
|
<label className="character-sheet-label" htmlFor="cs-guild-desc">
|
|
{"Guild Description"}
|
|
</label>
|
|
<textarea
|
|
className="character-sheet-textarea"
|
|
id="cs-guild-desc"
|
|
maxLength={500}
|
|
onChange={handleGuildDescChange}
|
|
placeholder="Describe your guild's history, goals, or loreโฆ"
|
|
rows={6}
|
|
value={draft.guildDescription}
|
|
/>
|
|
<span className="character-sheet-hint">
|
|
{draft.guildDescription.length}
|
|
{" / 500"}
|
|
</span>
|
|
</div>
|
|
|
|
{error === null
|
|
? null
|
|
: <p className="character-sheet-error">{error}</p>
|
|
}
|
|
|
|
<div className="character-sheet-actions">
|
|
<button
|
|
className="character-sheet-cancel"
|
|
onClick={handleCancel}
|
|
type="button"
|
|
>
|
|
{"Cancel"}
|
|
</button>
|
|
<button
|
|
className="character-sheet-save"
|
|
disabled={isSaveDisabled}
|
|
onClick={handleSaveClick}
|
|
type="button"
|
|
>
|
|
{saveLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const subtitleParts = [ sheet.characterRace, sheet.characterClass ].filter(
|
|
(part) => {
|
|
return part !== "";
|
|
},
|
|
);
|
|
const subtitle = subtitleParts.join(" ยท ");
|
|
|
|
const completedChapters = state?.story?.completedChapters ?? [];
|
|
|
|
return (
|
|
<section className="panel character-sheet-panel">
|
|
<div className="panel-header">
|
|
<h2>{"๐ Character Sheet"}</h2>
|
|
<div className="character-sheet-header-actions">
|
|
<button
|
|
className="character-sheet-edit-btn"
|
|
onClick={handleShareClick}
|
|
type="button"
|
|
>
|
|
{copied
|
|
? "โ Copied!"
|
|
: "๐ Share"}
|
|
</button>
|
|
<a className="character-sheet-edit-btn" href="/leaderboards">
|
|
{"๐ Boards"}
|
|
</a>
|
|
<button
|
|
className="character-sheet-edit-btn"
|
|
onClick={handleEdit}
|
|
type="button"
|
|
>
|
|
{"โ๏ธ Edit"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="character-sheet-view">
|
|
<div className="character-sheet-section">
|
|
<h3 className="character-sheet-section-title">{"โ๏ธ Character"}</h3>
|
|
<div className="character-sheet-field">
|
|
<span className="character-sheet-field-label">{"Name"}</span>
|
|
<span className="character-sheet-field-value">
|
|
{sheet.characterName === ""
|
|
? <em className="character-sheet-empty">{"Not set"}</em>
|
|
: sheet.characterName
|
|
}
|
|
</span>
|
|
</div>
|
|
<div className="character-sheet-field">
|
|
<span className="character-sheet-field-label">{"Streak"}</span>
|
|
<span className="character-sheet-streak">
|
|
{"๐ฅ "}
|
|
{loginStreak}
|
|
{"-day login streak"}
|
|
</span>
|
|
</div>
|
|
{sheet.activeTitle === ""
|
|
? null
|
|
: <div className="character-sheet-field">
|
|
<span className="character-sheet-field-label">{"Title"}</span>
|
|
<span
|
|
className={"character-sheet-field-value character-sheet-title"}
|
|
>
|
|
{sheet.unlockedTitles.find((title) => {
|
|
return title.id === sheet.activeTitle;
|
|
})?.name ?? sheet.activeTitle}
|
|
</span>
|
|
</div>
|
|
}
|
|
{sheet.pronouns === ""
|
|
? null
|
|
: <div className="character-sheet-field">
|
|
<span className="character-sheet-field-label">{"Pronouns"}</span>
|
|
<span className="character-sheet-field-value">
|
|
{sheet.pronouns}
|
|
</span>
|
|
</div>
|
|
}
|
|
{subtitle === ""
|
|
? null
|
|
: <div className="character-sheet-field">
|
|
<span className="character-sheet-field-label">{"Identity"}</span>
|
|
<span className="character-sheet-field-value">{subtitle}</span>
|
|
</div>
|
|
}
|
|
{sheet.bio === ""
|
|
? null
|
|
: <div className="character-sheet-bio">
|
|
<span className="character-sheet-field-label">{"About"}</span>
|
|
<p className="character-sheet-bio-text">{sheet.bio}</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div className="character-sheet-section">
|
|
<h3 className="character-sheet-section-title">{"๐ก๏ธ Equipment"}</h3>
|
|
{sheet.equippedItems.length > 0
|
|
? <div className="character-sheet-equipment-list">
|
|
{sheet.equippedItems.map((item) => {
|
|
return (
|
|
<div
|
|
className="character-sheet-equipment-item"
|
|
key={item.type}
|
|
>
|
|
<div className="character-sheet-equipment-header">
|
|
<span className="character-sheet-equipment-slot">
|
|
{slotIcons[item.type]}
|
|
</span>
|
|
<span
|
|
className={
|
|
"character-sheet-equipment-name"
|
|
+ ` character-sheet-rarity--${item.rarity}`
|
|
}
|
|
>
|
|
{item.name}
|
|
</span>
|
|
<span
|
|
className={
|
|
"character-sheet-equipment-rarity"
|
|
+ ` character-sheet-rarity--${item.rarity}`
|
|
}
|
|
>
|
|
{item.rarity}
|
|
</span>
|
|
</div>
|
|
<p className="character-sheet-equipment-bonus">
|
|
{formatBonus(item.bonus)}
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
: <p className="character-sheet-empty">
|
|
{"No equipment found. Defeat bosses to earn gear!"}
|
|
</p>
|
|
}
|
|
</div>
|
|
|
|
<div className="character-sheet-section">
|
|
<h3 className="character-sheet-section-title">{"๐ฐ Guild"}</h3>
|
|
{sheet.guildName === ""
|
|
? <p className="character-sheet-empty">
|
|
{"No guild registered yet. Click โ๏ธ Edit to add one!"}
|
|
</p>
|
|
: <>
|
|
<div className="character-sheet-field">
|
|
<span className="character-sheet-field-label">{"Name"}</span>
|
|
<span className="character-sheet-field-value">
|
|
{sheet.guildName}
|
|
</span>
|
|
</div>
|
|
{sheet.guildDescription === ""
|
|
? null
|
|
: <div className="character-sheet-bio">
|
|
<span className="character-sheet-field-label">{"Lore"}</span>
|
|
<p className="character-sheet-bio-text">
|
|
{sheet.guildDescription}
|
|
</p>
|
|
</div>
|
|
}
|
|
</>
|
|
}
|
|
</div>
|
|
|
|
{completedChapters.length === 0
|
|
? null
|
|
: <div className="character-sheet-section">
|
|
<h3 className="character-sheet-section-title">
|
|
{"๐ Story Choices"}
|
|
</h3>
|
|
{completedChapters.map((completion) => {
|
|
const chapter = STORY_CHAPTERS.find((candidate) => {
|
|
return candidate.id === completion.chapterId;
|
|
});
|
|
if (chapter === undefined) {
|
|
return null;
|
|
}
|
|
const choice = chapter.choices.find((candidate) => {
|
|
return candidate.id === completion.choiceId;
|
|
});
|
|
if (choice === undefined) {
|
|
return null;
|
|
}
|
|
const characterName
|
|
= player?.characterName === ""
|
|
|| player?.characterName === undefined
|
|
? "the guild leader"
|
|
: player.characterName;
|
|
const outcome = choice.outcome.replaceAll(
|
|
"{characterName}",
|
|
characterName,
|
|
);
|
|
return (
|
|
<div
|
|
className="character-sheet-story-entry"
|
|
key={completion.chapterId}
|
|
>
|
|
<span className="character-sheet-story-chapter">
|
|
{chapter.title}
|
|
</span>
|
|
<span className="character-sheet-story-choice">
|
|
{choice.label}
|
|
</span>
|
|
<p className="character-sheet-story-outcome">{outcome}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { CharacterSheetPanel };
|