From 6babacefc3ae56191625207436fc6faaaca10445 Mon Sep 17 00:00:00 2001 From: langemike Date: Wed, 24 Jan 2024 14:51:54 +0100 Subject: [PATCH 001/128] feat(project): dynamic gtm snippet Co-authored-by: Mike van Veenhuijzen --- platforms/web/vite.config.ts | 54 ++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index 6b3801f7f..4ad4d3b35 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -1,6 +1,6 @@ import path from 'path'; -import { defineConfig } from 'vite'; +import { defineConfig, HtmlTagDescriptor } from 'vite'; import type { ConfigEnv, UserConfigExport } from 'vitest/config'; import react from '@vitejs/plugin-react'; import eslintPlugin from 'vite-plugin-eslint'; @@ -26,6 +26,41 @@ export default ({ mode, command }: ConfigEnv): UserConfigExport => { process.env.NODE_ENV = 'production'; } + const getGoogleScripts = () => { + const tags: HtmlTagDescriptor[] = []; + + if (process.env.APP_GOOGLE_SITE_VERIFICATION_ID) { + tags.push({ + tag: 'meta', + injectTo: 'head', + attrs: { + content: process.env.APP_GOOGLE_SITE_VERIFICATION_ID, + name: 'google-site-verification', + }, + }); + } + if (process.env.APP_GTM_TAG_ID) { + tags.push( + { + injectTo: 'head', + tag: 'script', + children: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': + new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], + j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= + 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); + })(window,document,'script','dataLayer','${process.env.APP_GTM_TAG_ID}');`, + }, + { + injectTo: 'body-prepend', + tag: 'noscript', + children: ``, + }, + ); + } + + return tags; + }; const fileCopyTargets: Target[] = [ { src: localFile, @@ -61,20 +96,9 @@ export default ({ mode, command }: ConfigEnv): UserConfigExport => { VitePWA(), createHtmlPlugin({ minify: true, - inject: process.env.APP_GOOGLE_SITE_VERIFICATION_ID - ? { - tags: [ - { - tag: 'meta', - injectTo: 'head', - attrs: { - content: process.env.APP_GOOGLE_SITE_VERIFICATION_ID, - name: 'google-site-verification', - }, - }, - ], - } - : {}, + inject: { + tags: getGoogleScripts(), + }, }), viteStaticCopy({ targets: fileCopyTargets, From 8c0cb9ad3c0000a78182e1cf75b7de9be5632b0b Mon Sep 17 00:00:00 2001 From: Roy Schut Date: Wed, 24 Jan 2024 12:17:27 -0300 Subject: [PATCH 002/128] refactor(project): make route definitions static and improve url utils --- packages/common/src/paths.tsx | 28 ++++++++ .../common/src/utils/urlFormatting.test.ts | 50 ++++++++++++- packages/common/src/utils/urlFormatting.ts | 70 +++++++++++++++++-- .../src/components/UserMenu/UserMenu.tsx | 26 +++++-- .../ui-react/src/containers/Layout/Layout.tsx | 10 +-- .../src/containers/Profiles/CreateProfile.tsx | 3 +- .../src/containers/Profiles/DeleteProfile.tsx | 3 +- .../src/containers/Profiles/EditProfile.tsx | 5 +- .../ui-react/src/containers/Profiles/Form.tsx | 3 +- .../src/containers/Profiles/Profiles.tsx | 19 +++-- packages/ui-react/src/pages/User/User.tsx | 41 ++++++++--- .../src/containers/AppRoutes/AppRoutes.tsx | 41 +++++++---- 12 files changed, 251 insertions(+), 48 deletions(-) create mode 100644 packages/common/src/paths.tsx diff --git a/packages/common/src/paths.tsx b/packages/common/src/paths.tsx new file mode 100644 index 000000000..2c90a7fb4 --- /dev/null +++ b/packages/common/src/paths.tsx @@ -0,0 +1,28 @@ +export const PATH_HOME = '/'; + +export const PATH_MEDIA = '/m/:id/*'; +export const PATH_PLAYLIST = '/p/:id/*'; +export const PATH_LEGACY_SERIES = '/s/:id/*'; + +export const PATH_SEARCH = '/q/*'; +export const PATH_ABOUT = '/o/about'; + +export const PATH_USER = '/u/*'; +export const PATH_USER_ACCOUNT = '/u/my-account'; +export const PATH_USER_MY_PROFILE = '/u/my-profile/:id'; +export const PATH_USER_FAVORITES = '/u/favorites'; +export const PATH_USER_PAYMENTS = '/u/payments'; +export const PATH_USER_PROFILES = '/u/profiles'; +export const PATH_USER_PROFILES_CREATE = '/u/profiles/create'; +export const PATH_USER_PROFILES_EDIT = '/u/profiles/edit'; +export const PATH_USER_PROFILES_EDIT_PROFILE = '/u/profiles/edit/:id'; + +// Get a nested path without the parent prefix (for nested routes) +// I.E.: getNestedPath('/u/*', '/u/my-account') => 'my-account' +const getNestedPath = (parentPath: string, fullPath: string) => fullPath.replace(parentPath.replace('*', ''), ''); + +export const NESTED_PATH_USER_ACCOUNT = getNestedPath(PATH_USER, PATH_USER_ACCOUNT); +export const NESTED_PATH_USER_MY_PROFILE = getNestedPath(PATH_USER, PATH_USER_MY_PROFILE); +export const NESTED_PATH_USER_FAVORITES = getNestedPath(PATH_USER, PATH_USER_FAVORITES); +export const NESTED_PATH_USER_PAYMENTS = getNestedPath(PATH_USER, PATH_USER_PAYMENTS); +export const NESTED_PATH_USER_PROFILES = getNestedPath(PATH_USER, PATH_USER_PROFILES); diff --git a/packages/common/src/utils/urlFormatting.test.ts b/packages/common/src/utils/urlFormatting.test.ts index 2af8d84cf..ab41efd2d 100644 --- a/packages/common/src/utils/urlFormatting.test.ts +++ b/packages/common/src/utils/urlFormatting.test.ts @@ -1,7 +1,14 @@ -import { createURL } from './urlFormatting'; +import playlistFixture from '@jwp/ott-testing/fixtures/playlist.json'; +import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; + +import type { Playlist, PlaylistItem } from '../../types/playlist'; +import type { EpgChannel } from '../../types/epg'; +import { NESTED_PATH_USER_ACCOUNT } from '../paths'; + +import { createURL, liveChannelsURL, mediaURL, playlistURL, userProfileURL } from './urlFormatting'; describe('createUrl', () => { - test('valid url from a path, query params', async () => { + test('valid url from a path, query params', () => { const url = createURL('/test', { foo: 'bar' }); expect(url).toEqual('/test?foo=bar'); @@ -23,3 +30,42 @@ describe('createUrl', () => { expect(url).toEqual('https://app-preview.jwplayer.com/?existing-param=1&foo=bar&u=payment-method-success'); }); }); + +describe('createPath, mediaURL, playlistURL and liveChannelsURL', () => { + test('valid media path', () => { + const playlist = playlistFixture as Playlist; + const media = playlist.playlist[0] as PlaylistItem; + const url = mediaURL({ media, playlistId: playlist.feedid, play: true }); + + expect(url).toEqual('/m/uB8aRnu6/agent-327?r=dGSUzs9o&play=1'); + }); + test('valid playlist path', () => { + const playlist = playlistFixture as Playlist; + const url = playlistURL(playlist.feedid || '', playlist.title); + + expect(url).toEqual('/p/dGSUzs9o/all-films'); + }); + test('valid live channel path', () => { + const playlist = playlistFixture as Playlist; + const channels: EpgChannel[] = epgChannelsFixture; + const channel = channels[0]; + const url = liveChannelsURL(playlist.feedid || '', channel.id, true); + + expect(url).toEqual('/p/dGSUzs9o/?channel=channel1&play=1'); + }); + test('valid live channel path', () => { + const url = userProfileURL('testprofile123'); + + expect(url).toEqual('/u/my-profile/testprofile123'); + }); + test('valid nested user path', () => { + const url = NESTED_PATH_USER_ACCOUNT; + + expect(url).toEqual('my-account'); + }); + test('valid nested user profile path', () => { + const url = userProfileURL('testprofile123', true); + + expect(url).toEqual('my-profile/testprofile123'); + }); +}); diff --git a/packages/common/src/utils/urlFormatting.ts b/packages/common/src/utils/urlFormatting.ts index f14651ee4..e0e4020ef 100644 --- a/packages/common/src/utils/urlFormatting.ts +++ b/packages/common/src/utils/urlFormatting.ts @@ -1,4 +1,5 @@ import type { PlaylistItem } from '../../types/playlist'; +import { NESTED_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE } from '../paths'; import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media'; @@ -25,6 +26,50 @@ export const createURL = (url: string, queryParams: QueryParamsArg) => { return `${baseUrl}${queryString ? `?${queryString}` : ''}`; }; +type ExtractRouteParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? { [K in Param | keyof ExtractRouteParams]: string } + : T extends `${infer _Start}:${infer Param}` + ? { [K in Param]: string } + : object; + +type PathParams = T extends `${infer _Start}*` ? ExtractRouteParams & Record : ExtractRouteParams; + +// Creates a route path from a path string and params object +export const createPath = (originalPath: Path, pathParams?: PathParams, queryParams?: QueryParamsArg): string => { + const path = originalPath + .split('/') + .map((segment) => { + if (segment === '*') { + // Wild card for optional segments: add all params that are not already in the path + if (!pathParams) return segment; + + return Object.entries(pathParams) + .filter(([key]) => !originalPath.includes(key)) + .map(([_, value]) => value) + .join('/'); + } + if (!segment.startsWith(':') || !pathParams) return segment; + + // Check if param is optional, and show a warning if it's not optional and missing + // Then remove all special characters to get the actual param name + const isOptional = segment.endsWith('?'); + const paramName = segment.replace(':', '').replace('?', ''); + const paramValue = pathParams[paramName as keyof typeof pathParams]; + + if (!paramValue) { + if (!isOptional) console.warn('Missing param in path creation.', { path: originalPath, paramName }); + + return ''; + } + + return paramValue; + }) + .join('/'); + + // Optionally add the query params + return queryParams ? createURL(path, queryParams) : path; +}; + export const slugify = (text: string, whitespaceChar: string = '-') => text .toString() @@ -47,16 +92,31 @@ export const mediaURL = ({ play?: boolean; episodeId?: string; }) => { - return createURL(`/m/${media.mediaid}/${slugify(media.title)}`, { r: playlistId, play: play ? '1' : null, e: episodeId }); + return createPath(PATH_MEDIA, { id: media.mediaid, title: slugify(media.title) }, { r: playlistId, play: play ? '1' : null, e: episodeId }); +}; + +export const playlistURL = (id: string, title?: string) => { + return createPath(PATH_PLAYLIST, { id, title: title ? slugify(title) : undefined }); }; export const liveChannelsURL = (playlistId: string, channelId?: string, play = false) => { - return createURL(`/p/${playlistId}`, { - channel: channelId, - play: play ? '1' : null, - }); + return createPath( + PATH_PLAYLIST, + { id: playlistId }, + { + channel: channelId, + play: play ? '1' : null, + }, + ); +}; + +export const userProfileURL = (profileId: string, nested = false) => { + const path = nested ? NESTED_PATH_USER_MY_PROFILE : PATH_USER_MY_PROFILE; + + return createPath(path, { id: profileId }); }; +// Legacy URLs export const legacySeriesURL = ({ seriesId, episodeId, diff --git a/packages/ui-react/src/components/UserMenu/UserMenu.tsx b/packages/ui-react/src/components/UserMenu/UserMenu.tsx index ccb6027ba..b4fcfa582 100644 --- a/packages/ui-react/src/components/UserMenu/UserMenu.tsx +++ b/packages/ui-react/src/components/UserMenu/UserMenu.tsx @@ -9,6 +9,8 @@ import AccountCircle from '@jwp/ott-theme/assets/icons/account_circle.svg?react' import Favorite from '@jwp/ott-theme/assets/icons/favorite.svg?react'; import BalanceWallet from '@jwp/ott-theme/assets/icons/balance_wallet.svg?react'; import Exit from '@jwp/ott-theme/assets/icons/exit.svg?react'; +import { PATH_USER_ACCOUNT, PATH_USER_FAVORITES, PATH_USER_PAYMENTS, PATH_USER_PROFILES_CREATE } from '@jwp/ott-common/src/paths'; +import { userProfileURL } from '@jwp/ott-common/src/utils/urlFormatting'; import MenuButton from '../MenuButton/MenuButton'; import Icon from '../Icon/Icon'; @@ -71,7 +73,7 @@ const UserMenu = ({ selectProfile={selectProfile} createButtonLabel={t('nav.add_profile')} switchProfilesLabel={t('nav.switch_profiles')} - onCreateButtonClick={() => navigate('/u/profiles/create')} + onCreateButtonClick={() => navigate(PATH_USER_PROFILES_CREATE)} /> )}
  • {t('nav.settings')}
  • @@ -80,7 +82,7 @@ const UserMenu = ({ } @@ -88,12 +90,26 @@ const UserMenu = ({ )}
  • - } tabIndex={tabIndex} /> + } + tabIndex={tabIndex} + />
  • {favoritesEnabled && (
  • - } tabIndex={tabIndex} /> + } + tabIndex={tabIndex} + />
  • )} {showPaymentsItem && ( @@ -101,7 +117,7 @@ const UserMenu = ({ } tabIndex={tabIndex} diff --git a/packages/ui-react/src/containers/Layout/Layout.tsx b/packages/ui-react/src/containers/Layout/Layout.tsx index 5504e0bf7..eaceefc42 100644 --- a/packages/ui-react/src/containers/Layout/Layout.tsx +++ b/packages/ui-react/src/containers/Layout/Layout.tsx @@ -15,6 +15,8 @@ import { IS_DEVELOPMENT_BUILD } from '@jwp/ott-common/src/utils/common'; import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; import useSearchQueryUpdater from '@jwp/ott-ui-react/src/hooks/useSearchQueryUpdater'; import { useProfiles, useSelectProfile } from '@jwp/ott-hooks-react/src/useProfiles'; +import { PATH_HOME, PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; +import { playlistURL } from '@jwp/ott-common/src/utils/urlFormatting'; import MarkdownComponent from '../../components/MarkdownComponent/MarkdownComponent'; import Header from '../../components/Header/Header'; @@ -51,8 +53,8 @@ const Layout = () => { } = useProfiles(); const selectProfile = useSelectProfile({ - onSuccess: () => navigate('/'), - onError: () => navigate('/u/profiles'), + onSuccess: () => navigate(PATH_HOME), + onError: () => navigate(PATH_USER_PROFILES), }); const { searchQuery, searchActive, userMenuOpen, languageMenuOpen } = useUIStore( @@ -186,13 +188,13 @@ const Layout = () => { > + + + + `; diff --git a/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx index 5ae067d4b..fc97c6058 100644 --- a/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; -import type { ChooseOfferFormData, OfferType } from '@jwp/ott-common/types/account'; import type { FormErrors } from '@jwp/ott-common/types/form'; -import type { Offer } from '@jwp/ott-common/types/checkout'; +import type { Offer, ChooseOfferFormData, OfferType } from '@jwp/ott-common/types/checkout'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { getOfferPrice, isSVODOffer } from '@jwp/ott-common/src/utils/subscription'; import { testId } from '@jwp/ott-common/src/utils/common'; diff --git a/packages/ui-react/src/components/DeleteAccountModal/DeleteAccountModal.tsx b/packages/ui-react/src/components/DeleteAccountModal/DeleteAccountModal.tsx index 053f5d3db..4025919df 100644 --- a/packages/ui-react/src/components/DeleteAccountModal/DeleteAccountModal.tsx +++ b/packages/ui-react/src/components/DeleteAccountModal/DeleteAccountModal.tsx @@ -1,11 +1,11 @@ import { useTranslation } from 'react-i18next'; -import { object, type SchemaOf, string } from 'yup'; +import { object, string } from 'yup'; import { useLocation, useNavigate } from 'react-router'; import { useCallback, useEffect, useState } from 'react'; import { useMutation } from 'react-query'; import type { DeleteAccountFormData } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import useForm from '@jwp/ott-hooks-react/src/useForm'; import PasswordField from '../PasswordField/PasswordField'; @@ -35,24 +35,20 @@ const DeleteAccountModal = () => { const navigate = useNavigate(); const location = useLocation(); - const validationSchema: SchemaOf = object().shape({ - password: string().required(t('login.field_required')), - }); - const initialValues: DeleteAccountFormData = { password: '' }; const { handleSubmit, handleChange, values, errors, reset: resetForm, - } = useForm( - initialValues, - () => { + } = useForm({ + initialValues: { password: '' }, + validationSchema: object().shape({ password: string().required(t('login.field_required')) }), + onSubmit: (values) => { setEnteredPassword(values.password); navigate(modalURLFromLocation(location, 'delete-account-confirmation'), { replace: true }); }, - validationSchema, - ); + }); useEffect(() => { if (!location.search.includes('delete-account-confirmation') && enteredPassword) { diff --git a/packages/ui-react/src/components/DeleteAccountPasswordWarning/DeleteAccountPasswordWarning.tsx b/packages/ui-react/src/components/DeleteAccountPasswordWarning/DeleteAccountPasswordWarning.tsx index 9dad15769..3d1d2bebd 100644 --- a/packages/ui-react/src/components/DeleteAccountPasswordWarning/DeleteAccountPasswordWarning.tsx +++ b/packages/ui-react/src/components/DeleteAccountPasswordWarning/DeleteAccountPasswordWarning.tsx @@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router'; import { useCallback, useState } from 'react'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import Button from '../Button/Button'; import FormFeedback from '../FormFeedback/FormFeedback'; diff --git a/packages/ui-react/src/components/EditCardPaymentForm/EditCardPaymentForm.tsx b/packages/ui-react/src/components/EditCardPaymentForm/EditCardPaymentForm.tsx index aa0561687..66e422b83 100644 --- a/packages/ui-react/src/components/EditCardPaymentForm/EditCardPaymentForm.tsx +++ b/packages/ui-react/src/components/EditCardPaymentForm/EditCardPaymentForm.tsx @@ -6,7 +6,7 @@ import { useMutation } from 'react-query'; import { shallow } from '@jwp/ott-common/src/utils/compare'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import useForm from '@jwp/ott-hooks-react/src/useForm'; import Button from '../Button/Button'; @@ -17,6 +17,15 @@ import TextField from '../TextField/TextField'; import styles from './EditCardPaymentForm.module.scss'; +type EditCardPaymentFormData = { + cardholderName: string; + cardNumber: string; + cardExpiry: string; + cardCVC: string; + cardExpMonth: string; + cardExpYear: string; +}; + type Props = { onCancel: () => void; setUpdatingCardDetails: (e: boolean) => void; @@ -28,9 +37,33 @@ const EditCardPaymentForm: React.FC = ({ onCancel, setUpdatingCardDetails const { t } = useTranslation('account'); const updateCard = useMutation(accountController.updateCardDetails); const { activePayment } = useAccountStore(({ activePayment }) => ({ activePayment }), shallow); - const paymentData = useForm( - { cardholderName: '', cardNumber: '', cardExpiry: '', cardCVC: '', cardExpMonth: '', cardExpYear: '' }, - async () => { + + const paymentData = useForm({ + initialValues: { cardholderName: '', cardNumber: '', cardExpiry: '', cardCVC: '', cardExpMonth: '', cardExpYear: '' }, + validationSchema: object().shape({ + cardholderName: string().required(), + cardNumber: string() + .required() + .test('card number validation', t('checkout.invalid_card_number'), (value) => { + return Payment.fns.validateCardNumber(value as string); + }), + cardExpiry: string() + .required() + .test('card expiry validation', t('checkout.invalid_card_expiry'), (value) => { + return Payment.fns.validateCardExpiry(value as string); + }), + cardCVC: string() + .required() + .test('cvc validation', t('checkout.invalid_cvc_number'), (value) => { + const issuer = Payment.fns.cardType(paymentData?.values?.cardNumber); + + return Payment.fns.validateCardCVC(value as string, issuer); + }), + cardExpMonth: string().required(), + cardExpYear: string().required(), + }), + validateOnBlur: true, + onSubmit: async () => { setUpdatingCardDetails(true); updateCard.mutate( { @@ -46,21 +79,7 @@ const EditCardPaymentForm: React.FC = ({ onCancel, setUpdatingCardDetails }, ); }, - object().shape({ - cardNumber: string().test('card number validation', t('checkout.invalid_card_number'), (value) => { - return Payment.fns.validateCardNumber(value as string); - }), - cardExpiry: string().test('card expiry validation', t('checkout.invalid_card_expiry'), (value) => { - return Payment.fns.validateCardExpiry(value as string); - }), - cardCVC: string().test('cvc validation', t('checkout.invalid_cvc_number'), (value) => { - const issuer = Payment.fns.cardType(paymentData?.values?.cardNumber); - return Payment.fns.validateCardCVC(value as string, issuer); - }), - }), - - true, - ); + }); useEffect(() => { if (paymentData.values.cardExpiry) { diff --git a/packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx b/packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx index 59e1318e1..18c7f2118 100644 --- a/packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx +++ b/packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx @@ -4,8 +4,8 @@ import { useLocation, useNavigate } from 'react-router'; import { useSearchParams } from 'react-router-dom'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback'; diff --git a/packages/ui-react/src/components/Header/Header.test.tsx b/packages/ui-react/src/components/Header/Header.test.tsx index 0f0d40d08..82046af6e 100644 --- a/packages/ui-react/src/components/Header/Header.test.tsx +++ b/packages/ui-react/src/components/Header/Header.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { mockService } from '@jwp/ott-common/test/mockService'; import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; diff --git a/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.test.tsx b/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.test.tsx index a8322402d..44a50daf7 100644 --- a/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.test.tsx +++ b/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.test.tsx @@ -5,7 +5,7 @@ import NoPaymentRequired from './NoPaymentRequired'; describe('', () => { test('renders and matches snapshot', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); diff --git a/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.tsx b/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.tsx index be6686422..a0eaf2ada 100644 --- a/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.tsx +++ b/packages/ui-react/src/components/NoPaymentRequired/NoPaymentRequired.tsx @@ -8,7 +8,7 @@ import styles from './NoPaymentRequired.module.scss'; type Props = { onSubmit?: () => void; - error?: string; + error: string | null; }; const NoPaymentRequired: React.FC = ({ onSubmit, error }) => { diff --git a/packages/ui-react/src/components/PayPal/PayPal.test.tsx b/packages/ui-react/src/components/PayPal/PayPal.test.tsx index fc93514b7..ac2b12f2b 100644 --- a/packages/ui-react/src/components/PayPal/PayPal.test.tsx +++ b/packages/ui-react/src/components/PayPal/PayPal.test.tsx @@ -5,7 +5,7 @@ import PayPal from './PayPal'; describe('', () => { test('renders and matches snapshot', () => { - const { container } = render(); + const { container } = render(); expect(container).toMatchSnapshot(); }); diff --git a/packages/ui-react/src/components/PayPal/PayPal.tsx b/packages/ui-react/src/components/PayPal/PayPal.tsx index ee88d4036..3b99a234a 100644 --- a/packages/ui-react/src/components/PayPal/PayPal.tsx +++ b/packages/ui-react/src/components/PayPal/PayPal.tsx @@ -8,7 +8,7 @@ import styles from './PayPal.module.scss'; type Props = { onSubmit?: () => void; - error?: string; + error: string | null; }; const PayPal: React.FC = ({ onSubmit, error }) => { diff --git a/packages/ui-react/src/components/PaymentForm/PaymentForm.module.scss b/packages/ui-react/src/components/PaymentForm/PaymentForm.module.scss index 6c31fcad8..11e772bfb 100644 --- a/packages/ui-react/src/components/PaymentForm/PaymentForm.module.scss +++ b/packages/ui-react/src/components/PaymentForm/PaymentForm.module.scss @@ -8,4 +8,8 @@ grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 15px; -} \ No newline at end of file +} + +.formError { + margin-top: 5px; +} diff --git a/packages/ui-react/src/components/PaymentForm/PaymentForm.tsx b/packages/ui-react/src/components/PaymentForm/PaymentForm.tsx index 310aac00f..d92043ff6 100644 --- a/packages/ui-react/src/components/PaymentForm/PaymentForm.tsx +++ b/packages/ui-react/src/components/PaymentForm/PaymentForm.tsx @@ -1,121 +1,108 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate } from 'react-router-dom'; import Payment from 'payment'; import { object, string } from 'yup'; -import { getModule } from '@jwp/ott-common/src/modules/container'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; import useForm from '@jwp/ott-hooks-react/src/useForm'; -import useCheckAccess from '@jwp/ott-hooks-react/src/useCheckAccess'; -import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; +import { testId } from '@jwp/ott-common/src/utils/common'; import Button from '../Button/Button'; import CreditCardCVCField from '../CreditCardCVCField/CreditCardCVCField'; import CreditCardExpiryField from '../CreditCardExpiryField/CreditCardExpiryField'; import CreditCardNumberField from '../CreditCardNumberField/CreditCardNumberField'; import TextField from '../TextField/TextField'; -import { modalURLFromLocation } from '../../utils/location'; +import FormFeedback from '../FormFeedback/FormFeedback'; import styles from './PaymentForm.module.scss'; -type Props = { - couponCode?: string; - setUpdatingOrder: (value: boolean) => void; +export type PaymentFormData = { + cardholderName: string; + cardNumber: string; + cardExpiry: string; + cardCVC: string; + cardExpMonth: string; + cardExpYear: string; }; -const PaymentForm: React.FC = ({ couponCode, setUpdatingOrder }) => { - const checkoutController = getModule(CheckoutController); +type Props = { + onPaymentFormSubmit: (values: PaymentFormData) => void; +}; +const PaymentForm: React.FC = ({ onPaymentFormSubmit }) => { const { t } = useTranslation('account'); - const location = useLocation(); - const navigate = useNavigate(); - const { intervalCheckAccess } = useCheckAccess(); - const paymentData = useForm( - { cardholderName: '', cardNumber: '', cardExpiry: '', cardCVC: '', cardExpMonth: '', cardExpYear: '' }, - async () => { - setUpdatingOrder(true); + const { values, errors, setValue, handleChange, handleBlur, handleSubmit } = useForm({ + initialValues: { cardholderName: '', cardNumber: '', cardExpiry: '', cardCVC: '', cardExpMonth: '', cardExpYear: '' }, + validationSchema: object() + .required() + .shape({ + cardholderName: string().required(), + cardNumber: string() + .required() + .test('card number validation', t('checkout.invalid_card_number'), (value) => Payment.fns.validateCardNumber(value as string)), + cardExpiry: string() + .required() + .test('card expiry validation', t('checkout.invalid_card_expiry'), (value) => Payment.fns.validateCardExpiry(value as string)), + cardCVC: string() + .required() + .test('cvc validation', t('checkout.invalid_cvc_number'), (value, context) => { + const issuer = Payment.fns.cardType(context.parent.cardNumber); - const referrer = window.location.href; - const returnUrl = createURL(window.location.href, { u: 'waiting-for-payment' }); - - await checkoutController.directPostCardPayment({ couponCode, ...paymentData.values }, referrer, returnUrl); - - intervalCheckAccess({ - interval: 15000, - callback: (hasAccess) => hasAccess && navigate(modalURLFromLocation(location, 'welcome')), - }); - }, - object().shape({ - cardNumber: string().test('card number validation', t('checkout.invalid_card_number'), (value) => { - return Payment.fns.validateCardNumber(value as string); - }), - cardExpiry: string().test('card expiry validation', t('checkout.invalid_card_expiry'), (value) => { - return Payment.fns.validateCardExpiry(value as string); + return Payment.fns.validateCardCVC(value as string, issuer); + }), + cardExpMonth: string().required(), + cardExpYear: string().required(), }), - cardCVC: string().test('cvc validation', t('checkout.invalid_cvc_number'), (value) => { - const issuer = Payment.fns.cardType(paymentData?.values?.cardNumber); - return Payment.fns.validateCardCVC(value as string, issuer); - }), - }), - true, - ); + validateOnBlur: true, + onSubmit: onPaymentFormSubmit, + }); useEffect(() => { - if (paymentData.values.cardExpiry) { - const expiry = Payment.fns.cardExpiryVal(paymentData.values.cardExpiry); + if (values.cardExpiry) { + const expiry = Payment.fns.cardExpiryVal(values.cardExpiry); if (expiry.month) { - paymentData.setValue('cardExpMonth', expiry.month.toString()); + setValue('cardExpMonth', expiry.month.toString()); } if (expiry.year) { - paymentData.setValue('cardExpYear', expiry.year.toString()); + setValue('cardExpYear', expiry.year.toString()); } } //eslint-disable-next-line - }, [paymentData.values.cardExpiry]); + }, [values.cardExpiry]); return (
    -
    - -
    -
    - -
    -
    +
    + {errors.form ? ( +
    + {errors.form} +
    + ) : null}
    -
    - + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    -
    -
    -
    +
    ); }; diff --git a/packages/ui-react/src/components/RegistrationForm/RegistrationForm.test.tsx b/packages/ui-react/src/components/RegistrationForm/RegistrationForm.test.tsx index 782ea6d69..c52ba9dfc 100644 --- a/packages/ui-react/src/components/RegistrationForm/RegistrationForm.test.tsx +++ b/packages/ui-react/src/components/RegistrationForm/RegistrationForm.test.tsx @@ -8,6 +8,7 @@ describe('', () => { test('renders and matches snapshot', () => { const { container } = renderWithRouter( = ({ diff --git a/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx b/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx index 6eedb656e..8ac1fcc28 100644 --- a/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx +++ b/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { mockService } from '@jwp/ott-common/test/mockService'; import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; diff --git a/packages/ui-react/src/components/UserMenu/UserMenu.tsx b/packages/ui-react/src/components/UserMenu/UserMenu.tsx index b21ca739c..d0879f27e 100644 --- a/packages/ui-react/src/components/UserMenu/UserMenu.tsx +++ b/packages/ui-react/src/components/UserMenu/UserMenu.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import AccountCircle from '@jwp/ott-theme/assets/icons/account_circle.svg?react'; import Favorite from '@jwp/ott-theme/assets/icons/favorite.svg?react'; import BalanceWallet from '@jwp/ott-theme/assets/icons/balance_wallet.svg?react'; diff --git a/packages/ui-react/src/containers/AccountModal/AccountModal.test.tsx b/packages/ui-react/src/containers/AccountModal/AccountModal.test.tsx index 17a6f5118..0fc8c32ea 100644 --- a/packages/ui-react/src/containers/AccountModal/AccountModal.test.tsx +++ b/packages/ui-react/src/containers/AccountModal/AccountModal.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { mockService } from '@jwp/ott-common/test/mockService'; import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; diff --git a/packages/ui-react/src/containers/AccountModal/forms/CancelSubscription.tsx b/packages/ui-react/src/containers/AccountModal/forms/CancelSubscription.tsx index 67830f104..626ba2482 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/CancelSubscription.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/CancelSubscription.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { formatLocalizedDate } from '@jwp/ott-common/src/utils/formatting'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; diff --git a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx index 95a818886..525f6dfd6 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx @@ -1,218 +1,88 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router'; -import { useTranslation } from 'react-i18next'; -import { shallow } from '@jwp/ott-common/src/utils/compare'; -import { getModule } from '@jwp/ott-common/src/modules/container'; -import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; -import { isSVODOffer } from '@jwp/ott-common/src/utils/subscription'; +import useCheckout from '@jwp/ott-hooks-react/src/useCheckout'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import useForm from '@jwp/ott-hooks-react/src/useForm'; import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; +import { FormValidationError } from '@jwp/ott-common/src/FormValidationError'; import CheckoutForm from '../../../components/CheckoutForm/CheckoutForm'; import LoadingOverlay from '../../../components/LoadingOverlay/LoadingOverlay'; import PayPal from '../../../components/PayPal/PayPal'; import NoPaymentRequired from '../../../components/NoPaymentRequired/NoPaymentRequired'; -import PaymentForm from '../../../components/PaymentForm/PaymentForm'; +import PaymentForm, { PaymentFormData } from '../../../components/PaymentForm/PaymentForm'; import AdyenInitialPayment from '../../AdyenInitialPayment/AdyenInitialPayment'; const Checkout = () => { - const accountController = getModule(AccountController); - const checkoutController = getModule(CheckoutController); - const location = useLocation(); - const { t } = useTranslation('account'); const navigate = useNavigate(); - const [paymentError, setPaymentError] = useState(undefined); - const [updatingOrder, setUpdatingOrder] = useState(false); - const [couponFormOpen, setCouponFormOpen] = useState(false); - const [couponCodeApplied, setCouponCodeApplied] = useState(false); - const [paymentMethodId, setPaymentMethodId] = useState(undefined); - - const { order, offer, paymentMethods, setOrder } = useCheckoutStore( - ({ order, offer, paymentMethods, setOrder }) => ({ - order, - offer, - paymentMethods, - setOrder, - }), - shallow, - ); + const [adyenUpdating, setAdyenUpdating] = useState(false); // @todo: integrate AdyenInitialPayment into useCheckout - const offerType = offer && !isSVODOffer(offer) ? 'tvod' : 'svod'; - - const paymentSuccessUrl = useMemo(() => { - return modalURLFromLocation(location, offerType === 'svod' ? 'welcome' : null); - }, [location, offerType]); - - const couponCodeForm = useForm({ couponCode: '' }, async (values, { setSubmitting, setErrors }) => { - setUpdatingOrder(true); - setCouponCodeApplied(false); - - if (values.couponCode && order) { - try { - await checkoutController.updateOrder(order, paymentMethodId, values.couponCode); - setCouponCodeApplied(true); - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message.includes(`Order with id ${order.id} not found`)) { - navigate(modalURLFromLocation(location, 'choose-offer'), { replace: true }); - } else { - setErrors({ couponCode: t('checkout.coupon_not_valid') }); - } - } + const [couponFormOpen, setCouponFormOpen] = useState(false); + const [showCouponCodeSuccess, setShowCouponCodeSuccess] = useState(false); + + const chooseOfferUrl = modalURLFromLocation(location, 'choose-offer'); + const welcomeUrl = modalURLFromLocation(location, 'welcome'); + const closeModalUrl = modalURLFromLocation(location, null); + + const backButtonClickHandler = () => navigate(chooseOfferUrl); + + const { offer, offerType, paymentMethods, order, isSubmitting, updateOrder, submitPaymentWithoutDetails, submitPaymentPaypal, submitPaymentStripe } = + useCheckout({ + onUpdateOrderSuccess: () => setShowCouponCodeSuccess(true), + onSubmitPaymentWithoutDetailsSuccess: () => navigate(offerType === 'svod' ? welcomeUrl : closeModalUrl, { replace: true }), + onSubmitPaypalPaymentSuccess: (paypalUrl: string) => { + window.location.href = paypalUrl; + }, + onSubmitStripePaymentSuccess: () => navigate(modalURLFromLocation(location, 'waiting-for-payment'), { replace: true }), + }); + + const { + values: { couponCode, paymentMethodId }, + setValue, + submitting: couponFormSubmitting, + errors, + handleChange, + handleSubmit, + } = useForm({ + initialValues: { couponCode: '', paymentMethodId: paymentMethods?.[0]?.id?.toString() || '' }, + onSubmit: async ({ couponCode, paymentMethodId }) => { + setShowCouponCodeSuccess(false); + + return await updateOrder.mutateAsync({ couponCode, paymentMethodId: parseInt(paymentMethodId) }); + }, + onSubmitSuccess: ({ couponCode }): void => setShowCouponCodeSuccess(!!couponCode), + onSubmitError: ({ error }) => { + if (error instanceof FormValidationError && error.errors.order?.includes(`not found`)) { + navigate(modalURLFromLocation(location, 'choose-offer'), { replace: true }); } - } - - setUpdatingOrder(false); - setSubmitting(false); + }, }); - const handleCouponFormSubmit: React.FormEventHandler = async (e) => { - e.preventDefault(); - setUpdatingOrder(true); - setCouponCodeApplied(false); - couponCodeForm.setErrors({ couponCode: undefined }); - if (couponCodeForm.values.couponCode && order) { - try { - await checkoutController.updateOrder(order, paymentMethodId, couponCodeForm.values.couponCode); - setCouponCodeApplied(true); - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message.includes(`Order with id ${order.id} not found`)) { - navigate(modalURLFromLocation(location, 'choose-offer'), { replace: true }); - } else { - couponCodeForm.setErrors({ couponCode: t('checkout.coupon_not_valid') }); - } - } - } - } + const handlePaymentMethodChange = (event: React.ChangeEvent) => { + handleChange(event); - setUpdatingOrder(false); - couponCodeForm.setSubmitting(false); + // Always send payment method to backend + updateOrder.mutateAsync({ couponCode, paymentMethodId: parseInt(event.target.value) }); }; useEffect(() => { - async function createNewOrder() { - if (offer) { - setUpdatingOrder(true); - setCouponCodeApplied(false); - const methods = await checkoutController.getPaymentMethods(); - - setPaymentMethodId(methods[0]?.id); - - await checkoutController.createOrder(offer, methods[0]?.id); - - setUpdatingOrder(false); - } - } - if (!offer) { - return navigate(modalURLFromLocation(location, 'choose-offer'), { replace: true }); + return navigate(chooseOfferUrl, { replace: true }); } + }, [navigate, chooseOfferUrl, offer]); - // noinspection JSIgnoredPromiseFromCall - createNewOrder(); - }, [location, navigate, offer, checkoutController]); - - // clear the order after closing the checkout modal + // Pre-select first payment method useEffect(() => { - return () => setOrder(null); - }, [setOrder]); - - const backButtonClickHandler = () => { - navigate(modalURLFromLocation(location, 'choose-offer')); - }; - - const handlePaymentMethodChange = (event: React.ChangeEvent) => { - const toPaymentMethodId = parseInt(event.target.value); - - setPaymentMethodId(toPaymentMethodId); - setPaymentError(undefined); - - if (order && toPaymentMethodId) { - setUpdatingOrder(true); - setCouponCodeApplied(false); - checkoutController - .updateOrder(order, toPaymentMethodId, couponCodeForm.values.couponCode) - .catch((error: Error) => { - if (error.message.includes(`Order with id ${order.id}} not found`)) { - navigate(modalURLFromLocation(location, 'choose-offer')); - } - }) - .finally(() => setUpdatingOrder(false)); - } - }; - - const handleNoPaymentRequiredSubmit = async () => { - try { - setUpdatingOrder(true); - setPaymentError(undefined); - await checkoutController.paymentWithoutDetails(); - await accountController.reloadSubscriptions({ delay: 1000 }); - navigate(paymentSuccessUrl, { replace: true }); - } catch (error: unknown) { - if (error instanceof Error) { - setPaymentError(error.message); - } - } - - setUpdatingOrder(false); - }; - - const handlePayPalSubmit = async () => { - try { - setPaymentError(undefined); - setUpdatingOrder(true); - const cancelUrl = createURL(window.location.href, { u: 'payment-cancelled' }); - const waitingUrl = createURL(window.location.href, { u: 'waiting-for-payment' }); - const errorUrl = createURL(window.location.href, { u: 'payment-error' }); - const successUrl = `${window.location.origin}${paymentSuccessUrl}`; - - const response = await checkoutController.paypalPayment(successUrl, waitingUrl, cancelUrl, errorUrl, couponCodeForm.values.couponCode); - - if (response.redirectUrl) { - window.location.href = response.redirectUrl; - } - } catch (error: unknown) { - if (error instanceof Error) { - setPaymentError(error.message); - } - } - setUpdatingOrder(false); - }; - - const renderPaymentMethod = () => { - const paymentMethod = paymentMethods?.find((method) => method.id === paymentMethodId); - - if (!order || !offer) return null; + if (!paymentMethods?.length) return; - if (!order.requiredPaymentDetails) { - return ; - } - - if (paymentMethod?.methodName === 'card') { - if (paymentMethod?.provider === 'stripe') { - return ; - } + setValue('paymentMethodId', paymentMethods[0].id.toString()); + }, [paymentMethods, setValue]); - return ( - - ); - } else if (paymentMethod?.methodName === 'paypal') { - return ; - } - - return null; - }; + // clear after closing the checkout modal + useEffect(() => { + return () => setShowCouponCodeSuccess(false); + }, []); // loading state if (!offer || !order || !paymentMethods || !offerType) { @@ -223,6 +93,19 @@ const Checkout = () => { ); } + const cancelUrl = createURL(window.location.href, { u: 'payment-cancelled' }); + const waitingUrl = createURL(window.location.href, { u: 'waiting-for-payment' }); + const errorUrl = createURL(window.location.href, { u: 'payment-error' }); + const successUrl = offerType === 'svod' ? welcomeUrl : closeModalUrl; + const successUrlWithOrigin = `${window.location.origin}${successUrl}`; + const referrer = window.location.href; + + const paymentMethod = paymentMethods?.find((method) => method.id === parseInt(paymentMethodId)); + const noPaymentRequired = !order?.requiredPaymentDetails; + const isStripePayment = paymentMethod?.methodName === 'card' && paymentMethod?.provider === 'stripe'; + const isAdyenPayment = paymentMethod?.methodName === 'card' && paymentMethod?.paymentGateway === 'adyen'; // @todo: conversion from controller? + const isPayPalPayment = paymentMethod?.methodName === 'paypal'; + return ( { paymentMethods={paymentMethods} paymentMethodId={paymentMethodId} onPaymentMethodChange={handlePaymentMethodChange} - onCouponFormSubmit={handleCouponFormSubmit} - onCouponInputChange={couponCodeForm.handleChange} + onCouponFormSubmit={handleSubmit} + onCouponInputChange={handleChange} onRedeemCouponButtonClick={() => setCouponFormOpen(true)} onCloseCouponFormClick={() => setCouponFormOpen(false)} - couponInputValue={couponCodeForm.values.couponCode} + couponInputValue={couponCode} couponFormOpen={couponFormOpen} - couponFormApplied={couponCodeApplied} - couponFormSubmitting={couponCodeForm.submitting} - couponFormError={couponCodeForm.errors.couponCode} - renderPaymentMethod={renderPaymentMethod} - submitting={updatingOrder} - /> + couponFormApplied={showCouponCodeSuccess} + couponFormSubmitting={couponFormSubmitting} + couponFormError={errors.couponCode} + submitting={isSubmitting || adyenUpdating} + > + {noPaymentRequired && } + {isStripePayment && ( + + await submitPaymentStripe.mutateAsync({ cardPaymentPayload, referrer, returnUrl: waitingUrl }) + } + /> + )} + {isAdyenPayment && ( + <> + + + )} + {isPayPalPayment && ( + submitPaymentPaypal.mutate({ successUrl: successUrlWithOrigin, waitingUrl, cancelUrl, errorUrl, couponCode })} + error={submitPaymentPaypal.error?.message || null} + /> + )} + ); }; diff --git a/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx b/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx index a38f0dcd8..edcc593ca 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx @@ -1,15 +1,15 @@ import React, { useCallback, useEffect } from 'react'; -import { mixed, object, type SchemaOf } from 'yup'; +import { mixed, object } from 'yup'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router'; import { shallow } from '@jwp/ott-common/src/utils/compare'; import type { Subscription } from '@jwp/ott-common/types/subscription'; -import type { ChooseOfferFormData } from '@jwp/ott-common/types/account'; +import type { ChooseOfferFormData } from '@jwp/ott-common/types/checkout'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import { logDev } from '@jwp/ott-common/src/utils/common'; import useOffers from '@jwp/ott-hooks-react/src/useOffers'; @@ -48,14 +48,6 @@ const ChooseOffer = () => { const availableOffers = isOfferSwitch ? offerSwitches : offers; const offerId = availableOffers[0]?.offerId || ''; - const validationSchema: SchemaOf = object().shape({ - offerId: mixed().required(t('choose_offer.field_required')), - }); - - const initialValues: ChooseOfferFormData = { - offerId: defaultOfferId, - }; - const updateAccountModal = useEventCallback((modal: keyof AccountModals) => { navigate(modalURLFromLocation(location, modal)); }); @@ -106,14 +98,18 @@ const ChooseOffer = () => { ], ); - const { handleSubmit, handleChange, setValue, values, errors, submitting } = useForm(initialValues, chooseOfferSubmitHandler, validationSchema); + const { handleSubmit, handleChange, setValue, values, errors, submitting } = useForm({ + initialValues: { offerId: defaultOfferId }, + validationSchema: object().shape({ offerId: mixed().required(t('choose_offer.field_required')) }), + onSubmit: chooseOfferSubmitHandler, + }); useEffect(() => { if (!isOfferSwitch) setValue('offerId', defaultOfferId); // Update offerId if the user is switching offers to ensure the correct offer is checked in the ChooseOfferForm // Initially, a defaultOfferId is set, but when switching offers, we need to use the id of the target offer - if (isOfferSwitch && values.offerId === initialValues.offerId) { + if (isOfferSwitch && values.offerId === defaultOfferId) { setValue('offerId', offerId); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/ui-react/src/containers/AccountModal/forms/EditPassword.tsx b/packages/ui-react/src/containers/AccountModal/forms/EditPassword.tsx index c14ec2126..cd1d6be96 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/EditPassword.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/EditPassword.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { EditPasswordFormData } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import useQueryParam from '@jwp/ott-ui-react/src/hooks/useQueryParam'; @@ -63,19 +63,20 @@ const ResetPassword = ({ type }: { type?: 'add' }) => { setSubmitting(false); }; - const passwordForm = useForm( - { password: '', passwordConfirmation: '' }, - passwordSubmitHandler, - object().shape({ + const passwordForm = useForm({ + initialValues: { password: '', passwordConfirmation: '' }, + validationSchema: object().shape({ email: string(), oldPassword: string(), password: string() .matches(/^(?=.*[a-z])(?=.*[0-9]).{8,}$/, t('registration.invalid_password')) .required(t('login.field_required')), - passwordConfirmation: string(), + passwordConfirmation: string().required(), + resetPasswordToken: string(), }), - true, - ); + validateOnBlur: true, + onSubmit: passwordSubmitHandler, + }); const resendEmailClickHandler = async () => { try { diff --git a/packages/ui-react/src/containers/AccountModal/forms/Login.tsx b/packages/ui-react/src/containers/AccountModal/forms/Login.tsx index 8674163ee..9764c212f 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Login.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Login.tsx @@ -1,15 +1,14 @@ import React from 'react'; -import { object, string, type SchemaOf } from 'yup'; +import { object, string } from 'yup'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router'; -import { useQueryClient } from 'react-query'; -import type { LoginFormData } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import useForm from '@jwp/ott-hooks-react/src/useForm'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import useSocialLoginUrls from '@jwp/ott-hooks-react/src/useSocialLoginUrls'; +import type { LoginFormData } from '@jwp/ott-common/types/account'; import LoginForm from '../../../components/LoginForm/LoginForm'; @@ -27,46 +26,27 @@ const Login: React.FC = ({ messageKey }: Props) => { const socialLoginURLs = useSocialLoginUrls(window.location.href.split('?')[0]); - const queryClient = useQueryClient(); - - const loginSubmitHandler: UseFormOnSubmitHandler = async (formData, { setErrors, setSubmitting, setValue }) => { - try { - await accountController.login(formData.email, formData.password, window.location.href); - await queryClient.invalidateQueries(['listProfiles']); - - // close modal - navigate(modalURLFromLocation(location, null)); - } catch (error: unknown) { - if (error instanceof Error) { - if (error.message.toLowerCase().includes('invalid param email')) { - setErrors({ email: t('login.wrong_email') }); - } else { - setErrors({ form: t('login.wrong_combination') }); - } - setValue('password', ''); - } - } - - setSubmitting(false); - }; - - const validationSchema: SchemaOf = object().shape({ - email: string().email(t('login.field_is_not_valid_email')).required(t('login.field_required')), - password: string().required(t('login.field_required')), + const { values, errors, submitting, handleSubmit, handleChange } = useForm({ + initialValues: { email: '', password: '' }, + validationSchema: object().shape({ + email: string().email(t('login.field_is_not_valid_email')).required(t('login.field_required')), + password: string().required(t('login.field_required')), + }), + onSubmit: ({ email, password }) => accountController.login(email, password, window.location.href), + onSubmitSuccess: () => navigate(modalURLFromLocation(location, null)), + onSubmitError: ({ resetValue }) => resetValue('password'), }); - const initialValues: LoginFormData = { email: '', password: '' }; - const { handleSubmit, handleChange, values, errors, submitting } = useForm(initialValues, loginSubmitHandler, validationSchema); return ( ); }; diff --git a/packages/ui-react/src/containers/AccountModal/forms/PersonalDetails.tsx b/packages/ui-react/src/containers/AccountModal/forms/PersonalDetails.tsx index d37e5e524..1e6e62db9 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/PersonalDetails.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/PersonalDetails.tsx @@ -6,7 +6,7 @@ import { useQuery } from 'react-query'; import type { CaptureCustomAnswer, CleengCaptureQuestionField, PersonalDetailsFormData } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; @@ -134,7 +134,10 @@ const PersonalDetails = () => { setSubmitting(false); }; - const { setValue, handleSubmit, handleChange, values, errors, submitting } = useForm(initialValues, PersonalDetailSubmitHandler); + const { setValue, handleSubmit, handleChange, values, errors, submitting } = useForm({ + initialValues, + onSubmit: PersonalDetailSubmitHandler, + }); if (isLoading) { return ( diff --git a/packages/ui-react/src/containers/AccountModal/forms/Registration.tsx b/packages/ui-react/src/containers/AccountModal/forms/Registration.tsx index befb43fdf..fc5bdb1a7 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Registration.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Registration.tsx @@ -1,15 +1,14 @@ -import React, { useEffect, useMemo, useState, type ChangeEventHandler } from 'react'; -import { object, string, type SchemaOf } from 'yup'; +import React, { useEffect, useState, type ChangeEventHandler } from 'react'; +import { object, string } from 'yup'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router'; -import { useQuery, useQueryClient } from 'react-query'; import type { RegistrationFormData } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import { checkConsentsFromValues, extractConsentValues } from '@jwp/ott-common/src/utils/collection'; -import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import { checkConsentsFromValues, extractConsentValues, formatConsentsFromValues } from '@jwp/ott-common/src/utils/collection'; +import useForm from '@jwp/ott-hooks-react/src/useForm'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import RegistrationForm from '../../../components/RegistrationForm/RegistrationForm'; @@ -19,15 +18,11 @@ const Registration = () => { const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('account'); + const [consentValues, setConsentValues] = useState>({}); const [consentErrors, setConsentErrors] = useState([]); - const appConfigId = useConfigStore(({ config }) => config.id); - const { data, isLoading: publisherConsentsLoading } = useQuery(['consents', appConfigId], accountController.getPublisherConsents); - - const publisherConsents = useMemo(() => data || [], [data]); - - const queryClient = useQueryClient(); + const { publisherConsents, loading } = useAccountStore(({ publisherConsents, loading }) => ({ publisherConsents, loading })); const handleChangeConsent: ChangeEventHandler = ({ currentTarget }) => { if (!currentTarget) return; @@ -45,62 +40,43 @@ const Registration = () => { }; useEffect(() => { - if (publisherConsents) { - setConsentValues(extractConsentValues(publisherConsents)); + if (!publisherConsents) { + accountController.getPublisherConsents(); + + return; } - }, [publisherConsents]); - const registrationSubmitHandler: UseFormOnSubmitHandler = async ({ email, password }, { setErrors, setSubmitting, setValue }) => { - try { - const { consentsErrors, customerConsents } = checkConsentsFromValues(publisherConsents, consentValues); + setConsentValues(extractConsentValues(publisherConsents)); + }, [accountController, publisherConsents]); + + const { handleSubmit, handleChange, handleBlur, values, errors, submitting } = useForm({ + initialValues: { email: '', password: '' }, + validationSchema: object().shape({ + email: string().email(t('registration.field_is_not_valid_email')).required(t('registration.field_required')), + password: string() + .matches(/^(?=.*[a-z])(?=.*[0-9]).{8,}$/, t('registration.invalid_password')) + .required(t('registration.field_required')), + }), + validateOnBlur: true, + onSubmit: async ({ email, password }) => { + const { consentsErrors } = checkConsentsFromValues(publisherConsents || [], consentValues); if (consentsErrors.length) { setConsentErrors(consentsErrors); - setSubmitting(false); - return; + throw new Error('Consents error'); } - await accountController.register(email, password, window.location.href, customerConsents); - await queryClient.invalidateQueries(['listProfiles']); - - navigate(modalURLFromLocation(location, 'personal-details')); - } catch (error: unknown) { - if (error instanceof Error) { - const errorMessage = error.message.toLowerCase(); - if (errorMessage.includes('customer already exists') || errorMessage.includes('account already exists')) { - setErrors({ form: t('registration.user_exists') }); - } else if (errorMessage.includes('invalid param password')) { - setErrors({ password: t('registration.invalid_password') }); - } else { - // in case the endpoint fails - setErrors({ password: t('registration.failed_to_create') }); - } - setValue('password', ''); - } - } - - setSubmitting(false); - }; - - const validationSchema: SchemaOf = object().shape({ - email: string().email(t('registration.field_is_not_valid_email')).required(t('registration.field_required')), - password: string() - .matches(/^(?=.*[a-z])(?=.*[0-9]).{8,}$/, t('registration.invalid_password')) - .required(t('registration.field_required')), + await accountController.register(email, password, window.location.href, formatConsentsFromValues(publisherConsents, consentValues)); + }, + onSubmitSuccess: () => navigate(modalURLFromLocation(location, 'personal-details')), + onSubmitError: ({ resetValue }) => resetValue('password'), }); - const initialRegistrationValues: RegistrationFormData = { email: '', password: '' }; - const { handleSubmit, handleChange, handleBlur, values, errors, submitting } = useForm( - initialRegistrationValues, - registrationSubmitHandler, - validationSchema, - true, - ); - return ( { submitting={submitting} consentValues={consentValues} publisherConsents={publisherConsents} - loading={publisherConsentsLoading} - onConsentChange={handleChangeConsent} + loading={loading} canSubmit={!!values.email && !!values.password} /> ); diff --git a/packages/ui-react/src/containers/AccountModal/forms/RenewSubscription.tsx b/packages/ui-react/src/containers/AccountModal/forms/RenewSubscription.tsx index 4eba0930c..40a596dd5 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/RenewSubscription.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/RenewSubscription.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router'; import { shallow } from '@jwp/ott-common/src/utils/compare'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import LoadingOverlay from '../../../components/LoadingOverlay/LoadingOverlay'; diff --git a/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx b/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx index cae72aa36..434de9e0d 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/ResetPassword.tsx @@ -6,7 +6,7 @@ import { shallow } from '@jwp/ott-common/src/utils/compare'; import type { ForgotPasswordFormData } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import { logDev } from '@jwp/ott-common/src/utils/common'; import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; @@ -91,14 +91,14 @@ const ResetPassword: React.FC = ({ type }: Prop) => { setSubmitting(false); }; - const emailForm = useForm( - { email: '' }, - emailSubmitHandler, - object().shape({ + const emailForm = useForm({ + initialValues: { email: '' }, + validationSchema: object().shape({ email: string().email(t('login.field_is_not_valid_email')).required(t('login.field_required')), }), - true, - ); + validateOnBlur: true, + onSubmit: emailSubmitHandler, + }); return ( diff --git a/packages/ui-react/src/containers/AdyenInitialPayment/AdyenInitialPayment.tsx b/packages/ui-react/src/containers/AdyenInitialPayment/AdyenInitialPayment.tsx index 2a4869343..bf836224b 100644 --- a/packages/ui-react/src/containers/AdyenInitialPayment/AdyenInitialPayment.tsx +++ b/packages/ui-react/src/containers/AdyenInitialPayment/AdyenInitialPayment.tsx @@ -4,27 +4,30 @@ import type DropinElement from '@adyen/adyen-web/dist/types/components/Dropin/Dr import { useNavigate } from 'react-router'; import type { AdyenPaymentSession } from '@jwp/ott-common/types/checkout'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; import { ADYEN_LIVE_CLIENT_KEY, ADYEN_TEST_CLIENT_KEY } from '@jwp/ott-common/src/constants'; +import { useTranslation } from 'react-i18next'; import Adyen from '../../components/Adyen/Adyen'; type Props = { setUpdatingOrder: (loading: boolean) => void; - setPaymentError: (errorMessage?: string) => void; type: AdyenPaymentMethodType; paymentSuccessUrl: string; orderId?: number; }; -export default function AdyenInitialPayment({ setUpdatingOrder, type, setPaymentError, paymentSuccessUrl, orderId }: Props) { +export default function AdyenInitialPayment({ setUpdatingOrder, type, paymentSuccessUrl, orderId }: Props) { const accountController = getModule(AccountController); const checkoutController = getModule(CheckoutController); + const [error, setError] = useState(); const [session, setSession] = useState(); + const { t } = useTranslation('error'); + const isSandbox = accountController.getSandbox(); const navigate = useNavigate(); @@ -38,7 +41,7 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, setPayment setSession(session); } catch (error: unknown) { if (error instanceof Error) { - setPaymentError(error.message); + setError(error.message); } } @@ -46,7 +49,7 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, setPayment }; createSession(); - }, [setUpdatingOrder, setPaymentError, checkoutController]); + }, [setUpdatingOrder, checkoutController]); const onSubmit = useCallback( async (state: AdyenEventData, handleAction: DropinElement['handleAction']) => { @@ -54,10 +57,10 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, setPayment try { setUpdatingOrder(true); - setPaymentError(undefined); + setError(undefined); if (orderId === undefined) { - setPaymentError('Order is unknown'); + setError(t('adyen_order_unknown')); return; } @@ -76,13 +79,13 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, setPayment navigate(paymentSuccessUrl, { replace: true }); } catch (error: unknown) { if (error instanceof Error) { - setPaymentError(error.message); + setError(error.message); } } setUpdatingOrder(false); }, - [navigate, orderId, paymentSuccessUrl, setPaymentError, setUpdatingOrder, accountController, checkoutController], + [navigate, orderId, paymentSuccessUrl, t, setUpdatingOrder, accountController, checkoutController], ); const adyenConfiguration: CoreOptions = useMemo( @@ -107,16 +110,16 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, setPayment navigate(paymentSuccessUrl, { replace: true }); } catch (error: unknown) { if (error instanceof Error) { - setPaymentError(error.message); + setError(error.message); setUpdatingOrder(false); } } }, onSubmit: (state: AdyenEventData, component: DropinElement) => onSubmit(state, component.handleAction), - onError: (error: Error) => setPaymentError(error.message), + onError: (error: Error) => setError(error.message), }), - [onSubmit, paymentSuccessUrl, isSandbox, session, orderId, navigate, setPaymentError, setUpdatingOrder, checkoutController], + [onSubmit, paymentSuccessUrl, isSandbox, session, orderId, navigate, setError, setUpdatingOrder, checkoutController], ); - return ; + return ; } diff --git a/packages/ui-react/src/containers/AdyenPaymentDetails/AdyenPaymentDetails.tsx b/packages/ui-react/src/containers/AdyenPaymentDetails/AdyenPaymentDetails.tsx index 0142e4ee7..5df910469 100644 --- a/packages/ui-react/src/containers/AdyenPaymentDetails/AdyenPaymentDetails.tsx +++ b/packages/ui-react/src/containers/AdyenPaymentDetails/AdyenPaymentDetails.tsx @@ -4,8 +4,8 @@ import type DropinElement from '@adyen/adyen-web/dist/types/components/Dropin/Dr import { useLocation, useNavigate } from 'react-router-dom'; import type { AdyenPaymentSession } from '@jwp/ott-common/types/checkout'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import { ADYEN_LIVE_CLIENT_KEY, ADYEN_TEST_CLIENT_KEY } from '@jwp/ott-common/src/constants'; import useQueryParam from '@jwp/ott-ui-react/src/hooks/useQueryParam'; diff --git a/packages/ui-react/src/containers/Cinema/Cinema.test.tsx b/packages/ui-react/src/containers/Cinema/Cinema.test.tsx index 24aef3fc8..501362bce 100644 --- a/packages/ui-react/src/containers/Cinema/Cinema.test.tsx +++ b/packages/ui-react/src/containers/Cinema/Cinema.test.tsx @@ -5,7 +5,7 @@ import { mockService } from '@jwp/ott-common/test/mockService'; import ApiService from '@jwp/ott-common/src/services/ApiService'; import GenericEntitlementService from '@jwp/ott-common/src/services/GenericEntitlementService'; import JWPEntitlementService from '@jwp/ott-common/src/services/JWPEntitlementService'; -import WatchHistoryController from '@jwp/ott-common/src/stores/WatchHistoryController'; +import WatchHistoryController from '@jwp/ott-common/src/controllers/WatchHistoryController'; import { renderWithRouter } from '../../../test/utils'; diff --git a/packages/ui-react/src/containers/FavoriteButton/FavoriteButton.tsx b/packages/ui-react/src/containers/FavoriteButton/FavoriteButton.tsx index 583dc0aa9..c524fc951 100644 --- a/packages/ui-react/src/containers/FavoriteButton/FavoriteButton.tsx +++ b/packages/ui-react/src/containers/FavoriteButton/FavoriteButton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useFavoritesStore } from '@jwp/ott-common/src/stores/FavoritesStore'; -import FavoritesController from '@jwp/ott-common/src/stores/FavoritesController'; +import FavoritesController from '@jwp/ott-common/src/controllers/FavoritesController'; import Favorite from '@jwp/ott-theme/assets/icons/favorite.svg?react'; import FavoriteBorder from '@jwp/ott-theme/assets/icons/favorite_border.svg?react'; import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; diff --git a/packages/ui-react/src/containers/Layout/Layout.test.tsx b/packages/ui-react/src/containers/Layout/Layout.test.tsx index 0b4c736ec..6468c7d97 100644 --- a/packages/ui-react/src/containers/Layout/Layout.test.tsx +++ b/packages/ui-react/src/containers/Layout/Layout.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import ProfileController from '@jwp/ott-common/src/stores/ProfileController'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { mockService } from '@jwp/ott-common/test/mockService'; import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; diff --git a/packages/ui-react/src/containers/Layout/Layout.tsx b/packages/ui-react/src/containers/Layout/Layout.tsx index b2ab4513a..2a4a67aad 100644 --- a/packages/ui-react/src/containers/Layout/Layout.tsx +++ b/packages/ui-react/src/containers/Layout/Layout.tsx @@ -9,7 +9,7 @@ import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import { useUIStore } from '@jwp/ott-common/src/stores/UIStore'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; -import ProfileController from '@jwp/ott-common/src/stores/ProfileController'; +import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import { IS_DEVELOPMENT_BUILD, unicodeToChar } from '@jwp/ott-common/src/utils/common'; import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; diff --git a/packages/ui-react/src/containers/PaymentContainer/PaymentContainer.tsx b/packages/ui-react/src/containers/PaymentContainer/PaymentContainer.tsx index 0607c1c91..0b9640c62 100644 --- a/packages/ui-react/src/containers/PaymentContainer/PaymentContainer.tsx +++ b/packages/ui-react/src/containers/PaymentContainer/PaymentContainer.tsx @@ -5,7 +5,7 @@ import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import useOffers from '@jwp/ott-hooks-react/src/useOffers'; import { useSubscriptionChange } from '@jwp/ott-hooks-react/src/useSubscriptionChange'; diff --git a/packages/ui-react/src/containers/Profiles/EditProfile.tsx b/packages/ui-react/src/containers/Profiles/EditProfile.tsx index f4e671085..009ae2eed 100644 --- a/packages/ui-react/src/containers/Profiles/EditProfile.tsx +++ b/packages/ui-react/src/containers/Profiles/EditProfile.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate, useParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import ProfileController from '@jwp/ott-common/src/stores/ProfileController'; +import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; import type { UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; import { useProfileErrorHandler, useUpdateProfile } from '@jwp/ott-hooks-react/src/useProfiles'; import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; diff --git a/packages/ui-react/src/containers/Profiles/Form.tsx b/packages/ui-react/src/containers/Profiles/Form.tsx index 5c52f7d1c..b9f5a677d 100644 --- a/packages/ui-react/src/containers/Profiles/Form.tsx +++ b/packages/ui-react/src/containers/Profiles/Form.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { object, string, type SchemaOf } from 'yup'; +import { number, object, string } from 'yup'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; @@ -41,16 +41,22 @@ const Form = ({ initialValues, formHandler, selectedAvatar, showCancelButton = t { label: t('profile.kids'), value: 'false' }, ]; - const validationSchema: SchemaOf<{ name: string }> = object().shape({ - name: string() - .trim() - .required(t('profile.validation.name.required')) - .min(3, t('profile.validation.name.too_short', { charactersCount: 3 })) - .max(30, t('profile.validation.name.too_long', { charactersCount: 30 })) - .matches(/^[a-zA-Z0-9\s]*$/, t('profile.validation.name.invalid_characters')), + const { handleSubmit, handleChange, values, errors, submitting, setValue } = useForm({ + initialValues, + validationSchema: object().shape({ + id: string(), + name: string() + .trim() + .required(t('profile.validation.name.required')) + .min(3, t('profile.validation.name.too_short', { charactersCount: 3 })) + .max(30, t('profile.validation.name.too_long', { charactersCount: 30 })) + .matches(/^[a-zA-Z0-9\s]*$/, t('profile.validation.name.invalid_characters')), + adult: string().required(), + avatar_url: string(), + pin: number(), + }), + onSubmit: formHandler, }); - - const { handleSubmit, handleChange, values, errors, submitting, setValue } = useForm(initialValues, formHandler, validationSchema); const isDirty = Object.entries(values).some(([k, v]) => v !== initialValues[k as keyof typeof initialValues]); useEffect(() => { setValue('avatar_url', selectedAvatar?.value || profile?.avatar_url || ''); diff --git a/packages/ui-react/src/containers/UpdatePaymentMethod/UpdatePaymentMethod.tsx b/packages/ui-react/src/containers/UpdatePaymentMethod/UpdatePaymentMethod.tsx index f2571360d..90138ad4c 100644 --- a/packages/ui-react/src/containers/UpdatePaymentMethod/UpdatePaymentMethod.tsx +++ b/packages/ui-react/src/containers/UpdatePaymentMethod/UpdatePaymentMethod.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; import useQueryParam from '@jwp/ott-ui-react/src/hooks/useQueryParam'; @@ -87,7 +87,7 @@ const UpdatePaymentMethod = ({ onCloseButtonClick }: Props) => { /> ); } else if (paymentMethod?.methodName === 'paypal') { - return ; + return ; } return null; diff --git a/packages/ui-react/src/pages/User/User.test.tsx b/packages/ui-react/src/pages/User/User.test.tsx index 2c77edfbf..33d164115 100644 --- a/packages/ui-react/src/pages/User/User.test.tsx +++ b/packages/ui-react/src/pages/User/User.test.tsx @@ -5,12 +5,12 @@ import type { PaymentDetail, Subscription, Transaction } from '@jwp/ott-common/t import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { useFavoritesStore } from '@jwp/ott-common/src/stores/FavoritesStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { mockService } from '@jwp/ott-common/test/mockService'; import ApiService from '@jwp/ott-common/src/services/ApiService'; -import FavoritesController from '@jwp/ott-common/src/stores/FavoritesController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; -import ProfileController from '@jwp/ott-common/src/stores/ProfileController'; +import FavoritesController from '@jwp/ott-common/src/controllers/FavoritesController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; +import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; import { ACCESS_MODEL, DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; import { Route, Routes } from 'react-router-dom'; import React from 'react'; @@ -79,6 +79,7 @@ describe('User Component tests', () => { mockService(ApiService, {}); mockService(AccountController, { logout: vi.fn(), + getPublisherConsents: vi.fn().mockResolvedValue([]), getFeatures: vi.fn(() => ({ ...DEFAULT_FEATURES, canUpdateEmail: false, diff --git a/packages/ui-react/src/pages/User/User.tsx b/packages/ui-react/src/pages/User/User.tsx index 84f6e90ed..89840061d 100644 --- a/packages/ui-react/src/pages/User/User.tsx +++ b/packages/ui-react/src/pages/User/User.tsx @@ -5,9 +5,9 @@ import { shallow } from '@jwp/ott-common/src/utils/compare'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import FavoritesController from '@jwp/ott-common/src/stores/FavoritesController'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import FavoritesController from '@jwp/ott-common/src/controllers/FavoritesController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; import { ACCESS_MODEL, PersonalShelf } from '@jwp/ott-common/src/constants'; import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; diff --git a/platforms/web/public/locales/en/error.json b/platforms/web/public/locales/en/error.json index f79876fcc..27e1b470e 100644 --- a/platforms/web/public/locales/en/error.json +++ b/platforms/web/public/locales/en/error.json @@ -9,5 +9,7 @@ "notfound_error_heading": "Not found", "playlist_not_found": "Playlist not found", "settings_invalid": "Invalid or missing settings", - "video_not_found": "Video not found" + "video_not_found": "Video not found", + "unknown_error": "Unknown error", + "adyen_order_unknown": "Order is unknown" } diff --git a/platforms/web/public/locales/es/error.json b/platforms/web/public/locales/es/error.json index 7b2ecf5ab..b1027f794 100644 --- a/platforms/web/public/locales/es/error.json +++ b/platforms/web/public/locales/es/error.json @@ -9,5 +9,7 @@ "notfound_error_heading": "No encontrado", "playlist_not_found": "Lista de reproducción no encontrada", "settings_invalid": "Ajustes inválidos o faltantes", - "video_not_found": "Video no encontrado" + "video_not_found": "Video no encontrado", + "unknown_error": "Error desconocido", + "adyen_order_unknown": "El orden es desconocido" } diff --git a/platforms/web/src/hooks/useNotifications.ts b/platforms/web/src/hooks/useNotifications.ts index 69dc695ff..e92dbacf9 100644 --- a/platforms/web/src/hooks/useNotifications.ts +++ b/platforms/web/src/hooks/useNotifications.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { queryClient } from '@jwp/ott-ui-react/src/containers/QueryProvider/QueryProvider'; import { simultaneousLoginWarningKey } from '@jwp/ott-common/src/constants'; import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; From 3deabfc766a75c12ab122a775376449e316ff232 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Tue, 6 Feb 2024 21:26:15 +0100 Subject: [PATCH 023/128] fix: favorites and history validation error --- packages/common/src/services/FavoriteService.ts | 2 +- packages/common/src/services/WatchHistoryService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/common/src/services/FavoriteService.ts b/packages/common/src/services/FavoriteService.ts index ccff461ec..b7f752b22 100644 --- a/packages/common/src/services/FavoriteService.ts +++ b/packages/common/src/services/FavoriteService.ts @@ -34,7 +34,7 @@ export default class FavoriteService { } private validateFavorites(favorites: unknown) { - if (schema.validateSync(favorites)) { + if (favorites && schema.validateSync(favorites)) { return favorites as SerializedFavorite[]; } diff --git a/packages/common/src/services/WatchHistoryService.ts b/packages/common/src/services/WatchHistoryService.ts index 215f62e2c..7e6636b28 100644 --- a/packages/common/src/services/WatchHistoryService.ts +++ b/packages/common/src/services/WatchHistoryService.ts @@ -61,7 +61,7 @@ export default class WatchHistoryService { }; private validateWatchHistory(history: unknown) { - if (schema.validateSync(history)) { + if (history && schema.validateSync(history)) { return history as SerializedWatchHistoryItem[]; } From ca71f29298ea6c4af2f5c2b6c4f6379d68385df0 Mon Sep 17 00:00:00 2001 From: Roy Schut Date: Tue, 6 Feb 2024 12:28:51 -0300 Subject: [PATCH 024/128] fix(payment): redirect after incorrect couponcode entry --- .../ui-react/src/containers/AccountModal/forms/Checkout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx index 525f6dfd6..843782474 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx @@ -53,7 +53,7 @@ const Checkout = () => { }, onSubmitSuccess: ({ couponCode }): void => setShowCouponCodeSuccess(!!couponCode), onSubmitError: ({ error }) => { - if (error instanceof FormValidationError && error.errors.order?.includes(`not found`)) { + if (error instanceof FormValidationError && error.errors.order?.includes(`Order with id ${order?.id} not found`)) { navigate(modalURLFromLocation(location, 'choose-offer'), { replace: true }); } }, From d01d1b71aba628feeb4510cb9b0b9d4132af3cb7 Mon Sep 17 00:00:00 2001 From: Roy Schut Date: Tue, 6 Feb 2024 13:08:07 -0300 Subject: [PATCH 025/128] fix(payment): tvod offer not showing in AuthVOD platform --- packages/hooks-react/src/useOffers.ts | 2 +- .../ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hooks-react/src/useOffers.ts b/packages/hooks-react/src/useOffers.ts index 3b3edb8c3..a29bbc855 100644 --- a/packages/hooks-react/src/useOffers.ts +++ b/packages/hooks-react/src/useOffers.ts @@ -16,7 +16,7 @@ const useOffers = () => { const hasMultipleOfferTypes = !hasPremierOffers && !!mediaOffers?.length && !!svodOfferIds.length; const offerIds: string[] = mergeOfferIds(mediaOffers || [], svodOfferIds); - const [offerType, setOfferType] = useState(hasPremierOffers || !svodOfferIds ? 'tvod' : 'svod'); + const [offerType, setOfferType] = useState(hasPremierOffers || !svodOfferIds.length ? 'tvod' : 'svod'); const updateOfferType = useMemo(() => (hasMultipleOfferTypes ? (type: OfferType) => setOfferType(type) : undefined), [hasMultipleOfferTypes]); const { data: allOffers, isLoading } = useQuery(['offers', offerIds.join('-')], () => checkoutController.getOffers({ offerIds })); diff --git a/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx b/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx index edcc593ca..78ffdea68 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/ChooseOffer.tsx @@ -105,7 +105,7 @@ const ChooseOffer = () => { }); useEffect(() => { - if (!isOfferSwitch) setValue('offerId', defaultOfferId); + if (!isOfferSwitch && !isLoading) setValue('offerId', defaultOfferId); // Update offerId if the user is switching offers to ensure the correct offer is checked in the ChooseOfferForm // Initially, a defaultOfferId is set, but when switching offers, we need to use the id of the target offer From c97c59b7268d540ee37cdb0bb41beb72d8946a7e Mon Sep 17 00:00:00 2001 From: Roy Schut Date: Tue, 6 Feb 2024 14:09:40 -0300 Subject: [PATCH 026/128] fix(payment): incorrect couponCode success message --- .../ui-react/src/containers/AccountModal/forms/Checkout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx index 843782474..9bfe801e4 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx @@ -29,7 +29,7 @@ const Checkout = () => { const { offer, offerType, paymentMethods, order, isSubmitting, updateOrder, submitPaymentWithoutDetails, submitPaymentPaypal, submitPaymentStripe } = useCheckout({ - onUpdateOrderSuccess: () => setShowCouponCodeSuccess(true), + onUpdateOrderSuccess: () => !!couponCode && setShowCouponCodeSuccess(true), onSubmitPaymentWithoutDetailsSuccess: () => navigate(offerType === 'svod' ? welcomeUrl : closeModalUrl, { replace: true }), onSubmitPaypalPaymentSuccess: (paypalUrl: string) => { window.location.href = paypalUrl; From 320fe4402f7338816825471de52c21aedb95a144 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Wed, 7 Feb 2024 08:37:26 +0100 Subject: [PATCH 027/128] fix: root error screen for unexpected errors --- packages/hooks-react/src/useBootstrapApp.ts | 2 +- .../DemoConfigDialog/DemoConfigDialog.tsx | 8 ++++++-- platforms/web/src/containers/Root/Root.tsx | 14 ++++++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/hooks-react/src/useBootstrapApp.ts b/packages/hooks-react/src/useBootstrapApp.ts index a542ea2ad..a1a2874bc 100644 --- a/packages/hooks-react/src/useBootstrapApp.ts +++ b/packages/hooks-react/src/useBootstrapApp.ts @@ -20,7 +20,7 @@ export const useBootstrapApp = (url: string, onReady: OnReadyCallback) => { const queryClient = useQueryClient(); const refreshEntitlements = () => queryClient.invalidateQueries({ queryKey: ['entitlements'] }); - const { data, isLoading, error, isSuccess, refetch } = useQuery( + const { data, isLoading, error, isSuccess, refetch } = useQuery( 'config-init', () => applicationController.initializeApp(url, refreshEntitlements), { diff --git a/platforms/web/src/components/DemoConfigDialog/DemoConfigDialog.tsx b/platforms/web/src/components/DemoConfigDialog/DemoConfigDialog.tsx index 0f71a7a32..69a55e671 100644 --- a/platforms/web/src/components/DemoConfigDialog/DemoConfigDialog.tsx +++ b/platforms/web/src/components/DemoConfigDialog/DemoConfigDialog.tsx @@ -11,6 +11,7 @@ import ConfirmationDialog from '@jwp/ott-ui-react/src/components/ConfirmationDia import LoadingOverlay from '@jwp/ott-ui-react/src/components/LoadingOverlay/LoadingOverlay'; import DevStackTrace from '@jwp/ott-ui-react/src/components/DevStackTrace/DevStackTrace'; import type { BootstrapData } from '@jwp/ott-hooks-react/src/useBootstrapApp'; +import { AppError } from '@jwp/ott-common/src/utils/error'; import styles from './DemoConfigDialog.module.scss'; @@ -53,6 +54,9 @@ const DemoConfigDialog = ({ query }: { query: BootstrapData }) => { const [state, setState] = useState(initialState); + const errorTitle = error && error instanceof AppError ? error.payload.title : ''; + const errorDescription = error && error instanceof AppError ? error.payload.description : ''; + const configNavigate = async (configSource: string | undefined) => { setState((s) => ({ ...s, configSource: configSource, error: undefined })); @@ -160,8 +164,8 @@ const DemoConfigDialog = ({ query }: { query: BootstrapData }) => { {!isSuccess && (
    { + if (error instanceof AppError) { + return ; + } + return ; +}; + const ProdContentLoader = ({ query }: { query: BootstrapData }) => { const { isLoading, error } = query; @@ -25,7 +33,7 @@ const ProdContentLoader = ({ query }: { query: BootstrapData }) => { } if (error) { - return ; + return renderError(error); } return null; @@ -44,9 +52,7 @@ const DemoContentLoader = ({ query }: { query: BootstrapData }) => { return ( <> {/* Show the error page when error except in demo mode (the demo mode shows its own error) */} - {!IS_DEMO_OR_PREVIEW && error && ( - - )} + {!IS_DEMO_OR_PREVIEW && error && renderError(error)} {IS_DEMO_OR_PREVIEW && } {/* Config select control to improve testing experience */} {(IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && } From ca3d38e2ad82235a84cb658da0174095c2eed10e Mon Sep 17 00:00:00 2001 From: Mike van Veenhuijzen Date: Wed, 7 Feb 2024 10:00:19 +0100 Subject: [PATCH 028/128] fix(project): undouble serieIds to prevent crash --- packages/common/src/services/WatchHistoryService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/common/src/services/WatchHistoryService.ts b/packages/common/src/services/WatchHistoryService.ts index 7e6636b28..adc8f34c9 100644 --- a/packages/common/src/services/WatchHistoryService.ts +++ b/packages/common/src/services/WatchHistoryService.ts @@ -47,8 +47,9 @@ export default class WatchHistoryService { const seriesIds = Object.keys(mediaWithSeries || {}) .map((key) => mediaWithSeries?.[key]?.[0]?.series_id) .filter(Boolean) as string[]; + const uniqueSerieIds = [...new Set(seriesIds)]; - const seriesItems = await this.apiService.getMediaByWatchlist(continueWatchingList, seriesIds); + const seriesItems = await this.apiService.getMediaByWatchlist(continueWatchingList, uniqueSerieIds); const seriesItemsDict = Object.keys(mediaWithSeries || {}).reduce((acc, key) => { const seriesItemId = mediaWithSeries?.[key]?.[0]?.series_id; if (seriesItemId) { From 86b461fc9df4b92401055b8c56d8e9d5aec13c99 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Wed, 7 Feb 2024 09:52:58 +0100 Subject: [PATCH 029/128] fix: hide start watching button in avod platform --- .../StartWatchingButton/StartWatchingButton.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ui-react/src/containers/StartWatchingButton/StartWatchingButton.tsx b/packages/ui-react/src/containers/StartWatchingButton/StartWatchingButton.tsx index e2acb9f79..4bec0458c 100644 --- a/packages/ui-react/src/containers/StartWatchingButton/StartWatchingButton.tsx +++ b/packages/ui-react/src/containers/StartWatchingButton/StartWatchingButton.tsx @@ -9,6 +9,8 @@ import { modalURLFromLocation } from '@jwp/ott-ui-react/src/utils/location'; import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; import useEntitlement from '@jwp/ott-hooks-react/src/useEntitlement'; import Play from '@jwp/ott-theme/assets/icons/play.svg?react'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; import Button from '../../components/Button/Button'; import Icon from '../../components/Icon/Icon'; @@ -29,6 +31,7 @@ const StartWatchingButton: React.VFC = ({ item, playUrl, disabled = false const breakpoint = useBreakpoint(); // account + const accessModel = useConfigStore((state) => state.accessModel); const user = useAccountStore((state) => state.user); const isLoggedIn = !!user; @@ -70,6 +73,11 @@ const StartWatchingButton: React.VFC = ({ item, playUrl, disabled = false return () => setRequestedMediaOffers(null); }, [mediaOffers, setRequestedMediaOffers]); + // the user can't purchase access in an AVOD platform due to missing configuration, so we hide the button + if (accessModel === ACCESS_MODEL.AVOD && !isEntitled) { + return null; + } + return (
    From ff28a07ec756763e3e98dbf5758e08edf66a89bc Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Fri, 9 Feb 2024 22:23:20 +0100 Subject: [PATCH 037/128] refactor: use keyed object in submit paypal method --- packages/common/src/controllers/CheckoutController.ts | 6 ++++-- packages/hooks-react/src/useCheckout.ts | 8 ++++++-- .../src/containers/AccountModal/forms/Checkout.tsx | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/common/src/controllers/CheckoutController.ts b/packages/common/src/controllers/CheckoutController.ts index d93a9f7d5..d9801546f 100644 --- a/packages/common/src/controllers/CheckoutController.ts +++ b/packages/common/src/controllers/CheckoutController.ts @@ -206,7 +206,7 @@ export default class CheckoutController { cancelUrl: string; errorUrl: string; couponCode: string; - }): Promise => { + }): Promise<{ redirectUrl: string }> => { const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); @@ -222,7 +222,9 @@ export default class CheckoutController { if (response.errors.length > 0) throw new Error(response.errors[0]); - return response.responseData.redirectUrl; + return { + redirectUrl: response.responseData.redirectUrl, + }; }; getSubscriptionSwitches = async (): Promise => { diff --git a/packages/hooks-react/src/useCheckout.ts b/packages/hooks-react/src/useCheckout.ts index 8bf6132c8..af93f6080 100644 --- a/packages/hooks-react/src/useCheckout.ts +++ b/packages/hooks-react/src/useCheckout.ts @@ -11,7 +11,7 @@ import { useMutation } from 'react-query'; type Props = { onUpdateOrderSuccess?: () => void; onSubmitPaymentWithoutDetailsSuccess: () => void; - onSubmitPaypalPaymentSuccess: (redirectUrl: string) => void; + onSubmitPaypalPaymentSuccess: (response: { redirectUrl: string }) => void; onSubmitStripePaymentSuccess: () => void; }; @@ -52,7 +52,11 @@ const useCheckout = ({ onUpdateOrderSuccess, onSubmitPaymentWithoutDetailsSucces }, }); - const submitPaymentPaypal = useMutation({ + const submitPaymentPaypal = useMutation< + { redirectUrl: string }, + Error, + { successUrl: string; waitingUrl: string; cancelUrl: string; errorUrl: string; couponCode: string } + >({ mutationKey: ['submitPaymentPaypal'], mutationFn: checkoutController.paypalPayment, onSuccess: onSubmitPaypalPaymentSuccess, diff --git a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx index df495ccd9..80143c64f 100644 --- a/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx +++ b/packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx @@ -31,8 +31,8 @@ const Checkout = () => { useCheckout({ onUpdateOrderSuccess: () => !!couponCode && setShowCouponCodeSuccess(true), onSubmitPaymentWithoutDetailsSuccess: () => navigate(offerType === 'svod' ? welcomeUrl : closeModalUrl, { replace: true }), - onSubmitPaypalPaymentSuccess: (paypalUrl: string) => { - window.location.href = paypalUrl; + onSubmitPaypalPaymentSuccess: ({ redirectUrl }) => { + window.location.href = redirectUrl; }, onSubmitStripePaymentSuccess: () => navigate(modalURLFromLocation(location, 'waiting-for-payment'), { replace: true }), }); From 5e1e84a2afb0a1b1c8b97921a862144598ffb72b Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Mon, 12 Feb 2024 11:05:08 +0100 Subject: [PATCH 038/128] chore: revert config footer text removal --- packages/common/src/services/ConfigService.ts | 4 +++- packages/common/src/utils/configSchema.ts | 1 + packages/common/types/config.ts | 4 ++++ packages/ui-react/src/containers/Layout/Layout.tsx | 5 +++-- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/common/src/services/ConfigService.ts b/packages/common/src/services/ConfigService.ts index 09b2cebe0..6c17f682e 100644 --- a/packages/common/src/services/ConfigService.ts +++ b/packages/common/src/services/ConfigService.ts @@ -28,7 +28,9 @@ export default class ConfigService { content: [], menu: [], integrations: {}, - styling: {}, + styling: { + footerText: '', + }, features: {}, }; diff --git a/packages/common/src/utils/configSchema.ts b/packages/common/src/utils/configSchema.ts index 7ef70b950..af6ffc382 100644 --- a/packages/common/src/utils/configSchema.ts +++ b/packages/common/src/utils/configSchema.ts @@ -41,6 +41,7 @@ const stylingSchema: SchemaOf = object({ backgroundColor: string().nullable(), highlightColor: string().nullable(), headerBackground: string().nullable(), + footerText: string().nullable(), }); export const configSchema: SchemaOf = object({ diff --git a/packages/common/types/config.ts b/packages/common/types/config.ts index 442dc85f1..7e60bfee8 100644 --- a/packages/common/types/config.ts +++ b/packages/common/types/config.ts @@ -59,6 +59,10 @@ export type Styling = { backgroundColor?: string | null; highlightColor?: string | null; headerBackground?: string | null; + /** + * @deprecated the footerText is present in the config, but can't be updated in the JWP Dashboard + */ + footerText?: string | null; }; export type Cleeng = { diff --git a/packages/ui-react/src/containers/Layout/Layout.tsx b/packages/ui-react/src/containers/Layout/Layout.tsx index 2a4a67aad..afa2a7d64 100644 --- a/packages/ui-react/src/containers/Layout/Layout.tsx +++ b/packages/ui-react/src/containers/Layout/Layout.tsx @@ -39,9 +39,10 @@ const Layout = () => { ); const isLoggedIn = !!useAccountStore(({ user }) => user); const favoritesEnabled = !!config.features?.favoritesList; - const { menu, assets, siteName, description, features } = config; + const { menu, assets, siteName, description, features, styling } = config; const metaDescription = description || t('default_description'); - const footerText = unicodeToChar(env.APP_FOOTER_TEXT); + const { footerText: configFooterText } = styling || {}; + const footerText = configFooterText || unicodeToChar(env.APP_FOOTER_TEXT); const profileController = getModule(ProfileController, false); From dd8d484c8f3d9e0405e7e2eab1ee44d18b7ac76d Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Tue, 20 Feb 2024 09:46:36 +0100 Subject: [PATCH 039/128] refactor: render error to component --- platforms/web/src/containers/Root/Root.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platforms/web/src/containers/Root/Root.tsx b/platforms/web/src/containers/Root/Root.tsx index 582a1382d..0b46529d4 100644 --- a/platforms/web/src/containers/Root/Root.tsx +++ b/platforms/web/src/containers/Root/Root.tsx @@ -18,7 +18,7 @@ import { useTrackConfigKeyChange } from '#src/hooks/useTrackConfigKeyChange'; const IS_DEMO_OR_PREVIEW = IS_DEMO_MODE || IS_PREVIEW_MODE; -const renderError = (error: Error | AppError) => { +const BootstrapError = ({ error }: { error: Error | AppError }) => { if (error instanceof AppError) { return ; } @@ -33,7 +33,7 @@ const ProdContentLoader = ({ query }: { query: BootstrapData }) => { } if (error) { - return renderError(error); + return ; } return null; @@ -52,7 +52,7 @@ const DemoContentLoader = ({ query }: { query: BootstrapData }) => { return ( <> {/* Show the error page when error except in demo mode (the demo mode shows its own error) */} - {!IS_DEMO_OR_PREVIEW && error && renderError(error)} + {!IS_DEMO_OR_PREVIEW && error && } {IS_DEMO_OR_PREVIEW && } {/* Config select control to improve testing experience */} {(IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && } From 3fdb220ef988383e6af80b72efb7c7d27e6bccb7 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Tue, 20 Feb 2024 10:14:46 +0100 Subject: [PATCH 040/128] fix: restore personal shelves after registration --- .../src/controllers/AccountController.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts index 7a7c64b46..36dae7e8d 100644 --- a/packages/common/src/controllers/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -66,8 +66,6 @@ export default class AccountController { if (authData) { await this.getAccount(); - await this.watchHistoryController.restoreWatchHistory(); - await this.favoritesController.restoreFavorites(); } } catch (error: unknown) { logDev('Failed to get user', error); @@ -145,10 +143,9 @@ export default class AccountController { try { const response = await this.accountService.getUser({ config }); + if (response) { await this.afterLogin(response.user, response.customerConsents); - await this.favoritesController.restoreFavorites().catch(logDev); - await this.watchHistoryController.restoreWatchHistory().catch(logDev); } useAccountStore.setState({ loading: false }); @@ -289,7 +286,10 @@ export default class AccountController { const updatedCustomer = await this.accountService.updateCaptureAnswers({ customer, ...capture }); - await this.afterLogin(updatedCustomer, customerConsents, false); + useAccountStore.setState({ + user: updatedCustomer, + customerConsents, + }); return updatedCustomer; }; @@ -499,7 +499,12 @@ export default class AccountController { customerConsents, }); - await Promise.allSettled([shouldReloadSubscription ? this.reloadSubscriptions() : Promise.resolve(), this.getPublisherConsents()]); + await Promise.allSettled([ + shouldReloadSubscription ? this.reloadSubscriptions() : Promise.resolve(), + this.getPublisherConsents(), + this.favoritesController.restoreFavorites(), + this.watchHistoryController.restoreWatchHistory(), + ]); useAccountStore.setState({ loading: false }); } @@ -533,7 +538,7 @@ export default class AccountController { this.profileController.unpersistProfile(); - await this.favoritesController.restoreFavorites().catch(logDev); - await this.watchHistoryController.restoreWatchHistory().catch(logDev); + await this.favoritesController.restoreFavorites(); + await this.watchHistoryController.restoreWatchHistory(); }; } From 2741eac5331657ed6156a9e5fba1906c8623227b Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Tue, 20 Feb 2024 14:03:57 +0100 Subject: [PATCH 041/128] fix: personal shelves restoration --- .../common/src/controllers/AccountController.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts index 36dae7e8d..bc51b6c59 100644 --- a/packages/common/src/controllers/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -170,9 +170,6 @@ export default class AccountController { if (response) { await this.afterLogin(response.user, response.customerConsents); - await this.favoritesController?.restoreFavorites(); - await this.watchHistoryController?.restoreWatchHistory(); - return; } } catch (error: unknown) { @@ -201,7 +198,7 @@ export default class AccountController { if (response) { const { user, customerConsents } = response; - await this.afterLogin(user, customerConsents); + await this.afterLogin(user, customerConsents, true); return; } @@ -493,18 +490,20 @@ export default class AccountController { return this.features; } - private async afterLogin(user: Customer, customerConsents: CustomerConsent[] | null, shouldReloadSubscription = true) { + private async afterLogin(user: Customer, customerConsents: CustomerConsent[] | null, registration = false) { useAccountStore.setState({ user, customerConsents, }); await Promise.allSettled([ - shouldReloadSubscription ? this.reloadSubscriptions() : Promise.resolve(), + this.reloadSubscriptions(), this.getPublisherConsents(), - this.favoritesController.restoreFavorites(), - this.watchHistoryController.restoreWatchHistory(), + // after registration, transfer the personal shelves to the account + registration ? this.favoritesController.persistFavorites() : this.favoritesController.restoreFavorites(), + registration ? this.watchHistoryController.persistWatchHistory() : this.watchHistoryController.restoreWatchHistory(), ]); + useAccountStore.setState({ loading: false }); } From cc02259ddb8faeed27813ddce850b5653fe1d0d3 Mon Sep 17 00:00:00 2001 From: langemike Date: Wed, 24 Jan 2024 13:15:23 +0100 Subject: [PATCH 042/128] feat(a11y): many accessibility optimisations fix(a11y): prevent double ids on inputs by requiring a name feat(a11y): apply aria-modal attribute and move header landmark (#48) feat(a11y): update button role and html structure of account and player pages (#47) feat(a11y): add correct text markups and aria attributes (#46) feat(home): add (geo) error message when all playlists are empty feat(a11y): add form error announcement feat(a11y): add solid header background color to ensure accessibility feat(a11y): implement aria-invalid and aria-described by to inputs on error feat(project): add google fonts from env vars feat: keyboard accessible LayoutGrid feat: optimize featured shelf slider for accessibility feat(a11y): accessible sidebar &
    landmark feat(a11y): enhance dialog and modals accessibility fix(a11y): alt text for images for EPG fix(a11y): empty alt for image because of adjacent text alternative fix(a11y): fix arrow keys for offer radio buttons fix(a11y): skiplink first element feat(a11y): improve html structure for VideoListItem fix(e2e): cardgrid card navigation feat(a11y): apply lang attribute to custom fields feat(a11y): accessible focus outline --- packages/common/src/constants.ts | 2 +- packages/common/src/env.ts | 10 + packages/common/src/utils/common.ts | 24 + packages/hooks-react/src/usePlaylists.ts | 73 +++ packages/testing/fixtures/favorites.json | 417 ++++++++++++++ .../src/components/Account/Account.tsx | 38 +- .../__snapshots__/Account.test.tsx.snap | 52 +- .../src/components/Button/Button.module.scss | 16 +- .../ui-react/src/components/Button/Button.tsx | 2 +- .../Button/__snapshots__/Button.test.tsx.snap | 1 + .../CancelSubscriptionForm.tsx | 1 + .../CancelSubscriptionForm.test.tsx.snap | 2 + .../src/components/Card/Card.module.scss | 7 +- .../ui-react/src/components/Card/Card.tsx | 23 +- .../src/components/CardGrid/CardGrid.tsx | 40 +- .../__snapshots__/CardGrid.test.tsx.snap | 36 +- .../src/components/Checkbox/Checkbox.tsx | 20 +- .../__snapshots__/Checkbox.test.tsx.snap | 1 + .../CheckoutForm/CheckoutForm.module.scss | 13 +- .../components/CheckoutForm/CheckoutForm.tsx | 2 +- .../__snapshots__/CheckoutForm.test.tsx.snap | 47 +- .../ChooseOfferForm.module.scss | 15 +- .../ChooseOfferForm/ChooseOfferForm.test.tsx | 16 +- .../ChooseOfferForm/ChooseOfferForm.tsx | 96 ++-- .../ChooseOfferForm.test.tsx.snap | 9 +- .../CollapsibleText/CollapsibleText.tsx | 11 +- .../CollapsibleText.test.tsx.snap | 4 +- .../ConfirmationDialog/ConfirmationDialog.tsx | 6 +- .../ConfirmationForm.test.tsx.snap | 1 + .../CreditCardCVCField.test.tsx.snap | 3 + .../CreditCardExpiryField.test.tsx.snap | 3 + .../CreditCardNumberField.test.tsx.snap | 3 + .../CustomRegisterField.tsx | 20 +- .../CustomRegisterField.test.tsx.snap | 20 +- .../src/components/DateField/DateField.tsx | 15 +- .../DeleteAccountModal/DeleteAccountModal.tsx | 21 +- .../DevConfigSelector/DevConfigSelector.tsx | 1 + .../ui-react/src/components/Dialog/Dialog.tsx | 15 +- .../Dialog/__snapshots__/Dialog.test.tsx.snap | 6 +- .../DialogBackButton/DialogBackButton.tsx | 5 +- .../DialogBackButton.test.tsx.snap | 2 +- .../src/components/Dropdown/Dropdown.tsx | 20 +- .../EditCardPaymentForm.tsx | 12 +- .../EditPasswordForm.test.tsx.snap | 12 + packages/ui-react/src/components/Epg/Epg.tsx | 8 +- .../components/EpgChannel/EpgChannelItem.tsx | 6 +- .../EpgProgramItem/EpgProgramItem.tsx | 3 +- .../src/components/ErrorPage/ErrorPage.tsx | 38 +- .../__snapshots__/ErrorPage.test.tsx.snap | 16 +- .../components/Favorites/Favorites.test.tsx | 17 +- .../src/components/Favorites/Favorites.tsx | 15 +- .../__snapshots__/Favorites.test.tsx.snap | 177 +++++- .../Filter/__snapshots__/Filter.test.tsx.snap | 4 + .../FinalizePayment/FinalizePayment.tsx | 3 + .../ForgotPasswordForm.test.tsx.snap | 3 + .../ui-react/src/components/Form/Form.tsx | 1 + .../src/components/Form/FormSection.tsx | 1 - .../FormFeedback/FormFeedback.module.scss | 12 + .../components/FormFeedback/FormFeedback.tsx | 20 +- .../__snapshots__/FormFeedback.test.tsx.snap | 1 + .../src/components/Header/Header.test.tsx | 1 + .../ui-react/src/components/Header/Header.tsx | 22 +- .../Header/__snapshots__/Header.test.tsx.snap | 18 +- .../src/components/HelperText/HelperText.tsx | 12 +- .../__snapshots__/HelperText.test.tsx.snap | 2 + .../IconButton/IconButton.module.scss | 1 + .../LayoutGrid/LayoutGrid.module.scss | 8 + .../src/components/LayoutGrid/LayoutGrid.tsx | 153 +++++ .../src/components/LoginForm/LoginForm.tsx | 2 +- .../__snapshots__/LoginForm.test.tsx.snap | 6 + .../MarkdownComponent/MarkdownComponent.tsx | 5 +- .../src/components/MenuButton/MenuButton.tsx | 3 +- .../__snapshots__/MenuButton.test.tsx.snap | 1 - .../src/components/Modal/Modal.test.tsx | 5 +- .../ui-react/src/components/Modal/Modal.tsx | 25 +- .../NoPaymentRequired.test.tsx.snap | 1 + .../__snapshots__/PasswordField.test.tsx.snap | 6 + .../PayPal/__snapshots__/PayPal.test.tsx.snap | 1 + .../src/components/Payment/Payment.tsx | 17 +- .../__snapshots__/Payment.test.tsx.snap | 51 +- .../__snapshots__/PaymentFailed.test.tsx.snap | 1 + .../PaymentMethodForm.module.scss | 13 +- .../PaymentMethodForm/PaymentMethodForm.tsx | 8 +- .../PersonalDetailsForm.test.tsx.snap | 113 +++- .../components/Popover/Popover.module.scss | 2 +- .../__snapshots__/Popover.test.tsx.snap | 1 - .../src/components/Radio/Radio.module.scss | 5 +- .../ui-react/src/components/Radio/Radio.tsx | 25 +- .../Radio/__snapshots__/Radio.test.tsx.snap | 13 +- .../RegistrationForm/RegistrationForm.tsx | 49 +- .../RegistrationForm.test.tsx.snap | 8 + .../RenewSubscriptionForm.tsx | 1 + .../RenewSubscriptionForm.test.tsx.snap | 2 + .../ResetPasswordForm/ResetPasswordForm.tsx | 2 +- .../ResetPasswordForm.test.tsx.snap | 2 + .../__snapshots__/ShareButton.test.tsx.snap | 1 + .../src/components/Shelf/Shelf.module.scss | 4 +- .../ui-react/src/components/Shelf/Shelf.tsx | 8 +- .../Shelf/__snapshots__/Shelf.test.tsx.snap | 540 +++++++++--------- .../components/Sidebar/Sidebar.module.scss | 3 + .../src/components/Sidebar/Sidebar.test.tsx | 12 +- .../src/components/Sidebar/Sidebar.tsx | 19 +- .../__snapshots__/Sidebar.test.tsx.snap | 55 +- .../SubscriptionCancelled.test.tsx.snap | 1 + .../SubscriptionRenewed.test.tsx.snap | 1 + .../TextField/TextField.module.scss | 6 + .../components/TextField/TextField.test.tsx | 18 +- .../src/components/TextField/TextField.tsx | 53 +- .../__snapshots__/TextField.test.tsx.snap | 73 +++ .../src/components/TileDock/TileDock.tsx | 8 +- .../UpgradeSubscription.tsx | 10 +- .../__snapshots__/UserMenu.test.tsx.snap | 3 - .../VideoDetails/VideoDetails.module.scss | 5 +- .../VideoDetails/VideoDetails.test.tsx | 3 +- .../components/VideoDetails/VideoDetails.tsx | 7 +- .../__snapshots__/VideoDetails.test.tsx.snap | 10 +- .../VideoLayout/VideoLayout.module.scss | 6 +- .../components/VideoLayout/VideoLayout.tsx | 3 +- .../VideoList/VideoList.module.scss | 13 +- .../src/components/VideoList/VideoList.tsx | 32 +- .../VideoListItem/VideoListItem.module.scss | 36 +- .../VideoListItem/VideoListItem.tsx | 41 +- .../WaitingForPayment/WaitingForPayment.tsx | 9 +- .../__snapshots__/Welcome.test.tsx.snap | 1 + .../AccountModal/forms/CancelSubscription.tsx | 13 +- .../AccountModal/forms/Checkout.tsx | 10 +- .../AccountModal/forms/ChooseOffer.tsx | 1 - .../AccountModal/forms/EditPassword.tsx | 6 +- .../containers/AccountModal/forms/Login.tsx | 8 +- .../AccountModal/forms/Registration.tsx | 14 +- .../AccountModal/forms/RenewSubscription.tsx | 3 + .../AccountModal/forms/ResetPassword.tsx | 4 +- .../AdyenInitialPayment.tsx | 14 +- .../AdyenPaymentDetails.tsx | 8 +- .../AnnoucementProvider.tsx | 45 ++ .../src/containers/Layout/Layout.module.scss | 4 +- .../ui-react/src/containers/Layout/Layout.tsx | 42 +- .../Layout/__snapshots__/Layout.test.tsx.snap | 113 ++-- .../PlaylistContainer/PlaylistContainer.tsx | 57 -- .../ShelfList/ShelfList.module.scss | 8 - .../src/containers/ShelfList/ShelfList.tsx | 102 ++-- .../containers/TrailerModal/TrailerModal.tsx | 8 +- .../ui-react/src/pages/Home/Home.test.tsx | 10 +- .../Home/__snapshots__/Home.test.tsx.snap | 248 ++++---- .../ScreenRouting/PlaylistScreenRouter.tsx | 4 + .../PlaylistGrid/PlaylistGrid.tsx | 13 +- .../PlaylistLiveChannels.tsx | 2 +- .../src/pages/Search/Search.module.scss | 6 +- packages/ui-react/src/pages/Search/Search.tsx | 4 +- packages/ui-react/src/pages/User/User.tsx | 27 +- .../User/__snapshots__/User.test.tsx.snap | 154 +++-- .../ui-react/src/styles/accessibility.scss | 32 ++ packages/ui-react/src/utils/theming.ts | 25 +- packages/ui-react/test/utils.tsx | 5 +- platforms/web/public/locales/en/account.json | 21 +- platforms/web/public/locales/en/common.json | 2 +- platforms/web/public/locales/en/error.json | 4 +- platforms/web/public/locales/en/user.json | 6 +- platforms/web/public/locales/en/video.json | 1 + platforms/web/public/locales/es/account.json | 21 +- platforms/web/public/locales/es/common.json | 2 +- platforms/web/public/locales/es/error.json | 4 +- platforms/web/public/locales/es/user.json | 6 +- platforms/web/public/locales/es/video.json | 1 + .../web/scripts/build-tools/buildTools.ts | 148 +++++ platforms/web/scripts/build-tools/settings.ts | 17 - platforms/web/src/App.tsx | 5 +- .../DemoConfigDialog.test.tsx.snap | 23 +- platforms/web/src/index.tsx | 2 + platforms/web/src/styles/main.scss | 4 + platforms/web/test-e2e/tests/account_test.ts | 14 +- .../web/test-e2e/tests/live_channel_test.ts | 4 +- .../tests/payments/subscription_test.ts | 6 +- .../web/test-e2e/tests/video_detail_test.ts | 2 +- platforms/web/test-e2e/utils/steps_file.ts | 17 +- platforms/web/vite.config.ts | 94 +-- 176 files changed, 3128 insertions(+), 1300 deletions(-) create mode 100644 packages/hooks-react/src/usePlaylists.ts create mode 100644 packages/testing/fixtures/favorites.json create mode 100644 packages/ui-react/src/components/LayoutGrid/LayoutGrid.module.scss create mode 100644 packages/ui-react/src/components/LayoutGrid/LayoutGrid.tsx create mode 100644 packages/ui-react/src/containers/AnnouncementProvider/AnnoucementProvider.tsx delete mode 100644 packages/ui-react/src/containers/PlaylistContainer/PlaylistContainer.tsx create mode 100644 platforms/web/scripts/build-tools/buildTools.ts delete mode 100644 platforms/web/scripts/build-tools/settings.ts diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 08b199752..06c8c0ee1 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -60,7 +60,7 @@ export const CACHE_TIME = 60 * 1000 * 20; // 20 minutes export const STALE_TIME = 60 * 1000 * 20; -export const CARD_ASPECT_RATIOS = ['2:1', '16:9', '5:3', '4:3', '1:1', '9:13', '2:3', '9:16'] as const; +export const CARD_ASPECT_RATIOS = ['1:1', '2:1', '2:3', '4:3', '5:3', '16:9', '9:13', '9:16'] as const; export const DEFAULT_FEATURES = { canUpdateEmail: false, diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts index 5d4066571..50c854c56 100644 --- a/packages/common/src/env.ts +++ b/packages/common/src/env.ts @@ -3,8 +3,13 @@ export type Env = { APP_API_BASE_URL: string; APP_PLAYER_ID: string; APP_FOOTER_TEXT: string; + APP_DEFAULT_LANGUAGE: string; + APP_DEFAULT_CONFIG_SOURCE?: string; APP_PLAYER_LICENSE_KEY?: string; + + APP_BODY_FONT?: string; + APP_BODY_ALT_FONT?: string; }; const env: Env = { @@ -12,6 +17,7 @@ const env: Env = { APP_API_BASE_URL: 'https://cdn.jwplayer.com', APP_PLAYER_ID: 'M4qoGvUk', APP_FOOTER_TEXT: '', + APP_DEFAULT_LANGUAGE: 'en', }; export const configureEnv = (options: Partial) => { @@ -19,9 +25,13 @@ export const configureEnv = (options: Partial) => { env.APP_API_BASE_URL = options.APP_API_BASE_URL || env.APP_API_BASE_URL; env.APP_PLAYER_ID = options.APP_PLAYER_ID || env.APP_PLAYER_ID; env.APP_FOOTER_TEXT = options.APP_FOOTER_TEXT || env.APP_FOOTER_TEXT; + env.APP_DEFAULT_LANGUAGE = options.APP_DEFAULT_LANGUAGE || env.APP_DEFAULT_LANGUAGE; env.APP_DEFAULT_CONFIG_SOURCE ||= options.APP_DEFAULT_CONFIG_SOURCE; env.APP_PLAYER_LICENSE_KEY ||= options.APP_PLAYER_LICENSE_KEY; + + env.APP_BODY_FONT = options.APP_BODY_FONT || env.APP_BODY_FONT; + env.APP_BODY_ALT_FONT = options.APP_BODY_ALT_FONT || env.APP_BODY_ALT_FONT; }; export default env; diff --git a/packages/common/src/utils/common.ts b/packages/common/src/utils/common.ts index 5b3395186..f3666cb2c 100644 --- a/packages/common/src/utils/common.ts +++ b/packages/common/src/utils/common.ts @@ -5,6 +5,30 @@ export function debounce void>(callback: T, wait = timeout = setTimeout(() => callback(...args), wait); }; } +export function throttle unknown>(func: T, limit: number): (...args: Parameters) => void { + let lastFunc: NodeJS.Timeout | undefined; + let lastRan: number | undefined; + + return function (this: ThisParameterType, ...args: Parameters): void { + const timeSinceLastRan = lastRan ? Date.now() - lastRan : limit; + + if (timeSinceLastRan >= limit) { + func.apply(this, args); + lastRan = Date.now(); + } else if (!lastFunc) { + lastFunc = setTimeout(() => { + if (lastRan) { + const timeSinceLastRan = Date.now() - lastRan; + if (timeSinceLastRan >= limit) { + func.apply(this, args); + lastRan = Date.now(); + } + } + lastFunc = undefined; + }, limit - timeSinceLastRan); + } + }; +} export const unicodeToChar = (text: string) => { return text.replace(/\\u[\dA-F]{4}/gi, (match) => { diff --git a/packages/hooks-react/src/usePlaylists.ts b/packages/hooks-react/src/usePlaylists.ts new file mode 100644 index 000000000..c430e816f --- /dev/null +++ b/packages/hooks-react/src/usePlaylists.ts @@ -0,0 +1,73 @@ +import { PersonalShelf, PersonalShelves, PLAYLIST_LIMIT } from '@jwp/ott-common/src/constants'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { useFavoritesStore } from '@jwp/ott-common/src/stores/FavoritesStore'; +import { useWatchHistoryStore } from '@jwp/ott-common/src/stores/WatchHistoryStore'; +import { generatePlaylistPlaceholder } from '@jwp/ott-common/src/utils/collection'; +import { isTruthyCustomParamValue } from '@jwp/ott-common/src/utils/common'; +import { isScheduledOrLiveMedia } from '@jwp/ott-common/src/utils/liveEvent'; +import type { Content } from '@jwp/ott-common/types/config'; +import type { Playlist } from '@jwp/ott-common/types/playlist'; +import { useQueries, useQueryClient } from 'react-query'; + +const placeholderData = generatePlaylistPlaceholder(30); + +type UsePlaylistResult = { + data: Playlist | undefined; + isLoading: boolean; + isSuccess?: boolean; + error?: unknown; +}[]; + +const usePlaylists = (content: Content[], rowsToLoad: number | undefined = undefined) => { + const page_limit = PLAYLIST_LIMIT.toString(); + const queryClient = useQueryClient(); + const apiService = getModule(ApiService); + + const favorites = useFavoritesStore((state) => state.getPlaylist()); + const watchHistory = useWatchHistoryStore((state) => state.getPlaylist()); + + const playlistQueries = useQueries( + content.map(({ contentId, type }, index) => ({ + enabled: !!contentId && (!rowsToLoad || rowsToLoad > index) && !PersonalShelves.some((pType) => pType === type), + queryKey: ['playlist', contentId], + queryFn: async () => { + const playlist = await apiService.getPlaylistById(contentId, { page_limit }); + + // This pre-caches all playlist items and makes navigating a lot faster. + playlist?.playlist?.forEach((playlistItem) => { + queryClient.setQueryData(['media', playlistItem.mediaid], playlistItem); + }); + + return playlist; + }, + placeholderData: !!contentId && placeholderData, + refetchInterval: (data: Playlist | undefined) => { + if (!data) return false; + + const autoRefetch = isTruthyCustomParamValue(data.refetch) || data.playlist.some(isScheduledOrLiveMedia); + + return autoRefetch ? 1000 * 30 : false; + }, + retry: false, + })), + ); + + const result: UsePlaylistResult = content.map(({ type }, index) => { + if (type === PersonalShelf.Favorites) return { data: favorites, isLoading: false, isSuccess: true }; + if (type === PersonalShelf.ContinueWatching) return { data: watchHistory, isLoading: false, isSuccess: true }; + + const { data, isLoading, isSuccess, error } = playlistQueries[index]; + + return { + data, + isLoading, + isSuccess, + error, + }; + }); + + return result; +}; + +export default usePlaylists; diff --git a/packages/testing/fixtures/favorites.json b/packages/testing/fixtures/favorites.json new file mode 100644 index 000000000..ab2c6a930 --- /dev/null +++ b/packages/testing/fixtures/favorites.json @@ -0,0 +1,417 @@ +{ + "feedid": "KKOhckQL", + "title": "Favorites", + "playlist": [ + { + "title": "SVOD 002: Caminandes 1 llama drama", + "mediaid": "1TJAvj2S", + "link": "https://cdn.jwplayer.com/previews/1TJAvj2S", + "image": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=720", + "images": [ + { + "src": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=320", + "width": 320, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=480", + "width": 480, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=640", + "width": 640, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=720", + "width": 720, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=1280", + "width": 1280, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/poster.jpg?width=1920", + "width": 1920, + "type": "image/jpeg" + } + ], + "feedid": "bdH6HTUi", + "duration": 90, + "pubdate": 1703166863, + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur nulla ligula, sollicitudin id felis eu, consequat aliquam ante. Suspendisse lacinia felis a quam laoreet, non tristique quam efficitur. Morbi ultrices, nibh et fringilla aliquet, ligula tortor porta libero, ut elementum nibh nisl vel odio. Praesent ornare luctus arcu nec condimentum. Vestibulum fringilla egestas neque, feugiat sollicitudin eros aliquet non. In enim augue, sodales eget dignissim eget, sagittis sit amet eros. Nulla tristique nisi iaculis dui egestas mollis. Aenean libero odio, vestibulum quis sodales pulvinar, consequat ut nisl.", + "tags": "svod", + "recommendations": "https://cdn.jwplayer.com/v2/playlists/bdH6HTUi?related_media_id=1TJAvj2S", + "sources": [ + { + "file": "https://cdn.jwplayer.com/manifests/1TJAvj2S.m3u8", + "type": "application/vnd.apple.mpegurl" + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-xAdPQ1TN.m4a", + "type": "audio/mp4", + "label": "AAC Audio", + "bitrate": 113513, + "filesize": 1277026 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-1Yfbe0HO.mp4", + "type": "video/mp4", + "height": 180, + "width": 320, + "label": "180p", + "bitrate": 241872, + "filesize": 2721071, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-JjxC7ylo.mp4", + "type": "video/mp4", + "height": 270, + "width": 480, + "label": "270p", + "bitrate": 356443, + "filesize": 4009992, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-kqEB96Md.mp4", + "type": "video/mp4", + "height": 360, + "width": 640, + "label": "360p", + "bitrate": 401068, + "filesize": 4512018, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-MskLmv79.mp4", + "type": "video/mp4", + "height": 406, + "width": 720, + "label": "406p", + "bitrate": 466271, + "filesize": 5245549, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-MCyoQl96.mp4", + "type": "video/mp4", + "height": 540, + "width": 960, + "label": "540p", + "bitrate": 713837, + "filesize": 8030667, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-yYzmiSbt.mp4", + "type": "video/mp4", + "height": 720, + "width": 1280, + "label": "720p", + "bitrate": 1088928, + "filesize": 12250450, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/1TJAvj2S-H4t30RCN.mp4", + "type": "video/mp4", + "height": 1080, + "width": 1920, + "label": "1080p", + "bitrate": 2391552, + "filesize": 26904961, + "framerate": 24 + } + ], + "tracks": [ + { + "file": "https://cdn.jwplayer.com/strips/1TJAvj2S-120.vtt", + "kind": "thumbnails" + } + ], + "variations": {}, + "cardImage": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/images/card.webp?poster_fallback=1", + "channelLogoImage": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/images/channel_logo.webp?poster_fallback=1", + "backgroundImage": "https://cdn.jwplayer.com/v2/media/1TJAvj2S/images/background.webp?poster_fallback=1" + }, + { + "title": "SVOD 003: Caminandes 2 gran dillama", + "mediaid": "rnibIt0n", + "link": "https://cdn.jwplayer.com/previews/rnibIt0n", + "image": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=720", + "images": [ + { + "src": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=320", + "width": 320, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=480", + "width": 480, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=640", + "width": 640, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=720", + "width": 720, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=1280", + "width": 1280, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/rnibIt0n/poster.jpg?width=1920", + "width": 1920, + "type": "image/jpeg" + } + ], + "feedid": "bdH6HTUi", + "duration": 146, + "pubdate": 1703166863, + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur nulla ligula, sollicitudin id felis eu, consequat aliquam ante. Suspendisse lacinia felis a quam laoreet, non tristique quam efficitur. Morbi ultrices, nibh et fringilla aliquet, ligula tortor porta libero, ut elementum nibh nisl vel odio. Praesent ornare luctus arcu nec condimentum. Vestibulum fringilla egestas neque, feugiat sollicitudin eros aliquet non. In enim augue, sodales eget dignissim eget, sagittis sit amet eros. Nulla tristique nisi iaculis dui egestas mollis. Aenean libero odio, vestibulum quis sodales pulvinar, consequat ut nisl.", + "tags": "svod", + "recommendations": "https://cdn.jwplayer.com/v2/playlists/bdH6HTUi?related_media_id=rnibIt0n", + "sources": [ + { + "file": "https://cdn.jwplayer.com/manifests/rnibIt0n.m3u8", + "type": "application/vnd.apple.mpegurl" + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-xAdPQ1TN.m4a", + "type": "audio/mp4", + "label": "AAC Audio", + "bitrate": 113503, + "filesize": 2071433 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-1Yfbe0HO.mp4", + "type": "video/mp4", + "height": 180, + "width": 320, + "label": "180p", + "bitrate": 342175, + "filesize": 6244705, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-JjxC7ylo.mp4", + "type": "video/mp4", + "height": 270, + "width": 480, + "label": "270p", + "bitrate": 501738, + "filesize": 9156729, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-kqEB96Md.mp4", + "type": "video/mp4", + "height": 360, + "width": 640, + "label": "360p", + "bitrate": 579321, + "filesize": 10572609, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-MskLmv79.mp4", + "type": "video/mp4", + "height": 406, + "width": 720, + "label": "406p", + "bitrate": 673083, + "filesize": 12283769, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-MCyoQl96.mp4", + "type": "video/mp4", + "height": 540, + "width": 960, + "label": "540p", + "bitrate": 984717, + "filesize": 17971095, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-yYzmiSbt.mp4", + "type": "video/mp4", + "height": 720, + "width": 1280, + "label": "720p", + "bitrate": 1527270, + "filesize": 27872694, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/rnibIt0n-H4t30RCN.mp4", + "type": "video/mp4", + "height": 1080, + "width": 1920, + "label": "1080p", + "bitrate": 3309652, + "filesize": 60401155, + "framerate": 24 + } + ], + "tracks": [ + { + "file": "https://cdn.jwplayer.com/strips/rnibIt0n-120.vtt", + "kind": "thumbnails" + } + ], + "variations": {}, + "genre": "Animation", + "cardImage": "https://cdn.jwplayer.com/v2/media/rnibIt0n/images/card.webp?poster_fallback=1", + "channelLogoImage": "https://cdn.jwplayer.com/v2/media/rnibIt0n/images/channel_logo.webp?poster_fallback=1", + "backgroundImage": "https://cdn.jwplayer.com/v2/media/rnibIt0n/images/background.webp?poster_fallback=1" + }, + { + "title": "SVOD 001: Tears of Steel", + "mediaid": "MaCvdQdE", + "link": "https://cdn.jwplayer.com/previews/MaCvdQdE", + "image": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=720", + "images": [ + { + "src": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=320", + "width": 320, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=480", + "width": 480, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=640", + "width": 640, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=720", + "width": 720, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=1280", + "width": 1280, + "type": "image/jpeg" + }, + { + "src": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/poster.jpg?width=1920", + "width": 1920, + "type": "image/jpeg" + } + ], + "feedid": "E2uaFiUM", + "duration": 734, + "pubdate": 1703166863, + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur nulla ligula, sollicitudin id felis eu, consequat aliquam ante. Suspendisse lacinia felis a quam laoreet, non tristique quam efficitur. Morbi ultrices, nibh et fringilla aliquet, ligula tortor porta libero, ut elementum nibh nisl vel odio. Praesent ornare luctus arcu nec condimentum. Vestibulum fringilla egestas neque, feugiat sollicitudin eros aliquet non. In enim augue, sodales eget dignissim eget, sagittis sit amet eros. Nulla tristique nisi iaculis dui egestas mollis. Aenean libero odio, vestibulum quis sodales pulvinar, consequat ut nisl.", + "tags": "svod", + "sources": [ + { + "file": "https://cdn.jwplayer.com/manifests/MaCvdQdE.m3u8", + "type": "application/vnd.apple.mpegurl" + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-xAdPQ1TN.m4a", + "type": "audio/mp4", + "label": "AAC Audio", + "bitrate": 113413, + "filesize": 10405724 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-1Yfbe0HO.mp4", + "type": "video/mp4", + "height": 134, + "width": 320, + "label": "180p", + "bitrate": 388986, + "filesize": 35689542, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-JjxC7ylo.mp4", + "type": "video/mp4", + "height": 200, + "width": 480, + "label": "270p", + "bitrate": 575378, + "filesize": 52790944, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-kqEB96Md.mp4", + "type": "video/mp4", + "height": 266, + "width": 640, + "label": "360p", + "bitrate": 617338, + "filesize": 56640812, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-MskLmv79.mp4", + "type": "video/mp4", + "height": 300, + "width": 720, + "label": "406p", + "bitrate": 715724, + "filesize": 65667691, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-MCyoQl96.mp4", + "type": "video/mp4", + "height": 400, + "width": 960, + "label": "540p", + "bitrate": 1029707, + "filesize": 94475629, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-yYzmiSbt.mp4", + "type": "video/mp4", + "height": 534, + "width": 1280, + "label": "720p", + "bitrate": 1570612, + "filesize": 144103685, + "framerate": 24 + }, + { + "file": "https://cdn.jwplayer.com/videos/MaCvdQdE-H4t30RCN.mp4", + "type": "video/mp4", + "height": 800, + "width": 1920, + "label": "1080p", + "bitrate": 3081227, + "filesize": 282702650, + "framerate": 24 + } + ], + "tracks": [ + { + "file": "https://cdn.jwplayer.com/strips/MaCvdQdE-120.vtt", + "kind": "thumbnails" + } + ], + "variations": {}, + "cardImage": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/images/card.webp?poster_fallback=1", + "channelLogoImage": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/images/channel_logo.webp?poster_fallback=1", + "backgroundImage": "https://cdn.jwplayer.com/v2/media/MaCvdQdE/images/background.webp?poster_fallback=1" + } + ] +} diff --git a/packages/ui-react/src/components/Account/Account.tsx b/packages/ui-react/src/components/Account/Account.tsx index ed715b695..71c4c2c2f 100644 --- a/packages/ui-react/src/components/Account/Account.tsx +++ b/packages/ui-react/src/components/Account/Account.tsx @@ -13,6 +13,7 @@ import { formatConsents, formatConsentsFromValues, formatConsentsToRegisterField import useToggle from '@jwp/ott-hooks-react/src/useToggle'; import Visibility from '@jwp/ott-theme/assets/icons/visibility.svg?react'; import VisibilityOff from '@jwp/ott-theme/assets/icons/visibility_off.svg?react'; +import env from '@jwp/ott-common/src/env'; import type { FormSectionContentArgs, FormSectionProps } from '../Form/FormSection'; import Alert from '../Alert/Alert'; @@ -25,6 +26,7 @@ import HelperText from '../HelperText/HelperText'; import CustomRegisterField from '../CustomRegisterField/CustomRegisterField'; import Icon from '../Icon/Icon'; import { modalURLFromLocation } from '../../utils/location'; +import { useAriaAnnouncer } from '../../containers/AnnouncementProvider/AnnoucementProvider'; import styles from './Account.module.scss'; @@ -45,13 +47,15 @@ interface FormErrors { const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true }: Props): JSX.Element => { const accountController = getModule(AccountController); - const { t } = useTranslation('user'); + const { t, i18n } = useTranslation('user'); + const announce = useAriaAnnouncer(); const navigate = useNavigate(); const location = useLocation(); const [viewPassword, toggleViewPassword] = useToggle(); const exportData = useMutation(accountController.exportAccountData); const [isAlertVisible, setIsAlertVisible] = useState(false); const exportDataMessage = exportData.isSuccess ? t('account.export_data_success') : t('account.export_data_error'); + const htmlLang = i18n.language !== env.APP_DEFAULT_LANGUAGE ? env.APP_DEFAULT_LANGUAGE : undefined; useEffect(() => { if (exportData.isSuccess || exportData.isError) { @@ -203,15 +207,17 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } return ( <> +

    {t('nav.account')}

    +
    {[ formSection({ label: t('account.about_you'), editButton: t('account.edit_information'), - onSubmit: (values) => { + onSubmit: async (values) => { const consents = formatConsentsFromValues(publisherConsents, { ...values.metadata, ...values.consentsValues }); - return accountController.updateUser({ + const response = await accountController.updateUser({ firstName: values.firstName || '', lastName: values.lastName || '', metadata: { @@ -220,6 +226,10 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } consents: JSON.stringify(consents), }, }); + + announce(t('account.update_success', { section: t('account.about_you') }), 'success'); + + return response; }, content: (section) => ( <> @@ -233,6 +243,7 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } helperText={section.errors?.firstName} disabled={section.isBusy} editing={section.isEditing} + lang={htmlLang} /> ), }), formSection({ label: t('account.email'), - onSubmit: (values) => - accountController.updateUser({ + onSubmit: async (values) => { + const response = await accountController.updateUser({ email: values.email || '', confirmationPassword: values.confirmationPassword, - }), + }); + + announce(t('account.update_success', { section: t('account.email') }), 'success'); + + return response; + }, canSave: (values) => !!(values.email && values.confirmationPassword), editButton: t('account.edit_account'), readOnly: !canUpdateEmail, @@ -304,7 +321,13 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } formSection({ label: t('account.terms_and_tracking'), saveButton: t('account.update_consents'), - onSubmit: (values) => accountController.updateConsents(formatConsentsFromValues(publisherConsents, values.consentsValues)), + onSubmit: async (values) => { + const response = await accountController.updateConsents(formatConsentsFromValues(publisherConsents, values.consentsValues)); + + announce(t('account.update_success', { section: t('account.terms_and_tracking') }), 'success'); + + return response; + }, content: (section) => ( <> {termsConsents?.map((consent, index) => ( @@ -315,6 +338,7 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } onChange={section.onChange} label={formatConsentLabel(consent.label)} disabled={consent.required || section.isBusy} + lang={htmlLang} /> ))} diff --git a/packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap b/packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap index 175e6fe0c..6578f12f2 100644 --- a/packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap +++ b/packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap @@ -2,6 +2,11 @@ exports[` > renders and matches snapshot 1`] = `
    +

    + nav.account +

    > renders and matches snapshot 1`] = ` > account.firstname -

    - John -

    +
    > renders and matches snapshot 1`] = ` > account.lastname -

    - Doe -

    +
    > renders and matches snapshot 1`] = ` > account.email -

    - email@domain.com -

    +
    > renders and matches snapshot 1`] = ` class="_row_531f07" > > renders and matches snapshot 1`] = ` /> diff --git a/packages/ui-react/src/components/Button/Button.module.scss b/packages/ui-react/src/components/Button/Button.module.scss index 429a2530e..40577575a 100644 --- a/packages/ui-react/src/components/Button/Button.module.scss +++ b/packages/ui-react/src/components/Button/Button.module.scss @@ -1,5 +1,6 @@ @use '@jwp/ott-ui-react/src/styles/variables'; @use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/accessibility'; @use '@jwp/ott-ui-react/src/styles/mixins/responsive'; $small-button-height: 28px; @@ -31,15 +32,7 @@ $large-button-height: 40px; &:hover, &:focus { z-index: 1; - transform: scale(1.1); - } - - &:focus:not(:focus-visible):not(:hover) { - transform: scale(1); - } - - &:focus-visible { - transform: scale(1.1); + transform: scale(1.05); } } } @@ -65,6 +58,10 @@ $large-button-height: 40px; &.primary { color: var(--highlight-contrast-color, theme.$btn-primary-color); background-color: var(--highlight-color, theme.$btn-primary-bg); + + &:focus { + @include accessibility.accessibleOutlineContrast; + } } &.outlined { @@ -76,6 +73,7 @@ $large-button-height: 40px; color: var(--highlight-contrast-color, theme.$btn-primary-color); background-color: var(--highlight-color, theme.$btn-primary-bg); border-color: var(--highlight-color, theme.$btn-primary-bg); + outline: none; } } } diff --git a/packages/ui-react/src/components/Button/Button.tsx b/packages/ui-react/src/components/Button/Button.tsx index fc4bf0c72..842e92934 100644 --- a/packages/ui-react/src/components/Button/Button.tsx +++ b/packages/ui-react/src/components/Button/Button.tsx @@ -42,7 +42,7 @@ const Button: React.FC = ({ size = 'medium', disabled, busy, - type, + type = 'button', to, as = 'button', onClick, diff --git a/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap b/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap index 766a87a85..aa5e13b5a 100644 --- a/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap +++ b/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap @@ -4,6 +4,7 @@ exports[`
    diff --git a/packages/ui-react/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap b/packages/ui-react/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap index e741084ab..e1f960683 100644 --- a/packages/ui-react/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap +++ b/packages/ui-react/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap @@ -14,6 +14,7 @@ exports[` > renders and matches snapshot 1`] = ` + + + + +`; diff --git a/packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap b/packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap index 6bf873a36..8fc29d1be 100644 --- a/packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap +++ b/packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap @@ -10,6 +10,7 @@ exports[` > renders Filter 1`] = `