diff --git a/.env b/.env index 3f295f058..fd3020c47 100644 --- a/.env +++ b/.env @@ -1,2 +1 @@ -APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=0 APP_API_BASE_URL=https://cdn.jwplayer.com diff --git a/.env.demo b/.env.demo index 3de722962..4b41f299d 100644 --- a/.env.demo +++ b/.env.demo @@ -1,3 +1 @@ -APP_CONFIG_DEFAULT_SOURCE= -APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1 -APP_DEMO_DEFAULT_CONFIG_ID=225tvq1i +APP_DEMO_FALLBACK_CONFIG_ID=225tvq1i diff --git a/.env.dev b/.env.dev deleted file mode 100644 index 22d12294a..000000000 --- a/.env.dev +++ /dev/null @@ -1,3 +0,0 @@ -APP_CONFIG_DEFAULT_SOURCE=gnnuzabk -APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1 -APP_API_BASE_URL=https://cdn.jwplayer.com diff --git a/.env.jwdev b/.env.jwdev index 98bd3e2d6..8fa586f15 100644 --- a/.env.jwdev +++ b/.env.jwdev @@ -1,3 +1 @@ -APP_CONFIG_DEFAULT_SOURCE=uzcyv8xh -APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1 APP_API_BASE_URL=https://content-portal.jwplatform.com diff --git a/.env.test b/.env.test deleted file mode 100644 index 7c6aada65..000000000 --- a/.env.test +++ /dev/null @@ -1,4 +0,0 @@ -APP_CONFIG_DEFAULT_SOURCE=gnnuzabk -APP_CONFIG_ALLOWED_SOURCES=kujzeu1b ata6ucb8 nvqkufhy 7weyqrua ozylzc5m -APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=0 -APP_API_BASE_URL=https://cdn.jwplayer.com diff --git a/.gitignore b/.gitignore index 33e9c258d..abd7da890 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ firebase-debug.log .idea .DS_Store .vscode/ +# Exclude ini files because they have customer specific data +ini/*.ini diff --git a/docs/configuration.md b/docs/configuration.md index 3843014f5..3b8cea013 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,34 +1,28 @@ # Configuration -JW OTT Webapp uses a JSON configuration file to store all configuration parameters. This file can be located at the following location: [`./public/config.json`](public/config.json). +The JW OTT Webapp is designed to consume a json configuration file served by the [JWP Delivery API](https://docs.jwplayer.com/platform/reference/get_apps-configs-config-id-json). +The easiest way to maintain configuration files is to use the 'Apps' section in your [JWP Dashboard account](https://dashboard.jwplayer.com/). -## Dynamic Configuration File Sources +## Configuration File Source -Using environment variables for build parameters, you can adjust what config file the application loads at startup and which, if any, it will allow to be set using the `app-config=` query param. The location can be specified using either the 8-character ID of the config from the JW Player dashboard (i.e. `gnnuzabk`), in which case the file will be loaded from the JW Player App Config delivery endpoint, or a relative (i.e. `/config.json`) or absolute (i.e. `https://cdn.jwplayer.com/apps/configs/gnnuzabk.json`) path, in which case the file will be loaded using fetch to make a 'get' request. +Which app config file the application uses is determined by the [ini file](initialization-file.md). -As mentioned above, if you have 1 or more allowed sources (see [`APP_CONFIG_ALLOWED_SOURCES`](#configuration-file-source-build-params) below), you can switch between them using the `app-config` (or `c`) query parameter when you first navigate to the web app. The parameter is automatically evaluated, loaded, and stored in session storage and should remain part of the url as the user navigates around the site. +You can specify the default that the application starts with and also which config, if any, it will allow to be set using the [`app-config=` query param](#switching-between-app-configs). +The location is usually specified by the 8-character ID (i.e. `gnnuzabk`) of the App Config from your JWP account, in which case the file will be loaded from the JW Player App Config delivery endpoint (i.e. `https://cdn.jwplayer.com/apps/configs/gnnuzabk.json`). +You may also specify a relative or absolute URL. -Note: to clear the value from local storage and return to the default, you can navigate to the site with the query parameter but leaving the value blank (i.e. `https://?app-config=`) +### Switching between app configs -You can also tell the application to allow any config source location (see [`APP_UNSAFE_ALLOW_DYNAMIC_CONFIG`](#configuration-file-source-build-params) below), but this is a potential vulnerability, since any user could then pass in any valid config file, completely changing the content of the application on your domain. It is recommended to limit this 'unsafe' option to dev, testing, demo environments, etc. +As mentioned above, if you have 1 or more additional allowed sources (see additionalAllowedConfigSources in [`initialization-file`](initialization-file.md)), you can switch between them by adding `app-config=` as a query parameter in the web app URL in your browser (i.e. `https:///?app-config=gnnuzabk`.) -### Configuration File Source Build Params +The parameter is automatically evaluated, loaded, and stored in browser session storage and should remain part of the url as the user navigates around the site. -**APP_CONFIG_DEFAULT_SOURCE** +>*Note: Be aware that this mechanism only sets the config for the local machine, browser, and session that you are accessing the site with and it does not change the default hosted app for other users.* -The ID or url path for the config that the web app will initially load with. Be careful to ensure that this config is always available or your app will fail to load. +Even sharing URL's should work as long as the query parameter of the desired config is part of the URL. However, once the query parameter is removed and the stored value in the session is released, the application will revert to loading the default config source. ---- - -**APP_CONFIG_ALLOWED_SOURCES** - -A **space** separated list of 8-character IDs and/or url paths for config files that can be set using the `app-config=` query param. You can mix ID's and paths as long as they are space separated. You do not need to add the value of `APP_CONFIG_DEFAULT_SOURCE` to this property. - ---- - -**APP_UNSAFE_ALLOW_DYNAMIC_CONFIG** - boolean flag which if true, enables any config ID or path to be specified with the `app-config=` query param +>*Note: to clear the value from session storage and return to the default, you can navigate to the site with a blank query parameter value (i.e. `?app-config=`)* - **Warning** - Generally the `APP_UNSAFE_ALLOW_DYNAMIC_CONFIG` option should only be used for dev and test, because it opens up your application so that anyone can specify their own config to run on your domain ## Available Configuration Parameters diff --git a/docs/initialization-file.md b/docs/initialization-file.md new file mode 100644 index 000000000..bab47ff33 --- /dev/null +++ b/docs/initialization-file.md @@ -0,0 +1,28 @@ +# Initialization (ini) File + +The JW OTT Web App loads a small initialization (.ini) file at startup. This file provides a mechanism to set key parameters without modifying the source code. +Template ini files are included in the repo and with the pre-compiled production release builds ([.webapp.prod.ini](/ini/templates/.webapp.prod.ini)). +Make sure you include a copy of the ini file edited to include your account data at `/public/.webapp.ini` for the application to load correctly. + +For all manual builds (`yarn start` or `yarn build`), the ini file is copied from `/ini/.webapp..ini` to `build/public/.webapp.ini`, which the application fetches and parses at startup. +If a file doesn't exist in /ini/.webapp..ini, then the template file will first be copied from [`/ini/templates`](/ini/templates). +All of the .ini files inside of /ini are ignored in git, so you can create your own files locally to run the application with your account parameters without creating conflicts with committed code or leaking your details into source control. + +## Ini Parameters + +### defaultConfigSource + +The 8 character ID of the app config from your JWP account (or the url path) that the web app will use to load its content. Be careful to ensure that this config is always available or your app will fail to load. + +> Note: you probably always want to include a default config source for any production deployment. If there are no valid config sources the application will throw an error at startup. +### additionalAllowedConfigSources[] + +An array of of 8-character IDs (entered 1 per line) for app configs in your JWP account (or url paths) that can be used with the [`app-config=` query param](configuration.md#switching-between-app-configs). +This may be useful for example if you have a staging or experimental config that you want to be able to test on your site using the [`app-config` query parameter](configuration.md#switching-between-app-configs) without changing the default config that the application loads with for all of your users. +See [.webapp.test.ini](/ini/templates/.webapp.test.ini) for an example. + +### UNSAFE_allowAnyConfigSource + +Boolean flag which if true, enables **ANY** 8-character app config ID (or path) to be specified with the [`app-config=` query param](configuration.md#switching-between-app-configs). + +>***Warning:** Generally the `UNSAFE_allowAnyConfigSource` option should only be used for dev, test, or demo deployments, because it will allow anyone to create URL's that specify any config to be displayed on your domain.* diff --git a/firebase.json b/firebase.json index da0e4e79a..b00c8fbfb 100644 --- a/firebase.json +++ b/firebase.json @@ -3,7 +3,6 @@ "public": "build/public", "ignore": [ "firebase.json", - "**/.*", "**/node_modules/**" ], "rewrites": [ diff --git a/ini/templates/.webapp.demo.ini b/ini/templates/.webapp.demo.ini new file mode 100644 index 000000000..e5d8bb64e --- /dev/null +++ b/ini/templates/.webapp.demo.ini @@ -0,0 +1,4 @@ +; The demo app should load with the config prompt screen +defaultConfigSource = +; Allow any config in demo mode (this should only really be run on JWP's demo site at https://app-preview.jwplayer.com/) +UNSAFE_allowAnyConfigSource = true diff --git a/ini/templates/.webapp.dev.ini b/ini/templates/.webapp.dev.ini new file mode 100644 index 000000000..01fd5196a --- /dev/null +++ b/ini/templates/.webapp.dev.ini @@ -0,0 +1,4 @@ +; This is a basic Blender demo +defaultConfigSource = gnnuzabk +; When developing, switching between configs is useful for test and debug +UNSAFE_allowAnyConfigSource = true diff --git a/ini/templates/.webapp.jwdev.ini b/ini/templates/.webapp.jwdev.ini new file mode 100644 index 000000000..ff3760166 --- /dev/null +++ b/ini/templates/.webapp.jwdev.ini @@ -0,0 +1,4 @@ +; jwdev mode runs in the JWP internal dev environment, so this is an app config hosted there +defaultConfigSource = uzcyv8xh +; When developing, switching between configs is useful for test and debug +UNSAFE_allowAnyConfigSource = true diff --git a/ini/templates/.webapp.prod.ini b/ini/templates/.webapp.prod.ini new file mode 100644 index 000000000..7a210c444 --- /dev/null +++ b/ini/templates/.webapp.prod.ini @@ -0,0 +1,11 @@ +; This is the app config that your application will load at startup +defaultConfigSource = ; Enter your 8 digit config ID (i.e. gnnuzabk) here (case sensitive) + +;******************************************************************************************** +; If you want to support app configs other than the default with the app-config= query param +; add them individually one per line here. +; See .webapp.test.ini for an example. +;******************************************************************************************** +;additionalAllowedConfigSources[] = +;additionalAllowedConfigSources[] = +;additionalAllowedConfigSources[] = diff --git a/ini/templates/.webapp.test.ini b/ini/templates/.webapp.test.ini new file mode 100644 index 000000000..ce0171f08 --- /dev/null +++ b/ini/templates/.webapp.test.ini @@ -0,0 +1,6 @@ +defaultConfigSource = gnnuzabk +additionalAllowedConfigSources[] = kujzeu1b +additionalAllowedConfigSources[] = ata6ucb8 +additionalAllowedConfigSources[] = nvqkufhy +additionalAllowedConfigSources[] = 7weyqrua +additionalAllowedConfigSources[] = ozylzc5m diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 361a119cc..000000000 --- a/netlify.toml +++ /dev/null @@ -1,4 +0,0 @@ -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 diff --git a/package.json b/package.json index 240b2d469..e7ed1415a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dompurify": "^2.3.8", "i18next": "^20.3.1", "i18next-browser-languagedetector": "^6.1.1", + "ini": "^3.0.1", "jwt-decode": "^3.1.2", "lodash.merge": "^4.6.2", "marked": "^4.1.1", @@ -67,6 +68,7 @@ "@testing-library/react": "^11.2.6", "@testing-library/react-hooks": "^8.0.1", "@types/dompurify": "^2.3.4", + "@types/ini": "^1.3.31", "@types/jwplayer": "^8.2.7", "@types/lodash.merge": "^4.6.6", "@types/luxon": "^3.0.2", @@ -135,4 +137,4 @@ "codeceptjs/**/ansi-regex": "^4.1.1", "codeceptjs/**/minimatch": "^3.0.5" } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index 7316b2c9e..523cb1e66 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,92 +1,50 @@ -import React, { Component } from 'react'; -import { getI18n, I18nextProvider } from 'react-i18next'; +import React, { useEffect, useState } from 'react'; +import { BrowserRouter, HashRouter } from 'react-router-dom'; -import type { Config } from '#types/Config'; -import Router from '#src/containers/Router/Router'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import QueryProvider from '#src/providers/QueryProvider'; -import { restoreWatchHistory } from '#src/stores/WatchHistoryController'; -import { initializeAccount } from '#src/stores/AccountController'; -import { initializeFavorites } from '#src/stores/FavoritesController'; -import { logDev } from '#src/utils/common'; -import { loadAndValidateConfig } from '#src/utils/configLoad'; -import { clearStoredConfig } from '#src/utils/configOverride'; -import { PersonalShelf } from '#src/enum/PersonalShelf'; -import initI18n from '#src/i18n/config'; - import '#src/screenMapping'; import '#src/styles/main.scss'; +import initI18n from '#src/i18n/config'; +import Root from '#components/Root/Root'; +import { ErrorPageWithoutTranslation } from '#components/ErrorPage/ErrorPage'; +import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; interface State { - error: Error | null; isLoading: boolean; + error?: Error; } -class App extends Component { - public state: State = { - error: null, - isLoading: true, - }; +export default function App() { + const [i18nState, seti18nState] = useState({ isLoading: true }); - componentDidCatch(error: Error) { - this.setState({ error }); - } - - async initializeServices(config: Config) { - if (config?.integrations?.cleeng?.id) { - await initializeAccount(); - } - - // We only request favorites and continue_watching data if there is a corresponding item in the content section - // and a playlist in the features section. - // We first initialize the account otherwise if we have favorites saved as externalData and in a local storage the sections may blink - if (config.features?.continueWatchingList && config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) { - await restoreWatchHistory(); - } + useEffect(() => { + initI18n() + .then(() => seti18nState({ isLoading: false })) + .catch((e) => seti18nState({ isLoading: false, error: e as Error })); + }, []); - if (config.features?.favoritesList && config.content.some((el) => el.type === PersonalShelf.Favorites)) { - await initializeFavorites(); - } + if (i18nState.isLoading) { + return ; } - configLoadingHandler = (isLoading: boolean) => { - this.setState({ isLoading }); - logDev(`Loading config: ${isLoading}`); - }; - - configErrorHandler = (error: Error) => { - clearStoredConfig(); - - this.setState({ error }); - this.setState({ isLoading: false }); - logDev('Error while loading the config:', error); - }; - - configValidationCompletedHandler = async (config: Config) => { - await this.initializeServices(config); - this.setState({ isLoading: false }); - }; - - async componentDidMount() { - await initI18n(); - await loadAndValidateConfig(this.configLoadingHandler, this.configErrorHandler, this.configValidationCompletedHandler); - } - - render() { - const { isLoading, error } = this.state; - - if (isLoading) { - return ; - } - + if (i18nState.error) { + // Don't be tempted to translate these strings. If i18n fails to load, translations won't work anyhow return ( - - - - - + ); } -} -export default App; + const Router = import.meta.env.APP_PUBLIC_GITHUB_PAGES ? HashRouter : BrowserRouter; + + return ( + + + + + + ); +} diff --git a/src/assets/logo.svg b/src/assets/logo.svg deleted file mode 100644 index bab44e737..000000000 --- a/src/assets/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index b40327c50..2e39e586e 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -4,7 +4,7 @@ import { NavLink } from 'react-router-dom'; import styles from './Button.module.scss'; -import Spinner from '#src/components/Spinner/Spinner'; +import Spinner from '#components/Spinner/Spinner'; type Color = 'default' | 'primary'; diff --git a/src/components/DemoConfigDialog/DemoConfigDialog.module.scss b/src/components/DemoConfigDialog/DemoConfigDialog.module.scss index 0a6ea23db..c6a7346c1 100644 --- a/src/components/DemoConfigDialog/DemoConfigDialog.module.scss +++ b/src/components/DemoConfigDialog/DemoConfigDialog.module.scss @@ -9,6 +9,10 @@ } } +.maxWidth { + max-width: 500px; +} + .configModal { position: absolute; top: 0; @@ -17,17 +21,18 @@ background: var(--body-background-color); p { - max-width: 400px; + max-width: 500px; margin-bottom: 0; color: theme.$text-field-resting-color; font-size: 14px; line-height: 18px; } - a { - color: theme.$text-field-resting-color; + a, a:visited, a:hover, a:active { + color: inherit; font-weight: var(--body-font-weight-bold); text-decoration: underline; + cursor: pointer; } } diff --git a/src/components/DemoConfigDialog/DemoConfigDialog.test.tsx b/src/components/DemoConfigDialog/DemoConfigDialog.test.tsx index 14a986d9e..ff57d0cd1 100644 --- a/src/components/DemoConfigDialog/DemoConfigDialog.test.tsx +++ b/src/components/DemoConfigDialog/DemoConfigDialog.test.tsx @@ -1,17 +1,19 @@ import React from 'react'; +import type { UseQueryResult } from 'react-query'; import { renderWithRouter } from '#test/testUtils'; import DemoConfigDialog from '#components/DemoConfigDialog/DemoConfigDialog'; +import type { Config } from '#types/Config'; describe('', () => { test('renders and matches snapshot', () => { - const { container } = renderWithRouter(); + const { container } = renderWithRouter(} selectedConfigSource={'abcdefgh'} />); expect(container).toMatchSnapshot(); }); - test('renders and matches snapshot with ID', () => { - const { container } = renderWithRouter(); + test('renders and matches snapshot error dialog', () => { + const { container } = renderWithRouter(} selectedConfigSource={'aaaaaaaa'} />); expect(container).toMatchSnapshot(); }); diff --git a/src/components/DemoConfigDialog/DemoConfigDialog.tsx b/src/components/DemoConfigDialog/DemoConfigDialog.tsx index 7d2b5d00e..0256c4c14 100644 --- a/src/components/DemoConfigDialog/DemoConfigDialog.tsx +++ b/src/components/DemoConfigDialog/DemoConfigDialog.tsx @@ -1,80 +1,204 @@ -import React, { MouseEventHandler, useState } from 'react'; +import React, { ChangeEventHandler, MouseEventHandler, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import type { UseQueryResult } from 'react-query'; import styles from './DemoConfigDialog.module.scss'; import ErrorPage from '#components/ErrorPage/ErrorPage'; import TextField from '#components/TextField/TextField'; import Button from '#components/Button/Button'; -import { addConfigQueryParam, clearStoredConfig, getConfigLocation } from '#src/utils/configOverride'; +import { getConfigNavigateCallback } from '#src/utils/configOverride'; import Link from '#components/Link/Link'; import ConfirmationDialog from '#components/ConfirmationDialog/ConfirmationDialog'; +import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; +import type { Config } from '#types/Config'; +import DevStackTrace from '#components/DevStackTrace/DevStackTrace'; -const fallbackConfig = import.meta.env.APP_DEMO_DEFAULT_CONFIG_ID; +const regex = /^[a-z,\d]{0,8}$/g; +const fallbackConfig = import.meta.env.APP_DEMO_FALLBACK_CONFIG_ID; interface Props { - configLocation?: string; + selectedConfigSource: string | undefined; + configQuery: UseQueryResult; } -const DemoConfigDialog = ({ configLocation = getConfigLocation() }: Props) => { +interface State { + configSource: string | undefined; + error: string | Error | undefined; + showDialog: boolean; + loaded: boolean; +} + +const initialState: State = { + configSource: undefined, + error: undefined, + showDialog: false, + loaded: false, +}; + +const DemoConfigDialog = ({ selectedConfigSource, configQuery }: Props) => { const { t } = useTranslation('demo'); - const [showDemoWarning, setShowWarning] = useState(false); + const navigate = useNavigate(); + const navigateCallback = getConfigNavigateCallback(navigate); + + const [state, setState] = useState(initialState); + + const configNavigate = async (configSource: string | undefined) => { + setState((s) => ({ ...s, configSource: configSource, error: undefined })); + + if (!configSource) { + setState((s) => ({ ...s, error: t('enter_a_value') })); + } + // If trying to fetch the same config again, use refetch since a query param change won't work + else if (configSource === selectedConfigSource) { + await configQuery.refetch(); + } + // Get a new config by triggering a query param change + else { + navigateCallback(configSource); + } + }; + + useEffect(() => { + // Don't grab values from props when config source is unset or still loading + if (!selectedConfigSource || configQuery.isLoading) { + return; + } + + // Initialize the config source if it's not yet set (this happens at first load) + setState((s) => ({ ...s, configSource: s.configSource ?? selectedConfigSource })); + + // If there's an error after loading is done, grab it to display it to the user + if (configQuery.error) { + setState((s) => ({ ...s, showDialog: false, error: configQuery.error as Error })); + } + }, [selectedConfigSource, configQuery.error, configQuery.isLoading]); const clearConfig = () => { - clearStoredConfig(); - window.location.reload(); + setState(initialState); + navigateCallback(''); }; - if (configLocation) { - return ( -
-
{t('currently_previewing_config', { configSource: configLocation })}
- {t('click_to_unselect_config')} -
- ); - } + const isValidSource = (configSource: string) => configSource.match(regex)?.some((m) => m === configSource); + + const onChange: ChangeEventHandler = (event) => { + setState((s) => ({ + ...s, + configSource: event.target.value, + error: isValidSource(event.target.value || '') ? undefined : t('invalid_config_id_format'), + })); + }; + + const submitClick: MouseEventHandler = async (event) => { + event.preventDefault(); + + let error: string = ''; + + if (state.configSource?.length !== 8) { + error = t('enter_8_digits'); + } else if (!isValidSource(state.configSource)) { + error = t('invalid_config_id_format'); + } + + if (error) { + setState((s) => ({ ...s, error: error })); + } else { + await configNavigate(state.configSource); + } + }; const cancelConfigClick: MouseEventHandler = (event) => { event.preventDefault(); if (fallbackConfig) { - setShowWarning(true); + setState({ configSource: '', error: undefined, loaded: false, showDialog: true }); } }; - const confirmDemoClick = () => { - addConfigQueryParam(fallbackConfig); - window.location.reload(); + const confirmDemoClick = async () => { + await configNavigate(fallbackConfig); }; - const cancelDemoClick = () => setShowWarning(false); + const cancelDemoClick = () => setState((s) => ({ ...s, showDialog: false })); + + // If the config loads, reset the demo ui state + useEffect(() => { + if (configQuery.isSuccess) { + setState(initialState); + } + }, [setState, configQuery.isSuccess]); + + // If someone links to the app with a config query, + // we want to show the normal spinner instead of the dialog while trying to load the first time + // Config is only undefined when it's the first load attempt. + if (configQuery.isLoading && state.configSource === undefined) { + return ; + } return ( -
- -
- -
-
- -

- {t('use_the_jwp_dashboard')} - - - {t('learn_more')} - -

-
+ <> + {configQuery.isSuccess && ( +
+
{t('currently_previewing_config', { configSource: selectedConfigSource })}
+ {t('click_to_unselect_config')} +
+ )} + {!configQuery.isSuccess && ( +
+ +
+ + {typeof state.error === 'string' ? state.error : state.error?.message} +
+
+ + + ) + } + onChange={onChange} + /> +
+
+ +

{t('use_the_jwp_dashboard')}

+

{t('demo_note')}

+
+
+ )} -
+ ); }; diff --git a/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap b/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap index d3e607983..9fcb582a4 100644 --- a/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap +++ b/src/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap @@ -1,6 +1,23 @@ // Vitest Snapshot v1 exports[` > renders and matches snapshot 1`] = ` +
+
+
+ currently_previewing_config +
+ + click_to_unselect_config + +
+
+`; + +exports[` > renders and matches snapshot error dialog 1`] = `
> renders and matches snapshot 1`] = `
+ Logo

> renders and matches snapshot 1`] = ` class="_main_8c5621" >

@@ -60,11 +85,14 @@ exports[` > renders and matches snapshot 1`] = `

use_the_jwp_dashboard - - - +

+

+ demo_note +

+
`; - -exports[` > renders and matches snapshot with ID 1`] = ` - -`; diff --git a/src/components/DevConfigSelector/DevConfigSelector.tsx b/src/components/DevConfigSelector/DevConfigSelector.tsx index 1481bedc1..97bd5a711 100644 --- a/src/components/DevConfigSelector/DevConfigSelector.tsx +++ b/src/components/DevConfigSelector/DevConfigSelector.tsx @@ -1,23 +1,33 @@ +import { ChangeEvent, useCallback } from 'react'; +import { useNavigate } from 'react-router'; + import styles from './DevConfigSelector.module.scss'; import Dropdown from '#components/Dropdown/Dropdown'; -import { configQueryKey, getConfigLocation } from '#src/utils/configOverride'; +import { getConfigNavigateCallback } from '#src/utils/configOverride'; import { jwDevEnvConfigs, testConfigs } from '#test/constants'; -const configs = import.meta.env.MODE === 'jwdev' ? jwDevEnvConfigs : testConfigs; -const configOptions: { value: string; label: string }[] = Object.values(configs).map(({ id, label }) => ({ value: id, label: `${id} - ${label}` })); +interface Props { + selectedConfig: string | undefined; +} -const DevConfigSelector = () => { - const selectedConfig = getConfigLocation() || ''; +const configs = import.meta.env.MODE === 'jwdev' ? jwDevEnvConfigs : testConfigs; +const configOptions: { value: string; label: string }[] = [ + { label: 'Select an App Config', value: '' }, + ...Object.values(configs).map(({ id, label }) => ({ value: id, label: `${id} - ${label}` })), +]; - const onChange = (event: React.ChangeEvent) => { - const url = new URL(window.location.href); +const DevConfigSelector = ({ selectedConfig }: Props) => { + const configNavigate = getConfigNavigateCallback(useNavigate()); - url.searchParams.set(configQueryKey, event.target.value); - window.location.href = url.toString(); - }; + const onChange = useCallback( + (event: ChangeEvent) => { + configNavigate(event.target.value); + }, + [configNavigate], + ); - return ; + return ; }; export default DevConfigSelector; diff --git a/src/components/DevStackTrace/DevStackTrace.module.scss b/src/components/DevStackTrace/DevStackTrace.module.scss new file mode 100644 index 000000000..f6e33f1c6 --- /dev/null +++ b/src/components/DevStackTrace/DevStackTrace.module.scss @@ -0,0 +1,6 @@ +.stack { + display: inline-block; + padding-bottom: 6px; + padding-left: 1.5em; + text-indent:-1.5em; +} diff --git a/src/components/DevStackTrace/DevStackTrace.tsx b/src/components/DevStackTrace/DevStackTrace.tsx new file mode 100644 index 000000000..99269a926 --- /dev/null +++ b/src/components/DevStackTrace/DevStackTrace.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; + +import styles from './DevStackTrace.module.scss'; + +export default function DevStackTrace({ error }: { error: Error | undefined }) { + const [open, setOpen] = useState(false); + + if (!error?.stack) { + return null; + } + + const showClick = () => { + setOpen((s) => !s); + }; + + return ( + <> + {(open ? 'Hide' : 'Show') + ' Stack Trace'} + {open && ( + <> +
+

+ {error?.stack?.split('/n').map((line, index) => ( + + {line} + + ))} +

+ + )} + + ); +} diff --git a/src/components/EditPasswordForm/EditPasswordForm.tsx b/src/components/EditPasswordForm/EditPasswordForm.tsx index 35e0dbf85..fbcbc3e06 100644 --- a/src/components/EditPasswordForm/EditPasswordForm.tsx +++ b/src/components/EditPasswordForm/EditPasswordForm.tsx @@ -3,18 +3,18 @@ import { useTranslation } from 'react-i18next'; import styles from './EditPasswordForm.module.scss'; +import type { FormErrors } from '#types/form'; +import type { EditPasswordFormData } from '#types/account'; import FormFeedback from '#components/FormFeedback/FormFeedback'; import TextField from '#components/TextField/TextField'; import Button from '#components/Button/Button'; import IconButton from '#components/IconButton/IconButton'; import Visibility from '#src/icons/Visibility'; import VisibilityOff from '#src/icons/VisibilityOff'; +import useToggle from '#src/hooks/useToggle'; import PasswordStrength from '#components/PasswordStrength/PasswordStrength'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import { testId } from '#src/utils/common'; -import useToggle from '#src/hooks/useToggle'; -import type { EditPasswordFormData } from '#types/account'; -import type { FormErrors } from '#types/form'; type Props = { onSubmit: React.FormEventHandler; diff --git a/src/components/ErrorPage/ErrorPage.module.scss b/src/components/ErrorPage/ErrorPage.module.scss index 0d3a71128..e902f7ce1 100644 --- a/src/components/ErrorPage/ErrorPage.module.scss +++ b/src/components/ErrorPage/ErrorPage.module.scss @@ -6,7 +6,14 @@ display: flex; justify-content: center; align-items: center; - height: 70vh; + min-height: 70vh; + + a, a:visited, a:hover, a:active { + color: inherit; + font-weight: var(--body-font-weight-bold); + text-decoration: underline; + cursor: pointer; + } } .box { @@ -36,3 +43,25 @@ font-size: 24px; } } + +.logo { + margin-bottom: 24px; +} + +.links { + position: relative; + +} + +.stack { + a { + position: absolute; + right: 0; + } +} + +.image { + max-width: 200px; + max-height: 80px; + margin-bottom: 24px; +} diff --git a/src/components/ErrorPage/ErrorPage.tsx b/src/components/ErrorPage/ErrorPage.tsx index 2f153ba1e..fb1cd6e51 100644 --- a/src/components/ErrorPage/ErrorPage.tsx +++ b/src/components/ErrorPage/ErrorPage.tsx @@ -1,20 +1,63 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import styles from './ErrorPage.module.scss'; -type Props = { - title: string; +import { IS_DEMO_MODE, IS_DEVELOPMENT_BUILD } from '#src/utils/common'; +import DevStackTrace from '#components/DevStackTrace/DevStackTrace'; +import { useConfigStore } from '#src/stores/ConfigStore'; +import { getPublicUrl } from '#src/utils/domHelpers'; + +interface Props { + disableFallbackTranslation?: boolean; + title?: string | ReactNode; + message?: string | ReactNode; + learnMoreLabel?: string; children?: React.ReactNode; + error?: Error; + helpLink?: string; +} + +const ErrorPage = ({ title, message, learnMoreLabel, ...rest }: Props) => { + const { t } = useTranslation('error'); + + return ( + + ); }; -const ErrorPage: React.FC = ({ title, children }: Props) => { +export const ErrorPageWithoutTranslation = ({ title, children, message, learnMoreLabel, error, helpLink }: Props) => { + const logo = useConfigStore((s) => s.config?.assets?.banner); + return (
+ {'Logo'}
-

{title}

+

{title || 'An error occurred'}

-
{children}
+
+ <> + {children ||

{message || 'Try refreshing this page or come back later.'}

} + {(IS_DEVELOPMENT_BUILD || IS_DEMO_MODE) && helpLink && ( +

+ + {learnMoreLabel || 'Learn More'} + + {IS_DEVELOPMENT_BUILD && error?.stack && ( + + + + )} +

+ )} + +
); diff --git a/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap b/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap index 1a84558bb..48b078ba7 100644 --- a/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap +++ b/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap @@ -8,6 +8,11 @@ exports[` > renders and matches snapshot 1`] = `
+ Logo

; diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index 57d932ddc..47245a6ab 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import styles from './LoginForm.module.scss'; +import useToggle from '#src/hooks/useToggle'; import TextField from '#components/TextField/TextField'; import Button from '#components/Button/Button'; import Link from '#components/Link/Link'; @@ -13,7 +14,6 @@ import VisibilityOff from '#src/icons/VisibilityOff'; import FormFeedback from '#components/FormFeedback/FormFeedback'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import { testId } from '#src/utils/common'; -import useToggle from '#src/hooks/useToggle'; import { addQueryParam } from '#src/utils/location'; import type { FormErrors } from '#types/form'; import type { LoginFormData } from '#types/account'; diff --git a/src/components/Root/Root.test.tsx b/src/components/Root/Root.test.tsx deleted file mode 100644 index ba82c427f..000000000 --- a/src/components/Root/Root.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; - -import Root from './Root'; - -import { mockWindowLocation, renderWithRouter } from '#test/testUtils'; - -describe('', () => { - it('renders error page when error prop is passed', () => { - mockWindowLocation('/'); - const error = new Error(); - const { queryByText } = renderWithRouter(); - - expect(queryByText('generic_error_heading')).toBeDefined(); - expect(queryByText('generic_error_description')).toBeDefined(); - }); -}); diff --git a/src/components/Root/Root.tsx b/src/components/Root/Root.tsx index 2aa7acbc5..b2ed44f3b 100644 --- a/src/components/Root/Root.tsx +++ b/src/components/Root/Root.tsx @@ -1,32 +1,77 @@ -import React, { FC } from 'react'; -import { Outlet } from 'react-router-dom'; +import React, { FC, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useSearchParams } from 'react-router-dom'; import ErrorPage from '#components/ErrorPage/ErrorPage'; import AccountModal from '#src/containers/AccountModal/AccountModal'; import { IS_DEMO_MODE, IS_DEVELOPMENT_BUILD } from '#src/utils/common'; import DemoConfigDialog from '#components/DemoConfigDialog/DemoConfigDialog'; +import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; +import DevConfigSelector from '#components/DevConfigSelector/DevConfigSelector'; +import { cleanupQueryParams, getConfigSource } from '#src/utils/configOverride'; +import { loadAndValidateConfig } from '#src/utils/configLoad'; +import { initSettings } from '#src/stores/SettingsController'; +import AppRoutes from '#src/containers/AppRoutes/AppRoutes'; -type Props = { - error?: Error | null; -}; - -const Root: FC = ({ error }) => { +const Root: FC = () => { const { t } = useTranslation('error'); + const settingsQuery = useQuery('settings-init', initSettings, { + enabled: true, + retry: 1, + refetchInterval: false, + }); + + const [searchParams, setSearchParams] = useSearchParams(); + + const configSource = useMemo(() => getConfigSource(searchParams, settingsQuery.data), [searchParams, settingsQuery.data]); + + // Update the query string to maintain the right params + useEffect(() => { + if (settingsQuery.data && cleanupQueryParams(searchParams, settingsQuery.data, configSource)) { + setSearchParams(searchParams, { replace: true }); + } + }, [configSource, searchParams, setSearchParams, settingsQuery.data]); + + const configQuery = useQuery('config-init-' + configSource, async () => await loadAndValidateConfig(configSource), { + enabled: settingsQuery.isSuccess, + retry: configSource ? 1 : 0, + refetchInterval: false, + }); + + // Show the spinner while loading except in demo mode (the demo config shows its own loading status) + if (settingsQuery.isLoading || (!IS_DEMO_MODE && configQuery.isLoading)) { + return ; + } + + if (settingsQuery.isError) { + return ( + + ); + } + return ( <> - {error ? ( - -

{IS_DEVELOPMENT_BUILD ? error.stack : t('generic_error_description', 'Try refreshing this page or come back later.')}

-
- ) : ( - <> - - - + {!configQuery.isError && !configQuery.isLoading && } + {/*Show the error page when error except in demo mode (the demo mode shows its own error)*/} + {configQuery.isError && !IS_DEMO_MODE && ( + )} - {IS_DEMO_MODE && } + {IS_DEMO_MODE && } + + {/* Config select control to improve testing experience */} + {IS_DEVELOPMENT_BUILD && } ); }; diff --git a/src/components/RootErrorPage/RootErrorPage.tsx b/src/components/RootErrorPage/RootErrorPage.tsx new file mode 100644 index 000000000..15ae140ef --- /dev/null +++ b/src/components/RootErrorPage/RootErrorPage.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useRouteError } from 'react-router'; + +import ErrorPage from '#components/ErrorPage/ErrorPage'; + +const RootErrorPage: React.FC = () => { + const error = useRouteError() as Error; + + return ; +}; + +export default RootErrorPage; diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 9fb7fa13e..f6cf6c295 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import styles from './TextField.module.scss'; -import HelperText from '#src/components/HelperText/HelperText'; +import HelperText from '#components/HelperText/HelperText'; import { testId as getTestId } from '#src/utils/common'; import useOpaqueId from '#src/hooks/useOpaqueId'; @@ -12,7 +12,7 @@ type InputProps = Omit, HTMLTextAreaElement>, 'id' | 'ref' | 'className'>; type InputOrTextAreaProps = - | ({ multiline?: false; inputRef?: RefObject; textAreaRef?: never } & InputProps) + | ({ multiline?: never; inputRef?: RefObject; textAreaRef?: never } & InputProps) | ({ multiline: true; inputRef?: never; textAreaRef?: RefObject } & TextAreaProps); type Props = { @@ -24,6 +24,7 @@ type Props = { error?: boolean; editing?: boolean; testId?: string; + multiline?: boolean; } & InputOrTextAreaProps; const TextField: React.FC = ({ @@ -37,13 +38,14 @@ const TextField: React.FC = ({ testId, inputRef, textAreaRef, + multiline, ...inputProps }: Props) => { const id = useOpaqueId('text-field', inputProps.name); const { t } = useTranslation('common'); - const isInputOrTextArea = (item: unknown): item is InputOrTextAreaProps => !!item && typeof item === 'object' && 'multiline' in item; - const isTextArea = (item: unknown): item is TextAreaProps => isInputOrTextArea(item) && !!item.multiline; + const isInputOrTextArea = (item: unknown): item is InputOrTextAreaProps => !!item && typeof item === 'object'; + const isTextArea = (item: unknown): item is TextAreaProps => isInputOrTextArea(item) && !!multiline; const textFieldClassName = classNames( styles.textField, diff --git a/src/containers/AccountModal/forms/ResetPassword.tsx b/src/containers/AccountModal/forms/ResetPassword.tsx index ea5922b4f..677148343 100644 --- a/src/containers/AccountModal/forms/ResetPassword.tsx +++ b/src/containers/AccountModal/forms/ResetPassword.tsx @@ -11,7 +11,6 @@ import type { ForgotPasswordFormData } from '#types/account'; import ConfirmationForm from '#components/ConfirmationForm/ConfirmationForm'; import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; import { addQueryParam, removeQueryParam } from '#src/utils/location'; -import { addQueryParams } from '#src/utils/formatting'; import { logDev } from '#src/utils/common'; import { logout, resetPassword } from '#src/stores/AccountController'; @@ -31,17 +30,27 @@ const ResetPassword: React.FC = ({ type }: Prop) => { }; const backToLoginClickHandler = async () => { + navigate( + { + pathname: '/', + search: 'u=login', + }, + { replace: true }, + ); + if (user) { await logout(); } - navigate(addQueryParams('/', { u: 'login' })); }; const resetPasswordClickHandler = async () => { const resetUrl = `${window.location.origin}/?u=edit-password`; try { - if (!user?.email) throw new Error('invalid param email'); + if (!user?.email) { + logDev('invalid param email'); + return; + } setResetPasswordSubmitting(true); await resetPassword(user.email, resetUrl); diff --git a/src/containers/AppRoutes/AppRoutes.tsx b/src/containers/AppRoutes/AppRoutes.tsx new file mode 100644 index 000000000..f7dd87b24 --- /dev/null +++ b/src/containers/AppRoutes/AppRoutes.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Route, Routes } from 'react-router-dom'; + +import ErrorPage from '#components/ErrorPage/ErrorPage'; +import RootErrorPage from '#components/RootErrorPage/RootErrorPage'; +import About from '#src/pages/About/About'; +import Home from '#src/pages/Home/Home'; +import Search from '#src/pages/Search/Search'; +import Series from '#src/pages/Series/Series'; +import User from '#src/pages/User/User'; +import MediaScreenRouter from '#src/pages/ScreenRouting/MediaScreenRouter'; +import PlaylistScreenRouter from '#src/pages/ScreenRouting/PlaylistScreenRouter'; +import Layout from '#src/containers/Layout/Layout'; + +export default function AppRoutes() { + const { t } = useTranslation('error'); + + return ( + + } errorElement={}> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + + + ); +} diff --git a/src/containers/Layout/Layout.tsx b/src/containers/Layout/Layout.tsx index 8f57c0f24..4af475f4e 100644 --- a/src/containers/Layout/Layout.tsx +++ b/src/containers/Layout/Layout.tsx @@ -17,10 +17,7 @@ import Sidebar from '#components/Sidebar/Sidebar'; import DynamicBlur from '#components/DynamicBlur/DynamicBlur'; import MenuButton from '#components/MenuButton/MenuButton'; import UserMenu from '#components/UserMenu/UserMenu'; -import DevConfigSelector from '#components/DevConfigSelector/DevConfigSelector'; import { addQueryParam } from '#src/utils/location'; -import { IS_DEVELOPMENT_BUILD, IS_TEST_MODE } from '#src/utils/common'; -import { addConfigQueryParam } from '#src/utils/configOverride'; const Layout = () => { const location = useLocation(); @@ -32,9 +29,6 @@ const Layout = () => { const { searchPlaylist } = features || {}; const { footerText, dynamicBlur } = styling || {}; - // This ensures that the app config ID query param is always re-added to the URL - addConfigQueryParam(); - const { blurImage, searchQuery, searchActive, userMenuOpen } = useUIStore( ({ blurImage, searchQuery, searchActive, userMenuOpen }) => ({ blurImage, @@ -149,9 +143,6 @@ const Layout = () => {

{!!footerText && } - - {/* Config select control to improve testing experience */} - {IS_DEVELOPMENT_BUILD && !IS_TEST_MODE && } ); }; diff --git a/src/containers/Router/Router.tsx b/src/containers/Router/Router.tsx deleted file mode 100644 index 0525e675e..000000000 --- a/src/containers/Router/Router.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { createBrowserRouter, createHashRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router-dom'; - -import ErrorPage from '#components/ErrorPage/ErrorPage'; -import Root from '#components/Root/Root'; -import Layout from '#src/containers/Layout/Layout'; -import About from '#src/pages/About/About'; -import Home from '#src/pages/Home/Home'; -import Search from '#src/pages/Search/Search'; -import Series from '#src/pages/Series/Series'; -import User from '#src/pages/User/User'; -import MediaScreenRouter from '#src/pages/ScreenRouting/MediaScreenRouter'; -import PlaylistScreenRouter from '#src/pages/ScreenRouting/PlaylistScreenRouter'; - -type Props = { - error?: Error | null; -}; - -export default function Router({ error }: Props) { - const { t } = useTranslation('error'); - - /** - * Ideally we should define the routes outside the router, but it doesn't work with the current setup because we need - * to pass the error to the Root component. - * @todo: refactor the app to use the errorElements that can be passed to the route components. see - * https://reactrouter.com/en/main/route/error-element so that we can define the routes outside the router. - */ - const routes = createRoutesFromElements( - }> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -

{t('notfound_error_description', "This page doesn't exist.")}

-
- } - /> - - , - ); - - const router = import.meta.env.APP_PUBLIC_GITHUB_PAGES ? createHashRouter(routes) : createBrowserRouter(routes); - - return ; -} diff --git a/src/i18n/locales/en_US/demo.json b/src/i18n/locales/en_US/demo.json index d4fe88c26..75b4371c8 100644 --- a/src/i18n/locales/en_US/demo.json +++ b/src/i18n/locales/en_US/demo.json @@ -3,7 +3,10 @@ "cancel_config_id": "Cancel", "click_to_unselect_config": "(click here to unselect this config)", "currently_previewing_config": "You are currently previewing config: '{{configSource}}'", - "learn_more": "Learn more", + "demo_note": "Note: The chosen app config will be temporarily stored in this browser only. Close the tab or use the link at the bottom of the main page to clear the setting and return to this dialog.", + "enter_8_digits": "The App Config ID should be 8 digits long. Please correct your input and try again.", + "enter_a_value": "Please enter an App Config ID", + "invalid_config_id_format": "The App Config ID should consist of only numbers and lowercase letters. Please check your input and then try again.", "please_enter_config_id": "Please enter an App Config ID from your JW Account", "submit_config_id": "Submit", "use_the_jwp_dashboard": "Don't have a config? Use the JWP Dashboard to create an App Config or click Cancel to view a generic demo.", diff --git a/src/i18n/locales/en_US/error.json b/src/i18n/locales/en_US/error.json index 038e2b7d2..f79876fcc 100644 --- a/src/i18n/locales/en_US/error.json +++ b/src/i18n/locales/en_US/error.json @@ -1,8 +1,13 @@ { + "check_your_config": "There was a problem retrieving the application configuration. Try again later. If the problem persists contact technical support.", + "check_your_settings": "There was a problem loading the default settings. Try again later. If the problem persists contact technical support.", + "config_invalid": "Invalid or missing config", "generic_error_description": "Try refreshing this page or come back later.", "generic_error_heading": "There was an issue loading the application", + "learn_more": "Learn more", "notfound_error_description": "This page doesn't exist.", "notfound_error_heading": "Not found", "playlist_not_found": "Playlist not found", + "settings_invalid": "Invalid or missing settings", "video_not_found": "Video not found" } diff --git a/src/i18n/locales/nl_NL/demo.json b/src/i18n/locales/nl_NL/demo.json index 3d5ed6027..2c702c3fd 100644 --- a/src/i18n/locales/nl_NL/demo.json +++ b/src/i18n/locales/nl_NL/demo.json @@ -3,7 +3,10 @@ "cancel_config_id": "", "click_to_unselect_config": "", "currently_previewing_config": "", - "learn_more": "", + "demo_note": "", + "enter_8_digits": "", + "enter_a_value": "", + "invalid_config_id_format": "", "please_enter_config_id": "", "submit_config_id": "", "use_the_jwp_dashboard": "", diff --git a/src/i18n/locales/nl_NL/error.json b/src/i18n/locales/nl_NL/error.json index 0a50ab94b..1077db05f 100644 --- a/src/i18n/locales/nl_NL/error.json +++ b/src/i18n/locales/nl_NL/error.json @@ -1,8 +1,13 @@ { + "check_your_config": "", + "check_your_settings": "", + "config_invalid": "", "generic_error_description": "", "generic_error_heading": "", + "learn_more": "", "notfound_error_description": "", "notfound_error_heading": "", "playlist_not_found": "", + "settings_invalid": "", "video_not_found": "" } diff --git a/src/pages/About/About.tsx b/src/pages/About/About.tsx index ab2ffcf7a..55e007ac9 100644 --- a/src/pages/About/About.tsx +++ b/src/pages/About/About.tsx @@ -9,7 +9,7 @@ const About = () => { JW OTT Webapp is an open-source, dynamically generated video website built around JW Player and JW Platform services. It enables you to easily publish your JW Player-hosted video content with no coding and minimal configuration. -To see an example of JW OTT Webapp in action, see [https://jw-ott-webapp.netlify.app/](https://jw-ott-webapp.netlify.app/). +To see an example of JW OTT Webapp in action, see [https://app-preview.jwplayer.com/](https://app-preview.jwplayer.com/). ## Supported Features @@ -20,7 +20,7 @@ To see an example of JW OTT Webapp in action, see [https://jw-ott-webapp.netlify - Playback of HLS video content from the JW Platform CDN. You can add external URLs (for example, URLS from your own server or CDN) to your playlists in the Content section of your JW Player account dashboard, but they must be HLS streams (\`.m3u8\` files). - Support for live video streams (must be registered as external .m3u8 URLs in your JW Dashboard). - Customize the user interface with your own branding. The default app is configured for JW Player branding and content, but you can easily change this to use your own assets by modifying the \`config.json\` file. Advanced customization is possible (for example, editing the CSS files), but you will need to modify the source code and build from source. -- Site-wide video search and related video recommendations powered by [JW Recommendations](https://support.jwplayer.com/customer/portal/articles/2191721-jw-recommendations). +- Site-wide video search and related video recommendations powered by [JW Recommendations](https://docs.jwplayer.com/platform/docs/vdh-learn-about-recommendations). - Basic playback analytics is reported to your JW Dashboard. - Ad integrations (VAST, VPAID, GoogleIMA, etc.). These features require a JW Player Ads Edition license. For more information, see the [JW Player pricing page](https://www.jwplayer.com/pricing/). - A "Favorites" feature for users to save videos for watching later. A separate list for "Continue Watching" is also kept so users can resume watching videos from where they left off. The lists are per-browser at this time (i.e., lists do not sync across user's browsers or devices). The "Continue Watching" list can be disabled in your JW OTT Webapp's \`config.json\` file. diff --git a/src/pages/About/__snapshots__/About.test.tsx.snap b/src/pages/About/__snapshots__/About.test.tsx.snap index 0efd8feba..81a7a9eac 100644 --- a/src/pages/About/__snapshots__/About.test.tsx.snap +++ b/src/pages/About/__snapshots__/About.test.tsx.snap @@ -20,11 +20,11 @@ exports[` > renders and matches snapshot 1`] = `

To see an example of JW OTT Webapp in action, see - https://jw-ott-webapp.netlify.app/ + https://app-preview.jwplayer.com/ .

@@ -86,7 +86,7 @@ exports[` > renders and matches snapshot 1`] = `
  • Site-wide video search and related video recommendations powered by diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 0bdbb7bf9..ac15207d9 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -87,7 +87,7 @@ const loadConfig = async (configLocation: string) => { }); if (!response.ok) { - throw new Error('Failed to load the config. Please check the config path and the file availability'); + throw new Error('Failed to load the config. Please check the config path and the file availability.'); } const data = await response.json(); diff --git a/src/stores/SettingsController.ts b/src/stores/SettingsController.ts new file mode 100644 index 000000000..969f89701 --- /dev/null +++ b/src/stores/SettingsController.ts @@ -0,0 +1,26 @@ +import ini from 'ini'; + +import { Settings, useSettingsStore } from '#src/stores/SettingsStore'; + +export const initSettings = async () => { + const settings = await fetch('/.webapp.ini') + .then((result) => result.text()) + .then((iniString) => ini.parse(iniString) as Settings); + + if (!settings) { + throw new Error('Unable to load .webapp.ini'); + } + + // This will result in an unusable app + if ( + !settings.defaultConfigSource && + (!settings.additionalAllowedConfigSources || settings.additionalAllowedConfigSources?.length === 0) && + !settings.UNSAFE_allowAnyConfigSource + ) { + throw new Error('No usable config sources'); + } + + useSettingsStore.setState(settings); + + return settings; +}; diff --git a/src/stores/SettingsStore.ts b/src/stores/SettingsStore.ts new file mode 100644 index 000000000..765a82b38 --- /dev/null +++ b/src/stores/SettingsStore.ts @@ -0,0 +1,11 @@ +import { createStore } from '#src/stores/utils'; + +export interface Settings { + defaultConfigSource?: string; + additionalAllowedConfigSources?: string[]; + UNSAFE_allowAnyConfigSource?: boolean; +} + +export const useSettingsStore = createStore('SettingsStore', () => ({ + additionalAllowedConfigSources: [], +})); diff --git a/src/utils/common.ts b/src/utils/common.ts index b50a129fb..38f141af1 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -58,7 +58,7 @@ export const IS_DEMO_MODE = import.meta.env.MODE === 'demo'; export const IS_TEST_MODE = import.meta.env.MODE === 'test'; export function logDev(message: unknown, ...optionalParams: unknown[]) { - if (IS_DEVELOPMENT_BUILD && !IS_TEST_MODE) { + if (IS_DEVELOPMENT_BUILD) { if (optionalParams.length > 0) { console.info(message, optionalParams); } else { diff --git a/src/utils/configLoad.ts b/src/utils/configLoad.ts index 9fcf11bca..b42023924 100644 --- a/src/utils/configLoad.ts +++ b/src/utils/configLoad.ts @@ -1,11 +1,14 @@ import merge from 'lodash.merge'; import { calculateContrastColor } from '#src/utils/common'; -import { getConfigLocation } from '#src/utils/configOverride'; import loadConfig, { validateConfig } from '#src/services/config.service'; import { addScript } from '#src/utils/dom'; import { useConfigStore } from '#src/stores/ConfigStore'; import type { AccessModel, Config, Styling } from '#types/Config'; +import { initializeAccount } from '#src/stores/AccountController'; +import { PersonalShelf } from '#src/enum/PersonalShelf'; +import { restoreWatchHistory } from '#src/stores/WatchHistoryController'; +import { initializeFavorites } from '#src/stores/FavoritesController'; const CONFIG_HOST = import.meta.env.APP_API_BASE_URL; @@ -66,52 +69,56 @@ const calculateAccessModel = (config: Config): AccessModel => { return 'SVOD'; }; -export const loadAndValidateConfig = async ( - onLoading: (isLoading: boolean) => void, - onValidationError: (error: Error) => void, - onValidationCompleted: (config: Config) => Promise, -) => { - onLoading(true); +export async function loadAndValidateConfig(configSource: string | undefined) { + configSource = formatSourceLocation(configSource); - try { - const configLocation = formatSourceLocation(getConfigLocation()); + if (!configSource) { + throw new Error('Config not defined'); + } - if (!configLocation) { - onValidationError(new Error('Config not defined')); - return; - } + let config = await loadConfig(configSource); + config.assets = config.assets || {}; - let config = await loadConfig(configLocation); + // make sure the banner always defaults to the JWP banner when not defined in the config + if (!config.assets.banner) { + config.assets.banner = defaultConfig.assets.banner; + } - if (!config) { - return; - } + // Store the logo right away and set css variables so the error page will be branded + useConfigStore.setState((s) => { + s.config.assets.banner = config.assets.banner; + }); - config = await validateConfig(config); - config = merge({}, defaultConfig, config); + setCssVariables(config.styling || {}); - // make sure the banner always defaults to the JWP banner when not defined in the config - if (!config.assets.banner) { - config.assets.banner = defaultConfig.assets.banner; - } + config = await validateConfig(config); + config = merge({}, defaultConfig, config); - const accessModel = calculateAccessModel(config); + const accessModel = calculateAccessModel(config); - useConfigStore.setState({ - config: config, - accessModel, - }); + useConfigStore.setState({ + config: config, + accessModel, + }); - setCssVariables(config.styling); - maybeInjectAnalyticsLibrary(config); - await onValidationCompleted(config); + maybeInjectAnalyticsLibrary(config); - return config; - } catch (ex: unknown) { - onValidationError(ex as Error); - return; + if (config?.integrations?.cleeng?.id) { + await initializeAccount(); } -}; + + // We only request favorites and continue_watching data if there is a corresponding item in the content section + // and a playlist in the features section. + // We first initialize the account otherwise if we have favorites saved as externalData and in a local storage the sections may blink + if (config.features?.continueWatchingList && config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) { + await restoreWatchHistory(); + } + if (config.features?.favoritesList && config.content.some((el) => el.type === PersonalShelf.Favorites)) { + await initializeFavorites(); + } + + return config; +} function formatSourceLocation(source?: string) { if (!source) { diff --git a/src/utils/configOverride.ts b/src/utils/configOverride.ts index 65be2eb2a..01afbd497 100644 --- a/src/utils/configOverride.ts +++ b/src/utils/configOverride.ts @@ -1,114 +1,103 @@ -// Use session storage so the override persists until the tab is closed and then resets +import type { NavigateFunction } from 'react-router/dist/lib/hooks'; + import { logDev } from '#src/utils/common'; +import type { Settings } from '#src/stores/SettingsStore'; +// Use session storage so the override persists until the tab is closed and then resets const storage = window.sessionStorage; -export const configQueryKey = 'app-config'; +const configQueryKey = 'app-config'; const configLegacyQueryKey = 'c'; const configFileStorageKey = 'config-file-override'; -const DEFAULT_CONFIG_SOURCE = import.meta.env.APP_CONFIG_DEFAULT_SOURCE; -const ALLOWED_SOURCES = import.meta.env.APP_CONFIG_ALLOWED_SOURCES?.split(' ').filter((c) => c.toLowerCase() !== DEFAULT_CONFIG_SOURCE?.toLowerCase()) || []; -const UNSAFE_ALLOW_DYNAMIC_CONFIG = ['1', 'true'].includes(import.meta.env.APP_UNSAFE_ALLOW_DYNAMIC_CONFIG?.toLowerCase() || ''); - -export function getConfigLocation() { - // Require a default source unless the dynamic (demo) mode is enabled - if (!DEFAULT_CONFIG_SOURCE && !UNSAFE_ALLOW_DYNAMIC_CONFIG) { - throw 'A default config is required'; - } - - return getConfigOverride() || DEFAULT_CONFIG_SOURCE; +export function getConfigNavigateCallback(navigate: NavigateFunction) { + return (configSource: string) => { + navigate( + { + pathname: '/', + search: new URLSearchParams([[configQueryKey, configSource]]).toString(), + }, + { replace: true }, + ); + }; } -export function addConfigQueryParam(config?: string) { - const selectedConfig = config || getConfigLocation(); - - // Make sure the config location is appended to the url, - // but only when dynamic (demo) mode is enabled or using multiple configs and not the default - if (selectedConfig && (UNSAFE_ALLOW_DYNAMIC_CONFIG || selectedConfig !== DEFAULT_CONFIG_SOURCE)) { - const url = new URL(window.location.href); - - if (url.searchParams.get(configQueryKey) !== selectedConfig) { - url.searchParams.set(configQueryKey, selectedConfig); - - window.history.replaceState(null, '', url.toString()); - } +export function getConfigSource(searchParams: URLSearchParams, settings: Settings | undefined) { + if (!settings) { + return ''; } -} - -const getStoredConfig = () => { - return storage.getItem(configFileStorageKey); -}; - -export const clearStoredConfig = () => { - storage.removeItem(configFileStorageKey); - const url = new URL(window.location.href); - - url.searchParams.delete(configQueryKey); - window.history.replaceState(null, '', url.toString()); -}; - -function getConfigOverride() { - if (!UNSAFE_ALLOW_DYNAMIC_CONFIG && ALLOWED_SOURCES.length <= 0) { - return undefined; + // Skip all the fancy logic below if there aren't any other options besides the default anyhow + if (!settings.UNSAFE_allowAnyConfigSource && (settings.additionalAllowedConfigSources?.length || 0) <= 0) { + return settings.defaultConfigSource; } - const url = new URL(window.location.href); + const configQueryParam = searchParams.get(configQueryKey) ?? searchParams.get(configLegacyQueryKey); - // If the query string has the legacy key, remove it - if (url.searchParams.has(configLegacyQueryKey)) { - const legacyValue = url.searchParams.get(configLegacyQueryKey); - url.searchParams.delete(configLegacyQueryKey); + if (configQueryParam !== null) { + // If the query param exists but the value is empty, clear the storage and allow fallback to the default config + if (!configQueryParam) { + storage.removeItem(configFileStorageKey); + return settings.defaultConfigSource; + } - // If the new query key is not set, set it from the old key, but do not replace it (the new key 'wins' if both are set) - if (legacyValue !== null && !url.searchParams.has(configQueryKey)) { - url.searchParams.set(configQueryKey, legacyValue); + // If it's valid, store it and return it + if (isValidConfigSource(configQueryParam, settings)) { + storage.setItem(configFileStorageKey, configQueryParam); + return configQueryParam; } - window.history.replaceState(null, '', url.toString()); + logDev(`Invalid app-config query param: ${configQueryParam}`); } + // Yes this falls through from above to look up the stored value if the query string is invalid and that's OK - if (url.searchParams.has(configQueryKey)) { - const configQuery = url.searchParams.get(configQueryKey); + const storedSource = storage.getItem(configFileStorageKey); - // If the query param exists but the value is empty, clear the storage and allow fallback to the default config - if (!configQuery) { - clearStoredConfig(); - return undefined; + // Make sure the stored value is still valid before returning it + if (storedSource) { + if (isValidConfigSource(storedSource, settings)) { + return storedSource; } - // If it's valid, store it and return it - if (isValidConfigSource(configQuery)) { - storage.setItem(configFileStorageKey, configQuery); - return configQuery; - } + logDev('Invalid stored config: ' + storedSource); + storage.removeItem(configFileStorageKey); + } - logDev(`Invalid app-config: ${configQuery}`); + return settings.defaultConfigSource; +} - // Remove the query param if it's invalid - url.searchParams.delete(configQueryKey); - window.history.replaceState(null, '', url.toString()); +export function cleanupQueryParams(searchParams: URLSearchParams, settings: Settings, configSource: string | undefined) { + let anyTouched = false; - // Yes this falls through to look up the stored value if the query string is invalid and that's OK + // Remove the old ?c= param + if (searchParams.has(configLegacyQueryKey)) { + searchParams.delete(configLegacyQueryKey); + anyTouched = true; } - const storedSource = getStoredConfig(); + // If there is no valid config source or the config source equals the default, remove the ?app-config= param + if (searchParams.has(configQueryKey) && (!configSource || configSource === settings?.defaultConfigSource)) { + searchParams.delete(configQueryKey); + anyTouched = true; + } - // Make sure the stored value is still valid before returning it - if (storedSource && isValidConfigSource(storedSource)) { - return storedSource; + // If the config source is not the default and the query string isn't set right, set the ?app-config= param + if (configSource && configSource !== settings?.defaultConfigSource && searchParams.get(configQueryKey) !== configSource) { + searchParams.set(configQueryKey, configSource); + anyTouched = true; } - return undefined; + return anyTouched; } -function isValidConfigSource(source: string) { +function isValidConfigSource(source: string, settings: Settings) { // Dynamic values are valid as long as they are defined - if (UNSAFE_ALLOW_DYNAMIC_CONFIG) { + if (settings?.UNSAFE_allowAnyConfigSource) { return !!source; } - return DEFAULT_CONFIG_SOURCE === source || ALLOWED_SOURCES.indexOf(source) >= 0; + return ( + settings?.defaultConfigSource === source || (settings?.additionalAllowedConfigSources && settings?.additionalAllowedConfigSources.indexOf(source) >= 0) + ); } diff --git a/types/env.d.ts b/types/env.d.ts index 696e2f4b4..3e6ac60b6 100644 --- a/types/env.d.ts +++ b/types/env.d.ts @@ -3,9 +3,6 @@ interface ImportMetaEnv { readonly APP_TITLE: string | undefined; readonly APP_GITHUB_PUBLIC_BASE_URL: string | undefined; - readonly APP_CONFIG_DEFAULT_SOURCE: string | undefined; - readonly APP_CONFIG_ALLOWED_SOURCES: string | undefined; - readonly APP_UNSAFE_ALLOW_DYNAMIC_CONFIG: string | undefined; } interface ImportMeta { diff --git a/vite.config.ts b/vite.config.ts index 3138fbb1d..8049c77ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import path from 'path'; +import fs from 'fs'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -6,7 +7,7 @@ import eslintPlugin from 'vite-plugin-eslint'; import StylelintPlugin from 'vite-plugin-stylelint'; import { VitePWA } from 'vite-plugin-pwa'; import { createHtmlPlugin } from 'vite-plugin-html'; -import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { Target, viteStaticCopy } from 'vite-plugin-static-copy'; export default ({ mode, command }: { mode: string; command: string }) => { // Shorten default mode names to dev / prod @@ -14,6 +15,17 @@ export default ({ mode, command }: { mode: string; command: string }) => { mode = mode === 'development' ? 'dev' : mode; mode = mode === 'production' ? 'prod' : mode; + const localFile = `ini/.webapp.${mode}.ini`; + const templateFile = `ini/templates/.webapp.${mode}.ini`; + + // The build ONLY uses .ini files in /ini to include in the build output. + // All .ini files in the directory are git ignored to customer specific values out of source control. + // However, this script will automatically create a .ini file for the current mode if it doesn't exist + // by copying the corresponding mode file from the ini/templates directory. + if (!fs.existsSync(localFile) && fs.existsSync(templateFile)) { + fs.copyFileSync(templateFile, localFile); + } + // Make sure to builds are always production type, // otherwise modes other than 'production' get built in dev if (command === 'build') { @@ -30,20 +42,28 @@ export default ({ mode, command }: { mode: string; command: string }) => { }), ]; + const fileCopyTargets: Target[] = [ + { + src: localFile, + dest: '', + rename: '.webapp.ini', + }, + ]; + // These files are only needed in dev / test / demo, so don't include in prod builds if (mode !== 'prod') { - plugins.push( - viteStaticCopy({ - targets: [ - { - src: 'test/epg/*', - dest: 'epg', - }, - ], - }), - ); + fileCopyTargets.push({ + src: 'test/epg/*', + dest: 'epg', + }); } + plugins.push( + viteStaticCopy({ + targets: fileCopyTargets, + }), + ); + return defineConfig({ plugins: plugins, publicDir: './public', diff --git a/yarn.lock b/yarn.lock index 05cba39c1..003c1e5a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1037,13 +1037,20 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": version "7.17.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.2.tgz#66f68591605e59da47523c631416b18508779941" integrity sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw== dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.0": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" + integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== + dependencies: + regenerator-runtime "^0.13.10" + "@babel/runtime@^7.13.10": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" @@ -1798,6 +1805,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/ini@^1.3.31": + version "1.3.31" + resolved "https://registry.yarnpkg.com/@types/ini/-/ini-1.3.31.tgz#c78541a187bd88d5c73e990711c9d85214800d1b" + integrity sha512-8ecxxaG4AlVEM1k9+BsziMw8UsX0qy3jYI1ad/71RrDZ+rdL6aZB0wLfAuflQiDhkD5o4yJ0uPK3OSUic3fG0w== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -5120,6 +5132,11 @@ ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.1.tgz#c76ec81007875bc44d544ff7a11a55d12294102d" + integrity sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ== + inquirer@^6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca" @@ -7555,6 +7572,11 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== +regenerator-runtime@^0.13.10: + version "0.13.10" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" + integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== + regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.9: version "0.13.9" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"