From 3e3e2b14c4926e596fad46c2ea7cc99dbced05f0 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Wed, 31 Jan 2024 17:03:04 +0100 Subject: [PATCH 01/79] feat(project)!: restructure for multiplatforms with workspaces (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: initialize workspace and separate services, stores and utils * chore: update configurations and set up linting * refactor(project): move web assets to theme package refactor(project): move poster aspect to constants refactor(project): move test fixtures and utils refactor: move hooks to react-hooks package chore: configurations and testing refactor: add mockService helper chore: remove .only from useLiveChannels test refactor: move components, containers and pages to ui-react package refactor: rename @jwplayer org to @jwp chore: fix epg fixtures for testing chore: fix unmet peer dependency warnings chore: fix e2e typings * docs: add i18n package readme chore(i18n): update i18next scripts * refactor: store supported languages in config store * chore: update release workflow * refactor: move web into platforms directory * chore: update web platform directory in workflows * chore: update gitignore * chore: fix epg fixtures * refactor(project): remove vite from common, ui-react and hooks-react (#8) * refactor: replace meta env imports with common env object * refactor(tests): fix broken ui-react unit tests * refactor(project): add scss typings * refactor(project): remove obsolete ts rule * refactor(tests): make sure mode and dev are globally available * refactor(project): minor code upgrade Co-authored-by: Christiaan Scheermeijer --------- Co-authored-by: Christiaan Scheermeijer * feat: remove browser typings from common * refactor(project): remove react from common package (#9) * refactor: replace meta env imports with common env object * refactor(tests): fix broken ui-react unit tests * refactor(project): add scss typings * refactor(project): remove obsolete ts rule * refactor(tests): make sure mode and dev are globally available * refactor(project): remove react and react-router-dom from common * refactor(project): replace react-query with tanstack/query-core in common * refactor(project): add location.search string failsafe * feat(player): show nice error message when media item is geo blocked Co-authored-by: Mike van Veenhuijzen * refactor(project): move icons to theme folder (#21) * refactor(project): add createURL util to replace all url utils * refactor(project): remove react-router from hooks-react package * refactor: allow custom integration registration via container chore: fix scss module import autocomplete in IDE chore: cleanup chore: rename calculate_intergration_type fix: jwp account service injection chore: fix unit tests refactor: move get integration type check # Conflicts: # packages/common/src/services/integrations/cleeng/CleengSubscriptionService.ts # packages/ui-react/src/containers/AdyenPaymentDetails/AdyenPaymentDetails.tsx # packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap * refactor(project): split url creation from location based * feat(accessibility): accessibility improvements and quick wins * feat(a11y): correct heading structure * refactor(a11y): modal id and a11y style selector * refactor(home): add home scss file * chore(a11y): add hidden h1 element to payment page * refactor(project): move accessibility import to main scss file * refactor(e2e): update tests after changes to header hierarchy * refactor(project): update styling import * refactor(tests): update account snapshot * feat(a11y): correct heading structure * refactor(a11y): modal id and a11y style selector * feat(a11y): add skiplink to header and update translations * refactor(project): add tabIndex prop and update translations * refactor(project): update import and move styling variable * refactor(project): add hidden h1 element to correct eelements * refactor(a11y): move hidden header element in dom structure * feat(a11y): link underlines, role attribute and page titles * feat(a11y): add an underline to links at all times * feat(a11y): add role attribute to dialogs * feat(a11y): add role attribute to dialogs * chore(a11y): apply aria-labelledby * chore(a11y): useOpaqueId for id * chore(a11y): improve role attribute value for test * chore: fix merge mistake --------- Co-authored-by: Mike van Veenhuijzen --------- Co-authored-by: Roy Schut Co-authored-by: Mike van Veenhuijzen * fix(a11y): alertdialog with aria-modal for alert component * fix(a11y): role=“alertdialog” for alert component with id fix * fix(a11y): add aria-modal to alert component --------- Co-authored-by: Mike van Veenhuijzen * fix(a11y): apply section to account and payment pages * fix(a11y): add prop arialabeledby to account sections * fix(tests): update useOpaqueId and snapshots * fix(a11y): update paymnents page sections with the correct prop --------- Co-authored-by: Rachid # Conflicts: # packages/ui-react/src/components/Payment/Payment.tsx * refactor(a11y): refactor language menu switch for accessibility compliance * fix(a11y): cards optimized for screen readers * fix(a11y): card optimized for screen readers * chore: update snapshots * chore: rewrite unit tests for image with empty alt * fix(e2e): rewrite aria-label with data-label for testing purposes --------- Co-authored-by: Mike van Veenhuijzen # Conflicts: # packages/ui-react/src/components/Card/Card.tsx # packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap * feat(i18n): reflect language switching in root html lang tag. Co-authored-by: Rachid * feat(a11y): make user menu keyboard accessible * fix: language menu button * fix: slow login and preventable errors * fix: load subscription after restoring SVOD session * fix(auth): set the correct loading state after logging the user in account-controller * fix: refresh entitlements on tvod purchase * chore: fix depcheck errors * fix: infinite loader when buying a subscription * docs(project): update documentation for workspaces * chore: rename web specific workflows * chore: pr feedback * chore: move build packages to configs directory * fix: epg service bindings --------- Co-authored-by: Roy Schut Co-authored-by: langemike Co-authored-by: Mike van Veenhuijzen Co-authored-by: Melissa Hart <48496458+MelissaDTH@users.noreply.github.com> Co-authored-by: Roy Schut Co-authored-by: Rachid Angelista <80318554+R-Cloud-Designs@users.noreply.github.com> Co-authored-by: Rachid --- .commitlintrc.js | 2 + .depcheckrc.yaml | 38 +- .eslintignore | 6 +- .eslintrc.js | 128 +- .../workflows/release-build-tag-release.yml | 3 +- .github/workflows/test-unit-snapshot.yml | 2 +- ...o.yml => web-release-deploy-prod-demo.yml} | 7 +- .../{test-e2e.yml => web-test-e2e.yml} | 8 +- ...ml => web-test-preview-and-lighthouse.yml} | 7 +- .gitignore | 15 - .syncpackrc.json | 39 + README.md | 1 + configs/eslint-config-jwp/.depcheckrc.yaml | 8 + .../eslint-config-jwp/lint-staged.config.js | 3 + configs/eslint-config-jwp/package.json | 17 + configs/eslint-config-jwp/react.js | 48 + configs/eslint-config-jwp/typescript.js | 94 + configs/postcss-config-jwp/.depcheckrc.yaml | 4 + .../postcss-config-jwp/index.js | 2 +- .../postcss-config-jwp/lint-staged.config.js | 3 + configs/postcss-config-jwp/package.json | 19 + configs/stylelint-config-jwp/.depcheckrc.yaml | 8 + .../stylelint-config-jwp/index.js | 6 +- .../lint-staged.config.js | 3 + configs/stylelint-config-jwp/package.json | 17 + docs/backend-services.md | 6 +- docs/build-from-source.md | 66 +- docs/content-types.md | 4 +- docs/developer-guidelines.md | 57 +- docs/e2e.md | 14 +- docs/easy-deployments.md | 4 +- docs/features/video-analytics.md | 4 +- docs/frameworks.md | 2 +- docs/initialization-file.md | 14 +- docs/modularization.md | 8 +- docs/translations.md | 2 +- docs/workspaces.md | 201 + i18next-parser.config.js | 5 +- lint-staged.config.js | 5 +- package.json | 139 +- packages/common/.depcheckrc.yaml | 6 + packages/common/.eslintrc.js | 3 + packages/common/lint-staged.config.js | 4 + packages/common/package.json | 41 + .../common/src/constants.ts | 5 +- packages/common/src/env.ts | 25 + .../common/src}/modules/container.ts | 16 +- .../functions/calculateIntegrationType.ts | 10 + .../modules/functions/getIntegrationType.ts | 11 + packages/common/src/modules/register.ts | 88 + packages/common/src/modules/types.ts | 5 + .../common/src/services/ApiService.ts | 73 +- packages/common/src/services/ConfigService.ts | 110 + .../common/src/services/EpgService.ts | 6 +- .../common/src/services/FavoriteService.ts | 24 +- .../src/services/GenericEntitlementService.ts | 3 +- .../src/services/JWPEntitlementService.ts | 0 .../common/src/services/SettingsService.ts | 41 +- .../common/src/services/StorageService.ts | 13 + .../src/services/WatchHistoryService.ts | 21 +- .../src/services/epg/JWEpgService.test.ts | 6 +- .../common/src/services/epg/JWEpgService.ts | 11 +- .../services/epg/ViewNexaEpgService.test.ts | 10 +- .../src/services/epg/ViewNexaEpgService.ts | 9 +- .../services/integrations/AccountService.ts | 33 +- .../services/integrations/CheckoutService.ts | 6 +- .../services/integrations/ProfileService.ts | 15 + .../integrations/SubscriptionService.ts | 8 +- .../cleeng/CleengAccountService.ts | 146 +- .../cleeng/CleengCheckoutService.ts | 164 + .../integrations/cleeng/CleengService.ts | 84 +- .../cleeng/CleengSubscriptionService.ts | 74 + .../integrations/jwp/JWPAccountService.ts | 101 +- .../integrations/jwp/JWPCheckoutService.ts | 19 +- .../integrations/jwp/JWPProfileService.ts | 32 +- .../jwp/JWPSubscriptionService.ts | 57 +- .../common/src}/stores/AccountController.ts | 247 +- .../common/src}/stores/AccountStore.ts | 9 +- packages/common/src/stores/AppController.ts | 120 + .../common/src}/stores/CheckoutController.ts | 258 +- .../common/src}/stores/CheckoutStore.ts | 7 +- .../common/src}/stores/ConfigStore.ts | 21 +- .../common/src}/stores/EpgController.test.ts | 12 +- .../common/src}/stores/EpgController.ts | 12 +- .../common/src}/stores/FavoritesController.ts | 29 +- .../common/src}/stores/FavoritesStore.ts | 8 +- .../common/src}/stores/ProfileController.ts | 76 +- .../common/src}/stores/ProfileStore.ts | 8 +- .../common/src}/stores/UIStore.ts | 6 +- .../src}/stores/WatchHistoryController.ts | 27 +- .../common/src}/stores/WatchHistoryStore.ts | 8 +- {src => packages/common/src}/stores/utils.ts | 2 +- packages/common/src/utils/ScreenMap.ts | 39 + .../common/src}/utils/analytics.ts | 11 +- {src => packages/common/src}/utils/api.ts | 6 +- .../common/src}/utils/collection.ts | 15 +- {src => packages/common/src}/utils/common.ts | 60 +- packages/common/src/utils/compare.ts | 3 + .../common/src}/utils/configSchema.ts | 4 +- .../common/src}/utils/datetime.ts | 0 .../common/src}/utils/entitlements.ts | 11 +- {src => packages/common/src}/utils/epg.ts | 2 +- {src => packages/common/src}/utils/error.ts | 0 .../common/src}/utils/formatting.ts | 93 +- packages/common/src/utils/i18n.ts | 13 + .../common/src}/utils/liveEvent.ts | 2 +- {src => packages/common/src}/utils/media.ts | 4 +- .../common/src}/utils/promiseQueue.test.ts | 0 .../common/src}/utils/promiseQueue.ts | 0 {src => packages/common/src}/utils/series.ts | 2 +- .../common/src}/utils/structuredData.ts | 28 +- .../common/src}/utils/subscription.ts | 5 +- .../common/src/utils/urlFormatting.test.ts | 25 + packages/common/src/utils/urlFormatting.ts | 84 + .../common/src}/utils/yupSchemaCreator.ts | 2 +- packages/common/test/mockService.ts | 50 + packages/common/tsconfig.json | 22 + {types => packages/common/types}/account.d.ts | 91 +- .../common/types}/ad-schedule.d.ts | 0 {types => packages/common/types}/adyen.d.ts | 0 .../types/calculate-integration-type.ts | 3 + .../common/types}/checkout.d.ts | 15 +- {types => packages/common/types}/cleeng.d.ts | 2 +- .../common/types/config.d.ts | 2 +- packages/common/types/entitlement.d.ts | 6 + {types => packages/common/types}/epg.d.ts | 4 - .../common/types}/favorite.d.ts | 0 {types => packages/common/types}/form.d.ts | 0 packages/common/types/get-customer-ip.d.ts | 1 + {types => packages/common/types}/global.d.ts | 2 - packages/common/types/i18n.d.ts | 4 + {types => packages/common/types}/i18next.d.ts | 0 .../common/types}/inplayer.d.ts | 2 - .../common/types}/jwplayer.d.ts | 0 {types => packages/common/types}/jwpltx.d.ts | 0 {types => packages/common/types}/media.d.ts | 0 .../common/types}/pagination.d.ts | 0 .../common/types}/playlist.d.ts | 2 +- .../common/types/profiles.d.ts | 2 +- {types => packages/common/types}/series.d.ts | 0 packages/common/types/service.d.ts | 9 + .../common/types}/settings.d.ts | 0 packages/common/types/static.d.ts | 18 + .../common/types}/subscription.d.ts | 6 +- packages/common/types/testing.d.ts | 4 + .../common/types}/watchHistory.d.ts | 0 packages/common/vitest.config.ts | 14 + packages/common/vitest.setup.ts | 28 + packages/hooks-react/.depcheckrc.yaml | 4 + packages/hooks-react/.eslintrc.js | 3 + packages/hooks-react/lint-staged.config.js | 4 + packages/hooks-react/package.json | 33 + .../hooks-react/src}/series/useEpisodes.ts | 11 +- .../hooks-react/src}/series/useNextEpisode.ts | 9 +- .../hooks-react/src}/series/useSeries.ts | 13 +- .../src}/series/useSeriesLookup.ts | 7 +- packages/hooks-react/src/testUtils.tsx | 21 + .../hooks-react/src}/useAds.ts | 11 +- packages/hooks-react/src/useBootstrapApp.ts | 44 + .../hooks-react/src}/useCheckAccess.ts | 24 +- .../hooks-react/src}/useContentProtection.ts | 18 +- .../hooks-react/src}/useCountdown.ts | 0 .../hooks-react/src}/useDebounce.ts | 5 +- .../hooks-react/src}/useEntitlement.ts | 23 +- .../hooks-react/src}/useEventCallback.ts | 0 .../hooks-react/src}/useFirstRender.ts | 0 .../hooks-react/src}/useForm.ts | 5 +- .../hooks-react/src}/useLiveChannels.test.ts | 127 +- .../hooks-react/src}/useLiveChannels.ts | 13 +- packages/hooks-react/src/useLiveEvent.ts | 9 + .../hooks-react/src}/useLiveProgram.test.ts | 6 +- .../hooks-react/src}/useLiveProgram.ts | 5 +- .../hooks-react/src}/useMedia.ts | 11 +- .../hooks-react/src}/useOffers.ts | 26 +- .../hooks-react/src}/useOpaqueId.ts | 9 +- .../hooks-react/src}/useOttAnalytics.ts | 10 +- .../hooks-react/src}/usePlanByEpg.test.ts | 6 +- .../hooks-react/src}/usePlanByEpg.ts | 5 +- .../hooks-react/src}/usePlaylist.ts | 19 +- .../src}/usePlaylistItemCallback.ts | 9 +- .../hooks-react/src}/useProfiles.ts | 80 +- packages/hooks-react/src/useProtectedMedia.ts | 27 + .../hooks-react/src/useSocialLoginUrls.ts | 20 + .../hooks-react/src}/useSubscriptionChange.ts | 11 +- .../hooks-react/src}/useToggle.ts | 0 .../hooks-react/src}/useWatchHistory.ts | 12 +- .../src}/useWatchHistoryListener.ts | 12 +- packages/hooks-react/tsconfig.json | 16 + packages/hooks-react/vitest.config.ts | 14 + packages/hooks-react/vitest.setup.ts | 2 + packages/i18n/.depcheckrc.yaml | 1 + packages/i18n/README.md | 19 + packages/i18n/package.json | 9 + {test => packages/testing}/constants.ts | 7 +- {test => packages/testing}/epg/channel1.json | 0 {test => packages/testing}/epg/channel2.json | 0 {test => packages/testing}/epg/channel4.json | 0 {test => packages/testing}/epg/jwChannel.json | 0 .../testing}/epg/viewNexaChannel.xml | 0 .../testing}/fixtures/config.json | 0 .../testing}/fixtures/customer.json | 0 .../testing}/fixtures/epgChannels.json | 0 .../testing}/fixtures/epgChannelsUpdate.json | 0 .../testing}/fixtures/livePlaylist.json | 0 .../testing}/fixtures/monthlyOffer.json | 0 .../testing}/fixtures/order.json | 0 .../testing}/fixtures/paymentDetail.json | 0 .../testing}/fixtures/playlist.json | 0 .../testing}/fixtures/schedule.json | 0 .../testing}/fixtures/subscription.json | 0 .../testing}/fixtures/transactions.json | 0 .../testing}/fixtures/tvodOffer.json | 0 .../testing}/fixtures/yearlyOffer.json | 0 packages/testing/lint-staged.config.js | 4 + packages/testing/package.json | 10 + packages/testing/tsconfig.json | 6 + packages/theme/.depcheckrc.yaml | 1 + .../theme/assets/icons/account_circle.svg | 3 + packages/theme/assets/icons/arrow_left.svg | 1 + .../theme/assets/icons/balance_wallet.svg | 3 + .../theme/assets/icons/cancel.svg | 11 +- packages/theme/assets/icons/check.svg | 3 + packages/theme/assets/icons/check_circle.svg | 3 + packages/theme/assets/icons/chevron_left.svg | 3 + packages/theme/assets/icons/chevron_right.svg | 3 + packages/theme/assets/icons/close.svg | 4 + packages/theme/assets/icons/creditcard.svg | 3 + packages/theme/assets/icons/edit.svg | 6 + packages/theme/assets/icons/exit.svg | 3 + packages/theme/assets/icons/external_link.svg | 3 + .../theme}/assets/icons/facebook.svg | 0 packages/theme/assets/icons/favorite.svg | 3 + .../theme/assets/icons/favorite_border.svg | 3 + .../theme}/assets/icons/google.svg | 0 .../theme/assets/icons/language.svg | 11 +- .../theme/assets/icons/lock.svg | 11 +- packages/theme/assets/icons/menu.svg | 4 + packages/theme/assets/icons/paypal.svg | 3 + packages/theme/assets/icons/play.svg | 3 + packages/theme/assets/icons/play_trailer.svg | 3 + .../theme/assets/icons/plus.svg | 11 +- packages/theme/assets/icons/search.svg | 3 + packages/theme/assets/icons/share.svg | 3 + packages/theme/assets/icons/today.svg | 4 + .../theme}/assets/icons/twitter.svg | 0 packages/theme/assets/icons/visibility.svg | 3 + .../theme/assets/icons/visibility_off.svg | 3 + .../theme}/assets/profiles/default_avatar.png | Bin packages/theme/package.json | 9 + packages/ui-react/.depcheckrc.yaml | 6 + packages/ui-react/.eslintrc.js | 3 + packages/ui-react/lint-staged.config.js | 5 + packages/ui-react/package.json | 56 + packages/ui-react/postcss.config.js | 1 + .../components/Account/Account.module.scss | 4 +- .../src/components/Account/Account.test.tsx | 30 + .../src}/components/Account/Account.tsx | 54 +- .../__snapshots__/Account.test.tsx.snap | 57 +- .../src/components/Adyen/Adyen.module.scss | 10 + .../ui-react/src}/components/Adyen/Adyen.tsx | 6 +- .../src}/components/Adyen/AdyenForm.scss | 6 +- .../src}/components/Alert/Alert.module.scss | 4 +- .../src}/components/Alert/Alert.test.tsx | 0 .../ui-react/src}/components/Alert/Alert.tsx | 14 +- .../Alert/__snapshots__/Alert.test.tsx.snap | 0 .../src}/components/Animation/Animation.tsx | 2 +- .../src}/components/Animation/Fade/Fade.tsx | 4 +- .../src}/components/Animation/Grow/Grow.tsx | 4 +- .../src}/components/Animation/Slide/Slide.tsx | 4 +- .../BackButton/BackButton.module.scss | 4 +- .../src}/components/BackButton/BackButton.tsx | 5 +- .../src}/components/Button/Button.module.scss | 6 +- .../src}/components/Button/Button.test.tsx | 0 .../src}/components/Button/Button.tsx | 8 +- .../Button/__snapshots__/Button.test.tsx.snap | 0 .../CancelSubscriptionForm.module.scss | 4 +- .../CancelSubscriptionForm.test.tsx | 0 .../CancelSubscriptionForm.tsx | 6 +- .../CancelSubscriptionForm.test.tsx.snap | 0 .../src}/components/Card/Card.module.scss | 8 +- .../src}/components/Card/Card.test.tsx | 17 +- .../ui-react/src}/components/Card/Card.tsx | 57 +- .../Card/__snapshots__/Card.test.tsx.snap | 27 +- .../components/CardGrid/CardGrid.module.scss | 0 .../components/CardGrid/CardGrid.test.tsx | 8 +- .../src}/components/CardGrid/CardGrid.tsx | 18 +- .../__snapshots__/CardGrid.test.tsx.snap | 63 +- .../components/Checkbox/Checkbox.module.scss | 10 +- .../components/Checkbox/Checkbox.test.tsx | 0 .../src}/components/Checkbox/Checkbox.tsx | 6 +- .../__snapshots__/Checkbox.test.tsx.snap | 0 .../CheckoutForm/CheckoutForm.module.scss | 4 +- .../CheckoutForm/CheckoutForm.test.tsx | 7 +- .../components/CheckoutForm/CheckoutForm.tsx | 33 +- .../__snapshots__/CheckoutForm.test.tsx.snap | 15 +- .../ChooseOfferForm.module.scss | 6 +- .../ChooseOfferForm/ChooseOfferForm.test.tsx | 9 +- .../ChooseOfferForm/ChooseOfferForm.tsx | 37 +- .../ChooseOfferForm.test.tsx.snap | 105 +- .../CollapsibleText.module.scss | 4 +- .../CollapsibleText/CollapsibleText.test.tsx | 0 .../CollapsibleText/CollapsibleText.tsx | 13 +- .../CollapsibleText.test.tsx.snap | 0 .../ConfirmationDialog.module.scss | 4 +- .../ConfirmationDialog.test.tsx | 0 .../ConfirmationDialog/ConfirmationDialog.tsx | 12 +- .../ConfirmationDialog.test.tsx.snap | 0 .../ConfirmationForm.module.scss | 8 +- .../ConfirmationForm.test.tsx | 4 +- .../ConfirmationForm/ConfirmationForm.tsx | 8 +- .../ConfirmationForm.test.tsx.snap | 0 .../CreditCardCVCField.test.tsx | 0 .../CreditCardCVCField/CreditCardCVCField.tsx | 0 .../CreditCardCVCField.test.tsx.snap | 4 +- .../CreditCardExpiryField.test.tsx | 0 .../CreditCardExpiryField.tsx | 0 .../CreditCardExpiryField.test.tsx.snap | 4 +- .../CreditCardNumberField.module.scss | 0 .../CreditCardNumberField.test.tsx | 0 .../CreditCardNumberField.tsx | 0 .../CreditCardNumberField.test.tsx.snap | 4 +- .../CustomRegisterField.test.tsx | 2 +- .../CustomRegisterField.tsx | 24 +- .../CustomRegisterField.test.tsx.snap | 283 + .../DateField/DateField.module.scss | 4 +- .../src}/components/DateField/DateField.tsx | 8 +- .../DeleteAccountModal.module.scss | 2 +- .../DeleteAccountModal/DeleteAccountModal.tsx | 25 +- .../DeleteAccountPasswordWarning.module.scss | 4 +- .../DeleteAccountPasswordWarning.tsx | 17 +- .../DetectOutsideClick/DetectOutsideClick.tsx | 0 .../DevConfigSelector.module.scss | 0 .../DevConfigSelector/DevConfigSelector.tsx | 16 +- .../DevStackTrace/DevStackTrace.module.scss | 0 .../DevStackTrace/DevStackTrace.tsx | 0 .../src}/components/Dialog/Dialog.module.scss | 4 +- .../src}/components/Dialog/Dialog.test.tsx | 2 +- .../src}/components/Dialog/Dialog.tsx | 18 +- .../Dialog/__snapshots__/Dialog.test.tsx.snap | 20 +- .../DialogBackButton.module.scss | 4 +- .../DialogBackButton.test.tsx | 0 .../DialogBackButton/DialogBackButton.tsx | 9 +- .../DialogBackButton.test.tsx.snap | 11 +- .../components/Dropdown/Dropdown.module.scss | 6 +- .../components/Dropdown/Dropdown.test.tsx | 1 + .../src}/components/Dropdown/Dropdown.tsx | 6 +- .../EditCardPaymentForm.module.scss | 0 .../EditCardPaymentForm.tsx | 11 +- .../EditForm/EditCardDetailsForm.module.scss | 4 +- .../EditForm/EditCardDetailsForm.tsx | 3 +- .../EditPasswordForm.module.scss | 6 +- .../EditPasswordForm.test.tsx | 0 .../EditPasswordForm/EditPasswordForm.tsx | 13 +- .../EditPasswordForm.test.tsx.snap | 26 +- .../src}/components/Epg/Epg.module.scss | 6 +- .../ui-react/src}/components/Epg/Epg.tsx | 31 +- .../EpgChannel/EpgChannelItem.module.scss | 6 +- .../components/EpgChannel/EpgChannelItem.tsx | 6 +- .../EpgProgramItem/EpgProgramItem.module.scss | 6 +- .../EpgProgramItem/EpgProgramItem.tsx | 7 +- .../EpgTimeline/EpgTimeline.module.scss | 4 +- .../components/EpgTimeline/EpgTimeline.tsx | 0 .../ErrorPage/ErrorPage.module.scss | 6 +- .../components/ErrorPage/ErrorPage.test.tsx | 0 .../src}/components/ErrorPage/ErrorPage.tsx | 10 +- .../__snapshots__/ErrorPage.test.tsx.snap | 0 .../Favorites/Favorites.module.scss | 8 +- .../components/Favorites/Favorites.test.tsx | 29 +- .../src}/components/Favorites/Favorites.tsx | 20 +- .../__snapshots__/Favorites.test.tsx.snap | 0 .../src}/components/Filter/Filter.module.scss | 6 +- .../src}/components/Filter/Filter.test.tsx | 0 .../src}/components/Filter/Filter.tsx | 10 +- .../Filter/__snapshots__/Filter.test.tsx.snap | 0 .../FinalizePayment.module.scss | 0 .../FinalizePayment/FinalizePayment.tsx | 24 +- .../ForgotPasswordForm.module.scss | 4 +- .../ForgotPasswordForm.test.tsx | 0 .../ForgotPasswordForm/ForgotPasswordForm.tsx | 14 +- .../ForgotPasswordForm.test.tsx.snap | 0 .../src}/components/Form/Form.module.scss | 6 +- .../src}/components/Form/Form.test.tsx | 0 .../ui-react/src}/components/Form/Form.tsx | 5 +- .../src}/components/Form/FormSection.tsx | 20 +- .../Form/__snapshots__/Form.test.tsx.snap | 12 +- .../FormFeedback/FormFeedback.module.scss | 4 +- .../FormFeedback/FormFeedback.test.tsx | 0 .../components/FormFeedback/FormFeedback.tsx | 0 .../__snapshots__/FormFeedback.test.tsx.snap | 0 .../src}/components/Header/Header.module.scss | 37 +- .../src}/components/Header/Header.test.tsx | 26 +- .../src}/components/Header/Header.tsx | 95 +- .../Header/__snapshots__/Header.test.tsx.snap | 36 +- .../HelperText/HelperText.module.scss | 4 +- .../components/HelperText/HelperText.test.tsx | 0 .../src}/components/HelperText/HelperText.tsx | 0 .../__snapshots__/HelperText.test.tsx.snap | 0 .../src}/components/Hero/Hero.module.scss | 8 +- .../ui-react/src}/components/Hero/Hero.tsx | 4 +- .../src/components/Icon}/Icon.module.scss | 4 +- .../src/components/Icon/Icon.test.tsx | 12 + .../ui-react/src/components/Icon/Icon.tsx | 15 + .../Icon/__snapshots__/Icon.test.tsx.snap | 3 + .../IconButton/IconButton.module.scss | 4 +- .../components/IconButton/IconButton.test.tsx | 3 +- .../src}/components/IconButton/IconButton.tsx | 10 +- .../__snapshots__/IconButton.test.tsx.snap | 19 +- .../src}/components/Image/Image.module.scss | 0 .../src}/components/Image/Image.test.tsx | 0 .../ui-react/src}/components/Image/Image.tsx | 5 +- .../InfiniteScrollLoader.module.scss | 0 .../InfiniteScrollLoader.tsx | 4 +- .../LanguageMenu/LanguageMenu.module.scss | 8 +- .../LanguageMenu/LanguageMenu.test.tsx | 53 + .../components/LanguageMenu/LanguageMenu.tsx | 78 + .../__snapshots__/LanguageMenu.test.tsx.snap | 27 + .../__snapshots__/UserMenu.test.tsx.snap | 0 .../src}/components/Link/Link.module.scss | 11 +- .../src}/components/Link/Link.test.tsx | 0 .../ui-react/src}/components/Link/Link.tsx | 6 +- .../Link/__snapshots__/Link.test.tsx.snap | 0 .../LoadingOverlay/LoadingOverlay.module.scss | 4 +- .../LoadingOverlay/LoadingOverlay.tsx | 4 +- .../LoginForm/LoginForm.module.scss | 4 +- .../components/LoginForm/LoginForm.test.tsx | 48 +- .../src}/components/LoginForm/LoginForm.tsx | 50 +- .../__snapshots__/LoginForm.test.tsx.snap | 11 +- .../src}/components/Logo/Logo.module.scss | 6 +- .../src}/components/Logo/Logo.test.tsx | 4 +- .../ui-react/src}/components/Logo/Logo.tsx | 0 .../Logo/__snapshots__/Logo.test.tsx.snap | 0 .../MarkdownComponent.module.scss | 10 +- .../MarkdownComponent.test.tsx | 0 .../MarkdownComponent/MarkdownComponent.tsx | 0 .../MarkdownComponent.test.tsx.snap | 0 .../MenuButton/MenuButton.module.scss | 11 +- .../components/MenuButton/MenuButton.test.tsx | 0 .../src}/components/MenuButton/MenuButton.tsx | 19 +- .../__snapshots__/MenuButton.test.tsx.snap | 0 .../src}/components/Modal/Modal.module.scss | 6 +- .../src}/components/Modal/Modal.test.tsx | 0 .../ui-react/src}/components/Modal/Modal.tsx | 15 +- .../Modal/__snapshots__/Modal.test.tsx.snap | 0 .../ModalCloseButton.module.scss | 6 +- .../ModalCloseButton.test.tsx | 0 .../ModalCloseButton/ModalCloseButton.tsx | 9 +- .../ModalCloseButton.test.tsx.snap | 19 +- .../NoPaymentRequired.module.scss | 4 +- .../NoPaymentRequired.test.tsx | 0 .../NoPaymentRequired/NoPaymentRequired.tsx | 6 +- .../NoPaymentRequired.test.tsx.snap | 0 .../OfferSwitch/OfferSwitch.module.scss | 4 +- .../components/OfferSwitch/OfferSwitch.tsx | 8 +- .../src}/components/Panel/Panel.module.scss | 4 +- .../ui-react/src/components/Panel/Panel.tsx | 18 + .../PasswordField/PasswordField.test.tsx | 0 .../PasswordField/PasswordField.tsx | 10 +- .../__snapshots__/PasswordField.test.tsx.snap | 11 +- .../PasswordStrength.module.scss | 4 +- .../PasswordStrength.test.tsx | 0 .../PasswordStrength/PasswordStrength.tsx | 0 .../PasswordStrength.test.tsx.snap | 0 .../src}/components/PayPal/PayPal.module.scss | 4 +- .../src}/components/PayPal/PayPal.test.tsx | 0 .../src}/components/PayPal/PayPal.tsx | 6 +- .../PayPal/__snapshots__/PayPal.test.tsx.snap | 0 .../components/Payment/Payment.module.scss | 7 +- .../src}/components/Payment/Payment.test.tsx | 16 +- .../src}/components/Payment/Payment.tsx | 69 +- .../__snapshots__/Payment.test.tsx.snap | 29 +- .../PaymentFailed/PaymentFailed.module.scss | 4 +- .../PaymentFailed/PaymentFailed.test.tsx | 0 .../PaymentFailed/PaymentFailed.tsx | 6 +- .../__snapshots__/PaymentFailed.test.tsx.snap | 0 .../PaymentForm/PaymentForm.module.scss | 0 .../components/PaymentForm/PaymentForm.tsx | 26 +- .../PaymentMethodForm.module.scss | 6 +- .../PaymentMethodForm/PaymentMethodForm.tsx | 18 +- .../PersonalDetailsForm.module.scss | 4 +- .../PersonalDetailsForm.test.tsx | 2 +- .../PersonalDetailsForm.tsx | 24 +- .../PersonalDetailsForm.test.tsx.snap | 64 +- .../src}/components/Player/Player.module.scss | 0 .../src}/components/Player/Player.test.tsx | 6 +- .../src}/components/Player/Player.tsx | 28 +- .../Player/__snapshots__/Player.test.tsx.snap | 0 .../PlayerError/PlayerError.module.scss | 23 + .../PlayerError/PlayerError.test.tsx | 12 + .../components/PlayerError/PlayerError.tsx | 24 + .../__snapshots__/PlayerError.test.tsx.snap | 18 + .../components/Popover/Popover.module.scss | 4 +- .../src}/components/Popover/Popover.test.tsx | 6 +- .../src}/components/Popover/Popover.tsx | 8 +- .../__snapshots__/Popover.test.tsx.snap | 0 .../components/ProfileBox/AddNewProfile.tsx | 7 +- .../ProfileBox/ProfileBox.module.scss | 2 +- .../src}/components/ProfileBox/ProfileBox.tsx | 13 +- .../ProfileCircle/ProfileCircle.module.scss | 5 + .../ProfileCircle/ProfileCircle.test.tsx | 13 + .../ProfileCircle}/ProfileCircle.tsx | 2 +- .../__snapshots__/ProfileCircle.test.tsx.snap | 11 + .../src}/components/Radio/Radio.module.scss | 4 +- .../src}/components/Radio/Radio.test.tsx | 0 .../ui-react/src}/components/Radio/Radio.tsx | 6 +- .../Radio/__snapshots__/Radio.test.tsx.snap | 0 .../RegistrationForm.module.scss | 10 +- .../RegistrationForm.test.tsx | 4 +- .../RegistrationForm/RegistrationForm.tsx | 41 +- .../RegistrationForm.test.tsx.snap | 11 +- .../RenewSubscriptionForm.module.scss | 4 +- .../RenewSubscriptionForm.test.tsx | 9 +- .../RenewSubscriptionForm.tsx | 12 +- .../RenewSubscriptionForm.test.tsx.snap | 0 .../ResetPasswordForm.module.scss | 4 +- .../ResetPasswordForm.test.tsx | 0 .../ResetPasswordForm/ResetPasswordForm.tsx | 10 +- .../ResetPasswordForm.test.tsx.snap | 10 +- .../RootErrorPage/RootErrorPage.tsx | 2 +- .../SearchBar/SearchBar.module.scss | 4 +- .../components/SearchBar/SearchBar.test.tsx | 2 +- .../src}/components/SearchBar/SearchBar.tsx | 13 +- .../__snapshots__/SearchBar.test.tsx.snap | 24 +- .../ShareButton/ShareButton.test.tsx | 0 .../components/ShareButton/ShareButton.tsx | 13 +- .../__snapshots__/ShareButton.test.tsx.snap | 27 + .../src}/components/Shelf/Shelf.module.scss | 0 .../src}/components/Shelf/Shelf.test.tsx | 5 +- .../ui-react/src}/components/Shelf/Shelf.tsx | 29 +- .../Shelf/__snapshots__/Shelf.test.tsx.snap | 125 +- .../components/Sidebar/Sidebar.module.scss | 6 +- .../src}/components/Sidebar/Sidebar.test.tsx | 6 +- .../src}/components/Sidebar/Sidebar.tsx | 13 +- .../__snapshots__/Sidebar.test.tsx.snap | 19 +- .../SocialButton/SocialButton.module.scss | 4 +- .../components/SocialButton/SocialButton.tsx | 22 +- .../SocialButtonsList.module.scss | 0 .../SocialButtonsList/SocialButtonsList.tsx | 21 + .../components/Spinner/Spinner.module.scss | 4 +- .../src}/components/Spinner/Spinner.tsx | 0 .../src}/components/StatusIcon/StatusIcon.tsx | 9 +- .../SubscriptionCancelled.module.scss | 4 +- .../SubscriptionCancelled.test.tsx | 0 .../SubscriptionCancelled.tsx | 4 +- .../SubscriptionCancelled.test.tsx.snap | 0 .../SubscriptionRenewed.module.scss | 4 +- .../SubscriptionRenewed.test.tsx | 9 +- .../SubscriptionRenewed.tsx | 10 +- .../SubscriptionRenewed.test.tsx.snap | 0 .../src}/components/Tag/Tag.module.scss | 6 +- .../ui-react/src}/components/Tag/Tag.tsx | 2 +- .../TextField/TextField.module.scss | 4 +- .../components/TextField/TextField.test.tsx | 0 .../src}/components/TextField/TextField.tsx | 10 +- .../__snapshots__/TextField.test.tsx.snap | 0 .../components/TileDock/TileDock.module.scss | 2 +- .../src}/components/TileDock/TileDock.tsx | 0 .../UpgradeSubscription.module.scss | 4 +- .../UpgradeSubscription.tsx | 4 +- .../UserMenu/ProfilesMenu/ProfilesMenu.tsx | 14 +- .../components/UserMenu/UserMenu.module.scss | 4 +- .../src/components/UserMenu/UserMenu.test.tsx | 21 + .../src}/components/UserMenu/UserMenu.tsx | 48 +- .../__snapshots__/UserMenu.test.tsx.snap | 33 +- .../VideoDetails/VideoDetails.module.scss | 10 +- .../VideoDetails/VideoDetails.test.tsx | 0 .../components/VideoDetails/VideoDetails.tsx | 12 +- .../__snapshots__/VideoDetails.test.tsx.snap | 4 +- .../VideoDetailsInline.module.scss | 8 +- .../VideoDetailsInline/VideoDetailsInline.tsx | 10 +- .../VideoLayout/VideoLayout.module.scss | 6 +- .../components/VideoLayout/VideoLayout.tsx | 24 +- .../VideoList/VideoList.module.scss | 8 +- .../src}/components/VideoList/VideoList.tsx | 14 +- .../VideoListItem/VideoListItem.module.scss | 8 +- .../VideoListItem/VideoListItem.tsx | 23 +- .../WaitingForPayment.module.scss | 0 .../WaitingForPayment/WaitingForPayment.tsx | 9 +- .../components/Welcome/Welcome.module.scss | 4 +- .../src}/components/Welcome/Welcome.test.tsx | 0 .../src}/components/Welcome/Welcome.tsx | 6 +- .../__snapshots__/Welcome.test.tsx.snap | 0 .../AccountModal/AccountModal.module.scss | 10 + .../AccountModal/AccountModal.test.tsx | 20 + .../containers/AccountModal/AccountModal.tsx | 93 +- .../__snapshots__/AccountModal.test.tsx.snap | 0 .../AccountModal/forms/CancelSubscription.tsx | 18 +- .../AccountModal/forms/Checkout.tsx | 51 +- .../AccountModal/forms/ChooseOffer.tsx | 41 +- .../AccountModal/forms/EditCardDetails.tsx | 8 +- .../AccountModal/forms/EditPassword.tsx | 20 +- .../containers/AccountModal/forms/Login.tsx | 28 +- .../AccountModal/forms/PersonalDetails.tsx | 24 +- .../AccountModal/forms/Registration.tsx | 26 +- .../AccountModal/forms/RenewSubscription.tsx | 18 +- .../AccountModal/forms/ResetPassword.tsx | 32 +- .../AdyenInitialPayment.tsx | 22 +- .../AdyenPaymentDetails.tsx | 33 +- .../src}/containers/Cinema/Cinema.module.scss | 8 +- .../src}/containers/Cinema/Cinema.test.tsx | 34 +- .../src}/containers/Cinema/Cinema.tsx | 17 +- .../Cinema/__snapshots__/Cinema.test.tsx.snap | 15 +- .../FavoriteButton/FavoriteButton.tsx | 23 +- .../InlinePlayer/InlinePlayer.module.scss | 7 +- .../containers/InlinePlayer/InlinePlayer.tsx | 23 +- .../src}/containers/Layout/Layout.module.scss | 8 +- .../src/containers/Layout/Layout.test.tsx | 22 + .../src}/containers/Layout/Layout.tsx | 81 +- .../Layout/__snapshots__/Layout.test.tsx.snap | 49 +- .../PaymentContainer/PaymentContainer.tsx | 77 +- .../PlayerContainer.module.scss | 15 + .../PlayerContainer/PlayerContainer.tsx | 23 +- .../PlaylistContainer/PlaylistContainer.tsx | 13 +- .../containers/Profiles/CreateProfile.tsx | 25 +- .../containers/Profiles/DeleteProfile.tsx | 29 +- .../src}/containers/Profiles/EditProfile.tsx | 32 +- .../src}/containers/Profiles/Form.tsx | 30 +- .../containers/Profiles/Profiles.module.scss | 2 +- .../src}/containers/Profiles/Profiles.tsx | 26 +- .../src}/containers/Profiles/avatarUrls.json | 0 .../QueryProvider/QueryProvider.tsx | 0 .../ShelfList/ShelfList.module.scss | 6 +- .../src}/containers/ShelfList/ShelfList.tsx | 26 +- .../StartWatchingButton.module.scss | 6 +- .../StartWatchingButton.tsx | 27 +- .../TrailerModal/TrailerModal.module.scss | 8 +- .../containers/TrailerModal/TrailerModal.tsx | 14 +- .../UpdatePaymentMethod.tsx | 40 +- .../ui-react/src}/hooks/useBreakpoint.ts | 4 +- packages/ui-react/src/hooks/useQueryParam.ts | 9 + .../src}/hooks/useSearchQueryUpdater.ts | 6 +- packages/ui-react/src/index.ts | 1 + .../src}/pages/About/About.module.scss | 4 +- .../ui-react/src}/pages/About/About.test.tsx | 0 .../ui-react/src}/pages/About/About.tsx | 4 +- .../About/__snapshots__/About.test.tsx.snap | 0 .../ui-react/src}/pages/Home/Home.test.tsx | 10 +- packages/ui-react/src/pages/Home/Home.tsx | 24 + .../Home/__snapshots__/Home.test.tsx.snap | 161 +- .../src}/pages/LegacySeries/LegacySeries.tsx | 56 +- .../ui-react/src}/pages/LegacySeries/utils.ts | 9 +- .../src}/pages/Loading/Loading.module.scss | 0 .../ui-react/src}/pages/Loading/Loading.tsx | 4 +- .../pages/ScreenRouting/MediaScreenRouter.tsx | 27 +- .../ScreenRouting/PlaylistScreenRouter.tsx | 20 +- .../MediaEpisode/MediaEpisode.tsx | 12 +- .../mediaScreens/MediaEvent/MediaEvent.tsx | 58 +- .../mediaScreens/MediaHub/MediaHub.tsx | 10 +- .../MediaLiveChannel/MediaLiveChannel.tsx | 12 +- .../mediaScreens/MediaMovie/MediaMovie.tsx | 52 +- .../mediaScreens/MediaSeries/MediaSeries.tsx | 72 +- .../MediaStaticPage.module.scss | 4 +- .../MediaStaticPage/MediaStaticPage.tsx | 14 +- .../PlaylistGrid/PlaylistGrid.module.scss | 8 +- .../PlaylistGrid/PlaylistGrid.tsx | 22 +- .../PlaylistLiveChannels.module.scss | 4 +- .../PlaylistLiveChannels.tsx | 49 +- .../src}/pages/Search/Search.module.scss | 11 +- .../ui-react/src}/pages/Search/Search.tsx | 28 +- .../ui-react/src}/pages/User/User.module.scss | 8 +- .../ui-react/src}/pages/User/User.test.tsx | 87 +- .../ui-react/src}/pages/User/User.tsx | 57 +- .../User/__snapshots__/User.test.tsx.snap | 282 +- .../ui-react/src}/styles/_theme.scss | 0 .../ui-react/src}/styles/_variables.scss | 0 .../ui-react/src/styles/accessibility.scss | 38 + .../src}/styles/mixins/_responsive.scss | 2 +- .../src}/styles/mixins/_typography.scss | 4 +- .../ui-react/src}/styles/mixins/_utils.scss | 2 +- packages/ui-react/src/utils/clipboard.ts | 12 + {src => packages/ui-react/src}/utils/dom.ts | 27 - packages/ui-react/src/utils/location.test.ts | 118 + packages/ui-react/src/utils/location.ts | 12 + .../ui-react/src}/utils/matchMedia.ts | 0 packages/ui-react/src/utils/theming.ts | 22 + packages/ui-react/stylelint.config.js | 3 + .../ui-react/test/utils.tsx | 6 +- packages/ui-react/tsconfig.json | 34 + packages/ui-react/types/env.d.ts | 1 + .../ui-react/types}/screens.d.ts | 0 packages/ui-react/types/static.d.ts | 59 + packages/ui-react/vitest.config.ts | 17 + packages/ui-react/vitest.setup.ts | 56 + platforms/web/.depcheckrc.yaml | 27 + .env => platforms/web/.env | 3 + .env.jwdev => platforms/web/.env.jwdev | 0 platforms/web/.eslintrc.js | 3 + .firebaserc => platforms/web/.firebaserc | 0 platforms/web/.gitignore | 19 + firebase.json => platforms/web/firebase.json | 0 index.html => platforms/web/index.html | 0 .../web/ini}/.webapp.dev.ini | 0 .../web/ini}/.webapp.test.ini | 0 .../web/ini}/templates/.webapp.demo.ini | 0 platforms/web/ini/templates/.webapp.dev.ini | 4 + .../web/ini}/templates/.webapp.jwdev.ini | 0 .../web/ini}/templates/.webapp.preview.ini | 0 .../web/ini}/templates/.webapp.prod.ini | 0 platforms/web/ini/templates/.webapp.test.ini | 9 + .../web/lighthouserc.js | 0 platforms/web/lint-staged.config.js | 5 + platforms/web/package.json | 94 + platforms/web/postcss.config.js | 1 + .../web/public}/browserconfig.xml | 0 {public => platforms/web/public}/favicon.ico | Bin .../public}/images/android-chrome-144x144.png | Bin .../web/public}/images/apple-touch-icon.png | Bin .../web/public}/images/avatars/Alien.svg | 0 .../web/public}/images/avatars/Bear.svg | 0 .../web/public}/images/avatars/Brainy.svg | 0 .../web/public}/images/avatars/Cooool.svg | 0 .../web/public}/images/avatars/Dummy.svg | 0 .../web/public}/images/avatars/Frog.svg | 0 .../web/public}/images/avatars/Goofball.svg | 0 .../web/public}/images/avatars/Marilyn.svg | 0 .../web/public}/images/avatars/Smiley.svg | 0 .../web/public}/images/avatars/ToughGuy.svg | 0 .../web/public}/images/avatars/UhOh.svg | 0 .../web/public}/images/avatars/Vibe.svg | 0 .../web/public}/images/favicon-16x16.png | Bin .../web/public}/images/favicon-32x32.png | Bin .../web/public}/images/logo.png | Bin .../web/public}/images/logo.svg | 0 .../web/public}/images/mstile-150x150.png | Bin .../web/public}/images/payments/amex.svg | 0 .../web/public}/images/payments/diners.svg | 0 .../web/public}/images/payments/discover.svg | 0 .../web/public}/images/payments/hiper.svg | 0 .../web/public}/images/payments/maestro.svg | 0 .../public}/images/payments/mastercard.svg | 0 .../web/public}/images/payments/unionpay.svg | 0 .../web/public}/images/payments/visa.svg | 0 .../web/public}/images/safari-pinned-tab.svg | 0 {public => platforms/web/public}/jwpltx.js | 0 .../web/public}/locales/en/account.json | 1 + .../web/public}/locales/en/common.json | 1 + .../web/public}/locales/en/country.json | 0 .../web/public}/locales/en/demo.json | 0 .../web/public}/locales/en/epg.json | 0 .../web/public}/locales/en/error.json | 0 .../web/public}/locales/en/menu.json | 3 +- .../web/public}/locales/en/search.json | 0 .../web/public}/locales/en/us_state.json | 0 .../web/public}/locales/en/user.json | 0 .../web/public}/locales/en/video.json | 4 + .../web/public}/locales/es/account.json | 1 + .../web/public}/locales/es/common.json | 1 + .../web/public}/locales/es/country.json | 0 .../web/public}/locales/es/demo.json | 0 .../web/public}/locales/es/epg.json | 0 .../web/public}/locales/es/error.json | 0 .../web/public}/locales/es/menu.json | 3 +- .../web/public}/locales/es/search.json | 0 .../web/public}/locales/es/us_state.json | 0 .../web/public}/locales/es/user.json | 0 .../web/public}/locales/es/video.json | 4 + .../web/public}/manifest.json | 0 {public => platforms/web/public}/robots.txt | 0 .../web/scripts}/build-tools/settings.ts | 0 .../web/scripts}/compressIni.sh | 0 .../web/scripts}/waitOnConfig.js | 0 {src => platforms/web/src}/App.tsx | 15 +- .../DemoConfigDialog.module.scss | 4 +- .../DemoConfigDialog.test.tsx | 4 +- .../DemoConfigDialog/DemoConfigDialog.tsx | 25 +- .../DemoConfigDialog.test.tsx.snap | 32 +- platforms/web/src/constants.ts | 1 + .../src}/containers/AppRoutes/AppRoutes.tsx | 38 +- platforms/web/src/containers/Root/Root.tsx | 104 + .../web/src}/hooks/useNotifications.ts | 29 +- .../web/src}/hooks/useTrackConfigKeyChange.ts | 5 +- {src => platforms/web/src}/i18n/config.ts | 28 +- {src => platforms/web/src}/i18n/resources.ts | 0 platforms/web/src/index.tsx | 31 + platforms/web/src/modules/register.ts | 32 + {src => platforms/web/src}/screenMapping.ts | 11 +- .../web/src/services/LocalStorageService.ts | 51 + {src => platforms/web/src}/styles/main.scss | 7 +- platforms/web/src/utils/ip.ts | 15 + platforms/web/stylelint.config.js | 3 + .../web/test-cases}/analytics/adv.feature | 0 .../web/test-cases}/analytics/init.feature | 0 .../web/test-cases}/analytics/params.feature | 0 .../test-cases}/analytics/quantile.feature | 0 .../web/test-e2e}/.eslintrc.js | 0 .../web/test-e2e}/codecept.desktop.js | 4 + .../web/test-e2e}/codecept.mobile.js | 4 + .../web/test-e2e}/tests/account_test.ts | 3 +- .../web/test-e2e}/tests/favorites_test.ts | 3 +- .../web/test-e2e}/tests/home_test.ts | 3 +- .../web/test-e2e}/tests/inline_layout_test.ts | 7 +- .../web/test-e2e}/tests/language_test.ts | 68 +- .../web/test-e2e}/tests/live_channel_test.ts | 6 +- .../web/test-e2e}/tests/login/account_test.ts | 3 +- .../web/test-e2e}/tests/login/home_test.ts | 3 +- .../test-e2e}/tests/payments/coupons_test.ts | 3 +- .../tests/payments/subscription_test.ts | 3 +- .../web/test-e2e}/tests/playlist_test.ts | 5 +- .../web/test-e2e}/tests/register_test.ts | 3 +- .../web/test-e2e}/tests/search_test.ts | 3 +- .../web/test-e2e}/tests/seo_test.ts | 7 +- .../web/test-e2e}/tests/series_test.ts | 41 +- .../web/test-e2e}/tests/video_detail_test.ts | 6 +- .../tests/watch_history/local_test.ts | 5 +- .../tests/watch_history/logged_in_test.ts | 5 +- .../web/test-e2e}/tsconfig.json | 10 +- .../web/test-e2e}/utils/constants.ts | 0 .../web/test-e2e}/utils/login.ts | 0 .../web/test-e2e}/utils/password_utils.ts | 0 .../web/test-e2e}/utils/payments.ts | 4 +- .../web/test-e2e}/utils/randomizers.ts | 0 .../web/test-e2e}/utils/steps.d.ts | 0 .../web/test-e2e}/utils/steps_file.ts | 5 +- .../web/test-e2e}/utils/watch_history.ts | 0 platforms/web/test/.depcheckrc.yaml | 1 + {test => platforms/web/test}/types.ts | 5 +- {test => platforms/web/test}/vitest.setup.ts | 42 - platforms/web/tsconfig.json | 27 + {types => platforms/web/types}/env.d.ts | 3 +- platforms/web/types/global.d.ts | 10 + platforms/web/types/i18next.d.ts | 10 + platforms/web/types/jwpltx.d.ts | 18 + {types => platforms/web/types}/static.d.ts | 0 .../web/vite.config.ts | 10 +- scripts/.eslintrc.js | 1 + scripts/i18next/generate.js | 4 +- scripts/i18next/tsconfig.json | 9 - scripts/i18next/update-translations.ts | 2 +- scripts/{content-types => }/tsconfig.json | 3 +- src/components/Account/Account.test.tsx | 35 - src/components/Adyen/Adyen.module.scss | 10 - .../CustomRegisterField.test.tsx.snap | 2041 ------- .../LanguageMenu/LanguageMenu.test.tsx | 44 - src/components/LanguageMenu/LanguageMenu.tsx | 27 - .../__snapshots__/LanguageMenu.test.tsx.snap | 28 - src/components/Panel/Panel.tsx | 13 - src/components/Root/Root.tsx | 77 - .../__snapshots__/ShareButton.test.tsx.snap | 30 - .../SocialButtonsList/SocialButtonsList.tsx | 38 - src/components/UserMenu/UserMenu.test.tsx | 23 - .../AccountModal/AccountModal.module.scss | 10 - .../AccountModal/AccountModal.test.tsx | 27 - src/containers/Layout/Layout.test.tsx | 23 - .../PlayerContainer.module.scss | 15 - src/hooks/useBootstrapApp.ts | 40 - src/hooks/useLiveEvent.ts | 9 - src/hooks/useProtectedMedia.ts | 11 - src/hooks/useQueryParam.ts | 11 - src/hooks/useStartWatchingButton.ts | 45 - src/icons/AccountCircle.tsx | 10 - src/icons/ArrowLeft.tsx | 10 - src/icons/ArrowLeftRight.tsx | 10 - src/icons/BalanceWallet.tsx | 10 - src/icons/Check.tsx | 10 - src/icons/CheckCircle.tsx | 10 - src/icons/ChevronLeft.tsx | 10 - src/icons/ChevronRight.tsx | 10 - src/icons/Close.tsx | 11 - src/icons/CreditCard.tsx | 10 - src/icons/Edit.tsx | 13 - src/icons/Exit.tsx | 10 - src/icons/ExternalLink.tsx | 10 - src/icons/Favorite.tsx | 10 - src/icons/FavoriteBorder.tsx | 10 - src/icons/Icon.tsx | 16 - src/icons/Menu.tsx | 11 - src/icons/PayPal.tsx | 10 - src/icons/Play.tsx | 10 - src/icons/PlayTrailer.tsx | 10 - src/icons/Search.tsx | 10 - src/icons/Share.tsx | 10 - src/icons/Today.tsx | 11 - src/icons/Visibility.tsx | 10 - src/icons/VisibilityOff.tsx | 10 - src/index.tsx | 20 - src/modules/register.ts | 84 - src/pages/Home/Home.tsx | 15 - src/pages/ScreenRouting/ScreenMap.ts | 40 - src/services/cleeng.checkout.service.ts | 149 - src/services/cleeng.subscription.service.ts | 74 - src/services/config.service.ts | 182 - src/services/profile.service.ts | 15 - src/stores/AppController.ts | 96 - src/utils/broadcaster.test.ts | 81 - src/utils/broadcaster.ts | 103 - src/utils/domHelpers.ts | 23 - src/utils/location.ts | 63 - src/utils/persist.ts | 60 - tsconfig.base.json | 26 + tsconfig.json | 49 - types/entitlement.d.ts | 6 - types/service.d.ts | 9 - vitest.workspace.ts | 3 + yarn.lock | 5216 +++++++++-------- 893 files changed, 10505 insertions(+), 10586 deletions(-) rename .github/workflows/{release-deploy-prod-demo.yml => web-release-deploy-prod-demo.yml} (81%) rename .github/workflows/{test-e2e.yml => web-test-e2e.yml} (87%) rename .github/workflows/{test-preview-and-lighthouse.yml => web-test-preview-and-lighthouse.yml} (87%) create mode 100644 .syncpackrc.json create mode 100644 configs/eslint-config-jwp/.depcheckrc.yaml create mode 100644 configs/eslint-config-jwp/lint-staged.config.js create mode 100644 configs/eslint-config-jwp/package.json create mode 100644 configs/eslint-config-jwp/react.js create mode 100644 configs/eslint-config-jwp/typescript.js create mode 100644 configs/postcss-config-jwp/.depcheckrc.yaml rename postcss.config.js => configs/postcss-config-jwp/index.js (78%) create mode 100644 configs/postcss-config-jwp/lint-staged.config.js create mode 100644 configs/postcss-config-jwp/package.json create mode 100644 configs/stylelint-config-jwp/.depcheckrc.yaml rename stylelint.config.js => configs/stylelint-config-jwp/index.js (97%) create mode 100644 configs/stylelint-config-jwp/lint-staged.config.js create mode 100644 configs/stylelint-config-jwp/package.json create mode 100644 docs/workspaces.md create mode 100644 packages/common/.depcheckrc.yaml create mode 100644 packages/common/.eslintrc.js create mode 100644 packages/common/lint-staged.config.js create mode 100644 packages/common/package.json rename src/config.ts => packages/common/src/constants.ts (91%) create mode 100644 packages/common/src/env.ts rename {src => packages/common/src}/modules/container.ts (79%) create mode 100644 packages/common/src/modules/functions/calculateIntegrationType.ts create mode 100644 packages/common/src/modules/functions/getIntegrationType.ts create mode 100644 packages/common/src/modules/register.ts create mode 100644 packages/common/src/modules/types.ts rename src/services/api.service.ts => packages/common/src/services/ApiService.ts (75%) create mode 100644 packages/common/src/services/ConfigService.ts rename src/services/epg/epg.service.ts => packages/common/src/services/EpgService.ts (75%) rename src/services/favorites.service.ts => packages/common/src/services/FavoriteService.ts (58%) rename src/services/genericEntitlement.service.ts => packages/common/src/services/GenericEntitlementService.ts (86%) rename src/services/jwpEntitlement.service.ts => packages/common/src/services/JWPEntitlementService.ts (100%) rename src/services/settings.service.ts => packages/common/src/services/SettingsService.ts (73%) create mode 100644 packages/common/src/services/StorageService.ts rename src/services/watchhistory.service.ts => packages/common/src/services/WatchHistoryService.ts (86%) rename src/services/epg/jw.epg.service.test.ts => packages/common/src/services/epg/JWEpgService.test.ts (96%) rename src/services/epg/jw.epg.service.ts => packages/common/src/services/epg/JWEpgService.ts (89%) rename src/services/epg/viewNexa.epg.service.test.ts => packages/common/src/services/epg/ViewNexaEpgService.test.ts (95%) rename src/services/epg/viewNexa.epg.service.ts => packages/common/src/services/epg/ViewNexaEpgService.ts (90%) rename src/services/account.service.ts => packages/common/src/services/integrations/AccountService.ts (84%) rename src/services/checkout.service.ts => packages/common/src/services/integrations/CheckoutService.ts (97%) create mode 100644 packages/common/src/services/integrations/ProfileService.ts rename src/services/subscription.service.ts => packages/common/src/services/integrations/SubscriptionService.ts (95%) rename src/services/cleeng.account.service.ts => packages/common/src/services/integrations/cleeng/CleengAccountService.ts (62%) create mode 100644 packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts rename src/services/cleeng.service.ts => packages/common/src/services/integrations/cleeng/CleengService.ts (76%) create mode 100644 packages/common/src/services/integrations/cleeng/CleengSubscriptionService.ts rename src/services/inplayer.account.service.ts => packages/common/src/services/integrations/jwp/JWPAccountService.ts (84%) rename src/services/inplayer.checkout.service.ts => packages/common/src/services/integrations/jwp/JWPCheckoutService.ts (93%) rename src/services/inplayer.profile.service.ts => packages/common/src/services/integrations/jwp/JWPProfileService.ts (70%) rename src/services/inplayer.subscription.service.ts => packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts (84%) rename {src => packages/common/src}/stores/AccountController.ts (74%) rename {src => packages/common/src}/stores/AccountStore.ts (79%) create mode 100644 packages/common/src/stores/AppController.ts rename {src => packages/common/src}/stores/CheckoutController.ts (63%) rename {src => packages/common/src}/stores/CheckoutStore.ts (85%) rename {src => packages/common/src}/stores/ConfigStore.ts (65%) rename {src => packages/common/src}/stores/EpgController.test.ts (91%) rename {src => packages/common/src}/stores/EpgController.ts (92%) rename {src => packages/common/src}/stores/FavoritesController.ts (78%) rename {src => packages/common/src}/stores/FavoritesStore.ts (84%) rename {src => packages/common/src}/stores/ProfileController.ts (62%) rename {src => packages/common/src}/stores/ProfileStore.ts (76%) rename {src => packages/common/src}/stores/UIStore.ts (80%) rename {src => packages/common/src}/stores/WatchHistoryController.ts (78%) rename {src => packages/common/src}/stores/WatchHistoryStore.ts (89%) rename {src => packages/common/src}/stores/utils.ts (88%) create mode 100644 packages/common/src/utils/ScreenMap.ts rename {src => packages/common/src}/utils/analytics.ts (74%) rename {src => packages/common/src}/utils/api.ts (80%) rename {src => packages/common/src}/utils/collection.ts (92%) rename {src => packages/common/src}/utils/common.ts (54%) create mode 100644 packages/common/src/utils/compare.ts rename {src => packages/common/src}/utils/configSchema.ts (92%) rename {src => packages/common/src}/utils/datetime.ts (100%) rename {src => packages/common/src}/utils/entitlements.ts (86%) rename {src => packages/common/src}/utils/epg.ts (94%) rename {src => packages/common/src}/utils/error.ts (100%) rename {src => packages/common/src}/utils/formatting.ts (52%) create mode 100644 packages/common/src/utils/i18n.ts rename {src => packages/common/src}/utils/liveEvent.ts (96%) rename {src => packages/common/src}/utils/media.ts (94%) rename {src => packages/common/src}/utils/promiseQueue.test.ts (100%) rename {src => packages/common/src}/utils/promiseQueue.ts (100%) rename {src => packages/common/src}/utils/series.ts (90%) rename {src => packages/common/src}/utils/structuredData.ts (62%) rename {src => packages/common/src}/utils/subscription.ts (67%) create mode 100644 packages/common/src/utils/urlFormatting.test.ts create mode 100644 packages/common/src/utils/urlFormatting.ts rename {src => packages/common/src}/utils/yupSchemaCreator.ts (100%) create mode 100644 packages/common/test/mockService.ts create mode 100644 packages/common/tsconfig.json rename {types => packages/common/types}/account.d.ts (72%) rename {types => packages/common/types}/ad-schedule.d.ts (100%) rename {types => packages/common/types}/adyen.d.ts (100%) create mode 100644 packages/common/types/calculate-integration-type.ts rename {types => packages/common/types}/checkout.d.ts (96%) rename {types => packages/common/types}/cleeng.d.ts (54%) rename types/Config.d.ts => packages/common/types/config.d.ts (97%) create mode 100644 packages/common/types/entitlement.d.ts rename {types => packages/common/types}/epg.d.ts (78%) rename {types => packages/common/types}/favorite.d.ts (100%) rename {types => packages/common/types}/form.d.ts (100%) create mode 100644 packages/common/types/get-customer-ip.d.ts rename {types => packages/common/types}/global.d.ts (79%) create mode 100644 packages/common/types/i18n.d.ts rename {types => packages/common/types}/i18next.d.ts (100%) rename {types => packages/common/types}/inplayer.d.ts (80%) rename {types => packages/common/types}/jwplayer.d.ts (100%) rename {types => packages/common/types}/jwpltx.d.ts (100%) rename {types => packages/common/types}/media.d.ts (100%) rename {types => packages/common/types}/pagination.d.ts (100%) rename {types => packages/common/types}/playlist.d.ts (97%) rename src/containers/Profiles/types.d.ts => packages/common/types/profiles.d.ts (75%) rename {types => packages/common/types}/series.d.ts (100%) create mode 100644 packages/common/types/service.d.ts rename {types => packages/common/types}/settings.d.ts (100%) create mode 100644 packages/common/types/static.d.ts rename {types => packages/common/types}/subscription.d.ts (94%) create mode 100644 packages/common/types/testing.d.ts rename {types => packages/common/types}/watchHistory.d.ts (100%) create mode 100644 packages/common/vitest.config.ts create mode 100644 packages/common/vitest.setup.ts create mode 100644 packages/hooks-react/.depcheckrc.yaml create mode 100644 packages/hooks-react/.eslintrc.js create mode 100644 packages/hooks-react/lint-staged.config.js create mode 100644 packages/hooks-react/package.json rename {src/hooks => packages/hooks-react/src}/series/useEpisodes.ts (82%) rename {src/hooks => packages/hooks-react/src}/series/useNextEpisode.ts (70%) rename {src/hooks => packages/hooks-react/src}/series/useSeries.ts (68%) rename {src/hooks => packages/hooks-react/src}/series/useSeriesLookup.ts (76%) create mode 100644 packages/hooks-react/src/testUtils.tsx rename {src/hooks => packages/hooks-react/src}/useAds.ts (78%) create mode 100644 packages/hooks-react/src/useBootstrapApp.ts rename {src/hooks => packages/hooks-react/src}/useCheckAccess.ts (64%) rename {src/hooks => packages/hooks-react/src}/useContentProtection.ts (75%) rename {src/hooks => packages/hooks-react/src}/useCountdown.ts (100%) rename {src/hooks => packages/hooks-react/src}/useDebounce.ts (76%) rename {src/hooks => packages/hooks-react/src}/useEntitlement.ts (75%) rename {src/hooks => packages/hooks-react/src}/useEventCallback.ts (100%) rename {src/hooks => packages/hooks-react/src}/useFirstRender.ts (100%) rename {src/hooks => packages/hooks-react/src}/useForm.ts (95%) rename {src/hooks => packages/hooks-react/src}/useLiveChannels.test.ts (74%) rename {src/hooks => packages/hooks-react/src}/useLiveChannels.ts (90%) create mode 100644 packages/hooks-react/src/useLiveEvent.ts rename {src/hooks => packages/hooks-react/src}/useLiveProgram.test.ts (94%) rename {src/hooks => packages/hooks-react/src}/useLiveProgram.ts (92%) rename {src/hooks => packages/hooks-react/src}/useMedia.ts (63%) rename {src/hooks => packages/hooks-react/src}/useOffers.ts (74%) rename {src/hooks => packages/hooks-react/src}/useOpaqueId.ts (69%) rename {src/hooks => packages/hooks-react/src}/useOttAnalytics.ts (88%) rename {src/hooks => packages/hooks-react/src}/usePlanByEpg.test.ts (93%) rename {src/hooks => packages/hooks-react/src}/usePlanByEpg.ts (95%) rename {src/hooks => packages/hooks-react/src}/usePlaylist.ts (66%) rename {src/hooks => packages/hooks-react/src}/usePlaylistItemCallback.ts (78%) rename {src/hooks => packages/hooks-react/src}/useProfiles.ts (65%) create mode 100644 packages/hooks-react/src/useProtectedMedia.ts create mode 100644 packages/hooks-react/src/useSocialLoginUrls.ts rename {src/hooks => packages/hooks-react/src}/useSubscriptionChange.ts (72%) rename {src/hooks => packages/hooks-react/src}/useToggle.ts (100%) rename {src/hooks => packages/hooks-react/src}/useWatchHistory.ts (70%) rename {src/hooks => packages/hooks-react/src}/useWatchHistoryListener.ts (90%) create mode 100644 packages/hooks-react/tsconfig.json create mode 100644 packages/hooks-react/vitest.config.ts create mode 100644 packages/hooks-react/vitest.setup.ts create mode 100644 packages/i18n/.depcheckrc.yaml create mode 100644 packages/i18n/README.md create mode 100644 packages/i18n/package.json rename {test => packages/testing}/constants.ts (89%) rename {test => packages/testing}/epg/channel1.json (100%) rename {test => packages/testing}/epg/channel2.json (100%) rename {test => packages/testing}/epg/channel4.json (100%) rename {test => packages/testing}/epg/jwChannel.json (100%) rename {test => packages/testing}/epg/viewNexaChannel.xml (100%) rename {test => packages/testing}/fixtures/config.json (100%) rename {test => packages/testing}/fixtures/customer.json (100%) rename {test => packages/testing}/fixtures/epgChannels.json (100%) rename {test => packages/testing}/fixtures/epgChannelsUpdate.json (100%) rename {test => packages/testing}/fixtures/livePlaylist.json (100%) rename {test => packages/testing}/fixtures/monthlyOffer.json (100%) rename {test => packages/testing}/fixtures/order.json (100%) rename {test => packages/testing}/fixtures/paymentDetail.json (100%) rename {test => packages/testing}/fixtures/playlist.json (100%) rename {test => packages/testing}/fixtures/schedule.json (100%) rename {test => packages/testing}/fixtures/subscription.json (100%) rename {test => packages/testing}/fixtures/transactions.json (100%) rename {test => packages/testing}/fixtures/tvodOffer.json (100%) rename {test => packages/testing}/fixtures/yearlyOffer.json (100%) create mode 100644 packages/testing/lint-staged.config.js create mode 100644 packages/testing/package.json create mode 100644 packages/testing/tsconfig.json create mode 100644 packages/theme/.depcheckrc.yaml create mode 100644 packages/theme/assets/icons/account_circle.svg create mode 100644 packages/theme/assets/icons/arrow_left.svg create mode 100644 packages/theme/assets/icons/balance_wallet.svg rename src/icons/Cancel.tsx => packages/theme/assets/icons/cancel.svg (59%) create mode 100644 packages/theme/assets/icons/check.svg create mode 100644 packages/theme/assets/icons/check_circle.svg create mode 100644 packages/theme/assets/icons/chevron_left.svg create mode 100644 packages/theme/assets/icons/chevron_right.svg create mode 100644 packages/theme/assets/icons/close.svg create mode 100644 packages/theme/assets/icons/creditcard.svg create mode 100644 packages/theme/assets/icons/edit.svg create mode 100644 packages/theme/assets/icons/exit.svg create mode 100644 packages/theme/assets/icons/external_link.svg rename {src => packages/theme}/assets/icons/facebook.svg (100%) create mode 100644 packages/theme/assets/icons/favorite.svg create mode 100644 packages/theme/assets/icons/favorite_border.svg rename {src => packages/theme}/assets/icons/google.svg (100%) rename src/icons/Language.tsx => packages/theme/assets/icons/language.svg (88%) rename src/icons/Lock.tsx => packages/theme/assets/icons/lock.svg (70%) create mode 100644 packages/theme/assets/icons/menu.svg create mode 100644 packages/theme/assets/icons/paypal.svg create mode 100644 packages/theme/assets/icons/play.svg create mode 100644 packages/theme/assets/icons/play_trailer.svg rename src/icons/Plus.tsx => packages/theme/assets/icons/plus.svg (80%) create mode 100644 packages/theme/assets/icons/search.svg create mode 100644 packages/theme/assets/icons/share.svg create mode 100644 packages/theme/assets/icons/today.svg rename {src => packages/theme}/assets/icons/twitter.svg (100%) create mode 100644 packages/theme/assets/icons/visibility.svg create mode 100644 packages/theme/assets/icons/visibility_off.svg rename {src => packages/theme}/assets/profiles/default_avatar.png (100%) create mode 100644 packages/theme/package.json create mode 100644 packages/ui-react/.depcheckrc.yaml create mode 100644 packages/ui-react/.eslintrc.js create mode 100644 packages/ui-react/lint-staged.config.js create mode 100644 packages/ui-react/package.json create mode 100644 packages/ui-react/postcss.config.js rename {src => packages/ui-react/src}/components/Account/Account.module.scss (74%) create mode 100644 packages/ui-react/src/components/Account/Account.test.tsx rename {src => packages/ui-react/src}/components/Account/Account.tsx (88%) rename {src => packages/ui-react/src}/components/Account/__snapshots__/Account.test.tsx.snap (76%) create mode 100644 packages/ui-react/src/components/Adyen/Adyen.module.scss rename {src => packages/ui-react/src}/components/Adyen/Adyen.tsx (93%) rename {src => packages/ui-react/src}/components/Adyen/AdyenForm.scss (84%) rename {src => packages/ui-react/src}/components/Alert/Alert.module.scss (69%) rename {src => packages/ui-react/src}/components/Alert/Alert.test.tsx (100%) rename {src => packages/ui-react/src}/components/Alert/Alert.tsx (59%) rename {src => packages/ui-react/src}/components/Alert/__snapshots__/Alert.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Animation/Animation.tsx (96%) rename {src => packages/ui-react/src}/components/Animation/Fade/Fade.tsx (88%) rename {src => packages/ui-react/src}/components/Animation/Grow/Grow.tsx (87%) rename {src => packages/ui-react/src}/components/Animation/Slide/Slide.tsx (91%) rename {src => packages/ui-react/src}/components/BackButton/BackButton.module.scss (53%) rename {src => packages/ui-react/src}/components/BackButton/BackButton.tsx (81%) rename {src => packages/ui-react/src}/components/Button/Button.module.scss (95%) rename {src => packages/ui-react/src}/components/Button/Button.test.tsx (100%) rename {src => packages/ui-react/src}/components/Button/Button.tsx (93%) rename {src => packages/ui-react/src}/components/Button/__snapshots__/Button.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss (64%) rename {src => packages/ui-react/src}/components/CancelSubscriptionForm/CancelSubscriptionForm.test.tsx (100%) rename {src => packages/ui-react/src}/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx (90%) rename {src => packages/ui-react/src}/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Card/Card.module.scss (96%) rename {src => packages/ui-react/src}/components/Card/Card.test.tsx (72%) rename {src => packages/ui-react/src}/components/Card/Card.tsx (78%) rename {src => packages/ui-react/src}/components/Card/__snapshots__/Card.test.tsx.snap (88%) rename {src => packages/ui-react/src}/components/CardGrid/CardGrid.module.scss (100%) rename {src => packages/ui-react/src}/components/CardGrid/CardGrid.test.tsx (73%) rename {src => packages/ui-react/src}/components/CardGrid/CardGrid.tsx (84%) rename {src => packages/ui-react/src}/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap (90%) rename {src => packages/ui-react/src}/components/Checkbox/Checkbox.module.scss (93%) rename {src => packages/ui-react/src}/components/Checkbox/Checkbox.test.tsx (100%) rename {src => packages/ui-react/src}/components/Checkbox/Checkbox.tsx (92%) rename {src => packages/ui-react/src}/components/Checkbox/__snapshots__/Checkbox.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/CheckoutForm/CheckoutForm.module.scss (97%) rename {src => packages/ui-react/src}/components/CheckoutForm/CheckoutForm.test.tsx (81%) rename {src => packages/ui-react/src}/components/CheckoutForm/CheckoutForm.tsx (88%) rename {src => packages/ui-react/src}/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap (89%) rename {src => packages/ui-react/src}/components/ChooseOfferForm/ChooseOfferForm.module.scss (93%) rename {src => packages/ui-react/src}/components/ChooseOfferForm/ChooseOfferForm.test.tsx (92%) rename {src => packages/ui-react/src}/components/ChooseOfferForm/ChooseOfferForm.tsx (81%) rename {src => packages/ui-react/src}/components/ChooseOfferForm/__snapshots__/ChooseOfferForm.test.tsx.snap (75%) rename {src => packages/ui-react/src}/components/CollapsibleText/CollapsibleText.module.scss (88%) rename {src => packages/ui-react/src}/components/CollapsibleText/CollapsibleText.test.tsx (100%) rename {src => packages/ui-react/src}/components/CollapsibleText/CollapsibleText.tsx (79%) rename {src => packages/ui-react/src}/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/ConfirmationDialog/ConfirmationDialog.module.scss (69%) rename {src => packages/ui-react/src}/components/ConfirmationDialog/ConfirmationDialog.test.tsx (100%) rename {src => packages/ui-react/src}/components/ConfirmationDialog/ConfirmationDialog.tsx (77%) rename {src => packages/ui-react/src}/components/ConfirmationDialog/__snapshots__/ConfirmationDialog.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/ConfirmationForm/ConfirmationForm.module.scss (74%) rename {src => packages/ui-react/src}/components/ConfirmationForm/ConfirmationForm.test.tsx (84%) rename {src => packages/ui-react/src}/components/ConfirmationForm/ConfirmationForm.tsx (84%) rename {src => packages/ui-react/src}/components/ConfirmationForm/__snapshots__/ConfirmationForm.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/CreditCardCVCField/CreditCardCVCField.test.tsx (100%) rename {src => packages/ui-react/src}/components/CreditCardCVCField/CreditCardCVCField.tsx (100%) rename {src => packages/ui-react/src}/components/CreditCardCVCField/__snapshots__/CreditCardCVCField.test.tsx.snap (89%) rename {src => packages/ui-react/src}/components/CreditCardExpiryField/CreditCardExpiryField.test.tsx (100%) rename {src => packages/ui-react/src}/components/CreditCardExpiryField/CreditCardExpiryField.tsx (100%) rename {src => packages/ui-react/src}/components/CreditCardExpiryField/__snapshots__/CreditCardExpiryField.test.tsx.snap (87%) rename {src => packages/ui-react/src}/components/CreditCardNumberField/CreditCardNumberField.module.scss (100%) rename {src => packages/ui-react/src}/components/CreditCardNumberField/CreditCardNumberField.test.tsx (100%) rename {src => packages/ui-react/src}/components/CreditCardNumberField/CreditCardNumberField.tsx (100%) rename {src => packages/ui-react/src}/components/CreditCardNumberField/__snapshots__/CreditCardNumberField.test.tsx.snap (87%) rename {src => packages/ui-react/src}/components/CustomRegisterField/CustomRegisterField.test.tsx (97%) rename {src => packages/ui-react/src}/components/CustomRegisterField/CustomRegisterField.tsx (72%) create mode 100644 packages/ui-react/src/components/CustomRegisterField/__snapshots__/CustomRegisterField.test.tsx.snap rename {src => packages/ui-react/src}/components/DateField/DateField.module.scss (94%) rename {src => packages/ui-react/src}/components/DateField/DateField.tsx (97%) rename {src => packages/ui-react/src}/components/DeleteAccountModal/DeleteAccountModal.module.scss (95%) rename {src => packages/ui-react/src}/components/DeleteAccountModal/DeleteAccountModal.tsx (83%) rename {src => packages/ui-react/src}/components/DeleteAccountPasswordWarning/DeleteAccountPasswordWarning.module.scss (93%) rename {src => packages/ui-react/src}/components/DeleteAccountPasswordWarning/DeleteAccountPasswordWarning.tsx (77%) rename {src => packages/ui-react/src}/components/DetectOutsideClick/DetectOutsideClick.tsx (100%) rename {src => packages/ui-react/src}/components/DevConfigSelector/DevConfigSelector.module.scss (100%) rename {src => packages/ui-react/src}/components/DevConfigSelector/DevConfigSelector.tsx (61%) rename {src => packages/ui-react/src}/components/DevStackTrace/DevStackTrace.module.scss (100%) rename {src => packages/ui-react/src}/components/DevStackTrace/DevStackTrace.tsx (100%) rename {src => packages/ui-react/src}/components/Dialog/Dialog.module.scss (80%) rename {src => packages/ui-react/src}/components/Dialog/Dialog.test.tsx (87%) rename {src => packages/ui-react/src}/components/Dialog/Dialog.tsx (55%) rename {src => packages/ui-react/src}/components/Dialog/__snapshots__/Dialog.test.tsx.snap (75%) rename {src => packages/ui-react/src}/components/DialogBackButton/DialogBackButton.module.scss (53%) rename {src => packages/ui-react/src}/components/DialogBackButton/DialogBackButton.test.tsx (100%) rename {src => packages/ui-react/src}/components/DialogBackButton/DialogBackButton.tsx (65%) rename {src => packages/ui-react/src}/components/DialogBackButton/__snapshots__/DialogBackButton.test.tsx.snap (68%) rename {src => packages/ui-react/src}/components/Dropdown/Dropdown.module.scss (94%) rename {src => packages/ui-react/src}/components/Dropdown/Dropdown.test.tsx (99%) rename {src => packages/ui-react/src}/components/Dropdown/Dropdown.tsx (95%) rename {src => packages/ui-react/src}/components/EditCardPaymentForm/EditCardPaymentForm.module.scss (100%) rename {src => packages/ui-react/src}/components/EditCardPaymentForm/EditCardPaymentForm.tsx (92%) rename {src => packages/ui-react/src}/components/EditForm/EditCardDetailsForm.module.scss (60%) rename {src => packages/ui-react/src}/components/EditForm/EditCardDetailsForm.tsx (92%) rename {src => packages/ui-react/src}/components/EditPasswordForm/EditPasswordForm.module.scss (83%) rename {src => packages/ui-react/src}/components/EditPasswordForm/EditPasswordForm.test.tsx (100%) rename {src => packages/ui-react/src}/components/EditPasswordForm/EditPasswordForm.tsx (90%) rename {src => packages/ui-react/src}/components/EditPasswordForm/__snapshots__/EditPasswordForm.test.tsx.snap (61%) rename {src => packages/ui-react/src}/components/Epg/Epg.module.scss (93%) rename {src => packages/ui-react/src}/components/Epg/Epg.tsx (79%) rename {src => packages/ui-react/src}/components/EpgChannel/EpgChannelItem.module.scss (82%) rename {src => packages/ui-react/src}/components/EpgChannel/EpgChannelItem.tsx (91%) rename {src => packages/ui-react/src}/components/EpgProgramItem/EpgProgramItem.module.scss (94%) rename {src => packages/ui-react/src}/components/EpgProgramItem/EpgProgramItem.tsx (91%) rename {src => packages/ui-react/src}/components/EpgTimeline/EpgTimeline.module.scss (87%) rename {src => packages/ui-react/src}/components/EpgTimeline/EpgTimeline.tsx (100%) rename {src => packages/ui-react/src}/components/ErrorPage/ErrorPage.module.scss (86%) rename {src => packages/ui-react/src}/components/ErrorPage/ErrorPage.test.tsx (100%) rename {src => packages/ui-react/src}/components/ErrorPage/ErrorPage.tsx (90%) rename {src => packages/ui-react/src}/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Favorites/Favorites.module.scss (72%) rename {src => packages/ui-react/src}/components/Favorites/Favorites.test.tsx (57%) rename {src => packages/ui-react/src}/components/Favorites/Favorites.tsx (74%) rename {src => packages/ui-react/src}/components/Favorites/__snapshots__/Favorites.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Filter/Filter.module.scss (59%) rename {src => packages/ui-react/src}/components/Filter/Filter.test.tsx (100%) rename {src => packages/ui-react/src}/components/Filter/Filter.tsx (89%) rename {src => packages/ui-react/src}/components/Filter/__snapshots__/Filter.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/FinalizePayment/FinalizePayment.module.scss (100%) rename {src => packages/ui-react/src}/components/FinalizePayment/FinalizePayment.tsx (73%) rename {src => packages/ui-react/src}/components/ForgotPasswordForm/ForgotPasswordForm.module.scss (66%) rename {src => packages/ui-react/src}/components/ForgotPasswordForm/ForgotPasswordForm.test.tsx (100%) rename {src => packages/ui-react/src}/components/ForgotPasswordForm/ForgotPasswordForm.tsx (81%) rename {src => packages/ui-react/src}/components/ForgotPasswordForm/__snapshots__/ForgotPasswordForm.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Form/Form.module.scss (65%) rename {src => packages/ui-react/src}/components/Form/Form.test.tsx (100%) rename {src => packages/ui-react/src}/components/Form/Form.tsx (91%) rename {src => packages/ui-react/src}/components/Form/FormSection.tsx (91%) rename {src => packages/ui-react/src}/components/Form/__snapshots__/Form.test.tsx.snap (81%) rename {src => packages/ui-react/src}/components/FormFeedback/FormFeedback.module.scss (87%) rename {src => packages/ui-react/src}/components/FormFeedback/FormFeedback.test.tsx (100%) rename {src => packages/ui-react/src}/components/FormFeedback/FormFeedback.tsx (100%) rename {src => packages/ui-react/src}/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Header/Header.module.scss (77%) rename {src => packages/ui-react/src}/components/Header/Header.test.tsx (74%) rename {src => packages/ui-react/src}/components/Header/Header.tsx (70%) rename {src => packages/ui-react/src}/components/Header/__snapshots__/Header.test.tsx.snap (69%) rename {src => packages/ui-react/src}/components/HelperText/HelperText.module.scss (59%) rename {src => packages/ui-react/src}/components/HelperText/HelperText.test.tsx (100%) rename {src => packages/ui-react/src}/components/HelperText/HelperText.tsx (100%) rename {src => packages/ui-react/src}/components/HelperText/__snapshots__/HelperText.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Hero/Hero.module.scss (81%) rename {src => packages/ui-react/src}/components/Hero/Hero.tsx (91%) rename {src/icons => packages/ui-react/src/components/Icon}/Icon.module.scss (62%) create mode 100644 packages/ui-react/src/components/Icon/Icon.test.tsx create mode 100644 packages/ui-react/src/components/Icon/Icon.tsx create mode 100644 packages/ui-react/src/components/Icon/__snapshots__/Icon.test.tsx.snap rename {src => packages/ui-react/src}/components/IconButton/IconButton.module.scss (77%) rename {src => packages/ui-react/src}/components/IconButton/IconButton.test.tsx (85%) rename {src => packages/ui-react/src}/components/IconButton/IconButton.tsx (69%) rename {src => packages/ui-react/src}/components/IconButton/__snapshots__/IconButton.test.tsx.snap (52%) rename {src => packages/ui-react/src}/components/Image/Image.module.scss (100%) rename {src => packages/ui-react/src}/components/Image/Image.test.tsx (100%) rename {src => packages/ui-react/src}/components/Image/Image.tsx (85%) rename {src => packages/ui-react/src}/components/InfiniteScrollLoader/InfiniteScrollLoader.module.scss (100%) rename {src => packages/ui-react/src}/components/InfiniteScrollLoader/InfiniteScrollLoader.tsx (83%) rename {src => packages/ui-react/src}/components/LanguageMenu/LanguageMenu.module.scss (60%) create mode 100644 packages/ui-react/src/components/LanguageMenu/LanguageMenu.test.tsx create mode 100644 packages/ui-react/src/components/LanguageMenu/LanguageMenu.tsx create mode 100644 packages/ui-react/src/components/LanguageMenu/__snapshots__/LanguageMenu.test.tsx.snap rename {src => packages/ui-react/src}/components/LanguageMenu/__snapshots__/UserMenu.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Link/Link.module.scss (55%) rename {src => packages/ui-react/src}/components/Link/Link.test.tsx (100%) rename {src => packages/ui-react/src}/components/Link/Link.tsx (89%) rename {src => packages/ui-react/src}/components/Link/__snapshots__/Link.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/LoadingOverlay/LoadingOverlay.module.scss (87%) rename {src => packages/ui-react/src}/components/LoadingOverlay/LoadingOverlay.tsx (93%) rename {src => packages/ui-react/src}/components/LoginForm/LoginForm.module.scss (72%) rename {src => packages/ui-react/src}/components/LoginForm/LoginForm.test.tsx (82%) rename {src => packages/ui-react/src}/components/LoginForm/LoginForm.tsx (59%) rename {src => packages/ui-react/src}/components/LoginForm/__snapshots__/LoginForm.test.tsx.snap (77%) rename {src => packages/ui-react/src}/components/Logo/Logo.module.scss (69%) rename {src => packages/ui-react/src}/components/Logo/Logo.test.tsx (93%) rename {src => packages/ui-react/src}/components/Logo/Logo.tsx (100%) rename {src => packages/ui-react/src}/components/Logo/__snapshots__/Logo.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/MarkdownComponent/MarkdownComponent.module.scss (87%) rename {src => packages/ui-react/src}/components/MarkdownComponent/MarkdownComponent.test.tsx (100%) rename {src => packages/ui-react/src}/components/MarkdownComponent/MarkdownComponent.tsx (100%) rename {src => packages/ui-react/src}/components/MarkdownComponent/__snapshots__/MarkdownComponent.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/MenuButton/MenuButton.module.scss (84%) rename {src => packages/ui-react/src}/components/MenuButton/MenuButton.test.tsx (100%) rename {src => packages/ui-react/src}/components/MenuButton/MenuButton.tsx (55%) rename {src => packages/ui-react/src}/components/MenuButton/__snapshots__/MenuButton.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Modal/Modal.module.scss (72%) rename {src => packages/ui-react/src}/components/Modal/Modal.test.tsx (100%) rename {src => packages/ui-react/src}/components/Modal/Modal.tsx (90%) rename {src => packages/ui-react/src}/components/Modal/__snapshots__/Modal.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/ModalCloseButton/ModalCloseButton.module.scss (78%) rename {src => packages/ui-react/src}/components/ModalCloseButton/ModalCloseButton.test.tsx (100%) rename {src => packages/ui-react/src}/components/ModalCloseButton/ModalCloseButton.tsx (76%) rename {src => packages/ui-react/src}/components/ModalCloseButton/__snapshots__/ModalCloseButton.test.tsx.snap (58%) rename {src => packages/ui-react/src}/components/NoPaymentRequired/NoPaymentRequired.module.scss (58%) rename {src => packages/ui-react/src}/components/NoPaymentRequired/NoPaymentRequired.test.tsx (100%) rename {src => packages/ui-react/src}/components/NoPaymentRequired/NoPaymentRequired.tsx (85%) rename {src => packages/ui-react/src}/components/NoPaymentRequired/__snapshots__/NoPaymentRequired.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/OfferSwitch/OfferSwitch.module.scss (91%) rename {src => packages/ui-react/src}/components/OfferSwitch/OfferSwitch.tsx (92%) rename {src => packages/ui-react/src}/components/Panel/Panel.module.scss (74%) create mode 100644 packages/ui-react/src/components/Panel/Panel.tsx rename {src => packages/ui-react/src}/components/PasswordField/PasswordField.test.tsx (100%) rename {src => packages/ui-react/src}/components/PasswordField/PasswordField.tsx (81%) rename {src => packages/ui-react/src}/components/PasswordField/__snapshots__/PasswordField.test.tsx.snap (58%) rename {src => packages/ui-react/src}/components/PasswordStrength/PasswordStrength.module.scss (90%) rename {src => packages/ui-react/src}/components/PasswordStrength/PasswordStrength.test.tsx (100%) rename {src => packages/ui-react/src}/components/PasswordStrength/PasswordStrength.tsx (100%) rename {src => packages/ui-react/src}/components/PasswordStrength/__snapshots__/PasswordStrength.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/PayPal/PayPal.module.scss (56%) rename {src => packages/ui-react/src}/components/PayPal/PayPal.test.tsx (100%) rename {src => packages/ui-react/src}/components/PayPal/PayPal.tsx (84%) rename {src => packages/ui-react/src}/components/PayPal/__snapshots__/PayPal.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Payment/Payment.module.scss (89%) rename {src => packages/ui-react/src}/components/Payment/Payment.test.tsx (69%) rename {src => packages/ui-react/src}/components/Payment/Payment.tsx (85%) rename {src => packages/ui-react/src}/components/Payment/__snapshots__/Payment.test.tsx.snap (89%) rename {src => packages/ui-react/src}/components/PaymentFailed/PaymentFailed.module.scss (54%) rename {src => packages/ui-react/src}/components/PaymentFailed/PaymentFailed.test.tsx (100%) rename {src => packages/ui-react/src}/components/PaymentFailed/PaymentFailed.tsx (92%) rename {src => packages/ui-react/src}/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/PaymentForm/PaymentForm.module.scss (100%) rename {src => packages/ui-react/src}/components/PaymentForm/PaymentForm.tsx (80%) rename {src => packages/ui-react/src}/components/PaymentMethodForm/PaymentMethodForm.module.scss (88%) rename {src => packages/ui-react/src}/components/PaymentMethodForm/PaymentMethodForm.tsx (84%) rename {src => packages/ui-react/src}/components/PersonalDetailsForm/PersonalDetailsForm.module.scss (62%) rename {src => packages/ui-react/src}/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx (97%) rename {src => packages/ui-react/src}/components/PersonalDetailsForm/PersonalDetailsForm.tsx (91%) rename {src => packages/ui-react/src}/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap (92%) rename {src => packages/ui-react/src}/components/Player/Player.module.scss (100%) rename {src => packages/ui-react/src}/components/Player/Player.test.tsx (85%) rename {src => packages/ui-react/src}/components/Player/Player.tsx (88%) rename {src => packages/ui-react/src}/components/Player/__snapshots__/Player.test.tsx.snap (100%) create mode 100644 packages/ui-react/src/components/PlayerError/PlayerError.module.scss create mode 100644 packages/ui-react/src/components/PlayerError/PlayerError.test.tsx create mode 100644 packages/ui-react/src/components/PlayerError/PlayerError.tsx create mode 100644 packages/ui-react/src/components/PlayerError/__snapshots__/PlayerError.test.tsx.snap rename {src => packages/ui-react/src}/components/Popover/Popover.module.scss (71%) rename {src => packages/ui-react/src}/components/Popover/Popover.test.tsx (78%) rename {src => packages/ui-react/src}/components/Popover/Popover.tsx (74%) rename {src => packages/ui-react/src}/components/Popover/__snapshots__/Popover.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/ProfileBox/AddNewProfile.tsx (86%) rename {src => packages/ui-react/src}/components/ProfileBox/ProfileBox.module.scss (98%) rename {src => packages/ui-react/src}/components/ProfileBox/ProfileBox.tsx (80%) create mode 100644 packages/ui-react/src/components/ProfileCircle/ProfileCircle.module.scss create mode 100644 packages/ui-react/src/components/ProfileCircle/ProfileCircle.test.tsx rename {src/icons => packages/ui-react/src/components/ProfileCircle}/ProfileCircle.tsx (81%) create mode 100644 packages/ui-react/src/components/ProfileCircle/__snapshots__/ProfileCircle.test.tsx.snap rename {src => packages/ui-react/src}/components/Radio/Radio.module.scss (92%) rename {src => packages/ui-react/src}/components/Radio/Radio.test.tsx (100%) rename {src => packages/ui-react/src}/components/Radio/Radio.tsx (92%) rename {src => packages/ui-react/src}/components/Radio/__snapshots__/Radio.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/RegistrationForm/RegistrationForm.module.scss (78%) rename {src => packages/ui-react/src}/components/RegistrationForm/RegistrationForm.test.tsx (91%) rename {src => packages/ui-react/src}/components/RegistrationForm/RegistrationForm.tsx (77%) rename {src => packages/ui-react/src}/components/RegistrationForm/__snapshots__/RegistrationForm.test.tsx.snap (75%) rename {src => packages/ui-react/src}/components/RenewSubscriptionForm/RenewSubscriptionForm.module.scss (89%) rename {src => packages/ui-react/src}/components/RenewSubscriptionForm/RenewSubscriptionForm.test.tsx (67%) rename {src => packages/ui-react/src}/components/RenewSubscriptionForm/RenewSubscriptionForm.tsx (83%) rename {src => packages/ui-react/src}/components/RenewSubscriptionForm/__snapshots__/RenewSubscriptionForm.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/ResetPasswordForm/ResetPasswordForm.module.scss (66%) rename {src => packages/ui-react/src}/components/ResetPasswordForm/ResetPasswordForm.test.tsx (100%) rename {src => packages/ui-react/src}/components/ResetPasswordForm/ResetPasswordForm.tsx (74%) rename {src => packages/ui-react/src}/components/ResetPasswordForm/__snapshots__/ResetPasswordForm.test.tsx.snap (85%) rename {src => packages/ui-react/src}/components/RootErrorPage/RootErrorPage.tsx (78%) rename {src => packages/ui-react/src}/components/SearchBar/SearchBar.module.scss (89%) rename {src => packages/ui-react/src}/components/SearchBar/SearchBar.test.tsx (95%) rename {src => packages/ui-react/src}/components/SearchBar/SearchBar.tsx (76%) rename {src => packages/ui-react/src}/components/SearchBar/__snapshots__/SearchBar.test.tsx.snap (54%) rename {src => packages/ui-react/src}/components/ShareButton/ShareButton.test.tsx (100%) rename {src => packages/ui-react/src}/components/ShareButton/ShareButton.tsx (67%) create mode 100644 packages/ui-react/src/components/ShareButton/__snapshots__/ShareButton.test.tsx.snap rename {src => packages/ui-react/src}/components/Shelf/Shelf.module.scss (100%) rename {src => packages/ui-react/src}/components/Shelf/Shelf.test.tsx (95%) rename {src => packages/ui-react/src}/components/Shelf/Shelf.tsx (84%) rename {src => packages/ui-react/src}/components/Shelf/__snapshots__/Shelf.test.tsx.snap (89%) rename {src => packages/ui-react/src}/components/Sidebar/Sidebar.module.scss (88%) rename {src => packages/ui-react/src}/components/Sidebar/Sidebar.test.tsx (79%) rename {src => packages/ui-react/src}/components/Sidebar/Sidebar.tsx (77%) rename {src => packages/ui-react/src}/components/Sidebar/__snapshots__/Sidebar.test.tsx.snap (71%) rename {src => packages/ui-react/src}/components/SocialButton/SocialButton.module.scss (92%) rename {src => packages/ui-react/src}/components/SocialButton/SocialButton.tsx (63%) rename {src => packages/ui-react/src}/components/SocialButtonsList/SocialButtonsList.module.scss (100%) create mode 100644 packages/ui-react/src/components/SocialButtonsList/SocialButtonsList.tsx rename {src => packages/ui-react/src}/components/Spinner/Spinner.module.scss (92%) rename {src => packages/ui-react/src}/components/Spinner/Spinner.tsx (100%) rename {src => packages/ui-react/src}/components/StatusIcon/StatusIcon.tsx (63%) rename {src => packages/ui-react/src}/components/SubscriptionCancelled/SubscriptionCancelled.module.scss (57%) rename {src => packages/ui-react/src}/components/SubscriptionCancelled/SubscriptionCancelled.test.tsx (100%) rename {src => packages/ui-react/src}/components/SubscriptionCancelled/SubscriptionCancelled.tsx (93%) rename {src => packages/ui-react/src}/components/SubscriptionCancelled/__snapshots__/SubscriptionCancelled.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/SubscriptionRenewed/SubscriptionRenewed.module.scss (58%) rename {src => packages/ui-react/src}/components/SubscriptionRenewed/SubscriptionRenewed.test.tsx (61%) rename {src => packages/ui-react/src}/components/SubscriptionRenewed/SubscriptionRenewed.tsx (77%) rename {src => packages/ui-react/src}/components/SubscriptionRenewed/__snapshots__/SubscriptionRenewed.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/Tag/Tag.module.scss (79%) rename {src => packages/ui-react/src}/components/Tag/Tag.tsx (91%) rename {src => packages/ui-react/src}/components/TextField/TextField.module.scss (94%) rename {src => packages/ui-react/src}/components/TextField/TextField.test.tsx (100%) rename {src => packages/ui-react/src}/components/TextField/TextField.tsx (91%) rename {src => packages/ui-react/src}/components/TextField/__snapshots__/TextField.test.tsx.snap (100%) rename {src => packages/ui-react/src}/components/TileDock/TileDock.module.scss (93%) rename {src => packages/ui-react/src}/components/TileDock/TileDock.tsx (100%) rename {src => packages/ui-react/src}/components/UpgradeSubscription/UpgradeSubscription.module.scss (62%) rename {src => packages/ui-react/src}/components/UpgradeSubscription/UpgradeSubscription.tsx (95%) rename {src => packages/ui-react/src}/components/UserMenu/ProfilesMenu/ProfilesMenu.tsx (79%) rename {src => packages/ui-react/src}/components/UserMenu/UserMenu.module.scss (91%) create mode 100644 packages/ui-react/src/components/UserMenu/UserMenu.test.tsx rename {src => packages/ui-react/src}/components/UserMenu/UserMenu.tsx (64%) rename {src => packages/ui-react/src}/components/UserMenu/__snapshots__/UserMenu.test.tsx.snap (57%) rename {src => packages/ui-react/src}/components/VideoDetails/VideoDetails.module.scss (94%) rename {src => packages/ui-react/src}/components/VideoDetails/VideoDetails.test.tsx (100%) rename {src => packages/ui-react/src}/components/VideoDetails/VideoDetails.tsx (85%) rename {src => packages/ui-react/src}/components/VideoDetails/__snapshots__/VideoDetails.test.tsx.snap (98%) rename {src => packages/ui-react/src}/components/VideoDetailsInline/VideoDetailsInline.module.scss (91%) rename {src => packages/ui-react/src}/components/VideoDetailsInline/VideoDetailsInline.tsx (79%) rename {src => packages/ui-react/src}/components/VideoLayout/VideoLayout.module.scss (95%) rename {src => packages/ui-react/src}/components/VideoLayout/VideoLayout.tsx (87%) rename {src => packages/ui-react/src}/components/VideoList/VideoList.module.scss (52%) rename {src => packages/ui-react/src}/components/VideoList/VideoList.tsx (83%) rename {src => packages/ui-react/src}/components/VideoListItem/VideoListItem.module.scss (93%) rename {src => packages/ui-react/src}/components/VideoListItem/VideoListItem.tsx (81%) rename {src => packages/ui-react/src}/components/WaitingForPayment/WaitingForPayment.module.scss (100%) rename {src => packages/ui-react/src}/components/WaitingForPayment/WaitingForPayment.tsx (66%) rename {src => packages/ui-react/src}/components/Welcome/Welcome.module.scss (50%) rename {src => packages/ui-react/src}/components/Welcome/Welcome.test.tsx (100%) rename {src => packages/ui-react/src}/components/Welcome/Welcome.tsx (88%) rename {src => packages/ui-react/src}/components/Welcome/__snapshots__/Welcome.test.tsx.snap (100%) create mode 100644 packages/ui-react/src/containers/AccountModal/AccountModal.module.scss create mode 100644 packages/ui-react/src/containers/AccountModal/AccountModal.test.tsx rename {src => packages/ui-react/src}/containers/AccountModal/AccountModal.tsx (62%) rename {src => packages/ui-react/src}/containers/AccountModal/__snapshots__/AccountModal.test.tsx.snap (100%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/CancelSubscription.tsx (67%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/Checkout.tsx (78%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/ChooseOffer.tsx (75%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/EditCardDetails.tsx (71%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/EditPassword.tsx (82%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/Login.tsx (65%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/PersonalDetails.tsx (86%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/Registration.tsx (79%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/RenewSubscription.tsx (69%) rename {src => packages/ui-react/src}/containers/AccountModal/forms/ResetPassword.tsx (74%) rename {src => packages/ui-react/src}/containers/AdyenInitialPayment/AdyenInitialPayment.tsx (83%) rename {src => packages/ui-react/src}/containers/AdyenPaymentDetails/AdyenPaymentDetails.tsx (78%) rename {src => packages/ui-react/src}/containers/Cinema/Cinema.module.scss (88%) rename {src => packages/ui-react/src}/containers/Cinema/Cinema.test.tsx (51%) rename {src => packages/ui-react/src}/containers/Cinema/Cinema.tsx (86%) rename {src => packages/ui-react/src}/containers/Cinema/__snapshots__/Cinema.test.tsx.snap (84%) rename {src => packages/ui-react/src}/containers/FavoriteButton/FavoriteButton.tsx (62%) rename {src => packages/ui-react/src}/containers/InlinePlayer/InlinePlayer.module.scss (89%) rename {src => packages/ui-react/src}/containers/InlinePlayer/InlinePlayer.tsx (76%) rename {src => packages/ui-react/src}/containers/Layout/Layout.module.scss (86%) create mode 100644 packages/ui-react/src/containers/Layout/Layout.test.tsx rename {src => packages/ui-react/src}/containers/Layout/Layout.tsx (69%) rename {src => packages/ui-react/src}/containers/Layout/__snapshots__/Layout.test.tsx.snap (77%) rename {src => packages/ui-react/src}/containers/PaymentContainer/PaymentContainer.tsx (62%) create mode 100644 packages/ui-react/src/containers/PlayerContainer/PlayerContainer.module.scss rename {src => packages/ui-react/src}/containers/PlayerContainer/PlayerContainer.tsx (72%) rename {src => packages/ui-react/src}/containers/PlaylistContainer/PlaylistContainer.tsx (80%) rename {src => packages/ui-react/src}/containers/Profiles/CreateProfile.tsx (71%) rename {src => packages/ui-react/src}/containers/Profiles/DeleteProfile.tsx (66%) rename {src => packages/ui-react/src}/containers/Profiles/EditProfile.tsx (77%) rename {src => packages/ui-react/src}/containers/Profiles/Form.tsx (83%) rename {src => packages/ui-react/src}/containers/Profiles/Profiles.module.scss (97%) rename {src => packages/ui-react/src}/containers/Profiles/Profiles.tsx (82%) rename {src => packages/ui-react/src}/containers/Profiles/avatarUrls.json (100%) rename {src => packages/ui-react/src}/containers/QueryProvider/QueryProvider.tsx (100%) rename {src => packages/ui-react/src}/containers/ShelfList/ShelfList.module.scss (79%) rename {src => packages/ui-react/src}/containers/ShelfList/ShelfList.tsx (77%) rename {src => packages/ui-react/src}/containers/StartWatchingButton/StartWatchingButton.module.scss (66%) rename {src => packages/ui-react/src}/containers/StartWatchingButton/StartWatchingButton.tsx (72%) rename {src => packages/ui-react/src}/containers/TrailerModal/TrailerModal.module.scss (71%) rename {src => packages/ui-react/src}/containers/TrailerModal/TrailerModal.tsx (78%) rename {src => packages/ui-react/src}/containers/UpdatePaymentMethod/UpdatePaymentMethod.tsx (68%) rename {src => packages/ui-react/src}/hooks/useBreakpoint.ts (95%) create mode 100644 packages/ui-react/src/hooks/useQueryParam.ts rename {src => packages/ui-react/src}/hooks/useSearchQueryUpdater.ts (83%) create mode 100644 packages/ui-react/src/index.ts rename {src => packages/ui-react/src}/pages/About/About.module.scss (61%) rename {src => packages/ui-react/src}/pages/About/About.test.tsx (100%) rename {src => packages/ui-react/src}/pages/About/About.tsx (97%) rename {src => packages/ui-react/src}/pages/About/__snapshots__/About.test.tsx.snap (100%) rename {src => packages/ui-react/src}/pages/Home/Home.test.tsx (90%) create mode 100644 packages/ui-react/src/pages/Home/Home.tsx rename {src => packages/ui-react/src}/pages/Home/__snapshots__/Home.test.tsx.snap (58%) rename {src => packages/ui-react/src}/pages/LegacySeries/LegacySeries.tsx (83%) rename {src => packages/ui-react/src}/pages/LegacySeries/utils.ts (92%) rename {src => packages/ui-react/src}/pages/Loading/Loading.module.scss (100%) rename {src => packages/ui-react/src}/pages/Loading/Loading.tsx (76%) rename {src => packages/ui-react/src}/pages/ScreenRouting/MediaScreenRouter.tsx (59%) rename {src => packages/ui-react/src}/pages/ScreenRouting/PlaylistScreenRouter.tsx (53%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaEpisode/MediaEpisode.tsx (73%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaEvent/MediaEvent.tsx (75%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaHub/MediaHub.tsx (73%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaLiveChannel/MediaLiveChannel.tsx (56%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaMovie/MediaMovie.tsx (77%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaSeries/MediaSeries.tsx (80%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaStaticPage/MediaStaticPage.module.scss (62%) rename {src => packages/ui-react/src}/pages/ScreenRouting/mediaScreens/MediaStaticPage/MediaStaticPage.tsx (67%) rename {src => packages/ui-react/src}/pages/ScreenRouting/playlistScreens/PlaylistGrid/PlaylistGrid.module.scss (85%) rename {src => packages/ui-react/src}/pages/ScreenRouting/playlistScreens/PlaylistGrid/PlaylistGrid.tsx (72%) rename {src => packages/ui-react/src}/pages/ScreenRouting/playlistScreens/PlaylistLiveChannels/PlaylistLiveChannels.module.scss (80%) rename {src => packages/ui-react/src}/pages/ScreenRouting/playlistScreens/PlaylistLiveChannels/PlaylistLiveChannels.tsx (81%) rename {src => packages/ui-react/src}/pages/Search/Search.module.scss (78%) rename {src => packages/ui-react/src}/pages/Search/Search.tsx (73%) rename {src => packages/ui-react/src}/pages/User/User.module.scss (92%) rename {src => packages/ui-react/src}/pages/User/User.test.tsx (56%) rename {src => packages/ui-react/src}/pages/User/User.tsx (72%) rename {src => packages/ui-react/src}/pages/User/__snapshots__/User.test.tsx.snap (74%) rename {src => packages/ui-react/src}/styles/_theme.scss (100%) rename {src => packages/ui-react/src}/styles/_variables.scss (100%) create mode 100644 packages/ui-react/src/styles/accessibility.scss rename {src => packages/ui-react/src}/styles/mixins/_responsive.scss (97%) rename {src => packages/ui-react/src}/styles/mixins/_typography.scss (90%) rename {src => packages/ui-react/src}/styles/mixins/_utils.scss (92%) create mode 100644 packages/ui-react/src/utils/clipboard.ts rename {src => packages/ui-react/src}/utils/dom.ts (58%) create mode 100644 packages/ui-react/src/utils/location.test.ts create mode 100644 packages/ui-react/src/utils/location.ts rename {src => packages/ui-react/src}/utils/matchMedia.ts (100%) create mode 100644 packages/ui-react/src/utils/theming.ts create mode 100644 packages/ui-react/stylelint.config.js rename test/testUtils.tsx => packages/ui-react/test/utils.tsx (87%) create mode 100644 packages/ui-react/tsconfig.json create mode 100644 packages/ui-react/types/env.d.ts rename {types => packages/ui-react/types}/screens.d.ts (100%) create mode 100644 packages/ui-react/types/static.d.ts create mode 100644 packages/ui-react/vitest.config.ts create mode 100644 packages/ui-react/vitest.setup.ts create mode 100644 platforms/web/.depcheckrc.yaml rename .env => platforms/web/.env (82%) rename .env.jwdev => platforms/web/.env.jwdev (100%) create mode 100644 platforms/web/.eslintrc.js rename .firebaserc => platforms/web/.firebaserc (100%) create mode 100644 platforms/web/.gitignore rename firebase.json => platforms/web/firebase.json (100%) rename index.html => platforms/web/index.html (100%) rename {ini/templates => platforms/web/ini}/.webapp.dev.ini (100%) rename {ini/templates => platforms/web/ini}/.webapp.test.ini (100%) rename {ini => platforms/web/ini}/templates/.webapp.demo.ini (100%) create mode 100644 platforms/web/ini/templates/.webapp.dev.ini rename {ini => platforms/web/ini}/templates/.webapp.jwdev.ini (100%) rename {ini => platforms/web/ini}/templates/.webapp.preview.ini (100%) rename {ini => platforms/web/ini}/templates/.webapp.prod.ini (100%) create mode 100644 platforms/web/ini/templates/.webapp.test.ini rename lighthouserc.js => platforms/web/lighthouserc.js (100%) create mode 100644 platforms/web/lint-staged.config.js create mode 100644 platforms/web/package.json create mode 100644 platforms/web/postcss.config.js rename {public => platforms/web/public}/browserconfig.xml (100%) rename {public => platforms/web/public}/favicon.ico (100%) rename {public => platforms/web/public}/images/android-chrome-144x144.png (100%) rename {public => platforms/web/public}/images/apple-touch-icon.png (100%) rename {public => platforms/web/public}/images/avatars/Alien.svg (100%) rename {public => platforms/web/public}/images/avatars/Bear.svg (100%) rename {public => platforms/web/public}/images/avatars/Brainy.svg (100%) rename {public => platforms/web/public}/images/avatars/Cooool.svg (100%) rename {public => platforms/web/public}/images/avatars/Dummy.svg (100%) rename {public => platforms/web/public}/images/avatars/Frog.svg (100%) rename {public => platforms/web/public}/images/avatars/Goofball.svg (100%) rename {public => platforms/web/public}/images/avatars/Marilyn.svg (100%) rename {public => platforms/web/public}/images/avatars/Smiley.svg (100%) rename {public => platforms/web/public}/images/avatars/ToughGuy.svg (100%) rename {public => platforms/web/public}/images/avatars/UhOh.svg (100%) rename {public => platforms/web/public}/images/avatars/Vibe.svg (100%) rename {public => platforms/web/public}/images/favicon-16x16.png (100%) rename {public => platforms/web/public}/images/favicon-32x32.png (100%) rename {public => platforms/web/public}/images/logo.png (100%) rename {public => platforms/web/public}/images/logo.svg (100%) rename {public => platforms/web/public}/images/mstile-150x150.png (100%) rename {public => platforms/web/public}/images/payments/amex.svg (100%) rename {public => platforms/web/public}/images/payments/diners.svg (100%) rename {public => platforms/web/public}/images/payments/discover.svg (100%) rename {public => platforms/web/public}/images/payments/hiper.svg (100%) rename {public => platforms/web/public}/images/payments/maestro.svg (100%) rename {public => platforms/web/public}/images/payments/mastercard.svg (100%) rename {public => platforms/web/public}/images/payments/unionpay.svg (100%) rename {public => platforms/web/public}/images/payments/visa.svg (100%) rename {public => platforms/web/public}/images/safari-pinned-tab.svg (100%) rename {public => platforms/web/public}/jwpltx.js (100%) rename {public => platforms/web/public}/locales/en/account.json (99%) rename {public => platforms/web/public}/locales/en/common.json (95%) rename {public => platforms/web/public}/locales/en/country.json (100%) rename {public => platforms/web/public}/locales/en/demo.json (100%) rename {public => platforms/web/public}/locales/en/epg.json (100%) rename {public => platforms/web/public}/locales/en/error.json (100%) rename {public => platforms/web/public}/locales/en/menu.json (75%) rename {public => platforms/web/public}/locales/en/search.json (100%) rename {public => platforms/web/public}/locales/en/us_state.json (100%) rename {public => platforms/web/public}/locales/en/user.json (100%) rename {public => platforms/web/public}/locales/en/video.json (85%) rename {public => platforms/web/public}/locales/es/account.json (98%) rename {public => platforms/web/public}/locales/es/common.json (95%) rename {public => platforms/web/public}/locales/es/country.json (100%) rename {public => platforms/web/public}/locales/es/demo.json (100%) rename {public => platforms/web/public}/locales/es/epg.json (100%) rename {public => platforms/web/public}/locales/es/error.json (100%) rename {public => platforms/web/public}/locales/es/menu.json (75%) rename {public => platforms/web/public}/locales/es/search.json (100%) rename {public => platforms/web/public}/locales/es/us_state.json (100%) rename {public => platforms/web/public}/locales/es/user.json (100%) rename {public => platforms/web/public}/locales/es/video.json (85%) rename {public => platforms/web/public}/manifest.json (100%) rename {public => platforms/web/public}/robots.txt (100%) rename {scripts => platforms/web/scripts}/build-tools/settings.ts (100%) rename {scripts => platforms/web/scripts}/compressIni.sh (100%) rename {scripts => platforms/web/scripts}/waitOnConfig.js (100%) rename {src => platforms/web/src}/App.tsx (71%) rename {src => platforms/web/src}/components/DemoConfigDialog/DemoConfigDialog.module.scss (90%) rename {src => platforms/web/src}/components/DemoConfigDialog/DemoConfigDialog.test.tsx (87%) rename {src => platforms/web/src}/components/DemoConfigDialog/DemoConfigDialog.tsx (86%) rename {src => platforms/web/src}/components/DemoConfigDialog/__snapshots__/DemoConfigDialog.test.tsx.snap (79%) create mode 100644 platforms/web/src/constants.ts rename {src => platforms/web/src}/containers/AppRoutes/AppRoutes.tsx (63%) create mode 100644 platforms/web/src/containers/Root/Root.tsx rename {src => platforms/web/src}/hooks/useNotifications.ts (69%) rename {src => platforms/web/src}/hooks/useTrackConfigKeyChange.ts (90%) rename {src => platforms/web/src}/i18n/config.ts (76%) rename {src => platforms/web/src}/i18n/resources.ts (100%) create mode 100644 platforms/web/src/index.tsx create mode 100644 platforms/web/src/modules/register.ts rename {src => platforms/web/src}/screenMapping.ts (81%) create mode 100644 platforms/web/src/services/LocalStorageService.ts rename {src => platforms/web/src}/styles/main.scss (87%) create mode 100644 platforms/web/src/utils/ip.ts create mode 100644 platforms/web/stylelint.config.js rename {test-cases => platforms/web/test-cases}/analytics/adv.feature (100%) rename {test-cases => platforms/web/test-cases}/analytics/init.feature (100%) rename {test-cases => platforms/web/test-cases}/analytics/params.feature (100%) rename {test-cases => platforms/web/test-cases}/analytics/quantile.feature (100%) rename {test-e2e => platforms/web/test-e2e}/.eslintrc.js (100%) rename {test-e2e => platforms/web/test-e2e}/codecept.desktop.js (92%) rename {test-e2e => platforms/web/test-e2e}/codecept.mobile.js (93%) rename {test-e2e => platforms/web/test-e2e}/tests/account_test.ts (99%) rename {test-e2e => platforms/web/test-e2e}/tests/favorites_test.ts (97%) rename {test-e2e => platforms/web/test-e2e}/tests/home_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/inline_layout_test.ts (96%) rename {test-e2e => platforms/web/test-e2e}/tests/language_test.ts (60%) rename {test-e2e => platforms/web/test-e2e}/tests/live_channel_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/login/account_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/login/home_test.ts (96%) rename {test-e2e => platforms/web/test-e2e}/tests/payments/coupons_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/payments/subscription_test.ts (99%) rename {test-e2e => platforms/web/test-e2e}/tests/playlist_test.ts (96%) rename {test-e2e => platforms/web/test-e2e}/tests/register_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/search_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/seo_test.ts (98%) rename {test-e2e => platforms/web/test-e2e}/tests/series_test.ts (65%) rename {test-e2e => platforms/web/test-e2e}/tests/video_detail_test.ts (97%) rename {test-e2e => platforms/web/test-e2e}/tests/watch_history/local_test.ts (97%) rename {test-e2e => platforms/web/test-e2e}/tests/watch_history/logged_in_test.ts (97%) rename {test-e2e => platforms/web/test-e2e}/tsconfig.json (81%) rename {test-e2e => platforms/web/test-e2e}/utils/constants.ts (100%) rename {test-e2e => platforms/web/test-e2e}/utils/login.ts (100%) rename {test-e2e => platforms/web/test-e2e}/utils/password_utils.ts (100%) rename {test-e2e => platforms/web/test-e2e}/utils/payments.ts (98%) rename {test-e2e => platforms/web/test-e2e}/utils/randomizers.ts (100%) rename {test-e2e => platforms/web/test-e2e}/utils/steps.d.ts (100%) rename {test-e2e => platforms/web/test-e2e}/utils/steps_file.ts (99%) rename {test-e2e => platforms/web/test-e2e}/utils/watch_history.ts (100%) create mode 100644 platforms/web/test/.depcheckrc.yaml rename {test => platforms/web/test}/types.ts (89%) rename {test => platforms/web/test}/vitest.setup.ts (52%) create mode 100644 platforms/web/tsconfig.json rename {types => platforms/web/types}/env.d.ts (83%) create mode 100644 platforms/web/types/global.d.ts create mode 100644 platforms/web/types/i18next.d.ts create mode 100644 platforms/web/types/jwpltx.d.ts rename {types => platforms/web/types}/static.d.ts (100%) rename vite.config.ts => platforms/web/vite.config.ts (91%) delete mode 100644 scripts/i18next/tsconfig.json rename scripts/{content-types => }/tsconfig.json (63%) delete mode 100644 src/components/Account/Account.test.tsx delete mode 100644 src/components/Adyen/Adyen.module.scss delete mode 100644 src/components/CustomRegisterField/__snapshots__/CustomRegisterField.test.tsx.snap delete mode 100644 src/components/LanguageMenu/LanguageMenu.test.tsx delete mode 100644 src/components/LanguageMenu/LanguageMenu.tsx delete mode 100644 src/components/LanguageMenu/__snapshots__/LanguageMenu.test.tsx.snap delete mode 100644 src/components/Panel/Panel.tsx delete mode 100644 src/components/Root/Root.tsx delete mode 100644 src/components/ShareButton/__snapshots__/ShareButton.test.tsx.snap delete mode 100644 src/components/SocialButtonsList/SocialButtonsList.tsx delete mode 100644 src/components/UserMenu/UserMenu.test.tsx delete mode 100644 src/containers/AccountModal/AccountModal.module.scss delete mode 100644 src/containers/AccountModal/AccountModal.test.tsx delete mode 100644 src/containers/Layout/Layout.test.tsx delete mode 100644 src/containers/PlayerContainer/PlayerContainer.module.scss delete mode 100644 src/hooks/useBootstrapApp.ts delete mode 100644 src/hooks/useLiveEvent.ts delete mode 100644 src/hooks/useProtectedMedia.ts delete mode 100644 src/hooks/useQueryParam.ts delete mode 100644 src/hooks/useStartWatchingButton.ts delete mode 100644 src/icons/AccountCircle.tsx delete mode 100644 src/icons/ArrowLeft.tsx delete mode 100644 src/icons/ArrowLeftRight.tsx delete mode 100644 src/icons/BalanceWallet.tsx delete mode 100644 src/icons/Check.tsx delete mode 100644 src/icons/CheckCircle.tsx delete mode 100644 src/icons/ChevronLeft.tsx delete mode 100644 src/icons/ChevronRight.tsx delete mode 100644 src/icons/Close.tsx delete mode 100644 src/icons/CreditCard.tsx delete mode 100644 src/icons/Edit.tsx delete mode 100644 src/icons/Exit.tsx delete mode 100644 src/icons/ExternalLink.tsx delete mode 100644 src/icons/Favorite.tsx delete mode 100644 src/icons/FavoriteBorder.tsx delete mode 100644 src/icons/Icon.tsx delete mode 100644 src/icons/Menu.tsx delete mode 100644 src/icons/PayPal.tsx delete mode 100644 src/icons/Play.tsx delete mode 100644 src/icons/PlayTrailer.tsx delete mode 100644 src/icons/Search.tsx delete mode 100644 src/icons/Share.tsx delete mode 100644 src/icons/Today.tsx delete mode 100644 src/icons/Visibility.tsx delete mode 100644 src/icons/VisibilityOff.tsx delete mode 100644 src/index.tsx delete mode 100644 src/modules/register.ts delete mode 100644 src/pages/Home/Home.tsx delete mode 100644 src/pages/ScreenRouting/ScreenMap.ts delete mode 100644 src/services/cleeng.checkout.service.ts delete mode 100644 src/services/cleeng.subscription.service.ts delete mode 100644 src/services/config.service.ts delete mode 100644 src/services/profile.service.ts delete mode 100644 src/stores/AppController.ts delete mode 100644 src/utils/broadcaster.test.ts delete mode 100644 src/utils/broadcaster.ts delete mode 100644 src/utils/domHelpers.ts delete mode 100644 src/utils/location.ts delete mode 100644 src/utils/persist.ts create mode 100644 tsconfig.base.json delete mode 100644 tsconfig.json delete mode 100644 types/entitlement.d.ts delete mode 100644 types/service.d.ts create mode 100644 vitest.workspace.ts diff --git a/.commitlintrc.js b/.commitlintrc.js index b2a5c30f7..6ac461ff6 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -15,6 +15,7 @@ module.exports = { 'user', 'watchhistory', 'favorites', + 'profiles', 'analytics', 'pwa', 'seo', @@ -29,6 +30,7 @@ module.exports = { 'epg', 'tests', 'i18n', + 'a11y', ], ], }, diff --git a/.depcheckrc.yaml b/.depcheckrc.yaml index 5b5e22f65..acab1bd22 100644 --- a/.depcheckrc.yaml +++ b/.depcheckrc.yaml @@ -1,30 +1,16 @@ ignores: [ - # These are dependencies for vite and vite plugins that depcheck doesn't recognize as being used - 'postcss-scss', - 'stylelint-order', - 'stylelint-config-recommended-scss', - 'stylelint-declaration-strict-value', - 'stylelint-scss', - '@vitest/coverage-v8', # This is used by commitlint in .commitlintrc.js '@commitlint/config-conventional', - # These are vite aliases / tsconfig paths that point to specific local directories - # Note the \ is necessary to escape the # or the ignore doesn't work - '\#src', - '\#test', - '\#types', - '\#components', - '\#utils', - 'src', # This is used in src/styles, which recognizes absolute paths from the repo root - 'allure-commandline', # To support e2e-reports - '@codeceptjs/allure-legacy', - 'faker', - 'i18next-parser', # For extracting i18next translation keys - 'npm-run-all', # To run linting checks - 'virtual:pwa-register', # Service Worker code is injected at build time - 'vite-plugin-pwa/client', # Used to generate pwa framework - 'reflect-metadata', # Used for ioc resolution - '@babel/plugin-proposal-decorators', # Used to build with decorators for ioc resolution - 'babel-plugin-transform-typescript-metadata', # Used to build with decorators for ioc resolution - '@babel/core', # Required peer dependency for babel plugins above + + # @todo: remove dep from main package, once i18next is moved to specific packages + 'ts-node', + + # Workspace packages + 'eslint-config-jwp', + '@typescript-eslint/parser', # Required by eslint-config-jwp + '@typescript-eslint/eslint-plugin', # Required by eslint-config-jwp + 'eslint-plugin-import', # Required by eslint-config-jwp + + 'depcheck', + 'ts-node' ] diff --git a/.eslintignore b/.eslintignore index bdea5b275..b4f25ea4d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,8 +1,6 @@ # Modules node_modules/ -# Test -coverage/ - -# Build output build/ + +coverage/ diff --git a/.eslintrc.js b/.eslintrc.js index c1d28d023..aace0df8b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,129 +1,3 @@ -const restrictedGlobals = require('confusing-browser-globals'); - module.exports = { - parser: '@typescript-eslint/parser', - - plugins: [ - // Enable Typescript linting - '@typescript-eslint', - - // Enable linting imports - 'import', - ], - - extends: [ - // Use default ESLint rules - 'eslint:recommended', - - // Use recommended TS rules - 'plugin:@typescript-eslint/recommended', - - // Use recommended React rules - 'plugin:react/recommended', - - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:import/typescript', - ], - - env: { - // Browser conf - browser: true, - es6: true, - }, - - rules: { - // Prevent development/debugging statements - 'no-console': ['error', { allow: ['warn', 'error', 'info', 'debug'] }], - 'no-alert': 'error', - 'no-debugger': 'error', - - // Prevent usage of confusing globals - 'no-restricted-globals': ['error'].concat(restrictedGlobals), - - // Assignments in function returns is confusing and could lead to unwanted side-effects - 'no-return-assign': ['error', 'always'], - - curly: ['error', 'multi-line'], - - // Strict import ordering - 'import/order': [ - 'warn', - { - groups: ['builtin', 'external', 'parent', 'sibling', 'index'], - pathGroups: [ - // Sort absolute root imports before parent imports - { - pattern: '/**', - group: 'parent', - position: 'before', - }, - ], - 'newlines-between': 'always', - }, - ], - // Not needed in React 17 - 'react/react-in-jsx-scope': 'off', - 'import/no-named-as-default-member': 'off', - }, - overrides: [ - { - files: ['*.js'], - env: { - // We may still use CJS in .js files (eg. local scripts) - commonjs: true, - }, - rules: { - // `require` is still allowed/recommended in JS - '@typescript-eslint/no-var-requires': 'off', - }, - }, - { - files: ['*.ts', '*.tsx'], - rules: { - // TypeScript 4.0 adds 'any' or 'unknown' type annotation on catch clause variables. - // We need to make sure error is of the type we are expecting - '@typescript-eslint/no-implicit-any-catch': 'error', - - // These are handled by TS - '@typescript-eslint/no-explicit-any': ['warn', { ignoreRestArgs: true }], - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-inferrable-types': 'off', - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-unused-vars': 'off', - 'import/no-unresolved': 'off', - }, - }, - { - files: ['*.jsx', '*.tsx', 'src/hooks/*.ts'], - plugins: [ - // Enable linting React code - 'react', - 'react-hooks', - ], - rules: { - // Help with Hooks syntax - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'error', - - // Handled by Typescript - 'react/prop-types': 'off', - - // This rule causes too many false positives, eg. with default exports or child render function - 'react/display-name': 'off', - }, - }, - ], - - settings: { - react: { - pragma: 'React', - version: '17', - }, - }, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, + extends: ['jwp/typescript'], }; diff --git a/.github/workflows/release-build-tag-release.yml b/.github/workflows/release-build-tag-release.yml index bce10b86f..26aa6ac42 100644 --- a/.github/workflows/release-build-tag-release.yml +++ b/.github/workflows/release-build-tag-release.yml @@ -24,6 +24,7 @@ jobs: echo "current-version=${version}" >> "$GITHUB_OUTPUT" - name: Build App + working-directory: ./platforms/web run: | yarn build cd build @@ -51,7 +52,7 @@ jobs: if: ${{ steps.package-version.outputs.current-version }} with: commit: 'release' - artifacts: 'build/ott-web-app-build-*.tar.gz, build/ott-web-app-build-*.zip' + artifacts: 'web/build/ott-web-app-build-*.tar.gz, web/build/ott-web-app-build-*.zip' tag: v${{ steps.package-version.outputs.current-version }} bodyFile: '.github/RELEASE_BODY_TEMPLATE.md' token: ${{ secrets.github_token }} diff --git a/.github/workflows/test-unit-snapshot.yml b/.github/workflows/test-unit-snapshot.yml index 9983e7620..a002dcb8e 100644 --- a/.github/workflows/test-unit-snapshot.yml +++ b/.github/workflows/test-unit-snapshot.yml @@ -19,7 +19,7 @@ jobs: uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} - - name: yarn install, build, and test + - name: yarn install and test run: | yarn yarn test diff --git a/.github/workflows/release-deploy-prod-demo.yml b/.github/workflows/web-release-deploy-prod-demo.yml similarity index 81% rename from .github/workflows/release-deploy-prod-demo.yml rename to .github/workflows/web-release-deploy-prod-demo.yml index c4186efb3..108934e81 100644 --- a/.github/workflows/release-deploy-prod-demo.yml +++ b/.github/workflows/web-release-deploy-prod-demo.yml @@ -1,10 +1,14 @@ -name: Release - Deploy Prod Demo Site +name: Web - Release - Deploy Prod Demo Site on: push: branches: ['release'] workflow_dispatch: +defaults: + run: + working-directory: ./platforms/web + jobs: deploy_live_website: runs-on: ubuntu-latest @@ -20,3 +24,4 @@ jobs: repoToken: '${{ secrets.GITHUB_TOKEN }}' firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' channelId: live + entryPoint: './platforms/web' diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/web-test-e2e.yml similarity index 87% rename from .github/workflows/test-e2e.yml rename to .github/workflows/web-test-e2e.yml index a2188c410..928541529 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/web-test-e2e.yml @@ -1,4 +1,4 @@ -name: Test - End to End +name: Web - Test - End to End on: pull_request: @@ -8,6 +8,10 @@ on: - cron: '30 3 * * 1-5' workflow_dispatch: +defaults: + run: + working-directory: ./platforms/web + jobs: test-e2e: runs-on: ubuntu-latest @@ -40,5 +44,5 @@ jobs: uses: actions/upload-artifact@v3 with: name: allure-report-${{ matrix.config }} - path: ./test-e2e/output/${{ matrix.config }} + path: ./platforms/web/test-e2e/output/${{ matrix.config }} retention-days: 7 diff --git a/.github/workflows/test-preview-and-lighthouse.yml b/.github/workflows/web-test-preview-and-lighthouse.yml similarity index 87% rename from .github/workflows/test-preview-and-lighthouse.yml rename to .github/workflows/web-test-preview-and-lighthouse.yml index f6e304754..480f56c91 100644 --- a/.github/workflows/test-preview-and-lighthouse.yml +++ b/.github/workflows/web-test-preview-and-lighthouse.yml @@ -1,8 +1,12 @@ -name: Test - Deploy Preview and Lighthouse Test +name: Web - Test - Deploy Preview and Lighthouse Test on: pull_request: +defaults: + run: + working-directory: ./platforms/web + jobs: build_and_preview: name: Build and preview @@ -18,6 +22,7 @@ jobs: - uses: FirebaseExtended/action-hosting-deploy@v0 id: firebase_hosting_preview with: + entryPoint: './platforms/web' repoToken: '${{ secrets.GITHUB_TOKEN }}' firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' expires: 30d diff --git a/.gitignore b/.gitignore index 54b1538c5..a135a3848 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,11 @@ # project, build, and deployment node_modules -build -.snowpack -coverage -output -public/locales/**/*_old.json yarn-error.log -.firebase -firebase-debug.log -.stylelintcache -.lighthouseci # os or editor .idea .DS_Store .vscode/ -# ignore local files -*.local - -# Exclude ini files because they have customer specific data -ini/*.ini - # Ignore working area for i18n checks .temp-translations diff --git a/.syncpackrc.json b/.syncpackrc.json new file mode 100644 index 000000000..c16bac2c8 --- /dev/null +++ b/.syncpackrc.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://unpkg.com/syncpack@11.2.1/dist/schema.json", + "semverGroups": [ + { + "dependencies": ["codeceptjs", "codeceptjs**", "react-router", "react-router-dom"], + "packages": ["**"], + "isIgnored": true + }, + { + "range": "^", + "dependencies": ["**"], + "packages": ["**"], + "dependencyTypes": ["prod", "dev", "peer", "resolutions"] + } + ], + "versionGroups": [ + { + "label": "Ensure semver ranges for locally developed packages satisfy the local version", + "dependencies": ["@jwp/**", "**-config-jwp"], + "dependencyTypes": ["peer"], + "packages": ["**"], + "pinVersion": "*" + }, + { + "label": "Ensure local packages are installed as peerDependency", + "dependencies": ["@jwp/**", "**-config-jwp"], + "dependencyTypes": ["dev", "prod"], + "packages": ["**"], + "isBanned": true + }, + { + "dependencies": ["@types/**"], + "dependencyTypes": ["!dev"], + "packages": ["**"], + "isBanned": true, + "label": "@types packages should only be under devDependencies" + } + ] +} diff --git a/README.md b/README.md index c897d870b..fdad7997b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ The JW OTT Webapp is an open-source, dynamically generated video website built a - [Frameworks, SDKs and Libraries](./docs/frameworks.md) - [Backend Services](./docs/backend-services.md) - [Developer Guidelines](./docs/developer-guidelines.md) +- [Workspaces](./docs/workspaces.md) ## Supported Features diff --git a/configs/eslint-config-jwp/.depcheckrc.yaml b/configs/eslint-config-jwp/.depcheckrc.yaml new file mode 100644 index 000000000..b7fda79d0 --- /dev/null +++ b/configs/eslint-config-jwp/.depcheckrc.yaml @@ -0,0 +1,8 @@ +ignores: [ + # Dynamically loaded in the eslint config + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + 'eslint-plugin-import', + 'eslint-plugin-react', + 'eslint-plugin-react-hooks' +] diff --git a/configs/eslint-config-jwp/lint-staged.config.js b/configs/eslint-config-jwp/lint-staged.config.js new file mode 100644 index 000000000..5416d297d --- /dev/null +++ b/configs/eslint-config-jwp/lint-staged.config.js @@ -0,0 +1,3 @@ +module.exports = { + '*.js': ['eslint --fix', 'prettier --write'], +}; diff --git a/configs/eslint-config-jwp/package.json b/configs/eslint-config-jwp/package.json new file mode 100644 index 000000000..50921c306 --- /dev/null +++ b/configs/eslint-config-jwp/package.json @@ -0,0 +1,17 @@ +{ + "name": "eslint-config-jwp", + "version": "1.0.0", + "private": true, + "scripts": { + "lint:ts": "exit 0", + "test": "exit 0" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.17.0", + "@typescript-eslint/parser": "^5.17.0", + "confusing-browser-globals": "^1.0.10", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-react": "^7.24.0", + "eslint-plugin-react-hooks": "^4.2.0" + } +} diff --git a/configs/eslint-config-jwp/react.js b/configs/eslint-config-jwp/react.js new file mode 100644 index 000000000..dc9ff7c72 --- /dev/null +++ b/configs/eslint-config-jwp/react.js @@ -0,0 +1,48 @@ +module.exports = { + extends: [ + // Extend the base config + './typescript', + + // Use recommended React rules + 'plugin:react/recommended', + ], + + rules: { + // Not needed in React 17 + 'react/react-in-jsx-scope': 'off', + }, + + overrides: [ + { + files: ['*.jsx', '*.tsx', '*.ts'], + plugins: [ + // Enable linting React code + 'react', + 'react-hooks', + ], + rules: { + // Help with Hooks syntax + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + + // Handled by Typescript + 'react/prop-types': 'off', + + // This rule causes too many false positives, e.g. with default exports or child render function + 'react/display-name': 'off', + }, + }, + ], + + settings: { + react: { + pragma: 'React', + version: '17', + }, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +}; diff --git a/configs/eslint-config-jwp/typescript.js b/configs/eslint-config-jwp/typescript.js new file mode 100644 index 000000000..cc4b40fca --- /dev/null +++ b/configs/eslint-config-jwp/typescript.js @@ -0,0 +1,94 @@ +const restrictedGlobals = require('confusing-browser-globals'); + +module.exports = { + parser: '@typescript-eslint/parser', + + plugins: [ + // Enable Typescript linting + '@typescript-eslint', + + // Enable linting imports + 'import', + ], + + extends: [ + // Use default ESLint rules + 'eslint:recommended', + + // Use recommended TS rules + 'plugin:@typescript-eslint/recommended', + + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + ], + + env: { + // Browser conf + browser: true, + es6: true, + }, + + rules: { + // Prevent development/debugging statements + 'no-console': ['error', { allow: ['warn', 'error', 'info', 'debug'] }], + 'no-alert': 'error', + 'no-debugger': 'error', + + // Prevent usage of confusing globals + 'no-restricted-globals': ['error'].concat(restrictedGlobals), + + // Assignments in function returns is confusing and could lead to unwanted side-effects + 'no-return-assign': ['error', 'always'], + + curly: ['error', 'multi-line'], + + 'import/no-named-as-default-member': 'off', + + // Strict import ordering + 'import/order': [ + 'warn', + { + groups: ['builtin', 'external', 'parent', 'sibling', 'index'], + pathGroups: [ + // Sort absolute root imports before parent imports + { + pattern: '/**', + group: 'parent', + position: 'before', + }, + ], + 'newlines-between': 'always', + }, + ], + }, + overrides: [ + { + files: ['*.js'], + env: { + // We may still use CJS in .js files (eg. local scripts) + commonjs: true, + }, + rules: { + // `require` is still allowed/recommended in JS + '@typescript-eslint/no-var-requires': 'off', + }, + }, + { + files: ['*.ts', '*.tsx'], + rules: { + // TypeScript 4.0 adds 'any' or 'unknown' type annotation on catch clause variables. + // We need to make sure error is of the type we are expecting + '@typescript-eslint/no-implicit-any-catch': 'error', + + // These are handled by TS + '@typescript-eslint/no-explicit-any': ['warn', { ignoreRestArgs: true }], + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'import/no-unresolved': 'off', + }, + }, + ], +}; diff --git a/configs/postcss-config-jwp/.depcheckrc.yaml b/configs/postcss-config-jwp/.depcheckrc.yaml new file mode 100644 index 000000000..39214c2d1 --- /dev/null +++ b/configs/postcss-config-jwp/.depcheckrc.yaml @@ -0,0 +1,4 @@ +ignores: [ + # Dynamically loaded in the postcss config + 'postcss-scss' +] diff --git a/postcss.config.js b/configs/postcss-config-jwp/index.js similarity index 78% rename from postcss.config.js rename to configs/postcss-config-jwp/index.js index 8e0264739..935cfc023 100644 --- a/postcss.config.js +++ b/configs/postcss-config-jwp/index.js @@ -1,4 +1,4 @@ -const stylelintConfig = require('./stylelint.config.js'); +const stylelintConfig = require('stylelint-config-jwp'); module.exports = { syntax: 'postcss-scss', diff --git a/configs/postcss-config-jwp/lint-staged.config.js b/configs/postcss-config-jwp/lint-staged.config.js new file mode 100644 index 000000000..5416d297d --- /dev/null +++ b/configs/postcss-config-jwp/lint-staged.config.js @@ -0,0 +1,3 @@ +module.exports = { + '*.js': ['eslint --fix', 'prettier --write'], +}; diff --git a/configs/postcss-config-jwp/package.json b/configs/postcss-config-jwp/package.json new file mode 100644 index 000000000..884171b26 --- /dev/null +++ b/configs/postcss-config-jwp/package.json @@ -0,0 +1,19 @@ +{ + "name": "postcss-config-jwp", + "version": "1.0.0", + "private": true, + "main": "index.js", + "scripts": { + "lint:ts": "exit 0", + "test": "exit 0" + }, + "devDependencies": { + "stylelint": "^15.11.0", + "postcss": "^8.4.31", + "postcss-import": "^14.0.2", + "postcss-scss": "^4.0.4" + }, + "peerDependencies": { + "stylelint-config-jwp": "*" + } +} diff --git a/configs/stylelint-config-jwp/.depcheckrc.yaml b/configs/stylelint-config-jwp/.depcheckrc.yaml new file mode 100644 index 000000000..c2a6ddbcb --- /dev/null +++ b/configs/stylelint-config-jwp/.depcheckrc.yaml @@ -0,0 +1,8 @@ +ignores: [ + # Dynamically loaded in the stylelint config + 'stylelint', + 'stylelint-order', + 'stylelint-config-recommended-scss', + 'stylelint-declaration-strict-value', + 'stylelint-scss' +] diff --git a/stylelint.config.js b/configs/stylelint-config-jwp/index.js similarity index 97% rename from stylelint.config.js rename to configs/stylelint-config-jwp/index.js index 40429891d..b2fca04db 100644 --- a/stylelint.config.js +++ b/configs/stylelint-config-jwp/index.js @@ -305,9 +305,9 @@ module.exports = (function () { 'no-unknown-animations': true, 'no-descending-specificity': null, - 'at-rule-semicolon-newline-after': 'always', - 'block-opening-brace-newline-after': 'always', - 'block-closing-brace-newline-after': 'always', + + // Reassess this + 'scss/comment-no-empty': null, }, }; })(); diff --git a/configs/stylelint-config-jwp/lint-staged.config.js b/configs/stylelint-config-jwp/lint-staged.config.js new file mode 100644 index 000000000..5416d297d --- /dev/null +++ b/configs/stylelint-config-jwp/lint-staged.config.js @@ -0,0 +1,3 @@ +module.exports = { + '*.js': ['eslint --fix', 'prettier --write'], +}; diff --git a/configs/stylelint-config-jwp/package.json b/configs/stylelint-config-jwp/package.json new file mode 100644 index 000000000..6c0208b6b --- /dev/null +++ b/configs/stylelint-config-jwp/package.json @@ -0,0 +1,17 @@ +{ + "name": "stylelint-config-jwp", + "version": "1.0.0", + "private": true, + "main": "index.js", + "scripts": { + "lint:ts": "exit 0", + "test": "exit 0" + }, + "devDependencies": { + "stylelint": "^15.11.0", + "stylelint-config-recommended-scss": "^13.1.0", + "stylelint-declaration-strict-value": "^1.9.2", + "stylelint-order": "^6.0.3", + "stylelint-scss": "^5.3.1" + } +} diff --git a/docs/backend-services.md b/docs/backend-services.md index bf9f7c900..9ddca20c1 100644 --- a/docs/backend-services.md +++ b/docs/backend-services.md @@ -11,7 +11,7 @@ accounts / authentication, subscription management, and checkout flows. The available backend integrations serve 3 main roles: Accounts, Subscription, and Checkout. Below are the methods that any backend integration needs to support broken down by role: -- [Account](src/services/account.service.ts) +- [Account](../packages/common/src/services/integrations/AccountService.ts) - login - register - getPublisherConsents @@ -25,12 +25,12 @@ that any backend integration needs to support broken down by role: - getLocales - getCaptureStatus - updateCaptureAnswers -- [Subscription](src/services/subscription.service.ts) +- [Subscription](../packages/common/src/services/integrations/SubscriptionService.ts) - getSubscriptions - updateSubscription - getPaymentDetails - getTransactions -- [Checkout](src/services/checkout.service.ts) +- [Checkout](../packages/common/src/services/integrations/CheckoutService.ts) - getOffer - createOrder - updateOrder diff --git a/docs/build-from-source.md b/docs/build-from-source.md index 8d91f907b..0840fd76c 100644 --- a/docs/build-from-source.md +++ b/docs/build-from-source.md @@ -13,47 +13,43 @@ The following tools are needed to start building JW OTT Webapp. Follow the instr ## Build the JW OTT Webapp 1. Clone the **ott-web-app** repository on your local machine. - -```shell -$ cd ~/ - -$ git clone https://github.com/jwplayer/ott-web-app.git -$ cd ott-web-app -``` - + ```shell + $ cd ~/ + + $ git clone https://github.com/jwplayer/ott-web-app.git + $ cd ott-web-app + ``` 2. Install the required dependencies. Optional dependencies include packages that are not necessary to build the project. These optional dependencies can be safely ignored. + ```shell + $ yarn --ignore-optional + ``` + > **NOTE**: Some of the [easy deployments](easy-deployments.md) instructions require installing these optional dependencies. Use the `yarn` command to install all dependencies. The `yarn` command can be run even if `yarn --ignore-optional` has been previously run. -```shell -$ yarn --ignore-optional -``` - -> **NOTE**: Some of the [easy deployments](easy-deployments.md) instructions require installing these optional dependencies. Use the `yarn` command to install all dependencies. The `yarn` command can be run even if `yarn --ignore-optional` has been previously run. - -3. Create or update the .ini files in `/ini` for the modes you will be running in (probably dev and prod). - You can copy the ini file from `/ini/templates` into `/ini`. The files in `/ini` are git-ignored, so you do not need to worry about account values in source control, but you will need to recreate the ini files each time you make a fresh checkout of the repository. - - The .ini files provide startup values to the application such as which app config to load by default. See [initialization-file](initialization-file.md) for more details. +3. Create or update the .ini files in `/platforms/web/ini` for the modes you will be running in (probably dev and prod). + You can copy the ini file from `/platforms/web/ini/templates` into `/platforms/web/ini`. The files in `/platforms/web/ini` are git-ignored, so you do not need to worry about account values in source control, but you will need to recreate the ini files each time you make a fresh checkout of the repository. 4. Start the local development server. -```shell -$ yarn start -``` - -If you encounter any errors, make sure you have correctly set the `defaultConfigSource` in `/ini/.webapp.dev.ini` to point to a valid app config from your JWP account. - -> **NOTE:** Only use the development server for development purposes. The development server is not optimized for production usage. + ```shell + $ cd platforms/web + $ yarn start + ``` + + If you encounter any errors, make sure you have correctly set the `defaultConfigSource` in `/platforms/web/ini/.webapp.dev.ini` to point to a valid app config from your JWP account. + + > **NOTE:** Only use the development server for development purposes. The development server is not optimized for production usage. 5. Build a deployable version of the JW OTT Webapp source code.

This command creates a new folder in the projects root folder named **build**. The `public` folder from the build directory can be uploaded to any static hosting provider to run the web app from that host. -```shell -$ yarn build -``` + ```shell + $ cd platforms/web + $ yarn build + ``` If you have not made any changes to the JW OTT Webapp configuration or source code, changes can now be made. Be sure to run the `yarn build` command after making any changes. -If you encounter any errors, first check to make sure you've properly updated `/ini/.webapp.prod.ini` to point `defaultConfigSource` to your production app config from your JWP account. +If you encounter any errors, first check to make sure you've properly updated `/platforms/web/ini/.webapp.prod.ini` to point `defaultConfigSource` to your production app config from your JWP account. ## Modes @@ -62,7 +58,7 @@ For most cases, you will want to use `dev`, `test`, or `prod` modes. The support Please keep in mind that there is a nuanced difference between vite `mode` and whether you are running a development or production build as determined by [`NODE_ENV`](https://nodejs.dev/en/learn/nodejs-the-difference-between-development-and-production/). Mode can be whatever different deployment environments that our application can be run in, while the build type will always be either `development` or `production`. -Typically when you run the development server using `yarn start`, it will be a `development` build and you build the code with `yarn build` and then host it from static hosting, you will be running a production build. +Typically, when you run the development server using `yarn start`, it will be a `development` build, and you build the code with `yarn build` and then host it from static hosting, you will be running a production build. Production builds optimize code and minimize debug information, while development builds are made for developers to dig into. @@ -70,22 +66,22 @@ Production builds optimize code and minimize debug information, while developmen - **test** - used when running unit and e2e tests. Should typically be run as a production build. Will only load a select list of test app configs. - **prod** - default used when running `yarn build` to create compiled code for production hosting. You should make sure to update the prod .ini file to only allow app configs from your account. - **demo** - used for the [JWP preview site](https://app-preview.jwplayer.com/) and includes a dialog to switch between app configs. Will allow any app-config to be loaded and does not have a default config. -- **preview** - used for github PR previews. Behaves like a hybrid between dev and demo. +- **preview** - used for GitHub PR previews. Behaves like a hybrid between dev and demo. - **jwdev** - this mode is for running code on JW's internal dev environment. It will only work for JW employees on the internal network. ## Env Variables To allow for adjustments to be made at compile time, there are several env variables that get replaced during [vite compile](https://vitejs.dev/guide/env-and-mode.html#env-variables). -These values are then defacto constants, which means code optimizations can remove unused code such as if/else checks using them. +These values are then de facto constants, which means code optimizations can remove unused code such as if/else checks using them. For non-sensitive values, you can add them directly to the appropriate .env file for each mode. -For sensitive values, if building with github actions we recommend using [github secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) and setting them in the [build action environment](https://docs.github.com/en/actions/learn-github-actions/variables). +For sensitive values, if building with GitHub actions we recommend using [GitHub secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) and setting them in the [build action environment](https://docs.github.com/en/actions/learn-github-actions/variables). You can see an example of how these are used in our [Firebase Live / Preview actions.](https://github.com/jwplayer/ott-web-app/blob/develop/.github/workflows/firebase-live.yml#L14) If building manually, you can create an .env.[mode].local file and add the values there. These files are git ignored which will prevent leaking your secrets to version control. -> Note: env variables must begin with the 'APP\_' prefix or they are ignored by our vite configuration. +> Note: env variables must begin with the 'APP_' prefix. or they are ignored by our vite configuration. ### APP_DEFAULT_CONFIG_SOURCE @@ -115,7 +111,7 @@ The value to use can be found labeled 'License Key' in the 'Self-Hosted Web Play If you link directly to your JWP cloud player using the [APP_PLAYER_ID](#app_player_id) environment variable or the [playerId ini setting](initialization-file.md#playerid), you do not need to provide a value for `APP_PLAYER_LICENSE_KEY`. -It is recommended that this value be provided via a .env.local file or a github secret to avoid saving it in version control. +It is recommended that this value be provided via a .env.local file or a GitHub secret to avoid saving it in version control. If you are using pre-compiled builds instead of building the code yourself, you can also set this value with the [playerLicenseKey ini setting](initialization-file.md#playerLicenseKey). Keep in mind, if the [playerLicenseKey ini setting](initialization-file.md#playerLicenseKey) is provided, it will be used even if the `APP_PLAYER_LICENSE_KEY` environment variable is set. diff --git a/docs/content-types.md b/docs/content-types.md index 106614c70..23f0cc7e0 100644 --- a/docs/content-types.md +++ b/docs/content-types.md @@ -4,7 +4,7 @@ In order to map data coming from the JWP delivery pipeline to the correct screen we use the concept of 'content types'. Basically, a content type is simply a custom parameter named 'contentType' on a media item with a value defining which type the media is (movie, trailer, series, etc.) -In the app, content types often map to specific screens (see [screenMapping.ts](src/screenMapping.ts) and [MediaScreenRouter.tsx](src/pages/ScreenRouting/MediaScreenRouter.tsx).) +In the app, content types often map to specific screens (see [screenMapping.ts](../platforms/web/src/screenMapping.ts) and [MediaScreenRouter.tsx](src/pages/ScreenRouting/MediaScreenRouter.tsx).) Each content type screen often expects different metadata attached to the media item's custom parameters. # Using content types in the JWP dashboard @@ -44,6 +44,6 @@ Content types do not change the underlying structure of data returned from the J ## Boolean custom params -Because custom params are always strings and any non-empty string in javascript converts to truthy, we have by convention decided on a few string values to consider striclty true or false. You can find these values in code: https://github.com/jwplayer/ott-web-app/blob/develop/src/utils/common.ts#L86 +Because custom params are always strings and any non-empty string in javascript converts to truthy, we have by convention decided on a few string values to consider strictly true or false. You can find these values in code: https://github.com/jwplayer/ott-web-app/blob/a95c3a3c9d0c5bc7c98b194928261ffc6fc4286f/src/utils/common.ts#L88-L90 Be careful to use the right version considering the fallback when the value is not matched. For example, if you want the property to only be true when matched and fallback to false, use IsTruthy. diff --git a/docs/developer-guidelines.md b/docs/developer-guidelines.md index 478120cd7..2c98923c5 100644 --- a/docs/developer-guidelines.md +++ b/docs/developer-guidelines.md @@ -1,9 +1,9 @@ ## When working on this project, keep these in mind: - Use yarn. -- Run the server through `yarn start` +- Run the server through `yarn start` (in the platforms/web directory) +- Run the e2e tests through `yarn codecept:mobile` and `yarn codecept:desktop` (in the platforms/web directory) - Run the tests through `yarn test` -- Run the e2e tests through `yarn codecept:mobile` and `yarn codecept:desktop` - Format the code through `yarn format` (or automatically do it via git hooks) - Lint through `yarn lint` (eslint, prettier, stylelint and tsc checks) - Run `yarn i18next` to extract all translations keys from source-code @@ -60,25 +60,7 @@ Please use one of the following: The scope must specify the location of the commit change. For example `home` or `search`. -The allowed scopes are: - -- project -- home -- playlist -- videodetail -- player -- series -- search -- user -- watchhistory -- favorites -- analytics -- pwa -- seo -- auth -- menu -- payment -- e2e +The allowed scopes can be found in the [commitlint config file](../.commitlintrc.js). ### Subject @@ -101,42 +83,17 @@ The footer should contain any information about **Breaking Changes** and is also ``` /.github - Templates and action workflows for Github /.husky - Husky scripts for running checks on git triggers -/build* - Directory where the code is compiled by `yarn build` -/coverage* - Location of the C8 coverage report /docs - Documentation /_images - Images used in the docs and README /features - Docs coverage specific product use cases -/ini - Directory to group different initialization files - /templates - Template .ini files per mode /node_modules* - Yarn generated dependencies -/public - Static files to be hosted with the application +/packages - Re-usable code for platfroms (registered in workspace) +/platforms - Platform entry points (registered in workspace) /scripts - Dev helper scripts for i18n, deployment, etc. -/src - Source code for the application - /assets - Static assets (image, svg, etc.) - /components - Reusable, side-effect free UI components - /containers - UI Containers - /hooks - Custom React hooks - /i18n - Internationalization tools - /locales - Languages specific folders with translation json files - /icons - SVG icons wrapped in React Components - /pages - Main application layout containers per route - /ScreenRouting- Mappings from media_type to layout container for medias - /services - Services which connects external data sources to the application - /stores - Zustand stores and controllers - /styles - Global SCSS rules, theming and variables - /utils - Utility functions - /App.tsx - The main React component which renders the app - /index.tsx - The entrypoint - /registerSer... - Script or SPA functionality -/test - Data and scripts for unit and e2e testing -/test-e2e - End to end tests and scripts -/types - Global type definitions -/.env<.mode> - Environment variables for different Vite modes /CHANGELOG.md - Auto-generated changelog -/firebase.json - Config for firebase static hosting -/index.html - Main html file entrypoint for the application /package.json - Yarn file for dependencies and scripts -/vite.config.ts - Vite build and test configuration file +/tsconfig.base.. - The base TS configuration file used in most packages and platforms +/vitest.worksp.. - Vitest workspace configuration file * = Generated directories, not in source control diff --git a/docs/e2e.md b/docs/e2e.md index 995cacbdf..021038b35 100644 --- a/docs/e2e.md +++ b/docs/e2e.md @@ -12,19 +12,19 @@ We use several libraries for e2e-tests: ## Folder structure -We store e2e logic in `test-e2e` folder. Test suites are located in `tests` folder, where each file represents a component / page being tested. If there are several features to test for one page / component, then it is recommended to organize them in a subfolder. +We store e2e logic in `platforms/web/test-e2e` folder. Test suites are located in `platforms/web/tests` folder, where each file represents a component / page being tested. If there are several features to test for one page / component, then it is recommended to organize them in a subfolder. -There are two config files for desktop and mobile testing. By default each test suite works for both mobile and desktop pack. In order to limit test suite as one suitable only for one platform, it is possible to write `(@mobile-only)` in the Scenario description. +There are two config files for desktop and mobile testing. By default, each test suite works for both mobile and desktop pack. In order to limit test suite as one suitable only for one platform, it is possible to write `(@mobile-only)` in the Scenario description. In the `data` folder we store ott-app configs necessary for testing purposes. To load config in the test suite it is possible to use `I.useConfig(__CONFIG_NAME__);` function. -`output` folder consists of allure test reports and screenshots of failed tests (with `mobile` and `desktop` subfolders to separate test results). +`platforms/web/output` folder consists of allure test reports and screenshots of failed tests (with `mobile` and `desktop` subfolders to separate test results). -`utils` folder can be used to store common utils / asserts necessary for test suits. +`platforms/web/test-e2e/utils` folder can be used to store common utils / asserts necessary for test suits. ## Test suite -Each test suite is a separate file located in the `tests` folder. It is necessary to label the suite with the following feature code: `Feature('account').retry(3);`. In order to reduce the chance of unintended failures it is also better to define retry count. This way a test will be relaunched several times in case it failed. +Each test suite is a separate file located in the `platforms/web/tests` folder. It is necessary to label the suite with the following feature code: `Feature('account').retry(3);`. In order to reduce the chance of unintended failures it is also better to define retry count. This way a test will be relaunched several times in case it failed. **TODO:** use `allure.createStep` to have readable steps in allure reports. [Read more.](https://codecept.io/plugins/#allure) @@ -36,6 +36,7 @@ We use several workers to launch tests for each platform. That increases the spe Basic commands: +- `cd platforms/web` - run the following commands from the web platform - `yarn codecept:mobile` - to run tests for a mobile device - `yarn codecept:desktop` - to run tests for desktop - `yarn serve-report:mobile` - to serve allure report from "./output/mobile" folder @@ -56,4 +57,5 @@ To serve allure reports locally `allure-commandline` package should be installed 1. Install Java 8 (for Mac homebrew `adoptopenjdk8` package can be used) 2. `yarn install` 3. Install `allure-commandline` globally (can help in the future to serve downloaded artifacts) -4. Run `yarn codecept-serve:desktop` +4. Run `cd platforms/web` +5. Run `yarn codecept-serve:desktop` diff --git a/docs/easy-deployments.md b/docs/easy-deployments.md index b39bf2ab8..faf0f630a 100644 --- a/docs/easy-deployments.md +++ b/docs/easy-deployments.md @@ -19,8 +19,8 @@ Firebase has both [free and paid plans](https://firebase.google.com/pricing). Th #### Usage Instructions -First, in your fork, you will need to update the project ID in [.firebaserc](.firebaserc). +First, in your fork, you will need to update the project ID in [.firebaserc](../platforms/web/.firebaserc). -The easiest way to deploy is to use the [Firebase-Github integration](https://firebase.google.com/docs/hosting/github-integration). You can find the action [.yml specifications here](https://github.com/marketplace/actions/deploy-to-firebase-hosting) to deploy to a preview channel for each PR and to the live channel for each merge to your main branch. If you want to manually setup the work, you can find those instructions [here](https://github.com/FirebaseExtended/action-hosting-deploy/blob/main/docs/service-account.md). +The easiest way to deploy is to use the [Firebase-Github integration](https://firebase.google.com/docs/hosting/github-integration). You can find the action [.yml specifications here](https://github.com/marketplace/actions/deploy-to-firebase-hosting) to deploy to a preview channel for each PR and to the live channel for each merge to your main branch. If you want to manually set up the work, you can find those instructions [here](https://github.com/FirebaseExtended/action-hosting-deploy/blob/main/docs/service-account.md). You can also manually deploy using the Firebase CLI, as described [here](https://firebase.google.com/docs/hosting/quickstart). diff --git a/docs/features/video-analytics.md b/docs/features/video-analytics.md index 1546e6d1b..c51b6fac4 100644 --- a/docs/features/video-analytics.md +++ b/docs/features/video-analytics.md @@ -41,7 +41,7 @@ The app sends the following events (param `e`) to JW platform: ## Event JS Script -The event trigger implementation for the ott web app can be found at [jwpltx.js](/public/jwpltx.js) +The event trigger implementation for the ott web app can be found at [jwpltx.js](/web/public/jwpltx.js) Note that `navigator.sendBeacon()` is used to call the endpoints. The browser will not do CORS checks on this operation. It furthermore minimizes performance impact as the browser doesn't wait for the response of the server. @@ -51,7 +51,7 @@ It also lets us to use `beforeunload` event in order to send remaining data to a A special data parameter is the Analytics ID (`aid`). It determines to which JW Player account & property the events belong. Each property has its unique analytics ID and is provided by a JW PLayer Solution Engineer or Account manager. -For the OTT Web App the Analytics ID is stored in [`config.json`](/public/config.json) as `analyticsToken` +For the OTT Web App the Analytics ID is stored in [`config.json`](/web/public/config.json) as `analyticsToken` ## Metrics diff --git a/docs/frameworks.md b/docs/frameworks.md index 260b03b7b..23b5fc9db 100644 --- a/docs/frameworks.md +++ b/docs/frameworks.md @@ -103,7 +103,7 @@ The Web App is optimized to use authentication, payments, and subscription servi - Identity management - Monthly and Yearly Subscriptions -- Payments with Paypal or Credit Card with 3D secure +- Payments with PayPal or Credit Card with 3D secure ## Cleeng MediaStore SDK (Optional) diff --git a/docs/initialization-file.md b/docs/initialization-file.md index e9cdc850d..5826605d4 100644 --- a/docs/initialization-file.md +++ b/docs/initialization-file.md @@ -1,12 +1,12 @@ # Initialization (ini) File The JW OTT Web App loads a small initialization (.ini) file at startup. This file provides a mechanism to set key startup parameters without modifying the source code. -Template ini files are included in the repo and with the pre-compiled production release builds ([.webapp.prod.ini](/ini/templates/.webapp.prod.ini)). -Make sure you include a copy of the ini file edited to include your account data at `/public/.webapp.ini` for the application to load correctly. +Template ini files are included in the repo and with the pre-compiled production release builds ([.webapp.prod.ini](/web/ini/templates/.webapp.prod.ini)). +Make sure you include a copy of the ini file edited to include your account data at `platforms/web/public/.webapp.ini` for the application to load correctly. -For all manual builds (`yarn start` or `yarn build`), the ini file is copied from `/ini/.webapp..ini` to `build/public/.webapp.ini`, which the application fetches and parses at startup. For production builds, the ini file is stripped of comments and extra whitespace. -If a file doesn't exist in /ini/.webapp..ini, then the template file will first be copied from [`/ini/templates`](/ini/templates). -All of the .ini files directly inside of `/ini` are ignored in git, so you can create your own files locally to run the application with your account parameters without creating conflicts with committed code or leaking your details into source control. +For all manual builds (`yarn start` or `yarn build`), the ini file is copied from `platforms/web/ini/.webapp..ini` to `platforms/web/build/public/.webapp.ini`, which the application fetches and parses at startup. For production builds, the ini file is stripped of comments and extra whitespace. +If a file doesn't exist in /ini/.webapp..ini, then the template file will first be copied from [`platforms/web/ini/templates`](../platforms/web/ini/templates). +All .ini files directly inside of `platforms/web/ini` are ignored in git, so you can create your own files locally to run the application with your account parameters without creating conflicts with committed code or leaking your details into source control. ## Ini Parameters @@ -47,10 +47,10 @@ Keep in mind, if the playerLicenseKey ini setting is provided, it will be used e An array of 8-character IDs (entered 1 per line) for app configs in your JWP account (or url paths) that can be used with the [`app-config=` query param](configuration.md#switching-between-app-configs). This may be useful for example if you have a staging or experimental config that you want to be able to test on your site using the [`app-config` query parameter](configuration.md#switching-between-app-configs) without changing the default config that the application loads with for all of your users. -See [.webapp.test.ini](/ini/templates/.webapp.test.ini) for an example. +See [.webapp.test.ini](/web/ini/templates/.webapp.test.ini) for an example. ### UNSAFE_allowAnyConfigSource -Boolean flag which if true, enables **ANY** 8-character app config ID (or path) to be specified with the [`app-config=` query param](configuration.md#switching-between-app-configs). +Boolean flag which when true, enables **ANY** 8-character app config ID (or path) to be specified with the [`app-config=` query param](configuration.md#switching-between-app-configs). > _**Warning:** Generally the `UNSAFE_allowAnyConfigSource` option should only be used for dev, test, or demo deployments, because it will allow anyone to create URL's that specify any config to be displayed on your domain._ diff --git a/docs/modularization.md b/docs/modularization.md index 8c26de5c3..198beaf0e 100644 --- a/docs/modularization.md +++ b/docs/modularization.md @@ -42,11 +42,11 @@ This is the price we need to pay to remove `inject` decorators from the construc ## Initialization -We use [register](src/modules/register.ts) function to initialize services and controllers. Some services don't depend on any integration provider (like `ConfigService` or `EpgService`), while such services as `CleengAccountService` or `InplayerAccountService` depend on the provider and get injected into controllers conditionally based on the `INTEGRATION_TYPE` dynamic value (`JWP` or `CLEENG`). +We use [register](../platforms/web/src/modules/register.ts) function to initialize services and controllers. Some services don't depend on any integration provider (like `ConfigService` or `EpgService`), while such services as `CleengAccountService` or `InplayerAccountService` depend on the provider and get injected into controllers conditionally based on the `INTEGRATION_TYPE` dynamic value (`JWP` or `CLEENG`). -Initialization starts in the [index.tsx](src/index.tsx) file where we register services. We do it outside of the react component to make services available in different parts of the application. +Initialization starts in the [index.tsx](../platforms/web/src/index.tsx) file where we register services. We do it outside of the react component to make services available in different parts of the application. -The app is loaded in the [useBootstrapApp](src/hooks/useBootstrapApp.ts) hook with the help of the `AppController` which is responsible for retrieving data from the Config and Settings services, initializing the initial state of the application and hitting init methods of the base app's controllers. +The app is loaded in the [useBootstrapApp](../packages/hooks-react/src/useBootstrapApp.ts) hook with the help of the `AppController` which is responsible for retrieving data from the Config and Settings services, initializing the initial state of the application and hitting init methods of the base app's controllers. ## Controllers and Services @@ -82,6 +82,6 @@ Controllers bind different parts of the application. Controllers use services, s ### Controllers / Services retrieval -To get access to the service / controller [getModule](src/modules/container.ts) utility can be used. It also accepts a `required` param which can be used in case the presence of the service is optional. If `required` is provided but service itself is not bound then the error will be thrown. +To get access to the service / controller [getModule](../packages/common/src/modules/container.ts) utility can be used. It also accepts a `required` param which can be used in case the presence of the service is optional. If `required` is provided but service itself is not bound then the error will be thrown. `getNamedModule` function is mostly use in controllers to retrieve integration-specific services, like AccountService or CheckoutService. diff --git a/docs/translations.md b/docs/translations.md index eda82b144..01896ecb1 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -43,7 +43,7 @@ Instead, the language must first be added to the "defined languages" list. This - As OTT Web App we want to be able to include many languages without enabling them all by default - For each language, the display name must be defined, which is shown in the language selection menu -Navigate to the `./src/i18n/config.ts` file and find the `DEFINED_LANGUAGES` constant. Each entry specifies the +Navigate to the [../platforms/web/src/i18n/config.ts](../platforms/web/src/i18n/config.ts) file and find the `DEFINED_LANGUAGES` constant. Each entry specifies the language code (or LCID string) and display name. > If you have added multiple languages using the LCID string identifier, each much be added to the list of defined diff --git a/docs/workspaces.md b/docs/workspaces.md new file mode 100644 index 000000000..02ed7f926 --- /dev/null +++ b/docs/workspaces.md @@ -0,0 +1,201 @@ +# Workspaces + +## Why workspaces? + +The JW OTT Web App is an open-source repository that showcases an OTT app implementing JWP services. The OTT Web App, as +the name implies, originates as a web only repository. But much of the source-code can be re-used for many different +platforms, like Capacitor, React Native, and other frameworks based on TypeScript. + +Using the previous codebase, it would be quite challenging to re-use the services because of the dependencies and +browser usage. For example, the AccountController could redirect a user to a different page by using `window.location`. +This will never work in a non-browser environment and will most likely crash the app. + +This means that we need to: + +- Make most of the shareable code platform-agnostic +- Make most of the shareable code framework-agnostic +- Make importing services, controllers, stores, and utils possible in any other projects/platforms +- Benefit from linting based on the environment (Node, Browser, Vite, ...) +- Easy linking of packages/dependencies + +## The solution + +Based on the re-usability of parts of the existing codebase, we've created separate packages using Yarn Workspaces. +This will combine all similar code and prevent installing redundant or conflicting dependencies. + +For example, all components, containers, and pages are be combined into the `packages/ui-react` package, which depends +on react and react-dom. To create a React Native app, you could add an `packages/ui-react-native` package and configure +aliases to use the correct package. + +## Packages & Platforms + +A split has been made between the platform and reusable code. All reusable code is further split into multiple packages. +This is mostly to separate the React from the non-react code. + +Here is a breakdown of each package: + +### Common + +Name: `@jwp/ott-common` + +The common package contains all non-react TypeScript code reusable between multiple frameworks. These are controllers, +services, stores, utilities, and typings. There should be no platform-specific dependencies like React or React DOM. + +Typings can also be reused for multiple frameworks. + +TypeScript is configured to prevent browser typings. You don't have access to Browser globals like `localStorage` or +`location`. + +**Example usage:** + +```ts +import { configureEnv } from '@jwp/ott-common/src/env'; + +configureEnv({ + APP_VERSION: 'v1.0.0', +}); +``` + +### React Hooks + +Name: `@jwp/ott-hooks-react` + +Hooks are special because they are React-dependent but can be shared between the React and React Native frameworks. +That’s why they are in a separate folder for use between the two frameworks. + +### I18n (TODO) + +Name: `@jwp/ott-i18n` + +We’re using i18next, which is also a framework-independent library. We can re-use the configuration and translation +files between all platforms. + +### Testing + +Name: `@jwp/ott-testing` + +This package contains all test fixtures and could contain some generic test utils. But it shouldn’t contain +CodeceptJS/Playwright-specific code. + +### Theme (TODO) + +Name: `@jwp/ott-theme` + +The most important theming comes from the app config, but many other SCSS variables can be abstracted into generic ( +JSON) tokens. These tokens can be used across multiple frameworks. + +Raw SVG icons are added here as well. + +The theme folder also contains generic assets like images, logos, and fonts. + +### UI-react + +Name: `@jwp/ott-ui-react` + +The ui-react package contains all the existing React UI code. +The ui-react package also contains the SCSS variables and theme for use across more platforms. + +### Platforms/web + +Name: `@jwp/ott-web` + +The web folder is located in the platforms directory in the project's root folder. A platform is the entry point for +platform-specific code. In the case of the web platform, this is all the Vite.js configuration and App.tsx for +bootstrapping the app. + +We can add more platforms by adding a folder to the platforms folder. + +Each platform is a standalone application that may use other packages defined in the packages folder as dependency. + +### Configs + +The configs directory contains packages that are used mainly for configuring common build tools. This ensures these +configurations are aligned between the different application packages in the `packages/*` and `platforms/*` folder. + +Since most application packages depend on ESLint and use the same configuration, the recommended way of doing this in a +monorepo is by creating a local package of the eslint config. + +**eslint-config-jwp** + +This is the ESLint config for React or TypeScript packages. Usage: + +**.eslintrc.js** + +```js +module.exports = { + extends: ['jwp/typescript'], // extends: ['jwp/react'], +}; +``` + +**postcss-config-jwp** + +This package contains the PostCSS config. It's not much, but it will ensure the config stays the same for all packages. + +**postcss.config.js** + +```js +module.exports = require('postcss-config-jwp'); +``` + +**stylelint-config-jwp** + +This package contains all Stylelint rules. + +**stylelint.config.js** + +```js +module.exports = { + extends: ['stylelint-config-jwp'], +}; +``` + +## Tips when working with workspaces + +### Setup + +You can set up the OTT Web App repository by following +the [Building from source](./build-from-source.md#build-the-jw-ott-webapp) documentation. + +All packages are automatically linked and can be used from source. This prevents us from needing to compile each +package while developing. + +### Developing + +While developing the web platform, you want to cd to the `platforms/web` directory first. + +```shell +cd platforms/web +``` + +This directory contains most of the "old" scripts that were available in the package.json. + +### Dependency validation + +Because there are multiple package.json files, you can use [syncpack](https://www.npmjs.com/package/syncpack) to lint +and align the dependencies. This package is not a dependency, but can be used with the NPX from the root directory. + +Lint package.json files based on the syncpack config. + +```shell +npx syncpack lint +``` + +Organize package.json files automatically based on the syncpack config. + +```shell +npx syncpack format +``` + +### TypeScript config + +Because most of the packages use TypeScript a [tsconfig.base.json](../tsconfig.base.json) file is found in the root. +Most packages extend this tsconfig file and make the changes accordingly for the package. + +For example, the [tsconfig](../packages/common/tsconfig.json) in the common package overrides the `compilerOptions#lib` +property to disallow browser globals. + +Another example is the [tsconfig](../packages/ui-react/tsconfig.json) in the ui-react package which extends Vite typings +and ensures SCSS modules are typed as well. + + + diff --git a/i18next-parser.config.js b/i18next-parser.config.js index a1b3131db..5164c5eaf 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -1,6 +1,7 @@ const fs = require('fs'); -const localesEntries = fs.readdirSync('./public/locales'); +// @TODO: make it work with all packages and the web platform +const localesEntries = fs.readdirSync('./platforms/web/public/locales'); const locales = localesEntries.filter((entry) => entry !== '..' && entry !== '.'); module.exports = { @@ -22,6 +23,6 @@ module.exports = { lineEnding: 'auto', locales, namespaceSeparator: ':', - output: 'public/locales/$LOCALE/$NAMESPACE.json', + output: 'platforms/web/public/locales/$LOCALE/$NAMESPACE.json', sort: true, }; diff --git a/lint-staged.config.js b/lint-staged.config.js index 0a1a86b72..9b622bbb5 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,5 +1,4 @@ module.exports = { - '{**/*,*}.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'], - 'src/**/*.scss': ['stylelint --fix'], - '{**/*,*}.{ts,tsx}': [() => 'tsc --pretty --noEmit'], + 'scripts/{**/*,*}.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'], + 'scripts/{**/*,*}.{ts,tsx}': [() => 'tsc --pretty --noEmit -p ./scripts'], }; diff --git a/package.json b/package.json index 9ae97447c..407b59ca6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { - "name": "jw-ott-webapp", + "name": "@jwp/ott", "version": "5.1.1", + "license": "Apache-2.0", "main": "index.js", "repository": "https://github.com/jwplayer/ott-web-app.git", "author": "JW Player", @@ -8,149 +9,57 @@ "engines": { "node": ">=18.13.0" }, + "workspaces": [ + "configs/*", + "packages/*", + "platforms/*" + ], "scripts": { - "prepare": "husky install", - "start": "vite", - "start:test": "vite build --mode test && vite preview --port 8080", - "build": "vite build --mode ${MODE:=prod} && sh scripts/compressIni.sh build/public/.webapp.ini", - "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", - "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest", - "test-coverage": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --coverage", - "test-commit": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --changed HEAD~1 --coverage", - "test-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update", - "i18next": "i18next src/{components,containers,pages,services,stores,hooks}/**/{**/,/}*.{ts,tsx} && node ./scripts/i18next/generate.js", - "i18next-diff": "npx ts-node ./scripts/i18next/diff-translations", - "i18next-update": "npx ts-node ./scripts/i18next/update-translations && yarn i18next", + "commit-msg": "commitlint --edit $1", + "depcheck": "depcheck && yarn workspaces run depcheck", "format": "run-s -c format:*", "format:eslint": "eslint \"{**/*,*}.{js,ts,jsx,tsx}\" --fix", "format:prettier": "prettier --write \"{**/*,*}.{js,ts,jsx,tsx}\"", "format:stylelint": "stylelint --fix '**/*.{css,scss}'", - "lint": "run-s -c lint:*", + "pre-commit": "yarn depcheck && lint-staged", + "prepare": "husky install", + "lint": "run-p -c lint:*", "lint:eslint": "eslint \"{**/*,*}.{js,ts,jsx,tsx}\"", "lint:prettier": "prettier --check \"{**/*,*}.{js,ts,jsx,tsx}\"", - "lint:ts": "tsc --pretty --noEmit -p .", + "lint:ts": "tsc --pretty --noEmit -p ./scripts && yarn workspaces run lint:ts", "lint:stylelint": "stylelint '**/*.{css,scss}'", - "commit-msg": "commitlint --edit $1", - "codecept:mobile": "cd test-e2e && rm -rf \"./output/mobile\" && codeceptjs run-workers --suites ${WORKER_COUNT:=8} --config ./codecept.mobile.js", - "codecept:desktop": "cd test-e2e && rm -rf \"./output/desktop\" && codeceptjs run-workers --suites ${WORKER_COUNT:=8} --config ./codecept.desktop.js", - "serve-report:mobile": "cd test-e2e && allure serve \"./output/mobile\"", - "serve-report:desktop": "cd test-e2e && allure serve \"./output/desktop\"", - "codecept-serve:mobile": "yarn codecept:mobile ; yarn serve-report:mobile", - "codecept-serve:desktop": "yarn codecept:desktop ; yarn serve-report:desktop", - "pre-commit": "depcheck && lint-staged && TZ=UTC yarn test-commit", - "load-content-types": "npx ts-node ./scripts/content-types/load-content-types" - }, - "dependencies": { - "@adyen/adyen-web": "^5.42.1", - "@codeceptjs/allure-legacy": "^1.0.2", - "@inplayer-org/inplayer.js": "^3.13.24", - "classnames": "^2.3.1", - "date-fns": "^2.28.0", - "dompurify": "^2.3.8", - "fast-xml-parser": "^4.3.2", - "i18next": "^22.4.15", - "i18next-browser-languagedetector": "^6.1.1", - "i18next-http-backend": "^2.2.0", - "ini": "^3.0.1", - "inversify": "^6.0.1", - "jwt-decode": "^3.1.2", - "lodash.merge": "^4.6.2", - "marked": "^4.1.1", - "payment": "^2.4.6", - "planby": "^0.3.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-helmet": "^6.1.0", - "react-i18next": "^12.2.2", - "react-infinite-scroller": "^1.2.6", - "react-query": "^3.39.0", - "react-router-dom": "^6.4.0", - "reflect-metadata": "^0.1.13", - "wicg-inert": "^3.1.1", - "yup": "^0.32.9", - "zustand": "^3.6.9" + "i18next": "i18next 'platforms/*/src/**/*.{ts,tsx}' 'packages/*/src/**/*.{ts,tsx}' && node ./scripts/i18next/generate.js", + "i18next-diff": "npx ts-node ./scripts/i18next/diff-translations", + "i18next-update": "npx ts-node ./scripts/i18next/update-translations.ts && yarn i18next", + "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" }, "devDependencies": { - "@babel/core": "^7.22.11", - "@babel/plugin-proposal-decorators": "^7.22.10", - "@codeceptjs/configure": "^0.8.0", "@commitlint/cli": "^12.1.1", "@commitlint/config-conventional": "^12.1.1", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^14.0.0", - "@types/dompurify": "^2.3.4", - "@types/ini": "^1.3.31", - "@types/jwplayer": "^8.2.13", - "@types/lodash.merge": "^4.6.6", - "@types/luxon": "^3.0.2", - "@types/marked": "^4.0.7", - "@types/node": "^17.0.23", - "@types/payment": "^2.1.4", - "@types/react": "^18.2.15", - "@types/react-dom": "18.2.7", - "@types/react-helmet": "^6.1.2", - "@types/react-infinite-scroller": "^1.2.3", - "@typescript-eslint/eslint-plugin": "^5.17.0", - "@typescript-eslint/parser": "^5.17.0", - "@vitejs/plugin-react": "^4.0.4", - "@vitest/coverage-v8": "^0.33.0", - "allure-commandline": "^2.17.2", - "babel-plugin-transform-typescript-metadata": "^0.3.2", - "codeceptjs": "3.5.5", - "confusing-browser-globals": "^1.0.10", + "@types/node": "^18.15.3", "csv-parse": "^5.4.0", "depcheck": "^1.4.3", "eslint": "^7.31.0", - "eslint-plugin-codeceptjs": "^1.3.0", - "eslint-plugin-import": "^2.23.4", - "eslint-plugin-react": "^7.24.0", - "eslint-plugin-react-hooks": "^4.2.0", "husky": "^6.0.0", "i18next-parser": "^8.0.0", - "jsdom": "^22.1.0", - "lint-staged": "^10.5.4", - "luxon": "^3.2.1", + "lint-staged": "^15.1.0", "npm-run-all": "^4.1.5", - "playwright": "^1.38.1", - "postcss": "^8.3.5", - "postcss-import": "^14.0.2", - "postcss-scss": "^4.0.4", "prettier": "^2.8.8", - "react-app-polyfill": "^3.0.0", "read": "^2.1.0", - "sass": "^1.49.10", - "stylelint": "^13.13.1", - "stylelint-config-recommended-scss": "^4.3.0", - "stylelint-declaration-strict-value": "^1.7.12", - "stylelint-order": "^4.1.0", - "stylelint-scss": "^3.20.0", - "timezone-mock": "^1.3.4", "ts-node": "^10.9.1", - "tsconfig-paths": "^4.1.0", "typescript": "^4.3.4", - "vi-fetch": "^0.8.0", - "vite": "^4.4.8", - "vite-plugin-eslint": "^1.8.1", - "vite-plugin-html": "^3.2.0", - "vite-plugin-pwa": "^0.14.0", - "vite-plugin-static-copy": "^0.17.0", - "vite-plugin-stylelint": "^4.3.0", - "vitest": "^0.34.1", - "workbox-build": "^6.5.4", - "workbox-window": "^6.5.4" + "vitest": "^0.34.6" }, "peerDependencies": { - "react-router": "^6.4.0" - }, - "optionalDependencies": { - "gh-pages": "^3.2.3", - "lighthouse": "^9.6.7" + "eslint-config-jwp": "*" }, "resolutions": { "glob-parent": "^5.1.2", "codeceptjs/**/ansi-regex": "^4.1.1", "codeceptjs/**/minimatch": "^3.0.5", "flat": "^5.0.1", - "json5": "^2.2.2" + "json5": "^2.2.2", + "vite": "^4.4.8" } } diff --git a/packages/common/.depcheckrc.yaml b/packages/common/.depcheckrc.yaml new file mode 100644 index 000000000..bb6560f17 --- /dev/null +++ b/packages/common/.depcheckrc.yaml @@ -0,0 +1,6 @@ +ignores: [ + # Installed by eslint-config-jwp + '@typescript-eslint/parser', + '@typescript-eslint/eslint-plugin', + 'eslint-plugin-import', +] diff --git a/packages/common/.eslintrc.js b/packages/common/.eslintrc.js new file mode 100644 index 000000000..aace0df8b --- /dev/null +++ b/packages/common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['jwp/typescript'], +}; diff --git a/packages/common/lint-staged.config.js b/packages/common/lint-staged.config.js new file mode 100644 index 000000000..e4e6b6a16 --- /dev/null +++ b/packages/common/lint-staged.config.js @@ -0,0 +1,4 @@ +module.exports = { + '*.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'], + '*.{ts,tsx}': [() => 'tsc --pretty --noEmit'], +}; diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 000000000..c267ea47d --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,41 @@ +{ + "name": "@jwp/ott-common", + "version": "4.30.0", + "main": "./src", + "private": true, + "scripts": { + "lint:ts": "tsc --pretty --noEmit -p ./", + "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest", + "test-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update" + }, + "dependencies": { + "@inplayer-org/inplayer.js": "^3.13.24", + "broadcast-channel": "^7.0.0", + "date-fns": "^2.28.0", + "fast-xml-parser": "^4.3.2", + "i18next": "^22.4.15", + "ini": "^3.0.1", + "inversify": "^6.0.1", + "jwt-decode": "^3.1.2", + "lodash.merge": "^4.6.2", + "react-i18next": "^12.3.1", + "reflect-metadata": "^0.1.13", + "yup": "^0.32.9", + "zustand": "^3.6.9" + }, + "devDependencies": { + "@types/ini": "^1.3.31", + "@types/jwplayer": "^8.2.13", + "@types/lodash.merge": "^4.6.6", + "jsdom": "^22.1.0", + "timezone-mock": "^1.3.4", + "vi-fetch": "^0.8.0", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "@jwp/ott-testing": "*", + "@jwp/ott-theme": "*", + "eslint-config-jwp": "*" + } +} diff --git a/src/config.ts b/packages/common/src/constants.ts similarity index 91% rename from src/config.ts rename to packages/common/src/constants.ts index 609e7d387..08b199752 100644 --- a/src/config.ts +++ b/packages/common/src/constants.ts @@ -60,6 +60,8 @@ 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 DEFAULT_FEATURES = { canUpdateEmail: false, canSupportEmptyFullName: false, @@ -70,10 +72,11 @@ export const DEFAULT_FEATURES = { canUpdatePaymentMethod: false, canShowReceipts: false, hasSocialURLs: false, - hasProfiles: false, hasNotifications: false, }; +export const simultaneousLoginWarningKey = 'simultaneous_logins'; + export const EPG_TYPE = { jwp: 'jwp', viewNexa: 'viewnexa', diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts new file mode 100644 index 000000000..606801157 --- /dev/null +++ b/packages/common/src/env.ts @@ -0,0 +1,25 @@ +export type Env = { + APP_VERSION: string; + APP_API_BASE_URL: string; + APP_PLAYER_ID: string; + + APP_DEFAULT_CONFIG_SOURCE?: string; + APP_PLAYER_LICENSE_KEY?: string; +}; + +const env: Env = { + APP_VERSION: '', + APP_API_BASE_URL: 'https://cdn.jwplayer.com', + APP_PLAYER_ID: 'M4qoGvUk', +}; + +export const configureEnv = (options: Partial) => { + env.APP_VERSION = options.APP_VERSION || env.APP_VERSION; + 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_DEFAULT_CONFIG_SOURCE ||= options.APP_DEFAULT_CONFIG_SOURCE; + env.APP_PLAYER_LICENSE_KEY ||= options.APP_PLAYER_LICENSE_KEY; +}; + +export default env; diff --git a/src/modules/container.ts b/packages/common/src/modules/container.ts similarity index 79% rename from src/modules/container.ts rename to packages/common/src/modules/container.ts index 03e1208ec..f98d06c26 100644 --- a/src/modules/container.ts +++ b/packages/common/src/modules/container.ts @@ -1,7 +1,11 @@ -import { Container, interfaces } from 'inversify'; +import { Container, injectable, type interfaces, inject } from 'inversify'; + +import { logDev } from '../utils/common'; export const container = new Container({ defaultScope: 'Singleton', skipBaseClassChecks: true }); +export { injectable, inject }; + export function getModule(constructorFunction: interfaces.ServiceIdentifier, required: false): T | undefined; export function getModule(constructorFunction: interfaces.ServiceIdentifier, required: true): T; export function getModule(constructorFunction: interfaces.ServiceIdentifier): T; @@ -28,9 +32,15 @@ export function getNamedModule(constructorFunction: interfaces.ServiceIdentif return module; } catch (err: unknown) { - if (required) { - throw new Error(`Service not found '${String(constructorFunction)}' with name '${name}'`); + if (err instanceof Error && err.message.includes('No matching bindings found')) { + if (required) { + throw new Error(`Service not found '${String(constructorFunction)}' with name '${name}'`); + } + + return; } + + logDev('Error caught while initializing service', err); } } diff --git a/packages/common/src/modules/functions/calculateIntegrationType.ts b/packages/common/src/modules/functions/calculateIntegrationType.ts new file mode 100644 index 000000000..bc6f342ed --- /dev/null +++ b/packages/common/src/modules/functions/calculateIntegrationType.ts @@ -0,0 +1,10 @@ +import type { CalculateIntegrationType } from '../../../types/calculate-integration-type'; +import { INTEGRATION } from '../../constants'; + +export const isCleengIntegrationType: CalculateIntegrationType = (config) => { + return config.integrations?.cleeng?.id ? INTEGRATION.CLEENG : null; +}; + +export const isJwpIntegrationType: CalculateIntegrationType = (config) => { + return config.integrations?.jwp?.clientId ? INTEGRATION.JWP : null; +}; diff --git a/packages/common/src/modules/functions/getIntegrationType.ts b/packages/common/src/modules/functions/getIntegrationType.ts new file mode 100644 index 000000000..d387b6b04 --- /dev/null +++ b/packages/common/src/modules/functions/getIntegrationType.ts @@ -0,0 +1,11 @@ +import type { interfaces } from 'inversify'; + +import AppController from '../../stores/AppController'; + +/** + * This function is used to get the integration type from the AppController and is mainly used for getting named + * modules from the container registry. + */ +export const getIntegrationType = (context: interfaces.Context) => { + return context.container.get(AppController).getIntegrationType(); +}; diff --git a/packages/common/src/modules/register.ts b/packages/common/src/modules/register.ts new file mode 100644 index 000000000..8eb29b055 --- /dev/null +++ b/packages/common/src/modules/register.ts @@ -0,0 +1,88 @@ +// To organize imports in a better way +/* eslint-disable import/order */ +import 'reflect-metadata'; // include once in the app for inversify (see: https://github.com/inversify/InversifyJS/blob/master/README.md#-installation) +import { INTEGRATION, EPG_TYPE } from '../constants'; +import { container } from './container'; +import { DETERMINE_INTEGRATION_TYPE, INTEGRATION_TYPE } from './types'; + +import ApiService from '../services/ApiService'; +import WatchHistoryService from '../services/WatchHistoryService'; +import GenericEntitlementService from '../services/GenericEntitlementService'; +import JWPEntitlementService from '../services/JWPEntitlementService'; +import FavoriteService from '../services/FavoriteService'; +import ConfigService from '../services/ConfigService'; +import SettingsService from '../services/SettingsService'; + +import WatchHistoryController from '../stores/WatchHistoryController'; +import CheckoutController from '../stores/CheckoutController'; +import AccountController from '../stores/AccountController'; +import ProfileController from '../stores/ProfileController'; +import FavoritesController from '../stores/FavoritesController'; +import AppController from '../stores/AppController'; +import EpgController from '../stores/EpgController'; + +// Epg services +import EpgService from '../services/EpgService'; +import ViewNexaEpgService from '../services/epg/ViewNexaEpgService'; +import JWEpgService from '../services/epg/JWEpgService'; + +// Integration interfaces +import AccountService from '../services/integrations/AccountService'; +import CheckoutService from '../services/integrations/CheckoutService'; +import SubscriptionService from '../services/integrations/SubscriptionService'; +import ProfileService from '../services/integrations/ProfileService'; + +// Cleeng integration +import CleengService from '../services/integrations/cleeng/CleengService'; +import CleengAccountService from '../services/integrations/cleeng/CleengAccountService'; +import CleengCheckoutService from '../services/integrations/cleeng/CleengCheckoutService'; +import CleengSubscriptionService from '../services/integrations/cleeng/CleengSubscriptionService'; + +// InPlayer integration +import JWPAccountService from '../services/integrations/jwp/JWPAccountService'; +import JWPCheckoutService from '../services/integrations/jwp/JWPCheckoutService'; +import JWPSubscriptionService from '../services/integrations/jwp/JWPSubscriptionService'; +import JWPProfileService from '../services/integrations/jwp/JWPProfileService'; +import { getIntegrationType } from './functions/getIntegrationType'; +import { isCleengIntegrationType, isJwpIntegrationType } from './functions/calculateIntegrationType'; + +// Common services +container.bind(ConfigService).toSelf(); +container.bind(WatchHistoryService).toSelf(); +container.bind(FavoriteService).toSelf(); +container.bind(GenericEntitlementService).toSelf(); +container.bind(ApiService).toSelf(); +container.bind(SettingsService).toSelf(); + +// Common controllers +container.bind(AppController).toSelf(); +container.bind(WatchHistoryController).toSelf(); +container.bind(FavoritesController).toSelf(); +container.bind(EpgController).toSelf(); + +// Integration controllers +container.bind(AccountController).toSelf(); +container.bind(CheckoutController).toSelf(); +container.bind(ProfileController).toSelf(); + +// EPG services +container.bind(EpgService).to(JWEpgService).whenTargetNamed(EPG_TYPE.jwp); +container.bind(EpgService).to(ViewNexaEpgService).whenTargetNamed(EPG_TYPE.viewNexa); + +// Functions +container.bind(INTEGRATION_TYPE).toDynamicValue(getIntegrationType); + +// Cleeng integration +container.bind(DETERMINE_INTEGRATION_TYPE).toConstantValue(isCleengIntegrationType); +container.bind(CleengService).toSelf(); +container.bind(AccountService).to(CleengAccountService).whenTargetNamed(INTEGRATION.CLEENG); +container.bind(CheckoutService).to(CleengCheckoutService).whenTargetNamed(INTEGRATION.CLEENG); +container.bind(SubscriptionService).to(CleengSubscriptionService).whenTargetNamed(INTEGRATION.CLEENG); + +// JWP integration +container.bind(DETERMINE_INTEGRATION_TYPE).toConstantValue(isJwpIntegrationType); +container.bind(JWPEntitlementService).toSelf(); +container.bind(AccountService).to(JWPAccountService).whenTargetNamed(INTEGRATION.JWP); +container.bind(CheckoutService).to(JWPCheckoutService).whenTargetNamed(INTEGRATION.JWP); +container.bind(SubscriptionService).to(JWPSubscriptionService).whenTargetNamed(INTEGRATION.JWP); +container.bind(ProfileService).to(JWPProfileService).whenTargetNamed(INTEGRATION.JWP); diff --git a/packages/common/src/modules/types.ts b/packages/common/src/modules/types.ts new file mode 100644 index 000000000..c00c26851 --- /dev/null +++ b/packages/common/src/modules/types.ts @@ -0,0 +1,5 @@ +export const INTEGRATION_TYPE = Symbol('INTEGRATION_TYPE'); + +export const DETERMINE_INTEGRATION_TYPE = Symbol('DETERMINE_INTEGRATION_TYPE'); + +export const GET_CUSTOMER_IP = Symbol('GET_CUSTOMER_IP'); diff --git a/src/services/api.service.ts b/packages/common/src/services/ApiService.ts similarity index 75% rename from src/services/api.service.ts rename to packages/common/src/services/ApiService.ts index b31343e3c..481e8db4a 100644 --- a/src/services/api.service.ts +++ b/packages/common/src/services/ApiService.ts @@ -1,15 +1,15 @@ -import { parseISO, isValid } from 'date-fns'; +import { isValid, parseISO } from 'date-fns'; import { injectable } from 'inversify'; import { getMediaStatusFromEventState } from '../utils/liveEvent'; - -import { addQueryParams } from '#src/utils/formatting'; -import { getDataOrThrow } from '#src/utils/api'; -import { filterMediaOffers } from '#src/utils/entitlements'; -import type { GetPlaylistParams, Playlist, PlaylistItem } from '#types/playlist'; -import type { AdSchedule } from '#types/ad-schedule'; -import type { EpisodesRes, EpisodesWithPagination, GetSeriesParams, Series, EpisodeInSeries } from '#types/series'; -import { useConfigStore as ConfigStore } from '#src/stores/ConfigStore'; +import { createURL } from '../utils/urlFormatting'; +import { getDataOrThrow } from '../utils/api'; +import { filterMediaOffers } from '../utils/entitlements'; +import { useConfigStore as ConfigStore } from '../stores/ConfigStore'; +import type { GetPlaylistParams, Playlist, PlaylistItem } from '../../types/playlist'; +import type { AdSchedule } from '../../types/ad-schedule'; +import type { EpisodeInSeries, EpisodesRes, EpisodesWithPagination, GetSeriesParams, Series } from '../../types/series'; +import env from '../env'; // change the values below to change the property used to look up the alternate image enum ImageProperty { @@ -28,7 +28,7 @@ export default class ApiService { */ private generateAlternateImageURL = ({ item, label, playlistLabel }: { item: PlaylistItem; label: string; playlistLabel?: string }) => { const pathname = `/v2/media/${item.mediaid}/images/${playlistLabel || label}.webp`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, { poster_fallback: 1, fallback: playlistLabel ? label : null }); + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { poster_fallback: 1, fallback: playlistLabel ? label : null }); return url; }; @@ -71,9 +71,6 @@ export default class ApiService { /** * Transform incoming playlists - * - * @param playlist - * @param relatedMediaId */ private transformPlaylist = (playlist: Playlist, relatedMediaId?: string) => { playlist.playlist = playlist.playlist.map((item) => this.transformMediaItem(item, playlist)); @@ -102,9 +99,6 @@ export default class ApiService { /** * Get playlist by id - * @param {string} id - * @param params - * @param {string} [drmPolicyId] */ getPlaylistById = async (id?: string, params: GetPlaylistParams = {}): Promise => { if (!id) { @@ -112,17 +106,15 @@ export default class ApiService { } const pathname = `/v2/playlists/${id}`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, params); + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, params); const response = await fetch(url); - const data = await getDataOrThrow(response); + const data = (await getDataOrThrow(response)) as Playlist; return this.transformPlaylist(data, params.related_media_id); }; /** * Get watchlist by playlistId - * @param {string} playlistId - * @param {string} [token] */ getMediaByWatchlist = async (playlistId: string, mediaIds: string[], token?: string): Promise => { if (!mediaIds?.length) { @@ -130,7 +122,7 @@ export default class ApiService { } const pathname = `/apps/watchlists/${playlistId}`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, { token, media_ids: mediaIds }); + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { token, media_ids: mediaIds }); const response = await fetch(url); const data = (await getDataOrThrow(response)) as Playlist; @@ -147,12 +139,13 @@ export default class ApiService { */ getMediaById = async (id: string, token?: string, drmPolicyId?: string): Promise => { const pathname = drmPolicyId ? `/v2/media/${id}/drm/${drmPolicyId}` : `/v2/media/${id}`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, { token }); + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { token }); const response = await fetch(url); const data = (await getDataOrThrow(response)) as Playlist; const mediaItem = data.playlist[0]; if (!mediaItem) throw new Error('MediaItem not found'); + return this.transformMediaItem(mediaItem); }; @@ -167,27 +160,25 @@ export default class ApiService { } const pathname = `/apps/series/${id}`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, params); + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, params); const response = await fetch(url); - const data = await getDataOrThrow(response); - return data; + return (await getDataOrThrow(response)) as Series; }; /** * Get all series for the given media_ids - * @param {string[]} mediaIds */ - getSeriesByMediaIds = async (mediaIds: string[]): Promise<{ [mediaId: string]: EpisodeInSeries[] | undefined } | undefined> => { + getSeriesByMediaIds = async (mediaIds: string[]): Promise | undefined> => { const pathname = `/apps/series`; - const url = `${import.meta.env.APP_API_BASE_URL}${pathname}?media_ids=${mediaIds.join(',')}`; + const url = `${env.APP_API_BASE_URL}${pathname}?media_ids=${mediaIds.join(',')}`; const response = await fetch(url); - return await getDataOrThrow(response); + + return (await getDataOrThrow(response)) as Record; }; /** * Get all episodes of the selected series (when no particular season is selected or when episodes are attached to series) - * @param {string} seriesId */ getEpisodes = async ({ seriesId, @@ -205,21 +196,20 @@ export default class ApiService { } const pathname = `/apps/series/${seriesId}/episodes`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, { + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { page_offset: pageOffset, page_limit: pageLimit, after_id: afterId, }); const response = await fetch(url); - const episodesRes: EpisodesRes = await getDataOrThrow(response); + const episodesResponse = (await getDataOrThrow(response)) as EpisodesRes; - return this.transformEpisodes(episodesRes); + return this.transformEpisodes(episodesResponse); }; /** * Get season of the selected series - * @param {string} seriesId */ getSeasonWithEpisodes = async ({ seriesId, @@ -237,10 +227,10 @@ export default class ApiService { } const pathname = `/apps/series/${seriesId}/seasons/${seasonNumber}/episodes`; - const url = addQueryParams(`${import.meta.env.APP_API_BASE_URL}${pathname}`, { page_offset: pageOffset, page_limit: pageLimit }); + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { page_offset: pageOffset, page_limit: pageLimit }); const response = await fetch(url); - const episodesRes: EpisodesRes = await getDataOrThrow(response); + const episodesRes = (await getDataOrThrow(response)) as EpisodesRes; return this.transformEpisodes(episodesRes, seasonNumber); }; @@ -250,22 +240,19 @@ export default class ApiService { throw new Error('Ad Schedule ID is required'); } - const url = import.meta.env.APP_API_BASE_URL + `/v2/advertising/schedules/${id}.json`; + const url = env.APP_API_BASE_URL + `/v2/advertising/schedules/${id}.json`; const response = await fetch(url, { credentials: 'omit' }); - const data = await getDataOrThrow(response); - return data; + return (await getDataOrThrow(response)) as AdSchedule; }; getMediaAds = async (url: string, mediaId: string): Promise => { - const urlWithQuery = addQueryParams(url, { + const urlWithQuery = createURL(url, { media_id: mediaId, }); const response = await fetch(urlWithQuery, { credentials: 'omit' }); - const data = (await getDataOrThrow(response)) as AdSchedule; - - return data; + return (await getDataOrThrow(response)) as AdSchedule; }; } diff --git a/packages/common/src/services/ConfigService.ts b/packages/common/src/services/ConfigService.ts new file mode 100644 index 000000000..6c17f682e --- /dev/null +++ b/packages/common/src/services/ConfigService.ts @@ -0,0 +1,110 @@ +import i18next from 'i18next'; +import { injectable } from 'inversify'; +import { getI18n } from 'react-i18next'; + +import { configSchema } from '../utils/configSchema'; +import { AppError } from '../utils/error'; +import type { Config } from '../../types/config'; +import env from '../env'; + +import ApiService from './ApiService'; + +/** + * Set config setup changes in both config.service.ts and config.d.ts + * */ + +@injectable() +export default class ConfigService { + private CONFIG_HOST = env.APP_API_BASE_URL; + // Explicitly set default config here as a local variable, + // otherwise if it's a module level const, the merge below causes changes to nested properties + private DEFAULT_CONFIG: Config = { + id: '', + siteName: '', + description: '', + assets: { + banner: '/images/logo.png', + }, + content: [], + menu: [], + integrations: {}, + styling: { + footerText: '', + }, + features: {}, + }; + + private readonly apiService: ApiService; + + constructor(apiService: ApiService) { + this.apiService = apiService; + } + + private enrichConfig = (config: Config): Config => { + const { content, siteName } = config; + const updatedContent = content.map((content) => Object.assign({ featured: false }, content)); + + return { ...config, siteName: siteName || i18next.t('common:default_site_name'), content: updatedContent }; + }; + + getDefaultConfig = (): Config => { + return this.DEFAULT_CONFIG; + }; + + validateConfig = (config?: Config): Promise => { + return configSchema.validate(config, { + strict: true, + }) as Promise; + }; + + formatSourceLocation = (source?: string) => { + if (!source) { + return undefined; + } + + if (source.match(/^[a-z,\d]{8}$/)) { + return `${this.CONFIG_HOST}/apps/configs/${source}.json`; + } + + return source; + }; + + loadAdSchedule = async (adScheduleId: string | undefined | null) => { + return this.apiService.getAdSchedule(adScheduleId); + }; + + loadConfig = async (configLocation: string | undefined) => { + const i18n = getI18n(); + + const errorPayload = { + title: i18n.t('error:config_invalid'), + description: i18n.t('error:check_your_config'), + helpLink: 'https://github.com/jwplayer/ott-web-app/blob/develop/docs/configuration.md', + }; + + if (!configLocation) { + throw new AppError('No config location found', errorPayload); + } + + const response = await fetch(configLocation, { + headers: { + Accept: 'application/json', + }, + method: 'GET', + }).catch(() => { + throw new AppError('Failed to load the config', errorPayload); + }); + + if (!response.ok) { + throw new AppError('Failed to load the config', errorPayload); + } + + const data = (await response.json()) as Config; + + if (!data) { + throw new Error('No config found'); + } + + return this.enrichConfig(data); + }; +} diff --git a/src/services/epg/epg.service.ts b/packages/common/src/services/EpgService.ts similarity index 75% rename from src/services/epg/epg.service.ts rename to packages/common/src/services/EpgService.ts index bb19d17de..35e6216e4 100644 --- a/src/services/epg/epg.service.ts +++ b/packages/common/src/services/EpgService.ts @@ -1,11 +1,11 @@ -import type { EpgProgram } from '#types/epg'; -import type { PlaylistItem } from '#types/playlist'; +import type { EpgProgram } from '../../types/epg'; +import type { PlaylistItem } from '../../types/playlist'; export default abstract class EpgService { /** * Fetch the schedule data for the given PlaylistItem */ - abstract fetchSchedule: (item: PlaylistItem) => Promise; + abstract fetchSchedule: (item: PlaylistItem) => Promise; /** * Validate the given data with the schema and transform it into an EpgProgram diff --git a/src/services/favorites.service.ts b/packages/common/src/services/FavoriteService.ts similarity index 58% rename from src/services/favorites.service.ts rename to packages/common/src/services/FavoriteService.ts index ba9babff1..205ebaab0 100644 --- a/src/services/favorites.service.ts +++ b/packages/common/src/services/FavoriteService.ts @@ -1,26 +1,28 @@ import { injectable } from 'inversify'; -import ApiService from './api.service'; +import { MAX_WATCHLIST_ITEMS_COUNT } from '../constants'; +import type { Favorite, SerializedFavorite } from '../../types/favorite'; +import type { PlaylistItem } from '../../types/playlist'; +import type { Customer } from '../../types/account'; -import * as persist from '#src/utils/persist'; -import type { Favorite, SerializedFavorite } from '#types/favorite'; -import type { PlaylistItem } from '#types/playlist'; -import { MAX_WATCHLIST_ITEMS_COUNT } from '#src/config'; -import type { Customer } from '#types/account'; +import ApiService from './ApiService'; +import StorageService from './StorageService'; @injectable() -export default class FavoritesService { +export default class FavoriteService { private MAX_FAVORITES_COUNT = 48; - private PERSIST_KEY_FAVORITES = `favorites${window.configId ? `-${window.configId}` : ''}`; + private PERSIST_KEY_FAVORITES = 'favorites'; private readonly apiService; + private readonly storageService; - constructor(apiService: ApiService) { + constructor(apiService: ApiService, storageService: StorageService) { this.apiService = apiService; + this.storageService = storageService; } getFavorites = async (user: Customer | null, favoritesList: string) => { - const savedItems = user ? user.externalData?.favorites : persist.getItem(this.PERSIST_KEY_FAVORITES); + const savedItems = user ? user.externalData?.favorites : await this.storageService.getItem(this.PERSIST_KEY_FAVORITES, true); if (savedItems?.length) { const playlistItems = await this.apiService.getMediaByWatchlist( @@ -37,7 +39,7 @@ export default class FavoritesService { }; persistFavorites = (favorites: Favorite[]) => { - persist.setItem(this.PERSIST_KEY_FAVORITES, this.serializeFavorites(favorites)); + this.storageService.setItem(this.PERSIST_KEY_FAVORITES, JSON.stringify(this.serializeFavorites(favorites))); }; getMaxFavoritesCount = () => { diff --git a/src/services/genericEntitlement.service.ts b/packages/common/src/services/GenericEntitlementService.ts similarity index 86% rename from src/services/genericEntitlement.service.ts rename to packages/common/src/services/GenericEntitlementService.ts index d7e7ce495..6463b4397 100644 --- a/src/services/genericEntitlement.service.ts +++ b/packages/common/src/services/GenericEntitlementService.ts @@ -1,6 +1,7 @@ import { injectable } from 'inversify'; -import type { GetMediaParams } from '#types/media'; +import type { GetMediaParams } from '../../types/media'; +import type { GetTokenResponse } from '../../types/entitlement'; @injectable() export default class GenericEntitlementService { diff --git a/src/services/jwpEntitlement.service.ts b/packages/common/src/services/JWPEntitlementService.ts similarity index 100% rename from src/services/jwpEntitlement.service.ts rename to packages/common/src/services/JWPEntitlementService.ts diff --git a/src/services/settings.service.ts b/packages/common/src/services/SettingsService.ts similarity index 73% rename from src/services/settings.service.ts rename to packages/common/src/services/SettingsService.ts index 1d42cc1e0..1a16fc72d 100644 --- a/src/services/settings.service.ts +++ b/packages/common/src/services/SettingsService.ts @@ -2,22 +2,28 @@ import { injectable } from 'inversify'; import ini from 'ini'; import { getI18n } from 'react-i18next'; -import type { Settings } from '#types/settings'; -import { CONFIG_FILE_STORAGE_KEY, CONFIG_QUERY_KEY, OTT_GLOBAL_PLAYER_ID } from '#src/config'; -import { logDev } from '#src/utils/common'; -import { AppError } from '#src/utils/error'; +import { CONFIG_FILE_STORAGE_KEY, CONFIG_QUERY_KEY, OTT_GLOBAL_PLAYER_ID } from '../constants'; +import { logDev } from '../utils/common'; +import { AppError } from '../utils/error'; +import type { Settings } from '../../types/settings'; +import env from '../env'; -// Use local storage so the override persists until cleared -const storage = window.localStorage; +import StorageService from './StorageService'; @injectable() export default class SettingsService { - getConfigSource(settings: Settings | undefined) { + private readonly storageService; + + constructor(storageService: StorageService) { + this.storageService = storageService; + } + + async getConfigSource(settings: Settings | undefined, url: string) { if (!settings) { return ''; } - const urlParams = new URLSearchParams(window.location.search); + const urlParams = new URLSearchParams(url.split('?')[1]); const configKey = urlParams.get(CONFIG_QUERY_KEY); // Skip all the fancy logic below if there aren't any other options besides the default anyhow @@ -28,13 +34,13 @@ export default class SettingsService { if (configKey !== null) { // If the query param exists but the value is empty, clear the storage and allow fallback to the default config if (!configKey) { - storage.removeItem(CONFIG_FILE_STORAGE_KEY); + await this.storageService.removeItem(CONFIG_FILE_STORAGE_KEY); return settings.defaultConfigSource; } // If it's valid, store it and return it if (this.isValidConfigSource(configKey, settings)) { - storage.setItem(CONFIG_FILE_STORAGE_KEY, configKey); + await this.storageService.setItem(CONFIG_FILE_STORAGE_KEY, configKey); return configKey; } @@ -42,16 +48,16 @@ export default class SettingsService { } // Yes this falls through from above to look up the stored value if the query string is invalid and that's OK - const storedSource = storage.getItem(CONFIG_FILE_STORAGE_KEY); + const storedSource = await this.storageService.getItem(CONFIG_FILE_STORAGE_KEY, false); // Make sure the stored value is still valid before returning it - if (storedSource) { + if (storedSource && typeof storedSource === 'string') { if (this.isValidConfigSource(storedSource, settings)) { return storedSource; } logDev('Invalid stored config: ' + storedSource); - storage.removeItem(CONFIG_FILE_STORAGE_KEY); + await this.storageService.removeItem(CONFIG_FILE_STORAGE_KEY); } return settings.defaultConfigSource; @@ -80,6 +86,9 @@ export default class SettingsService { const i18n = getI18n(); + // @TODO: use `i18next.t()`? + // t('error:settings_invalid') + // t('error:check_your_settings') const errorPayload = { title: i18n.t('error:settings_invalid'), description: i18n.t('error:check_your_settings'), @@ -91,9 +100,9 @@ export default class SettingsService { } // The ini file values will be used if provided, even if compile-time values are set - settings.defaultConfigSource ||= import.meta.env.APP_DEFAULT_CONFIG_SOURCE; - settings.playerId ||= import.meta.env.APP_PLAYER_ID || OTT_GLOBAL_PLAYER_ID; - settings.playerLicenseKey ||= import.meta.env.APP_PLAYER_LICENSE_KEY; + settings.defaultConfigSource ||= env.APP_DEFAULT_CONFIG_SOURCE; + settings.playerId ||= env.APP_PLAYER_ID || OTT_GLOBAL_PLAYER_ID; + settings.playerLicenseKey ||= env.APP_PLAYER_LICENSE_KEY; // The player key should be set if using the global ott player if (settings.playerId === OTT_GLOBAL_PLAYER_ID && !settings.playerLicenseKey) { diff --git a/packages/common/src/services/StorageService.ts b/packages/common/src/services/StorageService.ts new file mode 100644 index 000000000..487513fdd --- /dev/null +++ b/packages/common/src/services/StorageService.ts @@ -0,0 +1,13 @@ +export default abstract class StorageService { + abstract initialize(prefix: string): void; + + abstract getItem(key: string, parse: boolean): Promise; + + abstract setItem(key: string, value: string, usePrefix?: boolean): Promise; + + abstract removeItem(key: string): Promise; + + abstract base64Decode(input: string): string; + + abstract base64Encode(input: string): string; +} diff --git a/src/services/watchhistory.service.ts b/packages/common/src/services/WatchHistoryService.ts similarity index 86% rename from src/services/watchhistory.service.ts rename to packages/common/src/services/WatchHistoryService.ts index ee34a21cd..29c619275 100644 --- a/src/services/watchhistory.service.ts +++ b/packages/common/src/services/WatchHistoryService.ts @@ -1,20 +1,23 @@ import { injectable } from 'inversify'; -import ApiService from './api.service'; +import type { PlaylistItem } from '../../types/playlist'; +import type { SerializedWatchHistoryItem, WatchHistoryItem } from '../../types/watchHistory'; +import type { Customer } from '../../types/account'; -import * as persist from '#src/utils/persist'; -import type { PlaylistItem } from '#types/playlist'; -import type { SerializedWatchHistoryItem, WatchHistoryItem } from '#types/watchHistory'; -import type { Customer } from '#types/account'; +import ApiService from './ApiService'; +import StorageService from './StorageService'; @injectable() export default class WatchHistoryService { - private PERSIST_KEY_WATCH_HISTORY = `history${window.configId ? `-${window.configId}` : ''}`; + private PERSIST_KEY_WATCH_HISTORY = 'history'; private MAX_WATCH_HISTORY_COUNT = 48; + private readonly apiService: ApiService; + private readonly storageService: StorageService; - constructor(apiService: ApiService) { + constructor(apiService: ApiService, storageService: StorageService) { this.apiService = apiService; + this.storageService = storageService; } // Retrieve watch history media items info using a provided watch list @@ -45,7 +48,7 @@ export default class WatchHistoryService { }; getWatchHistory = async (user: Customer | null, continueWatchingList: string) => { - const savedItems = user ? user.externalData?.history : persist.getItem(this.PERSIST_KEY_WATCH_HISTORY); + const savedItems = user ? user.externalData?.history : await this.storageService.getItem(this.PERSIST_KEY_WATCH_HISTORY, true); if (savedItems?.length) { // When item is an episode of the new flow -> show the card as a series one, but keep episode to redirect in a right way @@ -74,7 +77,7 @@ export default class WatchHistoryService { })); persistWatchHistory = (watchHistory: WatchHistoryItem[]) => { - persist.setItem(this.PERSIST_KEY_WATCH_HISTORY, this.serializeWatchHistory(watchHistory)); + this.storageService.setItem(this.PERSIST_KEY_WATCH_HISTORY, JSON.stringify(this.serializeWatchHistory(watchHistory))); }; /** Use mediaid of originally watched movie / episode. diff --git a/src/services/epg/jw.epg.service.test.ts b/packages/common/src/services/epg/JWEpgService.test.ts similarity index 96% rename from src/services/epg/jw.epg.service.test.ts rename to packages/common/src/services/epg/JWEpgService.test.ts index f7cff7d82..af0976bc3 100644 --- a/src/services/epg/jw.epg.service.test.ts +++ b/packages/common/src/services/epg/JWEpgService.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect } from 'vitest'; import { mockFetch, mockGet } from 'vi-fetch'; import { unregister } from 'timezone-mock'; +import livePlaylistFixture from '@jwp/ott-testing/fixtures/livePlaylist.json'; -import JWEpgService from './jw.epg.service'; +import type { Playlist } from '../../../types/playlist'; -import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; -import type { Playlist } from '#types/playlist'; +import JWEpgService from './JWEpgService'; const livePlaylist = livePlaylistFixture as Playlist; const epgService = new JWEpgService(); diff --git a/src/services/epg/jw.epg.service.ts b/packages/common/src/services/epg/JWEpgService.ts similarity index 89% rename from src/services/epg/jw.epg.service.ts rename to packages/common/src/services/epg/JWEpgService.ts index 1066e424f..dc49e13e8 100644 --- a/src/services/epg/jw.epg.service.ts +++ b/packages/common/src/services/epg/JWEpgService.ts @@ -2,12 +2,11 @@ import { array, object, string } from 'yup'; import { isValid } from 'date-fns'; import { injectable } from 'inversify'; -import EpgService from './epg.service'; - -import type { PlaylistItem } from '#types/playlist'; -import { getDataOrThrow } from '#src/utils/api'; -import { logDev } from '#src/utils/common'; -import type { EpgProgram } from '#types/epg'; +import EpgService from '../EpgService'; +import type { PlaylistItem } from '../../../types/playlist'; +import type { EpgProgram } from '../../../types/epg'; +import { getDataOrThrow } from '../../utils/api'; +import { logDev } from '../../utils/common'; const AUTHENTICATION_HEADER = 'API-KEY'; diff --git a/src/services/epg/viewNexa.epg.service.test.ts b/packages/common/src/services/epg/ViewNexaEpgService.test.ts similarity index 95% rename from src/services/epg/viewNexa.epg.service.test.ts rename to packages/common/src/services/epg/ViewNexaEpgService.test.ts index c71c4c2fa..b2730a91a 100644 --- a/src/services/epg/viewNexa.epg.service.test.ts +++ b/packages/common/src/services/epg/ViewNexaEpgService.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect } from 'vitest'; import { mockFetch, mockGet } from 'vi-fetch'; import { unregister } from 'timezone-mock'; +import viewNexaChannel from '@jwp/ott-testing/epg/viewNexaChannel.xml?raw'; +import livePlaylistFixture from '@jwp/ott-testing/fixtures/livePlaylist.json'; -import ViewNexaEpgService from './viewNexa.epg.service'; +import type { Playlist } from '../../../types/playlist'; +import { EPG_TYPE } from '../../constants'; -import viewNexaChannel from '#test/epg/viewNexaChannel.xml?raw'; -import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; -import type { Playlist } from '#types/playlist'; -import { EPG_TYPE } from '#src/config'; +import ViewNexaEpgService from './ViewNexaEpgService'; const livePlaylist = livePlaylistFixture as Playlist; const epgService = new ViewNexaEpgService(); diff --git a/src/services/epg/viewNexa.epg.service.ts b/packages/common/src/services/epg/ViewNexaEpgService.ts similarity index 90% rename from src/services/epg/viewNexa.epg.service.ts rename to packages/common/src/services/epg/ViewNexaEpgService.ts index cc8142de6..b1ea1850c 100644 --- a/src/services/epg/viewNexa.epg.service.ts +++ b/packages/common/src/services/epg/ViewNexaEpgService.ts @@ -2,11 +2,10 @@ import { object, string } from 'yup'; import { parse } from 'date-fns'; import { injectable } from 'inversify'; -import EpgService from './epg.service'; - -import type { PlaylistItem } from '#types/playlist'; -import { logDev } from '#src/utils/common'; -import type { EpgProgram } from '#types/epg'; +import EpgService from '../EpgService'; +import type { PlaylistItem } from '../../../types/playlist'; +import { logDev } from '../../utils/common'; +import type { EpgProgram } from '../../../types/epg'; const viewNexaEpgProgramSchema = object().shape({ 'episode-num': object().shape({ diff --git a/src/services/account.service.ts b/packages/common/src/services/integrations/AccountService.ts similarity index 84% rename from src/services/account.service.ts rename to packages/common/src/services/integrations/AccountService.ts index 52649ad99..f6c795f3c 100644 --- a/src/services/account.service.ts +++ b/packages/common/src/services/integrations/AccountService.ts @@ -1,25 +1,25 @@ -import type { Config } from '#types/Config'; +import type { AccessModel, Config } from '../../../types/config'; import type { + AuthData, ChangePassword, + ChangePasswordWithOldPassword, + Customer, + CustomerConsent, + DeleteAccount, + ExportAccountData, + GetCaptureStatus, GetCustomerConsents, GetPublisherConsents, Login, + NotificationsData, Register, ResetPassword, + GetSocialURLs, + UpdateCaptureAnswers, UpdateCustomer, UpdateCustomerConsents, - GetCaptureStatus, - UpdateCaptureAnswers, - AuthData, - SocialURLSData, - ChangePasswordWithOldPassword, UpdatePersonalShelves, - ExportAccountData, - NotificationsData, - DeleteAccount, - Customer, - CustomerConsent, -} from '#types/account'; +} from '../../../types/account'; export type AccountServiceFeatures = { readonly canUpdateEmail: boolean; @@ -31,18 +31,21 @@ export type AccountServiceFeatures = { readonly canUpdatePaymentMethod: boolean; readonly canShowReceipts: boolean; readonly hasSocialURLs: boolean; - readonly hasProfiles: boolean; readonly hasNotifications: boolean; }; export default abstract class AccountService { readonly features: AccountServiceFeatures; + abstract accessModel: AccessModel; + abstract svodOfferIds: string[]; + abstract sandbox: boolean; + protected constructor(features: AccountServiceFeatures) { this.features = features; } - abstract initialize: (config: Config, logoutCallback: () => Promise) => Promise; + abstract initialize: (config: Config, url: string, logoutCallback: () => Promise) => Promise; abstract getAuthData: () => Promise; @@ -76,7 +79,7 @@ export default abstract class AccountService { abstract subscribeToNotifications: NotificationsData; - abstract getSocialUrls?: SocialURLSData; + abstract getSocialUrls?: GetSocialURLs; abstract exportAccountData?: ExportAccountData; diff --git a/src/services/checkout.service.ts b/packages/common/src/services/integrations/CheckoutService.ts similarity index 97% rename from src/services/checkout.service.ts rename to packages/common/src/services/integrations/CheckoutService.ts index 2bbceab13..483422073 100644 --- a/src/services/checkout.service.ts +++ b/packages/common/src/services/integrations/CheckoutService.ts @@ -4,9 +4,11 @@ import type { DeletePaymentMethod, FinalizeAdyenPaymentDetails, GetAdyenPaymentSession, + GetDirectPostCardPayment, GetEntitlements, GetFinalizeAdyenPayment, GetInitialAdyenPayment, + GetOffer, GetOffers, GetOrder, GetPaymentMethods, @@ -15,11 +17,9 @@ import type { PaymentWithoutDetails, PaymentWithPayPal, SwitchSubscription, - GetDirectPostCardPayment, UpdateOrder, UpdatePaymentWithPayPal, - GetOffer, -} from '#types/checkout'; +} from '../../../types/checkout'; export default abstract class CheckoutService { abstract getOffers: GetOffers; diff --git a/packages/common/src/services/integrations/ProfileService.ts b/packages/common/src/services/integrations/ProfileService.ts new file mode 100644 index 000000000..b0ed2fc0a --- /dev/null +++ b/packages/common/src/services/integrations/ProfileService.ts @@ -0,0 +1,15 @@ +import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../types/account'; + +export default abstract class ProfileService { + abstract listProfiles: ListProfiles; + + abstract createProfile: CreateProfile; + + abstract updateProfile: UpdateProfile; + + abstract enterProfile: EnterProfile; + + abstract getProfileDetails: GetProfileDetails; + + abstract deleteProfile: DeleteProfile; +} diff --git a/src/services/subscription.service.ts b/packages/common/src/services/integrations/SubscriptionService.ts similarity index 95% rename from src/services/subscription.service.ts rename to packages/common/src/services/integrations/SubscriptionService.ts index 9f146e786..ce8522da2 100644 --- a/src/services/subscription.service.ts +++ b/packages/common/src/services/integrations/SubscriptionService.ts @@ -1,15 +1,15 @@ import type { ChangeSubscription, FetchReceipt, - GetPaymentDetails, GetActivePayment, + GetActiveSubscription, + GetAllTransactions, + GetPaymentDetails, GetSubscriptions, GetTransactions, UpdateCardDetails, UpdateSubscription, - GetAllTransactions, - GetActiveSubscription, -} from '#types/subscription'; +} from '../../../types/subscription'; export default abstract class SubscriptionService { abstract getActiveSubscription: GetActiveSubscription; diff --git a/src/services/cleeng.account.service.ts b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts similarity index 62% rename from src/services/cleeng.account.service.ts rename to packages/common/src/services/integrations/cleeng/CleengAccountService.ts index 7d7e57eb0..1ad66bf10 100644 --- a/src/services/cleeng.account.service.ts +++ b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts @@ -1,44 +1,53 @@ import jwtDecode from 'jwt-decode'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; -import AccountService from './account.service'; -import CleengService from './cleeng.service'; - -import type { Config } from '#types/Config'; -import { getOverrideIP } from '#src/utils/common'; +import type { AccessModel, Config } from '../../../../types/config'; import type { + AuthData, + Capture, ChangePassword, + ChangePasswordWithOldPassword, + GetCaptureStatus, + GetCaptureStatusResponse, GetCustomer, GetCustomerConsents, + GetCustomerConsentsResponse, + GetLocales, GetPublisherConsents, + GetPublisherConsentsResponse, + JwtDetails, Login, + LoginPayload, + NotificationsData, Register, + RegisterPayload, ResetPassword, - UpdateCustomer, - UpdateCustomerConsents, - GetCaptureStatus, UpdateCaptureAnswers, - AuthData, - JwtDetails, - GetCustomerConsentsResponse, - GetCaptureStatusResponse, - Capture, - GetLocales, - LoginPayload, - RegisterPayload, UpdateCaptureAnswersPayload, + UpdateCustomer, + UpdateCustomerConsents, UpdateCustomerConsentsPayload, UpdateCustomerPayload, - ChangePasswordWithOldPassword, UpdatePersonalShelves, - NotificationsData, -} from '#types/account'; +} from '../../../../types/account'; +import AccountService from '../AccountService'; +import { GET_CUSTOMER_IP } from '../../../modules/types'; +import type { GetCustomerIP } from '../../../../types/get-customer-ip'; +import { ACCESS_MODEL } from '../../../constants'; + +import CleengService from './CleengService'; @injectable() export default class CleengAccountService extends AccountService { - private readonly cleengService: CleengService; + private readonly cleengService; + private readonly getCustomerIP; + private publisherId = ''; + + accessModel: AccessModel = ACCESS_MODEL.AUTHVOD; + svodOfferIds: string[] = []; + sandbox = false; - constructor(cleengService: CleengService) { + constructor(cleengService: CleengService, @inject(GET_CUSTOMER_IP) getCustomerIP: GetCustomerIP) { super({ canUpdateEmail: true, canSupportEmptyFullName: true, @@ -48,12 +57,12 @@ export default class CleengAccountService extends AccountService { canDeleteAccount: false, canUpdatePaymentMethod: true, canShowReceipts: true, - hasProfiles: false, hasSocialURLs: false, hasNotifications: false, }); this.cleengService = cleengService; + this.getCustomerIP = getCustomerIP; } private handleErrors = (errors: ApiResponse['errors']) => { @@ -67,16 +76,29 @@ export default class CleengAccountService extends AccountService { return decodedToken.customerId; }; - private getCustomer: GetCustomer = async (payload, sandbox) => { - return this.cleengService.get(sandbox, `/customers/${payload.customerId}`, { authenticate: true }); + private getCustomer: GetCustomer = async (payload) => { + return this.cleengService.get(`/customers/${payload.customerId}`, { authenticate: true }); }; - private getLocales: GetLocales = async (sandbox) => { - return this.cleengService.getLocales(sandbox); + private getLocales: GetLocales = async () => { + return this.cleengService.getLocales(); }; - initialize = async (config: Config, logoutCallback: () => Promise) => { - await this.cleengService.initialize(!!config.integrations.cleeng?.useSandbox, logoutCallback); + initialize = async (config: Config, _url: string, logoutCallback: () => Promise) => { + const cleengConfig = config?.integrations?.cleeng; + + if (!cleengConfig?.id) { + throw new Error('Failed to initialize Cleeng integration. The publisherId is missing.'); + } + + // set accessModel and publisherId + this.publisherId = cleengConfig.id; + this.accessModel = cleengConfig.monthlyOffer || cleengConfig.yearlyOffer ? ACCESS_MODEL.SVOD : ACCESS_MODEL.AUTHVOD; + this.svodOfferIds = [cleengConfig?.monthlyOffer, cleengConfig?.yearlyOffer].filter(Boolean).map(String); + + // initialize the Cleeng service + this.sandbox = !!cleengConfig.useSandbox; + await this.cleengService.initialize(this.sandbox, logoutCallback); }; getAuthData = async () => { @@ -91,10 +113,8 @@ export default class CleengAccountService extends AccountService { }; getCustomerConsents: GetCustomerConsents = async (payload) => { - const { config, customer } = payload; - const { cleeng } = config.integrations; - - const response: ServiceResponse = await this.cleengService.get(!!cleeng?.useSandbox, `/customers/${customer?.id}/consents`, { + const { customer } = payload; + const response: ServiceResponse = await this.cleengService.get(`/customers/${customer?.id}/consents`, { authenticate: true, }); this.handleErrors(response.errors); @@ -105,15 +125,14 @@ export default class CleengAccountService extends AccountService { }; updateCustomerConsents: UpdateCustomerConsents = async (payload) => { - const { config, customer } = payload; - const { cleeng } = config.integrations; + const { customer } = payload; const params: UpdateCustomerConsentsPayload = { id: customer.id, consents: payload.consents, }; - const response: ServiceResponse = await this.cleengService.put(!!cleeng?.useSandbox, `/customers/${customer?.id}/consents`, JSON.stringify(params), { + const response: ServiceResponse = await this.cleengService.put(`/customers/${customer?.id}/consents`, JSON.stringify(params), { authenticate: true, }); this.handleErrors(response.errors); @@ -125,15 +144,11 @@ export default class CleengAccountService extends AccountService { const payload: LoginPayload = { email, password, - publisherId: config.integrations.cleeng?.id || '', - customerIP: getOverrideIP(), + publisherId: this.publisherId, + customerIP: await this.getCustomerIP(), }; - const { responseData: auth, errors }: ServiceResponse = await this.cleengService.post( - !!config.integrations.cleeng?.useSandbox, - '/auths', - JSON.stringify(payload), - ); + const { responseData: auth, errors }: ServiceResponse = await this.cleengService.post('/auths', JSON.stringify(payload)); this.handleErrors(errors); await this.cleengService.setTokens({ accessToken: auth.jwt, refreshToken: auth.refreshToken }); @@ -148,7 +163,7 @@ export default class CleengAccountService extends AccountService { }; register: Register = async ({ config, email, password, consents }) => { - const localesResponse = await this.getLocales(!!config.integrations.cleeng?.useSandbox); + const localesResponse = await this.getLocales(); this.handleErrors(localesResponse.errors); @@ -158,15 +173,11 @@ export default class CleengAccountService extends AccountService { locale: localesResponse.responseData.locale, country: localesResponse.responseData.country, currency: localesResponse.responseData.currency, - publisherId: config.integrations.cleeng?.id || '', - customerIP: getOverrideIP(), + publisherId: this.publisherId, + customerIP: await this.getCustomerIP(), }; - const { responseData: auth, errors }: ServiceResponse = await this.cleengService.post( - !!config.integrations.cleeng?.useSandbox, - '/customers', - JSON.stringify(payload), - ); + const { responseData: auth, errors }: ServiceResponse = await this.cleengService.post('/customers', JSON.stringify(payload)); this.handleErrors(errors); await this.cleengService.setTokens({ accessToken: auth.jwt, refreshToken: auth.refreshToken }); @@ -195,7 +206,7 @@ export default class CleengAccountService extends AccountService { if (!authData) throw new Error('Not logged in'); const customerId = this.getCustomerIdFromAuthData(authData); - const { responseData: user, errors } = await this.getCustomer({ customerId }, !!config.integrations.cleeng?.useSandbox); + const { responseData: user, errors } = await this.getCustomer({ customerId }); this.handleErrors(errors); @@ -214,7 +225,7 @@ export default class CleengAccountService extends AccountService { getPublisherConsents: GetPublisherConsents = async (config) => { const { cleeng } = config.integrations; - const response = await this.cleengService.get(!!cleeng?.useSandbox, `/publishers/${cleeng?.id}/consents`); + const response: ServiceResponse = await this.cleengService.get(`/publishers/${cleeng?.id}/consents`); this.handleErrors(response.errors); @@ -223,8 +234,8 @@ export default class CleengAccountService extends AccountService { }; }; - getCaptureStatus: GetCaptureStatus = async ({ customer }, sandbox) => { - const response: ServiceResponse = await this.cleengService.get(sandbox, `/customers/${customer?.id}/capture/status`, { + getCaptureStatus: GetCaptureStatus = async ({ customer }) => { + const response: ServiceResponse = await this.cleengService.get(`/customers/${customer?.id}/capture/status`, { authenticate: true, }); @@ -233,18 +244,18 @@ export default class CleengAccountService extends AccountService { return response; }; - updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...payload }, sandbox) => { + updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...payload }) => { const params: UpdateCaptureAnswersPayload = { customerId: customer.id, ...payload, }; - const response: ServiceResponse = await this.cleengService.put(sandbox, `/customers/${customer.id}/capture`, JSON.stringify(params), { + const response: ServiceResponse = await this.cleengService.put(`/customers/${customer.id}/capture`, JSON.stringify(params), { authenticate: true, }); this.handleErrors(response.errors); - const { responseData, errors } = await this.getCustomer({ customerId: customer.id }, sandbox); + const { responseData, errors } = await this.getCustomer({ customerId: customer.id }); this.handleErrors(errors); return { @@ -253,12 +264,12 @@ export default class CleengAccountService extends AccountService { }; }; - resetPassword: ResetPassword = async (payload, sandbox) => { - return this.cleengService.put(sandbox, '/customers/passwords', JSON.stringify(payload)); + resetPassword: ResetPassword = async (payload) => { + return this.cleengService.put('/customers/passwords', JSON.stringify({ ...payload, publisherId: this.publisherId })); }; - changePasswordWithResetToken: ChangePassword = async (payload, sandbox) => { - return this.cleengService.patch(sandbox, '/customers/passwords', JSON.stringify(payload)); + changePasswordWithResetToken: ChangePassword = async (payload) => { + return this.cleengService.patch('/customers/passwords', JSON.stringify({ ...payload, publisherId: this.publisherId })); }; changePasswordWithOldPassword: ChangePasswordWithOldPassword = async () => { @@ -268,18 +279,21 @@ export default class CleengAccountService extends AccountService { }; }; - updateCustomer: UpdateCustomer = async (payload, sandbox) => { + updateCustomer: UpdateCustomer = async (payload) => { const { id, metadata, fullName, ...rest } = payload; const params: UpdateCustomerPayload = { id, ...rest, }; // enable keepalive to ensure data is persisted when closing the browser/tab - return this.cleengService.patch(sandbox, `/customers/${id}`, JSON.stringify(params), { authenticate: true, keepalive: true }); + return this.cleengService.patch(`/customers/${id}`, JSON.stringify(params), { + authenticate: true, + keepalive: true, + }); }; - updatePersonalShelves: UpdatePersonalShelves = async (payload, sandbox) => { - return await this.updateCustomer(payload, sandbox); + updatePersonalShelves: UpdatePersonalShelves = async (payload) => { + return await this.updateCustomer(payload); }; subscribeToNotifications: NotificationsData = async () => { diff --git a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts new file mode 100644 index 000000000..1386611d0 --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts @@ -0,0 +1,164 @@ +import { inject, injectable } from 'inversify'; + +import type { + AddAdyenPaymentDetails, + CreateOrder, + CreateOrderPayload, + DeletePaymentMethod, + FinalizeAdyenPaymentDetails, + GetAdyenPaymentSession, + GetEntitlements, + GetFinalizeAdyenPayment, + GetInitialAdyenPayment, + GetOffer, + GetOffers, + GetOrder, + GetPaymentMethods, + GetSubscriptionSwitch, + GetSubscriptionSwitches, + PaymentWithoutDetails, + PaymentWithPayPal, + SwitchSubscription, + UpdateOrder, + UpdatePaymentWithPayPal, +} from '../../../../types/checkout'; +import CheckoutService from '../CheckoutService'; +import { GET_CUSTOMER_IP } from '../../../modules/types'; +import type { GetCustomerIP } from '../../../../types/get-customer-ip'; + +import CleengService from './CleengService'; + +@injectable() +export default class CleengCheckoutService extends CheckoutService { + private readonly cleengService: CleengService; + private readonly getCustomerIP: GetCustomerIP; + + constructor(cleengService: CleengService, @inject(GET_CUSTOMER_IP) getCustomerIP: GetCustomerIP) { + super(); + this.cleengService = cleengService; + this.getCustomerIP = getCustomerIP; + } + + getOffers: GetOffers = async (payload) => { + return await Promise.all( + payload.offerIds.map(async (offerId) => { + const response = await this.getOffer({ offerId: String(offerId) }); + + if (response.errors.length > 0) { + throw new Error(response.errors[0]); + } + + return response.responseData; + }), + ); + }; + + getOffer: GetOffer = async (payload) => { + const customerIP = await this.getCustomerIP(); + + return this.cleengService.get(`/offers/${payload.offerId}${customerIP ? '?customerIP=' + customerIP : ''}`); + }; + + createOrder: CreateOrder = async (payload) => { + const locales = await this.cleengService.getLocales(); + + if (locales.errors.length > 0) throw new Error(locales.errors[0]); + + const createOrderPayload: CreateOrderPayload = { + offerId: payload.offer.offerId, + customerId: payload.customerId, + country: payload.country, + currency: locales?.responseData?.currency || 'EUR', + customerIP: payload.customerIP, + paymentMethodId: payload.paymentMethodId, + }; + + return this.cleengService.post('/orders', JSON.stringify(createOrderPayload), { authenticate: true }); + }; + + getOrder: GetOrder = async ({ orderId }) => { + return this.cleengService.get(`/orders/${orderId}`, { authenticate: true }); + }; + + updateOrder: UpdateOrder = async ({ order, ...payload }) => { + return this.cleengService.patch(`/orders/${order.id}`, JSON.stringify(payload), { authenticate: true }); + }; + + getPaymentMethods: GetPaymentMethods = async () => { + return this.cleengService.get('/payment-methods', { authenticate: true }); + }; + + paymentWithoutDetails: PaymentWithoutDetails = async (payload) => { + return this.cleengService.post('/payments', JSON.stringify(payload), { authenticate: true }); + }; + + paymentWithPayPal: PaymentWithPayPal = async (payload) => { + const { order, successUrl, cancelUrl, errorUrl } = payload; + + const paypalPayload = { + orderId: order.id, + successUrl, + cancelUrl, + errorUrl, + }; + + return this.cleengService.post('/connectors/paypal/v1/tokens', JSON.stringify(paypalPayload), { authenticate: true }); + }; + + getSubscriptionSwitches: GetSubscriptionSwitches = async (payload) => { + return this.cleengService.get(`/customers/${payload.customerId}/subscription_switches/${payload.offerId}/availability`, { authenticate: true }); + }; + + getSubscriptionSwitch: GetSubscriptionSwitch = async (payload) => { + return this.cleengService.get(`/subscription_switches/${payload.switchId}`, { authenticate: true }); + }; + + switchSubscription: SwitchSubscription = async (payload) => { + return this.cleengService.post( + `/customers/${payload.customerId}/subscription_switches/${payload.offerId}`, + JSON.stringify({ toOfferId: payload.toOfferId, switchDirection: payload.switchDirection }), + { authenticate: true }, + ); + }; + + getEntitlements: GetEntitlements = async (payload) => { + return this.cleengService.get(`/entitlements/${payload.offerId}`, { authenticate: true }); + }; + + createAdyenPaymentSession: GetAdyenPaymentSession = async (payload) => { + return await this.cleengService.post('/connectors/adyen/sessions', JSON.stringify(payload), { authenticate: true }); + }; + + initialAdyenPayment: GetInitialAdyenPayment = async (payload) => + this.cleengService.post( + '/connectors/adyen/initial-payment', + JSON.stringify({ ...payload, attemptAuthentication: this.cleengService.sandbox ? 'always' : undefined }), + { authenticate: true }, + ); + + finalizeAdyenPayment: GetFinalizeAdyenPayment = async (payload) => + this.cleengService.post('/connectors/adyen/initial-payment/finalize', JSON.stringify(payload), { authenticate: true }); + + updatePaymentMethodWithPayPal: UpdatePaymentWithPayPal = async (payload) => { + return this.cleengService.post('/connectors/paypal/v1/payment_details/tokens', JSON.stringify(payload), { authenticate: true }); + }; + + deletePaymentMethod: DeletePaymentMethod = async (payload) => { + return this.cleengService.remove(`/payment_details/${payload.paymentDetailsId}`, { authenticate: true }); + }; + + addAdyenPaymentDetails: AddAdyenPaymentDetails = async (payload) => + this.cleengService.post( + '/connectors/adyen/payment-details', + JSON.stringify({ + ...payload, + attemptAuthentication: this.cleengService.sandbox ? 'always' : undefined, + }), + { authenticate: true }, + ); + + finalizeAdyenPaymentDetails: FinalizeAdyenPaymentDetails = async (payload) => + this.cleengService.post('/connectors/adyen/payment-details/finalize', JSON.stringify(payload), { authenticate: true }); + + directPostCardPayment = async () => false; +} diff --git a/src/services/cleeng.service.ts b/packages/common/src/services/integrations/cleeng/CleengService.ts similarity index 76% rename from src/services/cleeng.service.ts rename to packages/common/src/services/integrations/cleeng/CleengService.ts index 67f857c49..e855c173e 100644 --- a/src/services/cleeng.service.ts +++ b/packages/common/src/services/integrations/cleeng/CleengService.ts @@ -1,12 +1,14 @@ import jwtDecode from 'jwt-decode'; import { object, string } from 'yup'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; +import { BroadcastChannel } from 'broadcast-channel'; -import { removeItem, getItem, setItem } from '#src/utils/persist'; -import { Broadcaster } from '#src/utils/broadcaster'; -import { getOverrideIP, IS_DEVELOPMENT_BUILD, logDev } from '#src/utils/common'; -import { PromiseQueue } from '#src/utils/promiseQueue'; -import type { GetLocales } from '#types/account'; +import { IS_DEVELOPMENT_BUILD, logDev } from '../../../utils/common'; +import { PromiseQueue } from '../../../utils/promiseQueue'; +import type { AuthData, GetLocales } from '../../../../types/account'; +import StorageService from '../../StorageService'; +import { GET_CUSTOMER_IP } from '../../../modules/types'; +import type { GetCustomerIP } from '../../../../types/get-customer-ip'; const AUTH_PERSIST_KEY = 'auth'; @@ -60,17 +62,6 @@ const getTokenExpiration = (token: string) => { return -1; }; -/** - * Persist the given token in the storage. Removes the token when the given token is `null`. - */ -const persistInStorage = async (tokens: Tokens | null) => { - if (tokens) { - setItem(AUTH_PERSIST_KEY, JSON.stringify(tokens)); - } else { - removeItem(AUTH_PERSIST_KEY); - } -}; - /** * The AuthService is responsible for managing JWT access tokens and refresh tokens. * @@ -85,16 +76,33 @@ const persistInStorage = async (tokens: Tokens | null) => { @injectable() export default class CleengService { - private readonly channel: Broadcaster; + private readonly storageService; + private readonly getCustomerIP; + private readonly channel: BroadcastChannel; private readonly queue = new PromiseQueue(); private isRefreshing = false; private expiration = -1; - private sandbox = false; + + sandbox = false; tokens: Tokens | null = null; - constructor() { - this.channel = new Broadcaster('jwp-refresh-token-channel'); - this.channel.addMessageListener(this.handleBroadcastMessage); + constructor(storageService: StorageService, @inject(GET_CUSTOMER_IP) getCustomerIP: GetCustomerIP) { + this.storageService = storageService; + this.getCustomerIP = getCustomerIP; + + this.channel = new BroadcastChannel('jwp-refresh-token-channel'); + this.channel.addEventListener('message', this.handleBroadcastMessage); + } + + /** + * Persist the given token in the storage. Removes the token when the given token is `null`. + */ + private async persistInStorage(tokens: Tokens | null) { + if (tokens) { + await this.storageService.setItem(AUTH_PERSIST_KEY, JSON.stringify(tokens)); + } else { + await this.storageService.removeItem(AUTH_PERSIST_KEY); + } } /** @@ -111,7 +119,7 @@ export default class CleengService { */ private getNewTokens: (tokens: Tokens) => Promise = async ({ refreshToken }) => { try { - const { responseData: newTokens } = await this.post(this.sandbox, '/auths/refresh_token', JSON.stringify({ refreshToken })); + const { responseData: newTokens } = await this.post>>('/auths/refresh_token', JSON.stringify({ refreshToken })); return { accessToken: newTokens.jwt, @@ -159,16 +167,16 @@ export default class CleengService { tokens, }; - this.channel.broadcastMessage(message); + this.channel.postMessage(message); }; - private getBaseUrl = (sandbox: boolean) => (sandbox ? 'https://mediastore-sandbox.cleeng.com' : 'https://mediastore.cleeng.com'); + private getBaseUrl = () => (this.sandbox ? 'https://mediastore-sandbox.cleeng.com' : 'https://mediastore.cleeng.com'); - private performRequest = async (sandbox: boolean, path: string = '/', method = 'GET', body?: string, options: RequestOptions = {}) => { + private performRequest = async (path: string = '/', method = 'GET', body?: string, options: RequestOptions = {}) => { try { const token = options.authenticate ? await this.getAccessTokenOrThrow() : undefined; - const resp = await fetch(`${this.getBaseUrl(sandbox)}${path}`, { + const resp = await fetch(`${this.getBaseUrl()}${path}`, { headers: { Accept: 'application/json', 'Content-Type': 'application/json', @@ -217,7 +225,7 @@ export default class CleengService { this.tokens = tokens; this.expiration = getTokenExpiration(tokens.accessToken); - await persistInStorage(this.tokens); + await this.persistInStorage(this.tokens); }; /** @@ -226,14 +234,14 @@ export default class CleengService { clearTokens = async () => { this.tokens = null; - await persistInStorage(null); + await this.persistInStorage(null); }; /** * Try to restore tokens from the storage and overwrite the current when they are newer. */ restoreTokensFromStorage = async () => { - const tokensString = await getItem(AUTH_PERSIST_KEY); + const tokensString = await this.storageService.getItem(AUTH_PERSIST_KEY, false); let tokens; if (typeof tokensString !== 'string') return; @@ -348,17 +356,19 @@ export default class CleengService { return accessToken; }; - getLocales: GetLocales = async (sandbox) => { - return this.get(sandbox, `/locales${getOverrideIP() ? '?customerIP=' + getOverrideIP() : ''}`); + getLocales: GetLocales = async () => { + const customerIP = await this.getCustomerIP(); + + return this.get(`/locales${customerIP ? '?customerIP=' + customerIP : ''}`); }; - get = (sandbox: boolean, path: string, options?: RequestOptions) => this.performRequest(sandbox, path, 'GET', undefined, options); + get = (path: string, options?: RequestOptions) => this.performRequest(path, 'GET', undefined, options) as T; - patch = (sandbox: boolean, path: string, body?: string, options?: RequestOptions) => this.performRequest(sandbox, path, 'PATCH', body, options); + patch = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PATCH', body, options) as T; - put = (sandbox: boolean, path: string, body?: string, options?: RequestOptions) => this.performRequest(sandbox, path, 'PUT', body, options); + put = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PUT', body, options) as T; - post = (sandbox: boolean, path: string, body?: string, options?: RequestOptions) => this.performRequest(sandbox, path, 'POST', body, options); + post = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'POST', body, options) as T; - remove = (sandbox: boolean, path: string, options?: RequestOptions) => this.performRequest(sandbox, path, 'DELETE', undefined, options); + remove = (path: string, options?: RequestOptions) => this.performRequest(path, 'DELETE', undefined, options) as T; } diff --git a/packages/common/src/services/integrations/cleeng/CleengSubscriptionService.ts b/packages/common/src/services/integrations/cleeng/CleengSubscriptionService.ts new file mode 100644 index 000000000..373155b8b --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/CleengSubscriptionService.ts @@ -0,0 +1,74 @@ +import { injectable } from 'inversify'; + +import { createURL } from '../../../utils/urlFormatting'; +import type { + FetchReceipt, + GetActivePayment, + GetActiveSubscription, + GetAllTransactions, + GetPaymentDetails, + GetSubscriptions, + GetTransactions, + UpdateSubscription, +} from '../../../../types/subscription'; +import SubscriptionService from '../SubscriptionService'; + +import CleengService from './CleengService'; + +@injectable() +export default class CleengSubscriptionService extends SubscriptionService { + private readonly cleengService: CleengService; + + constructor(cleengService: CleengService) { + super(); + this.cleengService = cleengService; + } + + getActiveSubscription: GetActiveSubscription = async ({ customerId }) => { + const response = await this.getSubscriptions({ customerId }); + + if (response.errors.length > 0) return null; + + return response.responseData.items.find((item) => item.status === 'active' || item.status === 'cancelled') || null; + }; + + getAllTransactions: GetAllTransactions = async ({ customerId }) => { + const response = await this.getTransactions({ customerId }); + + if (response.errors.length > 0) return null; + + return response.responseData.items; + }; + + getActivePayment: GetActivePayment = async ({ customerId }) => { + const response = await this.getPaymentDetails({ customerId }); + + if (response.errors.length > 0) return null; + + return response.responseData.paymentDetails.find((paymentDetails) => paymentDetails.active) || null; + }; + + getSubscriptions: GetSubscriptions = async (payload) => { + return this.cleengService.get(`/customers/${payload.customerId}/subscriptions`, { authenticate: true }); + }; + + updateSubscription: UpdateSubscription = async (payload) => { + return this.cleengService.patch(`/customers/${payload.customerId}/subscriptions`, JSON.stringify(payload), { authenticate: true }); + }; + + getPaymentDetails: GetPaymentDetails = async (payload) => { + return this.cleengService.get(`/customers/${payload.customerId}/payment_details`, { authenticate: true }); + }; + + getTransactions: GetTransactions = async ({ customerId, limit, offset }) => { + return this.cleengService.get(createURL(`/customers/${customerId}/transactions`, { limit, offset }), { authenticate: true }); + }; + + fetchReceipt: FetchReceipt = async ({ transactionId }) => { + return this.cleengService.get(`/receipt/${transactionId}`, { authenticate: true }); + }; + + updateCardDetails: undefined; + + changeSubscription: undefined; +} diff --git a/src/services/inplayer.account.service.ts b/packages/common/src/services/integrations/jwp/JWPAccountService.ts similarity index 84% rename from src/services/inplayer.account.service.ts rename to packages/common/src/services/integrations/jwp/JWPAccountService.ts index 7ffaeb7c1..627fc368c 100644 --- a/src/services/inplayer.account.service.ts +++ b/packages/common/src/services/integrations/jwp/JWPAccountService.ts @@ -1,11 +1,10 @@ -import InPlayer, { AccountData, Env, FavoritesData, UpdateAccountData, WatchHistory, type RegisterField } from '@inplayer-org/inplayer.js'; +import InPlayer, { Env } from '@inplayer-org/inplayer.js'; +import type { AccountData, FavoritesData, RegisterField, UpdateAccountData, WatchHistory } from '@inplayer-org/inplayer.js'; import i18next from 'i18next'; import { injectable } from 'inversify'; -import AccountService from './account.service'; - -import { formatConsentsToRegisterFields } from '#src/utils/collection'; -import { getCommonResponseData, isCommonError } from '#src/utils/api'; +import { formatConsentsToRegisterFields } from '../../../utils/collection'; +import { getCommonResponseData, isCommonError } from '../../../utils/api'; import type { AuthData, Capture, @@ -14,6 +13,7 @@ import type { Consent, Customer, CustomerConsent, + CustomRegisterFieldVariant, DeleteAccount, ExportAccountData, ExternalData, @@ -22,32 +22,43 @@ import type { GetCustomerConsentsResponse, GetPublisherConsents, Login, + NotificationsData, Register, ResetPassword, - SocialURLSData, + GetSocialURLs, UpdateCaptureAnswers, UpdateCustomer, UpdateCustomerArgs, UpdateCustomerConsents, UpdatePersonalShelves, - CustomRegisterFieldVariant, - NotificationsData, -} from '#types/account'; -import type { Config } from '#types/Config'; -import type { InPlayerAuthData } from '#types/inplayer'; -import type { Favorite } from '#types/favorite'; -import type { WatchHistoryItem } from '#types/watchHistory'; +} from '../../../../types/account'; +import type { AccessModel, Config } from '../../../../types/config'; +import type { InPlayerAuthData } from '../../../../types/inplayer'; +import type { Favorite } from '../../../../types/favorite'; +import type { WatchHistoryItem } from '../../../../types/watchHistory'; +import AccountService from '../AccountService'; +import StorageService from '../../StorageService'; +import { ACCESS_MODEL } from '../../../constants'; enum InPlayerEnv { Development = 'development', Production = 'production', Daily = 'daily', } + const JW_TERMS_URL = 'https://inplayer.com/legal/terms'; @injectable() -export default class InplayerAccountService extends AccountService { - constructor() { +export default class JWPAccountService extends AccountService { + private readonly storageService; + private clientId = ''; + + accessModel: AccessModel = ACCESS_MODEL.AUTHVOD; + assetId: number | null = null; + svodOfferIds: string[] = []; + sandbox = false; + + constructor(storageService: StorageService) { super({ canUpdateEmail: false, canSupportEmptyFullName: false, @@ -58,9 +69,10 @@ export default class InplayerAccountService extends AccountService { canShowReceipts: true, canDeleteAccount: true, hasNotifications: true, - hasProfiles: true, hasSocialURLs: true, }); + + this.storageService = storageService; } private getCustomerExternalData = async (): Promise => { @@ -130,16 +142,40 @@ export default class InplayerAccountService extends AccountService { }; } - initialize = async (config: Config, _logoutFn: () => Promise) => { - const env: string = config.integrations?.jwp?.useSandbox ? InPlayerEnv.Development : InPlayerEnv.Production; + initialize = async (config: Config, url: string, _logoutFn: () => Promise) => { + const jwpConfig = config.integrations?.jwp; + + if (!jwpConfig?.clientId) { + throw new Error('Failed to initialize JWP integration. The clientId is missing.'); + } + + // set environment + this.sandbox = !!jwpConfig.useSandbox; + + const env: string = this.sandbox ? InPlayerEnv.Development : InPlayerEnv.Production; InPlayer.setConfig(env as Env); - const queryParams = new URLSearchParams(window.location.href.split('#')[1]); + + // calculate access model + if (jwpConfig.clientId) { + this.clientId = jwpConfig.clientId; + } + + if (jwpConfig.assetId) { + this.accessModel = ACCESS_MODEL.SVOD; + this.assetId = jwpConfig.assetId; + this.svodOfferIds = jwpConfig.assetId ? [String(jwpConfig.assetId)] : []; + } + + // restore session from URL params + const queryParams = new URLSearchParams(url.split('#')[1]); const token = queryParams.get('token'); const refreshToken = queryParams.get('refresh_token'); const expires = queryParams.get('expires'); + if (!token || !refreshToken || !expires) { return; } + InPlayer.Account.setToken(token, refreshToken, parseInt(expires)); }; @@ -156,10 +192,9 @@ export default class InplayerAccountService extends AccountService { return null; }; - getPublisherConsents: GetPublisherConsents = async (config) => { + getPublisherConsents: GetPublisherConsents = async () => { try { - const { jwp } = config.integrations; - const { data } = await InPlayer.Account.getRegisterFields(jwp?.clientId || ''); + const { data } = await InPlayer.Account.getRegisterFields(this.clientId); const terms = data?.collection.find(({ name }) => name === 'terms'); @@ -237,7 +272,7 @@ export default class InplayerAccountService extends AccountService { }; updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...newAnswers }) => { - return (await this.updateCustomer({ ...customer, ...newAnswers }, true)) as ServiceResponse; + return (await this.updateCustomer({ ...customer, ...newAnswers })) as ServiceResponse; }; changePasswordWithOldPassword: ChangePasswordWithOldPassword = async (payload) => { @@ -260,11 +295,11 @@ export default class InplayerAccountService extends AccountService { } }; - resetPassword: ResetPassword = async ({ customerEmail, publisherId }) => { + resetPassword: ResetPassword = async ({ customerEmail }) => { try { await InPlayer.Account.requestNewPassword({ email: customerEmail, - merchantUuid: publisherId || '', + merchantUuid: this.clientId, brandingId: 0, }); return { @@ -276,13 +311,13 @@ export default class InplayerAccountService extends AccountService { } }; - login: Login = async ({ config, email, password }) => { + login: Login = async ({ config, email, password, referrer }) => { try { const { data } = await InPlayer.Account.signInV2({ email, password, + referrer, clientId: config.integrations.jwp?.clientId || '', - referrer: window.location.href, }); const user = this.formatAccount(data.account); @@ -298,11 +333,12 @@ export default class InplayerAccountService extends AccountService { } }; - register: Register = async ({ config, email, password, consents }) => { + register: Register = async ({ config, email, password, referrer, consents }) => { try { const { data } = await InPlayer.Account.signUpV2({ email, password, + referrer, passwordConfirmation: password, fullName: email, metadata: { @@ -313,7 +349,6 @@ export default class InplayerAccountService extends AccountService { }, type: 'consumer', clientId: config.integrations.jwp?.clientId || '', - referrer: window.location.href, }); const user = this.formatAccount(data.account); @@ -434,6 +469,8 @@ export default class InplayerAccountService extends AccountService { getTermsConsent = ({ label: termsUrl }: RegisterField): Consent => { const termsLink = `${i18next.t('account:registration.terms_and_conditions')}`; + // t('account:registration.terms_consent_jwplayer') + // t('account:registration.terms_consent') return { type: 'checkbox', isCustomRegisterField: true, @@ -522,11 +559,11 @@ export default class InplayerAccountService extends AccountService { } }; - getSocialUrls: SocialURLSData = async (config: Config) => { - const socialState = window.btoa( + getSocialUrls: GetSocialURLs = async ({ config, redirectUrl }) => { + const socialState = this.storageService.base64Encode( JSON.stringify({ client_id: config.integrations.jwp?.clientId || '', - redirect: window.location.href.split('u=')[0], + redirect: redirectUrl, }), ); diff --git a/src/services/inplayer.checkout.service.ts b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts similarity index 93% rename from src/services/inplayer.checkout.service.ts rename to packages/common/src/services/integrations/jwp/JWPCheckoutService.ts index d791f0dd7..2f7ab6ef0 100644 --- a/src/services/inplayer.checkout.service.ts +++ b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts @@ -1,8 +1,7 @@ -import InPlayer, { AccessFee, MerchantPaymentMethod } from '@inplayer-org/inplayer.js'; +import InPlayer, { type AccessFee, type MerchantPaymentMethod } from '@inplayer-org/inplayer.js'; import { injectable } from 'inversify'; -import CheckoutService from './checkout.service'; - +import { isSVODOffer } from '../../../utils/subscription'; import type { CardPaymentData, CreateOrder, @@ -19,11 +18,11 @@ import type { PaymentWithoutDetails, PaymentWithPayPal, UpdateOrder, -} from '#types/checkout'; -import { isSVODOffer } from '#src/utils/subscription'; +} from '../../../../types/checkout'; +import CheckoutService from '../CheckoutService'; @injectable() -export default class InplayerCheckoutService extends CheckoutService { +export default class JWPCheckoutService extends CheckoutService { private readonly cardPaymentProvider = 'stripe'; private formatPaymentMethod = (method: MerchantPaymentMethod, cardPaymentProvider: string): PaymentMethod => { @@ -134,7 +133,7 @@ export default class InplayerCheckoutService extends CheckoutService { paymentWithPayPal: PaymentWithPayPal = async (payload) => { try { const response = await InPlayer.Payment.getPayPalParams({ - origin: `${window.location.origin}?u=waiting-for-payment`, + origin: payload.waitingUrl, accessFeeId: payload.order.id, paymentMethod: 2, voucherCode: payload.couponCode, @@ -211,7 +210,7 @@ export default class InplayerCheckoutService extends CheckoutService { } }; - directPostCardPayment = async (cardPaymentPayload: CardPaymentData, order: Order) => { + directPostCardPayment = async (cardPaymentPayload: CardPaymentData, order: Order, referrer: string, returnUrl: string) => { const payload = { number: cardPaymentPayload.cardNumber.replace(/\s/g, ''), cardName: cardPaymentPayload.cardholderName, @@ -221,8 +220,8 @@ export default class InplayerCheckoutService extends CheckoutService { accessFee: order.id, paymentMethod: 1, voucherCode: cardPaymentPayload.couponCode, - referrer: window.location.href, - returnUrl: `${window.location.href}&u=waiting-for-payment`, + referrer, + returnUrl, }; try { diff --git a/src/services/inplayer.profile.service.ts b/packages/common/src/services/integrations/jwp/JWPProfileService.ts similarity index 70% rename from src/services/inplayer.profile.service.ts rename to packages/common/src/services/integrations/jwp/JWPProfileService.ts index 5aa5a06b7..132ebe379 100644 --- a/src/services/inplayer.profile.service.ts +++ b/packages/common/src/services/integrations/jwp/JWPProfileService.ts @@ -1,13 +1,20 @@ import InPlayer from '@inplayer-org/inplayer.js'; import { injectable } from 'inversify'; +import defaultAvatar from '@jwp/ott-theme/assets/profiles/default_avatar.png'; -import ProfileService from './profile.service'; - -import type { ListProfiles, CreateProfile, UpdateProfile, EnterProfile, GetProfileDetails, DeleteProfile } from '#types/account'; -import defaultAvatar from '#src/assets/profiles/default_avatar.png'; +import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../../types/account'; +import ProfileService from '../ProfileService'; +import StorageService from '../../StorageService'; @injectable() -export default class InplayerProfileService extends ProfileService { +export default class JWPProfileService extends ProfileService { + private readonly storageService; + + constructor(storageService: StorageService) { + super(); + this.storageService = storageService; + } + listProfiles: ListProfiles = async () => { try { const response = await InPlayer.Account.getProfiles(); @@ -56,8 +63,21 @@ export default class InplayerProfileService extends ProfileService { enterProfile: EnterProfile = async ({ id, pin }) => { try { const response = await InPlayer.Account.enterProfile(id, pin); + const profile = response.data; + + // this sets the inplayer_token for the InPlayer SDK + if (profile) { + const tokenData = JSON.stringify({ + expires: profile.credentials.expires, + token: profile.credentials.access_token, + refreshToken: '', + }); + + await this.storageService.setItem('inplayer_token', tokenData, false); + } + return { - responseData: response.data, + responseData: profile, errors: [], }; } catch { diff --git a/src/services/inplayer.subscription.service.ts b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts similarity index 84% rename from src/services/inplayer.subscription.service.ts rename to packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts index 15f97df8d..566d78a53 100644 --- a/src/services/inplayer.subscription.service.ts +++ b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts @@ -1,10 +1,11 @@ import i18next from 'i18next'; -import InPlayer, { PaymentHistory, Card, GetItemAccessV1, SubscriptionDetails as InplayerSubscription } from '@inplayer-org/inplayer.js'; -import { injectable } from 'inversify'; - -import SubscriptionService from './subscription.service'; +import InPlayer from '@inplayer-org/inplayer.js'; +import type { Card, GetItemAccessV1, PaymentHistory, SubscriptionDetails as InplayerSubscription } from '@inplayer-org/inplayer.js'; +import { injectable, named } from 'inversify'; +import { isCommonError } from '../../../utils/api'; import type { + ChangeSubscription, GetActivePayment, GetActiveSubscription, GetAllTransactions, @@ -13,10 +14,11 @@ import type { Transaction, UpdateCardDetails, UpdateSubscription, - ChangeSubscription, -} from '#types/subscription'; -import type { Config } from '#types/Config'; -import { isCommonError } from '#src/utils/api'; +} from '../../../../types/subscription'; +import SubscriptionService from '../SubscriptionService'; +import AccountService from '../AccountService'; + +import type JWPAccountService from './JWPAccountService'; interface SubscriptionDetails extends InplayerSubscription { item_id?: number; @@ -34,8 +36,22 @@ interface SubscriptionDetails extends InplayerSubscription { } @injectable() -export default class InplayerSubscriptionService extends SubscriptionService { - private formatCardDetails = (card: Card & { card_type: string; account_id: number; currency: string }): PaymentDetail => { +export default class JWPSubscriptionService extends SubscriptionService { + private readonly accountService: JWPAccountService; + + constructor(@named('JWP') accountService: AccountService) { + super(); + + this.accountService = accountService as JWPAccountService; + } + + private formatCardDetails = ( + card: Card & { + card_type: string; + account_id: number; + currency: string; + }, + ): PaymentDetail => { const { number, exp_month, exp_year, card_name, card_type, account_id, currency } = card; const zeroFillExpMonth = `0${exp_month}`.slice(-2); return { @@ -136,9 +152,12 @@ export default class InplayerSubscriptionService extends SubscriptionService { } as Subscription; }; - getActiveSubscription: GetActiveSubscription = async ({ config }: { config: Config }) => { + getActiveSubscription: GetActiveSubscription = async () => { + const assetId = this.accountService.assetId; + + if (assetId === null) throw new Error("Couldn't fetch active subscription, there is no assetId configured"); + try { - const assetId = config.integrations.jwp?.assetId || 0; const hasAccess = await InPlayer.Asset.checkAccessForAsset(assetId); if (hasAccess) { @@ -212,7 +231,10 @@ export default class InplayerSubscriptionService extends SubscriptionService { changeSubscription: ChangeSubscription = async ({ accessFeeId, subscriptionId }) => { try { - const response = await InPlayer.Subscription.changeSubscriptionPlan({ access_fee_id: parseInt(accessFeeId), inplayer_token: subscriptionId }); + const response = await InPlayer.Subscription.changeSubscriptionPlan({ + access_fee_id: parseInt(accessFeeId), + inplayer_token: subscriptionId, + }); return { errors: [], responseData: { message: response.data.message }, @@ -224,7 +246,14 @@ export default class InplayerSubscriptionService extends SubscriptionService { updateCardDetails: UpdateCardDetails = async ({ cardName, cardNumber, cvc, expMonth, expYear, currency }) => { try { - const response = await InPlayer.Payment.setDefaultCreditCard({ cardName, cardNumber, cvc, expMonth, expYear, currency }); + const response = await InPlayer.Payment.setDefaultCreditCard({ + cardName, + cardNumber, + cvc, + expMonth, + expYear, + currency, + }); return { errors: [], responseData: response.data, diff --git a/src/stores/AccountController.ts b/packages/common/src/stores/AccountController.ts similarity index 74% rename from src/stores/AccountController.ts rename to packages/common/src/stores/AccountController.ts index 7ac5e6a1a..35448fdc2 100644 --- a/src/stores/AccountController.ts +++ b/packages/common/src/stores/AccountController.ts @@ -1,12 +1,13 @@ import i18next from 'i18next'; -import { injectable, inject } from 'inversify'; - -import FavoritesController from './FavoritesController'; -import WatchHistoryController from './WatchHistoryController'; -import ProfileController from './ProfileController'; -import { useProfileStore } from './ProfileStore'; - -import { useConfigStore } from '#src/stores/ConfigStore'; +import { inject, injectable } from 'inversify'; + +import { ACCESS_MODEL, DEFAULT_FEATURES } from '../constants'; +import { logDev } from '../utils/common'; +import type { IntegrationType } from '../../types/config'; +import CheckoutService from '../services/integrations/CheckoutService'; +import AccountService, { type AccountServiceFeatures } from '../services/integrations/AccountService'; +import SubscriptionService from '../services/integrations/SubscriptionService'; +import type { Offer } from '../../types/checkout'; import type { Capture, Customer, @@ -17,22 +18,18 @@ import type { GetCustomerConsentsResponse, GetPublisherConsentsResponse, SubscribeToNotificationsPayload, -} from '#types/account'; -import type { Offer } from '#types/checkout'; -import { useAccountStore } from '#src/stores/AccountStore'; -import { queryClient } from '#src/containers/QueryProvider/QueryProvider'; -import { logDev } from '#src/utils/common'; -import SubscriptionService from '#src/services/subscription.service'; -import AccountService, { type AccountServiceFeatures } from '#src/services/account.service'; -import CheckoutService from '#src/services/checkout.service'; -import { useFavoritesStore } from '#src/stores/FavoritesStore'; -import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; -import * as persist from '#src/utils/persist'; -import { ACCESS_MODEL, DEFAULT_FEATURES } from '#src/config'; -import { assertFeature, assertModuleMethod, getNamedModule } from '#src/modules/container'; -import type { IntegrationType } from '#types/Config'; - -const PERSIST_PROFILE = 'profile'; +} from '../../types/account'; +import { assertFeature, assertModuleMethod, getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; + +import { useWatchHistoryStore } from './WatchHistoryStore'; +import { useFavoritesStore } from './FavoritesStore'; +import { useAccountStore } from './AccountStore'; +import { useConfigStore } from './ConfigStore'; +import { useProfileStore } from './ProfileStore'; +import ProfileController from './ProfileController'; +import WatchHistoryController from './WatchHistoryController'; +import FavoritesController from './FavoritesController'; @injectable() export default class AccountController { @@ -44,8 +41,11 @@ export default class AccountController { private readonly profileController?: ProfileController; private readonly features: AccountServiceFeatures; + // temporary callback for refreshing the query cache until we've updated to react-query v4 or v5 + private refreshEntitlements: (() => Promise) | undefined; + constructor( - @inject('INTEGRATION_TYPE') integrationType: IntegrationType, + @inject(INTEGRATION_TYPE) integrationType: IntegrationType, favoritesController: FavoritesController, watchHistoryController: WatchHistoryController, profileController?: ProfileController, @@ -82,23 +82,30 @@ export default class AccountController { } }; - initialize = async () => { - useAccountStore.setState({ - loading: true, - }); + initialize = async (url: string, refreshEntitlements?: () => Promise) => { + this.refreshEntitlements = refreshEntitlements; + + useAccountStore.setState({ loading: true }); const config = useConfigStore.getState().config; await this.profileController?.loadPersistedProfile(); - await this.accountService.initialize(config, this.logout); + await this.accountService.initialize(config, url, this.logout); + + // set the accessModel before restoring the user session + useConfigStore.setState({ accessModel: this.accountService.accessModel }); + await this.loadUserData(); useAccountStore.setState({ loading: false }); }; + getSandbox() { + return this.accountService.sandbox; + } + updatePersonalShelves = async () => { const { watchHistory } = useWatchHistoryStore.getState(); const { favorites } = useFavoritesStore.getState(); - const { isSandbox } = useConfigStore.getState(); const { getAccountInfo } = useAccountStore.getState(); const { customer } = getAccountInfo(); @@ -110,19 +117,15 @@ export default class AccountController { favorites: this.favoritesController?.serializeFavorites(favorites), }; - return this.accountService?.updatePersonalShelves( - { - id: customer.id, - externalData: personalShelfData, - }, - isSandbox, - ); + return this.accountService?.updatePersonalShelves({ + id: customer.id, + externalData: personalShelfData, + }); }; updateUser = async (values: FirstLastNameInput | EmailConfirmPasswordInput): Promise> => { useAccountStore.setState({ loading: true }); - const { isSandbox } = useConfigStore.getState(); const { user } = useAccountStore.getState(); const { canUpdateEmail, canSupportEmptyFullName } = this.getFeatures(); @@ -148,7 +151,7 @@ export default class AccountController { payload = { ...values, email: user.email }; } - const response = await this.accountService.updateCustomer({ ...payload, id: user.id.toString() }, isSandbox); + const response = await this.accountService.updateCustomer({ ...payload, id: user.id.toString() }); if (!response) { throw new Error('Unknown error'); @@ -184,12 +187,13 @@ export default class AccountController { } }; - login = async (email: string, password: string) => { + login = async (email: string, password: string, referrer: string) => { const { config, accessModel } = useConfigStore.getState(); useAccountStore.setState({ loading: true }); - const response = await this.accountService.login({ config, email, password }); + const response = await this.accountService.login({ config, email, password, referrer }); + if (response) { await this.afterLogin(response.user, response.customerConsents, accessModel); @@ -200,40 +204,19 @@ export default class AccountController { useAccountStore.setState({ loading: false }); }; - logout = async (logoutOptions: { includeNetworkRequest: boolean } = { includeNetworkRequest: true }) => { - // this invalidates all entitlements caches which makes the useEntitlement hook to verify the entitlements. - await queryClient.invalidateQueries('entitlements'); - - useAccountStore.setState({ - user: null, - subscription: null, - transactions: null, - activePayment: null, - customerConsents: null, - publisherConsents: null, - loading: false, - }); - - await this.favoritesController?.restoreFavorites(); - await this.watchHistoryController?.restoreWatchHistory(); + logout = async () => { await this.accountService?.logout(); - - persist.removeItem(PERSIST_PROFILE); - - // this invalidates all entitlements caches which makes the useEntitlement hook to verify the entitlements. - await queryClient.invalidateQueries('entitlements'); - await this.clearLoginState(); - if (logoutOptions.includeNetworkRequest) { - await this.accountService?.logout(); - } + + // let the application know to refresh all entitlements + await this.refreshEntitlements?.(); }; - register = async (email: string, password: string, consents: CustomerConsent[]) => { + register = async (email: string, password: string, referrer: string, consents: CustomerConsent[]) => { const { config, accessModel } = useConfigStore.getState(); useAccountStore.setState({ loading: true }); - const response = await this.accountService.register({ config, email, password, consents }); + const response = await this.accountService.register({ config, email, password, consents, referrer }); if (response) { const { user, customerConsents } = response; @@ -301,10 +284,9 @@ export default class AccountController { getCaptureStatus = async (): Promise => { const { getAccountInfo } = useAccountStore.getState(); - const { isSandbox } = useConfigStore.getState(); const { customer } = getAccountInfo(); - const { responseData } = await this.accountService.getCaptureStatus({ customer }, isSandbox); + const { responseData } = await this.accountService.getCaptureStatus({ customer }); return responseData; }; @@ -312,11 +294,9 @@ export default class AccountController { updateCaptureAnswers = async (capture: Capture): Promise => { const { getAccountInfo } = useAccountStore.getState(); const { accessModel } = useConfigStore.getState(); - const { isSandbox } = useConfigStore.getState(); - const { customer, customerConsents } = getAccountInfo(); - const response = await this.accountService.updateCaptureAnswers({ customer, ...capture }, isSandbox); + const response = await this.accountService.updateCaptureAnswers({ customer, ...capture }); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -326,16 +306,10 @@ export default class AccountController { }; resetPassword = async (email: string, resetUrl: string) => { - const { isSandbox, clientId: publisherId } = useConfigStore.getState(); - - const response = await this.accountService.resetPassword( - { - customerEmail: email, - publisherId, - resetUrl, - }, - isSandbox, - ); + const response = await this.accountService.resetPassword({ + customerEmail: email, + resetUrl, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -343,28 +317,23 @@ export default class AccountController { }; changePasswordWithOldPassword = async (oldPassword: string, newPassword: string, newPasswordConfirmation: string) => { - const { isSandbox } = useConfigStore.getState(); - - const response = await this.accountService.changePasswordWithOldPassword( - { - oldPassword, - newPassword, - newPasswordConfirmation, - }, - isSandbox, - ); + const response = await this.accountService.changePasswordWithOldPassword({ + oldPassword, + newPassword, + newPasswordConfirmation, + }); if (response?.errors?.length > 0) throw new Error(response.errors[0]); return response?.responseData; }; changePasswordWithToken = async (customerEmail: string, newPassword: string, resetPasswordToken: string, newPasswordConfirmation: string) => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - - const response = await this.accountService.changePasswordWithResetToken( - { publisherId: authProviderId, customerEmail, newPassword, resetPasswordToken, newPasswordConfirmation }, - isSandbox, - ); + const response = await this.accountService.changePasswordWithResetToken({ + customerEmail, + newPassword, + resetPasswordToken, + newPasswordConfirmation, + }); if (response?.errors?.length > 0) throw new Error(response.errors[0]); @@ -372,7 +341,6 @@ export default class AccountController { }; updateSubscription = async (status: 'active' | 'cancelled'): Promise => { - const { isSandbox } = useConfigStore.getState(); const { getAccountInfo } = useAccountStore.getState(); const { customerId } = getAccountInfo(); @@ -380,15 +348,12 @@ export default class AccountController { const { subscription } = useAccountStore.getState(); if (!subscription) throw new Error('user has no active subscription'); - const response = await this.subscriptionService?.updateSubscription( - { - customerId, - offerId: subscription.offerId, - status, - unsubscribeUrl: subscription.unsubscribeUrl, - }, - isSandbox, - ); + const response = await this.subscriptionService?.updateSubscription({ + customerId, + offerId: subscription.offerId, + status, + unsubscribeUrl: subscription.unsubscribeUrl, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -412,25 +377,21 @@ export default class AccountController { expYear: number; currency: string; }) => { - const { isSandbox } = useConfigStore.getState(); const { getAccountInfo } = useAccountStore.getState(); const { customerId } = getAccountInfo(); assertModuleMethod(this.subscriptionService.updateCardDetails, 'updateCardDetails is not available in subscription service'); - const response = await this.subscriptionService.updateCardDetails( - { - cardName, - cardNumber, - cvc, - expMonth, - expYear, - currency, - }, - isSandbox, - ); - const activePayment = (await this.subscriptionService.getActivePayment({ sandbox: isSandbox, customerId })) || null; + const response = await this.subscriptionService.updateCardDetails({ + cardName, + cardNumber, + cvc, + expMonth, + expYear, + currency, + }); + const activePayment = (await this.subscriptionService.getActivePayment({ customerId })) || null; useAccountStore.setState({ loading: false, @@ -440,21 +401,17 @@ export default class AccountController { }; checkEntitlements = async (offerId?: string): Promise => { - const { isSandbox } = useConfigStore.getState(); - if (!offerId) { return false; } - const { responseData } = await this.checkoutService.getEntitlements({ offerId }, isSandbox); + const { responseData } = await this.checkoutService.getEntitlements({ offerId }); return !!responseData?.accessGranted; }; reloadActiveSubscription = async ({ delay }: { delay: number } = { delay: 0 }): Promise => { useAccountStore.setState({ loading: true }); - const { isSandbox, config } = useConfigStore.getState(); - const { getAccountInfo } = useAccountStore.getState(); const { customerId } = getAccountInfo(); @@ -462,16 +419,16 @@ export default class AccountController { // so here's a delay mechanism to give it time to process if (delay > 0) { return new Promise((resolve: (value?: unknown) => void) => { - window.setTimeout(() => { + setTimeout(() => { this.reloadActiveSubscription().finally(resolve); }, delay); }); } const [activeSubscription, transactions, activePayment] = await Promise.all([ - this.subscriptionService.getActiveSubscription({ sandbox: isSandbox, customerId, config }), - this.subscriptionService.getAllTransactions({ sandbox: isSandbox, customerId }), - this.subscriptionService.getActivePayment({ sandbox: isSandbox, customerId }), + this.subscriptionService.getActiveSubscription({ customerId }), + this.subscriptionService.getAllTransactions({ customerId }), + this.subscriptionService.getActivePayment({ customerId }), ]); let pendingOffer: Offer | null = null; @@ -482,8 +439,8 @@ export default class AccountController { assertModuleMethod(this.checkoutService.getOffer, 'getOffer is not available in checkout service'); assertModuleMethod(this.checkoutService.getSubscriptionSwitch, 'getSubscriptionSwitch is not available in checkout service'); - const switchOffer = await this.checkoutService.getSubscriptionSwitch({ switchId: activeSubscription.pendingSwitchId }, isSandbox); - const offerResponse = await this.checkoutService.getOffer({ offerId: switchOffer.responseData.toOfferId }, isSandbox); + const switchOffer = await this.checkoutService.getSubscriptionSwitch({ switchId: activeSubscription.pendingSwitchId }); + const offerResponse = await this.checkoutService.getOffer({ offerId: switchOffer.responseData.toOfferId }); pendingOffer = offerResponse.responseData; } @@ -491,13 +448,13 @@ export default class AccountController { logDev('Failed to fetch the pending offer', error); } - // this invalidates all entitlements caches which makes the useEntitlement hook to verify the entitlements. - await queryClient.invalidateQueries('entitlements'); + // let the app know to refresh the entitlements + await this.refreshEntitlements?.(); useAccountStore.setState({ subscription: activeSubscription, - pendingOffer, loading: false, + pendingOffer, transactions, activePayment, }); @@ -509,17 +466,17 @@ export default class AccountController { assertModuleMethod(this.accountService.exportAccountData, 'exportAccountData is not available in account service'); assertFeature(canExportAccountData, 'Export account'); - return this.accountService?.exportAccountData(undefined, true); + return this.accountService?.exportAccountData(undefined); }; - getSocialLoginUrls = () => { + getSocialLoginUrls = (redirectUrl: string) => { const { config } = useConfigStore.getState(); const { hasSocialURLs } = this.getFeatures(); assertModuleMethod(this.accountService.getSocialUrls, 'getSocialUrls is not available in account service'); assertFeature(hasSocialURLs, 'Social logins'); - return this.accountService.getSocialUrls(config); + return this.accountService.getSocialUrls({ config, redirectUrl }); }; deleteAccountData = async (password: string) => { @@ -528,15 +485,13 @@ export default class AccountController { assertModuleMethod(this.accountService.deleteAccount, 'deleteAccount is not available in account service'); assertFeature(canDeleteAccount, 'Delete account'); - return this.accountService.deleteAccount({ password }, true); + return this.accountService.deleteAccount({ password }); }; getReceipt = async (transactionId: string) => { - const { isSandbox } = useConfigStore.getState(); - assertModuleMethod(this.subscriptionService.fetchReceipt, 'fetchReceipt is not available in subscription service'); - const { responseData } = await this.subscriptionService.fetchReceipt({ transactionId }, isSandbox); + const { responseData } = await this.subscriptionService.fetchReceipt({ transactionId }); return responseData; }; @@ -559,10 +514,12 @@ export default class AccountController { customerConsents, }); - return await Promise.allSettled([ + await Promise.allSettled([ accessModel === ACCESS_MODEL.SVOD && shouldSubscriptionReload ? this.reloadActiveSubscription() : Promise.resolve(), this.getPublisherConsents(), ]); + + useAccountStore.setState({ loading: false }); } private validateInputLength = (values: { firstName: string; lastName: string }) => { diff --git a/src/stores/AccountStore.ts b/packages/common/src/stores/AccountStore.ts similarity index 79% rename from src/stores/AccountStore.ts rename to packages/common/src/stores/AccountStore.ts index 91671afaa..f16a86513 100644 --- a/src/stores/AccountStore.ts +++ b/packages/common/src/stores/AccountStore.ts @@ -1,7 +1,8 @@ -import { createStore } from '#src/stores/utils'; -import type { Consent, Customer, CustomerConsent } from '#types/account'; -import type { Offer } from '#types/checkout'; -import type { PaymentDetail, Subscription, Transaction } from '#types/subscription'; +import type { Consent, Customer, CustomerConsent } from '../../types/account'; +import type { Offer } from '../../types/checkout'; +import type { PaymentDetail, Subscription, Transaction } from '../../types/subscription'; + +import { createStore } from './utils'; type AccountStore = { loading: boolean; diff --git a/packages/common/src/stores/AppController.ts b/packages/common/src/stores/AppController.ts new file mode 100644 index 000000000..87feb43f6 --- /dev/null +++ b/packages/common/src/stores/AppController.ts @@ -0,0 +1,120 @@ +import merge from 'lodash.merge'; +import { inject, injectable } from 'inversify'; + +import { PersonalShelf } from '../constants'; +import SettingsService from '../services/SettingsService'; +import ConfigService from '../services/ConfigService'; +import { container, getModule } from '../modules/container'; +import StorageService from '../services/StorageService'; +import type { Config } from '../../types/config'; +import type { CalculateIntegrationType } from '../../types/calculate-integration-type'; +import { DETERMINE_INTEGRATION_TYPE } from '../modules/types'; + +import AccountController from './AccountController'; +import WatchHistoryController from './WatchHistoryController'; +import FavoritesController from './FavoritesController'; +import { useConfigStore } from './ConfigStore'; + +@injectable() +export default class AppController { + private readonly configService: ConfigService; + private readonly settingsService: SettingsService; + private readonly storageService: StorageService; + + constructor( + @inject(ConfigService) configService: ConfigService, + @inject(SettingsService) settingsService: SettingsService, + @inject(StorageService) storageService: StorageService, + ) { + this.configService = configService; + this.settingsService = settingsService; + this.storageService = storageService; + } + + loadAndValidateConfig = async (configSource: string | undefined) => { + const configLocation = this.configService.formatSourceLocation(configSource); + const defaultConfig = this.configService.getDefaultConfig(); + + if (!configLocation) { + useConfigStore.setState({ config: defaultConfig }); + } + + let config = await this.configService.loadConfig(configLocation); + + config.id = configSource; + config.assets = config.assets || {}; + + // make sure the banner always defaults to the JWP banner when not defined in the config + if (!config.assets.banner) { + config.assets.banner = defaultConfig.assets.banner; + } + + // Store the logo right away and set css variables so the error page will be branded + const banner = config.assets.banner; + + useConfigStore.setState((s) => { + s.config.assets.banner = banner; + }); + + config = await this.configService.validateConfig(config); + config = merge({}, defaultConfig, config); + + return config; + }; + + initializeApp = async (url: string, refreshEntitlements?: () => Promise) => { + const settings = await this.settingsService.initialize(); + const configSource = await this.settingsService.getConfigSource(settings, url); + const config = await this.loadAndValidateConfig(configSource); + const integrationType = this.calculateIntegrationType(config); + + // update the config store + useConfigStore.setState({ config, loaded: true, integrationType }); + + // we could add the configSource to the storage prefix, but this would cause a breaking change for end users + // (since 'window.configId' isn't used anymore, all platforms currently use the same prefix) + this.storageService.initialize('jwapp'); + + // update settings in the config store + useConfigStore.setState({ settings }); + + if (config.features?.continueWatchingList && config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) { + await getModule(WatchHistoryController).initialize(); + } + + if (config.features?.favoritesList && config.content.some((el) => el.type === PersonalShelf.Favorites)) { + await getModule(FavoritesController).initialize(); + } + + // when an integration is set, we initialize the AccountController + if (integrationType) { + await getModule(AccountController).initialize(url, refreshEntitlements); + } + + return { config, settings, configSource }; + }; + + calculateIntegrationType = (config: Config) => { + const registerIntegrationTypes = container.getAll(DETERMINE_INTEGRATION_TYPE); + + const activatedIntegrationTypes = registerIntegrationTypes.reduce((previousValue, calculateIntegrationType) => { + const integrationType = calculateIntegrationType(config); + + return integrationType ? [...previousValue, integrationType] : previousValue; + }, [] as string[]); + + if (activatedIntegrationTypes.length > 1) { + throw new Error(`Failed to initialize app, more than 1 integrations are enabled: ${activatedIntegrationTypes.join(', ')}`); + } + + return activatedIntegrationTypes[0] || null; + }; + + getIntegrationType = (): string | null => { + const configState = useConfigStore.getState(); + + if (!configState.loaded) throw new Error('A call to `AppController#getIntegrationType()` was made before loading the config'); + + return configState.integrationType; + }; +} diff --git a/src/stores/CheckoutController.ts b/packages/common/src/stores/CheckoutController.ts similarity index 63% rename from src/stores/CheckoutController.ts rename to packages/common/src/stores/CheckoutController.ts index 9dea09ea6..8f4e54a51 100644 --- a/src/stores/CheckoutController.ts +++ b/packages/common/src/stores/CheckoutController.ts @@ -1,50 +1,55 @@ import { inject, injectable } from 'inversify'; -import { getOverrideIP } from '../utils/common'; - -import { useAccountStore } from '#src/stores/AccountStore'; import type { AddAdyenPaymentDetailsResponse, AdyenPaymentSession, CardPaymentData, CreateOrderArgs, - FinalizeAdyenPaymentDetails, FinalizeAdyenPayment, + FinalizeAdyenPaymentDetailsResponse, + GetEntitlements, + GetOffers, InitialAdyenPayment, Offer, Order, PaymentMethod, PaymentWithPayPalResponse, - UpdateOrderPayload, SwitchOffer, - GetOffers, - GetEntitlements, -} from '#types/checkout'; -import { useCheckoutStore } from '#src/stores/CheckoutStore'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import CheckoutService from '#src/services/checkout.service'; -import SubscriptionService from '#src/services/subscription.service'; -import { assertModuleMethod, getNamedModule } from '#src/modules/container'; -import type { IntegrationType } from '#types/Config'; + UpdateOrderPayload, +} from '../../types/checkout'; +import CheckoutService from '../services/integrations/CheckoutService'; +import SubscriptionService from '../services/integrations/SubscriptionService'; +import type { IntegrationType } from '../../types/config'; +import { assertModuleMethod, getNamedModule } from '../modules/container'; +import { GET_CUSTOMER_IP, INTEGRATION_TYPE } from '../modules/types'; +import type { GetCustomerIP } from '../../types/get-customer-ip'; +import AccountService from '../services/integrations/AccountService'; + +import { useCheckoutStore } from './CheckoutStore'; +import { useAccountStore } from './AccountStore'; @injectable() export default class CheckoutController { + private readonly accountService: AccountService; private readonly checkoutService: CheckoutService; private readonly subscriptionService: SubscriptionService; + private readonly getCustomerIP: GetCustomerIP; - constructor(@inject('INTEGRATION_TYPE') integrationType: IntegrationType) { + constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, @inject(GET_CUSTOMER_IP) getCustomerIP: GetCustomerIP) { + this.getCustomerIP = getCustomerIP; + this.accountService = getNamedModule(AccountService, integrationType); this.checkoutService = getNamedModule(CheckoutService, integrationType); this.subscriptionService = getNamedModule(SubscriptionService, integrationType); } + getSubscriptionOfferIds = () => { + return this.accountService.svodOfferIds; + }; + createOrder = async (offer: Offer, paymentMethodId?: number): Promise => { const { getAccountInfo } = useAccountStore.getState(); - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - const { customer } = getAccountInfo(); - if (!authProviderId) throw new Error('auth provider is not configured'); - const createOrderArgs: CreateOrderArgs = { offer, customerId: customer.id, @@ -53,7 +58,7 @@ export default class CheckoutController { paymentMethodId, }; - const response = await this.checkoutService.createOrder(createOrderArgs, isSandbox); + const response = await this.checkoutService.createOrder(createOrderArgs); if (response?.errors?.length > 0) { useCheckoutStore.getState().setOrder(null); @@ -65,17 +70,13 @@ export default class CheckoutController { }; updateOrder = async (order: Order, paymentMethodId?: number, couponCode?: string | null): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - - if (!authProviderId) throw new Error('auth provider is not configured'); - const updateOrderPayload: UpdateOrderPayload = { order, paymentMethodId, couponCode, }; - const response = await this.checkoutService.updateOrder(updateOrderPayload, isSandbox); + const response = await this.checkoutService.updateOrder(updateOrderPayload); if (response.errors.length > 0) { // clear the order when the order doesn't exist on the server if (response.errors[0].includes(`Order with ${order.id} not found`)) { @@ -91,12 +92,11 @@ export default class CheckoutController { }; getPaymentMethods = async (): Promise => { - const { isSandbox } = useConfigStore.getState(); const { paymentMethods } = useCheckoutStore.getState(); if (paymentMethods) return paymentMethods; // already fetched payment methods - const response = await this.checkoutService.getPaymentMethods(isSandbox); + const response = await this.checkoutService.getPaymentMethods(); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -106,14 +106,11 @@ export default class CheckoutController { }; paymentWithoutDetails = async (): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); - if (!authProviderId) throw new Error('auth provider is not configured'); - const response = await this.checkoutService.paymentWithoutDetails({ orderId: order.id }, isSandbox); + const response = await this.checkoutService.paymentWithoutDetails({ orderId: order.id }); if (response.errors.length > 0) throw new Error(response.errors[0]); if (response.responseData.rejectedReason) throw new Error(response.responseData.rejectedReason); @@ -121,34 +118,26 @@ export default class CheckoutController { return response.responseData; }; - directPostCardPayment = async (cardPaymentPayload: CardPaymentData): Promise => { - const { clientId: authProviderId } = useConfigStore.getState(); + directPostCardPayment = async (cardPaymentPayload: CardPaymentData, referrer: string, returnUrl: string): Promise => { const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); - if (!authProviderId) throw new Error('auth provider is not configured'); - return await this.checkoutService.directPostCardPayment(cardPaymentPayload, order); + return await this.checkoutService.directPostCardPayment(cardPaymentPayload, order, referrer, returnUrl); }; createAdyenPaymentSession = async (returnUrl: string, isInitialPayment: boolean = true): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); const { order } = useCheckoutStore.getState(); - const orderId = order?.id; - if (!authProviderId) throw new Error('auth provider is not configured'); if (isInitialPayment && !orderId) throw new Error('There is no order to pay for'); assertModuleMethod(this.checkoutService.createAdyenPaymentSession, 'createAdyenPaymentSession is not available in checkout service'); - const response = await this.checkoutService.createAdyenPaymentSession( - { - orderId: orderId, - returnUrl: returnUrl, - }, - isSandbox, - ); + const response = await this.checkoutService.createAdyenPaymentSession({ + orderId: orderId, + returnUrl: returnUrl, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -156,24 +145,18 @@ export default class CheckoutController { }; initialAdyenPayment = async (paymentMethod: AdyenPaymentMethod, returnUrl: string): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); - if (!authProviderId) throw new Error('auth provider is not configured'); assertModuleMethod(this.checkoutService.initialAdyenPayment, 'initialAdyenPayment is not available in checkout service'); - const response = await this.checkoutService.initialAdyenPayment( - { - orderId: order.id, - returnUrl: returnUrl, - paymentMethod, - attemptAuthentication: isSandbox ? 'always' : undefined, - customerIP: getOverrideIP(), - }, - isSandbox, - ); + const response = await this.checkoutService.initialAdyenPayment({ + orderId: order.id, + returnUrl: returnUrl, + paymentMethod, + customerIP: await this.getCustomerIP(), + }); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -181,44 +164,40 @@ export default class CheckoutController { }; finalizeAdyenPayment = async (details: unknown, orderId?: number, paymentData?: string): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - if (!orderId) throw new Error('No order created'); - if (!authProviderId) throw new Error('auth provider is not configured'); assertModuleMethod(this.checkoutService.finalizeAdyenPayment, 'finalizeAdyenPayment is not available in checkout service'); - const response = await this.checkoutService.finalizeAdyenPayment( - { - orderId, - details, - paymentData, - }, - isSandbox, - ); + const response = await this.checkoutService.finalizeAdyenPayment({ + orderId, + details, + paymentData, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); return response.responseData; }; - paypalPayment = async (successUrl: string, cancelUrl: string, errorUrl: string, couponCode: string = ''): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); + paypalPayment = async ( + successUrl: string, + waitingUrl: string, + cancelUrl: string, + errorUrl: string, + couponCode: string = '', + ): Promise => { const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); - if (!authProviderId) throw new Error('auth provider is not configured'); - - const response = await this.checkoutService.paymentWithPayPal( - { - order: order, - successUrl, - cancelUrl, - errorUrl, - couponCode, - }, - isSandbox, - ); + + const response = await this.checkoutService.paymentWithPayPal({ + order: order, + successUrl, + waitingUrl, + cancelUrl, + errorUrl, + couponCode, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); @@ -227,12 +206,9 @@ export default class CheckoutController { getSubscriptionSwitches = async (): Promise => { const { getAccountInfo } = useAccountStore.getState(); - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); const { customerId } = getAccountInfo(); - if (!authProviderId) throw new Error('auth provider is not configured'); - assertModuleMethod(this.checkoutService.getSubscriptionSwitches, 'getSubscriptionSwitches is not available in checkout service'); assertModuleMethod(this.checkoutService.getOffer, 'getOffer is not available in checkout service'); @@ -240,18 +216,16 @@ export default class CheckoutController { if (!subscription) return; - const response = await this.checkoutService.getSubscriptionSwitches( - { - customerId: customerId, - offerId: subscription.offerId, - }, - isSandbox, - ); + const response = await this.checkoutService.getSubscriptionSwitches({ + customerId: customerId, + offerId: subscription.offerId, + }); if (!response.responseData.available.length) return; - // @ts-expect-error we have checked the presence of the method - const switchOffers = response.responseData.available.map((offer: SwitchOffer) => this.checkoutService.getOffer({ offerId: offer.toOfferId }, isSandbox)); + // create variable for `getOffer` to ensure it's typed in `Array#map` scope + const getOfferDelegate = this.checkoutService.getOffer; + const switchOffers = response.responseData.available.map((offer: SwitchOffer) => getOfferDelegate({ offerId: offer.toOfferId })); const offers = await Promise.all(switchOffers); // Sort offers for proper ordering in "Choose Offer" modal when applicable @@ -261,12 +235,9 @@ export default class CheckoutController { switchSubscription = async (toOfferId: string, switchDirection: 'upgrade' | 'downgrade'): Promise => { const { getAccountInfo } = useAccountStore.getState(); - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); const { customerId } = getAccountInfo(); - if (!authProviderId) throw new Error('auth provider is not configured'); - assertModuleMethod(this.checkoutService.switchSubscription, 'switchSubscription is not available in checkout service'); const { subscription } = useAccountStore.getState(); @@ -279,104 +250,85 @@ export default class CheckoutController { switchDirection: switchDirection, }; - await this.checkoutService.switchSubscription(SwitchSubscriptionPayload, isSandbox); + await this.checkoutService.switchSubscription(SwitchSubscriptionPayload); // clear current offers useCheckoutStore.setState({ offerSwitches: [] }); }; changeSubscription = async ({ accessFeeId, subscriptionId }: { accessFeeId: string; subscriptionId: string }) => { - const { isSandbox } = useConfigStore.getState(); - assertModuleMethod(this.subscriptionService.changeSubscription, 'changeSubscription is not available in subscription service'); - const { responseData } = await this.subscriptionService.changeSubscription( - { - accessFeeId, - subscriptionId, - }, - isSandbox, - ); + const { responseData } = await this.subscriptionService.changeSubscription({ + accessFeeId, + subscriptionId, + }); return responseData; }; - updatePayPalPaymentMethod = async (successUrl: string, cancelUrl: string, errorUrl: string, paymentMethodId: number, currentPaymentId?: number) => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - - if (!authProviderId) throw new Error('auth provider is not configured'); - + updatePayPalPaymentMethod = async ( + successUrl: string, + waitingUrl: string, + cancelUrl: string, + errorUrl: string, + paymentMethodId: number, + currentPaymentId?: number, + ) => { assertModuleMethod(this.checkoutService.updatePaymentMethodWithPayPal, 'updatePaymentMethodWithPayPal is not available in checkout service'); assertModuleMethod(this.checkoutService.deletePaymentMethod, 'deletePaymentMethod is not available in checkout service'); - const response = await this.checkoutService.updatePaymentMethodWithPayPal( - { - paymentMethodId, - successUrl, - cancelUrl, - errorUrl, - }, - isSandbox, - ); + const response = await this.checkoutService.updatePaymentMethodWithPayPal({ + paymentMethodId, + successUrl, + waitingUrl, + cancelUrl, + errorUrl, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); if (currentPaymentId) { - await this.checkoutService.deletePaymentMethod({ paymentDetailsId: currentPaymentId }, isSandbox); + await this.checkoutService.deletePaymentMethod({ paymentDetailsId: currentPaymentId }); } return response.responseData; }; addAdyenPaymentDetails = async (paymentMethod: AdyenPaymentMethod, paymentMethodId: number, returnUrl: string): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - - if (!authProviderId) throw new Error('auth provider is not configured'); - assertModuleMethod(this.checkoutService.addAdyenPaymentDetails, 'addAdyenPaymentDetails is not available in checkout service'); - const response = await this.checkoutService.addAdyenPaymentDetails( - { - paymentMethodId, - returnUrl, - paymentMethod, - attemptAuthentication: isSandbox ? 'always' : undefined, - customerIP: getOverrideIP(), - }, - isSandbox, - ); + const response = await this.checkoutService.addAdyenPaymentDetails({ + paymentMethodId, + returnUrl, + paymentMethod, + customerIP: await this.getCustomerIP(), + }); if (response.errors.length > 0) throw new Error(response.errors[0]); return response.responseData; }; - finalizeAdyenPaymentDetails = async (details: unknown, paymentMethodId: number, paymentData?: string): Promise => { - const { isSandbox, clientId: authProviderId } = useConfigStore.getState(); - - if (!authProviderId) throw new Error('auth provider is not configured'); - + finalizeAdyenPaymentDetails = async (details: unknown, paymentMethodId: number, paymentData?: string): Promise => { assertModuleMethod(this.checkoutService.finalizeAdyenPaymentDetails, 'finalizeAdyenPaymentDetails is not available in checkout service'); - const response = await this.checkoutService.finalizeAdyenPaymentDetails( - { - paymentMethodId, - details, - paymentData, - }, - isSandbox, - ); + const response = await this.checkoutService.finalizeAdyenPaymentDetails({ + paymentMethodId, + details, + paymentData, + }); if (response.errors.length > 0) throw new Error(response.errors[0]); return response.responseData; }; - getOffers: GetOffers = (payload, sandbox) => { - return this.checkoutService.getOffers(payload, sandbox); + getOffers: GetOffers = (payload) => { + return this.checkoutService.getOffers(payload); }; - getEntitlements: GetEntitlements = (payload, sandbox) => { - return this.checkoutService.getEntitlements(payload, sandbox); + getEntitlements: GetEntitlements = (payload) => { + return this.checkoutService.getEntitlements(payload); }; } diff --git a/src/stores/CheckoutStore.ts b/packages/common/src/stores/CheckoutStore.ts similarity index 85% rename from src/stores/CheckoutStore.ts rename to packages/common/src/stores/CheckoutStore.ts index aa532c1f5..273a56441 100644 --- a/src/stores/CheckoutStore.ts +++ b/packages/common/src/stores/CheckoutStore.ts @@ -1,6 +1,7 @@ -import type { Offer, Order, PaymentMethod } from '#types/checkout'; -import type { MediaOffer } from '#types/media'; -import { createStore } from '#src/stores/utils'; +import type { Offer, Order, PaymentMethod } from '../../types/checkout'; +import type { MediaOffer } from '../../types/media'; + +import { createStore } from './utils'; type CheckoutStore = { offer: Offer | null; diff --git a/src/stores/ConfigStore.ts b/packages/common/src/stores/ConfigStore.ts similarity index 65% rename from src/stores/ConfigStore.ts rename to packages/common/src/stores/ConfigStore.ts index 8877aad2f..9e35334aa 100644 --- a/src/stores/ConfigStore.ts +++ b/packages/common/src/stores/ConfigStore.ts @@ -1,18 +1,17 @@ -import { createStore } from './utils'; +import { ACCESS_MODEL, OTT_GLOBAL_PLAYER_ID } from '../constants'; +import type { AccessModel, Config } from '../../types/config'; +import type { Settings } from '../../types/settings'; +import type { LanguageDefinition } from '../../types/i18n'; -import type { AccessModel, Config, IntegrationType } from '#types/Config'; -import type { Settings } from '#types/settings'; -import { ACCESS_MODEL, OTT_GLOBAL_PLAYER_ID } from '#src/config'; +import { createStore } from './utils'; type ConfigState = { loaded: boolean; config: Config; accessModel: AccessModel; settings: Settings; - integrationType: IntegrationType | null; - isSandbox: boolean; - clientId: string | null; - offers: string[]; + integrationType: string | null; + supportedLanguages: LanguageDefinition[]; }; export const useConfigStore = createStore('ConfigStore', () => ({ @@ -43,9 +42,7 @@ export const useConfigStore = createStore('ConfigStore', () => ({ additionalAllowedConfigSources: [], playerId: OTT_GLOBAL_PLAYER_ID, }, - accessModel: ACCESS_MODEL.SVOD, + supportedLanguages: [], + accessModel: ACCESS_MODEL.AVOD, integrationType: null, - isSandbox: true, - clientId: null, - offers: [], })); diff --git a/src/stores/EpgController.test.ts b/packages/common/src/stores/EpgController.test.ts similarity index 91% rename from src/stores/EpgController.test.ts rename to packages/common/src/stores/EpgController.test.ts index 704add4f4..b4f44865f 100644 --- a/src/stores/EpgController.test.ts +++ b/packages/common/src/stores/EpgController.test.ts @@ -1,19 +1,19 @@ import { afterEach, beforeEach, describe, expect } from 'vitest'; import { unregister } from 'timezone-mock'; +import channel1 from '@jwp/ott-testing/epg/jwChannel.json'; +import livePlaylistFixture from '@jwp/ott-testing/fixtures/livePlaylist.json'; -import EpgController from './EpgController'; +import EpgService from '../services/EpgService'; +import type { Playlist } from '../../types/playlist'; -import channel1 from '#test/epg/jwChannel.json'; -import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; -import EpgService from '#src/services/epg/epg.service'; -import type { Playlist } from '#types/playlist'; +import EpgController from './EpgController'; const livePlaylist = livePlaylistFixture as Playlist; const transformProgram = vi.fn(); const fetchSchedule = vi.fn(); -vi.mock('#src/modules/container', () => ({ +vi.mock('@jwp/ott-common/src/modules/container', () => ({ getNamedModule: (type: typeof EpgService) => { switch (type) { case EpgService: diff --git a/src/stores/EpgController.ts b/packages/common/src/stores/EpgController.ts similarity index 92% rename from src/stores/EpgController.ts rename to packages/common/src/stores/EpgController.ts index 8bb30f219..edbb44587 100644 --- a/src/stores/EpgController.ts +++ b/packages/common/src/stores/EpgController.ts @@ -1,12 +1,12 @@ import { addDays, differenceInDays } from 'date-fns'; import { injectable } from 'inversify'; -import EpgService from '#src/services/epg/epg.service'; -import { logDev } from '#src/utils/common'; -import type { PlaylistItem } from '#types/playlist'; -import type { EpgProgram, EpgChannel } from '#types/epg'; -import { EPG_TYPE } from '#src/config'; -import { getNamedModule } from '#src/modules/container'; +import EpgService from '../services/EpgService'; +import { EPG_TYPE } from '../constants'; +import { getNamedModule } from '../modules/container'; +import { logDev } from '../utils/common'; +import type { PlaylistItem } from '../../types/playlist'; +import type { EpgChannel, EpgProgram } from '../../types/epg'; export const isFulfilled = (input: PromiseSettledResult): input is PromiseFulfilledResult => { if (input.status === 'fulfilled') { diff --git a/src/stores/FavoritesController.ts b/packages/common/src/stores/FavoritesController.ts similarity index 78% rename from src/stores/FavoritesController.ts rename to packages/common/src/stores/FavoritesController.ts index ba27ba585..f428e1698 100644 --- a/src/stores/FavoritesController.ts +++ b/packages/common/src/stores/FavoritesController.ts @@ -1,23 +1,25 @@ import i18next from 'i18next'; import { inject, injectable } from 'inversify'; -import { useAccountStore } from '#src/stores/AccountStore'; -import { useFavoritesStore } from '#src/stores/FavoritesStore'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import FavoritesService from '#src/services/favorites.service'; -import AccountService from '#src/services/account.service'; -import type { PlaylistItem } from '#types/playlist'; -import type { Favorite, SerializedFavorite } from '#types/favorite'; -import type { Customer } from '#types/account'; -import { getNamedModule } from '#src/modules/container'; -import type { IntegrationType } from '#types/Config'; +import FavoriteService from '../services/FavoriteService'; +import AccountService from '../services/integrations/AccountService'; +import type { PlaylistItem } from '../../types/playlist'; +import type { Favorite, SerializedFavorite } from '../../types/favorite'; +import type { Customer } from '../../types/account'; +import type { IntegrationType } from '../../types/config'; +import { getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; + +import { useAccountStore } from './AccountStore'; +import { useFavoritesStore } from './FavoritesStore'; +import { useConfigStore } from './ConfigStore'; @injectable() export default class FavoritesController { - private readonly favoritesService: FavoritesService; + private readonly favoritesService: FavoriteService; private readonly accountService?: AccountService; - constructor(@inject('INTEGRATION_TYPE') integrationType: IntegrationType, favoritesService: FavoritesService) { + constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, favoritesService: FavoriteService) { this.favoritesService = favoritesService; this.accountService = getNamedModule(AccountService, integrationType, false); } @@ -55,10 +57,9 @@ export default class FavoritesController { persistFavorites = async () => { const { favorites } = useFavoritesStore.getState(); const { user } = useAccountStore.getState(); - const { isSandbox } = useConfigStore.getState(); if (user?.id && user?.externalData) { - return this.accountService?.updatePersonalShelves({ id: user.id, externalData: user.externalData }, isSandbox); + return this.accountService?.updatePersonalShelves({ id: user.id, externalData: user.externalData }); } this.favoritesService.persistFavorites(favorites); diff --git a/src/stores/FavoritesStore.ts b/packages/common/src/stores/FavoritesStore.ts similarity index 84% rename from src/stores/FavoritesStore.ts rename to packages/common/src/stores/FavoritesStore.ts index d412e837f..48433060f 100644 --- a/src/stores/FavoritesStore.ts +++ b/packages/common/src/stores/FavoritesStore.ts @@ -1,8 +1,8 @@ -import { createStore } from './utils'; +import { PersonalShelf } from '../constants'; +import type { Favorite } from '../../types/favorite'; +import type { Playlist, PlaylistItem } from '../../types/playlist'; -import type { Favorite } from '#types/favorite'; -import { PersonalShelf } from '#src/config'; -import type { Playlist, PlaylistItem } from '#types/playlist'; +import { createStore } from './utils'; type FavoritesState = { favorites: Favorite[]; diff --git a/src/stores/ProfileController.ts b/packages/common/src/stores/ProfileController.ts similarity index 62% rename from src/stores/ProfileController.ts rename to packages/common/src/stores/ProfileController.ts index 880709f8e..22586458f 100644 --- a/src/stores/ProfileController.ts +++ b/packages/common/src/stores/ProfileController.ts @@ -2,14 +2,14 @@ import { inject, injectable } from 'inversify'; import type { ProfilesData } from '@inplayer-org/inplayer.js'; import * as yup from 'yup'; -import { useProfileStore } from './ProfileStore'; -import { useConfigStore } from './ConfigStore'; +import type { EnterProfilePayload, ProfileDetailsPayload, ProfilePayload } from '../../types/account'; +import ProfileService from '../services/integrations/ProfileService'; +import type { IntegrationType } from '../../types/config'; +import { assertModuleMethod, getNamedModule } from '../modules/container'; +import StorageService from '../services/StorageService'; +import { INTEGRATION_TYPE } from '../modules/types'; -import type { ProfilePayload, EnterProfilePayload, ProfileDetailsPayload } from '#types/account'; -import ProfileService from '#src/services/profile.service'; -import * as persist from '#src/utils/persist'; -import { assertModuleMethod, getNamedModule } from '#src/modules/container'; -import type { IntegrationType } from '#types/Config'; +import { useProfileStore } from './ProfileStore'; const PERSIST_PROFILE = 'profile'; @@ -27,45 +27,48 @@ const profileSchema = yup.object().shape({ @injectable() export default class ProfileController { private readonly profileService?: ProfileService; + private readonly storageService: StorageService; - constructor(@inject('INTEGRATION_TYPE') integrationType: IntegrationType) { + constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, storageService: StorageService) { this.profileService = getNamedModule(ProfileService, integrationType, false); + this.storageService = storageService; } - private getSandbox = () => { - return useConfigStore.getState().isSandbox ?? true; + private isValidProfile = (profile: unknown): profile is ProfilesData => { + try { + profileSchema.validateSync(profile); + return true; + } catch (e: unknown) { + return false; + } }; + isEnabled() { + return !!this.profileService; + } + listProfiles = async () => { assertModuleMethod(this.profileService?.listProfiles, 'listProfiles is not available in profile service'); - const res = await this.profileService.listProfiles(undefined, this.getSandbox()); - - const { canManageProfiles } = useProfileStore.getState(); - - if (!canManageProfiles && res?.responseData.canManageProfiles) { - useProfileStore.setState({ canManageProfiles: true }); - } - - return res; + return this.profileService.listProfiles(undefined); }; createProfile = async ({ name, adult, avatar_url, pin }: ProfilePayload) => { assertModuleMethod(this.profileService?.createProfile, 'createProfile is not available in profile service'); - return this.profileService?.createProfile({ name, adult, avatar_url, pin }, this.getSandbox()); + return this.profileService.createProfile({ name, adult, avatar_url, pin }); }; updateProfile = async ({ id, name, adult, avatar_url, pin }: ProfilePayload) => { assertModuleMethod(this.profileService?.updateProfile, 'updateProfile is not available in profile service'); - return this.profileService.updateProfile({ id, name, adult, avatar_url, pin }, this.getSandbox()); + return this.profileService.updateProfile({ id, name, adult, avatar_url, pin }); }; enterProfile = async ({ id, pin }: EnterProfilePayload) => { assertModuleMethod(this.profileService?.enterProfile, 'enterProfile is not available in profile service'); - const response = await this.profileService.enterProfile({ id, pin }, this.getSandbox()); + const response = await this.profileService.enterProfile({ id, pin }); const profile = response?.responseData; @@ -79,44 +82,33 @@ export default class ProfileController { deleteProfile = async ({ id }: ProfileDetailsPayload) => { assertModuleMethod(this.profileService?.deleteProfile, 'deleteProfile is not available in profile service'); - return this.profileService.deleteProfile({ id }, this.getSandbox()); + return this.profileService.deleteProfile({ id }); }; getProfileDetails = async ({ id }: ProfileDetailsPayload) => { assertModuleMethod(this.profileService?.getProfileDetails, 'getProfileDetails is not available in profile service'); - return this.profileService.getProfileDetails({ id }, this.getSandbox()); + return this.profileService.getProfileDetails({ id }); }; persistProfile = ({ profile }: { profile: ProfilesData }) => { - persist.setItem(PERSIST_PROFILE, profile); - persist.setItemStorage('inplayer_token', { - expires: profile.credentials.expires, - token: profile.credentials.access_token, - refreshToken: '', - }); + this.storageService.setItem(PERSIST_PROFILE, JSON.stringify(profile)); }; - unpersistProfile = () => { - persist.removeItem(PERSIST_PROFILE); + unpersistProfile = async () => { + await this.storageService.removeItem(PERSIST_PROFILE); }; - isValidProfile = (profile: unknown): profile is ProfilesData => { - try { - profileSchema.validateSync(profile); - return true; - } catch (e: unknown) { - return false; - } - }; + loadPersistedProfile = async () => { + const profile = await this.storageService.getItem(PERSIST_PROFILE, true); - loadPersistedProfile = () => { - const profile = persist.getItem(PERSIST_PROFILE); if (this.isValidProfile(profile)) { useProfileStore.getState().setProfile(profile); return profile; } + useProfileStore.getState().setProfile(null); + return null; }; diff --git a/src/stores/ProfileStore.ts b/packages/common/src/stores/ProfileStore.ts similarity index 76% rename from src/stores/ProfileStore.ts rename to packages/common/src/stores/ProfileStore.ts index 0314ab4db..c55528a5e 100644 --- a/src/stores/ProfileStore.ts +++ b/packages/common/src/stores/ProfileStore.ts @@ -1,6 +1,8 @@ -import { createStore } from '#src/stores/utils'; -import type { Profile } from '#types/account'; -import defaultAvatar from '#src/assets/profiles/default_avatar.png'; +import defaultAvatar from '@jwp/ott-theme/assets/profiles/default_avatar.png'; + +import type { Profile } from '../../types/account'; + +import { createStore } from './utils'; type ProfileStore = { profile: Profile | null; diff --git a/src/stores/UIStore.ts b/packages/common/src/stores/UIStore.ts similarity index 80% rename from src/stores/UIStore.ts rename to packages/common/src/stores/UIStore.ts index 65267fa3b..af33c22b4 100644 --- a/src/stores/UIStore.ts +++ b/packages/common/src/stores/UIStore.ts @@ -1,5 +1,3 @@ -import type { Location } from 'react-router-dom'; - import { createStore } from './utils'; type UIState = { @@ -7,12 +5,12 @@ type UIState = { searchActive: boolean; userMenuOpen: boolean; languageMenuOpen: boolean; - preSearchPage?: Location; + preSearchPage?: string; }; export const useUIStore = createStore('UIStore', () => ({ searchQuery: '', searchActive: false, - languageMenuOpen: false, userMenuOpen: false, + languageMenuOpen: false, })); diff --git a/src/stores/WatchHistoryController.ts b/packages/common/src/stores/WatchHistoryController.ts similarity index 78% rename from src/stores/WatchHistoryController.ts rename to packages/common/src/stores/WatchHistoryController.ts index 2cec5b611..28791c8e0 100644 --- a/src/stores/WatchHistoryController.ts +++ b/packages/common/src/stores/WatchHistoryController.ts @@ -1,22 +1,24 @@ import { inject, injectable } from 'inversify'; -import { useAccountStore } from '#src/stores/AccountStore'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; -import WatchHistoryService from '#src/services/watchhistory.service'; -import AccountService from '#src/services/account.service'; -import type { PlaylistItem } from '#types/playlist'; -import type { SerializedWatchHistoryItem, WatchHistoryItem } from '#types/watchHistory'; -import type { Customer } from '#types/account'; -import { getNamedModule } from '#src/modules/container'; -import type { IntegrationType } from '#types/Config'; +import WatchHistoryService from '../services/WatchHistoryService'; +import AccountService from '../services/integrations/AccountService'; +import type { PlaylistItem } from '../../types/playlist'; +import type { SerializedWatchHistoryItem, WatchHistoryItem } from '../../types/watchHistory'; +import type { Customer } from '../../types/account'; +import type { IntegrationType } from '../../types/config'; +import { getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; + +import { useAccountStore } from './AccountStore'; +import { useConfigStore } from './ConfigStore'; +import { useWatchHistoryStore } from './WatchHistoryStore'; @injectable() export default class WatchHistoryController { private readonly watchHistoryService: WatchHistoryService; private readonly accountService?: AccountService; - constructor(@inject('INTEGRATION_TYPE') integrationType: IntegrationType, watchHistoryService: WatchHistoryService) { + constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, watchHistoryService: WatchHistoryService) { this.watchHistoryService = watchHistoryService; this.accountService = getNamedModule(AccountService, integrationType, false); } @@ -65,10 +67,9 @@ export default class WatchHistoryController { persistWatchHistory = async () => { const { watchHistory } = useWatchHistoryStore.getState(); const { user } = useAccountStore.getState(); - const { isSandbox } = useConfigStore.getState(); if (user?.id && user?.externalData) { - return this.accountService?.updatePersonalShelves({ id: user.id, externalData: user.externalData }, isSandbox); + return this.accountService?.updatePersonalShelves({ id: user.id, externalData: user.externalData }); } this.watchHistoryService.persistWatchHistory(watchHistory); diff --git a/src/stores/WatchHistoryStore.ts b/packages/common/src/stores/WatchHistoryStore.ts similarity index 89% rename from src/stores/WatchHistoryStore.ts rename to packages/common/src/stores/WatchHistoryStore.ts index 21f270e0e..45dabc323 100644 --- a/src/stores/WatchHistoryStore.ts +++ b/packages/common/src/stores/WatchHistoryStore.ts @@ -1,8 +1,8 @@ -import { createStore } from './utils'; +import { PersonalShelf, VideoProgressMinMax } from '../constants'; +import type { WatchHistoryItem } from '../../types/watchHistory'; +import type { Playlist, PlaylistItem } from '../../types/playlist'; -import { VideoProgressMinMax, PersonalShelf } from '#src/config'; -import type { WatchHistoryItem } from '#types/watchHistory'; -import type { Playlist, PlaylistItem } from '#types/playlist'; +import { createStore } from './utils'; type WatchHistoryState = { watchHistory: WatchHistoryItem[]; diff --git a/src/stores/utils.ts b/packages/common/src/stores/utils.ts similarity index 88% rename from src/stores/utils.ts rename to packages/common/src/stores/utils.ts index 84c99ce43..63b1b9dca 100644 --- a/src/stores/utils.ts +++ b/packages/common/src/stores/utils.ts @@ -2,7 +2,7 @@ import type { State, StateCreator } from 'zustand'; import create from 'zustand'; import { devtools, subscribeWithSelector } from 'zustand/middleware'; -import { IS_DEVELOPMENT_BUILD, IS_TEST_MODE } from '#src/utils/common'; +import { IS_DEVELOPMENT_BUILD, IS_TEST_MODE } from '../utils/common'; export const createStore = (name: string, storeFn: StateCreator) => { const store = subscribeWithSelector(storeFn); diff --git a/packages/common/src/utils/ScreenMap.ts b/packages/common/src/utils/ScreenMap.ts new file mode 100644 index 000000000..3af52ba07 --- /dev/null +++ b/packages/common/src/utils/ScreenMap.ts @@ -0,0 +1,39 @@ +import type { Playlist, PlaylistItem } from '../../types/playlist'; + +type ScreenPredicate = (data?: T) => boolean; + +type ScreenDefinition = { + predicate: ScreenPredicate; + component: C; +}; + +export class ScreenMap { + private defaultScreen?: C = undefined; + private definitions: ScreenDefinition[] = []; + + register(component: C, predicate: ScreenPredicate) { + this.definitions.push({ component, predicate }); + } + + registerByContentType(component: C, contentType: string) { + this.register(component, (data) => data?.contentType?.toLowerCase() === contentType); + } + + registerDefault(component: C) { + this.defaultScreen = component; + } + + getScreen(data?: T): C { + const screen = this.definitions.find(({ predicate }) => predicate(data))?.component; + + if (screen) { + return screen; + } + + if (!this.defaultScreen) { + throw new Error('Default screen not defined'); + } + + return this.defaultScreen; + } +} diff --git a/src/utils/analytics.ts b/packages/common/src/utils/analytics.ts similarity index 74% rename from src/utils/analytics.ts rename to packages/common/src/utils/analytics.ts index 11501f7ee..aac42e5b8 100644 --- a/src/utils/analytics.ts +++ b/packages/common/src/utils/analytics.ts @@ -1,9 +1,10 @@ -import { useAccountStore } from '#src/stores/AccountStore'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { useProfileStore } from '#src/stores/ProfileStore'; -import type { PlaylistItem, Source } from '#types/playlist'; +import { useAccountStore } from '../stores/AccountStore'; +import { useConfigStore } from '../stores/ConfigStore'; +import { useProfileStore } from '../stores/ProfileStore'; +import type { PlaylistItem, Source } from '../../types/playlist'; export const attachAnalyticsParams = (item: PlaylistItem) => { + // @todo pass these as params instead of reading the stores const { config } = useConfigStore.getState(); const { user } = useAccountStore.getState(); const { profile } = useProfileStore.getState(); @@ -14,7 +15,7 @@ export const attachAnalyticsParams = (item: PlaylistItem) => { const profileId = profile?.id; const isJwIntegration = !!config?.integrations?.jwp; - return sources.map((source: Source) => { + sources.map((source: Source) => { const url = new URL(source.file); const mediaId = mediaid.toLowerCase(); diff --git a/src/utils/api.ts b/packages/common/src/utils/api.ts similarity index 80% rename from src/utils/api.ts rename to packages/common/src/utils/api.ts index aa994fdc2..bf1647f3a 100644 --- a/src/utils/api.ts +++ b/packages/common/src/utils/api.ts @@ -1,6 +1,6 @@ import type { CommonResponse } from '@inplayer-org/inplayer.js'; -import type { InPlayerError } from '#types/inplayer'; +import type { InPlayerError } from '../../types/inplayer'; export class ApiError extends Error { code: number; @@ -23,9 +23,9 @@ export const getDataOrThrow = async (response: Response) => { if (!response.ok) { const message = `Request '${response.url}' failed with ${response.status}`; - const error = new ApiError(data?.message || message, response.status || 500); + const apiMessage = data && typeof data === 'object' && 'message' in data && typeof data.message === 'string' ? data.message : undefined; - throw error; + throw new ApiError(apiMessage || message, response.status || 500); } return data; diff --git a/src/utils/collection.ts b/packages/common/src/utils/collection.ts similarity index 92% rename from src/utils/collection.ts rename to packages/common/src/utils/collection.ts index 438aff68c..f8edb5178 100644 --- a/src/utils/collection.ts +++ b/packages/common/src/utils/collection.ts @@ -1,9 +1,10 @@ -import type { Consent, CustomerConsent } from '#types/account'; -import type { Config } from '#types/Config'; -import type { GenericFormValues } from '#types/form'; -import type { Playlist, PlaylistItem } from '#types/playlist'; -import type { PosterAspectRatio } from '#components/Card/Card'; -import { cardAspectRatios } from '#components/Card/Card'; +import type { Consent, CustomerConsent } from '../../types/account'; +import type { Config } from '../../types/config'; +import type { GenericFormValues } from '../../types/form'; +import type { Playlist, PlaylistItem } from '../../types/playlist'; +import { CARD_ASPECT_RATIOS } from '../constants'; + +export type PosterAspectRatio = (typeof CARD_ASPECT_RATIOS)[number]; const getFiltersFromConfig = (config: Config, playlistId: string | undefined): string[] => { const menuItem = config.menu.find((item) => item.contentId === playlistId); @@ -177,7 +178,7 @@ const deepCopy = (obj: unknown) => { }; const parseAspectRatio = (input: unknown) => { - if (typeof input === 'string' && (cardAspectRatios as readonly string[]).includes(input)) return input as PosterAspectRatio; + if (typeof input === 'string' && (CARD_ASPECT_RATIOS as readonly string[]).includes(input)) return input as PosterAspectRatio; }; const parseTilesDelta = (posterAspect?: PosterAspectRatio) => { diff --git a/src/utils/common.ts b/packages/common/src/utils/common.ts similarity index 54% rename from src/utils/common.ts rename to packages/common/src/utils/common.ts index b8a3b142a..7ee4bd418 100644 --- a/src/utils/common.ts +++ b/packages/common/src/utils/common.ts @@ -1,5 +1,3 @@ -import { overrideIPCookieKey } from '#test/constants'; - export function debounce void>(callback: T, wait = 200) { let timeout: NodeJS.Timeout | null; return (...args: unknown[]) => { @@ -53,18 +51,19 @@ export function calculateContrastColor(color: string) { // Build is either Development or Production // Mode can be dev, jwdev, demo, test, prod, etc. -export const IS_DEVELOPMENT_BUILD = import.meta.env.DEV; +export const IS_DEVELOPMENT_BUILD = __dev__; // Demo mode is used to run our firebase demo instance -export const IS_DEMO_MODE = import.meta.env.MODE === 'demo'; +export const IS_DEMO_MODE = __mode__ === 'demo'; // Test mode is used for e2e and unit tests -export const IS_TEST_MODE = import.meta.env.MODE === 'test'; -// Preview mode is used for previewing Pull Requests on github -export const IS_PREVIEW_MODE = import.meta.env.MODE === 'preview'; +export const IS_TEST_MODE = __mode__ === 'test'; + +// Preview mode is used for previewing Pull Requests on GitHub +export const IS_PREVIEW_MODE = __mode__ === 'preview'; // Production mode -export const IS_PROD_MODE = import.meta.env.MODE === 'prod'; +export const IS_PROD_MODE = __mode__ === 'prod'; export function logDev(message: unknown, ...optionalParams: unknown[]) { - if (IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) { + if ((IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && !IS_TEST_MODE) { if (optionalParams.length > 0) { console.info(message, optionalParams); } else { @@ -73,18 +72,6 @@ export function logDev(message: unknown, ...optionalParams: unknown[]) { } } -export function getOverrideIP() { - if (!IS_TEST_MODE && !IS_DEVELOPMENT_BUILD && !IS_PREVIEW_MODE) { - return undefined; - } - - return document.cookie - .split(';') - .find((s) => s.trim().startsWith(`${overrideIPCookieKey}=`)) - ?.split('=')[1] - .trim(); -} - export const isTruthyCustomParamValue = (value: unknown): boolean => ['true', '1', 'yes', 'on'].includes(String(value)?.toLowerCase()); export const isFalsyCustomParamValue = (value: unknown): boolean => ['false', '0', 'no', 'off'].includes(String(value)?.toLowerCase()); @@ -95,34 +82,3 @@ export function testId(value: string | undefined) { type Truthy = T extends false | '' | 0 | null | undefined ? never : T; export const isTruthy = (value: T | true): value is Truthy => Boolean(value); - -/** - * Handles billing receipts by either downloading the receipt directly if it is an instance of Blob, - * or opening it in a new window if it is a string representation. - * - * @param {Blob | string} receipt - The billing receipt data. If a Blob, it will be downloaded; if a string, - * it will be treated as an HTML representation and opened in a new window. - * @param {string} transactionId - The unique identifier for the transaction associated with the receipt. - * - * @returns {void} - * - */ -export const processBillingReceipt = (receipt: Blob | string, transactionId: string) => { - if (receipt instanceof Blob) { - const url = window.URL.createObjectURL(new Blob([receipt])); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `receipt_${transactionId}.pdf`); - document.body.appendChild(link); - link.click(); - } else { - const newWindow = window.open('', `Receipt ${transactionId}`, ''); - const htmlString = window.atob(receipt as unknown as string); - - if (newWindow) { - newWindow.opener = null; - newWindow.document.write(htmlString); - newWindow.document.close(); - } - } -}; diff --git a/packages/common/src/utils/compare.ts b/packages/common/src/utils/compare.ts new file mode 100644 index 000000000..84782b654 --- /dev/null +++ b/packages/common/src/utils/compare.ts @@ -0,0 +1,3 @@ +import shallow from 'zustand/shallow'; + +export { shallow }; diff --git a/src/utils/configSchema.ts b/packages/common/src/utils/configSchema.ts similarity index 92% rename from src/utils/configSchema.ts rename to packages/common/src/utils/configSchema.ts index 166fe314b..af6ffc382 100644 --- a/src/utils/configSchema.ts +++ b/packages/common/src/utils/configSchema.ts @@ -1,6 +1,6 @@ -import { array, boolean, mixed, number, object, SchemaOf, string, StringSchema } from 'yup'; +import { array, boolean, mixed, number, object, type SchemaOf, string, StringSchema } from 'yup'; -import type { Cleeng, JWP, Config, Content, Features, Menu, Styling } from '#types/Config'; +import type { Cleeng, Config, Content, Features, JWP, Menu, Styling } from '../../types/config'; const contentSchema: SchemaOf = object({ contentId: string().notRequired(), diff --git a/src/utils/datetime.ts b/packages/common/src/utils/datetime.ts similarity index 100% rename from src/utils/datetime.ts rename to packages/common/src/utils/datetime.ts diff --git a/src/utils/entitlements.ts b/packages/common/src/utils/entitlements.ts similarity index 86% rename from src/utils/entitlements.ts rename to packages/common/src/utils/entitlements.ts index c66e9e8c2..980afdf24 100644 --- a/src/utils/entitlements.ts +++ b/packages/common/src/utils/entitlements.ts @@ -1,8 +1,9 @@ -import type { AccessModel } from '#types/Config'; -import type { MediaOffer } from '#types/media'; -import type { PlaylistItem } from '#types/playlist'; -import { isTruthyCustomParamValue, isFalsyCustomParamValue } from '#src/utils/common'; -import { ACCESS_MODEL } from '#src/config'; +import { ACCESS_MODEL } from '../constants'; +import type { AccessModel } from '../../types/config'; +import type { MediaOffer } from '../../types/media'; +import type { PlaylistItem } from '../../types/playlist'; + +import { isFalsyCustomParamValue, isTruthyCustomParamValue } from './common'; /** * The appearance of the lock icon, depending on the access model diff --git a/src/utils/epg.ts b/packages/common/src/utils/epg.ts similarity index 94% rename from src/utils/epg.ts rename to packages/common/src/utils/epg.ts index 89bde3401..304236c28 100644 --- a/src/utils/epg.ts +++ b/packages/common/src/utils/epg.ts @@ -1,6 +1,6 @@ import { isAfter, isFuture, isPast, subHours } from 'date-fns'; -import type { EpgChannel, EpgProgram } from '#types/epg'; +import type { EpgChannel, EpgProgram } from '../../types/epg'; /** * Returns true when the program is currently live e.g. the startTime is before now and the endTime is after now diff --git a/src/utils/error.ts b/packages/common/src/utils/error.ts similarity index 100% rename from src/utils/error.ts rename to packages/common/src/utils/error.ts diff --git a/src/utils/formatting.ts b/packages/common/src/utils/formatting.ts similarity index 52% rename from src/utils/formatting.ts rename to packages/common/src/utils/formatting.ts index c6ce4735a..c18e434f9 100644 --- a/src/utils/formatting.ts +++ b/packages/common/src/utils/formatting.ts @@ -1,6 +1,4 @@ -import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media'; - -import type { Playlist, PlaylistItem } from '#types/playlist'; +import type { Playlist, PlaylistItem } from '../../types/playlist'; export const formatDurationTag = (seconds: number): string | null => { if (!seconds) return null; @@ -32,95 +30,6 @@ export const formatDuration = (duration: number): string | null => { return `${hoursString}${minutesString}`; }; -export const addQueryParams = (url: string, queryParams: { [key: string]: string | number | string[] | undefined | null }) => { - const queryStringIndex = url.indexOf('?'); - const urlWithoutSearch = queryStringIndex > -1 ? url.slice(0, queryStringIndex) : url; - const urlSearchParams = new URLSearchParams(queryStringIndex > -1 ? url.slice(queryStringIndex) : undefined); - - Object.keys(queryParams).forEach((key) => { - const value = queryParams[key]; - - // null or undefined - if (value == null) return; - - if (typeof value === 'object' && !value?.length) return; - - const formattedValue = Array.isArray(value) ? value.join(',') : value; - - urlSearchParams.set(key, String(formattedValue)); - }); - const queryString = urlSearchParams.toString(); - - return `${urlWithoutSearch}${queryString ? `?${queryString}` : ''}`; -}; - -export function removeQueryParamFromUrl(key: string): string { - const url = new URL(window.location.href); - const urlSearchParams = new URLSearchParams(url.search); - - urlSearchParams.delete(key); - - const searchParams = urlSearchParams.toString(); - - return `${url.pathname}${searchParams ? `?${searchParams}` : ''}`; -} - -export const slugify = (text: string, whitespaceChar: string = '-') => - text - .toString() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^\w-]+/g, '') - .replace(/--+/g, '-') - .replace(/^-+/, '') - .replace(/-+$/, '') - .replace(/-/g, whitespaceChar); - -export const mediaURL = ({ - media, - playlistId, - play = false, - episodeId, -}: { - media: PlaylistItem; - playlistId?: string | null; - play?: boolean; - episodeId?: string; -}) => addQueryParams(`/m/${media.mediaid}/${slugify(media.title)}`, { r: playlistId, play: play ? '1' : null, e: episodeId }); - -export const liveChannelsURL = (playlistId: string, channelId?: string, play = false) => { - return addQueryParams(`/p/${playlistId}`, { - channel: channelId, - play: play ? '1' : null, - }); -}; - -export const legacySeriesURL = ({ - seriesId, - episodeId, - play, - playlistId, -}: { - seriesId: string; - episodeId?: string; - play?: boolean; - playlistId?: string | null; -}) => addQueryParams(`/s/${seriesId}`, { r: playlistId, e: episodeId, play: play ? '1' : null }); - -export const buildLegacySeriesUrlFromMediaItem = (media: PlaylistItem, play: boolean, playlistId: string | null) => { - const legacyPlaylistIdFromTags = getLegacySeriesPlaylistIdFromEpisodeTags(media); - const legacyPlaylistIdFromCustomParams = getSeriesPlaylistIdFromCustomParams(media); - - return legacySeriesURL({ - // Use the id grabbed from either custom params for series or tags for an episode - seriesId: legacyPlaylistIdFromCustomParams || legacyPlaylistIdFromTags || '', - play, - playlistId, - // Add episode id only if series id can be retrieved from tags - episodeId: legacyPlaylistIdFromTags && media.mediaid, - }); -}; - export const formatPrice = (price: number, currency: string, country?: string) => { return new Intl.NumberFormat(country || 'en-US', { style: 'currency', diff --git a/packages/common/src/utils/i18n.ts b/packages/common/src/utils/i18n.ts new file mode 100644 index 000000000..1006f6537 --- /dev/null +++ b/packages/common/src/utils/i18n.ts @@ -0,0 +1,13 @@ +import type { LanguageDefinition } from '../../types/i18n'; + +export const filterSupportedLanguages = (definedLanguages: LanguageDefinition[], enabledLanguages: string[]) => { + return enabledLanguages.reduce((languages, languageCode) => { + const foundLanguage = definedLanguages.find(({ code }) => code === languageCode); + + if (foundLanguage) { + return [...languages, foundLanguage]; + } + + throw new Error(`Missing defined language for code: ${languageCode}`); + }, [] as LanguageDefinition[]); +}; diff --git a/src/utils/liveEvent.ts b/packages/common/src/utils/liveEvent.ts similarity index 96% rename from src/utils/liveEvent.ts rename to packages/common/src/utils/liveEvent.ts index b60bab6e5..215040e9a 100644 --- a/src/utils/liveEvent.ts +++ b/packages/common/src/utils/liveEvent.ts @@ -1,4 +1,4 @@ -import type { PlaylistItem } from '#types/playlist'; +import type { PlaylistItem } from '../../types/playlist'; export enum EventState { PRE_LIVE = 'PRE_LIVE', diff --git a/src/utils/media.ts b/packages/common/src/utils/media.ts similarity index 94% rename from src/utils/media.ts rename to packages/common/src/utils/media.ts index 3596bac80..024ee6038 100644 --- a/src/utils/media.ts +++ b/packages/common/src/utils/media.ts @@ -1,5 +1,5 @@ -import { CONTENT_TYPE } from '#src/config'; -import type { Playlist, PlaylistItem } from '#types/playlist'; +import { CONTENT_TYPE } from '../constants'; +import type { Playlist, PlaylistItem } from '../../types/playlist'; type RequiredProperties = T & Required>; diff --git a/src/utils/promiseQueue.test.ts b/packages/common/src/utils/promiseQueue.test.ts similarity index 100% rename from src/utils/promiseQueue.test.ts rename to packages/common/src/utils/promiseQueue.test.ts diff --git a/src/utils/promiseQueue.ts b/packages/common/src/utils/promiseQueue.ts similarity index 100% rename from src/utils/promiseQueue.ts rename to packages/common/src/utils/promiseQueue.ts diff --git a/src/utils/series.ts b/packages/common/src/utils/series.ts similarity index 90% rename from src/utils/series.ts rename to packages/common/src/utils/series.ts index a3687aacd..2414a78c3 100644 --- a/src/utils/series.ts +++ b/packages/common/src/utils/series.ts @@ -1,4 +1,4 @@ -import type { EpisodeMetadata, Series } from '#types/series'; +import type { EpisodeMetadata, Series } from '../../types/series'; /** * Get an array of options for a season filter diff --git a/src/utils/structuredData.ts b/packages/common/src/utils/structuredData.ts similarity index 62% rename from src/utils/structuredData.ts rename to packages/common/src/utils/structuredData.ts index 08340adce..6470ae526 100644 --- a/src/utils/structuredData.ts +++ b/packages/common/src/utils/structuredData.ts @@ -1,11 +1,11 @@ -import { mediaURL } from './formatting'; -import { secondsToISO8601 } from './datetime'; +import type { PlaylistItem } from '../../types/playlist'; +import type { EpisodeMetadata, Series } from '../../types/series'; -import type { PlaylistItem } from '#types/playlist'; -import type { EpisodeMetadata, Series } from '#types/series'; +import { mediaURL } from './urlFormatting'; +import { secondsToISO8601 } from './datetime'; -export const generateMovieJSONLD = (item: PlaylistItem) => { - const movieCanonical = `${window.location.origin}${mediaURL({ media: item })}`; +export const generateMovieJSONLD = (item: PlaylistItem, origin: string) => { + const movieCanonical = `${origin}${mediaURL({ media: item })}`; return JSON.stringify({ '@context': 'http://schema.org/', @@ -19,9 +19,9 @@ export const generateMovieJSONLD = (item: PlaylistItem) => { }); }; -export const generateSeriesMetadata = (series: Series, media: PlaylistItem, seriesId: string | undefined) => { +export const generateSeriesMetadata = (series: Series, media: PlaylistItem, seriesId: string, origin: string) => { // Use playlist for old flow and media id for a new flow - const seriesCanonical = `${window.location.origin}/m/${seriesId}`; + const seriesCanonical = `${origin}/m/${seriesId}`; return { '@type': 'TVSeries', @@ -32,9 +32,15 @@ export const generateSeriesMetadata = (series: Series, media: PlaylistItem, seri }; }; -export const generateEpisodeJSONLD = (series: Series, media: PlaylistItem, episode: PlaylistItem | undefined, episodeMetadata: EpisodeMetadata | undefined) => { - const episodeCanonical = `${window.location.origin}/m/${series.series_id}?e=${episode?.mediaid}`; - const seriesMetadata = generateSeriesMetadata(series, media, series.series_id); +export const generateEpisodeJSONLD = ( + series: Series, + media: PlaylistItem, + origin: string, + episode: PlaylistItem | undefined, + episodeMetadata: EpisodeMetadata | undefined, +) => { + const episodeCanonical = `${origin}/m/${series.series_id}?e=${episode?.mediaid}`; + const seriesMetadata = generateSeriesMetadata(series, media, series.series_id, origin); if (!episode) { return JSON.stringify(seriesMetadata); diff --git a/src/utils/subscription.ts b/packages/common/src/utils/subscription.ts similarity index 67% rename from src/utils/subscription.ts rename to packages/common/src/utils/subscription.ts index 73f6d17c2..d87001355 100644 --- a/src/utils/subscription.ts +++ b/packages/common/src/utils/subscription.ts @@ -1,5 +1,6 @@ -import { formatPrice } from '#src/utils/formatting'; -import type { Offer, Order } from '#types/checkout'; +import type { Offer, Order } from '../../types/checkout'; + +import { formatPrice } from './formatting'; export const getOfferPrice = (offer: Offer) => formatPrice(offer.customerPriceInclTax, offer.customerCurrency, offer.customerCountry); diff --git a/packages/common/src/utils/urlFormatting.test.ts b/packages/common/src/utils/urlFormatting.test.ts new file mode 100644 index 000000000..2af8d84cf --- /dev/null +++ b/packages/common/src/utils/urlFormatting.test.ts @@ -0,0 +1,25 @@ +import { createURL } from './urlFormatting'; + +describe('createUrl', () => { + test('valid url from a path, query params', async () => { + const url = createURL('/test', { foo: 'bar' }); + + expect(url).toEqual('/test?foo=bar'); + }); + test('valid url from a path including params, query params', async () => { + const url = createURL('/test?existing-param=1', { foo: 'bar' }); + + expect(url).toEqual('/test?existing-param=1&foo=bar'); + }); + + test('valid url from a path including params, removing one with a query param', async () => { + const url = createURL('/test?existing-param=1', { [`existing-param`]: null }); + + expect(url).toEqual('/test'); + }); + test('valid redirect url from a location including params, query params', async () => { + const url = createURL('https://app-preview.jwplayer.com/?existing-param=1&foo=bar', { u: 'payment-method-success' }); + + expect(url).toEqual('https://app-preview.jwplayer.com/?existing-param=1&foo=bar&u=payment-method-success'); + }); +}); diff --git a/packages/common/src/utils/urlFormatting.ts b/packages/common/src/utils/urlFormatting.ts new file mode 100644 index 000000000..f14651ee4 --- /dev/null +++ b/packages/common/src/utils/urlFormatting.ts @@ -0,0 +1,84 @@ +import type { PlaylistItem } from '../../types/playlist'; + +import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media'; + +export type QueryParamsArg = { [key: string]: string | number | string[] | undefined | null }; + +// Creates a new URL from a url string (could include search params) and an object to add and remove query params +// For example: createURL(window.location.pathname, { foo: 'bar' }); +export const createURL = (url: string, queryParams: QueryParamsArg) => { + const [baseUrl, urlQueryString = ''] = url.split('?'); + const urlSearchParams = new URLSearchParams(urlQueryString); + + Object.entries(queryParams).forEach(([key, value]) => { + if (value === null || value === undefined) { + return urlSearchParams.delete(key); + } + + const formattedValue = Array.isArray(value) ? value.join(',') : value; + + urlSearchParams.set(key, String(formattedValue)); + }); + + const queryString = urlSearchParams.toString(); + + return `${baseUrl}${queryString ? `?${queryString}` : ''}`; +}; + +export const slugify = (text: string, whitespaceChar: string = '-') => + text + .toString() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w-]+/g, '') + .replace(/--+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') + .replace(/-/g, whitespaceChar); + +export const mediaURL = ({ + media, + playlistId, + play = false, + episodeId, +}: { + media: PlaylistItem; + playlistId?: string | null; + play?: boolean; + episodeId?: string; +}) => { + return createURL(`/m/${media.mediaid}/${slugify(media.title)}`, { r: playlistId, play: play ? '1' : null, e: episodeId }); +}; + +export const liveChannelsURL = (playlistId: string, channelId?: string, play = false) => { + return createURL(`/p/${playlistId}`, { + channel: channelId, + play: play ? '1' : null, + }); +}; + +export const legacySeriesURL = ({ + seriesId, + episodeId, + play, + playlistId, +}: { + seriesId: string; + episodeId?: string; + play?: boolean; + playlistId?: string | null; +}) => createURL(`/s/${seriesId}`, { r: playlistId, e: episodeId, play: play ? '1' : null }); + +export const buildLegacySeriesUrlFromMediaItem = (media: PlaylistItem, play: boolean, playlistId: string | null) => { + const legacyPlaylistIdFromTags = getLegacySeriesPlaylistIdFromEpisodeTags(media); + const legacyPlaylistIdFromCustomParams = getSeriesPlaylistIdFromCustomParams(media); + + return legacySeriesURL({ + // Use the id grabbed from either custom params for series or tags for an episode + seriesId: legacyPlaylistIdFromCustomParams || legacyPlaylistIdFromTags || '', + play, + playlistId, + // Add episode id only if series id can be retrieved from tags + episodeId: legacyPlaylistIdFromTags && media.mediaid, + }); +}; diff --git a/src/utils/yupSchemaCreator.ts b/packages/common/src/utils/yupSchemaCreator.ts similarity index 100% rename from src/utils/yupSchemaCreator.ts rename to packages/common/src/utils/yupSchemaCreator.ts index 3430273d0..d1e322437 100644 --- a/src/utils/yupSchemaCreator.ts +++ b/packages/common/src/utils/yupSchemaCreator.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import * as yupRaw from 'yup'; import type { AnySchema } from 'yup'; +import * as yupRaw from 'yup'; import type Reference from 'yup/lib/Reference'; import type Lazy from 'yup/lib/Lazy'; diff --git a/packages/common/test/mockService.ts b/packages/common/test/mockService.ts new file mode 100644 index 000000000..c5361602f --- /dev/null +++ b/packages/common/test/mockService.ts @@ -0,0 +1,50 @@ +import type { interfaces } from 'inversify'; +import { afterEach } from 'vitest'; + +type ServiceMockEntry = { + serviceIdentifier: interfaces.ServiceIdentifier; + implementation: unknown; +}; + +export type OptionalMembers = { [K in keyof T]?: T[K] }; + +export let mockedServices: ServiceMockEntry[] = []; + +const getName = (serviceIdentifier: interfaces.ServiceIdentifier) => + serviceIdentifier instanceof Function ? serviceIdentifier.name : serviceIdentifier.toString(); + +export const mockService = >(serviceIdentifier: interfaces.ServiceIdentifier, implementation: B) => { + if (mockedServices.some((mock) => mock.serviceIdentifier === serviceIdentifier)) { + throw new Error(`There already is a mocked service for ${getName(serviceIdentifier)}`); + } + mockedServices = mockedServices.filter((a) => a.serviceIdentifier !== serviceIdentifier); + + mockedServices.push({ + serviceIdentifier, + implementation, + }); + + return implementation; +}; + +// After importing this file, the `afterEach` and `vi.mock` are registered automatically +afterEach(() => { + mockedServices = []; +}); + +vi.mock('@jwp/ott-common/src/modules/container', async () => { + const actual = (await vi.importActual('@jwp/ott-common/src/modules/container')) as Record; + + return { + ...actual, + getModule: (serviceIdentifier: interfaces.ServiceIdentifier) => { + const mockedService = mockedServices.find((current) => current.serviceIdentifier === serviceIdentifier); + + if (!mockedService) { + throw new Error(`Couldn't find mocked service for '${getName(serviceIdentifier)}'`); + } + + return mockedService.implementation; + }, + }; +}); diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json new file mode 100644 index 000000000..38d953004 --- /dev/null +++ b/packages/common/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src", + "types", + "test", + "vitest.config.ts", + "vitest.setup.ts" + ], + "compilerOptions": { + "noEmit": true, + "target": "ESNext", + "lib": [ + "esnext" + ], + "types": [ + "reflect-metadata", + "vi-fetch/matchers", + "vitest/globals" + ] + } +} diff --git a/types/account.d.ts b/packages/common/types/account.d.ts similarity index 72% rename from types/account.d.ts rename to packages/common/types/account.d.ts index f5bedb08d..9cfbb98e5 100644 --- a/types/account.d.ts +++ b/packages/common/types/account.d.ts @@ -1,9 +1,8 @@ -import type { CommonResponse, ProfilesData } from '@inplayer-org/inplayer.js'; +import type { ProfilesData } from '@inplayer-org/inplayer.js'; import type { SerializedWatchHistoryItem } from './watchHistory'; import type { SerializedFavorite } from './favorite'; - -import type { Config } from '#types/Config'; +import type { Config } from './config'; export type AuthData = { jwt: string; @@ -24,6 +23,7 @@ export type LoginArgs = { config: Config; email: string; password: string; + referrer: string; }; export type RegistrationArgs = LoginArgs & { @@ -147,26 +147,17 @@ export type GetCustomerConsentsResponse = { export type ResetPasswordPayload = { customerEmail: string; offerId?: string; - publisherId: string | null; resetUrl?: string; }; -export type ChangePasswordPayload = { - customerEmail: string; - publisherId: string; - resetPasswordToken: string; - newPassword: string; -}; - export type ChangePasswordWithTokenPayload = { customerEmail?: string; - publisherId: string | null; resetPasswordToken: string; newPassword: string; newPasswordConfirmation: string; }; -export type changePasswordWithOldPasswordPayload = { +export type ChangePasswordWithOldPasswordPayload = { oldPassword: string; newPassword: string; newPasswordConfirmation: string; @@ -218,7 +209,7 @@ export type UpdateCustomerArgs = { firstName?: string | undefined; lastName?: string | undefined; externalData?: ExternalData | undefined; - metadata?: Record; + metadata?: Record; fullName?: string; }; @@ -369,35 +360,43 @@ export type SubscribeToNotificationsPayload = { onMessage: (payload: string) => void; }; -export type SocialURLs = { - facebook: string; - twitter: string; - google: string; -}; - -type Login = PromiseRequest; -type Register = PromiseRequest; -type Login = PromiseRequest; -type Register = PromiseRequest; -type GetCustomer = EnvironmentServiceRequest; -type UpdateCustomer = EnvironmentServiceRequest; -type GetPublisherConsents = PromiseRequest; -type GetCustomerConsents = PromiseRequest; -type UpdateCustomerConsents = PromiseRequest; -type GetCaptureStatus = EnvironmentServiceRequest; -type UpdateCaptureAnswers = EnvironmentServiceRequest; -type ResetPassword = EnvironmentServiceRequest>; -type ChangePassword = EnvironmentServiceRequest>; -type ChangePasswordWithOldPassword = EnvironmentServiceRequest>; -type UpdatePersonalShelves = EnvironmentServiceRequest>; -type GetLocales = EmptyServiceRequest; -type ExportAccountData = EnvironmentServiceRequest; -type SocialURLSData = PromiseRequest; -type NotificationsData = PromiseRequest; -type DeleteAccount = EnvironmentServiceRequest; -type ListProfiles = EnvironmentServiceRequest; -type CreateProfile = EnvironmentServiceRequest; -type UpdateProfile = EnvironmentServiceRequest; -type EnterProfile = EnvironmentServiceRequest; -type GetProfileDetails = EnvironmentServiceRequest; -type DeleteProfile = EnvironmentServiceRequest; +export type GetSocialURLsPayload = { + config: Config; + redirectUrl: string; +}; + +export type SocialURLs = + | { + facebook: string; + } + | { + twitter: string; + } + | { + google: string; + }; + +export type Login = PromiseRequest; +export type Register = PromiseRequest; +export type GetCustomer = EnvironmentServiceRequest; +export type UpdateCustomer = EnvironmentServiceRequest; +export type GetPublisherConsents = PromiseRequest; +export type GetCustomerConsents = PromiseRequest; +export type UpdateCustomerConsents = PromiseRequest; +export type GetCaptureStatus = EnvironmentServiceRequest; +export type UpdateCaptureAnswers = EnvironmentServiceRequest; +export type ResetPassword = EnvironmentServiceRequest>; +export type ChangePassword = EnvironmentServiceRequest; +export type ChangePasswordWithOldPassword = EnvironmentServiceRequest; +export type UpdatePersonalShelves = EnvironmentServiceRequest>; +export type GetLocales = EmptyServiceRequest; +export type ExportAccountData = EnvironmentServiceRequest; +export type GetSocialURLs = PromiseRequest; +export type NotificationsData = PromiseRequest; +export type DeleteAccount = EnvironmentServiceRequest; +export type ListProfiles = EnvironmentServiceRequest; +export type CreateProfile = EnvironmentServiceRequest; +export type UpdateProfile = EnvironmentServiceRequest; +export type EnterProfile = EnvironmentServiceRequest; +export type GetProfileDetails = EnvironmentServiceRequest; +export type DeleteProfile = EnvironmentServiceRequest; diff --git a/types/ad-schedule.d.ts b/packages/common/types/ad-schedule.d.ts similarity index 100% rename from types/ad-schedule.d.ts rename to packages/common/types/ad-schedule.d.ts diff --git a/types/adyen.d.ts b/packages/common/types/adyen.d.ts similarity index 100% rename from types/adyen.d.ts rename to packages/common/types/adyen.d.ts diff --git a/packages/common/types/calculate-integration-type.ts b/packages/common/types/calculate-integration-type.ts new file mode 100644 index 000000000..8c15bb1aa --- /dev/null +++ b/packages/common/types/calculate-integration-type.ts @@ -0,0 +1,3 @@ +import type { Config } from './config'; + +export type CalculateIntegrationType = (config: Config) => string | null; diff --git a/types/checkout.d.ts b/packages/common/types/checkout.d.ts similarity index 96% rename from types/checkout.d.ts rename to packages/common/types/checkout.d.ts index d734971ae..6c6f50032 100644 --- a/types/checkout.d.ts +++ b/packages/common/types/checkout.d.ts @@ -1,4 +1,5 @@ -import type { PayloadWithIPOverride } from '#types/account'; +import type { PayloadWithIPOverride } from './account'; +import type { PaymentDetail } from './subscription'; export type Offer = { id: number | null; @@ -182,7 +183,7 @@ export type GetSubscriptionSwitchPayload = { }; export type GetSubscriptionSwitchResponse = { - id: id; + id: string; customerId: number; direction: 'downgrade' | 'upgrade'; algorithm: string; @@ -254,6 +255,7 @@ export type PaymentWithPayPalPayload = { successUrl: string; cancelUrl: string; errorUrl: string; + waitingUrl: string; couponCode?: string; }; @@ -292,7 +294,6 @@ export type InitialAdyenPaymentPayload = { origin?: string; customerIP?: string; browserInfo?: unknown; - attemptAuthentication?: 'always' | 'never' | 'preferNo'; enable3DSRedirectFlow?: boolean; }; @@ -336,7 +337,7 @@ export type AdyenPaymentSession = { sessionData: string; }; -export type UpdatePaymentWithPayPalPayload = Omit & { paymentMethodId: number }; +export type UpdatePaymentWithPayPalPayload = Omit & { paymentMethodId: number }; export type DeletePaymentMethodPayload = { paymentDetailsId: number; @@ -348,13 +349,13 @@ export type DeletePaymentMethodResponse = { export type AddAdyenPaymentDetailsPayload = Omit & { paymentMethodId: number }; -export type AddAdyenPaymentDetailsResponse = Omit; +export type AddAdyenPaymentDetailsResponse = Omit | AdyenAction; export type FinalizeAdyenPaymentDetailsPayload = Omit & { paymentMethodId: number }; export type FinalizeAdyenPaymentDetailsResponse = PaymentDetail; -export type GetOffers = (payload: GetOffersPayload, sandbox: boolean) => Promise; +export type GetOffers = PromiseRequest; export type GetOffer = EnvironmentServiceRequest; export type CreateOrder = EnvironmentServiceRequest; export type GetOrder = EnvironmentServiceRequest; @@ -374,4 +375,4 @@ export type UpdatePaymentWithPayPal = EnvironmentServiceRequest; export type AddAdyenPaymentDetails = EnvironmentServiceRequest; export type FinalizeAdyenPaymentDetails = EnvironmentServiceRequest; -export type GetDirectPostCardPayment = (cardPaymentPayload: CardPaymentData, order: Order) => Promise; +export type GetDirectPostCardPayment = (cardPaymentPayload: CardPaymentData, order: Order, referrer: string, returnUrl: string) => Promise; diff --git a/types/cleeng.d.ts b/packages/common/types/cleeng.d.ts similarity index 54% rename from types/cleeng.d.ts rename to packages/common/types/cleeng.d.ts index f5c505d8a..742f7a7ff 100644 --- a/types/cleeng.d.ts +++ b/packages/common/types/cleeng.d.ts @@ -3,4 +3,4 @@ interface ApiResponse { } type CleengResponse = { responseData: R } & ApiResponse; -type CleengRequest = (payload: P, sandbox: boolean) => Promise>; +type CleengRequest = (payload: P) => Promise>; diff --git a/types/Config.d.ts b/packages/common/types/config.d.ts similarity index 97% rename from types/Config.d.ts rename to packages/common/types/config.d.ts index f9c6a4bd7..b74104536 100644 --- a/types/Config.d.ts +++ b/packages/common/types/config.d.ts @@ -1,4 +1,4 @@ -import type { AdScheduleUrls } from '#types/ad-schedule'; +import type { AdScheduleUrls } from './ad-schedule'; /** * Set config setup changes in both config.services.ts and config.d.ts diff --git a/packages/common/types/entitlement.d.ts b/packages/common/types/entitlement.d.ts new file mode 100644 index 000000000..5b5803420 --- /dev/null +++ b/packages/common/types/entitlement.d.ts @@ -0,0 +1,6 @@ +export type GetTokenResponse = { + entitled: boolean; + token: string; +}; + +export type EntitlementType = 'media' | 'playlist' | 'library'; diff --git a/types/epg.d.ts b/packages/common/types/epg.d.ts similarity index 78% rename from types/epg.d.ts rename to packages/common/types/epg.d.ts index 36ce09fbc..54a6bf7c7 100644 --- a/types/epg.d.ts +++ b/packages/common/types/epg.d.ts @@ -1,5 +1,3 @@ -import type { EPG_TYPE } from '#src/config'; - export type EpgChannel = { id: string; title: string; @@ -19,5 +17,3 @@ export type EpgProgram = { cardImage?: string; backgroundImage?: string; }; - -export type EpgScheduleType = keyof typeof EPG_TYPE; diff --git a/types/favorite.d.ts b/packages/common/types/favorite.d.ts similarity index 100% rename from types/favorite.d.ts rename to packages/common/types/favorite.d.ts diff --git a/types/form.d.ts b/packages/common/types/form.d.ts similarity index 100% rename from types/form.d.ts rename to packages/common/types/form.d.ts diff --git a/packages/common/types/get-customer-ip.d.ts b/packages/common/types/get-customer-ip.d.ts new file mode 100644 index 000000000..1d0494fda --- /dev/null +++ b/packages/common/types/get-customer-ip.d.ts @@ -0,0 +1 @@ +export type GetCustomerIP = () => Promise; diff --git a/types/global.d.ts b/packages/common/types/global.d.ts similarity index 79% rename from types/global.d.ts rename to packages/common/types/global.d.ts index 023e0fe1a..2da10aad7 100644 --- a/types/global.d.ts +++ b/packages/common/types/global.d.ts @@ -1,7 +1,5 @@ // noinspection JSUnusedGlobalSymbols interface Window { - configLocation: configLocation; - configId: string; jwplayer?: jwplayer.JWPlayerStatic; jwpltx: Jwpltx; AdyenCheckout: Adyen.AdyenCheckoutStatic; diff --git a/packages/common/types/i18n.d.ts b/packages/common/types/i18n.d.ts new file mode 100644 index 000000000..b47ef70ab --- /dev/null +++ b/packages/common/types/i18n.d.ts @@ -0,0 +1,4 @@ +export type LanguageDefinition = { + code: string; + displayName: string; +}; diff --git a/types/i18next.d.ts b/packages/common/types/i18next.d.ts similarity index 100% rename from types/i18next.d.ts rename to packages/common/types/i18next.d.ts diff --git a/types/inplayer.d.ts b/packages/common/types/inplayer.d.ts similarity index 80% rename from types/inplayer.d.ts rename to packages/common/types/inplayer.d.ts index 3fa64e3ea..67d651e56 100644 --- a/types/inplayer.d.ts +++ b/packages/common/types/inplayer.d.ts @@ -1,5 +1,3 @@ -import type { PurchaseHistoryCollection } from '@inplayer-org/inplayer.js'; - export type InPlayerAuthData = { access_token: string; expires?: number; diff --git a/types/jwplayer.d.ts b/packages/common/types/jwplayer.d.ts similarity index 100% rename from types/jwplayer.d.ts rename to packages/common/types/jwplayer.d.ts diff --git a/types/jwpltx.d.ts b/packages/common/types/jwpltx.d.ts similarity index 100% rename from types/jwpltx.d.ts rename to packages/common/types/jwpltx.d.ts diff --git a/types/media.d.ts b/packages/common/types/media.d.ts similarity index 100% rename from types/media.d.ts rename to packages/common/types/media.d.ts diff --git a/types/pagination.d.ts b/packages/common/types/pagination.d.ts similarity index 100% rename from types/pagination.d.ts rename to packages/common/types/pagination.d.ts diff --git a/types/playlist.d.ts b/packages/common/types/playlist.d.ts similarity index 97% rename from types/playlist.d.ts rename to packages/common/types/playlist.d.ts index 8e5c39225..23ef6cfbe 100644 --- a/types/playlist.d.ts +++ b/packages/common/types/playlist.d.ts @@ -1,4 +1,4 @@ -import type { MediaOffer } from '#types/media'; +import type { MediaOffer } from './media'; export type GetPlaylistParams = { page_limit?: string; related_media_id?: string; token?: string; search?: string }; diff --git a/src/containers/Profiles/types.d.ts b/packages/common/types/profiles.d.ts similarity index 75% rename from src/containers/Profiles/types.d.ts rename to packages/common/types/profiles.d.ts index f6f3f4b07..87e35f070 100644 --- a/src/containers/Profiles/types.d.ts +++ b/packages/common/types/profiles.d.ts @@ -1,4 +1,4 @@ -import type { ProfilePayload } from '#types/account'; +import type { ProfilePayload } from './account'; export type ProfileFormValues = Omit & { adult: string }; diff --git a/types/series.d.ts b/packages/common/types/series.d.ts similarity index 100% rename from types/series.d.ts rename to packages/common/types/series.d.ts diff --git a/packages/common/types/service.d.ts b/packages/common/types/service.d.ts new file mode 100644 index 000000000..e4de4bf36 --- /dev/null +++ b/packages/common/types/service.d.ts @@ -0,0 +1,9 @@ +interface ApiResponse { + errors: string[]; +} + +type ServiceResponse = { responseData: R } & ApiResponse; +type PromiseRequest = (payload: P) => Promise; +type EmptyServiceRequest = () => Promise>; +type EmptyEnvironmentServiceRequest = () => Promise>; +type EnvironmentServiceRequest = (payload: P) => Promise>; diff --git a/types/settings.d.ts b/packages/common/types/settings.d.ts similarity index 100% rename from types/settings.d.ts rename to packages/common/types/settings.d.ts diff --git a/packages/common/types/static.d.ts b/packages/common/types/static.d.ts new file mode 100644 index 000000000..58b6d72b1 --- /dev/null +++ b/packages/common/types/static.d.ts @@ -0,0 +1,18 @@ +// @todo: should not be necessary in the common package +declare module '*.png' { + const ref: string; + export default ref; +} + +declare module '*.xml' { + const ref: string; + export default ref; +} + +declare module '*.xml?raw' { + const raw: string; + export default raw; +} + +declare const __mode__: string; +declare const __dev__: boolean; diff --git a/types/subscription.d.ts b/packages/common/types/subscription.d.ts similarity index 94% rename from types/subscription.d.ts rename to packages/common/types/subscription.d.ts index 8a6713063..6a3185c5e 100644 --- a/types/subscription.d.ts +++ b/packages/common/types/subscription.d.ts @@ -1,4 +1,4 @@ -import type { DefaultCreditCardData, SetDefaultCard, ChangeSubscriptionPlanRequestBody, ChangeSubscriptionPlanResponse } from '@inplayer-org/inplayer.js'; +import type { ChangeSubscriptionPlanResponse, DefaultCreditCardData, SetDefaultCard } from '@inplayer-org/inplayer.js'; // Subscription types export type Subscription = { subscriptionId: number | string; @@ -132,19 +132,15 @@ type ChangeSubscriptionPayload = { }; type GetActivePaymentPayload = { - sandbox: boolean; customerId: string; }; type GetAllTransactionsPayload = { - sandbox: boolean; customerId: string; }; type GetActiveSubscriptionPayload = { - sandbox: boolean; customerId: string; - config: Config; }; type GetActivePaymentResponse = PaymentDetail | null; diff --git a/packages/common/types/testing.d.ts b/packages/common/types/testing.d.ts new file mode 100644 index 000000000..2b84dd6db --- /dev/null +++ b/packages/common/types/testing.d.ts @@ -0,0 +1,4 @@ +export type CopyProperties = { [K in keyof T as T[K] extends V ? K : never]: T[K] }; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type MockedService = CopyProperties; diff --git a/types/watchHistory.d.ts b/packages/common/types/watchHistory.d.ts similarity index 100% rename from types/watchHistory.d.ts rename to packages/common/types/watchHistory.d.ts diff --git a/packages/common/vitest.config.ts b/packages/common/vitest.config.ts new file mode 100644 index 000000000..938e56753 --- /dev/null +++ b/packages/common/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['vitest.setup.ts'], + css: true, + }, + define: { + __mode__: '"test"', + __dev__: true, + }, +}); diff --git a/packages/common/vitest.setup.ts b/packages/common/vitest.setup.ts new file mode 100644 index 000000000..1308d84dc --- /dev/null +++ b/packages/common/vitest.setup.ts @@ -0,0 +1,28 @@ +import 'vi-fetch/setup'; +import 'reflect-metadata'; + +// a really simple BroadcastChannel stub. Normally, a Broadcast channel would not call event listeners on the same +// instance. But for testing purposes, that really doesn't matter... +vi.stubGlobal( + 'BroadcastChannel', + vi.fn().mockImplementation(() => { + const listeners: Record void)[]> = {}; + + return { + close: () => undefined, + addEventListener: (type: string, listener: () => void) => { + listeners[type] = listeners[type] || []; + listeners[type].push(listener); + }, + removeEventListener: (type: string, listener: () => void) => { + listeners[type] = listeners[type] || []; + listeners[type] = listeners[type].filter((current) => current !== listener); + }, + postMessage: (message: string) => { + const messageListeners = listeners['message'] || []; + + messageListeners.forEach((listener) => listener({ type: 'message', data: message })); + }, + }; + }), +); diff --git a/packages/hooks-react/.depcheckrc.yaml b/packages/hooks-react/.depcheckrc.yaml new file mode 100644 index 000000000..0fd1e3728 --- /dev/null +++ b/packages/hooks-react/.depcheckrc.yaml @@ -0,0 +1,4 @@ +ignores: [ + 'eslint-plugin-react', + 'eslint-plugin-react-hooks' +] diff --git a/packages/hooks-react/.eslintrc.js b/packages/hooks-react/.eslintrc.js new file mode 100644 index 000000000..641a9bf64 --- /dev/null +++ b/packages/hooks-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['jwp/react'], +}; diff --git a/packages/hooks-react/lint-staged.config.js b/packages/hooks-react/lint-staged.config.js new file mode 100644 index 000000000..e4e6b6a16 --- /dev/null +++ b/packages/hooks-react/lint-staged.config.js @@ -0,0 +1,4 @@ +module.exports = { + '*.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'], + '*.{ts,tsx}': [() => 'tsc --pretty --noEmit'], +}; diff --git a/packages/hooks-react/package.json b/packages/hooks-react/package.json new file mode 100644 index 000000000..813b5141a --- /dev/null +++ b/packages/hooks-react/package.json @@ -0,0 +1,33 @@ +{ + "name": "@jwp/ott-hooks-react", + "version": "4.30.0", + "private": true, + "scripts": { + "lint:ts": "tsc --pretty --noEmit -p ./", + "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest", + "test-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update" + }, + "dependencies": { + "@inplayer-org/inplayer.js": "^3.13.24", + "date-fns": "^2.28.0", + "i18next": "^22.4.15", + "planby": "^0.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^12.3.1", + "react-query": "^3.39.0", + "reflect-metadata": "^0.1.13", + "yup": "^0.32.9" + }, + "devDependencies": { + "@testing-library/react": "^14.0.0", + "vi-fetch": "^0.8.0", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "@jwp/ott-common": "*", + "@jwp/ott-testing": "*", + "eslint-config-jwp": "*" + } +} diff --git a/src/hooks/series/useEpisodes.ts b/packages/hooks-react/src/series/useEpisodes.ts similarity index 82% rename from src/hooks/series/useEpisodes.ts rename to packages/hooks-react/src/series/useEpisodes.ts index 6c2296bd9..7d91fa9e2 100644 --- a/src/hooks/series/useEpisodes.ts +++ b/packages/hooks-react/src/series/useEpisodes.ts @@ -1,10 +1,9 @@ import { useInfiniteQuery } from 'react-query'; - -import type { EpisodesWithPagination } from '#types/series'; -import type { Pagination } from '#types/pagination'; -import { CACHE_TIME, STALE_TIME } from '#src/config'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; +import type { EpisodesWithPagination } from '@jwp/ott-common/types/series'; +import type { Pagination } from '@jwp/ott-common/types/pagination'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; const getNextPageParam = (pagination: Pagination) => { const { page, page_limit, total } = pagination; diff --git a/src/hooks/series/useNextEpisode.ts b/packages/hooks-react/src/series/useNextEpisode.ts similarity index 70% rename from src/hooks/series/useNextEpisode.ts rename to packages/hooks-react/src/series/useNextEpisode.ts index 5a520b2c3..2a33d573c 100644 --- a/src/hooks/series/useNextEpisode.ts +++ b/packages/hooks-react/src/series/useNextEpisode.ts @@ -1,9 +1,8 @@ import { useQuery } from 'react-query'; - -import type { Series } from '#types/series'; -import { CACHE_TIME, STALE_TIME } from '#src/config'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; +import type { Series } from '@jwp/ott-common/types/series'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; export const useNextEpisode = ({ series, episodeId }: { series: Series | undefined; episodeId: string | undefined }) => { const apiService = getModule(ApiService); diff --git a/src/hooks/series/useSeries.ts b/packages/hooks-react/src/series/useSeries.ts similarity index 68% rename from src/hooks/series/useSeries.ts rename to packages/hooks-react/src/series/useSeries.ts index ca1a2ba5e..332d5eefb 100644 --- a/src/hooks/series/useSeries.ts +++ b/packages/hooks-react/src/series/useSeries.ts @@ -1,10 +1,9 @@ -import { useQuery, UseQueryResult } from 'react-query'; - -import type { Series } from '#types/series'; -import type { ApiError } from '#src/utils/api'; -import { CACHE_TIME, STALE_TIME } from '#src/config'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; +import { useQuery, type UseQueryResult } from 'react-query'; +import type { Series } from '@jwp/ott-common/types/series'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import type { ApiError } from '@jwp/ott-common/src/utils/api'; +import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; export const useSeries = ( seriesId: string | undefined, diff --git a/src/hooks/series/useSeriesLookup.ts b/packages/hooks-react/src/series/useSeriesLookup.ts similarity index 76% rename from src/hooks/series/useSeriesLookup.ts rename to packages/hooks-react/src/series/useSeriesLookup.ts index b02dbcb55..1dcaf7de6 100644 --- a/src/hooks/series/useSeriesLookup.ts +++ b/packages/hooks-react/src/series/useSeriesLookup.ts @@ -1,8 +1,7 @@ import { useQuery } from 'react-query'; - -import { STALE_TIME, CACHE_TIME } from '#src/config'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; export const useSeriesLookup = (mediaId: string | undefined) => { const apiService = getModule(ApiService); diff --git a/packages/hooks-react/src/testUtils.tsx b/packages/hooks-react/src/testUtils.tsx new file mode 100644 index 000000000..83d861454 --- /dev/null +++ b/packages/hooks-react/src/testUtils.tsx @@ -0,0 +1,21 @@ +import React, { type ReactElement, type ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { act } from '@testing-library/react'; + +interface WrapperProps { + children?: ReactNode; +} + +export const queryClientWrapper = () => { + const client = new QueryClient(); + + return ({ children }: WrapperProps) => {children as ReactElement}; +}; + +// native 'waitFor' uses 'setInterval' under the hood which is also faked when using vi.useFakeTimers... +// this custom method is to trigger micro task queue and wait for updates +export const waitForWithFakeTimers = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; diff --git a/src/hooks/useAds.ts b/packages/hooks-react/src/useAds.ts similarity index 78% rename from src/hooks/useAds.ts rename to packages/hooks-react/src/useAds.ts index d54aec8d5..b18e05bc4 100644 --- a/src/hooks/useAds.ts +++ b/packages/hooks-react/src/useAds.ts @@ -1,9 +1,8 @@ import { useQuery } from 'react-query'; - -import { useConfigStore } from '#src/stores/ConfigStore'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; -import { addQueryParams } from '#src/utils/formatting'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; const CACHE_TIME = 60 * 1000 * 20; @@ -34,7 +33,7 @@ export const useAds = ({ mediaId }: { mediaId: string }) => { const { data: adSchedule, isLoading: isAdScheduleLoading } = useLegacyStandaloneAds({ adScheduleId, enabled: !useAppBasedFlow }); const adConfig = { client: 'vast', - schedule: addQueryParams(adScheduleUrls?.xml || '', { + schedule: createURL(adScheduleUrls?.xml || '', { media_id: mediaId, }), }; diff --git a/packages/hooks-react/src/useBootstrapApp.ts b/packages/hooks-react/src/useBootstrapApp.ts new file mode 100644 index 000000000..491e03f19 --- /dev/null +++ b/packages/hooks-react/src/useBootstrapApp.ts @@ -0,0 +1,44 @@ +import { useQuery, useQueryClient } from 'react-query'; +import type { Config } from '@jwp/ott-common/types/config'; +import type { Settings } from '@jwp/ott-common/types/settings'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import AppController from '@jwp/ott-common/src/stores/AppController'; +import type { AppError } from '@jwp/ott-common/src/utils/error'; +import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; + +const applicationController = getModule(AppController); + +type Resources = { + config: Config; + configSource: string | undefined; + settings: Settings; +}; + +export type OnReadyCallback = (config: Config | undefined) => void; + +export const useBootstrapApp = (url: string, onReady: OnReadyCallback) => { + const queryClient = useQueryClient(); + const refreshEntitlements = () => queryClient.invalidateQueries({ queryKey: ['entitlements'] }); + + const { data, isLoading, error, isSuccess, refetch } = useQuery( + 'config-init', + () => applicationController.initializeApp(url, refreshEntitlements), + { + refetchInterval: false, + retry: 1, + onSettled: (query) => onReady(query?.config), + cacheTime: CACHE_TIME, + staleTime: STALE_TIME, + }, + ); + + return { + data, + isLoading, + error, + isSuccess, + refetch, + }; +}; + +export type BootstrapData = ReturnType; diff --git a/src/hooks/useCheckAccess.ts b/packages/hooks-react/src/useCheckAccess.ts similarity index 64% rename from src/hooks/useCheckAccess.ts rename to packages/hooks-react/src/useCheckAccess.ts index 770101a01..6b682ae89 100644 --- a/src/hooks/useCheckAccess.ts +++ b/packages/hooks-react/src/useCheckAccess.ts @@ -1,31 +1,28 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; +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 { addQueryParam } from '#src/utils/location'; -import AccountController from '#src/stores/AccountController'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { getModule } from '#src/modules/container'; - -type intervalCheckAccessPayload = { +type IntervalCheckAccessPayload = { interval?: number; iterations?: number; offerId?: string; + callback?: (hasAccess: boolean) => void; }; const useCheckAccess = () => { const accountController = getModule(AccountController); + const checkoutController = getModule(CheckoutController); const intervalRef = useRef(); - const navigate = useNavigate(); - const location = useLocation(); const [errorMessage, setErrorMessage] = useState(null); const { t } = useTranslation('user'); - const { offers } = useConfigStore(); + const offers = checkoutController.getSubscriptionOfferIds(); const intervalCheckAccess = useCallback( - ({ interval = 3000, iterations = 5, offerId }: intervalCheckAccessPayload) => { + ({ interval = 3000, iterations = 5, offerId, callback }: IntervalCheckAccessPayload) => { if (!offerId && offers?.[0]) { offerId = offers[0]; } @@ -35,14 +32,15 @@ const useCheckAccess = () => { if (hasAccess) { await accountController.reloadActiveSubscription(); - navigate(addQueryParam(location, 'u', 'welcome')); + callback?.(true); } else if (--iterations === 0) { window.clearInterval(intervalRef.current); setErrorMessage(t('payment.longer_than_usual')); + callback?.(false); } }, interval); }, - [offers, navigate, location, t, accountController], + [offers, t, accountController], ); useEffect(() => { diff --git a/src/hooks/useContentProtection.ts b/packages/hooks-react/src/useContentProtection.ts similarity index 75% rename from src/hooks/useContentProtection.ts rename to packages/hooks-react/src/useContentProtection.ts index e910544a1..8a8e040fb 100644 --- a/src/hooks/useContentProtection.ts +++ b/packages/hooks-react/src/useContentProtection.ts @@ -1,13 +1,13 @@ import { useQuery } from 'react-query'; - -import { useConfigStore } from '#src/stores/ConfigStore'; -import type { GetPlaylistParams } from '#types/playlist'; -import type { GetMediaParams } from '#types/media'; -import AccountController from '#src/stores/AccountController'; -import GenericEntitlementService from '#src/services/genericEntitlement.service'; -import JWPEntitlementService from '#src/services/jwpEntitlement.service'; -import { getModule } from '#src/modules/container'; -import { isTruthyCustomParamValue } from '#src/utils/common'; +import type { GetPlaylistParams } from '@jwp/ott-common/types/playlist'; +import type { GetMediaParams } from '@jwp/ott-common/types/media'; +import type { EntitlementType } from '@jwp/ott-common/types/entitlement'; +import GenericEntitlementService from '@jwp/ott-common/src/services/GenericEntitlementService'; +import JWPEntitlementService from '@jwp/ott-common/src/services/JWPEntitlementService'; +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 { isTruthyCustomParamValue } from '@jwp/ott-common/src/utils/common'; const useContentProtection = ( type: EntitlementType, diff --git a/src/hooks/useCountdown.ts b/packages/hooks-react/src/useCountdown.ts similarity index 100% rename from src/hooks/useCountdown.ts rename to packages/hooks-react/src/useCountdown.ts diff --git a/src/hooks/useDebounce.ts b/packages/hooks-react/src/useDebounce.ts similarity index 76% rename from src/hooks/useDebounce.ts rename to packages/hooks-react/src/useDebounce.ts index 8701dd9d2..24133a863 100644 --- a/src/hooks/useDebounce.ts +++ b/packages/hooks-react/src/useDebounce.ts @@ -1,6 +1,5 @@ -import { MutableRefObject, useEffect, useRef } from 'react'; - -import { debounce } from '#src/utils/common'; +import { type MutableRefObject, useEffect, useRef } from 'react'; +import { debounce } from '@jwp/ott-common/src/utils/common'; const useDebounce = unknown>(callback: T, time: number) => { const fnRef = useRef() as MutableRefObject; diff --git a/src/hooks/useEntitlement.ts b/packages/hooks-react/src/useEntitlement.ts similarity index 75% rename from src/hooks/useEntitlement.ts rename to packages/hooks-react/src/useEntitlement.ts index 2f8decd06..9a5e5f3ed 100644 --- a/src/hooks/useEntitlement.ts +++ b/packages/hooks-react/src/useEntitlement.ts @@ -1,14 +1,13 @@ import { useQueries } from 'react-query'; -import shallow from 'zustand/shallow'; - -import type { MediaOffer } from '#types/media'; -import type { GetEntitlementsResponse } from '#types/checkout'; -import type { PlaylistItem } from '#types/playlist'; -import { isLocked } from '#src/utils/entitlements'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { useAccountStore } from '#src/stores/AccountStore'; -import CheckoutController from '#src/stores/CheckoutController'; -import { getModule } from '#src/modules/container'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import type { GetEntitlementsResponse } from '@jwp/ott-common/types/checkout'; +import type { MediaOffer } from '@jwp/ott-common/types/media'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; +import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import { isLocked } from '@jwp/ott-common/src/utils/entitlements'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; export type UseEntitlementResult = { isEntitled: boolean; @@ -34,7 +33,7 @@ const notifyOnChangeProps = ['data' as const, 'isLoading' as const]; * * */ const useEntitlement: UseEntitlement = (playlistItem) => { - const { accessModel, isSandbox } = useConfigStore(); + const { accessModel } = useConfigStore(); const { user, subscription } = useAccountStore( ({ user, subscription }) => ({ user, @@ -52,7 +51,7 @@ const useEntitlement: UseEntitlement = (playlistItem) => { const mediaEntitlementQueries = useQueries( mediaOffers.map(({ offerId }) => ({ queryKey: ['entitlements', offerId], - queryFn: () => checkoutController?.getEntitlements({ offerId }, isSandbox), + queryFn: () => checkoutController?.getEntitlements({ offerId }), enabled: !!playlistItem && !!user && !!user.id && !!offerId && !isPreEntitled, refetchOnMount: 'always' as const, notifyOnChangeProps, diff --git a/src/hooks/useEventCallback.ts b/packages/hooks-react/src/useEventCallback.ts similarity index 100% rename from src/hooks/useEventCallback.ts rename to packages/hooks-react/src/useEventCallback.ts diff --git a/src/hooks/useFirstRender.ts b/packages/hooks-react/src/useFirstRender.ts similarity index 100% rename from src/hooks/useFirstRender.ts rename to packages/hooks-react/src/useFirstRender.ts diff --git a/src/hooks/useForm.ts b/packages/hooks-react/src/useForm.ts similarity index 95% rename from src/hooks/useForm.ts rename to packages/hooks-react/src/useForm.ts index ff130687b..01ef3e373 100644 --- a/src/hooks/useForm.ts +++ b/packages/hooks-react/src/useForm.ts @@ -1,7 +1,6 @@ import { useCallback, useState } from 'react'; -import { ValidationError, AnySchema } from 'yup'; - -import type { FormErrors, GenericFormValues, UseFormChangeHandler, UseFormBlurHandler, UseFormSubmitHandler } from '#types/form'; +import { type AnySchema, ValidationError } from 'yup'; +import type { FormErrors, GenericFormValues, UseFormBlurHandler, UseFormChangeHandler, UseFormSubmitHandler } from '@jwp/ott-common/types/form'; export type UseFormReturnValue = { values: T; diff --git a/src/hooks/useLiveChannels.test.ts b/packages/hooks-react/src/useLiveChannels.test.ts similarity index 74% rename from src/hooks/useLiveChannels.test.ts rename to packages/hooks-react/src/useLiveChannels.test.ts index 503968a5b..e7faa7fec 100644 --- a/src/hooks/useLiveChannels.test.ts +++ b/packages/hooks-react/src/useLiveChannels.test.ts @@ -1,30 +1,20 @@ import { describe, expect, test } from 'vitest'; import { act, renderHook } from '@testing-library/react'; +import type { Playlist } from '@jwp/ott-common/types/playlist'; +import type { EpgChannel } from '@jwp/ott-common/types/epg'; +import livePlaylistFixture from '@jwp/ott-testing/fixtures/livePlaylist.json'; +import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; +import epgChannelsUpdateFixture from '@jwp/ott-testing/fixtures/epgChannelsUpdate.json'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import EpgController from '@jwp/ott-common/src/stores/EpgController'; -import type { EpgChannel } from '#types/epg'; -import useLiveChannels from '#src/hooks/useLiveChannels'; -import { createWrapper, waitForWithFakeTimers } from '#test/testUtils'; -import type { Playlist } from '#types/playlist'; -import livePlaylistFixture from '#test/fixtures/livePlaylist.json'; -import epgChannelsFixture from '#test/fixtures/epgChannels.json'; -import epgChannelsUpdateFixture from '#test/fixtures/epgChannelsUpdate.json'; -import EpgController from '#src/stores/EpgController'; +import { queryClientWrapper, waitForWithFakeTimers } from './testUtils'; +import useLiveChannels from './useLiveChannels'; const livePlaylist: Playlist = livePlaylistFixture; const schedule: EpgChannel[] = epgChannelsFixture; const scheduleUpdate: EpgChannel[] = epgChannelsUpdateFixture; -const mockSchedule = vi.fn(); - -vi.mock('#src/modules/container', () => ({ - getModule: (type: typeof EpgController) => { - switch (type) { - case EpgController: - return { getSchedules: mockSchedule }; - } - }, -})); - describe('useLiveChannels', () => { beforeEach(() => { vi.useFakeTimers(); @@ -36,10 +26,12 @@ describe('useLiveChannels', () => { }); test('gets the date using the EPG service getSchedules method', async () => { - const mock = mockSchedule.mockResolvedValue(schedule); + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: '' }, }); @@ -50,7 +42,7 @@ describe('useLiveChannels', () => { await waitForWithFakeTimers(); - expect(mock).toHaveBeenCalledWith(livePlaylist.playlist); + expect(getSchedules).toHaveBeenCalledWith(livePlaylist.playlist); // channels are set in state expect(result.current.channels).toEqual(schedule); // first channel selected @@ -58,16 +50,19 @@ describe('useLiveChannels', () => { }); test('selects the initial channel based of the initialChannelId', async () => { - const mock = mockSchedule.mockResolvedValue(schedule); + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: 'channel2' }, }); await waitForWithFakeTimers(); - expect(mock).toHaveBeenCalledOnce(); - expect(mock).toHaveBeenCalledWith(livePlaylist.playlist); + expect(getSchedules).toHaveBeenCalledOnce(); + expect(getSchedules).toHaveBeenCalledWith(livePlaylist.playlist); // second channel selected (initial channel id) expect(result.current.channel).toEqual(schedule[1]); }); @@ -77,9 +72,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T10:45:00Z')); }); - mockSchedule.mockResolvedValue(schedule); + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -94,9 +92,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T09:00:00Z')); }); - mockSchedule.mockResolvedValue(schedule); + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result, rerender } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -118,9 +119,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); }); - mockSchedule.mockResolvedValue(schedule); + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -144,9 +148,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); }); - mockSchedule.mockResolvedValue(schedule); + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -169,9 +176,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); }); - mockSchedule.mockResolvedValue(schedule); + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -201,9 +211,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); }); - mockSchedule.mockResolvedValue(schedule); + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -225,9 +238,12 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); }); - const mock = mockSchedule.mockResolvedValue(schedule); + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -235,9 +251,9 @@ describe('useLiveChannels', () => { await waitForWithFakeTimers(); expect(result.current.program).toMatchObject({ id: 'program1' }); expect(result.current.channel?.programs.length).toBe(2); - expect(mock).toHaveBeenCalledTimes(1); + expect(getSchedules).toHaveBeenCalledTimes(1); - mock.mockResolvedValueOnce(scheduleUpdate); + getSchedules.mockResolvedValueOnce(scheduleUpdate); act(() => { vi.runOnlyPendingTimers(); @@ -245,7 +261,7 @@ describe('useLiveChannels', () => { await waitForWithFakeTimers(); // the endTime for program1 should be changed - expect(mock).toHaveBeenCalledTimes(2); + expect(getSchedules).toHaveBeenCalledTimes(2); expect(result.current.channel?.programs.length).toBe(3); expect(result.current.program).toMatchObject({ id: 'program1', endTime: '2022-07-15T10:45:00Z' }); }); @@ -255,18 +271,22 @@ describe('useLiveChannels', () => { vi.setSystemTime(new Date('2022-07-15T11:05:00Z')); }); - const mock = mockSchedule.mockResolvedValue(schedule); + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); // no program is selected (we have an outdated schedule) await waitForWithFakeTimers(); + expect(result.current.program).toBeUndefined(); - expect(mock).toHaveBeenCalledTimes(1); + expect(getSchedules).toHaveBeenCalledTimes(1); - mock.mockResolvedValue(scheduleUpdate); + getSchedules.mockResolvedValue(scheduleUpdate); act(() => { vi.runOnlyPendingTimers(); @@ -274,7 +294,7 @@ describe('useLiveChannels', () => { await waitForWithFakeTimers(); // the program should be updated to the live program with the updated data - expect(mock).toHaveBeenCalledTimes(2); + expect(getSchedules).toHaveBeenCalledTimes(2); expect(result.current.program).toMatchObject({ id: 'program5' }); }); @@ -284,9 +304,12 @@ describe('useLiveChannels', () => { }); // start with update schedule (which has more programs) - const mock = mockSchedule.mockResolvedValue(scheduleUpdate); + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(scheduleUpdate), + }); + const { result } = renderHook((props) => useLiveChannels(props), { - wrapper: createWrapper(), + wrapper: queryClientWrapper(), initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, }); @@ -295,14 +318,14 @@ describe('useLiveChannels', () => { expect(result.current.program).toMatchObject({ id: 'program5' }); // we use the default schedule data which doesn't have program5 - mock.mockResolvedValue(schedule); + getSchedules.mockResolvedValue(schedule); act(() => { vi.runOnlyPendingTimers(); }); await waitForWithFakeTimers(); // the program should be undefined since it couldn't be found in the latest data - expect(mock).toHaveBeenCalledTimes(2); + expect(getSchedules).toHaveBeenCalledTimes(2); expect(result.current.program).toBeUndefined(); }); }); diff --git a/src/hooks/useLiveChannels.ts b/packages/hooks-react/src/useLiveChannels.ts similarity index 90% rename from src/hooks/useLiveChannels.ts rename to packages/hooks-react/src/useLiveChannels.ts index 2573fb1c7..0268375fc 100644 --- a/src/hooks/useLiveChannels.ts +++ b/packages/hooks-react/src/useLiveChannels.ts @@ -1,12 +1,11 @@ import { useQuery } from 'react-query'; import { useCallback, useEffect, useState } from 'react'; - -import type { PlaylistItem } from '#types/playlist'; -import type { EpgProgram, EpgChannel } from '#types/epg'; -import { getLiveProgram, programIsLive } from '#src/utils/epg'; -import { LIVE_CHANNELS_REFETCH_INTERVAL } from '#src/config'; -import { getModule } from '#src/modules/container'; -import EpgController from '#src/stores/EpgController'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import type { EpgChannel, EpgProgram } from '@jwp/ott-common/types/epg'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { getLiveProgram, programIsLive } from '@jwp/ott-common/src/utils/epg'; +import { LIVE_CHANNELS_REFETCH_INTERVAL } from '@jwp/ott-common/src/constants'; +import EpgController from '@jwp/ott-common/src/stores/EpgController'; /** * This hook fetches the schedules for the given list of playlist items and manages the current channel and program. diff --git a/packages/hooks-react/src/useLiveEvent.ts b/packages/hooks-react/src/useLiveEvent.ts new file mode 100644 index 000000000..eadc11359 --- /dev/null +++ b/packages/hooks-react/src/useLiveEvent.ts @@ -0,0 +1,9 @@ +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { isLiveEvent, isPlayable } from '@jwp/ott-common/src/utils/liveEvent'; + +export function useLiveEvent(media: PlaylistItem) { + return { + isLiveEvent: isLiveEvent(media), + isPlayable: isPlayable(media), + }; +} diff --git a/src/hooks/useLiveProgram.test.ts b/packages/hooks-react/src/useLiveProgram.test.ts similarity index 94% rename from src/hooks/useLiveProgram.test.ts rename to packages/hooks-react/src/useLiveProgram.test.ts index 8ab0d2117..874bc3111 100644 --- a/src/hooks/useLiveProgram.test.ts +++ b/packages/hooks-react/src/useLiveProgram.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest'; import { act, renderHook } from '@testing-library/react'; +import type { EpgChannel } from '@jwp/ott-common/types/epg'; +import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; -import useLiveProgram from '#src/hooks/useLiveProgram'; -import epgChannelsFixture from '#test/fixtures/epgChannels.json'; -import type { EpgChannel } from '#types/epg'; +import useLiveProgram from './useLiveProgram'; const schedule: EpgChannel[] = epgChannelsFixture; diff --git a/src/hooks/useLiveProgram.ts b/packages/hooks-react/src/useLiveProgram.ts similarity index 92% rename from src/hooks/useLiveProgram.ts rename to packages/hooks-react/src/useLiveProgram.ts index aa230c949..d03db24de 100644 --- a/src/hooks/useLiveProgram.ts +++ b/packages/hooks-react/src/useLiveProgram.ts @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; - -import type { EpgProgram } from '#types/epg'; -import { programIsFullyWatchable, programIsLive, programIsVod } from '#src/utils/epg'; +import type { EpgProgram } from '@jwp/ott-common/types/epg'; +import { programIsFullyWatchable, programIsLive, programIsVod } from '@jwp/ott-common/src/utils/epg'; /** * This hook returns memoized program state variables that change based on the given program and the current time. diff --git a/src/hooks/useMedia.ts b/packages/hooks-react/src/useMedia.ts similarity index 63% rename from src/hooks/useMedia.ts rename to packages/hooks-react/src/useMedia.ts index 442458142..a3cd68aa3 100644 --- a/src/hooks/useMedia.ts +++ b/packages/hooks-react/src/useMedia.ts @@ -1,9 +1,8 @@ -import { UseBaseQueryResult, useQuery } from 'react-query'; - -import type { PlaylistItem } from '#types/playlist'; -import { isScheduledOrLiveMedia } from '#src/utils/liveEvent'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; +import { useQuery, type UseBaseQueryResult } from 'react-query'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { isScheduledOrLiveMedia } from '@jwp/ott-common/src/utils/liveEvent'; export type UseMediaResult = UseBaseQueryResult; diff --git a/src/hooks/useOffers.ts b/packages/hooks-react/src/useOffers.ts similarity index 74% rename from src/hooks/useOffers.ts rename to packages/hooks-react/src/useOffers.ts index 8b89902fd..25da82389 100644 --- a/src/hooks/useOffers.ts +++ b/packages/hooks-react/src/useOffers.ts @@ -1,21 +1,21 @@ import { useQuery } from 'react-query'; import { useMemo, useState } from 'react'; -import shallow from 'zustand/shallow'; - -import { useCheckoutStore } from '#src/stores/CheckoutStore'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import type { Offer } from '#types/checkout'; -import type { OfferType } from '#types/account'; -import { isSVODOffer } from '#src/utils/subscription'; -import CheckoutController from '#src/stores/CheckoutController'; -import { ACCESS_MODEL } from '#src/config'; -import { getModule } from '#src/modules/container'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; +import type { OfferType } from '@jwp/ott-common/types/account'; +import type { Offer } 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 { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import { isSVODOffer } from '@jwp/ott-common/src/utils/subscription'; +import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; const useOffers = () => { - const { offers, isSandbox, accessModel } = useConfigStore(); - const checkoutController = getModule(CheckoutController); + const { accessModel } = useConfigStore(); + const offers = checkoutController.getSubscriptionOfferIds(); + const { requestedMediaOffers } = useCheckoutStore(({ requestedMediaOffers }) => ({ requestedMediaOffers }), shallow); const hasTvodOffer = (requestedMediaOffers || []).some((offer) => offer.offerId); const hasPremierOffer = (requestedMediaOffers || []).some((offer) => offer.premier); @@ -26,7 +26,7 @@ const useOffers = () => { return [...(requestedMediaOffers || []).map(({ offerId }) => offerId), ...offers].filter(Boolean); }, [requestedMediaOffers, offers]); - const { data: allOffers, isLoading } = useQuery(['offers', offerIds.join('-')], () => checkoutController.getOffers({ offerIds }, isSandbox)); + const { data: allOffers, isLoading } = useQuery(['offers', offerIds.join('-')], () => checkoutController.getOffers({ offerIds })); // The `offerQueries` variable mutates on each render which prevents the useMemo to work properly. return useMemo(() => { diff --git a/src/hooks/useOpaqueId.ts b/packages/hooks-react/src/useOpaqueId.ts similarity index 69% rename from src/hooks/useOpaqueId.ts rename to packages/hooks-react/src/useOpaqueId.ts index 6ea337b6f..f26ae1995 100644 --- a/src/hooks/useOpaqueId.ts +++ b/packages/hooks-react/src/useOpaqueId.ts @@ -1,11 +1,16 @@ +import { IS_TEST_MODE } from '@jwp/ott-common/src/utils/common'; import { useEffect, useState } from 'react'; const generateId = (prefix?: string, suffix?: string) => { // This test code ensures that ID's in snapshots are always the same. // Ideally it would be mocked in the test setup but there seems to be a bug with vitest if you mock Math.random - const randomId = import.meta.env.MODE === 'test' ? 1235 : Math.random() * 10000; + const randomId = IS_TEST_MODE ? 1235 : Math.random() * 10000; - return [prefix, Math.round(randomId), suffix].filter(Boolean).join('_'); + return [prefix, Math.round(randomId), suffix] + .filter(Boolean) + .join('_') + .replace(/[\s.]+/g, '_') + .toLowerCase(); }; const useOpaqueId = (prefix?: string, suffix?: string, override?: string): string => { diff --git a/src/hooks/useOttAnalytics.ts b/packages/hooks-react/src/useOttAnalytics.ts similarity index 88% rename from src/hooks/useOttAnalytics.ts rename to packages/hooks-react/src/useOttAnalytics.ts index 7aee5fa26..9a867ffda 100644 --- a/src/hooks/useOttAnalytics.ts +++ b/packages/hooks-react/src/useOttAnalytics.ts @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; - -import type { PlaylistItem } from '#types/playlist'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { useAccountStore } from '#src/stores/AccountStore'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; +import env from '@jwp/ott-common/src/env'; const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { const analyticsToken = useConfigStore((s) => s.config.analyticsToken); @@ -14,7 +14,7 @@ const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { // app config id (oiid) const oiid = config?.id; // app version number (av) - const av = import.meta.env.APP_VERSION; + const av = env.APP_VERSION; const [player, setPlayer] = useState(null); diff --git a/src/hooks/usePlanByEpg.test.ts b/packages/hooks-react/src/usePlanByEpg.test.ts similarity index 93% rename from src/hooks/usePlanByEpg.test.ts rename to packages/hooks-react/src/usePlanByEpg.test.ts index 6b15f005c..27393387f 100644 --- a/src/hooks/usePlanByEpg.test.ts +++ b/packages/hooks-react/src/usePlanByEpg.test.ts @@ -1,10 +1,10 @@ import * as planby from 'planby'; import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; +import type { EpgChannel } from '@jwp/ott-common/types/epg'; +import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; -import usePlanByEpg, { makeTheme, formatChannel, formatProgram } from '#src/hooks/usePlanByEpg'; -import epgChannelsFixture from '#test/fixtures/epgChannels.json'; -import type { EpgChannel } from '#types/epg'; +import usePlanByEpg, { formatChannel, formatProgram, makeTheme } from './usePlanByEpg'; const schedule: EpgChannel[] = epgChannelsFixture; diff --git a/src/hooks/usePlanByEpg.ts b/packages/hooks-react/src/usePlanByEpg.ts similarity index 95% rename from src/hooks/usePlanByEpg.ts rename to packages/hooks-react/src/usePlanByEpg.ts index d3ddc81ee..651f5597d 100644 --- a/src/hooks/usePlanByEpg.ts +++ b/packages/hooks-react/src/usePlanByEpg.ts @@ -1,9 +1,8 @@ import { useMemo } from 'react'; import { useEpg } from 'planby'; import { startOfDay, startOfToday, startOfTomorrow } from 'date-fns'; - -import type { EpgChannel, EpgProgram } from '#types/epg'; -import { is12HourClock } from '#src/utils/datetime'; +import type { EpgChannel, EpgProgram } from '@jwp/ott-common/types/epg'; +import { is12HourClock } from '@jwp/ott-common/src/utils/datetime'; const isBaseTimeFormat = is12HourClock(); diff --git a/src/hooks/usePlaylist.ts b/packages/hooks-react/src/usePlaylist.ts similarity index 66% rename from src/hooks/usePlaylist.ts rename to packages/hooks-react/src/usePlaylist.ts index e06b6c06b..728526653 100644 --- a/src/hooks/usePlaylist.ts +++ b/packages/hooks-react/src/usePlaylist.ts @@ -1,18 +1,17 @@ -import { useQuery } from 'react-query'; - -import { generatePlaylistPlaceholder } from '#src/utils/collection'; -import type { GetPlaylistParams, Playlist } from '#types/playlist'; -import { queryClient } from '#src/containers/QueryProvider/QueryProvider'; -import { isScheduledOrLiveMedia } from '#src/utils/liveEvent'; -import { isTruthyCustomParamValue } from '#src/utils/common'; -import type { ApiError } from '#src/utils/api'; -import ApiService from '#src/services/api.service'; -import { getModule } from '#src/modules/container'; +import { useQuery, useQueryClient } from 'react-query'; +import type { GetPlaylistParams, Playlist } from '@jwp/ott-common/types/playlist'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { generatePlaylistPlaceholder } from '@jwp/ott-common/src/utils/collection'; +import { isScheduledOrLiveMedia } from '@jwp/ott-common/src/utils/liveEvent'; +import { isTruthyCustomParamValue } from '@jwp/ott-common/src/utils/common'; +import type { ApiError } from '@jwp/ott-common/src/utils/api'; const placeholderData = generatePlaylistPlaceholder(30); export default function usePlaylist(playlistId?: string, params: GetPlaylistParams = {}, enabled: boolean = true, usePlaceholderData: boolean = true) { const apiService = getModule(ApiService); + const queryClient = useQueryClient(); const callback = async (playlistId?: string, params?: GetPlaylistParams) => { const playlist = await apiService.getPlaylistById(playlistId, { ...params }); diff --git a/src/hooks/usePlaylistItemCallback.ts b/packages/hooks-react/src/usePlaylistItemCallback.ts similarity index 78% rename from src/hooks/usePlaylistItemCallback.ts rename to packages/hooks-react/src/usePlaylistItemCallback.ts index ab65f6b8f..e19bb0028 100644 --- a/src/hooks/usePlaylistItemCallback.ts +++ b/packages/hooks-react/src/usePlaylistItemCallback.ts @@ -1,6 +1,7 @@ -import useEventCallback from '#src/hooks/useEventCallback'; -import type { PlaylistItem } from '#types/playlist'; -import { addQueryParams } from '#src/utils/formatting'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; + +import useEventCallback from './useEventCallback'; export const usePlaylistItemCallback = (startDateTime?: string | null, endDateTime?: string | null) => { const applyLiveStreamOffset = (item: PlaylistItem) => { @@ -15,7 +16,7 @@ export const usePlaylistItemCallback = (startDateTime?: string | null, endDateTi allSources: undefined, // `allSources` need to be cleared otherwise JW Player will use those instead sources: item.sources.map((source) => ({ ...source, - file: addQueryParams(source.file, { + file: createURL(source.file, { t: timeParam, }), })), diff --git a/src/hooks/useProfiles.ts b/packages/hooks-react/src/useProfiles.ts similarity index 65% rename from src/hooks/useProfiles.ts rename to packages/hooks-react/src/useProfiles.ts index 2e7d22a8d..42e9d43ec 100644 --- a/src/hooks/useProfiles.ts +++ b/packages/hooks-react/src/useProfiles.ts @@ -1,21 +1,17 @@ import type { ProfilesData } from '@inplayer-org/inplayer.js'; -import { UseMutationOptions, UseQueryOptions, useMutation, useQuery } from 'react-query'; -import { useNavigate } from 'react-router'; +import { useMutation, useQuery, type UseMutationOptions, type UseQueryOptions } from 'react-query'; import { useTranslation } from 'react-i18next'; - -import { useProfileStore } from '#src/stores/ProfileStore'; -import type { CommonAccountResponse, ListProfilesResponse, ProfileDetailsPayload, ProfilePayload } from '#types/account'; -import { useAccountStore } from '#src/stores/AccountStore'; -import type { GenericFormErrors } from '#types/form'; -import type { ProfileFormSubmitError, ProfileFormValues } from '#src/containers/Profiles/types'; -import { logDev } from '#src/utils/common'; -import ProfileController from '#src/stores/ProfileController'; -import AccountController from '#src/stores/AccountController'; -import { getModule } from '#src/modules/container'; - -export const useSelectProfile = () => { - const navigate = useNavigate(); - +import type { GenericFormErrors } from '@jwp/ott-common/types/form'; +import type { CommonAccountResponse, ListProfilesResponse, ProfileDetailsPayload, ProfilePayload } from '@jwp/ott-common/types/account'; +import type { ProfileFormSubmitError, ProfileFormValues } from '@jwp/ott-common/types/profiles'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; +import ProfileController from '@jwp/ott-common/src/stores/ProfileController'; +import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import { logDev } from '@jwp/ott-common/src/utils/common'; + +export const useSelectProfile = (options?: { onSuccess: () => void; onError: () => void }) => { const accountController = getModule(AccountController, false); const profileController = getModule(ProfileController, false); @@ -25,71 +21,68 @@ export const useSelectProfile = () => { }, onSuccess: async () => { useProfileStore.setState({ selectingProfileAvatar: null }); - navigate('/'); await accountController?.loadUserData(); + options?.onSuccess?.(); }, onError: () => { useProfileStore.setState({ selectingProfileAvatar: null }); - navigate('/u/profiles'); logDev('Unable to enter profile'); + options?.onError?.(); }, }); }; export const useCreateProfile = (options?: UseMutationOptions | undefined, unknown, ProfilePayload, unknown>) => { const { query: listProfiles } = useProfiles(); - const navigate = useNavigate(); const profileController = getModule(ProfileController, false); return useMutation | undefined, unknown, ProfilePayload, unknown>(async (data) => profileController?.createProfile(data), { - onSuccess: (res) => { - const profile = res?.responseData; - if (profile?.id) { - listProfiles.refetch(); - navigate(`/u/profiles?success=true&id=${profile.id}`); - } - }, ...options, + onSuccess: (data, variables, context) => { + listProfiles.refetch(); + + options?.onSuccess?.(data, variables, context); + }, }); }; export const useUpdateProfile = (options?: UseMutationOptions | undefined, unknown, ProfilePayload, unknown>) => { const { query: listProfiles } = useProfiles(); - const navigate = useNavigate(); const profileController = getModule(ProfileController, false); return useMutation(async (data) => profileController?.updateProfile(data), { - onSuccess: () => { - navigate('/u/profiles'); - }, - onSettled: () => { + ...options, + onSettled: (...args) => { listProfiles.refetch(); + + options?.onSettled?.(...args); }, - ...options, }); }; export const useDeleteProfile = (options?: UseMutationOptions | undefined, unknown, ProfileDetailsPayload, unknown>) => { const { query: listProfiles } = useProfiles(); - const navigate = useNavigate(); const profileController = getModule(ProfileController, false); return useMutation | undefined, unknown, ProfileDetailsPayload, unknown>( async (id) => profileController?.deleteProfile(id), { - onSuccess: () => { + ...options, + onSuccess: (...args) => { listProfiles.refetch(); - navigate('/u/profiles'); + + options?.onSuccess?.(...args); }, - ...options, }, ); }; -export const isProfileFormSubmitError = (e: unknown): e is ProfileFormSubmitError => !!e && typeof e === 'object' && 'message' in e; +export const isProfileFormSubmitError = (e: unknown): e is ProfileFormSubmitError => { + return !!e && typeof e === 'object' && 'message' in e; +}; export const useProfileErrorHandler = () => { const { t } = useTranslation('user'); @@ -107,18 +100,19 @@ export const useProfiles = ( options?: UseQueryOptions | undefined, unknown, ServiceResponse | undefined, string[]>, ) => { const { user } = useAccountStore(); - const { canManageProfiles } = useProfileStore(); const isLoggedIn = !!user; - const profileController = getModule(ProfileController, false); - const accountController = getModule(AccountController, false); + const profileController = getModule(ProfileController); - const { hasProfiles } = accountController?.getFeatures() || {}; + const profilesEnabled = profileController.isEnabled(); - const query = useQuery(['listProfiles'], () => profileController?.listProfiles(), { ...options, enabled: isLoggedIn }); + const query = useQuery(['listProfiles'], () => profileController.listProfiles(), { + ...options, + enabled: isLoggedIn && profilesEnabled, + }); return { query, - profilesEnabled: !!(query.data?.responseData.canManageProfiles && hasProfiles && canManageProfiles), + profilesEnabled: !!query.data?.responseData.canManageProfiles, }; }; diff --git a/packages/hooks-react/src/useProtectedMedia.ts b/packages/hooks-react/src/useProtectedMedia.ts new file mode 100644 index 000000000..ed6ef5535 --- /dev/null +++ b/packages/hooks-react/src/useProtectedMedia.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { getModule } from '@jwp/ott-common/src/modules/container'; + +import useContentProtection from './useContentProtection'; + +export default function useProtectedMedia(item: PlaylistItem) { + const apiService = getModule(ApiService); + + const [isGeoBlocked, setIsGeoBlocked] = useState(false); + const contentProtectionQuery = useContentProtection('media', item.mediaid, (token, drmPolicyId) => apiService.getMediaById(item.mediaid, token, drmPolicyId)); + + useEffect(() => { + const m3u8 = contentProtectionQuery.data?.sources.find((source) => source.file.indexOf('.m3u8') !== -1); + if (m3u8) { + fetch(m3u8.file, { method: 'HEAD' }).then((response) => { + response.status === 403 && setIsGeoBlocked(true); + }); + } + }, [contentProtectionQuery.data]); + + return { + ...contentProtectionQuery, + isGeoBlocked, + }; +} diff --git a/packages/hooks-react/src/useSocialLoginUrls.ts b/packages/hooks-react/src/useSocialLoginUrls.ts new file mode 100644 index 000000000..1843415d7 --- /dev/null +++ b/packages/hooks-react/src/useSocialLoginUrls.ts @@ -0,0 +1,20 @@ +import { useQuery } from 'react-query'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import AccountController from '@jwp/ott-common/src/stores/AccountController'; + +export type SocialLoginURLs = Record; + +export default function useSocialLoginUrls(url: string) { + const accountController = getModule(AccountController); + + const urls = useQuery(['socialUrls'], () => accountController.getSocialLoginUrls(url), { + enabled: accountController.getFeatures().hasSocialURLs, + retry: false, + }); + + if (urls.error || !urls.data) { + return null; + } + + return urls.data.reduce((acc, url) => ({ ...acc, ...url }), {} as SocialLoginURLs); +} diff --git a/src/hooks/useSubscriptionChange.ts b/packages/hooks-react/src/useSubscriptionChange.ts similarity index 72% rename from src/hooks/useSubscriptionChange.ts rename to packages/hooks-react/src/useSubscriptionChange.ts index 521dac283..e648d2315 100644 --- a/src/hooks/useSubscriptionChange.ts +++ b/packages/hooks-react/src/useSubscriptionChange.ts @@ -1,10 +1,9 @@ import { useMutation } from 'react-query'; - -import { useAccountStore } from '#src/stores/AccountStore'; -import type { Customer } from '#types/account'; -import AccountController from '#src/stores/AccountController'; -import CheckoutController from '#src/stores/CheckoutController'; -import { getModule } from '#src/modules/container'; +import type { Customer } 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 CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; export const useSubscriptionChange = ( isUpgradeOffer: boolean, diff --git a/src/hooks/useToggle.ts b/packages/hooks-react/src/useToggle.ts similarity index 100% rename from src/hooks/useToggle.ts rename to packages/hooks-react/src/useToggle.ts diff --git a/src/hooks/useWatchHistory.ts b/packages/hooks-react/src/useWatchHistory.ts similarity index 70% rename from src/hooks/useWatchHistory.ts rename to packages/hooks-react/src/useWatchHistory.ts index 8a7d8d2cf..b416817f0 100644 --- a/src/hooks/useWatchHistory.ts +++ b/packages/hooks-react/src/useWatchHistory.ts @@ -1,11 +1,11 @@ import { useMemo } from 'react'; +import type { JWPlayer } from '@jwp/ott-common/types/jwplayer'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { useWatchHistoryStore } from '@jwp/ott-common/src/stores/WatchHistoryStore'; +import { VideoProgressMinMax } from '@jwp/ott-common/src/constants'; -import type { PlaylistItem } from '#types/playlist'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore'; -import { VideoProgressMinMax } from '#src/config'; -import { useWatchHistoryListener } from '#src/hooks/useWatchHistoryListener'; -import type { JWPlayer } from '#types/jwplayer'; +import { useWatchHistoryListener } from './useWatchHistoryListener'; export const useWatchHistory = (player: JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { // config diff --git a/src/hooks/useWatchHistoryListener.ts b/packages/hooks-react/src/useWatchHistoryListener.ts similarity index 90% rename from src/hooks/useWatchHistoryListener.ts rename to packages/hooks-react/src/useWatchHistoryListener.ts index 7c72dad59..2549724ed 100644 --- a/src/hooks/useWatchHistoryListener.ts +++ b/packages/hooks-react/src/useWatchHistoryListener.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import type { JWPlayer } from '@jwp/ott-common/types/jwplayer'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import WatchHistoryController from '@jwp/ott-common/src/stores/WatchHistoryController'; -import type { JWPlayer } from '#types/jwplayer'; -import type { PlaylistItem } from '#types/playlist'; -import useEventCallback from '#src/hooks/useEventCallback'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import WatchHistoryController from '#src/stores/WatchHistoryController'; -import { getModule } from '#src/modules/container'; +import useEventCallback from './useEventCallback'; type QueuedProgress = { item: PlaylistItem; diff --git a/packages/hooks-react/tsconfig.json b/packages/hooks-react/tsconfig.json new file mode 100644 index 000000000..1c7abf924 --- /dev/null +++ b/packages/hooks-react/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src", + "vitest.setup.ts", + "vitest.config.ts", + "../common/types" + ], + "compilerOptions": { + "noEmit": true, + "types": [ + "vi-fetch/matchers", + "vitest/globals" + ] + } +} diff --git a/packages/hooks-react/vitest.config.ts b/packages/hooks-react/vitest.config.ts new file mode 100644 index 000000000..938e56753 --- /dev/null +++ b/packages/hooks-react/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['vitest.setup.ts'], + css: true, + }, + define: { + __mode__: '"test"', + __dev__: true, + }, +}); diff --git a/packages/hooks-react/vitest.setup.ts b/packages/hooks-react/vitest.setup.ts new file mode 100644 index 000000000..1be0f33d1 --- /dev/null +++ b/packages/hooks-react/vitest.setup.ts @@ -0,0 +1,2 @@ +import 'vi-fetch/setup'; +import 'reflect-metadata'; diff --git a/packages/i18n/.depcheckrc.yaml b/packages/i18n/.depcheckrc.yaml new file mode 100644 index 000000000..8bb6edae4 --- /dev/null +++ b/packages/i18n/.depcheckrc.yaml @@ -0,0 +1 @@ +ignores: [] diff --git a/packages/i18n/README.md b/packages/i18n/README.md new file mode 100644 index 000000000..44bd25a3b --- /dev/null +++ b/packages/i18n/README.md @@ -0,0 +1,19 @@ +# OTT I18n + +This package is currently unused. We have plans to move all translations files from the `../../web/public/locales` +folder here to make it easier to re-use the translations. + +The challenge here is that the web platform loads the translations via i18next-http-backend. We need to find a way to +sync the translations files without losing too much functionality. + +## RFC + +- Move translations files to this package +- Move resources (list of namespaces) file to this package +- No Typescript/Vite/React/i18next dependencies +- Scan platforms and packages for translation keys using i18next-parser + +### Impediments + +- Loading translations using i18next-http-backend +- Combining translations keys can create conflicts between platforms diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 000000000..db723bed2 --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,9 @@ +{ + "name": "@jwp/ott-i18n", + "version": "4.30.0", + "private": true, + "scripts": { + "lint:ts": "exit 0", + "test": "exit 0" + } +} diff --git a/test/constants.ts b/packages/testing/constants.ts similarity index 89% rename from test/constants.ts rename to packages/testing/constants.ts index f47389388..bee692b5f 100644 --- a/test/constants.ts +++ b/packages/testing/constants.ts @@ -1,4 +1,7 @@ -import type { TestConfig } from './types'; +export interface TestConfig { + id: string; + label: string; +} export const testConfigs = { jwpAuth: { @@ -45,5 +48,3 @@ export const jwDevEnvConfigs = { label: 'JW-Dev Basic Demo', } as TestConfig, }; - -export const overrideIPCookieKey = 'overrideIP'; diff --git a/test/epg/channel1.json b/packages/testing/epg/channel1.json similarity index 100% rename from test/epg/channel1.json rename to packages/testing/epg/channel1.json diff --git a/test/epg/channel2.json b/packages/testing/epg/channel2.json similarity index 100% rename from test/epg/channel2.json rename to packages/testing/epg/channel2.json diff --git a/test/epg/channel4.json b/packages/testing/epg/channel4.json similarity index 100% rename from test/epg/channel4.json rename to packages/testing/epg/channel4.json diff --git a/test/epg/jwChannel.json b/packages/testing/epg/jwChannel.json similarity index 100% rename from test/epg/jwChannel.json rename to packages/testing/epg/jwChannel.json diff --git a/test/epg/viewNexaChannel.xml b/packages/testing/epg/viewNexaChannel.xml similarity index 100% rename from test/epg/viewNexaChannel.xml rename to packages/testing/epg/viewNexaChannel.xml diff --git a/test/fixtures/config.json b/packages/testing/fixtures/config.json similarity index 100% rename from test/fixtures/config.json rename to packages/testing/fixtures/config.json diff --git a/test/fixtures/customer.json b/packages/testing/fixtures/customer.json similarity index 100% rename from test/fixtures/customer.json rename to packages/testing/fixtures/customer.json diff --git a/test/fixtures/epgChannels.json b/packages/testing/fixtures/epgChannels.json similarity index 100% rename from test/fixtures/epgChannels.json rename to packages/testing/fixtures/epgChannels.json diff --git a/test/fixtures/epgChannelsUpdate.json b/packages/testing/fixtures/epgChannelsUpdate.json similarity index 100% rename from test/fixtures/epgChannelsUpdate.json rename to packages/testing/fixtures/epgChannelsUpdate.json diff --git a/test/fixtures/livePlaylist.json b/packages/testing/fixtures/livePlaylist.json similarity index 100% rename from test/fixtures/livePlaylist.json rename to packages/testing/fixtures/livePlaylist.json diff --git a/test/fixtures/monthlyOffer.json b/packages/testing/fixtures/monthlyOffer.json similarity index 100% rename from test/fixtures/monthlyOffer.json rename to packages/testing/fixtures/monthlyOffer.json diff --git a/test/fixtures/order.json b/packages/testing/fixtures/order.json similarity index 100% rename from test/fixtures/order.json rename to packages/testing/fixtures/order.json diff --git a/test/fixtures/paymentDetail.json b/packages/testing/fixtures/paymentDetail.json similarity index 100% rename from test/fixtures/paymentDetail.json rename to packages/testing/fixtures/paymentDetail.json diff --git a/test/fixtures/playlist.json b/packages/testing/fixtures/playlist.json similarity index 100% rename from test/fixtures/playlist.json rename to packages/testing/fixtures/playlist.json diff --git a/test/fixtures/schedule.json b/packages/testing/fixtures/schedule.json similarity index 100% rename from test/fixtures/schedule.json rename to packages/testing/fixtures/schedule.json diff --git a/test/fixtures/subscription.json b/packages/testing/fixtures/subscription.json similarity index 100% rename from test/fixtures/subscription.json rename to packages/testing/fixtures/subscription.json diff --git a/test/fixtures/transactions.json b/packages/testing/fixtures/transactions.json similarity index 100% rename from test/fixtures/transactions.json rename to packages/testing/fixtures/transactions.json diff --git a/test/fixtures/tvodOffer.json b/packages/testing/fixtures/tvodOffer.json similarity index 100% rename from test/fixtures/tvodOffer.json rename to packages/testing/fixtures/tvodOffer.json diff --git a/test/fixtures/yearlyOffer.json b/packages/testing/fixtures/yearlyOffer.json similarity index 100% rename from test/fixtures/yearlyOffer.json rename to packages/testing/fixtures/yearlyOffer.json diff --git a/packages/testing/lint-staged.config.js b/packages/testing/lint-staged.config.js new file mode 100644 index 000000000..e4e6b6a16 --- /dev/null +++ b/packages/testing/lint-staged.config.js @@ -0,0 +1,4 @@ +module.exports = { + '*.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'], + '*.{ts,tsx}': [() => 'tsc --pretty --noEmit'], +}; diff --git a/packages/testing/package.json b/packages/testing/package.json new file mode 100644 index 000000000..7ff6e0a0f --- /dev/null +++ b/packages/testing/package.json @@ -0,0 +1,10 @@ +{ + "name": "@jwp/ott-testing", + "version": "1.0.0", + "private": true, + "author": "JW Player", + "scripts": { + "lint:ts": "tsc --pretty --noEmit -p ./", + "test": "exit 0" + } +} diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json new file mode 100644 index 000000000..cf0d4ad52 --- /dev/null +++ b/packages/testing/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "**/*" + ] +} diff --git a/packages/theme/.depcheckrc.yaml b/packages/theme/.depcheckrc.yaml new file mode 100644 index 000000000..8bb6edae4 --- /dev/null +++ b/packages/theme/.depcheckrc.yaml @@ -0,0 +1 @@ +ignores: [] diff --git a/packages/theme/assets/icons/account_circle.svg b/packages/theme/assets/icons/account_circle.svg new file mode 100644 index 000000000..3fdceac7e --- /dev/null +++ b/packages/theme/assets/icons/account_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/arrow_left.svg b/packages/theme/assets/icons/arrow_left.svg new file mode 100644 index 000000000..2091c7982 --- /dev/null +++ b/packages/theme/assets/icons/arrow_left.svg @@ -0,0 +1 @@ + diff --git a/packages/theme/assets/icons/balance_wallet.svg b/packages/theme/assets/icons/balance_wallet.svg new file mode 100644 index 000000000..bd9d1d0e5 --- /dev/null +++ b/packages/theme/assets/icons/balance_wallet.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/Cancel.tsx b/packages/theme/assets/icons/cancel.svg similarity index 59% rename from src/icons/Cancel.tsx rename to packages/theme/assets/icons/cancel.svg index 7b3b9bc7d..da0aad0f0 100644 --- a/src/icons/Cancel.tsx +++ b/packages/theme/assets/icons/cancel.svg @@ -1,10 +1,3 @@ -import React from 'react'; - -import createIcon from './Icon'; - -export default createIcon( - '0 0 18 18', - + - , -); + diff --git a/packages/theme/assets/icons/check.svg b/packages/theme/assets/icons/check.svg new file mode 100644 index 000000000..41ab31c03 --- /dev/null +++ b/packages/theme/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/check_circle.svg b/packages/theme/assets/icons/check_circle.svg new file mode 100644 index 000000000..1e1fadc50 --- /dev/null +++ b/packages/theme/assets/icons/check_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/chevron_left.svg b/packages/theme/assets/icons/chevron_left.svg new file mode 100644 index 000000000..c0d1d8de1 --- /dev/null +++ b/packages/theme/assets/icons/chevron_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/chevron_right.svg b/packages/theme/assets/icons/chevron_right.svg new file mode 100644 index 000000000..622261dd4 --- /dev/null +++ b/packages/theme/assets/icons/chevron_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/close.svg b/packages/theme/assets/icons/close.svg new file mode 100644 index 000000000..4810bc636 --- /dev/null +++ b/packages/theme/assets/icons/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/theme/assets/icons/creditcard.svg b/packages/theme/assets/icons/creditcard.svg new file mode 100644 index 000000000..46fe0a43d --- /dev/null +++ b/packages/theme/assets/icons/creditcard.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/edit.svg b/packages/theme/assets/icons/edit.svg new file mode 100644 index 000000000..03cb69576 --- /dev/null +++ b/packages/theme/assets/icons/edit.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/theme/assets/icons/exit.svg b/packages/theme/assets/icons/exit.svg new file mode 100644 index 000000000..368bb5103 --- /dev/null +++ b/packages/theme/assets/icons/exit.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/external_link.svg b/packages/theme/assets/icons/external_link.svg new file mode 100644 index 000000000..476985571 --- /dev/null +++ b/packages/theme/assets/icons/external_link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/facebook.svg b/packages/theme/assets/icons/facebook.svg similarity index 100% rename from src/assets/icons/facebook.svg rename to packages/theme/assets/icons/facebook.svg diff --git a/packages/theme/assets/icons/favorite.svg b/packages/theme/assets/icons/favorite.svg new file mode 100644 index 000000000..937a70f16 --- /dev/null +++ b/packages/theme/assets/icons/favorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/favorite_border.svg b/packages/theme/assets/icons/favorite_border.svg new file mode 100644 index 000000000..850a75057 --- /dev/null +++ b/packages/theme/assets/icons/favorite_border.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/google.svg b/packages/theme/assets/icons/google.svg similarity index 100% rename from src/assets/icons/google.svg rename to packages/theme/assets/icons/google.svg diff --git a/src/icons/Language.tsx b/packages/theme/assets/icons/language.svg similarity index 88% rename from src/icons/Language.tsx rename to packages/theme/assets/icons/language.svg index bd3d76f72..30e16ced5 100644 --- a/src/icons/Language.tsx +++ b/packages/theme/assets/icons/language.svg @@ -1,11 +1,6 @@ -import React from 'react'; - -import createIcon from './Icon'; - -export default createIcon( - '0 0 24 24', + , -); + /> + diff --git a/src/icons/Lock.tsx b/packages/theme/assets/icons/lock.svg similarity index 70% rename from src/icons/Lock.tsx rename to packages/theme/assets/icons/lock.svg index 59f29324a..ef141de5b 100644 --- a/src/icons/Lock.tsx +++ b/packages/theme/assets/icons/lock.svg @@ -1,11 +1,6 @@ -import React from 'react'; - -import createIcon from './Icon'; - -export default createIcon( - '0 0 24 24', + , -); + /> + diff --git a/packages/theme/assets/icons/menu.svg b/packages/theme/assets/icons/menu.svg new file mode 100644 index 000000000..73f5d6bf6 --- /dev/null +++ b/packages/theme/assets/icons/menu.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/theme/assets/icons/paypal.svg b/packages/theme/assets/icons/paypal.svg new file mode 100644 index 000000000..5792576b5 --- /dev/null +++ b/packages/theme/assets/icons/paypal.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/play.svg b/packages/theme/assets/icons/play.svg new file mode 100644 index 000000000..087b606f5 --- /dev/null +++ b/packages/theme/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/play_trailer.svg b/packages/theme/assets/icons/play_trailer.svg new file mode 100644 index 000000000..30c40ead9 --- /dev/null +++ b/packages/theme/assets/icons/play_trailer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/Plus.tsx b/packages/theme/assets/icons/plus.svg similarity index 80% rename from src/icons/Plus.tsx rename to packages/theme/assets/icons/plus.svg index 2ed993b5a..6c0815ef9 100644 --- a/src/icons/Plus.tsx +++ b/packages/theme/assets/icons/plus.svg @@ -1,13 +1,6 @@ -import React from 'react'; - -import createIcon from './Icon'; - -export default createIcon( - '0 0 9.33 9.33', - + - , -); + diff --git a/packages/theme/assets/icons/search.svg b/packages/theme/assets/icons/search.svg new file mode 100644 index 000000000..8d90ec21d --- /dev/null +++ b/packages/theme/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/share.svg b/packages/theme/assets/icons/share.svg new file mode 100644 index 000000000..c2af5b5d3 --- /dev/null +++ b/packages/theme/assets/icons/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/today.svg b/packages/theme/assets/icons/today.svg new file mode 100644 index 000000000..6792e95f7 --- /dev/null +++ b/packages/theme/assets/icons/today.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/twitter.svg b/packages/theme/assets/icons/twitter.svg similarity index 100% rename from src/assets/icons/twitter.svg rename to packages/theme/assets/icons/twitter.svg diff --git a/packages/theme/assets/icons/visibility.svg b/packages/theme/assets/icons/visibility.svg new file mode 100644 index 000000000..72c3d1d5e --- /dev/null +++ b/packages/theme/assets/icons/visibility.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/visibility_off.svg b/packages/theme/assets/icons/visibility_off.svg new file mode 100644 index 000000000..ff74f2333 --- /dev/null +++ b/packages/theme/assets/icons/visibility_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/profiles/default_avatar.png b/packages/theme/assets/profiles/default_avatar.png similarity index 100% rename from src/assets/profiles/default_avatar.png rename to packages/theme/assets/profiles/default_avatar.png diff --git a/packages/theme/package.json b/packages/theme/package.json new file mode 100644 index 000000000..fc09e6c91 --- /dev/null +++ b/packages/theme/package.json @@ -0,0 +1,9 @@ +{ + "name": "@jwp/ott-theme", + "version": "4.30.0", + "private": true, + "scripts": { + "lint:ts": "exit 0", + "test": "exit 0" + } +} diff --git a/packages/ui-react/.depcheckrc.yaml b/packages/ui-react/.depcheckrc.yaml new file mode 100644 index 000000000..7e92b5b8a --- /dev/null +++ b/packages/ui-react/.depcheckrc.yaml @@ -0,0 +1,6 @@ +ignores: [ + '@jwp/ott-ui-react', + 'eslint-plugin-react', + 'eslint-plugin-react-hooks', + 'typescript-plugin-css-modules', +] diff --git a/packages/ui-react/.eslintrc.js b/packages/ui-react/.eslintrc.js new file mode 100644 index 000000000..641a9bf64 --- /dev/null +++ b/packages/ui-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['jwp/react'], +}; diff --git a/packages/ui-react/lint-staged.config.js b/packages/ui-react/lint-staged.config.js new file mode 100644 index 000000000..ee3d6af3c --- /dev/null +++ b/packages/ui-react/lint-staged.config.js @@ -0,0 +1,5 @@ +module.exports = { + '*.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'], + '*.scss': ['stylelint --fix'], + '*.{ts,tsx}': [() => 'tsc --pretty --noEmit'], +}; diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json new file mode 100644 index 000000000..47517e1f8 --- /dev/null +++ b/packages/ui-react/package.json @@ -0,0 +1,56 @@ +{ + "name": "@jwp/ott-ui-react", + "version": "4.30.0", + "main": "./src", + "private": true, + "scripts": { + "lint:ts": "tsc --pretty --noEmit -p ./", + "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest", + "test-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update" + }, + "dependencies": { + "@adyen/adyen-web": "^5.42.1", + "@inplayer-org/inplayer.js": "^3.13.24", + "classnames": "^2.3.1", + "date-fns": "^2.28.0", + "dompurify": "^2.3.8", + "i18next": "^22.4.15", + "marked": "^4.1.1", + "payment": "^2.4.6", + "planby": "^0.3.0", + "react": "^18.2.0", + "react-app-polyfill": "^3.0.0", + "react-dom": "^18.2.0", + "react-helmet": "^6.1.0", + "react-i18next": "^12.3.1", + "react-infinite-scroller": "^1.2.6", + "react-query": "^3.39.0", + "react-router": "6.14.2", + "react-router-dom": "6.14.2", + "reflect-metadata": "^0.1.13", + "yup": "^0.32.9" + }, + "devDependencies": { + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", + "@types/dompurify": "^2.3.4", + "@types/marked": "^4.0.7", + "@types/payment": "^2.1.4", + "@types/react-infinite-scroller": "^1.2.3", + "@vitejs/plugin-react": "^4.0.4", + "sass": "^1.49.10", + "typescript-plugin-css-modules": "^5.0.2", + "vi-fetch": "^0.8.0", + "vite-plugin-svgr": "4.2.0", + "vitest": "^0.34.6" + }, + "peerDependencies": { + "@jwp/ott-common": "*", + "@jwp/ott-hooks-react": "*", + "@jwp/ott-testing": "*", + "@jwp/ott-theme": "*", + "eslint-config-jwp": "*", + "postcss-config-jwp": "*" + } +} diff --git a/packages/ui-react/postcss.config.js b/packages/ui-react/postcss.config.js new file mode 100644 index 000000000..c916cfb35 --- /dev/null +++ b/packages/ui-react/postcss.config.js @@ -0,0 +1 @@ +module.exports = require('postcss-config-jwp'); diff --git a/src/components/Account/Account.module.scss b/packages/ui-react/src/components/Account/Account.module.scss similarity index 74% rename from src/components/Account/Account.module.scss rename to packages/ui-react/src/components/Account/Account.module.scss index 68e25c900..0be33221b 100644 --- a/src/components/Account/Account.module.scss +++ b/packages/ui-react/src/components/Account/Account.module.scss @@ -1,3 +1,5 @@ +@use '@jwp/ott-ui-react/src/styles/accessibility'; + .textWithButtonContainer { display: flex; flex-direction: column; @@ -8,4 +10,4 @@ display: flex; flex-direction: column; gap: 0.5em; -} \ No newline at end of file +} diff --git a/packages/ui-react/src/components/Account/Account.test.tsx b/packages/ui-react/src/components/Account/Account.test.tsx new file mode 100644 index 000000000..54e257d3a --- /dev/null +++ b/packages/ui-react/src/components/Account/Account.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type { Consent } from '@jwp/ott-common/types/account'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; +import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import customer from '@jwp/ott-testing/fixtures/customer.json'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; + +import { renderWithRouter } from '../../../test/utils'; + +import Account from './Account'; + +describe('', () => { + beforeEach(() => { + // TODO: remove AccountController from component + mockService(AccountController, { getFeatures: () => DEFAULT_FEATURES }); + }); + + test('renders and matches snapshot', () => { + useAccountStore.setState({ + user: customer, + publisherConsents: Array.of({ name: 'marketing', label: 'Receive Marketing Emails' } as Consent), + }); + + const { container } = renderWithRouter(); + + // todo + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/Account/Account.tsx b/packages/ui-react/src/components/Account/Account.tsx similarity index 88% rename from src/components/Account/Account.tsx rename to packages/ui-react/src/components/Account/Account.tsx index 32af8496b..064bb1c71 100644 --- a/src/components/Account/Account.tsx +++ b/packages/ui-react/src/components/Account/Account.tsx @@ -1,31 +1,32 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; -import shallow from 'zustand/shallow'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; import DOMPurify from 'dompurify'; import { useMutation } from 'react-query'; +import type { Consent } 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 { isTruthy, isTruthyCustomParamValue, logDev, testId } from '@jwp/ott-common/src/utils/common'; +import { formatConsents, formatConsentsFromValues, formatConsentsToRegisterFields, formatConsentValues } from '@jwp/ott-common/src/utils/collection'; +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 styles from './Account.module.scss'; +import type { FormSectionContentArgs, FormSectionProps } from '../Form/FormSection'; +import Alert from '../Alert/Alert'; +import Button from '../Button/Button'; +import Form from '../Form/Form'; +import IconButton from '../IconButton/IconButton'; +import TextField from '../TextField/TextField'; +import Checkbox from '../Checkbox/Checkbox'; +import HelperText from '../HelperText/HelperText'; +import CustomRegisterField from '../CustomRegisterField/CustomRegisterField'; +import Icon from '../Icon/Icon'; +import { modalURLFromLocation } from '../../utils/location'; -import { isTruthyCustomParamValue, isTruthy, logDev, testId } from '#src/utils/common'; -import type { FormSectionContentArgs, FormSectionProps } from '#components/Form/FormSection'; -import type { Consent } from '#types/account'; -import Alert from '#components/Alert/Alert'; -import Visibility from '#src/icons/Visibility'; -import VisibilityOff from '#src/icons/VisibilityOff'; -import Button from '#components/Button/Button'; -import Form from '#components/Form/Form'; -import IconButton from '#components/IconButton/IconButton'; -import TextField from '#components/TextField/TextField'; -import Checkbox from '#components/Checkbox/Checkbox'; -import HelperText from '#components/HelperText/HelperText'; -import CustomRegisterField from '#components/CustomRegisterField/CustomRegisterField'; -import useToggle from '#src/hooks/useToggle'; -import { formatConsentsFromValues, formatConsents, formatConsentValues, formatConsentsToRegisterFields } from '#src/utils/collection'; -import { addQueryParam } from '#src/utils/location'; -import { useAccountStore } from '#src/stores/AccountStore'; -import AccountController from '#src/stores/AccountController'; -import { getModule } from '#src/modules/container'; +import styles from './Account.module.scss'; type Props = { panelClassName?: string; @@ -185,10 +186,10 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } } if (isSocialLogin && shouldAddPassword) { await accountController.resetPassword(customer.email, ''); - return navigate(addQueryParam(location, 'u', 'add-password')); + return navigate(modalURLFromLocation(location, 'add-password')); } - const modal = canChangePasswordWithOldPassword ? 'edit-password' : 'reset-password'; - navigate(addQueryParam(location, 'u', modal)); + + navigate(modalURLFromLocation(location, canChangePasswordWithOldPassword ? 'edit-password' : 'reset-password')); }; return ( @@ -213,6 +214,7 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } }, content: (section) => ( <> +

{t('nav.account')}

toggleViewPassword()}> - {viewPassword ? : } + } required @@ -366,7 +368,7 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } type="button" variant="danger" onClick={() => { - navigate(addQueryParam(location, 'u', shouldAddPassword ? 'warning-account-deletion' : 'delete-account')); + navigate(modalURLFromLocation(location, shouldAddPassword ? 'warning-account-deletion' : 'delete-account')); }} /> diff --git a/src/components/Account/__snapshots__/Account.test.tsx.snap b/packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap similarity index 76% rename from src/components/Account/__snapshots__/Account.test.tsx.snap rename to packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap index 2a20a13d7..175e6fe0c 100644 --- a/src/components/Account/__snapshots__/Account.test.tsx.snap +++ b/packages/ui-react/src/components/Account/__snapshots__/Account.test.tsx.snap @@ -2,26 +2,34 @@ exports[` > renders and matches snapshot 1`] = `
-
-

+

account.about_you -

+
+

+ nav.account +

@@ -34,7 +42,7 @@ exports[` > renders and matches snapshot 1`] = ` > @@ -55,16 +63,19 @@ exports[` > renders and matches snapshot 1`] = `
-
-
+
-

+

account.email -

+
> renders and matches snapshot 1`] = `
-
-
+
-

+

account.security -

+
> renders and matches snapshot 1`] = `
- -
+
-

+

account.terms_and_tracking -

+
> renders and matches snapshot 1`] = ` class="_row_531f07" > @@ -161,6 +178,6 @@ exports[` > renders and matches snapshot 1`] = `
-
+
`; diff --git a/packages/ui-react/src/components/Adyen/Adyen.module.scss b/packages/ui-react/src/components/Adyen/Adyen.module.scss new file mode 100644 index 000000000..e6407fbd7 --- /dev/null +++ b/packages/ui-react/src/components/Adyen/Adyen.module.scss @@ -0,0 +1,10 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; + +.adyen { + margin-bottom: 24px; +} + +.container { + margin-bottom: 24px; +} diff --git a/src/components/Adyen/Adyen.tsx b/packages/ui-react/src/components/Adyen/Adyen.tsx similarity index 93% rename from src/components/Adyen/Adyen.tsx rename to packages/ui-react/src/components/Adyen/Adyen.tsx index d30230510..506fddc6e 100644 --- a/src/components/Adyen/Adyen.tsx +++ b/packages/ui-react/src/components/Adyen/Adyen.tsx @@ -4,10 +4,10 @@ import AdyenCheckout from '@adyen/adyen-web'; import type { CoreOptions } from '@adyen/adyen-web/dist/types/core/types'; import type { PaymentMethods } from '@adyen/adyen-web/dist/types/types'; -import styles from './Adyen.module.scss'; +import Button from '../Button/Button'; +import FormFeedback from '../FormFeedback/FormFeedback'; -import Button from '#components/Button/Button'; -import FormFeedback from '#components/FormFeedback/FormFeedback'; +import styles from './Adyen.module.scss'; import '@adyen/adyen-web/dist/adyen.css'; import './AdyenForm.scss'; diff --git a/src/components/Adyen/AdyenForm.scss b/packages/ui-react/src/components/Adyen/AdyenForm.scss similarity index 84% rename from src/components/Adyen/AdyenForm.scss rename to packages/ui-react/src/components/Adyen/AdyenForm.scss index bd5d1da2e..f87f5056b 100644 --- a/src/components/Adyen/AdyenForm.scss +++ b/packages/ui-react/src/components/Adyen/AdyenForm.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .adyen-checkout__card-input .adyen-checkout__card__form { .adyen-checkout__label__text { @@ -22,4 +22,4 @@ font-size: 16px; line-height: 18px; } -} \ No newline at end of file +} diff --git a/src/components/Alert/Alert.module.scss b/packages/ui-react/src/components/Alert/Alert.module.scss similarity index 69% rename from src/components/Alert/Alert.module.scss rename to packages/ui-react/src/components/Alert/Alert.module.scss index e957dd38a..e1b2b141a 100644 --- a/src/components/Alert/Alert.module.scss +++ b/packages/ui-react/src/components/Alert/Alert.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .title { margin-bottom: 24px; diff --git a/src/components/Alert/Alert.test.tsx b/packages/ui-react/src/components/Alert/Alert.test.tsx similarity index 100% rename from src/components/Alert/Alert.test.tsx rename to packages/ui-react/src/components/Alert/Alert.test.tsx diff --git a/src/components/Alert/Alert.tsx b/packages/ui-react/src/components/Alert/Alert.tsx similarity index 59% rename from src/components/Alert/Alert.tsx rename to packages/ui-react/src/components/Alert/Alert.tsx index b2c057962..865f65a81 100644 --- a/src/components/Alert/Alert.tsx +++ b/packages/ui-react/src/components/Alert/Alert.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import useOpaqueId from '@jwp/ott-hooks-react/src/useOpaqueId'; -import styles from './Alert.module.scss'; +import Dialog from '../Dialog/Dialog'; +import Button from '../Button/Button'; -import Dialog from '#components/Dialog/Dialog'; -import Button from '#components/Button/Button'; +import styles from './Alert.module.scss'; type Props = { open: boolean; @@ -17,10 +18,13 @@ type Props = { const Alert: React.FC = ({ open, message, onClose, isSuccess, actionsOverride, titleOverride }: Props) => { const { t } = useTranslation('common'); + const headingId = useOpaqueId('alert-heading'); return ( - -

{titleOverride ?? (isSuccess ? t('alert.success') : t('alert.title'))}

+ +

+ {titleOverride ?? (isSuccess ? t('alert.success') : t('alert.title'))} +

{message}

{actionsOverride ??
diff --git a/src/components/Alert/__snapshots__/Alert.test.tsx.snap b/packages/ui-react/src/components/Alert/__snapshots__/Alert.test.tsx.snap similarity index 100% rename from src/components/Alert/__snapshots__/Alert.test.tsx.snap rename to packages/ui-react/src/components/Alert/__snapshots__/Alert.test.tsx.snap diff --git a/src/components/Animation/Animation.tsx b/packages/ui-react/src/components/Animation/Animation.tsx similarity index 96% rename from src/components/Animation/Animation.tsx rename to packages/ui-react/src/components/Animation/Animation.tsx index fc5ef5da9..c0c78d1e0 100644 --- a/src/components/Animation/Animation.tsx +++ b/packages/ui-react/src/components/Animation/Animation.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useEffect, useRef, useState } from 'react'; +import React, { type CSSProperties, useEffect, useRef, useState } from 'react'; type Props = { className?: string; diff --git a/src/components/Animation/Fade/Fade.tsx b/packages/ui-react/src/components/Animation/Fade/Fade.tsx similarity index 88% rename from src/components/Animation/Fade/Fade.tsx rename to packages/ui-react/src/components/Animation/Fade/Fade.tsx index 6a5d22db7..0d19247a2 100644 --- a/src/components/Animation/Fade/Fade.tsx +++ b/packages/ui-react/src/components/Animation/Fade/Fade.tsx @@ -1,6 +1,6 @@ -import React, { CSSProperties, ReactNode } from 'react'; +import React, { type CSSProperties, type ReactNode } from 'react'; -import Animation, { Status } from '#components/Animation/Animation'; +import Animation, { type Status } from '../Animation'; type Props = { className?: string; diff --git a/src/components/Animation/Grow/Grow.tsx b/packages/ui-react/src/components/Animation/Grow/Grow.tsx similarity index 87% rename from src/components/Animation/Grow/Grow.tsx rename to packages/ui-react/src/components/Animation/Grow/Grow.tsx index 793c1c87b..1fa2a745b 100644 --- a/src/components/Animation/Grow/Grow.tsx +++ b/packages/ui-react/src/components/Animation/Grow/Grow.tsx @@ -1,6 +1,6 @@ -import React, { ReactNode, CSSProperties } from 'react'; +import React, { type CSSProperties, type ReactNode } from 'react'; -import Animation, { Status } from '#components/Animation/Animation'; +import Animation, { type Status } from '../Animation'; type Props = { open?: boolean; diff --git a/src/components/Animation/Slide/Slide.tsx b/packages/ui-react/src/components/Animation/Slide/Slide.tsx similarity index 91% rename from src/components/Animation/Slide/Slide.tsx rename to packages/ui-react/src/components/Animation/Slide/Slide.tsx index 9c6fc8622..fd03957e9 100644 --- a/src/components/Animation/Slide/Slide.tsx +++ b/packages/ui-react/src/components/Animation/Slide/Slide.tsx @@ -1,6 +1,6 @@ -import React, { ReactNode, CSSProperties } from 'react'; +import React, { type CSSProperties, type ReactNode } from 'react'; -import Animation, { Status } from '#components/Animation/Animation'; +import Animation, { type Status } from '../Animation'; type Props = { open?: boolean; diff --git a/src/components/BackButton/BackButton.module.scss b/packages/ui-react/src/components/BackButton/BackButton.module.scss similarity index 53% rename from src/components/BackButton/BackButton.module.scss rename to packages/ui-react/src/components/BackButton/BackButton.module.scss index 041025693..f46a791d3 100644 --- a/src/components/BackButton/BackButton.module.scss +++ b/packages/ui-react/src/components/BackButton/BackButton.module.scss @@ -1,5 +1,5 @@ -@use '../../styles/variables'; -@use '../../styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .backButton { position: absolute; diff --git a/src/components/BackButton/BackButton.tsx b/packages/ui-react/src/components/BackButton/BackButton.tsx similarity index 81% rename from src/components/BackButton/BackButton.tsx rename to packages/ui-react/src/components/BackButton/BackButton.tsx index a4e169b9a..3b5d625bb 100644 --- a/src/components/BackButton/BackButton.tsx +++ b/packages/ui-react/src/components/BackButton/BackButton.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; +import ArrowLeft from '@jwp/ott-theme/assets/icons/icon.svg?react'; import IconButton from '../IconButton/IconButton'; -import ArrowLeft from '../../icons/ArrowLeft'; +import Icon from '../Icon/Icon'; import styles from './BackButton.module.scss'; @@ -17,7 +18,7 @@ const BackButton: React.FC = ({ className, onClick }: Props) => { return ( - + ); }; diff --git a/src/components/Button/Button.module.scss b/packages/ui-react/src/components/Button/Button.module.scss similarity index 95% rename from src/components/Button/Button.module.scss rename to packages/ui-react/src/components/Button/Button.module.scss index db3068634..429a2530e 100644 --- a/src/components/Button/Button.module.scss +++ b/packages/ui-react/src/components/Button/Button.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; $small-button-height: 28px; $medium-button-height: 36px; diff --git a/src/components/Button/Button.test.tsx b/packages/ui-react/src/components/Button/Button.test.tsx similarity index 100% rename from src/components/Button/Button.test.tsx rename to packages/ui-react/src/components/Button/Button.test.tsx diff --git a/src/components/Button/Button.tsx b/packages/ui-react/src/components/Button/Button.tsx similarity index 93% rename from src/components/Button/Button.tsx rename to packages/ui-react/src/components/Button/Button.tsx index a199d3af7..fc4bf0c72 100644 --- a/src/components/Button/Button.tsx +++ b/packages/ui-react/src/components/Button/Button.tsx @@ -1,10 +1,10 @@ -import React, { MouseEventHandler } from 'react'; +import React, { type MouseEventHandler } from 'react'; import classNames from 'classnames'; import { NavLink } from 'react-router-dom'; -import styles from './Button.module.scss'; +import Spinner from '../Spinner/Spinner'; -import Spinner from '#components/Spinner/Spinner'; +import styles from './Button.module.scss'; type Color = 'default' | 'primary' | 'delete'; @@ -16,7 +16,7 @@ type Props = { active?: boolean; color?: Color; fullWidth?: boolean; - startIcon?: JSX.Element; + startIcon?: React.ReactElement; variant?: Variant; onClick?: MouseEventHandler; tabIndex?: number; diff --git a/src/components/Button/__snapshots__/Button.test.tsx.snap b/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap similarity index 100% rename from src/components/Button/__snapshots__/Button.test.tsx.snap rename to packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap diff --git a/src/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss b/packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss similarity index 64% rename from src/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss rename to packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss index a5e41db7f..332f112c2 100644 --- a/src/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss +++ b/packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .title { margin-bottom: 8px; diff --git a/src/components/CancelSubscriptionForm/CancelSubscriptionForm.test.tsx b/packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.test.tsx similarity index 100% rename from src/components/CancelSubscriptionForm/CancelSubscriptionForm.test.tsx rename to packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.test.tsx diff --git a/src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx b/packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx similarity index 90% rename from src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx rename to packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx index 1b503128e..aae94d0a2 100644 --- a/src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx +++ b/packages/ui-react/src/components/CancelSubscriptionForm/CancelSubscriptionForm.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import styles from './CancelSubscriptionForm.module.scss'; +import Button from '../Button/Button'; +import FormFeedback from '../FormFeedback/FormFeedback'; -import Button from '#components/Button/Button'; -import FormFeedback from '#components/FormFeedback/FormFeedback'; +import styles from './CancelSubscriptionForm.module.scss'; type Props = { onConfirm: () => void; diff --git a/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap b/packages/ui-react/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap similarity index 100% rename from src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap rename to packages/ui-react/src/components/CancelSubscriptionForm/__snapshots__/CancelSubscriptionForm.test.tsx.snap diff --git a/src/components/Card/Card.module.scss b/packages/ui-react/src/components/Card/Card.module.scss similarity index 96% rename from src/components/Card/Card.module.scss rename to packages/ui-react/src/components/Card/Card.module.scss index afdd7fcd1..f66e142b1 100644 --- a/src/components/Card/Card.module.scss +++ b/packages/ui-react/src/components/Card/Card.module.scss @@ -1,11 +1,11 @@ @use 'sass:math'; -@use 'src/styles/mixins/responsive'; -@use 'src/styles/theme'; -@use 'src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; .card { display: flex; - flex-direction: column; + flex-direction: column-reverse; justify-content: flex-start; color: inherit; text-decoration: none; diff --git a/src/components/Card/Card.test.tsx b/packages/ui-react/src/components/Card/Card.test.tsx similarity index 72% rename from src/components/Card/Card.test.tsx rename to packages/ui-react/src/components/Card/Card.test.tsx index ea646de7c..a1bef4985 100644 --- a/src/components/Card/Card.test.tsx +++ b/packages/ui-react/src/components/Card/Card.test.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { fireEvent } from '@testing-library/react'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; -import Card from './Card'; +import { renderWithRouter } from '../../../test/utils'; -import type { PlaylistItem } from '#types/playlist'; -import { renderWithRouter } from '#test/testUtils'; +import Card from './Card'; const item = { title: 'aa', duration: 120 } as PlaylistItem; const itemWithImage = { title: 'This is a movie', duration: 120, cardImage: 'http://movie.jpg' } as PlaylistItem; @@ -22,18 +22,19 @@ describe('', () => { it('renders the image with the image prop when valid', () => { const { getByAltText } = renderWithRouter(); - expect(getByAltText('This is a movie')).toHaveAttribute('src', 'http://movie.jpg?width=320'); + expect(getByAltText('')).toHaveAttribute('src', 'http://movie.jpg?width=320'); }); it('makes the image visible after load', () => { const { getByAltText } = renderWithRouter(); + const image = getByAltText(''); // Image alt is intentionally empty for a11y - expect(getByAltText('This is a movie')).toHaveAttribute('src', 'http://movie.jpg?width=320'); - expect(getByAltText('This is a movie')).toHaveStyle({ opacity: 0 }); + expect(image).toHaveAttribute('src', 'http://movie.jpg?width=320'); + expect(image).toHaveStyle({ opacity: 0 }); - fireEvent.load(getByAltText('This is a movie')); + fireEvent.load(image); - expect(getByAltText('This is a movie')).toHaveStyle({ opacity: 1 }); + expect(image).toHaveStyle({ opacity: 1 }); }); it('should render anchor tag', () => { diff --git a/src/components/Card/Card.tsx b/packages/ui-react/src/components/Card/Card.tsx similarity index 78% rename from src/components/Card/Card.tsx rename to packages/ui-react/src/components/Card/Card.tsx index 621112e2f..cc0cf065e 100644 --- a/src/components/Card/Card.tsx +++ b/packages/ui-react/src/components/Card/Card.tsx @@ -2,26 +2,24 @@ import React, { memo, useState } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { formatDurationTag, formatLocalizedDateTime, formatSeriesMetaString } from '@jwp/ott-common/src/utils/formatting'; +import { isLiveChannel, isSeries } from '@jwp/ott-common/src/utils/media'; +import { MediaStatus } from '@jwp/ott-common/src/utils/liveEvent'; +import Lock from '@jwp/ott-theme/assets/icons/lock.svg?react'; +import Today from '@jwp/ott-theme/assets/icons/today.svg?react'; +import { testId } from '@jwp/ott-common/src/utils/common'; -import styles from './Card.module.scss'; - -import { formatDurationTag, formatLocalizedDateTime, formatSeriesMetaString } from '#src/utils/formatting'; -import Lock from '#src/icons/Lock'; -import Image from '#components/Image/Image'; -import Today from '#src/icons/Today'; -import type { PlaylistItem } from '#types/playlist'; -import { isLiveChannel, isSeries } from '#src/utils/media'; -import { MediaStatus } from '#src/utils/liveEvent'; - -export const cardAspectRatios = ['2:1', '16:9', '5:3', '4:3', '1:1', '9:13', '2:3', '9:16'] as const; +import Image from '../Image/Image'; +import Icon from '../Icon/Icon'; -export type PosterAspectRatio = (typeof cardAspectRatios)[number]; +import styles from './Card.module.scss'; type CardProps = { item: PlaylistItem; onHover?: () => void; progress?: number; - posterAspect?: PosterAspectRatio; + posterAspect?: string; featured?: boolean; disabled?: boolean; loading?: boolean; @@ -49,6 +47,8 @@ function Card({ t, i18n: { language }, } = useTranslation(['common', 'video']); + // t('play_item') + const [imageLoaded, setImageLoaded] = useState(false); const cardClassName = classNames(styles.card, { [styles.featured]: featured, @@ -79,7 +79,7 @@ function Card({ } else if (isScheduled) { return (
- + {t('scheduled')}
); @@ -88,43 +88,44 @@ function Card({ return ( e.preventDefault() : undefined} onMouseEnter={onHover} tabIndex={disabled ? -1 : 0} - aria-label={title} + data-testid={testId(title)} > + {!featured && !disabled && ( +
+

{title}

+ {!!scheduledStart && ( +
{formatLocalizedDateTime(scheduledStart, language)}
+ )} +
+ )}
- setImageLoaded(true)} alt={title} /> - {isCurrent &&
{currentLabel}
} + setImageLoaded(true)} alt="" /> {!loading && (
- {featured && !disabled &&
{title}
} + {featured && !disabled &&

{title}

}
{isLocked && ( -
- +
+
)} {renderTag()}
)} + {isCurrent &&
{currentLabel}
} {progress ? (
) : null}
- {!featured && !disabled && ( -
- {!!scheduledStart && ( -
{formatLocalizedDateTime(scheduledStart, language)}
- )} -
{title}
-
- )} ); } diff --git a/src/components/Card/__snapshots__/Card.test.tsx.snap b/packages/ui-react/src/components/Card/__snapshots__/Card.test.tsx.snap similarity index 88% rename from src/components/Card/__snapshots__/Card.test.tsx.snap rename to packages/ui-react/src/components/Card/__snapshots__/Card.test.tsx.snap index 8c63b5145..1d08e59d4 100644 --- a/src/components/Card/__snapshots__/Card.test.tsx.snap +++ b/packages/ui-react/src/components/Card/__snapshots__/Card.test.tsx.snap @@ -3,16 +3,26 @@ exports[` > should render anchor tag 1`] = ` -
-
- This is a movie -
-
`; diff --git a/src/components/CardGrid/CardGrid.module.scss b/packages/ui-react/src/components/CardGrid/CardGrid.module.scss similarity index 100% rename from src/components/CardGrid/CardGrid.module.scss rename to packages/ui-react/src/components/CardGrid/CardGrid.module.scss diff --git a/src/components/CardGrid/CardGrid.test.tsx b/packages/ui-react/src/components/CardGrid/CardGrid.test.tsx similarity index 73% rename from src/components/CardGrid/CardGrid.test.tsx rename to packages/ui-react/src/components/CardGrid/CardGrid.test.tsx index 0e3f06a16..c974bdd98 100644 --- a/src/components/CardGrid/CardGrid.test.tsx +++ b/packages/ui-react/src/components/CardGrid/CardGrid.test.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; +import type { Playlist, PlaylistItem } from '@jwp/ott-common/types/playlist'; +import playlistFixture from '@jwp/ott-testing/fixtures/playlist.json'; -import CardGrid from './CardGrid'; +import { renderWithRouter } from '../../../test/utils'; -import playlistFixture from '#test/fixtures/playlist.json'; -import { renderWithRouter } from '#test/testUtils'; -import type { Playlist, PlaylistItem } from '#types/playlist'; +import CardGrid from './CardGrid'; describe('', () => { it('renders and matches snapshot', () => { diff --git a/src/components/CardGrid/CardGrid.tsx b/packages/ui-react/src/components/CardGrid/CardGrid.tsx similarity index 84% rename from src/components/CardGrid/CardGrid.tsx rename to packages/ui-react/src/components/CardGrid/CardGrid.tsx index 643c5a151..25b597496 100644 --- a/src/components/CardGrid/CardGrid.tsx +++ b/packages/ui-react/src/components/CardGrid/CardGrid.tsx @@ -1,16 +1,16 @@ import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import InfiniteScroll from 'react-infinite-scroller'; +import type { Playlist, PlaylistItem } from '@jwp/ott-common/types/playlist'; +import type { AccessModel } from '@jwp/ott-common/types/config'; +import { isLocked } from '@jwp/ott-common/src/utils/entitlements'; +import { parseAspectRatio, parseTilesDelta } from '@jwp/ott-common/src/utils/collection'; +import useBreakpoint, { Breakpoint, type Breakpoints } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import styles from './CardGrid.module.scss'; +import Card from '../Card/Card'; +import InfiniteScrollLoader from '../InfiniteScrollLoader/InfiniteScrollLoader'; -import useBreakpoint, { Breakpoint, Breakpoints } from '#src/hooks/useBreakpoint'; -import { isLocked } from '#src/utils/entitlements'; -import Card from '#components/Card/Card'; -import type { AccessModel } from '#types/Config'; -import type { Playlist, PlaylistItem } from '#types/playlist'; -import { parseAspectRatio, parseTilesDelta } from '#src/utils/collection'; -import InfiniteScrollLoader from '#components/InfiniteScrollLoader/InfiniteScrollLoader'; +import styles from './CardGrid.module.scss'; const INITIAL_ROW_COUNT = 6; const LOAD_ROWS_COUNT = 4; @@ -92,7 +92,7 @@ function CardGrid({ return ( }>
- {/* When loadMore is present -> we get accumulated data (playlist.playlist) from the outside (we do it for series) + {/* When loadMore is present -> we get accumulated data (playlist.playlist) from the outside (we do it for series) When not -> we hide some cards visually to save some computing resources spent on rendering */} {(loadMore ? playlist.playlist : playlist.playlist.slice(0, rowCount * visibleTiles)).map(renderTile)}
diff --git a/src/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap b/packages/ui-react/src/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap similarity index 90% rename from src/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap rename to packages/ui-react/src/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap index d03b668ff..6866aa887 100644 --- a/src/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap +++ b/packages/ui-react/src/components/CardGrid/__snapshots__/CardGrid.test.tsx.snap @@ -15,11 +15,21 @@ exports[` > renders and matches snapshot 1`] = ` role="cell" > +
+

+ Agent 327 +

+
@@ -37,15 +47,6 @@ exports[` > renders and matches snapshot 1`] = `
-
-
- Agent 327 -
-
@@ -57,11 +58,21 @@ exports[` > renders and matches snapshot 1`] = ` role="cell" > +
+

+ Big Buck Bunny +

+
@@ -79,15 +90,6 @@ exports[` > renders and matches snapshot 1`] = `
-
-
- Big Buck Bunny -
-
@@ -99,11 +101,21 @@ exports[` > renders and matches snapshot 1`] = ` role="cell" > +
+

+ Elephants Dream +

+
@@ -121,15 +133,6 @@ exports[` > renders and matches snapshot 1`] = `
-
-
- Elephants Dream -
-
diff --git a/src/components/Checkbox/Checkbox.module.scss b/packages/ui-react/src/components/Checkbox/Checkbox.module.scss similarity index 93% rename from src/components/Checkbox/Checkbox.module.scss rename to packages/ui-react/src/components/Checkbox/Checkbox.module.scss index a8c346ae7..8f8491a28 100644 --- a/src/components/Checkbox/Checkbox.module.scss +++ b/packages/ui-react/src/components/Checkbox/Checkbox.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .checkbox { display: inline-block; @@ -12,11 +12,7 @@ a { color: variables.$white; font-weight: var(--body-font-weight-bold); - text-decoration: none; - - &:hover { - text-decoration: underline; - } + text-decoration: underline; } } diff --git a/src/components/Checkbox/Checkbox.test.tsx b/packages/ui-react/src/components/Checkbox/Checkbox.test.tsx similarity index 100% rename from src/components/Checkbox/Checkbox.test.tsx rename to packages/ui-react/src/components/Checkbox/Checkbox.test.tsx diff --git a/src/components/Checkbox/Checkbox.tsx b/packages/ui-react/src/components/Checkbox/Checkbox.tsx similarity index 92% rename from src/components/Checkbox/Checkbox.tsx rename to packages/ui-react/src/components/Checkbox/Checkbox.tsx index d8a5ca933..4f3db3a60 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui-react/src/components/Checkbox/Checkbox.tsx @@ -1,11 +1,11 @@ import React, { type ReactNode } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; +import useOpaqueId from '@jwp/ott-hooks-react/src/useOpaqueId'; -import styles from './Checkbox.module.scss'; +import HelperText from '../HelperText/HelperText'; -import HelperText from '#components/HelperText/HelperText'; -import useOpaqueId from '#src/hooks/useOpaqueId'; +import styles from './Checkbox.module.scss'; type Props = { label?: ReactNode; diff --git a/src/components/Checkbox/__snapshots__/Checkbox.test.tsx.snap b/packages/ui-react/src/components/Checkbox/__snapshots__/Checkbox.test.tsx.snap similarity index 100% rename from src/components/Checkbox/__snapshots__/Checkbox.test.tsx.snap rename to packages/ui-react/src/components/Checkbox/__snapshots__/Checkbox.test.tsx.snap diff --git a/src/components/CheckoutForm/CheckoutForm.module.scss b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.module.scss similarity index 97% rename from src/components/CheckoutForm/CheckoutForm.module.scss rename to packages/ui-react/src/components/CheckoutForm/CheckoutForm.module.scss index 12a478e1c..603581af0 100644 --- a/src/components/CheckoutForm/CheckoutForm.module.scss +++ b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .backButton { position: absolute; diff --git a/src/components/CheckoutForm/CheckoutForm.test.tsx b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx similarity index 81% rename from src/components/CheckoutForm/CheckoutForm.test.tsx rename to packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx index 6b49ad592..88060d339 100644 --- a/src/components/CheckoutForm/CheckoutForm.test.tsx +++ b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { render } from '@testing-library/react'; +import type { Offer, Order } from '@jwp/ott-common/types/checkout'; +import offer from '@jwp/ott-testing/fixtures/monthlyOffer.json'; +import order from '@jwp/ott-testing/fixtures/order.json'; import CheckoutForm from './CheckoutForm'; -import offer from '#test/fixtures/monthlyOffer.json'; -import order from '#test/fixtures/order.json'; -import type { Offer, Order } from '#types/checkout'; - describe('', () => { test('renders and matches snapshot', () => { const { container } = render( diff --git a/src/components/CheckoutForm/CheckoutForm.tsx b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx similarity index 88% rename from src/components/CheckoutForm/CheckoutForm.tsx rename to packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx index 11c8d1a1a..e69ad5385 100644 --- a/src/components/CheckoutForm/CheckoutForm.tsx +++ b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx @@ -1,20 +1,21 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; +import type { OfferType } from '@jwp/ott-common/types/account'; +import type { Offer, Order, PaymentMethod } from '@jwp/ott-common/types/checkout'; +import { formatPrice } from '@jwp/ott-common/src/utils/formatting'; +import Close from '@jwp/ott-theme/assets/icons/close.svg?react'; +import PayPal from '@jwp/ott-theme/assets/icons/paypal.svg?react'; +import CreditCard from '@jwp/ott-theme/assets/icons/creditcard.svg?react'; -import styles from './CheckoutForm.module.scss'; +import Button from '../Button/Button'; +import IconButton from '../IconButton/IconButton'; +import FormFeedback from '../FormFeedback/FormFeedback'; +import DialogBackButton from '../DialogBackButton/DialogBackButton'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; +import Icon from '../Icon/Icon'; -import Button from '#components/Button/Button'; -import type { Offer, Order, PaymentMethod } from '#types/checkout'; -import IconButton from '#components/IconButton/IconButton'; -import FormFeedback from '#components/FormFeedback/FormFeedback'; -import { formatPrice } from '#src/utils/formatting'; -import Close from '#src/icons/Close'; -import DialogBackButton from '#components/DialogBackButton/DialogBackButton'; -import PayPal from '#src/icons/PayPal'; -import CreditCard from '#src/icons/CreditCard'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import type { OfferType } from '#types/account'; +import styles from './CheckoutForm.module.scss'; type Props = { paymentMethodId?: number; @@ -90,7 +91,7 @@ const CheckoutForm: React.FC = ({ return (
-

{t('checkout.payment_method')}

+

{t('checkout.payment_method')}

{orderTitle}

@@ -106,7 +107,7 @@ const CheckoutForm: React.FC = ({
- + = ({ onChange={onPaymentMethodChange} />
) : null} @@ -203,7 +204,7 @@ const CheckoutForm: React.FC = ({ onChange={onPaymentMethodChange} />
) : null} diff --git a/src/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap b/packages/ui-react/src/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap similarity index 89% rename from src/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap rename to packages/ui-react/src/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap index 6b68bd69c..ca44a7a17 100644 --- a/src/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap +++ b/packages/ui-react/src/components/CheckoutForm/__snapshots__/CheckoutForm.test.tsx.snap @@ -11,23 +11,20 @@ exports[` > renders and matches snapshot 1`] = ` >
-

checkout.payment_method -

+
diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.module.scss b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.module.scss similarity index 93% rename from src/components/ChooseOfferForm/ChooseOfferForm.module.scss rename to packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.module.scss index 15266b3fc..b30409b27 100644 --- a/src/components/ChooseOfferForm/ChooseOfferForm.module.scss +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .title { margin-bottom: 8px; diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx similarity index 92% rename from src/components/ChooseOfferForm/ChooseOfferForm.test.tsx rename to packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx index ce7fbc471..c48ab7cd1 100644 --- a/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx @@ -1,13 +1,12 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; +import type { Offer } from '@jwp/ott-common/types/checkout'; +import monthlyOffer from '@jwp/ott-testing/fixtures/monthlyOffer.json'; +import yearlyOffer from '@jwp/ott-testing/fixtures/yearlyOffer.json'; +import tvodOffer from '@jwp/ott-testing/fixtures/tvodOffer.json'; import ChooseOfferForm from './ChooseOfferForm'; -import monthlyOffer from '#test/fixtures/monthlyOffer.json'; -import yearlyOffer from '#test/fixtures/yearlyOffer.json'; -import tvodOffer from '#test/fixtures/tvodOffer.json'; -import type { Offer } from '#types/checkout'; - const svodOffers = [monthlyOffer, yearlyOffer] as Offer[]; const tvodOffers = [tvodOffer] as Offer[]; diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.tsx b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx similarity index 81% rename from src/components/ChooseOfferForm/ChooseOfferForm.tsx rename to packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx index 8812257b4..5ae067d4b 100644 --- a/src/components/ChooseOfferForm/ChooseOfferForm.tsx +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx @@ -1,20 +1,21 @@ 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 { 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'; +import CheckCircle from '@jwp/ott-theme/assets/icons/check_circle.svg?react'; -import styles from './ChooseOfferForm.module.scss'; +import Button from '../Button/Button'; +import FormFeedback from '../FormFeedback/FormFeedback'; +import DialogBackButton from '../DialogBackButton/DialogBackButton'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; +import Icon from '../Icon/Icon'; -import Button from '#components/Button/Button'; -import FormFeedback from '#components/FormFeedback/FormFeedback'; -import DialogBackButton from '#components/DialogBackButton/DialogBackButton'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import CheckCircle from '#src/icons/CheckCircle'; -import type { Offer } from '#types/checkout'; -import { getOfferPrice, isSVODOffer } from '#src/utils/subscription'; -import type { FormErrors } from '#types/form'; -import { testId } from '#src/utils/common'; -import type { ChooseOfferFormData, OfferType } from '#types/account'; -import { useConfigStore } from '#src/stores/ConfigStore'; +import styles from './ChooseOfferForm.module.scss'; type Props = { values: ChooseOfferFormData; @@ -81,22 +82,22 @@ const ChooseOfferForm: React.FC = ({ aria-label={ariaLabel} />
@@ -67,7 +64,7 @@ exports[` > renders and matches snapshot 1`] = ` > @@ -76,7 +73,7 @@ exports[` > renders and matches snapshot 1`] = ` > > renders and matches snapshot 1`] = ` >
diff --git a/src/components/Epg/Epg.module.scss b/packages/ui-react/src/components/Epg/Epg.module.scss similarity index 93% rename from src/components/Epg/Epg.module.scss rename to packages/ui-react/src/components/Epg/Epg.module.scss index 1c3d87a07..863df32f8 100644 --- a/src/components/Epg/Epg.module.scss +++ b/packages/ui-react/src/components/Epg/Epg.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; $base-z-index: variables.$epg-z-index; $cover-z-index: $base-z-index + 1; diff --git a/src/components/Epg/Epg.tsx b/packages/ui-react/src/components/Epg/Epg.tsx similarity index 79% rename from src/components/Epg/Epg.tsx rename to packages/ui-react/src/components/Epg/Epg.tsx index 9b776e3c8..0f74e0002 100644 --- a/src/components/Epg/Epg.tsx +++ b/packages/ui-react/src/components/Epg/Epg.tsx @@ -2,21 +2,22 @@ import { Epg as EpgContainer, Layout } from 'planby'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { isBefore, subHours } from 'date-fns'; +import type { EpgChannel, EpgProgram } from '@jwp/ott-common/types/epg'; +import type { Config } from '@jwp/ott-common/types/config'; +import usePlanByEpg from '@jwp/ott-hooks-react/src/usePlanByEpg'; +import ChevronRight from '@jwp/ott-theme/assets/icons/chevron_right.svg?react'; +import ChevronLeft from '@jwp/ott-theme/assets/icons/chevron_left.svg?react'; +import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import styles from './Epg.module.scss'; +import IconButton from '../IconButton/IconButton'; +import Button from '../Button/Button'; +import EpgChannelItem from '../EpgChannel/EpgChannelItem'; +import EpgProgramItem from '../EpgProgramItem/EpgProgramItem'; +import EpgTimeline from '../EpgTimeline/EpgTimeline'; +import Spinner from '../Spinner/Spinner'; +import Icon from '../Icon/Icon'; -import type { Config } from '#types/Config'; -import type { EpgProgram, EpgChannel } from '#types/epg'; -import IconButton from '#components/IconButton/IconButton'; -import Button from '#components/Button/Button'; -import EpgChannelItem from '#components/EpgChannel/EpgChannelItem'; -import EpgProgramItem from '#components/EpgProgramItem/EpgProgramItem'; -import EpgTimeline from '#components/EpgTimeline/EpgTimeline'; -import Spinner from '#components/Spinner/Spinner'; -import ChevronRight from '#src/icons/ChevronRight'; -import ChevronLeft from '#src/icons/ChevronLeft'; -import usePlanByEpg from '#src/hooks/usePlanByEpg'; -import useBreakpoint, { Breakpoint } from '#src/hooks/useBreakpoint'; +import styles from './Epg.module.scss'; type Props = { channels: EpgChannel[]; @@ -53,10 +54,10 @@ export default function Epg({ channels, onChannelClick, onProgramClick, channel,
}> diff --git a/src/components/EpgChannel/EpgChannelItem.module.scss b/packages/ui-react/src/components/EpgChannel/EpgChannelItem.module.scss similarity index 82% rename from src/components/EpgChannel/EpgChannelItem.module.scss rename to packages/ui-react/src/components/EpgChannel/EpgChannelItem.module.scss index 1105805fe..5f9ef017a 100644 --- a/src/components/EpgChannel/EpgChannelItem.module.scss +++ b/packages/ui-react/src/components/EpgChannel/EpgChannelItem.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .epgChannelBox { position: absolute; diff --git a/src/components/EpgChannel/EpgChannelItem.tsx b/packages/ui-react/src/components/EpgChannel/EpgChannelItem.tsx similarity index 91% rename from src/components/EpgChannel/EpgChannelItem.tsx rename to packages/ui-react/src/components/EpgChannel/EpgChannelItem.tsx index a2a7c606c..a3f2349df 100644 --- a/src/components/EpgChannel/EpgChannelItem.tsx +++ b/packages/ui-react/src/components/EpgChannel/EpgChannelItem.tsx @@ -1,11 +1,11 @@ import React from 'react'; import type { Channel } from 'planby'; import classNames from 'classnames'; +import { testId } from '@jwp/ott-common/src/utils/common'; -import styles from './EpgChannelItem.module.scss'; +import Image from '../Image/Image'; -import Image from '#components/Image/Image'; -import { testId } from '#src/utils/common'; +import styles from './EpgChannelItem.module.scss'; type Props = { channel: Channel; diff --git a/src/components/EpgProgramItem/EpgProgramItem.module.scss b/packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.module.scss similarity index 94% rename from src/components/EpgProgramItem/EpgProgramItem.module.scss rename to packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.module.scss index 4d3c69e40..910c8d171 100644 --- a/src/components/EpgProgramItem/EpgProgramItem.module.scss +++ b/packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .epgProgramBox { position: absolute; diff --git a/src/components/EpgProgramItem/EpgProgramItem.tsx b/packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.tsx similarity index 91% rename from src/components/EpgProgramItem/EpgProgramItem.tsx rename to packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.tsx index 28efc53d9..e632253f9 100644 --- a/src/components/EpgProgramItem/EpgProgramItem.tsx +++ b/packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { Program, useProgram } from 'planby'; +import { useProgram, type Program } from 'planby'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; +import { testId } from '@jwp/ott-common/src/utils/common'; import styles from './EpgProgramItem.module.scss'; -import { testId } from '#src/utils/common'; - type Props = { program: Program; onClick?: (program: Program) => void; @@ -53,7 +52,7 @@ const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, di {showLiveTagInImage &&
{t('live')}
}
{compact && isLive &&
{t('live')}
} -

{title}

+

{title}

{sinceTime} - {tillTime} diff --git a/src/components/EpgTimeline/EpgTimeline.module.scss b/packages/ui-react/src/components/EpgTimeline/EpgTimeline.module.scss similarity index 87% rename from src/components/EpgTimeline/EpgTimeline.module.scss rename to packages/ui-react/src/components/EpgTimeline/EpgTimeline.module.scss index ca2e5073e..9fa8f3e93 100644 --- a/src/components/EpgTimeline/EpgTimeline.module.scss +++ b/packages/ui-react/src/components/EpgTimeline/EpgTimeline.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .timelineBox { position: relative; diff --git a/src/components/EpgTimeline/EpgTimeline.tsx b/packages/ui-react/src/components/EpgTimeline/EpgTimeline.tsx similarity index 100% rename from src/components/EpgTimeline/EpgTimeline.tsx rename to packages/ui-react/src/components/EpgTimeline/EpgTimeline.tsx diff --git a/src/components/ErrorPage/ErrorPage.module.scss b/packages/ui-react/src/components/ErrorPage/ErrorPage.module.scss similarity index 86% rename from src/components/ErrorPage/ErrorPage.module.scss rename to packages/ui-react/src/components/ErrorPage/ErrorPage.module.scss index 5931fda13..32effb13e 100644 --- a/src/components/ErrorPage/ErrorPage.module.scss +++ b/packages/ui-react/src/components/ErrorPage/ErrorPage.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .errorPage { display: flex; diff --git a/src/components/ErrorPage/ErrorPage.test.tsx b/packages/ui-react/src/components/ErrorPage/ErrorPage.test.tsx similarity index 100% rename from src/components/ErrorPage/ErrorPage.test.tsx rename to packages/ui-react/src/components/ErrorPage/ErrorPage.test.tsx diff --git a/src/components/ErrorPage/ErrorPage.tsx b/packages/ui-react/src/components/ErrorPage/ErrorPage.tsx similarity index 90% rename from src/components/ErrorPage/ErrorPage.tsx rename to packages/ui-react/src/components/ErrorPage/ErrorPage.tsx index dd7070c68..7de42e1fd 100644 --- a/src/components/ErrorPage/ErrorPage.tsx +++ b/packages/ui-react/src/components/ErrorPage/ErrorPage.tsx @@ -1,11 +1,11 @@ -import React, { ReactNode } from 'react'; +import React, { type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { IS_DEMO_MODE, IS_DEVELOPMENT_BUILD, IS_PREVIEW_MODE } from '@jwp/ott-common/src/utils/common'; -import styles from './ErrorPage.module.scss'; +import DevStackTrace from '../DevStackTrace/DevStackTrace'; -import { IS_DEMO_MODE, IS_DEVELOPMENT_BUILD, IS_PREVIEW_MODE } from '#src/utils/common'; -import DevStackTrace from '#components/DevStackTrace/DevStackTrace'; -import { useConfigStore } from '#src/stores/ConfigStore'; +import styles from './ErrorPage.module.scss'; interface Props { disableFallbackTranslation?: boolean; diff --git a/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap b/packages/ui-react/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap similarity index 100% rename from src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap rename to packages/ui-react/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap diff --git a/src/components/Favorites/Favorites.module.scss b/packages/ui-react/src/components/Favorites/Favorites.module.scss similarity index 72% rename from src/components/Favorites/Favorites.module.scss rename to packages/ui-react/src/components/Favorites/Favorites.module.scss index 2ef860218..443f9290d 100644 --- a/src/components/Favorites/Favorites.module.scss +++ b/packages/ui-react/src/components/Favorites/Favorites.module.scss @@ -1,12 +1,12 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .header { display: flex; margin-bottom: calc(#{variables.$base-spacing} * 1.5); - > h3 { + > h1 { margin-right: calc(#{variables.$base-spacing} * 1.5); font-weight: var(--body-font-weight-bold); font-size: 34px; diff --git a/src/components/Favorites/Favorites.test.tsx b/packages/ui-react/src/components/Favorites/Favorites.test.tsx similarity index 57% rename from src/components/Favorites/Favorites.test.tsx rename to packages/ui-react/src/components/Favorites/Favorites.test.tsx index 8eabfd715..99eac2f9f 100644 --- a/src/components/Favorites/Favorites.test.tsx +++ b/packages/ui-react/src/components/Favorites/Favorites.test.tsx @@ -1,26 +1,19 @@ import React from 'react'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { PersonalShelf } from '@jwp/ott-common/src/constants'; +import { mockService } from '@jwp/ott-common/test/mockService'; -import Favorites from './Favorites'; - -import { PersonalShelf } from '#src/config'; -import PlaylistContainer from '#src/containers/PlaylistContainer/PlaylistContainer'; -import { renderWithRouter } from '#test/testUtils'; -import ApiService from '#src/services/api.service'; +import { renderWithRouter } from '../../../test/utils'; +import PlaylistContainer from '../../containers/PlaylistContainer/PlaylistContainer'; -vi.mock('#src/modules/container', () => ({ - getModule: (type: typeof ApiService) => { - switch (type) { - case ApiService: - return { - getPlaylistById: vi.fn(() => ({ - id: 'fake_id', - })), - }; - } - }, -})); +import Favorites from './Favorites'; describe('', () => { + beforeEach(() => { + // TODO: Remove ApiService from component + mockService(ApiService, {}); + }); + test('renders and matches snapshot', () => { const { container } = renderWithRouter( diff --git a/src/components/Favorites/Favorites.tsx b/packages/ui-react/src/components/Favorites/Favorites.tsx similarity index 74% rename from src/components/Favorites/Favorites.tsx rename to packages/ui-react/src/components/Favorites/Favorites.tsx index 85f3e2df5..318a156fb 100644 --- a/src/components/Favorites/Favorites.tsx +++ b/packages/ui-react/src/components/Favorites/Favorites.tsx @@ -1,16 +1,16 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import type { AccessModel } from '@jwp/ott-common/types/config'; +import type { Playlist, PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { mediaURL } from '@jwp/ott-common/src/utils/urlFormatting'; +import { Breakpoint, type Breakpoints } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import styles from './Favorites.module.scss'; +import Button from '../Button/Button'; +import CardGrid from '../CardGrid/CardGrid'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; +import ErrorPage from '../ErrorPage/ErrorPage'; -import Button from '#components/Button/Button'; -import CardGrid from '#components/CardGrid/CardGrid'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import ErrorPage from '#components/ErrorPage/ErrorPage'; -import { Breakpoint, Breakpoints } from '#src/hooks/useBreakpoint'; -import type { AccessModel } from '#types/Config'; -import type { Playlist, PlaylistItem } from '#types/playlist'; -import { mediaURL } from '#src/utils/formatting'; +import styles from './Favorites.module.scss'; type Props = { playlist: Playlist; @@ -44,7 +44,7 @@ const Favorites = ({ playlist, error, isLoading, accessModel, hasSubscription, o return (
-

{t('favorites.title')}

+

{t('favorites.title')}

{playlist.playlist.length > 0 ?
{playlist.playlist.length > 0 ? ( diff --git a/src/components/Favorites/__snapshots__/Favorites.test.tsx.snap b/packages/ui-react/src/components/Favorites/__snapshots__/Favorites.test.tsx.snap similarity index 100% rename from src/components/Favorites/__snapshots__/Favorites.test.tsx.snap rename to packages/ui-react/src/components/Favorites/__snapshots__/Favorites.test.tsx.snap diff --git a/src/components/Filter/Filter.module.scss b/packages/ui-react/src/components/Filter/Filter.module.scss similarity index 59% rename from src/components/Filter/Filter.module.scss rename to packages/ui-react/src/components/Filter/Filter.module.scss index d0d00ee15..ae40c41e0 100644 --- a/src/components/Filter/Filter.module.scss +++ b/packages/ui-react/src/components/Filter/Filter.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .filterRow { display: flex; diff --git a/src/components/Filter/Filter.test.tsx b/packages/ui-react/src/components/Filter/Filter.test.tsx similarity index 100% rename from src/components/Filter/Filter.test.tsx rename to packages/ui-react/src/components/Filter/Filter.test.tsx diff --git a/src/components/Filter/Filter.tsx b/packages/ui-react/src/components/Filter/Filter.tsx similarity index 89% rename from src/components/Filter/Filter.tsx rename to packages/ui-react/src/components/Filter/Filter.tsx index 8fa2d53e7..b7f590129 100644 --- a/src/components/Filter/Filter.tsx +++ b/packages/ui-react/src/components/Filter/Filter.tsx @@ -1,11 +1,11 @@ -import React, { Fragment, FC } from 'react'; +import React, { type FC, Fragment } from 'react'; import { useTranslation } from 'react-i18next'; +import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import styles from './Filter.module.scss'; +import Dropdown from '../Dropdown/Dropdown'; +import Button from '../Button/Button'; -import Dropdown from '#components/Dropdown/Dropdown'; -import Button from '#components/Button/Button'; -import useBreakpoint, { Breakpoint } from '#src/hooks/useBreakpoint'; +import styles from './Filter.module.scss'; type FilterOption = | { diff --git a/src/components/Filter/__snapshots__/Filter.test.tsx.snap b/packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap similarity index 100% rename from src/components/Filter/__snapshots__/Filter.test.tsx.snap rename to packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap diff --git a/src/components/FinalizePayment/FinalizePayment.module.scss b/packages/ui-react/src/components/FinalizePayment/FinalizePayment.module.scss similarity index 100% rename from src/components/FinalizePayment/FinalizePayment.module.scss rename to packages/ui-react/src/components/FinalizePayment/FinalizePayment.module.scss diff --git a/src/components/FinalizePayment/FinalizePayment.tsx b/packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx similarity index 73% rename from src/components/FinalizePayment/FinalizePayment.tsx rename to packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx index 0bb86c527..95671e06e 100644 --- a/src/components/FinalizePayment/FinalizePayment.tsx +++ b/packages/ui-react/src/components/FinalizePayment/FinalizePayment.tsx @@ -2,18 +2,18 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; 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 { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; +import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback'; -import styles from './FinalizePayment.module.scss'; +import Button from '../Button/Button'; +import Spinner from '../Spinner/Spinner'; +import { modalURLFromLocation } from '../../utils/location'; -import Button from '#components/Button/Button'; -import Spinner from '#components/Spinner/Spinner'; -import useEventCallback from '#src/hooks/useEventCallback'; -import { useConfigStore } from '#src/stores/ConfigStore'; -import { replaceQueryParam, removeQueryParam, addQueryParam } from '#src/utils/location'; -import AccountController from '#src/stores/AccountController'; -import CheckoutController from '#src/stores/CheckoutController'; -import { ACCESS_MODEL } from '#src/config'; -import { getModule } from '#src/modules/container'; +import styles from './FinalizePayment.module.scss'; const FinalizePayment = () => { const accountController = getModule(AccountController); @@ -31,7 +31,7 @@ const FinalizePayment = () => { const [errorMessage, setErrorMessage] = useState(); const paymentSuccessUrl = useMemo(() => { - return accessModel === ACCESS_MODEL.SVOD ? replaceQueryParam(location, 'u', 'welcome') : removeQueryParam(location, 'u'); + return modalURLFromLocation(location, accessModel === ACCESS_MODEL.SVOD ? 'welcome' : null); }, [accessModel, location]); const checkPaymentResult = useEventCallback(async (redirectResult: string) => { @@ -65,7 +65,7 @@ const FinalizePayment = () => { variant="contained" color="primary" size="large" - onClick={() => navigate(addQueryParam(location, 'u', 'checkout'))} + onClick={() => navigate(modalURLFromLocation(location, 'checkout'))} fullWidth /> diff --git a/src/components/ForgotPasswordForm/ForgotPasswordForm.module.scss b/packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.module.scss similarity index 66% rename from src/components/ForgotPasswordForm/ForgotPasswordForm.module.scss rename to packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.module.scss index 711096e1f..31ddcdbcc 100644 --- a/src/components/ForgotPasswordForm/ForgotPasswordForm.module.scss +++ b/packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .title { margin: 24px 0; diff --git a/src/components/ForgotPasswordForm/ForgotPasswordForm.test.tsx b/packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.test.tsx similarity index 100% rename from src/components/ForgotPasswordForm/ForgotPasswordForm.test.tsx rename to packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.test.tsx diff --git a/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx b/packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx similarity index 81% rename from src/components/ForgotPasswordForm/ForgotPasswordForm.tsx rename to packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx index 297758bd8..ae3c69613 100644 --- a/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx +++ b/packages/ui-react/src/components/ForgotPasswordForm/ForgotPasswordForm.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import type { ForgotPasswordFormData } from '@jwp/ott-common/types/account'; +import type { FormErrors } from '@jwp/ott-common/types/form'; +import { testId } from '@jwp/ott-common/src/utils/common'; -import styles from './ForgotPasswordForm.module.scss'; +import Button from '../Button/Button'; +import TextField from '../TextField/TextField'; +import FormFeedback from '../FormFeedback/FormFeedback'; -import Button from '#components/Button/Button'; -import TextField from '#components/TextField/TextField'; -import type { FormErrors } from '#types/form'; -import type { ForgotPasswordFormData } from '#types/account'; -import FormFeedback from '#components/FormFeedback/FormFeedback'; -import { testId } from '#src/utils/common'; +import styles from './ForgotPasswordForm.module.scss'; type Props = { onSubmit: React.FormEventHandler; diff --git a/src/components/ForgotPasswordForm/__snapshots__/ForgotPasswordForm.test.tsx.snap b/packages/ui-react/src/components/ForgotPasswordForm/__snapshots__/ForgotPasswordForm.test.tsx.snap similarity index 100% rename from src/components/ForgotPasswordForm/__snapshots__/ForgotPasswordForm.test.tsx.snap rename to packages/ui-react/src/components/ForgotPasswordForm/__snapshots__/ForgotPasswordForm.test.tsx.snap diff --git a/src/components/Form/Form.module.scss b/packages/ui-react/src/components/Form/Form.module.scss similarity index 65% rename from src/components/Form/Form.module.scss rename to packages/ui-react/src/components/Form/Form.module.scss index e651f732b..8798f645b 100644 --- a/src/components/Form/Form.module.scss +++ b/packages/ui-react/src/components/Form/Form.module.scss @@ -1,6 +1,6 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .flexBox { display: flex; diff --git a/src/components/Form/Form.test.tsx b/packages/ui-react/src/components/Form/Form.test.tsx similarity index 100% rename from src/components/Form/Form.test.tsx rename to packages/ui-react/src/components/Form/Form.test.tsx diff --git a/src/components/Form/Form.tsx b/packages/ui-react/src/components/Form/Form.tsx similarity index 91% rename from src/components/Form/Form.tsx rename to packages/ui-react/src/components/Form/Form.tsx index 903026ee6..92e273cd9 100644 --- a/src/components/Form/Form.tsx +++ b/packages/ui-react/src/components/Form/Form.tsx @@ -1,10 +1,9 @@ -import React, { createContext, SetStateAction, useCallback, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useEffect, useState, type SetStateAction } from 'react'; +import type { GenericFormValues } from '@jwp/ott-common/types/form'; import type { FormSectionProps } from './FormSection'; import { FormSection } from './FormSection'; -import type { GenericFormValues } from '#types/form'; - interface Props { initialValues: TData; isLoading?: boolean; diff --git a/src/components/Form/FormSection.tsx b/packages/ui-react/src/components/Form/FormSection.tsx similarity index 91% rename from src/components/Form/FormSection.tsx rename to packages/ui-react/src/components/Form/FormSection.tsx index 1fdf3bbdd..12ce4eb10 100644 --- a/src/components/Form/FormSection.tsx +++ b/packages/ui-react/src/components/Form/FormSection.tsx @@ -1,12 +1,12 @@ -import React, { ReactElement, ReactNode, useCallback, useContext } from 'react'; +import React, { useCallback, useContext, type ReactElement, type ReactNode } from 'react'; +import type { GenericFormValues } from '@jwp/ott-common/types/form'; +import useOpaqueId from '@jwp/ott-hooks-react/src/useOpaqueId'; -import { FormContext } from './Form'; -import styles from './Form.module.scss'; +import Button from '../Button/Button'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; -import Button from '#components/Button/Button'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import useOpaqueId from '#src/hooks/useOpaqueId'; -import type { GenericFormValues } from '#types/form'; +import styles from './Form.module.scss'; +import { FormContext } from './Form'; export interface FormSectionContentArgs { values: T; @@ -147,9 +147,9 @@ export function FormSection({ ); return ( -
+
-

{label}

+

{label}

{isBusy && isEditing && } {content && ( @@ -183,6 +183,6 @@ export function FormSection({ )}
)} -
+ ); } diff --git a/src/components/Form/__snapshots__/Form.test.tsx.snap b/packages/ui-react/src/components/Form/__snapshots__/Form.test.tsx.snap similarity index 81% rename from src/components/Form/__snapshots__/Form.test.tsx.snap rename to packages/ui-react/src/components/Form/__snapshots__/Form.test.tsx.snap index 8c2122f37..aa5227432 100644 --- a/src/components/Form/__snapshots__/Form.test.tsx.snap +++ b/packages/ui-react/src/components/Form/__snapshots__/Form.test.tsx.snap @@ -2,11 +2,15 @@ exports[` > renders Form 1`] = `
-
+
-

+

Edit This -

+
> renders Form 1`] = `
-
+
`; diff --git a/src/components/FormFeedback/FormFeedback.module.scss b/packages/ui-react/src/components/FormFeedback/FormFeedback.module.scss similarity index 87% rename from src/components/FormFeedback/FormFeedback.module.scss rename to packages/ui-react/src/components/FormFeedback/FormFeedback.module.scss index cdab3a135..501a4dc41 100644 --- a/src/components/FormFeedback/FormFeedback.module.scss +++ b/packages/ui-react/src/components/FormFeedback/FormFeedback.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .formFeedback { margin-bottom: 24px; diff --git a/src/components/FormFeedback/FormFeedback.test.tsx b/packages/ui-react/src/components/FormFeedback/FormFeedback.test.tsx similarity index 100% rename from src/components/FormFeedback/FormFeedback.test.tsx rename to packages/ui-react/src/components/FormFeedback/FormFeedback.test.tsx diff --git a/src/components/FormFeedback/FormFeedback.tsx b/packages/ui-react/src/components/FormFeedback/FormFeedback.tsx similarity index 100% rename from src/components/FormFeedback/FormFeedback.tsx rename to packages/ui-react/src/components/FormFeedback/FormFeedback.tsx diff --git a/src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap b/packages/ui-react/src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap similarity index 100% rename from src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap rename to packages/ui-react/src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap diff --git a/src/components/Header/Header.module.scss b/packages/ui-react/src/components/Header/Header.module.scss similarity index 77% rename from src/components/Header/Header.module.scss rename to packages/ui-react/src/components/Header/Header.module.scss index 1f657d030..a21a924cb 100644 --- a/src/components/Header/Header.module.scss +++ b/packages/ui-react/src/components/Header/Header.module.scss @@ -1,7 +1,8 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; -@use 'src/styles/mixins/responsive'; -@use 'src/styles/mixins/utils'; +@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'; +@use '@jwp/ott-ui-react/src/styles/mixins/utils'; // // Header @@ -86,6 +87,34 @@ } } +.skipToContent { + @include accessibility.focus-visible() using ($value) { + box-shadow: $value; + } + + position: absolute; + top: 50px; + width: 1px; + height: 1px; + overflow: hidden; + color: var(--highlight-contrast-color, black); + font-weight: bold; + border-radius: 20px; + clip: rect(1px, 1px, 1px, 1px); + + &:focus { + z-index: 1; + width: auto; + height: auto; + padding: calc(#{variables.$base-spacing} / 2); + white-space: nowrap; + text-decoration: none; + background: var(--highlight-color, white); + box-shadow: theme.$panel-box-shadow; + clip: auto; + } +} + // // Header actions // diff --git a/src/components/Header/Header.test.tsx b/packages/ui-react/src/components/Header/Header.test.tsx similarity index 74% rename from src/components/Header/Header.test.tsx rename to packages/ui-react/src/components/Header/Header.test.tsx index 6888a5a93..0f0d40d08 100644 --- a/src/components/Header/Header.test.tsx +++ b/packages/ui-react/src/components/Header/Header.test.tsx @@ -1,27 +1,23 @@ import React from 'react'; import { render } from '@testing-library/react'; +import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; -import Header from './Header'; +import Button from '../Button/Button'; -import Button from '#components/Button/Button'; -import AccountController from '#src/stores/AccountController'; +import Header from './Header'; vi.mock('react-router-dom', () => ({ NavLink: () => 'a', })); -vi.mock('#src/modules/container', () => ({ - getModule: (type: typeof AccountController) => { - switch (type) { - case AccountController: - return { - logout: vi.fn(() => null), - }; - } - }, -})); - describe('
', () => { + beforeEach(() => { + // TODO: remove AccountController from component + mockService(AccountController, { getFeatures: () => DEFAULT_FEATURES }); + }); + test('renders header', () => { const playlistMenuItems = [ - -
+ +
-

+

user:payment.billing_history -

+
> renders and matches snapshot 1`] = `
- + `; diff --git a/src/components/PaymentFailed/PaymentFailed.module.scss b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss similarity index 54% rename from src/components/PaymentFailed/PaymentFailed.module.scss rename to packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss index 9785ac180..557d8f05e 100644 --- a/src/components/PaymentFailed/PaymentFailed.module.scss +++ b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .title { font-weight: var(--body-font-weight-bold); diff --git a/src/components/PaymentFailed/PaymentFailed.test.tsx b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.test.tsx similarity index 100% rename from src/components/PaymentFailed/PaymentFailed.test.tsx rename to packages/ui-react/src/components/PaymentFailed/PaymentFailed.test.tsx diff --git a/src/components/PaymentFailed/PaymentFailed.tsx b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx similarity index 92% rename from src/components/PaymentFailed/PaymentFailed.tsx rename to packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx index 0f7941567..db17d9d08 100644 --- a/src/components/PaymentFailed/PaymentFailed.tsx +++ b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import styles from './PaymentFailed.module.scss'; +import Button from '../Button/Button'; -import Button from '#components/Button/Button'; +import styles from './PaymentFailed.module.scss'; type Props = { type: 'error' | 'cancelled'; - message?: string; + message: string | null; onCloseButtonClick: () => void; }; diff --git a/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap b/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap similarity index 100% rename from src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap rename to packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap diff --git a/src/components/PaymentForm/PaymentForm.module.scss b/packages/ui-react/src/components/PaymentForm/PaymentForm.module.scss similarity index 100% rename from src/components/PaymentForm/PaymentForm.module.scss rename to packages/ui-react/src/components/PaymentForm/PaymentForm.module.scss diff --git a/src/components/PaymentForm/PaymentForm.tsx b/packages/ui-react/src/components/PaymentForm/PaymentForm.tsx similarity index 80% rename from src/components/PaymentForm/PaymentForm.tsx rename to packages/ui-react/src/components/PaymentForm/PaymentForm.tsx index 4e4f88860..310aac00f 100644 --- a/src/components/PaymentForm/PaymentForm.tsx +++ b/packages/ui-react/src/components/PaymentForm/PaymentForm.tsx @@ -1,21 +1,23 @@ 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 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 styles from './PaymentForm.module.scss'; -import useForm from '#src/hooks/useForm'; -import useCheckAccess from '#src/hooks/useCheckAccess'; -import CheckoutController from '#src/stores/CheckoutController'; -import { getModule } from '#src/modules/container'; - type Props = { couponCode?: string; setUpdatingOrder: (value: boolean) => void; @@ -25,14 +27,24 @@ const PaymentForm: React.FC = ({ couponCode, setUpdatingOrder }) => { const checkoutController = getModule(CheckoutController); 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); - await checkoutController.directPostCardPayment({ couponCode, ...paymentData.values }); - intervalCheckAccess({ interval: 15000 }); + + 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) => { diff --git a/src/components/PaymentMethodForm/PaymentMethodForm.module.scss b/packages/ui-react/src/components/PaymentMethodForm/PaymentMethodForm.module.scss similarity index 88% rename from src/components/PaymentMethodForm/PaymentMethodForm.module.scss rename to packages/ui-react/src/components/PaymentMethodForm/PaymentMethodForm.module.scss index 65c26dc47..19bb1121b 100644 --- a/src/components/PaymentMethodForm/PaymentMethodForm.module.scss +++ b/packages/ui-react/src/components/PaymentMethodForm/PaymentMethodForm.module.scss @@ -1,6 +1,6 @@ -@use '../../styles/variables'; -@use '../../styles/theme'; -@use '../../styles/mixins/responsive'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .title { diff --git a/src/components/PaymentMethodForm/PaymentMethodForm.tsx b/packages/ui-react/src/components/PaymentMethodForm/PaymentMethodForm.tsx similarity index 84% rename from src/components/PaymentMethodForm/PaymentMethodForm.tsx rename to packages/ui-react/src/components/PaymentMethodForm/PaymentMethodForm.tsx index 917aca8ce..d1b6cd085 100644 --- a/src/components/PaymentMethodForm/PaymentMethodForm.tsx +++ b/packages/ui-react/src/components/PaymentMethodForm/PaymentMethodForm.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import type { PaymentMethod } from '@jwp/ott-common/types/checkout'; +import CreditCard from '@jwp/ott-theme/assets/icons/creditcard.svg?react'; +import PayPal from '@jwp/ott-theme/assets/icons/paypal.svg?react'; -import styles from './PaymentMethodForm.module.scss'; +import Button from '../Button/Button'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; +import Icon from '../Icon/Icon'; -import CreditCard from '#src/icons/CreditCard'; -import PayPal from '#src/icons/PayPal'; -import Button from '#components/Button/Button'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import type { PaymentMethod } from '#types/checkout'; +import styles from './PaymentMethodForm.module.scss'; type Props = { paymentMethodId?: number; @@ -29,6 +30,7 @@ const PaymentMethodForm: React.FC = ({ updateSuccess, }) => { const { t } = useTranslation('account'); + // t('payment.longer_than_usual'); const cardPaymentMethod = paymentMethods?.find((method) => method.methodName === 'card'); const paypalPaymentMethod = paymentMethods?.find((method) => method.methodName === 'paypal'); @@ -56,7 +58,7 @@ const PaymentMethodForm: React.FC = ({ onChange={onPaymentMethodChange} /> @@ -73,7 +75,7 @@ const PaymentMethodForm: React.FC = ({ onChange={onPaymentMethodChange} /> ) : null} diff --git a/src/components/PersonalDetailsForm/PersonalDetailsForm.module.scss b/packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.module.scss similarity index 62% rename from src/components/PersonalDetailsForm/PersonalDetailsForm.module.scss rename to packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.module.scss index 372376b57..1efc9dd0d 100644 --- a/src/components/PersonalDetailsForm/PersonalDetailsForm.module.scss +++ b/packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.module.scss @@ -1,5 +1,5 @@ -@use 'src/styles/variables'; -@use 'src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; .title { margin-bottom: 24px; diff --git a/src/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx b/packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx similarity index 97% rename from src/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx rename to packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx index c72522a4d..5d339ff12 100644 --- a/src/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx +++ b/packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; -import PersonalDetailsForm from '#components/PersonalDetailsForm/PersonalDetailsForm'; +import PersonalDetailsForm from './PersonalDetailsForm'; const noop = () => { /**/ diff --git a/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx b/packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx similarity index 91% rename from src/components/PersonalDetailsForm/PersonalDetailsForm.tsx rename to packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx index d62f010d7..acffd987c 100644 --- a/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx +++ b/packages/ui-react/src/components/PersonalDetailsForm/PersonalDetailsForm.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import type { FormErrors } from '@jwp/ott-common/types/form'; +import type { CleengCaptureField, CleengCaptureQuestionField, PersonalDetailsFormData } from '@jwp/ott-common/types/account'; +import { testId } from '@jwp/ott-common/src/utils/common'; -import styles from './PersonalDetailsForm.module.scss'; +import TextField from '../TextField/TextField'; +import Button from '../Button/Button'; +import Dropdown from '../Dropdown/Dropdown'; +import Checkbox from '../Checkbox/Checkbox'; +import Radio from '../Radio/Radio'; +import DateField from '../DateField/DateField'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; +import FormFeedback from '../FormFeedback/FormFeedback'; -import TextField from '#components/TextField/TextField'; -import Button from '#components/Button/Button'; -import Dropdown from '#components/Dropdown/Dropdown'; -import Checkbox from '#components/Checkbox/Checkbox'; -import Radio from '#components/Radio/Radio'; -import DateField from '#components/DateField/DateField'; -import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay'; -import FormFeedback from '#components/FormFeedback/FormFeedback'; -import { testId } from '#src/utils/common'; -import type { FormErrors } from '#types/form'; -import type { PersonalDetailsFormData, CleengCaptureField, CleengCaptureQuestionField } from '#types/account'; +import styles from './PersonalDetailsForm.module.scss'; type Props = { onSubmit: React.FormEventHandler; diff --git a/src/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap b/packages/ui-react/src/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap similarity index 92% rename from src/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap rename to packages/ui-react/src/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap index 84e309a07..aae29ec1b 100644 --- a/src/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap +++ b/packages/ui-react/src/components/PersonalDetailsForm/__snapshots__/PersonalDetailsForm.test.tsx.snap @@ -21,7 +21,7 @@ exports[` > Renders with errors 1`] = ` > @@ -30,7 +30,7 @@ exports[` > Renders with errors 1`] = ` > > Renders with errors 1`] = ` > @@ -58,7 +58,7 @@ exports[` > Renders with errors 1`] = ` > > Renders with errors 1`] = ` >
setSideBarOpen(false)}> {menu.map((item) => ( - + ))}
{renderUserActions(sideBarOpen)} diff --git a/packages/ui-react/src/containers/Profiles/CreateProfile.tsx b/packages/ui-react/src/containers/Profiles/CreateProfile.tsx index 104ae133f..ffd41f9cb 100644 --- a/packages/ui-react/src/containers/Profiles/CreateProfile.tsx +++ b/packages/ui-react/src/containers/Profiles/CreateProfile.tsx @@ -5,6 +5,7 @@ import type { UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; import { useCreateProfile, useProfileErrorHandler, useProfiles } from '@jwp/ott-hooks-react/src/useProfiles'; import type { ProfileFormValues } from '@jwp/ott-common/types/profiles'; import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; +import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; import styles from '../../pages/User/User.module.scss'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; @@ -39,7 +40,7 @@ const CreateProfile = () => { onSuccess: (res) => { const id = res?.responseData?.id; - !!id && navigate(createURL(`/u/profiles`, { success: 'true', id })); + !!id && navigate(createURL(PATH_USER_PROFILES, { success: 'true', id })); }, }); diff --git a/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx b/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx index 052e7690f..87e2f1a59 100644 --- a/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx +++ b/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx @@ -3,6 +3,7 @@ import { Navigate, useLocation, useNavigate, useParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import useQueryParam from '@jwp/ott-ui-react/src/hooks/useQueryParam'; import { useDeleteProfile } from '@jwp/ott-hooks-react/src/useProfiles'; +import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; import Button from '../../components/Button/Button'; import Dialog from '../../components/Dialog/Dialog'; @@ -28,7 +29,7 @@ const DeleteProfile = () => { const deleteProfile = useDeleteProfile({ onMutate: closeHandler, - onSuccess: () => navigate('/u/profiles'), + onSuccess: () => navigate(PATH_USER_PROFILES), onError: () => setIsDeleting(false), }); diff --git a/packages/ui-react/src/containers/Profiles/EditProfile.tsx b/packages/ui-react/src/containers/Profiles/EditProfile.tsx index a429f9d22..44eb91855 100644 --- a/packages/ui-react/src/containers/Profiles/EditProfile.tsx +++ b/packages/ui-react/src/containers/Profiles/EditProfile.tsx @@ -9,6 +9,7 @@ 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'; import type { ProfileFormValues } from '@jwp/ott-common/types/profiles'; +import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; import styles from '../../pages/User/User.module.scss'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; @@ -58,10 +59,10 @@ const EditProfile = ({ contained = false }: EditProfileProps) => { }, [profileDetails?.avatar_url]); if (!id || (!isFetching && !isLoading && !profileDetails)) { - navigate('/u/profiles'); + navigate(PATH_USER_PROFILES); } - const updateProfile = useUpdateProfile({ onSuccess: () => navigate('/u/profiles') }); + const updateProfile = useUpdateProfile({ onSuccess: () => navigate(PATH_USER_PROFILES) }); const handleErrors = useProfileErrorHandler(); diff --git a/packages/ui-react/src/containers/Profiles/Form.tsx b/packages/ui-react/src/containers/Profiles/Form.tsx index c5da495d5..5c52f7d1c 100644 --- a/packages/ui-react/src/containers/Profiles/Form.tsx +++ b/packages/ui-react/src/containers/Profiles/Form.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; import type { ProfileFormValues } from '@jwp/ott-common/types/profiles'; +import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; import styles from '../../pages/User/User.module.scss'; import Button from '../../components/Button/Button'; @@ -109,7 +110,7 @@ const Form = ({ initialValues, formHandler, selectedAvatar, showCancelButton = t <> + + + + `; 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 24/79] 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 25/79] 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 26/79] 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 27/79] 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 28/79] 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 29/79] 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 30/79] 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 38/79] 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 39/79] 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 40/79] 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 41/79] 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 42/79] 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 43/79] 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`] = `