Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,409 @@
import React, { useCallback, useEffect, useState } from "react";
import ArrowDown from "@dndbeyond/fontawesome-cache/svgs/regular/arrow-down-to-line.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import config from "~/config";
import { UserPreferenceProvider } from "~/tools/js/smartComponents/UserPreference";
import { logUnlockSubscribeClicked } from "../../../../helpers/analytics";
import { byProp, createSortValue } from "../../../../helpers/sortUtils";
import useLocalStorage from "../../../../hooks/useLocalStorage";
import useSubscriptionTier, {
FREE_TIER,
HERO_TIER,
} from "../../../../hooks/useSubscriptionTier";
import useUserId from "../../../../hooks/useUserId";
import {
getId,
getSearchableTerms,
getStatus,
SortByPropMap,
} from "../../../../state/selectors/characterUtils";
import {
CharacterData,
CharacterStatusEnum,
SortOrderEnum,
SortTypeEnum,
} from "../../../../types";
import { ApiStatusIndicator } from "../ApiStatusIndicator";
import { CharacterCard } from "../CharacterCard";
import { SubscriptionBanner } from "../SubscriptionBanner";
import { SearchSort } from "./SearchSort";
import { SecondaryHeader } from "./SecondaryHeader";
import styles from "./styles.module.css";
const overrideStatusForUnlock = ({
character,
characterIdsToUnlock,
hasLockedCharacters,
}) => {
if (!hasLockedCharacters) {
return getStatus(character);
}
return characterIdsToUnlock.includes(getId(character))
? CharacterStatusEnum.Active
: CharacterStatusEnum.Locked;
};
export interface SortState {
sortBy: SortTypeEnum;
sortOrder: SortOrderEnum;
}
export interface CharacterGridProps {
className?: string;
isLoading: boolean;
characters: Array<CharacterData>;
loadingError?: Error | null;
reload: () => void;
hasLockedCharacters: boolean;
maxCharacterSlotsAllowed: number | null;
hasMaxCharacters: boolean;
}
export const CharacterGrid: React.FC<CharacterGridProps> = ({
className = "",
isLoading,
characters,
loadingError,
reload,
hasLockedCharacters,
maxCharacterSlotsAllowed,
hasMaxCharacters,
}) => {
const userId = useUserId();
const subscriptionTier = useSubscriptionTier();
const [sortPreference, setSortPreference] = useLocalStorage<string>(
`MY_CHARACTERS_SORT_PREFERENCE:${userId}`,
createSortValue(SortTypeEnum.Created, SortOrderEnum.Ascending)
);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortState>({
sortBy: SortTypeEnum.Created,
sortOrder: SortOrderEnum.Ascending,
});
const [isMaxCharacterMessageOpen, setIsMaxCharacterMessageOpen] =
useState(false);
const [characterIdsToUnlock, setCharacterIdsToUnlock] = useState<
Array<number>
>([]);
const canUnlockMore: boolean = maxCharacterSlotsAllowed
? characterIdsToUnlock.length < maxCharacterSlotsAllowed
: true;
useEffect(() => {
// Don't try to set the sort if there is no preference or if the listing is currently loading results
if (!sortPreference || isLoading) {
return;
}
const [sortPreferenceBy, sortPreferenceOrder] = sortPreference.split("-");
// If they are false then use the default
if (!sortPreferenceBy || !sortPreferenceOrder) {
return;
}
// Prevent an infinite loop of reloading once the sort preference has actually been applied
if (
sortPreferenceBy === sort.sortBy &&
sortPreferenceOrder === sort.sortOrder
) {
return;
}
setSort({
sortBy: sortPreferenceBy as SortTypeEnum,
sortOrder: sortPreferenceOrder as SortOrderEnum,
});
}, [isLoading, sortPreference, sort]);
useEffect(() => {
if (hasLockedCharacters && characterIdsToUnlock.length === 0) {
setCharacterIdsToUnlock(
characters
.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
)
.map(getId)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasLockedCharacters, characters]);
const unlockCharacter = useCallback(
(character: CharacterData) => {
if (!characterIdsToUnlock.includes(getId(character))) {
setCharacterIdsToUnlock([...characterIdsToUnlock, getId(character)]);
}
},
[characterIdsToUnlock]
);
const lockCharacter = useCallback(
(character: CharacterData) => {
if (characterIdsToUnlock.includes(getId(character))) {
setCharacterIdsToUnlock(
characterIdsToUnlock.filter((id) => id !== getId(character))
);
}
},
[characterIdsToUnlock]
);
let filteredCharacters = characters.filter((character) => {
return getSearchableTerms(character)
.join(" ")
.toLowerCase()
.includes(search.toLowerCase());
});
const sortByPropSelector = SortByPropMap[sort.sortBy];
if (sortByPropSelector) {
filteredCharacters = filteredCharacters.sort(byProp(sortByPropSelector));
if (sort.sortOrder === SortOrderEnum.Descending) {
filteredCharacters.reverse();
}
}
let lockedCharacters: Array<CharacterData> = [];
if (!hasLockedCharacters && canUnlockMore) {
lockedCharacters = filteredCharacters.filter(
(character) => getStatus(character) === CharacterStatusEnum.Locked
);
filteredCharacters = filteredCharacters.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
);
}
const characterCount: number = characters.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
).length;
const overCharacterLimit: boolean =
maxCharacterSlotsAllowed !== null &&
characterCount >= maxCharacterSlotsAllowed;
const createCharacterHref = !hasMaxCharacters
? `${config.basePathname}/builder`
: undefined;
const createCharacterOnClick = hasMaxCharacters
? () => setIsMaxCharacterMessageOpen(true)
: undefined;
return (
<UserPreferenceProvider>
<div
className={[className, "ddb-characters-listing"]
.filter(Boolean)
.join(" ")}
>
<div className="ddb-characters-listing-header">
{/*
The CTA component will be shown depending on some conditions:
- If the user doesn't have a subscription, and has 6 characters, show the Hero CTA
- If the user has a Hero subscription, show the Master CTA
Remember that the Images for the CTA's are served by Waterdeep (then, the images could be shown as broken)
*/}
{subscriptionTier === FREE_TIER &&
characterCount === maxCharacterSlotsAllowed && (
<SubscriptionBanner
iconType="hero"
text="Your party is full. Unlock unlimited character creation."
buttonLabel="Subscribe Now!"
onClick={() =>
(window.location.href = `${config.ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
{subscriptionTier.includes(HERO_TIER) &&
characterCount === maxCharacterSlotsAllowed && (
<SubscriptionBanner
iconType="master"
text="Unlock your full potential and EXCLUSIVE perks."
buttonLabel="Upgrade Now!"
onClick={() =>
(window.location.href = `${config.ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
<header className={styles.header}>
<h1 className={styles.h1}>My Characters</h1>
<Button
className={styles.button}
href={createCharacterHref}
onClick={createCharacterOnClick}
>
Create A Character
</Button>
</header>
<div className={styles.subheader}>
<p className={styles.slots}>
Slots:
<span className={styles.count}>
{characterCount}/
{/* TODO: Come back and use the subscription role to figure this out? This number won't be the same as our DB max int */}
{maxCharacterSlotsAllowed === null ||
maxCharacterSlotsAllowed === 2147483647
? "Unlimited"
: `${maxCharacterSlotsAllowed} Used`}
</span>
</p>
<div className={styles.pdfLinkWrapper}>
<a
href="https://media.dndbeyond.com/compendium-images/free-rules/ph/character-sheet.pdf"
target="_blank"
className={styles.pdfLink}
>
<ArrowDown className={styles.pdfLinkSvg}></ArrowDown>
Download a blank character sheet
</a>
</div>
</div>
<div className="ddb-characters-listing-header-secondary">
<SecondaryHeader
hasLockedCharacters={hasLockedCharacters}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
characterCount={characterCount}
characters={characters}
characterIdsToUnlock={characterIdsToUnlock}
subscriptionTier={subscriptionTier}
/>
</div>
</div>
<SearchSort
search={search}
onSearch={setSearch}
sort={sort}
onSort={setSort}
sortPreference={sortPreference}
onSortPreference={setSortPreference}
/>
<div className="ddb-characters-listing-body j-characters-listing__content">
<div className="listing-container listing-container-ul RPGCharacter-listing">
<div className="listing-body">
<ul className="listing listing-rpgcharacter rpgcharacter-listing">
{filteredCharacters.map((character) => (
<CharacterCard
key={`listing-item_${getId(character)}`}
character={{
...character,
status: overrideStatusForUnlock({
character,
characterIdsToUnlock,
hasLockedCharacters,
}),
}}
hasLockedCharacters={hasLockedCharacters}
overCharacterLimit={overCharacterLimit}
reloadListing={reload}
lockCharacter={lockCharacter}
unlockCharacter={unlockCharacter}
canUnlockMore={canUnlockMore}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
currentCharacterCount={characterCount}
/>
))}
</ul>
{filteredCharacters.length === 0 && (
<>
{!!search ? (
<>
<p className={styles.noResultsTitle}>
It looks like we failed our investigation check.
</p>
<p className={styles.noResultsText}>
Cast guidance on us by refining your search, and
we&apos;ll try again!
</p>
</>
) : (
<>
<p className={styles.noResultsTitle}>
Looks like you haven&apos;t created a character yet.
</p>
<p className={styles.noResultsText}>
Start your adventure by creating a character!
</p>
</>
)}
</>
)}
</div>
{lockedCharacters.length > 0 && (
<>
<h2 className={styles.deactivatedHeading}>
Deactivated Characters
</h2>
<p className={styles.deactivatedText}>
Activate characters by freeing up a slot, or add slots with a
<Button
className={styles.deactivatedButton}
variant="text"
size="small"
href={`${config.ddbBaseUrl}/subscribe`}
onClick={() => logUnlockSubscribeClicked()}
>
D&amp;D Beyond subscription.
</Button>
</p>
<div className="listing-body">
<ul className="listing listing-rpgcharacter rpgcharacter-listing">
{lockedCharacters.map((character) => (
<CharacterCard
key={`listing-item_${getId(character)}`}
character={{
...character,
status: overrideStatusForUnlock({
character,
characterIdsToUnlock,
hasLockedCharacters,
}),
}} // TODO can this logic go in the utils?
hasLockedCharacters={hasLockedCharacters}
overCharacterLimit={overCharacterLimit}
reloadListing={reload}
lockCharacter={lockCharacter}
unlockCharacter={unlockCharacter}
canUnlockMore={canUnlockMore}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
currentCharacterCount={characterCount}
/>
))}
</ul>
</div>
</>
)}
</div>
</div>
<div className="ddb-characters-listing__loading-indicator-wrapper">
<ApiStatusIndicator
isLoading={isLoading}
error={loadingError || undefined}
loadingMessage={"Loading Characters"}
errorMessage={
loadingError ? loadingError.message : "Error Loading Characters"
}
></ApiStatusIndicator>
</div>
<div className="ddbcl-my-characters-listing__version">
My Characters Version: v{config.version}
</div>
<MaxCharactersDialog
open={isMaxCharacterMessageOpen}
onClose={() => setIsMaxCharacterMessageOpen(false)}
/>
</div>
</UserPreferenceProvider>
);
};
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logUnlockFinishUnlockingCancelled,
logUnlockFinishUnlockingClicked,
logUnlockFinishUnlockingConfirmed,
} from "../../../../../helpers/analytics";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
import { UnlockConfirmation } from "../UnlockConfirmation";
import styles from "./styles.module.css";
interface FinalizeUnlockProps {
charactersToUnlock: Array<CharacterData>;
maxCharacterSlotsAllowed: number | null;
finalizeUnlock: () => void;
disabled: boolean;
}
export const FinalizeUnlock: React.FC<FinalizeUnlockProps> = ({
charactersToUnlock,
finalizeUnlock,
disabled,
maxCharacterSlotsAllowed,
}) => {
const [isOpen, setIsOpen] = useState(false);
const charactersToUnlockCount = charactersToUnlock.length;
const handleClose = () => {
logUnlockFinishUnlockingCancelled(
charactersToUnlockCount,
maxCharacterSlotsAllowed
);
setIsOpen(false);
};
const handleConfirm = () => {
finalizeUnlock();
logUnlockFinishUnlockingConfirmed(
charactersToUnlockCount,
maxCharacterSlotsAllowed
);
setIsOpen(false);
};
const handleClick = () => {
setIsOpen(true);
logUnlockFinishUnlockingClicked(
charactersToUnlockCount,
maxCharacterSlotsAllowed
);
};
return (
<>
<ConfirmationModal
onClose={handleClose}
isOpen={isOpen}
onConfirm={handleConfirm}
message={
<UnlockConfirmation
charactersToUnlock={charactersToUnlock}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
message={
<p className={styles.text}>
Other characters will remain deactivated until you free up a
slot or add slots with a D&D Beyond subscription.
</p>
}
/>
}
title="Activate these characters?"
/>
<Button size="large" disabled={disabled} onClick={handleClick}>
Continue
</Button>
</>
);
};
@@ -0,0 +1,160 @@
import {
ChangeEvent,
Dispatch,
FC,
SetStateAction,
useRef,
useState,
} from "react";
import Gear from "@dndbeyond/fontawesome-cache/svgs/regular/gear.svg";
import CircleXMark from "@dndbeyond/fontawesome-cache/svgs/solid/circle-xmark.svg";
import MagnifyingGlass from "@dndbeyond/fontawesome-cache/svgs/solid/magnifying-glass.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import { Select } from "@dndbeyond/ttui/components/Select";
import PreferenceUpdateLocation from "~/tools/js/Shared/constants/PreferenceUpdateLocation";
import CharacterSettingsModal from "~/tools/js/smartComponents/CharacterSettingsModal";
import { SortOrderEnum, SortTypeEnum } from "~/types";
import { logListingSortChanged } from "../../../../../helpers/analytics";
import { createSortValue } from "../../../../../helpers/sortUtils";
import { SortState } from "../CharacterGrid";
import styles from "./styles.module.css";
const sortOptions = [
{
value: createSortValue(SortTypeEnum.Created, SortOrderEnum.Descending),
label: "Created: Newest",
},
{
value: createSortValue(SortTypeEnum.Created, SortOrderEnum.Ascending),
label: "Created: Oldest",
},
{
value: createSortValue(SortTypeEnum.Name, SortOrderEnum.Ascending),
label: "Name: A to Z",
},
{
value: createSortValue(SortTypeEnum.Name, SortOrderEnum.Descending),
label: "Name: Z to A",
},
{
value: createSortValue(SortTypeEnum.Level, SortOrderEnum.Ascending),
label: "Level: Low to High",
},
{
value: createSortValue(SortTypeEnum.Level, SortOrderEnum.Descending),
label: "Level: High to Low",
},
{
value: createSortValue(SortTypeEnum.Modified, SortOrderEnum.Descending),
label: "Modified: Latest",
},
{
value: createSortValue(SortTypeEnum.Modified, SortOrderEnum.Ascending),
label: "Modified: Oldest",
},
];
interface SearchSortProps {
search: string;
onSearch: Dispatch<SetStateAction<string>>;
sort: SortState;
onSort: Dispatch<SetStateAction<SortState>>;
sortPreference: string;
onSortPreference: Dispatch<SetStateAction<string>>;
}
export const SearchSort: FC<SearchSortProps> = ({
search,
onSearch,
sort,
onSort,
sortPreference,
onSortPreference,
}) => {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const handleSearchClick = () => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
};
const handleSort = (e: ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const [sortBy, sortOrder] = value.split("-");
onSort({
sortBy: sortBy as SortTypeEnum,
sortOrder: sortOrder as SortOrderEnum,
});
onSortPreference(value);
logListingSortChanged(value);
};
const getSortValue =
sortOptions.find(
(opt) => opt.value === createSortValue(sort.sortBy, sort.sortOrder)
) || sortOptions[0];
return (
<div className={styles.searchSort}>
<div className={styles.search} onClick={handleSearchClick}>
<MagnifyingGlass />
<label className={styles.searchLabel} htmlFor="search">
Search
</label>
<input
className={styles.searchInput}
type="search"
id="search"
placeholder={"Search by Name, Level, Class, Species, or Campaign"}
value={search}
onChange={(e) => onSearch(e.target.value)}
ref={searchInputRef}
/>
{search && (
<button
className={styles.clearButton}
onClick={(e) => onSearch("")}
aria-label="Clear search terms"
>
<CircleXMark className={styles.clearIcon} />
</button>
)}
</div>
<Select
className={styles.sort}
placeholder="Select a movement"
name="sort"
label="Sort By"
value={getSortValue.label}
options={sortOptions}
onChange={handleSort}
/>
<>
<Button
className={styles.settingsButton}
variant="outline"
size="small"
onClick={() => setShowSettingsModal(true)}
>
<Gear />
<span className={styles.settingsButtonText}>Settings</span>
</Button>
<CharacterSettingsModal
open={showSettingsModal}
updateLocation={PreferenceUpdateLocation.CharacterListing}
handleClose={() => setShowSettingsModal(false)}
/>
</>
</div>
);
};
@@ -0,0 +1,127 @@
import { toWords } from "number-to-words";
import { FC, useEffect, useRef } from "react";
import SpinnerThird from "@dndbeyond/fontawesome-cache/svgs/regular/spinner-third.svg";
import useApiCall from "~/hooks/useApiCall";
import config from "../../../../../config";
import { logUnlockSubscribeClicked } from "../../../../../helpers/analytics";
import { unlockCharacters } from "../../../../../helpers/characterServiceApi";
import { getId } from "../../../../../state/selectors/characterUtils";
import { CharacterGridProps } from "../CharacterGrid";
import { FinalizeUnlock } from "../FinalizeUnlock";
import styles from "./styles.module.css";
interface SecondaryHeaderProps
extends Pick<
CharacterGridProps,
"maxCharacterSlotsAllowed" | "characters" | "hasLockedCharacters"
> {
characterCount: number;
subscriptionTier: string;
characterIdsToUnlock: Array<number>;
}
export const SecondaryHeader: FC<SecondaryHeaderProps> = ({
hasLockedCharacters,
maxCharacterSlotsAllowed,
characterCount,
subscriptionTier,
characters,
characterIdsToUnlock,
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const [
finalizeUnlock,
isFinalizingUnlock,
// Unlock state depends on context state passed by Waterdeep on the root element so we have to do a full page reload after a successful unlock.
] = useApiCall(unlockCharacters, () => window.location.reload());
useEffect(() => {
if (isFinalizingUnlock) {
// Hacky workaround since showModal() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.showModal();
} else {
// Hacky workaround since close() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.close();
}
}, [isFinalizingUnlock]);
if (hasLockedCharacters) {
return (
<>
{/* Loading Dialog */}
<dialog
className={styles.dialog}
ref={dialogRef}
onClick={(evt) => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
}}
>
<SpinnerThird className={styles.spinner} />
</dialog>
{/* Max Characters Alert */}
<div className={styles.alert}>
<p className={styles.alertTitle}>Character Slots Exceeded</p>
Your current D&amp;D Beyond Membership is:{" "}
<strong>
{subscriptionTier.toUpperCase()} ({maxCharacterSlotsAllowed} slots).{" "}
</strong>
To continue, select up to{" "}
<strong>
{maxCharacterSlotsAllowed
? `${toWords(
maxCharacterSlotsAllowed
)} (${maxCharacterSlotsAllowed}) `
: "unlimited "}
</strong>
characters to activate. Your other characters will be deactivated
until you restore them by deleting an active character to free up a
slot, or by adding more slots with a{` `}
<a
href={`${config.ddbBaseUrl}/subscribe`}
onClick={() => logUnlockSubscribeClicked()}
>
D&amp;D Beyond Subscription
</a>
</div>
{/* Bottom Unlock Bar */}
<div className={styles.bottomBar}>
<div className={styles.bottomContainer}>
<p className={styles.bottomText}>
Selected:{" "}
<strong>
{characterIdsToUnlock.length} / {maxCharacterSlotsAllowed}
</strong>
</p>
<FinalizeUnlock
charactersToUnlock={characters.filter((character) =>
characterIdsToUnlock.includes(getId(character))
)}
finalizeUnlock={() => finalizeUnlock(characterIdsToUnlock)}
disabled={isFinalizingUnlock}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
/>
</div>
</div>
</>
);
}
if (characterCount === 0) {
return (
<div className="ddb-characters-listing-count-active">
Character slots allow players to create characters. Once created, all of
your characters will appear in the list below.
</div>
);
}
return null;
};
@@ -0,0 +1,40 @@
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
import { getName } from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import styles from "./styles.module.css";
export interface UnlockConfirmationProps {
charactersToUnlock: Array<CharacterData>;
maxCharacterSlotsAllowed: number | null;
message?: React.ReactNode;
}
export const UnlockConfirmation: React.FC<UnlockConfirmationProps> = ({
charactersToUnlock,
maxCharacterSlotsAllowed,
message = null,
}) => {
// TODO: Character Slots update copy
if (charactersToUnlock.length === 0)
return (
<p className={styles.unlockText}>
Are you sure you want to unlock no characters? All of your characters
will be permanently locked until a new subscription is applied to your
account, but you will be able to create and access up to{" "}
{maxCharacterSlotsAllowed} new characters. Locked characters do not
count towards your character limit.
</p>
);
return (
<div className={styles.pills}>
{charactersToUnlock.map(getName).map((characterName) => (
<LabelChip className={styles.pill} key={characterName}>
{characterName}
</LabelChip>
))}
{message && message}
</div>
);
};