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,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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user