New source found from dndbeyond.com

This commit is contained in:
2026-07-08 01:00:09 -07:00
parent 9a983a6d7b
commit dcefa0d2f2
2865 changed files with 222467 additions and 49053 deletions
+23
View File
@@ -0,0 +1,23 @@
type JsonStringifyRest = Parameters<typeof JSON.stringify> extends [Error, ...infer R] ? R : never;
const prepareSerializableError = (error: Error | unknown) => {
if (!(error instanceof Error)) return;
const serializableError: Record<string, string | undefined> = {
name: error.name || error.constructor.name || 'Unknown Error',
message: error.message,
cause: error.cause as string,
stack: undefined,
};
for (const key of Object.getOwnPropertyNames(error)) {
if (!(key in serializableError)) {
serializableError[key] = (error as unknown as Record<string, string>)[key];
}
}
return serializableError;
};
export const stringifyError = (error: Error | unknown, ...rest: JsonStringifyRest) =>
JSON.stringify(prepareSerializableError(error), ...rest);
+71
View File
@@ -0,0 +1,71 @@
export const BI_ELEMENT_TYPE_ATTRIBUTE = 'data-element_type';
export const BI_SUB_ELEMENT_TYPE_ATTRIBUTE = 'data-sub_element_type';
export const BI_ACTION_DETAIL_ATTRIBUTE = 'data-action_detail';
export const BI_CONTAINER_ELEMENT_NAME = 'Navigation Menu';
export const getElementAttributes = (type: string, id: string | undefined, name: string) => ({
[BI_ELEMENT_TYPE_ATTRIBUTE]: type,
'data-element_id': id,
'data-element_name': name,
});
export const getSubElementAttributes = (
name: string,
type: 'slide' | 'card' | 'tab contents' | 'main tile',
index: number | undefined,
) => ({
[BI_SUB_ELEMENT_TYPE_ATTRIBUTE]: type,
'data-sub_element_name': name,
'data-sub_element_index': index,
});
export const getItemAttributes = (
name: string,
format: 'image' | 'link' | 'button',
actionDetail:
| 'navigation'
| 'scroll'
| 'filter'
| 'see more'
| 'popup screen'
| 'close'
| 'play'
| 'pause'
| 'retry'
| undefined,
targetUrl: string | undefined,
index: number | undefined,
) => ({
[BI_ACTION_DETAIL_ATTRIBUTE]: actionDetail,
'data-item_name': name,
'data-item_format': format,
'data-target_url': targetUrl,
'data-item_index': index,
});
export type BIItemAttributes = ReturnType<typeof getItemAttributes>;
export const getElementAttributeValues = (containerElement: HTMLElement) => ({
elementName: containerElement.dataset.element_name,
elementType: containerElement.dataset.element_type,
elementId: containerElement.dataset.element_id,
});
export const getSubElementAttributeValues = (subElement: HTMLElement) => ({
subElementName: subElement.dataset.sub_element_name,
subElementType: subElement.dataset.sub_element_type,
subElementIndex: subElement.dataset.sub_element_index,
});
export const getItemAttributeValues = (actionItem: HTMLElement) => {
const validLinkHref = (actionItem.tagName === 'A' && actionItem.getAttribute('href')) || undefined;
return {
itemName: actionItem.dataset.item_name ?? actionItem.textContent ?? undefined,
itemIndex: actionItem.dataset.item_index,
itemFormat: actionItem.dataset.item_format ?? (validLinkHref && 'link'),
actionDetail: actionItem.dataset.action_detail ?? (validLinkHref && 'navigation'),
targetUrl: actionItem.dataset.target_url ?? validLinkHref,
};
};
@@ -0,0 +1,30 @@
import type { FC } from 'react';
import type { AmplienceContentItem, BaseContentItem } from '../../types';
import { GenericSlot } from '../GenericSlot';
import { PromoCarouselAdapter } from '../PromoCarouselAdapter';
export const getAmplienceLocale = (appLocale: string) => {
switch (appLocale) {
case 'de':
return 'de-DE';
case 'en':
default:
return 'en';
}
};
const getComponentForSchema = (schemaName: string) => {
switch (schemaName) {
case 'https://content.hasbro.com/slots/generic-slot':
return GenericSlot;
case 'https://content.hasbro.com/models/promotion-message':
return PromoCarouselAdapter;
default:
return () => null;
}
};
export const AmplienceComponent = ({ item, locale }: AmplienceContentItem<BaseContentItem>) => {
const Component = getComponentForSchema(item._meta?.schema) as FC<AmplienceContentItem<BaseContentItem>>;
return <Component item={item} locale={getAmplienceLocale(locale)} />;
};
@@ -0,0 +1,15 @@
import type { GenericContentSlot } from '../../schemas/generic-slot-schema';
import type { AmplienceContentItem, BaseContentItem } from '../../types';
import { AmplienceComponent } from '../AmplienceComponent';
export const GenericSlot = ({ item: { content }, locale }: AmplienceContentItem<GenericContentSlot>) => {
// Amplience Generic Slot is a localized component, unlike Amplience Core components.
// Because of this, it receives two different types of responses that we need to handle below.
const item = (content?.values?.find((item) => item.locale === locale)?.value ?? content) as
| BaseContentItem
| undefined;
if (!item) return null;
return <AmplienceComponent item={item} locale={locale} />;
};
@@ -0,0 +1,19 @@
import { ROUTING_LOCALES } from '../../shared/constants';
type Locale = (typeof ROUTING_LOCALES)[number];
function stripLocalePrefix(pathname: string): string {
const segments = pathname.split('/');
const [_, potentialLocale, ...rest] = segments;
if (ROUTING_LOCALES.includes(potentialLocale as Locale)) {
const remainder = rest.join('/');
return remainder ? `/${remainder}` : '/';
}
return pathname;
}
export const getPathname = () => {
return typeof window !== 'undefined' ? stripLocalePrefix(window.location.pathname) : '/';
};
@@ -0,0 +1,18 @@
'use client';
import type { Title as PromotionMessage } from '../../schemas/promotion-message-schema';
import type { AmplienceContentItem } from '../../types';
import { PromoCarousel } from '../TopNavigation/PromoCarousel';
export const PromoCarouselAdapter = ({ item: { promoMessages } }: AmplienceContentItem<PromotionMessage>) => {
if (!promoMessages || !promoMessages.length) return;
const adaptedData = promoMessages
.filter((promoMessage) => promoMessage?.content?.richTextField)
.map((promoMessage) => ({
text: promoMessage.content?.richTextField ?? '',
color: promoMessage.color,
}));
return <PromoCarousel promotions={adaptedData} />;
};
@@ -0,0 +1,92 @@
'use client';
import ChevronLeft from '@dndbeyond/fontawesome-cache/svgs/regular/chevron-left.svg';
import ChevronRight from '@dndbeyond/fontawesome-cache/svgs/regular/chevron-right.svg';
import clsx from 'clsx';
import useEmblaCarousel from 'embla-carousel-react';
import { useCallback, useEffect, useState } from 'react';
import Markdown from 'react-markdown';
import { getItemAttributes, getSubElementAttributes } from '../../../bi/helpers';
import styles from './PromoCarousel.module.css';
type PromoCarouselType = {
promotions: {
text: string;
color?: string;
}[];
};
export const PromoCarousel = ({ promotions }: PromoCarouselType) => {
const [emblaRef, emblaApi] = useEmblaCarousel();
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
const [activeSlide, setActiveSlide] = useState(0);
const onPrevButtonClick = () => {
emblaApi?.scrollPrev();
determineSliderPosition();
if (emblaApi) setActiveSlide(emblaApi.selectedScrollSnap());
};
const onNextButtonClick = () => {
emblaApi?.scrollNext();
determineSliderPosition();
if (emblaApi) setActiveSlide(emblaApi.selectedScrollSnap());
};
const determineSliderPosition = useCallback(() => {
setPrevBtnDisabled(!emblaApi?.canScrollPrev());
setNextBtnDisabled(!emblaApi?.canScrollNext());
}, [emblaApi]);
useEffect(() => {
if (emblaApi) setActiveSlide(emblaApi.selectedScrollSnap());
determineSliderPosition();
}, [determineSliderPosition]);
return (
<div className={styles.carouselContainer}>
<button
onClick={onPrevButtonClick}
className={clsx(
styles.navigationButton,
prevBtnDisabled && styles.navigationButtonDisabled,
promotions.length === 1 && styles.navigationButtonHidden,
)}
disabled={prevBtnDisabled}
{...getItemAttributes('Previous', 'button', 'scroll', undefined, undefined)}
>
<ChevronLeft />
</button>
<div
className={styles.carousel}
ref={emblaRef}
{...getSubElementAttributes('Promo Carousel', 'main tile', undefined)}
>
<div className={styles.carouselScrollContainer}>
{promotions.map((promo, index) => (
// React versions below 19 remove the 'inert' attribute when provided as a boolean. Passing it as a string "true" as a workaround.
<div
inert={activeSlide !== index && ('inert' as unknown as boolean)}
key={index + promo.text}
className={styles.slide}
>
<Markdown>{promo.text}</Markdown>
</div>
))}
</div>
</div>
<button
onClick={onNextButtonClick}
className={clsx(
styles.navigationButton,
nextBtnDisabled && styles.navigationButtonDisabled,
promotions.length === 1 && styles.navigationButtonHidden,
)}
disabled={nextBtnDisabled}
{...getItemAttributes('Next', 'button', 'scroll', undefined, undefined)}
>
<ChevronRight />
</button>
</div>
);
};
@@ -0,0 +1,53 @@
'use client';
import { Button } from '@dndbeyond/ttui/components/Button';
import clsx from 'clsx';
import { forwardRef } from 'react';
import { getItemAttributes, getSubElementAttributes } from '../../bi/helpers';
import type { Meta, Title as PromoCarouselAdapterSchema } from '../../schemas/promotion-message-schema';
import type { LanguageType, TopNavigationMessages } from '../../types';
import { AmplienceComponent } from '../AmplienceComponent';
import styles from './TopNavigation.module.css';
type TopNavigationType = {
className?: string;
promoSlotData?: {
content: PromoCarouselAdapterSchema;
_meta: Meta;
};
messages: TopNavigationMessages;
locale: LanguageType;
onDismissBanner?: () => void;
};
export const TopNavigation = forwardRef<HTMLDivElement, TopNavigationType>(
({ className, promoSlotData, messages, locale, onDismissBanner }, ref) => {
return (
<div
ref={ref}
className={clsx(styles.promoBannerWrapper, className)}
data-testid="topNavigation-wrapper"
{...getSubElementAttributes('Top Navigation', 'main tile', undefined)}
>
<div className={styles.promoBanner}>
<div></div>
<div>{promoSlotData && <AmplienceComponent item={promoSlotData} locale={locale} />}</div>
{!!onDismissBanner && (
<Button
variant="outline"
color="primary"
size="x-small"
className={styles.dismissButton}
onClick={onDismissBanner}
data-testid="dismiss-button"
{...getItemAttributes('Dismiss Promo Banner', 'button', 'close', undefined, undefined)}
>
{messages.dismiss}
</Button>
)}
</div>
</div>
);
},
);
TopNavigation.displayName = 'TopNavigation';
@@ -0,0 +1,57 @@
export class InvalidTokenError extends Error {
}
InvalidTokenError.prototype.name = "InvalidTokenError";
function b64DecodeUnicode(str) {
return decodeURIComponent(atob(str).replace(/(.)/g, (m, p) => {
let code = p.charCodeAt(0).toString(16).toUpperCase();
if (code.length < 2) {
code = "0" + code;
}
return "%" + code;
}));
}
function base64UrlDecode(str) {
let output = str.replace(/-/g, "+").replace(/_/g, "/");
switch (output.length % 4) {
case 0:
break;
case 2:
output += "==";
break;
case 3:
output += "=";
break;
default:
throw new Error("base64 string is not of the correct length");
}
try {
return b64DecodeUnicode(output);
}
catch (err) {
return atob(output);
}
}
export function jwtDecode(token, options) {
if (typeof token !== "string") {
throw new InvalidTokenError("Invalid token specified: must be a string");
}
options || (options = {});
const pos = options.header === true ? 0 : 1;
const part = token.split(".")[pos];
if (typeof part !== "string") {
throw new InvalidTokenError(`Invalid token specified: missing part #${pos + 1}`);
}
let decoded;
try {
decoded = base64UrlDecode(part);
}
catch (e) {
throw new InvalidTokenError(`Invalid token specified: invalid base64 for part #${pos + 1} (${e.message})`);
}
try {
return JSON.parse(decoded);
}
catch (e) {
throw new InvalidTokenError(`Invalid token specified: invalid json for part #${pos + 1} (${e.message})`);
}
}