diff --git a/.commitlintrc.js b/.commitlintrc.js index 6ac461ff6..d1c72c78d 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -31,6 +31,7 @@ module.exports = { 'tests', 'i18n', 'a11y', + 'integrations', ], ], }, diff --git a/CHANGELOG.md b/CHANGELOG.md index f12f444a8..0974d173c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## [6.5.0](https://github.com/jwplayer/ott-web-app/compare/v6.4.0...v6.5.0) (2024-07-25) + + +### Features + +* add backClick event support for player ([f29f6dc](https://github.com/jwplayer/ott-web-app/commit/f29f6dcd244f95568b5b2ba1437bc7e39dd0febc)) +* add ellipsis to card title ([ab6b2a8](https://github.com/jwplayer/ott-web-app/commit/ab6b2a83d947b163c5a10d8a1701823ee2fcd266)) +* implement tile-slider dependency ([d58f1cb](https://github.com/jwplayer/ott-web-app/commit/d58f1cb73696c8f2f64b4bb8d9fcdeec8ffc34b8)) +* **integrations:** replace InPlayer SDK with direct API calls to JwPlayer SIMS domain ([#578](https://github.com/jwplayer/ott-web-app/issues/578)) ([0a87a46](https://github.com/jwplayer/ott-web-app/commit/0a87a46af6f0ec4a0e77006ef1fba7b98bcc5cbd)) +* **menu:** support more items in header navigation ([15bbce0](https://github.com/jwplayer/ott-web-app/commit/15bbce0fcc1ffdaf8adb8539150701a43f3cb1d9)) +* **profiles:** remove all remaining dead code assotiated with profiles ([892f41b](https://github.com/jwplayer/ott-web-app/commit/892f41b5d73f7aaccd2ded141407f35209ed7926)) +* **project:** add cancel functions for debounce and throttle utils ([3fd9add](https://github.com/jwplayer/ott-web-app/commit/3fd9add7ec93563fc4ac778090c326263dfde244)) +* **project:** add ssai ads for vod ([#583](https://github.com/jwplayer/ott-web-app/issues/583)) ([d3a4750](https://github.com/jwplayer/ott-web-app/commit/d3a4750af29c2cc460e390120ca457620d43bfdb)) + + +### Bug Fixes + +* **videodetail:** buttons wrapping ([4b6f524](https://github.com/jwplayer/ott-web-app/commit/4b6f52412c9fff5aa9a1ac997f62b97b95ecf7bc)) +* wait for geo status and cache it's value ([6e4d263](https://github.com/jwplayer/ott-web-app/commit/6e4d2634d8a78e731ea39a9689720db8e8be38ef)) + ## [6.4.0](https://github.com/jwplayer/ott-web-app/compare/v6.3.0...v6.4.0) (2024-07-04) diff --git a/package.json b/package.json index 75ab10d24..eb489a3b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jwp/ott", - "version": "6.4.0", + "version": "6.5.0", "private": true, "license": "Apache-2.0", "repository": "https://github.com/jwplayer/ott-web-app.git", diff --git a/packages/common/package.json b/packages/common/package.json index 9487287f7..d93c32b25 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -9,7 +9,7 @@ "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" }, "dependencies": { - "@inplayer-org/inplayer.js": "^3.13.25", + "@inplayer-org/inplayer.js": "^3.13.28", "broadcast-channel": "^7.0.0", "date-fns": "^2.30.0", "fast-xml-parser": "^4.4.0", diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts index b56f802b0..3e6ec3c30 100644 --- a/packages/common/src/controllers/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -21,12 +21,10 @@ 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 { logError } from '../logger'; import WatchHistoryController from './WatchHistoryController'; -import ProfileController from './ProfileController'; import FavoritesController from './FavoritesController'; @injectable() @@ -34,7 +32,6 @@ 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; @@ -46,7 +43,6 @@ export default class AccountController { @inject(INTEGRATION_TYPE) integrationType: IntegrationType, favoritesController: FavoritesController, watchHistoryController: WatchHistoryController, - profileController: ProfileController, ) { this.checkoutService = getNamedModule(CheckoutService, integrationType); this.accountService = getNamedModule(AccountService, integrationType); @@ -55,7 +51,6 @@ export default class AccountController { // @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; } @@ -84,7 +79,6 @@ export default class AccountController { 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 @@ -553,13 +547,6 @@ export default class AccountController { 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/ProfileController.ts b/packages/common/src/controllers/ProfileController.ts deleted file mode 100644 index c73948433..000000000 --- a/packages/common/src/controllers/ProfileController.ts +++ /dev/null @@ -1,118 +0,0 @@ -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/modules/register.ts b/packages/common/src/modules/register.ts index e26533416..131d93bf8 100644 --- a/packages/common/src/modules/register.ts +++ b/packages/common/src/modules/register.ts @@ -16,7 +16,6 @@ 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'; @@ -30,7 +29,6 @@ import JWEpgService from '../services/epg/JWEpgService'; 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'; @@ -38,11 +36,11 @@ import CleengAccountService from '../services/integrations/cleeng/CleengAccountS import CleengCheckoutService from '../services/integrations/cleeng/CleengCheckoutService'; import CleengSubscriptionService from '../services/integrations/cleeng/CleengSubscriptionService'; -// InPlayer integration +// JWP integration +import JWPAPIService from '../services/integrations/jwp/JWPAPIService'; 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'; @@ -63,7 +61,6 @@ 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); @@ -81,8 +78,8 @@ container.bind(SubscriptionService).to(CleengSubscriptionService).whenTargetName // JWP integration container.bind(DETERMINE_INTEGRATION_TYPE).toConstantValue(isJwpIntegrationType); +container.bind(JWPAPIService).toSelf(); 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/paths.tsx b/packages/common/src/paths.tsx index 7baec0c6f..08bedab91 100644 --- a/packages/common/src/paths.tsx +++ b/packages/common/src/paths.tsx @@ -12,16 +12,9 @@ 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/JWPEntitlementService.ts b/packages/common/src/services/JWPEntitlementService.ts index 2b857eb39..34a126108 100644 --- a/packages/common/src/services/JWPEntitlementService.ts +++ b/packages/common/src/services/JWPEntitlementService.ts @@ -1,11 +1,29 @@ -import InPlayer from '@inplayer-org/inplayer.js'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; + +import type { SignedMediaResponse } from './integrations/jwp/types'; +import JWPAPIService from './integrations/jwp/JWPAPIService'; @injectable() export default class JWPEntitlementService { + private readonly apiService; + + constructor(@inject(JWPAPIService) apiService: JWPAPIService) { + this.apiService = apiService; + } + getJWPMediaToken = async (configId: string = '', mediaId: string) => { try { - const { data } = await InPlayer.Asset.getSignedMediaToken(configId, mediaId); + const data = await this.apiService.get( + 'v2/items/jw-media/token', + { + withAuthentication: true, + }, + { + app_config_id: configId, + media_id: mediaId, + }, + ); + return data.token; } catch { throw new Error('Unauthorized'); diff --git a/packages/common/src/services/StorageService.ts b/packages/common/src/services/StorageService.ts index 487513fdd..f52f0be26 100644 --- a/packages/common/src/services/StorageService.ts +++ b/packages/common/src/services/StorageService.ts @@ -1,7 +1,7 @@ export default abstract class StorageService { abstract initialize(prefix: string): void; - abstract getItem(key: string, parse: boolean): Promise; + abstract getItem(key: string, parse: boolean, usePrefix?: boolean): Promise; abstract setItem(key: string, value: string, usePrefix?: boolean): Promise; diff --git a/packages/common/src/services/integrations/ProfileService.ts b/packages/common/src/services/integrations/ProfileService.ts deleted file mode 100644 index dac065623..000000000 --- a/packages/common/src/services/integrations/ProfileService.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/jwp/JWPAPIService.ts b/packages/common/src/services/integrations/jwp/JWPAPIService.ts new file mode 100644 index 000000000..5c4c65066 --- /dev/null +++ b/packages/common/src/services/integrations/jwp/JWPAPIService.ts @@ -0,0 +1,142 @@ +import { inject, injectable } from 'inversify'; + +import StorageService from '../../StorageService'; + +import type { JWPError } from './types'; + +const INPLAYER_TOKEN_KEY = 'inplayer_token'; +const INPLAYER_IOT_KEY = 'inplayer_iot'; + +const CONTENT_TYPES = { + json: 'application/json', + form: 'application/x-www-form-urlencoded', +}; + +type RequestOptions = { + withAuthentication?: boolean; + keepalive?: boolean; + contentType?: keyof typeof CONTENT_TYPES; + responseType?: 'json' | 'blob'; + includeFullResponse?: boolean; +}; + +@injectable() +export default class JWPAPIService { + private readonly storageService: StorageService; + + private useSandboxEnv = true; + + constructor(@inject(StorageService) storageService: StorageService) { + this.storageService = storageService; + } + + setup = (useSandboxEnv: boolean) => { + this.useSandboxEnv = useSandboxEnv; + }; + + private getBaseUrl = () => (this.useSandboxEnv ? 'https://staging-sims.jwplayer.com' : 'http://sims.jwplayer.com'); + + setToken = (token: string, refreshToken = '', expires: number) => { + return this.storageService.setItem(INPLAYER_TOKEN_KEY, JSON.stringify({ token, refreshToken, expires }), false); + }; + + getToken = async () => { + const tokenObject = await this.storageService.getItem(INPLAYER_TOKEN_KEY, true, false); + + if (tokenObject) { + return tokenObject as { token: string; refreshToken: string; expires: number }; + } + + return { token: '', refreshToken: '', expires: 0 }; + }; + + removeToken = async () => { + await Promise.all([this.storageService.removeItem(INPLAYER_TOKEN_KEY), this.storageService.removeItem(INPLAYER_IOT_KEY)]); + }; + + isAuthenticated = async () => { + const tokenObject = await this.getToken(); + + return !!tokenObject.token && tokenObject.expires > Date.now() / 1000; + }; + + private performRequest = async ( + path: string = '/', + method = 'GET', + body?: Record, + { contentType = 'form', responseType = 'json', withAuthentication = false, keepalive, includeFullResponse = false }: RequestOptions = {}, + searchParams?: Record, + ) => { + const headers: Record = { + 'Content-Type': CONTENT_TYPES[contentType], + }; + + if (withAuthentication) { + const tokenObject = await this.getToken(); + + if (tokenObject.token) { + headers.Authorization = `Bearer ${tokenObject.token}`; + } + } + + const formData = new URLSearchParams(); + + if (body) { + Object.entries(body).forEach(([key, value]) => { + if (value || value === 0) { + if (typeof value === 'object') { + Object.entries(value as Record).forEach(([innerKey, innerValue]) => { + formData.append(`${key}[${innerKey}]`, innerValue.toString()); + }); + } else { + formData.append(key, value.toString()); + } + } + }); + } + + const endpoint = `${path.startsWith('http') ? path : `${this.getBaseUrl()}${path}`}${ + searchParams ? `?${new URLSearchParams(searchParams as Record).toString()}` : '' + }`; + + const resp = await fetch(endpoint, { + headers, + keepalive, + method, + body: body && formData.toString(), + }); + + const resParsed = await resp[responseType]?.(); + + if (!resp.ok) { + throw { response: { data: resParsed } }; + } + + if (includeFullResponse) { + return { ...resp, data: resParsed }; + } + + return resParsed; + }; + + get = (path: string, options?: RequestOptions, searchParams?: Record) => + this.performRequest(path, 'GET', undefined, options, searchParams) as Promise; + + patch = (path: string, body?: Record, options?: RequestOptions) => this.performRequest(path, 'PATCH', body, options) as Promise; + + put = (path: string, body?: Record, options?: RequestOptions) => this.performRequest(path, 'PUT', body, options) as Promise; + + post = (path: string, body?: Record, options?: RequestOptions) => this.performRequest(path, 'POST', body, options) as Promise; + + remove = (path: string, options?: RequestOptions, body?: Record) => this.performRequest(path, 'DELETE', body, options) as Promise; + + static isCommonError = (error: unknown): error is JWPError => { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as JWPError).response?.data?.code === 'number' && + typeof (error as JWPError).response?.data?.message === 'string' + ); + }; +} diff --git a/packages/common/src/services/integrations/jwp/JWPAccountService.ts b/packages/common/src/services/integrations/jwp/JWPAccountService.ts index 5daba8d5e..2222fe1ea 100644 --- a/packages/common/src/services/integrations/jwp/JWPAccountService.ts +++ b/packages/common/src/services/integrations/jwp/JWPAccountService.ts @@ -1,10 +1,8 @@ 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, @@ -31,13 +29,27 @@ import type { 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'; +import type { + JWPAuthData, + GetRegisterFieldsResponse, + RegisterField, + CreateAccount, + AccountData, + CommonResponse, + GetWatchHistoryResponse, + WatchHistory, + FavoritesData, + GetFavoritesResponse, + ListSocialURLs, +} from './types'; +import JWPAPIService from './JWPAPIService'; + enum InPlayerEnv { Development = 'development', Production = 'production', @@ -49,6 +61,8 @@ const JW_TERMS_URL = 'https://inplayer.com/legal/terms'; @injectable() export default class JWPAccountService extends AccountService { private readonly storageService; + private readonly apiService; + private clientId = ''; accessModel: AccessModel = ACCESS_MODEL.AUTHVOD; @@ -56,7 +70,7 @@ export default class JWPAccountService extends AccountService { svodOfferIds: string[] = []; sandbox = false; - constructor(storageService: StorageService) { + constructor(storageService: StorageService, apiService: JWPAPIService) { super({ canUpdateEmail: false, canSupportEmptyFullName: false, @@ -73,6 +87,7 @@ export default class JWPAccountService extends AccountService { }); this.storageService = storageService; + this.apiService = apiService; } private parseJson = (value: string, fallback = {}) => { @@ -117,7 +132,7 @@ export default class JWPAccountService extends AccountService { }; }; - private formatAuth(auth: InPlayerAuthData): AuthData { + private formatAuth(auth: JWPAuthData): AuthData { const { access_token: jwt } = auth; return { jwt, @@ -138,6 +153,8 @@ export default class JWPAccountService extends AccountService { const env: string = this.sandbox ? InPlayerEnv.Development : InPlayerEnv.Production; InPlayer.setConfig(env as Env); + this.apiService.setup(this.sandbox); + // calculate access model if (jwpConfig.clientId) { this.clientId = jwpConfig.clientId; @@ -159,12 +176,12 @@ export default class JWPAccountService extends AccountService { return; } - InPlayer.Account.setToken(token, refreshToken, parseInt(expires)); + this.apiService.setToken(token, refreshToken, parseInt(expires)); }; getAuthData = async () => { - if (InPlayer.Account.isAuthenticated()) { - const credentials = InPlayer.Account.getToken().toObject(); + if (await this.apiService.isAuthenticated()) { + const credentials = await this.apiService.getToken(); return { jwt: credentials.token, @@ -177,7 +194,7 @@ export default class JWPAccountService extends AccountService { getPublisherConsents: GetPublisherConsents = async () => { try { - const { data } = await InPlayer.Account.getRegisterFields(this.clientId); + const data = await this.apiService.get(`/accounts/register-fields/${this.clientId}`); const terms = data?.collection.find(({ name }) => name === 'terms'); @@ -242,7 +259,7 @@ export default class JWPAccountService extends AccountService { }, }; - const { data } = await InPlayer.Account.updateAccount(params); + const data = await this.apiService.put('/accounts', params, { withAuthentication: true }); return this.parseJson(data?.metadata?.consents as string, []); } catch { @@ -258,13 +275,17 @@ export default class JWPAccountService extends AccountService { const { oldPassword, newPassword, newPasswordConfirmation } = payload; try { - await InPlayer.Account.changePassword({ - oldPassword, - password: newPassword, - passwordConfirmation: newPasswordConfirmation, - }); + await this.apiService.post( + '/accounts/change-password', + { + old_password: oldPassword, + password: newPassword, + password_confirmation: newPasswordConfirmation, + }, + { withAuthentication: true }, + ); } catch (error: unknown) { - if (isCommonError(error)) { + if (JWPAPIService.isCommonError(error)) { throw new Error(error.response.data.message); } throw new Error('Failed to change password'); @@ -273,10 +294,10 @@ export default class JWPAccountService extends AccountService { resetPassword: ResetPassword = async ({ customerEmail }) => { try { - await InPlayer.Account.requestNewPassword({ + await this.apiService.post('/accounts/forgot-password', { email: customerEmail, - merchantUuid: this.clientId, - brandingId: 0, + merchant_uuid: this.clientId, + branding_id: 0, }); } catch { throw new Error('Failed to reset password.'); @@ -285,13 +306,16 @@ export default class JWPAccountService extends AccountService { login: Login = async ({ email, password, referrer }) => { try { - const { data } = await InPlayer.Account.signInV2({ - email, - password, + const data = await this.apiService.post('/v2/accounts/authenticate', { + client_id: this.clientId || '', + grant_type: 'password', referrer, - clientId: this.clientId || '', + username: email, + password, }); + this.apiService.setToken(data.access_token, '', data.expires); + const user = this.formatAccount(data.account); return { @@ -306,22 +330,25 @@ export default class JWPAccountService extends AccountService { register: Register = async ({ email, password, referrer, consents }) => { try { - const { data } = await InPlayer.Account.signUpV2({ - email, + const data = await this.apiService.post('/accounts', { + full_name: email, + username: email, password, + password_confirmation: password, + client_id: this.clientId || '', + type: 'consumer', referrer, - passwordConfirmation: password, - fullName: email, + grant_type: 'password', metadata: { first_name: ' ', surname: ' ', ...formatConsentsToRegisterFields(consents), consents: JSON.stringify(consents), }, - type: 'consumer', - clientId: this.clientId || '', }); + this.apiService.setToken(data.access_token, '', data.expires); + const user = this.formatAccount(data.account); return { @@ -330,7 +357,7 @@ export default class JWPAccountService extends AccountService { customerConsents: this.parseJson(user?.metadata?.consents as string, []), }; } catch (error: unknown) { - if (isCommonError(error)) { + if (JWPAPIService.isCommonError(error)) { throw new Error(error.response.data.message); } throw new Error('Failed to create account.'); @@ -343,8 +370,9 @@ export default class JWPAccountService extends AccountService { InPlayer.Notifications.unsubscribe(); } - if (InPlayer.Account.isAuthenticated()) { - await InPlayer.Account.signOut(); + if (await this.apiService.isAuthenticated()) { + await this.apiService.get('/accounts/logout', { withAuthentication: true }); + await this.apiService.removeToken(); } } catch { throw new Error('Failed to sign out.'); @@ -353,7 +381,7 @@ export default class JWPAccountService extends AccountService { getUser = async () => { try { - const { data } = await InPlayer.Account.getAccountInfo(); + const data = await this.apiService.get(`/accounts`, { withAuthentication: true }); const user = this.formatAccount(data); @@ -368,9 +396,9 @@ export default class JWPAccountService extends AccountService { updateCustomer: UpdateCustomer = async (customer) => { try { - const response = await InPlayer.Account.updateAccount(this.formatUpdateAccount(customer)); + const data = await this.apiService.put('/accounts', this.formatUpdateAccount(customer), { withAuthentication: true }); - return this.formatAccount(response.data); + return this.formatAccount(data); } catch { throw new Error('Failed to update user data.'); } @@ -380,13 +408,15 @@ export default class JWPAccountService extends AccountService { 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, + + const data = { + full_name: fullName, metadata, }; @@ -417,16 +447,13 @@ export default class JWPAccountService extends AccountService { changePasswordWithResetToken: ChangePassword = async (payload) => { const { resetPasswordToken = '', newPassword, newPasswordConfirmation = '' } = payload; try { - await InPlayer.Account.setNewPassword( - { - password: newPassword, - passwordConfirmation: newPasswordConfirmation, - brandingId: 0, - }, - resetPasswordToken, - ); + await this.apiService.put(`/accounts/forgot-password/${resetPasswordToken}`, { + password: newPassword, + password_confirmation: newPasswordConfirmation, + branding_id: 0, + }); } catch (error: unknown) { - if (isCommonError(error)) { + if (JWPAPIService.isCommonError(error)) { throw new Error(error.response.data.message); } throw new Error('Failed to change password.'); @@ -461,7 +488,14 @@ export default class JWPAccountService extends AccountService { 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); + return this.apiService.patch( + '/v2/accounts/media/watch-history', + { + media_id: mediaid, + progress, + }, + { withAuthentication: true }, + ); } }), ); @@ -474,29 +508,41 @@ export default class JWPAccountService extends AccountService { // save new favorites await Promise.allSettled( - payloadFavoriteIds.map((mediaId) => { - return !currentFavoriteIds.includes(mediaId) ? InPlayer.Account.addToFavorites(mediaId) : Promise.resolve(); + payloadFavoriteIds.map((media_id) => { + return !currentFavoriteIds.includes(media_id) + ? this.apiService.post('/v2/accounts/media/favorites', { media_id }, { withAuthentication: true }) + : Promise.resolve(); }), ); // delete removed favorites await Promise.allSettled( currentFavoriteIds.map((mediaId) => { - return !payloadFavoriteIds.includes(mediaId) ? InPlayer.Account.deleteFromFavorites(mediaId) : Promise.resolve(); + return !payloadFavoriteIds.includes(mediaId) + ? this.apiService.remove(`/v2/accounts/media/favorites/${mediaId}`, { withAuthentication: true }) + : Promise.resolve(); }), ); }; getFavorites = async () => { - const favoritesData = await InPlayer.Account.getFavorites(); + const favoritesData = await this.apiService.get('/v2/accounts/media/favorites', { withAuthentication: true }); - return favoritesData.data?.collection?.map(this.formatFavorite) || []; + return favoritesData?.collection?.map(this.formatFavorite) || []; }; getWatchHistory = async () => { - const watchHistoryData = await InPlayer.Account.getWatchHistory({}); + const watchHistoryData = await this.apiService.get( + '/v2/accounts/media/watch-history', + { + withAuthentication: true, + }, + { + filter: 'currently_watching', + }, + ); - return watchHistoryData.data?.collection?.map(this.formatHistoryItem) || []; + return watchHistoryData?.collection?.map(this.formatHistoryItem) || []; }; subscribeToNotifications: NotificationsData = async ({ uuid, onMessage }) => { @@ -516,9 +562,7 @@ export default class JWPAccountService extends AccountService { 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; + return await this.apiService.post('/accounts/export', { password: undefined, branding_id: 0 }, { withAuthentication: true }); } catch { throw new Error('Failed to export account data'); } @@ -526,11 +570,9 @@ export default class JWPAccountService extends AccountService { deleteAccount: DeleteAccount = async ({ password }) => { try { - const response = await InPlayer.Account.deleteAccount({ password, brandingId: 0 }); - - return response.data; + return await this.apiService.remove('/accounts/erase', { withAuthentication: true }, { password, branding_id: 0 }); } catch (error: unknown) { - if (isCommonError(error)) { + if (JWPAPIService.isCommonError(error)) { throw new Error(error.response.data.message || 'Failed to delete account'); } @@ -546,7 +588,15 @@ export default class JWPAccountService extends AccountService { }), ); - const socialResponse = await InPlayer.Account.getSocialLoginUrls(socialState); + const socialResponse = await this.apiService.get<{ status: number; data: ListSocialURLs }>( + '/accounts/social', + { + includeFullResponse: true, + }, + { + state: socialState, + }, + ); if (socialResponse.status !== 200) { throw new Error('Failed to fetch social urls'); diff --git a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts index c22c678ed..91b556298 100644 --- a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts +++ b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts @@ -1,5 +1,4 @@ -import InPlayer, { type AccessFee, type MerchantPaymentMethod } from '@inplayer-org/inplayer.js'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import { isSVODOffer } from '../../../utils/offers'; import type { @@ -21,12 +20,29 @@ import type { } from '../../../../types/checkout'; import CheckoutService from '../CheckoutService'; import type { ServiceResponse } from '../../../../types/service'; -import { isCommonError } from '../../../utils/api'; + +import type { + CommonResponse, + GetAccessFeesResponse, + AccessFee, + MerchantPaymentMethod, + GeneratePayPalParameters, + VoucherDiscountPrice, + GetItemAccessResponse, +} from './types'; +import JWPAPIService from './JWPAPIService'; @injectable() export default class JWPCheckoutService extends CheckoutService { private readonly cardPaymentProvider = 'stripe'; + private readonly apiService; + + constructor(@inject(JWPAPIService) apiService: JWPAPIService) { + super(); + this.apiService = apiService; + } + private formatPaymentMethod = (method: MerchantPaymentMethod, cardPaymentProvider: string): PaymentMethod => { return { id: method.id, @@ -124,7 +140,7 @@ export default class JWPCheckoutService extends CheckoutService { const offers = await Promise.all( payload.offerIds.map(async (offerId) => { try { - const { data } = await InPlayer.Asset.getAssetAccessFees(this.parseOfferId(offerId)); + const data = await this.apiService.get(`/v2/items/${this.parseOfferId(offerId)}/access-fees`); return data?.map((offer) => this.formatOffer(offer)); } catch { @@ -138,9 +154,9 @@ export default class JWPCheckoutService extends CheckoutService { getPaymentMethods: GetPaymentMethods = async () => { try { - const response = await InPlayer.Payment.getPaymentMethods(); + const data = await this.apiService.get('/payments/methods', { withAuthentication: true }); const paymentMethods: PaymentMethod[] = []; - response.data.forEach((method: MerchantPaymentMethod) => { + data.forEach((method: MerchantPaymentMethod) => { if (['card', 'paypal'].includes(method.method_name.toLowerCase())) { paymentMethods.push(this.formatPaymentMethod(method, this.cardPaymentProvider)); } @@ -160,14 +176,18 @@ export default class JWPCheckoutService extends CheckoutService { paymentWithPayPal: PaymentWithPayPal = async (payload) => { try { - const response = await InPlayer.Payment.getPayPalParams({ - origin: payload.waitingUrl, - accessFeeId: payload.order.id, - paymentMethod: 2, - voucherCode: payload.couponCode, - }); + const data = await this.apiService.post( + '/external-payments', + { + origin: payload.waitingUrl, + access_fee: payload.order.id, + payment_method: 2, + voucher_code: payload.couponCode, + }, + { withAuthentication: true }, + ); - if (response.data?.id) { + if (data?.id) { return { errors: ['Already have an active access'], responseData: { @@ -178,7 +198,7 @@ export default class JWPCheckoutService extends CheckoutService { return { errors: [], responseData: { - redirectUrl: response.data.endpoint, + redirectUrl: data.endpoint, }, }; } catch { @@ -202,15 +222,19 @@ export default class JWPCheckoutService extends CheckoutService { updateOrder: UpdateOrder = async ({ order, couponCode }) => { try { - const response = await InPlayer.Voucher.getDiscount({ - voucherCode: `${couponCode}`, - accessFeeId: order.id, - }); + const data = await this.apiService.post( + '/vouchers/discount', + { + voucher_code: `${couponCode}`, + access_fee_id: order.id, + }, + { withAuthentication: true }, + ); - const discountAmount = order.totalPrice - response.data.amount; + const discountAmount = order.totalPrice - data.amount; const updatedOrder: Order = { ...order, - totalPrice: response.data.amount, + totalPrice: data.amount, priceBreakdown: { ...order.priceBreakdown, discountAmount, @@ -219,7 +243,7 @@ export default class JWPCheckoutService extends CheckoutService { discount: { applied: true, type: 'coupon', - periods: response.data.discount_duration, + periods: data.discount_duration, }, }; @@ -232,7 +256,7 @@ export default class JWPCheckoutService extends CheckoutService { }, }; } catch (error: unknown) { - if (isCommonError(error) && error.response.data.message === 'Voucher not found') { + if (JWPAPIService.isCommonError(error) && error.response.data.message === 'Voucher not found') { throw new Error('Invalid coupon code'); } @@ -242,8 +266,11 @@ export default class JWPCheckoutService extends CheckoutService { getEntitlements: GetEntitlements = async ({ offerId }) => { try { - const response = await InPlayer.Asset.checkAccessForAsset(this.parseOfferId(offerId)); - return this.formatEntitlements(response.data.expires_at, true); + const data = await this.apiService.get(`/items/${this.parseOfferId(offerId)}/access`, { + withAuthentication: true, + }); + + return this.formatEntitlements(data.expires_at, true); } catch { return this.formatEntitlements(); } @@ -252,22 +279,22 @@ export default class JWPCheckoutService extends CheckoutService { 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 || '', + card_name: cardPaymentPayload.cardholderName, + exp_month: cardPaymentPayload.cardExpMonth || '', + exp_year: cardPaymentPayload.cardExpYear || '', cvv: cardPaymentPayload.cardCVC, - accessFee: order.id, - paymentMethod: 1, - voucherCode: cardPaymentPayload.couponCode, + access_fee: order.id, + payment_method: 1, + voucher_code: cardPaymentPayload.couponCode, referrer, - returnUrl, + return_url: returnUrl, }; try { if (isSVODOffer(order)) { - await InPlayer.Subscription.createSubscription(payload); + await this.apiService.post('/subscriptions', payload, { withAuthentication: true }); } else { - await InPlayer.Payment.createPayment(payload); + await this.apiService.post('/payments', payload, { withAuthentication: true }); } return true; diff --git a/packages/common/src/services/integrations/jwp/JWPProfileService.ts b/packages/common/src/services/integrations/jwp/JWPProfileService.ts deleted file mode 100644 index 4d66c365f..000000000 --- a/packages/common/src/services/integrations/jwp/JWPProfileService.ts +++ /dev/null @@ -1,100 +0,0 @@ -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'; -import { logError } from '../../../logger'; - -@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 (error: unknown) { - logError('JWPProfileService', 'Unable to list profiles', { error }); - 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 index 6d42b457e..b2e1ed5a3 100644 --- a/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts +++ b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts @@ -1,9 +1,6 @@ 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 { inject, injectable, named } from 'inversify'; -import { isCommonError } from '../../../utils/api'; import type { ChangeSubscription, GetActivePayment, @@ -18,9 +15,22 @@ import type { import SubscriptionService from '../SubscriptionService'; import AccountService from '../AccountService'; +import type { + GetItemAccessResponse, + GetSubscriptionsResponse, + GetPaymentHistoryResponse, + GetDefaultCardResponse, + CancelSubscriptionResponse, + ChangeSubscriptionPlanResponse, + SetDefaultCardResponse, + Card, + PaymentHistory, + JWPSubscription, +} from './types'; import type JWPAccountService from './JWPAccountService'; +import JWPAPIService from './JWPAPIService'; -interface SubscriptionDetails extends InplayerSubscription { +interface SubscriptionDetails extends JWPSubscription { item_id?: number; item_title?: string; subscription_id?: string; @@ -38,11 +48,13 @@ interface SubscriptionDetails extends InplayerSubscription { @injectable() export default class JWPSubscriptionService extends SubscriptionService { private readonly accountService: JWPAccountService; + private readonly apiService: JWPAPIService; - constructor(@named('JWP') accountService: AccountService) { + constructor(@named('JWP') accountService: AccountService, @inject(JWPAPIService) apiService: JWPAPIService) { super(); this.accountService = accountService as JWPAccountService; + this.apiService = apiService; } private formatCardDetails = ( @@ -138,7 +150,7 @@ export default class JWPSubscriptionService extends SubscriptionService { } as Subscription; }; - private formatGrantedSubscription = (subscription: GetItemAccessV1) => { + private formatGrantedSubscription = (subscription: GetItemAccessResponse) => { return { subscriptionId: '', offerId: subscription.item.id.toString(), @@ -163,21 +175,34 @@ export default class JWPSubscriptionService extends SubscriptionService { if (assetId === null) throw new Error("Couldn't fetch active subscription, there is no assetId configured"); try { - const hasAccess = await InPlayer.Asset.checkAccessForAsset(assetId); + const hasAccess = await this.apiService.get(`/items/${assetId}/access`, { + withAuthentication: true, + }); if (hasAccess) { - const { data } = await InPlayer.Subscription.getSubscriptions(); + const data = await this.apiService.get( + '/subscriptions', + { + withAuthentication: true, + contentType: 'json', + }, + { + limit: 15, + page: 0, + }, + ); + const activeSubscription = data.collection.find((subscription: SubscriptionDetails) => subscription.item_id === assetId); if (activeSubscription) { - return this.formatActiveSubscription(activeSubscription, hasAccess?.data?.expires_at); + return this.formatActiveSubscription(activeSubscription, hasAccess?.expires_at); } - return this.formatGrantedSubscription(hasAccess.data); + return this.formatGrantedSubscription(hasAccess); } return null; } catch (error: unknown) { - if (isCommonError(error) && error.response.data.code === 402) { + if (JWPAPIService.isCommonError(error) && error.response.data.code === 402) { return null; } throw new Error('Unable to fetch customer subscriptions.'); @@ -186,7 +211,10 @@ export default class JWPSubscriptionService extends SubscriptionService { getAllTransactions: GetAllTransactions = async () => { try { - const { data } = await InPlayer.Payment.getPaymentHistory(); + const data = await this.apiService.get('/v2/accounting/payment-history', { + withAuthentication: true, + contentType: 'json', + }); return data?.collection?.map((transaction) => this.formatTransaction(transaction)); } catch { @@ -196,7 +224,11 @@ export default class JWPSubscriptionService extends SubscriptionService { getActivePayment: GetActivePayment = async () => { try { - const { data } = await InPlayer.Payment.getDefaultCreditCard(); + const data = await this.apiService.get('/v2/payments/cards/default', { + withAuthentication: true, + contentType: 'json', + }); + const cards: PaymentDetail[] = []; for (const currency in data?.cards) { cards.push( @@ -224,7 +256,7 @@ export default class JWPSubscriptionService extends SubscriptionService { throw new Error('Missing unsubscribe url'); } try { - await InPlayer.Subscription.cancelSubscription(unsubscribeUrl); + await this.apiService.get(unsubscribeUrl, { withAuthentication: true, contentType: 'json' }); return { errors: [], responseData: { offerId: offerId, status: 'cancelled', expiresAt: 0 }, @@ -236,13 +268,19 @@ export default class JWPSubscriptionService extends SubscriptionService { changeSubscription: ChangeSubscription = async ({ accessFeeId, subscriptionId }) => { try { - const response = await InPlayer.Subscription.changeSubscriptionPlan({ - access_fee_id: parseInt(accessFeeId), - inplayer_token: subscriptionId, - }); + const data = await this.apiService.post( + '/v2/subscriptions/stripe:switch', + { + inplayer_token: subscriptionId, + access_fee_id: accessFeeId, + }, + { + withAuthentication: true, + }, + ); return { errors: [], - responseData: { message: response.data.message }, + responseData: { message: data.message }, }; } catch { throw new Error('Failed to change subscription'); @@ -251,18 +289,20 @@ export default class JWPSubscriptionService extends SubscriptionService { 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, - }; + const responseData = await this.apiService.put( + '/v2/payments/cards/default', + { + number: cardNumber, + card_name: cardName, + cvv: cvc, + exp_month: expMonth, + exp_year: expYear, + currency_iso: currency, + }, + { withAuthentication: true }, + ); + + return { errors: [], responseData }; } catch { throw new Error('Failed to update card details'); } @@ -270,11 +310,13 @@ export default class JWPSubscriptionService extends SubscriptionService { fetchReceipt = async ({ transactionId }: { transactionId: string }) => { try { - const { data } = await InPlayer.Payment.getBillingReceipt({ trxToken: transactionId }); - return { - errors: [], - responseData: data, - }; + const responseData = await this.apiService.get(`/v2/accounting/transactions/${transactionId}/receipt`, { + withAuthentication: true, + contentType: 'json', + responseType: 'blob', + }); + + return { errors: [], responseData }; } catch { throw new Error('Failed to get billing receipt'); } diff --git a/packages/common/src/services/integrations/jwp/types.ts b/packages/common/src/services/integrations/jwp/types.ts new file mode 100644 index 000000000..91302feef --- /dev/null +++ b/packages/common/src/services/integrations/jwp/types.ts @@ -0,0 +1,347 @@ +export type JWPAuthData = { + access_token: string; + expires?: number; +}; + +export type JWPError = { + response: { + data: { + code: number; + message: string; + }; + }; +}; + +export type CommonResponse = { + code: number; + message: string; +}; + +export type AccountData = { + id: number; + email: string; + full_name: string; + referrer: string; + metadata: Record; + social_apps_metadata: Record[]; + roles: string[]; + completed: boolean; + created_at: number; + updated_at: number; + date_of_birth: number; + uuid: string; + merchant_uuid: string; +}; + +export type CreateAccount = { + access_token: string; + expires: number; + account: AccountData; +}; + +export type RegisterFieldOptions = Record; + +export type RegisterField = { + id: number; + name: string; + label: string; + type: string; + required: boolean; + default_value: string; + placeholder: string; + options: RegisterFieldOptions; +}; + +export type GetRegisterFieldsResponse = { + collection: RegisterField[]; +}; + +type CollectionWithCursor = { + collection: T[]; + cursor?: string; +}; + +export type WatchHistory = { + media_id: string; + progress: number; + created_at: number; + updated_at: number; +}; + +export type GetWatchHistoryResponse = CollectionWithCursor; + +export type FavoritesData = { + media_id: string; + created_at: number; +}; + +export type GetFavoritesResponse = CollectionWithCursor; + +export type SocialURLs = { + facebook: string; + twitter: string; + google: string; +}; + +export type ListSocialURLs = { + social_urls: SocialURLs[]; + code: number; +}; + +export type SignedMediaResponse = { + token: string; +}; + +export type AccessType = { + id: number; + account_id: number; + name: string; + quantity: number; + period: string; + updated_at: number; + created_at: number; +}; + +export type AccessControlType = { + id: number; + name: string; + auth: boolean; +}; + +export type ItemType = { + id: number; + name: string; + content_type: string; + host: string; + description: string; +}; + +export type AgeRestriction = { + min_age: number; +}; + +export type Item = { + id: number; + merchant_id: number; + merchant_uuid: string; + active: boolean; + title: string; + access_control_type: AccessControlType; + item_type: ItemType; + age_restriction: AgeRestriction | null; + metadata?: Record[]; + metahash?: Record; + content?: string; + template_id: number | null; + created_at: number; + update_at: number; + plan_switch_enabled: boolean; +}; + +export type TrialPeriod = { + quantity: number; + period: string; + description: string; +}; + +export type SetupFee = { + id: number; + fee_amount: number; + description: string; +}; + +export type SeasonalFee = { + id: number; + access_fee_id: number; + merchant_id: number; + current_price_amount: number; + off_season_access: boolean; + anchor_date: number; + created_at: number; + updated_at: number; +}; + +export type ExternalFee = { + id: number; + payment_provider_id: number; + access_fee_id: number; + external_id: string; + merchant_id: number; +}; + +export type GeoRestriction = { + id: number; + country_iso: string; + country_set_id: number; + type: string; +}; + +export type CurrentPhase = { + access_fee_id: number; + anchor_date: number; + created_at: number; + currency: string; + current_price: number; + expires_at: number; + id: number; + season_price: number; + starts_at: number; + status: string; + updated_at: number; +}; + +export type AccessFee = { + id: number; + merchant_id: number; + amount: number; + currency: string; + description: string; + expires_at: number; + starts_at: number; + updated_at: number; + title: string; + created_at: number; + merchant_uuid: string; + access_type: AccessType; + plan_switch_enabled: boolean; + item: Item; + item_id: number; + next_phase: CurrentPhase | null; + template_id: number | null; + trial_period: TrialPeriod | null; + setup_fee: SetupFee | null; + seasonal_fee: SeasonalFee | null; + external_fees: Array | null; + geo_restriction: GeoRestriction | null; + current_phase: CurrentPhase | null; +}; + +export type GetAccessFeesResponse = AccessFee[]; + +export type MerchantPaymentMethod = { + id: number; + method_name: string; + is_external: boolean; +}; + +export type GeneratePayPalParameters = { + endpoint: string; + business: string; + item_name: string; + currency_code: string; + return: string; + cancel_return: string; + id?: string; +}; + +export type VoucherDiscountPrice = { + amount: number; + discount_duration: number; +}; + +export type ItemDetails = { + id: number; + merchant_id: number; + merchant_uuid: string; + is_active: boolean; + title: string; + access_control_type: AccessControlType; + item_type: ItemType; + age_restriction: Record; + metadata: Record[]; + created_at: number; + updated_at: number; + content: string; +}; + +export type GetItemAccessResponse = { + id: number; + account_id: number; + customer_id: number; + customer_uuid: string; + ip_address: string; + country_code: string; + created_at: number; + expires_at: number; + item: ItemDetails; +}; + +export type JWPSubscription = { + cancel_token: string; + status: string; + description: string; + asset_title: string; + asset_id: number; + formatted_amount: string; + amount: number; + currency: string; + merchant_id: number; + created_at: number; + updated_at: number; + next_billing_date: number; + unsubscribe_url: string; +}; + +export type GetSubscriptionsResponse = { + total: number; + page: number; + offset: number; + limit: number; + collection: JWPSubscription[]; +}; + +export type PaymentHistory = { + merchant_id: number; + consumer_id: number; + gateway_id: number; + transaction_token: string; + payment_tool_token: string; + trx_token: string; + payment_method_name: string; + action_type: string; + item_access_id: number; + item_id: number; + item_type: string; + item_title: string; + charged_amount: number; + currency_iso: string; + note: string; + created_at: number; +}; +export type GetPaymentHistoryResponse = { + collection: PaymentHistory[]; + total: number; +}; + +export type Card = { + number: number; + card_name: string; + exp_month: string; + exp_year: string; + card_type: string; + account_id: number; +}; + +export type GetDefaultCardResponse = { + cards: Card[]; +}; + +export type CancelSubscriptionResponse = { + code: number; + subscription: string; + operation: string; + description: string; + status: string; + timestamp: number; +}; + +export type ChangeSubscriptionPlanResponse = { + message: string; +}; + +export type SetDefaultCardResponse = { + number: number; + card_name: string; + exp_month: string; + exp_year: string; +}; diff --git a/packages/common/src/stores/ProfileStore.ts b/packages/common/src/stores/ProfileStore.ts deleted file mode 100644 index 9b4bd2b48..000000000 --- a/packages/common/src/stores/ProfileStore.ts +++ /dev/null @@ -1,28 +0,0 @@ -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; - selectingProfileAvatar: string | null; - canManageProfiles: boolean; - setProfile: (profile: Profile | null) => void; -}; - -export const useProfileStore = createStore('AccountStore', () => ({ - profile: null, - selectingProfileAvatar: null, - canManageProfiles: false, - setProfile: (profile) => { - useProfileStore.setState({ - profile: profile - ? { - ...profile, - avatar_url: profile?.avatar_url || defaultAvatar, - } - : null, - }); - }, -})); diff --git a/packages/common/src/utils/analytics.ts b/packages/common/src/utils/analytics.ts deleted file mode 100644 index aac42e5b8..000000000 --- a/packages/common/src/utils/analytics.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 index bf1647f3a..ca6ede3af 100644 --- a/packages/common/src/utils/api.ts +++ b/packages/common/src/utils/api.ts @@ -1,7 +1,3 @@ -import type { CommonResponse } from '@inplayer-org/inplayer.js'; - -import type { InPlayerError } from '../../types/inplayer'; - export class ApiError extends Error { code: number; message: string; @@ -30,27 +26,3 @@ export const getDataOrThrow = async (response: Response) => { 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/packages/common/src/utils/common.ts b/packages/common/src/utils/common.ts index 3756f8be2..3b4448959 100644 --- a/packages/common/src/utils/common.ts +++ b/packages/common/src/utils/common.ts @@ -1,17 +1,21 @@ import type { Playlist, PlaylistItem } from '../../types/playlist'; export function debounce void>(callback: T, wait = 200) { - let timeout: NodeJS.Timeout | null; - return (...args: unknown[]) => { + let timeout: NodeJS.Timeout | undefined; + function debounced(...args: unknown[]) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => callback(...args), wait); - }; + } + + debounced.cancel = () => clearTimeout(timeout); + return debounced; } -export function throttle unknown>(func: T, limit: number): (...args: Parameters) => void { + +export function throttle unknown>(func: T, limit: number) { let lastFunc: NodeJS.Timeout | undefined; let lastRan: number | undefined; - return function (this: ThisParameterType, ...args: Parameters): void { + function throttled(this: ThisParameterType, ...args: Parameters): void { const timeSinceLastRan = lastRan ? Date.now() - lastRan : limit; if (timeSinceLastRan >= limit) { @@ -29,7 +33,10 @@ export function throttle unknown>(func: T, limit: lastFunc = undefined; }, limit - timeSinceLastRan); } - }; + } + + throttled.cancel = () => clearTimeout(lastFunc); + return throttled; } export const unicodeToChar = (text: string) => { diff --git a/packages/common/src/utils/configSchema.ts b/packages/common/src/utils/configSchema.ts index e72d1993f..43c2d60af 100644 --- a/packages/common/src/utils/configSchema.ts +++ b/packages/common/src/utils/configSchema.ts @@ -50,6 +50,8 @@ export const configSchema: SchemaOf = object({ description: string().defined(), analyticsToken: string().nullable(), adSchedule: string().nullable(), + adDeliveryMethod: mixed().oneOf(['csai', 'ssai']).notRequired(), + adConfig: string().nullable(), siteId: string().defined(), assets: object({ banner: string().notRequired().nullable(), diff --git a/packages/common/src/utils/sources.ts b/packages/common/src/utils/sources.ts new file mode 100644 index 000000000..c96a35022 --- /dev/null +++ b/packages/common/src/utils/sources.ts @@ -0,0 +1,43 @@ +import type { PlaylistItem, Source } from '@jwp/ott-common/types/playlist'; +import type { Config } from '@jwp/ott-common/types/config'; +import type { Customer } from '@jwp/ott-common/types/account'; + +const isVODManifestType = (sourceUrl: string, baseUrl: string, mediaId: string, extensions: ('m3u8' | 'mpd')[]) => { + return extensions.some((ext) => sourceUrl === `${baseUrl}/manifests/${mediaId}.${ext}`); +}; + +const isBCLManifestType = (sourceUrl: string, baseUrl: string, mediaId: string, extensions: ('m3u8' | 'mpd')[]) => { + return extensions.some((ext) => sourceUrl === `${baseUrl}/live/broadcast/${mediaId}.${ext}`); +}; + +export const getSources = ({ item, baseUrl, config, user }: { item: PlaylistItem; baseUrl: string; config: Config; user: Customer | null }) => { + const { sources, mediaid } = item; + const { adConfig, siteId, adDeliveryMethod } = config; + + const userId = user?.id; + const hasServerAds = !!adConfig && adDeliveryMethod === 'ssai'; + + return sources.map((source: Source) => { + const url = new URL(source.file); + + const sourceUrl = url.href; + + const isBCLManifest = isBCLManifestType(sourceUrl, baseUrl, mediaid, ['m3u8', 'mpd']); + const isVODManifest = isVODManifestType(sourceUrl, baseUrl, mediaid, ['m3u8', 'mpd']); + const isDRM = url.searchParams.has('exp') && url.searchParams.has('sig'); + + // Use SSAI URL for configs with server side ads, DRM is not supported + if (isVODManifest && hasServerAds && !isDRM) { + // Only HLS is supported now + url.href = `${baseUrl}/v2/sites/${siteId}/media/${mediaid}/ssai.m3u8`; + url.searchParams.set('ad_config_id', adConfig); + // Attach user_id only for VOD and BCL SaaS Live Streams (doesn't work with SSAI items) + } else if ((isVODManifest || isBCLManifest) && userId) { + url.searchParams.set('user_id', userId); + } + + source.file = url.toString(); + + return source; + }); +}; diff --git a/packages/common/src/utils/urlFormatting.test.ts b/packages/common/src/utils/urlFormatting.test.ts index 22b439c48..4fc93fc1f 100644 --- a/packages/common/src/utils/urlFormatting.test.ts +++ b/packages/common/src/utils/urlFormatting.test.ts @@ -5,7 +5,7 @@ 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'; +import { createURL, liveChannelsURL, mediaURL, playlistURL } from './urlFormatting'; describe('createUrl', () => { test('valid url from a path, query params', () => { @@ -53,19 +53,9 @@ describe('createPath, mediaURL, playlistURL and liveChannelsURL', () => { 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 index 33ecc99b1..09994e790 100644 --- a/packages/common/src/utils/urlFormatting.ts +++ b/packages/common/src/utils/urlFormatting.ts @@ -1,5 +1,5 @@ import type { PlaylistItem } from '../../types/playlist'; -import { RELATIVE_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE, PATH_CONTENT_LIST } from '../paths'; +import { PATH_MEDIA, PATH_PLAYLIST, PATH_CONTENT_LIST } from '../paths'; import { logWarn } from '../logger'; import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media'; @@ -136,12 +136,6 @@ export const liveChannelsURL = (playlistId: string, channelId?: string, play = f ); }; -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, diff --git a/packages/common/types/ad-schedule.ts b/packages/common/types/ad-schedule.ts index 4ead8eded..d01c00c1e 100644 --- a/packages/common/types/ad-schedule.ts +++ b/packages/common/types/ad-schedule.ts @@ -8,3 +8,5 @@ export type AdScheduleUrls = { json?: string | null; xml?: string | null; }; + +export type AdDeliveryMethod = 'csai' | 'ssai'; diff --git a/packages/common/types/config.ts b/packages/common/types/config.ts index babd67c44..d3b62cfe9 100644 --- a/packages/common/types/config.ts +++ b/packages/common/types/config.ts @@ -1,6 +1,6 @@ import type { PLAYLIST_TYPE } from '../src/constants'; -import type { AdScheduleUrls } from './ad-schedule'; +import type { AdScheduleUrls, AdDeliveryMethod } from './ad-schedule'; /** * Set config setup changes in both config.services.ts and config.d.ts @@ -11,6 +11,8 @@ export type Config = { description: string; analyticsToken?: string | null; adSchedule?: string | null; + adConfig?: string | null; + adDeliveryMethod?: AdDeliveryMethod; adScheduleUrls?: AdScheduleUrls; integrations: { cleeng?: Cleeng; diff --git a/packages/common/types/inplayer.ts b/packages/common/types/inplayer.ts deleted file mode 100644 index 75b30a0da..000000000 --- a/packages/common/types/inplayer.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type InPlayerAuthData = { - access_token: string; - expires?: number; -}; - -export type InPlayerError = { - response: { - data: { - code: number; - message: string; - }; - }; -}; diff --git a/packages/common/types/profiles.ts b/packages/common/types/profiles.ts deleted file mode 100644 index 58bcfbedb..000000000 --- a/packages/common/types/profiles.ts +++ /dev/null @@ -1,42 +0,0 @@ -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/packages/common/types/subscription.ts b/packages/common/types/subscription.ts index 6582c7b23..9d4b7afdc 100644 --- a/packages/common/types/subscription.ts +++ b/packages/common/types/subscription.ts @@ -1,5 +1,3 @@ -import type { ChangeSubscriptionPlanResponse, DefaultCreditCardData, SetDefaultCard } from '@inplayer-org/inplayer.js'; - import type { CleengRequest } from './cleeng'; import type { EnvironmentServiceRequest, PromiseRequest } from './service'; @@ -41,6 +39,22 @@ export type PaymentMethodSpecificParam = { socialSecurityNumber?: string; }; +type DefaultCreditCardDataParams = { + cardNumber: string; + cardName: string; + cvc: number; + expMonth: number; + expYear: number; + currency: string; +}; + +type SetDefaultCardResponse = { + number: number; + card_name: string; + exp_month: string; + exp_year: string; +}; + export type Transaction = { transactionId: string; transactionDate: number; @@ -120,9 +134,9 @@ export type GetTransactionsResponse = { items: Transaction[]; }; -export type UpdateCardDetailsPayload = DefaultCreditCardData; +export type UpdateCardDetailsPayload = DefaultCreditCardDataParams; -export type UpdateCardDetails = EnvironmentServiceRequest; +export type UpdateCardDetails = EnvironmentServiceRequest; export type FetchReceiptPayload = { transactionId: string; @@ -135,6 +149,10 @@ type ChangeSubscriptionPayload = { subscriptionId: string; }; +type ChangeSubscriptionPlanResponse = { + message: string; +}; + type GetActivePaymentPayload = { customerId: string; }; diff --git a/packages/hooks-react/package.json b/packages/hooks-react/package.json index 712690f17..f6654f020 100644 --- a/packages/hooks-react/package.json +++ b/packages/hooks-react/package.json @@ -9,7 +9,6 @@ "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" }, "dependencies": { - "@inplayer-org/inplayer.js": "^3.13.25", "date-fns": "^2.30.0", "i18next": "^22.5.1", "planby": "^0.3.0", diff --git a/packages/hooks-react/src/useAds.ts b/packages/hooks-react/src/useAds.ts index b18e05bc4..f5249d8d4 100644 --- a/packages/hooks-react/src/useAds.ts +++ b/packages/hooks-react/src/useAds.ts @@ -7,7 +7,7 @@ import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; const CACHE_TIME = 60 * 1000 * 20; /** - * @deprecated Use adScheduleUrls.xml form the config instead. + * @deprecated Use ad-config instead. */ const useLegacyStandaloneAds = ({ adScheduleId, enabled }: { adScheduleId: string | null | undefined; enabled: boolean }) => { const apiService = getModule(ApiService); @@ -25,21 +25,24 @@ const useLegacyStandaloneAds = ({ adScheduleId, enabled }: { adScheduleId: strin }; export const useAds = ({ mediaId }: { mediaId: string }) => { - const { adSchedule: adScheduleId, adScheduleUrls } = useConfigStore((s) => s.config); - - // adScheduleUrls.xml prop exists when ad-config is attached to the App Config - const useAppBasedFlow = !!adScheduleUrls?.xml; - - const { data: adSchedule, isLoading: isAdScheduleLoading } = useLegacyStandaloneAds({ adScheduleId, enabled: !useAppBasedFlow }); - const adConfig = { - client: 'vast', - schedule: createURL(adScheduleUrls?.xml || '', { - media_id: mediaId, - }), - }; + const { adSchedule: adScheduleId, adConfig: adConfigId, adScheduleUrls, adDeliveryMethod } = useConfigStore((s) => s.config); + + // We use client side ads only when delivery method is not pointing at server ads + // adConfig and adScheduled can't be enabled at the same time + const useAdConfigFlow = !!adConfigId && adDeliveryMethod !== 'ssai'; + + const { data: adSchedule, isLoading: isAdScheduleLoading } = useLegacyStandaloneAds({ adScheduleId, enabled: !!adScheduleId }); + const adConfig = useAdConfigFlow + ? { + client: 'vast', + schedule: createURL(adScheduleUrls?.xml || '', { + media_id: mediaId, + }), + } + : undefined; return { - isLoading: useAppBasedFlow ? false : isAdScheduleLoading, - data: useAppBasedFlow ? adConfig : adSchedule, + isLoading: useAdConfigFlow ? false : isAdScheduleLoading, + data: useAdConfigFlow ? adConfig : adSchedule, }; }; diff --git a/packages/hooks-react/src/useMediaSources.ts b/packages/hooks-react/src/useMediaSources.ts new file mode 100644 index 000000000..8f344f018 --- /dev/null +++ b/packages/hooks-react/src/useMediaSources.ts @@ -0,0 +1,13 @@ +import { useMemo } from 'react'; +import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; +import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; +import type { PlaylistItem, Source } from '@jwp/ott-common/types/playlist'; +import { getSources } from '@jwp/ott-common/src/utils/sources'; + +/** Modify manifest URLs to handle server ads and analytics params */ +export const useMediaSources = ({ item, baseUrl }: { item: PlaylistItem; baseUrl: string }): Source[] => { + const config = useConfigStore((s) => s.config); + const user = useAccountStore((s) => s.user); + + return useMemo(() => getSources({ item, baseUrl, config, user }), [item, baseUrl, config, user]); +}; diff --git a/packages/hooks-react/src/useProfiles.ts b/packages/hooks-react/src/useProfiles.ts deleted file mode 100644 index b5416a1de..000000000 --- a/packages/hooks-react/src/useProfiles.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import { logError } from '@jwp/ott-common/src/logger'; - -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: (error) => { - useProfileStore.setState({ selectingProfileAvatar: null }); - logError('useProfiles', 'Unable to enter profile', { error }); - 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((state) => state.user); - const accessModel = useConfigStore((state) => state.accessModel); - const { profile } = useProfileStore(); - const isLoggedIn = !!user; - - const profileController = getModule(ProfileController); - - const profilesEnabled = profileController.isEnabled(); - - const query = useQuery(['listProfiles', user?.id || ''], () => profileController.listProfiles(), { - ...options, - enabled: isLoggedIn && profilesEnabled, - }); - - const shouldManageProfiles = !!user && profilesEnabled && query.data?.canManageProfiles && !profile && (accessModel === 'SVOD' || accessModel === 'AUTHVOD'); - - return { - query, - shouldManageProfiles, - profilesEnabled: !!query.data?.canManageProfiles, - }; -}; diff --git a/packages/hooks-react/src/useProtectedMedia.ts b/packages/hooks-react/src/useProtectedMedia.ts index ed6ef5535..c75649b95 100644 --- a/packages/hooks-react/src/useProtectedMedia.ts +++ b/packages/hooks-react/src/useProtectedMedia.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useQuery } 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'; @@ -7,21 +7,25 @@ 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]); + const { isLoading, data: isGeoBlocked } = useQuery( + ['media', 'geo', item.mediaid], + () => { + const m3u8 = contentProtectionQuery.data?.sources.find((source) => source.file.indexOf('.m3u8') !== -1); + if (m3u8) { + return fetch(m3u8.file, { method: 'HEAD' }).then((response) => response.status === 403); + } + return false; + }, + { + enabled: contentProtectionQuery.isFetched, + }, + ); return { ...contentProtectionQuery, isGeoBlocked, + isLoading: contentProtectionQuery.isLoading || isLoading, }; } diff --git a/packages/theme/assets/icons/edit.svg b/packages/theme/assets/icons/edit.svg deleted file mode 100644 index 03cb69576..000000000 --- a/packages/theme/assets/icons/edit.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/packages/theme/assets/icons/plus.svg b/packages/theme/assets/icons/plus.svg deleted file mode 100644 index 7500579b9..000000000 --- a/packages/theme/assets/icons/plus.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/packages/theme/assets/profiles/default_avatar.png b/packages/theme/assets/profiles/default_avatar.png deleted file mode 100644 index e90ab780e..000000000 Binary files a/packages/theme/assets/profiles/default_avatar.png and /dev/null differ diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 624baee47..547c4eda2 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@adyen/adyen-web": "^5.66.1", - "@inplayer-org/inplayer.js": "^3.13.25", + "@videodock/tile-slider": "^2.0.0", "classnames": "^2.5.1", "date-fns": "^2.30.0", "dompurify": "^2.5.5", @@ -46,8 +46,8 @@ "vi-fetch": "^0.8.0", "vite-plugin-svgr": "^4.2.0", "vitest": "^1.6.0", - "wicg-inert": "^3.1.2", - "vitest-axe": "^1.0.0-pre.3" + "vitest-axe": "^1.0.0-pre.3", + "wicg-inert": "^3.1.2" }, "peerDependencies": { "@jwp/ott-common": "*", diff --git a/packages/ui-react/src/components/Card/Card.module.scss b/packages/ui-react/src/components/Card/Card.module.scss index 7b7b0cb56..f1b429d26 100644 --- a/packages/ui-react/src/components/Card/Card.module.scss +++ b/packages/ui-react/src/components/Card/Card.module.scss @@ -29,13 +29,12 @@ &.featured { .title { + display: inline-block; height: variables.$base-line-height; padding-right: 8px; font-family: var(--body-font-family); font-size: 34px; - line-height: variables.$base-line-height; white-space: nowrap; - text-overflow: ellipsis; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.75); @include responsive.mobile-only { @@ -103,12 +102,6 @@ left: 0; width: 100%; height: 100%; - opacity: 0; - transition: opacity 0.3s ease-out; - - &.visible { - opacity: 1; - } } } @@ -145,6 +138,7 @@ $aspects: ((1, 1), (2, 1), (2, 3), (4, 3), (5, 3), (16, 9), (9, 16), (9, 13)); } .title { + display: -webkit-box; height: calc(variables.$base-line-height * 2); overflow: hidden; color: var(--card-color); @@ -153,7 +147,10 @@ $aspects: ((1, 1), (2, 1), (2, 3), (4, 3), (5, 3), (16, 9), (9, 16), (9, 13)); font-size: 1em; line-height: variables.$base-line-height; text-align: left; + text-overflow: ellipsis; 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); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; &.loading { &::before { diff --git a/packages/ui-react/src/components/Card/Card.test.tsx b/packages/ui-react/src/components/Card/Card.test.tsx index 61ac21205..b1d52dd3e 100644 --- a/packages/ui-react/src/components/Card/Card.test.tsx +++ b/packages/ui-react/src/components/Card/Card.test.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { axe } from 'vitest-axe'; -import { fireEvent } from '@testing-library/react'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import { renderWithRouter } from '../../../test/utils'; @@ -26,18 +25,6 @@ describe('', () => { expect(getByAltText('')).toHaveAttribute('src', 'http://movie.jpg?width=320'); }); - it('makes the image visible after load', () => { - const { getByAltText } = renderWithRouter(); - const image = getByAltText(''); // Image alt is intentionally empty for a11y - - expect(image).toHaveAttribute('src', 'http://movie.jpg?width=320'); - expect(image).toHaveStyle({ opacity: 0 }); - - fireEvent.load(image); - - expect(image).toHaveStyle({ opacity: 1 }); - }); - it('should render anchor tag', () => { const { container } = renderWithRouter(); expect(container).toMatchSnapshot(); diff --git a/packages/ui-react/src/components/Card/Card.tsx b/packages/ui-react/src/components/Card/Card.tsx index 371dbc5e3..1deb3f7db 100644 --- a/packages/ui-react/src/components/Card/Card.tsx +++ b/packages/ui-react/src/components/Card/Card.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState } from 'react'; +import React, { memo } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -57,7 +57,6 @@ function Card({ } = useTranslation(['common', 'video']); // t('play_item') - const [imageLoaded, setImageLoaded] = useState(false); const cardClassName = classNames(styles.card, { [styles.featured]: featured, [styles.disabled]: disabled, @@ -66,16 +65,13 @@ function Card({ const posterClassNames = classNames(styles.poster, aspectRatioClass, { [styles.current]: isCurrent, }); - const posterImageClassNames = classNames(styles.posterImage, { - [styles.visible]: imageLoaded, - }); const isSeriesItem = isSeries(item); const isLive = mediaStatus === MediaStatus.LIVE || isLiveChannel(item); const isScheduled = mediaStatus === MediaStatus.SCHEDULED; const renderTag = () => { - if (loading || disabled || !title) return null; + if (loading || !title) return null; if (isSeriesItem) { return
{t('video:series')}
; @@ -107,7 +103,7 @@ function Card({ tabIndex={disabled ? -1 : tabIndex} data-testid={testId(title)} > - {!featured && !disabled && ( + {!featured && (
{heading} {!!scheduledStart && isLiveEvent(item) && ( @@ -116,10 +112,10 @@ function Card({
)}
- setImageLoaded(true)} alt="" /> + {!loading && (
- {featured && !disabled && heading} + {featured && heading}
{isLocked && (
diff --git a/packages/ui-react/src/components/CustomRegisterField/CustomRegisterField.tsx b/packages/ui-react/src/components/CustomRegisterField/CustomRegisterField.tsx index 5bcb0916d..127ff88ee 100644 --- a/packages/ui-react/src/components/CustomRegisterField/CustomRegisterField.tsx +++ b/packages/ui-react/src/components/CustomRegisterField/CustomRegisterField.tsx @@ -1,6 +1,5 @@ import { type ChangeEventHandler, type FC, type ReactNode, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { RegisterFieldOptions } from '@inplayer-org/inplayer.js'; import type { CustomRegisterFieldVariant } from '@jwp/ott-common/types/account'; import { isTruthyCustomParamValue, testId } from '@jwp/ott-common/src/utils/common'; @@ -22,7 +21,7 @@ export type CustomRegisterFieldCommonProps = { helperText: string; disabled: boolean; required: boolean; - options: RegisterFieldOptions; + options: Record; editing: boolean; lang: string; }>; diff --git a/packages/ui-react/src/components/Header/Header.module.scss b/packages/ui-react/src/components/Header/Header.module.scss index 0d601ac84..90ccf3e50 100644 --- a/packages/ui-react/src/components/Header/Header.module.scss +++ b/packages/ui-react/src/components/Header/Header.module.scss @@ -10,7 +10,7 @@ .header { height: variables.$header-height; - padding: 10px calc(#{variables.$base-spacing} * 2); + padding: 0 calc(#{variables.$base-spacing} * 2); color: var(--header-contrast-color, variables.$white); background: var(--header-background, transparent); @@ -39,6 +39,8 @@ position: relative; display: flex; flex-direction: row; + align-items: center; + gap: variables.$base-spacing; height: 100%; } @@ -66,20 +68,22 @@ // .brand { align-self: center; - margin-right: variables.$base-spacing; } // // Header navigation // .nav { - display: inline-block; + display: flex; flex: 1; align-items: center; + padding: calc(variables.$base-spacing / 2) 0; + overflow: hidden; > ul { margin: 0; padding: 0; + white-space: nowrap; list-style-type: none; li { @@ -157,9 +161,9 @@ &::after { position: absolute; - bottom: calc(((variables.$header-height - 36px) / 2) * -1); - left: 0; - width: 100%; + right: calc(variables.$base-spacing / 2); + bottom: 0; + left: calc(variables.$base-spacing / 2); height: 2px; background-color: variables.$white; content: ''; @@ -177,7 +181,7 @@ @include responsive.mobile-and-tablet() { .header { height: variables.$header-height-mobile; - padding: 10px calc(#{variables.$base-spacing} * 2); + padding: 10px calc(variables.$base-spacing * 2); } .menu { @@ -186,7 +190,6 @@ .brand { flex: 1; - margin-left: variables.$base-spacing; } .nav { @@ -208,8 +211,6 @@ } .brand { - margin-right: 0; - margin-left: 0; text-align: center; } diff --git a/packages/ui-react/src/components/Header/HeaderNavigation.tsx b/packages/ui-react/src/components/Header/HeaderNavigation.tsx index 3158c0932..ddae03a0e 100644 --- a/packages/ui-react/src/components/Header/HeaderNavigation.tsx +++ b/packages/ui-react/src/components/Header/HeaderNavigation.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import Button from '../Button/Button'; @@ -9,10 +9,29 @@ type NavItem = { to: string; }; +const scrollOffset = 100; + const HeaderNavigation = ({ navItems }: { navItems: NavItem[] }) => { + const navRef = useRef(null); + + const focusHandler = (event: React.FocusEvent) => { + if (!navRef.current) return; + + const navRect = navRef.current.getBoundingClientRect(); + const targetRect = (event.target as HTMLElement).getBoundingClientRect(); + + // get the element offset position within the navigation scroll container + const targetScrollTo = targetRect.left + navRef.current.scrollLeft - navRect.left; + // the first half items will reset the scroll offset to 0 + // all elements after will be scrolled into view with an offset, so that the previous item is still visible + const scrollTo = targetScrollTo < navRect.width / 2 ? 0 : targetScrollTo - scrollOffset; + + navRef.current.scrollTo({ left: scrollTo, behavior: 'smooth' }); + }; + return ( -
+ ), [t], ); - const renderLeftControl = useCallback( - (doSlide: () => void) => ( -
(event.key === 'Enter' || event.key === ' ') && handleSlide(doSlide)} - onClick={() => handleSlide(doSlide)} - > + const renderLeftControl: RenderControl = useCallback( + ({ onClick }) => ( +
+ ), - [didSlideBefore, t], - ); - - const renderPaginationDots = (index: number, pageIndex: number) => ( - - ); - - const renderPageIndicator = (pageIndex: number, pages: number) => ( -
- {t('slide_indicator', { page: pageIndex + 1, pages })} -
+ [t], ); - const handleSlide = (doSlide: () => void): void => { - setDidSlideBefore(true); - doSlide(); + const renderPagination: RenderPagination = ({ page, pages }) => { + const items = Array.from({ length: pages }, (_, pageIndex) => pageIndex); + + return ( + <> +
+ {t('slide_indicator', { page: page + 1, pages })} +
+ {featured && ( + + )} + + ); }; if (error || !playlist?.playlist) return

Could not load items

; @@ -147,19 +135,16 @@ const Shelf = ({ return (
{featured ? null : loading ?
:

{title || playlist.title}

} - + + className={styles.slider} items={playlist.playlist} tilesToShow={tilesToShow} - wrapWithEmptyTiles={featured && playlist.playlist.length === 1} - cycleMode={'restart'} + cycleMode={CYCLE_MODE_RESTART} showControls={!loading} - showDots={featured} - transitionTime={'0.3s'} spacing={8} renderLeftControl={renderLeftControl} renderRightControl={renderRightControl} - renderPaginationDots={renderPaginationDots} - renderPageIndicator={renderPageIndicator} + renderPagination={renderPagination} renderTile={renderTile} />
diff --git a/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap b/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap index a2ed26d6c..5257cd707 100644 --- a/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap +++ b/packages/ui-react/src/components/Shelf/__snapshots__/Shelf.test.tsx.snap @@ -6,16 +6,14 @@ exports[`Shelf Component tests > Featured shelf 1`] = ` class="_shelf_81910b" >
-
-
+ +
+ + -
- -
@@ -229,172 +203,184 @@ exports[`Shelf Component tests > Regular shelf 1`] = ` Test Shelf - - -
  • - +
  • +
  • -
    -

    - Movie 2 -

    -
    -
    +

    + Movie 2 +

    +
    - 1 min +
    + 1 min +
    -
  • - - -
  • - +
  • +
  • -
    -

    - Third movie -

    -
    -
    +

    + Third movie +

    +
    - 1 min +
    + 1 min +
    - -
    -
  • -
  • - +
  • +
  • -
    -

    - Last playlist item -

    -
    -
    +

    + Last playlist item +

    +
    - 21 min +
    + 21 min +
    - -
    -
  • - + + + + + diff --git a/packages/ui-react/src/components/TileDock/TileDock.module.scss b/packages/ui-react/src/components/TileDock/TileDock.module.scss deleted file mode 100644 index 73f0ace7a..000000000 --- a/packages/ui-react/src/components/TileDock/TileDock.module.scss +++ /dev/null @@ -1,49 +0,0 @@ -@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; - -.tileDock ul { - display: block; - margin: 0; - padding: 0; - white-space: nowrap; -} -.tileDock li { - display: inline-block; - white-space: normal; - vertical-align: top; - list-style-type: none; -} -.notInView { - opacity: 0.5; - @media (hover: hover) and (pointer: fine) { - opacity: 0.3; - } -} -.tileDock .leftControl { - position: absolute; - top: calc(50% + 22px); - left: 1px; - z-index: 1; - transform: translateY(-100%); -} -.tileDock .rightControl { - position: absolute; - top: calc(50% + 22px); - right: 1px; - z-index: 1; - transform: translateY(-100%); -} -.emptyTile::before { - content: ''; - display: block; - padding-top: 56.25%; - background: rgba(255, 255, 255, 0.12); - border-radius: 4px; -} - -.dots { - position: relative; - display: flex; - justify-content: center; - width: 100%; - margin-top: 12px; -} diff --git a/packages/ui-react/src/components/TileDock/TileDock.tsx b/packages/ui-react/src/components/TileDock/TileDock.tsx deleted file mode 100644 index 252e1b06b..000000000 --- a/packages/ui-react/src/components/TileDock/TileDock.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import classNames from 'classnames'; -import React, { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import styles from './TileDock.module.scss'; - -export type CycleMode = 'stop' | 'restart' | 'endless'; -type Direction = 'left' | 'right'; -type Position = { x: number; y: number }; - -export type TileDockProps = { - items: T[]; - cycleMode?: CycleMode; - tilesToShow?: number; - spacing?: number; - tileHeight?: number; - minimalTouchMovement?: number; - showControls?: boolean; - showDots?: boolean; - animationModeOverride?: boolean; - wrapWithEmptyTiles?: boolean; - transitionTime?: string; - renderTile: (item: T, isInView: boolean) => ReactNode; - renderLeftControl?: (handleClick: () => void) => ReactNode; - renderRightControl?: (handleClick: () => void) => ReactNode; - renderPaginationDots?: (index: number, pageIndex: number) => ReactNode; - renderPageIndicator?: (pageIndex: number, pages: number) => ReactNode; -}; - -type Tile = { - item: T; - key: string; -}; - -const makeTiles = (originalList: T[], slicedItems: T[]): Tile[] => { - const itemIndices: string[] = []; - - return slicedItems.map((item) => { - let key = `tile_${originalList.indexOf(item)}`; - while (itemIndices.includes(key)) { - key += '_'; - } - itemIndices.push(key); - return { item, key }; - }); -}; - -const sliceItems = (items: T[], isMultiPage: boolean, index: number, tilesToShow: number, cycleMode: CycleMode): Tile[] => { - if (!isMultiPage) return makeTiles(items, items); - - const sliceFrom: number = index; - const sliceTo: number = index + tilesToShow * 3; - - const cycleModeEndlessCompensation: number = cycleMode === 'endless' ? tilesToShow : 0; - const listStartClone: T[] = items.slice(0, tilesToShow + cycleModeEndlessCompensation + 1); - const listEndClone: T[] = items.slice(0 - (tilesToShow + cycleModeEndlessCompensation + 1)); - - const itemsWithClones: T[] = [...listEndClone, ...items, ...listStartClone]; - const itemsSlice: T[] = itemsWithClones.slice(sliceFrom, sliceTo + 2); - - return makeTiles(items, itemsSlice); -}; - -function TileDock({ - items, - tilesToShow = 6, - cycleMode = 'endless', - spacing = 12, - minimalTouchMovement = 30, - showControls = true, - animationModeOverride, - transitionTime = '0.6s', - wrapWithEmptyTiles = false, - showDots = false, - renderTile, - renderLeftControl, - renderRightControl, - renderPaginationDots, - renderPageIndicator, -}: TileDockProps) { - const [index, setIndex] = useState(0); - const [slideToIndex, setSlideToIndex] = useState(0); - const [transform, setTransform] = useState(-100); - // Prevent animation mode from changing after first load - const [isAnimated] = useState(animationModeOverride ?? !window.matchMedia('(prefers-reduced-motion)').matches); - const [isAnimationDone, setIsAnimationDone] = useState(false); - const [isAnimationRunning, setIsAnimationRunning] = useState(false); - - const frameRef = useRef() as React.MutableRefObject; - const tileWidth: number = 100 / tilesToShow; - const isMultiPage: boolean = items?.length > tilesToShow; - const transformWithOffset: number = isMultiPage ? 100 - tileWidth * (tilesToShow + 1) + transform : wrapWithEmptyTiles ? -100 : 0; - const pages = items.length / tilesToShow; - const tileList: Tile[] = useMemo(() => { - return sliceItems(items, isMultiPage, index, tilesToShow, cycleMode); - }, [items, isMultiPage, index, tilesToShow, cycleMode]); - - const transitionBasis: string = isMultiPage && isAnimated && isAnimationRunning ? `transform ${transitionTime} ease` : ''; - - const needControls: boolean = showControls && isMultiPage; - const showLeftControl: boolean = needControls && !(cycleMode === 'stop' && index === 0); - const showRightControl: boolean = needControls && !(cycleMode === 'stop' && index === items.length - tilesToShow); - - const slide = useCallback( - (direction: Direction): void => { - // Debounce slide events based on if the animation is running - if (isAnimationRunning) { - return; - } - - const directionFactor = direction === 'right' ? 1 : -1; - let nextIndex: number = index + tilesToShow * directionFactor; - - if (nextIndex < 0) { - if (cycleMode === 'stop') nextIndex = 0; - if (cycleMode === 'restart') nextIndex = index === 0 ? 0 - tilesToShow : 0; - } - - if (nextIndex > items.length - tilesToShow) { - if (cycleMode === 'stop') nextIndex = items.length - tilesToShow; - if (cycleMode === 'restart') nextIndex = index >= items.length - tilesToShow ? items.length : items.length - tilesToShow; - } - - const steps: number = Math.abs(index - nextIndex); - const movement: number = steps * tileWidth * (0 - directionFactor); - - setSlideToIndex(nextIndex); - setTransform(-100 + movement); - - // If this is an animated slider, start the animation 'slide' - if (isAnimated) { - setIsAnimationRunning(true); - } - // If not anmiated, trigger the post animation code right away - else { - setIsAnimationDone(true); - } - }, - [isAnimated, cycleMode, index, items.length, tileWidth, tilesToShow, isAnimationRunning], - ); - - const handleTouchStart = useCallback( - (event: React.TouchEvent): void => { - const touchPosition: Position = { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }; - - function handleTouchMove(this: Document, event: TouchEvent): void { - const newPosition: Position = { - x: event.changedTouches[0].clientX, - y: event.changedTouches[0].clientY, - }; - const movementX: number = Math.abs(newPosition.x - touchPosition.x); - const movementY: number = Math.abs(newPosition.y - touchPosition.y); - - if (movementX > movementY && movementX > 10) { - event.preventDefault(); - event.stopPropagation(); - } - } - - function handleTouchEnd(this: Document, event: TouchEvent): void { - const newPosition = { - x: event.changedTouches[0].clientX, - y: event.changedTouches[0].clientY, - }; - - const movementX: number = Math.abs(newPosition.x - touchPosition.x); - const movementY: number = Math.abs(newPosition.y - touchPosition.y); - const direction: Direction = newPosition.x < touchPosition.x ? 'right' : 'left'; - - if (movementX > minimalTouchMovement && movementX > movementY) { - slide(direction); - } - - cleanup(); - } - - function handleTouchCancel() { - cleanup(); - } - - function cleanup() { - document.removeEventListener('touchmove', handleTouchMove); - document.removeEventListener('touchend', handleTouchEnd); - document.removeEventListener('touchcancel', handleTouchCancel); - } - - document.addEventListener('touchmove', handleTouchMove, { passive: false }); - document.addEventListener('touchend', handleTouchEnd); - document.addEventListener('touchcancel', handleTouchCancel); - }, - [minimalTouchMovement, slide], - ); - - // Run code after the slide animation to set the new index - useLayoutEffect(() => { - const postAnimationCleanup = (): void => { - let resetIndex: number = slideToIndex; - - resetIndex = resetIndex >= items.length ? slideToIndex - items.length : resetIndex; - resetIndex = resetIndex < 0 ? items.length + slideToIndex : resetIndex; - - if (resetIndex !== slideToIndex) { - setSlideToIndex(resetIndex); - } - - setIndex(resetIndex); - setTransform(-100); - setIsAnimationRunning(false); - setIsAnimationDone(false); - }; - - if (isAnimationDone) { - postAnimationCleanup(); - } - }, [isAnimationDone, index, items.length, slideToIndex, tileWidth, tilesToShow, transitionBasis]); - - const handleTransitionEnd = (event: React.TransitionEvent) => { - if (event.target === frameRef.current) { - setIsAnimationDone(true); - } - }; - - const ulStyle = { - transform: `translate3d(${transformWithOffset}%, 0, 0)`, - // prettier-ignore - 'WebkitTransform': `translate3d(${transformWithOffset}%, 0, 0)`, - transition: transitionBasis, - marginLeft: -spacing / 2, - marginRight: -spacing / 2, - }; - - const slideOffset = index - slideToIndex; - - const paginationDots = () => { - if (showDots && isMultiPage && !!renderPaginationDots) { - const length = pages; - - // Using aria-hidden="true" due to virtualization issues, making pagination purely visual for now. This is a temporary fix pending a more accessible solution. - return ( - - ); - } - }; - - return ( - -
    - {showLeftControl && !!renderLeftControl &&
    {renderLeftControl(() => slide('left'))}
    } -
      - {wrapWithEmptyTiles ? ( -
    • - {renderTile(tile.item, isInView)} -
    • - ); - })} - {wrapWithEmptyTiles ? ( -
    - {showRightControl && !!renderRightControl &&
    {renderRightControl(() => slide('right'))}
    } -
    - {paginationDots()} - {isMultiPage && renderPageIndicator && renderPageIndicator(Math.ceil(index / tilesToShow), Math.ceil(pages))} -
    - ); -} - -export default TileDock; diff --git a/packages/ui-react/src/components/UserMenu/UserMenu.module.scss b/packages/ui-react/src/components/UserMenu/UserMenu.module.scss index 24c78503d..162313747 100644 --- a/packages/ui-react/src/components/UserMenu/UserMenu.module.scss +++ b/packages/ui-react/src/components/UserMenu/UserMenu.module.scss @@ -20,15 +20,6 @@ top: 10px; } -.buttonContainer { - // this is a visual fix for putting a button with background besides a transparent button - margin-left: variables.$base-spacing; - - > button:first-child { - margin-right: calc(#{variables.$base-spacing} / 2); - } -} - .menuItems { width: auto; margin: 0; diff --git a/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx b/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx index eebe0e0a2..81897f3ca 100644 --- a/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx +++ b/packages/ui-react/src/components/UserMenu/UserMenu.test.tsx @@ -8,20 +8,7 @@ import UserMenu from './UserMenu'; describe('', () => { test('renders and matches snapshot', () => { const { container } = renderWithRouter( - , + , ); expect(container).toMatchSnapshot(); @@ -29,20 +16,7 @@ describe('', () => { test('WCAG 2.2 (AA) compliant', async () => { const { container } = renderWithRouter( - , + , ); expect(await axe(container, { runOnly: ['wcag21a', 'wcag21aa', 'wcag22aa'] })).toHaveNoViolations(); diff --git a/packages/ui-react/src/components/UserMenu/UserMenu.tsx b/packages/ui-react/src/components/UserMenu/UserMenu.tsx index 23f047d90..0b6cce988 100644 --- a/packages/ui-react/src/components/UserMenu/UserMenu.tsx +++ b/packages/ui-react/src/components/UserMenu/UserMenu.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import AccountCircle from '@jwp/ott-theme/assets/icons/account_circle.svg?react'; -import type { Profile } from '@jwp/ott-common/types/profiles'; import Icon from '../Icon/Icon'; -import ProfileCircle from '../ProfileCircle/ProfileCircle'; import Popover from '../Popover/Popover'; import Panel from '../Panel/Panel'; import Button from '../Button/Button'; @@ -21,22 +19,17 @@ type Props = { onSignUpButtonClick: () => void; isLoggedIn: boolean; favoritesEnabled: boolean; - profilesEnabled: boolean; - profile: Profile | null; - profiles: Profile[] | null; - profileLoading: boolean; - onSelectProfile: (params: { id: string; avatarUrl: string }) => void; }; -const UserMenu = ({ isLoggedIn, favoritesEnabled, open, onClose, onOpen, onLoginButtonClick, onSignUpButtonClick, profilesEnabled, profile }: Props) => { +const UserMenu = ({ isLoggedIn, favoritesEnabled, open, onClose, onOpen, onLoginButtonClick, onSignUpButtonClick }: Props) => { const { t } = useTranslation('menu'); if (!isLoggedIn) { return ( -
    + <>
    + ); } @@ -50,12 +43,12 @@ const UserMenu = ({ isLoggedIn, favoritesEnabled, open, onClose, onOpen, onLogin onClick={onOpen} onBlur={onClose} > - {profilesEnabled && profile ? : } +
    - +
    diff --git a/packages/ui-react/src/components/UserMenuNav/UserMenuNav.tsx b/packages/ui-react/src/components/UserMenuNav/UserMenuNav.tsx index d1717585e..45d23830f 100644 --- a/packages/ui-react/src/components/UserMenuNav/UserMenuNav.tsx +++ b/packages/ui-react/src/components/UserMenuNav/UserMenuNav.tsx @@ -9,7 +9,6 @@ import Favorite from '@jwp/ott-theme/assets/icons/favorite.svg?react'; import BalanceWallet from '@jwp/ott-theme/assets/icons/balance_wallet.svg?react'; import Exit from '@jwp/ott-theme/assets/icons/exit.svg?react'; import { PATH_USER_ACCOUNT, PATH_USER_FAVORITES, PATH_USER_PAYMENTS } from '@jwp/ott-common/src/paths'; -import type { Profile } from '@jwp/ott-common/types/profiles'; import styles from '../UserMenu/UserMenu.module.scss'; // TODO inherit styling import MenuButton from '../MenuButton/MenuButton'; @@ -21,7 +20,6 @@ type Props = { showPaymentItems: boolean; onButtonClick?: () => void; titleId?: string; - currentProfile?: Profile | null; favoritesEnabled?: boolean; }; diff --git a/packages/ui-react/src/components/VideoDetails/VideoDetails.module.scss b/packages/ui-react/src/components/VideoDetails/VideoDetails.module.scss index 4d406e36f..334ebba6a 100644 --- a/packages/ui-react/src/components/VideoDetails/VideoDetails.module.scss +++ b/packages/ui-react/src/components/VideoDetails/VideoDetails.module.scss @@ -108,28 +108,21 @@ flex-wrap: wrap; justify-content: flex-start; align-items: center; + gap: calc(variables.$base-spacing / 2); width: 100%; > button { + flex: 1; justify-content: center; - - &:not(:last-child) { - margin-right: 12px; - } - - @include responsive.mobile-and-tablet() { - flex: 1; - margin: 6px 6px 6px 0; - - &:first-child { - flex-basis: 100%; - margin: 0 0 6px 0; - } - &:last-child { - margin-right: 0; - } + + &:first-child { + flex-basis: 100%; } } + + @include responsive.tablet-and-larger() { + max-width: 350px; + } } .poster { diff --git a/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.module.scss b/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.module.scss index dc7511e4a..93ccd197f 100644 --- a/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.module.scss +++ b/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.module.scss @@ -26,32 +26,15 @@ .inlinePlayerMetadata { display: flex; align-items: center; + gap: variables.$base-spacing; margin-bottom: variables.$base-spacing; padding-bottom: variables.$base-spacing; border-bottom: 1px solid rgba(255, 255, 255, 0.3); - > button { - min-width: fit-content; - margin-left: 8px; - } - @include responsive.mobile-and-small-tablet() { flex-wrap: wrap; + padding-bottom: 0; border-bottom: 0; - > button:nth-last-child(3) { - flex: 1 1 100%; - margin: 0 0 8px 0; - } - - > button:nth-last-child(2) { - flex: 0 0 calc(50% - 4px); - margin: 0 4px 0 0; - } - - > button:nth-last-child(1) { - flex: 0 0 calc(50% - 4px); - margin: 0 0 0 4px; - } } } @@ -80,16 +63,26 @@ div.title { letter-spacing: 0.15px; } -.secondaryMetadata { - margin-top: 24px; - margin-bottom: 8px; - font-size: 20px; - line-height: variables.$base-line-height; - letter-spacing: 0.5px; +.buttonBar { + display: flex; + gap: calc(variables.$base-spacing / 2); + flex: 1; - @include responsive.mobile-only() { - margin: 4px 0; - font-size: 18px; + > button { + white-space: nowrap; + } + + @include responsive.mobile-and-small-tablet() { + flex-wrap: wrap; + + > button:nth-last-child(3) { + flex-basis: 100%; + } + + > button:nth-last-child(2), + > button:nth-last-child(1) { + flex: 1; + } } } diff --git a/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.tsx b/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.tsx index 7b979c499..2e7d88777 100644 --- a/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.tsx +++ b/packages/ui-react/src/components/VideoDetailsInline/VideoDetailsInline.tsx @@ -28,9 +28,11 @@ const VideoDetailsInline: React.FC = ({ title, description, primaryMetada {title}
    {primaryMetadata}
    - {trailerButton} - {favoriteButton} - {shareButton} +
    + {trailerButton} + {favoriteButton} + {shareButton} +
    {isMobile ? ( diff --git a/packages/ui-react/src/containers/Cinema/Cinema.tsx b/packages/ui-react/src/containers/Cinema/Cinema.tsx index 43b2b87ab..0449a15c0 100644 --- a/packages/ui-react/src/containers/Cinema/Cinema.tsx +++ b/packages/ui-react/src/containers/Cinema/Cinema.tsx @@ -109,6 +109,7 @@ const Cinema: React.FC = ({ onUserActive={handleUserActive} onUserInActive={handleUserInactive} onNext={handleNext} + onBackClick={onClose} liveEndDateTime={liveEndDateTime} liveFromBeginning={liveFromBeginning} liveStartDateTime={liveStartDateTime} diff --git a/packages/ui-react/src/containers/HeaderSearch/HeaderSearch.module.scss b/packages/ui-react/src/containers/HeaderSearch/HeaderSearch.module.scss index ee12f170d..4fa28d5f2 100644 --- a/packages/ui-react/src/containers/HeaderSearch/HeaderSearch.module.scss +++ b/packages/ui-react/src/containers/HeaderSearch/HeaderSearch.module.scss @@ -5,16 +5,17 @@ @use '@jwp/ott-ui-react/src/styles/mixins/utils'; .searchContainer { - position: absolute; - left: -#{variables.$search-bar-width-desktop}; display: flex; width: variables.$search-bar-width-desktop; > div:first-child { flex: 1; + margin-right: calc(variables.$base-spacing / 2); } @include responsive.mobile-and-tablet() { + position: absolute; + top: 0; right: 0; left: 0; width: auto; diff --git a/packages/ui-react/src/containers/HeaderUserMenu/HeaderUserMenu.tsx b/packages/ui-react/src/containers/HeaderUserMenu/HeaderUserMenu.tsx index 79e31d070..8044bbf1d 100644 --- a/packages/ui-react/src/containers/HeaderUserMenu/HeaderUserMenu.tsx +++ b/packages/ui-react/src/containers/HeaderUserMenu/HeaderUserMenu.tsx @@ -44,11 +44,6 @@ const HeaderUserMenu = () => { favoritesEnabled={favoritesEnabled} onLoginButtonClick={loginButtonClickHandler} onSignUpButtonClick={signUpButtonClickHandler} - profilesEnabled={false} - profile={null} - profiles={[]} - profileLoading={false} - onSelectProfile={() => {}} /> ); }; diff --git a/packages/ui-react/src/containers/Layout/Layout.test.tsx b/packages/ui-react/src/containers/Layout/Layout.test.tsx index 691ad9c64..6ba246c09 100644 --- a/packages/ui-react/src/containers/Layout/Layout.test.tsx +++ b/packages/ui-react/src/containers/Layout/Layout.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { axe } from 'vitest-axe'; -import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { mockService } from '@jwp/ott-common/test/mockService'; import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; @@ -11,7 +10,6 @@ import Layout from './Layout'; describe('', () => { beforeEach(() => { - mockService(ProfileController, { isEnabled: vi.fn().mockReturnValue(false) }); mockService(AccountController, { getFeatures: () => DEFAULT_FEATURES }); }); diff --git a/packages/ui-react/src/containers/PlayerContainer/PlayerContainer.tsx b/packages/ui-react/src/containers/PlayerContainer/PlayerContainer.tsx index 873e5da53..7360be315 100644 --- a/packages/ui-react/src/containers/PlayerContainer/PlayerContainer.tsx +++ b/packages/ui-react/src/containers/PlayerContainer/PlayerContainer.tsx @@ -20,6 +20,7 @@ type Props = { onUserActive?: () => void; onUserInActive?: () => void; onNext?: () => void; + onBackClick?: () => void; feedId?: string; liveStartDateTime?: string | null; liveEndDateTime?: string | null; @@ -37,6 +38,7 @@ const PlayerContainer: React.FC = ({ onUserActive, onUserInActive, onNext, + onBackClick, liveEndDateTime, liveFromBeginning, liveStartDateTime, @@ -88,6 +90,7 @@ const PlayerContainer: React.FC = ({ onUserActive={onUserActive} onUserInActive={onUserInActive} onNext={onNext} + onBackClick={onBackClick} onPlaylistItemCallback={handlePlaylistItemCallback} startTime={startTime} autostart={autostart} diff --git a/packages/ui-react/src/containers/Profiles/CreateProfile.tsx b/packages/ui-react/src/containers/Profiles/CreateProfile.tsx deleted file mode 100644 index 8e751a934..000000000 --- a/packages/ui-react/src/containers/Profiles/CreateProfile.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router'; -import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import type { UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; -import { useCreateProfile, useProfileErrorHandler, useProfiles } from '@jwp/ott-hooks-react/src/useProfiles'; -import type { ProfileFormValues } from '@jwp/ott-common/types/profiles'; -import { createURL } from '@jwp/ott-common/src/utils/urlFormatting'; -import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; - -import styles from '../../pages/User/User.module.scss'; -import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; - -import Form from './Form'; -import AVATARS from './avatarUrls.json'; - -const CreateProfile = () => { - const navigate = useNavigate(); - const { - query: { isLoading: loadingProfilesList }, - profilesEnabled, - } = useProfiles(); - - const [avatarUrl, setAvatarUrl] = useState(AVATARS[Math.floor(Math.random() * AVATARS.length)]); - - const breakpoint: Breakpoint = useBreakpoint(); - const isMobile = breakpoint === Breakpoint.xs; - - useEffect(() => { - if (!profilesEnabled) navigate('/'); - }, [profilesEnabled, navigate]); - - const initialValues = { - name: '', - adult: 'true', - avatar_url: avatarUrl, - pin: undefined, - }; - - const createProfile = useCreateProfile({ - onSuccess: (res) => { - const id = res?.id; - - !!id && navigate(createURL(PATH_USER_PROFILES, { success: 'true', id })); - }, - }); - - const handleErrors = useProfileErrorHandler(); - - const createProfileHandler: UseFormOnSubmitHandler = async (formData, { setSubmitting, setErrors }) => - createProfile.mutate( - { - name: formData.name, - adult: formData.adult === 'true', - avatar_url: formData.avatar_url, - }, - { - onError: (e: unknown) => handleErrors(e, setErrors), - onSettled: () => { - setSubmitting(false); - }, - }, - ); - - if (loadingProfilesList) return ; - - return ( -
    -
    -
    -
    -
    - ); -}; - -export default CreateProfile; diff --git a/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx b/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx deleted file mode 100644 index f7051a057..000000000 --- a/packages/ui-react/src/containers/Profiles/DeleteProfile.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Navigate, useLocation, useNavigate, useParams } from 'react-router'; -import { useTranslation } from 'react-i18next'; -import useQueryParam from '@jwp/ott-ui-react/src/hooks/useQueryParam'; -import { useDeleteProfile } from '@jwp/ott-hooks-react/src/useProfiles'; -import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; - -import Button from '../../components/Button/Button'; -import Dialog from '../../components/Dialog/Dialog'; -import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; -import { createURLFromLocation } from '../../utils/location'; - -import styles from './Profiles.module.scss'; - -const DeleteProfile = () => { - const navigate = useNavigate(); - const { id } = useParams(); - const location = useLocation(); - - const { t } = useTranslation('user'); - - const viewParam = useQueryParam('action'); - const [view, setView] = useState(viewParam); - const [isDeleting, setIsDeleting] = useState(false); - - const closeHandler = () => { - navigate(createURLFromLocation(location, { action: null })); - }; - - const deleteProfile = useDeleteProfile({ - onMutate: closeHandler, - onSuccess: () => navigate(PATH_USER_PROFILES), - onError: () => setIsDeleting(false), - }); - - const deleteHandler = async () => id && deleteProfile.mutate({ id }); - - useEffect(() => { - // make sure the last view is rendered even when the modal gets closed - if (viewParam) setView(viewParam); - }, [viewParam]); - - if (!id) { - return ; - } - - if (view !== 'delete-profile') return null; - return ( -
    - - {isDeleting && } -
    -
    -

    {t('profile.delete')}

    -

    {t('profile.delete_confirmation')}

    -
    -
    -
    -
    -
    -
    - ); -}; - -export default DeleteProfile; diff --git a/packages/ui-react/src/containers/Profiles/EditProfile.tsx b/packages/ui-react/src/containers/Profiles/EditProfile.tsx deleted file mode 100644 index 009ae2eed..000000000 --- a/packages/ui-react/src/containers/Profiles/EditProfile.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useQuery } from 'react-query'; -import { useLocation, useNavigate, useParams } from 'react-router'; -import { useTranslation } from 'react-i18next'; -import classNames from 'classnames'; -import { getModule } from '@jwp/ott-common/src/modules/container'; -import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; -import type { UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; -import { useProfileErrorHandler, useUpdateProfile } from '@jwp/ott-hooks-react/src/useProfiles'; -import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import type { ProfileFormValues } from '@jwp/ott-common/types/profiles'; -import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; - -import styles from '../../pages/User/User.module.scss'; -import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; -import Button from '../../components/Button/Button'; -import { createURLFromLocation } from '../../utils/location'; - -import DeleteProfile from './DeleteProfile'; -import Form from './Form'; -import profileStyles from './Profiles.module.scss'; - -type EditProfileProps = { - contained?: boolean; -}; - -const EditProfile = ({ contained = false }: EditProfileProps) => { - const params = useParams(); - const { id } = params; - const location = useLocation(); - const navigate = useNavigate(); - const { t } = useTranslation('user'); - - const profileController = getModule(ProfileController); - - const breakpoint: Breakpoint = useBreakpoint(); - const isMobile = breakpoint === Breakpoint.xs; - - const { - data: profileDetails, - isLoading, - isFetching, - } = useQuery(['getProfileDetails'], () => profileController.getProfileDetails({ id: id || '' }), { - staleTime: 0, - }); - - const initialValues = useMemo(() => { - return { - id: profileDetails?.id || '', - name: profileDetails?.name || '', - adult: profileDetails?.adult ? 'true' : 'false', - avatar_url: profileDetails?.avatar_url || '', - pin: undefined, - }; - }, [profileDetails?.id, profileDetails?.name, profileDetails?.adult, profileDetails?.avatar_url]); - - const [selectedAvatar, setSelectedAvatar] = useState(initialValues?.avatar_url); - - useEffect(() => { - setSelectedAvatar(profileDetails?.avatar_url); - }, [profileDetails?.avatar_url]); - - if (!id || (!isFetching && !isLoading && !profileDetails)) { - navigate(PATH_USER_PROFILES); - } - - const updateProfile = useUpdateProfile({ onSuccess: () => navigate(PATH_USER_PROFILES) }); - - const handleErrors = useProfileErrorHandler(); - - const updateProfileHandler: UseFormOnSubmitHandler = async (formData, { setErrors, setSubmitting }) => - updateProfile.mutate( - { - id: id, - name: formData.name, - adult: formData.adult === 'true', - avatar_url: formData.avatar_url || profileDetails?.avatar_url, - }, - { - onError: (e: unknown) => handleErrors(e, setErrors), - onSettled: () => { - setSubmitting(false); - }, - }, - ); - - if (isLoading || isFetching) return ; - - return ( -
    -
    - -
    -
    -

    {t('profile.delete')}

    -
    -
    {t(profileDetails?.default ? 'profile.delete_main' : 'profile.delete_description')}
    - {!profileDetails?.default && ( -
    -
    - -
    - ); -}; - -export default EditProfile; diff --git a/packages/ui-react/src/containers/Profiles/Form.tsx b/packages/ui-react/src/containers/Profiles/Form.tsx deleted file mode 100644 index a367fd538..000000000 --- a/packages/ui-react/src/containers/Profiles/Form.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect } from 'react'; -import { number, object, string } from 'yup'; -import { useNavigate } from 'react-router'; -import { useTranslation } from 'react-i18next'; -import classNames from 'classnames'; -import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; -import useForm, { type UseFormOnSubmitHandler } from '@jwp/ott-hooks-react/src/useForm'; -import type { ProfileFormValues } from '@jwp/ott-common/types/profiles'; -import { PATH_USER_PROFILES } from '@jwp/ott-common/src/paths'; - -import styles from '../../pages/User/User.module.scss'; -import Button from '../../components/Button/Button'; -import Dropdown from '../../components/form-fields/Dropdown/Dropdown'; -import FormFeedback from '../../components/FormFeedback/FormFeedback'; -import TextField from '../../components/form-fields/TextField/TextField'; -import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; -import ProfileBox from '../../components/ProfileBox/ProfileBox'; - -import AVATARS from './avatarUrls.json'; -import profileStyles from './Profiles.module.scss'; - -type Props = { - initialValues: ProfileFormValues; - formHandler: UseFormOnSubmitHandler; - selectedAvatar?: { - set: (avatarUrl: string) => void; - value: string; - }; - showCancelButton?: boolean; - showContentRating?: boolean; - isMobile?: boolean; -}; - -const Form = ({ initialValues, formHandler, selectedAvatar, showCancelButton = true, showContentRating = false, isMobile = false }: Props) => { - const navigate = useNavigate(); - const { t } = useTranslation('user'); - const { profile } = useProfileStore(); - - const options: { value: string; label: string }[] = [ - { label: t('profile.adult'), value: 'true' }, - { label: t('profile.kids'), value: 'false' }, - ]; - - const { handleSubmit, handleChange, values, errors, submitting, setValue } = useForm({ - initialValues, - validationSchema: object().shape({ - id: string(), - name: string() - .trim() - .required(t('profile.validation.name.required')) - .min(3, t('profile.validation.name.too_short', { charactersCount: 3 })) - .max(30, t('profile.validation.name.too_long', { charactersCount: 30 })) - .matches(/^[a-zA-Z0-9\s]*$/, t('profile.validation.name.invalid_characters')), - adult: string().required(), - avatar_url: string(), - pin: number(), - }), - onSubmit: formHandler, - }); - useEffect(() => { - setValue('avatar_url', selectedAvatar?.value || profile?.avatar_url || ''); - }, [profile?.avatar_url, selectedAvatar?.value, setValue]); - - const formLabel = t('profile.info'); - - return ( - -
    -
    -

    {formLabel}

    -
    -
    {t('profile.description')}
    -
    - {errors.form ? {errors.form} : null} - {submitting && } - - {showContentRating && ( - - )} -
    -
    -
    -

    {t('profile.avatar')}

    -
    - {AVATARS.map((avatarUrl) => ( - null} - onClick={() => selectedAvatar?.set(avatarUrl)} - selected={selectedAvatar?.value === avatarUrl} - key={avatarUrl} - adult={true} - image={avatarUrl} - /> - ))} -
    -
    - <> -
    - - ); -}; - -export default Form; diff --git a/packages/ui-react/src/containers/Profiles/Profiles.module.scss b/packages/ui-react/src/containers/Profiles/Profiles.module.scss deleted file mode 100644 index c4f76a60b..000000000 --- a/packages/ui-react/src/containers/Profiles/Profiles.module.scss +++ /dev/null @@ -1,131 +0,0 @@ -@use '@jwp/ott-ui-react/src/styles/variables'; - -.headings { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 24px; -} - -.heading { - font-weight: bold; - font-size: 24px; -} - -.profileInfo { - display: flex; - justify-content: flex-start; - align-items: center; - padding-bottom: 20px; -} - -.paragraph { - margin: 0; - font-size: 36px; -} - -.wrapper { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - gap: 50px; - padding: 80px 20px; - @media (max-width: 768px) { - padding: 80px 0; - } - @media (max-width: 330px) { - height: auto; - padding: 80px 20px; - } -} - -.flex { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - gap: 40px; -} - -.formFields { - display: flex; - flex-direction: column; - max-width: 350px; - gap: 10px; - margin-bottom: 20px; -} - -.avatar { - img { - position: relative; - width: 100%; - } -} - -.overlayBox { - position: relative; -} - -.deleteModal { - .deleteButtons { - display: flex; - justify-content: flex-end; - gap: 6px; - } - h2 { - font-weight: 400; - font-size: 20px; - } - p { - font-size: 16px; - } -} - -.contained { - margin: 0; -} - -.divider { - margin: 24px 0 24px; - border: none; - border-top: 1px solid rgba(variables.$white, 0.34); -} - -.noBottomBorder { - margin-bottom: none; - padding-bottom: 0; - border-bottom: none; -} - -.avatarsContainer { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - grid-gap: 16px; - width: 100%; - padding: 32px 0; - justify-items: center; -} - -.buttonContainer { - @media (max-width: 768px) { - width: 100%; - margin-top: auto; - padding: 10px; - } -} - -.nameHeading { - padding-bottom: 6px; - font-size: 20px; -} - -.modalActions { - display: flex; - justify-content: flex-end; - width: 100%; - gap: 8px; -} diff --git a/packages/ui-react/src/containers/Profiles/Profiles.tsx b/packages/ui-react/src/containers/Profiles/Profiles.tsx deleted file mode 100644 index 58baf0b06..000000000 --- a/packages/ui-react/src/containers/Profiles/Profiles.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router'; -import { shallow } from '@jwp/ott-common/src/utils/compare'; -import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router-dom'; -import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import { useProfiles, useSelectProfile } from '@jwp/ott-hooks-react/src/useProfiles'; -import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import { PATH_USER_PROFILES, PATH_USER_PROFILES_CREATE, PATH_USER_PROFILES_EDIT } from '@jwp/ott-common/src/paths'; -import type { Profile } from '@jwp/ott-common/types/profiles'; - -import ProfileBox from '../../components/ProfileBox/ProfileBox'; -import AddNewProfile from '../../components/ProfileBox/AddNewProfile'; -import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; -import Button from '../../components/Button/Button'; -import Alert from '../../components/Alert/Alert'; - -import styles from './Profiles.module.scss'; - -const MAX_PROFILES = 4; - -type Props = { - editMode?: boolean; -}; - -const Profiles = ({ editMode = false }: Props) => { - const navigate = useNavigate(); - const { t } = useTranslation('user'); - const { loading, user } = useAccountStore(({ loading, user }) => ({ loading, user }), shallow); - - const [params] = useSearchParams(); - const creationSuccess = params.get('success') === 'true'; - const createdProfileId = params.get('id'); - - const breakpoint: Breakpoint = useBreakpoint(); - const isMobile = breakpoint === Breakpoint.xs; - - const { - query: { data, isLoading, isFetching, isError }, - profilesEnabled, - } = useProfiles(); - - const activeProfiles = data?.collection.length || 0; - const canAddNew = activeProfiles < MAX_PROFILES; - - useEffect(() => { - if (!profilesEnabled || !user?.id || isError) { - navigate('/'); - } - }, [profilesEnabled, navigate, user?.id, isError]); - - const selectProfile = useSelectProfile({ - onSuccess: () => navigate('/'), - onError: () => navigate(PATH_USER_PROFILES), - }); - - const createdProfileData = data?.collection.find((profile: Profile) => profile.id === createdProfileId); - - if (loading || isLoading || isFetching) return ; - - return ( - <> -
    - {activeProfiles === 0 ? ( -
    -

    {t('profile.no_one_watching')}

    -

    {t('profile.create_message')}

    -
    - ) : ( -

    {t('account.who_is_watching')}

    - )} -
    - {data?.collection?.map((profile: Profile) => ( - navigate(`/u/profiles/edit/${profile.id}`)} - onClick={() => selectProfile.mutate({ id: profile.id, avatarUrl: profile.avatar_url })} - key={profile.id} - name={profile.name} - adult={profile.adult} - image={profile.avatar_url} - /> - ))} - {canAddNew && navigate(PATH_USER_PROFILES_CREATE)} />} -
    - {activeProfiles > 0 && ( -
    - {!editMode ? ( -
    - )} -
    - navigate(PATH_USER_PROFILES)} - actionsOverride={ -
    -
    - } - /> - - ); -}; - -export default Profiles; diff --git a/packages/ui-react/src/containers/Profiles/avatarUrls.json b/packages/ui-react/src/containers/Profiles/avatarUrls.json deleted file mode 100644 index 1a68eac81..000000000 --- a/packages/ui-react/src/containers/Profiles/avatarUrls.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - "/images/avatars/Alien.svg", - "/images/avatars/Bear.svg", - "/images/avatars/Brainy.svg", - "/images/avatars/Cooool.svg", - "/images/avatars/Dummy.svg", - "/images/avatars/Frog.svg", - "/images/avatars/Goofball.svg", - "/images/avatars/Marilyn.svg", - "/images/avatars/Smiley.svg", - "/images/avatars/ToughGuy.svg", - "/images/avatars/UhOh.svg", - "/images/avatars/Vibe.svg" -] diff --git a/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap b/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap index ab576efc0..27e966e86 100644 --- a/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap +++ b/packages/ui-react/src/pages/Home/__snapshots__/Home.test.tsx.snap @@ -27,92 +27,104 @@ exports[`Home Component tests > Home test 1`] = ` This is a playlist - - -
  • - +
  • +
  • -
    -

    - Other Vids -

    -
    -
    +

    + Other Vids +

    +
    - 6 min +
    + 6 min +
    - -
    -
  • - + + + + + @@ -130,92 +142,104 @@ exports[`Home Component tests > Home test 1`] = ` Second Playlist - - -
  • - +
  • +
  • -
    -

    - Other Vids -

    -
    -
    +

    + Other Vids +

    +
    - 6 min +
    + 6 min +
    - -
    -
  • - + + + + + diff --git a/packages/ui-react/src/pages/User/User.module.scss b/packages/ui-react/src/pages/User/User.module.scss index e98f6e1c9..0e5aca889 100644 --- a/packages/ui-react/src/pages/User/User.module.scss +++ b/packages/ui-react/src/pages/User/User.module.scss @@ -96,8 +96,3 @@ } } -.profileIcon { - width: 20px; - height: 20px; - border-radius: 50%; -} diff --git a/packages/ui-react/src/pages/User/User.test.tsx b/packages/ui-react/src/pages/User/User.test.tsx index 4366cd48d..f693a5967 100644 --- a/packages/ui-react/src/pages/User/User.test.tsx +++ b/packages/ui-react/src/pages/User/User.test.tsx @@ -10,7 +10,6 @@ import { mockService } from '@jwp/ott-common/test/mockService'; import ApiService from '@jwp/ott-common/src/services/ApiService'; import FavoritesController from '@jwp/ott-common/src/controllers/FavoritesController'; import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; -import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; import { ACCESS_MODEL, DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; import { Route, Routes } from 'react-router-dom'; import React from 'react'; @@ -92,7 +91,6 @@ describe('User Component tests', () => { getSubscriptionSwitches: vi.fn(), getSubscriptionOfferIds: vi.fn().mockReturnValue([]), }); - mockService(ProfileController, { listProfiles: vi.fn(), isEnabled: vi.fn().mockReturnValue(false) }); useConfigStore.setState({ accessModel: ACCESS_MODEL.SVOD, diff --git a/packages/ui-react/src/pages/User/User.tsx b/packages/ui-react/src/pages/User/User.tsx index 97cbfd14d..f93196e3a 100644 --- a/packages/ui-react/src/pages/User/User.tsx +++ b/packages/ui-react/src/pages/User/User.tsx @@ -1,27 +1,19 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; import { shallow } from '@jwp/ott-common/src/utils/compare'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import FavoritesController from '@jwp/ott-common/src/controllers/FavoritesController'; import AccountController from '@jwp/ott-common/src/controllers/AccountController'; -import { useProfileStore } from '@jwp/ott-common/src/stores/ProfileStore'; import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; import useBreakpoint, { Breakpoint } from '@jwp/ott-ui-react/src/hooks/useBreakpoint'; -import { useProfiles } from '@jwp/ott-hooks-react/src/useProfiles'; import AccountCircle from '@jwp/ott-theme/assets/icons/account_circle.svg?react'; import BalanceWallet from '@jwp/ott-theme/assets/icons/balance_wallet.svg?react'; import Exit from '@jwp/ott-theme/assets/icons/exit.svg?react'; import Favorite from '@jwp/ott-theme/assets/icons/favorite.svg?react'; -import { - RELATIVE_PATH_USER_ACCOUNT, - RELATIVE_PATH_USER_FAVORITES, - RELATIVE_PATH_USER_MY_PROFILE, - RELATIVE_PATH_USER_PAYMENTS, -} from '@jwp/ott-common/src/paths'; -import { userProfileURL } from '@jwp/ott-common/src/utils/urlFormatting'; +import { RELATIVE_PATH_USER_ACCOUNT, RELATIVE_PATH_USER_FAVORITES, RELATIVE_PATH_USER_PAYMENTS } from '@jwp/ott-common/src/paths'; import { useFavoritesStore } from '@jwp/ott-common/src/stores/FavoritesStore'; import AccountComponent from '../../components/Account/Account'; @@ -30,7 +22,6 @@ import ConfirmationDialog from '../../components/ConfirmationDialog/Confirmation import Favorites from '../../components/Favorites/Favorites'; import LoadingOverlay from '../../components/LoadingOverlay/LoadingOverlay'; import PaymentContainer from '../../containers/PaymentContainer/PaymentContainer'; -import EditProfile from '../../containers/Profiles/EditProfile'; import Icon from '../../components/Icon/Icon'; import styles from './User.module.scss'; @@ -50,18 +41,12 @@ const User = (): JSX.Element => { const { t } = useTranslation('user'); const breakpoint = useBreakpoint(); const [clearFavoritesOpen, setClearFavoritesOpen] = useState(false); - const location = useLocation(); const isLargeScreen = breakpoint > Breakpoint.md; const { user: customer, subscription, loading } = useAccountStore(); const { canUpdateEmail } = accountController.getFeatures(); - const { profile } = useProfileStore(); const favorites = useFavoritesStore((state) => state.getPlaylist()); - const { profilesEnabled } = useProfiles(); - - const profileAndFavoritesPage = location.pathname?.includes('my-profile') || location.pathname.includes('favorites'); - const onLogout = useCallback(async () => { // Empty customer on a user page leads to navigate (code bellow), so we don't repeat it here await accountController.logout(); @@ -87,29 +72,17 @@ const User = (): JSX.Element => { @@ -148,7 +119,6 @@ const User = (): JSX.Element => { path={RELATIVE_PATH_USER_ACCOUNT} element={} /> - {profilesEnabled && } />} {favoritesList && ( void; pipEnter: () => void; pipLeave: () => void; + backClick: () => void; } type ConfigOptions = { diff --git a/platforms/web/public/locales/en/menu.json b/platforms/web/public/locales/en/menu.json index 8ae298bee..3d31e3b62 100644 --- a/platforms/web/public/locales/en/menu.json +++ b/platforms/web/public/locales/en/menu.json @@ -4,7 +4,6 @@ "logo_alt": "Homepage of {{siteName}}", "open_menu": "Open menu", "open_user_menu": "Open user menu", - "profile_icon": "Profile icon", "sign_in": "Sign in", "sign_up": "Sign up", "skip_to_content": "Skip to content" diff --git a/platforms/web/public/locales/en/user.json b/platforms/web/public/locales/en/user.json index 3873713ad..55941feaf 100644 --- a/platforms/web/public/locales/en/user.json +++ b/platforms/web/public/locales/en/user.json @@ -7,7 +7,6 @@ "cancel": "Cancel", "confirm_password": "Confirm password", "continue": "Continue", - "delete": "Delete", "delete_account": { "body": "Permanently delete your account and all of your content.", "error": "An error occurred while deleting your account. Please try again.", @@ -77,7 +76,6 @@ "favorites": "Favorites", "logout": "Log out", "payments": "Payments", - "profile": "Profile", "settings": "Settings" }, "payment": { @@ -123,29 +121,5 @@ "update_payment_details": "Update payment details", "upgrade_plan_success": "You've successfully changed the subscription plan. You can enjoy your additional benefits immediately.", "weekly_subscription": "Weekly subscription" - }, - "profile": { - "adult": "Adult", - "avatar": "Avatar", - "content_rating": "Content rating", - "delete": "Delete profile", - "delete_confirmation": "Are you sure you want to delete your profile? You will lose all you watch history and settings connected to this profile.", - "delete_description": "Permanently delete your profile along with all of your watch history and favorites.", - "delete_main": "The main profile cannot be deleted because it’s linked to your account's watch history and favorites.", - "description": "Profiles allow you to watch content and assemble your own personal collection of favorites.", - "edit": "Edit profile", - "form_error": "Something went wrong. Please try again later.", - "info": "Profile info", - "kids": "Kids", - "name": "User name", - "validation": { - "name": { - "already_exists": "This profile name is already taken. Please choose another one.", - "invalid_characters": "Your profile name can only contain letters", - "required": "Please enter a name for your profile.", - "too_long": "Please limit your profile name to {{charactersCount}} characters or fewer.", - "too_short": "Please enter at least {{charactersCount}} characters." - } - } } } diff --git a/platforms/web/public/locales/es/menu.json b/platforms/web/public/locales/es/menu.json index 9df1f6161..0e3b6acbb 100644 --- a/platforms/web/public/locales/es/menu.json +++ b/platforms/web/public/locales/es/menu.json @@ -4,7 +4,6 @@ "logo_alt": "Página de inicio de {{siteName}}", "open_menu": "Abrir menú", "open_user_menu": "Abrir menú de usuario", - "profile_icon": "Icono de perfil", "sign_in": "Iniciar sesión", "sign_up": "Registrarse", "skip_to_content": "Saltar al contenido" diff --git a/platforms/web/public/locales/es/user.json b/platforms/web/public/locales/es/user.json index 1d21dee39..0fb391847 100644 --- a/platforms/web/public/locales/es/user.json +++ b/platforms/web/public/locales/es/user.json @@ -7,7 +7,6 @@ "cancel": "Cancelar", "confirm_password": "Confirmar contraseña", "continue": "Continuar", - "delete": "Eliminar", "delete_account": { "body": "Elimine permanentemente su cuenta y todo su contenido.", "error": "Ocurrió un error al eliminar su cuenta. Inténtalo de nuevo.", @@ -77,7 +76,6 @@ "favorites": "Favoritos", "logout": "Cerrar sesión", "payments": "Pagos", - "profile": "Perfil", "settings": "Ajustes" }, "payment": { @@ -124,29 +122,5 @@ "update_payment_details": "Actualizar detalles de pago", "upgrade_plan_success": "Haz cambiado exitosamente el plan de tu suscripción. Puedes comenzar a disfrutar de los beneficios adicionales de inmediato.", "weekly_subscription": "Suscripción semanal" - }, - "profile": { - "adult": "Adultos", - "avatar": "Avatar", - "content_rating": "Puntuación del contenido", - "delete": "Eliminar perfil", - "delete_confirmation": "¿Estás seguro que quieres eliminar tu perfil? Perderás todo tu historial y ajustes en este perfil.", - "delete_description": "Borrar permentemente el perfil junto con el contenido de tu historial y favoritos.", - "delete_main": "El perfil principal no puede ser borrado debido a que está ligado al historial de tu cuenta y tus favoritos.", - "description": "Los perfiles te permiten ver el contenido y organizar tu colección de favoritos.", - "edit": "Editar perfil", - "form_error": "Algo salió mal. Intenta de nuevo más tarde", - "info": "Información del perfil", - "kids": "Niños", - "name": "Nombre del usuario", - "validation": { - "name": { - "already_exists": "Este nombre de perfil ya está en uso. Por favor elije otro.", - "invalid_characters": "El nombre de su perfil solo puede contener letras, números y espacios.", - "required": "Por favor ingrese un nombre para su perfil.", - "too_long": "Por favor, limite el nombre de su perfil a {{charactersCount}} caracteres o menos.", - "too_short": "Por favor introduzca al menos {{charactersCount}} caracteres." - } - } } } diff --git a/platforms/web/src/services/LocalStorageService.ts b/platforms/web/src/services/LocalStorageService.ts index 6c6f8c21e..eee2c5d6a 100644 --- a/platforms/web/src/services/LocalStorageService.ts +++ b/platforms/web/src/services/LocalStorageService.ts @@ -14,9 +14,9 @@ export class LocalStorageService extends StorageService { return `${this.prefix}.${key}`; } - async getItem(key: string, parse: boolean) { + async getItem(key: string, parse: boolean, usePrefix = true) { try { - const value = window.localStorage.getItem(this.getStorageKey(key)); + const value = window.localStorage.getItem(usePrefix ? this.getStorageKey(key) : key); return value && parse ? JSON.parse(value) : value; } catch (error: unknown) { diff --git a/platforms/web/test-e2e/tests/home_test.ts b/platforms/web/test-e2e/tests/home_test.ts index af8dd071c..c06248990 100644 --- a/platforms/web/test-e2e/tests/home_test.ts +++ b/platforms/web/test-e2e/tests/home_test.ts @@ -41,10 +41,11 @@ Scenario('Header button navigates to playlist screen', async ({ I }) => { Scenario('I can slide within the featured shelf', async ({ I }) => { const isDesktop = await I.isDesktop(); + const visibleTilesLocator = locate({ css: `section[data-testid="shelf-${ShelfId.featured}"] .TileSlider--visible` }); async function slide(swipeText: string) { if (isDesktop) { - I.click({ css: 'div[aria-label="Next slide"]' }); + I.click({ css: 'button[aria-label="Next slide"]' }); } else { await I.swipeLeft({ text: swipeText }); } @@ -52,23 +53,31 @@ Scenario('I can slide within the featured shelf', async ({ I }) => { await within(makeShelfXpath(ShelfId.featured), async () => { I.see('Blender Channel'); + }); + await within(visibleTilesLocator, function () { I.dontSee('Spring'); I.dontSee('8 min'); + }); + await within(makeShelfXpath(ShelfId.featured), async () => { await slide('Blender Channel'); + }); + await within(visibleTilesLocator, function () { I.waitForElement('text=Spring', 3); I.see('8 min'); - I.waitForInvisible('text="Blender Channel"', 3); - I.dontSee('Blender Channel'); + }); - // Without this extra wait, the second slide action happens too fast after the first and even though the - // expected elements are present, the slide doesn't work. I think there must be a debounce check on the carousel. - I.wait(1); + // Without this extra wait, the second slide action happens too fast after the first and even though the + // expected elements are present, the slide doesn't work. I think there must be a debounce check on the carousel. + I.wait(1); + await within(makeShelfXpath(ShelfId.featured), async () => { await slide('Spring'); + }); + await within(visibleTilesLocator, function () { I.waitForElement('text="Blender Channel"', 3); I.dontSee('Spring'); }); @@ -76,10 +85,11 @@ Scenario('I can slide within the featured shelf', async ({ I }) => { Scenario('I can slide within non-featured shelves', async ({ I }) => { const isDesktop = await I.isDesktop(); + const visibleTilesLocator = locate({ css: `section[data-testid="shelf-${ShelfId.allFilms}"] .TileSlider--visible` }); async function slideRight(swipeText) { if (isDesktop) { - I.click({ css: 'div[aria-label="Next slide"]' }, makeShelfXpath(ShelfId.allFilms)); + I.click({ css: 'button[aria-label="Next slide"]' }, makeShelfXpath(ShelfId.allFilms)); } else { await I.swipeLeft({ text: swipeText }); } @@ -87,7 +97,7 @@ Scenario('I can slide within non-featured shelves', async ({ I }) => { async function slideLeft(swipeText) { if (isDesktop) { - I.click({ css: 'div[aria-label="Previous slide"]' }, makeShelfXpath(ShelfId.allFilms)); + I.click({ css: 'button[aria-label="Previous slide"]' }, makeShelfXpath(ShelfId.allFilms)); } else { await I.swipeRight({ text: swipeText }); } @@ -100,31 +110,44 @@ Scenario('I can slide within non-featured shelves', async ({ I }) => { duration: '11 min', }; - I.see('All Films'); - I.see('Agent 327'); - I.see('4 min'); - I.dontSee(rightMedia.name); - I.dontSee(rightMedia.duration); + await within(makeShelfXpath(ShelfId.allFilms), function () { + I.see('All Films'); + }); + + await within(visibleTilesLocator, function () { + I.see('Agent 327'); + I.see('4 min'); + I.dontSee(rightMedia.name); + I.dontSee(rightMedia.duration); + }); + await slideRight('Agent 327'); - I.waitForElement(`text="${rightMedia.name}"`, 3); - I.see(rightMedia.duration); - I.dontSee('Agent 327'); + + await within(visibleTilesLocator, function () { + I.waitForText(rightMedia.name, 3); + I.waitForText(rightMedia.duration, 3); + I.dontSee('Agent 327'); + }); // Without this extra wait, the second slide action happens too fast after the first and even though the // expected elements are present, the slide doesn't work. I think there must be a debounce on the carousel. I.wait(1); await slideLeft(rightMedia.name); - I.waitForElement('text="Agent 327"', 3); - I.dontSee(rightMedia.name); + await within(visibleTilesLocator, function () { + I.waitForElement('text="Agent 327"', 3); + I.dontSee(rightMedia.name); + }); // Without this extra wait, the second slide action happens too fast after the first and even though the // expected elements are present, the slide doesn't work. I think there must be a debounce on the carousel. I.wait(1); await slideLeft('Agent 327'); - I.waitForText('The Daily Dweebs', 3); - I.dontSee('Agent 327'); + await within(visibleTilesLocator, function () { + I.waitForText('The Daily Dweebs', 3); + I.dontSee('Agent 327'); + }); }); Scenario('I can see the footer', ({ I }) => { diff --git a/platforms/web/test-e2e/utils/steps_file.ts b/platforms/web/test-e2e/utils/steps_file.ts index 7e5fe3156..ac546d64e 100644 --- a/platforms/web/test-e2e/utils/steps_file.ts +++ b/platforms/web/test-e2e/utils/steps_file.ts @@ -11,7 +11,7 @@ const configFileQueryKey = 'app-config'; const loaderElement = '[class*=_loadingOverlay]'; type SwipeTarget = { text: string } | { xpath: string }; -type SwipeDirection = { direction: 'left' | 'right' } | { points: { x1: number; y1: number; x2: number; y2: number } }; +type SwipeDirection = { direction: 'left' | 'right'; delta?: number } | { points: { x1: number; y1: number; x2: number; y2: number } }; const stepsObj = { useConfig: function (this: CodeceptJS.I, config: TestConfig) { @@ -296,15 +296,16 @@ const stepsObj = { swipe: async function (this: CodeceptJS.I, args: SwipeTarget & SwipeDirection) { await this.executeScript((args) => { const xpath = args.xpath || `//*[text() = "${args.text}"]`; + const delta = args.delta || 25; const points = args.direction === 'left' - ? { x1: 100, y1: 1, x2: 50, y2: 1 } + ? { x1: delta, y1: 1, x2: 0, y2: 1 } : args.direction === 'right' ? { - x1: 50, + x1: 0, y1: 1, - x2: 100, + x2: delta, y2: 1, } : args.points; @@ -329,19 +330,38 @@ const stepsObj = { }), ); - element.dispatchEvent( - new TouchEvent('touchend', { - bubbles: true, - changedTouches: [ - new Touch({ - identifier: Date.now() + 1, - target: element, - clientX: points.x2, - clientY: points.y2, + return new Promise((resolve) => { + setTimeout(() => { + element.dispatchEvent( + new TouchEvent('touchmove', { + bubbles: true, + changedTouches: [ + new Touch({ + identifier: Date.now() + 1, + target: element, + clientX: points.x2, + clientY: points.y2, + }), + ], }), - ], - }), - ); + ); + + element.dispatchEvent( + new TouchEvent('touchend', { + bubbles: true, + changedTouches: [ + new Touch({ + identifier: Date.now() + 2, + target: element, + clientX: points.x2, + clientY: points.y2, + }), + ], + }), + ); + resolve(); + }, 16); + }); }, args); }, waitForPlayerPlaying: async function (title: string, tries = 10) { @@ -502,9 +522,10 @@ const stepsObj = { await this.swipe({ xpath: shelfLocator ? `${shelfLocator}//*[@tabindex=0]` : `${cardLocator}/ancestor::ul/li/a[@tabindex=0]`, direction: scrollToTheRight ? 'left' : 'right', + delta: 15, // slow swipe to prevent sliding over }); } else { - this.click({ css: `div[aria-label="${scrollToTheRight ? 'Next slide' : 'Previous slide'}"]` }, shelfLocator); + this.click({ css: `button[aria-label="${scrollToTheRight ? 'Next slide' : 'Previous slide'}"]` }, shelfLocator); } this.wait(1); diff --git a/yarn.lock b/yarn.lock index 8a4a9acc5..66478c708 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2091,10 +2091,10 @@ resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.4.tgz#106f911134035b4157ec92a0c154a6b6f88fa4c1" integrity sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw== -"@inplayer-org/inplayer.js@^3.13.25": - version "3.13.27" - resolved "https://registry.yarnpkg.com/@inplayer-org/inplayer.js/-/inplayer.js-3.13.27.tgz#54c4748fc8c88d7ae9632179daffe5f14aec815d" - integrity sha512-bpTIAs09mdEZCpMMDi6ZrHYOUxDlAy6zEN6qpWILdKtSn957OoQEsZ7+4WR9hvudTB6NfQF5eOLLTpkZe//LdQ== +"@inplayer-org/inplayer.js@^3.13.28": + version "3.13.28" + resolved "https://registry.yarnpkg.com/@inplayer-org/inplayer.js/-/inplayer.js-3.13.28.tgz#5c8ef3dbd46ae823ec94c17c2c462040ba97bbaa" + integrity sha512-8W0yIoU9jw7i1TyaIs9mk/5PgSON+lDdlYTlhUR2x343OboM0t4d9Y6JR24JIHZkMZq9YbCdSO0YxZltuJf7nA== dependencies: aws-iot-device-sdk "^2.2.6" axios "^0.21.2" @@ -3447,6 +3447,11 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@videodock/tile-slider@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@videodock/tile-slider/-/tile-slider-2.0.0.tgz#f8fb6aaa266106ba43e1c52b6a48ae4a6f4aac5c" + integrity sha512-EYItAF5dMiRA1E12c3vNtymr1sFFZyJBjAkxyxuaoQsVBkRfzoIN2MBnwAov/H5eHasVt4Jtn2DA0JCFZ5/PLQ== + "@vite-pwa/assets-generator@^0.2.4": version "0.2.4" resolved "https://registry.yarnpkg.com/@vite-pwa/assets-generator/-/assets-generator-0.2.4.tgz#ffd5dee762f6e98eaff9938fd52591cb04d8dbc7"