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,42 @@
import React from "react";
import CircleExclamation from "@dndbeyond/fontawesome-cache/svgs/solid/circle-exclamation.svg";
import SpinnerThird from "@dndbeyond/fontawesome-cache/svgs/solid/spinner-third.svg";
import styles from "./styles.module.css";
export interface ApiStatusIndicatorProps {
isLoading: boolean;
error?: Error;
loadingMessage: string;
errorMessage: string;
}
export const ApiStatusIndicator: React.FC<ApiStatusIndicatorProps> = ({
isLoading,
error,
loadingMessage,
errorMessage,
}) => {
if (!isLoading && !error) {
return null;
}
return (
<div className={styles.indicator}>
{isLoading && (
<>
<SpinnerThird className={styles.spinner} />
<span>{loadingMessage}</span>
</>
)}
{error && (
<>
<CircleExclamation className={styles.error} />
<span>{errorMessage}</span>
</>
)}
</div>
);
};
export default ApiStatusIndicator;
@@ -0,0 +1,50 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import { CharacterData } from "~/types";
import { logUnlockCharacterUnlocked } from "../../../../../helpers/analytics";
import { UnlockConfirmation } from "../../CharacterGrid/UnlockConfirmation";
import { ConfirmationModal } from "../../ConfirmationModal";
interface ActivateButtonProps {
character: CharacterData;
activateCharacter: (character: CharacterData) => void;
maxCharacterSlotsAllowed: number | null;
}
export const ActivateButton: React.FC<ActivateButtonProps> = ({
character,
activateCharacter,
maxCharacterSlotsAllowed,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
onConfirm={() => {
activateCharacter(character);
logUnlockCharacterUnlocked();
setIsOpen(false);
}}
title="Activate these characters?"
confirmText="Activate"
message={
<UnlockConfirmation
charactersToUnlock={[character]}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
/>
}
/>
<Button size="small" variant="text" onClick={() => setIsOpen(true)}>
Activate
</Button>
</div>
);
};
@@ -0,0 +1,239 @@
import clsx from "clsx";
import { FC } from "react";
import Lock from "@dndbeyond/fontawesome-cache/svgs/regular/lock.svg";
import Check from "@dndbeyond/fontawesome-cache/svgs/sharp-regular/check.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import useApiCall from "~/hooks/useApiCall";
import {
logCharacterCampaignClicked,
logUnlockCharacterLocked,
logUnlockCharacterUnlocked,
} from "../../../../helpers/analytics";
import {
copyCharacter as copyCharacterApi,
deleteCharacter as deleteCharacterApi,
leaveCampaign as leaveCampaignApi,
activateCharacter as activateCharacterApi,
} from "../../../../helpers/characterServiceApi";
import {
getAvatarUrl,
getBackdropUrl,
getCampaignDetailsLink,
getCampaignName,
getCoverImageUrl,
getDetailsLink,
getIsAssigned,
getName,
getSecondaryInfo,
getStatus,
getStatusSlug,
isInCampaign,
} from "../../../../state/selectors/characterUtils";
import { CharacterData, CharacterStatusEnum } from "../../../../types";
import { ApiStatusIndicator } from "../ApiStatusIndicator";
import "./CharacterCard.scss";
import { CharacterCardLinks } from "./CharacterCardLinks";
import { LeaveCampaignButton } from "./LeaveCampaignButton";
import styles from "./styles.module.css";
export interface CharacterCardProps {
character: CharacterData;
reloadListing: () => void;
lockCharacter: (character: CharacterData) => void;
unlockCharacter: (character: CharacterData) => void;
canUnlockMore: boolean;
hasLockedCharacters: boolean;
overCharacterLimit: boolean;
maxCharacterSlotsAllowed: number | null;
currentCharacterCount: number;
}
export const CharacterCard: FC<CharacterCardProps> = ({
character,
reloadListing,
lockCharacter,
unlockCharacter,
canUnlockMore,
hasLockedCharacters,
overCharacterLimit,
maxCharacterSlotsAllowed,
currentCharacterCount,
}) => {
const [copyCharacter, isCopyingCharacter, errorCopyingCharacter] = useApiCall(
copyCharacterApi,
reloadListing
);
const [deleteCharacter, isDeletingCharacter, errorDeletingCharacter] =
useApiCall(deleteCharacterApi, reloadListing);
const [leaveCampaign, isLeavingCampaign, errorLeavingCampaign] = useApiCall(
leaveCampaignApi,
reloadListing
);
const [activateCharacter, isActivatingCharacter, errorActivatingCharacter] =
useApiCall(activateCharacterApi, reloadListing);
const isLocked = getStatus(character) === CharacterStatusEnum.Locked;
const handleToggleLock = () => {
if (isLocked) {
unlockCharacter(character);
logUnlockCharacterUnlocked();
} else {
lockCharacter(character);
logUnlockCharacterLocked();
}
};
return (
<li className="ddb-campaigns-character-card-wrapper j-characters-listing__item">
<div
className={clsx([`status-${getStatusSlug(character)}`, styles.card])}
>
<div className="ddb-campaigns-character-card-header">
{getBackdropUrl(character) ? (
<div
className="ddb-campaigns-character-card-header-cover-image ddb-campaigns-character-card-header-cover-image-user-backdrop"
style={{
backgroundImage: `url(${getBackdropUrl(character)})`,
}}
/>
) : (
<div
className="ddb-campaigns-character-card-header-cover-image"
style={{
backgroundImage: `url(${getCoverImageUrl(character)})`,
}}
/>
)}
<div className="ddb-campaigns-character-card-header-upper">
{!hasLockedCharacters &&
getStatus(character) === CharacterStatusEnum.Active && (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={getDetailsLink(character)}
className="ddb-campaigns-character-card-header-upper-details-link"
// There already is a view link - this is just making the top area clickable so it shouldn't be tabbed to or announced
tabIndex={-1}
aria-hidden={true}
/>
)}
<div className="ddb-campaigns-character-card-header-upper-portrait">
{getAvatarUrl(character) ? (
<div
className="image user-selected-avatar"
style={{
backgroundImage: `url(${getAvatarUrl(character)})`,
}}
/>
) : (
<div className="image default-character-avatar" />
)}
</div>
<div className="ddb-campaigns-character-card-header-upper-character-info">
<h2 className={styles.name}>{getName(character)}</h2>
<div className="ddb-campaigns-character-card-header-upper-character-info-secondary">
{getSecondaryInfo(character)}
</div>
</div>
</div>
</div>
{hasLockedCharacters && (
<Button
className={clsx([styles.cornerButton, isLocked && styles.locked])}
color="primary"
aria-label={isLocked ? "Click to unlock" : "Click to lock"}
onClick={handleToggleLock}
disabled={isLocked && !canUnlockMore}
>
{isLocked ? (
<Lock className={styles.lockIcon} />
) : (
<Check className={styles.checkIcon} />
)}
</Button>
)}
{isInCampaign(character) && (
<div className="ddb-campaigns-character-card-campaign-links">
<div className="ddb-campaigns-character-card-campaign-links-campaign">
<strong>Campaign:</strong>{" "}
{!hasLockedCharacters ? (
<a
className="ddb-campaigns-character-card-campaign-links-campaign-link"
href={getCampaignDetailsLink(character)}
onClick={() => logCharacterCampaignClicked()}
>
{getCampaignName(character)}
</a>
) : (
<span>{getCampaignName(character)}</span>
)}
{!getIsAssigned(character) && " (Unassigned)"}
</div>
<div className="ddb-campaigns-character-card-campaign-links-actions">
{!hasLockedCharacters && (
<LeaveCampaignButton
character={character}
leaveCampaign={() => leaveCampaign(character)}
/>
)}
</div>
</div>
)}
<div className="ddb-campaigns-character-card-footer">
<div
className={[
"ddb-campaigns-character-card-footer-links",
hasLockedCharacters && "is-unlocking",
]
.filter(Boolean)
.join(" ")}
>
<CharacterCardLinks
character={character}
hasLockedCharacters={hasLockedCharacters}
overCharacterLimit={overCharacterLimit}
copyCharacter={() => copyCharacter(character)}
deleteCharacter={() => deleteCharacter(character)}
lockCharacter={lockCharacter}
unlockCharacter={unlockCharacter}
canUnlockMore={canUnlockMore}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
activateCharacter={() => activateCharacter(character)}
currentCharacterCount={currentCharacterCount}
/>
</div>
<ApiStatusIndicator
isLoading={isCopyingCharacter}
error={errorCopyingCharacter}
loadingMessage={`Copying ${getName(character)}`}
errorMessage={`Error copying ${getName(character)}`}
/>
<ApiStatusIndicator
isLoading={isDeletingCharacter}
error={errorDeletingCharacter}
loadingMessage={`Deleting ${getName(character)}`}
errorMessage={`Error deleting ${getName(character)}`}
/>
<ApiStatusIndicator
isLoading={isActivatingCharacter}
error={errorActivatingCharacter}
loadingMessage={`Activating ${getName(character)}`}
errorMessage={`Error activating ${getName(character)}`}
/>
<ApiStatusIndicator
isLoading={isLeavingCampaign}
error={errorLeavingCampaign}
loadingMessage={`Leaving ${getCampaignName(character)}`}
errorMessage={`Error leaving ${getCampaignName(character)}`}
/>
</div>
</div>
</li>
);
};
@@ -0,0 +1,116 @@
import { Button } from "@dndbeyond/ttui/components/Button";
import { CharacterStatusEnum } from "~/types";
import {
logCharacterEditClicked,
logCharacterViewClicked,
logUnlockCharacterLocked,
logUnlockCharacterUnlocked,
} from "../../../../../helpers/analytics";
import {
getDetailsLink,
getEditLink,
getStatus,
} from "../../../../../state/selectors/characterUtils";
import { ActivateButton } from "../ActivateButton";
import type { CharacterCardProps } from "../CharacterCard";
import { CopyButton } from "../CopyButton";
import { DeleteButton } from "../DeleteButton";
import styles from "./styles.module.css";
interface CharacterCardLinksProps
extends Omit<CharacterCardProps, "reloadListing"> {
copyCharacter: () => void;
deleteCharacter: () => void;
activateCharacter: () => void;
}
export const CharacterCardLinks: React.FC<CharacterCardLinksProps> = ({
character,
hasLockedCharacters,
overCharacterLimit,
copyCharacter,
deleteCharacter,
lockCharacter,
unlockCharacter,
canUnlockMore,
maxCharacterSlotsAllowed,
activateCharacter,
currentCharacterCount,
}) => {
const isLocked = getStatus(character) === CharacterStatusEnum.Locked;
const showActivateButton =
maxCharacterSlotsAllowed === null ||
currentCharacterCount < maxCharacterSlotsAllowed;
const handleToggleLock = () => {
if (isLocked) {
unlockCharacter(character);
logUnlockCharacterUnlocked();
} else {
lockCharacter(character);
logUnlockCharacterLocked();
}
};
if (hasLockedCharacters)
return (
<>
<Button
size="small"
variant={isLocked ? "outline" : "solid"}
onClick={handleToggleLock}
disabled={isLocked && !canUnlockMore}
title={
isLocked && !canUnlockMore
? "Max characters selected to unlock. Lock other characters to unlock this character."
: undefined
}
>
{isLocked ? "Select" : "Deselect"}
</Button>
<p className={styles.text}>
{isLocked
? "This Character will be deactivated."
: "This Character will be activated."}
</p>
</>
);
return (
<>
{!isLocked && (
<>
<Button
size="small"
variant="text"
href={getDetailsLink(character)}
onClick={() => logCharacterViewClicked()}
>
View
</Button>
<Button
size="small"
variant="text"
href={getEditLink(character)}
onClick={() => logCharacterEditClicked()}
>
Edit
</Button>
{!overCharacterLimit && (
<CopyButton character={character} copyCharacter={copyCharacter} />
)}
</>
)}
{isLocked && showActivateButton && (
<ActivateButton
character={character}
activateCharacter={activateCharacter}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
/>
)}
<DeleteButton character={character} deleteCharacter={deleteCharacter} />
</>
);
};
@@ -0,0 +1,54 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logCharacterCopyCancelled,
logCharacterCopyClicked,
logCharacterCopyConfirmed,
} from "../../../../../helpers/analytics";
import { getName } from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
interface CopyProps {
character: CharacterData;
copyCharacter: () => void;
}
export const CopyButton: React.FC<CopyProps> = ({
character,
copyCharacter,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
onClose={() => {
setIsOpen(false);
logCharacterCopyCancelled();
}}
isOpen={isOpen}
onConfirm={() => {
copyCharacter();
logCharacterCopyConfirmed();
setIsOpen(false);
}}
message={`Are you sure you want to copy ${getName(character)}?`}
title="Copy this character?"
confirmText="Copy"
/>
<Button
variant="text"
size="small"
onClick={() => {
logCharacterCopyClicked();
setIsOpen(true);
}}
>
Copy
</Button>
</div>
);
};
@@ -0,0 +1,58 @@
import { useState } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logCharacterDeleteCancelled,
logCharacterDeleteClicked,
logCharacterDeleteConfirmed,
} from "../../../../../helpers/analytics";
import { getName } from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
interface DeleteButtonProps {
character: CharacterData;
deleteCharacter: () => void;
}
export const DeleteButton: React.FC<DeleteButtonProps> = ({
character,
deleteCharacter,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
isOpen={isOpen}
onClose={() => {
logCharacterDeleteCancelled();
setIsOpen(false);
}}
onConfirm={() => {
deleteCharacter();
logCharacterDeleteConfirmed();
setIsOpen(false);
}}
message={`To delete ${getName(
character
)}, type the word DELETE into the field below.`}
title="Delete this character?"
typeValueToConfirm="delete"
confirmText="Delete"
/>
<Button
size="small"
variant="text"
color="error"
onClick={() => {
logCharacterDeleteClicked();
setIsOpen(true);
}}
>
Delete
</Button>
</div>
);
};
@@ -0,0 +1,57 @@
import { useState } from "react";
import {
logCharacterLeaveCampaignCancelled,
logCharacterLeaveCampaignClicked,
logCharacterLeaveCampaignConfirmed,
} from "../../../../../helpers/analytics";
import {
getCampaignName,
getName,
} from "../../../../../state/selectors/characterUtils";
import { CharacterData } from "../../../../../types";
import { ConfirmationModal } from "../../ConfirmationModal";
import styles from "./styles.module.css";
interface LeaveCampaignProps {
character: CharacterData;
leaveCampaign: () => void;
}
export const LeaveCampaignButton: React.FC<LeaveCampaignProps> = ({
character,
leaveCampaign,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<ConfirmationModal
isOpen={isOpen}
onClose={() => {
logCharacterLeaveCampaignCancelled();
setIsOpen(false);
}}
onConfirm={() => {
leaveCampaign();
logCharacterLeaveCampaignConfirmed();
setIsOpen(false);
}}
message={`Are you sure you want ${getName(
character
)} to leave ${getCampaignName(character)}?`}
title="Leave this Campaign?"
confirmText="Leave"
/>
<button
className={styles.button}
onClick={() => {
logCharacterLeaveCampaignClicked();
setIsOpen(true);
}}
>
Leave Campaign
</button>
</div>
);
};
@@ -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>
);
};
@@ -0,0 +1,121 @@
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import TriangleExclamation from "@dndbeyond/fontawesome-cache/svgs/regular/triangle-exclamation.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import styles from "./styles.module.css";
export interface ConfirmationModalProps {
onClose: () => void;
onConfirm: () => void;
typeValueToConfirm?: string;
isOpen: boolean;
message: React.ReactNode;
title: string;
confirmText?: string;
}
export const ConfirmationModal = ({
onClose,
onConfirm,
typeValueToConfirm,
isOpen,
message,
title,
confirmText = "Confirm",
}: ConfirmationModalProps) => {
const [confirmEnabled, setConfirmEnabled] = useState(!typeValueToConfirm);
const [inputValue, setInputValue] = useState("");
const dialogRef = useRef<HTMLDialogElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const isMatch =
value?.toLowerCase().trim() === typeValueToConfirm?.toLowerCase();
setInputValue(value);
if (isMatch) {
setConfirmEnabled(true);
} else if (confirmEnabled) {
setConfirmEnabled(false);
}
};
const handleConfirm = (e) => {
e.preventDefault();
if (confirmEnabled) onConfirm();
};
const handleClickBackdrop = (e) => {
const rect = dialogRef.current?.getBoundingClientRect();
const isClickInDialog =
rect &&
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
if (!isClickInDialog) onClose();
};
useEffect(() => {
if (isOpen) {
// 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();
}
}, [onClose, isOpen]);
return (
<dialog
className={styles.confirmationDialog}
ref={dialogRef}
onClick={handleClickBackdrop}
aria-labelledby="confirm-dialog"
>
<div className={styles.container}>
<header className={styles.header}>
<div id="confirm-dialog" className={styles.title}>
{title}
</div>
{!typeValueToConfirm ? (
<div className={styles.text}>{message}</div>
) : (
<div className={styles.alert}>
<TriangleExclamation className={styles.alertIcon} />
<span className={styles.alertText}>{message}</span>
</div>
)}
</header>
{typeValueToConfirm && (
<input
type="text"
className={styles.input}
onChange={handleChange}
value={inputValue}
autoFocus
/>
)}
<footer className={styles.footer}>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
autoFocus={confirmEnabled}
disabled={!confirmEnabled}
onClick={handleConfirm}
>
{confirmText}
</Button>
</footer>
</div>
</dialog>
);
};
@@ -0,0 +1,65 @@
/**
* @depricated is this shared?
*/
import config from "~/config";
import "./Header.scss";
const BASE_PATHNAME = config.basePathname;
export function Header({ hasMaxCharacters }: { hasMaxCharacters: boolean }) {
return (
<header className="page-header">
<div className="page-header__backdrop"></div>
<div className="page-header__extras">
<div className="page-header__extra page-header__extra--content1">
<section className="atf" role="complementary">
{/* <!-- BREADCRUMBS --> */}
<nav className="b-breadcrumb b-breadcrumb-a">
{/* <!-- WRAPPER --> */}
<ul
className="b-breadcrumb-wrapper"
data-itemscope=""
data-itemtype="http://schema.org/BreadcrumbList"
>
{/* <!-- ITEM --> */}
<li
className="b-breadcrumb-item"
data-itemprop="itemListElement"
data-itemscope=""
data-itemtype="http://schema.org/ListItem"
>
<a href="/" rel="up" data-itemprop="item">
<span data-itemprop="name">Home</span>
</a>
<meta data-itemprop="position" content="1" />
</li>
{/* <!-- ITEM --> */}
<li
className="b-breadcrumb-item"
data-itemprop="itemListElement"
data-itemscope=""
data-itemtype="http://schema.org/ListItem"
>
<a href={BASE_PATHNAME} rel="up" data-itemprop="item">
<span data-itemprop="name">My Characters</span>
</a>
<meta data-itemprop="position" content="2" />
</li>
</ul>
</nav>
</section>
</div>
<div className="page-header__spacer"></div>
</div>
<div className="page-header__primary">
<div className="page-heading">
<div className="page-heading__content">
<h1 className="page-title">My Characters</h1>
</div>
</div>
</div>
</header>
);
}
@@ -0,0 +1,136 @@
import { useLayoutEffect, useEffect, useState } from "react";
import { useHeadContext } from "~/contexts/Head";
import { SessionNames } from "../../../../constants";
import { initAnalytics } from "../../../../helpers/analytics";
import { getFeatureFlagsFromCharacterService } from "../../../../helpers/characterServiceApi";
import useLocalStorage from "../../../../hooks/useLocalStorage";
import useUserId from "../../../../hooks/useUserId";
import useUserName from "../../../../hooks/useUserName";
import {
generateAppData,
getStatus,
} from "../../../../state/selectors/characterUtils";
import { CharacterStatusEnum, QueryResponseData } from "../../../../types";
import { CharacterGrid } from "../CharacterGrid";
import { Header } from "../Header";
import { PlayerAppBanner } from "../PlayerAppBanner";
import "./MyCharacters.scss";
interface Props {
characterQuery: {
isLoading: boolean;
data: QueryResponseData | undefined;
refetch: () => void;
error: Error | null;
};
}
export const MyCharacters = ({ characterQuery }: Props) => {
useEffect(() => {
initAnalytics(SessionNames.DDB);
}, []);
const userId = useUserId();
const userName = useUserName();
const { setTitle } = useHeadContext();
const [storeUrls, setStoreUrls] = useState({
appleStoreLink: "",
googlePlayStoreLink: "",
});
useLayoutEffect(() => {
document
.getElementsByTagName("body")[0]
.classList.add(
"site",
"site-dndbeyond",
"site-www",
"body-mycontent",
"body-mycontent-characterindex"
);
return () => {
document
.getElementsByTagName("body")[0]
.classList.remove(
"site",
"site-dndbeyond",
"site-www",
"body-mycontent",
"body-mycontent-characterindex"
);
};
}, []);
useEffect(() => {
async function getFlags() {
try {
const flagResponse = await getFeatureFlagsFromCharacterService([
"players-app-apple-store-link",
"players-app-google-play-store-link",
]);
const { data } = await flagResponse.json();
setStoreUrls({
appleStoreLink: data["players-app-apple-store-link"],
googlePlayStoreLink: data["players-app-google-play-store-link"],
});
} catch (e) {
/* no-op */
}
}
getFlags();
}, []);
useEffect(() => {
setTitle("My Characters");
}, [setTitle]);
const [playerAppBannerDismissed, setPlayerAppBannerDismissed] =
useLocalStorage(
`MY_CHARACTERS_PLAYER_APP_BANNER_DISMISSED:${userId}`,
false
);
const showPlayerAppBanner: boolean = !playerAppBannerDismissed;
const { isLoading, data, refetch, error } = characterQuery;
const { characters, hasLockedCharacters, maxCharacterSlotsAllowed } =
generateAppData(userName, data);
// Check for max active characters
const hasMaxCharacters =
characters?.filter(
(character) => getStatus(character) === CharacterStatusEnum.Active
).length >= (maxCharacterSlotsAllowed ?? Infinity);
return (
<div className="my-characters-wrapper">
<Header hasMaxCharacters={hasMaxCharacters} />
<div className="content">
{showPlayerAppBanner && (
<PlayerAppBanner
appleStoreLink={storeUrls.appleStoreLink}
googlePlayStoreLink={storeUrls.googlePlayStoreLink}
onDismiss={() => setPlayerAppBannerDismissed(true)}
/>
)}
<CharacterGrid
className="ddbcl-my-characters-listing"
hasLockedCharacters={hasLockedCharacters}
maxCharacterSlotsAllowed={maxCharacterSlotsAllowed}
characters={characters}
isLoading={isLoading}
hasMaxCharacters={hasMaxCharacters}
reload={refetch}
loadingError={error}
/>
{showPlayerAppBanner && (
<PlayerAppBanner
appleStoreLink={storeUrls.appleStoreLink}
googlePlayStoreLink={storeUrls.googlePlayStoreLink}
onDismiss={() => setPlayerAppBannerDismissed(true)}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,90 @@
import clsx from "clsx";
import React from "react";
import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import { Button } from "@dndbeyond/ttui/components/Button";
import {
logPlayerAppBannerClickedCta,
logPlayerAppBannerDismissed,
} from "../../../../helpers/analytics";
import appleStoreBadge from "../../../../images/apple-store-badge.svg?url";
import cellphones from "../../../../images/cellphones.png";
import googlePlayBadge from "../../../../images/google-play-badge.png";
import styles from "./styles.module.css";
export interface PlayerAppBannerProps {
appleStoreLink: string | "DISABLED";
className?: string;
googlePlayStoreLink: string | "DISABLED";
onDismiss: () => void;
}
export const PlayerAppBanner: React.FC<PlayerAppBannerProps> = ({
appleStoreLink,
className = "",
googlePlayStoreLink,
onDismiss,
}) => {
const handleClose = () => {
logPlayerAppBannerDismissed();
onDismiss();
};
return (
<aside
className={clsx(["ddbcl-player-app-banner", styles.banner, className])}
>
<p className={styles.text}>Play Anywhere, Anytime</p>
<img
className={styles.image}
src={cellphones}
alt="3 cell phones with Dungeons & Dragons"
width={200}
/>
<div className={styles.buttons}>
{appleStoreLink && appleStoreLink !== "DISABLED" && (
<a
className={styles.storeLink}
href={appleStoreLink}
target="_blank"
rel="noreferrer"
onClick={() => logPlayerAppBannerClickedCta("Apple")}
aria-label="Download the D&amp;D Beyond Player App on the Apple App Store"
>
<img
className={styles.storeImage}
src={appleStoreBadge}
alt="Apple app store button"
width={120}
/>
</a>
)}
{googlePlayStoreLink && googlePlayStoreLink !== "DISABLED" && (
<a
className={styles.storeLink}
href={googlePlayStoreLink}
target="_blank"
rel="noreferrer"
onClick={() => logPlayerAppBannerClickedCta("Apple")}
aria-label="Download the D&amp;D Beyond Player App on Google Play"
>
<img
className={styles.storeImage}
src={googlePlayBadge}
alt="Google Play store button"
width={134}
/>
</a>
)}
<Button
className={styles.closeButton}
onClick={handleClose}
aria-label="Dismiss player app banner"
>
<XMark className={styles.closeIcon} />
</Button>
</div>
</aside>
);
};
@@ -0,0 +1,43 @@
import { HTMLAttributes } from "react";
import { Button } from "~/components/Button";
import heroIcon from "../../../../images/icons/hero-tier-icon.png";
import masterIcon from "../../../../images/icons/master-tier-icon.png";
import styles from "./styles.module.css";
export interface SubscriptionBannerProps
extends HTMLAttributes<HTMLDivElement> {
iconType: "hero" | "master" | null;
text: string;
buttonLabel: string;
onClick: () => void;
}
export const SubscriptionBanner: React.FC<SubscriptionBannerProps> = ({
iconType,
text,
buttonLabel,
onClick,
...props
}) => {
return (
<div className={styles.container} data-testid="subscription-banner">
<div className={styles.detailContainer}>
{iconType && (
<img
src={iconType === "master" ? masterIcon : heroIcon}
alt={`${iconType} tier icon`}
className={styles.icon}
/>
)}
<div className={styles.text}>{text || props.children}</div>
</div>
{onClick && (
<Button variant="builder" onClick={onClick} className={styles.button}>
{buttonLabel}
</Button>
)}
</div>
);
};