New source found from dndbeyond.com

This commit is contained in:
2026-07-08 01:00:09 -07:00
parent 9a983a6d7b
commit dcefa0d2f2
2865 changed files with 222467 additions and 49053 deletions
@@ -0,0 +1,416 @@
import clsx from "clsx";
import { orderBy } from "lodash";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import {
PremadeCharacter,
PremadeInfoStatus,
PremadeSourceContract,
} from "@dndbeyond/character-rules-engine";
import Exclamation from "@dndbeyond/fontawesome-cache/svgs/regular/circle-exclamation.svg";
import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import ArrowLeft from "@dndbeyond/fontawesome-cache/svgs/solid/arrow-left.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import { PremadeCharacterCard } from "@dndbeyond/ttui/components/PremadeCharacterCard";
import { Toast } from "@dndbeyond/ttui/components/Toast";
import { Accordion } from "~/components/Accordion";
import { MaxCharactersDialog } from "~/components/MaxCharactersDialog";
import { NoResultsFound } from "~/components/NoResultsFound";
import { characterServiceBaseUrl } from "~/config";
import { ddbBaseUrl } from "~/config";
import { claimCharacter, joinCampaign } from "~/helpers/characterServiceApi";
import { summon } from "~/helpers/summon";
import useSubscriptionTier, {
FREE_TIER,
HERO_TIER,
} from "~/hooks/useSubscriptionTier";
import { SubscriptionBanner } from "~/subApps/listing/components/SubscriptionBanner";
import { NavigationUtils } from "~/tools/js/CharacterBuilder/utils";
import UserRoles from "~/tools/js/Shared/constants/UserRoles";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
import { Search } from "../../components/Search";
import styles from "./styles.module.css";
interface SourceGroup {
name: string;
sourceId: number;
characters: Array<PremadeCharacter>;
displayOrder: number;
}
export const PremadeListing = () => {
const [premadeCharacters, setPremadeCharacters] = useState<
Array<PremadeCharacter>
>([]);
const [elevatedUserCharacters, setElevatedUserCharacters] = useState<
Array<PremadeCharacter>
>([]);
const [showElevatedUserCharacters, setShowElevatedUserCharacters] =
useState(false);
const [showProgressForIndex, setShowProgressForIndex] = useState<
number | null
>(null);
const [showDialog, setShowDialog] = useState(false);
const [showAlert, setShowAlert] = useState(false);
const [query, setQuery] = useState("");
const subscriptionTier = useSubscriptionTier();
const userRoles = useSelector(appEnvSelectors.getUserRoles);
const isElevatedUser =
userRoles?.includes(UserRoles.LOREKEEPER) ||
userRoles?.includes(UserRoles.ADMIN);
const characterSlotInfo = useSelector(appEnvSelectors.getCharacterSlots);
const hasUnlimitedSlots = characterSlotInfo.characterSlotLimit === null;
const hasSlots =
hasUnlimitedSlots ||
(characterSlotInfo.characterSlotLimit !== null &&
characterSlotInfo.characterSlotLimit -
characterSlotInfo.activeCharacterCount >
0);
const params = new URLSearchParams(globalThis.location.search);
const campaignJoinCode = params.get("campaignJoinCode");
const isAssigned = params.get("isAssigned") !== "false";
const campaignId = params.get("campaignId");
const filteredCharacters = premadeCharacters.filter((character) => {
return [
character.characterData.name,
character.characterData.characterLevel.toString(),
`level ${character.characterData.characterLevel}`,
`lvl ${character.characterData.characterLevel}`,
character.characterData.primaryClassName,
character.characterData.speciesName,
character.characterData.characterLevel.toString(),
...character.sources.map((source) => source.sourceName),
]
.join(" ")
.toLowerCase()
.includes(query.toLowerCase());
});
// Create an array of source groups with their associated premade characters
// Order the gorups by source name and display order
const groupPremadesBySource = (characters: Array<PremadeCharacter>) => {
const groups: Array<SourceGroup> = [];
characters.forEach((character) => {
const primarySource: PremadeSourceContract = character.sources.find(
(source) => source.isPrimary
) || {
sourceName: "Not Sourced",
sourceId: 0,
displayOrder: 9999,
isPrimary: true,
};
const index = groups.findIndex(
(group) => group.sourceId === primarySource.sourceId
);
if (index < 0) {
groups.push({
name: primarySource.sourceName,
sourceId: primarySource.sourceId,
displayOrder: primarySource.displayOrder,
characters: [character],
});
} else {
groups[index].characters.push(character);
}
});
return orderBy(groups, ["displayOrder", "name"]);
};
const publishedGroups = groupPremadesBySource(filteredCharacters);
const onClaim = async (characterId: number, key: number) => {
try {
setShowProgressForIndex(key);
const response = await claimCharacter(characterId, isAssigned);
let clonedId: number;
if (response.ok) {
const { id } = await response.json();
clonedId = id;
} else {
if (response.status === 406) {
setShowDialog(true);
setShowProgressForIndex(null);
return;
} else {
throw new Error("Error when cloning premade character!");
}
}
let campaignId: string | null = null;
if (campaignJoinCode) {
const campaignResponse = await joinCampaign(campaignJoinCode, clonedId);
if (campaignResponse.ok) {
const data = await campaignResponse.json();
campaignId = data.campaignId;
} else {
throw new Error(
"Error when attempting to attach cloned character to campaign!"
);
}
}
// Redirect to character page or campaign page
const characterUrl = NavigationUtils.getCharacterSheetUrl(clonedId);
if (isAssigned) {
window.location = characterUrl as string & Location;
} else {
// If, somehow, the campaignId is null, send them to their new character sheet. Otherwise, send them
// back to the campaign page.
window.location = campaignId
? (`${ddbBaseUrl}/campaigns/${campaignId}` as string & Location)
: (characterUrl as string & Location);
}
} catch (e) {
setShowAlert(true);
setShowProgressForIndex(null);
throw e;
} finally {
setShowProgressForIndex(null);
}
};
useEffect(() => {
const fetchCharacters = async () => {
try {
const req = await summon(
`${characterServiceBaseUrl}/premade${
campaignId ? `?campaignId=${campaignId}` : ""
}`
);
if (req.ok) {
const json: { data: Array<PremadeCharacter> } = await req.json();
setPremadeCharacters(
json.data.filter(
(char) => char.publishStatus === PremadeInfoStatus.PUBLISHED
)
);
setElevatedUserCharacters(
json.data.filter(
(char) => char.publishStatus !== PremadeInfoStatus.PUBLISHED
)
);
}
} catch {
throw new Error("Non-OK response from GET published premades");
}
};
fetchCharacters();
}, []);
const renderAccordion = (group: SourceGroup) => {
return (
<Accordion
id={group.sourceId.toString()}
summary={
<h2 className={styles.accordionSummary} id={group.name}>
{group.name}
</h2>
}
key={group.sourceId}
className={styles.accordion}
variant="text"
forceShow={true}
resetOpen={premadeCharacters.length !== filteredCharacters.length}
>
<div className={styles.premadesContainer}>
<div className={styles.grid}>
{orderBy(group.characters, (char) => char.characterData.name).map(
(
{
characterData: {
name,
characterLevel,
speciesName,
primaryClassName,
},
definition: { longDescription, imageUrl, imageAltText },
characterId,
publishStatus,
},
i
) => (
<PremadeCharacterCard
key={i}
data-testid="premade-character-card"
characterName={name}
characterLevel={characterLevel}
characterId={characterId}
className={clsx([
styles.premadeCard,
styles[publishStatus.toLowerCase()],
!hasSlots && styles.hideClaim,
])}
speciesName={speciesName}
primaryClassName={primaryClassName}
longDescription={longDescription || ""}
imageUrl={imageUrl || ""}
imageAlt={
imageAltText || `${speciesName} ${primaryClassName}`
}
createButtonText={
publishStatus === PremadeInfoStatus.PUBLISHED
? `Create${campaignJoinCode ? " and Join" : ""}`
: `Status: ${publishStatus}`
}
showProgress={
publishStatus === PremadeInfoStatus.PUBLISHED
? showProgressForIndex === i
: false
}
previewLink={`${ddbBaseUrl}/characters/${characterId}${
campaignJoinCode
? `?campaignJoinCode=${campaignJoinCode}`
: ""
}`}
onClaim={
publishStatus === PremadeInfoStatus.PUBLISHED
? () => onClaim(characterId, i)
: () => {}
}
disableClaim={
publishStatus === PremadeInfoStatus.PUBLISHED
? !hasSlots
: true
}
/>
)
)}
</div>
</div>
</Accordion>
);
};
return (
<div className={styles.premadeListing} data-pagename="premade-listing">
{/* Fastly: We're going to try to make this work in the character app, so we can keep the URL param retrieval logic below */}
{(campaignJoinCode || campaignId) && (
<Button
className={styles.backButton}
color="info"
href={`${ddbBaseUrl}/campaigns/${
campaignJoinCode ? `join/${campaignJoinCode}` : campaignId
}`}
>
<ArrowLeft />
Back to{campaignJoinCode ? " Join" : ""} Campaign
</Button>
)}
<h1 className={styles.pageTitle}>Premade Characters</h1>
<p className={styles.pageDescription}>
Not sure how to create your first character? Our premade character
sheets let you skip the complicated stuff and start playing in minutes.
</p>
{subscriptionTier === FREE_TIER && !hasSlots && (
<SubscriptionBanner
iconType="hero"
text="Your party is full. Unlock unlimited character creation."
buttonLabel="Subscribe Now!"
onClick={() =>
(window.location.href = `${ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
{/* 2025: This will not be seen, but including for consistency between this page and the Characters Grid component. */}
{subscriptionTier.includes(HERO_TIER) && !hasSlots && (
<SubscriptionBanner
iconType="master"
text="Unlock your full potential and EXCLUSIVE perks."
buttonLabel="Upgrade Now!"
onClick={() =>
(window.location.href = `${ddbBaseUrl}/store/subscribe#plans`)
}
/>
)}
<Search
className={styles.search}
value={query}
onChange={(e) => {
setQuery(e.target.value);
}}
placeholder="Search by Name, Level, Class, Species, or Source..."
/>
{/* Published Premades */}
{publishedGroups.length > 0 ? (
publishedGroups.map((group) => {
return renderAccordion(group);
})
) : (
<NoResultsFound message="No premade characters found." />
)}
{/* Unpublished, admin only Premades */}
{isElevatedUser && (
<div className={styles.elevatedUserContainer}>
<Button
className={styles.elevatedUserButton}
size="x-small"
color="secondary"
onClick={() =>
setShowElevatedUserCharacters(!showElevatedUserCharacters)
}
>
{showElevatedUserCharacters ? "Hide" : "View"} All Draft and
Archived Premade Characters
</Button>
{showElevatedUserCharacters &&
groupPremadesBySource(elevatedUserCharacters).map((group) => {
return renderAccordion(group);
})}
</div>
)}
{/* We know at the top level if someone doesn't have enough slots and displays a message at the top of the page and have the create character buttons disabled. The dialog here provides extra context if somehow the user tries to create a character without enough slots. */}
{showDialog && (
<MaxCharactersDialog
open={showDialog}
onClose={() => setShowDialog(false)}
useMyCharactersLink
/>
)}
<Toast
open={showAlert}
onClose={() => setShowAlert(false)}
autoHideDuration={10000}
align="right"
className={styles.toast}
>
<div className={styles.toastContent}>
<Exclamation />
<p>
An unexpected error occured.
{campaignJoinCode ? (
<>
{" "}
Try adding from the{" "}
<a
href={`${ddbBaseUrl}/campaigns/${campaignJoinCode}`}
className={styles.dialogLink}
>
Campaign Page
</a>{" "}
again.
</>
) : (
""
)}
</p>
</div>
<Button variant="tool">
<XMark />
</Button>
</Toast>
</div>
);
};