New source found from dndbeyond.com
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user