Skip to content

Instantly share code, notes, and snippets.

@TClark1011
Last active December 18, 2024 23:35
Show Gist options
  • Select an option

  • Save TClark1011/200d0050e617bbaaca16319a9cea06cc to your computer and use it in GitHub Desktop.

Select an option

Save TClark1011/200d0050e617bbaaca16319a9cea06cc to your computer and use it in GitHub Desktop.
Work (BTC) Helpers
import {
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
} from "react";
type AccordionMode = "single" | "multiple";
type AccordionOptions = {
mode: AccordionMode;
defaultOpenKeys: string[];
};
type AccordionState = {
options: AccordionOptions;
openKeys: string[];
};
const checkAccordionOpenKeysAgainstMode = ({
mode,
openKeys,
}: {
mode: AccordionMode;
openKeys: string[];
}) => {
if (mode === "single" && openKeys.length > 1)
throw new Error("No more than 1 key can be open in single mode");
const duplicateCount = openKeys.length - new Set(openKeys).size;
if (duplicateCount > 0)
throw new Error("defaultOpenKeys must not contain duplicates");
};
type AccordionAction = {
type: "toggleKey";
payload: {
key: string;
};
};
const accordionReducer = (
state: AccordionState,
action: AccordionAction,
): AccordionState => {
if (action.type === "toggleKey") {
const itemIsAlreadyOpen = state.openKeys.includes(action.payload.key);
if (itemIsAlreadyOpen) {
const newOpenKeys = state.openKeys.filter(
(key) => key !== action.payload.key,
);
return {
...state,
openKeys: newOpenKeys,
};
}
if (state.options.mode === "single") {
return {
...state,
openKeys: [action.payload.key],
};
}
return {
...state,
openKeys: [...state.openKeys, action.payload.key],
};
}
throw new Error(`Invalid action type "${action.type}"`);
};
type AccordionContextValue = {
openKeys: string[];
onToggleKey: (key: string) => void;
};
const AccordionContext = createContext<AccordionContextValue>({
onToggleKey: () => {},
openKeys: [],
});
export const AccordionController = ({
children,
mode,
defaultOpenKeys,
}: PropsWithChildren<AccordionOptions>) => {
useEffect(() => {
checkAccordionOpenKeysAgainstMode({ mode, openKeys: defaultOpenKeys });
}, [mode, defaultOpenKeys]);
const [state, dispatch] = useReducer(accordionReducer, {
options: { mode, defaultOpenKeys },
openKeys: defaultOpenKeys,
});
const onToggleKey = useCallback((key: string) => {
dispatch({
type: "toggleKey",
payload: {
key,
},
});
}, []);
const contextValue: AccordionContextValue = useMemo(
() => ({
openKeys: state.openKeys,
onToggleKey,
}),
[state.openKeys, onToggleKey],
);
return (
<AccordionContext.Provider value={contextValue}>
{children}
</AccordionContext.Provider>
);
};
type AccordionItemChildFunctionInput = {
isOpen: boolean;
onToggle: () => void;
};
export const AccordionItem = ({
children,
accordionKey,
}: {
children: ({
isOpen,
onToggle,
}: AccordionItemChildFunctionInput) => JSX.Element;
accordionKey: string;
}) => {
const { openKeys, onToggleKey } = useContext(AccordionContext);
const isOpen = useMemo(
() => openKeys.includes(accordionKey),
[openKeys, accordionKey],
);
const onToggle = useCallback(() => {
onToggleKey(accordionKey);
}, [accordionKey, onToggleKey]);
return children({ isOpen, onToggle });
};
import { CreateStoryParams, Story } from "@gs-libs/bridge";
import { bridge } from "../services";
// Get the id used to call `bridge.editStory`. Returns
// the revisionId if it exists, otherwise returns the id
export const getStoryEditId = (story: Story): number =>
(story as { revisionId?: number }).revisionId || story.id;
export const findStoryInChannelWithTitle = async (
storyTitle: string,
channelId?: number,
): Promise<Story | undefined> => {
const stories = await bridge.getList<Story>({
entityName: "story",
parentEntityName: "channel",
peid: channelId,
});
const matchingStory = stories.find(({ title }) => title === storyTitle);
return matchingStory;
};
// Take the parameters required to create a story. Searches
// for an existing story with the same title. If one exists,
// update that story, otherwise create a new story.
export const createOrEditStory = async ({
title,
channelId,
...createStoryParams
}: CreateStoryParams) => {
const existingStory = await findStoryInChannelWithTitle(title, channelId);
const creationParams = {
title,
channelId,
...createStoryParams,
};
if (existingStory) {
return bridge.editStory({
...creationParams,
storyId: getStoryEditId(existingStory),
});
}
return bridge.createStory(creationParams);
};
// Encode an object to a JSON string so we can create
// a new JSON file with `bridge.createFile`
export const encodeObjectToJSON = (data: any) =>
Buffer.from(JSON.stringify(data)).toString("base64");
type CreateJSONFileOptions = {
fileName: string;
data: unknown;
};
export const createJSONFile = ({ fileName, data }: CreateJSONFileOptions) =>
bridge.createFile({
fileData: encodeObjectToJSON(data),
fileExt: "json",
fileName,
});
export const FILE_COLORS = new Map([
['excel', 'rgb(0, 132, 57)'],
['word', 'rgb(0, 161, 212)'],
['pdf', '#f01300'],
['powerpoint', '#ff7752'],
['video', '#6a73ff'],
['image', '#9052e3'],
['doc', '#3ea2d9'],
['zip', '#fab600'],
['visio', '#38549e'],
['project', '#17a149'],
['presentation', '#f26724'],
['csv', '#24a62b'],
['xls', '#08872c'],
['keynote', '#43b7f1'],
['ibook', '#f0842e'],
['form', '#01aeb4'],
['epub', '#97cf00'],
['audio', '#da42db'],
['cad', '#d0021b'],
['twixl', '#9a1656'],
['scrollmotion', '#0098bc'],
]);
export const STORY_BADGE_COLOR = '#317E8C';

Best Way to Handle Mapping

Create these types:

import { EntityType } from "@gs-libs/bridge";
import { ReactComponent as IconComponent } from "./assets/icons/arrow-right.svg";

export type StrictExclude<T, U extends T> = T extends U ? never : T;
export type StrictExtract<T, U extends T> = T extends U ? T : never;
export type Nullable<T> = T | null | undefined;

export type SystemClassName = "ios" | "android" | "windows";

export type OpenableType = StrictExclude<
	EntityType,
	"fileCollection" | "interestArea" | "user" | "link"
>;

export type MappingItemCoreFields =
	| {
			type: OpenableType;
			payload: {
				entityId: number;
			};
	  }
	| {
			type: "url";
			payload: {
				url: string;
			};
	  };

export type MappingItem = {
	index: number;
	title: string;
	iconComponent?: typeof IconComponent;
} & MappingItemCoreFields;

You can then create this hook for handling the on clicks:

import { useMutation } from "@gs-libs/bridge";
import { MappingItemCoreFields } from "../type";
import { bridge } from "../services";

/**
 * We use `MappingItemCoreFields` because some mapping may use customized types,
 * so by only requiring the core fields, we can make sure that the handler should
 * be able to handle any mapping item.
 */
export const useMappingItemOnClick = () =>
	useMutation((item: MappingItemCoreFields) => {
		if (item.type === "url") {
			return bridge.openURL({ url: item.payload.url });
		}

		return bridge.openEntity({
			entityName: item.type,
			id: item.payload.entityId,
		});
	});
import {
Channel,
File,
FileCollection,
Group,
InterestArea,
Story,
Tab,
User,
} from "@gs-libs/bridge";
export type AnyBridgeEntity =
| Story
| File
| FileCollection
| Channel
| Tab
| User
| Event
| Group
| InterestArea;
import {
Channel,
Comment,
Event,
File,
FileCollection,
Group,
InterestArea,
SearchFile,
SearchStory,
Story,
Tab,
User,
} from "@jsb/bridge";
export const isStory = (entity: any): entity is Story | SearchStory =>
entity?.type === "story";
export const isChannel = (entity: any): entity is Channel =>
entity?.type === "channel";
export const isTab = (entity: any): entity is Tab => entity?.type === "tab";
export const isFile = (entity: any): entity is File | SearchFile =>
entity?.type === "file";
export const isFileCollection = (entity: any): entity is FileCollection =>
entity?.type === "fileCollection";
export const isUser = (entity: any): entity is User => entity?.type === "user";
export const isComment = (entity: any): entity is Comment =>
entity?.type === "comment";
export const isGroup = (entity: any): entity is Group =>
entity?.type === "group";
export const isInterestArea = (entity: any): entity is InterestArea =>
entity?.type === "interestArea";
export const isEvent = (entity: any): entity is Event =>
entity?.type === "event";

ESLint Shows Old Errors

If your ESLint is showing errors despite you having updated the eslint config to stop this being possible, delete your node_modules folder and reinstall your dependencies.

Monorepo: If you are in a monorepo, make sure to delete the node_modules folder from all of the packages

Fetching Bookmarked Files

Individual bookmarked files are returned by getBookmarkList as fileCollections that only contain a single file. So you have to keep fetching fileCollections, pulling the files out from ones that only contain a single file until you have as much as you need.

$excel-color: rgb(60, 132, 57);
$word-color: rgb(90, 161, 212);
$pdf-color: #f01300;
$powerpoint-color: #ff7752;
$video-color: #6a73ff;
$image-color: #9052e3;
$doc-color: #3ea2d9;
$zip-color: #fab600;
$visio-color: #38549e;
$project-color: #17a149;
$presentation-color: #f26724;
$csv-color: #24a62b;
$xls-color: #08872c;
$keynote-color: #43b7f1;
$ibook-color: #f0842e;
$form-color: #01aeb4;
$epub-color: #97cf00;
$audio-color: #da42db;
$cad-color: #d0021b;
$twixl-color: #9a1656;
$scrollmotion-color: #0098bc;
import { titleCase } from "@/utils";
export const formatFileCategory = (category: string) => {
switch (category) {
case "pdf":
return "PDF";
case "powerpoint":
default:
return titleCase(category);
}
};
import { FormikContextType, useFormikContext } from "formik";
import * as React from "react";
export type FormikSpyProps = {
effect: (values: FormikContextType<any>) => any;
};
const FormikSpy = ({ effect }: FormikSpyProps) => {
const form = useFormikContext();
React.useEffect(() => {
effect(form);
}, [form, effect]);
return null;
};
export default FormikSpy;
const fileCategoryToColorMap: Record<string, string> = {
excel: '#096B38',
pdf: '#AA0D01',
powerpoint: '#C64322',
video: '#0F2F66',
web: '#EAB000',
word: '#5585EB',
};
const getColorForFileCategory = (category: string): string =>
fileCategoryToColorMap[category] ?? '#02B140';
export default getColorForFileCategory;
type ResourceListingEntityType = "story" | "channel" | "tab";
type ResourceListing = {
id: number; // Just used for internal code logic
primaryLabel?: string;
secondaryLabel?: string; // We will set it up so at least one of the label fields is required
entityType: ResourceListingEntityType;
entityId: number;
badgeText?: string;
order?: number; // If not specified, goes by the order in the list
};
type ResourceGrouping = {
id: number; // Just used for internal code logic
label: string;
listings: ResourceListing[];
order?: number; // If not specified, goes by the order in the list
};
type GoreAlertBannerOptions = {
visible: boolean;
title: string;
description: string;
targetUrl?: string;
};
type GoreHSConfig = {
alertBanner?: GoreAlertBannerOptions;
productResourceGroupings: ResourceGrouping[];
additionalResources: ResourceGrouping;
};
// TEMP: DELETE THIS
console.log("DELETE THIS CODE !!!");
const exampleResourceGropingNames = [
"Aortic",
"Peripheral",
"Cardiac",
"GMP",
"Provider Portfolio",
];
const officialSoundingWords = [
"Reports",
"Dashboard",
"Data",
"Information",
"Analytics",
"Insights",
"Metrics",
"Statistics",
"Trends",
"Analysis",
"Research",
"Studies",
"Findings",
"Results",
"Performance",
"Benchmarking",
"Monitoring",
"Evaluation",
"Outcomes",
"Outliers",
"Indicators",
"Outcomes",
"Quality",
"Improvement",
"Compliance",
"Regulations",
"Standards",
"Protocols",
"Best Practices",
"Efficiency",
"Effectiveness",
"Utilization",
"Utilization Rates",
"Utilization Patterns",
"Utilization Trends",
"Patient Satisfaction",
"Patient Outcomes",
"Clinical Trials",
"Clinical Data",
"Clinical Pathways",
"Treatment Protocols",
"Diagnostic Insights",
"Diagnostic Accuracy",
"Diagnostic Trends",
"Prescription Patterns",
"Medication Adherence",
"Medication Safety",
"Adverse Events",
"Incident Reporting",
"Risk Management",
"Healthcare Costs",
"Cost Analysis",
"Cost-effectiveness",
"Cost Containment",
];
const getRandomItemFrom = <T>(arr: T[]): T => {
if (arr.length === 0) {
throw new Error("Array is empty");
}
const randomIndex = Math.floor(Math.random() * arr.length);
const item = arr[randomIndex];
if (item === undefined) {
throw new Error("Undefined item");
}
return item;
};
const randomChance = (chance: number): boolean => {
const random = Math.random();
return random < chance;
};
const randomIntBetween = (min: number, max: number): number =>
Math.floor(Math.random() * (max - min + 1) + min);
const generateSecondaryLabel = (): string => {
const numberOfPieces = randomIntBetween(2, 4);
const pieces = Array.from({ length: numberOfPieces }, () =>
getRandomItemFrom(officialSoundingWords),
);
return pieces.join(" ");
};
const generateListing = (): ConfigResourceListing => {
const hasPrimaryLabel = randomChance(0.9);
const hasSecondaryLabel = !hasPrimaryLabel || randomChance(0.9);
return {
id: Math.floor(Math.random() * 1000),
entityId: 0,
entityType: "story",
badgeText: randomChance(0.05) ? "New" : undefined,
primaryLabel: hasPrimaryLabel
? `GORE® ${getRandomItemFrom(officialSoundingWords)}®`
: undefined,
secondaryLabel: hasSecondaryLabel ? generateSecondaryLabel() : undefined,
};
};
const randomListingGroupings: ConfigResourceGrouping[] =
exampleResourceGropingNames.map((name) => ({
id: Math.floor(Math.random() * 1000),
label: name,
listings: Array.from({ length: randomIntBetween(3, 6) }, generateListing),
}));
const exampleConfig: ConfigData = {
alertBanner: {
visible: true,
title: "Server is currently down",
description:
"We are troubleshooting the issue and will have a progress update in 30 minutes",
targetUrl: "https://www.google.com",
},
additionalResources: {
label: "Additional Resources",
id: 99,
listings: Array.from({ length: 4 }, generateListing),
},
productResourceGroupings: randomListingGroupings,
};
console.log("(dataTypes): ", { exampleConfig });

Hot Reload Freeze Bug

There is a bug where after hot reloading a few times, the page stops responding to interaction. To fix this:

  1. Upgrade react-scripts to version 4.0.3,
  2. Add the following to the bottom of package.json:
 "resolutions": {
    "react-error-overlay": "6.0.9"
  }

Fix Framer Motion

At time of writing, it seems that the version of CRA that the hs-template uses is incompatible with versions of the framer-motion package past 4.1.17. Here is how to get it to work:

  1. Go to the scripts/craco.config.js file
  2. Paste this code right above the line that says webpackConfig.module.rules.forEach((r) => {
webpackConfig.module.rules.push({
	test: /\.mjs$/,
	include: /node_modules/,
	type: "javascript/auto",
});
  1. It should now work. You may get some errors in the terminal when you run the app that say something about babel not being able to load in some modules, if that is the case then delete your node_modules folder and yarn.lock file and run yarn.
/* eslint-disable jsx-a11y/alt-text */
import React, { ImgHTMLAttributes, useState, useEffect } from 'react';
export type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
fallbackSrc?: string;
};
const Image = ({ src, fallbackSrc, onError, ...props }: ImageProps) => {
const [hasThrownError, setHasThrownError] = useState(false);
const finalSrc = hasThrownError ? fallbackSrc : src;
useEffect(() => {
setHasThrownError(false); // reset error state on src change
}, [src]);
return (
<img
{...props}
src={finalSrc}
onError={(e) => {
onError?.(e);
setHasThrownError(true);
}}
/>
);
};
export default Image;
import { mergeWith, isPlainObject } from "lodash/fp";
export const immutableDeepMergePlainObjects = (object: any, source: any) =>
mergeWith((obj, src) => {
if (isPlainObject(obj)) {
return { ...obj, ...src };
}
return src;
})(object)(source);
export const updateObject = <T>(object: T, update: DeepPartial<T>) =>
immutableDeepMergePlainObjects(object, update) as T;
const FALLBACK_THUMBNAIL_FILE_NAME_REGEX =
/defaultImage_[%#][0-9a-f]{8}\.\w{2,4}$/gi;
export const isFallbackImage = (url: string) =>
FALLBACK_THUMBNAIL_FILE_NAME_REGEX.test(url);

Mapping Helpers

// type.ts
/* ---------------------------------- */
/*            Mapping Types           */
/* ---------------------------------- */

export type ReactSVGComponent = React.FunctionComponent<
	React.SVGProps<SVGSVGElement> & { title?: string }
>;

export type MappingEntityData = Pick<OpenEntityParams, "entityName" | "id">;
export type MappingLinkData = {
	url: string;
};
export type CustomActionMappingData = {
	customActionName: string;
};

export type MappingItem = {
	label: string;
	icon?: ReactSVGComponent;
} & (
	| {
			type: "entity";
			data: MappingEntityData;
	  }
	| {
			type: "link";
			data: MappingLinkData;
	  }
	| {
			type: "customAction";
			data: CustomActionMappingData;
	  }
);
// utils
// Derive a key from a mapping item that can be used when
// generating JSX from an array of mapping items
// eslint-disable-next-line consistent-return
export const deriveMappingItemKey = (item: MappingItem): string | number => {
	// eslint-disable-next-line default-case
	switch (item.type) {
		case "entity":
			return item.data.id;
		case "link":
			return item.data.url;
		case "customAction":
			return item.data.customActionName;
	}
};
// hooks
const handleMappingItem = async (
  item: MappingItem,
  customActionHandler?: (customActionName: string) => Promise<void>
): Promise<void> => {
  // eslint-disable-next-line default-case
  switch (item.type) {
    case 'entity':
      await bridge.openEntity(item.data);
      break;
    case 'link':
      await bridge.openURL(item.data);
      break;
    case 'customAction':
      await customActionHandler?.(item.data.customActionName);
      break;
  }
  await new Promise<void>((resolve) => resolve());
};

export const useMappingAction = (
  customActionHandler?: (customActionName: string) => Promise<void>
) =>
  useMutation((mappingItem: MappingItem) =>
    handleMappingItem(mappingItem, customActionHandler)
  );
	```
import { EntityType } from "@gs-libs/bridge";
import { ReactComponent as IconComponent } from "./assets/icons/arrow-right.svg";
export type StrictExclude<T, U extends T> = T extends U ? never : T;
export type StrictExtract<T, U extends T> = T extends U ? T : never;
export type Nullable<T> = T | null | undefined;
export type SystemClassName = "ios" | "android" | "windows";
export type OpenableType = StrictExclude<
EntityType,
"fileCollection" | "interestArea" | "user" | "link"
>;
export type MappingItem = {
index: number;
title: string;
iconComponent?: typeof IconComponent;
} & (
| {
type: OpenableType;
payload: {
entityId: number;
};
}
| {
type: "link";
payload: {
url: string;
};
}
);
@mixin reset-all-styles() {
background: none;
border: none;
box-sizing: border-box;
font-family: unset;
font-size: v.$font-size-base;
font-weight: normal;
list-style: none;
margin: 0;
padding: 0;
text-align: unset;
}
@mixin axis-property($property, $axis, $value) {
@if ($axis == "horizontal" or $axis == "x") {
#{$property}-left: $value;
#{$property}-right: $value;
}
@if ($axis == "vertical" or $axis == "y") {
#{$property}-top: $value;
#{$property}-bottom: $value;
}
}
@mixin size($size) {
width: $size;
height: $size;
}
@mixin max-lines($line) {
-webkit-box-orient: vertical;
display: box;
height: auto;
-webkit-line-clamp: $line;
overflow: hidden;
}
export { default } from './Modal';
@use '$style-utils' as *;
$gutter: 20px;
.dialog {
position: relative;
width: 100%;
max-width: 600px;
max-height: 600px;
overflow: auto;
padding: 0;
border-radius: 8px;
margin: auto;
&::backdrop {
background-color: rgba(0 0 0 / 60%);
}
.topRow {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: $gutter;
padding-bottom: 0;
margin-bottom: $gutter;
.title {
font-weight: 700;
font-size: 20px;
}
}
.closeButton {
color: $brand-dark-grey-2;
&.overContent {
position: absolute;
right: $gutter;
top: $gutter;
}
}
.inner {
width: 100%;
padding: $gutter;
overflow: visible;
}
.topRow + .inner {
padding-top: 0;
}
}
import { PropsWithChildren, useEffect, useRef } from 'react';
import cx from 'classnames';
import styles from './Modal.module.scss';
import { CrossIcon } from '@/components/icons';
export type ModalProps = PropsWithChildren<{
onClose: () => void;
isOpen: boolean;
noCloseOnEscape?: boolean;
noCloseOnClickOut?: boolean;
title?: string;
hideCloseButton?: boolean;
innerClassName?: string;
disableExtraCloseMethods?: boolean;
}>;
const Modal = ({
onClose,
isOpen,
children,
noCloseOnEscape = false,
hideCloseButton = false,
noCloseOnClickOut = false,
title,
innerClassName,
disableExtraCloseMethods = false,
}: ModalProps) => {
const allowExtraCloseMethods = !disableExtraCloseMethods;
const shouldCloseOnEscape = allowExtraCloseMethods && !noCloseOnEscape;
const showCloseButton = allowExtraCloseMethods && !hideCloseButton;
const shouldCloseOnClickOut = allowExtraCloseMethods && !noCloseOnClickOut;
const dialogElementRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (isOpen) {
dialogElementRef.current?.showModal();
} else {
dialogElementRef.current?.close();
}
}, [isOpen, onClose]);
const closeButton = (
<button
aria-label="close"
className={cx(
styles.closeButton,
!title && showCloseButton && styles.overContent
)}
onClick={onClose}
>
<CrossIcon />
</button>
);
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<dialog
ref={dialogElementRef}
className={styles.dialog}
onCancel={(e) => {
e.preventDefault();
if (shouldCloseOnEscape) {
onClose();
}
}}
onClick={(e) => {
const typedTarget = e.target as HTMLElement;
const clickWasOutside = typedTarget.nodeName === 'DIALOG';
if (shouldCloseOnClickOut && clickWasOutside) {
onClose();
}
}}
>
{!!title && (
<div className={styles.topRow}>
<h2 className={styles.title}>{title}</h2>
{showCloseButton && closeButton}
</div>
)}
{!title && showCloseButton && closeButton}
<div className={cx(styles.inner, innerClassName)}>{children}</div>
</dialog>
);
};
export default Modal;

New HS Tweaks

Tweaks to apply upon creating a new HS

Change Package Name

Update the name field in package.json

Add Mixins

@mixin reset-all-styles() {
	background: none;
	border: none;
	box-sizing: border-box;
	font-size: $font-size-base;
	font-weight: normal;
	list-style: none;
	text-decoration: none;
	margin: 0;
	padding: 0;
	text-align: unset;
	color: unset;
}

@mixin hover-or-focus() {
	&:hover,
	&:focus {
		@content;
	}
}

Global Styles

Add this to top of global.scss

@use "./mixins" as mix;

*,
*::before,
*::after {
	box-sizing: border-box;
}

Add this to bottom global.scss

// Style Resets
button,
ul,
li {
	@include mix.reset-all-styles();
}

button {
	cursor: pointer;
}

h1,
h2,
h3,
h4,
h5,
h6,
p {
	margin: 0;
	font-weight: unset;
}

Typography

  1. Replace contents of _typography.scss with this: (make sure to check that the fonts that are setup in that file have not changed since this was written)
/* ---------------------------------- */
/*             SF Pro Text            */
/* ---------------------------------- */
@font-face {
	font-display: swap;
	font-family: "SF Pro Text";
	font-style: normal;
	font-weight: 400;
	src: url("../assets/fonts/SF-Pro/SF-Pro-Text-Regular.otf") format("opentype");
}

@font-face {
	font-display: swap;
	font-family: "SF Pro Text";
	font-style: normal;
	font-weight: 600;
	src: url("../assets/fonts/SF-Pro/SF-Pro-Text-Semibold.otf") format("opentype");
}

@font-face {
	font-display: swap;
	font-family: "SF Pro Text";
	font-style: normal;
	font-weight: 700;
	src: url("../assets/fonts/SF-Pro/SF-Pro-Display-Bold.otf") format("opentype");
}

/* ---------------------------------- */
/*              Open Sans             */
/* ---------------------------------- */

@font-face {
	font-display: swap;
	font-family: "Open Sans";
	font-style: normal;
	font-weight: 400;
	src: url("../assets/fonts/openSans/OpenSansRegular.ttf") format("truetype");
}

@font-face {
	font-display: swap;
	font-family: "Open Sans";
	font-style: italic;
	font-weight: 400;
	src: url("../assets/fonts/openSans/OpenSansItalic.ttf") format("truetype");
}

@font-face {
	font-display: swap;
	font-family: "Open Sans";
	font-style: normal;
	font-weight: 600;
	src: url("../assets/fonts/openSans/OpenSansSemibold.ttf") format("truetype");
}

@font-face {
	font-display: swap;
	font-family: "Open Sans";
	font-style: normal;
	font-weight: 700;
	src: url("../assets/fonts/openSans/OpenSansBold.ttf") format("truetype");
}

/* ---------------------------------- */
/*              Segoe UI              */
/* ---------------------------------- */

@font-face {
	font-display: swap;
	font-family: "Segoe UI";
	font-style: normal;
	font-weight: 300;
	src: url("../assets/fonts/segoeui/segoe-ui-semilight-411.ttf") format("truetype");
}

@font-face {
	font-display: swap;
	font-family: "Segoe UI";
	font-style: normal;
	font-weight: 400;
	src: url("../assets/fonts/segoeui/SegoeUI.ttf") format("truetype");
}

@font-face {
	font-display: swap;
	font-family: "Segoe UI";
	font-style: normal;
	font-weight: 700;
	src: url("../assets/fonts/segoeui/SegoeUiBold.ttf") format("truetype");
}
  1. Update usage of font-families to use the standard method of applying font-family and font-weight separately

Font Names: Don't forget to fix the font names used in the font family variables! (eg; OpenSans Regular must now be Open Sans )

  1. Rename font-family variables and remove the ones that were used just to apply font weights. My preferred renaming:

$font-regular -> do not change $font-sf-pro-text -> $font-mac $font-segoe -> $font-windows $font-openSans -> Remove, replace all uses with $font-regular

Color Variables

Refactor color variables to make sense. My preferred setup at time of writing:

NOTE: You can't just copy/paste this over the default color variables because it will break components that use the templates default variable names. You need to manually rename them 1 by 1 using vscode search/replace.

// Brand Colors
$brand-white: #ffffff;
$brand-black: #000000;
$brand-black-text: #222222;

$brand-grey: #cccccc;
$brand-grey-2: #a5a6ac;
$brand-grey-3: #999999;
$brand-grey-4: #7a7a7a;
$brand-grey-5: #666666;
$brand-grey-6: #333333;
$brand-grey-7: #0000001a;

$brand-teal: #a3e5e4;
$brand-navy: #172430;
$brand-red: #ff000a;
$brand-blue: #0074b3;
$brand-orange: #d44633;
$brand-orange-light: #f5673e;

StyleLint

Set Rules:

no-descending-specificity: null

Remove Rules:

order/properties-alphabetical-order: true
import { getCountryName, isCountryCode } from '@redbull/common';
import { MyProfile } from '../api/services/account/type';
import useGetMyDetails from './useGetMyDetails';
const regionAdminGroupNameExpression = /^redbull_([A-Z]{2})_Local Admin$/;
const extractCountryCodeFromGroupName = (groupName: string) => {
const match = groupName.match(regionAdminGroupNameExpression);
if (match) {
return match[1];
}
return undefined;
};
const selectAvailableCountriesFromProfile = (
profile?: MyProfile
): string[] | undefined => {
if (!profile) return undefined;
const availableCountryCodes: string[] = [];
profile.groups.forEach((group) => {
const countryCode = extractCountryCodeFromGroupName(group.name);
if (countryCode) {
availableCountryCodes.push(countryCode);
}
});
const countryNames: string[] = [];
availableCountryCodes.forEach((countryCode) => {
if (!isCountryCode(countryCode)) return;
const countryName = getCountryName(countryCode);
if (!countryName) return;
if (!countryNames.includes(countryName)) {
countryNames.push(countryName);
}
});
console.log('(useAvailableCountries): ', {
profile,
availableCountryCodes,
countryNames,
});
return countryNames;
};
const useAvailableCountries = () => {
const detailsQuery = useGetMyDetails();
return {
...detailsQuery,
data: selectAvailableCountriesFromProfile(detailsQuery.data),
};
};
export default useAvailableCountries;
// -------------------------------
const TYPES = ['Retail', 'Impulse'];
const VARIANTS = [
'RB Zero',
'Energy Drink',
'RB Green Edition',
'RB Red Edition',
'RB Purple Edition',
'RB Orange Edition',
'RB Summer Edition',
'RB Tropical Edition',
'RB Jade Edition',
'RB Sugar Free Edition',
] as const;
const takeRandom = <T>(arr: T[] | readonly T[]): T => {
if (arr.length === 0) {
throw new Error('Cannot take from empty array');
}
return arr[Math.floor(Math.random() * arr.length)];
};
const sample = <T>(arr: T[] | readonly T[], size: number): T[] => {
const result: T[] = [];
for (let i = 0; i < size; i += 1) {
const unused = arr.filter((item) => !result.includes(item));
const index = Math.floor(Math.random() * unused.length);
result.push(unused[index]);
}
return result;
};
const usedItems = new Set<any>();
const sampleWithoutRepeats = <T>(
arr: T[] | readonly T[],
size: number
): T[] => {
const unused = arr.filter((item) => !usedItems.has(item));
const result = sample(unused, size);
result.forEach((item) => usedItems.add(item));
return result;
};
const VARIANT_IMAGES: Record<typeof VARIANTS[number], string> = {
'RB Zero':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/1e/1efb6d62b83cde7787caaa5a1c9a8fb413358dc52e9810533ec92dc8b5f26342-thumb-400x400.png?v=0',
'Energy Drink':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/e6/e6ecbf45bdf7cf7effa13e31c88d4bf4025c648845a7d631d3d112141e5e8c41-thumb-400x400.png?v=0',
'RB Green Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/b3/b3ec8c7c65b939e77856dc00705e61e5b45bcb336dca650807b9a4ac40d7a06f-thumb-400x400.png?v=0',
'RB Red Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/82/8221a27ea215b6f95a87fc897902f16ba8cebddc061cd28540a3d174a4146613-thumb-400x400.png?v=0',
'RB Summer Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/e4/e478c0e2dc0d9f195b41aeab48fb65a9690338a2664107a94c5ca28352239b5e-thumb-400x400.png?v=0',
'RB Purple Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/40/407bfa7b744a2df638abfa24ea31f71ff0d62bbb39bd412b3dc37696b7101b5c-thumb-400x400.png?v=0',
'RB Orange Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/c2/c2695c09477307b08fa2af6f4d33c9f66adce182613bf9f6aae681ee9f1075da-thumb-400x400.png?v=0',
'RB Tropical Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/d0/d0f1a43d32fea2352920a82d1546e6db2f49fa8f2e57d49cdaccd1550a30608f-thumb-400x400.png?v=0',
'RB Jade Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/a2/a25a8933e651be8c1c0778f91a83542a3c062acc1beb2eea701c50b417d3a28e-thumb-400x400.png?v=0',
'RB Sugar Free Edition':
'https://push.bigtincan.co.uk/f/5M3evg9nJjxbL7KGDOQr/thumbnail/41/412806cc0354dd9d24c7785dcc797b9ed92249ee66fb89c2fb53424632a0e23c-thumb-400x400.png?v=0',
};
const ALL_IMAGES = Object.values(VARIANT_IMAGES);
const COLORS = [
'#95002b',
'#580019',
'#D2003C',
'#5685EB',
'#1443A8',
'#11388d',
'#1c2f56',
'#0d1628',
'#0D2D71',
'#369E36',
'#FFC000',
'#938E8F',
'#656061',
'#464243',
'#363334',
'#565152',
];
const UNITS_PER_CASE = [1, 4, 6, 8, 12, 24];
const isKeyOf = <T extends Record<string, any>>(
obj: T,
key: any
): key is keyof T => key in obj;
const ML_SIZES = [250, 355];
const PACK_SIZES = [1, 4];
const generateRandomUpliftDistributionProducts =
(): UpliftDistributionProduct[] =>
TYPES.flatMap((type) => {
const variantsToUse = ['Editions', ...sampleWithoutRepeats(VARIANTS, 3)];
return variantsToUse.flatMap((variant) =>
ML_SIZES.flatMap((sizeMl) =>
PACK_SIZES.map(
(packSize): UpliftDistributionProduct => ({
id: `${type}-${variant}-${sizeMl}-${packSize}`,
image: isKeyOf(VARIANT_IMAGES, variant)
? VARIANT_IMAGES[variant]
: takeRandom(ALL_IMAGES),
packSize,
variant,
type,
sizeMl,
unitsPerCase: takeRandom(UNITS_PER_CASE),
backgroundColor: takeRandom(COLORS),
})
)
)
);
});
console.log(generateRandomUpliftDistributionProducts());
import { safeJsonParse, safeJsonStringify } from '@redbull/common';
import { Result, ServiceError } from '@redbull/services';
import { AxiosResponse } from 'axios';
import AdminService from '../../service';
const extractDataFromDatabaseResponse = (
response:
| Result<null, ServiceError>
| Result<
AxiosResponse<{
value: string;
}>,
null
>
): any => {
const data = response.value?.data?.value;
if (data === undefined) return undefined;
return safeJsonParse(data);
};
type ValidJson =
| string
| number
| boolean
| null
| ValidJson[]
| { [key: string]: ValidJson };
class UserDataDepot<DataType extends ValidJson> extends AdminService {
constructor(key: string) {
super();
this.client.defaults.baseURL = `${this.client.defaults.baseURL}/api/v1/phase3/user/${key}`;
}
public async getData(): Promise<DataType | undefined> {
const response = await this.doGet('');
const data = extractDataFromDatabaseResponse(response);
return data;
}
public async postData(newData: DataType): Promise<DataType> {
const response = await this.doPost('', {
value: safeJsonStringify(newData),
});
const responseData = extractDataFromDatabaseResponse(response);
return responseData;
}
public async applyUpdateToData(
updater: (data: DataType | undefined) => DataType
): Promise<DataType> {
const data = await this.getData();
const updatedData = updater(data);
return this.postData(updatedData);
}
}
import { useCustomQuery } from "@gs-libs/bridge";
import { useCallback, useEffect, useMemo, useState } from "react";
import { queryClient } from "@/services/queryClient";
export type CustomQueryOptions<QueryData> = {
queryFn: () => Promise<QueryData>;
queryKey: unknown[];
retry?:
| boolean
| number
| ((failureCount: number, error: unknown) => boolean);
retryDelay?: number | ((failureCount: number, error: unknown) => number);
initialData?: QueryData;
enabled?: boolean;
cacheTime?: number;
isDataEqual?: (oldData: QueryData | undefined, newData: QueryData) => boolean;
keepPreviousData?: boolean;
onError?: (error: unknown) => void;
onSettled?: (data: QueryData | undefined, error: unknown) => void;
onSuccess?: (data: QueryData) => void;
refetchInterval?: number | false;
refetchIntervalInBackground?: boolean;
refetchOnMount?: boolean | "always";
refetchOnWindowFocus?: boolean | "always";
refetchOnReconnect?: boolean | "always";
staleTime?: number;
structuralSharing?: boolean;
useErrorBoundary?: boolean;
};
export type CustomQueryOptionsWithSelect<QueryData, Selection> =
CustomQueryOptions<QueryData> & {
select: (value: QueryData) => Selection;
};
/**
* Instead of defining our re-usable queries as custom hooks, we can
* instead define them as functions that return their options object.
* This gives us more control over how we use the query, as it becomes
* extremely easy to override options in different contexts.
*
* @example
* const getUserQuery = (userId:string) =>
* queryOptions({
* queryFn: () => fetchUser(userId),
* queryKey: ['user', userId],
* });
*/
export function queryOptions<QueryData, Selection>(
options: CustomQueryOptionsWithSelect<QueryData, Selection>,
): CustomQueryOptionsWithSelect<QueryData, Selection>;
export function queryOptions<QueryData>(
options: CustomQueryOptions<QueryData>,
): CustomQueryOptions<QueryData>;
export function queryOptions(options: any) {
return options;
}
export function useCustomSuspenseQueryData<Data, Selection>(
options: CustomQueryOptionsWithSelect<Data, Selection>,
): Selection;
export function useCustomSuspenseQueryData<Data>(
options: CustomQueryOptions<Data>,
): Data;
export function useCustomSuspenseQueryData(options: any) {
return useCustomQuery({
...options,
suspense: true,
}).data;
}
export const getQueryState = <Data>(options: CustomQueryOptions<Data>) =>
queryClient.getQueryState<Data>(options.queryKey);
export const fetchQuery = async <Data>(
options: CustomQueryOptions<Data>,
): Promise<Data> => queryClient.fetchQuery<Data>(options);
/**
* If query has already been fetched, return that data. Otherwise,
* fetch the query and return the data.
*/
export const ensureQuery = async <Data>(
options: CustomQueryOptions<Data>,
): Promise<Data> => {
const beforePrefetchQueryState = getQueryState(options);
if (beforePrefetchQueryState?.data === "success") {
return beforePrefetchQueryState.data;
}
await queryClient.prefetchQuery(options);
const afterPrefetchQueryState = getQueryState(options);
if (afterPrefetchQueryState?.data) {
return afterPrefetchQueryState.data;
}
if (afterPrefetchQueryState?.error) {
throw afterPrefetchQueryState.error;
}
throw new Error("Query failed to fetch");
};
type StateUpdater<T> = ((oldState: T) => T) | T;
const deriveNewState = <T>(oldState: T, updater: StateUpdater<T>): T =>
typeof updater === "function"
? (updater as (_oldState: T) => T)(oldState)
: updater;
export const useQueryOptimisticUpdater = <Data>(
options: CustomQueryOptions<Data>,
) => {
const query = useCustomQuery(options);
const [previousData, setPreviousData] = useState<Data | undefined>(undefined);
const [lastOptimisticUpdateDate, setLastOptimisticUpdateDate] = useState<
Date | undefined
>(undefined);
const [lastDetectedDataUpdateDate, setLastDetectedDataUpdateDate] = useState<
Date | undefined
>(undefined);
useEffect(() => {
setLastDetectedDataUpdateDate(new Date());
}, [query.data]);
const currentQueryDataIsOptimistic = useMemo(() => {
if (!lastOptimisticUpdateDate) return false;
if (!lastDetectedDataUpdateDate) return true;
const msDiffBetweenLastOptimisticUpdateAndLastDetectedDataUpdate = Math.abs(
lastOptimisticUpdateDate.getTime() - lastDetectedDataUpdateDate.getTime(),
);
return msDiffBetweenLastOptimisticUpdateAndLastDetectedDataUpdate < 100;
}, [lastOptimisticUpdateDate, lastDetectedDataUpdateDate]);
const revert = useCallback(() => {
if (currentQueryDataIsOptimistic) {
queryClient.setQueryData(options.queryKey, previousData);
}
}, [previousData, options.queryKey, currentQueryDataIsOptimistic]);
const optimisticUpdate = useCallback(
(stateUpdater: StateUpdater<Data | undefined>) => {
const nextState = deriveNewState(query.data, stateUpdater);
setPreviousData(query.data);
queryClient.setQueryData(options.queryKey, nextState);
setLastOptimisticUpdateDate(new Date());
},
[query.data, options.queryKey],
);
/**
* Takes a promise and an optimistic update for the query. Applies the
* optimistic update straight away, then waits for the promise to resolve.
* If the promise resolves, the query is refetched, and if a value adapter
* function is provided, the value return by the promise is adapted and
* inserted into the query while the query is being refetched.
*
* If the promise rejects, the optimistic update is reverted.
*/
const connectToPromise = useCallback(
async <PromiseValue>(
thePromise: Promise<PromiseValue>,
optimisticValue: Data,
valueAdapter?: (promiseValue: PromiseValue) => Data,
) => {
optimisticUpdate(optimisticValue);
try {
const result = await thePromise;
if (valueAdapter) {
const newQueryData = valueAdapter(result);
optimisticUpdate(newQueryData);
}
query.refetch();
} catch (error) {
revert();
throw error;
}
},
[optimisticUpdate, revert, query],
);
return {
optimisticUpdate,
revert,
connectToPromise,
currentQueryDataIsOptimistic,
};
};
export const readFirstJsonFileInStory = async <Data>(
storyId: number,
): Promise<Data> => {
const fullStoryRequest = await bridge.getEntity<Story>({
entityName: "story",
id: storyId,
});
if (!fullStoryRequest.value)
throw new Error(`Unable to fetch story entity with id ${storyId}`);
const firstJsonFile = fullStoryRequest.value.files?.find((f) =>
f.filename.endsWith(".json"),
);
if (!firstJsonFile)
throw new Error(
`Unable to find any JSON files from story "${fullStoryRequest.value.title}" (id: ${storyId})`,
);
const readRequest = await bridge.readFile({ fileId: firstJsonFile.id });
if (!readRequest.value)
throw new Error(
`Unable to read file "${firstJsonFile.description}" (id: ${firstJsonFile.id})`,
);
return JSON.parse(readRequest.value) as Data;
};

Route Helpers Setup

//routes.ts
import { generatePath } from "react-router";
import { LookupKeyByValue, OverrideIncompatibleValues } from "../type";

export type AppRoutePath =
	| "/"
	| "/detailPage/:filter?/:id/:name"
	| "/brand/:title/:pageKey"
	| "/detailPage/:tabName/channel/:id/:name";

export type AppRouteParams = {
	root: never;
	detailPage: {
		filter: string;
		name: string;
		id: number;
	};
	brand: Record<"title" | "pageKey", string>;
	detailPageWithPreselectedChannel: {
		tabName: string;
		name: string;
		id: number;
	};
};

export type AppRouteName = keyof AppRouteParams;

export type GetUrlParamsForPage<RouteName extends AppRouteName> =
	OverrideIncompatibleValues<AppRouteParams[RouteName], string>;

export const APP_ROUTES_PATHS: Record<AppRouteName, AppRoutePath> = {
	root: "/",
	detailPage: "/detailPage/:filter?/:id/:name",
	brand: "/brand/:title/:pageKey",
	detailPageWithPreselectedChannel: "/detailPage/:tabName/channel/:id/:name",
};

type AppRouteNamesThatHaveNoParams = LookupKeyByValue<AppRouteParams, never>;

export function composeAppRouteUrl<
	RouteName extends Exclude<AppRouteName, AppRouteNamesThatHaveNoParams>,
>(routeName: RouteName, params: AppRouteParams[RouteName]): string;
export function composeAppRouteUrl<
	RouteName extends AppRouteNamesThatHaveNoParams,
>(routeName: RouteName): string;
export function composeAppRouteUrl<RouteName extends AppRouteName>(
	routeName: RouteName,
	...extraArgs: RouteName extends AppRouteNamesThatHaveNoParams
		? []
		: [AppRouteParams[RouteName]]
) {
	return generatePath(APP_ROUTES_PATHS[routeName], extraArgs[0]);
}

Usage

When creating a link you can use the composeAppRouteUrl function to safely generate the pathname:

<Link
	href={composeAppRouteUrl("brand", {
		title: "some-title",
		pageKey: "some-page-key",
	})}
>
	To Brand Page
</Link>

In your page components you can use the GetUrlParamsForPage type:

//BrandPage.tsx
type Params = GetUrlParamsForPage<"brand">;

const BrandPage = () => {
	const { title, pageKey } = useParams<Params>();
	//...
};

Dependencies

export type ValueFrom<T> = T[keyof T];

export type LookupKeyByValue<Obj, Value> = Extract<
	ValueFrom<{
		[K in keyof Obj]: Obj[K] extends Value ? K : never;
	}>,
	keyof Obj
>;

export type OverrideIncompatibleValues<Obj, NewValue> = {
	[K in keyof Obj]: Obj[K] extends NewValue ? Obj[K] : NewValue;
};
// Paste this into "App.tsx" and invoke this component
// at the root.
const RouteLogger = () => {
const { pathname } = useLocation();
React.useEffect(() => {
console.log(
`%c[PATHNAME]: %c${pathname}`,
"font-weight: bold; background: white; color: dodgerblue;",
"font-weight: bold; background: white; color: black;",
);
}, [pathname]);
return null;
};

NODE_OPTIONS=--max-old-space-size=6096 *your-command-here*

import { File, Story } from "@gs-libs/bridge";
import { satisfiesShim } from ".";
const HUB_SORT_BY_VALUES = [
"createDate",
"title",
"likesCount",
"likes",
"authorFirstName",
"author_first_name",
"authorLastName",
"author_last_name",
"sequence",
"score",
"readCount",
"date",
"mostread",
"leastread",
"size",
"description",
"storyCount",
"channelCount",
"firstName",
"lastName",
"content_score",
] as const;
export type HubSortBy = (typeof HUB_SORT_BY_VALUES)[number];
const channelSortByValuesThatCanBeDoneOnHub = satisfiesShim<HubSortBy[]>()([
"mostread",
"title",
"content_score",
"likes",
"sequence",
"date",
"author_first_name",
"author_last_name",
]);
type ChannelSortByThatCanBeDoneOnHub =
(typeof channelSortByValuesThatCanBeDoneOnHub)[number];
export const isChannelSortByValueThatCanBeDoneOnHub = (
val: string,
): val is ChannelSortByThatCanBeDoneOnHub => {
return channelSortByValuesThatCanBeDoneOnHub.includes(
val as ChannelSortByThatCanBeDoneOnHub,
);
};
/**
* Pass a value from `channel.defaultSortBy`, if that type of sorting
* can be achieve via `bridge.getList`, then we return the value that
* you need to pass to the `sortBy` field. If that type of sorting is
* not supported in `bridge.getList` then this returns undefined.
*/
export const convertDefaultSortByToHubSortBy = (
defaultSortBy: string | undefined,
): string | undefined => {
if (!isChannelSortByValueThatCanBeDoneOnHub(defaultSortBy ?? ""))
return undefined;
switch (defaultSortBy) {
case "mostread":
return "readCount";
case "content_score":
return "score";
case "likes":
return "likesCount";
case "author_first_name":
return "authorFirstName";
case "author_last_name":
return "authorLastName";
case "sequence":
return "sequence";
case "date":
return "createDate";
case "title":
return "title";
default:
return undefined;
}
};
const coerceStringToHubSortBy = (sortBy?: string): HubSortBy | undefined => {
if (HUB_SORT_BY_VALUES.includes(sortBy as HubSortBy)) {
return sortBy as HubSortBy;
}
switch (sortBy) {
case undefined:
case "":
return undefined;
default:
console.warn(
`Received invalid sortBy value "${sortBy}", defaulting to "createDate"`,
);
return "createDate";
}
};
const compareFilesAccordingToSortBy = (
fileA: File,
fileB: File,
sortBy: HubSortBy | undefined,
): number => {
switch (sortBy) {
case "createDate":
case "date":
return fileB.createDate - fileA.createDate;
case "description":
case "title":
return fileA.description.localeCompare(fileB.description);
case "size":
return fileB.size - fileA.size;
default:
if (sortBy !== undefined) {
// `undefined` sortBy is allowed so we don't log a warning
console.warn(
`Received invalid file sortBy value "${sortBy}", no sorting will be applied`,
);
}
return 0;
}
};
const compareStoriesAccordingToSortBy = (
storyA: Story,
storyB: Story,
sortBy: HubSortBy | undefined,
): number => {
switch (sortBy) {
case "createDate":
case "date":
return storyB.createDate - storyA.createDate;
case "title":
return storyA.title.localeCompare(storyB.title);
case "readCount":
case "mostread":
case "leastread":
if (!storyA.readCount || !storyB.readCount) {
if (process.env.NODE_ENV === "development") {
console.warn(
"ERROR: There was a request to sort by read count, but the provided story objects did not have their read count data. You must make sure that `includeAttributes: ['readCount']` is passed to the `bridge.getList` call.",
);
}
return 0;
}
if (sortBy === "leastread") {
return storyA.readCount - storyB.readCount;
}
return (storyB?.readCount ?? 0) - (storyA?.readCount ?? 0);
case "authorFirstName":
case "author_first_name":
case "authorLastName":
case "author_last_name":
if (!storyA.author || !storyB.author) {
if (process.env.NODE_ENV === "development")
console.warn(
"ERROR: There was a request to sort by author's name, but the provided story objects did not have their author data.",
);
return 0;
}
if (sortBy === "authorFirstName" || sortBy === "author_first_name") {
return storyA.author.firstName.localeCompare(storyB.author.firstName);
}
return storyA.author.lastName.localeCompare(storyB.author.lastName);
case "sequence":
return (storyB.sequence ?? 0) - (storyA.sequence ?? 0);
case "content_score":
return 0;
case "likesCount":
case "likes":
return storyB.likesCount - storyA.likesCount;
default:
if (sortBy !== undefined) {
// `undefined` sortBy is allowed so we don't log a warning
console.warn(
`Received invalid story sortBy value "${sortBy}", no sorting will be applied`,
);
}
return 0;
}
};
export const sortFilesByHubSort = <F extends File>(
files: F[] | readonly F[],
sortBy: string | undefined,
): F[] => {
const typedSortBy = coerceStringToHubSortBy(sortBy);
const sortedFiles = [...files].sort((fileA, fileB) =>
compareFilesAccordingToSortBy(fileA, fileB, typedSortBy),
);
return sortedFiles;
};
export const sortStoriesByHubSort = <S extends Story>(
stories: S[] | readonly S[],
sortBy: string | undefined,
): S[] => {
const typedSortBy = coerceStringToHubSortBy(sortBy);
if (
isChannelSortByValueThatCanBeDoneOnHub(sortBy ?? "") &&
process.env.NODE_ENV === "development"
) {
console.warn(
`You are performing client-side sorting to sort by ${sortBy}. This can be achieved by passing ${sortBy} to convertDefaultSortByToHubSortBy, and then passing the output of that to bridge.getList sortBy field. This is preferable to doing client side sorting.`,
);
}
const sortedStories = [...stories].sort((storyA, storyB) =>
compareStoriesAccordingToSortBy(storyA, storyB, typedSortBy),
);
return sortedStories;
};
import { useCallback, useState } from "react";
import { SwiperClass } from "swiper/react";
const countVisibleItemsInSwiperInstance = (swiper: SwiperClass) => {
const widthOfSlidesContainer = swiper.el.clientWidth;
const slideWidth = swiper.slides[0].clientWidth;
const spaceBetweenSlides = Number(swiper.params?.spaceBetween ?? 0);
let visibleSlidesCounted = 0;
while (visibleSlidesCounted < swiper.slides.length) {
const totalEmptySpace = spaceBetweenSlides * visibleSlidesCounted;
const totalWidthOfVisibleSlides =
slideWidth * (visibleSlidesCounted + 1) + totalEmptySpace;
if (totalWidthOfVisibleSlides > widthOfSlidesContainer) {
break;
}
visibleSlidesCounted += 1;
}
return visibleSlidesCounted;
};
export const useSwiperAutoGroups = () => {
const [slidesPerGroup, setSlidesPerGroup] = useState(0);
const updateSlidesPerGroup = useCallback((swiper: SwiperClass) => {
const visibleItems = countVisibleItemsInSwiperInstance(swiper);
setSlidesPerGroup(visibleItems);
}, []);
return { slidesPerGroup, updateSlidesPerGroup };
};
@use "../../../style/variables" as *;
@use "../../../style/mixins" as *;
$numbered-icon-gap: 4px;
.numberedIcon {
display: flex;
gap: $numbered-icon-gap;
align-items: center;
* {
font-size: 13px;
// tailwind shadow-lg
text-shadow: $box-shadow-small-card-active;
}
svg {
height: 22px;
filter: drop-shadow(0px 1px 2px #00000080);
}
}
.thumbnailCard {
display: flex;
flex-direction: column;
align-items: center;
&:hover {
cursor: pointer;
.tile {
box-shadow: $box-shadow-small-card;
}
.text {
text-decoration: underline;
}
}
.tile {
$tile-border-radius: 8px;
position: relative;
border-radius: $tile-border-radius;
margin-bottom: 8px;
color: white;
// tailwind base shadow
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
transition: 0.1s box-shadow;
.topGradient,
.bottomGradient {
display: none;
position: absolute;
left: 0;
border-radius: $tile-border-radius;
height: 64px;
width: 100%;
}
&.showTopGradient .topGradient {
display: block;
}
&.showBottomGradient .bottomGradient {
display: block;
}
.topGradient {
top: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0) 89.84%
);
}
.bottomGradient {
bottom: 0;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.3) 100%
);
}
$gutter: 8px;
.engagement {
display: flex;
gap: $numbered-icon-gap;
position: absolute;
top: $gutter;
right: $gutter;
}
.fileCount {
position: absolute;
bottom: $gutter;
left: $gutter;
}
}
.text {
display: flex;
flex-direction: column;
align-items: center;
.label {
@include clamp(1);
text-align: center;
color: black;
margin-bottom: 4px;
}
.bottomRow {
display: flex;
align-items: center;
gap: rem(8px);
height: 24px;
font-size: rem(12px);
.badge {
&.text {
color: var(--badge-color);
font-weight: 600;
}
&.pill {
padding: 4px 8px;
border-radius: 6px;
background-color: var(--badge-color);
color: white;
}
}
}
}
}
import * as React from "react";
import cx from "classnames";
import { File, FileCollection, Story } from "@gs-libs/bridge";
import dayjs from "dayjs";
import styles from "./ThumbnailCard.module.scss";
import {
isFile,
isFileCollection,
isStory,
} from "../../../utils/bridgeTypeguards";
import {
getColorForFileCategory,
getFileCategoryShortDisplayName,
} from "../../../utils";
import { bridge } from "../../../services";
import { ReactComponent as LikeIcon } from "../../../assets/images/LikeSolid.svg";
import { ReactComponent as FileIcon } from "../../../assets/images/FileSolid.svg";
import { ReactComponent as CommentIcon } from "../../../assets/images/CommentSolid.svg";
import fallbackThumbnailSrc from "../../../assets/images/FallbackThumbnail.png";
type AllowedEntity = Story | File | FileCollection;
type EntityBadge = {
type: "text" | "pill";
text: string;
color: string;
};
type EntityEngagementMetrics = {
likes: number;
comments: number;
};
const getEntityLabel = (entity: AllowedEntity): string => {
if (isFile(entity)) return entity.description;
if (isFileCollection(entity)) return entity.name;
return entity.title;
};
const getEntityFileCount = (entity: AllowedEntity): number | undefined => {
if (isFile(entity)) return undefined;
if (isStory(entity)) return entity.fileCount;
return entity.files.length;
};
const getEntityThumbnail = (entity: AllowedEntity): string | undefined => {
if (isFileCollection(entity))
return entity.files[0]?.thumbnail || fallbackThumbnailSrc;
return entity.thumbnail || fallbackThumbnailSrc;
};
const composeFileCountBadgeText = (fileCount: number): string =>
`${fileCount} Files`;
const getEntityBadge = (entity: AllowedEntity): EntityBadge | undefined => {
const badgeData = ((): EntityBadge => {
if (
isFile(entity) ||
(isFileCollection(entity) && entity.files.length === 1)
) {
const file = isFile(entity) ? entity : entity.files[0];
return {
type: "pill",
text: getFileCategoryShortDisplayName(file.category),
color: getColorForFileCategory(file.category),
};
}
if (isFileCollection(entity))
return {
type: "pill",
text: composeFileCountBadgeText(entity.files.length),
color: "#000000",
};
return {
type: "text",
text: entity.badgeTitle,
color: entity.badgeColour,
};
})();
if (!badgeData.text || badgeData.text === composeFileCountBadgeText(0))
return undefined;
return badgeData;
};
const getEntityEngagementMetrics = (
entity: AllowedEntity,
): EntityEngagementMetrics | undefined => {
if (!isStory(entity)) return undefined;
return {
comments: entity.commentCount,
likes: entity.likesCount,
};
};
const openEntity = async (entity: AllowedEntity): Promise<void> => {
if (isFileCollection(entity)) {
await Promise.all(
entity.files.map((file) =>
bridge.openEntity({ entityName: "file", id: file.id }),
),
);
return;
}
await bridge.openEntity({ entityName: entity.type, id: entity.id });
};
type NumberedIconProps = {
icon: typeof LikeIcon;
count: number;
};
const NumberedIcon = ({ icon: Icon, count }: NumberedIconProps) => (
<div className={styles.numberedIcon}>
<Icon />
<div className={styles.number}>{count}</div>
</div>
);
export type ThumbnailCardProps = {
className?: string;
entity: AllowedEntity;
width: number;
};
const ThumbnailCard = ({ className, entity, width }: ThumbnailCardProps) => {
const badge = getEntityBadge(entity);
const engagementMetrics = getEntityEngagementMetrics(entity);
const fileCount = getEntityFileCount(entity);
return (
<div
className={cx(styles.thumbnailCard, className)}
style={{
width,
}}
onClick={() => openEntity(entity)}
>
<div
className={cx(
styles.tile,
engagementMetrics &&
(engagementMetrics.likes > 0 || engagementMetrics.comments > 0) &&
styles.showTopGradient,
fileCount && styles.showBottomGradient,
)}
style={{
backgroundImage: `url(${getEntityThumbnail(entity)})`,
width,
height: width,
}}
>
<div className={styles.topGradient} />
<div className={styles.bottomGradient} />
{engagementMetrics && (
<div className={styles.engagement}>
{engagementMetrics.likes > 0 && (
<NumberedIcon count={engagementMetrics.likes} icon={LikeIcon} />
)}
{engagementMetrics.comments > 0 && (
<NumberedIcon
count={engagementMetrics.comments}
icon={CommentIcon}
/>
)}
</div>
)}
{!!fileCount && (
<div className={styles.fileCount}>
<NumberedIcon count={fileCount} icon={FileIcon} />
</div>
)}
</div>
<div className={styles.text}>
<div className={styles.label}>{getEntityLabel(entity)}</div>
<div className={styles.bottomRow}>
{!!badge && (
<div
className={cx(styles.badge, styles[badge.type])}
style={
{
"--badge-color": badge.color,
} as any
}
>
{badge.text}
</div>
)}
<div className={styles.date}>
{dayjs.unix(entity.createDate).format("MMMM DD, YYYY")}
</div>
</div>
</div>
</div>
);
};
export default ThumbnailCard;

Translation Transcription Process

  1. Copy an existing language translation
  2. Replace all the values in that file with empty strings. You cando this quickly using VSCode's RegEx find/replace tool, simply replace this: :\s"(.*)" with this: : "". NOTE: We do this step so it's easy to keep track of where you are up to.
  3. Copy in the translations
// Use this to type guard the result of the bridge call
// so you don't have to null check it all the time.
const throwIfBadBridgeResult = <RequestResult extends Result<any>>(
result: RequestResult,
) => {
// These 2 conditions are effectively equivalent, including
// !result.value in the the throw condition means that all
// code after it will have type safety for result.value.
if (result.error && !result.value) throw Error(result.error);
};
export default throwIfBadBridgeResult;
:root {
@each $name, $value in $grid-breakpoints {
--breakpoint-#{$name}: #{$value};
}
}
import { useLayoutEffect, useState } from "react";
type QueryOperator = "=" | "<" | ">" | "<=" | ">=" | "!=";
type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl";
const convertPxToNumber = (px: string) => parseInt(px.replace("px", ""), 10);
const getBreakpointValue = (breakpoint: Breakpoint) => {
/**
* In our global styles, we create CSS variables that hold the
* values of our breakpoints.
*/
const rawValue = getComputedStyle(document.documentElement).getPropertyValue(
`--breakpoint-${breakpoint}`,
);
return convertPxToNumber(rawValue);
};
const evaluateBreakpointQuery = (
operator: QueryOperator,
breakpoint: Breakpoint,
): boolean => {
const value = getBreakpointValue(breakpoint);
const windowWidth = window.innerWidth;
switch (operator) {
case "=":
return windowWidth === value;
case "<":
return windowWidth < value;
case ">":
return windowWidth > value;
case "<=":
return windowWidth <= value;
case ">=":
return windowWidth >= value;
case "!=":
return windowWidth !== value;
default:
throw new Error(`Invalid operator: ${operator}`);
}
};
const useBreakpointQuery = (
operator: QueryOperator,
breakpoint: Breakpoint,
) => {
const [matches, setMatches] = useState(
evaluateBreakpointQuery(operator, breakpoint),
);
useLayoutEffect(() => {
setMatches(evaluateBreakpointQuery(operator, breakpoint));
});
return matches;
};
export default useBreakpointQuery;
import { UseQueryOptions, useQuery } from "react-query";
import bridge from "$bridge";
import queryKeys from "@/queryKeys";
export const isStandardBridgeError = (
data: unknown,
): data is {
message: string;
statusCode: number;
} => {
if (typeof data !== "object" || data === null) return false;
const { message, statusCode } = data as {
message: unknown;
statusCode: unknown;
};
return typeof message === "string" && typeof statusCode === "number";
};
type EntityType = "tab" | "channel" | "file" | "story";
const getEntityChildrenType = (
entityType: EntityType,
): EntityType | undefined => {
switch (entityType) {
case "tab":
return "channel";
case "channel":
return "story";
case "story":
return "file";
case "file":
return undefined;
default:
throw new Error(
`unable to determine children type of entity type "${entityType}"`,
);
}
};
const isEntityThatCanBeIndividuallyFetched = (
entityType: EntityType,
): entityType is "story" | "file" =>
entityType === "file" || entityType === "story";
const PERMISSION_DENIED_ERROR_CODE = 403;
const getUserCanAccessEntity = async (
entityType: EntityType,
entityId: number,
): Promise<boolean> => {
const canBeIndividuallyFetched =
isEntityThatCanBeIndividuallyFetched(entityType);
// eslint-disable-next-line no-useless-catch
try {
if (canBeIndividuallyFetched) {
await bridge.getEntity({
entityName: entityType,
id: entityId,
});
return true;
}
const entityChildrenType = getEntityChildrenType(entityType);
if (!entityChildrenType)
throw new Error("type safety, this will never hit");
const childrenList = await bridge.getList({
entityName: entityChildrenType,
parentEntityName: entityType,
peid: entityId,
showAlias: true,
limit: 1,
});
if (childrenList.length === 0) return false;
return true;
} catch (e) {
if (!isStandardBridgeError(e)) throw e;
const errorWasNotPermissionRelated =
e.statusCode !== PERMISSION_DENIED_ERROR_CODE;
if (errorWasNotPermissionRelated)
throw new Error(`${e.message} (${e.statusCode})`);
return false;
}
};
export const composeUserCanAccessEntityQueryOptions = (
entityType: EntityType,
entityId: number,
) =>
({
queryFn: () => getUserCanAccessEntity(entityType, entityId),
queryKey: queryKeys.getUserCanAccessEntity(entityType, entityId),
} satisfies UseQueryOptions<boolean>);
const useGetUserCanAccessEntity = (entityType: EntityType, entityId: number) =>
useQuery(composeUserCanAccessEntityQueryOptions(entityType, entityId));
export default useGetUserCanAccessEntity;
import { useBridge, useMutation } from "@gs-libs/bridge";
const useOpenEntity = () => {
const bridge = useBridge();
const { mutate, mutateAsync, ...mutation } = useMutation(bridge.openEntity);
return { openEntity: mutate, openEntityAsync: mutateAsync, ...mutation };
};
export default useOpenEntity;
const useRouterDebugging = () => {
const { pathname } = useLocation();
React.useEffect(() => {
console.debug(`pathname: ${pathname}`);
}, [pathname]);
};
import { MutableRefObject, useEffect, useRef } from "react";
const wrapSubStringWithTag = (
text: string,
textToWrap: string,
tag: string,
) => {
const wrappedText = `<${tag}>${textToWrap}</${tag}>`;
const patternThatMatchesWrappedText = new RegExp(
wrappedText,
"g",
);
const wrappingHasAlreadyBeenApplied = text.match(
patternThatMatchesWrappedText,
);
if (wrappingHasAlreadyBeenApplied) return text;
const textWithWrappingApplied = text.replace(
new RegExp(textToWrap, "g"),
wrappedText,
);
return textWithWrappingApplied;
};
// Pass the returned ref to an element to wrap small symbols
// (eg; `®`) in `sup` tags
const useSmallSymbolsRef = () => {
const elementRef = useRef<HTMLElement | null>();
const lastWrappedTextRef = useRef<string | null>(null);
useEffect(() => {
const textHasChanged =
lastWrappedTextRef.current !== elementRef.current?.innerHTML;
if (elementRef.current && textHasChanged) {
elementRef.current.innerHTML = wrapSubStringWithTag(
elementRef.current.innerHTML,
"®",
"sup",
);
lastWrappedTextRef.current = elementRef.current.innerHTML;
}
});
return elementRef as MutableRefObject<any>;
};
export default useSmallSymbolsRef;
/**
* Must add corresponding global styles, eg;
*
sub,
sup {
font-size: 70%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.4em;
}
sub {
bottom: -0.25em;
}
*/

Using Swiper

Import Swiper Styles

Add this to index.tsx

import "swiper/swiper-bundle.min.css";
import "swiper/modules/navigation/navigation.min.css";

Import Customizations into Global Styles

Add to top global.scss:

@import "./swiper.scss";

Import Correct Component

Make sure you are importing the Swiper and SwiperSlide components from swiper/react

import { Swiper, SwiperSlide } from "swiper/react";

Must Define Width

If you don't set a width on the root Swiper component or somewhere above it, then it will bug out and have a massive width.

import dayjs, { Dayjs } from "dayjs";
/**
* This is a helper function that helps you write
* utility functions for dates with 'dayjs' without
* having to first wrap the date parameter with
* `dayjs()` and then call `.toDate()` at the end.
*/
const withDayjs =
<ExtraParams extends any[] = []>(
callback: (p: Dayjs, ...extraParams: ExtraParams) => Dayjs,
) =>
(date: Date, ...extraParams: ExtraParams): Date => {
const wrappedDate = dayjs(date);
const updated = callback(wrappedDate, ...extraParams);
return updated.toDate();
};
/* ------------ Examples ------------ */
const setDateToStartOfNextHour = withDayjs((date) =>
date.add(1, "hour").startOf("hour"),
);
const addMonths = withDayjs<[number]>((date, amount) =>
date.add(amount, "month"),
);
export default withDayjs;
type BasicAxiosCall<T> = (
address: string,
config?: AxiosRequestConfig
) => Promise<AxiosResponse<T>>;
const wrapAxiosCall =
(axiosCall: BasicAxiosCall<any>) =>
<T>(address: string, config: AxiosRequestConfig = {}): Promise<T> =>
axiosCall(address, config).then((res) => res.data);
class BrookdaleAdmin {
get = wrapAxiosCall(brookdaleAdminApi.get);
post = wrapAxiosCall(brookdaleAdminApi.post);
put = wrapAxiosCall(brookdaleAdminApi.put);
delete = wrapAxiosCall(brookdaleAdminApi.delete);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment