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 deleted file mode 100644 index 5b5e22f65..000000000 --- a/.depcheckrc.yaml +++ /dev/null @@ -1,30 +0,0 @@ -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 - ] diff --git a/.env b/.env deleted file mode 100644 index 90a0dd00d..000000000 --- a/.env +++ /dev/null @@ -1,8 +0,0 @@ -APP_API_BASE_URL=https://cdn.jwplayer.com -APP_PLAYER_ID=M4qoGvUk - -# the default language that the app should load when the language couldn't be detected -APP_DEFAULT_LANGUAGE=en - -# a comma separated list of languages that are enabled (this only works for languages that are enabled in the app) -APP_ENABLED_LANGUAGES=en,es \ No newline at end of file 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/release-deploy-prod-demo.yml b/.github/workflows/release-deploy-prod-demo.yml deleted file mode 100644 index c4186efb3..000000000 --- a/.github/workflows/release-deploy-prod-demo.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Release - Deploy Prod Demo Site - -on: - push: - branches: ['release'] - workflow_dispatch: - -jobs: - deploy_live_website: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Build - env: - APP_PLAYER_LICENSE_KEY: ${{ secrets.PLAYER_LICENSE_KEY }} - APP_GOOGLE_SITE_VERIFICATION_ID: ${{ vars.GOOGLE_SITE_VERIFICATION_ID }} - run: yarn && MODE=demo yarn build - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: '${{ secrets.GITHUB_TOKEN }}' - firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' - channelId: live diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml deleted file mode 100644 index a2188c410..000000000 --- a/.github/workflows/test-e2e.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Test - End to End - -on: - pull_request: - push: - branches: [ 'develop', 'release' ] - schedule: - - cron: '30 3 * * 1-5' - workflow_dispatch: - -jobs: - test-e2e: - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - node-version: [18.x] - config: [desktop, mobile] - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - name: Install dependencies - run: | - yarn - yarn global add wait-on - - name: Start preview server - run: yarn start:test & - - name: Run tests - run: wait-on -v -t 60000 -c ./scripts/waitOnConfig.js http-get://localhost:8080 && yarn codecept:${{ matrix.config }} - env: - TEST_RETRY_COUNT: 2 - WORKER_COUNT: 2 - - name: Uploading artifact - if: always() - uses: actions/upload-artifact@v3 - with: - name: allure-report-${{ matrix.config }} - path: ./test-e2e/output/${{ matrix.config }} - retention-days: 7 diff --git a/.github/workflows/test-preview-and-lighthouse.yml b/.github/workflows/test-preview-and-lighthouse.yml deleted file mode 100644 index f6e304754..000000000 --- a/.github/workflows/test-preview-and-lighthouse.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Test - Deploy Preview and Lighthouse Test - -on: - pull_request: - -jobs: - build_and_preview: - name: Build and preview - runs-on: ubuntu-latest - outputs: - output1: ${{ steps.firebase_hosting_preview.outputs.details_url }} - steps: - - uses: actions/checkout@v3 - - name: Build Preview Link - env: - APP_PLAYER_LICENSE_KEY: ${{ secrets.PLAYER_LICENSE_KEY }} - run: yarn && MODE=preview yarn build - - uses: FirebaseExtended/action-hosting-deploy@v0 - id: firebase_hosting_preview - with: - repoToken: '${{ secrets.GITHUB_TOKEN }}' - firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' - expires: 30d - - lhci: - name: Lighthouse - runs-on: ubuntu-latest - needs: build_and_preview - steps: - - uses: actions/checkout@v3 - - name: Install Lighthouse CI - run: sudo yarn global add @lhci/cli@0.12.x - - name: Run Lighthouse CI - run: lhci autorun --collect.url=${{ needs.build_and_preview.outputs.output1 }}?app-config=gnnuzabk --config=./lighthouserc.js 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/web-release-deploy-prod-demo.yml b/.github/workflows/web-release-deploy-prod-demo.yml new file mode 100644 index 000000000..108934e81 --- /dev/null +++ b/.github/workflows/web-release-deploy-prod-demo.yml @@ -0,0 +1,27 @@ +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 + steps: + - uses: actions/checkout@v3 + - name: Build + env: + APP_PLAYER_LICENSE_KEY: ${{ secrets.PLAYER_LICENSE_KEY }} + APP_GOOGLE_SITE_VERIFICATION_ID: ${{ vars.GOOGLE_SITE_VERIFICATION_ID }} + run: yarn && MODE=demo yarn build + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' + channelId: live + entryPoint: './platforms/web' diff --git a/.github/workflows/web-test-deploy-preview-and-lighthouse.yml b/.github/workflows/web-test-deploy-preview-and-lighthouse.yml new file mode 100644 index 000000000..dd984638c --- /dev/null +++ b/.github/workflows/web-test-deploy-preview-and-lighthouse.yml @@ -0,0 +1,39 @@ +name: Web - Test - PR Deploy Preview and Lighthouse Test + +on: + pull_request: + +defaults: + run: + working-directory: ./platforms/web + +jobs: + build_and_preview: + name: Build and preview + runs-on: ubuntu-latest + outputs: + output1: ${{ steps.firebase_hosting_preview.outputs.details_url }} + steps: + - uses: actions/checkout@v3 + - name: Build Preview Link + env: + APP_PLAYER_LICENSE_KEY: ${{ secrets.PLAYER_LICENSE_KEY }} + run: yarn && MODE=preview yarn build + - 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 + + lhci: + name: Lighthouse + runs-on: ubuntu-latest + needs: build_and_preview + steps: + - uses: actions/checkout@v3 + - name: Install Lighthouse CI + run: sudo yarn global add @lhci/cli@0.12.x + - name: Run Lighthouse CI + run: lhci autorun --collect.url=${{ needs.build_and_preview.outputs.output1 }}?app-config=gnnuzabk --config=./lighthouserc.cjs diff --git a/.github/workflows/web-test-deploy-preview-aws.yml b/.github/workflows/web-test-deploy-preview-aws.yml new file mode 100644 index 000000000..3f788b9be --- /dev/null +++ b/.github/workflows/web-test-deploy-preview-aws.yml @@ -0,0 +1,84 @@ +# Example workflow to deploy the web platform to an AWS S3 bucket on pull requests +# This workflow is disabled for the OTT Web App +# +# See +# - https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html +# - https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-policies-s3.html#iam-policy-ex0 +# +# Create an environment named `Deployment Previews` or change the environment.name below +# +# Environment variables: +# - DEPLOYMENT_PREVIEWS - Set this any value to enable this workflow +# - AWS_ACCESS_KEY_ID - The AWS access key id +# - AWS_REGION - The AWS region where the bucket is created. Example `us-west-1` +# - CLOUDFRONT_ID - Optionally define the AWS Cloudfront ID to invalidate the cache after the build +# +# Environment secrets: +# - AWS_SECRET_ACCESS_KEY - The AWS secret access key + +name: Web - Test - PR Deploy Preview +run-name: Deploy Preview - PR ${{ github.event.pull_request.number || '0' }} + +on: + pull_request: + +defaults: + run: + working-directory: ./platforms/web + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: Deployment Previews + url: ${{ format('https://pr-{0}.{1}?app-config={2}', github.event.pull_request.number || '0' , vars.PREVIEW_DOMAIN, vars.PREVIEW_DEFAULT_CONFIG) }} + if: ${{ vars.DEPLOYMENT_PREVIEWS != '' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install packages + run: yarn install --frozen-lockfile + - name: Build + run: | + yarn build + env: + MODE: demo + APP_GTM_TAG_ID: ${{ vars.APP_GTM_TAG_ID }} + APP_FOOTER_TEXT: ${{ vars.APP_FOOTER_TEXT }} + APP_BODY_FONT_FAMILY: ${{ vars.APP_BODY_FONT_FAMILY }} + APP_BODY_ALT_FONT_FAMILY: ${{ vars.APP_BODY_ALT_FONT_FAMILY }} + APP_DEFAULT_CONFIG_SOURCE: ${{ vars.APP_DEFAULT_CONFIG_SOURCE }} + APP_PLAYER_LICENSE_KEY: ${{ vars.APP_PLAYER_LICENSE_KEY }} + - uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ vars.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + - name: Copy dist files to S3 Bucket + run: | + aws s3 sync build/public s3://$BUCKET/$DIR --cache-control max-age=60 --delete + env: + BUCKET: ${{ vars.BUCKET }} + DIR: ${{ github.event.pull_request.number || '0' }} + - name: Set different cache control for index.html + run: | + aws s3 cp build/public/index.html s3://$BUCKET/$DIR/index.html --cache-control max-age=0,no-cache + env: + BUCKET: ${{ vars.BUCKET }} + DIR: ${{ github.event.pull_request.number || '0' }} + - name: Set different cache control for files in assets folder + run: | + aws s3 cp build/public/assets s3://$BUCKET/$DIR/assets --cache-control max-age=31536000 --recursive + env: + BUCKET: ${{ vars.BUCKET }} + DIR: ${{ github.event.pull_request.number || '0' }} + - name: Invalidate cloudfront distribution + if: ${{ vars.CLOUDFRONT_ID }} + run: aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_ID --paths "/$DIR/images/*" "/$DIR/*.*" + env: + CLOUDFRONT_ID: ${{ vars.CLOUDFRONT_ID }} + DIR: ${{ github.event.pull_request.number || '0' }} diff --git a/.github/workflows/web-test-e2e.yml b/.github/workflows/web-test-e2e.yml new file mode 100644 index 000000000..928541529 --- /dev/null +++ b/.github/workflows/web-test-e2e.yml @@ -0,0 +1,48 @@ +name: Web - Test - End to End + +on: + pull_request: + push: + branches: [ 'develop', 'release' ] + schedule: + - cron: '30 3 * * 1-5' + workflow_dispatch: + +defaults: + run: + working-directory: ./platforms/web + +jobs: + test-e2e: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: [18.x] + config: [desktop, mobile] + + steps: + - uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: | + yarn + yarn global add wait-on + - name: Start preview server + run: yarn start:test & + - name: Run tests + run: wait-on -v -t 60000 -c ./scripts/waitOnConfig.js http-get://localhost:8080 && yarn codecept:${{ matrix.config }} + env: + TEST_RETRY_COUNT: 2 + WORKER_COUNT: 2 + - name: Uploading artifact + if: always() + uses: actions/upload-artifact@v3 + with: + name: allure-report-${{ matrix.config }} + path: ./platforms/web/test-e2e/output/${{ matrix.config }} + retention-days: 7 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..5de916305 --- /dev/null +++ b/.syncpackrc.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://unpkg.com/syncpack@11.2.1/dist/schema.json", + "sortFirst": [ + "name", + "description", + "version", + "private", + "license", + "repository", + "author", + "main", + "exports", + "engines", + "workspaces", + "scripts" + ], + "semverGroups": [ + { + "dependencies": [ + "codeceptjs", + "codeceptjs**", + "react-router", + "react-router-dom", + "typescript" + ], + "packages": [ + "**" + ], + "isIgnored": true + }, + { + "range": "^", + "dependencies": [ + "**" + ], + "packages": [ + "**" + ], + "dependencyTypes": [ + "prod", + "dev", + "peer" + ] + } + ], + "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/CHANGELOG.md b/CHANGELOG.md index 5402f754e..5591a712e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ +## [6.0.0](https://github.com/jwplayer/ott-web-app/compare/v5.1.1...v6.0.0) (2024-03-25) + + +### ⚠ BREAKING CHANGES + +* **project:** restructure for multiplatforms with workspaces (#435) + +### Features + +* **a11y:** many accessibility optimisations ([cc02259](https://github.com/jwplayer/ott-web-app/commit/cc02259ddb8faeed27813ddce850b5653fe1d0d3)), closes [#48](https://github.com/jwplayer/ott-web-app/issues/48) [#47](https://github.com/jwplayer/ott-web-app/issues/47) [#46](https://github.com/jwplayer/ott-web-app/issues/46) +* **a11y:** update font colors for contrast and adjust active state in header ([#76](https://github.com/jwplayer/ott-web-app/issues/76)) ([6444282](https://github.com/jwplayer/ott-web-app/commit/6444282c6c2adedb93980904ba968c8d5bf2680d)) +* accessibility improvements and bug fixes ([82b5967](https://github.com/jwplayer/ott-web-app/commit/82b5967dcf9415aba21e8f6c7bdaabd57f1589af)), closes [#127](https://github.com/jwplayer/ott-web-app/issues/127) [#109](https://github.com/jwplayer/ott-web-app/issues/109) [#115](https://github.com/jwplayer/ott-web-app/issues/115) [#117](https://github.com/jwplayer/ott-web-app/issues/117) [#116](https://github.com/jwplayer/ott-web-app/issues/116) [#121](https://github.com/jwplayer/ott-web-app/issues/121) [#125](https://github.com/jwplayer/ott-web-app/issues/125) +* **payment:** disable deprecated receipts cleeng ([#458](https://github.com/jwplayer/ott-web-app/issues/458)) ([d37905d](https://github.com/jwplayer/ott-web-app/commit/d37905df4022103e3fdf62ad936d58af6e12617c)) +* **project:** add app content search ([71433ab](https://github.com/jwplayer/ott-web-app/commit/71433abfca80431243effc246afdbe8c6901521e)) +* **project:** customizable footer through env-var ([9d8ff15](https://github.com/jwplayer/ott-web-app/commit/9d8ff150dc298880bd4cdc1323a4fc68f8d0fcba)) +* **project:** dynamic gtm snippet ([6babace](https://github.com/jwplayer/ott-web-app/commit/6babacefc3ae56191625207436fc6faaaca10445)) +* **project:** favicons in different sizes ([a1c6188](https://github.com/jwplayer/ott-web-app/commit/a1c6188ae0e6b634977d9ac0642bad437a31df3b)) +* **project:** restructure for multiplatforms with workspaces ([#435](https://github.com/jwplayer/ott-web-app/issues/435)) ([3e3e2b1](https://github.com/jwplayer/ott-web-app/commit/3e3e2b14c4926e596fad46c2ea7cc99dbced05f0)), closes [#8](https://github.com/jwplayer/ott-web-app/issues/8) +* **project:** update default content-type schemas ([0a9817a](https://github.com/jwplayer/ott-web-app/commit/0a9817a9b6f5bbfaae5ce5967bbf3c16436d2f15)) +* underline for active header item and add lineair gradient ([1d2f25f](https://github.com/jwplayer/ott-web-app/commit/1d2f25f04dc06463a0a37aa4c6fddabab455cdcf)) +* **watchhistory:** change max items limit ([#418](https://github.com/jwplayer/ott-web-app/issues/418)) ([d7db57a](https://github.com/jwplayer/ott-web-app/commit/d7db57ab330630c270412ca8fa39ea94052d7675)) + + +### Bug Fixes + +* **a11y:** close search bar when pressing escape ([7a14497](https://github.com/jwplayer/ott-web-app/commit/7a14497224bc8d159bda1ac3238cc0eb566b5437)) +* **a11y:** constrast enhancement for search field ([b4c3230](https://github.com/jwplayer/ott-web-app/commit/b4c323026bc713519fcd6e1ac8efbb68dc8f8fff)) +* **a11y:** format date call caused an error to be raised ([aef1415](https://github.com/jwplayer/ott-web-app/commit/aef1415229901704af0e15b57fd3a0d42040cbe7)) +* **a11y:** prevent duplicate global a11y selectors ([b3ccaff](https://github.com/jwplayer/ott-web-app/commit/b3ccaffe4ea1249d7ad5a80cfe6e8e20a94ef3b7)) +* **a11y:** remove outline when user is not tabbing ([5fe1665](https://github.com/jwplayer/ott-web-app/commit/5fe1665ad30e08dd7eed234c93243da7df748cce)) +* **a11y:** shelf item navigation with screen reader ([91dc66c](https://github.com/jwplayer/ott-web-app/commit/91dc66cd6b6bdbae3b48c0454fe1da335acc9f91)) +* **account:** delete account error ([a2885eb](https://github.com/jwplayer/ott-web-app/commit/a2885eb34598b413e4b81f9cdbb542bbbd849a64)) +* **auth:** capture error to prevent misleading “wrong combination” error ([588f69a](https://github.com/jwplayer/ott-web-app/commit/588f69ac9034736ddf7804bbebaddb141fe30860)) +* click not working in layout grid ([2ded57b](https://github.com/jwplayer/ott-web-app/commit/2ded57b41bbac7a08f5c1d0eeccb149fdcde3242)) +* e2e test optimisations and small fixes ([b700fbb](https://github.com/jwplayer/ott-web-app/commit/b700fbb7ea31cde67740bab99246a0b705058c55)) +* e2e tests for a11y ([c4d09c5](https://github.com/jwplayer/ott-web-app/commit/c4d09c52494b20e0d8e31fb4595f898729817255)) +* enter key not closing the account modal ([1791b4c](https://github.com/jwplayer/ott-web-app/commit/1791b4ca91e161250e354ae96e1d32f0b7bf30c9)) +* favorites and history validation error ([3deabfc](https://github.com/jwplayer/ott-web-app/commit/3deabfc766a75c12ab122a775376449e316ff232)) +* footer overlap fix ([bf79d10](https://github.com/jwplayer/ott-web-app/commit/bf79d108356418c80cfe7a9ddc2496977ae9bb17)) +* hide start watching button in avod platform ([86b461f](https://github.com/jwplayer/ott-web-app/commit/86b461fc9df4b92401055b8c56d8e9d5aec13c99)) +* language menu icon not centered ([ddcfc91](https://github.com/jwplayer/ott-web-app/commit/ddcfc91973548d6cdacc084dd6b88bb3453c6d36)) +* layout grid arrow down and end problem ([6a291a7](https://github.com/jwplayer/ott-web-app/commit/6a291a77f51626c46c082f268f454ff00cde9310)) +* layout grid home and page down problem ([a6305ef](https://github.com/jwplayer/ott-web-app/commit/a6305efbe94b0261786a2adcb76a6530aff3e4cc)) +* logo and header layout issues ([a0cca10](https://github.com/jwplayer/ott-web-app/commit/a0cca10419b9a74b4761d5701e242db2c4e8d562)) +* **menu:** ensure logo does not exceed width of the header ([ea4af42](https://github.com/jwplayer/ott-web-app/commit/ea4af42e23ddefce292a0784c8a0db9b98e4895d)) +* **payment:** incorrect couponCode success message ([c97c59b](https://github.com/jwplayer/ott-web-app/commit/c97c59b7268d540ee37cdb0bb41beb72d8946a7e)) +* **payment:** redirect after incorrect couponcode entry ([ca71f29](https://github.com/jwplayer/ott-web-app/commit/ca71f29298ea6c4af2f5c2b6c4f6379d68385df0)) +* **payment:** subscription offer panel shown for authvod+tvod ([d63b056](https://github.com/jwplayer/ott-web-app/commit/d63b0562e74c624bae0690048467442595928fe2)) +* **payment:** tvod offer not showing in AuthVOD platform ([d01d1b7](https://github.com/jwplayer/ott-web-app/commit/d01d1b71aba628feeb4510cb9b0b9d4132af3cb7)) +* personal shelves restoration ([2741eac](https://github.com/jwplayer/ott-web-app/commit/2741eac5331657ed6156a9e5fba1906c8623227b)) +* **player:** inlineplayer not supporting tvod ([bb593e9](https://github.com/jwplayer/ott-web-app/commit/bb593e97ce2635d9c33c1d027da51075509a7462)) +* **project:** ensure modals obscure underlying elements ([f52a0f3](https://github.com/jwplayer/ott-web-app/commit/f52a0f3377ed1c0d22e47f27995c597d7ec71e55)) +* **project:** fix live stream duration check for ott plugin ([#460](https://github.com/jwplayer/ott-web-app/issues/460)) ([69eff3c](https://github.com/jwplayer/ott-web-app/commit/69eff3c1e9763d2b80e1669809323ee9c1de5f63)) +* **project:** show footer when custom footer is provided ([6503267](https://github.com/jwplayer/ott-web-app/commit/65032674e0ca75e129e5d6fca35e54fc6b690f3d)) +* **project:** undouble serieIds to prevent crash ([ca3d38e](https://github.com/jwplayer/ott-web-app/commit/ca3d38e2ad82235a84cb658da0174095c2eed10e)) +* **project:** unused dep ([72325a6](https://github.com/jwplayer/ott-web-app/commit/72325a63d43ca19f43b9ac1d76a024037889aab0)) +* related videos title layout issue ([361c58a](https://github.com/jwplayer/ott-web-app/commit/361c58a53cb6ae56b84cb6751c7b4ad3d64c702b)) +* restore personal shelves after registration ([3fdb220](https://github.com/jwplayer/ott-web-app/commit/3fdb220ef988383e6af80b72efb7c7d27e6bccb7)) +* root error screen for unexpected errors ([320fe44](https://github.com/jwplayer/ott-web-app/commit/320fe4402f7338816825471de52c21aedb95a144)) +* set wrong loading state in early return ([0837944](https://github.com/jwplayer/ott-web-app/commit/0837944dc4d022ecfe95d6e3f30959d943be9e99)) +* update order error handling ([bf3e5b5](https://github.com/jwplayer/ott-web-app/commit/bf3e5b575624580a38301968a63023748bd9a6df)) +* **user:** tvod subscription not reloaded after login for authvod/avod ([7de84ae](https://github.com/jwplayer/ott-web-app/commit/7de84ae37f174c04bdea26af1818721dfe9956d8)) + ## [5.1.1](https://github.com/jwplayer/ott-web-app/compare/v5.1.0...v5.1.1) (2024-01-24) diff --git a/README.md b/README.md index c897d870b..de3f9da99 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ ![JW OTT Webapp](docs/_images/homepage-screenshot.png) -The JW OTT Webapp is an open-source, dynamically generated video website built around JW Player and JW Platform services. It enables you to easily publish your JW Player-hosted video content with no coding and minimal configuration. +The JW OTT Webapp is an open-source, dynamically generated video website built around JW Player and JW Platform +services. It enables you to easily publish your JW Player-hosted video content with no coding and minimal configuration. **Examples of JW OTT Webapp in action:** @@ -34,23 +35,37 @@ The JW OTT Webapp is an open-source, dynamically generated video website built a - [Configure JW OTT Webapp](./docs/configuration.md) - [Configure Translations](./docs/translations.md) - [Contributing Guidelines](CONTRIBUTING.md) -- [Frameworks, SDKs and Libraries](./docs/frameworks.md) -- [Backend Services](./docs/backend-services.md) +- [Frameworks, SDKs and Libraries](./platforms/web/docs/frameworks.md) +- [Backend Services](./packages/common/docs/backend-services.md) - [Developer Guidelines](./docs/developer-guidelines.md) +- [Workspaces](./docs/workspaces.md) ## Supported Features -- Works with any JW Player edition, from Free to Enterprise (note that usage will count against your monthly JW streaming limits). Only cloud-hosted JW Players are supported. -- It looks great on any device. The responsive UI automatically optimizes itself for desktop, tablet, and mobile screens. -- Populates your site's media content using JSON feeds. If you are using JW Platform, this happens auto-magically based on playlists that you specify. Using feeds from other sources will require you to hack the source code. +- Works with any JW Player edition, from Free to Enterprise (note that usage will count against your monthly JW + streaming limits). Only cloud-hosted JW Players are supported. +- It looks great on any device. The responsive UI automatically optimizes itself for desktop, tablet, and mobile + screens. +- Populates your site's media content using JSON feeds. If you are using JW Platform, this happens auto-magically based + on playlists that you specify. Using feeds from other sources will require you to hack the source code. - Video titles, descriptions and hero images are populated from JW Platform JSON feed metadata. -- Playback of HLS video content from the JW Platform CDN. You can add external URLs (for example, URLS from your own server or CDN) to your playlists in the Content section of your JW Player account dashboard, but they must be HLS streams (`.m3u8` files). +- Playback of HLS video content from the JW Platform CDN. You can add external URLs (for example, URLS from your own + server or CDN) to your playlists in the Content section of your JW Player account dashboard, but they must be HLS + streams (`.m3u8` files). - Support for live video streams (must be registered as external .m3u8 URLs in your JW Dashboard). -- Customize the user interface with your own branding. The default app is configured for JW Player branding and content, but you can easily change this to use your own assets by modifying the `config.json` file. Advanced customization is possible (for example, editing the CSS files), but you will need to modify the source code and [build from source](docs/build-from-source.md). -- Site-wide video search and related video recommendations powered by [JW Recommendations](https://docs.jwplayer.com/platform/docs/vdh-create-a-recommendations-playlist). +- Customize the user interface with your own branding. The default app is configured for JW Player branding and content, + but you can easily change this to use your own assets by modifying the `config.json` file. Advanced customization is + possible (for example, editing the CSS files), but you will need to modify the source code + and [build from source](docs/build-from-source.md). +- Site-wide video search and related video recommendations powered + by [JW Recommendations](https://docs.jwplayer.com/platform/docs/vdh-create-a-recommendations-playlist). - Basic playback analytics is reported to your JW Dashboard. -- Ad integrations (VAST, VPAID, GoogleIMA, etc.). These features require a JW Player Ads Edition license. For more information, see the [JW Player pricing page](https://www.jwplayer.com/pricing/). -- A "Favorites" feature for users to save videos for watching later. A separate list for "Continue Watching" is also kept so users can resume watching videos from where they left off. The lists are per-browser at this time (i.e., lists do not sync across user's browsers or devices). The "Continue Watching" list can be disabled in your JW OTT Webapp's `config.json` file. +- Ad integrations (VAST, VPAID, GoogleIMA, etc.). These features require a JW Player Ads Edition license. For more + information, see the [JW Player pricing page](https://www.jwplayer.com/pricing/). +- A "Favorites" feature for users to save videos for watching later. A separate list for "Continue Watching" is also + kept so users can resume watching videos from where they left off. The lists are per-browser at this time (i.e., lists + do not sync across user's browsers or devices). The "Continue Watching" list can be disabled in your JW OTT + Webapp's `config.json` file. - A grid view for a particular playlist of videos, with the ability to deep-link to the playlist through a static URL. - Social sharing options using the device native sharing dialog. - 24x7 live channel(s) screen with Electronic Programming Guide (EPG) view. @@ -61,14 +76,22 @@ The JW OTT Webapp is an open-source, dynamically generated video website built a ## Getting started -- Clone this repository -- Run `yarn` to install dependencies -- Run `yarn start` +The easiest way to get the OTT Web App running on your machine, use the following commands: + +```shell +$ yarn +$ yarn web start +``` + +These commands will install all dependencies and start a dev server serving the web app. Read +the [full documentation](./platforms/web/README.md) for more information about the web app. ## Support and Bug Reporting -To report bugs and feature requests, or request help using JW OTT Webapp, use this repository's [Issues](https://github.com/jwplayer/ott-web-app/issues) page. +To report bugs and feature requests, or request help using JW OTT Webapp, use this +repository's [Issues](https://github.com/jwplayer/ott-web-app/issues) page. ## Software License -This project is licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). See [LICENSE.txt](LICENSE.txt) for more details. +This project is licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). +See [LICENSE.txt](LICENSE.txt) for more details. 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..ef0e5c49a --- /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": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "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..5ffa0dede --- /dev/null +++ b/configs/eslint-config-jwp/typescript.js @@ -0,0 +1,90 @@ +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: { + // 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/index.js b/configs/postcss-config-jwp/index.js new file mode 100644 index 000000000..935cfc023 --- /dev/null +++ b/configs/postcss-config-jwp/index.js @@ -0,0 +1,14 @@ +const stylelintConfig = require('stylelint-config-jwp'); + +module.exports = { + syntax: 'postcss-scss', + plugins: [ + require('postcss-import')({ + plugins: [ + require('stylelint')({ + config: stylelintConfig, + }), + ], + }), + ], +}; 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..82252eb78 --- /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": { + "postcss": "^8.4.31", + "postcss-import": "^14.0.2", + "postcss-scss": "^4.0.4", + "stylelint": "^15.11.0" + }, + "peerDependencies": { + "stylelint-config-jwp": "*" + } +} diff --git a/configs/stylelint-config-jwp/index.js b/configs/stylelint-config-jwp/index.js new file mode 100644 index 000000000..b2fca04db --- /dev/null +++ b/configs/stylelint-config-jwp/index.js @@ -0,0 +1,313 @@ +const special = ['composes']; + +const positioning = ['position', 'top', 'right', 'bottom', 'left', 'z-index']; + +const boxmodel = [ + 'display', + 'flex', + 'flex-grow', + 'flex-shrink', + 'flex-basis', + 'flex-flow', + 'flex-direction', + 'flex-wrap', + 'justify-content', + 'align-content', + 'align-items', + 'align-self', + 'order', + 'float', + 'clear', + 'box-sizing', + 'width', + 'min-width', + 'max-width', + 'height', + 'min-height', + 'max-height', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-block', + 'margin-block-start', + 'margin-block-end', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-block', + 'padding-block-start', + 'padding-block-end', + 'overflow', + 'overflow-x', + 'overflow-y', +]; + +const typography = [ + 'color', + 'font', + 'font-family', + 'font-weight', + 'font-size', + 'font-style', + 'font-variant', + 'font-size-adjust', + 'font-stretch', + 'font-effect', + 'font-emphasize', + 'font-emphasize-position', + 'font-emphasize-style', + 'font-smooth', + 'line-height', + 'direction', + 'letter-spacing', + 'white-space', + 'text-align', + 'text-align-last', + 'text-transform', + 'text-decoration', + 'text-emphasis', + 'text-emphasis-color', + 'text-emphasis-style', + 'text-emphasis-position', + 'text-indent', + 'text-justify', + 'text-outline', + 'text-wrap', + 'text-overflow', + 'text-overflow-ellipsis', + 'text-overflow-mode', + 'text-orientation', + 'text-shadow', + 'vertical-align', + 'word-wrap', + 'word-break', + 'word-spacing', + 'overflow-wrap', + 'tab-size', + 'hyphens', + 'unicode-bidi', + 'columns', + 'column-count', + 'column-fill', + 'column-gap', + 'column-rule', + 'column-rule-color', + 'column-rule-style', + 'column-rule-width', + 'column-span', + 'column-width', + 'page-break-after', + 'page-break-before', + 'page-break-inside', + 'src', +]; + +const visual = [ + 'list-style', + 'list-style-position', + 'list-style-type', + 'list-style-image', + 'table-layout', + 'empty-cells', + 'caption-side', + 'background', + 'background-color', + 'background-image', + 'background-repeat', + 'background-position', + 'background-position-x', + 'background-position-y', + 'background-size', + 'background-clip', + 'background-origin', + 'background-attachment', + 'background-blend-mode', + 'box-decoration-break', + 'border', + 'border-width', + 'border-style', + 'border-color', + 'border-top', + 'border-top-width', + 'border-top-style', + 'border-top-color', + 'border-right', + 'border-right-width', + 'border-right-style', + 'border-right-color', + 'border-bottom', + 'border-bottom-width', + 'border-bottom-style', + 'border-bottom-color', + 'border-left', + 'border-left-width', + 'border-left-style', + 'border-left-color', + 'border-radius', + 'border-top-left-radius', + 'border-top-right-radius', + 'border-bottom-right-radius', + 'border-bottom-left-radius', + 'border-image', + 'border-image-source', + 'border-image-slice', + 'border-image-width', + 'border-image-outset', + 'border-image-repeat', + 'border-collapse', + 'border-spacing', + 'outline', + 'outline-width', + 'outline-style', + 'outline-color', + 'outline-offset', + 'box-shadow', + 'transform', + 'transform-origin', + 'transform-style', + 'backface-visibility', + 'perspective', + 'perspective-origin', + 'visibility', + 'cursor', + 'opacity', + 'filter', + 'backdrop-filter', +]; + +const animation = [ + 'transition', + 'transition-delay', + 'transition-timing-function', + 'transition-duration', + 'transition-property', + 'animation', + 'animation-name', + 'animation-duration', + 'animation-play-state', + 'animation-timing-function', + 'animation-delay', + 'animation-iteration-count', + 'animation-direction', + 'animation-fill-mode', +]; + +const misc = [ + 'appearance', + 'clip', + 'clip-path', + 'counter-reset', + 'counter-increment', + 'resize', + 'user-select', + 'nav-index', + 'nav-up', + 'nav-right', + 'nav-down', + 'nav-left', + 'pointer-events', + 'quotes', + 'touch-action', + 'will-change', + 'zoom', + 'fill', + 'fill-rule', + 'clip-rule', + 'stroke', +]; + +module.exports = (function () { + return { + extends: ['stylelint-config-recommended-scss'], + + plugins: [ + // Support SCSS + 'stylelint-scss', + + // Automatic rule ordering + 'stylelint-order', + + 'stylelint-declaration-strict-value', + ], + + defaultSeverity: 'error', + + rules: { + 'order/order': [ + // Variables first + 'custom-properties', + 'dollar-variables', + + // Any mixins + { + type: 'at-rule', + name: 'include', + }, + + // Style declarations + 'declarations', + + // Pseudo elements + { + type: 'rule', + name: 'Pseudo Element', + selector: /^&:(:|-)/, + }, + + // Normal child elements + 'rules', + + // State modifier + { + type: 'rule', + name: 'State Modifier', + selector: /^&(:(?!:)|\[)/, + }, + + { + type: 'at-rule', + name: 'include', + parameter: new RegExp('responsive'), + }, + ], + + // Ensure logical and consistent property ordering + 'order/properties-order': [...special, ...positioning, ...boxmodel, ...typography, ...visual, ...animation, ...misc], + + // Ensure that certain properties have a strict variable + 'scale-unlimited/declaration-strict-value': [ + ['/color/', 'z-index'], + { + ignoreValues: { + '': ['currentColor', 'inherit', 'transparent'], + 'z-index': ['-1', '0', '1'], + }, + + disableFix: true, + }, + ], + + // Double colon should be used for pseudo elements + // https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements + 'selector-pseudo-element-colon-notation': 'double', + + // PostCSS takes care of automatic vendor prefixing (not implemented currently) + 'property-no-vendor-prefix': null, + + // No units are needed for zero + 'length-zero-no-unit': true, + + // Prevent using global animations + 'no-unknown-animations': true, + + 'no-descending-specificity': null, + + // 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 deleted file mode 100644 index bf9f7c900..000000000 --- a/docs/backend-services.md +++ /dev/null @@ -1,50 +0,0 @@ -# Backend dependencies and architecture - -The application is built as a single page web app that can run without its own dedicated backend. This is useful for -hosting it with a very simple, static host. The server serves the static web content and the frontend -calls the [JW Player Delivery API](https://developer.jwplayer.com/jwplayer/docs) directly. -However, for additional functionality, the application can also connect to other backends to provide user -accounts / authentication, subscription management, and checkout flows. - -## Roles and Functions - -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) - - login - - register - - getPublisherConsents - - getCustomerConsents - - resetPassword - - changePassword - - updateCustomer - - updateCustomerConsents - - getCustomer - - refreshToken - - getLocales - - getCaptureStatus - - updateCaptureAnswers -- [Subscription](src/services/subscription.service.ts) - - getSubscriptions - - updateSubscription - - getPaymentDetails - - getTransactions -- [Checkout](src/services/checkout.service.ts) - - getOffer - - createOrder - - updateOrder - - getPaymentMethods - - paymentWithoutDetails - - paymentWithAdyen - - paymentWithPayPal - -## Existing Configurations - -### JWP - -The OTT Web App is optimized to work with JWP authentication, subscriptions, and payments. For configuration options see [configuration.md](configuration.md) - -### Cleeng (https://developers.cleeng.com/docs) - -The Web App was also developed with support for Cleeng. Cleeng is a 3rd party platform that also provides support for the 3 functional roles above. diff --git a/docs/configuration.md b/docs/configuration.md index 506121e23..0413dce0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,27 +1,11 @@ # Configuration -The JW OTT Webapp is designed to consume a json configuration file served by the [JWP Delivery API](https://docs.jwplayer.com/platform/reference/get_apps-configs-config-id-json). -The easiest way to maintain configuration files is to use the 'Apps' section in your [JWP Dashboard account](https://dashboard.jwplayer.com/). +The JW OTT Webapp is designed to consume a JSON configuration file served by +the [JWP Delivery API](https://docs.jwplayer.com/platform/reference/get_apps-configs-config-id-json). +The easiest way to maintain configuration files is to use the 'Apps' section in +your [JWP Dashboard account](https://dashboard.jwplayer.com/). -## Configuration File Source - -Which app config file the application uses is determined by the [ini file](initialization-file.md). - -You can specify the default that the application starts with and also which config, if any, it will allow to be set using the [`app-config=` query param](#switching-between-app-configs). -The location is usually specified by the 8-character ID (i.e. `gnnuzabk`) of the App Config from your JWP account, in which case the file will be loaded from the JW Player App Config delivery endpoint (i.e. `https://cdn.jwplayer.com/apps/configs/gnnuzabk.json`). -You may also specify a relative or absolute URL. - -### Switching between app configs - -As mentioned above, if you have 1 or more additional allowed sources (see additionalAllowedConfigSources in [`initialization-file`](initialization-file.md)), you can switch between them by adding `app-config=` as a query parameter in the web app URL in your browser (i.e. `https:///?app-config=gnnuzabk`). - -The parameter is automatically evaluated, loaded, and stored in browser session storage and should remain part of the url as the user navigates around the site. - -> _Note: Be aware that this mechanism only sets the config for the local machine, browser, and session that you are accessing the site with and it does not change the default hosted app for other users._ - -Even sharing URL's should work as long as the query parameter of the desired config is part of the URL. However, once the query parameter is removed and the stored value in the session is released, the application will revert to loading the default config source. - -> _Note: to clear the value from session storage and return to the default, you can navigate to the site with a blank query parameter value (i.e. `?app-config=`)_ +> See the [web configuration documentation](../platforms/web/docs/web-configuration.md) for configuring the OTT Web App. ## Available Configuration Parameters @@ -29,13 +13,15 @@ These are the available configuration parameters for the JW OTT Webapp's config. **siteName** -Title of your website. JW OTT Webapp will automatically update the `` tag of your site to this value when the site loads. If **siteName** is not set, the default name `My OTT Application` will be used. +Title of your website. JW OTT Webapp will automatically update the `<title>` tag of your site to this value when the +site loads. If **siteName** is not set, the default name `My OTT Application` will be used. --- **description** -Short description of your website. JW OTT Webapp will automatically update the `<meta name='description'>` tag in your site to this value, which can help to improve your site's search engine performance. +Short description of your website. JW OTT Webapp will automatically update the `<meta name='description'>` tag in your +site to this value, which can help to improve your site's search engine performance. --- @@ -80,7 +66,8 @@ The label is what the user sees in the header and sidebar. **menu[].contentId** -The eight-character Playlists IDs from the JW Player dashboard. These IDs populate the grid when the user navigates to the given screen. +The eight-character Playlists IDs from the JW Player dashboard. These IDs populate the grid when the user navigates to +the given screen. --- @@ -92,7 +79,9 @@ You can optionally define a list of comma separated tags which are used in the " **content** -Use the `content` array to define which and how content playlists should be displayed in "shelves". For optimal performance and user experience, we recommend a maximum of 10 playlists. See the available options below to configure each shelf separately. +Use the `content` array to define which and how content playlists should be displayed in "shelves". For optimal +performance and user experience, we recommend a maximum of 10 playlists. See the available options below to configure +each shelf separately. ``` { @@ -115,15 +104,19 @@ Use the `content` array to define which and how content playlists should be disp **content[].contentId** -The eight-character Playlists IDs from the JW Player dashboard. These IDs populate the video "shelves" in your site. **contentId** is not required if you use `continue_watching` or `favorites` **type**. +The eight-character Playlists IDs from the JW Player dashboard. These IDs populate the video "shelves" on your site. * +*contentId** is not required if you use `continue_watching` or `favorites` **type**. --- **content[].type** -It is possible to use 'playlist', 'continue_watching' or 'favorites' as a type. With this, you can change the position of the shelves and turn on/off extra `continue_watching` and `favorites` shelves. +It is possible to use 'playlist', 'continue_watching' or 'favorites' as a type. With this, you can change the position +of the shelves and turn on/off extra `continue_watching` and `favorites` shelves. -If you want to include `favorites` / `continue_watching` shelf, you should also add a corresponding playlist with `watchlist` type to features section (`features.favoritesList` and `features.continueWatchingList`). To exclude the shelves, remove a corresponding array item and a playlist in `features`. +If you want to include `favorites` / `continue_watching` shelf, you should also add a corresponding playlist +with `watchlist` type to features section (`features.favoritesList` and `features.continueWatchingList`). To exclude the +shelves, remove a corresponding array item and a playlist in `features`. ``` { @@ -135,7 +128,8 @@ If you want to include `favorites` / `continue_watching` shelf, you should also **content[].title** (optional) -You can change the playlist title and choose a custom one. It is also possible to do for `continue_watching` and `favorites` types. +You can change the playlist title and choose a custom one. It is also possible to do for `continue_watching` +and `favorites` types. --- @@ -147,7 +141,7 @@ Controls if the playlist should be used as a large "Featured" shelf on your JW O **content[].backgroundColor** (optional) -You can change the background color of the shelf with the help of this property (e.g. #ff0000). +You can change the background color of the shelf with the help of this property (e.g., #ff0000). --- @@ -160,8 +154,7 @@ Use the `styling` object to define extra styles for your application. "styling": { "backgroundColor": null, "highlightColor": null, - "headerBackground": null, - "footerText": "Blender Foundation" + "headerBackground": null } ``` @@ -169,13 +162,13 @@ Use the `styling` object to define extra styles for your application. **styling.backgroundColor** (optional) -Override the theme's background color without needing to change CSS (e.g. #ff0000). +Override the theme's background color without needing to change CSS (e.g., #ff0000). --- **styling.highlightColor** (optional) -Controls the color used for certain UI elements such as progress spinner, buttons, etc. The default is light red. +Controls the color used for certain UI elements such as progress spinner, buttons, etc. The default is light-red. Specify the color in hexadecimal format. For example, if you want bright yellow, set it to #ffff00 @@ -183,13 +176,8 @@ Specify the color in hexadecimal format. For example, if you want bright yellow, **styling.headerBackground** (optional) -Use this parameter to change the background color of the header. By default, the header is transparent. Recommended is to use a HEX color (e.g. `#1a1a1a`) so that the contrast color of the buttons and links can be calculated. - ---- - -**styling.footerText** (optional) - -Text that will be placed in the footer of the site. Markdown links are supported. +Use this parameter to change the background color of the header. By default, the header is transparent. Recommended is +to use a HEX color (e.g. `#1a1a1a`) so that the contrast color of the buttons and links can be calculated. --- @@ -209,25 +197,32 @@ Use the `features` object to define extra properties for your app. **features.recommendationsPlaylist** (optional) -The eight-character Playlist ID of the Recommendations playlist that you want to use to populate the "Related Videos" shelf in your site. Note that Recommendations requires a JW Player Enterprise license. For more information about Recommendations playlists, see [this JW Player Support article](https://support.jwplayer.com/customer/portal/articles/2191721-jw-recommendations). +The eight-character Playlist ID of the Recommendations playlist that you want to use to populate the "Related Videos" +shelf in your site. Note that Recommendations requires a JW Player Enterprise license. For more information about +Recommendations playlists, +see [this JW Player Support article](https://support.jwplayer.com/customer/portal/articles/2191721-jw-recommendations). --- **features.searchPlaylist** (optional) -The eight-character Playlist ID of the Search playlist that you want to use to enable search on your site. Note that Search requires a JW Player Enterprise license. For more information about Search playlists, see [this JW Player Support article](https://support.jwplayer.com/customer/portal/articles/2383600-building-managing-data-driven-feeds). +The eight-character Playlist ID of the Search playlist that you want to use to enable search on your site. Note that +Search requires a JW Player Enterprise license. For more information about Search playlists, +see [this JW Player Support article](https://support.jwplayer.com/customer/portal/articles/2383600-building-managing-data-driven-feeds). --- **features.favoritesList** (optional) -The eight-character Playlist ID of the Watchlist playlist that you want to use to populate the "Favorites" shelf in your site. +The eight-character Playlist ID of the Watchlist playlist that you want to use to populate the "Favorites" shelf in your +site. --- **features.continueWatchingList** (optional) -The eight-character Playlist ID of the Watchlist playlist that you want to use to populate the "Continue Watching" shelf in your site. +The eight-character Playlist ID of the Watchlist playlist that you want to use to populate the "Continue Watching" shelf +in your site. --- @@ -251,19 +246,25 @@ Use the `integrations.jwp` object to activate the JWP integrations for authentic **integrations.jwp.clientId** (optional) -The ID of your JWP Authentication and Subscription environment if you would like to activate JWP account, subscription, and payment functionality. Omit this key in your config to disable this functionality. See [docs/backend-services](backend-services.md) for more details. +The ID of your JWP Authentication and Subscription environment if you would like to activate JWP account, subscription, +and payment functionality. Omit this key in your config to disable this functionality. +See [docs/backend-services](backend-services.md) for more details. --- **integrations.jwp.assetId** (optional) -If JWP authentication is enabled, and you want to show the Payments and Subscription functionality as well, you need to include the asset ID. The application uses this ID to map to a subscription offer that you've configured in your JWP environment that represent your subscription options. +If JWP authentication is enabled, and you want to show the Payments and Subscription functionality as well, you need to +include the asset ID. The application uses this ID to map to a subscription offer that you've configured in your JWP +environment that represent your subscription options. --- **integrations.jwp.useSandbox** (optional) -This setting determines which JWP environment is used. If false or not defined, the production environment is used. If true, the test (sandbox) environment is used. Note, this setting is ignored if JWP integrations are not enabled (i.e. there is not clientId defined) +This setting determines which JWP environment is used. If false or not defined, the production environment is used. If +true, the test (sandbox) environment is used. Note, this setting is ignored if JWP integrations are not enabled (i.e. +there is not clientId defined) --- @@ -287,29 +288,41 @@ Use the `integrations.cleeng` object to integrate with Cleeng. **integrations.cleeng.id** (optional) -The ID of your Cleeng ID environment if you would like to integrate with Cleeng as a backend for account, subscription, and checkout functionality. Omit this key in your config to disable Cleeng and the related functionality. See [docs/backend-services](backend-services.md) for more details. +The ID of your Cleeng ID environment if you would like to integrate with Cleeng as a backend for account, subscription, +and checkout functionality. Omit this key in your config to disable Cleeng and the related functionality. +See [docs/backend-services](backend-services.md) for more details. --- **integrations.cleeng.useSandbox** (optional) -This setting determines which Cleeng mediastore URL is used. If false or not defined, the Cleeng production URL is used (https://mediastore.cleeng.com). If true, the Cleeng sandbox URL is used (https://mediastore-sandbox.cleeng.com). Note, this setting is ignored if Cleeng is not enabled (i.e. there is not Cleeng ID defined) +This setting determines which Cleeng mediastore URL is used. If false or not defined, the Cleeng production URL is +used (https://mediastore.cleeng.com). If true, the Cleeng sandbox URL is used (https://mediastore-sandbox.cleeng.com). +Note, this setting is ignored if Cleeng is not enabled (i.e. there is not Cleeng ID defined) --- **integrations.cleeng.monthlyOffer** (optional) -If Cleeng is enabled, and you want to show the Payments and Subscription functionality, you need to include at least 1 offer ID (either this or the yearly offer property.) The application uses this ID to map to an offer that you've configured in your Cleeng environment under Offers to represent your monthly subscription. Note that the only the data used from the Cleeng offer is the price, the free days, and the free period and the app does not verify if the offer length is actually monthly. If no monthly or yearly offer is configured, the Payments section will not be shown. +If Cleeng is enabled, and you want to show the Payments and Subscription functionality, you need to include at least one +offer ID (either this or the yearly offer property). +The application uses this ID to map to an offer that you've configured in your Cleeng environment under Offers to +represent your monthly subscription. +Note that only the data used from the Cleeng offer is the price, the free days, and the free period, +and the app does not verify if the offer length is actually monthly. +If no monthly or yearly offer is configured, the Payments section will not be shown. --- **integrations.cleeng.yearlyOffer** (optional) -If Cleeng is enabled, and you want to show the Payments and Subscription functionality, you need to include at least 1 -offer ID (either this or the monthly offer property.) The application uses this ID to map to an offer that you've -configured in your Cleeng environment under Offers to represent your yearly subscription. Note that the only the data -used from the Cleeng offer is the price, the free days, and the free period and the app does not verify if the offer -length is actually yearly. If no monthly or yearly offer is configured, the Payments section will not be shown. +If Cleeng is enabled, and you want to show the Payments and Subscription functionality, you need to include at least one +offer ID (either this or the monthly offer property). +The application uses this ID to map to an offer that you've configured in your Cleeng environment under Offers to +represent your yearly subscription. +Note that only the data used from the Cleeng offer is the price, the free days, and the free period, +and the app does not verify if the offer length is actually yearly. +If no monthly or yearly offer is configured, the Payments section will not be shown. --- @@ -337,10 +350,10 @@ official [URL Signing Documentation](https://developer.jwplayer.com/jwplayer/doc **contentSigningService.drmPolicyId** (optional) When DRM is enabled for your JW Dashboard Property, all playlist and media requests MUST use the DRM specific endpoints. -When this property is configured, OTT Web App automatically does this for you but all DRM requests must be +When this property is configured, OTT Web App automatically does this for you, but all DRM requests must be signed as well. -For this to work the entitlement service must implement the following endpoints: +For this to work, the entitlement service must implement the following endpoints: **Default public endpoints:** @@ -354,8 +367,8 @@ path. [POST] `${host}/media/${mediaid}/sign_all_public/drm/${drmPolicyId}` -In order to sign multiple media items at once for the favorites and watch history shelves, a different endpoint is used. -The request body contains all media IDs which needs to be signed, for example: +To sign multiple media items at once for the favorites and watch history shelves, a different endpoint is used. +The request body contains all media IDs that need to be signed, for example: ```json { diff --git a/docs/content-types.md b/docs/content-types.md index 106614c70..2d3117b0e 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](../packages/ui-react/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 @@ -13,7 +13,7 @@ To help ensure that content editors use the right content types and custom param You can upload the schema for the content types that your app expects. Content editors will then be able to choose a content type for each media item and will see the expected metadata when they are editing the item on the dashboard. -> Note: Content types on the JWP dashboard requires a specific entitlement. Please speak with your account rep to enable this feature for your account. +> Note: Content types on the JWP dashboard require a specific entitlement. Please speak with your account rep to enable this feature for your account. ## Uploading content types to the dashboard @@ -21,22 +21,22 @@ In order to quickly upload the content types, you can use the yarn script includ `yarn load-content-types --site-id=<site id>` -By default, the script will load the content types that the vanilla web app expects found in [content-types.json](scripts/content-types/content-types.json). -You can modify this file in your fork of the web app code or optionally specify another file to load by adding a `--source-file=<file path>` param to the yarn script call. +By default, the script will load the content types that the vanilla web app expects found in [content-types.json](../scripts/content-types/content-types.json). +You can modify this file in your fork of the web app code or optionally specify another file to load by adding a `--source-file=<file path>` param to the yarn script call. ### Content type upload file definition The upload file should be a json property with the schemas defined as an array on the `schemas` property on the root object. -Please refer to [content-types.json](scripts/content-types/content-types.json) and the JWP documentation for the schema format. +Please refer to [content-types.json](../scripts/content-types/content-types.json) and the JWP documentation for the schema format. -To avoid unnecessary duplication the file also allows some basic abstraction. +To avoid unnecessary duplication, the file also allows some basic abstraction. You can define reused fields and sections as key-value entries on the `fields` and `sections` properties respectively. Then you can include these reusable entities by putting their string key into schemas the same way that you would for inline fields or sections. -There are many examples in the included [content-types.json](scripts/content-types/content-types.json) +There are many examples in the included [content-types.json](../scripts/content-types/content-types.json) -> Note: Although the upload file allows you to define reused fields and sections, when these are uploaded they become distinct copies for each instance in the schemas where they are used. +> Note: Although the upload file allows you to define reused fields and sections, when these are uploaded, they become distinct copies for each instance in the schemas where they are used. > That means that changing fields and sections via the api after they are uploaded must be done individually for each schema. -> Alternatively, you can re-upload and overwrite the existing schemas, but use caution because you will lose any other manual changes you have made. +> Alternatively, you can re-upload and overwrite the existing schemas, but use caution because you will lose any other manual changes you have made. # Content Type Data Structure @@ -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 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..2a6158b75 100644 --- a/docs/developer-guidelines.md +++ b/docs/developer-guidelines.md @@ -1,13 +1,13 @@ ## When working on this project, keep these in mind: -- Use yarn. -- Run the server through `yarn start` -- Run the tests through `yarn test` -- Run the e2e tests through `yarn codecept:mobile` and `yarn codecept:desktop` +- Use `yarn` +- Run all unit tests through `yarn test` - 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 -- The JW organization requires personal access tokens for all of their repositories. In order to create a branch or pull request you'll need to [Generate a Personal Access Token](https://github.com/settings/tokens) and then [store it in your git config](https://stackoverflow.com/questions/46645843/where-to-store-my-git-personal-access-token/67360592). (For token permissions, `repo` should be sufficient). +- Run `yarn depcheck` to validating dependency usages for all packages +- Run `npx syncpack lint` for validating dependency issues for all workspaces +- The JW organization requires personal access tokens for all of their repositories. To create a branch or pull request, you'll need to [Generate a Personal Access Token](https://github.com/settings/tokens) and then [store it in your git config](https://stackoverflow.com/questions/46645843/where-to-store-my-git-personal-access-token/67360592). (For token permissions, `repo` should be sufficient). ## Versioning and Changelog @@ -27,7 +27,9 @@ The GitHub action will update the project package.json, create a release tag in ## Git Commit Guidelines (conventional changelog) -We use the conventional changelog thereby defining very precise rules over how our git commit messages can be formatted. This leads to **more readable messages** that are easy to follow when looking through the **project history**. But also, we allow the git commit messages to **generate the change log**. +We use the conventional changelog, thereby defining very precise rules over how our git commit messages can be formatted. +This leads to **more readable messages** that are easy to follow when looking through the **project history**. +But also, we allow the git commit messages to **generate the change log**. ### Commit Message Format @@ -41,7 +43,8 @@ Each commit message consists of a **header**, a **body** and a **footer**. The h <footer> ``` -The subject line of the commit message cannot be longer 100 characters. This allows the message to be easier to read on GitHub as well as in various git tools. +The subject line of the commit message cannot be longer than 100 characters. +This allows the message to be easier to read on GitHub as well as in various git tools. ### Type @@ -50,8 +53,8 @@ Please use one of the following: - **feat**: A new feature - **fix**: A bug fix - **docs**: Documentation only changes -- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) -- **refactor**: A code change that neither fixes a bug or adds a feature +- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.) +- **refactor**: A code change that neither fixes a bug nor adds a feature - **perf**: A code change that improves performance - **test**: Adding missing tests - **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation @@ -60,25 +63,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 [../.commitlintrc.js](../.commitlintrc.js) file. ### Subject @@ -101,42 +86,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 platforms (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 deleted file mode 100644 index 995cacbdf..000000000 --- a/docs/e2e.md +++ /dev/null @@ -1,59 +0,0 @@ -# e2e tests - -## Instruments used - -We use several libraries for e2e-tests: - -- CodeceptJS as a test launcher -- Playwright as a cross-browser e2e engine -- Allure to build reports - -[Read more.](https://codecept.io/playwright/#setup) - -## 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. - -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). - -`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. - -**TODO:** use `allure.createStep` to have readable steps in allure reports. [Read more.](https://codecept.io/plugins/#allure) - -## Tests launching - -We use several workers to launch tests for each platform. That increases the speed and guaranties the autonomy of each Scenario. - -**(!)** In order to support allure reports it is necessary to install Java 8. - -Basic commands: - -- `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 -- `yarn serve-report:desktop` - to serve allure report from "./output/desktop" folder -- `yarn codecept-serve:mobile` - to run desktop tests and serve the report -- `yarn codecept-serve:desktop` - to run mobile tests and serve the report - -## GitHub Actions - -We have two actions: one for desktop and one for mobile device. Each one runs independently. After the action run it is possible to download an artifact with an allure report and build a nice report locally. - -To do it on Mac: `allure serve ~/Downloads/allure-report-desktop` - -To serve allure reports locally `allure-commandline` package should be installed globally. - -## Simple steps to run tests locally for desktop - -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` diff --git a/docs/features/related-videos.md b/docs/features/related-videos.md index abaf6dabc..323a34f0c 100644 --- a/docs/features/related-videos.md +++ b/docs/features/related-videos.md @@ -19,7 +19,7 @@ GET playlists/fuD6TWcf?related_media_id=dwEE1oBP "description":"", "kind":"FEED", "feedid":"fuD6TWcf", - "playlist":[ + "playlist": [ { "title":"Elephants Dream", "mediaid":"8pN9r7vd", @@ -33,9 +33,8 @@ GET playlists/fuD6TWcf?related_media_id=dwEE1oBP "rating":"CC-BY", "genre":"Drama", "trailerId":"EorcUZCU" - }, -    {} - ] + } + ] } ``` diff --git a/docs/features/user-watchlists.md b/docs/features/user-watchlists.md index f8928df27..4aa61c100 100644 --- a/docs/features/user-watchlists.md +++ b/docs/features/user-watchlists.md @@ -127,10 +127,16 @@ Example data format } ``` -### Max 48 items +### Max amount of items -Cleeng customer `externalData` attribute has maxsize of 5000 symbols. +#### JWP -The length of one stringified object of History equals to 52 symbols, one Favorites object equals to 22 symbols. Taking into account only History objects, we get 5000 / 52 = ~96, so 48 for Favorites and 48 for History. We also leave some extra space for possible further updates. +For JWP the limit is 48 items for Favorites and 48 for Watch History. + +#### Cleeng + +Cleeng customer `externalData` attribute has maxsize of 4000 symbols. + +The length of one stringified object of History equals to 41 symbols, one Favorites object equals to 22 symbols. Taking into account only History objects, we get 4000 / 41 = ~97.56, so 48 for Favorites and 48 for Watch History. We also leave some extra space for possible further updates. We rotate the oldest continue watching object to the first item position after its progress property gets a new value. 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/features/video-protection.md b/docs/features/video-protection.md index 28c9927d1..71d181287 100644 --- a/docs/features/video-protection.md +++ b/docs/features/video-protection.md @@ -11,7 +11,7 @@ This article outlines how such an authorization service should work. ## Signed URLs -With [URL signing](https://support.jwplayer.com/articles/how-to-enable-url-token-signing) enabled on the JW property, a video client can only access the media URLs from JW backends when it has a valid JWT token: +With [URL signing](https://docs.jwplayer.com/platform/reference/protect-your-content-with-signed-urls) enabled on the JW property, a video client can only access the media URLs from JW backends when it has a valid JWT token: ``` GET media/PEEzDfdA?token=<tokenA> diff --git a/docs/modularization.md b/docs/modularization.md deleted file mode 100644 index 8c26de5c3..000000000 --- a/docs/modularization.md +++ /dev/null @@ -1,87 +0,0 @@ -# Architecture - -In order to implement a more structural approach of organizing the code and to reduce coupling we decided to add Dependency Injection (DI) and Inversion of Control (IOC) patterns support for services and controllers present in the application. We expect these patterns to be even more effective when working with different OTT platforms where JS code can be reused. - -## DI library - -InversifyJS is used to provide IOC container and to perform DI for both services and controllers. Injection happens automatically with the help of the reflect-metadata package (by adding `injectable` decorators). - -> **Important:** The type of the service / controller defined in the constructor should be used as a value, without the `type` keyword. - -Won't work: - -``` -import type {CleengService} from './cleeng.service'; - -@injectable() -export default class CleengAccountService extends AccountService { - private readonly cleengService: CleengService; - - constructor(cleengService: CleengService) { - ... - } -} -``` - -Will work: - -``` -import CleengService from './cleeng.service'; - -@injectable() -export default class CleengAccountService extends AccountService { - private readonly cleengService: CleengService; - - constructor(cleengService: CleengService) { - ... - } -} -``` - -This is the price we need to pay to remove `inject` decorators from the constructor to avoid boilerplate code. - -## 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`). - -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. - -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. - -## Controllers and Services - -Both Controllers and Services are defined as classes. We use `injectable` decorator to make them visible for the InversifyJS library. - -> **Important:** Use arrow functions for class methods to avoid lost context. - -### Services - -Business logic should be mostly stored in Services. We use services to communicate with the back-end and to process the data we receive. - -Services also help to manage different dependencies. For example, we can use them to support several integration providers. If this is the case we should also create a common interface and make dependant entities use the interface instead of the actual implementation. This is how inversion of control principle can be respected. Then when we inject services into controllers, we use interface types instead of the implementation classes. - -All in all: - -- Services contain the actual business logic; -- They can be injected into controllers (which orchestrate different services) or into other services; -- We should avoid using services in the View part of the application and prefer controllers instead. However, it is still possible to do in case controllers fully duplicate service's methods (EPG service). In this case we can use a react hook (for the web app) and get access to the service there. -- One service can use provides different implementations. For example, we can split it into Cleeng and JWP implementation (account, checkout and so on). - -> **Important:** Services should be written in an environment / client agnostic way (i.e. no Window usage) to be reused on different platforms (Web, SmartTV and so on). - -### Controllers - -Controllers bind different parts of the application. Controllers use services, store and provide methods to operate with business logic in the UI and in the App. If we need to share code across controllers then it is better to promote the code to the next level (we do it in the AppController). Then it is possible to modify both controllers to call the same (now shared) code. - -- They can be called from the View part of the application; -- They use the data from the Store and from the UI to operate different injected services; -- They use the Store to persist the entities when needed; -- They return data back to the UI when needed. - -> **Important:** We should try to avoid controllers calling each other because it leads to circular dependencies and makes the code messy. However now they do it sometimes (to be refactored). - -### 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. - -`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..05c449083 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -6,7 +6,7 @@ Before changing or adding languages, read the sections below to understand the m ## Translation status -Here is a list of all supported translations included in the OTT Web App. The status indicates if the translations are +Here is a list of all supported translations included in the OTT Web App. The status indicates if the translations are generated using Google Translate/ChatGTP or validated by a person. | Language | Code | Status | Validated by | @@ -17,39 +17,42 @@ generated using Google Translate/ChatGTP or validated by a person. ## Translation files -The web app uses the [react-i18next](https://react.i18next.com/) library for displaying translated strings. Translation -files are defined in JSON format and can be found in the `./public/locales` directory. Translation files are separated -by namespace. +The web app uses the [react-i18next](https://react.i18next.com/) library for displaying translated strings. Translation +files are defined in JSON format and can be found in the `./platforms/web/public/locales` directory. Translation files +are separated by namespace. -The structure of all translation files is generated automatically using the `yarn i18next` command. This script -extracts all namespaces and translation keys from the source code. Refrain from editing the structure (e.g., adding or +> The translation files will be moved to the `./packages/i18n` package in the near future. + +The structure of all translation files is generated automatically using the `yarn i18next` command. This script +extracts all namespaces and translation keys from the source code. Refrain from editing the structure (e.g., adding or removing keys) of translation files manually because this is prone to mistakes. -To add a new language, create a new subdirectory in the `./public/locales` directory using the language code or -LCID string if the language is region specific. For example, using the `fr` language code will be available for all -French-speaking regions (e.g., Belgium, Canada, France, Luxembourg, etc.). But if you have different translations for -one or more French-speaking regions, use the LCID string (e.g., `fr-be`, `fr-ca`, `fr-lu`, or `fr-fr`) instead of the -language code. The downside of this, when having multiple French-speaking regions, a lot of translations will be +To add a new language, create a new subdirectory in the `./platforms/web/public/locales` directory using the language +code or LCID string if the language is region-specific. For example, using the `fr` language code will be available for +all French-speaking regions (e.g., Belgium, Canada, France, Luxembourg, etc.). But if you have different translations +for one or more French-speaking regions, use the LCID string (e.g., `fr-be`, `fr-ca`, `fr-lu`, or `fr-fr`) instead of +the language code. The downside of this, when having multiple French-speaking regions, a lot of translations will be duplicate. -After adding the subdirectory, run the `yarn i18next` command to generate all the added -language(s) translation files. You can now translate each key for the added language(s). +After adding the subdirectory, run the `yarn i18next` command to generate all the added +language(s) translation files. You can now translate each key for the added language(s). ## Defined languages -When a language is added to the `./public/locales` folder, the OTT Web App doesn't automatically recognize this. -Instead, the language must first be added to the "defined languages" list. This is for two reasons: +When a language is added to the `./platforms/web/public/locales` folder, the OTT Web App doesn't automatically recognize +this. Instead, the language must first be added to the "defined languages" list. This is for two reasons: -- As OTT Web App we want to be able to include many languages without enabling them all by default +- 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 -language code (or LCID string) and display name. +Navigate to the `./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 +> If you have added multiple languages using the LCID string identifier, each much be added to the list of defined > languages and ensure to include the region in the `displayName`. For example: `Français Canadien` -The `displayName` is always translated for the language it is written for. This ensures that when the current language is +The `displayName` is always translated for the language it is written for. This ensures that when the current language +is wrong for the current user, he/her will still be able to recognize the language. ```ts @@ -65,23 +68,23 @@ export const DEFINED_LANGUAGES: LanguageDefinition[] = [ ]; ``` -> You won't have to delete entries with languages that are not supported for your OTT Web App. Instead, don't enable -> the language in the app (see the next step). +> You won't have to delete entries with languages that are not supported for your OTT Web App. Instead, don't enable +> the language in the app (see the next step). ## Enabled languages -Languages can be enabled or disabled by updating the `APP_ENABLED_LANGUAGES` environment variable. This can be changed -in the `.env` file or by adding the environment variable to the start/build commands. +Languages can be enabled or disabled by updating the `APP_ENABLED_LANGUAGES` environment variable. This can be changed +in the `/platforms/web/.env` file or by adding the environment variable to the start/build commands. -This disables the multilingual feature by only supporting the English language. The language selection icon will be +This disables the multilingual feature by only supporting the English language. The language selection icon will be hidden in the header. ```shell $ APP_ENABLED_LANGUAGES=en yarn build ``` -This builds an OTT Web App supporting the English and French languages. The language selection icon will be shown in -the header. +This builds an OTT Web App supporting the English and French languages. The language selection icon will be shown in +the header. ```shell $ APP_ENABLED_LANGUAGES=en,fr yarn build @@ -89,14 +92,14 @@ $ APP_ENABLED_LANGUAGES=en,fr yarn build ## Default language -OTT Web App will try to predict the user language by looking at the Browser language. When the language can't be -predicted, or there is no support for the Browser language, the default language will be used. By default, this is set +OTT Web App will try to predict the user language by looking at the Browser language. When the language can't be +predicted, or there is no support for the Browser language, the default language will be used. By default, this is set to `en`. -The default language can be changed in the `.env` file as well or by adding the `APP_DEFAULT_LANGUAGE` environment -variable to the start/build commands. +The default language can be changed in the `/platforms/web/.env` file as well or by adding the `APP_DEFAULT_LANGUAGE` +environment variable to the start/build commands. -Build an OTT Web App with English and French translations, but default to French when the language couldn't be +Build an OTT Web App with English and French translations, but default to French when the language couldn't be predicted. ```shell diff --git a/docs/workspaces.md b/docs/workspaces.md new file mode 100644 index 000000000..7b143091a --- /dev/null +++ b/docs/workspaces.md @@ -0,0 +1,151 @@ +# 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, such as CapacitorJS, React Native, LightningJS 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 crash the app. + +This means that we need to: + +- Make some of the shareable code platform-agnostic +- Make some 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, ...) + +## The solution + +Based on the re-usability of the existing codebase, we've created separate modules using Yarn Workspaces. +This will combine all similar code and prevent installing redundant or conflicting dependencies. + +For example, all components, containers, and pages are combined into the `packages/ui-react` module, which depends on +React and React DOM. +To create a React Native app, you could add a `packages/ui-react-native` module and configure +aliases to use the correct module. + +## 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 done to separate the React from the non-react code. + +Here is a breakdown of each module: + +### Common + +Name: `@jwp/ott-common` + +The common module 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 usage from both 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 module can contain all test fixtures and perhaps 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 usage across multiple 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 modules defined in the packages folder as dependencies. + +### ESLint, PostCSS and Stylelint + +Besides the mentioned packages, there are also three utility packages listed in the configs folder. +These utility packages exist to align linting dependencies and configurations between the different packages and apps. + +All packages depend on Eslint and need a configuration. The recommended way of doing this in a monorepo is by creating +a local package. + +**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'], +}; +``` 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/index.html b/index.html deleted file mode 100644 index ddda3a29f..000000000 --- a/index.html +++ /dev/null @@ -1,41 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8" /> - <title>JW OTT Webapp - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - diff --git a/knip.config.ts b/knip.config.ts new file mode 100644 index 000000000..2a724d99b --- /dev/null +++ b/knip.config.ts @@ -0,0 +1,78 @@ +import type { KnipConfig } from 'knip'; + +const config: KnipConfig = { + ignoreBinaries: [ + // These are installed, but don't have valid package.json bin fields for knip to detect them + 'stylelint', + ], + workspaces: { + '.': { + entry: ['scripts/**/*'], + ignoreDependencies: [ + // Workspace packages + 'eslint-config-jwp', + ], + ignoreBinaries: [ + // false positives from yarn scripts in github actions + 'build', + 'global', + 'start:test', + 'codecept:*', + ], + }, + 'packages/common': { + entry: ['src/**/*'], + }, + 'packages/ui-react': { + entry: ['src/**/*'], + ignoreDependencies: [ + 'sass', // Used in css + ], + }, + 'platforms/web': { + ignoreDependencies: [ + '@codeceptjs/allure-legacy', + '@codeceptjs/configure', // Used in e2e tests + '@babel/plugin-proposal-decorators', // Used to build with decorators for ioc resolution + '@babel/core', // Required peer dependency for babel plugins + '@types/luxon', // Used in tests + 'babel-plugin-transform-typescript-metadata', // Used to build with decorators for ioc resolution + 'eslint-plugin-codeceptjs', // Used by apps/web/test-e2e/.eslintrc.cjs + 'luxon', // Used in tests + 'playwright', // Used in test configs + 'sharp', // Requirement for @vite-pwa/assets-generator + 'tsconfig-paths', // Used for e2e test setup + 'virtual:pwa-register', // Service Worker code is injected at build time, + ], + }, + 'configs/eslint-config-jwp': { + entry: ['*.*'], + ignoreDependencies: [ + // Dynamically loaded in the eslint config + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + 'eslint-plugin-import', + 'eslint-plugin-react', + 'eslint-plugin-react-hooks', + ], + }, + 'configs/postcss-config-jwp': { + ignoreDependencies: [ + // Dynamically loaded in the postcss config + 'postcss-scss', + ], + }, + 'configs/stylelint-config-jwp': { + ignoreDependencies: [ + // Dynamically loaded in the stylelint config + 'stylelint', + 'stylelint-order', + 'stylelint-config-recommended-scss', + 'stylelint-declaration-strict-value', + 'stylelint-scss', + ], + }, + }, +}; + +export default config; 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..6792857f4 100644 --- a/package.json +++ b/package.json @@ -1,156 +1,66 @@ { - "name": "jw-ott-webapp", - "version": "5.1.1", - "main": "index.js", + "name": "@jwp/ott", + "version": "6.0.0", + "private": true, + "license": "Apache-2.0", "repository": "https://github.com/jwplayer/ott-web-app.git", "author": "JW Player", - "private": true, + "main": "index.js", "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": "knip --dependencies", "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:*", + "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", + "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: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" + "lint:ts": "tsc --pretty --noEmit -p ./scripts && yarn workspaces run lint:ts", + "load-content-types": "ts-node ./scripts/content-types/load-content-types", + "pre-commit": "yarn depcheck && lint-staged", + "prepare": "husky install", + "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest", + "web": "yarn --cwd platforms/web" }, "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", + "eslint": "^8.57.0", "husky": "^6.0.0", "i18next-parser": "^8.0.0", - "jsdom": "^22.1.0", - "lint-staged": "^10.5.4", - "luxon": "^3.2.1", + "knip": "^5.0.3", + "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" + "typescript": "5.3.3", + "vitest": "^1.3.1" }, "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", + "glob-parent": "^5.1.2", "json5": "^2.2.2" } } 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/docs/backend-services.md b/packages/common/docs/backend-services.md new file mode 100644 index 000000000..9793b1edb --- /dev/null +++ b/packages/common/docs/backend-services.md @@ -0,0 +1,52 @@ +# Backend dependencies and architecture + +The application is built as a single page web app that can run without its own dedicated backend. This is useful for +hosting it with a basic, static host. The server serves the static web content, and the frontend +calls the [JW Player Delivery API](https://developer.jwplayer.com/jwplayer/docs) directly. +However, for additional functionality, the application can also connect to other backends to provide user +accounts / authentication, subscription management, and checkout flows. + +## Roles and Functions + +The available backend integrations serve three main roles: Accounts, Subscription, and Checkout. +Below are the methods that any backend integration needs to support broken down by role: + +- [Account](../packages/common/src/services/integrations/AccountService.ts) + - login + - register + - getPublisherConsents + - getCustomerConsents + - resetPassword + - changePassword + - updateCustomer + - updateCustomerConsents + - getCustomer + - refreshToken + - getLocales + - getCaptureStatus + - updateCaptureAnswers +- [Subscription](../packages/common/src/services/integrations/SubscriptionService.ts) + - getSubscriptions + - updateSubscription + - getPaymentDetails + - getTransactions +- [Checkout](../packages/common/src/services/integrations/CheckoutService.ts) + - getOffer + - createOrder + - updateOrder + - getPaymentMethods + - paymentWithoutDetails + - paymentWithAdyen + - paymentWithPayPal + +## Existing Configurations + +### JWP + +The OTT Web App is optimized to work with JWP authentication, subscriptions, and payments. +For configuration options see [configuration.md](configuration.md) + +### Cleeng (https://developers.cleeng.com/docs) + +The Web App was also developed with support for Cleeng. +Cleeng is a third party platform that also provides support for the three functional roles above. diff --git a/packages/common/docs/modularization.md b/packages/common/docs/modularization.md new file mode 100644 index 000000000..cdc0e665c --- /dev/null +++ b/packages/common/docs/modularization.md @@ -0,0 +1,116 @@ +# Architecture + +To implement a more structural approach of organizing the code and to reduce coupling, we decided to add Dependency +Injection (DI) and Inversion of Control (IOC) patterns support for services and controllers present in the application. +We expect these patterns to be even more effective when working with different OTT platforms where JS code can be reused. + +## DI library + +InversifyJS is used to provide IOC container and to perform DI for both services and controllers. +Injection happens automatically with the help of the reflect-metadata package (by adding `injectable` decorators). + +> **Important:** The type of the service / controller defined in the constructor should be used as a value, without +> the `type` keyword. + +Won't work: + +``` +import type {CleengService} from './cleeng.service'; + +@injectable() +export default class CleengAccountService extends AccountService { + private readonly cleengService: CleengService; + + constructor(cleengService: CleengService) { + ... + } +} +``` + +Will work: + +``` +import CleengService from './cleeng.service'; + +@injectable() +export default class CleengAccountService extends AccountService { + private readonly cleengService: CleengService; + + constructor(cleengService: CleengService) { + ... + } +} +``` + +This is the price we need to pay to remove `inject` decorators from the constructor to avoid boilerplate code. + +## 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`). + +Initialization starts in the platform index.tsx file where we register services. We do it outside the React component to +make services available in different parts of the application. + +Platforms load all the required data with the help of the [`AppController`](../src/controllers/AppController.ts). The +AppController 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 + +Both Controllers and Services are defined as classes. +We use `injectable` decorator to make them visible for the InversifyJS library. + +> **Important:** Use arrow functions for class methods to avoid lost context. + +### Services + +Business logic should be mostly stored in Services. We use services to communicate with the back-end and to process the +data we receive. + +Services also help to manage different dependencies. +For example, we can use them to support several integration providers. +If this is the case, +we should also create a common interface and make dependent entities +use the interface instead of the actual implementation. +This is how inversion of control principle can be respected. +Then, when we inject services into controllers, we use interface types instead of the implementation classes. + +All in all: + +- Services contain the actual business logic; +- They can be injected into controllers (which orchestrate different services) or into other services; +- We should avoid using services in the View part of the application and prefer controllers instead. + However, it is still possible to do in case controllers fully duplicate service methods (EPG service). + In this case, we can use a React hook (for the web app) and get access to the service there. +- One service can use provides different implementations. + For example, we can split it into Cleeng and JWP implementation (account, checkout and so on). + +> **Important:** Services should be written in an environment / client agnostic way (i.e. no Window usage) to be reused +> on different platforms (Web, SmartTV and so on). + +### Controllers + +Controllers bind different parts of the application. Controllers use services, store and provide methods to operate with +business logic in the UI and in the App. If we need to share code across controllers, then it is better to promote the +code to the next level (we do it in the AppController). Then it is possible to modify both controllers to call the +same (now shared) code. + +- They can be called from the View part of the application; +- They use the data from the Store and from the UI to operate different injected services; +- They use the Store to persist the entities when needed; +- They return data back to the UI when needed. + +> **Important:** We should try to avoid controllers calling each other because it leads to circular dependencies and +> makes the code messy. However, now they sometimes do it (to be refactored). + +### 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. + +`getNamedModule` function is mostly used in controllers to retrieve integration-specific services, like AccountService or +CheckoutService. 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..bda2d81f3 --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,39 @@ +{ + "name": "@jwp/ott-common", + "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-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" + }, + "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/lodash.merge": "^4.6.6", + "jsdom": "^22.1.0", + "timezone-mock": "^1.3.4", + "vi-fetch": "^0.8.0", + "vitest": "^1.3.1" + }, + "peerDependencies": { + "@jwp/ott-testing": "*", + "@jwp/ott-theme": "*", + "eslint-config-jwp": "*" + } +} diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts new file mode 100644 index 000000000..bfaf1d4b8 --- /dev/null +++ b/packages/common/src/constants.ts @@ -0,0 +1,83 @@ +export enum PersonalShelf { + ContinueWatching = 'continue_watching', + Favorites = 'favorites', +} + +export const PersonalShelves = [PersonalShelf.Favorites, PersonalShelf.ContinueWatching]; + +export const INTEGRATION = { + JWP: 'JWP', + CLEENG: 'CLEENG', +} as const; + +export const ACCESS_MODEL = { + AVOD: 'AVOD', + AUTHVOD: 'AUTHVOD', + SVOD: 'SVOD', +} as const; + +export const VideoProgressMinMax = { + Min: 0.05, + Max: 0.95, +}; + +export const PLAYLIST_LIMIT = 25; + +export const ADYEN_TEST_CLIENT_KEY = 'test_I4OFGUUCEVB5TI222AS3N2Y2LY6PJM3K'; + +export const ADYEN_LIVE_CLIENT_KEY = 'live_BQDOFBYTGZB3XKF62GBYSLPUJ4YW2TPL'; + +// how often the live channel schedule is refetched in ms +export const LIVE_CHANNELS_REFETCH_INTERVAL = 15 * 60_000; + +// Some predefined types of JW +export const CONTENT_TYPE = { + // Series page with seasons / episodes + series: 'series', + // Separate episode page + episode: 'episode', + // Page with a list of channels + live: 'live', + // Separate channel page + liveChannel: 'livechannel', + // Static page with markdown + page: 'page', + // Page with shelves list + hub: 'hub', +} as const; + +// OTT shared player +export const OTT_GLOBAL_PLAYER_ID = 'M4qoGvUk'; + +export const CONFIG_QUERY_KEY = 'app-config'; + +export const CONFIG_FILE_STORAGE_KEY = 'config-file-override'; + +export const CACHE_TIME = 60 * 1000 * 20; // 20 minutes + +export const STALE_TIME = 60 * 1000 * 20; + +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 MAX_WATCHLIST_ITEMS_COUNT = 48; // Default value + +export const DEFAULT_FEATURES = { + canUpdateEmail: false, + canSupportEmptyFullName: false, + canChangePasswordWithOldPassword: false, + canRenewSubscription: false, + canExportAccountData: false, + canDeleteAccount: false, + canUpdatePaymentMethod: false, + canShowReceipts: false, + hasSocialURLs: false, + hasNotifications: false, + watchListSizeLimit: MAX_WATCHLIST_ITEMS_COUNT, +}; + +export const simultaneousLoginWarningKey = 'simultaneous_logins'; + +export const EPG_TYPE = { + jwp: 'jwp', + viewNexa: 'viewnexa', +} as const; diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts new file mode 100644 index 000000000..5ee095675 --- /dev/null +++ b/packages/common/src/controllers/AccountController.ts @@ -0,0 +1,555 @@ +import i18next from 'i18next'; +import { inject, injectable } from 'inversify'; + +import { 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, + CustomerConsent, + EmailConfirmPasswordInput, + FirstLastNameInput, + GetCaptureStatusResponse, + SubscribeToNotificationsPayload, +} from '../../types/account'; +import { assertFeature, assertModuleMethod, getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; +import type { ServiceResponse } from '../../types/service'; +import { useAccountStore } from '../stores/AccountStore'; +import { useConfigStore } from '../stores/ConfigStore'; +import { useProfileStore } from '../stores/ProfileStore'; +import { FormValidationError } from '../errors/FormValidationError'; + +import WatchHistoryController from './WatchHistoryController'; +import ProfileController from './ProfileController'; +import FavoritesController from './FavoritesController'; + +@injectable() +export default class AccountController { + private readonly checkoutService: CheckoutService; + private readonly accountService: AccountService; + private readonly subscriptionService: SubscriptionService; + private readonly profileController: ProfileController; + private readonly favoritesController: FavoritesController; + private readonly watchHistoryController: WatchHistoryController; + 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, + favoritesController: FavoritesController, + watchHistoryController: WatchHistoryController, + profileController: ProfileController, + ) { + this.checkoutService = getNamedModule(CheckoutService, integrationType); + this.accountService = getNamedModule(AccountService, integrationType); + this.subscriptionService = getNamedModule(SubscriptionService, integrationType); + + // @TODO: Controllers shouldn't be depending on other controllers, but we've agreed to keep this as is for now + this.favoritesController = favoritesController; + this.watchHistoryController = watchHistoryController; + this.profileController = profileController; + + this.features = integrationType ? this.accountService.features : DEFAULT_FEATURES; + } + + loadUserData = async () => { + try { + const authData = await this.accountService.getAuthData(); + + if (authData) { + await this.getAccount(); + } + } catch (error: unknown) { + logDev('Failed to get user', error); + + // clear the session when the token was invalid + // don't clear the session when the error is unknown (network hiccup or something similar) + if (error instanceof Error && error.message.includes('Invalid JWT token')) { + await this.logout(); + } + } + }; + + 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, 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; + } + + updateUser = async (values: FirstLastNameInput | EmailConfirmPasswordInput): Promise> => { + useAccountStore.setState({ loading: true }); + + const { user } = useAccountStore.getState(); + const { canUpdateEmail, canSupportEmptyFullName } = this.getFeatures(); + + if (Object.prototype.hasOwnProperty.call(values, 'email') && !canUpdateEmail) { + throw new Error('Email update not supported'); + } + + if (!user) { + throw new Error('User not logged in'); + } + + const errors = this.validateInputLength(values as FirstLastNameInput); + if (errors.length) { + return { + errors, + responseData: {} as Customer, + }; + } + + let payload = values; + // this is needed as a fallback when the name is empty (cannot be empty on JWP integration) + if (!canSupportEmptyFullName) { + payload = { ...values, email: user.email }; + } + + const updatedUser = await this.accountService.updateCustomer({ ...payload, id: user.id.toString() }); + + if (!updatedUser) { + throw new Error('Unknown error'); + } + + useAccountStore.setState({ user: updatedUser }); + + return { errors: [], responseData: updatedUser }; + }; + + getAccount = async () => { + const { config } = useConfigStore.getState(); + + try { + const response = await this.accountService.getUser({ config }); + + if (response) { + await this.afterLogin(response.user, response.customerConsents); + } + + useAccountStore.setState({ loading: false }); + } catch (error: unknown) { + useAccountStore.setState({ + user: null, + subscription: null, + transactions: null, + activePayment: null, + customerConsents: null, + publisherConsents: null, + loading: false, + }); + } + }; + + login = async (email: string, password: string, referrer: string) => { + useAccountStore.setState({ loading: true }); + + try { + const response = await this.accountService.login({ email, password, referrer }); + + if (response) { + await this.afterLogin(response.user, response.customerConsents); + return; + } + } catch (error: unknown) { + if (error instanceof Error && error.message.toLowerCase().includes('invalid param email')) { + throw new FormValidationError({ email: [i18next.t('account:login.wrong_email')] }); + } + } finally { + useAccountStore.setState({ loading: false }); + } + + // consider any unknown response as a wrong combinations error (we could specify this even more) + throw new FormValidationError({ form: [i18next.t('account:login.wrong_combination')] }); + }; + + logout = async () => { + await this.accountService?.logout(); + await this.clearLoginState(); + + // let the application know to refresh all entitlements + await this.refreshEntitlements?.(); + }; + + register = async (email: string, password: string, referrer: string, consentsValues: CustomerConsent[]) => { + try { + const response = await this.accountService.register({ email, password, consents: consentsValues, referrer }); + + if (response) { + const { user, customerConsents } = response; + await this.afterLogin(user, customerConsents, true); + + return; + } + } catch (error: unknown) { + if (error instanceof Error) { + const errorMessage = error.message.toLowerCase(); + + if (errorMessage.includes('customer already exists') || errorMessage.includes('account already exists')) { + throw new FormValidationError({ form: [i18next.t('account:registration.user_exists')] }); + } else if (errorMessage.includes('please enter a valid e-mail address')) { + throw new FormValidationError({ email: [i18next.t('account:registration.field_is_not_valid_email')] }); + } else if (errorMessage.includes('invalid param password')) { + throw new FormValidationError({ password: [i18next.t('account:registration.invalid_password')] }); + } + } + } + + // in case the endpoint fails + throw new FormValidationError({ form: [i18next.t('account:registration.failed_to_create')] }); + }; + + updateConsents = async (customerConsents: CustomerConsent[]): Promise> => { + const { getAccountInfo } = useAccountStore.getState(); + const { customer } = getAccountInfo(); + + useAccountStore.setState({ loading: true }); + + try { + const updatedConsents = await this.accountService?.updateCustomerConsents({ + customer, + consents: customerConsents, + }); + + if (updatedConsents) { + useAccountStore.setState({ customerConsents: updatedConsents }); + return { + responseData: updatedConsents, + errors: [], + }; + } + return { + responseData: [], + errors: [], + }; + } finally { + useAccountStore.setState({ loading: false }); + } + }; + + // TODO: Decide if it's worth keeping this or just leave combined with getUser + // noinspection JSUnusedGlobalSymbols + getCustomerConsents = async () => { + const { getAccountInfo } = useAccountStore.getState(); + const { customer } = getAccountInfo(); + + const consents = await this.accountService.getCustomerConsents({ customer }); + + if (consents) { + useAccountStore.setState({ customerConsents: consents }); + } + + return consents; + }; + + getPublisherConsents = async () => { + const { config } = useConfigStore.getState(); + + useAccountStore.setState({ publisherConsentsLoading: true }); + const consents = await this.accountService.getPublisherConsents(config); + + useAccountStore.setState({ publisherConsents: consents, publisherConsentsLoading: false }); + }; + + getCaptureStatus = async (): Promise => { + const { getAccountInfo } = useAccountStore.getState(); + const { customer } = getAccountInfo(); + + return this.accountService.getCaptureStatus({ customer }); + }; + + updateCaptureAnswers = async (capture: Capture): Promise => { + const { getAccountInfo } = useAccountStore.getState(); + const { customer, customerConsents } = getAccountInfo(); + + const updatedCustomer = await this.accountService.updateCaptureAnswers({ customer, ...capture }); + + useAccountStore.setState({ + user: updatedCustomer, + customerConsents, + }); + + return updatedCustomer; + }; + + resetPassword = async (email: string, resetUrl: string) => { + await this.accountService.resetPassword({ + customerEmail: email, + resetUrl, + }); + }; + + changePasswordWithOldPassword = async (oldPassword: string, newPassword: string, newPasswordConfirmation: string) => { + await this.accountService.changePasswordWithOldPassword({ + oldPassword, + newPassword, + newPasswordConfirmation, + }); + }; + + changePasswordWithToken = async (customerEmail: string, newPassword: string, resetPasswordToken: string, newPasswordConfirmation: string) => { + await this.accountService.changePasswordWithResetToken({ + customerEmail, + newPassword, + resetPasswordToken, + newPasswordConfirmation, + }); + }; + + updateSubscription = async (status: 'active' | 'cancelled'): Promise => { + const { getAccountInfo } = useAccountStore.getState(); + + const { customerId } = getAccountInfo(); + + 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, + }); + + if (response.errors.length > 0) throw new Error(response.errors[0]); + + await this.reloadSubscriptions({ delay: 2000 }); + + return response?.responseData; + }; + + updateCardDetails = async ({ + cardName, + cardNumber, + cvc, + expMonth, + expYear, + currency, + }: { + cardName: string; + cardNumber: string; + cvc: number; + expMonth: number; + expYear: number; + currency: string; + }) => { + 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, + }); + const activePayment = (await this.subscriptionService.getActivePayment({ customerId })) || null; + + useAccountStore.setState({ + loading: false, + activePayment, + }); + return response; + }; + + checkEntitlements = async (offerId?: string): Promise => { + if (!offerId) { + return false; + } + + const { responseData } = await this.checkoutService.getEntitlements({ offerId }); + return !!responseData?.accessGranted; + }; + + reloadSubscriptions = async ({ delay }: { delay: number } = { delay: 0 }): Promise => { + useAccountStore.setState({ loading: true }); + + const { getAccountInfo } = useAccountStore.getState(); + const { customerId } = getAccountInfo(); + const { accessModel } = useConfigStore.getState(); + + // The subscription data takes a few seconds to load after it's purchased, + // so here's a delay mechanism to give it time to process + if (delay > 0) { + return new Promise((resolve: (value?: unknown) => void) => { + setTimeout(() => { + this.reloadSubscriptions().finally(resolve); + }, delay); + }); + } + + // For non-SVOD platforms, there could be TVOD items, so we only reload entitlements + if (accessModel !== 'SVOD') { + await this.refreshEntitlements?.(); + + return useAccountStore.setState({ loading: false }); + } + + const [activeSubscription, transactions, activePayment] = await Promise.all([ + this.subscriptionService.getActiveSubscription({ customerId }), + this.subscriptionService.getAllTransactions({ customerId }), + this.subscriptionService.getActivePayment({ customerId }), + ]); + + let pendingOffer: Offer | null = null; + + // resolve and fetch the pending offer after upgrade/downgrade + try { + if (activeSubscription?.pendingSwitchId) { + 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 }); + const offerResponse = await this.checkoutService.getOffer({ offerId: switchOffer.responseData.toOfferId }); + + pendingOffer = offerResponse.responseData; + } + } catch (error: unknown) { + logDev('Failed to fetch the pending offer', error); + } + + // let the app know to refresh the entitlements + await this.refreshEntitlements?.(); + + useAccountStore.setState({ + subscription: activeSubscription, + loading: false, + pendingOffer, + transactions, + activePayment, + }); + }; + + exportAccountData = async () => { + const { canExportAccountData } = this.getFeatures(); + + assertModuleMethod(this.accountService.exportAccountData, 'exportAccountData is not available in account service'); + assertFeature(canExportAccountData, 'Export account'); + + return this.accountService?.exportAccountData(undefined); + }; + + getSocialLoginUrls = (redirectUrl: string) => { + const { hasSocialURLs } = this.getFeatures(); + + assertModuleMethod(this.accountService.getSocialUrls, 'getSocialUrls is not available in account service'); + assertFeature(hasSocialURLs, 'Social logins'); + + return this.accountService.getSocialUrls({ redirectUrl }); + }; + + deleteAccountData = async (password: string) => { + const { canDeleteAccount } = this.getFeatures(); + + assertModuleMethod(this.accountService.deleteAccount, 'deleteAccount is not available in account service'); + assertFeature(canDeleteAccount, 'Delete account'); + + try { + await this.accountService.deleteAccount({ password }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message.toLowerCase() : ''; + + if (message.includes('invalid credentials')) { + throw new FormValidationError({ form: [i18next.t('user:account.delete_account.invalid_credentials')] }); + } + + throw new FormValidationError({ form: [i18next.t('user:account.delete_account.error')] }); + } + }; + + getReceipt = async (transactionId: string) => { + assertModuleMethod(this.subscriptionService.fetchReceipt, 'fetchReceipt is not available in subscription service'); + + const { responseData } = await this.subscriptionService.fetchReceipt({ transactionId }); + + return responseData; + }; + + getAuthData = async () => { + return this.accountService.getAuthData(); + }; + + subscribeToNotifications = async ({ uuid, onMessage }: SubscribeToNotificationsPayload) => { + return this.accountService.subscribeToNotifications({ uuid, onMessage }); + }; + + getFeatures() { + return this.features; + } + + private async afterLogin(user: Customer, customerConsents: CustomerConsent[] | null, registration = false) { + useAccountStore.setState({ + user, + customerConsents, + }); + + await Promise.allSettled([ + this.reloadSubscriptions(), + this.getPublisherConsents(), + // 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 }); + } + + private validateInputLength = (values: { firstName: string; lastName: string }) => { + const errors: string[] = []; + if (Number(values?.firstName?.length) > 50) { + errors.push(i18next.t('account:validation.first_name')); + } + if (Number(values?.lastName?.length) > 50) { + errors.push(i18next.t('account:validation.last_name')); + } + + return errors; + }; + + private clearLoginState = async () => { + useAccountStore.setState({ + user: null, + subscription: null, + transactions: null, + activePayment: null, + customerConsents: null, + publisherConsents: null, + loading: false, + }); + + useProfileStore.setState({ + profile: null, + selectingProfileAvatar: null, + }); + + this.profileController.unpersistProfile(); + + await this.favoritesController.restoreFavorites(); + await this.watchHistoryController.restoreWatchHistory(); + }; +} diff --git a/packages/common/src/controllers/AppController.ts b/packages/common/src/controllers/AppController.ts new file mode 100644 index 000000000..c4db83831 --- /dev/null +++ b/packages/common/src/controllers/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 { useConfigStore } from '../stores/ConfigStore'; + +import WatchHistoryController from './WatchHistoryController'; +import FavoritesController from './FavoritesController'; +import AccountController from './AccountController'; + +@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 }); + + // when an integration is set, we initialize the AccountController + if (integrationType) { + await getModule(AccountController).initialize(url, refreshEntitlements); + } + + 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(); + } + + 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/packages/common/src/controllers/CheckoutController.ts b/packages/common/src/controllers/CheckoutController.ts new file mode 100644 index 000000000..63362677e --- /dev/null +++ b/packages/common/src/controllers/CheckoutController.ts @@ -0,0 +1,368 @@ +import { inject, injectable } from 'inversify'; +import i18next from 'i18next'; + +import type { + AddAdyenPaymentDetailsResponse, + AdyenPaymentSession, + CardPaymentData, + CreateOrderArgs, + FinalizeAdyenPayment, + FinalizeAdyenPaymentDetailsResponse, + GetEntitlements, + GetOffers, + InitialAdyenPayment, + Offer, + Order, + Payment, + PaymentMethod, +} 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 '../stores/CheckoutStore'; +import { useAccountStore } from '../stores/AccountStore'; +import { FormValidationError } from '../errors/FormValidationError'; +import { determineSwitchDirection } from '../utils/subscription'; + +@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, @inject(GET_CUSTOMER_IP) getCustomerIP: GetCustomerIP) { + this.getCustomerIP = getCustomerIP; + this.accountService = getNamedModule(AccountService, integrationType); + this.checkoutService = getNamedModule(CheckoutService, integrationType); + this.subscriptionService = getNamedModule(SubscriptionService, integrationType); + } + + initialiseOffers = async () => { + const requestedMediaOffers = useCheckoutStore.getState().requestedMediaOffers; + const mediaOffers = requestedMediaOffers ? await this.getOffers({ offerIds: requestedMediaOffers.map(({ offerId }) => offerId) }) : []; + useCheckoutStore.setState({ mediaOffers }); + + if (!useCheckoutStore.getState().subscriptionOffers.length && this.accountService.svodOfferIds) { + const subscriptionOffers = await this.getOffers({ offerIds: this.accountService.svodOfferIds }); + useCheckoutStore.setState({ subscriptionOffers }); + } + + if (!useCheckoutStore.getState().switchSubscriptionOffers.length) { + const subscriptionSwitches = await this.getSubscriptionSwitches(); + const switchSubscriptionOffers = subscriptionSwitches ? await this.getOffers({ offerIds: subscriptionSwitches }) : []; + useCheckoutStore.setState({ switchSubscriptionOffers }); + } + }; + + getSubscriptionOfferIds = () => this.accountService.svodOfferIds; + + chooseOffer = async (selectedOffer: Offer) => { + useCheckoutStore.setState({ selectedOffer }); + }; + + initialiseOrder = async (offer: Offer): Promise => { + const { getAccountInfo } = useAccountStore.getState(); + const { customer } = getAccountInfo(); + + const paymentMethods = await this.getPaymentMethods(); + const paymentMethodId = paymentMethods[0]?.id; + + const createOrderArgs: CreateOrderArgs = { + offer, + customerId: customer.id, + country: customer?.country || '', + paymentMethodId, + }; + + const response = await this.checkoutService.createOrder(createOrderArgs); + + if (response?.errors?.length > 0) { + useCheckoutStore.getState().setOrder(null); + + throw new Error(response?.errors[0]); + } + + useCheckoutStore.getState().setOrder(response.responseData?.order); + }; + + updateOrder = async (order: Order, paymentMethodId?: number, couponCode?: string | null): Promise => { + let response; + + try { + response = await this.checkoutService.updateOrder({ order, paymentMethodId, couponCode }); + } catch (error: unknown) { + // TODO: we currently (falsely) assume that the only error caught is because the coupon is not valid, but there + // could be a network failure as well (JWPCheckoutService) + throw new FormValidationError({ couponCode: [i18next.t('account:checkout.coupon_not_valid')] }); + } + + 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`)) { + useCheckoutStore.getState().setOrder(null); + } + + // TODO: this handles the `Coupon ${couponCode} not found` message (CleengCheckoutService) + if (response.errors[0].includes(`not found`)) { + throw new FormValidationError({ couponCode: [i18next.t('account:checkout.coupon_not_valid')] }); + } + + throw new FormValidationError({ form: response.errors }); + } + + if (response.responseData.order) { + useCheckoutStore.getState().setOrder(response.responseData?.order); + } + }; + + getPaymentMethods = async (): Promise => { + const { paymentMethods } = useCheckoutStore.getState(); + + if (paymentMethods) return paymentMethods; // already fetched payment methods + + const response = await this.checkoutService.getPaymentMethods(); + + if (response.errors.length > 0) throw new Error(response.errors[0]); + + useCheckoutStore.getState().setPaymentMethods(response.responseData?.paymentMethods); + + return response.responseData?.paymentMethods; + }; + + // + paymentWithoutDetails = async (): Promise => { + const { order } = useCheckoutStore.getState(); + + if (!order) throw new Error('No order created'); + + 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); + + return response.responseData; + }; + + directPostCardPayment = async ({ cardPaymentPayload, referrer, returnUrl }: { cardPaymentPayload: CardPaymentData; referrer: string; returnUrl: string }) => { + const { order } = useCheckoutStore.getState(); + + if (!order) throw new Error('No order created'); + + return await this.checkoutService.directPostCardPayment(cardPaymentPayload, order, referrer, returnUrl); + }; + + createAdyenPaymentSession = async (returnUrl: string, isInitialPayment: boolean = true): Promise => { + const { order } = useCheckoutStore.getState(); + const orderId = order?.id; + + 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, + }); + + if (response.errors.length > 0) { + throw new Error(response.errors[0]); + } + + return response.responseData; + }; + + initialAdyenPayment = async (paymentMethod: AdyenPaymentMethod, returnUrl: string): Promise => { + const { order } = useCheckoutStore.getState(); + + if (!order) throw new Error('No order created'); + + assertModuleMethod(this.checkoutService.initialAdyenPayment, 'initialAdyenPayment is not available in checkout service'); + + 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]); + + return response.responseData; + }; + + finalizeAdyenPayment = async (details: unknown, orderId?: number, paymentData?: string): Promise => { + if (!orderId) throw new Error('No order created'); + + assertModuleMethod(this.checkoutService.finalizeAdyenPayment, 'finalizeAdyenPayment is not available in checkout service'); + + 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, + waitingUrl, + cancelUrl, + errorUrl, + couponCode = '', + }: { + successUrl: string; + waitingUrl: string; + cancelUrl: string; + errorUrl: string; + couponCode: string; + }): Promise<{ redirectUrl: string }> => { + const { order } = useCheckoutStore.getState(); + + if (!order) throw new Error('No order created'); + + const response = await this.checkoutService.paymentWithPayPal({ + order: order, + successUrl, + waitingUrl, + cancelUrl, + errorUrl, + couponCode, + }); + + if (response.errors.length > 0) throw new Error(response.errors[0]); + + return { + redirectUrl: response.responseData.redirectUrl, + }; + }; + + getSubscriptionSwitches = async (): Promise => { + const { getAccountInfo } = useAccountStore.getState(); + + const { customerId } = getAccountInfo(); + + assertModuleMethod(this.checkoutService.getSubscriptionSwitches, 'getSubscriptionSwitches is not available in checkout service'); + assertModuleMethod(this.checkoutService.getOffer, 'getOffer is not available in checkout service'); + + const { subscription } = useAccountStore.getState(); + + if (!subscription) return null; + + const response = await this.checkoutService.getSubscriptionSwitches({ + customerId: customerId, + offerId: subscription.offerId, + }); + + if (!response.responseData.available.length) return null; + + return response.responseData.available.map(({ toOfferId }) => toOfferId); + }; + + switchSubscription = async () => { + const selectedOffer = useCheckoutStore.getState().selectedOffer; + const subscription = useAccountStore.getState().subscription; + const { getAccountInfo } = useAccountStore.getState(); + const { customerId } = getAccountInfo(); + + if (!selectedOffer || !subscription) throw new Error('No offer selected'); + + assertModuleMethod(this.checkoutService.switchSubscription, 'switchSubscription is not available in checkout service'); + + const switchDirection: 'upgrade' | 'downgrade' = determineSwitchDirection(subscription); + + const switchSubscriptionPayload = { + toOfferId: selectedOffer.offerId, + customerId: customerId, + offerId: subscription.offerId, + switchDirection: switchDirection, + }; + + await this.checkoutService.switchSubscription(switchSubscriptionPayload); + }; + + changeSubscription = async ({ accessFeeId, subscriptionId }: { accessFeeId: string; subscriptionId: string }) => { + assertModuleMethod(this.subscriptionService.changeSubscription, 'changeSubscription is not available in subscription service'); + + const { responseData } = await this.subscriptionService.changeSubscription({ + accessFeeId, + subscriptionId, + }); + + return responseData; + }; + + 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, + waitingUrl, + cancelUrl, + errorUrl, + }); + + if (response.errors.length > 0) throw new Error(response.errors[0]); + + if (currentPaymentId) { + await this.checkoutService.deletePaymentMethod({ paymentDetailsId: currentPaymentId }); + } + + return response.responseData; + }; + + addAdyenPaymentDetails = async (paymentMethod: AdyenPaymentMethod, paymentMethodId: number, returnUrl: string): Promise => { + assertModuleMethod(this.checkoutService.addAdyenPaymentDetails, 'addAdyenPaymentDetails is not available in checkout service'); + + 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 => { + assertModuleMethod(this.checkoutService.finalizeAdyenPaymentDetails, 'finalizeAdyenPaymentDetails is not available in checkout service'); + + 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) => { + return this.checkoutService.getOffers(payload); + }; + + getEntitlements: GetEntitlements = (payload) => { + return this.checkoutService.getEntitlements(payload); + }; +} diff --git a/src/stores/EpgController.test.ts b/packages/common/src/controllers/EpgController.test.ts similarity index 91% rename from src/stores/EpgController.test.ts rename to packages/common/src/controllers/EpgController.test.ts index 704add4f4..b4f44865f 100644 --- a/src/stores/EpgController.test.ts +++ b/packages/common/src/controllers/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/controllers/EpgController.ts similarity index 92% rename from src/stores/EpgController.ts rename to packages/common/src/controllers/EpgController.ts index 8bb30f219..edbb44587 100644 --- a/src/stores/EpgController.ts +++ b/packages/common/src/controllers/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/packages/common/src/controllers/FavoritesController.ts b/packages/common/src/controllers/FavoritesController.ts new file mode 100644 index 000000000..e871bcf63 --- /dev/null +++ b/packages/common/src/controllers/FavoritesController.ts @@ -0,0 +1,90 @@ +import i18next from 'i18next'; +import { injectable } from 'inversify'; + +import FavoriteService from '../services/FavoriteService'; +import type { PlaylistItem } from '../../types/playlist'; +import { useAccountStore } from '../stores/AccountStore'; +import { useFavoritesStore } from '../stores/FavoritesStore'; +import { useConfigStore } from '../stores/ConfigStore'; + +@injectable() +export default class FavoritesController { + private readonly favoritesService: FavoriteService; + + constructor(favoritesService: FavoriteService) { + this.favoritesService = favoritesService; + } + + initialize = async () => { + await this.restoreFavorites(); + }; + + restoreFavorites = async () => { + const { user } = useAccountStore.getState(); + const favoritesList = useConfigStore.getState().config.features?.favoritesList; + + if (!favoritesList) { + return; + } + + const favorites = await this.favoritesService.getFavorites(user, favoritesList); + + useFavoritesStore.setState({ favorites, favoritesPlaylistId: favoritesList }); + }; + + persistFavorites = async () => { + const { favorites } = useFavoritesStore.getState(); + const { user } = useAccountStore.getState(); + + await this.favoritesService.persistFavorites(favorites, user); + }; + + saveItem = async (item: PlaylistItem) => { + const { favorites } = useFavoritesStore.getState(); + + if (!favorites.some(({ mediaid }) => mediaid === item.mediaid)) { + const items = [this.favoritesService.createFavorite(item)].concat(favorites); + useFavoritesStore.setState({ favorites: items }); + await this.persistFavorites(); + } + }; + + removeItem = async (item: PlaylistItem) => { + const { favorites } = useFavoritesStore.getState(); + + const items = favorites.filter(({ mediaid }) => mediaid !== item.mediaid); + useFavoritesStore.setState({ favorites: items }); + + await this.persistFavorites(); + }; + + toggleFavorite = async (item: PlaylistItem | undefined) => { + const { favorites, hasItem, setWarning } = useFavoritesStore.getState(); + + if (!item) { + return; + } + + const isFavorite = hasItem(item); + + if (isFavorite) { + await this.removeItem(item); + + return; + } + + // If we exceed the max available number of favorites, we show a warning + if (favorites.length > this.favoritesService.getMaxFavoritesCount()) { + setWarning(i18next.t('video:favorites_warning', { maxCount: this.favoritesService.getMaxFavoritesCount() })); + return; + } + + await this.saveItem(item); + }; + + clear = async () => { + useFavoritesStore.setState({ favorites: [] }); + + await this.persistFavorites(); + }; +} diff --git a/packages/common/src/controllers/ProfileController.ts b/packages/common/src/controllers/ProfileController.ts new file mode 100644 index 000000000..c73948433 --- /dev/null +++ b/packages/common/src/controllers/ProfileController.ts @@ -0,0 +1,118 @@ +import { inject, injectable } from 'inversify'; +import type { ProfilesData } from '@inplayer-org/inplayer.js'; +import * as yup from 'yup'; + +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 { EnterProfilePayload, ProfileDetailsPayload, ProfilePayload } from '../../types/profiles'; +import { useProfileStore } from '../stores/ProfileStore'; + +const PERSIST_PROFILE = 'profile'; + +const profileSchema = yup.object().shape({ + id: yup.string().required(), + name: yup.string().required(), + avatar_url: yup.string(), + adult: yup.boolean().required(), + credentials: yup.object().shape({ + access_token: yup.string().required(), + expires: yup.number().required(), + }), +}); + +@injectable() +export default class ProfileController { + private readonly profileService?: ProfileService; + private readonly storageService: StorageService; + + constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, storageService: StorageService) { + this.profileService = getNamedModule(ProfileService, integrationType, false); + this.storageService = storageService; + } + + 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'); + + 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 }); + }; + + 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 }); + }; + + enterProfile = async ({ id, pin }: EnterProfilePayload) => { + assertModuleMethod(this.profileService?.enterProfile, 'enterProfile is not available in profile service'); + + const profile = await this.profileService.enterProfile({ id, pin }); + + if (!profile) { + throw new Error('Unable to enter profile'); + } + + await this.initializeProfile({ profile }); + }; + + deleteProfile = async ({ id }: ProfileDetailsPayload) => { + assertModuleMethod(this.profileService?.deleteProfile, 'deleteProfile is not available in profile service'); + + 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 }); + }; + + persistProfile = ({ profile }: { profile: ProfilesData }) => { + this.storageService.setItem(PERSIST_PROFILE, JSON.stringify(profile)); + }; + + unpersistProfile = async () => { + await this.storageService.removeItem(PERSIST_PROFILE); + }; + + loadPersistedProfile = async () => { + const profile = await this.storageService.getItem(PERSIST_PROFILE, true); + + if (this.isValidProfile(profile)) { + useProfileStore.getState().setProfile(profile); + return profile; + } + + useProfileStore.getState().setProfile(null); + + return null; + }; + + initializeProfile = async ({ profile }: { profile: ProfilesData }) => { + this.persistProfile({ profile }); + useProfileStore.getState().setProfile(profile); + + return profile; + }; +} diff --git a/packages/common/src/controllers/WatchHistoryController.ts b/packages/common/src/controllers/WatchHistoryController.ts new file mode 100644 index 000000000..708914ec1 --- /dev/null +++ b/packages/common/src/controllers/WatchHistoryController.ts @@ -0,0 +1,66 @@ +import { injectable } from 'inversify'; + +import WatchHistoryService from '../services/WatchHistoryService'; +import type { PlaylistItem } from '../../types/playlist'; +import type { WatchHistoryItem } from '../../types/watchHistory'; +import { useAccountStore } from '../stores/AccountStore'; +import { useConfigStore } from '../stores/ConfigStore'; +import { useWatchHistoryStore } from '../stores/WatchHistoryStore'; + +@injectable() +export default class WatchHistoryController { + private readonly watchHistoryService: WatchHistoryService; + + constructor(watchHistoryService: WatchHistoryService) { + this.watchHistoryService = watchHistoryService; + } + + initialize = async () => { + await this.restoreWatchHistory(); + }; + + restoreWatchHistory = async () => { + const { user } = useAccountStore.getState(); + const continueWatchingList = useConfigStore.getState().config.features?.continueWatchingList; + + if (!continueWatchingList) { + return; + } + + const watchHistory = await this.watchHistoryService.getWatchHistory(user, continueWatchingList); + + useWatchHistoryStore.setState({ + watchHistory: watchHistory.filter((item): item is WatchHistoryItem => !!item?.mediaid), + playlistItemsLoaded: true, + continueWatchingPlaylistId: continueWatchingList, + }); + }; + + persistWatchHistory = async () => { + const { watchHistory } = useWatchHistoryStore.getState(); + const { user } = useAccountStore.getState(); + + await this.watchHistoryService.persistWatchHistory(watchHistory, user); + }; + + /** + * If we already have an element with continue watching state, we: + * 1. Update the progress + * 2. Move the element to the continue watching list start + * Otherwise: + * 1. Move the element to the continue watching list start + * 2. If there are many elements in continue watching state we remove the oldest one + */ + saveItem = async (item: PlaylistItem, seriesItem: PlaylistItem | undefined, videoProgress: number | null) => { + const { watchHistory } = useWatchHistoryStore.getState(); + + if (!videoProgress) return; + + const updatedHistory = await this.watchHistoryService.saveItem(item, seriesItem, videoProgress, watchHistory); + + if (updatedHistory) { + useWatchHistoryStore.setState({ watchHistory: updatedHistory }); + await this.persistWatchHistory(); + } + }; +} diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts new file mode 100644 index 000000000..50c854c56 --- /dev/null +++ b/packages/common/src/env.ts @@ -0,0 +1,37 @@ +export type Env = { + APP_VERSION: string; + 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 = { + APP_VERSION: '', + APP_API_BASE_URL: 'https://cdn.jwplayer.com', + APP_PLAYER_ID: 'M4qoGvUk', + APP_FOOTER_TEXT: '', + APP_DEFAULT_LANGUAGE: 'en', +}; + +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_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/errors/FormValidationError.ts b/packages/common/src/errors/FormValidationError.ts new file mode 100644 index 000000000..a3d6a5805 --- /dev/null +++ b/packages/common/src/errors/FormValidationError.ts @@ -0,0 +1,11 @@ +type FormValidationErrors = Record; + +export class FormValidationError extends Error { + public errors: FormValidationErrors; + + constructor(errors: FormValidationErrors) { + super(Object.values(errors).flat().join(';')); + + this.errors = errors; + } +} diff --git a/packages/common/src/modules/container.ts b/packages/common/src/modules/container.ts new file mode 100644 index 000000000..f98d06c26 --- /dev/null +++ b/packages/common/src/modules/container.ts @@ -0,0 +1,53 @@ +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; +export function getModule(constructorFunction: interfaces.ServiceIdentifier, required = true): T | undefined { + const module = container.getAll(constructorFunction)[0]; + + if (required && !module) throw new Error(`Service / Controller '${String(constructorFunction)}' not found`); + + return module; +} + +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required: false): T | undefined; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required: true): T; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null): T; +export function getNamedModule(constructorFunction: interfaces.ServiceIdentifier, name: string | null, required = true): T | undefined { + if (!name) { + return; + } + + let module; + + try { + module = container.getAllNamed(constructorFunction, name)[0]; + + return module; + } catch (err: unknown) { + 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); + } +} + +export function assertModuleMethod(method: T, message: string): asserts method is NonNullable { + if (!method) throw new Error(message); +} + +export function assertFeature(isEnabled: boolean, featureName: string): asserts isEnabled is true { + if (!isEnabled) throw new Error(`${featureName} feature is not enabled`); +} 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..8b3b88678 --- /dev/null +++ b/packages/common/src/modules/functions/getIntegrationType.ts @@ -0,0 +1,11 @@ +import type { interfaces } from 'inversify'; + +import AppController from '../../controllers/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..e26533416 --- /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 '../controllers/WatchHistoryController'; +import CheckoutController from '../controllers/CheckoutController'; +import AccountController from '../controllers/AccountController'; +import ProfileController from '../controllers/ProfileController'; +import FavoritesController from '../controllers/FavoritesController'; +import AppController from '../controllers/AppController'; +import EpgController from '../controllers/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/packages/common/src/paths.tsx b/packages/common/src/paths.tsx new file mode 100644 index 000000000..c0cf2530d --- /dev/null +++ b/packages/common/src/paths.tsx @@ -0,0 +1,26 @@ +export const PATH_HOME = '/'; + +export const PATH_MEDIA = '/m/:id/*'; +export const PATH_PLAYLIST = '/p/:id/*'; +export const PATH_LEGACY_SERIES = '/s/:id/*'; + +export const PATH_SEARCH = '/q/*'; +export const PATH_ABOUT = '/o/about'; + +export const PATH_USER_BASE = '/u'; +export const PATH_USER = `${PATH_USER_BASE}/*`; + +export const RELATIVE_PATH_USER_ACCOUNT = 'my-account'; +export const RELATIVE_PATH_USER_MY_PROFILE = 'my-profile/:id'; +export const RELATIVE_PATH_USER_FAVORITES = 'favorites'; +export const RELATIVE_PATH_USER_PAYMENTS = 'payments'; +export const RELATIVE_PATH_USER_PROFILES = 'profiles'; + +export const PATH_USER_ACCOUNT = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_ACCOUNT}`; +export const PATH_USER_MY_PROFILE = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_MY_PROFILE}`; +export const PATH_USER_FAVORITES = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_FAVORITES}`; +export const PATH_USER_PAYMENTS = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PAYMENTS}`; +export const PATH_USER_PROFILES = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}`; +export const PATH_USER_PROFILES_CREATE = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}/create`; +export const PATH_USER_PROFILES_EDIT = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}/edit`; +export const PATH_USER_PROFILES_EDIT_PROFILE = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}/edit/:id`; diff --git a/packages/common/src/services/ApiService.ts b/packages/common/src/services/ApiService.ts new file mode 100644 index 000000000..919822364 --- /dev/null +++ b/packages/common/src/services/ApiService.ts @@ -0,0 +1,261 @@ +import { isValid, parseISO } from 'date-fns'; +import { injectable } from 'inversify'; + +import { getMediaStatusFromEventState } from '../utils/liveEvent'; +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 { + CARD = 'card', + BACKGROUND = 'background', + CHANNEL_LOGO = 'channel_logo', +} + +const PAGE_LIMIT = 20; + +@injectable() +export default class ApiService { + /** + * We use playlistLabel prop to define the label used for all media items inside. + * That way we can change the behavior of the same media items being in different playlists + */ + private generateAlternateImageURL = ({ item, label, playlistLabel }: { item: PlaylistItem; label: string; playlistLabel?: string }) => { + const pathname = `/v2/media/${item.mediaid}/images/${playlistLabel || label}.webp`; + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { poster_fallback: 1, fallback: playlistLabel ? label : null }); + + return url; + }; + + private parseDate = (item: PlaylistItem, prop: string) => { + const date = item[prop] as string | undefined; + + if (date && !isValid(new Date(date))) { + console.error(`Invalid "${prop}" date provided for the "${item.title}" media item`); + return undefined; + } + + return date ? parseISO(date) : undefined; + }; + + /** + * Transform incoming media items + * - Parses productId into MediaOffer[] for all cleeng offers + */ + private transformMediaItem = (item: PlaylistItem, playlist?: Playlist) => { + const config = ConfigStore.getState().config; + const offerKeys = Object.keys(config?.integrations)[0]; + const playlistLabel = playlist?.imageLabel; + + const transformedMediaItem = { + ...item, + cardImage: this.generateAlternateImageURL({ item, label: ImageProperty.CARD, playlistLabel }), + channelLogoImage: this.generateAlternateImageURL({ item, label: ImageProperty.CHANNEL_LOGO, playlistLabel }), + backgroundImage: this.generateAlternateImageURL({ item, label: ImageProperty.BACKGROUND }), + mediaOffers: item.productIds ? filterMediaOffers(offerKeys, item.productIds) : undefined, + scheduledStart: this.parseDate(item, 'VCH.ScheduledStart'), + scheduledEnd: this.parseDate(item, 'VCH.ScheduledEnd'), + }; + + // add the media status to the media item after the transformation because the live media status depends on the scheduledStart and scheduledEnd + transformedMediaItem.mediaStatus = getMediaStatusFromEventState(transformedMediaItem); + + return transformedMediaItem; + }; + + /** + * Transform incoming playlists + */ + private transformPlaylist = (playlist: Playlist, relatedMediaId?: string) => { + playlist.playlist = playlist.playlist.map((item) => this.transformMediaItem(item, playlist)); + + // remove the related media item (when this is a recommendations playlist) + if (relatedMediaId) playlist.playlist.filter((item) => item.mediaid !== relatedMediaId); + + return playlist; + }; + + private transformEpisodes = (episodesRes: EpisodesRes, seasonNumber?: number) => { + const { episodes, page, page_limit, total } = episodesRes; + + // Adding images and keys for media items + return { + episodes: episodes + .filter((el) => el.media_item) + .map((el) => ({ + ...this.transformMediaItem(el.media_item as PlaylistItem), + seasonNumber: seasonNumber?.toString() || el.season_number?.toString() || '', + episodeNumber: String(el.episode_number), + })), + pagination: { page, page_limit, total }, + }; + }; + + /** + * Get playlist by id + */ + getPlaylistById = async (id?: string, params: GetPlaylistParams = {}): Promise => { + if (!id) { + return undefined; + } + + const pathname = `/v2/playlists/${id}`; + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, params); + const response = await fetch(url); + const data = (await getDataOrThrow(response)) as Playlist; + + return this.transformPlaylist(data, params.related_media_id); + }; + + /** + * Get watchlist by playlistId + */ + getMediaByWatchlist = async (playlistId: string, mediaIds: string[], token?: string): Promise => { + if (!mediaIds?.length) { + return []; + } + + const pathname = `/apps/watchlists/${playlistId}`; + 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; + + if (!data) throw new Error(`The data was not found using the watchlist ${playlistId}`); + + return (data.playlist || []).map((item) => this.transformMediaItem(item)); + }; + + /** + * Get media by id + * @param {string} id + * @param {string} [token] + * @param {string} [drmPolicyId] + */ + getMediaById = async (id: string, token?: string, drmPolicyId?: string): Promise => { + const pathname = drmPolicyId ? `/v2/media/${id}/drm/${drmPolicyId}` : `/v2/media/${id}`; + 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); + }; + + /** + * Get series by id + * @param {string} id + * @param params + */ + getSeries = async (id: string, params: GetSeriesParams = {}): Promise => { + if (!id) { + throw new Error('Series ID is required'); + } + + const pathname = `/apps/series/${id}`; + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, params); + const response = await fetch(url); + + return (await getDataOrThrow(response)) as Series; + }; + + /** + * Get all series for the given media_ids + */ + getSeriesByMediaIds = async (mediaIds: string[]): Promise | undefined> => { + const pathname = `/apps/series`; + const url = `${env.APP_API_BASE_URL}${pathname}?media_ids=${mediaIds.join(',')}`; + const response = await fetch(url); + + 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) + */ + getEpisodes = async ({ + seriesId, + pageOffset, + pageLimit = PAGE_LIMIT, + afterId, + }: { + seriesId: string | undefined; + pageOffset?: number; + pageLimit?: number; + afterId?: string; + }): Promise => { + if (!seriesId) { + throw new Error('Series ID is required'); + } + + const pathname = `/apps/series/${seriesId}/episodes`; + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { + page_offset: pageOffset, + page_limit: pageLimit, + after_id: afterId, + }); + + const response = await fetch(url); + const episodesResponse = (await getDataOrThrow(response)) as EpisodesRes; + + return this.transformEpisodes(episodesResponse); + }; + + /** + * Get season of the selected series + */ + getSeasonWithEpisodes = async ({ + seriesId, + seasonNumber, + pageOffset, + pageLimit = PAGE_LIMIT, + }: { + seriesId: string | undefined; + seasonNumber: number; + pageOffset?: number; + pageLimit?: number; + }): Promise => { + if (!seriesId) { + throw new Error('Series ID is required'); + } + + const pathname = `/apps/series/${seriesId}/seasons/${seasonNumber}/episodes`; + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { page_offset: pageOffset, page_limit: pageLimit }); + + const response = await fetch(url); + const episodesRes = (await getDataOrThrow(response)) as EpisodesRes; + + return this.transformEpisodes(episodesRes, seasonNumber); + }; + + getAdSchedule = async (id: string | undefined | null): Promise => { + if (!id) { + throw new Error('Ad Schedule ID is required'); + } + + const url = env.APP_API_BASE_URL + `/v2/advertising/schedules/${id}.json`; + const response = await fetch(url, { credentials: 'omit' }); + + return (await getDataOrThrow(response)) as AdSchedule; + }; + + getAppContentSearch = async (siteId: string, searchQuery: string | undefined) => { + const pathname = `/v2/sites/${siteId}/app_content/media/search`; + + const url = createURL(`${env.APP_API_BASE_URL}${pathname}`, { + search_query: searchQuery, + }); + + const response = await fetch(url); + const data = (await getDataOrThrow(response)) as Playlist; + + return this.transformPlaylist(data); + }; +} diff --git a/packages/common/src/services/ConfigService.ts b/packages/common/src/services/ConfigService.ts new file mode 100644 index 000000000..c775a87c8 --- /dev/null +++ b/packages/common/src/services/ConfigService.ts @@ -0,0 +1,111 @@ +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: '', + siteId: '', + 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/packages/common/src/services/EpgService.ts b/packages/common/src/services/EpgService.ts new file mode 100644 index 000000000..35e6216e4 --- /dev/null +++ b/packages/common/src/services/EpgService.ts @@ -0,0 +1,14 @@ +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; + + /** + * Validate the given data with the schema and transform it into an EpgProgram + */ + abstract transformProgram: (data: unknown) => Promise; +} diff --git a/packages/common/src/services/FavoriteService.ts b/packages/common/src/services/FavoriteService.ts new file mode 100644 index 000000000..ed833889a --- /dev/null +++ b/packages/common/src/services/FavoriteService.ts @@ -0,0 +1,103 @@ +import { inject, injectable } from 'inversify'; +import { object, array, string } from 'yup'; + +import type { Favorite, SerializedFavorite } from '../../types/favorite'; +import type { PlaylistItem } from '../../types/playlist'; +import type { Customer } from '../../types/account'; +import { getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; +import { logDev } from '../utils/common'; +import { MAX_WATCHLIST_ITEMS_COUNT } from '../constants'; + +import ApiService from './ApiService'; +import StorageService from './StorageService'; +import AccountService from './integrations/AccountService'; + +const schema = array( + object().shape({ + mediaid: string(), + }), +); + +@injectable() +export default class FavoriteService { + private PERSIST_KEY_FAVORITES = 'favorites'; + + private readonly apiService; + private readonly storageService; + private readonly accountService; + + constructor(@inject(INTEGRATION_TYPE) integrationType: string, apiService: ApiService, storageService: StorageService) { + this.apiService = apiService; + this.storageService = storageService; + this.accountService = getNamedModule(AccountService, integrationType, false); + } + + private validateFavorites(favorites: unknown) { + if (favorites && schema.validateSync(favorites)) { + return favorites as SerializedFavorite[]; + } + + return []; + } + + private async getFavoritesFromAccount(user: Customer) { + const favorites = await this.accountService?.getFavorites({ user }); + + return this.validateFavorites(favorites); + } + + private async getFavoritesFromStorage() { + const favorites = await this.storageService.getItem(this.PERSIST_KEY_FAVORITES, true); + + return this.validateFavorites(favorites); + } + + getFavorites = async (user: Customer | null, favoritesList: string) => { + const savedItems = user ? await this.getFavoritesFromAccount(user) : await this.getFavoritesFromStorage(); + const mediaIds = savedItems.map(({ mediaid }) => mediaid); + + if (!mediaIds) { + return []; + } + + try { + const playlistItems = await this.apiService.getMediaByWatchlist(favoritesList, mediaIds); + + return (playlistItems || []).map((item) => this.createFavorite(item)); + } catch (error: unknown) { + logDev('Failed to get favorites', error); + } + + return []; + }; + + serializeFavorites = (favorites: Favorite[]): SerializedFavorite[] => { + return favorites.map(({ mediaid }) => ({ mediaid })); + }; + + persistFavorites = async (favorites: Favorite[], user: Customer | null) => { + if (user) { + return this.accountService?.updateFavorites({ + favorites: this.serializeFavorites(favorites), + user, + }); + } else { + await this.storageService.setItem(this.PERSIST_KEY_FAVORITES, JSON.stringify(this.serializeFavorites(favorites))); + } + }; + + getMaxFavoritesCount = () => { + return this.accountService?.features?.watchListSizeLimit || MAX_WATCHLIST_ITEMS_COUNT; + }; + + createFavorite = (item: PlaylistItem): Favorite => { + return { + mediaid: item.mediaid, + title: item.title, + tags: item.tags, + duration: item.duration, + playlistItem: item, + } as Favorite; + }; +} diff --git a/packages/common/src/services/GenericEntitlementService.ts b/packages/common/src/services/GenericEntitlementService.ts new file mode 100644 index 000000000..6463b4397 --- /dev/null +++ b/packages/common/src/services/GenericEntitlementService.ts @@ -0,0 +1,27 @@ +import { injectable } from 'inversify'; + +import type { GetMediaParams } from '../../types/media'; +import type { GetTokenResponse } from '../../types/entitlement'; + +@injectable() +export default class GenericEntitlementService { + private getToken = async (url: string, body: unknown = {}, jwt?: string): Promise => { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: jwt ? `Bearer ${jwt}` : '', + }, + body: JSON.stringify(body), + }); + + return (await response.json()) as T; + }; + + getMediaToken = async (host: string, id: string, jwt?: string, params?: GetMediaParams, drmPolicyId?: string) => { + const data = await this.getToken(`${host}/media/${id}/sign${drmPolicyId ? `/drm/${drmPolicyId}` : ''}`, params, jwt); + + if (!data.entitled) throw new Error('Unauthorized'); + + return data.token; + }; +} 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/packages/common/src/services/SettingsService.ts b/packages/common/src/services/SettingsService.ts new file mode 100644 index 000000000..1a16fc72d --- /dev/null +++ b/packages/common/src/services/SettingsService.ts @@ -0,0 +1,123 @@ +import { injectable } from 'inversify'; +import ini from 'ini'; +import { getI18n } from 'react-i18next'; + +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'; + +import StorageService from './StorageService'; + +@injectable() +export default class SettingsService { + private readonly storageService; + + constructor(storageService: StorageService) { + this.storageService = storageService; + } + + async getConfigSource(settings: Settings | undefined, url: string) { + if (!settings) { + return ''; + } + + 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 + if (!settings.UNSAFE_allowAnyConfigSource && (settings.additionalAllowedConfigSources?.length || 0) <= 0) { + return settings.defaultConfigSource; + } + + 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) { + 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)) { + await this.storageService.setItem(CONFIG_FILE_STORAGE_KEY, configKey); + return configKey; + } + + logDev(`Invalid app-config query param: ${configKey}`); + } + // Yes this falls through from above to look up the stored value if the query string is invalid and that's OK + + const storedSource = await this.storageService.getItem(CONFIG_FILE_STORAGE_KEY, false); + + // Make sure the stored value is still valid before returning it + if (storedSource && typeof storedSource === 'string') { + if (this.isValidConfigSource(storedSource, settings)) { + return storedSource; + } + + logDev('Invalid stored config: ' + storedSource); + await this.storageService.removeItem(CONFIG_FILE_STORAGE_KEY); + } + + return settings.defaultConfigSource; + } + + isValidConfigSource = (source: string, settings: Settings) => { + // Dynamic values are valid as long as they are defined + if (settings?.UNSAFE_allowAnyConfigSource) { + return !!source; + } + + return ( + settings?.defaultConfigSource === source || (settings?.additionalAllowedConfigSources && settings?.additionalAllowedConfigSources.indexOf(source) >= 0) + ); + }; + + initialize = async () => { + const settings = await fetch('/.webapp.ini') + .then((result) => result.text()) + .then((iniString) => ini.parse(iniString) as Settings) + .catch((e) => { + logDev(e); + // It's possible to not use the ini settings files, so an error doesn't have to be fatal + return {} as Settings; + }); + + 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'), + helpLink: 'https://github.com/jwplayer/ott-web-app/blob/develop/docs/initialization-file.md', + }; + + if (!settings) { + throw new AppError('Unable to load .webapp.ini', errorPayload); + } + + // The ini file values will be used if provided, even if compile-time values are set + 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) { + console.warn('Using Global OTT Player without setting player key. Some features, such as analytics, may not work correctly.'); + } + + // This will result in an unusable app + if ( + !settings.defaultConfigSource && + (!settings.additionalAllowedConfigSources || settings.additionalAllowedConfigSources?.length === 0) && + !settings.UNSAFE_allowAnyConfigSource + ) { + throw new AppError('No usable config sources', errorPayload); + } + + return settings; + }; +} 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/packages/common/src/services/WatchHistoryService.ts b/packages/common/src/services/WatchHistoryService.ts new file mode 100644 index 000000000..62e4408ac --- /dev/null +++ b/packages/common/src/services/WatchHistoryService.ts @@ -0,0 +1,174 @@ +import { inject, injectable } from 'inversify'; +import { array, number, object, string } from 'yup'; + +import type { PlaylistItem } from '../../types/playlist'; +import type { SerializedWatchHistoryItem, WatchHistoryItem } from '../../types/watchHistory'; +import type { Customer } from '../../types/account'; +import { getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; +import { logDev } from '../utils/common'; +import { MAX_WATCHLIST_ITEMS_COUNT } from '../constants'; + +import ApiService from './ApiService'; +import StorageService from './StorageService'; +import AccountService from './integrations/AccountService'; + +const schema = array( + object().shape({ + mediaid: string(), + progress: number(), + }), +); + +@injectable() +export default class WatchHistoryService { + private PERSIST_KEY_WATCH_HISTORY = 'history'; + + private readonly apiService; + private readonly storageService; + private readonly accountService; + + constructor(@inject(INTEGRATION_TYPE) integrationType: string, apiService: ApiService, storageService: StorageService) { + this.apiService = apiService; + this.storageService = storageService; + this.accountService = getNamedModule(AccountService, integrationType); + } + + // Retrieve watch history media items info using a provided watch list + private getWatchHistoryItems = async (continueWatchingList: string, ids: string[]): Promise> => { + const watchHistoryItems = await this.apiService.getMediaByWatchlist(continueWatchingList, ids); + const watchHistoryItemsDict = Object.fromEntries((watchHistoryItems || []).map((item) => [item.mediaid, item])); + + return watchHistoryItemsDict; + }; + + // We store separate episodes in the watch history and to show series card in the Continue Watching shelf we need to get their parent media items + private getWatchHistorySeriesItems = async (continueWatchingList: string, ids: string[]): Promise> => { + const mediaWithSeries = await this.apiService.getSeriesByMediaIds(ids); + 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, uniqueSerieIds); + const seriesItemsDict = Object.keys(mediaWithSeries || {}).reduce((acc, key) => { + const seriesItemId = mediaWithSeries?.[key]?.[0]?.series_id; + if (seriesItemId) { + acc[key] = seriesItems?.find((el) => el.mediaid === seriesItemId); + } + return acc; + }, {} as Record); + + return seriesItemsDict; + }; + + private validateWatchHistory(history: unknown) { + if (history && schema.validateSync(history)) { + return history as SerializedWatchHistoryItem[]; + } + + return []; + } + + private async getWatchHistoryFromAccount(user: Customer) { + const history = await this.accountService.getWatchHistory({ user }); + + return this.validateWatchHistory(history); + } + + private async getWatchHistoryFromStorage() { + const history = await this.storageService.getItem(this.PERSIST_KEY_WATCH_HISTORY, true); + + return this.validateWatchHistory(history); + } + + getWatchHistory = async (user: Customer | null, continueWatchingList: string) => { + const savedItems = user ? await this.getWatchHistoryFromAccount(user) : await this.getWatchHistoryFromStorage(); + + // 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 + const ids = savedItems.map(({ mediaid }) => mediaid); + + if (!ids.length) { + return []; + } + + try { + const watchHistoryItems = await this.getWatchHistoryItems(continueWatchingList, ids); + const seriesItems = await this.getWatchHistorySeriesItems(continueWatchingList, ids); + + return savedItems + .map((item) => { + const parentSeries = seriesItems?.[item.mediaid]; + const historyItem = watchHistoryItems[item.mediaid]; + + if (historyItem) { + return this.createWatchHistoryItem(parentSeries || historyItem, item.mediaid, parentSeries?.mediaid, item.progress); + } + }) + .filter((item): item is WatchHistoryItem => Boolean(item)); + } catch (error: unknown) { + logDev('Failed to get watch history items', error); + } + + return []; + }; + + serializeWatchHistory = (watchHistory: WatchHistoryItem[]): SerializedWatchHistoryItem[] => + watchHistory.map(({ mediaid, progress }) => ({ + mediaid, + progress, + })); + + persistWatchHistory = async (watchHistory: WatchHistoryItem[], user: Customer | null) => { + if (user) { + await this.accountService?.updateWatchHistory({ + history: this.serializeWatchHistory(watchHistory), + user, + }); + } else { + await this.storageService.setItem(this.PERSIST_KEY_WATCH_HISTORY, JSON.stringify(this.serializeWatchHistory(watchHistory))); + } + }; + + /** Use mediaid of originally watched movie / episode. + * A playlistItem can be either a series item (to show series card) or media item + * */ + createWatchHistoryItem = (item: PlaylistItem, mediaid: string, seriesId: string | undefined, videoProgress: number): WatchHistoryItem => { + return { + mediaid, + seriesId, + title: item.title, + tags: item.tags, + duration: item.duration, + progress: videoProgress, + playlistItem: item, + } as WatchHistoryItem; + }; + + getMaxWatchHistoryCount = () => { + return this.accountService?.features?.watchListSizeLimit || MAX_WATCHLIST_ITEMS_COUNT; + }; + + /** + * If we already have an element with continue watching state, we: + * 1. Update the progress + * 2. Move the element to the continue watching list start + * Otherwise: + * 1. Move the element to the continue watching list start + * 2. If there are many elements in continue watching state we remove the oldest one + */ + saveItem = async (item: PlaylistItem, seriesItem: PlaylistItem | undefined, videoProgress: number | null, watchHistory: WatchHistoryItem[]) => { + if (!videoProgress) return; + + const watchHistoryItem = this.createWatchHistoryItem(seriesItem || item, item.mediaid, seriesItem?.mediaid, videoProgress); + // filter out the existing watch history item, so we can add it to the beginning of the list + const updatedHistory = watchHistory.filter(({ mediaid, seriesId }) => { + return mediaid !== watchHistoryItem.mediaid && (!seriesId || seriesId !== watchHistoryItem.seriesId); + }); + + updatedHistory.unshift(watchHistoryItem); + updatedHistory.splice(this.getMaxWatchHistoryCount()); + + return updatedHistory; + }; +} diff --git a/packages/common/src/services/epg/JWEpgService.test.ts b/packages/common/src/services/epg/JWEpgService.test.ts new file mode 100644 index 000000000..af0976bc3 --- /dev/null +++ b/packages/common/src/services/epg/JWEpgService.test.ts @@ -0,0 +1,165 @@ +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 type { Playlist } from '../../../types/playlist'; + +import JWEpgService from './JWEpgService'; + +const livePlaylist = livePlaylistFixture as Playlist; +const epgService = new JWEpgService(); + +describe('JWwEpgService', () => { + beforeEach(() => { + mockFetch.clearAll(); + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + // must be called before `vi.useRealTimers()` + unregister(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('fetchSchedule performs a request', async () => { + const mock = mockGet('/epg/jwChannel.json').willResolve([]); + const data = await epgService.fetchSchedule(livePlaylist.playlist[0]); + + const request = mock.getRouteCalls()[0]; + const requestHeaders = request?.[1]?.headers; + + expect(data).toEqual([]); + expect(mock).toHaveFetched(); + expect(requestHeaders).toEqual(new Headers()); // no headers expected + }); + + test('fetchSchedule adds authentication token', async () => { + const mock = mockGet('/epg/jwChannel.json').willResolve([]); + const item = Object.assign({}, livePlaylist.playlist[0]); + + item.scheduleToken = 'AUTH-TOKEN'; + const data = await epgService.fetchSchedule(item); + + const request = mock.getRouteCalls()[0]; + const requestHeaders = request?.[1]?.headers; + + expect(data).toEqual([]); + expect(mock).toHaveFetched(); + expect(requestHeaders).toEqual(new Headers({ 'API-KEY': 'AUTH-TOKEN' })); + }); + + test('transformProgram should transform valid program entries', async () => { + const program1 = await epgService.transformProgram({ + id: '1234-1234-1234-1234', + title: 'Test item', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + }); + + const program2 = await epgService.transformProgram({ + id: '1234-1234-1234-1234', + title: 'Test item 2', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + chapterPointCustomProperties: [], + }); + + const program3 = await epgService.transformProgram({ + id: '1234-1234-1234-1234', + title: 'Test item 3', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + chapterPointCustomProperties: [ + { + key: 'description', + value: 'A description', + }, + { + key: 'image', + value: 'https://cdn.jwplayer/logo.jpg', + }, + { + key: 'other-key', + value: 'this property should be ignored', + }, + ], + }); + + expect(program1).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + description: undefined, + image: undefined, + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program2).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 2', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + description: undefined, + image: undefined, + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program3).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 3', + startTime: '2022-07-19T12:00:00Z', + endTime: '2022-07-19T15:00:00Z', + description: 'A description', + cardImage: 'https://cdn.jwplayer/logo.jpg', + backgroundImage: 'https://cdn.jwplayer/logo.jpg', + }); + }); + + test('transformProgram should reject invalid entries', async () => { + // missing title + await epgService + .transformProgram({ + id: '1234-1234-1234-1234-1234', + startTime: '2022-07-19T09:00:00Z', + endTime: '2022-07-19T12:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing startTime + await epgService + .transformProgram({ + id: '1234-1234-1234-1234-1234', + title: 'The title', + endTime: '2022-07-19T12:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing endTime + await epgService + .transformProgram({ + id: '1234-1234-1234-1234-1234', + title: 'The title', + startTime: '2022-07-19T09:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing id + await epgService + .transformProgram({ + title: 'The title', + startTime: '2022-07-19T09:00:00Z', + endTime: '2022-07-19T12:00:00Z', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + }); +}); diff --git a/packages/common/src/services/epg/JWEpgService.ts b/packages/common/src/services/epg/JWEpgService.ts new file mode 100644 index 000000000..dc49e13e8 --- /dev/null +++ b/packages/common/src/services/epg/JWEpgService.ts @@ -0,0 +1,73 @@ +import { array, object, string } from 'yup'; +import { isValid } from 'date-fns'; +import { injectable } from 'inversify'; + +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'; + +const jwEpgProgramSchema = object().shape({ + id: string().required(), + title: string().required(), + startTime: string() + .required() + .test((value) => (value ? isValid(new Date(value)) : false)), + endTime: string() + .required() + .test((value) => (value ? isValid(new Date(value)) : false)), + chapterPointCustomProperties: array().of( + object().shape({ + key: string().required(), + value: string().test('required-but-empty', 'value is required', (value: unknown) => typeof value === 'string'), + }), + ), +}); + +@injectable() +export default class JWEpgService extends EpgService { + transformProgram = async (data: unknown): Promise => { + const program = await jwEpgProgramSchema.validate(data); + const image = program.chapterPointCustomProperties?.find((item) => item.key === 'image')?.value || undefined; + + return { + id: program.id, + title: program.title, + startTime: program.startTime, + endTime: program.endTime, + cardImage: image, + backgroundImage: image, + description: program.chapterPointCustomProperties?.find((item) => item.key === 'description')?.value || undefined, + }; + }; + + fetchSchedule = async (item: PlaylistItem) => { + if (!item.scheduleUrl) { + logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); + return undefined; + } + + const headers = new Headers(); + + // add authentication token when `scheduleToken` is defined + if (item.scheduleToken) { + headers.set(AUTHENTICATION_HEADER, item.scheduleToken); + } + + try { + const response = await fetch(item.scheduleUrl, { + headers, + }); + + // await needed to ensure the error is caught here + return await getDataOrThrow(response); + } catch (error: unknown) { + if (error instanceof Error) { + logDev(`Fetch failed for EPG schedule: '${item.scheduleUrl}'`, error); + } + } + }; +} diff --git a/packages/common/src/services/epg/ViewNexaEpgService.test.ts b/packages/common/src/services/epg/ViewNexaEpgService.test.ts new file mode 100644 index 000000000..b2730a91a --- /dev/null +++ b/packages/common/src/services/epg/ViewNexaEpgService.test.ts @@ -0,0 +1,237 @@ +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 type { Playlist } from '../../../types/playlist'; +import { EPG_TYPE } from '../../constants'; + +import ViewNexaEpgService from './ViewNexaEpgService'; + +const livePlaylist = livePlaylistFixture as Playlist; +const epgService = new ViewNexaEpgService(); + +describe('ViewNexaEpgService', () => { + beforeEach(() => { + mockFetch.clearAll(); + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + // must be called before `vi.useRealTimers()` + unregister(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('fetchSchedule performs a request', async () => { + const mock = mockGet('/epg/viewNexaChannel.xml').willResolveOnce([]); + const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.viewNexa }); + + expect(mock).toHaveFetched(); + expect(data).toEqual([]); + }); + + test('fetchSchedule parses xml content', async () => { + const mock = mockGet('/epg/viewNexaChannel.xml').willResolveOnce(viewNexaChannel); + const data = await epgService.fetchSchedule({ ...livePlaylist.playlist[0], scheduleUrl: '/epg/viewNexaChannel.xml', scheduleType: EPG_TYPE.viewNexa }); + + expect(mock).toHaveFetched(); + expect(data[0]).toEqual({ + channel: 'dc4b6b04-7c6f-49f1-aac1-1ff61cb7d089', + date: 20231120, + desc: { + '#text': + 'Tears of Steel (code-named Project Mango) is a short science fiction film by producer Ton Roosendaal and director/writer Ian Hubert. The film is both live-action and CGI; it was made using new enhancements to the visual effects capabilities of Blender, a free and open-source 3D computer graphics app. Set in a dystopian future, the short film features a group of warriors and scientists who gather at the Oude Kerk in Amsterdam in a desperate attempt to save the world from destructive robots.', + lang: 'en', + }, + 'episode-num': { + '#text': '5a66fb0a-7ad7-4429-a736-168862df98e5', + system: 'assetId', + }, + genre: { + '#text': 'action', + lang: 'en', + }, + icon: { + height: '720', + src: 'https://fueltools-prod01-public.fuelmedia.io/4523afd9-82d5-45ae-9496-786451f2b517/20230330/5a66fb0a-7ad7-4429-a736-168862df98e5/thumbnail_20230330012948467.jpg', + width: '1728', + }, + length: { + '#text': 734.192, + units: 'seconds', + }, + rating: { + system: 'bfcc', + value: 'CC-BY', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + title: { + '#text': 'Tears of Steel', + lang: 'en', + }, + }); + }); + + test('transformProgram should transform valid program entries', async () => { + const program1 = await epgService.transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item', + }, + desc: { + '#text': 'Test desc', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }); + + const program2 = await epgService.transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 2', + }, + desc: { + '#text': 'Test desc', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }); + + const program3 = await epgService.transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }); + + expect(program1).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item', + startTime: '2023-11-20T07:30:00.000Z', + endTime: '2023-11-20T10:50:14.000Z', + description: 'Test desc', + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program2).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 2', + startTime: '2023-11-20T07:30:00.000Z', + endTime: '2023-11-20T10:50:14.000Z', + description: 'Test desc', + cardImage: undefined, + backgroundImage: undefined, + }); + + expect(program3).toEqual({ + id: '1234-1234-1234-1234', + title: 'Test item 3', + startTime: '2023-11-20T07:30:00.000Z', + endTime: '2023-11-20T10:50:14.000Z', + description: 'A description', + cardImage: 'https://cdn.jwplayer/logo.jpg', + backgroundImage: 'https://cdn.jwplayer/logo.jpg', + }); + }); + + test('transformProgram should reject invalid entries', async () => { + // missing title + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing startTime + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + stop: '20231120105014 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing endTime + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + + // missing id + await epgService + .transformProgram({ + 'episode-num': { + '#text': '1234-1234-1234-1234', + }, + title: { + '#text': 'Test item 3', + }, + desc: { + '#text': 'A description', + }, + icon: { + src: 'https://cdn.jwplayer/logo.jpg', + }, + start: '20231120073000 +0000', + stop: '20231120105014 +0000', + }) + .then((res) => expect(res).toBeUndefined()) + .catch((err) => expect(err).toBeDefined()); + }); +}); diff --git a/packages/common/src/services/epg/ViewNexaEpgService.ts b/packages/common/src/services/epg/ViewNexaEpgService.ts new file mode 100644 index 000000000..b1ea1850c --- /dev/null +++ b/packages/common/src/services/epg/ViewNexaEpgService.ts @@ -0,0 +1,72 @@ +import { object, string } from 'yup'; +import { parse } from 'date-fns'; +import { injectable } from 'inversify'; + +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({ + '#text': string().required(), + }), + title: object().shape({ + '#text': string().required(), + }), + desc: object().shape({ + '#text': string(), + }), + icon: object().shape({ + src: string(), + }), + start: string().required(), + stop: string().required(), +}); + +const parseData = (date: string): string => parse(date, 'yyyyMdHms xxxx', new Date()).toISOString(); + +@injectable() +export default class ViewNexaEpgService extends EpgService { + transformProgram = async (data: unknown): Promise => { + const program = await viewNexaEpgProgramSchema.validate(data); + + return { + id: program['episode-num']['#text'], + title: program['title']['#text'], + startTime: parseData(program['start']), + endTime: parseData(program['stop']), + description: program?.['desc']?.['#text'], + cardImage: program?.['icon']?.['src'], + backgroundImage: program?.['icon']?.['src'], + }; + }; + + fetchSchedule = async (item: PlaylistItem) => { + const { XMLParser } = await import('fast-xml-parser'); + + const scheduleUrl = item.scheduleUrl; + + if (!scheduleUrl) { + logDev('Tried requesting a schedule for an item with missing `scheduleUrl`', item); + return undefined; + } + + const xmlParserOptions = { + ignoreAttributes: false, + attributeNamePrefix: '', + }; + + try { + const data = await fetch(scheduleUrl).then((res) => res.text()); + const parser = new XMLParser(xmlParserOptions); + const schedule = parser.parse(data); + + return schedule?.tv?.programme || []; + } catch (error: unknown) { + if (error instanceof Error) { + logDev(`Fetch failed for View Nexa EPG schedule: '${scheduleUrl}'`, error); + } + } + }; +} diff --git a/packages/common/src/services/integrations/AccountService.ts b/packages/common/src/services/integrations/AccountService.ts new file mode 100644 index 000000000..deb186041 --- /dev/null +++ b/packages/common/src/services/integrations/AccountService.ts @@ -0,0 +1,97 @@ +import type { AccessModel, Config } from '../../../types/config'; +import type { + ChangePassword, + ChangePasswordWithOldPassword, + DeleteAccount, + ExportAccountData, + GetCaptureStatus, + GetCustomerConsents, + GetPublisherConsents, + Login, + NotificationsData, + Register, + ResetPassword, + GetSocialURLs, + UpdateCaptureAnswers, + UpdateCustomerConsents, + Logout, + GetAuthData, + UpdateCustomer, + UpdateWatchHistory, + UpdateFavorites, + GetWatchHistory, + GetFavorites, + GetUser, +} from '../../../types/account'; + +export type AccountServiceFeatures = { + readonly canUpdateEmail: boolean; + readonly canSupportEmptyFullName: boolean; + readonly canChangePasswordWithOldPassword: boolean; + readonly canRenewSubscription: boolean; + readonly canExportAccountData: boolean; + readonly canDeleteAccount: boolean; + readonly canUpdatePaymentMethod: boolean; + readonly canShowReceipts: boolean; + readonly hasSocialURLs: boolean; + readonly hasNotifications: boolean; + readonly watchListSizeLimit: number; +}; + +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, url: string, logoutCallback: () => Promise) => Promise; + + abstract getAuthData: GetAuthData; + + abstract login: Login; + + abstract register: Register; + + abstract logout: Logout; + + abstract getUser: GetUser; + + abstract getPublisherConsents: GetPublisherConsents; + + abstract getCustomerConsents: GetCustomerConsents; + + abstract updateCustomerConsents: UpdateCustomerConsents; + + abstract getCaptureStatus: GetCaptureStatus; + + abstract updateCaptureAnswers: UpdateCaptureAnswers; + + abstract resetPassword: ResetPassword; + + abstract changePasswordWithResetToken: ChangePassword; + + abstract changePasswordWithOldPassword: ChangePasswordWithOldPassword; + + abstract updateCustomer: UpdateCustomer; + + abstract updateWatchHistory: UpdateWatchHistory; + + abstract updateFavorites: UpdateFavorites; + + abstract getWatchHistory: GetWatchHistory; + + abstract getFavorites: GetFavorites; + + abstract subscribeToNotifications: NotificationsData; + + abstract getSocialUrls?: GetSocialURLs; + + abstract exportAccountData?: ExportAccountData; + + abstract deleteAccount?: DeleteAccount; +} diff --git a/packages/common/src/services/integrations/CheckoutService.ts b/packages/common/src/services/integrations/CheckoutService.ts new file mode 100644 index 000000000..483422073 --- /dev/null +++ b/packages/common/src/services/integrations/CheckoutService.ts @@ -0,0 +1,64 @@ +import type { + AddAdyenPaymentDetails, + CreateOrder, + DeletePaymentMethod, + FinalizeAdyenPaymentDetails, + GetAdyenPaymentSession, + GetDirectPostCardPayment, + GetEntitlements, + GetFinalizeAdyenPayment, + GetInitialAdyenPayment, + GetOffer, + GetOffers, + GetOrder, + GetPaymentMethods, + GetSubscriptionSwitch, + GetSubscriptionSwitches, + PaymentWithoutDetails, + PaymentWithPayPal, + SwitchSubscription, + UpdateOrder, + UpdatePaymentWithPayPal, +} from '../../../types/checkout'; + +export default abstract class CheckoutService { + abstract getOffers: GetOffers; + + abstract createOrder: CreateOrder; + + abstract updateOrder: UpdateOrder; + + abstract getPaymentMethods: GetPaymentMethods; + + abstract paymentWithoutDetails: PaymentWithoutDetails; + + abstract paymentWithPayPal: PaymentWithPayPal; + + abstract getEntitlements: GetEntitlements; + + abstract directPostCardPayment: GetDirectPostCardPayment; + + abstract getOffer?: GetOffer; + + abstract getOrder?: GetOrder; + + abstract switchSubscription?: SwitchSubscription; + + abstract getSubscriptionSwitches?: GetSubscriptionSwitches; + + abstract getSubscriptionSwitch?: GetSubscriptionSwitch; + + abstract createAdyenPaymentSession?: GetAdyenPaymentSession; + + abstract initialAdyenPayment?: GetInitialAdyenPayment; + + abstract finalizeAdyenPayment?: GetFinalizeAdyenPayment; + + abstract updatePaymentMethodWithPayPal?: UpdatePaymentWithPayPal; + + abstract deletePaymentMethod?: DeletePaymentMethod; + + abstract addAdyenPaymentDetails?: AddAdyenPaymentDetails; + + abstract finalizeAdyenPaymentDetails?: FinalizeAdyenPaymentDetails; +} diff --git a/packages/common/src/services/integrations/ProfileService.ts b/packages/common/src/services/integrations/ProfileService.ts new file mode 100644 index 000000000..dac065623 --- /dev/null +++ b/packages/common/src/services/integrations/ProfileService.ts @@ -0,0 +1,15 @@ +import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../types/profiles'; + +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/packages/common/src/services/integrations/SubscriptionService.ts b/packages/common/src/services/integrations/SubscriptionService.ts new file mode 100644 index 000000000..ce8522da2 --- /dev/null +++ b/packages/common/src/services/integrations/SubscriptionService.ts @@ -0,0 +1,34 @@ +import type { + ChangeSubscription, + FetchReceipt, + GetActivePayment, + GetActiveSubscription, + GetAllTransactions, + GetPaymentDetails, + GetSubscriptions, + GetTransactions, + UpdateCardDetails, + UpdateSubscription, +} from '../../../types/subscription'; + +export default abstract class SubscriptionService { + abstract getActiveSubscription: GetActiveSubscription; + + abstract getAllTransactions: GetAllTransactions; + + abstract getActivePayment: GetActivePayment; + + abstract getSubscriptions: GetSubscriptions; + + abstract updateSubscription: UpdateSubscription; + + abstract fetchReceipt: FetchReceipt; + + abstract changeSubscription?: ChangeSubscription; + + abstract updateCardDetails?: UpdateCardDetails; + + abstract getPaymentDetails?: GetPaymentDetails; + + abstract getTransactions?: GetTransactions; +} diff --git a/packages/common/src/services/integrations/cleeng/CleengAccountService.ts b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts new file mode 100644 index 000000000..f89994bd8 --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts @@ -0,0 +1,356 @@ +import jwtDecode from 'jwt-decode'; +import { inject, injectable } from 'inversify'; + +import type { AccessModel, Config } from '../../../../types/config'; +import type { + AuthData, + ChangePassword, + ChangePasswordWithOldPassword, + GetCaptureStatus, + GetCaptureStatusResponse, + GetCustomerConsents, + GetPublisherConsents, + JwtDetails, + Login, + LoginPayload, + NotificationsData, + Register, + RegisterPayload, + ResetPassword, + UpdateCaptureAnswers, + UpdateCaptureAnswersPayload, + UpdateCustomer, + UpdateCustomerConsents, + UpdateCustomerConsentsPayload, + UpdateCustomerPayload, + UpdateFavorites, + UpdateWatchHistory, +} 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 type { ServiceResponse } from '../../../../types/service'; +import type { SerializedWatchHistoryItem } from '../../../../types/watchHistory'; +import type { SerializedFavorite } from '../../../../types/favorite'; + +import CleengService from './CleengService'; +import type { + GetCustomerResponse, + GetCustomerConsentsResponse, + GetPublisherConsentsResponse, + UpdateConsentsResponse, + UpdateCustomerResponse, + AuthResponse, +} from './types/account'; +import { formatCustomer } from './formatters/customer'; +import { formatPublisherConsent } from './formatters/consents'; +import type { Response } from './types/api'; + +@injectable() +export default class CleengAccountService extends AccountService { + private readonly cleengService; + private readonly getCustomerIP; + private publisherId = ''; + + private externalData: Record = {}; + + accessModel: AccessModel = ACCESS_MODEL.AUTHVOD; + svodOfferIds: string[] = []; + sandbox = false; + + constructor(cleengService: CleengService, @inject(GET_CUSTOMER_IP) getCustomerIP: GetCustomerIP) { + super({ + canUpdateEmail: true, + canSupportEmptyFullName: true, + canChangePasswordWithOldPassword: false, + canRenewSubscription: true, + canExportAccountData: false, + canDeleteAccount: false, + canUpdatePaymentMethod: true, + canShowReceipts: false, + hasSocialURLs: false, + hasNotifications: false, + // The 'externalData' attribute of Cleeng can contain max 4000 characters + watchListSizeLimit: 48, + }); + + this.cleengService = cleengService; + this.getCustomerIP = getCustomerIP; + } + + private handleErrors = (errors: string[]) => { + if (errors.length > 0) { + throw new Error(errors[0]); + } + }; + + private getCustomerIdFromAuthData = (auth: AuthData) => { + const decodedToken: JwtDetails = jwtDecode(auth.jwt); + return decodedToken.customerId; + }; + + private getCustomer = async ({ customerId }: { customerId: string }) => { + const { responseData, errors } = await this.cleengService.get(`/customers/${customerId}`, { + authenticate: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + + return formatCustomer(responseData); + }; + + private getLocales = async () => { + return this.cleengService.getLocales(); + }; + + 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 () => { + if (this.cleengService.tokens) { + return { + jwt: this.cleengService.tokens.accessToken, + refreshToken: this.cleengService.tokens.refreshToken, + } as AuthData; + } + + return null; + }; + + getCustomerConsents: GetCustomerConsents = async (payload) => { + const { customer } = payload; + const response = await this.cleengService.get(`/customers/${customer?.id}/consents`, { + authenticate: true, + }); + this.handleErrors(response.errors); + + return response?.responseData?.consents || []; + }; + + updateCustomerConsents: UpdateCustomerConsents = async (payload) => { + const { customer } = payload; + + const params: UpdateCustomerConsentsPayload = { + id: customer.id, + consents: payload.consents, + }; + + const response = await this.cleengService.put(`/customers/${customer?.id}/consents`, JSON.stringify(params), { + authenticate: true, + }); + this.handleErrors(response.errors); + + return await this.getCustomerConsents(payload); + }; + + login: Login = async ({ email, password }) => { + const payload: LoginPayload = { + email, + password, + publisherId: this.publisherId, + customerIP: await this.getCustomerIP(), + }; + + const { responseData: auth, errors } = await this.cleengService.post('/auths', JSON.stringify(payload)); + this.handleErrors(errors); + + await this.cleengService.setTokens({ accessToken: auth.jwt, refreshToken: auth.refreshToken }); + + const { user, customerConsents } = await this.getUser(); + + return { + user, + auth, + customerConsents, + }; + }; + + register: Register = async ({ email, password, consents }) => { + const localesResponse = await this.getLocales(); + + this.handleErrors(localesResponse.errors); + + const payload: RegisterPayload = { + email, + password, + locale: localesResponse.responseData.locale, + country: localesResponse.responseData.country, + currency: localesResponse.responseData.currency, + publisherId: this.publisherId, + customerIP: await this.getCustomerIP(), + }; + + 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 }); + + const { user } = await this.getUser(); + + const customerConsents = await this.updateCustomerConsents({ consents, customer: user }).catch(() => { + // error caught while updating the consents, but continue the registration process + return []; + }); + + return { + user, + auth, + customerConsents, + }; + }; + + logout = async () => { + // clear the persisted access tokens + await this.cleengService.clearTokens(); + }; + + getUser = async () => { + const authData = await this.getAuthData(); + + if (!authData) throw new Error('Not logged in'); + + const customerId = this.getCustomerIdFromAuthData(authData); + const user = await this.getCustomer({ customerId }); + const consents = await this.getCustomerConsents({ customer: user }); + + return { + user, + customerConsents: consents, + }; + }; + + getPublisherConsents: GetPublisherConsents = async () => { + const response = await this.cleengService.get(`/publishers/${this.publisherId}/consents`); + + this.handleErrors(response.errors); + + return (response.responseData?.consents || []).map(formatPublisherConsent); + }; + + getCaptureStatus: GetCaptureStatus = async ({ customer }) => { + const response: ServiceResponse = await this.cleengService.get(`/customers/${customer?.id}/capture/status`, { + authenticate: true, + }); + + this.handleErrors(response.errors); + + return response.responseData; + }; + + updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...payload }) => { + const params: UpdateCaptureAnswersPayload = { + customerId: customer.id, + ...payload, + }; + + const response = await this.cleengService.put(`/customers/${customer.id}/capture`, JSON.stringify(params), { + authenticate: true, + }); + this.handleErrors(response.errors); + + return this.getCustomer({ customerId: customer.id }); + }; + + resetPassword: ResetPassword = async (payload) => { + const response = await this.cleengService.put>( + '/customers/passwords', + JSON.stringify({ + ...payload, + publisherId: this.publisherId, + }), + ); + + this.handleErrors(response.errors); + }; + + changePasswordWithResetToken: ChangePassword = async (payload) => { + const response = await this.cleengService.patch>( + '/customers/passwords', + JSON.stringify({ + ...payload, + publisherId: this.publisherId, + }), + ); + + this.handleErrors(response.errors); + }; + + changePasswordWithOldPassword: ChangePasswordWithOldPassword = async () => { + // Cleeng doesn't support this feature + }; + + 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 + const { responseData, errors } = await this.cleengService.patch(`/customers/${id}`, JSON.stringify(params), { + authenticate: true, + keepalive: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + + return formatCustomer(responseData); + }; + + updateWatchHistory: UpdateWatchHistory = async ({ user, history }) => { + const payload = { id: user.id, externalData: { ...this.externalData, history } }; + const { errors, responseData } = await this.cleengService.patch(`/customers/${user.id}`, JSON.stringify(payload), { + authenticate: true, + keepalive: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + }; + + updateFavorites: UpdateFavorites = async ({ user, favorites }) => { + const payload = { id: user.id, externalData: { ...this.externalData, favorites } }; + const { errors, responseData } = await this.cleengService.patch(`/customers/${user.id}`, JSON.stringify(payload), { + authenticate: true, + keepalive: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + }; + + getWatchHistory = async () => { + return (this.externalData['history'] || []) as SerializedWatchHistoryItem[]; + }; + + getFavorites = async () => { + return (this.externalData['favorites'] || []) as SerializedFavorite[]; + }; + + subscribeToNotifications: NotificationsData = async () => { + return false; + }; + + getSocialUrls: undefined; + + exportAccountData: undefined; + + deleteAccount: undefined; +} 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..c0dcd4e9c --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts @@ -0,0 +1,166 @@ +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 customerIP = locales.responseData.ipAddress; + + const createOrderPayload: CreateOrderPayload = { + offerId: payload.offer.offerId, + customerId: payload.customerId, + country: payload.country, + currency: locales?.responseData?.currency || 'EUR', + 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/packages/common/src/services/integrations/cleeng/CleengService.ts b/packages/common/src/services/integrations/cleeng/CleengService.ts new file mode 100644 index 000000000..34d0ba298 --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/CleengService.ts @@ -0,0 +1,377 @@ +import jwtDecode from 'jwt-decode'; +import { object, string } from 'yup'; +import { inject, injectable } from 'inversify'; +import { BroadcastChannel } from 'broadcast-channel'; + +import { IS_DEVELOPMENT_BUILD, logDev } from '../../../utils/common'; +import { PromiseQueue } from '../../../utils/promiseQueue'; +import type { AuthData } from '../../../../types/account'; +import StorageService from '../../StorageService'; +import { GET_CUSTOMER_IP } from '../../../modules/types'; +import type { GetCustomerIP } from '../../../../types/get-customer-ip'; + +import type { GetLocalesResponse } from './types/account'; +import type { Response } from './types/api'; + +const AUTH_PERSIST_KEY = 'auth'; + +type Tokens = { + accessToken: string; + refreshToken: string; +}; + +type MessageAction = 'refreshing' | 'resolved' | 'rejected'; + +type MessageData = { + action: MessageAction; + tokens?: Tokens; +}; + +type JWTPayload = { + exp?: number; +}; + +type RequestOptions = { + authenticate?: boolean; + keepalive?: boolean; +}; + +const tokensSchema = object().shape({ + accessToken: string().required(), + refreshToken: string().required(), +}); + +/** + * Validate the given input if it confirms to the tokenSchema + */ +const isValidTokens = (candidate: unknown): candidate is Tokens => { + return tokensSchema.isValidSync(candidate); +}; + +/** + * Given an JWT, return token expiration in milliseconds. Returns -1 when the token is invalid. + */ +const getTokenExpiration = (token: string) => { + try { + const decodedToken: JWTPayload = jwtDecode(token); + + if (typeof decodedToken.exp === 'number') { + return decodedToken.exp * 1000; + } + } catch (error: unknown) { + // failed to parse the JWT string + } + + return -1; +}; + +/** + * The AuthService is responsible for managing JWT access tokens and refresh tokens. + * + * Once an access token and refresh token is set, it will automatically refresh the access token when it is about to + * expire. + * + * It uses a PromiseQueue to prevent multiple instances refreshing the same token which fails. + * + * The Broadcaster ensures that all potential browser tabs are notified about the updated token. This prevents + * race-conditions when the user has multiple tabs open (but also very helpful while developing). + */ + +@injectable() +export default class CleengService { + private readonly storageService; + private readonly getCustomerIP; + private readonly channel: BroadcastChannel; + private readonly queue = new PromiseQueue(); + private isRefreshing = false; + private expiration = -1; + + sandbox = false; + tokens: Tokens | null = null; + + 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); + } + } + + /** + * The `logoutCallback` is a delegate that can be set to handle what should happen when the token is expired or not + * valid anymore. Since this service shouldn't care about the UI or Zustand state, a controller can use this delegate + * to update the UI and clear the logged in state. Alternatively, this could dispatch an event, but a delegate is + * easier to align with the InPlayer integration. + */ + private logoutCallback?: () => Promise; + + /** + * This function does the actual refresh of the access token by calling the `refreshToken` endpoint in the Cleeng + * service. + */ + private getNewTokens: (tokens: Tokens) => Promise = async ({ refreshToken }) => { + try { + const { responseData: newTokens } = await this.post>('/auths/refresh_token', JSON.stringify({ refreshToken })); + + return { + accessToken: newTokens.jwt, + refreshToken: newTokens.refreshToken, + }; + } catch (error: unknown) { + if (error instanceof Error) { + logDev('Failed to refresh accessToken', error); + + // only logout when the token is expired or invalid, this prevents logging out users when the request failed due to a + // network error or for aborted requests + if (error.message.includes('Refresh token is expired or does not exist') || error.message.includes('Missing or invalid parameter')) { + if (!this.logoutCallback) logDev('logoutCallback is not set'); + await this.logoutCallback?.(); + } + } + + throw error; + } + }; + + /** + * This function is called when a broadcast message is received from another browser tab (same origin) + */ + private handleBroadcastMessage = async (data: MessageData) => { + this.isRefreshing = data.action === 'refreshing'; + + if (data.tokens) { + await this.setTokens(data.tokens); + } + + if (data.action === 'resolved') { + await this.queue.resolve(); + } else if (data.action === 'rejected') { + await this.queue.reject(); + } + }; + + /** + * Notify other browser tabs about a change in the auth state + */ + private sendBroadcastMessage = (state: MessageAction, tokens?: Tokens) => { + const message: MessageData = { + action: state, + tokens, + }; + + this.channel.postMessage(message); + }; + + private getBaseUrl = () => (this.sandbox ? 'https://mediastore-sandbox.cleeng.com' : 'https://mediastore.cleeng.com'); + + 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()}${path}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: token ? `Bearer ${token}` : '', + }, + keepalive: options.keepalive, + method, + body, + }); + + return await resp.json(); + } catch (error: unknown) { + return { + errors: Array.of(error as string), + }; + } + }; + + /** + * Initialize the auth service and try to restore the session from the storage. + * + * When a valid session is found, it refreshes the access token when needed. + * + * For development builds, a small random delay is added to prevent all open tabs refreshing the same token when + * being fully refreshed. + */ + initialize = async (sandbox: boolean, logoutCallback: () => Promise) => { + this.sandbox = sandbox; + this.logoutCallback = logoutCallback; + + await this.restoreTokensFromStorage(); + + // wait a random time to prevent refresh token race-conditions when multiple tabs are open + // this is only needed when dealing with a live reload environment + if (IS_DEVELOPMENT_BUILD) { + await new Promise((resolve) => setTimeout(resolve, Math.random() * 500)); + } + + await this.maybeRefreshAccessToken(); + }; + + /** + * Set tokens and persist the tokens in the storage + */ + setTokens = async (tokens: Tokens) => { + this.tokens = tokens; + this.expiration = getTokenExpiration(tokens.accessToken); + + await this.persistInStorage(this.tokens); + }; + + /** + * Remove tokens and clear the storage + */ + clearTokens = async () => { + this.tokens = 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 this.storageService.getItem(AUTH_PERSIST_KEY, false); + let tokens; + + if (typeof tokensString !== 'string') return; + + try { + tokens = JSON.parse(tokensString); + + if (!isValidTokens(tokens)) return; + } catch { + return; + } + + const expires = getTokenExpiration(tokens.accessToken); + + // verify if the token from storage is newer than the one we have in the current session + if (expires > this.expiration) { + this.tokens = tokens; + this.expiration = expires; + } + }; + + /** + * Returns true when the current access token is expired + */ + accessTokenIsExpired = () => { + if (!this.tokens) return false; + + return this.expiration > -1 && Date.now() - this.expiration > 0; + }; + + hasTokens = () => { + return !!this.tokens; + }; + + /** + * Don't use this method directly, but use the `maybeRefreshAccessToken` method instead. + * This function will fetch new tokens and updates the isRefreshing state. It also resolves or rejects the promise + * queue. + */ + private refreshTokens = async (tokens: Tokens) => { + this.sendBroadcastMessage('refreshing'); + this.isRefreshing = true; + + try { + const newTokens = await this.getNewTokens(tokens); + + if (newTokens) { + await this.setTokens(newTokens); + this.isRefreshing = false; + this.sendBroadcastMessage('resolved', newTokens); + await this.queue.resolve(); + + return; + } + } catch (error: unknown) { + logDev('Failed to refresh tokens', error); + } + + // if we are here, we didn't receive new tokens + await this.clearTokens(); + this.isRefreshing = false; + this.sendBroadcastMessage('rejected'); + await this.queue.reject(); + }; + + /** + * Use this function ensure that the access token is not expired. If the current token is expired, it will request + * new tokens. When another session is already refreshing the tokens it will wait in queue instead and use the same + * results. + */ + maybeRefreshAccessToken = async () => { + try { + // token is already refreshing, let's wait for it + if (this.isRefreshing) { + logDev('Token is already refreshing, waiting in queue...'); + return await this.queue.enqueue(); + } + + // token is not expired or there is no session + if (!this.accessTokenIsExpired() || !this.tokens) { + return; + } + + await this.refreshTokens(this.tokens); + } catch (error: unknown) { + logDev('Error caught while refreshing the access token', error); + } + }; + + /** + * Get access optional token + */ + getAccessToken = async () => { + await this.maybeRefreshAccessToken(); + + // fallback to always "syncing" the tokens from storage in case the broadcast channel isn't supported + await this.restoreTokensFromStorage(); + + return this.tokens?.accessToken; + }; + + /** + * Get access token or throw an error + */ + getAccessTokenOrThrow = async () => { + const accessToken = await this.getAccessToken(); + + if (!accessToken) { + throw new Error('Access token is missing'); + } + + return accessToken; + }; + + getLocales = async () => { + const customerIP = await this.getCustomerIP(); + + return this.get(`/locales${customerIP ? '?customerIP=' + customerIP : ''}`); + }; + + get = (path: string, options?: RequestOptions) => this.performRequest(path, 'GET', undefined, options) as Promise; + + patch = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PATCH', body, options) as Promise; + + put = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PUT', body, options) as Promise; + + post = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'POST', body, options) as Promise; + + remove = (path: string, options?: RequestOptions) => this.performRequest(path, 'DELETE', undefined, options) as Promise; +} 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/packages/common/src/services/integrations/cleeng/formatters/consents.ts b/packages/common/src/services/integrations/cleeng/formatters/consents.ts new file mode 100644 index 000000000..910c9908f --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/formatters/consents.ts @@ -0,0 +1,16 @@ +import type { PublisherConsent } from '../types/models'; +import type { CustomFormField } from '../../../../../types/account'; + +export const formatPublisherConsent = (consent: PublisherConsent): CustomFormField => { + return { + type: 'checkbox', + name: consent.name, + label: consent.label, + defaultValue: '', + required: consent.required, + placeholder: consent.placeholder, + options: {}, + enabledByDefault: false, + version: consent.version, + }; +}; diff --git a/packages/common/src/services/integrations/cleeng/formatters/customer.ts b/packages/common/src/services/integrations/cleeng/formatters/customer.ts new file mode 100644 index 000000000..3a206a2bb --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/formatters/customer.ts @@ -0,0 +1,15 @@ +import type { CleengCustomer } from '../types/models'; +import type { Customer } from '../../../../../types/account'; + +export const formatCustomer = (customer: CleengCustomer): Customer => { + return { + id: customer.id, + email: customer.email, + country: customer.country, + firstName: customer.firstName, + lastName: customer.lastName, + fullName: `${customer.firstName} ${customer.lastName}`, + // map `externalData` to `metadata` (NOTE; The Cleeng API returns parsed values) + metadata: customer.externalData || {}, + }; +}; diff --git a/packages/common/src/services/integrations/cleeng/types/account.ts b/packages/common/src/services/integrations/cleeng/types/account.ts new file mode 100644 index 000000000..956015ecf --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/types/account.ts @@ -0,0 +1,21 @@ +import type { AuthData } from '../../../../../types/account'; + +import type { Response } from './api'; +import type { CleengCustomer, LocalesData, PublisherConsent, CustomerConsent, UpdateConfirmation } from './models'; + +// Cleeng typings for the account endpoints + +// Auth +export type AuthResponse = Response; + +// Customer +export type GetCustomerResponse = Response; +export type UpdateCustomerResponse = Response; + +// Consents +export type UpdateConsentsResponse = Response; +export type GetPublisherConsentsResponse = Response<{ consents: PublisherConsent[] }>; +export type GetCustomerConsentsResponse = Response<{ consents: CustomerConsent[] }>; + +// Locales +export type GetLocalesResponse = Response; diff --git a/packages/common/src/services/integrations/cleeng/types/api.ts b/packages/common/src/services/integrations/cleeng/types/api.ts new file mode 100644 index 000000000..3c5a198e6 --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/types/api.ts @@ -0,0 +1,3 @@ +// Cleeng typings for generic API response structures + +export type Response = { responseData: R; errors: string[] }; diff --git a/packages/common/src/services/integrations/cleeng/types/models.ts b/packages/common/src/services/integrations/cleeng/types/models.ts new file mode 100644 index 000000000..f5e50c82b --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/types/models.ts @@ -0,0 +1,47 @@ +// Cleeng typings for API models + +export interface CleengCustomer { + id: string; + email: string; + country: string; + regDate: string; + lastLoginDate?: string; + lastUserIp: string; + firstName?: string; + lastName?: string; + externalId?: string; + externalData?: Record; +} + +export interface UpdateConfirmation { + success: boolean; +} + +export interface LocalesData { + country: string; + currency: string; + locale: string; + ipAddress: string; +} + +export interface PublisherConsent { + name: string; + label: string; + placeholder: string; + required: boolean; + version: string; + value: string; +} + +export interface CustomerConsent { + customerId: string; + date: number; + label: string; + name: string; + needsUpdate: boolean; + newestVersion: string; + required: boolean; + state: 'accepted' | 'declined'; + value: string | boolean; + version: string; +} diff --git a/packages/common/src/services/integrations/jwp/JWPAccountService.ts b/packages/common/src/services/integrations/jwp/JWPAccountService.ts new file mode 100644 index 000000000..a2e258592 --- /dev/null +++ b/packages/common/src/services/integrations/jwp/JWPAccountService.ts @@ -0,0 +1,554 @@ +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 { formatConsentsToRegisterFields } from '../../../utils/collection'; +import { isCommonError } from '../../../utils/api'; +import type { + AuthData, + ChangePassword, + ChangePasswordWithOldPassword, + CustomFormField, + Customer, + CustomerConsent, + CustomRegisterFieldVariant, + DeleteAccount, + ExportAccountData, + GetCaptureStatus, + GetCustomerConsents, + GetPublisherConsents, + Login, + NotificationsData, + Register, + ResetPassword, + GetSocialURLs, + UpdateCaptureAnswers, + UpdateCustomerArgs, + UpdateCustomerConsents, + UpdateFavorites, + UpdateWatchHistory, + UpdateCustomer, +} from '../../../../types/account'; +import type { AccessModel, Config } from '../../../../types/config'; +import type { InPlayerAuthData } from '../../../../types/inplayer'; +import type { SerializedFavorite } from '../../../../types/favorite'; +import type { SerializedWatchHistoryItem } 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 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, + canChangePasswordWithOldPassword: true, + canRenewSubscription: false, + canExportAccountData: true, + canUpdatePaymentMethod: false, + canShowReceipts: true, + canDeleteAccount: true, + hasNotifications: true, + hasSocialURLs: true, + // Limit of media_ids length passed to the /apps/watchlists endpoint + watchListSizeLimit: 48, + }); + + this.storageService = storageService; + } + + private parseJson = (value: string, fallback = {}) => { + try { + return JSON.parse(value); + } catch { + return fallback; + } + }; + + private formatFavorite = (favorite: FavoritesData): SerializedFavorite => { + return { + mediaid: favorite.media_id, + }; + }; + + private formatHistoryItem = (history: WatchHistory): SerializedWatchHistoryItem => { + return { + mediaid: history.media_id, + progress: history.progress, + }; + }; + + private formatAccount = (account: AccountData): Customer => { + const { id, uuid, email, full_name: fullName, metadata, created_at: createdAt } = account; + const regDate = new Date(createdAt * 1000).toLocaleString(); + + const firstName = metadata?.first_name as string; + const lastName = metadata?.surname as string; + + return { + id: id.toString(), + uuid, + email, + fullName, + firstName, + lastName, + metadata, + regDate, + country: '', + lastUserIp: '', + }; + }; + + private formatAuth(auth: InPlayerAuthData): AuthData { + const { access_token: jwt } = auth; + return { + jwt, + refreshToken: '', + }; + } + + 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); + + // 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)); + }; + + getAuthData = async () => { + if (InPlayer.Account.isAuthenticated()) { + const credentials = InPlayer.Account.getToken().toObject(); + + return { + jwt: credentials.token, + refreshToken: credentials.refreshToken, + } as AuthData; + } + + return null; + }; + + getPublisherConsents: GetPublisherConsents = async () => { + try { + const { data } = await InPlayer.Account.getRegisterFields(this.clientId); + + const terms = data?.collection.find(({ name }) => name === 'terms'); + + const result = data?.collection + // we exclude these fields because we already have them by default + .filter((field) => !['email_confirmation', 'first_name', 'surname'].includes(field.name) && ![terms].includes(field)) + .map( + (field): CustomFormField => ({ + type: field.type as CustomRegisterFieldVariant, + isCustomRegisterField: true, + name: field.name, + label: field.label, + placeholder: field.placeholder, + required: field.required, + options: field.options, + defaultValue: '', + version: '1', + ...(field.type === 'checkbox' + ? { + enabledByDefault: field.default_value === 'true', + } + : { + defaultValue: field.default_value, + }), + }), + ); + + return terms ? [this.getTermsConsent(terms), ...result] : result; + } catch { + throw new Error('Failed to fetch publisher consents.'); + } + }; + + getCustomerConsents: GetCustomerConsents = async (payload) => { + try { + if (!payload?.customer) { + return { + consents: [], + }; + } + + const { customer } = payload; + + return this.parseJson(customer.metadata?.consents as string, []); + } catch { + throw new Error('Unable to fetch Customer consents.'); + } + }; + + updateCustomerConsents: UpdateCustomerConsents = async (payload) => { + try { + const { customer, consents } = payload; + + const existingAccountData = this.formatUpdateAccount(customer); + + const params = { + ...existingAccountData, + metadata: { + ...existingAccountData.metadata, + ...formatConsentsToRegisterFields(consents), + consents: JSON.stringify(consents), + }, + }; + + const { data } = await InPlayer.Account.updateAccount(params); + + return this.parseJson(data?.metadata?.consents as string, []); + } catch { + throw new Error('Unable to update Customer consents'); + } + }; + + updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...newAnswers }) => { + return this.updateCustomer({ ...customer, ...newAnswers }); + }; + + changePasswordWithOldPassword: ChangePasswordWithOldPassword = async (payload) => { + const { oldPassword, newPassword, newPasswordConfirmation } = payload; + + try { + await InPlayer.Account.changePassword({ + oldPassword, + password: newPassword, + passwordConfirmation: newPasswordConfirmation, + }); + } catch (error: unknown) { + if (isCommonError(error)) { + throw new Error(error.response.data.message); + } + throw new Error('Failed to change password'); + } + }; + + resetPassword: ResetPassword = async ({ customerEmail }) => { + try { + await InPlayer.Account.requestNewPassword({ + email: customerEmail, + merchantUuid: this.clientId, + brandingId: 0, + }); + } catch { + throw new Error('Failed to reset password.'); + } + }; + + login: Login = async ({ email, password, referrer }) => { + try { + const { data } = await InPlayer.Account.signInV2({ + email, + password, + referrer, + clientId: this.clientId || '', + }); + + const user = this.formatAccount(data.account); + + return { + auth: this.formatAuth(data), + user, + customerConsents: this.parseJson(user?.metadata?.consents as string, []), + }; + } catch { + throw new Error('Failed to authenticate user.'); + } + }; + + register: Register = async ({ email, password, referrer, consents }) => { + try { + const { data } = await InPlayer.Account.signUpV2({ + email, + password, + referrer, + passwordConfirmation: password, + fullName: email, + metadata: { + first_name: ' ', + surname: ' ', + ...formatConsentsToRegisterFields(consents), + consents: JSON.stringify(consents), + }, + type: 'consumer', + clientId: this.clientId || '', + }); + + const user = this.formatAccount(data.account); + + return { + auth: this.formatAuth(data), + user, + customerConsents: this.parseJson(user?.metadata?.consents as string, []), + }; + } catch (error: unknown) { + if (isCommonError(error)) { + throw new Error(error.response.data.message); + } + throw new Error('Failed to create account.'); + } + }; + + logout = async () => { + try { + if (InPlayer.Notifications.isSubscribed()) { + InPlayer.Notifications.unsubscribe(); + } + + if (InPlayer.Account.isAuthenticated()) { + await InPlayer.Account.signOut(); + } + } catch { + throw new Error('Failed to sign out.'); + } + }; + + getUser = async () => { + try { + const { data } = await InPlayer.Account.getAccountInfo(); + + const user = this.formatAccount(data); + + return { + user, + customerConsents: this.parseJson(user?.metadata?.consents as string, []) as CustomerConsent[], + }; + } catch { + throw new Error('Failed to fetch user data.'); + } + }; + + updateCustomer: UpdateCustomer = async (customer) => { + try { + const response = await InPlayer.Account.updateAccount(this.formatUpdateAccount(customer)); + + return this.formatAccount(response.data); + } catch { + throw new Error('Failed to update user data.'); + } + }; + + formatUpdateAccount = (customer: UpdateCustomerArgs) => { + const firstName = customer.firstName?.trim() || ''; + const lastName = customer.lastName?.trim() || ''; + const fullName = `${firstName} ${lastName}`.trim() || (customer.email as string); + const metadata: Record = { + ...customer.metadata, + first_name: firstName, + surname: lastName, + }; + const data: UpdateAccountData = { + fullName, + metadata, + }; + + return data; + }; + + getCaptureStatus: GetCaptureStatus = async ({ customer }) => { + return { + isCaptureEnabled: true, + shouldCaptureBeDisplayed: true, + settings: [ + { + answer: { + firstName: customer.firstName || null, + lastName: customer.lastName || null, + }, + enabled: true, + key: 'firstNameLastName', + required: true, + }, + ], + }; + }; + + changePasswordWithResetToken: ChangePassword = async (payload) => { + const { resetPasswordToken = '', newPassword, newPasswordConfirmation = '' } = payload; + try { + await InPlayer.Account.setNewPassword( + { + password: newPassword, + passwordConfirmation: newPasswordConfirmation, + brandingId: 0, + }, + resetPasswordToken, + ); + } catch (error: unknown) { + if (isCommonError(error)) { + throw new Error(error.response.data.message); + } + throw new Error('Failed to change password.'); + } + }; + + getTermsConsent = ({ label: termsUrl }: RegisterField): CustomFormField => { + 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, + required: true, + name: 'terms', + defaultValue: '', + label: termsUrl + ? i18next.t('account:registration.terms_consent', { termsLink }) + : i18next.t('account:registration.terms_consent_jwplayer', { termsLink }), + enabledByDefault: false, + placeholder: '', + options: {}, + version: '1', + }; + }; + + updateWatchHistory: UpdateWatchHistory = async ({ history }) => { + const current = await this.getWatchHistory(); + const savedHistory = current.map((e) => e.mediaid) || []; + + await Promise.allSettled( + history.map(({ mediaid, progress }) => { + if (!savedHistory.includes(mediaid) || current.some((e) => e.mediaid == mediaid && e.progress != progress)) { + return InPlayer.Account.updateWatchHistory(mediaid, progress); + } + }), + ); + }; + + updateFavorites: UpdateFavorites = async ({ favorites }) => { + const current = await this.getFavorites(); + const currentFavoriteIds = current.map((e) => e.mediaid) || []; + const payloadFavoriteIds = favorites.map((e) => e.mediaid); + + // save new favorites + await Promise.allSettled( + payloadFavoriteIds.map((mediaId) => { + return !currentFavoriteIds.includes(mediaId) ? InPlayer.Account.addToFavorites(mediaId) : Promise.resolve(); + }), + ); + + // delete removed favorites + await Promise.allSettled( + currentFavoriteIds.map((mediaId) => { + return !payloadFavoriteIds.includes(mediaId) ? InPlayer.Account.deleteFromFavorites(mediaId) : Promise.resolve(); + }), + ); + }; + + getFavorites = async () => { + const favoritesData = await InPlayer.Account.getFavorites(); + + return favoritesData.data?.collection?.map(this.formatFavorite) || []; + }; + + getWatchHistory = async () => { + const watchHistoryData = await InPlayer.Account.getWatchHistory({}); + + return watchHistoryData.data?.collection?.map(this.formatHistoryItem) || []; + }; + + subscribeToNotifications: NotificationsData = async ({ uuid, onMessage }) => { + try { + if (!InPlayer.Notifications.isSubscribed()) { + InPlayer.subscribe(uuid, { + onMessage: onMessage, + onOpen: () => true, + }); + } + return true; + } catch { + return false; + } + }; + + exportAccountData: ExportAccountData = async () => { + // password is sent as undefined because it is now optional on BE + try { + const response = await InPlayer.Account.exportData({ password: undefined, brandingId: 0 }); + + return response.data; + } catch { + throw new Error('Failed to export account data'); + } + }; + + deleteAccount: DeleteAccount = async ({ password }) => { + try { + const response = await InPlayer.Account.deleteAccount({ password, brandingId: 0 }); + + return response.data; + } catch (error: unknown) { + if (isCommonError(error)) { + throw new Error(error.response.data.message || 'Failed to delete account'); + } + + throw new Error('Failed to delete account'); + } + }; + + getSocialUrls: GetSocialURLs = async ({ redirectUrl }) => { + const socialState = this.storageService.base64Encode( + JSON.stringify({ + client_id: this.clientId || '', + redirect: redirectUrl, + }), + ); + + const socialResponse = await InPlayer.Account.getSocialLoginUrls(socialState); + + if (socialResponse.status !== 200) { + throw new Error('Failed to fetch social urls'); + } + + return socialResponse.data.social_urls; + }; +} diff --git a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts new file mode 100644 index 000000000..006071ce1 --- /dev/null +++ b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts @@ -0,0 +1,264 @@ +import InPlayer, { type AccessFee, type MerchantPaymentMethod } from '@inplayer-org/inplayer.js'; +import { injectable } from 'inversify'; + +import { isSVODOffer } from '../../../utils/offers'; +import type { + CardPaymentData, + CreateOrder, + CreateOrderArgs, + GetEntitlements, + GetEntitlementsResponse, + GetOffers, + GetPaymentMethods, + Offer, + Order, + Payment, + PaymentMethod, + PaymentWithAdyen, + PaymentWithoutDetails, + PaymentWithPayPal, + UpdateOrder, +} from '../../../../types/checkout'; +import CheckoutService from '../CheckoutService'; +import type { ServiceResponse } from '../../../../types/service'; + +@injectable() +export default class JWPCheckoutService extends CheckoutService { + private readonly cardPaymentProvider = 'stripe'; + + private formatPaymentMethod = (method: MerchantPaymentMethod, cardPaymentProvider: string): PaymentMethod => { + return { + id: method.id, + methodName: method.method_name.toLocaleLowerCase(), + provider: cardPaymentProvider, + logoUrl: '', + } as PaymentMethod; + }; + + private formatEntitlements = (expiresAt: number = 0, accessGranted: boolean = false): ServiceResponse => { + return { + errors: [], + responseData: { + accessGranted, + expiresAt, + }, + }; + }; + + private formatOffer = (offer: AccessFee): Offer => { + const ppvOffers = ['ppv', 'ppv_custom']; + const offerId = ppvOffers.includes(offer.access_type.name) ? `C${offer.id}` : `S${offer.id}`; + + return { + id: offer.id, + offerId, + offerCurrency: offer.currency, + customerPriceInclTax: offer.amount, + customerCurrency: offer.currency, + offerTitle: offer.description, + active: true, + period: offer.access_type.period === 'month' && offer.access_type.quantity === 12 ? 'year' : offer.access_type.period, + freePeriods: offer.trial_period ? 1 : 0, + planSwitchEnabled: offer.item.plan_switch_enabled ?? false, + } as Offer; + }; + + private formatOrder = (payload: CreateOrderArgs): Order => { + return { + id: payload.offer.id, + customerId: payload.customerId, + offerId: payload.offer.offerId, + totalPrice: payload.offer.customerPriceInclTax, + priceBreakdown: { + offerPrice: payload.offer.customerPriceInclTax, + discountAmount: payload.offer.customerPriceInclTax, + discountedPrice: payload.offer.customerPriceInclTax, + paymentMethodFee: 0, + taxValue: 0, + }, + taxRate: 0, + currency: payload.offer.offerCurrency || 'EUR', + requiredPaymentDetails: true, + } as Order; + }; + + createOrder: CreateOrder = async (payload) => { + return { + errors: [], + responseData: { + message: '', + order: this.formatOrder(payload), + success: true, + }, + }; + }; + + getOffers: GetOffers = async (payload) => { + const offers = await Promise.all( + payload.offerIds.map(async (assetId) => { + try { + const { data } = await InPlayer.Asset.getAssetAccessFees(parseInt(`${assetId}`)); + + return data?.map((offer) => this.formatOffer(offer)); + } catch { + throw new Error('Failed to get offers'); + } + }), + ); + + return offers.flat(); + }; + + getPaymentMethods: GetPaymentMethods = async () => { + try { + const response = await InPlayer.Payment.getPaymentMethods(); + const paymentMethods: PaymentMethod[] = []; + response.data.forEach((method: MerchantPaymentMethod) => { + if (['card', 'paypal'].includes(method.method_name.toLowerCase())) { + paymentMethods.push(this.formatPaymentMethod(method, this.cardPaymentProvider)); + } + }); + return { + errors: [], + responseData: { + message: '', + paymentMethods, + status: 1, + }, + }; + } catch { + throw new Error('Failed to get payment methods'); + } + }; + + paymentWithPayPal: PaymentWithPayPal = async (payload) => { + try { + const response = await InPlayer.Payment.getPayPalParams({ + origin: payload.waitingUrl, + accessFeeId: payload.order.id, + paymentMethod: 2, + voucherCode: payload.couponCode, + }); + + if (response.data?.id) { + return { + errors: ['Already have an active access'], + responseData: { + redirectUrl: payload.errorUrl, + }, + }; + } + return { + errors: [], + responseData: { + redirectUrl: response.data.endpoint, + }, + }; + } catch { + throw new Error('Failed to generate PayPal payment url'); + } + }; + + iFrameCardPayment: PaymentWithAdyen = async () => { + return { + errors: [], + responseData: {} as Payment, + }; + }; + + paymentWithoutDetails: PaymentWithoutDetails = async () => { + return { + errors: [], + responseData: {} as Payment, + }; + }; + + updateOrder: UpdateOrder = async ({ order, couponCode }) => { + try { + const response = await InPlayer.Voucher.getDiscount({ + voucherCode: `${couponCode}`, + accessFeeId: order.id, + }); + order.discount = { + applied: true, + type: 'coupon', + periods: response.data.discount_duration, + }; + + const discountedAmount = order.totalPrice - response.data.amount; + order.totalPrice = response.data.amount; + order.priceBreakdown.discountAmount = discountedAmount; + order.priceBreakdown.discountedPrice = discountedAmount; + return { + errors: [], + responseData: { + message: 'successfully updated', + order: order, + success: true, + }, + }; + } catch { + throw new Error('Invalid coupon code'); + } + }; + + getEntitlements: GetEntitlements = async ({ offerId }) => { + try { + const response = await InPlayer.Asset.checkAccessForAsset(parseInt(offerId)); + return this.formatEntitlements(response.data.expires_at, true); + } catch { + return this.formatEntitlements(); + } + }; + + directPostCardPayment = async (cardPaymentPayload: CardPaymentData, order: Order, referrer: string, returnUrl: string) => { + const payload = { + number: cardPaymentPayload.cardNumber.replace(/\s/g, ''), + cardName: cardPaymentPayload.cardholderName, + expMonth: cardPaymentPayload.cardExpMonth || '', + expYear: cardPaymentPayload.cardExpYear || '', + cvv: cardPaymentPayload.cardCVC, + accessFee: order.id, + paymentMethod: 1, + voucherCode: cardPaymentPayload.couponCode, + referrer, + returnUrl, + }; + + try { + if (isSVODOffer(order)) { + await InPlayer.Subscription.createSubscription(payload); + } else { + await InPlayer.Payment.createPayment(payload); + } + + return true; + } catch { + throw new Error('Failed to make payment'); + } + }; + + getSubscriptionSwitches = undefined; + + getOrder = undefined; + + switchSubscription = undefined; + + getSubscriptionSwitch = undefined; + + createAdyenPaymentSession = undefined; + + initialAdyenPayment = undefined; + + finalizeAdyenPayment = undefined; + + updatePaymentMethodWithPayPal = undefined; + + deletePaymentMethod = undefined; + + addAdyenPaymentDetails = undefined; + + finalizeAdyenPaymentDetails = undefined; + + getOffer = undefined; +} diff --git a/packages/common/src/services/integrations/jwp/JWPProfileService.ts b/packages/common/src/services/integrations/jwp/JWPProfileService.ts new file mode 100644 index 000000000..5ed0a36ae --- /dev/null +++ b/packages/common/src/services/integrations/jwp/JWPProfileService.ts @@ -0,0 +1,99 @@ +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 '../ProfileService'; +import StorageService from '../../StorageService'; +import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../../types/profiles'; + +@injectable() +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(); + + return { + canManageProfiles: true, + collection: + response.data.map((profile) => ({ + ...profile, + avatar_url: profile?.avatar_url || defaultAvatar, + })) ?? [], + }; + } catch { + console.error('Unable to list profiles.'); + return { + canManageProfiles: false, + collection: [], + }; + } + }; + + createProfile: CreateProfile = async (payload) => { + const response = await InPlayer.Account.createProfile(payload.name, payload.adult, payload.avatar_url, payload.pin); + + return response.data; + }; + + updateProfile: UpdateProfile = async (payload) => { + if (!payload.id) { + throw new Error('Profile id is required.'); + } + + const response = await InPlayer.Account.updateProfile(payload.id, payload.name, payload.avatar_url, payload.adult); + + return response.data; + }; + + 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 profile; + } catch { + throw new Error('Unable to enter profile.'); + } + }; + + getProfileDetails: GetProfileDetails = async ({ id }) => { + try { + const response = await InPlayer.Account.getProfileDetails(id); + + return response.data; + } catch { + throw new Error('Unable to get profile details.'); + } + }; + + deleteProfile: DeleteProfile = async ({ id }) => { + try { + await InPlayer.Account.deleteProfile(id); + + return { + message: 'Profile deleted successfully', + code: 200, + }; + } catch { + throw new Error('Unable to delete profile.'); + } + }; +} diff --git a/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts new file mode 100644 index 000000000..6d42b457e --- /dev/null +++ b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts @@ -0,0 +1,286 @@ +import i18next from 'i18next'; +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, + PaymentDetail, + Subscription, + Transaction, + UpdateCardDetails, + UpdateSubscription, +} from '../../../../types/subscription'; +import SubscriptionService from '../SubscriptionService'; +import AccountService from '../AccountService'; + +import type JWPAccountService from './JWPAccountService'; + +interface SubscriptionDetails extends InplayerSubscription { + item_id?: number; + item_title?: string; + subscription_id?: string; + subscription_price?: number; + action_type?: 'recurrent' | 'canceled' | 'free-trial' | 'ended' | 'incomplete_expired'; + next_rebill_date?: number; + charged_amount?: number; + payment_method_name?: string; + access_type?: { + period: string; + }; + access_fee_id?: number; +} + +@injectable() +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 { + id: 0, + paymentMethodId: 0, + paymentGateway: 'card', + paymentMethod: 'card', + customerId: account_id.toString(), + paymentMethodSpecificParams: { + holderName: card_name, + variant: card_type, + lastCardFourDigits: String(number), + cardExpirationDate: `${zeroFillExpMonth}/${exp_year}`, + }, + active: true, + currency, + } as PaymentDetail; + }; + + private formatTransaction = (transaction: PaymentHistory): Transaction => { + const purchasedAmount = transaction?.charged_amount?.toString() || '0'; + + return { + transactionId: transaction.transaction_token || i18next.t('user:payment.access_granted'), + transactionDate: transaction.created_at, + trxToken: transaction.trx_token, + offerId: transaction.item_id?.toString() || i18next.t('user:payment.no_transaction'), + offerType: transaction.item_type || '', + offerTitle: transaction?.item_title || '', + offerPeriod: '', + transactionPriceExclTax: purchasedAmount, + transactionCurrency: transaction.currency_iso || 'EUR', + discountedOfferPrice: purchasedAmount, + offerCurrency: transaction.currency_iso || 'EUR', + offerPriceExclTax: purchasedAmount, + applicableTax: '0', + transactionPriceInclTax: purchasedAmount, + customerId: transaction.consumer_id?.toString(), + customerEmail: '', + customerLocale: '', + customerCountry: 'en', + customerIpCountry: '', + customerCurrency: '', + paymentMethod: transaction.payment_method_name || i18next.t('user:payment.access_granted'), + }; + }; + + private formatActiveSubscription = (subscription: SubscriptionDetails, expiresAt: number) => { + let status = ''; + switch (subscription.action_type) { + case 'free-trial': + status = 'active_trial'; + break; + case 'recurrent': + status = 'active'; + break; + case 'canceled': + status = 'cancelled'; + break; + case 'incomplete_expired' || 'ended': + status = 'expired'; + break; + default: + status = 'terminated'; + } + + return { + subscriptionId: subscription.subscription_id, + offerId: subscription.item_id?.toString(), + accessFeeId: `S${subscription.access_fee_id}`, + status, + expiresAt, + nextPaymentAt: subscription.next_rebill_date, + nextPaymentPrice: subscription.subscription_price, + nextPaymentCurrency: subscription.currency, + paymentGateway: 'stripe', + paymentMethod: subscription.payment_method_name, + offerTitle: subscription.item_title, + period: subscription.access_type?.period, + totalPrice: subscription.charged_amount, + unsubscribeUrl: subscription.unsubscribe_url, + pendingSwitchId: null, + } as Subscription; + }; + + private formatGrantedSubscription = (subscription: GetItemAccessV1) => { + return { + subscriptionId: '', + offerId: subscription.item.id.toString(), + status: 'active', + expiresAt: subscription.expires_at, + nextPaymentAt: subscription.expires_at, + nextPaymentPrice: 0, + nextPaymentCurrency: 'EUR', + paymentGateway: 'none', + paymentMethod: i18next.t('user:payment.access_granted'), + offerTitle: subscription.item.title, + period: 'granted', + totalPrice: 0, + unsubscribeUrl: '', + pendingSwitchId: null, + } as Subscription; + }; + + 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 hasAccess = await InPlayer.Asset.checkAccessForAsset(assetId); + + if (hasAccess) { + const { data } = await InPlayer.Subscription.getSubscriptions(); + const activeSubscription = data.collection.find((subscription: SubscriptionDetails) => subscription.item_id === assetId); + + if (activeSubscription) { + return this.formatActiveSubscription(activeSubscription, hasAccess?.data?.expires_at); + } + + return this.formatGrantedSubscription(hasAccess.data); + } + return null; + } catch (error: unknown) { + if (isCommonError(error) && error.response.data.code === 402) { + return null; + } + throw new Error('Unable to fetch customer subscriptions.'); + } + }; + + getAllTransactions: GetAllTransactions = async () => { + try { + const { data } = await InPlayer.Payment.getPaymentHistory(); + + return data?.collection?.map((transaction) => this.formatTransaction(transaction)); + } catch { + throw new Error('Failed to get transactions'); + } + }; + + getActivePayment: GetActivePayment = async () => { + try { + const { data } = await InPlayer.Payment.getDefaultCreditCard(); + const cards: PaymentDetail[] = []; + for (const currency in data?.cards) { + cards.push( + this.formatCardDetails({ + ...data.cards?.[currency], + currency: currency, + }), + ); + } + return cards.find((paymentDetails) => paymentDetails.active) || null; + } catch { + return null; + } + }; + + getSubscriptions = async () => { + return { + errors: [], + responseData: { items: [] }, + }; + }; + + updateSubscription: UpdateSubscription = async ({ offerId, unsubscribeUrl }) => { + if (!unsubscribeUrl) { + throw new Error('Missing unsubscribe url'); + } + try { + await InPlayer.Subscription.cancelSubscription(unsubscribeUrl); + return { + errors: [], + responseData: { offerId: offerId, status: 'cancelled', expiresAt: 0 }, + }; + } catch { + throw new Error('Failed to update subscription'); + } + }; + + changeSubscription: ChangeSubscription = async ({ accessFeeId, subscriptionId }) => { + try { + const response = await InPlayer.Subscription.changeSubscriptionPlan({ + access_fee_id: parseInt(accessFeeId), + inplayer_token: subscriptionId, + }); + return { + errors: [], + responseData: { message: response.data.message }, + }; + } catch { + throw new Error('Failed to change subscription'); + } + }; + + updateCardDetails: UpdateCardDetails = async ({ cardName, cardNumber, cvc, expMonth, expYear, currency }) => { + try { + const response = await InPlayer.Payment.setDefaultCreditCard({ + cardName, + cardNumber, + cvc, + expMonth, + expYear, + currency, + }); + return { + errors: [], + responseData: response.data, + }; + } catch { + throw new Error('Failed to update card details'); + } + }; + + fetchReceipt = async ({ transactionId }: { transactionId: string }) => { + try { + const { data } = await InPlayer.Payment.getBillingReceipt({ trxToken: transactionId }); + return { + errors: [], + responseData: data, + }; + } catch { + throw new Error('Failed to get billing receipt'); + } + }; + + getPaymentDetails = undefined; + + getTransactions = undefined; +} diff --git a/packages/common/src/stores/AccountStore.ts b/packages/common/src/stores/AccountStore.ts new file mode 100644 index 000000000..3521537a7 --- /dev/null +++ b/packages/common/src/stores/AccountStore.ts @@ -0,0 +1,40 @@ +import type { CustomFormField, 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; + user: Customer | null; + subscription: Subscription | null; + transactions: Transaction[] | null; + activePayment: PaymentDetail | null; + customerConsents: CustomerConsent[] | null; + publisherConsents: CustomFormField[] | null; + publisherConsentsLoading: boolean; + pendingOffer: Offer | null; + setLoading: (loading: boolean) => void; + getAccountInfo: () => { customerId: string; customer: Customer; customerConsents: CustomerConsent[] | null }; +}; + +export const useAccountStore = createStore('AccountStore', (set, get) => ({ + loading: true, + user: null, + subscription: null, + transactions: null, + activePayment: null, + customerConsents: null, + publisherConsents: null, + publisherConsentsLoading: false, + pendingOffer: null, + setLoading: (loading: boolean) => set({ loading }), + getAccountInfo: () => { + const user = get().user; + const customerConsents = get().customerConsents; + + if (!user?.id) throw new Error('user not logged in'); + + return { customerId: user?.id, customer: user, customerConsents }; + }, +})); diff --git a/packages/common/src/stores/CheckoutStore.ts b/packages/common/src/stores/CheckoutStore.ts new file mode 100644 index 000000000..0fa3fddaf --- /dev/null +++ b/packages/common/src/stores/CheckoutStore.ts @@ -0,0 +1,32 @@ +import type { Offer, Order, PaymentMethod } from '../../types/checkout'; +import type { MediaOffer } from '../../types/media'; + +import { createStore } from './utils'; + +type CheckoutStore = { + requestedMediaOffers: MediaOffer[]; + mediaOffers: Offer[]; + subscriptionOffers: Offer[]; + switchSubscriptionOffers: Offer[]; + selectedOffer: Offer | null; + defaultOfferId: string | null; + order: Order | null; + paymentMethods: PaymentMethod[] | null; + setRequestedMediaOffers: (requestedMediaOffers: MediaOffer[]) => void; + setOrder: (order: Order | null) => void; + setPaymentMethods: (paymentMethods: PaymentMethod[] | null) => void; +}; + +export const useCheckoutStore = createStore('CheckoutStore', (set) => ({ + requestedMediaOffers: [], + mediaOffers: [], + subscriptionOffers: [], + switchSubscriptionOffers: [], + selectedOffer: null, + defaultOfferId: null, + order: null, + paymentMethods: null, + setRequestedMediaOffers: (requestedMediaOffers) => set({ requestedMediaOffers }), + setOrder: (order) => set({ order }), + setPaymentMethods: (paymentMethods) => set({ paymentMethods }), +})); diff --git a/packages/common/src/stores/ConfigStore.ts b/packages/common/src/stores/ConfigStore.ts new file mode 100644 index 000000000..4a71a4115 --- /dev/null +++ b/packages/common/src/stores/ConfigStore.ts @@ -0,0 +1,47 @@ +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 { createStore } from './utils'; + +type ConfigState = { + loaded: boolean; + config: Config; + accessModel: AccessModel; + settings: Settings; + integrationType: string | null; + supportedLanguages: LanguageDefinition[]; +}; + +export const useConfigStore = createStore('ConfigStore', () => ({ + loaded: false, + config: { + id: '', + siteName: '', + description: '', + player: '', + siteId: '', + assets: {}, + content: [], + menu: [], + integrations: { + cleeng: { + useSandbox: true, + }, + jwp: { + clientId: null, + assetId: null, + useSandbox: true, + }, + }, + styling: {}, + }, + settings: { + additionalAllowedConfigSources: [], + playerId: OTT_GLOBAL_PLAYER_ID, + }, + supportedLanguages: [], + accessModel: ACCESS_MODEL.AVOD, + integrationType: null, +})); 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/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..9b4bd2b48 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/profiles'; + +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/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/packages/common/src/stores/utils.ts b/packages/common/src/stores/utils.ts new file mode 100644 index 000000000..63b1b9dca --- /dev/null +++ b/packages/common/src/stores/utils.ts @@ -0,0 +1,21 @@ +import type { State, StateCreator } from 'zustand'; +import create from 'zustand'; +import { devtools, subscribeWithSelector } from 'zustand/middleware'; + +import { IS_DEVELOPMENT_BUILD, IS_TEST_MODE } from '../utils/common'; + +export const createStore = (name: string, storeFn: StateCreator) => { + const store = subscribeWithSelector(storeFn); + + // https://github.com/pmndrs/zustand/issues/852#issuecomment-1059783350 + if (IS_DEVELOPMENT_BUILD && !IS_TEST_MODE) { + return create( + devtools(store, { + name, + anonymousActionType: `${name}/Action`, + }), + ); + } + + return create(store); +}; 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/packages/common/src/utils/analytics.ts b/packages/common/src/utils/analytics.ts new file mode 100644 index 000000000..aac42e5b8 --- /dev/null +++ b/packages/common/src/utils/analytics.ts @@ -0,0 +1,38 @@ +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(); + + const { sources, mediaid } = item; + + const userId = user?.id; + const profileId = profile?.id; + const isJwIntegration = !!config?.integrations?.jwp; + + sources.map((source: Source) => { + const url = new URL(source.file); + + const mediaId = mediaid.toLowerCase(); + const sourceUrl = url.href.toLowerCase(); + + // Attach user_id and profile_id only for VOD and BCL SaaS Live Streams + const isVOD = sourceUrl === `https://cdn.jwplayer.com/manifests/${mediaId}.m3u8`; + const isBCL = sourceUrl === `https://content.jwplatform.com/live/broadcast/${mediaId}.m3u8`; + + if ((isVOD || isBCL) && userId) { + url.searchParams.set('user_id', userId); + + if (isJwIntegration && profileId) { + url.searchParams.set('profile_id', profileId); + } + } + + source.file = url.toString(); + }); +}; diff --git a/packages/common/src/utils/api.ts b/packages/common/src/utils/api.ts new file mode 100644 index 000000000..bf1647f3a --- /dev/null +++ b/packages/common/src/utils/api.ts @@ -0,0 +1,56 @@ +import type { CommonResponse } from '@inplayer-org/inplayer.js'; + +import type { InPlayerError } from '../../types/inplayer'; + +export class ApiError extends Error { + code: number; + message: string; + + constructor(message = '', code: number) { + super(message); + this.name = 'ApiError'; + this.message = message; + this.code = code; + } +} + +/** + * Get data + * @param response + */ +export const getDataOrThrow = async (response: Response) => { + const data = await response.json(); + + if (!response.ok) { + const message = `Request '${response.url}' failed with ${response.status}`; + const apiMessage = data && typeof data === 'object' && 'message' in data && typeof data.message === 'string' ? data.message : undefined; + + throw new ApiError(apiMessage || message, response.status || 500); + } + + return data; +}; + +export const getCommonResponseData = (response: { data: CommonResponse }) => { + const { code, message } = response.data; + if (code !== 200) { + throw new Error(message); + } + return { + errors: [], + responseData: { + message, + code, + }, + }; +}; + +export const isCommonError = (error: unknown): error is InPlayerError => { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as InPlayerError).response?.data?.code === 'number' && + typeof (error as InPlayerError).response?.data?.message === 'string' + ); +}; diff --git a/src/utils/collection.ts b/packages/common/src/utils/collection.ts similarity index 83% rename from src/utils/collection.ts rename to packages/common/src/utils/collection.ts index 438aff68c..a12804783 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 { CustomFormField, 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); @@ -56,7 +57,7 @@ const generatePlaylistPlaceholder = (playlistLength: number = 15): Playlist => ( ), }); -const formatConsentValues = (publisherConsents: Consent[] | null = [], customerConsents: CustomerConsent[] | null = []) => { +const formatConsentValues = (publisherConsents: CustomFormField[] | null = [], customerConsents: CustomerConsent[] | null = []) => { if (!publisherConsents || !customerConsents) { return {}; } @@ -75,7 +76,7 @@ const formatConsentValues = (publisherConsents: Consent[] | null = [], customerC return values; }; -const formatConsents = (publisherConsents: Consent[] | null = [], customerConsents: CustomerConsent[] | null = []) => { +const formatConsents = (publisherConsents: CustomFormField[] | null = [], customerConsents: CustomerConsent[] | null = []) => { if (!publisherConsents || !customerConsents) { return {}; } @@ -89,7 +90,7 @@ const formatConsents = (publisherConsents: Consent[] | null = [], customerConsen return values; }; -const extractConsentValues = (consents?: Consent[]) => { +const extractConsentValues = (consents?: CustomFormField[]) => { const values: Record = {}; if (!consents) { @@ -103,7 +104,7 @@ const extractConsentValues = (consents?: Consent[]) => { return values; }; -const formatConsentsFromValues = (publisherConsents: Consent[] | null, values?: GenericFormValues) => { +const formatConsentsFromValues = (publisherConsents: CustomFormField[] | null, values?: GenericFormValues) => { const consents: CustomerConsent[] = []; if (!publisherConsents || !values) return consents; @@ -120,7 +121,7 @@ const formatConsentsFromValues = (publisherConsents: Consent[] | null, values?: return consents; }; -const checkConsentsFromValues = (publisherConsents: Consent[], consents: Record) => { +const checkConsentsFromValues = (publisherConsents: CustomFormField[], consents: Record) => { const customerConsents: CustomerConsent[] = []; const consentsErrors: string[] = []; @@ -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/packages/common/src/utils/common.ts b/packages/common/src/utils/common.ts new file mode 100644 index 000000000..f3666cb2c --- /dev/null +++ b/packages/common/src/utils/common.ts @@ -0,0 +1,114 @@ +export function debounce void>(callback: T, wait = 200) { + let timeout: NodeJS.Timeout | null; + return (...args: unknown[]) => { + if (timeout) clearTimeout(timeout); + 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) => { + return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16)); + }); +}; + +/** + * Parse hex color and return the RGB colors + * @param color + * @return {{r: number, b: number, g: number}|undefined} + */ +export function hexToRgb(color: string) { + if (color.indexOf('#') === 0) { + color = color.slice(1); + } + + // convert 3-digit hex to 6-digits. + if (color.length === 3) { + color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; + } + + if (color.length !== 6) { + return undefined; + } + + return { + r: parseInt(color.slice(0, 2), 16), + g: parseInt(color.slice(2, 4), 16), + b: parseInt(color.slice(4, 6), 16), + }; +} + +/** + * Get the contrast color based on the given color + * @param {string} color Hex or RGBA color string + * @link {https://stackoverflow.com/a/35970186/1790728} + * @return {string} + */ +export function calculateContrastColor(color: string) { + const rgb = hexToRgb(color); + + if (!rgb) { + return ''; + } + + // http://stackoverflow.com/a/3943023/112731 + return rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186 ? '#000000' : '#FFFFFF'; +} + +// Build is either Development or Production +// Mode can be dev, jwdev, demo, test, prod, etc. +export const IS_DEVELOPMENT_BUILD = __dev__; +// Demo mode is used to run our firebase demo instance +export const IS_DEMO_MODE = __mode__ === 'demo'; +// Test mode is used for e2e and unit tests +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 = __mode__ === 'prod'; + +export function logDev(message: unknown, ...optionalParams: unknown[]) { + if ((IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && !IS_TEST_MODE) { + if (optionalParams.length > 0) { + console.info(message, optionalParams); + } else { + console.info(message); + } + } +} + +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()); + +export function testId(value: string | undefined) { + return IS_DEVELOPMENT_BUILD || IS_TEST_MODE || IS_PREVIEW_MODE ? value : undefined; +} + +type Truthy = T extends false | '' | 0 | null | undefined ? never : T; +export const isTruthy = (value: T | true): value is Truthy => Boolean(value); 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 91% rename from src/utils/configSchema.ts rename to packages/common/src/utils/configSchema.ts index 166fe314b..e564a4eac 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(), @@ -50,6 +50,7 @@ export const configSchema: SchemaOf = object({ description: string().defined(), analyticsToken: string().nullable(), adSchedule: string().nullable(), + siteId: string().defined(), assets: object({ banner: string().notRequired().nullable(), }).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/packages/common/src/utils/formatting.ts b/packages/common/src/utils/formatting.ts new file mode 100644 index 000000000..83a8cb06a --- /dev/null +++ b/packages/common/src/utils/formatting.ts @@ -0,0 +1,68 @@ +export const formatDurationTag = (seconds: number) => { + if (!seconds) return ''; + + const minutes = Math.ceil(seconds / 60); + + return `${minutes} min`; +}; + +/** + * @param duration Duration in seconds + * + * Calculates hours and minutes into a string + * Hours are only shown if at least 1 + * Minutes get rounded + * + * @returns string, such as '2hrs 24min' or '31min' + */ + +export const formatDuration = (duration: number) => { + if (!duration) return ''; + + const hours = Math.floor(duration / 3600); + const minutes = Math.round((duration - hours * 3600) / 60); + + const hoursString = hours ? `${hours}hrs ` : ''; + const minutesString = minutes ? `${minutes}min ` : ''; + + return `${hoursString}${minutesString}`; +}; + +export const formatPrice = (price: number, currency: string, country?: string) => { + return new Intl.NumberFormat(country || 'en-US', { + style: 'currency', + currency: currency, + }).format(price); +}; + +export const formatSeriesMetaString = (seasonNumber?: string, episodeNumber?: string) => { + if (!seasonNumber && !episodeNumber) { + return ''; + } + + return seasonNumber && seasonNumber !== '0' ? `S${seasonNumber}:E${episodeNumber}` : `E${episodeNumber}`; +}; + +export const formatVideoSchedule = (locale: string, scheduledStart?: Date, scheduledEnd?: Date) => { + if (!scheduledStart) { + return ''; + } + + if (!scheduledEnd) { + return formatLocalizedDateTime(scheduledStart, locale, ' • '); + } + + return `${formatLocalizedDateTime(scheduledStart, locale, ' • ')} - ${formatLocalizedTime(scheduledEnd, locale)}`; +}; + +export const formatLocalizedDate = (date: Date, locale: string) => { + return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(date); +}; + +export const formatLocalizedTime = (date: Date, locale: string) => { + return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: 'numeric' }).format(date); +}; + +export const formatLocalizedDateTime = (date: Date, locale: string, separator = ' ') => { + return `${formatLocalizedDate(date, locale)}${separator}${formatLocalizedTime(date, locale)}`; +}; 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/packages/common/src/utils/media.ts b/packages/common/src/utils/media.ts new file mode 100644 index 000000000..cc0fa17bc --- /dev/null +++ b/packages/common/src/utils/media.ts @@ -0,0 +1,51 @@ +import type { Playlist, PlaylistItem } from '../../types/playlist'; +import { CONTENT_TYPE } from '../constants'; + +type RequiredProperties = T & Required>; + +type DeprecatedPlaylistItem = { + seriesPlayListId?: string; + seriesPlaylistId?: string; +}; + +export const isPlaylist = (item: unknown): item is Playlist => !!item && typeof item === 'object' && 'feedid' in item; +export const isPlaylistItem = (item: unknown): item is PlaylistItem => !!item && typeof item === 'object' && 'mediaid' in item; + +// For the deprecated series flow we store seriesId in custom params +export const getSeriesPlaylistIdFromCustomParams = (item: (PlaylistItem & DeprecatedPlaylistItem) | undefined) => + item ? item.seriesPlayListId || item.seriesPlaylistId || item.seriesId : undefined; + +// For the deprecated flow we store seriesId in the media custom params +export const isLegacySeriesFlow = (item: PlaylistItem) => { + return typeof getSeriesPlaylistIdFromCustomParams(item) !== 'undefined'; +}; + +// For the new series flow we use contentType custom param to define media item to be series +// In this case media item and series item have the same id +export const isSeriesContentType = (item: PlaylistItem) => item.contentType?.toLowerCase() === CONTENT_TYPE.series; + +export const isSeries = (item: PlaylistItem) => isLegacySeriesFlow(item) || isSeriesContentType(item); + +export const isEpisode = (item: PlaylistItem) => { + return typeof item?.episodeNumber !== 'undefined' || item?.contentType?.toLowerCase() === CONTENT_TYPE.episode; +}; + +export const getLegacySeriesPlaylistIdFromEpisodeTags = (item: PlaylistItem | undefined) => { + if (!item || !isEpisode(item)) { + return; + } + + const tags = item.tags ? item.tags.split(',') : []; + const seriesIdTag = tags.find(function (tag) { + return /seriesid_([\w\d]+)/i.test(tag); + }); + + if (seriesIdTag) { + return seriesIdTag.split('_')[1]; + } + + return; +}; + +export const isLiveChannel = (item: PlaylistItem): item is RequiredProperties => + item.contentType?.toLowerCase() === CONTENT_TYPE.liveChannel && !!item.liveChannelsId; diff --git a/packages/common/src/utils/metadata.ts b/packages/common/src/utils/metadata.ts new file mode 100644 index 000000000..3d7c12d90 --- /dev/null +++ b/packages/common/src/utils/metadata.ts @@ -0,0 +1,37 @@ +import type { Playlist, PlaylistItem } from '../../types/playlist'; + +import { formatDuration, formatVideoSchedule } from './formatting'; + +export const createVideoMetadata = (media: PlaylistItem, episodesLabel?: string) => { + const metaData = []; + const duration = formatDuration(media.duration); + + if (media.pubdate) metaData.push(String(new Date(media.pubdate * 1000).getFullYear())); + if (!episodesLabel && duration) metaData.push(duration); + if (episodesLabel) metaData.push(episodesLabel); + if (media.genre) metaData.push(media.genre); + if (media.rating) metaData.push(media.rating); + + return metaData; +}; +export const createPlaylistMetadata = (playlist: Playlist, episodesLabel?: string) => { + const metaData = []; + + if (episodesLabel) metaData.push(episodesLabel); + if (playlist.genre) metaData.push(playlist.genre as string); + if (playlist.rating) metaData.push(playlist.rating as string); + + return metaData; +}; +export const createLiveEventMetadata = (media: PlaylistItem, locale: string) => { + const metaData = []; + const scheduled = formatVideoSchedule(locale, media.scheduledStart, media.scheduledEnd); + const duration = formatDuration(media.duration); + + if (scheduled) metaData.push(scheduled); + if (duration) metaData.push(duration); + if (media.genre) metaData.push(media.genre); + if (media.rating) metaData.push(media.rating); + + return metaData; +}; diff --git a/packages/common/src/utils/offers.ts b/packages/common/src/utils/offers.ts new file mode 100644 index 000000000..d87001355 --- /dev/null +++ b/packages/common/src/utils/offers.ts @@ -0,0 +1,7 @@ +import type { Offer, Order } from '../../types/checkout'; + +import { formatPrice } from './formatting'; + +export const getOfferPrice = (offer: Offer) => formatPrice(offer.customerPriceInclTax, offer.customerCurrency, offer.customerCountry); + +export const isSVODOffer = (offer: Offer | Order) => offer.offerId?.[0] === 'S'; 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/packages/common/src/utils/structuredData.ts b/packages/common/src/utils/structuredData.ts new file mode 100644 index 000000000..6470ae526 --- /dev/null +++ b/packages/common/src/utils/structuredData.ts @@ -0,0 +1,59 @@ +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, origin: string) => { + const movieCanonical = `${origin}${mediaURL({ media: item })}`; + + return JSON.stringify({ + '@context': 'http://schema.org/', + '@type': 'VideoObject', + '@id': movieCanonical, + name: item.title, + description: item.description, + duration: secondsToISO8601(item.duration, true), + thumbnailUrl: item.image, + uploadDate: secondsToISO8601(item.pubdate), + }); +}; + +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 = `${origin}/m/${seriesId}`; + + return { + '@type': 'TVSeries', + '@id': seriesCanonical, + name: media.title, + numberOfEpisodes: String(series?.episode_count || 0), + numberOfSeasons: String(series?.season_count || 0), + }; +}; + +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); + } + + return JSON.stringify({ + '@context': 'http://schema.org/', + '@type': 'TVEpisode', + '@id': episodeCanonical, + episodeNumber: episodeMetadata?.episodeNumber || '0', + seasonNumber: episodeMetadata?.seasonNumber || '0', + name: episode.title, + uploadDate: secondsToISO8601(episode.pubdate), + partOfSeries: seriesMetadata, + }); +}; diff --git a/packages/common/src/utils/subscription.ts b/packages/common/src/utils/subscription.ts new file mode 100644 index 000000000..41b038541 --- /dev/null +++ b/packages/common/src/utils/subscription.ts @@ -0,0 +1,10 @@ +import type { Subscription } from '../../types/subscription'; + +export const determineSwitchDirection = (subscription: Subscription | null) => { + const currentPeriod = subscription?.period; + + if (currentPeriod === 'month') return 'upgrade'; + if (currentPeriod === 'year') return 'downgrade'; + + return 'upgrade'; // Default to 'upgrade' if the period is not 'month' or 'year' +}; diff --git a/packages/common/src/utils/urlFormatting.test.ts b/packages/common/src/utils/urlFormatting.test.ts new file mode 100644 index 000000000..22b439c48 --- /dev/null +++ b/packages/common/src/utils/urlFormatting.test.ts @@ -0,0 +1,71 @@ +import playlistFixture from '@jwp/ott-testing/fixtures/playlist.json'; +import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; + +import type { Playlist, PlaylistItem } from '../../types/playlist'; +import type { EpgChannel } from '../../types/epg'; +import { RELATIVE_PATH_USER_ACCOUNT } from '../paths'; + +import { createURL, liveChannelsURL, mediaURL, playlistURL, userProfileURL } from './urlFormatting'; + +describe('createUrl', () => { + test('valid url from a path, query params', () => { + 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'); + }); +}); + +describe('createPath, mediaURL, playlistURL and liveChannelsURL', () => { + test('valid media path', () => { + const playlist = playlistFixture as Playlist; + const media = playlist.playlist[0] as PlaylistItem; + const url = mediaURL({ media, playlistId: playlist.feedid, play: true }); + + expect(url).toEqual('/m/uB8aRnu6/agent-327?r=dGSUzs9o&play=1'); + }); + test('valid playlist path', () => { + const playlist = playlistFixture as Playlist; + const url = playlistURL(playlist.feedid || '', playlist.title); + + expect(url).toEqual('/p/dGSUzs9o/all-films'); + }); + test('valid live channel path', () => { + const playlist = playlistFixture as Playlist; + const channels: EpgChannel[] = epgChannelsFixture; + const channel = channels[0]; + const url = liveChannelsURL(playlist.feedid || '', channel.id, true); + + expect(url).toEqual('/p/dGSUzs9o/?channel=channel1&play=1'); + }); + test('valid live channel path', () => { + const url = userProfileURL('testprofile123'); + + expect(url).toEqual('/u/my-profile/testprofile123'); + }); + test('valid nested user path', () => { + const url = RELATIVE_PATH_USER_ACCOUNT; + + expect(url).toEqual('my-account'); + }); + test('valid nested user profile path', () => { + const url = userProfileURL('testprofile123', true); + + expect(url).toEqual('my-profile/testprofile123'); + }); +}); diff --git a/packages/common/src/utils/urlFormatting.ts b/packages/common/src/utils/urlFormatting.ts new file mode 100644 index 000000000..4ce11e919 --- /dev/null +++ b/packages/common/src/utils/urlFormatting.ts @@ -0,0 +1,144 @@ +import type { PlaylistItem } from '../../types/playlist'; +import { RELATIVE_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE } from '../paths'; + +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}` : ''}`; +}; + +type ExtractRouteParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? { [K in Param | keyof ExtractRouteParams]: string } + : T extends `${infer _Start}:${infer Param}` + ? { [K in Param]: string } + : object; + +type PathParams = T extends `${infer _Start}*` ? ExtractRouteParams & Record : ExtractRouteParams; + +// Creates a route path from a path string and params object +export const createPath = (originalPath: Path, pathParams?: PathParams, queryParams?: QueryParamsArg): string => { + const path = originalPath + .split('/') + .map((segment) => { + if (segment === '*') { + // Wild card for optional segments: add all params that are not already in the path + if (!pathParams) return segment; + + return Object.entries(pathParams) + .filter(([key]) => !originalPath.includes(key)) + .map(([_, value]) => value) + .join('/'); + } + if (!segment.startsWith(':') || !pathParams) return segment; + + // Check if param is optional, and show a warning if it's not optional and missing + // Then remove all special characters to get the actual param name + const isOptional = segment.endsWith('?'); + const paramName = segment.replace(':', '').replace('?', ''); + const paramValue = pathParams[paramName as keyof typeof pathParams]; + + if (!paramValue) { + if (!isOptional) console.warn('Missing param in path creation.', { path: originalPath, paramName }); + + return ''; + } + + return paramValue; + }) + .join('/'); + + // Optionally add the query params + return queryParams ? createURL(path, queryParams) : path; +}; + +export const slugify = (text: string, whitespaceChar: string = '-') => + text + .toString() + .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 createPath(PATH_MEDIA, { id: media.mediaid, title: slugify(media.title) }, { r: playlistId, play: play ? '1' : null, e: episodeId }); +}; + +export const playlistURL = (id: string, title?: string) => { + return createPath(PATH_PLAYLIST, { id, title: title ? slugify(title) : undefined }); +}; + +export const liveChannelsURL = (playlistId: string, channelId?: string, play = false) => { + return createPath( + PATH_PLAYLIST, + { id: playlistId }, + { + channel: channelId, + play: play ? '1' : null, + }, + ); +}; + +export const userProfileURL = (profileId: string, nested = false) => { + const path = nested ? RELATIVE_PATH_USER_MY_PROFILE : PATH_USER_MY_PROFILE; + + return createPath(path, { id: profileId }); +}; + +// Legacy URLs +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/packages/common/types/account.ts b/packages/common/types/account.ts new file mode 100644 index 000000000..603467fce --- /dev/null +++ b/packages/common/types/account.ts @@ -0,0 +1,335 @@ +import type { Config } from './config'; +import type { PromiseRequest } from './service'; +import type { SerializedWatchHistoryItem } from './watchHistory'; +import type { SerializedFavorite } from './favorite'; + +export type AuthData = { + jwt: string; + refreshToken: string; +}; + +export type JwtDetails = { + customerId: string; + exp: number; + publisherId: number; +}; + +export type PayloadWithIPOverride = { + customerIP?: string; +}; + +export type LoginArgs = { + email: string; + password: string; + referrer: string; +}; + +export type RegistrationArgs = LoginArgs & { + consents: CustomerConsent[]; +}; + +export type AuthResponse = { + auth: AuthData; + user: Customer; + customerConsents: CustomerConsent[]; +}; + +export type LoginPayload = PayloadWithIPOverride & { + email: string; + password: string; + offerId?: string; + publisherId?: string; +}; + +export type LoginFormData = { + email: string; + password: string; +}; + +export type RegistrationFormData = { + email: string; + password: string; +}; + +export type ForgotPasswordFormData = { + email: string; +}; + +export type DeleteAccountFormData = { + password: string; +}; + +export type EditPasswordFormData = { + email?: string; + oldPassword?: string; + password: string; + passwordConfirmation: string; + resetPasswordToken?: string; +}; + +export type GetUserArgs = { + config: Config; +}; + +export type GetUserPayload = { + user: Customer; + customerConsents: CustomerConsent[]; +}; + +export type RegisterPayload = PayloadWithIPOverride & { + email: string; + password: string; + offerId?: string; + publisherId?: string; + locale: string; + country: string; + currency: string; + firstName?: string; + lastName?: string; + externalId?: string; + externalData?: string; +}; + +export type CleengCaptureField = { + key: string; + enabled: boolean; + required: boolean; + answer: string | Record | null; +}; + +export type CleengCaptureQuestionField = { + key: string; + enabled: boolean; + required: boolean; + value: string; + question: string; + answer: string | null; +}; + +export type PersonalDetailsFormData = { + firstName: string; + lastName: string; + birthDate: string; + companyName: string; + phoneNumber: string; + address: string; + address2: string; + city: string; + state: string; + postCode: string; + country: string; +}; + +export type GetCustomerConsentsResponse = { + consents: CustomerConsent[]; +}; + +export type ResetPasswordPayload = { + customerEmail: string; + offerId?: string; + resetUrl?: string; +}; + +export type ChangePasswordWithTokenPayload = { + customerEmail?: string; + resetPasswordToken: string; + newPassword: string; + newPasswordConfirmation: string; +}; + +export type ChangePasswordWithOldPasswordPayload = { + oldPassword: string; + newPassword: string; + newPasswordConfirmation: string; +}; + +export type UpdateCustomerPayload = { + id?: string; + email?: string; + confirmationPassword?: string; + firstName?: string; + lastName?: string; +}; + +export type UpdateCustomerConsentsPayload = { + id?: string; + consents: CustomerConsent[]; +}; + +export type Customer = { + id: string; + email: string; + firstName?: string; + country?: string; + metadata: Record; + lastName?: string; + fullName?: string; + [key: string]: unknown; +}; + +export type UpdateCustomerArgs = { + id?: string | undefined; + email?: string | undefined; + confirmationPassword?: string | undefined; + firstName?: string | undefined; + lastName?: string | undefined; + metadata?: Record; + fullName?: string; +}; + +export type CustomRegisterFieldVariant = 'input' | 'select' | 'country' | 'us_state' | 'radio' | 'checkbox' | 'datepicker'; + +export interface CustomFormField { + type: CustomRegisterFieldVariant; + isCustomRegisterField?: boolean; + enabledByDefault?: boolean; + defaultValue?: string; + name: string; + label: string; + placeholder: string; + required: boolean; + options: Record; + version: string; +} + +export type CustomerConsent = { + customerId?: string; + date?: number; + label?: string; + name: string; + needsUpdate?: boolean; + newestVersion?: string; + required?: boolean; + state: 'accepted' | 'declined'; + value?: string | boolean; + version: string; +}; + +export type CustomerConsentArgs = { + customer: Customer; +}; + +export type UpdateCustomerConsentsArgs = { + customer: Customer; + consents: CustomerConsent[]; +}; + +export type GetCaptureStatusResponse = { + isCaptureEnabled: boolean; + shouldCaptureBeDisplayed: boolean; + settings: Array; +}; + +export type CaptureCustomAnswer = { + questionId: string; + question: string; + value: string; +}; + +export type Capture = { + firstName?: string; + address?: string; + address2?: string; + city?: string; + state?: string; + postCode?: string; + country?: string; + birthDate?: string; + companyName?: string; + phoneNumber?: string; + customAnswers?: CaptureCustomAnswer[]; +}; + +export type GetCaptureStatusArgs = { + customer: Customer; +}; + +export type UpdateCaptureStatusArgs = { + customer: Customer; +} & Capture; + +export type UpdateCaptureAnswersPayload = { + customerId: string; +} & Capture; + +export type FirstLastNameInput = { + firstName: string; + lastName: string; + metadata?: Record; +}; + +export type EmailConfirmPasswordInput = { + email: string; + confirmationPassword: string; +}; + +export type CommonAccountResponse = { + message: string; + code: number; + errors?: Record; +}; + +export type DeleteAccountPayload = { + password: string; +}; + +export type SubscribeToNotificationsPayload = { + uuid: string; + onMessage: (payload: string) => void; +}; + +export type GetSocialURLsPayload = { + redirectUrl: string; +}; + +export type SocialURLs = + | { + facebook: string; + } + | { + twitter: string; + } + | { + google: string; + }; + +export type UpdateWatchHistoryArgs = { + user: Customer; + history: SerializedWatchHistoryItem[]; +}; + +export type UpdateFavoritesArgs = { + user: Customer; + favorites: SerializedFavorite[]; +}; + +export type GetFavoritesArgs = { + user: Customer; +}; + +export type GetWatchHistoryArgs = { + user: Customer; +}; + +export type GetAuthData = () => Promise; +export type Login = PromiseRequest; +export type Register = PromiseRequest; +export type GetUser = PromiseRequest; +export type Logout = () => Promise; +export type UpdateCustomer = PromiseRequest; +export type GetPublisherConsents = PromiseRequest; +export type GetCustomerConsents = PromiseRequest; +export type UpdateCustomerConsents = PromiseRequest; +export type GetCaptureStatus = PromiseRequest; +export type UpdateCaptureAnswers = PromiseRequest; +export type ResetPassword = PromiseRequest; +export type ChangePassword = PromiseRequest; +export type ChangePasswordWithOldPassword = PromiseRequest; +export type GetSocialURLs = PromiseRequest; +export type NotificationsData = PromiseRequest; +export type UpdateWatchHistory = PromiseRequest; +export type UpdateFavorites = PromiseRequest; +export type GetWatchHistory = PromiseRequest; +export type GetFavorites = PromiseRequest; +export type ExportAccountData = PromiseRequest; +export type DeleteAccount = PromiseRequest; diff --git a/types/ad-schedule.d.ts b/packages/common/types/ad-schedule.ts similarity index 100% rename from types/ad-schedule.d.ts rename to packages/common/types/ad-schedule.ts diff --git a/packages/common/types/adyen.ts b/packages/common/types/adyen.ts new file mode 100644 index 000000000..15006e053 --- /dev/null +++ b/packages/common/types/adyen.ts @@ -0,0 +1,44 @@ +interface AdyenPaymentMethod { + encryptedCardNumber: string; + encryptedExpiryMonth: string; + encryptedExpiryYear: string; + encryptedSecurityCode: string; + type: string; +} + +interface AdyenEventData { + isValid: boolean; + data: { + browserInfo: { + acceptHeader: string; + colorDepth: string; + language: string; + javaEnabled: boolean; + screenHeight: string; + screenWidth: string; + userAgent: string; + timeZoneOffset: number; + }; + paymentMethod: AdyenPaymentMethod; + billingAddress?: { + street: string; + houseNumberOrName: string; + postalCode: string; + city: string; + country: string; + stateOrProvince: string; + }; + }; +} + +interface AdyenAdditionalEventData { + isValid: boolean; + data: { + details: unknown; + }; +} + +// currently only card payments with Adyen are supported +const adyenPaymentMethods = ['card'] as const; + +type AdyenPaymentMethodType = (typeof adyenPaymentMethods)[number]; 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/packages/common/types/checkout.ts b/packages/common/types/checkout.ts new file mode 100644 index 000000000..89210f117 --- /dev/null +++ b/packages/common/types/checkout.ts @@ -0,0 +1,386 @@ +import type { PayloadWithIPOverride } from './account'; +import type { PaymentDetail } from './subscription'; +import type { EmptyEnvironmentServiceRequest, EnvironmentServiceRequest, PromiseRequest } from './service'; + +export type Offer = { + id: number | null; + offerId: string; + offerPrice: number; + offerCurrency: string; + offerCurrencySymbol: string; + offerCountry: string; + customerPriceInclTax: number; + customerPriceExclTax: number; + customerCurrency: string; + customerCurrencySymbol: string; + customerCountry: string; + discountedCustomerPriceInclTax: number | null; + discountedCustomerPriceExclTax: number | null; + discountPeriods: number | null; + durationPeriod?: string | null; // Duration isn't sent from Cleeng yet + durationAmount?: number | null; // Same for this + offerUrl: string | null; + offerTitle: string; + offerDescription: null; + active: boolean; + createdAt: number; + updatedAt: number; + applicableTaxRate: number; + geoRestrictionEnabled: boolean; + geoRestrictionType: string | null; + geoRestrictionCountries: string[]; + socialCommissionRate: number; + averageRating: number; + contentType: string | null; + period: 'day' | 'week' | 'month' | 'year'; + freePeriods: number | null; + freeDays: number | null; + expiresAt: string | null; + accessToTags: string[]; + videoId: string | null; + contentExternalId: number | null; + contentExternalData: string | null; + contentAgeRestriction: string | null; + planSwitchEnabled?: boolean; +}; + +export type OfferType = 'svod' | 'tvod'; + +export type ChooseOfferFormData = { + selectedOfferType?: OfferType; + selectedOfferId?: string; +}; + +export type OrderOffer = { + title: string; + description: string | null; + price: number; + currency: string; +}; + +export type Order = { + id: number; + customerId: string; + customer: { + locale: string; + email: string; + }; + publisherId: number; + offerId: string; + offer: OrderOffer; + totalPrice: number; + priceBreakdown: { + offerPrice: number; + discountAmount: number; + discountedPrice: number; + taxValue: number; + customerServiceFee: number; + paymentMethodFee: number; + }; + taxRate: number; + taxBreakdown: string | null; + currency: string; + country: string | null; + paymentMethodId: number; + expirationDate: number; + billingAddress: null; + couponId: null; + discount: { + applied: boolean; + type: string; + periods: number; + }; + requiredPaymentDetails: boolean; +}; + +export type PaymentMethod = { + id: number; + methodName: 'card' | 'paypal'; + provider?: 'stripe' | 'adyen'; + paymentGateway?: 'adyen' | 'paypal'; // @todo: merge with provider + logoUrl: string; +}; + +export type PaymentMethodResponse = { + message: string; + paymentMethods: PaymentMethod[]; + status: number; +}; + +export type PaymentStatus = { + status: 'successful' | 'failed'; + message?: string; +}; + +export type CardPaymentData = { + couponCode?: string; + cardholderName: string; + cardNumber: string; + cardExpiry: string; + cardExpMonth?: string; + cardExpYear?: string; + cardCVC: string; +}; + +export type Payment = { + id: number; + orderId: number; + status: string; + totalAmount: number; + currency: string; + customerId: string; + paymentGateway: string; + paymentMethod: string; + externalPaymentId: string | number; + couponId: number | null; + amount: number; + country: string; + offerType: 'subscription'; + taxValue: number; + paymentMethodFee: number; + customerServiceFee: number; + rejectedReason: string | null; + refundedReason: string | null; + paymentDetailsId: number | null; + paymentOperation: string; + gatewaySpecificParams?: string; +}; + +export type GetOfferPayload = { + offerId: string; +}; + +export type GetOffersPayload = { + offerIds: string[] | number[]; +}; + +export type CreateOrderPayload = { + offerId: string; + customerId: string; + country: string; + currency: string; + customerIP: string; + paymentMethodId?: number; + couponCode?: string; +}; + +export type SwitchOffer = { + currency: string; + currencySymbol: string; + nextPaymentPrice: number; + nextPaymentPriceCurrency: string; + nextPaymentPriceCurrencySymbol: string; + period: string; + price: number; + switchDirection: string; + title: string; + toOfferId: string; +}; + +export type GetSubscriptionSwitchesPayload = { + customerId: string; + offerId: string; +}; + +export type GetSubscriptionSwitchesResponse = { + available: SwitchOffer[]; + unavailable: SwitchOffer[]; +}; + +export type GetSubscriptionSwitchPayload = { + switchId: string; +}; + +export type GetSubscriptionSwitchResponse = { + id: string; + customerId: number; + direction: 'downgrade' | 'upgrade'; + algorithm: string; + fromOfferId: string; + toOfferId: string; + subscriptionId: string; + status: string; + createdAt: number; + updatedAt: number; +}; + +export type SwitchSubscriptionPayload = { + customerId: string; + offerId: string; + toOfferId: string; + switchDirection: string; +}; + +export type SwitchSubscriptionResponse = string; + +export type CreateOrderArgs = { + offer: Offer; + customerId: string; + country: string; + paymentMethodId?: number; + couponCode?: string; +}; + +export type CreateOrderResponse = { + message: string; + order: Order; + success: boolean; +}; + +export type UpdateOrderPayload = { + order: Order; + paymentMethodId?: number; + couponCode?: string | null; +}; + +export type UpdateOrderResponse = { + message: string; + order: Order; + success: boolean; +}; + +export type GetOrderPayload = { + orderId: number; +}; + +export type GetOrderResponse = { + message: string; + order: Order; + success: boolean; +}; + +export type PaymentWithoutDetailsPayload = { + orderId: number; +}; + +export type PaymentWithAdyenPayload = PayloadWithIPOverride & { + orderId: number; + card: AdyenPaymentMethod; +}; + +export type PaymentWithPayPalPayload = { + order: Order; + successUrl: string; + cancelUrl: string; + errorUrl: string; + waitingUrl: string; + couponCode?: string; +}; + +export type PaymentWithPayPalResponse = { + redirectUrl: string; +}; + +export type GetEntitlementsPayload = { + offerId: string; +}; + +export type GetEntitlementsResponse = { + accessGranted: boolean; + expiresAt: number; +}; + +export type AdyenPaymentMethodPayload = { + orderId?: number; + returnUrl: string; + filteredPaymentMethods?: string[]; + filterPaymentMethodsByType?: string[]; +}; + +export type InitialAdyenPaymentPayload = { + orderId: number; + returnUrl: string; + paymentMethod: AdyenPaymentMethod; + billingAddress?: { + street: string; + houseNumberOrName: string; + city: string; + postalCode: string; + country: string; + stateOrProvince: string; + }; + origin?: string; + customerIP?: string; + browserInfo?: unknown; + enable3DSRedirectFlow?: boolean; +}; + +export type AdyenAction = { + action: { + paymentMethodType: string; + url: string; + data: unknown; + method: string; + type: string; + }; +}; + +export type InitialAdyenPayment = Payment | AdyenAction; + +export type FinalizeAdyenPaymentPayload = { + orderId: number; + details: unknown; + paymentData?: string; +}; + +export type FinalizeAdyenPayment = { + payment: Payment; +}; + +export type AdyenPaymentSession = { + allowedPaymentMethods: string[]; + blockedPaymentMethods: string[]; + shopperStatement: string; + amount: { + currency: string; + value: number; + }; + countryCode: string; + expiresAt: string; + id: string; + returnUrl: string; + merchantAccount: string; + reference: string; + paymentMethod: AdyenPaymentMethod[]; + sessionData: string; +}; + +export type UpdatePaymentWithPayPalPayload = Omit & { paymentMethodId: number }; + +export type DeletePaymentMethodPayload = { + paymentDetailsId: number; +}; + +export type DeletePaymentMethodResponse = { + paymentDetailsId: string; +}; + +export type AddAdyenPaymentDetailsPayload = Omit & { paymentMethodId: number }; + +export type AddAdyenPaymentDetailsResponse = Omit | AdyenAction; + +export type FinalizeAdyenPaymentDetailsPayload = Omit & { paymentMethodId: number }; + +export type FinalizeAdyenPaymentDetailsResponse = PaymentDetail; + +export type GetOffers = PromiseRequest; +export type GetOffer = EnvironmentServiceRequest; +export type CreateOrder = EnvironmentServiceRequest; +export type GetOrder = EnvironmentServiceRequest; +export type UpdateOrder = EnvironmentServiceRequest; +export type GetPaymentMethods = EmptyEnvironmentServiceRequest; +export type PaymentWithoutDetails = EnvironmentServiceRequest; +export type PaymentWithAdyen = EnvironmentServiceRequest; +export type PaymentWithPayPal = EnvironmentServiceRequest; +export type GetSubscriptionSwitches = EnvironmentServiceRequest; +export type GetSubscriptionSwitch = EnvironmentServiceRequest; +export type SwitchSubscription = EnvironmentServiceRequest; +export type GetEntitlements = EnvironmentServiceRequest; +export type GetAdyenPaymentSession = EnvironmentServiceRequest; +export type GetInitialAdyenPayment = EnvironmentServiceRequest; +export type GetFinalizeAdyenPayment = EnvironmentServiceRequest; +export type UpdatePaymentWithPayPal = EnvironmentServiceRequest; +export type DeletePaymentMethod = EnvironmentServiceRequest; +export type AddAdyenPaymentDetails = EnvironmentServiceRequest; +export type FinalizeAdyenPaymentDetails = EnvironmentServiceRequest; +export type GetDirectPostCardPayment = (cardPaymentPayload: CardPaymentData, order: Order, referrer: string, returnUrl: string) => Promise; diff --git a/packages/common/types/cleeng.ts b/packages/common/types/cleeng.ts new file mode 100644 index 000000000..d005c92ef --- /dev/null +++ b/packages/common/types/cleeng.ts @@ -0,0 +1,6 @@ +interface ApiResponse { + errors: string[]; +} + +export type CleengResponse = { responseData: R } & ApiResponse; +export type CleengRequest = (payload: P) => Promise>; diff --git a/packages/common/types/config.ts b/packages/common/types/config.ts new file mode 100644 index 000000000..fa1a790f2 --- /dev/null +++ b/packages/common/types/config.ts @@ -0,0 +1,96 @@ +import type { AdScheduleUrls } from './ad-schedule'; + +/** + * Set config setup changes in both config.services.ts and config.d.ts + * */ +export type Config = { + id?: string; + siteName?: string; + description: string; + analyticsToken?: string | null; + adSchedule?: string | null; + adScheduleUrls?: AdScheduleUrls; + integrations: { + cleeng?: Cleeng; + jwp?: JWP; + }; + assets: { banner?: string | null }; + content: Content[]; + menu: Menu[]; + styling: Styling; + features?: Features; + custom?: Record; + contentSigningService?: ContentSigningConfig; + contentProtection?: ContentProtection; + siteId: string; +}; + +export type ContentSigningConfig = { + host: string; + drmPolicyId?: string; +}; + +export type ContentProtection = { + accessModel: 'free' | 'freeauth' | 'authvod' | 'svod'; + drm?: Drm; +}; + +export type Drm = { + defaultPolicyId: string; +}; + +export type ContentType = 'playlist' | 'continue_watching' | 'favorites'; + +export type Content = { + contentId?: string; + title?: string; + type: ContentType; + featured?: boolean; + backgroundColor?: string | null; +}; + +export type Menu = { + label: string; + contentId: string; + type?: Extract; + filterTags?: string; +}; + +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 = { + id?: string | null; + monthlyOffer?: string | null; + yearlyOffer?: string | null; + useSandbox?: boolean; +}; +export type JWP = { + clientId?: string | null; + assetId?: number | null; + useSandbox?: boolean; +}; +export type Features = { + recommendationsPlaylist?: string | null; + searchPlaylist?: string | null; + favoritesList?: string | null; + continueWatchingList?: string | null; +}; + +/** + * AVOD: Advert based + * AUTHVOD: Authorisation based, needs registration + * SVOD: Subscription based + * + * TVOD: Transactional based. This can only be set per item, so is not a valid accessModel value + * */ +export type AccessModel = 'AVOD' | 'AUTHVOD' | 'SVOD'; + +export type IntegrationType = 'JWP' | 'CLEENG'; diff --git a/packages/common/types/entitlement.ts b/packages/common/types/entitlement.ts new file mode 100644 index 000000000..5b5803420 --- /dev/null +++ b/packages/common/types/entitlement.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.ts similarity index 100% rename from types/favorite.d.ts rename to packages/common/types/favorite.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.ts b/packages/common/types/get-customer-ip.ts new file mode 100644 index 000000000..1d0494fda --- /dev/null +++ b/packages/common/types/get-customer-ip.ts @@ -0,0 +1 @@ +export type GetCustomerIP = () => Promise; diff --git a/packages/common/types/i18n.ts b/packages/common/types/i18n.ts new file mode 100644 index 000000000..b47ef70ab --- /dev/null +++ b/packages/common/types/i18n.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/packages/common/types/inplayer.ts b/packages/common/types/inplayer.ts new file mode 100644 index 000000000..75b30a0da --- /dev/null +++ b/packages/common/types/inplayer.ts @@ -0,0 +1,13 @@ +export type InPlayerAuthData = { + access_token: string; + expires?: number; +}; + +export type InPlayerError = { + response: { + data: { + code: number; + message: string; + }; + }; +}; diff --git a/packages/common/types/jwpltx.d.ts b/packages/common/types/jwpltx.d.ts new file mode 100644 index 000000000..ab23e5ce3 --- /dev/null +++ b/packages/common/types/jwpltx.d.ts @@ -0,0 +1,18 @@ +export interface Jwpltx { + ready: ( + analyticsid: string, + hostname: string, + feedid: string, + mediaid: string, + title: string, + accountid?: number, + appid?: string, + appversion?: string, + ) => void; + adImpression: () => void; + seek: (offset: number, duration: number) => void; + seeked: () => void; + time: (position: number, duration: number) => void; + complete: () => void; + remove: () => void; +} diff --git a/packages/common/types/media.ts b/packages/common/types/media.ts new file mode 100644 index 000000000..b90452274 --- /dev/null +++ b/packages/common/types/media.ts @@ -0,0 +1,21 @@ +import type { PlaylistItem } from './playlist'; + +export type GetMediaParams = { + poster_width?: number; + default_source_fallback?: boolean; + token?: string; + max_resolution?: number; +}; + +export type Media = { + description?: string; + feed_instance_id: string; + kind: string; + title: string; + playlist: PlaylistItem[]; +}; + +export type MediaOffer = { + offerId: string; + premier: boolean; +}; diff --git a/types/pagination.d.ts b/packages/common/types/pagination.ts similarity index 100% rename from types/pagination.d.ts rename to packages/common/types/pagination.ts diff --git a/packages/common/types/playlist.ts b/packages/common/types/playlist.ts new file mode 100644 index 000000000..f4b0bdba2 --- /dev/null +++ b/packages/common/types/playlist.ts @@ -0,0 +1,87 @@ +import type { MediaStatus } from '../src/utils/liveEvent'; + +import type { MediaOffer } from './media'; + +export type GetPlaylistParams = { page_limit?: string; related_media_id?: string; token?: string; search?: string }; + +export type Image = { + src: string; + type: string; + width: number; +}; + +export type Source = { + file: string; + type: string; +}; + +export type Track = { + file: string; + kind: string; + label?: string; +}; + +export type PlaylistItem = { + description: string; + duration: number; + feedid: string; + image: string; + images: Image[]; + cardImage?: string; + backgroundImage?: string; + channelLogoImage?: string; + link: string; + genre?: string; + mediaid: string; + pubdate: number; + rating?: string; + requiresSubscription?: string | null; + sources: Source[]; + seriesId?: string; + episodeNumber?: string; + seasonNumber?: string; + tags?: string; + trailerId?: string; + title: string; + tracks?: Track[]; + variations?: Record; + free?: string; + productIds?: string; + mediaOffers?: MediaOffer[] | null; + contentType?: string; + liveChannelsId?: string; + scheduleUrl?: string | null; + scheduleToken?: string; + scheduleDataFormat?: string; + scheduleDemo?: string; + catchupHours?: string; + mediaStatus?: MediaStatus; + scheduledStart?: Date; + scheduledEnd?: Date; + markdown?: string; + scheduleType?: string; + [key: string]: unknown; +}; + +export type Link = { + first?: string; + last?: string; +}; + +export type Playlist = { + description?: string; + feed_instance_id?: string; + feedid?: string; + kind?: string; + links?: Link; + playlist: PlaylistItem[]; + title: string; + contentType?: string; + /** + * @deprecated Use {@link Playlist.cardImageAspectRatio} instead. + */ + shelfImageAspectRatio?: string; + cardImageAspectRatio?: string; + imageLabel?: string; + [key: string]: unknown; +}; diff --git a/packages/common/types/profiles.ts b/packages/common/types/profiles.ts new file mode 100644 index 000000000..58bcfbedb --- /dev/null +++ b/packages/common/types/profiles.ts @@ -0,0 +1,42 @@ +import type { ProfilesData } from '@inplayer-org/inplayer.js'; + +import type { PromiseRequest } from './service'; +import type { CommonAccountResponse } from './account'; + +export type Profile = ProfilesData; + +export type ProfilePayload = { + id?: string; + name: string; + adult: boolean; + avatar_url?: string; + pin?: number; +}; + +export type EnterProfilePayload = { + id: string; + pin?: number; +}; + +export type ProfileDetailsPayload = { + id: string; +}; + +export type ListProfilesResponse = { + canManageProfiles: boolean; + collection: ProfilesData[]; +}; + +export type ProfileFormSubmitError = { + code: number; + message: string; +}; + +export type ProfileFormValues = Omit & { adult: string }; + +export type ListProfiles = PromiseRequest; +export type CreateProfile = PromiseRequest; +export type UpdateProfile = PromiseRequest; +export type EnterProfile = PromiseRequest; +export type GetProfileDetails = PromiseRequest; +export type DeleteProfile = PromiseRequest; diff --git a/types/series.d.ts b/packages/common/types/series.ts similarity index 100% rename from types/series.d.ts rename to packages/common/types/series.ts diff --git a/packages/common/types/service.ts b/packages/common/types/service.ts new file mode 100644 index 000000000..91a9bb8ce --- /dev/null +++ b/packages/common/types/service.ts @@ -0,0 +1,9 @@ +interface ApiResponse { + errors: string[]; +} + +export type ServiceResponse = { responseData: R } & ApiResponse; +export type PromiseRequest = (payload: P) => Promise; +export type EmptyServiceRequest = () => Promise>; +export type EmptyEnvironmentServiceRequest = () => Promise>; +export type EnvironmentServiceRequest = (payload: P) => Promise>; diff --git a/types/settings.d.ts b/packages/common/types/settings.ts similarity index 100% rename from types/settings.d.ts rename to packages/common/types/settings.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/packages/common/types/subscription.ts b/packages/common/types/subscription.ts new file mode 100644 index 000000000..6582c7b23 --- /dev/null +++ b/packages/common/types/subscription.ts @@ -0,0 +1,165 @@ +import type { ChangeSubscriptionPlanResponse, DefaultCreditCardData, SetDefaultCard } from '@inplayer-org/inplayer.js'; + +import type { CleengRequest } from './cleeng'; +import type { EnvironmentServiceRequest, PromiseRequest } from './service'; + +// Subscription types +export type Subscription = { + subscriptionId: number | string; + offerId: string; + accessFeeId?: string; + status: 'active' | 'active_trial' | 'cancelled' | 'expired' | 'terminated'; + expiresAt: number; + nextPaymentPrice: number; + nextPaymentCurrency: string; + paymentGateway: string; + paymentMethod: string; + offerTitle: string; + pendingSwitchId: string | null; + period: 'day' | 'week' | 'month' | 'year' | 'granted'; + totalPrice: number; + unsubscribeUrl?: string; +}; + +export type PaymentDetail = { + id: number; + customerId: string; + paymentGateway: string; + paymentMethod: string; + paymentMethodSpecificParams: PaymentMethodSpecificParam; + paymentMethodId: number; + active: boolean; + currency?: string; +}; + +export type PaymentMethodSpecificParam = { + payerId?: 'string'; + holderName?: 'string'; + variant?: 'string'; + lastCardFourDigits?: string; + cardExpirationDate?: string; + socialSecurityNumber?: string; +}; + +export type Transaction = { + transactionId: string; + transactionDate: number; + trxToken?: string; + offerId: string; + offerType: string; + offerTitle: string; + offerPeriod: string; + publisherSiteName?: string; + transactionPriceExclTax: string; + transactionCurrency: string; + contentExternalId?: string; + contentType?: string; + shortUrl?: string; + campaignId?: string; + campaignName?: string; + couponCode?: string | null; + discountType?: string; + discountRate?: string; + discountValue?: string; + discountedOfferPrice?: string; + offerCurrency: string; + offerPriceExclTax: string; + applicableTax: string; + transactionPriceInclTax: string; + appliedExchangeRateCustomer?: string; + customerId: string; + customerEmail: string; + customerLocale: string; + customerCountry: string; + customerIpCountry: string; + customerCurrency: string; + paymentMethod?: string; + referalUrl?: string; + transactionExternalData?: string; + publisherId?: string; +}; + +// Payload types +export type GetSubscriptionsPayload = { + customerId: string; +}; + +export type GetSubscriptionsResponse = { + items: Subscription[]; +}; + +export type UpdateSubscriptionPayload = { + customerId: string; + offerId: string; + status: 'active' | 'cancelled'; + cancellationReason?: string; + unsubscribeUrl?: string; +}; + +export type UpdateSubscriptionResponse = { + offerId: string; + status: 'active' | 'cancelled' | 'expired' | 'terminated'; + expiresAt: number; +}; + +export type GetPaymentDetailsPayload = { + customerId: string; +}; + +export type GetPaymentDetailsResponse = { + paymentDetails: PaymentDetail[]; +}; + +export type GetTransactionsPayload = { + customerId: string; + limit?: string; + offset?: string; +}; + +export type GetTransactionsResponse = { + items: Transaction[]; +}; + +export type UpdateCardDetailsPayload = DefaultCreditCardData; + +export type UpdateCardDetails = EnvironmentServiceRequest; + +export type FetchReceiptPayload = { + transactionId: string; +}; + +export type FetchReceiptResponse = Blob | string; + +type ChangeSubscriptionPayload = { + accessFeeId: string; + subscriptionId: string; +}; + +type GetActivePaymentPayload = { + customerId: string; +}; + +type GetAllTransactionsPayload = { + customerId: string; +}; + +type GetActiveSubscriptionPayload = { + customerId: string; +}; + +type GetActivePaymentResponse = PaymentDetail | null; + +type GetAllTransactionsResponse = Transaction[] | null; + +type GetActiveSubscriptionResponse = Subscription | null; + +export type GetSubscriptions = CleengRequest; +export type UpdateSubscription = CleengRequest; +export type GetPaymentDetails = CleengRequest; +export type GetTransactions = CleengRequest; + +export type GetActiveSubscription = PromiseRequest; +export type GetAllTransactions = PromiseRequest; +export type GetActivePayment = PromiseRequest; +export type ChangeSubscription = EnvironmentServiceRequest; +export type FetchReceipt = EnvironmentServiceRequest; diff --git a/packages/common/types/testing.ts b/packages/common/types/testing.ts new file mode 100644 index 000000000..2b84dd6db --- /dev/null +++ b/packages/common/types/testing.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.ts similarity index 100% rename from types/watchHistory.d.ts rename to packages/common/types/watchHistory.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/.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..f0573aeda --- /dev/null +++ b/packages/hooks-react/package.json @@ -0,0 +1,35 @@ +{ + "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-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" + }, + "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", + "@types/jwplayer": "^8.2.13", + "jsdom": "^22.1.0", + "vi-fetch": "^0.8.0", + "vitest": "^1.3.1" + }, + "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/packages/hooks-react/src/series/useNextEpisode.ts b/packages/hooks-react/src/series/useNextEpisode.ts new file mode 100644 index 000000000..2a33d573c --- /dev/null +++ b/packages/hooks-react/src/series/useNextEpisode.ts @@ -0,0 +1,24 @@ +import { useQuery } 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 { 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); + + const { isLoading, data } = useQuery( + ['next-episode', series?.series_id, episodeId], + async () => { + const item = await apiService.getEpisodes({ seriesId: series?.series_id, pageLimit: 1, afterId: episodeId }); + + return item?.episodes?.[0]; + }, + { staleTime: STALE_TIME, cacheTime: CACHE_TIME, enabled: !!(series?.series_id && episodeId) }, + ); + + return { + isLoading, + data, + }; +}; diff --git a/packages/hooks-react/src/series/useSeries.ts b/packages/hooks-react/src/series/useSeries.ts new file mode 100644 index 000000000..332d5eefb --- /dev/null +++ b/packages/hooks-react/src/series/useSeries.ts @@ -0,0 +1,36 @@ +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, +): { + data: Series | undefined; + error: ApiError | null; + isLoading: boolean; +} => { + const apiService = getModule(ApiService); + + // Try to get new series flow data + const { data, isLoading, error }: UseQueryResult = useQuery( + ['series', seriesId], + async () => { + const series = await apiService.getSeries(seriesId || ''); + + return series; + }, + { + enabled: !!seriesId, + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + // Don't retry when we got a not found error from either series or media item request (prevent unneeded requests) + // Both errors mean that old series flow should be used + retry: (failureCount, error: ApiError) => error.code !== 404 && failureCount < 2, + }, + ); + + return { data, isLoading, error }; +}; 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..a1a2874bc --- /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/controllers/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/packages/hooks-react/src/useCheckAccess.ts b/packages/hooks-react/src/useCheckAccess.ts new file mode 100644 index 000000000..4c55dca48 --- /dev/null +++ b/packages/hooks-react/src/useCheckAccess.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; + +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 [errorMessage, setErrorMessage] = useState(null); + const { t } = useTranslation('user'); + + const offers = checkoutController.getSubscriptionOfferIds(); + + const intervalCheckAccess = useCallback( + ({ interval = 3000, iterations = 5, offerId, callback }: IntervalCheckAccessPayload) => { + if (!offerId && offers?.[0]) { + offerId = offers[0]; + } + + intervalRef.current = window.setInterval(async () => { + const hasAccess = await accountController.checkEntitlements(offerId); + + if (hasAccess) { + await accountController.reloadSubscriptions({ delay: 2000 }); // Delay needed for backend processing (Cleeng API returns empty subscription, even after accessGranted from entitlements call + callback?.(true); + } else if (--iterations === 0) { + window.clearInterval(intervalRef.current); + setErrorMessage(t('payment.longer_than_usual')); + callback?.(false); + } + }, interval); + }, + [offers, t, accountController], + ); + + useEffect(() => { + return () => { + window.clearInterval(intervalRef.current); + }; + }, []); + + return { intervalCheckAccess, errorMessage }; +}; + +export default useCheckAccess; diff --git a/packages/hooks-react/src/useCheckout.ts b/packages/hooks-react/src/useCheckout.ts new file mode 100644 index 000000000..29614f08b --- /dev/null +++ b/packages/hooks-react/src/useCheckout.ts @@ -0,0 +1,102 @@ +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; +import type { FormValidationError } from '@jwp/ott-common/src/errors/FormValidationError'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; +import { isSVODOffer } from '@jwp/ott-common/src/utils/offers'; +import type { CardPaymentData, Offer, OfferType, Payment } from '@jwp/ott-common/types/checkout'; +import { useEffect } from 'react'; +import { useMutation } from 'react-query'; + +type Props = { + onUpdateOrderSuccess?: () => void; + onSubmitPaymentWithoutDetailsSuccess: () => void; + onSubmitPaypalPaymentSuccess: (response: { redirectUrl: string }) => void; + onSubmitStripePaymentSuccess: () => void; +}; + +const useCheckout = ({ onUpdateOrderSuccess, onSubmitPaymentWithoutDetailsSuccess, onSubmitPaypalPaymentSuccess, onSubmitStripePaymentSuccess }: Props) => { + const accountController = getModule(AccountController); + const checkoutController = getModule(CheckoutController); + + const { order, selectedOffer, paymentMethods, setOrder } = useCheckoutStore(({ order, selectedOffer, paymentMethods, setOrder }) => ({ + order, + selectedOffer, + paymentMethods, + setOrder, + })); + + const offerType: OfferType = selectedOffer && isSVODOffer(selectedOffer) ? 'svod' : 'tvod'; + + const initialiseOrder = useMutation({ + mutationKey: ['initialiseOrder'], + mutationFn: async ({ offer }) => !!offer && checkoutController.initialiseOrder(offer), + }); + + const updateOrder = useMutation({ + mutationKey: ['updateOrder'], + mutationFn: async ({ paymentMethodId, couponCode }) => { + if (!order || !paymentMethodId) return; + + return await checkoutController.updateOrder(order, paymentMethodId, couponCode); + }, + onSuccess: onUpdateOrderSuccess, + }); + + const submitPaymentWithoutDetails = useMutation({ + mutationKey: ['submitPaymentWithoutDetails'], + mutationFn: checkoutController.paymentWithoutDetails, + onSuccess: async () => { + await accountController.reloadSubscriptions({ delay: 1000 }); + onSubmitPaymentWithoutDetailsSuccess(); + }, + }); + + const submitPaymentPaypal = useMutation< + { redirectUrl: string }, + Error, + { successUrl: string; waitingUrl: string; cancelUrl: string; errorUrl: string; couponCode: string } + >({ + mutationKey: ['submitPaymentPaypal'], + mutationFn: checkoutController.paypalPayment, + onSuccess: onSubmitPaypalPaymentSuccess, + }); + + const submitPaymentStripe = useMutation({ + mutationKey: ['submitPaymentStripe'], + mutationFn: checkoutController.directPostCardPayment, + onSuccess: onSubmitStripePaymentSuccess, + }); + + useEffect(() => { + if (selectedOffer && !order && !initialiseOrder.isLoading && !initialiseOrder.isError) { + initialiseOrder.mutate({ offer: selectedOffer }); + } + }, [selectedOffer, order, initialiseOrder]); + + // Clear the order when unmounted + useEffect(() => { + return () => setOrder(null); + }, [setOrder]); + + const isSubmitting = + initialiseOrder.isLoading || + updateOrder.isLoading || + submitPaymentWithoutDetails.isLoading || + submitPaymentPaypal.isLoading || + submitPaymentStripe.isLoading; + + return { + selectedOffer, + offerType, + paymentMethods, + order, + isSubmitting, + updateOrder, + submitPaymentWithoutDetails, + submitPaymentPaypal, + submitPaymentStripe, + }; +}; + +export default useCheckout; 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..a3598af93 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/controllers/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..caef0a950 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/controllers/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/packages/hooks-react/src/useForm.ts b/packages/hooks-react/src/useForm.ts new file mode 100644 index 000000000..3908b67ad --- /dev/null +++ b/packages/hooks-react/src/useForm.ts @@ -0,0 +1,196 @@ +import { useCallback, useState } from 'react'; +import { type AnySchema, ValidationError, type SchemaOf } from 'yup'; +import type { FormErrors, GenericFormValues, UseFormBlurHandler, UseFormChangeHandler, UseFormSubmitHandler } from '@jwp/ott-common/types/form'; +import { FormValidationError } from '@jwp/ott-common/src/errors/FormValidationError'; +import { useTranslation } from 'react-i18next'; + +export type UseFormReturnValue = { + values: T; + errors: FormErrors; + validationSchemaError: boolean; + submitting: boolean; + handleChange: UseFormChangeHandler; + handleBlur: UseFormBlurHandler; + handleSubmit: UseFormSubmitHandler; + setValue: (key: keyof T, value: T[keyof T]) => void; + setErrors: (errors: FormErrors) => void; + setSubmitting: (submitting: boolean) => void; + setValidationSchemaError: (error: boolean) => void; + reset: () => void; +}; + +type UseFormMethods = { + setValue: (key: keyof T, value: string | boolean) => void; + setErrors: (errors: FormErrors) => void; + setSubmitting: (submitting: boolean) => void; + setValidationSchemaError: (error: boolean) => void; + validate: (validationSchema: AnySchema) => boolean; +}; + +export type UseFormOnSubmitHandler = (values: T, formMethods: UseFormMethods) => void; + +export default function useForm({ + initialValues, + validationSchema, + validateOnBlur = false, + onSubmit, + onSubmitSuccess, + onSubmitError, +}: { + initialValues: T; + validationSchema?: SchemaOf; + validateOnBlur?: boolean; + onSubmit: UseFormOnSubmitHandler; + onSubmitSuccess?: (values: T) => void; + onSubmitError?: ({ error, resetValue }: { error: unknown; resetValue: (key: keyof T) => void }) => void; +}): UseFormReturnValue { + const { t } = useTranslation('error'); + const [touched, setTouched] = useState>( + Object.fromEntries((Object.keys(initialValues) as Array).map((key) => [key, false])) as Record, + ); + const [values, setValues] = useState(initialValues); + const [submitting, setSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + const [validationSchemaError, setValidationSchemaError] = useState(false); + + const reset = useCallback(() => { + setValues(initialValues); + setErrors({}); + setValidationSchemaError(false); + setSubmitting(false); + setTouched(Object.fromEntries((Object.keys(initialValues) as Array).map((key) => [key, false])) as Record); + }, [initialValues]); + + const validateField = (name: string, formValues: T) => { + if (!validationSchema) return; + + try { + validationSchema.validateSyncAt(name, formValues); + + // clear error + setErrors((errors) => ({ ...errors, [name]: null })); + } catch (error: unknown) { + if (error instanceof ValidationError) { + const errorMessage = error.errors[0]; + setErrors((errors) => ({ ...errors, [name]: errorMessage })); + setValidationSchemaError(true); + } + } + }; + + const setValue = useCallback((name: keyof T, value: string | boolean) => { + setValues((current) => ({ ...current, [name]: value })); + }, []); + + const handleChange: UseFormChangeHandler = (event) => { + const name = event.target.name; + const value = event.target instanceof HTMLInputElement && event.target.type === 'checkbox' ? event.target.checked : event.target.value; + + const newValues = { ...values, [name]: value }; + + setValues(newValues); + setTouched((current) => ({ ...current, [name]: value })); + + if (errors[name]) { + validateField(name, newValues); + } + }; + + const handleBlur: UseFormBlurHandler = (event) => { + if (!validateOnBlur || !touched[event.target.name]) return; + + validateField(event.target.name, values); + }; + + const validate = (validationSchema: AnySchema) => { + try { + validationSchema.validateSync(values, { abortEarly: false }); + + return true; + } catch (error: unknown) { + if (error instanceof ValidationError) { + const newErrors: Record = {}; + + for (let index = 0; index < error.inner.length; index++) { + const path = error.inner[index].path as string; + const message = error.inner[index].errors[0] as string; + + if (path && message && !newErrors[path]) { + newErrors[path] = message; + } + } + + if (error.inner.every((error) => error.type === 'required')) { + newErrors.form = t('validation_form_error.required'); + } else { + newErrors.form = t('validation_form_error.other'); + } + + setErrors(newErrors as FormErrors); + } + } + + return false; + }; + + const handleSubmit: UseFormSubmitHandler = async (event) => { + event.preventDefault(); + + if (!onSubmit || submitting) return; + + // reset errors before submitting + setErrors({}); + setValidationSchemaError(false); + + // validate values with schema + if (validationSchema && !validate(validationSchema)) { + setValidationSchemaError(true); + return; + } + + // start submitting + setSubmitting(true); + + try { + await onSubmit(values, { setValue, setErrors, setSubmitting, setValidationSchemaError, validate }); + onSubmitSuccess?.(values); + } catch (error: unknown) { + const newErrors: Record = {}; + + if (error instanceof FormValidationError) { + Object.entries(error.errors).forEach(([key, value]) => { + if (key && value && !newErrors[key]) { + newErrors[key] = value.join(','); + } + }); + } else if (error instanceof Error) { + newErrors.form = error.message; + } else { + newErrors.form = t('unknown_error'); + } + setErrors(newErrors as FormErrors); + + onSubmitError?.({ + error, + resetValue: (key: keyof T) => setValue(key, ''), + }); + } + + setSubmitting(false); + }; + + return { + values, + errors, + validationSchemaError, + handleChange, + handleBlur, + handleSubmit, + submitting, + setValue, + setErrors, + setSubmitting, + setValidationSchemaError, + reset, + }; +} diff --git a/packages/hooks-react/src/useLiveChannels.test.ts b/packages/hooks-react/src/useLiveChannels.test.ts new file mode 100644 index 000000000..e53b04f23 --- /dev/null +++ b/packages/hooks-react/src/useLiveChannels.test.ts @@ -0,0 +1,331 @@ +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/controllers/EpgController'; + +import { queryClientWrapper, waitForWithFakeTimers } from './testUtils'; +import useLiveChannels from './useLiveChannels'; + +const livePlaylist: Playlist = livePlaylistFixture; +const schedule: EpgChannel[] = epgChannelsFixture; +const scheduleUpdate: EpgChannel[] = epgChannelsUpdateFixture; + +describe('useLiveChannels', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + test('gets the date using the EPG service getSchedules method', async () => { + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: '' }, + }); + + // initial empty channels + expect(result.current.channels).toEqual([]); + expect(result.current.channel).toBeUndefined(); + expect(result.current.program).toBeUndefined(); + + await waitForWithFakeTimers(); + + expect(getSchedules).toHaveBeenCalledWith(livePlaylist.playlist); + // channels are set in state + expect(result.current.channels).toEqual(schedule); + // first channel selected + expect(result.current.channel).toEqual(schedule[0]); + }); + + test('selects the initial channel based of the initialChannelId', async () => { + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: 'channel2' }, + }); + + await waitForWithFakeTimers(); + + expect(getSchedules).toHaveBeenCalledOnce(); + expect(getSchedules).toHaveBeenCalledWith(livePlaylist.playlist); + // second channel selected (initial channel id) + expect(result.current.channel).toEqual(schedule[1]); + }); + + test('selects the live program of the current channel', async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:45:00Z')); + }); + + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + await waitForWithFakeTimers(); + + // select live program on first channel + expect(result.current.program).toMatchObject({ id: 'program2' }); + }); + + test('updates the program automatically when no program was found', async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T09:00:00Z')); + }); + + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result, rerender } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + // no program is selected + // update time to next program + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:01:00Z')); + }); + + rerender({ playlist: livePlaylist.playlist, initialChannelId: undefined }); + + await waitForWithFakeTimers(); + + expect(result.current.program).toMatchObject({ id: 'program1' }); + }); + + test('updates the program automatically when being live', async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); + }); + + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + await waitForWithFakeTimers(); + // first program is selected + expect(result.current.program).toMatchObject({ id: 'program1' }); + + // update time to next program + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:31:00Z')); + vi.advanceTimersToNextTimer(); + }); + + await waitForWithFakeTimers(); + + expect(result.current.program).toMatchObject({ id: 'program2' }); + }); + + test("doesn't update the program automatically when not being live", async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); + }); + + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + // first program is selected + await waitForWithFakeTimers(); + expect(result.current.program).toMatchObject({ id: 'program1' }); + + // update time to next program + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:31:00Z')); + vi.advanceTimersToNextTimer(); + }); + + await waitForWithFakeTimers(); + expect(result.current.program).toMatchObject({ id: 'program2' }); + }); + + test('updates the channel and program when using the `setChannel` function', async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); + }); + + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + // first program is selected + await waitForWithFakeTimers(); + expect(result.current.program).toMatchObject({ id: 'program1' }); + + act(() => { + result.current.setActiveChannel('channel2'); + }); + + // channel 2 should be selected and program 3 which is live + expect(result.current.channel).toMatchObject({ id: 'channel2' }); + expect(result.current.program).toMatchObject({ id: 'program3' }); + + // update channel and program + act(() => { + result.current.setActiveChannel('channel1', 'program2'); + }); + + expect(result.current.channel).toMatchObject({ id: 'channel1' }); + expect(result.current.program).toMatchObject({ id: 'program2' }); + }); + + test("doesn't update the channel or program when using the `setChannel` function with an invalid channelId ", async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); + }); + + mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + // first program is selected + await waitForWithFakeTimers(); + await expect(result.current.program).toMatchObject({ id: 'program1' }); + + act(() => { + result.current.setActiveChannel('channel3', 'program5'); + }); + + // channel 1 should still be selected + expect(result.current.channel).toMatchObject({ id: 'channel1' }); + expect(result.current.program).toMatchObject({ id: 'program1' }); + }); + + test('updates the channel and program when the data changes', async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T10:15:00Z')); + }); + + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + // first program is selected + await waitForWithFakeTimers(); + expect(result.current.program).toMatchObject({ id: 'program1' }); + expect(result.current.channel?.programs.length).toBe(2); + expect(getSchedules).toHaveBeenCalledTimes(1); + + getSchedules.mockResolvedValueOnce(scheduleUpdate); + + act(() => { + vi.runOnlyPendingTimers(); + }); + + await waitForWithFakeTimers(); + // the endTime for program1 should be changed + 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' }); + }); + + test('updates the channel and program when the data changes', async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T11:05:00Z')); + }); + + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(schedule), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + 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(getSchedules).toHaveBeenCalledTimes(1); + + getSchedules.mockResolvedValue(scheduleUpdate); + + act(() => { + vi.runOnlyPendingTimers(); + }); + + await waitForWithFakeTimers(); + // the program should be updated to the live program with the updated data + expect(getSchedules).toHaveBeenCalledTimes(2); + expect(result.current.program).toMatchObject({ id: 'program5' }); + }); + + test("clears the program when the current program get's removed from the data", async () => { + act(() => { + vi.setSystemTime(new Date('2022-07-15T11:05:00Z')); + }); + + // start with update schedule (which has more programs) + const { getSchedules } = mockService(EpgController, { + getSchedules: vi.fn().mockResolvedValue(scheduleUpdate), + }); + + const { result } = renderHook((props) => useLiveChannels(props), { + wrapper: queryClientWrapper(), + initialProps: { playlist: livePlaylist.playlist, initialChannelId: undefined }, + }); + + // program 5 is selected + await waitForWithFakeTimers(); + expect(result.current.program).toMatchObject({ id: 'program5' }); + + // we use the default schedule data which doesn't have program5 + getSchedules.mockResolvedValue(schedule); + act(() => { + vi.runOnlyPendingTimers(); + }); + + await waitForWithFakeTimers(); + // the program should be undefined since it couldn't be found in the latest data + 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..6257a24de 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/controllers/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/packages/hooks-react/src/useMedia.ts b/packages/hooks-react/src/useMedia.ts new file mode 100644 index 000000000..a3cd68aa3 --- /dev/null +++ b/packages/hooks-react/src/useMedia.ts @@ -0,0 +1,23 @@ +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; + +export default function useMedia(mediaId: string, enabled: boolean = true): UseMediaResult { + const apiService = getModule(ApiService); + + return useQuery(['media', mediaId], () => apiService.getMediaById(mediaId), { + enabled: !!mediaId && enabled, + refetchInterval: (data, _) => { + if (!data) return false; + + const autoRefetch = isScheduledOrLiveMedia(data); + + return autoRefetch ? 1000 * 30 : false; + }, + staleTime: 60 * 1000 * 10, // 10 min + }); +} diff --git a/packages/hooks-react/src/useOffers.ts b/packages/hooks-react/src/useOffers.ts new file mode 100644 index 000000000..b62ef8865 --- /dev/null +++ b/packages/hooks-react/src/useOffers.ts @@ -0,0 +1,62 @@ +import { useMutation } from 'react-query'; +import { useEffect } from 'react'; +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 CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import type { OfferType } from '@jwp/ott-common/types/checkout'; + +const useOffers = () => { + const checkoutController = getModule(CheckoutController); + const accountController = getModule(AccountController); + + const { mediaOffers, subscriptionOffers, switchSubscriptionOffers, requestedMediaOffers } = useCheckoutStore( + ({ mediaOffers, subscriptionOffers, switchSubscriptionOffers, requestedMediaOffers }) => ({ + mediaOffers, + subscriptionOffers, + switchSubscriptionOffers, + requestedMediaOffers, + }), + shallow, + ); + + const { mutate: initialise, isLoading: isInitialisationLoading } = useMutation({ + mutationKey: ['initialiseOffers', requestedMediaOffers], + mutationFn: checkoutController.initialiseOffers, + }); + + const chooseOffer = useMutation({ + mutationKey: ['chooseOffer'], + mutationFn: checkoutController.chooseOffer, + }); + + const switchSubscription = useMutation({ + mutationKey: ['switchSubscription'], + mutationFn: checkoutController.switchSubscription, + onSuccess: () => accountController.reloadSubscriptions({ delay: 7500 }), // @todo: Is there a better way to wait? + }); + + useEffect(() => { + initialise(); + }, [requestedMediaOffers, initialise]); + + const hasMediaOffers = mediaOffers.length > 0; + const hasSubscriptionOffers = subscriptionOffers.length > 0; + const hasPremierOffers = requestedMediaOffers.some((mediaOffer) => mediaOffer.premier); + const hasMultipleOfferTypes = (subscriptionOffers.length > 0 || switchSubscriptionOffers.length > 0) && hasMediaOffers && !hasPremierOffers; + const defaultOfferType: OfferType = hasPremierOffers || !hasSubscriptionOffers ? 'tvod' : 'svod'; + + return { + isLoading: isInitialisationLoading || chooseOffer.isLoading, + mediaOffers, + subscriptionOffers, + switchSubscriptionOffers, + chooseOffer, + switchSubscription, + hasMultipleOfferTypes, + defaultOfferType, + }; +}; + +export default useOffers; diff --git a/packages/hooks-react/src/useOpaqueId.ts b/packages/hooks-react/src/useOpaqueId.ts new file mode 100644 index 000000000..f26ae1995 --- /dev/null +++ b/packages/hooks-react/src/useOpaqueId.ts @@ -0,0 +1,26 @@ +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 = IS_TEST_MODE ? 1235 : Math.random() * 10000; + + return [prefix, Math.round(randomId), suffix] + .filter(Boolean) + .join('_') + .replace(/[\s.]+/g, '_') + .toLowerCase(); +}; + +const useOpaqueId = (prefix?: string, suffix?: string, override?: string): string => { + const [id, setId] = useState(override || generateId(prefix, suffix)); + + useEffect(() => { + setId(override || generateId(prefix, suffix)); + }, [override, prefix, suffix]); + + return id; +}; + +export default useOpaqueId; diff --git a/packages/hooks-react/src/useOttAnalytics.ts b/packages/hooks-react/src/useOttAnalytics.ts new file mode 100644 index 000000000..c750c7fd6 --- /dev/null +++ b/packages/hooks-react/src/useOttAnalytics.ts @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +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'; +import type { Jwpltx } from '@jwp/ott-common/types/jwpltx'; + +const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { + const analyticsToken = useConfigStore((s) => s.config.analyticsToken); + const user = useAccountStore((state) => state.user); + const { config } = useConfigStore((s) => s); + + // ott app user id (oaid) + const oaid: number | undefined = user?.id ? Number(user.id) : undefined; + // app config id (oiid) + const oiid = config?.id; + // app version number (av) + const av = env.APP_VERSION; + + const [player, setPlayer] = useState(null); + + useEffect(() => { + const jwpltx = 'jwpltx' in window ? (window.jwpltx as Jwpltx) : undefined; + + if (!jwpltx || !analyticsToken || !player || !item) { + return; + } + + const timeHandler = ({ position, duration }: jwplayer.TimeParam) => { + jwpltx.time(position, duration); + }; + + const seekHandler = ({ offset }: jwplayer.SeekParam) => { + // TODO: according to JWPlayer typings, the seek param doesn't contain a `duration` property, but it actually does + jwpltx.seek(offset, player.getDuration()); + }; + + const seekedHandler = () => { + jwpltx.seeked(); + }; + + const playlistItemHandler = () => { + if (!analyticsToken) return; + + if (!item) { + return; + } + + jwpltx.ready(analyticsToken, window.location.hostname, feedId, item.mediaid, item.title, oaid, oiid, av); + }; + + const completeHandler = () => { + jwpltx.complete(); + }; + + const adImpressionHandler = () => { + jwpltx.adImpression(); + }; + + player.on('playlistItem', playlistItemHandler); + player.on('complete', completeHandler); + player.on('time', timeHandler); + player.on('seek', seekHandler); + player.on('seeked', seekedHandler); + player.on('adImpression', adImpressionHandler); + + return () => { + // Fire remaining seconds / minutes watched + jwpltx.remove(); + player.off('playlistItem', playlistItemHandler); + player.off('complete', completeHandler); + player.off('time', timeHandler); + player.off('seek', seekHandler); + player.off('seeked', seekedHandler); + player.off('adImpression', adImpressionHandler); + }; + }, [player, item, analyticsToken, feedId, oaid, oiid, av]); + + return setPlayer; +}; + +export default useOttAnalytics; 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/packages/hooks-react/src/usePlaylist.ts b/packages/hooks-react/src/usePlaylist.ts new file mode 100644 index 000000000..728526653 --- /dev/null +++ b/packages/hooks-react/src/usePlaylist.ts @@ -0,0 +1,42 @@ +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 }); + + // This pre-caches all playlist items and makes navigating a lot faster. + playlist?.playlist?.forEach((playlistItem) => { + queryClient.setQueryData(['media', playlistItem.mediaid], playlistItem); + }); + + return playlist; + }; + + const queryKey = ['playlist', playlistId, params]; + const isEnabled = !!playlistId && enabled; + + return useQuery(queryKey, () => callback(playlistId, params), { + enabled: isEnabled, + placeholderData: usePlaceholderData && isEnabled ? placeholderData : undefined, + refetchInterval: (data, _) => { + if (!data) return false; + + const autoRefetch = isTruthyCustomParamValue(data.refetch) || data.playlist.some(isScheduledOrLiveMedia); + + return autoRefetch ? 1000 * 30 : false; + }, + retry: false, + }); +} 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/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/hooks-react/src/useProfiles.ts b/packages/hooks-react/src/useProfiles.ts new file mode 100644 index 000000000..aef289e6f --- /dev/null +++ b/packages/hooks-react/src/useProfiles.ts @@ -0,0 +1,113 @@ +import type { ProfilesData } from '@inplayer-org/inplayer.js'; +import { useMutation, useQuery, type UseMutationOptions, type UseQueryOptions } from 'react-query'; +import { useTranslation } from 'react-i18next'; +import type { GenericFormErrors } from '@jwp/ott-common/types/form'; +import type { CommonAccountResponse } from '@jwp/ott-common/types/account'; +import type { ListProfilesResponse, ProfileDetailsPayload, ProfileFormSubmitError, ProfileFormValues, ProfilePayload } 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/controllers/ProfileController'; +import AccountController from '@jwp/ott-common/src/controllers/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); + + return useMutation(async (vars: { id: string; pin?: number; avatarUrl: string }) => profileController?.enterProfile({ id: vars.id, pin: vars.pin }), { + onMutate: ({ avatarUrl }) => { + useProfileStore.setState({ selectingProfileAvatar: avatarUrl }); + }, + onSuccess: async () => { + useProfileStore.setState({ selectingProfileAvatar: null }); + await accountController?.loadUserData(); + options?.onSuccess?.(); + }, + onError: () => { + useProfileStore.setState({ selectingProfileAvatar: null }); + logDev('Unable to enter profile'); + options?.onError?.(); + }, + }); +}; + +export const useCreateProfile = (options?: UseMutationOptions) => { + const { query: listProfiles } = useProfiles(); + + const profileController = getModule(ProfileController, false); + + return useMutation(async (data) => profileController?.createProfile(data), { + ...options, + onSuccess: (data, variables, context) => { + listProfiles.refetch(); + + options?.onSuccess?.(data, variables, context); + }, + }); +}; + +export const useUpdateProfile = (options?: UseMutationOptions) => { + const { query: listProfiles } = useProfiles(); + + const profileController = getModule(ProfileController, false); + + return useMutation(async (data) => profileController?.updateProfile(data), { + ...options, + onSettled: (...args) => { + listProfiles.refetch(); + + options?.onSettled?.(...args); + }, + }); +}; + +export const useDeleteProfile = (options?: UseMutationOptions) => { + const { query: listProfiles } = useProfiles(); + + const profileController = getModule(ProfileController, false); + + return useMutation(async (id) => profileController?.deleteProfile(id), { + ...options, + onSuccess: (...args) => { + listProfiles.refetch(); + + options?.onSuccess?.(...args); + }, + }); +}; + +export const isProfileFormSubmitError = (e: unknown): e is ProfileFormSubmitError => { + return !!e && typeof e === 'object' && 'message' in e; +}; + +export const useProfileErrorHandler = () => { + const { t } = useTranslation('user'); + + return (e: unknown, setErrors: (errors: Partial) => void) => { + if (isProfileFormSubmitError(e) && e.message.includes('409')) { + setErrors({ name: t('profile.validation.name.already_exists') }); + return; + } + setErrors({ form: t('profile.form_error') }); + }; +}; + +export const useProfiles = (options?: UseQueryOptions) => { + const { user } = useAccountStore(); + const isLoggedIn = !!user; + + const profileController = getModule(ProfileController); + + const profilesEnabled = profileController.isEnabled(); + + const query = useQuery(['listProfiles', user?.id || ''], () => profileController.listProfiles(), { + ...options, + enabled: isLoggedIn && profilesEnabled, + }); + + return { + query, + profilesEnabled: !!query.data?.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/useSearch.ts b/packages/hooks-react/src/useSearch.ts new file mode 100644 index 000000000..76d0acb43 --- /dev/null +++ b/packages/hooks-react/src/useSearch.ts @@ -0,0 +1,54 @@ +import { useQuery, type UseQueryResult } from 'react-query'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; +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 usePlaylist from '@jwp/ott-hooks-react/src/usePlaylist'; +import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { isTruthyCustomParamValue } from '@jwp/ott-common/src/utils/common'; +import type { Playlist } from '@jwp/ott-common/types/playlist'; +import { generatePlaylistPlaceholder } from '@jwp/ott-common/src/utils/collection'; + +const placeholderData = generatePlaylistPlaceholder(30); + +const useAppContentSearch = ({ siteId, enabled, query }: { query: string; siteId: string; enabled: boolean }) => { + const apiService = getModule(ApiService); + + const appContentSearchQuery: UseQueryResult = useQuery( + ['app-search', query], + async () => { + const searchResult = await apiService.getAppContentSearch(siteId, query); + + return searchResult; + }, + { + placeholderData: enabled ? placeholderData : undefined, + enabled: enabled, + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + }, + ); + + return appContentSearchQuery; +}; + +export const useSearch = (query: string) => { + const { config } = useConfigStore(({ config }) => ({ config }), shallow); + + const siteId = config?.siteId; + const searchPlaylist = config?.features?.searchPlaylist; + const hasAppContentSearch = isTruthyCustomParamValue(config?.custom?.appContentSearch); + + const playlistQuery = usePlaylist(searchPlaylist || '', { search: query || '' }, !hasAppContentSearch, !!query); + // New app content search flow + const appContentSearchQuery = useAppContentSearch({ siteId, enabled: hasAppContentSearch, query }); + + return hasAppContentSearch + ? { data: appContentSearchQuery.data, isFetching: appContentSearchQuery.isFetching, error: appContentSearchQuery.error } + : { + isFetching: playlistQuery.isFetching, + error: playlistQuery.error, + data: playlistQuery.data, + }; +}; diff --git a/packages/hooks-react/src/useSocialLoginUrls.ts b/packages/hooks-react/src/useSocialLoginUrls.ts new file mode 100644 index 000000000..5c3d59a02 --- /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/controllers/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/packages/hooks-react/src/useSubscriptionChange.ts b/packages/hooks-react/src/useSubscriptionChange.ts new file mode 100644 index 000000000..772397ef4 --- /dev/null +++ b/packages/hooks-react/src/useSubscriptionChange.ts @@ -0,0 +1,38 @@ +import { useMutation } from 'react-query'; +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/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; + +export const useSubscriptionChange = ( + isUpgradeOffer: boolean, + selectedOfferId: string | null, + customer: Customer | null, + activeSubscriptionId: string | number | undefined, +) => { + const accountController = getModule(AccountController); + const checkoutController = getModule(CheckoutController); + + const updateSubscriptionMetadata = useMutation(accountController.updateUser, { + onSuccess: () => { + useAccountStore.setState({ + loading: false, + }); + }, + }); + + return useMutation(checkoutController.changeSubscription, { + onSuccess: () => { + if (!isUpgradeOffer && selectedOfferId) { + updateSubscriptionMetadata.mutate({ + firstName: customer?.firstName || '', + lastName: customer?.lastName || '', + metadata: { + [`${activeSubscriptionId}_pending_downgrade`]: selectedOfferId, + }, + }); + } + }, + }); +}; 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/packages/hooks-react/src/useWatchHistory.ts b/packages/hooks-react/src/useWatchHistory.ts new file mode 100644 index 000000000..7a861a69d --- /dev/null +++ b/packages/hooks-react/src/useWatchHistory.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +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 { useWatchHistoryListener } from './useWatchHistoryListener'; + +export const useWatchHistory = (player: jwplayer.JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { + // config + const { features } = useConfigStore((s) => s.config); + const continueWatchingList = features?.continueWatchingList; + const watchHistoryEnabled = !!continueWatchingList; + + // watch history listener + useWatchHistoryListener(player, item, seriesItem); + + // watch History + const watchHistoryItem = useWatchHistoryStore((state) => (!!item && watchHistoryEnabled ? state.getItem(item) : undefined)); + + // calculate the `startTime` of the current item based on the watch progress + return useMemo(() => { + const videoProgress = watchHistoryItem?.progress; + + if (videoProgress && videoProgress > VideoProgressMinMax.Min && videoProgress < VideoProgressMinMax.Max) { + return videoProgress * item.duration; + } + + // start at the beginning of the video (only for VOD content) + return 0; + }, [item.duration, watchHistoryItem?.progress]); +}; diff --git a/src/hooks/useWatchHistoryListener.ts b/packages/hooks-react/src/useWatchHistoryListener.ts similarity index 87% rename from src/hooks/useWatchHistoryListener.ts rename to packages/hooks-react/src/useWatchHistoryListener.ts index 7c72dad59..d9d2d9002 100644 --- a/src/hooks/useWatchHistoryListener.ts +++ b/packages/hooks-react/src/useWatchHistoryListener.ts @@ -1,11 +1,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +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/controllers/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; @@ -31,7 +30,7 @@ const PROGRESSIVE_SAVE_INTERVAL = 300_000; // 5 minutes * item. When this needs to be saved, the queue is used to look up the last progress and item and save it when * necessary. The queue is then cleared to prevent duplicate saves and API calls. */ -export const useWatchHistoryListener = (player: JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { +export const useWatchHistoryListener = (player: jwplayer.JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { const queuedWatchProgress = useRef(null); const watchHistoryController = getModule(WatchHistoryController); @@ -58,7 +57,7 @@ export const useWatchHistoryListener = (player: JWPlayer | undefined, item: Play // live streams have a negative duration, we ignore these for now if (event.duration < 0) return; - const progress = event.position / event.duration; + const progress = Number((event.position / event.duration).toFixed(5)); if (!isNaN(progress) && isFinite(progress)) { queuedWatchProgress.current = { diff --git a/packages/hooks-react/tsconfig.json b/packages/hooks-react/tsconfig.json new file mode 100644 index 000000000..8e73fc1c1 --- /dev/null +++ b/packages/hooks-react/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src", + "vitest.setup.ts", + "vitest.config.ts", + "../common/types" + ], + "compilerOptions": { + "noEmit": true, + "types": [ + "@types/jwplayer", + "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/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/packages/testing/constants.ts b/packages/testing/constants.ts new file mode 100644 index 000000000..bee692b5f --- /dev/null +++ b/packages/testing/constants.ts @@ -0,0 +1,50 @@ +export interface TestConfig { + id: string; + label: string; +} + +export const testConfigs = { + jwpAuth: { + id: '9qqwmnbx', + label: 'JWP AUTHVOD', + }, + jwpSvod: { + id: 'a2kbjdv0', + label: 'JWP SVOD', + }, + jwpAuthNoWatchlist: { + id: 'oqrsyxin', + label: 'JWP Authvod (No WL)', + }, + basicNoAuth: { + id: 'gnnuzabk', + label: 'Demo App (No Auth)', + }, + noStyling: { + id: 'kujzeu1b', + label: 'No Styling (No Auth)', + }, + inlinePlayer: { + id: 'ata6ucb8', + label: 'Inline Player', + }, + cleengAuthvod: { + id: 'nvqkufhy', + label: 'Cleeng Authvod', + }, + cleengAuthvodNoWatchlist: { + id: '7weyqrua', + label: 'Cleeng Authvod (No WL)', + }, + svod: { + id: 'ozylzc5m', + label: 'Cleeng SVOD', + }, +}; + +export const jwDevEnvConfigs = { + basicNoAuth: { + id: 'uzcyv8xh', + label: 'JW-Dev Basic Demo', + } as TestConfig, +}; 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 91% rename from test/fixtures/config.json rename to packages/testing/fixtures/config.json index eb4268611..0d9676d53 100644 --- a/test/fixtures/config.json +++ b/packages/testing/fixtures/config.json @@ -55,8 +55,7 @@ "styling": { "backgroundColor": null, "highlightColor": null, - "headerBackground": null, - "footerText": "\u00a9 Blender Foundation | [cloud.blender.org](https://cloud.blender.org)" + "headerBackground": null }, "description": "Blender demo site", "analyticsToken": "lDd_MCg4EeuMunbqcIJccw", diff --git a/packages/testing/fixtures/customer.json b/packages/testing/fixtures/customer.json new file mode 100644 index 000000000..f20d481ff --- /dev/null +++ b/packages/testing/fixtures/customer.json @@ -0,0 +1,31 @@ +{ + "id": "362344331", + "email": "email@domain.com", + "firstName": "John", + "lastName": "Doe", + "country": "NL", + "regDate": "2021-04-28 13:03:26", + "lastLoginDate": "2021-07-26 16:54:38", + "lastUserIp": "123.123.123.123", + "externalId": "", + "metadata": { + "history": [ + { + "mediaid": "YzUqQnrx", + "progress": 0.3710328907847994 + }, + { + "mediaid": "k6K5ugEC", + "progress": 0.448087431693989 + } + ], + "favorites": [ + { + "mediaid": "2v7rOqOE" + }, + { + "mediaid": "5cMy3jIp" + } + ] + } +} 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/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/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/assets/app-icon.png b/packages/theme/assets/app-icon.png new file mode 100644 index 000000000..968b01560 Binary files /dev/null and b/packages/theme/assets/app-icon.png differ diff --git a/packages/theme/assets/favicon.ico b/packages/theme/assets/favicon.ico new file mode 100644 index 000000000..a7d669098 Binary files /dev/null and b/packages/theme/assets/favicon.ico differ 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/packages/theme/assets/icons/cancel.svg b/packages/theme/assets/icons/cancel.svg new file mode 100644 index 000000000..da0aad0f0 --- /dev/null +++ b/packages/theme/assets/icons/cancel.svg @@ -0,0 +1,3 @@ + + + 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/packages/theme/assets/icons/language.svg b/packages/theme/assets/icons/language.svg new file mode 100644 index 000000000..30e16ced5 --- /dev/null +++ b/packages/theme/assets/icons/language.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/theme/assets/icons/lock.svg b/packages/theme/assets/icons/lock.svg new file mode 100644 index 000000000..ef141de5b --- /dev/null +++ b/packages/theme/assets/icons/lock.svg @@ -0,0 +1,6 @@ + + + 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/packages/theme/assets/icons/plus.svg b/packages/theme/assets/icons/plus.svg new file mode 100644 index 000000000..7500579b9 --- /dev/null +++ b/packages/theme/assets/icons/plus.svg @@ -0,0 +1,6 @@ + + + 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/.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..85b12ee45 --- /dev/null +++ b/packages/ui-react/package.json @@ -0,0 +1,58 @@ +{ + "name": "@jwp/ott-ui-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-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run --update", + "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" + }, + "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", + "inversify": "^6.0.1", + "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/jwplayer": "^8.2.13", + "@types/marked": "^4.0.7", + "@types/payment": "^2.1.4", + "@types/react-infinite-scroller": "^1.2.3", + "@vitejs/plugin-react": "^4.0.4", + "jsdom": "^22.1.0", + "sass": "^1.49.10", + "typescript-plugin-css-modules": "^5.0.2", + "vi-fetch": "^0.8.0", + "vite-plugin-svgr": "^4.2.0", + "vitest": "^1.3.1" + }, + "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 98% rename from src/components/Account/Account.module.scss rename to packages/ui-react/src/components/Account/Account.module.scss index 68e25c900..2dedd6b99 100644 --- a/src/components/Account/Account.module.scss +++ b/packages/ui-react/src/components/Account/Account.module.scss @@ -8,4 +8,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..801927a48 --- /dev/null +++ b/packages/ui-react/src/components/Account/Account.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type { CustomFormField } from '@jwp/ott-common/types/account'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; +import AccountController from '@jwp/ott-common/src/controllers/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 CustomFormField), + }); + + const { container } = renderWithRouter(); + + // todo + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/ui-react/src/components/Account/Account.tsx b/packages/ui-react/src/components/Account/Account.tsx new file mode 100644 index 000000000..94b61e2c7 --- /dev/null +++ b/packages/ui-react/src/components/Account/Account.tsx @@ -0,0 +1,435 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; +import DOMPurify from 'dompurify'; +import { useMutation } from 'react-query'; +import type { CustomFormField } 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/controllers/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 env from '@jwp/ott-common/src/env'; + +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 FormFeedback from '../FormFeedback/FormFeedback'; +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'; + +type Props = { + panelClassName?: string; + panelHeaderClassName?: string; + canUpdateEmail?: boolean; +}; + +interface FormErrors { + email?: string; + confirmationPassword?: string; + firstName?: string; + lastName?: string; + form?: string; +} + +const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true }: Props): JSX.Element => { + const accountController = getModule(AccountController); + + 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) { + setIsAlertVisible(true); + } + }, [exportData.isSuccess, exportData.isError]); + + const { customer, customerConsents, publisherConsents } = useAccountStore( + ({ user, customerConsents, publisherConsents }) => ({ + customer: user, + customerConsents, + publisherConsents, + }), + shallow, + ); + + const { canChangePasswordWithOldPassword, canExportAccountData, canDeleteAccount } = accountController.getFeatures(); + // users authenticated with social (register_source: facebook, google, twitter) do not have password by default + const registerSource = customer?.metadata?.register_source; + const isSocialLogin = (registerSource && registerSource !== 'inplayer') || false; + const shouldAddPassword = (isSocialLogin && !customer?.metadata?.has_password) || false; + + // load consents (move to `useConsents` hook?) + useEffect(() => { + if (!publisherConsents) { + accountController.getPublisherConsents(); + + return; + } + }, [accountController, publisherConsents]); + + const [termsConsents, nonTermsConsents] = useMemo(() => { + const terms: CustomFormField[] = []; + const nonTerms: CustomFormField[] = []; + + publisherConsents?.forEach((consent) => { + if (!consent?.type || consent?.type === 'checkbox') { + terms.push(consent); + } else { + nonTerms.push(consent); + } + }); + + return [terms, nonTerms]; + }, [publisherConsents]); + + const consents = useMemo(() => formatConsents(publisherConsents, customerConsents), [publisherConsents, customerConsents]); + + const consentsValues = useMemo(() => formatConsentValues(publisherConsents, customerConsents), [publisherConsents, customerConsents]); + + const initialValues = useMemo( + () => ({ + ...customer, + consents, + consentsValues, + confirmationPassword: '', + }), + [customer, consents, consentsValues], + ); + + const formatConsentLabel = (label: string): string | JSX.Element => { + const sanitizedLabel = DOMPurify.sanitize(label); + const hasHrefOpenTag = //.test(sanitizedLabel); + const hasHrefCloseTag = /<\/a(.|\n)*?>/.test(sanitizedLabel); + + if (hasHrefOpenTag && hasHrefCloseTag) { + return ; + } + + return label; + }; + + function translateErrors(errors?: string[]): FormErrors { + const formErrors: FormErrors = {}; + // Some errors are combined in a single CSV string instead of one string per error + errors + ?.flatMap((e) => e.split(',')) + .forEach((error) => { + switch (error.trim()) { + case 'Invalid param email': + formErrors.form = t('account.errors.validation_error'); + formErrors.email = t('account.errors.invalid_param_email'); + break; + case 'Customer email already exists': + formErrors.form = t('account.errors.validation_error'); + formErrors.email = t('account.errors.email_exists'); + break; + case 'Please enter a valid e-mail address.': + formErrors.form = t('account.errors.validation_error'); + formErrors.email = t('account.errors.please_enter_valid_email'); + break; + case 'Invalid confirmationPassword': { + formErrors.form = t('account.errors.validation_error'); + formErrors.confirmationPassword = t('account.errors.invalid_password'); + break; + } + case 'firstName can have max 50 characters.': { + formErrors.form = t('account.errors.validation_error'); + formErrors.firstName = t('account.errors.first_name_too_long'); + break; + } + case 'lastName can have max 50 characters.': { + formErrors.form = t('account.errors.validation_error'); + formErrors.lastName = t('account.errors.last_name_too_long'); + break; + } + case 'Email update not supported': { + formErrors.form = t('account.errors.email_update_not_supported'); + break; + } + case 'Wrong email/password combination': { + formErrors.form = t('account.errors.wrong_combination'); + break; + } + default: { + formErrors.form = t('account.errors.unknown_error'); + logDev('Unknown error', error); + break; + } + } + }); + + return formErrors; + } + + function formSection(props: FormSectionProps) { + return { + ...props, + className: panelClassName, + panelHeaderClassName: panelHeaderClassName, + saveButton: t('account.save'), + cancelButton: t('account.cancel'), + content: (args: FormSectionContentArgs) => { + // This function just allows the sections below to use the FormError type instead of an array of errors + const formErrors = translateErrors(args.errors); + + // Render the section content, but also add a warning text if there's a form level error + return ( + <> + {formErrors?.form ? {formErrors.form} : null} + {props.content?.({ ...args, errors: formErrors })} + + ); + }, + }; + } + + const editPasswordClickHandler = async () => { + if (!customer) { + return; + } + if (isSocialLogin && shouldAddPassword) { + await accountController.resetPassword(customer.email, ''); + return navigate(modalURLFromLocation(location, 'add-password')); + } + + navigate(modalURLFromLocation(location, canChangePasswordWithOldPassword ? 'edit-password' : 'reset-password')); + }; + + return ( + <> +

{t('nav.account')}

+ +
+ {[ + formSection({ + label: t('account.about_you'), + editButton: t('account.edit_information'), + onSubmit: async (values) => { + const consents = formatConsentsFromValues(publisherConsents, { ...values.metadata, ...values.consentsValues }); + + const response = await accountController.updateUser({ + firstName: values.firstName || '', + lastName: values.lastName || '', + metadata: { + ...values.metadata, + ...formatConsentsToRegisterFields(consents), + consents: JSON.stringify(consents), + }, + }); + + announce(t('account.update_success', { section: t('account.about_you') }), 'success'); + + return response; + }, + content: (section) => ( + <> + + + + ), + }), + formSection({ + label: t('account.email'), + onSubmit: async (values) => { + if (!values.email || !values.confirmationPassword) { + throw new Error('Wrong email/password combination'); + } + const response = await accountController.updateUser({ + email: values.email || '', + confirmationPassword: values.confirmationPassword, + }); + + announce(t('account.update_success', { section: t('account.email') }), 'success'); + + return response; + }, + editButton: t('account.edit_account'), + readOnly: !canUpdateEmail, + content: (section) => ( + <> + + {section.isEditing && ( + toggleViewPassword()} aria-pressed={viewPassword}> + + + } + required + /> + )} + + ), + }), + formSection({ + label: t('account.security'), + editButton: ( + + + +
+
+

+ account.email +

+
+
+
+ + +
+
+
+ +
+
+
+
+

+ account.security +

+
+
+
+ +
+
+
+
+

+ account.terms_and_tracking +

+
+ +
+
+ + +
+
+ +
+
+ +`; 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/packages/ui-react/src/components/Adyen/AdyenForm.scss b/packages/ui-react/src/components/Adyen/AdyenForm.scss new file mode 100644 index 000000000..acd3d5a40 --- /dev/null +++ b/packages/ui-react/src/components/Adyen/AdyenForm.scss @@ -0,0 +1,25 @@ +@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 { + color: variables.$white; + font-family: var(--body-font-family); + font-size: 16px; + line-height: variables.$base-line-height; + } + + .adyen-checkout__error-text { + font-family: var(--body-font-family); + font-size: 14px; + } +} + +.adyen-checkout__label { + .adyen-checkout__label__text { + color: variables.$white; + font-family: var(--body-font-family); + font-size: 16px; + line-height: variables.$base-line-height; + } +} diff --git a/packages/ui-react/src/components/Alert/Alert.module.scss b/packages/ui-react/src/components/Alert/Alert.module.scss new file mode 100644 index 000000000..e1b2b141a --- /dev/null +++ b/packages/ui-react/src/components/Alert/Alert.module.scss @@ -0,0 +1,17 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; + +.title { + margin-bottom: 24px; + font-family: theme.$body-font-family; + font-weight: 700; + font-size: 24px; +} + +.body { + font-family: theme.$body-font-family; +} + +.confirmButton { + margin-bottom: 8px; +} 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/packages/ui-react/src/components/Alert/Alert.tsx b/packages/ui-react/src/components/Alert/Alert.tsx new file mode 100644 index 000000000..865f65a81 --- /dev/null +++ b/packages/ui-react/src/components/Alert/Alert.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import useOpaqueId from '@jwp/ott-hooks-react/src/useOpaqueId'; + +import Dialog from '../Dialog/Dialog'; +import Button from '../Button/Button'; + +import styles from './Alert.module.scss'; + +type Props = { + open: boolean; + message: string | null; + onClose: () => void; + isSuccess?: boolean; + actionsOverride?: React.ReactNode; + titleOverride?: string; +}; + +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'))} +

+

{message}

+ {actionsOverride ??
+ ); +}; + +export default Alert; 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 86% rename from src/components/Animation/Fade/Fade.tsx rename to packages/ui-react/src/components/Animation/Fade/Fade.tsx index 6a5d22db7..14a7ab9f2 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; @@ -20,6 +20,7 @@ const Fade: React.FC = ({ className, open = true, duration = 250, delay = const createStyle = (status: Status): CSSProperties => ({ transition, opacity: status === 'opening' || status === 'open' ? 1 : 0, + willChange: 'opacity', }); return ( diff --git a/src/components/Animation/Grow/Grow.tsx b/packages/ui-react/src/components/Animation/Grow/Grow.tsx similarity index 76% rename from src/components/Animation/Grow/Grow.tsx rename to packages/ui-react/src/components/Animation/Grow/Grow.tsx index 793c1c87b..1f524ca35 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; @@ -9,9 +9,10 @@ type Props = { onOpenAnimationEnd?: () => void; onCloseAnimationEnd?: () => void; children: ReactNode; + className?: string; }; -const Grow = ({ open = true, duration = 250, delay = 0, onOpenAnimationEnd, onCloseAnimationEnd, children }: Props): JSX.Element | null => { +const Grow = ({ open = true, duration = 250, delay = 0, onOpenAnimationEnd, onCloseAnimationEnd, children, className }: Props): JSX.Element | null => { const seconds = duration / 1000; const transition = `transform ${seconds}s ease-out`; // todo: -webkit-transform; const createStyle = (status: Status): CSSProperties => ({ @@ -27,6 +28,7 @@ const Grow = ({ open = true, duration = 250, delay = 0, onOpenAnimationEnd, onCl delay={delay} onOpenAnimationEnd={onOpenAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd} + className={className} > {children} 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/packages/ui-react/src/components/BackButton/BackButton.module.scss b/packages/ui-react/src/components/BackButton/BackButton.module.scss new file mode 100644 index 000000000..f46a791d3 --- /dev/null +++ b/packages/ui-react/src/components/BackButton/BackButton.module.scss @@ -0,0 +1,10 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; + +.backButton { + position: absolute; + top: 16px; + left: 16px; + z-index: 1; + pointer-events: auto; +} 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 88% rename from src/components/Button/Button.module.scss rename to packages/ui-react/src/components/Button/Button.module.scss index db3068634..477b3196a 100644 --- a/src/components/Button/Button.module.scss +++ b/packages/ui-react/src/components/Button/Button.module.scss @@ -1,6 +1,7 @@ -@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/accessibility'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; $small-button-height: 28px; $medium-button-height: 36px; @@ -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,13 +73,14 @@ $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; } } } &.text { background: none; - opacity: 0.7; + opacity: 0.9; &:not(.disabled) { &.active, 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 89% rename from src/components/Button/Button.tsx rename to packages/ui-react/src/components/Button/Button.tsx index a199d3af7..32067f2d8 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; @@ -29,6 +29,7 @@ type Props = { busy?: boolean; id?: string; as?: 'button' | 'a'; + activeClassname?: string; } & React.AriaAttributes; const Button: React.FC = ({ @@ -42,16 +43,18 @@ const Button: React.FC = ({ size = 'medium', disabled, busy, - type, + type = 'button', to, as = 'button', onClick, className, + activeClassname = '', ...rest }: Props) => { const buttonClassName = (isActive: boolean) => classNames(styles.button, className, styles[color], styles[variant], { [styles.active]: isActive, + [activeClassname]: isActive, [styles.fullWidth]: fullWidth, [styles.large]: size === 'large', [styles.small]: size === 'small', 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 93% rename from src/components/Button/__snapshots__/Button.test.tsx.snap rename to packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap index 766a87a85..aa5e13b5a 100644 --- a/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[` + +
+ + + + + + + + + + + + +
+ checkout.total_price + + € 6,99 +
+ checkout.applicable_tax + + € 1,21 +
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+ +
+
+ +`; diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.module.scss b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.module.scss similarity index 86% rename from src/components/ChooseOfferForm/ChooseOfferForm.module.scss rename to packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.module.scss index 15266b3fc..7bce23ecf 100644 --- a/src/components/ChooseOfferForm/ChooseOfferForm.module.scss +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.module.scss @@ -1,6 +1,7 @@ -@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/accessibility'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; .title { margin-bottom: 8px; @@ -36,6 +37,11 @@ .offer { flex: 1; margin: 0 4px; + + &:focus-within .label { + @include accessibility.accessibleOutlineContrast; + transform: scale(1.03); + } } .radio { @@ -47,13 +53,6 @@ clip: rect(0 0 0 0); clip-path: inset(50%); - :focus, - :active { - + .label { - border-color: variables.$white; - } - } - &:checked + .label { color: variables.$black; background-color: variables.$white; @@ -70,7 +69,7 @@ border: 1px solid rgba(variables.$white, 0.34); border-radius: 4px; cursor: pointer; - transition: border 0.2s ease, background 0.2s ease, transform 0.3s ease-out; + transition: border 0.2s ease, background 0.2s ease, transform 0.2s ease-out; } .offerGroupLabel { diff --git a/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx new file mode 100644 index 000000000..a380b65a3 --- /dev/null +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx @@ -0,0 +1,131 @@ +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'; + +const svodOffers = [monthlyOffer, yearlyOffer] as Offer[]; +const tvodOffers = [tvodOffer] as Offer[]; + +describe('', () => { + test('renders and matches snapshot', () => { + const { container } = render( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('renders and matches snapshot', () => { + const { container } = render( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('checks the monthly offer correctly', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('S916977979_NL')).toBeChecked(); + }); + + test('checks the yearly offer correctly', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('S345569153_NL')).toBeChecked(); + }); + + test('checks the tvod offer correctly', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('R892134629_NL')).toBeChecked(); + }); + + test('calls the onChange callback when changing the offer', () => { + const onChange = vi.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId('S345569153_NL')); + + expect(onChange).toBeCalled(); + }); + + test('calls the onSubmit callback when submitting the form', () => { + const onSubmit = vi.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.submit(getByTestId('choose-offer-form')); + + expect(onSubmit).toBeCalled(); + }); +}); diff --git a/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx new file mode 100644 index 000000000..a1766a602 --- /dev/null +++ b/packages/ui-react/src/components/ChooseOfferForm/ChooseOfferForm.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import type { FormErrors } from '@jwp/ott-common/types/form'; +import type { Offer, ChooseOfferFormData } from '@jwp/ott-common/types/checkout'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { getOfferPrice, isSVODOffer } from '@jwp/ott-common/src/utils/offers'; +import { testId } from '@jwp/ott-common/src/utils/common'; +import CheckCircle from '@jwp/ott-theme/assets/icons/check_circle.svg?react'; + +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 styles from './ChooseOfferForm.module.scss'; + +type OfferBoxProps = { + offer: Offer; + selected: boolean; +} & Pick; + +const OfferBox: React.FC = ({ offer, selected, onChange }: OfferBoxProps) => { + const { t } = useTranslation('account'); + + const getFreeTrialText = (offer: Offer) => { + if (offer.freeDays) { + return t('choose_offer.benefits.first_days_free', { count: offer.freeDays }); + } else if (offer.freePeriods) { + // t('periods.day', { count }) + // t('periods.week', { count }) + // t('periods.month', { count }) + // t('periods.year', { count }) + const period = t(`periods.${offer.period}`, { count: offer.freePeriods }); + + return t('choose_offer.benefits.first_periods_free', { count: offer.freePeriods, period }); + } + + return null; + }; + + const renderOption = ({ title, periodString, secondBenefit }: { title: string; periodString?: string; secondBenefit?: string }) => ( +
+ +
+`; + +exports[` > renders and matches snapshot 2`] = ` +
+
+

+ choose_offer.title +

+

+ choose_offer.watch_this_on_platform +

+
+ + + + +
+
+
+ +
+ + +
+`; diff --git a/src/components/CollapsibleText/CollapsibleText.module.scss b/packages/ui-react/src/components/CollapsibleText/CollapsibleText.module.scss similarity index 88% rename from src/components/CollapsibleText/CollapsibleText.module.scss rename to packages/ui-react/src/components/CollapsibleText/CollapsibleText.module.scss index 788aac76b..abe62e3ba 100644 --- a/src/components/CollapsibleText/CollapsibleText.module.scss +++ b/packages/ui-react/src/components/CollapsibleText/CollapsibleText.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'; .collapsibleText { position: relative; diff --git a/src/components/CollapsibleText/CollapsibleText.test.tsx b/packages/ui-react/src/components/CollapsibleText/CollapsibleText.test.tsx similarity index 100% rename from src/components/CollapsibleText/CollapsibleText.test.tsx rename to packages/ui-react/src/components/CollapsibleText/CollapsibleText.test.tsx diff --git a/packages/ui-react/src/components/CollapsibleText/CollapsibleText.tsx b/packages/ui-react/src/components/CollapsibleText/CollapsibleText.tsx new file mode 100644 index 000000000..8656584af --- /dev/null +++ b/packages/ui-react/src/components/CollapsibleText/CollapsibleText.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import ChevronRight from '@jwp/ott-theme/assets/icons/chevron_right.svg?react'; +import useBreakpoint from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; + +import IconButton from '../IconButton/IconButton'; +import Icon from '../Icon/Icon'; + +import styles from './CollapsibleText.module.scss'; + +type Props = { + text: string; + className?: string; + maxHeight?: 'none' | number; +}; + +const CollapsibleText: React.FC = ({ text, className, maxHeight = 'none' }: Props) => { + const divRef = useRef() as React.MutableRefObject; + const breakpoint = useBreakpoint(); + const [doesFlowOver, setDoesFlowOver] = useState(false); + const [expanded, setExpanded] = useState(false); + + const ariaLabel = expanded ? 'Collapse' : 'Expand'; + + const clippablePixels = 4; + + useEffect(() => { + divRef.current && + setDoesFlowOver( + divRef.current.scrollHeight > divRef.current.offsetHeight + clippablePixels || (maxHeight !== 'none' && maxHeight < divRef.current.offsetHeight), + ); + }, [maxHeight, text, breakpoint]); + + return ( +
+

+ {text} +

+ {doesFlowOver && ( + setExpanded(!expanded)} + > + + + )} +
+ ); +}; + +export default CollapsibleText; diff --git a/src/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap b/packages/ui-react/src/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap similarity index 86% rename from src/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap rename to packages/ui-react/src/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap index 52373ad11..101afed39 100644 --- a/src/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap +++ b/packages/ui-react/src/components/CollapsibleText/__snapshots__/CollapsibleText.test.tsx.snap @@ -5,12 +5,13 @@ exports[` > renders and matches snapshot 1`] = `
-
Test... -
+

`; diff --git a/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.module.scss b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.module.scss new file mode 100644 index 000000000..e1b2b141a --- /dev/null +++ b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.module.scss @@ -0,0 +1,17 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; + +.title { + margin-bottom: 24px; + font-family: theme.$body-font-family; + font-weight: 700; + font-size: 24px; +} + +.body { + font-family: theme.$body-font-family; +} + +.confirmButton { + margin-bottom: 8px; +} diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx similarity index 100% rename from src/components/ConfirmationDialog/ConfirmationDialog.test.tsx rename to packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.tsx similarity index 87% rename from src/components/ConfirmationDialog/ConfirmationDialog.tsx rename to packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.tsx index b8f803c4c..eed259ccd 100644 --- a/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import styles from './ConfirmationDialog.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 './ConfirmationDialog.module.scss'; type Props = { open: boolean; @@ -19,7 +19,7 @@ const ConfirmationDialog: React.FC = ({ open, title, body, onConfirm, onC const { t } = useTranslation('common'); return ( - +

{title}

{body}

+ + +`; 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/packages/ui-react/src/components/Epg/Epg.tsx b/packages/ui-react/src/components/Epg/Epg.tsx new file mode 100644 index 000000000..e8052f673 --- /dev/null +++ b/packages/ui-react/src/components/Epg/Epg.tsx @@ -0,0 +1,102 @@ +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 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 styles from './Epg.module.scss'; + +type Props = { + channels: EpgChannel[]; + onChannelClick: (channelId: string) => void; + onProgramClick: (programId: string, channelId: string) => void; + selectedChannel: EpgChannel | undefined; + program: EpgProgram | undefined; + config: Config; +}; + +export default function Epg({ channels, selectedChannel, onChannelClick, onProgramClick, program, config }: Props) { + const breakpoint = useBreakpoint(); + const { t } = useTranslation('common'); + + const isMobile = breakpoint < Breakpoint.sm; + const sidebarWidth = isMobile ? 70 : 184; + // the subtracted value is used for spacing in the sidebar + const channelItemWidth = isMobile ? sidebarWidth - 10 : sidebarWidth - 24; + const itemHeight = isMobile ? 80 : 106; + + // Epg + const { highlightColor, backgroundColor } = config.styling; + const { getEpgProps, getLayoutProps, onScrollToNow, onScrollLeft, onScrollRight } = usePlanByEpg({ + channels, + sidebarWidth, + itemHeight, + highlightColor, + backgroundColor, + }); + const catchupHoursDict = useMemo(() => Object.fromEntries(channels.map((channel) => [channel.id, channel.catchupHours])), [channels]); + const titlesDict = useMemo(() => Object.fromEntries(channels.map((channel) => [channel.id, channel.title])), [channels]); + + return ( +
+
+
+ }> + } + renderChannel={({ channel: epgChannel }) => ( + { + onChannelClick(toChannel.uuid); + onScrollToNow(); + }} + title={titlesDict[epgChannel.uuid] || ''} + isActive={selectedChannel?.id === epgChannel.uuid} + /> + )} + renderProgram={({ program: programItem, isBaseTimeFormat }) => { + const catchupHours = catchupHoursDict[programItem.data.channelUuid]; + const disabled = isBefore(new Date(programItem.data.since), subHours(new Date(), catchupHours)); + + return ( + !disabled && onProgramClick(program.data.id, program.data.channelUuid)} + isActive={program?.id === programItem.data.id} + compact={isMobile} + isBaseTimeFormat={isBaseTimeFormat} + /> + ); + }} + /> + +
+ ); +} 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 80% rename from src/components/EpgChannel/EpgChannelItem.tsx rename to packages/ui-react/src/components/EpgChannel/EpgChannelItem.tsx index a2a7c606c..d423abb43 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; @@ -13,9 +13,10 @@ type Props = { sidebarWidth: number; onClick?: (channel: Channel) => void; isActive: boolean; + title: string; }; -const EpgChannelItem: React.VFC = ({ channel, channelItemWidth, sidebarWidth, onClick, isActive }) => { +const EpgChannelItem: React.VFC = ({ channel, channelItemWidth, sidebarWidth, onClick, isActive, title }) => { const { position, uuid, channelLogoImage } = channel; const style = { top: position.top, height: position.height, width: sidebarWidth }; @@ -26,8 +27,9 @@ const EpgChannelItem: React.VFC = ({ channel, channelItemWidth, sidebarWi style={{ width: channelItemWidth }} onClick={() => onClick && onClick(channel)} data-testid={testId(uuid)} + role="button" > - Logo + {title} ); diff --git a/src/components/EpgProgramItem/EpgProgramItem.module.scss b/packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.module.scss similarity index 91% rename from src/components/EpgProgramItem/EpgProgramItem.module.scss rename to packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.module.scss index 4d3c69e40..70c9d79f9 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; @@ -97,14 +97,13 @@ font-family: var(--body-font-family); font-weight: theme.$body-font-weight-bold; font-size: 16px; - line-height: 20px; + line-height: variables.$base-line-height; white-space: nowrap; text-overflow: ellipsis; @include responsive.mobile-only { margin: auto 0; font-size: 14px; - line-height: 16px; } } @@ -113,14 +112,13 @@ color: theme.$epg-text-color; font-family: var(--body-font-family); font-size: 14px; - line-height: 16px; + line-height: variables.$base-line-height; white-space: nowrap; text-overflow: ellipsis; @include responsive.mobile-only { margin-top: auto; font-size: 12px; - line-height: 14px; } } diff --git a/src/components/EpgProgramItem/EpgProgramItem.tsx b/packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.tsx similarity index 86% rename from src/components/EpgProgramItem/EpgProgramItem.tsx rename to packages/ui-react/src/components/EpgProgramItem/EpgProgramItem.tsx index 28efc53d9..6c5b01a90 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; @@ -37,6 +36,7 @@ const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, di const showImage = !compact && isMinWidth; const showLiveTagInImage = !compact && isMinWidth && isLive; + const alt = ''; // intentionally empty for a11y, because adjacent text alternative return (
onClick && onClick(program)}> @@ -49,11 +49,11 @@ const ProgramItem: React.VFC = ({ program, onClick, isActive, compact, di style={{ width: styles.width }} data-testid={testId(program.data.id)} > - {showImage && Preview} + {showImage && {alt}} {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/packages/ui-react/src/components/ErrorPage/ErrorPage.tsx b/packages/ui-react/src/components/ErrorPage/ErrorPage.tsx new file mode 100644 index 000000000..94911cbb3 --- /dev/null +++ b/packages/ui-react/src/components/ErrorPage/ErrorPage.tsx @@ -0,0 +1,63 @@ +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 DevStackTrace from '../DevStackTrace/DevStackTrace'; + +import styles from './ErrorPage.module.scss'; + +interface Props { + disableFallbackTranslation?: boolean; + title?: string | ReactNode; + message?: string | ReactNode; + learnMoreLabel?: string; + children?: React.ReactNode; + error?: Error; + helpLink?: string; +} + +const ErrorPage = ({ title, message, learnMoreLabel, ...rest }: Props) => { + const { t } = useTranslation('error'); + + return ( + + ); +}; + +export const ErrorPageWithoutTranslation = ({ title, children, message, learnMoreLabel, error, helpLink }: Props) => { + const logo = useConfigStore((s) => s.config?.assets?.banner); + const alt = ''; // intentionally empty for a11y, because adjacent text alternative + + return ( +
+
+ {alt} +

{title || 'An error occurred'}

+
+

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

+ {children} + {(IS_DEVELOPMENT_BUILD || IS_DEMO_MODE || IS_PREVIEW_MODE) && helpLink && ( +
+ + {learnMoreLabel || 'Learn More'} + + {(IS_DEVELOPMENT_BUILD || IS_PREVIEW_MODE) && error?.stack && ( + + + + )} +
+ )} +
+
+
+ ); +}; + +export default ErrorPage; diff --git a/packages/ui-react/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap b/packages/ui-react/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap new file mode 100644 index 000000000..7dea1eabe --- /dev/null +++ b/packages/ui-react/src/components/ErrorPage/__snapshots__/ErrorPage.test.tsx.snap @@ -0,0 +1,34 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders and matches snapshot 1`] = ` +
+
+
+ +

+ This is the title +

+
+

+ generic_error_description +

+ This is the content +
+
+
+
+`; diff --git a/packages/ui-react/src/components/Favorites/Favorites.module.scss b/packages/ui-react/src/components/Favorites/Favorites.module.scss new file mode 100644 index 000000000..443f9290d --- /dev/null +++ b/packages/ui-react/src/components/Favorites/Favorites.module.scss @@ -0,0 +1,18 @@ +@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); + + > h1 { + margin-right: calc(#{variables.$base-spacing} * 1.5); + font-weight: var(--body-font-weight-bold); + font-size: 34px; + font-style: normal; + line-height: 36px; + letter-spacing: 0.25px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 3px 4px rgba(0, 0, 0, 0.12), 0 1px 5px rgba(0, 0, 0, 0.2); + } +} diff --git a/packages/ui-react/src/components/Favorites/Favorites.test.tsx b/packages/ui-react/src/components/Favorites/Favorites.test.tsx new file mode 100644 index 000000000..d7a8f2f02 --- /dev/null +++ b/packages/ui-react/src/components/Favorites/Favorites.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import ApiService from '@jwp/ott-common/src/services/ApiService'; +import { mockService } from '@jwp/ott-common/test/mockService'; +import favorites from '@jwp/ott-testing/fixtures/favorites.json'; + +import { renderWithRouter } from '../../../test/utils'; + +import Favorites from './Favorites'; + +describe('', () => { + beforeEach(() => { + // TODO: Remove ApiService from component + mockService(ApiService, {}); + }); + + test('renders and matches snapshot', () => { + const { container } = renderWithRouter( + null} onClearFavoritesClick={() => null} hasSubscription={true} accessModel={'SVOD'} />, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/ui-react/src/components/Favorites/Favorites.tsx b/packages/ui-react/src/components/Favorites/Favorites.tsx new file mode 100644 index 000000000..4d991ba59 --- /dev/null +++ b/packages/ui-react/src/components/Favorites/Favorites.tsx @@ -0,0 +1,57 @@ +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 Button from '../Button/Button'; +import CardGrid from '../CardGrid/CardGrid'; + +import styles from './Favorites.module.scss'; + +type Props = { + playlist: Playlist; + accessModel: AccessModel; + hasSubscription: boolean; + onCardHover?: (item: PlaylistItem) => void; + onClearFavoritesClick: () => void; +}; + +const cols: Breakpoints = { + [Breakpoint.xs]: 2, + [Breakpoint.sm]: 3, + [Breakpoint.md]: 3, + [Breakpoint.lg]: 3, + [Breakpoint.xl]: 3, +}; + +const Favorites = ({ playlist, accessModel, hasSubscription, onCardHover, onClearFavoritesClick }: Props): JSX.Element => { + const { t } = useTranslation('user'); + const getURL = (playlistItem: PlaylistItem) => mediaURL({ media: playlistItem, playlistId: playlistItem.feedid }); + + return ( +
+
+

{t('favorites.title')}

+ {playlist.playlist.length > 0 ?
+ {playlist.playlist.length > 0 ? ( + + ) : ( +

{t('favorites.no_favorites')}

+ )} +
+ ); +}; + +export default Favorites; diff --git a/packages/ui-react/src/components/Favorites/__snapshots__/Favorites.test.tsx.snap b/packages/ui-react/src/components/Favorites/__snapshots__/Favorites.test.tsx.snap new file mode 100644 index 000000000..f3cef5d83 --- /dev/null +++ b/packages/ui-react/src/components/Favorites/__snapshots__/Favorites.test.tsx.snap @@ -0,0 +1,178 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders and matches snapshot 1`] = ` + +`; diff --git a/packages/ui-react/src/components/Filter/Filter.module.scss b/packages/ui-react/src/components/Filter/Filter.module.scss new file mode 100644 index 000000000..cfa30d2c7 --- /dev/null +++ b/packages/ui-react/src/components/Filter/Filter.module.scss @@ -0,0 +1,27 @@ +@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; + align-items: center; + margin-left: 16px; + + > button { + margin: 0 4px; + } +} + +.dropDown { + margin-bottom: 0; + + label { + // hide the label and optional label + display: none; + } +} + +.filterDropDown { + display: flex; + align-items: flex-end; +} 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 78% rename from src/components/Filter/Filter.tsx rename to packages/ui-react/src/components/Filter/Filter.tsx index 8fa2d53e7..ffe24f0db 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 = | { @@ -41,10 +41,11 @@ const Filter: FC = ({ name, value, defaultLabel, options, setValue, force {options.map((option) => { const optionLabel = typeof option === 'string' ? option : option.label; const optionValue = typeof option === 'string' ? option : option.value; + const active = value === optionValue; - return
) : (
@@ -57,6 +58,7 @@ const Filter: FC = ({ name, value, defaultLabel, options, setValue, force value={value} onChange={handleChange} aria-label={t('filter_videos_by', { name })} + hideOptional />
)} 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 81% rename from src/components/Filter/__snapshots__/Filter.test.tsx.snap rename to packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap index 6bf873a36..84b56601a 100644 --- a/src/components/Filter/__snapshots__/Filter.test.tsx.snap +++ b/packages/ui-react/src/components/Filter/__snapshots__/Filter.test.tsx.snap @@ -8,32 +8,40 @@ exports[` > renders Filter 1`] = ` role="listbox" >
+ ); +}; + +export default FinalizePayment; diff --git a/packages/ui-react/src/components/Footer/Footer.module.scss b/packages/ui-react/src/components/Footer/Footer.module.scss new file mode 100644 index 000000000..186f2a31f --- /dev/null +++ b/packages/ui-react/src/components/Footer/Footer.module.scss @@ -0,0 +1,36 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; + +.footer { + padding: 20px 40px; + line-height: variables.$base-line-height; + letter-spacing: 0.15px; + text-align: center; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 3px 4px rgba(0, 0, 0, 0.12), 0 1px 5px rgba(0, 0, 0, 0.2); + + a, + a:visited, + a:active, + a:hover { + color: variables.$white; + text-decoration: underline; + } +} + +.list { + margin: 0; + padding: 0; + list-style: none; + + li { + display: inline-block; + padding: 0 3px; + + &:not(:last-child)::after { + content: ' | '; + } + } +} + +.testFixMargin { + margin-bottom: 50px; +} diff --git a/packages/ui-react/src/components/Footer/Footer.test.tsx b/packages/ui-react/src/components/Footer/Footer.test.tsx new file mode 100644 index 000000000..29e02b613 --- /dev/null +++ b/packages/ui-react/src/components/Footer/Footer.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import Footer from './Footer'; + +describe('