diff --git a/.github/workflows/release-build-tag-release.yml b/.github/workflows/release-build-tag-release.yml index 96488eeb6..ebcc74626 100644 --- a/.github/workflows/release-build-tag-release.yml +++ b/.github/workflows/release-build-tag-release.yml @@ -56,4 +56,4 @@ jobs: artifacts: 'platforms/web/build/ott-web-app-build-*.tar.gz, platforms/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 }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-create-release-candidate-branch.yml b/.github/workflows/release-create-release-candidate-branch.yml index 153323254..c59aaf2b1 100644 --- a/.github/workflows/release-create-release-candidate-branch.yml +++ b/.github/workflows/release-create-release-candidate-branch.yml @@ -6,6 +6,12 @@ on: permissions: contents: write + pull-requests: write + +env: + GH_USER: github-actions[bot] + GH_EMAIL: github-actions[bot]@users.noreply.github.com + jobs: create-release-branch: runs-on: ubuntu-latest @@ -13,25 +19,25 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - token: ${{ secrets.ACTION_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} ref: release # It would be easier to create the branch from develop, # but unfortunately the script that creates the PR's only considers commits added during the action run - name: Merge latest from target branch run: | - git config --global user.name 'Release Script' - git config --global user.email 'ott-release-script@jwplayer.com' + git config user.name "${{ env.GH_USER }}" + git config user.email "${{ env.GH_EMAIL }}" git fetch origin ${{ github.ref_name }} git merge origin/${{ github.ref_name }} yarn && yarn workspace @jwp/ott-web run i18next env: - GITHUB_TOKEN: ${{ secrets.ACTION_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate changelog id: changelog uses: TriPSs/conventional-changelog-action@v5.1.0 with: - github-token: ${{ secrets.ACTION_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} release-count: 0 skip-tag: true git-push: false @@ -42,7 +48,7 @@ jobs: - name: Create Pull Request uses: peter-evans/create-pull-request@v6 with: - token: ${{ secrets.ACTION_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} title: Release Candidate - ${{ steps.changelog.outputs.tag }}${{ github.ref_name != 'develop' && ' (Hotfix)' || ''}} base: release branch: ${{ github.ref_name == 'develop' && 'release-candidate' || 'hotfix-release-candidate' }} diff --git a/.github/workflows/release-merge-back.yml b/.github/workflows/release-merge-back.yml index b601b5dc7..87b5f90d2 100644 --- a/.github/workflows/release-merge-back.yml +++ b/.github/workflows/release-merge-back.yml @@ -22,4 +22,4 @@ jobs: from_branch: release target_branch: develop message: 'chore: merge release to develop' - github_token: ${{ secrets.ACTION_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 323efec74..e2dbb8c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## [6.10.0](https://github.com/jwplayer/ott-web-app/compare/v6.9.0...v6.10.0) (2025-04-09) + + +### Features + +* add external payment methods and mapping ([86dce68](https://github.com/jwplayer/ott-web-app/commit/86dce687952cb597060a6fb2809d4bbbc2073626)) +* card subtitle support ([5b83938](https://github.com/jwplayer/ott-web-app/commit/5b8393820120f047605e34a88e4ab7325bc7a862)) +* cookie banner and defer gtm load ([afa916d](https://github.com/jwplayer/ott-web-app/commit/afa916d5196e3dae91b2f9e33ed6d3a62b21212b)) +* **entitlement:** add access type to generic entitlement payload ([4e7ced7](https://github.com/jwplayer/ott-web-app/commit/4e7ced71b8ba8aebd2a427dd00f0a940b2f32d07)) +* **entitlement:** remove lock icon from cards to hub screens ([1e3c3dc](https://github.com/jwplayer/ott-web-app/commit/1e3c3dc181f849f08a72005acca577b0e2ae98e0)) +* **home:** hero shelf styling improvements ([8bc0835](https://github.com/jwplayer/ott-web-app/commit/8bc0835191ddd9689baabfb644186208cf00de05)) +* **i18n:** add nl/fr/de languages ([788497d](https://github.com/jwplayer/ott-web-app/commit/788497d54f21a8f6a039bca0f9a154dfc5d13164)) +* movie screen by composition ([bba7337](https://github.com/jwplayer/ott-web-app/commit/bba73378fc64ea364b4c00143f1d14d16830b526)) +* **player:** add support for custom chromecast receiver ([8cff147](https://github.com/jwplayer/ott-web-app/commit/8cff14793c83c09f17e85bd51a8c341ba41e9ced)) +* show hub items in grid layout when single shelf ([fc9c7a3](https://github.com/jwplayer/ott-web-app/commit/fc9c7a3c4a4e917d673f9ccd4eb41b0a31f40497)) + + +### Bug Fixes + +* allow playing signed trailer items ([9c1c967](https://github.com/jwplayer/ott-web-app/commit/9c1c9673265dda6bb2b75256a6c57b5536ae9af6)) +* catch error when retrieving the offers fail ([28b300a](https://github.com/jwplayer/ott-web-app/commit/28b300a3fbd46ab8420bf18dfaa8fa65a9296298)) +* **entitlement:** refresh token before calling entitlement ([f4bc386](https://github.com/jwplayer/ott-web-app/commit/f4bc386cd07d0316360293c2e1c62675f2e70938)) +* hero shelf clipping on safari browsers ([c5ea54f](https://github.com/jwplayer/ott-web-app/commit/c5ea54f7436ccc2e44cf03a5a27f113d8570f0e2)) +* **home:** decrease shelf title font size on mobile ([741b9d5](https://github.com/jwplayer/ott-web-app/commit/741b9d5e61638600256547acb85dfcf65aacbdec)) +* **i18n:** sync translations ([#675](https://github.com/jwplayer/ott-web-app/issues/675)) ([189a27f](https://github.com/jwplayer/ott-web-app/commit/189a27fcd63d5b51276d6668ad65c1d83e704e1f)) +* **i18n:** update i18n keys ([#683](https://github.com/jwplayer/ott-web-app/issues/683)) ([673b199](https://github.com/jwplayer/ott-web-app/commit/673b199f9febd02b5f580c459de3a4a5696781ec)) +* infinite scrolling ([b26d2f1](https://github.com/jwplayer/ott-web-app/commit/b26d2f1aa681c1665b51ebb175598d088282ba64)) +* prevent google fonts preload when using system font ([4a70728](https://github.com/jwplayer/ott-web-app/commit/4a707287733678d9a729b9725106a4c04b8dd18a)) +* support newline break in markdown content ([77b66b7](https://github.com/jwplayer/ott-web-app/commit/77b66b74380115d76922230781d4191d9fb7e369)) +* video details buttons wrapping ([82f2143](https://github.com/jwplayer/ott-web-app/commit/82f2143fce74353645ed069cc6a0be6a8f535792)) + ## [6.9.0](https://github.com/jwplayer/ott-web-app/compare/v6.8.0...v6.9.0) (2024-12-17) diff --git a/configs/eslint-config-jwp/package.json b/configs/eslint-config-jwp/package.json index fcd944b25..b6dd6ef35 100644 --- a/configs/eslint-config-jwp/package.json +++ b/configs/eslint-config-jwp/package.json @@ -7,11 +7,11 @@ "test": "exit 0" }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^8.17.0", - "@typescript-eslint/parser": "^8.17.0", + "@typescript-eslint/eslint-plugin": "^8.26.1", + "@typescript-eslint/parser": "^8.26.1", "confusing-browser-globals": "^1.0.11", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^4.6.2" } } diff --git a/configs/postcss-config-jwp/package.json b/configs/postcss-config-jwp/package.json index af44fa259..6ef9a2d69 100644 --- a/configs/postcss-config-jwp/package.json +++ b/configs/postcss-config-jwp/package.json @@ -8,7 +8,7 @@ "test": "exit 0" }, "devDependencies": { - "postcss": "^8.4.49", + "postcss": "^8.5.3", "postcss-import": "^14.1.0", "postcss-scss": "^4.0.9", "stylelint": "^15.11.0" diff --git a/configs/stylelint-config-jwp/package.json b/configs/stylelint-config-jwp/package.json index 60dd5c856..3e189d1d1 100644 --- a/configs/stylelint-config-jwp/package.json +++ b/configs/stylelint-config-jwp/package.json @@ -10,7 +10,7 @@ "devDependencies": { "stylelint": "^15.11.0", "stylelint-config-recommended-scss": "^13.1.0", - "stylelint-declaration-strict-value": "^1.10.6", + "stylelint-declaration-strict-value": "^1.10.11", "stylelint-order": "^6.0.4", "stylelint-scss": "^5.3.2" } diff --git a/docs/configuration.md b/docs/configuration.md index 0413dce0d..827a72dcf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -104,8 +104,8 @@ each shelf separately. **content[].contentId** -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**. +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\*\*. --- @@ -145,6 +145,16 @@ You can change the background color of the shelf with the help of this property --- +**content[].filterTags** (optional) + +You can optionally define a list of comma separated labels which are used to filter shelves. + +The OTT Web App offers a demonstration of its filtering capabilities. This example can be enabled by configuring the `enableLabelsFiltering` custom parameter. + +Filtering by device type, country, and day of week is supported. See the `Home.tsx` file for a code example. + +--- + **styling** Use the `styling` object to define extra styles for your application. @@ -305,11 +315,11 @@ Note, this setting is ignored if Cleeng is not enabled (i.e. there is not Cleeng **integrations.cleeng.monthlyOffer** (optional) 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. +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. +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. --- diff --git a/knip.config.ts b/knip.config.ts index 21ae954d8..b30932a1c 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -21,6 +21,7 @@ const config: KnipConfig = { 'packages/ui-react': { entry: ['src/**/*'], ignoreDependencies: [ + '@types/dompurify', // Somehow this is not recognised 'sass-embedded', // Used in Vite 'postcss-config-jwp', // Used in postcss.config ], @@ -29,16 +30,14 @@ const config: KnipConfig = { 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 '@jwp/ott-testing', // Used in e2e testing '@types/luxon', // Used in tests - 'babel-plugin-transform-typescript-metadata', // Used to build with decorators for ioc resolution 'core-js', // Conditionally imported at build time 'eslint-plugin-codeceptjs', // Used by apps/web/test-e2e/.eslintrc.cjs 'i18next-parser', 'luxon', // Used in tests 'playwright', // Used in test configs + 'axe-playwright', // Used in test configs 'tsconfig-paths', // Used for e2e test setup ], }, diff --git a/package.json b/package.json index b6b48d233..a28c516d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jwp/ott", - "version": "6.9.0", + "version": "6.10.0", "private": true, "license": "Apache-2.0", "repository": "https://github.com/jwplayer/ott-web-app.git", @@ -32,37 +32,33 @@ "pre-commit": "yarn depcheck && lint-staged", "prepare": "husky install", "test": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run", + "test-update": "TZ=UTC LC_ALL=en_US.UTF-8 vitest run -u", "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest", "web": "yarn --cwd platforms/web", "access-bridge": "yarn --cwd platforms/access-bridge" }, "devDependencies": { - "@commitlint/cli": "^19.6.0", - "@commitlint/config-conventional": "^19.6.0", - "@types/node": "^22.10.1", + "@babel/core": "^7.26.10", + "@babel/plugin-proposal-decorators": "^7.25.9", + "@commitlint/cli": "^19.8.0", + "@commitlint/config-conventional": "^19.8.0", + "@types/node": "^22.13.10", "csv-parse": "^5.6.0", "eslint": "^8.57.1", "eslint-config-jwp": "*", "husky": "^6.0.0", - "i18next-parser-workspaces": "^0.2.0", - "knip": "^5.39.1", - "lint-staged": "^15.2.10", + "i18next-parser-workspaces": "^0.2.1", + "knip": "^5.61.2", + "lint-staged": "^15.4.3", "npm-run-all": "^4.1.5", "prettier": "^2.8.8", "read": "^2.1.0", "ts-node": "^10.9.2", - "typescript": "5.7.2", - "vitest": "^2.1.8" + "typescript": "5.8.3", + "vitest": "^3.0.8" }, "resolutions": { - "axios": "^0.29.0", - "cheerio": "1.0.0-rc.12", - "codeceptjs/**/fast-xml-parser": "^4.5.0", - "eslint/**/cross-spawn": "^7.0.6", - "lint-staged/**/cross-spawn": "^7.0.6", - "npm-run-all/**/cross-spawn": "^6.0.6", - "micromatch": ">=4.0.8", - "vitest/**/vite": "^5.4.11", - "ws": ">=5.2.4" + "axios": "^0.30.0", + "**/minimatch/brace-expansion": "^1.1.12" } } diff --git a/packages/common/package.json b/packages/common/package.json index bf8ebec41..2bd33b486 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -11,15 +11,15 @@ "dependencies": { "@inplayer-org/inplayer.js": "^3.13.28", "date-fns": "^4.1.0", - "fast-xml-parser": "^4.5.0", - "i18next": "^24.0.5", + "fast-xml-parser": "^4.5.3", + "i18next": "^24.2.2", "ini": "^3.0.1", "inversify": "^6.1.5", "jwt-decode": "^4.0.0", "lodash.merge": "^4.6.2", - "react-i18next": "^15.1.3", + "react-i18next": "^15.4.1", "reflect-metadata": "^0.2.2", - "yup": "^1.4.0", + "yup": "^1.6.1", "zustand": "^4.5.5" }, "devDependencies": { @@ -27,9 +27,9 @@ "@types/ini": "^1.3.34", "@types/lodash.merge": "^4.6.9", "eslint-config-jwp": "*", - "jsdom": "^22.1.0", + "jsdom": "^26.0.0", "timezone-mock": "^1.3.6", "vi-fetch": "^0.8.0", - "vitest": "^2.1.8" + "vitest": "^3.0.8" } } diff --git a/packages/common/src/controllers/CheckoutController.ts b/packages/common/src/controllers/CheckoutController.ts index c1a5b3d7d..c75540db4 100644 --- a/packages/common/src/controllers/CheckoutController.ts +++ b/packages/common/src/controllers/CheckoutController.ts @@ -224,6 +224,8 @@ export default class CheckoutController { return response.responseData; }; + finalizeStripePpvPayment = (paymentIntent: string) => this.checkoutService?.finalizeStripePpvPayment?.({ paymentIntent }); + paypalPayment = async ({ successUrl, waitingUrl, diff --git a/packages/common/src/services/ApiService.ts b/packages/common/src/services/ApiService.ts index 0f36588c1..368c4e9d6 100644 --- a/packages/common/src/services/ApiService.ts +++ b/packages/common/src/services/ApiService.ts @@ -69,9 +69,9 @@ export default class ApiService { * Transform incoming content lists */ protected transformContentList = (contentList: ContentList, language: string): Playlist => { - const { list, ...rest } = contentList; + const { list, id, ...rest } = contentList; - const playlist: Playlist = { ...rest, playlist: [] }; + const playlist: Playlist = { ...rest, feedid: id, playlist: [] }; playlist.playlist = list.map((item) => { const { custom_params, media_id, description, tags, ...rest } = item; diff --git a/packages/common/src/services/integrations/CheckoutService.ts b/packages/common/src/services/integrations/CheckoutService.ts index 483422073..ecce38568 100644 --- a/packages/common/src/services/integrations/CheckoutService.ts +++ b/packages/common/src/services/integrations/CheckoutService.ts @@ -7,6 +7,7 @@ import type { GetDirectPostCardPayment, GetEntitlements, GetFinalizeAdyenPayment, + GetFinalizeStripePpvPayment, GetInitialAdyenPayment, GetOffer, GetOffers, @@ -54,6 +55,8 @@ export default abstract class CheckoutService { abstract finalizeAdyenPayment?: GetFinalizeAdyenPayment; + abstract finalizeStripePpvPayment?: GetFinalizeStripePpvPayment; + abstract updatePaymentMethodWithPayPal?: UpdatePaymentWithPayPal; abstract deletePaymentMethod?: DeletePaymentMethod; diff --git a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts index ae4338076..3d8b0046b 100644 --- a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts @@ -162,6 +162,8 @@ export default class CleengCheckoutService extends CheckoutService { finalizeAdyenPayment: GetFinalizeAdyenPayment = async (payload) => this.cleengService.post('/connectors/adyen/initial-payment/finalize', JSON.stringify(payload), { authenticate: true }); + finalizeStripePpvPayment: undefined; + updatePaymentMethodWithPayPal: UpdatePaymentWithPayPal = async (payload) => { return this.cleengService.post('/connectors/paypal/v1/payment_details/tokens', JSON.stringify(payload), { authenticate: true }); }; diff --git a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts index 18a5b10a3..2445eaf06 100644 --- a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts +++ b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts @@ -99,9 +99,12 @@ export default class JWPCheckoutService extends CheckoutService { 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, + period: + offer.access_type.period === 'month' && offer.access_type.quantity === 12 ? 'year' : (offer.access_type.period as 'day' | 'week' | 'month' | 'year'), planSwitchEnabled: offer.item.plan_switch_enabled ?? false, + + // trial period is always assumed to be in days + freeDays: offer.trial_period?.quantity || 0, } as Offer; }; @@ -303,6 +306,14 @@ export default class JWPCheckoutService extends CheckoutService { } }; + finalizeStripePpvPayment = async ({ paymentIntent }: { paymentIntent: string }) => { + try { + await this.apiService.post('/payments', { pi_id: paymentIntent }, { withAuthentication: true }); + } catch { + throw new Error('Failed to confirm payment'); + } + }; + getSubscriptionSwitches = undefined; getOrder = undefined; diff --git a/packages/common/src/utils/configSchema.ts b/packages/common/src/utils/configSchema.ts index a24bf5732..c06ad6e79 100644 --- a/packages/common/src/utils/configSchema.ts +++ b/packages/common/src/utils/configSchema.ts @@ -9,6 +9,7 @@ const contentSchema: ObjectSchema = object({ backgroundColor: string().nullable().optional(), type: string().oneOf(['playlist', 'continue_watching', 'favorites', 'content_list']).required(), custom: object().optional(), + filterTags: string().optional(), }).defined(); const menuSchema: ObjectSchema = object().shape({ diff --git a/packages/common/src/utils/datetime.ts b/packages/common/src/utils/datetime.ts index cb8501a47..83d1c2cd9 100644 --- a/packages/common/src/utils/datetime.ts +++ b/packages/common/src/utils/datetime.ts @@ -28,3 +28,19 @@ export const is12HourClock = () => { const date = new Date(Date.UTC(2022, 6, 20, 18, 0, 0)); return /am|pm/.test(date.toLocaleTimeString()); }; + +export const formatItemDate = (date?: string | null, locale?: string) => { + if (!date) return null; + + if (date.length <= 4) return date; + + try { + const [day, month, year] = date.split('-'); + const dateObj = new Date(`${year}-${month}-${day}`); + if (dateObj.toString() === 'Invalid Date') return date; // fallback to original string + + return dateObj.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }); + } catch (error: unknown) { + return date; // fallback to original string + } +}; diff --git a/packages/common/src/utils/metadata.ts b/packages/common/src/utils/metadata.ts index 6b5bfb172..807e5bcd6 100644 --- a/packages/common/src/utils/metadata.ts +++ b/packages/common/src/utils/metadata.ts @@ -36,3 +36,7 @@ export const createLiveEventMetadata = (media: PlaylistItem, locale: string, i18 return metaData; }; + +export const countListValues = (value: string) => (!value ? 0 : value.split(',').length); + +export const hasFormatMetadata = (media: PlaylistItem) => ['subtitleFormat', 'videoFormat', 'audioFormat'].some((property) => media[property]); diff --git a/packages/common/types/checkout.ts b/packages/common/types/checkout.ts index c286f4ec6..3df79281d 100644 --- a/packages/common/types/checkout.ts +++ b/packages/common/types/checkout.ts @@ -94,10 +94,12 @@ export type Order = { requiredPaymentDetails: boolean; }; +export type PaymentProvider = 'stripe' | 'adyen'; + export type PaymentMethod = { id: number; methodName: 'card' | 'paypal'; - provider?: 'stripe' | 'adyen'; + provider?: PaymentProvider; paymentGateway?: 'adyen' | 'paypal'; // @todo: merge with provider logoUrl: string; }; @@ -383,6 +385,7 @@ export type GetEntitlements = EnvironmentServiceRequest; export type GetInitialAdyenPayment = EnvironmentServiceRequest; export type GetFinalizeAdyenPayment = EnvironmentServiceRequest; +export type GetFinalizeStripePpvPayment = PromiseRequest<{ paymentIntent: string }, void>; export type UpdatePaymentWithPayPal = EnvironmentServiceRequest; export type DeletePaymentMethod = EnvironmentServiceRequest; export type AddAdyenPaymentDetails = EnvironmentServiceRequest; diff --git a/packages/common/types/config.ts b/packages/common/types/config.ts index a09ba23bb..c608ba23f 100644 --- a/packages/common/types/config.ts +++ b/packages/common/types/config.ts @@ -51,6 +51,7 @@ export type Content = { featured?: boolean; backgroundColor?: string | null; custom?: Record; + filterTags?: string; }; export type Menu = { diff --git a/packages/hooks-react/package.json b/packages/hooks-react/package.json index 6cfa3651d..7df9eced1 100644 --- a/packages/hooks-react/package.json +++ b/packages/hooks-react/package.json @@ -11,22 +11,22 @@ "dependencies": { "@jwp/ott-common": "*", "date-fns": "^4.1.0", - "i18next": "^24.0.5", + "i18next": "^24.2.2", "planby": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-i18next": "^15.1.3", + "react-i18next": "^15.4.1", "react-query": "^3.39.3", "reflect-metadata": "^0.2.2", - "yup": "^1.4.0" + "yup": "^1.6.1" }, "devDependencies": { "@jwp/ott-testing": "*", "@testing-library/react": "^14.3.1", "@types/jwplayer": "^8.31.3", "eslint-config-jwp": "*", - "jsdom": "^22.1.0", + "jsdom": "^26.0.0", "vi-fetch": "^0.8.0", - "vitest": "^2.1.8" + "vitest": "^3.0.8" } } diff --git a/packages/hooks-react/src/useMovieData.ts b/packages/hooks-react/src/useMovieData.ts new file mode 100644 index 000000000..94dda92c1 --- /dev/null +++ b/packages/hooks-react/src/useMovieData.ts @@ -0,0 +1,65 @@ +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createVideoMetadata } from '@jwp/ott-common/src/utils/metadata'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { mediaURL } from '@jwp/ott-common/src/utils/urlFormatting'; +import useMedia from '@jwp/ott-hooks-react/src/useMedia'; +import usePlaylist from '@jwp/ott-hooks-react/src/usePlaylist'; +import useEntitlement from '@jwp/ott-hooks-react/src/useEntitlement'; + +export default function useMovieData(data: PlaylistItem, id: string, feedId: string | null) { + const { t } = useTranslation('common'); + const [playTrailer, setPlayTrailer] = useState(false); + + // Config + const { config, accessModel } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow); + const { features } = config; + const isFavoritesEnabled: boolean = Boolean(features?.favoritesList); + + // Media + const { isLoading: isTrailerLoading, data: trailerItem } = useMedia(data?.trailerId || ''); + const { isLoading: isPlaylistLoading, data: playlist } = usePlaylist(features?.recommendationsPlaylist || '', { related_media_id: id }); + const nextItem = useMemo(() => { + if (!id || !playlist) return; + + const index = playlist.playlist.findIndex(({ mediaid }) => mediaid === id); + const nextItem = playlist.playlist[index + 1]; + + return nextItem; + }, [id, playlist]); + + // User, entitlement + const { isEntitled, mediaOffers } = useEntitlement(data); + + // UI + const movieURL = mediaURL({ id: data.mediaid, title: data.title, playlistId: feedId, play: false }); + const playUrl = mediaURL({ id: data.mediaid, title: data.title, playlistId: feedId, play: true }); + const hasTrailer = !!trailerItem || isTrailerLoading; + const primaryMetadata = [ + ...createVideoMetadata(data, { + hoursAbbreviation: t('common:abbreviation.hours'), + minutesAbbreviation: t('common:abbreviation.minutes'), + }), + ...(data?.contentType ? [data.contentType] : []), + ]; + + return { + playlist, + isTrailerLoading, + isPlaylistLoading, + mediaOffers, + movieURL, + playUrl, + nextItem, + trailerItem, + accessModel, + hasTrailer, + isFavoritesEnabled, + isEntitled, + playTrailer, + primaryMetadata, + setPlayTrailer, + }; +} diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index e80798423..b32ff4a3f 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -13,13 +13,13 @@ "@jwp/ott-common": "*", "@jwp/ott-hooks-react": "*", "@jwp/ott-theme": "*", - "@videodock/tile-slider": "^2.2.0", + "@videodock/tile-slider": "^2.3.0", "classnames": "^2.5.1", "date-fns": "^4.1.0", - "dompurify": "^3.2.2", - "i18next": "^24.0.5", + "dompurify": "^3.2.4", + "i18next": "^24.2.2", "inversify": "^6.1.5", - "marked": "^15.0.3", + "marked": "^15.0.7", "payment": "^2.4.7", "planby": "^0.3.0", "react": "^18.3.1", @@ -27,13 +27,13 @@ "react-dom": "^18.3.1", "react-google-recaptcha": "^3.1.0", "react-helmet": "^6.1.0", - "react-i18next": "^15.1.3", + "react-i18next": "^15.4.1", "react-infinite-scroller": "^1.2.6", "react-query": "^3.39.3", "react-router": "6.14.2", "react-router-dom": "6.14.2", "reflect-metadata": "^0.2.2", - "yup": "^1.4.0" + "yup": "^1.6.1" }, "devDependencies": { "@jwp/ott-testing": "*", @@ -46,13 +46,13 @@ "@types/react-infinite-scroller": "^1.2.5", "@vitejs/plugin-react": "^4.3.4", "eslint-config-jwp": "*", - "jsdom": "^22.1.0", + "jsdom": "^26.0.0", "postcss-config-jwp": "*", - "sass-embedded": "^1.81.1", + "sass-embedded": "^1.85.1", "typescript-plugin-css-modules": "^5.1.0", "vi-fetch": "^0.8.0", "vite-plugin-svgr": "^4.3.0", - "vitest": "^2.1.8", + "vitest": "^3.0.8", "vitest-axe": "^1.0.0-pre.3", "wicg-inert": "^3.1.3" } diff --git a/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap b/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap index 849295f57..b21cf1f66 100644 --- a/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap +++ b/packages/ui-react/src/components/Button/__snapshots__/Button.test.tsx.snap @@ -3,7 +3,7 @@ exports[` reset.not_sure reset.try_again diff --git a/packages/ui-react/src/components/CustomRegisterField/__snapshots__/CustomRegisterField.test.tsx.snap b/packages/ui-react/src/components/CustomRegisterField/__snapshots__/CustomRegisterField.test.tsx.snap index 4e8facf32..e34cb3d16 100644 --- a/packages/ui-react/src/components/CustomRegisterField/__snapshots__/CustomRegisterField.test.tsx.snap +++ b/packages/ui-react/src/components/CustomRegisterField/__snapshots__/CustomRegisterField.test.tsx.snap @@ -3,10 +3,10 @@ exports[` > renders and matches snapshot 1`] = `
> renders and matches snapshot 1`] = ` exports[` > renders and matches snapshot 1`] = `
> renders and matches snapshot > renders and matches snapshot > renders and matches snapshot 1`] = `
> renders and matches snapshot 1`] = `
> renders and matches snapshot 1`] = `
Dialog contents
reset.password_helper_text
> renders and matches snapshot 1`] = ` value="" />
> renders Form 1`] = ` />
a
login.forgot_password

login.not_registered login.sign_up diff --git a/packages/ui-react/src/components/Logo/__snapshots__/Logo.test.tsx.snap b/packages/ui-react/src/components/Logo/__snapshots__/Logo.test.tsx.snap index 795953c0d..8bd7694ba 100644 --- a/packages/ui-react/src/components/Logo/__snapshots__/Logo.test.tsx.snap +++ b/packages/ui-react/src/components/Logo/__snapshots__/Logo.test.tsx.snap @@ -7,7 +7,7 @@ exports[` > renders and matches snapshot 1`] = ` > logo diff --git a/packages/ui-react/src/components/MarkdownComponent/__snapshots__/MarkdownComponent.test.tsx.snap b/packages/ui-react/src/components/MarkdownComponent/__snapshots__/MarkdownComponent.test.tsx.snap index 11a4df90a..729e3ba22 100644 --- a/packages/ui-react/src/components/MarkdownComponent/__snapshots__/MarkdownComponent.test.tsx.snap +++ b/packages/ui-react/src/components/MarkdownComponent/__snapshots__/MarkdownComponent.test.tsx.snap @@ -3,7 +3,7 @@ exports[` > renders and matches snapshot 1`] = `

This is a test markdown string with a diff --git a/packages/ui-react/src/components/MediaHead/MediaHead.tsx b/packages/ui-react/src/components/MediaHead/MediaHead.tsx new file mode 100644 index 000000000..45ce00da7 --- /dev/null +++ b/packages/ui-react/src/components/MediaHead/MediaHead.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { shallow } from '@jwp/ott-common/src/utils/compare'; +import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { generateMovieJSONLD } from '@jwp/ott-common/src/utils/structuredData'; +import env from '@jwp/ott-common/src/env'; + +const MediaHead = ({ data, canonicalUrl }: { data: PlaylistItem; canonicalUrl: string }) => { + const { config } = useConfigStore(({ config, accessModel }) => ({ config, accessModel }), shallow); + const { siteName } = config; + + const pageTitle = `${data.title} - ${siteName}`; + + return ( + + {pageTitle} + + + + + + {data.image && } + {data.image && } + + + + + + + + + + + {data.tags?.split(',').map((tag) => ( + + ))} + {data ? : null} + + ); +}; + +export default MediaHead; diff --git a/packages/ui-react/src/components/MediaHero/MediaHero.module.scss b/packages/ui-react/src/components/MediaHero/MediaHero.module.scss new file mode 100644 index 000000000..b5243cf1e --- /dev/null +++ b/packages/ui-react/src/components/MediaHero/MediaHero.module.scss @@ -0,0 +1,18 @@ +.infoSmallScreenReorder { + // Re-ordering the elements on small screens + display: flex; + flex-direction: column; + + [class^='_title'] { + order: -1; + margin-bottom: 24px; + } + + [class^='_buttonBar'] { + order: -1; + } + + [class^='_metaContainer'] { + order: 99; + } +} diff --git a/packages/ui-react/src/components/MediaHero/MediaHero.tsx b/packages/ui-react/src/components/MediaHero/MediaHero.tsx new file mode 100644 index 000000000..afa36cf71 --- /dev/null +++ b/packages/ui-react/src/components/MediaHero/MediaHero.tsx @@ -0,0 +1,19 @@ +import { type PropsWithChildren } from 'react'; +import Hero from '@jwp/ott-ui-react/src/components/Hero/Hero'; +import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; + +import styles from './MediaHero.module.scss'; + +const MediaHero = ({ image, children }: PropsWithChildren<{ image?: string }>) => { + const breakpoint = useBreakpoint(); + + return ( +

+ + {children} + +
+ ); +}; + +export default MediaHero; diff --git a/packages/ui-react/src/components/MediaHeroButtons/MediaHeroButtons.module.scss b/packages/ui-react/src/components/MediaHeroButtons/MediaHeroButtons.module.scss new file mode 100644 index 000000000..16214d194 --- /dev/null +++ b/packages/ui-react/src/components/MediaHeroButtons/MediaHeroButtons.module.scss @@ -0,0 +1,62 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; + +.buttonBar { + display: flex; + align-items: center; + margin-top: 24px; + gap: calc(variables.$base-spacing / 2); + + > button { + flex-shrink: 0; + } + + @include responsive.mobile-and-small-tablet() { + flex-wrap: wrap; + > button { + flex: 1; + justify-content: center; + + &:first-child { + flex-basis: 100%; + margin-bottom: 8px; + } + } + } +} + +.roundButton { + justify-content: center; + width: 38px; + height: 38px; + border-radius: 50%; + + > [class^='_startIcon'] { + margin-right: 0; + } + + > span { + display: none; + } +} + +.rectangleButton { + flex-direction: column; + height: auto; + padding: 16px; + background-color: transparent !important; + border: none !important; + + > [class^='_startIcon'] { + margin-right: 0; + margin-bottom: 8px; + } + + > span { + font-weight: var(--body-font-weight-bold); + } + + &[class*='primary'] { + color: inherit; // Toggle state for Favorite button + } +} diff --git a/packages/ui-react/src/components/MediaHeroButtons/MediaHeroButtons.tsx b/packages/ui-react/src/components/MediaHeroButtons/MediaHeroButtons.tsx new file mode 100644 index 000000000..925fbd45e --- /dev/null +++ b/packages/ui-react/src/components/MediaHeroButtons/MediaHeroButtons.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; + +import styles from './MediaHeroButtons.module.scss'; + +const MediaHeroButtons = ({ + cta, + children, + buttonClassOverride = true, +}: { + cta: React.ReactNode; + children: React.ReactNode; + buttonClassOverride?: boolean; +}) => { + const breakpoint = useBreakpoint(); + const buttonClassName = buttonClassOverride ? (breakpoint < Breakpoint.md ? styles.rectangleButton : styles.roundButton) : undefined; + + const validChildren = React.Children.toArray(children).filter(React.isValidElement); + + return ( +
+ {cta} + {validChildren.map((child) => React.cloneElement(child as React.ReactElement, { className: buttonClassName }))} +
+ ); +}; + +export default MediaHeroButtons; diff --git a/packages/ui-react/src/components/MediaHeroInfo/MediaHeroInfo.module.scss b/packages/ui-react/src/components/MediaHeroInfo/MediaHeroInfo.module.scss new file mode 100644 index 000000000..35f84acb6 --- /dev/null +++ b/packages/ui-react/src/components/MediaHeroInfo/MediaHeroInfo.module.scss @@ -0,0 +1,37 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/mixins/typography'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; + +.title { + @include typography.video-title-base() { + margin-bottom: calc(variables.$base-spacing / 2); + } +} + +.description { + line-height: variables.$base-line-height; +} + +.metaContainer { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 8px; + gap: 16px; + font-size: 1em; + line-height: variables.$base-line-height; + letter-spacing: 0.15px; + + > div:last-of-type { + // Enforce lengthy single-line metadata list in VideoMetaData on large screens + white-space: nowrap; + } + + @include responsive.mobile-only() { + font-size: 0.8em; + + > div:last-of-type { + white-space: initial; + } + } +} diff --git a/packages/ui-react/src/components/MediaHeroInfo/MediaHeroInfo.tsx b/packages/ui-react/src/components/MediaHeroInfo/MediaHeroInfo.tsx new file mode 100644 index 000000000..2692b0295 --- /dev/null +++ b/packages/ui-react/src/components/MediaHeroInfo/MediaHeroInfo.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import styles from './MediaHeroInfo.module.scss'; + +const MediaHeroInfo = ({ + title, + description, + metadataComponent, + formatComponent, + definitionComponent, +}: { + title: string; + description?: string | React.ReactElement | null; + metadataComponent: React.ReactElement | null; + formatComponent: React.ReactElement | null; + definitionComponent: React.ReactElement | null; +}) => { + return ( + <> +

{title}

+
+ {formatComponent} + {metadataComponent} +
+ {description && typeof description === 'string' ?

{description}

: description} + {definitionComponent} + + ); +}; + +export default MediaHeroInfo; diff --git a/packages/ui-react/src/components/MediaInlineLayout/MediaInlineLayout.module.scss b/packages/ui-react/src/components/MediaInlineLayout/MediaInlineLayout.module.scss new file mode 100644 index 000000000..ca13d64ce --- /dev/null +++ b/packages/ui-react/src/components/MediaInlineLayout/MediaInlineLayout.module.scss @@ -0,0 +1,62 @@ +@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'; + +.videoInlineLayout { + position: relative; + display: grid; + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: calc(variables.$base-spacing * 3); + grid-template-areas: + 'player related' + 'details related'; + grid-template-columns: 1fr 360px; + grid-template-rows: auto 1fr; + + @include responsive.mobile-and-tablet() { + grid-template-areas: + 'player' + 'details' + 'related'; + grid-template-columns: 1fr; + } + + @include responsive.tablet-only() { + padding: calc(variables.$base-spacing * 2); + } + + @include responsive.mobile-only() { + padding: variables.$base-spacing; + } +} + +.player { + position: relative; + grid-area: player; + aspect-ratio: 16 / 9; + box-shadow: 0 8px 10px rgb(0 0 0 / 14%), 0 3px 14px rgb(0 0 0 / 12%), 0 4px 5px rgb(0 0 0 / 20%); + + @supports not (aspect-ratio: 16 / 9) { + &::after { + display: block; + content: ''; + clear: both; + } + &::before { + float: left; + padding-top: 56.25%; + content: ''; + } + } + + @include responsive.mobile-only() { + margin: calc(variables.$base-spacing * -1) calc(variables.$base-spacing * -1) 0; + } +} + +.videoDetailsInline { + grid-area: details; + height: 100%; +} diff --git a/packages/ui-react/src/components/MediaInlineLayout/MediaInlineLayout.tsx b/packages/ui-react/src/components/MediaInlineLayout/MediaInlineLayout.tsx new file mode 100644 index 000000000..d756e4f12 --- /dev/null +++ b/packages/ui-react/src/components/MediaInlineLayout/MediaInlineLayout.tsx @@ -0,0 +1,16 @@ +import React, { type PropsWithChildren } from 'react'; +import { testId } from '@jwp/ott-common/src/utils/common'; + +import styles from './MediaInlineLayout.module.scss'; + +const MediaInlineLayout = ({ player, videoDetails, children }: PropsWithChildren<{ player: React.ReactNode; videoDetails: React.ReactNode }>) => { + return ( +
+
{player}
+
{videoDetails}
+ {children} +
+ ); +}; + +export default MediaInlineLayout; diff --git a/packages/ui-react/src/components/MenuButton/__snapshots__/MenuButton.test.tsx.snap b/packages/ui-react/src/components/MenuButton/__snapshots__/MenuButton.test.tsx.snap index 708c0ac7c..6435be357 100644 --- a/packages/ui-react/src/components/MenuButton/__snapshots__/MenuButton.test.tsx.snap +++ b/packages/ui-react/src/components/MenuButton/__snapshots__/MenuButton.test.tsx.snap @@ -3,7 +3,7 @@ exports[` > renders and matches snapshot 1`] = `
diff --git a/packages/ui-react/src/components/ModalCloseButton/__snapshots__/ModalCloseButton.test.tsx.snap b/packages/ui-react/src/components/ModalCloseButton/__snapshots__/ModalCloseButton.test.tsx.snap index 6211ad3ab..a648985f0 100644 --- a/packages/ui-react/src/components/ModalCloseButton/__snapshots__/ModalCloseButton.test.tsx.snap +++ b/packages/ui-react/src/components/ModalCloseButton/__snapshots__/ModalCloseButton.test.tsx.snap @@ -4,12 +4,12 @@ exports[` > renders and matches snapshot 1`] = `

May 5, 2021 @@ -127,20 +127,20 @@ exports[` > renders and matches snapshot 1`] = `

user:payment.price_paid_with

May 5, 2021 @@ -153,20 +153,20 @@ exports[` > renders and matches snapshot 1`] = `

user:payment.price_paid_with

May 5, 2021 @@ -179,10 +179,10 @@ exports[` > renders and matches snapshot 1`] = `

user:payment.price_paid_with
diff --git a/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap b/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap index 39895c02c..eb28562f9 100644 --- a/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap +++ b/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap @@ -4,18 +4,18 @@ exports[` > renders and matches snapshot 1`] = `

checkout.payment_error

Error message

> Renders with errors 1`] = `
No way Jose @@ -504,7 +510,7 @@ exports[` > Renders with errors 1`] = `
reset.password_helper_text @@ -105,7 +105,7 @@ exports[` > renders and matches snapshot 1`] = `

registration.already_account login.sign_in diff --git a/packages/ui-react/src/components/RenewSubscriptionForm/__snapshots__/RenewSubscriptionForm.test.tsx.snap b/packages/ui-react/src/components/RenewSubscriptionForm/__snapshots__/RenewSubscriptionForm.test.tsx.snap index 5941dbf5a..4e977af5f 100644 --- a/packages/ui-react/src/components/RenewSubscriptionForm/__snapshots__/RenewSubscriptionForm.test.tsx.snap +++ b/packages/ui-react/src/components/RenewSubscriptionForm/__snapshots__/RenewSubscriptionForm.test.tsx.snap @@ -4,7 +4,7 @@ exports[` > renders and matches snapshot 1`] = `

renew_subscription.renew_your_subscription

@@ -12,7 +12,7 @@ exports[` > renders and matches snapshot 1`] = ` renew_subscription.explanation

@@ -23,7 +23,7 @@ exports[` > renders and matches snapshot 1`] = ` renew_subscription.next_billing_date_on

€ 22,15 @@ -36,7 +36,7 @@ exports[` > renders and matches snapshot 1`] = `

@@ -207,15 +212,15 @@ exports[`Shelf Component tests > Featured shelf 1`] = ` exports[`Shelf Component tests > Regular shelf 1`] = `

Test Shelf

Regular shelf 1`] = ` style="width: 20%; padding-left: 8px; padding-right: 8px;" >

Movie 1

1 common:abbreviation.minutes
@@ -271,32 +276,32 @@ exports[`Shelf Component tests > Regular shelf 1`] = ` style="width: 20%; padding-left: 8px; padding-right: 8px;" >

Movie 2

1 common:abbreviation.minutes
@@ -311,32 +316,32 @@ exports[`Shelf Component tests > Regular shelf 1`] = ` style="width: 20%; padding-left: 8px; padding-right: 8px;" >

Third movie

1 common:abbreviation.minutes
@@ -351,32 +356,32 @@ exports[`Shelf Component tests > Regular shelf 1`] = ` style="width: 20%; padding-left: 8px; padding-right: 8px;" >

Last playlist item

21 common:abbreviation.minutes
diff --git a/packages/ui-react/src/components/Sidebar/__snapshots__/Sidebar.test.tsx.snap b/packages/ui-react/src/components/Sidebar/__snapshots__/Sidebar.test.tsx.snap index 147084db0..04834623b 100644 --- a/packages/ui-react/src/components/Sidebar/__snapshots__/Sidebar.test.tsx.snap +++ b/packages/ui-react/src/components/Sidebar/__snapshots__/Sidebar.test.tsx.snap @@ -24,20 +24,20 @@ exports[` > renders sideBar opened 1`] = ` style="transition: transform 0.3s ease, opacity 0.3s ease; transform: translate(0, 0); opacity: 1; z-index: 15;" >