New source found from dndbeyond.com
This commit is contained in:
+23
@@ -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
@@ -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,
|
||||
};
|
||||
};
|
||||
Generated
Vendored
+30
@@ -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)} />;
|
||||
};
|
||||
Generated
Vendored
+15
@@ -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} />;
|
||||
};
|
||||
Generated
Vendored
+19
@@ -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) : '/';
|
||||
};
|
||||
Generated
Vendored
+18
@@ -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} />;
|
||||
};
|
||||
Generated
Vendored
+92
@@ -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>
|
||||
);
|
||||
};
|
||||
Generated
Vendored
+53
@@ -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';
|
||||
Generated
Vendored
+57
@@ -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})`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user