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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+116
@@ -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>
|
||||
);
|
||||
};
|
||||
+57
@@ -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'll try again!
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.noResultsTitle}>
|
||||
Looks like you haven'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&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>
|
||||
);
|
||||
};
|
||||
+127
@@ -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&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&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;
|
||||
};
|
||||
+40
@@ -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&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&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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user