diff --git a/docs/configuration.md b/docs/configuration.md index 506121e23..5f9ee4dce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -160,8 +160,7 @@ Use the `styling` object to define extra styles for your application. "styling": { "backgroundColor": null, "highlightColor": null, - "headerBackground": null, - "footerText": "Blender Foundation" + "headerBackground": null } ``` @@ -187,12 +186,6 @@ Use this parameter to change the background color of the header. By default, the --- -**styling.footerText** (optional) - -Text that will be placed in the footer of the site. Markdown links are supported. - ---- - **features** Use the `features` object to define extra properties for your app. diff --git a/docs/features/video-protection.md b/docs/features/video-protection.md index 28c9927d1..71d181287 100644 --- a/docs/features/video-protection.md +++ b/docs/features/video-protection.md @@ -11,7 +11,7 @@ This article outlines how such an authorization service should work. ## Signed URLs -With [URL signing](https://support.jwplayer.com/articles/how-to-enable-url-token-signing) enabled on the JW property, a video client can only access the media URLs from JW backends when it has a valid JWT token: +With [URL signing](https://docs.jwplayer.com/platform/reference/protect-your-content-with-signed-urls) enabled on the JW property, a video client can only access the media URLs from JW backends when it has a valid JWT token: ``` GET media/PEEzDfdA?token= diff --git a/packages/common/.depcheckrc.yaml b/packages/common/.depcheckrc.yaml index bb6560f17..a149870a7 100644 --- a/packages/common/.depcheckrc.yaml +++ b/packages/common/.depcheckrc.yaml @@ -3,4 +3,7 @@ ignores: [ '@typescript-eslint/parser', '@typescript-eslint/eslint-plugin', 'eslint-plugin-import', + + # Installed by inplayer SDK (only used for typings) + 'axios' ] diff --git a/packages/common/package.json b/packages/common/package.json index c267ea47d..6b15d2649 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -26,7 +26,6 @@ }, "devDependencies": { "@types/ini": "^1.3.31", - "@types/jwplayer": "^8.2.13", "@types/lodash.merge": "^4.6.6", "jsdom": "^22.1.0", "timezone-mock": "^1.3.4", diff --git a/packages/common/src/stores/AccountController.ts b/packages/common/src/controllers/AccountController.ts similarity index 73% rename from packages/common/src/stores/AccountController.ts rename to packages/common/src/controllers/AccountController.ts index 35448fdc2..bc51b6c59 100644 --- a/packages/common/src/stores/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -1,7 +1,7 @@ import i18next from 'i18next'; import { inject, injectable } from 'inversify'; -import { ACCESS_MODEL, DEFAULT_FEATURES } from '../constants'; +import { DEFAULT_FEATURES } from '../constants'; import { logDev } from '../utils/common'; import type { IntegrationType } from '../../types/config'; import CheckoutService from '../services/integrations/CheckoutService'; @@ -15,20 +15,18 @@ import type { EmailConfirmPasswordInput, FirstLastNameInput, GetCaptureStatusResponse, - GetCustomerConsentsResponse, - GetPublisherConsentsResponse, SubscribeToNotificationsPayload, } from '../../types/account'; import { assertFeature, assertModuleMethod, getNamedModule } from '../modules/container'; import { INTEGRATION_TYPE } from '../modules/types'; +import type { ServiceResponse } from '../../types/service'; +import { useAccountStore } from '../stores/AccountStore'; +import { useConfigStore } from '../stores/ConfigStore'; +import { useProfileStore } from '../stores/ProfileStore'; +import { FormValidationError } from '../errors/FormValidationError'; -import { useWatchHistoryStore } from './WatchHistoryStore'; -import { useFavoritesStore } from './FavoritesStore'; -import { useAccountStore } from './AccountStore'; -import { useConfigStore } from './ConfigStore'; -import { useProfileStore } from './ProfileStore'; -import ProfileController from './ProfileController'; import WatchHistoryController from './WatchHistoryController'; +import ProfileController from './ProfileController'; import FavoritesController from './FavoritesController'; @injectable() @@ -36,9 +34,9 @@ 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 profileController?: ProfileController; private readonly features: AccountServiceFeatures; // temporary callback for refreshing the query cache until we've updated to react-query v4 or v5 @@ -48,13 +46,13 @@ export default class AccountController { @inject(INTEGRATION_TYPE) integrationType: IntegrationType, favoritesController: FavoritesController, watchHistoryController: WatchHistoryController, - profileController?: ProfileController, + profileController: ProfileController, ) { this.checkoutService = getNamedModule(CheckoutService, integrationType); this.accountService = getNamedModule(AccountService, integrationType); this.subscriptionService = getNamedModule(SubscriptionService, integrationType); - // @TODO refactor? + // @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; @@ -68,8 +66,6 @@ export default class AccountController { if (authData) { await this.getAccount(); - await this.watchHistoryController?.restoreWatchHistory(); - await this.favoritesController?.restoreFavorites(); } } catch (error: unknown) { logDev('Failed to get user', error); @@ -103,26 +99,6 @@ export default class AccountController { return this.accountService.sandbox; } - updatePersonalShelves = async () => { - const { watchHistory } = useWatchHistoryStore.getState(); - const { favorites } = useFavoritesStore.getState(); - const { getAccountInfo } = useAccountStore.getState(); - - const { customer } = getAccountInfo(); - - if (!watchHistory && !favorites) return; - - const personalShelfData = { - history: this.watchHistoryController?.serializeWatchHistory(watchHistory), - favorites: this.favoritesController?.serializeFavorites(favorites), - }; - - return this.accountService?.updatePersonalShelves({ - id: customer.id, - externalData: personalShelfData, - }); - }; - updateUser = async (values: FirstLastNameInput | EmailConfirmPasswordInput): Promise> => { useAccountStore.setState({ loading: true }); @@ -151,26 +127,25 @@ export default class AccountController { payload = { ...values, email: user.email }; } - const response = await this.accountService.updateCustomer({ ...payload, id: user.id.toString() }); + const updatedUser = await this.accountService.updateCustomer({ ...payload, id: user.id.toString() }); - if (!response) { + if (!updatedUser) { throw new Error('Unknown error'); } - if (response.errors?.length === 0) { - useAccountStore.setState({ user: response.responseData }); - } + useAccountStore.setState({ user: updatedUser }); - return response; + return { errors: [], responseData: updatedUser }; }; getAccount = async () => { - const { config, accessModel } = useConfigStore.getState(); + const { config } = useConfigStore.getState(); try { const response = await this.accountService.getUser({ config }); + if (response) { - await this.afterLogin(response.user, response.customerConsents, accessModel); + await this.afterLogin(response.user, response.customerConsents); } useAccountStore.setState({ loading: false }); @@ -188,20 +163,25 @@ export default class AccountController { }; login = async (email: string, password: string, referrer: string) => { - const { config, accessModel } = useConfigStore.getState(); - useAccountStore.setState({ loading: true }); - const response = await this.accountService.login({ config, email, password, referrer }); - - if (response) { - await this.afterLogin(response.user, response.customerConsents, accessModel); + try { + const response = await this.accountService.login({ email, password, referrer }); - await this.favoritesController?.restoreFavorites(); - await this.watchHistoryController?.restoreWatchHistory(); + if (response) { + await this.afterLogin(response.user, response.customerConsents); + return; + } + } catch (error: unknown) { + if (error instanceof Error && error.message.toLowerCase().includes('invalid param email')) { + throw new FormValidationError({ email: [i18next.t('account:login.wrong_email')] }); + } + } finally { + useAccountStore.setState({ loading: false }); } - useAccountStore.setState({ loading: false }); + // consider any unknown response as a wrong combinations error (we could specify this even more) + throw new FormValidationError({ form: [i18next.t('account:login.wrong_combination')] }); }; logout = async () => { @@ -212,38 +192,48 @@ export default class AccountController { await this.refreshEntitlements?.(); }; - register = async (email: string, password: string, referrer: string, consents: CustomerConsent[]) => { - const { config, accessModel } = useConfigStore.getState(); + register = async (email: string, password: string, referrer: string, consentsValues: CustomerConsent[]) => { + try { + const response = await this.accountService.register({ email, password, consents: consentsValues, referrer }); - useAccountStore.setState({ loading: true }); - const response = await this.accountService.register({ config, email, password, consents, referrer }); + if (response) { + const { user, customerConsents } = response; + await this.afterLogin(user, customerConsents, true); - if (response) { - const { user, customerConsents } = response; - await this.afterLogin(user, customerConsents, accessModel); + return; + } + } catch (error: unknown) { + if (error instanceof Error) { + const errorMessage = error.message.toLowerCase(); + + if (errorMessage.includes('customer already exists') || errorMessage.includes('account already exists')) { + throw new FormValidationError({ form: [i18next.t('account:registration.user_exists')] }); + } else if (errorMessage.includes('invalid param password')) { + throw new FormValidationError({ password: [i18next.t('account:registration.invalid_password')] }); + } + } } - await this.updatePersonalShelves(); + // in case the endpoint fails + throw new FormValidationError({ form: [i18next.t('account:registration.failed_to_create')] }); }; updateConsents = async (customerConsents: CustomerConsent[]): Promise> => { const { getAccountInfo } = useAccountStore.getState(); - const { config } = useConfigStore.getState(); const { customer } = getAccountInfo(); useAccountStore.setState({ loading: true }); try { - const response = await this.accountService?.updateCustomerConsents({ - config, + const updatedConsents = await this.accountService?.updateCustomerConsents({ customer, consents: customerConsents, }); - if (response?.consents) { - useAccountStore.setState({ customerConsents: response.consents }); + if (updatedConsents) { + useAccountStore.setState({ customerConsents: updatedConsents }); return { - responseData: response.consents, + responseData: updatedConsents, errors: [], }; } @@ -258,86 +248,71 @@ export default class AccountController { // TODO: Decide if it's worth keeping this or just leave combined with getUser // noinspection JSUnusedGlobalSymbols - getCustomerConsents = async (): Promise => { + getCustomerConsents = async () => { const { getAccountInfo } = useAccountStore.getState(); - const { config } = useConfigStore.getState(); const { customer } = getAccountInfo(); - const response = await this.accountService.getCustomerConsents({ config, customer }); + const consents = await this.accountService.getCustomerConsents({ customer }); - if (response?.consents) { - useAccountStore.setState({ customerConsents: response.consents }); + if (consents) { + useAccountStore.setState({ customerConsents: consents }); } - return response; + return consents; }; - getPublisherConsents = async (): Promise => { + getPublisherConsents = async () => { const { config } = useConfigStore.getState(); - const response = await this.accountService.getPublisherConsents(config); - - useAccountStore.setState({ publisherConsents: response.consents }); + useAccountStore.setState({ publisherConsentsLoading: true }); + const consents = await this.accountService.getPublisherConsents(config); - return response; + useAccountStore.setState({ publisherConsents: consents, publisherConsentsLoading: false }); }; getCaptureStatus = async (): Promise => { const { getAccountInfo } = useAccountStore.getState(); const { customer } = getAccountInfo(); - const { responseData } = await this.accountService.getCaptureStatus({ customer }); - - return responseData; + return this.accountService.getCaptureStatus({ customer }); }; updateCaptureAnswers = async (capture: Capture): Promise => { const { getAccountInfo } = useAccountStore.getState(); - const { accessModel } = useConfigStore.getState(); const { customer, customerConsents } = getAccountInfo(); - const response = await this.accountService.updateCaptureAnswers({ customer, ...capture }); + const updatedCustomer = await this.accountService.updateCaptureAnswers({ customer, ...capture }); - if (response.errors.length > 0) throw new Error(response.errors[0]); - - await this.afterLogin(response.responseData as Customer, customerConsents, accessModel, false); + useAccountStore.setState({ + user: updatedCustomer, + customerConsents, + }); - return response.responseData; + return updatedCustomer; }; resetPassword = async (email: string, resetUrl: string) => { - const response = await this.accountService.resetPassword({ + await this.accountService.resetPassword({ customerEmail: email, resetUrl, }); - - if (response.errors.length > 0) throw new Error(response.errors[0]); - - return response.responseData; }; changePasswordWithOldPassword = async (oldPassword: string, newPassword: string, newPasswordConfirmation: string) => { - const response = await this.accountService.changePasswordWithOldPassword({ + await this.accountService.changePasswordWithOldPassword({ oldPassword, newPassword, newPasswordConfirmation, }); - if (response?.errors?.length > 0) throw new Error(response.errors[0]); - - return response?.responseData; }; changePasswordWithToken = async (customerEmail: string, newPassword: string, resetPasswordToken: string, newPasswordConfirmation: string) => { - const response = await this.accountService.changePasswordWithResetToken({ + await this.accountService.changePasswordWithResetToken({ customerEmail, newPassword, resetPasswordToken, newPasswordConfirmation, }); - - if (response?.errors?.length > 0) throw new Error(response.errors[0]); - - return response?.responseData; }; updateSubscription = async (status: 'active' | 'cancelled'): Promise => { @@ -357,7 +332,7 @@ export default class AccountController { if (response.errors.length > 0) throw new Error(response.errors[0]); - await this.reloadActiveSubscription({ delay: 2000 }); + await this.reloadSubscriptions({ delay: 2000 }); return response?.responseData; }; @@ -409,22 +384,30 @@ export default class AccountController { return !!responseData?.accessGranted; }; - reloadActiveSubscription = async ({ delay }: { delay: number } = { delay: 0 }): Promise => { + reloadSubscriptions = async ({ delay }: { delay: number } = { delay: 0 }): Promise => { useAccountStore.setState({ loading: true }); const { getAccountInfo } = useAccountStore.getState(); const { customerId } = getAccountInfo(); + const { accessModel } = useConfigStore.getState(); // The subscription data takes a few seconds to load after it's purchased, // so here's a delay mechanism to give it time to process if (delay > 0) { return new Promise((resolve: (value?: unknown) => void) => { setTimeout(() => { - this.reloadActiveSubscription().finally(resolve); + this.reloadSubscriptions().finally(resolve); }, delay); }); } + // For non-SVOD platforms, there could be TVOD items, so we only reload entitlements + if (accessModel !== 'SVOD') { + await this.refreshEntitlements?.(); + + return useAccountStore.setState({ loading: false }); + } + const [activeSubscription, transactions, activePayment] = await Promise.all([ this.subscriptionService.getActiveSubscription({ customerId }), this.subscriptionService.getAllTransactions({ customerId }), @@ -470,13 +453,12 @@ export default class AccountController { }; getSocialLoginUrls = (redirectUrl: string) => { - const { config } = useConfigStore.getState(); const { hasSocialURLs } = this.getFeatures(); assertModuleMethod(this.accountService.getSocialUrls, 'getSocialUrls is not available in account service'); assertFeature(hasSocialURLs, 'Social logins'); - return this.accountService.getSocialUrls({ config, redirectUrl }); + return this.accountService.getSocialUrls({ redirectUrl }); }; deleteAccountData = async (password: string) => { @@ -508,15 +490,18 @@ export default class AccountController { return this.features; } - private async afterLogin(user: Customer, customerConsents: CustomerConsent[] | null, accessModel: string, shouldSubscriptionReload: boolean = true) { + private async afterLogin(user: Customer, customerConsents: CustomerConsent[] | null, registration = false) { useAccountStore.setState({ user, customerConsents, }); await Promise.allSettled([ - accessModel === ACCESS_MODEL.SVOD && shouldSubscriptionReload ? this.reloadActiveSubscription() : Promise.resolve(), + this.reloadSubscriptions(), this.getPublisherConsents(), + // after registration, transfer the personal shelves to the account + registration ? this.favoritesController.persistFavorites() : this.favoritesController.restoreFavorites(), + registration ? this.watchHistoryController.persistWatchHistory() : this.watchHistoryController.restoreWatchHistory(), ]); useAccountStore.setState({ loading: false }); @@ -550,9 +535,9 @@ export default class AccountController { selectingProfileAvatar: null, }); - this.profileController?.unpersistProfile(); + this.profileController.unpersistProfile(); - await this.favoritesController?.restoreFavorites(); - await this.watchHistoryController?.restoreWatchHistory(); + await this.favoritesController.restoreFavorites(); + await this.watchHistoryController.restoreWatchHistory(); }; } diff --git a/packages/common/src/stores/AppController.ts b/packages/common/src/controllers/AppController.ts similarity index 98% rename from packages/common/src/stores/AppController.ts rename to packages/common/src/controllers/AppController.ts index 87feb43f6..c4db83831 100644 --- a/packages/common/src/stores/AppController.ts +++ b/packages/common/src/controllers/AppController.ts @@ -9,11 +9,11 @@ import StorageService from '../services/StorageService'; import type { Config } from '../../types/config'; import type { CalculateIntegrationType } from '../../types/calculate-integration-type'; import { DETERMINE_INTEGRATION_TYPE } from '../modules/types'; +import { useConfigStore } from '../stores/ConfigStore'; -import AccountController from './AccountController'; import WatchHistoryController from './WatchHistoryController'; import FavoritesController from './FavoritesController'; -import { useConfigStore } from './ConfigStore'; +import AccountController from './AccountController'; @injectable() export default class AppController { @@ -78,6 +78,11 @@ export default class AppController { // update settings in the config store useConfigStore.setState({ settings }); + // when an integration is set, we initialize the AccountController + if (integrationType) { + await getModule(AccountController).initialize(url, refreshEntitlements); + } + if (config.features?.continueWatchingList && config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) { await getModule(WatchHistoryController).initialize(); } @@ -86,11 +91,6 @@ export default class AppController { await getModule(FavoritesController).initialize(); } - // when an integration is set, we initialize the AccountController - if (integrationType) { - await getModule(AccountController).initialize(url, refreshEntitlements); - } - return { config, settings, configSource }; }; diff --git a/packages/common/src/stores/CheckoutController.ts b/packages/common/src/controllers/CheckoutController.ts similarity index 84% rename from packages/common/src/stores/CheckoutController.ts rename to packages/common/src/controllers/CheckoutController.ts index 8f4e54a51..d9801546f 100644 --- a/packages/common/src/stores/CheckoutController.ts +++ b/packages/common/src/controllers/CheckoutController.ts @@ -1,4 +1,5 @@ import { inject, injectable } from 'inversify'; +import i18next from 'i18next'; import type { AddAdyenPaymentDetailsResponse, @@ -12,10 +13,9 @@ import type { InitialAdyenPayment, Offer, Order, + Payment, PaymentMethod, - PaymentWithPayPalResponse, SwitchOffer, - UpdateOrderPayload, } from '../../types/checkout'; import CheckoutService from '../services/integrations/CheckoutService'; import SubscriptionService from '../services/integrations/SubscriptionService'; @@ -24,9 +24,9 @@ import { assertModuleMethod, getNamedModule } from '../modules/container'; import { GET_CUSTOMER_IP, INTEGRATION_TYPE } from '../modules/types'; import type { GetCustomerIP } from '../../types/get-customer-ip'; import AccountService from '../services/integrations/AccountService'; - -import { useCheckoutStore } from './CheckoutStore'; -import { useAccountStore } from './AccountStore'; +import { useCheckoutStore } from '../stores/CheckoutStore'; +import { useAccountStore } from '../stores/AccountStore'; +import { FormValidationError } from '../errors/FormValidationError'; @injectable() export default class CheckoutController { @@ -46,15 +46,17 @@ export default class CheckoutController { return this.accountService.svodOfferIds; }; - createOrder = async (offer: Offer, paymentMethodId?: number): Promise => { + createOrder = async (offer: Offer): Promise => { const { getAccountInfo } = useAccountStore.getState(); const { customer } = getAccountInfo(); + const paymentMethods = await this.getPaymentMethods(); + const paymentMethodId = paymentMethods[0]?.id; + const createOrderArgs: CreateOrderArgs = { offer, customerId: customer.id, country: customer?.country || '', - customerIP: customer?.lastUserIp || '', paymentMethodId, }; @@ -70,23 +72,31 @@ export default class CheckoutController { }; updateOrder = async (order: Order, paymentMethodId?: number, couponCode?: string | null): Promise => { - const updateOrderPayload: UpdateOrderPayload = { - order, - paymentMethodId, - couponCode, - }; + let response; + + try { + response = await this.checkoutService.updateOrder({ order, paymentMethodId, couponCode }); + } catch (error: unknown) { + // TODO: we currently (falsely) assume that the only error caught is because the coupon is not valid, but there + // could be a network failure as well (JWPCheckoutService) + throw new FormValidationError({ couponCode: [i18next.t('account:checkout.coupon_not_valid')] }); + } - const response = await this.checkoutService.updateOrder(updateOrderPayload); if (response.errors.length > 0) { // clear the order when the order doesn't exist on the server if (response.errors[0].includes(`Order with ${order.id} not found`)) { useCheckoutStore.getState().setOrder(null); } - throw new Error(response.errors[0]); + // TODO: this handles the `Coupon ${couponCode} not found` message (CleengCheckoutService) + if (response.errors[0].includes(`not found`)) { + throw new FormValidationError({ couponCode: [i18next.t('account:checkout.coupon_not_valid')] }); + } + + throw new FormValidationError({ form: response.errors }); } - if (response.responseData?.order) { + if (response.responseData.order) { useCheckoutStore.getState().setOrder(response.responseData?.order); } }; @@ -105,7 +115,8 @@ export default class CheckoutController { return response.responseData?.paymentMethods; }; - paymentWithoutDetails = async (): Promise => { + // + paymentWithoutDetails = async (): Promise => { const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); @@ -118,7 +129,7 @@ export default class CheckoutController { return response.responseData; }; - directPostCardPayment = async (cardPaymentPayload: CardPaymentData, referrer: string, returnUrl: string): Promise => { + directPostCardPayment = async ({ cardPaymentPayload, referrer, returnUrl }: { cardPaymentPayload: CardPaymentData; referrer: string; returnUrl: string }) => { const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); @@ -139,7 +150,9 @@ export default class CheckoutController { returnUrl: returnUrl, }); - if (response.errors.length > 0) throw new Error(response.errors[0]); + if (response.errors.length > 0) { + throw new Error(response.errors[0]); + } return response.responseData; }; @@ -174,18 +187,26 @@ export default class CheckoutController { paymentData, }); - if (response.errors.length > 0) throw new Error(response.errors[0]); + if (response.errors.length > 0) { + throw new Error(response.errors[0]); + } return response.responseData; }; - paypalPayment = async ( - successUrl: string, - waitingUrl: string, - cancelUrl: string, - errorUrl: string, - couponCode: string = '', - ): Promise => { + paypalPayment = async ({ + successUrl, + waitingUrl, + cancelUrl, + errorUrl, + couponCode = '', + }: { + successUrl: string; + waitingUrl: string; + cancelUrl: string; + errorUrl: string; + couponCode: string; + }): Promise<{ redirectUrl: string }> => { const { order } = useCheckoutStore.getState(); if (!order) throw new Error('No order created'); @@ -201,7 +222,9 @@ export default class CheckoutController { if (response.errors.length > 0) throw new Error(response.errors[0]); - return response.responseData; + return { + redirectUrl: response.responseData.redirectUrl, + }; }; getSubscriptionSwitches = async (): Promise => { diff --git a/packages/common/src/stores/EpgController.test.ts b/packages/common/src/controllers/EpgController.test.ts similarity index 100% rename from packages/common/src/stores/EpgController.test.ts rename to packages/common/src/controllers/EpgController.test.ts diff --git a/packages/common/src/stores/EpgController.ts b/packages/common/src/controllers/EpgController.ts similarity index 100% rename from packages/common/src/stores/EpgController.ts rename to packages/common/src/controllers/EpgController.ts diff --git a/packages/common/src/stores/FavoritesController.ts b/packages/common/src/controllers/FavoritesController.ts similarity index 56% rename from packages/common/src/stores/FavoritesController.ts rename to packages/common/src/controllers/FavoritesController.ts index f428e1698..8f8639adb 100644 --- a/packages/common/src/stores/FavoritesController.ts +++ b/packages/common/src/controllers/FavoritesController.ts @@ -1,42 +1,23 @@ import i18next from 'i18next'; -import { inject, injectable } from 'inversify'; +import { injectable } from 'inversify'; import FavoriteService from '../services/FavoriteService'; -import AccountService from '../services/integrations/AccountService'; import type { PlaylistItem } from '../../types/playlist'; -import type { Favorite, SerializedFavorite } from '../../types/favorite'; -import type { Customer } from '../../types/account'; -import type { IntegrationType } from '../../types/config'; -import { getNamedModule } from '../modules/container'; -import { INTEGRATION_TYPE } from '../modules/types'; - -import { useAccountStore } from './AccountStore'; -import { useFavoritesStore } from './FavoritesStore'; -import { useConfigStore } from './ConfigStore'; +import { useAccountStore } from '../stores/AccountStore'; +import { useFavoritesStore } from '../stores/FavoritesStore'; +import { useConfigStore } from '../stores/ConfigStore'; @injectable() export default class FavoritesController { private readonly favoritesService: FavoriteService; - private readonly accountService?: AccountService; - constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, favoritesService: FavoriteService) { + constructor(favoritesService: FavoriteService) { this.favoritesService = favoritesService; - this.accountService = getNamedModule(AccountService, integrationType, false); } - private updateUserFavorites(favorites: Favorite[]) { - const { user } = useAccountStore.getState(); - - if (user) { - useAccountStore.setState((state) => ({ - ...state, - user: { - ...(state.user as Customer), - externalData: { ...state.user?.externalData, favorites: this.serializeFavorites(favorites) }, - }, - })); - } - } + initialize = async () => { + await this.restoreFavorites(); + }; restoreFavorites = async () => { const { user } = useAccountStore.getState(); @@ -45,11 +26,8 @@ export default class FavoritesController { if (!favoritesList) { return; } - const favorites = await this.favoritesService.getFavorites(user, favoritesList); - if (!favorites) { - return; - } + const favorites = await this.favoritesService.getFavorites(user, favoritesList); useFavoritesStore.setState({ favorites, favoritesPlaylistId: favoritesList }); }; @@ -58,19 +36,7 @@ export default class FavoritesController { const { favorites } = useFavoritesStore.getState(); const { user } = useAccountStore.getState(); - if (user?.id && user?.externalData) { - return this.accountService?.updatePersonalShelves({ id: user.id, externalData: user.externalData }); - } - - this.favoritesService.persistFavorites(favorites); - }; - - serializeFavorites = (favorites: Favorite[]): SerializedFavorite[] => { - return this.favoritesService.serializeFavorites(favorites); - }; - - initialize = async () => { - await this.restoreFavorites(); + await this.favoritesService.persistFavorites(favorites, user); }; saveItem = async (item: PlaylistItem) => { @@ -79,7 +45,6 @@ export default class FavoritesController { if (!favorites.some(({ mediaid }) => mediaid === item.mediaid)) { const items = [this.favoritesService.createFavorite(item)].concat(favorites); useFavoritesStore.setState({ favorites: items }); - this.updateUserFavorites(items); await this.persistFavorites(); } }; @@ -89,7 +54,6 @@ export default class FavoritesController { const items = favorites.filter(({ mediaid }) => mediaid !== item.mediaid); useFavoritesStore.setState({ favorites: items }); - this.updateUserFavorites(items); await this.persistFavorites(); }; @@ -101,9 +65,9 @@ export default class FavoritesController { return; } - const isFavorited = hasItem(item); + const isFavorite = hasItem(item); - if (isFavorited) { + if (isFavorite) { await this.removeItem(item); return; diff --git a/packages/common/src/stores/ProfileController.ts b/packages/common/src/controllers/ProfileController.ts similarity index 94% rename from packages/common/src/stores/ProfileController.ts rename to packages/common/src/controllers/ProfileController.ts index 22586458f..c73948433 100644 --- a/packages/common/src/stores/ProfileController.ts +++ b/packages/common/src/controllers/ProfileController.ts @@ -2,14 +2,13 @@ import { inject, injectable } from 'inversify'; import type { ProfilesData } from '@inplayer-org/inplayer.js'; import * as yup from 'yup'; -import type { EnterProfilePayload, ProfileDetailsPayload, ProfilePayload } from '../../types/account'; import ProfileService from '../services/integrations/ProfileService'; import type { IntegrationType } from '../../types/config'; import { assertModuleMethod, getNamedModule } from '../modules/container'; import StorageService from '../services/StorageService'; import { INTEGRATION_TYPE } from '../modules/types'; - -import { useProfileStore } from './ProfileStore'; +import type { EnterProfilePayload, ProfileDetailsPayload, ProfilePayload } from '../../types/profiles'; +import { useProfileStore } from '../stores/ProfileStore'; const PERSIST_PROFILE = 'profile'; @@ -68,9 +67,7 @@ export default class ProfileController { enterProfile = async ({ id, pin }: EnterProfilePayload) => { assertModuleMethod(this.profileService?.enterProfile, 'enterProfile is not available in profile service'); - const response = await this.profileService.enterProfile({ id, pin }); - - const profile = response?.responseData; + const profile = await this.profileService.enterProfile({ id, pin }); if (!profile) { throw new Error('Unable to enter profile'); diff --git a/packages/common/src/controllers/WatchHistoryController.ts b/packages/common/src/controllers/WatchHistoryController.ts new file mode 100644 index 000000000..708914ec1 --- /dev/null +++ b/packages/common/src/controllers/WatchHistoryController.ts @@ -0,0 +1,66 @@ +import { injectable } from 'inversify'; + +import WatchHistoryService from '../services/WatchHistoryService'; +import type { PlaylistItem } from '../../types/playlist'; +import type { WatchHistoryItem } from '../../types/watchHistory'; +import { useAccountStore } from '../stores/AccountStore'; +import { useConfigStore } from '../stores/ConfigStore'; +import { useWatchHistoryStore } from '../stores/WatchHistoryStore'; + +@injectable() +export default class WatchHistoryController { + private readonly watchHistoryService: WatchHistoryService; + + constructor(watchHistoryService: WatchHistoryService) { + this.watchHistoryService = watchHistoryService; + } + + initialize = async () => { + await this.restoreWatchHistory(); + }; + + restoreWatchHistory = async () => { + const { user } = useAccountStore.getState(); + const continueWatchingList = useConfigStore.getState().config.features?.continueWatchingList; + + if (!continueWatchingList) { + return; + } + + const watchHistory = await this.watchHistoryService.getWatchHistory(user, continueWatchingList); + + useWatchHistoryStore.setState({ + watchHistory: watchHistory.filter((item): item is WatchHistoryItem => !!item?.mediaid), + playlistItemsLoaded: true, + continueWatchingPlaylistId: continueWatchingList, + }); + }; + + persistWatchHistory = async () => { + const { watchHistory } = useWatchHistoryStore.getState(); + const { user } = useAccountStore.getState(); + + await this.watchHistoryService.persistWatchHistory(watchHistory, user); + }; + + /** + * If we already have an element with continue watching state, we: + * 1. Update the progress + * 2. Move the element to the continue watching list start + * Otherwise: + * 1. Move the element to the continue watching list start + * 2. If there are many elements in continue watching state we remove the oldest one + */ + saveItem = async (item: PlaylistItem, seriesItem: PlaylistItem | undefined, videoProgress: number | null) => { + const { watchHistory } = useWatchHistoryStore.getState(); + + if (!videoProgress) return; + + const updatedHistory = await this.watchHistoryService.saveItem(item, seriesItem, videoProgress, watchHistory); + + if (updatedHistory) { + useWatchHistoryStore.setState({ watchHistory: updatedHistory }); + await this.persistWatchHistory(); + } + }; +} diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts index 606801157..5d4066571 100644 --- a/packages/common/src/env.ts +++ b/packages/common/src/env.ts @@ -2,7 +2,7 @@ export type Env = { APP_VERSION: string; APP_API_BASE_URL: string; APP_PLAYER_ID: string; - + APP_FOOTER_TEXT: string; APP_DEFAULT_CONFIG_SOURCE?: string; APP_PLAYER_LICENSE_KEY?: string; }; @@ -11,12 +11,14 @@ const env: Env = { APP_VERSION: '', APP_API_BASE_URL: 'https://cdn.jwplayer.com', APP_PLAYER_ID: 'M4qoGvUk', + APP_FOOTER_TEXT: '', }; export const configureEnv = (options: Partial) => { env.APP_VERSION = options.APP_VERSION || env.APP_VERSION; env.APP_API_BASE_URL = options.APP_API_BASE_URL || env.APP_API_BASE_URL; env.APP_PLAYER_ID = options.APP_PLAYER_ID || env.APP_PLAYER_ID; + env.APP_FOOTER_TEXT = options.APP_FOOTER_TEXT || env.APP_FOOTER_TEXT; env.APP_DEFAULT_CONFIG_SOURCE ||= options.APP_DEFAULT_CONFIG_SOURCE; env.APP_PLAYER_LICENSE_KEY ||= options.APP_PLAYER_LICENSE_KEY; diff --git a/packages/common/src/errors/FormValidationError.ts b/packages/common/src/errors/FormValidationError.ts new file mode 100644 index 000000000..a3d6a5805 --- /dev/null +++ b/packages/common/src/errors/FormValidationError.ts @@ -0,0 +1,11 @@ +type FormValidationErrors = Record; + +export class FormValidationError extends Error { + public errors: FormValidationErrors; + + constructor(errors: FormValidationErrors) { + super(Object.values(errors).flat().join(';')); + + this.errors = errors; + } +} diff --git a/packages/common/src/modules/functions/getIntegrationType.ts b/packages/common/src/modules/functions/getIntegrationType.ts index d387b6b04..8b3b88678 100644 --- a/packages/common/src/modules/functions/getIntegrationType.ts +++ b/packages/common/src/modules/functions/getIntegrationType.ts @@ -1,6 +1,6 @@ import type { interfaces } from 'inversify'; -import AppController from '../../stores/AppController'; +import AppController from '../../controllers/AppController'; /** * This function is used to get the integration type from the AppController and is mainly used for getting named diff --git a/packages/common/src/modules/register.ts b/packages/common/src/modules/register.ts index 8eb29b055..e26533416 100644 --- a/packages/common/src/modules/register.ts +++ b/packages/common/src/modules/register.ts @@ -13,13 +13,13 @@ import FavoriteService from '../services/FavoriteService'; import ConfigService from '../services/ConfigService'; import SettingsService from '../services/SettingsService'; -import WatchHistoryController from '../stores/WatchHistoryController'; -import CheckoutController from '../stores/CheckoutController'; -import AccountController from '../stores/AccountController'; -import ProfileController from '../stores/ProfileController'; -import FavoritesController from '../stores/FavoritesController'; -import AppController from '../stores/AppController'; -import EpgController from '../stores/EpgController'; +import WatchHistoryController from '../controllers/WatchHistoryController'; +import CheckoutController from '../controllers/CheckoutController'; +import AccountController from '../controllers/AccountController'; +import ProfileController from '../controllers/ProfileController'; +import FavoritesController from '../controllers/FavoritesController'; +import AppController from '../controllers/AppController'; +import EpgController from '../controllers/EpgController'; // Epg services import EpgService from '../services/EpgService'; diff --git a/packages/common/src/paths.tsx b/packages/common/src/paths.tsx new file mode 100644 index 000000000..c0cf2530d --- /dev/null +++ b/packages/common/src/paths.tsx @@ -0,0 +1,26 @@ +export const PATH_HOME = '/'; + +export const PATH_MEDIA = '/m/:id/*'; +export const PATH_PLAYLIST = '/p/:id/*'; +export const PATH_LEGACY_SERIES = '/s/:id/*'; + +export const PATH_SEARCH = '/q/*'; +export const PATH_ABOUT = '/o/about'; + +export const PATH_USER_BASE = '/u'; +export const PATH_USER = `${PATH_USER_BASE}/*`; + +export const RELATIVE_PATH_USER_ACCOUNT = 'my-account'; +export const RELATIVE_PATH_USER_MY_PROFILE = 'my-profile/:id'; +export const RELATIVE_PATH_USER_FAVORITES = 'favorites'; +export const RELATIVE_PATH_USER_PAYMENTS = 'payments'; +export const RELATIVE_PATH_USER_PROFILES = 'profiles'; + +export const PATH_USER_ACCOUNT = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_ACCOUNT}`; +export const PATH_USER_MY_PROFILE = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_MY_PROFILE}`; +export const PATH_USER_FAVORITES = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_FAVORITES}`; +export const PATH_USER_PAYMENTS = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PAYMENTS}`; +export const PATH_USER_PROFILES = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}`; +export const PATH_USER_PROFILES_CREATE = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}/create`; +export const PATH_USER_PROFILES_EDIT = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}/edit`; +export const PATH_USER_PROFILES_EDIT_PROFILE = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_PROFILES}/edit/:id`; diff --git a/packages/common/src/services/FavoriteService.ts b/packages/common/src/services/FavoriteService.ts index 205ebaab0..e2319b07c 100644 --- a/packages/common/src/services/FavoriteService.ts +++ b/packages/common/src/services/FavoriteService.ts @@ -1,12 +1,23 @@ -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; +import { object, array, string } from 'yup'; import { MAX_WATCHLIST_ITEMS_COUNT } from '../constants'; import type { Favorite, SerializedFavorite } from '../../types/favorite'; import type { PlaylistItem } from '../../types/playlist'; import type { Customer } from '../../types/account'; +import { getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; +import { logDev } from '../utils/common'; import ApiService from './ApiService'; import StorageService from './StorageService'; +import AccountService from './integrations/AccountService'; + +const schema = array( + object().shape({ + mediaid: string(), + }), +); @injectable() export default class FavoriteService { @@ -15,31 +26,66 @@ export default class FavoriteService { private readonly apiService; private readonly storageService; + private readonly accountService; - constructor(apiService: ApiService, storageService: StorageService) { + constructor(@inject(INTEGRATION_TYPE) integrationType: string, apiService: ApiService, storageService: StorageService) { this.apiService = apiService; this.storageService = storageService; + this.accountService = getNamedModule(AccountService, integrationType, false); + } + + private validateFavorites(favorites: unknown) { + if (favorites && schema.validateSync(favorites)) { + return favorites as SerializedFavorite[]; + } + + return []; + } + + private async getFavoritesFromAccount(user: Customer) { + const favorites = await this.accountService?.getFavorites({ user }); + + return this.validateFavorites(favorites); + } + + private async getFavoritesFromStorage() { + const favorites = await this.storageService.getItem(this.PERSIST_KEY_FAVORITES, true); + + return this.validateFavorites(favorites); } getFavorites = async (user: Customer | null, favoritesList: string) => { - const savedItems = user ? user.externalData?.favorites : await this.storageService.getItem(this.PERSIST_KEY_FAVORITES, true); + const savedItems = user ? await this.getFavoritesFromAccount(user) : await this.getFavoritesFromStorage(); + const mediaIds = savedItems.map(({ mediaid }) => mediaid); - if (savedItems?.length) { - const playlistItems = await this.apiService.getMediaByWatchlist( - favoritesList, - savedItems.map(({ mediaid }) => mediaid), - ); + if (!mediaIds) { + return []; + } + + try { + const playlistItems = await this.apiService.getMediaByWatchlist(favoritesList, mediaIds); return (playlistItems || []).map((item) => this.createFavorite(item)); + } catch (error: unknown) { + logDev('Failed to get favorites', error); } + + return []; }; serializeFavorites = (favorites: Favorite[]): SerializedFavorite[] => { return favorites.map(({ mediaid }) => ({ mediaid })); }; - persistFavorites = (favorites: Favorite[]) => { - this.storageService.setItem(this.PERSIST_KEY_FAVORITES, JSON.stringify(this.serializeFavorites(favorites))); + persistFavorites = async (favorites: Favorite[], user: Customer | null) => { + if (user) { + return this.accountService?.updateFavorites({ + favorites: this.serializeFavorites(favorites), + user, + }); + } else { + await this.storageService.setItem(this.PERSIST_KEY_FAVORITES, JSON.stringify(this.serializeFavorites(favorites))); + } }; getMaxFavoritesCount = () => { diff --git a/packages/common/src/services/WatchHistoryService.ts b/packages/common/src/services/WatchHistoryService.ts index 29c619275..e5df748d3 100644 --- a/packages/common/src/services/WatchHistoryService.ts +++ b/packages/common/src/services/WatchHistoryService.ts @@ -1,23 +1,37 @@ -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; +import { array, number, object, string } from 'yup'; import type { PlaylistItem } from '../../types/playlist'; import type { SerializedWatchHistoryItem, WatchHistoryItem } from '../../types/watchHistory'; import type { Customer } from '../../types/account'; +import { getNamedModule } from '../modules/container'; +import { INTEGRATION_TYPE } from '../modules/types'; +import { logDev } from '../utils/common'; import ApiService from './ApiService'; import StorageService from './StorageService'; +import AccountService from './integrations/AccountService'; + +const schema = array( + object().shape({ + mediaid: string(), + progress: number(), + }), +); @injectable() export default class WatchHistoryService { private PERSIST_KEY_WATCH_HISTORY = 'history'; private MAX_WATCH_HISTORY_COUNT = 48; - private readonly apiService: ApiService; - private readonly storageService: StorageService; + private readonly apiService; + private readonly storageService; + private readonly accountService; - constructor(apiService: ApiService, storageService: StorageService) { + constructor(@inject(INTEGRATION_TYPE) integrationType: string, apiService: ApiService, storageService: StorageService) { this.apiService = apiService; this.storageService = storageService; + this.accountService = getNamedModule(AccountService, integrationType); } // Retrieve watch history media items info using a provided watch list @@ -34,8 +48,9 @@ export default class WatchHistoryService { const seriesIds = Object.keys(mediaWithSeries || {}) .map((key) => mediaWithSeries?.[key]?.[0]?.series_id) .filter(Boolean) as string[]; + const uniqueSerieIds = [...new Set(seriesIds)]; - const seriesItems = await this.apiService.getMediaByWatchlist(continueWatchingList, seriesIds); + const seriesItems = await this.apiService.getMediaByWatchlist(continueWatchingList, uniqueSerieIds); const seriesItemsDict = Object.keys(mediaWithSeries || {}).reduce((acc, key) => { const seriesItemId = mediaWithSeries?.[key]?.[0]?.series_id; if (seriesItemId) { @@ -47,27 +62,55 @@ export default class WatchHistoryService { return seriesItemsDict; }; + private validateWatchHistory(history: unknown) { + if (history && schema.validateSync(history)) { + return history as SerializedWatchHistoryItem[]; + } + + return []; + } + + private async getWatchHistoryFromAccount(user: Customer) { + const history = await this.accountService.getWatchHistory({ user }); + + return this.validateWatchHistory(history); + } + + private async getWatchHistoryFromStorage() { + const history = await this.storageService.getItem(this.PERSIST_KEY_WATCH_HISTORY, true); + + return this.validateWatchHistory(history); + } + getWatchHistory = async (user: Customer | null, continueWatchingList: string) => { - const savedItems = user ? user.externalData?.history : await this.storageService.getItem(this.PERSIST_KEY_WATCH_HISTORY, true); + const savedItems = user ? await this.getWatchHistoryFromAccount(user) : await this.getWatchHistoryFromStorage(); + + // When item is an episode of the new flow -> show the card as a series one, but keep episode to redirect in a right way + const ids = savedItems.map(({ mediaid }) => mediaid); - if (savedItems?.length) { - // When item is an episode of the new flow -> show the card as a series one, but keep episode to redirect in a right way - const ids = savedItems.map(({ mediaid }) => mediaid); + if (!ids.length) { + return []; + } + try { const watchHistoryItems = await this.getWatchHistoryItems(continueWatchingList, ids); const seriesItems = await this.getWatchHistorySeriesItems(continueWatchingList, ids); - const watchHistory = savedItems.map((item) => { - const parentSeries = seriesItems?.[item.mediaid]; - const historyItem = watchHistoryItems[item.mediaid]; - - if (historyItem) { - return this.createWatchHistoryItem(parentSeries || historyItem, item.mediaid, parentSeries?.mediaid, item.progress); - } - }); - - return watchHistory; + return savedItems + .map((item) => { + const parentSeries = seriesItems?.[item.mediaid]; + const historyItem = watchHistoryItems[item.mediaid]; + + if (historyItem) { + return this.createWatchHistoryItem(parentSeries || historyItem, item.mediaid, parentSeries?.mediaid, item.progress); + } + }) + .filter((item): item is WatchHistoryItem => Boolean(item)); + } catch (error: unknown) { + logDev('Failed to get watch history items', error); } + + return []; }; serializeWatchHistory = (watchHistory: WatchHistoryItem[]): SerializedWatchHistoryItem[] => @@ -76,8 +119,15 @@ export default class WatchHistoryService { progress, })); - persistWatchHistory = (watchHistory: WatchHistoryItem[]) => { - this.storageService.setItem(this.PERSIST_KEY_WATCH_HISTORY, JSON.stringify(this.serializeWatchHistory(watchHistory))); + persistWatchHistory = async (watchHistory: WatchHistoryItem[], user: Customer | null) => { + if (user) { + await this.accountService?.updateWatchHistory({ + history: this.serializeWatchHistory(watchHistory), + user, + }); + } else { + await this.storageService.setItem(this.PERSIST_KEY_WATCH_HISTORY, JSON.stringify(this.serializeWatchHistory(watchHistory))); + } }; /** Use mediaid of originally watched movie / episode. diff --git a/packages/common/src/services/integrations/AccountService.ts b/packages/common/src/services/integrations/AccountService.ts index f6c795f3c..b72475b3d 100644 --- a/packages/common/src/services/integrations/AccountService.ts +++ b/packages/common/src/services/integrations/AccountService.ts @@ -1,10 +1,7 @@ import type { AccessModel, Config } from '../../../types/config'; import type { - AuthData, ChangePassword, ChangePasswordWithOldPassword, - Customer, - CustomerConsent, DeleteAccount, ExportAccountData, GetCaptureStatus, @@ -16,9 +13,15 @@ import type { ResetPassword, GetSocialURLs, UpdateCaptureAnswers, - UpdateCustomer, UpdateCustomerConsents, - UpdatePersonalShelves, + Logout, + GetAuthData, + UpdateCustomer, + UpdateWatchHistory, + UpdateFavorites, + GetWatchHistory, + GetFavorites, + GetUser, } from '../../../types/account'; export type AccountServiceFeatures = { @@ -47,15 +50,15 @@ export default abstract class AccountService { abstract initialize: (config: Config, url: string, logoutCallback: () => Promise) => Promise; - abstract getAuthData: () => Promise; + abstract getAuthData: GetAuthData; abstract login: Login; abstract register: Register; - abstract logout: () => Promise; + abstract logout: Logout; - abstract getUser: ({ config }: { config: Config }) => Promise<{ user: Customer; customerConsents: CustomerConsent[] }>; + abstract getUser: GetUser; abstract getPublisherConsents: GetPublisherConsents; @@ -75,7 +78,13 @@ export default abstract class AccountService { abstract updateCustomer: UpdateCustomer; - abstract updatePersonalShelves: UpdatePersonalShelves; + abstract updateWatchHistory: UpdateWatchHistory; + + abstract updateFavorites: UpdateFavorites; + + abstract getWatchHistory: GetWatchHistory; + + abstract getFavorites: GetFavorites; abstract subscribeToNotifications: NotificationsData; diff --git a/packages/common/src/services/integrations/ProfileService.ts b/packages/common/src/services/integrations/ProfileService.ts index b0ed2fc0a..dac065623 100644 --- a/packages/common/src/services/integrations/ProfileService.ts +++ b/packages/common/src/services/integrations/ProfileService.ts @@ -1,4 +1,4 @@ -import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../types/account'; +import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../types/profiles'; export default abstract class ProfileService { abstract listProfiles: ListProfiles; diff --git a/packages/common/src/services/integrations/cleeng/CleengAccountService.ts b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts index 1ad66bf10..40c55a285 100644 --- a/packages/common/src/services/integrations/cleeng/CleengAccountService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts @@ -4,17 +4,12 @@ import { inject, injectable } from 'inversify'; import type { AccessModel, Config } from '../../../../types/config'; import type { AuthData, - Capture, ChangePassword, ChangePasswordWithOldPassword, GetCaptureStatus, GetCaptureStatusResponse, - GetCustomer, GetCustomerConsents, - GetCustomerConsentsResponse, - GetLocales, GetPublisherConsents, - GetPublisherConsentsResponse, JwtDetails, Login, LoginPayload, @@ -28,14 +23,29 @@ import type { UpdateCustomerConsents, UpdateCustomerConsentsPayload, UpdateCustomerPayload, - UpdatePersonalShelves, + UpdateFavorites, + UpdateWatchHistory, } from '../../../../types/account'; import AccountService from '../AccountService'; import { GET_CUSTOMER_IP } from '../../../modules/types'; import type { GetCustomerIP } from '../../../../types/get-customer-ip'; import { ACCESS_MODEL } from '../../../constants'; +import type { ServiceResponse } from '../../../../types/service'; +import type { SerializedWatchHistoryItem } from '../../../../types/watchHistory'; +import type { SerializedFavorite } from '../../../../types/favorite'; import CleengService from './CleengService'; +import type { + GetCustomerResponse, + GetCustomerConsentsResponse, + GetPublisherConsentsResponse, + UpdateConsentsResponse, + UpdateCustomerResponse, + AuthResponse, +} from './types/account'; +import { formatCustomer } from './formatters/customer'; +import { formatPublisherConsent } from './formatters/consents'; +import type { Response } from './types/api'; @injectable() export default class CleengAccountService extends AccountService { @@ -43,6 +53,8 @@ export default class CleengAccountService extends AccountService { private readonly getCustomerIP; private publisherId = ''; + private externalData: Record = {}; + accessModel: AccessModel = ACCESS_MODEL.AUTHVOD; svodOfferIds: string[] = []; sandbox = false; @@ -65,7 +77,7 @@ export default class CleengAccountService extends AccountService { this.getCustomerIP = getCustomerIP; } - private handleErrors = (errors: ApiResponse['errors']) => { + private handleErrors = (errors: string[]) => { if (errors.length > 0) { throw new Error(errors[0]); } @@ -76,11 +88,18 @@ export default class CleengAccountService extends AccountService { return decodedToken.customerId; }; - private getCustomer: GetCustomer = async (payload) => { - return this.cleengService.get(`/customers/${payload.customerId}`, { authenticate: true }); + private getCustomer = async ({ customerId }: { customerId: string }) => { + const { responseData, errors } = await this.cleengService.get(`/customers/${customerId}`, { + authenticate: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + + return formatCustomer(responseData); }; - private getLocales: GetLocales = async () => { + private getLocales = async () => { return this.cleengService.getLocales(); }; @@ -114,14 +133,12 @@ export default class CleengAccountService extends AccountService { getCustomerConsents: GetCustomerConsents = async (payload) => { const { customer } = payload; - const response: ServiceResponse = await this.cleengService.get(`/customers/${customer?.id}/consents`, { + const response = await this.cleengService.get(`/customers/${customer?.id}/consents`, { authenticate: true, }); this.handleErrors(response.errors); - return { - consents: response?.responseData?.consents || [], - }; + return response?.responseData?.consents || []; }; updateCustomerConsents: UpdateCustomerConsents = async (payload) => { @@ -132,7 +149,7 @@ export default class CleengAccountService extends AccountService { consents: payload.consents, }; - const response: ServiceResponse = await this.cleengService.put(`/customers/${customer?.id}/consents`, JSON.stringify(params), { + const response = await this.cleengService.put(`/customers/${customer?.id}/consents`, JSON.stringify(params), { authenticate: true, }); this.handleErrors(response.errors); @@ -140,7 +157,7 @@ export default class CleengAccountService extends AccountService { return await this.getCustomerConsents(payload); }; - login: Login = async ({ config, email, password }) => { + login: Login = async ({ email, password }) => { const payload: LoginPayload = { email, password, @@ -148,12 +165,12 @@ export default class CleengAccountService extends AccountService { customerIP: await this.getCustomerIP(), }; - const { responseData: auth, errors }: ServiceResponse = await this.cleengService.post('/auths', JSON.stringify(payload)); + const { responseData: auth, errors } = await this.cleengService.post('/auths', JSON.stringify(payload)); this.handleErrors(errors); await this.cleengService.setTokens({ accessToken: auth.jwt, refreshToken: auth.refreshToken }); - const { user, customerConsents } = await this.getUser({ config }); + const { user, customerConsents } = await this.getUser(); return { user, @@ -162,7 +179,7 @@ export default class CleengAccountService extends AccountService { }; }; - register: Register = async ({ config, email, password, consents }) => { + register: Register = async ({ email, password, consents }) => { const localesResponse = await this.getLocales(); this.handleErrors(localesResponse.errors); @@ -182,9 +199,9 @@ export default class CleengAccountService extends AccountService { await this.cleengService.setTokens({ accessToken: auth.jwt, refreshToken: auth.refreshToken }); - const { user, customerConsents } = await this.getUser({ config }); + const { user, customerConsents } = await this.getUser(); - await this.updateCustomerConsents({ config, consents, customer: user }).catch(() => { + await this.updateCustomerConsents({ consents, customer: user }).catch(() => { // error caught while updating the consents, but continue the registration process }); @@ -200,22 +217,14 @@ export default class CleengAccountService extends AccountService { await this.cleengService.clearTokens(); }; - getUser = async ({ config }: { config: Config }) => { + getUser = async () => { const authData = await this.getAuthData(); if (!authData) throw new Error('Not logged in'); const customerId = this.getCustomerIdFromAuthData(authData); - const { responseData: user, errors } = await this.getCustomer({ customerId }); - - this.handleErrors(errors); - - const consentsPayload = { - config, - customer: user, - }; - - const { consents } = await this.getCustomerConsents(consentsPayload); + const user = await this.getCustomer({ customerId }); + const consents = await this.getCustomerConsents({ customer: user }); return { user, @@ -223,15 +232,12 @@ export default class CleengAccountService extends AccountService { }; }; - getPublisherConsents: GetPublisherConsents = async (config) => { - const { cleeng } = config.integrations; - const response: ServiceResponse = await this.cleengService.get(`/publishers/${cleeng?.id}/consents`); + getPublisherConsents: GetPublisherConsents = async () => { + const response = await this.cleengService.get(`/publishers/${this.publisherId}/consents`); this.handleErrors(response.errors); - return { - consents: response?.responseData?.consents || [], - }; + return (response.responseData?.consents || []).map(formatPublisherConsent); }; getCaptureStatus: GetCaptureStatus = async ({ customer }) => { @@ -241,7 +247,7 @@ export default class CleengAccountService extends AccountService { this.handleErrors(response.errors); - return response; + return response.responseData; }; updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...payload }) => { @@ -250,33 +256,40 @@ export default class CleengAccountService extends AccountService { ...payload, }; - const response: ServiceResponse = await this.cleengService.put(`/customers/${customer.id}/capture`, JSON.stringify(params), { + const response = await this.cleengService.put(`/customers/${customer.id}/capture`, JSON.stringify(params), { authenticate: true, }); this.handleErrors(response.errors); - const { responseData, errors } = await this.getCustomer({ customerId: customer.id }); - this.handleErrors(errors); - - return { - errors: [], - responseData, - }; + return this.getCustomer({ customerId: customer.id }); }; resetPassword: ResetPassword = async (payload) => { - return this.cleengService.put('/customers/passwords', JSON.stringify({ ...payload, publisherId: this.publisherId })); + const response = await this.cleengService.put>( + '/customers/passwords', + JSON.stringify({ + ...payload, + publisherId: this.publisherId, + }), + ); + + this.handleErrors(response.errors); }; changePasswordWithResetToken: ChangePassword = async (payload) => { - return this.cleengService.patch('/customers/passwords', JSON.stringify({ ...payload, publisherId: this.publisherId })); + const response = await this.cleengService.patch>( + '/customers/passwords', + JSON.stringify({ + ...payload, + publisherId: this.publisherId, + }), + ); + + this.handleErrors(response.errors); }; changePasswordWithOldPassword: ChangePasswordWithOldPassword = async () => { - return { - errors: [], - responseData: {}, - }; + // Cleeng doesn't support this feature }; updateCustomer: UpdateCustomer = async (payload) => { @@ -285,15 +298,47 @@ export default class CleengAccountService extends AccountService { id, ...rest, }; + // enable keepalive to ensure data is persisted when closing the browser/tab - return this.cleengService.patch(`/customers/${id}`, JSON.stringify(params), { + const { responseData, errors } = await this.cleengService.patch(`/customers/${id}`, JSON.stringify(params), { authenticate: true, keepalive: true, }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + + return formatCustomer(responseData); + }; + + updateWatchHistory: UpdateWatchHistory = async ({ user, history }) => { + const payload = { id: user.id, externalData: { ...this.externalData, history } }; + const { errors, responseData } = await this.cleengService.patch(`/customers/${user.id}`, JSON.stringify(payload), { + authenticate: true, + keepalive: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + }; + + updateFavorites: UpdateFavorites = async ({ user, favorites }) => { + const payload = { id: user.id, externalData: { ...this.externalData, favorites } }; + const { errors, responseData } = await this.cleengService.patch(`/customers/${user.id}`, JSON.stringify(payload), { + authenticate: true, + keepalive: true, + }); + + this.handleErrors(errors); + this.externalData = responseData.externalData || {}; + }; + + getWatchHistory = async () => { + return (this.externalData['history'] || []) as SerializedWatchHistoryItem[]; }; - updatePersonalShelves: UpdatePersonalShelves = async (payload) => { - return await this.updateCustomer(payload); + getFavorites = async () => { + return (this.externalData['favorites'] || []) as SerializedFavorite[]; }; subscribeToNotifications: NotificationsData = async () => { diff --git a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts index 1386611d0..c0dcd4e9c 100644 --- a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts @@ -64,12 +64,14 @@ export default class CleengCheckoutService extends CheckoutService { if (locales.errors.length > 0) throw new Error(locales.errors[0]); + const customerIP = locales.responseData.ipAddress; + const createOrderPayload: CreateOrderPayload = { offerId: payload.offer.offerId, customerId: payload.customerId, country: payload.country, currency: locales?.responseData?.currency || 'EUR', - customerIP: payload.customerIP, + customerIP, paymentMethodId: payload.paymentMethodId, }; diff --git a/packages/common/src/services/integrations/cleeng/CleengService.ts b/packages/common/src/services/integrations/cleeng/CleengService.ts index e855c173e..34d0ba298 100644 --- a/packages/common/src/services/integrations/cleeng/CleengService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengService.ts @@ -5,11 +5,14 @@ import { BroadcastChannel } from 'broadcast-channel'; import { IS_DEVELOPMENT_BUILD, logDev } from '../../../utils/common'; import { PromiseQueue } from '../../../utils/promiseQueue'; -import type { AuthData, GetLocales } from '../../../../types/account'; +import type { AuthData } from '../../../../types/account'; import StorageService from '../../StorageService'; import { GET_CUSTOMER_IP } from '../../../modules/types'; import type { GetCustomerIP } from '../../../../types/get-customer-ip'; +import type { GetLocalesResponse } from './types/account'; +import type { Response } from './types/api'; + const AUTH_PERSIST_KEY = 'auth'; type Tokens = { @@ -119,7 +122,7 @@ export default class CleengService { */ private getNewTokens: (tokens: Tokens) => Promise = async ({ refreshToken }) => { try { - const { responseData: newTokens } = await this.post>>('/auths/refresh_token', JSON.stringify({ refreshToken })); + const { responseData: newTokens } = await this.post>('/auths/refresh_token', JSON.stringify({ refreshToken })); return { accessToken: newTokens.jwt, @@ -356,19 +359,19 @@ export default class CleengService { return accessToken; }; - getLocales: GetLocales = async () => { + getLocales = async () => { const customerIP = await this.getCustomerIP(); - return this.get(`/locales${customerIP ? '?customerIP=' + customerIP : ''}`); + return this.get(`/locales${customerIP ? '?customerIP=' + customerIP : ''}`); }; - get = (path: string, options?: RequestOptions) => this.performRequest(path, 'GET', undefined, options) as T; + get = (path: string, options?: RequestOptions) => this.performRequest(path, 'GET', undefined, options) as Promise; - patch = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PATCH', body, options) as T; + patch = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PATCH', body, options) as Promise; - put = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PUT', body, options) as T; + put = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'PUT', body, options) as Promise; - post = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'POST', body, options) as T; + post = (path: string, body?: string, options?: RequestOptions) => this.performRequest(path, 'POST', body, options) as Promise; - remove = (path: string, options?: RequestOptions) => this.performRequest(path, 'DELETE', undefined, options) as T; + remove = (path: string, options?: RequestOptions) => this.performRequest(path, 'DELETE', undefined, options) as Promise; } diff --git a/packages/common/src/services/integrations/cleeng/formatters/consents.ts b/packages/common/src/services/integrations/cleeng/formatters/consents.ts new file mode 100644 index 000000000..910c9908f --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/formatters/consents.ts @@ -0,0 +1,16 @@ +import type { PublisherConsent } from '../types/models'; +import type { CustomFormField } from '../../../../../types/account'; + +export const formatPublisherConsent = (consent: PublisherConsent): CustomFormField => { + return { + type: 'checkbox', + name: consent.name, + label: consent.label, + defaultValue: '', + required: consent.required, + placeholder: consent.placeholder, + options: {}, + enabledByDefault: false, + version: consent.version, + }; +}; diff --git a/packages/common/src/services/integrations/cleeng/formatters/customer.ts b/packages/common/src/services/integrations/cleeng/formatters/customer.ts new file mode 100644 index 000000000..3a206a2bb --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/formatters/customer.ts @@ -0,0 +1,15 @@ +import type { CleengCustomer } from '../types/models'; +import type { Customer } from '../../../../../types/account'; + +export const formatCustomer = (customer: CleengCustomer): Customer => { + return { + id: customer.id, + email: customer.email, + country: customer.country, + firstName: customer.firstName, + lastName: customer.lastName, + fullName: `${customer.firstName} ${customer.lastName}`, + // map `externalData` to `metadata` (NOTE; The Cleeng API returns parsed values) + metadata: customer.externalData || {}, + }; +}; diff --git a/packages/common/src/services/integrations/cleeng/types/account.ts b/packages/common/src/services/integrations/cleeng/types/account.ts new file mode 100644 index 000000000..956015ecf --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/types/account.ts @@ -0,0 +1,21 @@ +import type { AuthData } from '../../../../../types/account'; + +import type { Response } from './api'; +import type { CleengCustomer, LocalesData, PublisherConsent, CustomerConsent, UpdateConfirmation } from './models'; + +// Cleeng typings for the account endpoints + +// Auth +export type AuthResponse = Response; + +// Customer +export type GetCustomerResponse = Response; +export type UpdateCustomerResponse = Response; + +// Consents +export type UpdateConsentsResponse = Response; +export type GetPublisherConsentsResponse = Response<{ consents: PublisherConsent[] }>; +export type GetCustomerConsentsResponse = Response<{ consents: CustomerConsent[] }>; + +// Locales +export type GetLocalesResponse = Response; diff --git a/packages/common/src/services/integrations/cleeng/types/api.ts b/packages/common/src/services/integrations/cleeng/types/api.ts new file mode 100644 index 000000000..3c5a198e6 --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/types/api.ts @@ -0,0 +1,3 @@ +// Cleeng typings for generic API response structures + +export type Response = { responseData: R; errors: string[] }; diff --git a/packages/common/src/services/integrations/cleeng/types/models.ts b/packages/common/src/services/integrations/cleeng/types/models.ts new file mode 100644 index 000000000..f5e50c82b --- /dev/null +++ b/packages/common/src/services/integrations/cleeng/types/models.ts @@ -0,0 +1,47 @@ +// Cleeng typings for API models + +export interface CleengCustomer { + id: string; + email: string; + country: string; + regDate: string; + lastLoginDate?: string; + lastUserIp: string; + firstName?: string; + lastName?: string; + externalId?: string; + externalData?: Record; +} + +export interface UpdateConfirmation { + success: boolean; +} + +export interface LocalesData { + country: string; + currency: string; + locale: string; + ipAddress: string; +} + +export interface PublisherConsent { + name: string; + label: string; + placeholder: string; + required: boolean; + version: string; + value: string; +} + +export interface CustomerConsent { + customerId: string; + date: number; + label: string; + name: string; + needsUpdate: boolean; + newestVersion: string; + required: boolean; + state: 'accepted' | 'declined'; + value: string | boolean; + version: string; +} diff --git a/packages/common/src/services/integrations/jwp/JWPAccountService.ts b/packages/common/src/services/integrations/jwp/JWPAccountService.ts index 627fc368c..379dbb19d 100644 --- a/packages/common/src/services/integrations/jwp/JWPAccountService.ts +++ b/packages/common/src/services/integrations/jwp/JWPAccountService.ts @@ -4,22 +4,19 @@ import i18next from 'i18next'; import { injectable } from 'inversify'; import { formatConsentsToRegisterFields } from '../../../utils/collection'; -import { getCommonResponseData, isCommonError } from '../../../utils/api'; +import { isCommonError } from '../../../utils/api'; import type { AuthData, - Capture, ChangePassword, ChangePasswordWithOldPassword, - Consent, + CustomFormField, Customer, CustomerConsent, CustomRegisterFieldVariant, DeleteAccount, ExportAccountData, - ExternalData, GetCaptureStatus, GetCustomerConsents, - GetCustomerConsentsResponse, GetPublisherConsents, Login, NotificationsData, @@ -27,15 +24,16 @@ import type { ResetPassword, GetSocialURLs, UpdateCaptureAnswers, - UpdateCustomer, UpdateCustomerArgs, UpdateCustomerConsents, - UpdatePersonalShelves, + UpdateFavorites, + UpdateWatchHistory, + UpdateCustomer, } from '../../../../types/account'; import type { AccessModel, Config } from '../../../../types/config'; import type { InPlayerAuthData } from '../../../../types/inplayer'; -import type { Favorite } from '../../../../types/favorite'; -import type { WatchHistoryItem } from '../../../../types/watchHistory'; +import type { SerializedFavorite } from '../../../../types/favorite'; +import type { SerializedWatchHistoryItem } from '../../../../types/watchHistory'; import AccountService from '../AccountService'; import StorageService from '../../StorageService'; import { ACCESS_MODEL } from '../../../constants'; @@ -75,7 +73,7 @@ export default class JWPAccountService extends AccountService { this.storageService = storageService; } - private getCustomerExternalData = async (): Promise => { + private getCustomerExternalData = async () => { const [favoritesData, historyData] = await Promise.all([InPlayer.Account.getFavorites(), await InPlayer.Account.getWatchHistory({})]); const favorites = favoritesData.data?.collection?.map((favorite: FavoritesData) => { @@ -100,17 +98,17 @@ export default class JWPAccountService extends AccountService { } }; - private formatFavorite = (favorite: FavoritesData): Favorite => { + private formatFavorite = (favorite: FavoritesData): SerializedFavorite => { return { mediaid: favorite.media_id, - } as Favorite; + }; }; - private formatHistoryItem = (history: WatchHistory): WatchHistoryItem => { + private formatHistoryItem = (history: WatchHistory): SerializedWatchHistoryItem => { return { mediaid: history.media_id, progress: history.progress, - } as WatchHistoryItem; + }; }; private formatAccount = (account: AccountData): Customer => { @@ -134,6 +132,11 @@ export default class JWPAccountService extends AccountService { }; }; + private async getAccountMetadata(account: AccountData) { + const shelves = await this.getCustomerExternalData(); + return { ...account.metadata, ...shelves }; + } + private formatAuth(auth: InPlayerAuthData): AuthData { const { access_token: jwt } = auth; return { @@ -202,7 +205,7 @@ export default class JWPAccountService extends AccountService { // we exclude these fields because we already have them by default .filter((field) => !['email_confirmation', 'first_name', 'surname'].includes(field.name) && ![terms].includes(field)) .map( - (field): Consent => ({ + (field): CustomFormField => ({ type: field.type as CustomRegisterFieldVariant, isCustomRegisterField: true, name: field.name, @@ -210,6 +213,7 @@ export default class JWPAccountService extends AccountService { placeholder: field.placeholder, required: field.required, options: field.options, + defaultValue: '', version: '1', ...(field.type === 'checkbox' ? { @@ -221,9 +225,7 @@ export default class JWPAccountService extends AccountService { }), ); - const consents = terms ? [this.getTermsConsent(terms), ...result] : result; - - return { consents }; + return terms ? [this.getTermsConsent(terms), ...result] : result; } catch { throw new Error('Failed to fetch publisher consents.'); } @@ -238,9 +240,8 @@ export default class JWPAccountService extends AccountService { } const { customer } = payload; - const consents: GetCustomerConsentsResponse = this.parseJson(customer.metadata?.consents as string, []); - return consents; + return this.parseJson(customer.metadata?.consents as string, []); } catch { throw new Error('Unable to fetch Customer consents.'); } @@ -263,30 +264,25 @@ export default class JWPAccountService extends AccountService { const { data } = await InPlayer.Account.updateAccount(params); - return { - consents: this.parseJson(data?.metadata?.consents as string, []), - }; + return this.parseJson(data?.metadata?.consents as string, []); } catch { throw new Error('Unable to update Customer consents'); } }; updateCaptureAnswers: UpdateCaptureAnswers = async ({ customer, ...newAnswers }) => { - return (await this.updateCustomer({ ...customer, ...newAnswers })) as ServiceResponse; + return this.updateCustomer({ ...customer, ...newAnswers }); }; changePasswordWithOldPassword: ChangePasswordWithOldPassword = async (payload) => { const { oldPassword, newPassword, newPasswordConfirmation } = payload; + try { await InPlayer.Account.changePassword({ oldPassword, password: newPassword, passwordConfirmation: newPasswordConfirmation, }); - return { - errors: [], - responseData: {}, - }; } catch (error: unknown) { if (isCommonError(error)) { throw new Error(error.response.data.message); @@ -302,26 +298,22 @@ export default class JWPAccountService extends AccountService { merchantUuid: this.clientId, brandingId: 0, }); - return { - errors: [], - responseData: {}, - }; } catch { throw new Error('Failed to reset password.'); } }; - login: Login = async ({ config, email, password, referrer }) => { + login: Login = async ({ email, password, referrer }) => { try { const { data } = await InPlayer.Account.signInV2({ email, password, referrer, - clientId: config.integrations.jwp?.clientId || '', + clientId: this.clientId || '', }); const user = this.formatAccount(data.account); - user.externalData = await this.getCustomerExternalData(); + user.metadata = await this.getAccountMetadata(data.account); return { auth: this.formatAuth(data), @@ -333,7 +325,7 @@ export default class JWPAccountService extends AccountService { } }; - register: Register = async ({ config, email, password, referrer, consents }) => { + register: Register = async ({ email, password, referrer, consents }) => { try { const { data } = await InPlayer.Account.signUpV2({ email, @@ -348,11 +340,11 @@ export default class JWPAccountService extends AccountService { consents: JSON.stringify(consents), }, type: 'consumer', - clientId: config.integrations.jwp?.clientId || '', + clientId: this.clientId || '', }); const user = this.formatAccount(data.account); - user.externalData = await this.getCustomerExternalData(); + user.metadata = await this.getAccountMetadata(data.account); return { auth: this.formatAuth(data), @@ -369,8 +361,13 @@ export default class JWPAccountService extends AccountService { logout = async () => { try { - InPlayer.Notifications.unsubscribe(); - await InPlayer.Account.signOut(); + if (InPlayer.Notifications.isSubscribed()) { + InPlayer.Notifications.unsubscribe(); + } + + if (InPlayer.Account.isAuthenticated()) { + await InPlayer.Account.signOut(); + } } catch { throw new Error('Failed to sign out.'); } @@ -381,7 +378,7 @@ export default class JWPAccountService extends AccountService { const { data } = await InPlayer.Account.getAccountInfo(); const user = this.formatAccount(data); - user.externalData = await this.getCustomerExternalData(); + user.metadata = await this.getAccountMetadata(data); return { user, @@ -396,10 +393,7 @@ export default class JWPAccountService extends AccountService { try { const response = await InPlayer.Account.updateAccount(this.formatUpdateAccount(customer)); - return { - errors: [], - responseData: this.formatAccount(response.data), - }; + return this.formatAccount(response.data); } catch { throw new Error('Failed to update user data.'); } @@ -424,22 +418,19 @@ export default class JWPAccountService extends AccountService { getCaptureStatus: GetCaptureStatus = async ({ customer }) => { return { - errors: [], - responseData: { - isCaptureEnabled: true, - shouldCaptureBeDisplayed: true, - settings: [ - { - answer: { - firstName: customer.firstName || null, - lastName: customer.lastName || null, - }, - enabled: true, - key: 'firstNameLastName', - required: true, + isCaptureEnabled: true, + shouldCaptureBeDisplayed: true, + settings: [ + { + answer: { + firstName: customer.firstName || null, + lastName: customer.lastName || null, }, - ], - }, + enabled: true, + key: 'firstNameLastName', + required: true, + }, + ], }; }; @@ -454,10 +445,6 @@ export default class JWPAccountService extends AccountService { }, resetPasswordToken, ); - return { - errors: [], - responseData: {}, - }; } catch (error: unknown) { if (isCommonError(error)) { throw new Error(error.response.data.message); @@ -466,7 +453,7 @@ export default class JWPAccountService extends AccountService { } }; - getTermsConsent = ({ label: termsUrl }: RegisterField): Consent => { + getTermsConsent = ({ label: termsUrl }: RegisterField): CustomFormField => { const termsLink = `${i18next.t('account:registration.terms_and_conditions')}`; // t('account:registration.terms_consent_jwplayer') @@ -476,6 +463,7 @@ export default class JWPAccountService extends AccountService { isCustomRegisterField: true, required: true, name: 'terms', + defaultValue: '', label: termsUrl ? i18next.t('account:registration.terms_consent', { termsLink }) : i18next.t('account:registration.terms_consent_jwplayer', { termsLink }), @@ -486,44 +474,49 @@ export default class JWPAccountService extends AccountService { }; }; - updatePersonalShelves: UpdatePersonalShelves = async (payload) => { - const { favorites, history } = payload.externalData; + updateWatchHistory: UpdateWatchHistory = async ({ history }) => { const externalData = await this.getCustomerExternalData(); - const currentFavoriteIds = externalData?.favorites?.map((e) => e.mediaid); - const payloadFavoriteIds = favorites?.map((e) => e.mediaid); - const currentWatchHistoryIds = externalData?.history?.map((e) => e.mediaid); + const savedHistory = externalData.history?.map((e) => e.mediaid) || []; - try { - history?.forEach(async (history) => { - if ( - !currentWatchHistoryIds?.includes(history.mediaid) || - externalData?.history?.some((e) => e.mediaid == history.mediaid && e.progress != history.progress) - ) { - await InPlayer.Account.updateWatchHistory(history.mediaid, history.progress); + await Promise.allSettled( + history.map(({ mediaid, progress }) => { + if (!savedHistory.includes(mediaid) || externalData.history?.some((e) => e.mediaid == mediaid && e.progress != progress)) { + return InPlayer.Account.updateWatchHistory(mediaid, progress); } - }); + }), + ); + }; - if (payloadFavoriteIds && payloadFavoriteIds.length > (currentFavoriteIds?.length || 0)) { - payloadFavoriteIds.forEach(async (mediaId) => { - if (!currentFavoriteIds?.includes(mediaId)) { - await InPlayer.Account.addToFavorites(mediaId); - } - }); - } else { - currentFavoriteIds?.forEach(async (mediaid) => { - if (!payloadFavoriteIds?.includes(mediaid)) { - await InPlayer.Account.deleteFromFavorites(mediaid); - } - }); - } + updateFavorites: UpdateFavorites = async ({ favorites }) => { + const externalData = await this.getCustomerExternalData(); + const currentFavoriteIds = externalData?.favorites?.map((e) => e.mediaid) || []; + const payloadFavoriteIds = favorites.map((e) => e.mediaid); - return { - errors: [], - responseData: {}, - }; - } catch { - throw new Error('Failed to update external data'); - } + // save new favorites + await Promise.allSettled( + payloadFavoriteIds.map((mediaId) => { + return !currentFavoriteIds.includes(mediaId) ? InPlayer.Account.addToFavorites(mediaId) : Promise.resolve(); + }), + ); + + // delete removed favorites + await Promise.allSettled( + currentFavoriteIds.map((mediaId) => { + return !payloadFavoriteIds.includes(mediaId) ? InPlayer.Account.deleteFromFavorites(mediaId) : Promise.resolve(); + }), + ); + }; + + getFavorites = async () => { + const favoritesData = await InPlayer.Account.getFavorites(); + + return favoritesData.data?.collection?.map(this.formatFavorite) || []; + }; + + getWatchHistory = async () => { + const watchHistoryData = await InPlayer.Account.getWatchHistory({}); + + return watchHistoryData.data?.collection?.map(this.formatHistoryItem) || []; }; subscribeToNotifications: NotificationsData = async ({ uuid, onMessage }) => { @@ -544,7 +537,8 @@ export default class JWPAccountService extends AccountService { // password is sent as undefined because it is now optional on BE try { const response = await InPlayer.Account.exportData({ password: undefined, brandingId: 0 }); - return getCommonResponseData(response); + + return response.data; } catch { throw new Error('Failed to export account data'); } @@ -553,16 +547,17 @@ export default class JWPAccountService extends AccountService { deleteAccount: DeleteAccount = async ({ password }) => { try { const response = await InPlayer.Account.deleteAccount({ password, brandingId: 0 }); - return getCommonResponseData(response); + + return response.data; } catch { throw new Error('Failed to delete account'); } }; - getSocialUrls: GetSocialURLs = async ({ config, redirectUrl }) => { + getSocialUrls: GetSocialURLs = async ({ redirectUrl }) => { const socialState = this.storageService.base64Encode( JSON.stringify({ - client_id: config.integrations.jwp?.clientId || '', + client_id: this.clientId || '', redirect: redirectUrl, }), ); diff --git a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts index 2f7ab6ef0..453eac280 100644 --- a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts +++ b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts @@ -20,6 +20,7 @@ import type { UpdateOrder, } from '../../../../types/checkout'; import CheckoutService from '../CheckoutService'; +import type { ServiceResponse } from '../../../../types/service'; @injectable() export default class JWPCheckoutService extends CheckoutService { diff --git a/packages/common/src/services/integrations/jwp/JWPProfileService.ts b/packages/common/src/services/integrations/jwp/JWPProfileService.ts index 132ebe379..5ed0a36ae 100644 --- a/packages/common/src/services/integrations/jwp/JWPProfileService.ts +++ b/packages/common/src/services/integrations/jwp/JWPProfileService.ts @@ -2,9 +2,9 @@ import InPlayer from '@inplayer-org/inplayer.js'; import { injectable } from 'inversify'; import defaultAvatar from '@jwp/ott-theme/assets/profiles/default_avatar.png'; -import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../../types/account'; import ProfileService from '../ProfileService'; import StorageService from '../../StorageService'; +import type { CreateProfile, DeleteProfile, EnterProfile, GetProfileDetails, ListProfiles, UpdateProfile } from '../../../../types/profiles'; @injectable() export default class JWPProfileService extends ProfileService { @@ -18,46 +18,38 @@ export default class JWPProfileService extends ProfileService { listProfiles: ListProfiles = async () => { try { const response = await InPlayer.Account.getProfiles(); + return { - responseData: { - canManageProfiles: true, - collection: - response.data.map((profile) => ({ - ...profile, - avatar_url: profile?.avatar_url || defaultAvatar, - })) ?? [], - }, - errors: [], + canManageProfiles: true, + collection: + response.data.map((profile) => ({ + ...profile, + avatar_url: profile?.avatar_url || defaultAvatar, + })) ?? [], }; } catch { console.error('Unable to list profiles.'); return { - responseData: { - canManageProfiles: false, - collection: [], - }, - errors: ['Unable to list profiles.'], + canManageProfiles: false, + collection: [], }; } }; createProfile: CreateProfile = async (payload) => { const response = await InPlayer.Account.createProfile(payload.name, payload.adult, payload.avatar_url, payload.pin); - return { - responseData: response.data, - errors: [], - }; + + 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 { - responseData: response.data, - errors: [], - }; + + return response.data; }; enterProfile: EnterProfile = async ({ id, pin }) => { @@ -76,10 +68,7 @@ export default class JWPProfileService extends ProfileService { await this.storageService.setItem('inplayer_token', tokenData, false); } - return { - responseData: profile, - errors: [], - }; + return profile; } catch { throw new Error('Unable to enter profile.'); } @@ -88,10 +77,8 @@ export default class JWPProfileService extends ProfileService { getProfileDetails: GetProfileDetails = async ({ id }) => { try { const response = await InPlayer.Account.getProfileDetails(id); - return { - responseData: response.data, - errors: [], - }; + + return response.data; } catch { throw new Error('Unable to get profile details.'); } @@ -100,12 +87,10 @@ export default class JWPProfileService extends ProfileService { deleteProfile: DeleteProfile = async ({ id }) => { try { await InPlayer.Account.deleteProfile(id); + return { - responseData: { - message: 'Profile deleted successfully', - code: 200, - }, - errors: [], + 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 566d78a53..6d42b457e 100644 --- a/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts +++ b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts @@ -54,12 +54,17 @@ export default class JWPSubscriptionService extends SubscriptionService { ): PaymentDetail => { const { number, exp_month, exp_year, card_name, card_type, account_id, currency } = card; const zeroFillExpMonth = `0${exp_month}`.slice(-2); + return { + id: 0, + paymentMethodId: 0, + paymentGateway: 'card', + paymentMethod: 'card', customerId: account_id.toString(), paymentMethodSpecificParams: { holderName: card_name, variant: card_type, - lastCardFourDigits: number, + lastCardFourDigits: String(number), cardExpirationDate: `${zeroFillExpMonth}/${exp_year}`, }, active: true, diff --git a/packages/common/src/stores/AccountStore.ts b/packages/common/src/stores/AccountStore.ts index f16a86513..3521537a7 100644 --- a/packages/common/src/stores/AccountStore.ts +++ b/packages/common/src/stores/AccountStore.ts @@ -1,4 +1,4 @@ -import type { Consent, Customer, CustomerConsent } from '../../types/account'; +import type { CustomFormField, Customer, CustomerConsent } from '../../types/account'; import type { Offer } from '../../types/checkout'; import type { PaymentDetail, Subscription, Transaction } from '../../types/subscription'; @@ -11,7 +11,8 @@ type AccountStore = { transactions: Transaction[] | null; activePayment: PaymentDetail | null; customerConsents: CustomerConsent[] | null; - publisherConsents: Consent[] | null; + publisherConsents: CustomFormField[] | null; + publisherConsentsLoading: boolean; pendingOffer: Offer | null; setLoading: (loading: boolean) => void; getAccountInfo: () => { customerId: string; customer: Customer; customerConsents: CustomerConsent[] | null }; @@ -25,6 +26,7 @@ export const useAccountStore = createStore('AccountStore', (set, g activePayment: null, customerConsents: null, publisherConsents: null, + publisherConsentsLoading: false, pendingOffer: null, setLoading: (loading: boolean) => set({ loading }), getAccountInfo: () => { diff --git a/packages/common/src/stores/ConfigStore.ts b/packages/common/src/stores/ConfigStore.ts index 9e35334aa..854a60977 100644 --- a/packages/common/src/stores/ConfigStore.ts +++ b/packages/common/src/stores/ConfigStore.ts @@ -34,9 +34,7 @@ export const useConfigStore = createStore('ConfigStore', () => ({ useSandbox: true, }, }, - styling: { - footerText: '', - }, + styling: {}, }, settings: { additionalAllowedConfigSources: [], diff --git a/packages/common/src/stores/ProfileStore.ts b/packages/common/src/stores/ProfileStore.ts index c55528a5e..9b4bd2b48 100644 --- a/packages/common/src/stores/ProfileStore.ts +++ b/packages/common/src/stores/ProfileStore.ts @@ -1,6 +1,6 @@ import defaultAvatar from '@jwp/ott-theme/assets/profiles/default_avatar.png'; -import type { Profile } from '../../types/account'; +import type { Profile } from '../../types/profiles'; import { createStore } from './utils'; diff --git a/packages/common/src/stores/WatchHistoryController.ts b/packages/common/src/stores/WatchHistoryController.ts deleted file mode 100644 index 28791c8e0..000000000 --- a/packages/common/src/stores/WatchHistoryController.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { inject, injectable } from 'inversify'; - -import WatchHistoryService from '../services/WatchHistoryService'; -import AccountService from '../services/integrations/AccountService'; -import type { PlaylistItem } from '../../types/playlist'; -import type { SerializedWatchHistoryItem, WatchHistoryItem } from '../../types/watchHistory'; -import type { Customer } from '../../types/account'; -import type { IntegrationType } from '../../types/config'; -import { getNamedModule } from '../modules/container'; -import { INTEGRATION_TYPE } from '../modules/types'; - -import { useAccountStore } from './AccountStore'; -import { useConfigStore } from './ConfigStore'; -import { useWatchHistoryStore } from './WatchHistoryStore'; - -@injectable() -export default class WatchHistoryController { - private readonly watchHistoryService: WatchHistoryService; - private readonly accountService?: AccountService; - - constructor(@inject(INTEGRATION_TYPE) integrationType: IntegrationType, watchHistoryService: WatchHistoryService) { - this.watchHistoryService = watchHistoryService; - this.accountService = getNamedModule(AccountService, integrationType, false); - } - - serializeWatchHistory = (watchHistory: WatchHistoryItem[]): SerializedWatchHistoryItem[] => { - return this.watchHistoryService.serializeWatchHistory(watchHistory); - }; - - private updateUserWatchHistory(watchHistory: WatchHistoryItem[]) { - const { user } = useAccountStore.getState(); - - if (user) { - useAccountStore.setState((state) => ({ - ...state, - user: { - ...(state.user as Customer), - externalData: { ...state.user?.externalData, history: this.serializeWatchHistory(watchHistory) }, - }, - })); - } - } - - initialize = async () => { - await this.restoreWatchHistory(); - }; - - restoreWatchHistory = async () => { - const { user } = useAccountStore.getState(); - const continueWatchingList = useConfigStore.getState().config.features?.continueWatchingList; - - if (!continueWatchingList) { - return; - } - - const watchHistory = await this.watchHistoryService.getWatchHistory(user, continueWatchingList); - - if (watchHistory?.length) { - useWatchHistoryStore.setState({ - watchHistory: watchHistory.filter((item): item is WatchHistoryItem => !!item?.mediaid), - playlistItemsLoaded: true, - continueWatchingPlaylistId: continueWatchingList, - }); - } - }; - - persistWatchHistory = async () => { - const { watchHistory } = useWatchHistoryStore.getState(); - const { user } = useAccountStore.getState(); - - if (user?.id && user?.externalData) { - return this.accountService?.updatePersonalShelves({ id: user.id, externalData: user.externalData }); - } - - this.watchHistoryService.persistWatchHistory(watchHistory); - }; - - /** - * If we already have an element with continue watching state, we: - * 1. Update the progress - * 2. Move the element to the continue watching list start - * Otherwise: - * 1. Move the element to the continue watching list start - * 2. If there are many elements in continue watching state we remove the oldest one - */ - saveItem = async (item: PlaylistItem, seriesItem: PlaylistItem | undefined, videoProgress: number | null) => { - const { watchHistory } = useWatchHistoryStore.getState(); - - if (!videoProgress) return; - - const updatedHistory = await this.watchHistoryService.saveItem(item, seriesItem, videoProgress, watchHistory); - - if (updatedHistory) { - useWatchHistoryStore.setState({ watchHistory: updatedHistory }); - this.updateUserWatchHistory(updatedHistory); - await this.persistWatchHistory(); - } - }; -} diff --git a/packages/common/src/utils/collection.ts b/packages/common/src/utils/collection.ts index f8edb5178..a12804783 100644 --- a/packages/common/src/utils/collection.ts +++ b/packages/common/src/utils/collection.ts @@ -1,4 +1,4 @@ -import type { Consent, CustomerConsent } from '../../types/account'; +import type { CustomFormField, CustomerConsent } from '../../types/account'; import type { Config } from '../../types/config'; import type { GenericFormValues } from '../../types/form'; import type { Playlist, PlaylistItem } from '../../types/playlist'; @@ -57,7 +57,7 @@ const generatePlaylistPlaceholder = (playlistLength: number = 15): Playlist => ( ), }); -const formatConsentValues = (publisherConsents: Consent[] | null = [], customerConsents: CustomerConsent[] | null = []) => { +const formatConsentValues = (publisherConsents: CustomFormField[] | null = [], customerConsents: CustomerConsent[] | null = []) => { if (!publisherConsents || !customerConsents) { return {}; } @@ -76,7 +76,7 @@ const formatConsentValues = (publisherConsents: Consent[] | null = [], customerC return values; }; -const formatConsents = (publisherConsents: Consent[] | null = [], customerConsents: CustomerConsent[] | null = []) => { +const formatConsents = (publisherConsents: CustomFormField[] | null = [], customerConsents: CustomerConsent[] | null = []) => { if (!publisherConsents || !customerConsents) { return {}; } @@ -90,7 +90,7 @@ const formatConsents = (publisherConsents: Consent[] | null = [], customerConsen return values; }; -const extractConsentValues = (consents?: Consent[]) => { +const extractConsentValues = (consents?: CustomFormField[]) => { const values: Record = {}; if (!consents) { @@ -104,7 +104,7 @@ const extractConsentValues = (consents?: Consent[]) => { return values; }; -const formatConsentsFromValues = (publisherConsents: Consent[] | null, values?: GenericFormValues) => { +const formatConsentsFromValues = (publisherConsents: CustomFormField[] | null, values?: GenericFormValues) => { const consents: CustomerConsent[] = []; if (!publisherConsents || !values) return consents; @@ -121,7 +121,7 @@ const formatConsentsFromValues = (publisherConsents: Consent[] | null, values?: return consents; }; -const checkConsentsFromValues = (publisherConsents: Consent[], consents: Record) => { +const checkConsentsFromValues = (publisherConsents: CustomFormField[], consents: Record) => { const customerConsents: CustomerConsent[] = []; const consentsErrors: string[] = []; diff --git a/packages/common/src/utils/common.ts b/packages/common/src/utils/common.ts index 7ee4bd418..5b3395186 100644 --- a/packages/common/src/utils/common.ts +++ b/packages/common/src/utils/common.ts @@ -6,6 +6,12 @@ export function debounce void>(callback: T, wait = }; } +export const unicodeToChar = (text: string) => { + return text.replace(/\\u[\dA-F]{4}/gi, (match) => { + return String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16)); + }); +}; + /** * Parse hex color and return the RGB colors * @param color diff --git a/packages/common/src/utils/offers.ts b/packages/common/src/utils/offers.ts new file mode 100644 index 000000000..0fc8d4a34 --- /dev/null +++ b/packages/common/src/utils/offers.ts @@ -0,0 +1,7 @@ +import type { MediaOffer } from '../../types/media'; + +export const mergeOfferIds = (mediaOffers: MediaOffer[] = [], svodOfferIds: string[] = []) => { + const mediaOfferIds = mediaOffers.map(({ offerId }) => offerId); + + return [...mediaOfferIds, ...svodOfferIds]; +}; diff --git a/packages/common/src/utils/urlFormatting.test.ts b/packages/common/src/utils/urlFormatting.test.ts index 2af8d84cf..22b439c48 100644 --- a/packages/common/src/utils/urlFormatting.test.ts +++ b/packages/common/src/utils/urlFormatting.test.ts @@ -1,7 +1,14 @@ -import { createURL } from './urlFormatting'; +import playlistFixture from '@jwp/ott-testing/fixtures/playlist.json'; +import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; + +import type { Playlist, PlaylistItem } from '../../types/playlist'; +import type { EpgChannel } from '../../types/epg'; +import { RELATIVE_PATH_USER_ACCOUNT } from '../paths'; + +import { createURL, liveChannelsURL, mediaURL, playlistURL, userProfileURL } from './urlFormatting'; describe('createUrl', () => { - test('valid url from a path, query params', async () => { + test('valid url from a path, query params', () => { const url = createURL('/test', { foo: 'bar' }); expect(url).toEqual('/test?foo=bar'); @@ -23,3 +30,42 @@ describe('createUrl', () => { expect(url).toEqual('https://app-preview.jwplayer.com/?existing-param=1&foo=bar&u=payment-method-success'); }); }); + +describe('createPath, mediaURL, playlistURL and liveChannelsURL', () => { + test('valid media path', () => { + const playlist = playlistFixture as Playlist; + const media = playlist.playlist[0] as PlaylistItem; + const url = mediaURL({ media, playlistId: playlist.feedid, play: true }); + + expect(url).toEqual('/m/uB8aRnu6/agent-327?r=dGSUzs9o&play=1'); + }); + test('valid playlist path', () => { + const playlist = playlistFixture as Playlist; + const url = playlistURL(playlist.feedid || '', playlist.title); + + expect(url).toEqual('/p/dGSUzs9o/all-films'); + }); + test('valid live channel path', () => { + const playlist = playlistFixture as Playlist; + const channels: EpgChannel[] = epgChannelsFixture; + const channel = channels[0]; + const url = liveChannelsURL(playlist.feedid || '', channel.id, true); + + expect(url).toEqual('/p/dGSUzs9o/?channel=channel1&play=1'); + }); + test('valid live channel path', () => { + const url = userProfileURL('testprofile123'); + + expect(url).toEqual('/u/my-profile/testprofile123'); + }); + test('valid nested user path', () => { + const url = 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 f14651ee4..4ce11e919 100644 --- a/packages/common/src/utils/urlFormatting.ts +++ b/packages/common/src/utils/urlFormatting.ts @@ -1,4 +1,5 @@ import type { PlaylistItem } from '../../types/playlist'; +import { RELATIVE_PATH_USER_MY_PROFILE, PATH_MEDIA, PATH_PLAYLIST, PATH_USER_MY_PROFILE } from '../paths'; import { getLegacySeriesPlaylistIdFromEpisodeTags, getSeriesPlaylistIdFromCustomParams } from './media'; @@ -25,6 +26,50 @@ export const createURL = (url: string, queryParams: QueryParamsArg) => { return `${baseUrl}${queryString ? `?${queryString}` : ''}`; }; +type ExtractRouteParams = T extends `${infer _Start}:${infer Param}/${infer Rest}` + ? { [K in Param | keyof ExtractRouteParams]: string } + : T extends `${infer _Start}:${infer Param}` + ? { [K in Param]: string } + : object; + +type PathParams = T extends `${infer _Start}*` ? ExtractRouteParams & Record : ExtractRouteParams; + +// Creates a route path from a path string and params object +export const createPath = (originalPath: Path, pathParams?: PathParams, queryParams?: QueryParamsArg): string => { + const path = originalPath + .split('/') + .map((segment) => { + if (segment === '*') { + // Wild card for optional segments: add all params that are not already in the path + if (!pathParams) return segment; + + return Object.entries(pathParams) + .filter(([key]) => !originalPath.includes(key)) + .map(([_, value]) => value) + .join('/'); + } + if (!segment.startsWith(':') || !pathParams) return segment; + + // Check if param is optional, and show a warning if it's not optional and missing + // Then remove all special characters to get the actual param name + const isOptional = segment.endsWith('?'); + const paramName = segment.replace(':', '').replace('?', ''); + const paramValue = pathParams[paramName as keyof typeof pathParams]; + + if (!paramValue) { + if (!isOptional) console.warn('Missing param in path creation.', { path: originalPath, paramName }); + + return ''; + } + + return paramValue; + }) + .join('/'); + + // Optionally add the query params + return queryParams ? createURL(path, queryParams) : path; +}; + export const slugify = (text: string, whitespaceChar: string = '-') => text .toString() @@ -47,16 +92,31 @@ export const mediaURL = ({ play?: boolean; episodeId?: string; }) => { - return createURL(`/m/${media.mediaid}/${slugify(media.title)}`, { r: playlistId, play: play ? '1' : null, e: episodeId }); + return createPath(PATH_MEDIA, { id: media.mediaid, title: slugify(media.title) }, { r: playlistId, play: play ? '1' : null, e: episodeId }); +}; + +export const playlistURL = (id: string, title?: string) => { + return createPath(PATH_PLAYLIST, { id, title: title ? slugify(title) : undefined }); }; export const liveChannelsURL = (playlistId: string, channelId?: string, play = false) => { - return createURL(`/p/${playlistId}`, { - channel: channelId, - play: play ? '1' : null, - }); + return createPath( + PATH_PLAYLIST, + { id: playlistId }, + { + channel: channelId, + play: play ? '1' : null, + }, + ); +}; + +export const userProfileURL = (profileId: string, nested = false) => { + const path = nested ? RELATIVE_PATH_USER_MY_PROFILE : PATH_USER_MY_PROFILE; + + return createPath(path, { id: profileId }); }; +// Legacy URLs export const legacySeriesURL = ({ seriesId, episodeId, diff --git a/packages/common/types/account.d.ts b/packages/common/types/account.ts similarity index 62% rename from packages/common/types/account.d.ts rename to packages/common/types/account.ts index 9cfbb98e5..603467fce 100644 --- a/packages/common/types/account.d.ts +++ b/packages/common/types/account.ts @@ -1,8 +1,7 @@ -import type { ProfilesData } from '@inplayer-org/inplayer.js'; - +import type { Config } from './config'; +import type { PromiseRequest } from './service'; import type { SerializedWatchHistoryItem } from './watchHistory'; import type { SerializedFavorite } from './favorite'; -import type { Config } from './config'; export type AuthData = { jwt: string; @@ -20,7 +19,6 @@ export type PayloadWithIPOverride = { }; export type LoginArgs = { - config: Config; email: string; password: string; referrer: string; @@ -69,10 +67,13 @@ export type EditPasswordFormData = { resetPasswordToken?: string; }; -export type OfferType = 'svod' | 'tvod'; +export type GetUserArgs = { + config: Config; +}; -export type ChooseOfferFormData = { - offerId?: string; +export type GetUserPayload = { + user: Customer; + customerConsents: CustomerConsent[]; }; export type RegisterPayload = PayloadWithIPOverride & { @@ -89,15 +90,6 @@ export type RegisterPayload = PayloadWithIPOverride & { externalData?: string; }; -export type RegisterArgs = { - config: Config; - user: RegisterPayload; -}; -export type CaptureFirstNameLastName = { - firstName: string; - lastName: string; -}; - export type CleengCaptureField = { key: string; enabled: boolean; @@ -128,18 +120,6 @@ export type PersonalDetailsFormData = { country: string; }; -export type GetPublisherConsentsPayload = { - publisherId: string; -}; - -export type GetPublisherConsentsResponse = { - consents: Consent[]; -}; - -export type GetCustomerConsentsPayload = { - customerId: string; -}; - export type GetCustomerConsentsResponse = { consents: CustomerConsent[]; }; @@ -163,22 +143,12 @@ export type ChangePasswordWithOldPasswordPayload = { newPasswordConfirmation: string; }; -export type GetCustomerPayload = { - customerId: string; -}; - export type UpdateCustomerPayload = { id?: string; email?: string; confirmationPassword?: string; firstName?: string; lastName?: string; - externalData?: ExternalData; -}; - -export type ExternalData = { - history?: SerializedWatchHistoryItem[]; - favorites?: SerializedFavorite[]; }; export type UpdateCustomerConsentsPayload = { @@ -189,17 +159,12 @@ export type UpdateCustomerConsentsPayload = { export type Customer = { id: string; email: string; - country: string; - regDate: string; - lastLoginDate?: string; - lastUserIp: string; firstName?: string; - metadata?: Record; + country?: string; + metadata: Record; lastName?: string; fullName?: string; - uuid?: string; - externalId?: string; - externalData?: ExternalData; + [key: string]: unknown; }; export type UpdateCustomerArgs = { @@ -208,15 +173,14 @@ export type UpdateCustomerArgs = { confirmationPassword?: string | undefined; firstName?: string | undefined; lastName?: string | undefined; - externalData?: ExternalData | undefined; metadata?: Record; fullName?: string; }; export type CustomRegisterFieldVariant = 'input' | 'select' | 'country' | 'us_state' | 'radio' | 'checkbox' | 'datepicker'; -export interface Consent { - type?: CustomRegisterFieldVariant; +export interface CustomFormField { + type: CustomRegisterFieldVariant; isCustomRegisterField?: boolean; enabledByDefault?: boolean; defaultValue?: string; @@ -242,28 +206,14 @@ export type CustomerConsent = { }; export type CustomerConsentArgs = { - config: Config; - customerId?: string; - customer?: Customer; + customer: Customer; }; export type UpdateCustomerConsentsArgs = { - config: Config; customer: Customer; consents: CustomerConsent[]; }; -export type LocalesData = { - country: string; - currency: string; - locale: string; - ipAddress: string; -}; - -export type GetCaptureStatusPayload = { - customerId: string; -}; - export type GetCaptureStatusResponse = { isCaptureEnabled: boolean; shouldCaptureBeDisplayed: boolean; @@ -302,38 +252,6 @@ export type UpdateCaptureAnswersPayload = { customerId: string; } & Capture; -export type UpdatePersonalShelvesArgs = { - id: string; - externalData: { - history?: SerializedWatchHistoryItem[]; - favorites?: SerializedFavorite[]; - }; -}; - -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 FirstLastNameInput = { firstName: string; lastName: string; @@ -361,7 +279,6 @@ export type SubscribeToNotificationsPayload = { }; export type GetSocialURLsPayload = { - config: Config; redirectUrl: string; }; @@ -376,27 +293,43 @@ export type SocialURLs = google: string; }; +export type UpdateWatchHistoryArgs = { + user: Customer; + history: SerializedWatchHistoryItem[]; +}; + +export type UpdateFavoritesArgs = { + user: Customer; + favorites: SerializedFavorite[]; +}; + +export type GetFavoritesArgs = { + user: Customer; +}; + +export type GetWatchHistoryArgs = { + user: Customer; +}; + +export type GetAuthData = () => Promise; export type Login = PromiseRequest; export type Register = PromiseRequest; -export type GetCustomer = EnvironmentServiceRequest; -export type UpdateCustomer = EnvironmentServiceRequest; -export type GetPublisherConsents = PromiseRequest; -export type GetCustomerConsents = PromiseRequest; -export type UpdateCustomerConsents = PromiseRequest; -export type GetCaptureStatus = EnvironmentServiceRequest; -export type UpdateCaptureAnswers = EnvironmentServiceRequest; -export type ResetPassword = EnvironmentServiceRequest>; -export type ChangePassword = EnvironmentServiceRequest; -export type ChangePasswordWithOldPassword = EnvironmentServiceRequest; -export type UpdatePersonalShelves = EnvironmentServiceRequest>; -export type GetLocales = EmptyServiceRequest; -export type ExportAccountData = EnvironmentServiceRequest; +export type GetUser = PromiseRequest; +export type Logout = () => Promise; +export type UpdateCustomer = PromiseRequest; +export type GetPublisherConsents = PromiseRequest; +export type GetCustomerConsents = PromiseRequest; +export type UpdateCustomerConsents = PromiseRequest; +export type GetCaptureStatus = PromiseRequest; +export type UpdateCaptureAnswers = PromiseRequest; +export type ResetPassword = PromiseRequest; +export type ChangePassword = PromiseRequest; +export type ChangePasswordWithOldPassword = PromiseRequest; export type GetSocialURLs = PromiseRequest; export type NotificationsData = PromiseRequest; -export type DeleteAccount = EnvironmentServiceRequest; -export type ListProfiles = EnvironmentServiceRequest; -export type CreateProfile = EnvironmentServiceRequest; -export type UpdateProfile = EnvironmentServiceRequest; -export type EnterProfile = EnvironmentServiceRequest; -export type GetProfileDetails = EnvironmentServiceRequest; -export type DeleteProfile = EnvironmentServiceRequest; +export type UpdateWatchHistory = PromiseRequest; +export type UpdateFavorites = PromiseRequest; +export type GetWatchHistory = PromiseRequest; +export type GetFavorites = PromiseRequest; +export type ExportAccountData = PromiseRequest; +export type DeleteAccount = PromiseRequest; diff --git a/packages/common/types/ad-schedule.d.ts b/packages/common/types/ad-schedule.ts similarity index 100% rename from packages/common/types/ad-schedule.d.ts rename to packages/common/types/ad-schedule.ts diff --git a/packages/common/types/adyen.d.ts b/packages/common/types/adyen.ts similarity index 77% rename from packages/common/types/adyen.d.ts rename to packages/common/types/adyen.ts index 8e4d344d2..15006e053 100644 --- a/packages/common/types/adyen.d.ts +++ b/packages/common/types/adyen.ts @@ -31,14 +31,6 @@ interface AdyenEventData { }; } -interface AdyenConfiguration { - onSubmit: (data: AdyenEventData) => void; - onChange: (data: AdyenEventData) => void; - showPayButton: boolean; - environment: 'test' | 'live'; - clientKey: string; -} - interface AdyenAdditionalEventData { isValid: boolean; data: { @@ -46,10 +38,6 @@ interface AdyenAdditionalEventData { }; } -interface AdyenCheckoutStatic { - (configuration: AdyenConfiguration): AdyenCheckout; -} - // currently only card payments with Adyen are supported const adyenPaymentMethods = ['card'] as const; diff --git a/packages/common/types/checkout.d.ts b/packages/common/types/checkout.ts similarity index 97% rename from packages/common/types/checkout.d.ts rename to packages/common/types/checkout.ts index 6c6f50032..f70db624a 100644 --- a/packages/common/types/checkout.d.ts +++ b/packages/common/types/checkout.ts @@ -1,5 +1,6 @@ import type { PayloadWithIPOverride } from './account'; import type { PaymentDetail } from './subscription'; +import type { EmptyEnvironmentServiceRequest, EnvironmentServiceRequest, PromiseRequest } from './service'; export type Offer = { id: number | null; @@ -43,6 +44,12 @@ export type Offer = { planSwitchEnabled?: boolean; }; +export type OfferType = 'svod' | 'tvod'; + +export type ChooseOfferFormData = { + offerId?: string; +}; + export type OrderOffer = { title: string; description: string | null; @@ -89,6 +96,7 @@ export type PaymentMethod = { id: number; methodName: 'card' | 'paypal'; provider?: 'stripe' | 'adyen'; + paymentGateway?: 'adyen' | 'paypal'; // @todo: merge with provider logoUrl: string; }; @@ -208,7 +216,6 @@ export type CreateOrderArgs = { offer: Offer; customerId: string; country: string; - customerIP: string; paymentMethodId?: number; couponCode?: string; }; diff --git a/packages/common/types/cleeng.d.ts b/packages/common/types/cleeng.d.ts deleted file mode 100644 index 742f7a7ff..000000000 --- a/packages/common/types/cleeng.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -interface ApiResponse { - errors: string[]; -} - -type CleengResponse = { responseData: R } & ApiResponse; -type CleengRequest = (payload: P) => Promise>; diff --git a/packages/common/types/cleeng.ts b/packages/common/types/cleeng.ts new file mode 100644 index 000000000..d005c92ef --- /dev/null +++ b/packages/common/types/cleeng.ts @@ -0,0 +1,6 @@ +interface ApiResponse { + errors: string[]; +} + +export type CleengResponse = { responseData: R } & ApiResponse; +export type CleengRequest = (payload: P) => Promise>; diff --git a/packages/common/types/config.d.ts b/packages/common/types/config.ts similarity index 94% rename from packages/common/types/config.d.ts rename to packages/common/types/config.ts index b74104536..7e60bfee8 100644 --- a/packages/common/types/config.d.ts +++ b/packages/common/types/config.ts @@ -59,6 +59,9 @@ export type Styling = { backgroundColor?: string | null; highlightColor?: string | null; headerBackground?: string | null; + /** + * @deprecated the footerText is present in the config, but can't be updated in the JWP Dashboard + */ footerText?: string | null; }; diff --git a/packages/common/types/entitlement.d.ts b/packages/common/types/entitlement.ts similarity index 100% rename from packages/common/types/entitlement.d.ts rename to packages/common/types/entitlement.ts diff --git a/packages/common/types/favorite.d.ts b/packages/common/types/favorite.ts similarity index 100% rename from packages/common/types/favorite.d.ts rename to packages/common/types/favorite.ts diff --git a/packages/common/types/get-customer-ip.d.ts b/packages/common/types/get-customer-ip.ts similarity index 100% rename from packages/common/types/get-customer-ip.d.ts rename to packages/common/types/get-customer-ip.ts diff --git a/packages/common/types/i18n.d.ts b/packages/common/types/i18n.ts similarity index 100% rename from packages/common/types/i18n.d.ts rename to packages/common/types/i18n.ts diff --git a/packages/common/types/inplayer.d.ts b/packages/common/types/inplayer.ts similarity index 60% rename from packages/common/types/inplayer.d.ts rename to packages/common/types/inplayer.ts index 67d651e56..75b30a0da 100644 --- a/packages/common/types/inplayer.d.ts +++ b/packages/common/types/inplayer.ts @@ -11,10 +11,3 @@ export type InPlayerError = { }; }; }; - -export type InPlayerResponse = { - data: Record; - status: number; - statusText: string; - config: AxiosRequestConfig; -}; diff --git a/packages/common/types/jwpltx.d.ts b/packages/common/types/jwpltx.d.ts index 1babd5222..ab23e5ce3 100644 --- a/packages/common/types/jwpltx.d.ts +++ b/packages/common/types/jwpltx.d.ts @@ -1,4 +1,4 @@ -interface Jwpltx { +export interface Jwpltx { ready: ( analyticsid: string, hostname: string, diff --git a/packages/common/types/media.d.ts b/packages/common/types/media.ts similarity index 52% rename from packages/common/types/media.d.ts rename to packages/common/types/media.ts index 7a2d32c30..b90452274 100644 --- a/packages/common/types/media.d.ts +++ b/packages/common/types/media.ts @@ -1,4 +1,11 @@ -export type GetMediaParams = { poster_width?: number; default_source_fallback?: boolean; token?: string; max_resolution?: number }; +import type { PlaylistItem } from './playlist'; + +export type GetMediaParams = { + poster_width?: number; + default_source_fallback?: boolean; + token?: string; + max_resolution?: number; +}; export type Media = { description?: string; diff --git a/packages/common/types/pagination.d.ts b/packages/common/types/pagination.ts similarity index 100% rename from packages/common/types/pagination.d.ts rename to packages/common/types/pagination.ts diff --git a/packages/common/types/playlist.d.ts b/packages/common/types/playlist.ts similarity index 96% rename from packages/common/types/playlist.d.ts rename to packages/common/types/playlist.ts index 23ef6cfbe..f4b0bdba2 100644 --- a/packages/common/types/playlist.d.ts +++ b/packages/common/types/playlist.ts @@ -1,3 +1,5 @@ +import type { MediaStatus } from '../src/utils/liveEvent'; + import type { MediaOffer } from './media'; export type GetPlaylistParams = { page_limit?: string; related_media_id?: string; token?: string; search?: string }; diff --git a/packages/common/types/profiles.d.ts b/packages/common/types/profiles.d.ts deleted file mode 100644 index 87e35f070..000000000 --- a/packages/common/types/profiles.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ProfilePayload } from './account'; - -export type ProfileFormValues = Omit & { adult: string }; - -export type ProfileFormSubmitError = { - code: number; - message: string; -}; diff --git a/packages/common/types/profiles.ts b/packages/common/types/profiles.ts new file mode 100644 index 000000000..58bcfbedb --- /dev/null +++ b/packages/common/types/profiles.ts @@ -0,0 +1,42 @@ +import type { ProfilesData } from '@inplayer-org/inplayer.js'; + +import type { PromiseRequest } from './service'; +import type { CommonAccountResponse } from './account'; + +export type Profile = ProfilesData; + +export type ProfilePayload = { + id?: string; + name: string; + adult: boolean; + avatar_url?: string; + pin?: number; +}; + +export type EnterProfilePayload = { + id: string; + pin?: number; +}; + +export type ProfileDetailsPayload = { + id: string; +}; + +export type ListProfilesResponse = { + canManageProfiles: boolean; + collection: ProfilesData[]; +}; + +export type ProfileFormSubmitError = { + code: number; + message: string; +}; + +export type ProfileFormValues = Omit & { adult: string }; + +export type ListProfiles = PromiseRequest; +export type CreateProfile = PromiseRequest; +export type UpdateProfile = PromiseRequest; +export type EnterProfile = PromiseRequest; +export type GetProfileDetails = PromiseRequest; +export type DeleteProfile = PromiseRequest; diff --git a/packages/common/types/series.d.ts b/packages/common/types/series.ts similarity index 100% rename from packages/common/types/series.d.ts rename to packages/common/types/series.ts diff --git a/packages/common/types/service.d.ts b/packages/common/types/service.d.ts deleted file mode 100644 index e4de4bf36..000000000 --- a/packages/common/types/service.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -interface ApiResponse { - errors: string[]; -} - -type ServiceResponse = { responseData: R } & ApiResponse; -type PromiseRequest = (payload: P) => Promise; -type EmptyServiceRequest = () => Promise>; -type EmptyEnvironmentServiceRequest = () => Promise>; -type EnvironmentServiceRequest = (payload: P) => Promise>; diff --git a/packages/common/types/service.ts b/packages/common/types/service.ts new file mode 100644 index 000000000..91a9bb8ce --- /dev/null +++ b/packages/common/types/service.ts @@ -0,0 +1,9 @@ +interface ApiResponse { + errors: string[]; +} + +export type ServiceResponse = { responseData: R } & ApiResponse; +export type PromiseRequest = (payload: P) => Promise; +export type EmptyServiceRequest = () => Promise>; +export type EmptyEnvironmentServiceRequest = () => Promise>; +export type EnvironmentServiceRequest = (payload: P) => Promise>; diff --git a/packages/common/types/settings.d.ts b/packages/common/types/settings.ts similarity index 100% rename from packages/common/types/settings.d.ts rename to packages/common/types/settings.ts diff --git a/packages/common/types/subscription.d.ts b/packages/common/types/subscription.ts similarity index 75% rename from packages/common/types/subscription.d.ts rename to packages/common/types/subscription.ts index 6a3185c5e..6582c7b23 100644 --- a/packages/common/types/subscription.d.ts +++ b/packages/common/types/subscription.ts @@ -1,4 +1,8 @@ import type { ChangeSubscriptionPlanResponse, DefaultCreditCardData, SetDefaultCard } from '@inplayer-org/inplayer.js'; + +import type { CleengRequest } from './cleeng'; +import type { EnvironmentServiceRequest, PromiseRequest } from './service'; + // Subscription types export type Subscription = { subscriptionId: number | string; @@ -22,7 +26,7 @@ export type PaymentDetail = { customerId: string; paymentGateway: string; paymentMethod: string; - paymentMethodSpecificParams: Record; + paymentMethodSpecificParams: PaymentMethodSpecificParam; paymentMethodId: number; active: boolean; currency?: string; @@ -149,13 +153,13 @@ type GetAllTransactionsResponse = Transaction[] | null; type GetActiveSubscriptionResponse = Subscription | null; -type GetSubscriptions = CleengRequest; -type UpdateSubscription = CleengRequest; -type GetPaymentDetails = CleengRequest; -type GetTransactions = CleengRequest; +export type GetSubscriptions = CleengRequest; +export type UpdateSubscription = CleengRequest; +export type GetPaymentDetails = CleengRequest; +export type GetTransactions = CleengRequest; -type GetActiveSubscription = PromiseRequest; -type GetAllTransactions = PromiseRequest; -type GetActivePayment = PromiseRequest; -type ChangeSubscription = EnvironmentServiceRequest; -type FetchReceipt = EnvironmentServiceRequest; +export type GetActiveSubscription = PromiseRequest; +export type GetAllTransactions = PromiseRequest; +export type GetActivePayment = PromiseRequest; +export type ChangeSubscription = EnvironmentServiceRequest; +export type FetchReceipt = EnvironmentServiceRequest; diff --git a/packages/common/types/testing.d.ts b/packages/common/types/testing.ts similarity index 100% rename from packages/common/types/testing.d.ts rename to packages/common/types/testing.ts diff --git a/packages/common/types/watchHistory.d.ts b/packages/common/types/watchHistory.ts similarity index 100% rename from packages/common/types/watchHistory.d.ts rename to packages/common/types/watchHistory.ts diff --git a/packages/hooks-react/package.json b/packages/hooks-react/package.json index 813b5141a..22d6b4494 100644 --- a/packages/hooks-react/package.json +++ b/packages/hooks-react/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@testing-library/react": "^14.0.0", + "@types/jwplayer": "^8.2.13", "vi-fetch": "^0.8.0", "vitest": "^0.34.6" }, diff --git a/packages/hooks-react/src/useBootstrapApp.ts b/packages/hooks-react/src/useBootstrapApp.ts index 491e03f19..a1a2874bc 100644 --- a/packages/hooks-react/src/useBootstrapApp.ts +++ b/packages/hooks-react/src/useBootstrapApp.ts @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from 'react-query'; import type { Config } from '@jwp/ott-common/types/config'; import type { Settings } from '@jwp/ott-common/types/settings'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AppController from '@jwp/ott-common/src/stores/AppController'; +import AppController from '@jwp/ott-common/src/controllers/AppController'; import type { AppError } from '@jwp/ott-common/src/utils/error'; import { CACHE_TIME, STALE_TIME } from '@jwp/ott-common/src/constants'; @@ -20,7 +20,7 @@ export const useBootstrapApp = (url: string, onReady: OnReadyCallback) => { const queryClient = useQueryClient(); const refreshEntitlements = () => queryClient.invalidateQueries({ queryKey: ['entitlements'] }); - const { data, isLoading, error, isSuccess, refetch } = useQuery( + const { data, isLoading, error, isSuccess, refetch } = useQuery( 'config-init', () => applicationController.initializeApp(url, refreshEntitlements), { diff --git a/packages/hooks-react/src/useCheckAccess.ts b/packages/hooks-react/src/useCheckAccess.ts index 6b682ae89..1926bb15a 100644 --- a/packages/hooks-react/src/useCheckAccess.ts +++ b/packages/hooks-react/src/useCheckAccess.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; type IntervalCheckAccessPayload = { interval?: number; @@ -31,7 +31,7 @@ const useCheckAccess = () => { const hasAccess = await accountController.checkEntitlements(offerId); if (hasAccess) { - await accountController.reloadActiveSubscription(); + await accountController.reloadSubscriptions(); callback?.(true); } else if (--iterations === 0) { window.clearInterval(intervalRef.current); diff --git a/packages/hooks-react/src/useCheckout.ts b/packages/hooks-react/src/useCheckout.ts new file mode 100644 index 000000000..af93f6080 --- /dev/null +++ b/packages/hooks-react/src/useCheckout.ts @@ -0,0 +1,98 @@ +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; +import type { FormValidationError } from '@jwp/ott-common/src/errors/FormValidationError'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; +import { isSVODOffer } from '@jwp/ott-common/src/utils/subscription'; +import type { CardPaymentData, Offer, OfferType, Payment } from '@jwp/ott-common/types/checkout'; +import { useEffect } from 'react'; +import { useMutation } from 'react-query'; + +type Props = { + onUpdateOrderSuccess?: () => void; + onSubmitPaymentWithoutDetailsSuccess: () => void; + onSubmitPaypalPaymentSuccess: (response: { redirectUrl: string }) => void; + onSubmitStripePaymentSuccess: () => void; +}; + +const useCheckout = ({ onUpdateOrderSuccess, onSubmitPaymentWithoutDetailsSuccess, onSubmitPaypalPaymentSuccess, onSubmitStripePaymentSuccess }: Props) => { + const accountController = getModule(AccountController); + const checkoutController = getModule(CheckoutController); + + const { order, offer, paymentMethods, setOrder } = useCheckoutStore(({ order, offer, paymentMethods, setOrder }) => ({ + order, + offer, + paymentMethods, + setOrder, + })); + + const offerType: OfferType = offer && !isSVODOffer(offer) ? 'tvod' : 'svod'; + + const createOrder = useMutation({ + mutationKey: ['createOrder'], + mutationFn: async ({ offer }) => !!offer && checkoutController.createOrder(offer), + }); + + const updateOrder = useMutation({ + mutationKey: ['updateOrder'], + mutationFn: async ({ paymentMethodId, couponCode }) => { + if (!order || !paymentMethodId) return; + + return await checkoutController.updateOrder(order, paymentMethodId, couponCode); + }, + onSuccess: onUpdateOrderSuccess, + }); + + const submitPaymentWithoutDetails = useMutation({ + mutationKey: ['submitPaymentWithoutDetails'], + mutationFn: checkoutController.paymentWithoutDetails, + onSuccess: async () => { + await accountController.reloadSubscriptions({ delay: 1000 }); + onSubmitPaymentWithoutDetailsSuccess(); + }, + }); + + const submitPaymentPaypal = useMutation< + { redirectUrl: string }, + Error, + { successUrl: string; waitingUrl: string; cancelUrl: string; errorUrl: string; couponCode: string } + >({ + mutationKey: ['submitPaymentPaypal'], + mutationFn: checkoutController.paypalPayment, + onSuccess: onSubmitPaypalPaymentSuccess, + }); + + const submitPaymentStripe = useMutation({ + mutationKey: ['submitPaymentStripe'], + mutationFn: checkoutController.directPostCardPayment, + onSuccess: onSubmitStripePaymentSuccess, + }); + + useEffect(() => { + if (offer && !order && !createOrder.isLoading) { + createOrder.mutate({ offer }); + } + }, [offer, order, createOrder]); + + // Clear the order when unmounted + useEffect(() => { + return () => setOrder(null); + }, [setOrder]); + + const isSubmitting = + createOrder.isLoading || updateOrder.isLoading || submitPaymentWithoutDetails.isLoading || submitPaymentPaypal.isLoading || submitPaymentStripe.isLoading; + + return { + offer, + offerType, + paymentMethods, + order, + isSubmitting, + updateOrder, + submitPaymentWithoutDetails, + submitPaymentPaypal, + submitPaymentStripe, + }; +}; + +export default useCheckout; diff --git a/packages/hooks-react/src/useContentProtection.ts b/packages/hooks-react/src/useContentProtection.ts index 8a8e040fb..a3598af93 100644 --- a/packages/hooks-react/src/useContentProtection.ts +++ b/packages/hooks-react/src/useContentProtection.ts @@ -5,7 +5,7 @@ import type { EntitlementType } from '@jwp/ott-common/types/entitlement'; import GenericEntitlementService from '@jwp/ott-common/src/services/GenericEntitlementService'; import JWPEntitlementService from '@jwp/ott-common/src/services/JWPEntitlementService'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { isTruthyCustomParamValue } from '@jwp/ott-common/src/utils/common'; diff --git a/packages/hooks-react/src/useEntitlement.ts b/packages/hooks-react/src/useEntitlement.ts index 9a5e5f3ed..caef0a950 100644 --- a/packages/hooks-react/src/useEntitlement.ts +++ b/packages/hooks-react/src/useEntitlement.ts @@ -5,7 +5,7 @@ import type { MediaOffer } from '@jwp/ott-common/types/media'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; import { isLocked } from '@jwp/ott-common/src/utils/entitlements'; import { shallow } from '@jwp/ott-common/src/utils/compare'; diff --git a/packages/hooks-react/src/useForm.ts b/packages/hooks-react/src/useForm.ts index 01ef3e373..fd5ef87c0 100644 --- a/packages/hooks-react/src/useForm.ts +++ b/packages/hooks-react/src/useForm.ts @@ -1,6 +1,8 @@ import { useCallback, useState } from 'react'; -import { type AnySchema, ValidationError } from 'yup'; +import { type AnySchema, ValidationError, SchemaOf } from 'yup'; import type { FormErrors, GenericFormValues, UseFormBlurHandler, UseFormChangeHandler, UseFormSubmitHandler } from '@jwp/ott-common/types/form'; +import { FormValidationError } from '@jwp/ott-common/src/errors/FormValidationError'; +import { useTranslation } from 'react-i18next'; export type UseFormReturnValue = { values: T; @@ -9,7 +11,7 @@ export type UseFormReturnValue = { handleChange: UseFormChangeHandler; handleBlur: UseFormBlurHandler; handleSubmit: UseFormSubmitHandler; - setValue: (key: keyof T, value: string) => void; + setValue: (key: keyof T, value: T[keyof T]) => void; setErrors: (errors: FormErrors) => void; setSubmitting: (submitting: boolean) => void; reset: () => void; @@ -24,12 +26,22 @@ type UseFormMethods = { export type UseFormOnSubmitHandler = (values: T, formMethods: UseFormMethods) => void; -export default function useForm( - initialValues: T, - onSubmit: UseFormOnSubmitHandler, - validationSchema?: AnySchema, - validateOnBlur: boolean = false, -): UseFormReturnValue { +export default function useForm({ + initialValues, + validationSchema, + validateOnBlur = false, + onSubmit, + onSubmitSuccess, + onSubmitError, +}: { + initialValues: T; + validationSchema?: SchemaOf; + validateOnBlur?: boolean; + onSubmit: UseFormOnSubmitHandler; + onSubmitSuccess?: (values: T) => void; + onSubmitError?: ({ error, resetValue }: { error: unknown; resetValue: (key: keyof T) => void }) => void; +}): UseFormReturnValue { + const { t } = useTranslation('error'); const [touched, setTouched] = useState>( Object.fromEntries((Object.keys(initialValues) as Array).map((key) => [key, false])) as Record, ); @@ -109,7 +121,7 @@ export default function useForm( return false; }; - const handleSubmit: UseFormSubmitHandler = (event) => { + const handleSubmit: UseFormSubmitHandler = async (event) => { event.preventDefault(); if (!onSubmit || submitting) return; @@ -125,7 +137,32 @@ export default function useForm( // start submitting setSubmitting(true); - onSubmit(values, { setValue, setErrors, setSubmitting, validate }); + try { + await onSubmit(values, { setValue, setErrors, setSubmitting, validate }); + onSubmitSuccess?.(values); + } catch (error: unknown) { + const newErrors: Record = {}; + + if (error instanceof FormValidationError) { + Object.entries(error.errors).forEach(([key, [value]]) => { + if (key && value && !newErrors[key]) { + newErrors[key] = value; + } + }); + } else if (error instanceof Error) { + newErrors.form = error.message; + } else { + newErrors.form = t('unknown_error'); + } + setErrors(newErrors as FormErrors); + + onSubmitError?.({ + error, + resetValue: (key: keyof T) => setValue(key, ''), + }); + } + + setSubmitting(false); }; return { values, errors, handleChange, handleBlur, handleSubmit, submitting, setValue, setErrors, setSubmitting, reset }; diff --git a/packages/hooks-react/src/useLiveChannels.test.ts b/packages/hooks-react/src/useLiveChannels.test.ts index e7faa7fec..e53b04f23 100644 --- a/packages/hooks-react/src/useLiveChannels.test.ts +++ b/packages/hooks-react/src/useLiveChannels.test.ts @@ -6,7 +6,7 @@ import livePlaylistFixture from '@jwp/ott-testing/fixtures/livePlaylist.json'; import epgChannelsFixture from '@jwp/ott-testing/fixtures/epgChannels.json'; import epgChannelsUpdateFixture from '@jwp/ott-testing/fixtures/epgChannelsUpdate.json'; import { mockService } from '@jwp/ott-common/test/mockService'; -import EpgController from '@jwp/ott-common/src/stores/EpgController'; +import EpgController from '@jwp/ott-common/src/controllers/EpgController'; import { queryClientWrapper, waitForWithFakeTimers } from './testUtils'; import useLiveChannels from './useLiveChannels'; diff --git a/packages/hooks-react/src/useLiveChannels.ts b/packages/hooks-react/src/useLiveChannels.ts index 0268375fc..6257a24de 100644 --- a/packages/hooks-react/src/useLiveChannels.ts +++ b/packages/hooks-react/src/useLiveChannels.ts @@ -5,7 +5,7 @@ import type { EpgChannel, EpgProgram } from '@jwp/ott-common/types/epg'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { getLiveProgram, programIsLive } from '@jwp/ott-common/src/utils/epg'; import { LIVE_CHANNELS_REFETCH_INTERVAL } from '@jwp/ott-common/src/constants'; -import EpgController from '@jwp/ott-common/src/stores/EpgController'; +import EpgController from '@jwp/ott-common/src/controllers/EpgController'; /** * This hook fetches the schedules for the given list of playlist items and manages the current channel and program. diff --git a/packages/hooks-react/src/useOffers.ts b/packages/hooks-react/src/useOffers.ts index 25da82389..a29bbc855 100644 --- a/packages/hooks-react/src/useOffers.ts +++ b/packages/hooks-react/src/useOffers.ts @@ -1,37 +1,29 @@ import { useQuery } from 'react-query'; import { useMemo, useState } from 'react'; import { shallow } from '@jwp/ott-common/src/utils/compare'; -import type { OfferType } from '@jwp/ott-common/types/account'; -import type { Offer } from '@jwp/ott-common/types/checkout'; +import { mergeOfferIds } from '@jwp/ott-common/src/utils/offers'; +import type { Offer, OfferType } from '@jwp/ott-common/types/checkout'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useCheckoutStore } from '@jwp/ott-common/src/stores/CheckoutStore'; -import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; import { isSVODOffer } from '@jwp/ott-common/src/utils/subscription'; -import { ACCESS_MODEL } from '@jwp/ott-common/src/constants'; const useOffers = () => { const checkoutController = getModule(CheckoutController); + const svodOfferIds = checkoutController.getSubscriptionOfferIds(); + const { requestedMediaOffers: mediaOffers } = useCheckoutStore(({ requestedMediaOffers }) => ({ requestedMediaOffers }), shallow); + const hasPremierOffers = mediaOffers?.some((offer) => offer.premier); + const hasMultipleOfferTypes = !hasPremierOffers && !!mediaOffers?.length && !!svodOfferIds.length; + const offerIds: string[] = mergeOfferIds(mediaOffers || [], svodOfferIds); - const { accessModel } = useConfigStore(); - const offers = checkoutController.getSubscriptionOfferIds(); - - const { requestedMediaOffers } = useCheckoutStore(({ requestedMediaOffers }) => ({ requestedMediaOffers }), shallow); - const hasTvodOffer = (requestedMediaOffers || []).some((offer) => offer.offerId); - const hasPremierOffer = (requestedMediaOffers || []).some((offer) => offer.premier); - const isPPV = hasTvodOffer || hasPremierOffer; - const [offerType, setOfferType] = useState(accessModel === ACCESS_MODEL.SVOD && !isPPV ? 'svod' : 'tvod'); - - const offerIds: string[] = useMemo(() => { - return [...(requestedMediaOffers || []).map(({ offerId }) => offerId), ...offers].filter(Boolean); - }, [requestedMediaOffers, offers]); + const [offerType, setOfferType] = useState(hasPremierOffers || !svodOfferIds.length ? 'tvod' : 'svod'); + const updateOfferType = useMemo(() => (hasMultipleOfferTypes ? (type: OfferType) => setOfferType(type) : undefined), [hasMultipleOfferTypes]); const { data: allOffers, isLoading } = useQuery(['offers', offerIds.join('-')], () => checkoutController.getOffers({ offerIds })); // The `offerQueries` variable mutates on each render which prevents the useMemo to work properly. return useMemo(() => { const offers = (allOffers || []).filter((offer: Offer) => (offerType === 'tvod' ? !isSVODOffer(offer) : isSVODOffer(offer))); - const hasMultipleOfferTypes = (allOffers || []).some((offer: Offer) => (offerType === 'tvod' ? isSVODOffer(offer) : !isSVODOffer(offer))); const offersDict = (!isLoading && Object.fromEntries(offers.map((offer: Offer) => [offer.offerId, offer]))) || {}; // we need to get the offerIds from the offer responses since it contains different offerIds based on the customers' @@ -39,18 +31,15 @@ const useOffers = () => { const defaultOfferId = (!isLoading && offers[offers.length - 1]?.offerId) || ''; return { - hasTVODOffers: offers.some((offer: Offer) => !isSVODOffer(offer)), - hasMultipleOfferTypes, + hasMediaOffers: allOffers?.some((offer: Offer) => !isSVODOffer(offer)), isLoading, - hasPremierOffer, defaultOfferId, offerType, - setOfferType, + setOfferType: updateOfferType, offers, offersDict, - isTvodRequested: hasTvodOffer, }; - }, [allOffers, isLoading, hasPremierOffer, offerType, hasTvodOffer]); + }, [allOffers, isLoading, offerType, updateOfferType]); }; export default useOffers; diff --git a/packages/hooks-react/src/useOttAnalytics.ts b/packages/hooks-react/src/useOttAnalytics.ts index 9a867ffda..c750c7fd6 100644 --- a/packages/hooks-react/src/useOttAnalytics.ts +++ b/packages/hooks-react/src/useOttAnalytics.ts @@ -3,6 +3,7 @@ import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; import env from '@jwp/ott-common/src/env'; +import type { Jwpltx } from '@jwp/ott-common/types/jwpltx'; const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { const analyticsToken = useConfigStore((s) => s.config.analyticsToken); @@ -19,21 +20,23 @@ const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { const [player, setPlayer] = useState(null); useEffect(() => { - if (!window.jwpltx || !analyticsToken || !player || !item) { + const jwpltx = 'jwpltx' in window ? (window.jwpltx as Jwpltx) : undefined; + + if (!jwpltx || !analyticsToken || !player || !item) { return; } const timeHandler = ({ position, duration }: jwplayer.TimeParam) => { - window.jwpltx.time(position, duration); + jwpltx.time(position, duration); }; const seekHandler = ({ offset }: jwplayer.SeekParam) => { // TODO: according to JWPlayer typings, the seek param doesn't contain a `duration` property, but it actually does - window.jwpltx.seek(offset, player.getDuration()); + jwpltx.seek(offset, player.getDuration()); }; const seekedHandler = () => { - window.jwpltx.seeked(); + jwpltx.seeked(); }; const playlistItemHandler = () => { @@ -43,15 +46,15 @@ const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { return; } - window.jwpltx.ready(analyticsToken, window.location.hostname, feedId, item.mediaid, item.title, oaid, oiid, av); + jwpltx.ready(analyticsToken, window.location.hostname, feedId, item.mediaid, item.title, oaid, oiid, av); }; const completeHandler = () => { - window.jwpltx.complete(); + jwpltx.complete(); }; const adImpressionHandler = () => { - window.jwpltx.adImpression(); + jwpltx.adImpression(); }; player.on('playlistItem', playlistItemHandler); @@ -63,7 +66,7 @@ const useOttAnalytics = (item?: PlaylistItem, feedId: string = '') => { return () => { // Fire remaining seconds / minutes watched - window.jwpltx.remove(); + jwpltx.remove(); player.off('playlistItem', playlistItemHandler); player.off('complete', completeHandler); player.off('time', timeHandler); diff --git a/packages/hooks-react/src/useProfiles.ts b/packages/hooks-react/src/useProfiles.ts index 42e9d43ec..aef289e6f 100644 --- a/packages/hooks-react/src/useProfiles.ts +++ b/packages/hooks-react/src/useProfiles.ts @@ -2,13 +2,13 @@ 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, ListProfilesResponse, ProfileDetailsPayload, ProfilePayload } from '@jwp/ott-common/types/account'; -import type { ProfileFormSubmitError, ProfileFormValues } from '@jwp/ott-common/types/profiles'; +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/stores/ProfileController'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import ProfileController from '@jwp/ott-common/src/controllers/ProfileController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { logDev } from '@jwp/ott-common/src/utils/common'; export const useSelectProfile = (options?: { onSuccess: () => void; onError: () => void }) => { @@ -32,12 +32,12 @@ export const useSelectProfile = (options?: { onSuccess: () => void; onError: () }); }; -export const useCreateProfile = (options?: UseMutationOptions | undefined, unknown, ProfilePayload, unknown>) => { +export const useCreateProfile = (options?: UseMutationOptions) => { const { query: listProfiles } = useProfiles(); const profileController = getModule(ProfileController, false); - return useMutation | undefined, unknown, ProfilePayload, unknown>(async (data) => profileController?.createProfile(data), { + return useMutation(async (data) => profileController?.createProfile(data), { ...options, onSuccess: (data, variables, context) => { listProfiles.refetch(); @@ -47,7 +47,7 @@ export const useCreateProfile = (options?: UseMutationOptions | undefined, unknown, ProfilePayload, unknown>) => { +export const useUpdateProfile = (options?: UseMutationOptions) => { const { query: listProfiles } = useProfiles(); const profileController = getModule(ProfileController, false); @@ -62,22 +62,19 @@ export const useUpdateProfile = (options?: UseMutationOptions | undefined, unknown, ProfileDetailsPayload, unknown>) => { +export const useDeleteProfile = (options?: UseMutationOptions) => { const { query: listProfiles } = useProfiles(); const profileController = getModule(ProfileController, false); - return useMutation | undefined, unknown, ProfileDetailsPayload, unknown>( - async (id) => profileController?.deleteProfile(id), - { - ...options, - onSuccess: (...args) => { - listProfiles.refetch(); + return useMutation(async (id) => profileController?.deleteProfile(id), { + ...options, + onSuccess: (...args) => { + listProfiles.refetch(); - options?.onSuccess?.(...args); - }, + options?.onSuccess?.(...args); }, - ); + }); }; export const isProfileFormSubmitError = (e: unknown): e is ProfileFormSubmitError => { @@ -96,9 +93,7 @@ export const useProfileErrorHandler = () => { }; }; -export const useProfiles = ( - options?: UseQueryOptions | undefined, unknown, ServiceResponse | undefined, string[]>, -) => { +export const useProfiles = (options?: UseQueryOptions) => { const { user } = useAccountStore(); const isLoggedIn = !!user; @@ -106,13 +101,13 @@ export const useProfiles = ( const profilesEnabled = profileController.isEnabled(); - const query = useQuery(['listProfiles'], () => profileController.listProfiles(), { + const query = useQuery(['listProfiles', user?.id || ''], () => profileController.listProfiles(), { ...options, enabled: isLoggedIn && profilesEnabled, }); return { query, - profilesEnabled: !!query.data?.responseData.canManageProfiles, + profilesEnabled: !!query.data?.canManageProfiles, }; }; diff --git a/packages/hooks-react/src/useSocialLoginUrls.ts b/packages/hooks-react/src/useSocialLoginUrls.ts index 1843415d7..5c3d59a02 100644 --- a/packages/hooks-react/src/useSocialLoginUrls.ts +++ b/packages/hooks-react/src/useSocialLoginUrls.ts @@ -1,6 +1,6 @@ import { useQuery } from 'react-query'; import { getModule } from '@jwp/ott-common/src/modules/container'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; export type SocialLoginURLs = Record; diff --git a/packages/hooks-react/src/useSubscriptionChange.ts b/packages/hooks-react/src/useSubscriptionChange.ts index e648d2315..772397ef4 100644 --- a/packages/hooks-react/src/useSubscriptionChange.ts +++ b/packages/hooks-react/src/useSubscriptionChange.ts @@ -2,8 +2,8 @@ import { useMutation } from 'react-query'; import type { Customer } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; -import CheckoutController from '@jwp/ott-common/src/stores/CheckoutController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; export const useSubscriptionChange = ( isUpgradeOffer: boolean, diff --git a/packages/hooks-react/src/useWatchHistory.ts b/packages/hooks-react/src/useWatchHistory.ts index b416817f0..7a861a69d 100644 --- a/packages/hooks-react/src/useWatchHistory.ts +++ b/packages/hooks-react/src/useWatchHistory.ts @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import type { JWPlayer } from '@jwp/ott-common/types/jwplayer'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; import { useWatchHistoryStore } from '@jwp/ott-common/src/stores/WatchHistoryStore'; @@ -7,7 +6,7 @@ import { VideoProgressMinMax } from '@jwp/ott-common/src/constants'; import { useWatchHistoryListener } from './useWatchHistoryListener'; -export const useWatchHistory = (player: JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { +export const useWatchHistory = (player: jwplayer.JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { // config const { features } = useConfigStore((s) => s.config); const continueWatchingList = features?.continueWatchingList; diff --git a/packages/hooks-react/src/useWatchHistoryListener.ts b/packages/hooks-react/src/useWatchHistoryListener.ts index 2549724ed..ef2d15e9c 100644 --- a/packages/hooks-react/src/useWatchHistoryListener.ts +++ b/packages/hooks-react/src/useWatchHistoryListener.ts @@ -1,9 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; -import type { JWPlayer } from '@jwp/ott-common/types/jwplayer'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore'; -import WatchHistoryController from '@jwp/ott-common/src/stores/WatchHistoryController'; +import WatchHistoryController from '@jwp/ott-common/src/controllers/WatchHistoryController'; import useEventCallback from './useEventCallback'; @@ -31,7 +30,7 @@ const PROGRESSIVE_SAVE_INTERVAL = 300_000; // 5 minutes * item. When this needs to be saved, the queue is used to look up the last progress and item and save it when * necessary. The queue is then cleared to prevent duplicate saves and API calls. */ -export const useWatchHistoryListener = (player: JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { +export const useWatchHistoryListener = (player: jwplayer.JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => { const queuedWatchProgress = useRef(null); const watchHistoryController = getModule(WatchHistoryController); diff --git a/packages/hooks-react/tsconfig.json b/packages/hooks-react/tsconfig.json index 1c7abf924..8e73fc1c1 100644 --- a/packages/hooks-react/tsconfig.json +++ b/packages/hooks-react/tsconfig.json @@ -9,6 +9,7 @@ "compilerOptions": { "noEmit": true, "types": [ + "@types/jwplayer", "vi-fetch/matchers", "vitest/globals" ] diff --git a/packages/testing/fixtures/config.json b/packages/testing/fixtures/config.json index eb4268611..0d9676d53 100644 --- a/packages/testing/fixtures/config.json +++ b/packages/testing/fixtures/config.json @@ -55,8 +55,7 @@ "styling": { "backgroundColor": null, "highlightColor": null, - "headerBackground": null, - "footerText": "\u00a9 Blender Foundation | [cloud.blender.org](https://cloud.blender.org)" + "headerBackground": null }, "description": "Blender demo site", "analyticsToken": "lDd_MCg4EeuMunbqcIJccw", diff --git a/packages/testing/fixtures/customer.json b/packages/testing/fixtures/customer.json index 7225a2de9..f20d481ff 100644 --- a/packages/testing/fixtures/customer.json +++ b/packages/testing/fixtures/customer.json @@ -8,35 +8,23 @@ "lastLoginDate": "2021-07-26 16:54:38", "lastUserIp": "123.123.123.123", "externalId": "", - "externalData": { + "metadata": { "history": [ { - "tags": "Movie,Drama", - "title": "The Omega Code", "mediaid": "YzUqQnrx", - "duration": 5968, "progress": 0.3710328907847994 }, { - "tags": "Trailer", - "title": "The Omega Code Official Trailer", "mediaid": "k6K5ugEC", - "duration": 113, "progress": 0.448087431693989 } ], "favorites": [ { - "tags": "Movie", - "title": "Megiddo The Omega Code 2", - "mediaid": "2v7rOqOE", - "duration": 6341 + "mediaid": "2v7rOqOE" }, { - "tags": "Laurie Crouch,Matt Crouch,Ron Luce,Episode,The State of Faith,seriesId_740sLvW8", - "title": "Praise | State Of Faith: China | March 25, 2021", - "mediaid": "5cMy3jIp", - "duration": 3376 + "mediaid": "5cMy3jIp" } ] } diff --git a/packages/theme/assets/app-icon.png b/packages/theme/assets/app-icon.png new file mode 100644 index 000000000..968b01560 Binary files /dev/null and b/packages/theme/assets/app-icon.png differ diff --git a/packages/theme/assets/favicon.ico b/packages/theme/assets/favicon.ico new file mode 100644 index 000000000..a7d669098 Binary files /dev/null and b/packages/theme/assets/favicon.ico differ diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 47517e1f8..e1ff5c7bb 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -16,6 +16,7 @@ "date-fns": "^2.28.0", "dompurify": "^2.3.8", "i18next": "^22.4.15", + "inversify": "^6.0.1", "marked": "^4.1.1", "payment": "^2.4.6", "planby": "^0.3.0", @@ -35,6 +36,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/dompurify": "^2.3.4", + "@types/jwplayer": "^8.2.13", "@types/marked": "^4.0.7", "@types/payment": "^2.1.4", "@types/react-infinite-scroller": "^1.2.3", diff --git a/packages/ui-react/src/components/Account/Account.test.tsx b/packages/ui-react/src/components/Account/Account.test.tsx index 54e257d3a..801927a48 100644 --- a/packages/ui-react/src/components/Account/Account.test.tsx +++ b/packages/ui-react/src/components/Account/Account.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import type { Consent } from '@jwp/ott-common/types/account'; +import type { CustomFormField } from '@jwp/ott-common/types/account'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import customer from '@jwp/ott-testing/fixtures/customer.json'; import { mockService } from '@jwp/ott-common/test/mockService'; import { DEFAULT_FEATURES } from '@jwp/ott-common/src/constants'; @@ -19,7 +19,7 @@ describe('', () => { test('renders and matches snapshot', () => { useAccountStore.setState({ user: customer, - publisherConsents: Array.of({ name: 'marketing', label: 'Receive Marketing Emails' } as Consent), + publisherConsents: Array.of({ name: 'marketing', label: 'Receive Marketing Emails' } as CustomFormField), }); const { container } = renderWithRouter(); diff --git a/packages/ui-react/src/components/Account/Account.tsx b/packages/ui-react/src/components/Account/Account.tsx index 064bb1c71..ed715b695 100644 --- a/packages/ui-react/src/components/Account/Account.tsx +++ b/packages/ui-react/src/components/Account/Account.tsx @@ -4,10 +4,10 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { shallow } from '@jwp/ott-common/src/utils/compare'; import DOMPurify from 'dompurify'; import { useMutation } from 'react-query'; -import type { Consent } from '@jwp/ott-common/types/account'; +import type { CustomFormField } from '@jwp/ott-common/types/account'; import { getModule } from '@jwp/ott-common/src/modules/container'; import { useAccountStore } from '@jwp/ott-common/src/stores/AccountStore'; -import AccountController from '@jwp/ott-common/src/stores/AccountController'; +import AccountController from '@jwp/ott-common/src/controllers/AccountController'; import { isTruthy, isTruthyCustomParamValue, logDev, testId } from '@jwp/ott-common/src/utils/common'; import { formatConsents, formatConsentsFromValues, formatConsentsToRegisterFields, formatConsentValues } from '@jwp/ott-common/src/utils/collection'; import useToggle from '@jwp/ott-hooks-react/src/useToggle'; @@ -74,9 +74,18 @@ const Account = ({ panelClassName, panelHeaderClassName, canUpdateEmail = true } const isSocialLogin = (registerSource && registerSource !== 'inplayer') || false; const shouldAddPassword = (isSocialLogin && !customer?.metadata?.has_password) || false; + // load consents (move to `useConsents` hook?) + useEffect(() => { + if (!publisherConsents) { + accountController.getPublisherConsents(); + + return; + } + }, [accountController, publisherConsents]); + const [termsConsents, nonTermsConsents] = useMemo(() => { - const terms: Consent[] = []; - const nonTerms: Consent[] = []; + const terms: CustomFormField[] = []; + const nonTerms: CustomFormField[] = []; publisherConsents?.forEach((consent) => { if (!consent?.type || consent?.type === 'checkbox') { diff --git a/packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx index 88060d339..2f6c1d366 100644 --- a/packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx +++ b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.test.tsx @@ -4,6 +4,8 @@ import type { Offer, Order } from '@jwp/ott-common/types/checkout'; import offer from '@jwp/ott-testing/fixtures/monthlyOffer.json'; import order from '@jwp/ott-testing/fixtures/order.json'; +import PaymentForm from '../PaymentForm/PaymentForm'; + import CheckoutForm from './CheckoutForm'; describe('', () => { @@ -25,7 +27,9 @@ describe('', () => { offer={offer as Offer} offerType={'svod'} submitting={false} - />, + > + + , ); expect(container).toMatchSnapshot(); diff --git a/packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx index e69ad5385..63a2013b0 100644 --- a/packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx +++ b/packages/ui-react/src/components/CheckoutForm/CheckoutForm.tsx @@ -1,8 +1,7 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; -import type { OfferType } from '@jwp/ott-common/types/account'; -import type { Offer, Order, PaymentMethod } from '@jwp/ott-common/types/checkout'; +import type { Offer, OfferType, Order, PaymentMethod } from '@jwp/ott-common/types/checkout'; import { formatPrice } from '@jwp/ott-common/src/utils/formatting'; import Close from '@jwp/ott-theme/assets/icons/close.svg?react'; import PayPal from '@jwp/ott-theme/assets/icons/paypal.svg?react'; @@ -18,7 +17,7 @@ import Icon from '../Icon/Icon'; import styles from './CheckoutForm.module.scss'; type Props = { - paymentMethodId?: number; + paymentMethodId?: string; onBackButtonClick: () => void; paymentMethods?: PaymentMethod[]; onPaymentMethodChange: React.ChangeEventHandler; @@ -34,7 +33,7 @@ type Props = { order: Order; offer: Offer; offerType: OfferType; - renderPaymentMethod?: () => JSX.Element | null; + children: ReactNode; submitting: boolean; }; @@ -55,7 +54,7 @@ const CheckoutForm: React.FC = ({ onCloseCouponFormClick, onCouponFormSubmit, onRedeemCouponButtonClick, - renderPaymentMethod, + children, submitting, }) => { const { t } = useTranslation('account'); @@ -181,10 +180,10 @@ const CheckoutForm: React.FC = ({