diff --git a/packages/common/package.json b/packages/common/package.json index 2be2b044d..a9d487d3f 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -9,7 +9,7 @@ "test-watch": "TZ=UTC LC_ALL=en_US.UTF-8 vitest" }, "dependencies": { - "@inplayer-org/inplayer.js": "^3.13.28", + "broadcast-channel": "^7.0.0", "date-fns": "^3.6.0", "fast-xml-parser": "^4.5.0", "i18next": "^22.5.1", diff --git a/packages/common/src/controllers/AccessController.ts b/packages/common/src/controllers/AccessController.ts index e10d1d52a..cb0df2a12 100644 --- a/packages/common/src/controllers/AccessController.ts +++ b/packages/common/src/controllers/AccessController.ts @@ -10,7 +10,6 @@ import { useConfigStore } from '../stores/ConfigStore'; import { INTEGRATION_TYPE } from '../modules/types'; import { getNamedModule } from '../modules/container'; import { useAccountStore } from '../stores/AccountStore'; -import { ApiError } from '../utils/api'; import { useAccessStore } from '../stores/AccessStore'; const ACCESS_TOKENS = 'access_tokens'; @@ -53,7 +52,7 @@ export default class AccessController { /** * Retrieves media by its ID using a passport token. * If no access tokens exist, it attempts to generate them, if the passport token is expired, it attempts to refresh them. - * If an access token retrieval fails or the user is not entitled to the content, an error is thrown. + * If the passport is not accepted, tries to generate new access tokens to get the latest entitled plans. */ getMediaById = async (mediaId: string) => { const { entitledPlan } = useAccountStore.getState(); @@ -62,23 +61,27 @@ export default class AccessController { return; } - try { - const accessTokens = await this.generateOrRefreshAccessTokens(); + const getMediaWithPassport = async (forceGenerate: boolean = false) => { + const accessTokens = forceGenerate ? await this.generateAccessTokens() : await this.generateOrRefreshAccessTokens(); if (!accessTokens?.passport) { - throw new Error('Failed to get / generate access tokens and retrieve media.'); + throw new Error('Failed to generate / refresh access tokens.'); } - return await this.apiService.getMediaByIdWithPassport({ id: mediaId, siteId: this.siteId, planId: entitledPlan.id, passport: accessTokens.passport }); + + return await this.apiService.getMediaByIdWithPassport({ + id: mediaId, + siteId: this.siteId, + planId: entitledPlan.id, + passport: accessTokens.passport, + }); + }; + + try { + return await getMediaWithPassport(); } catch (error: unknown) { - if (error instanceof ApiError && error.code === 403) { - // If the passport is invalid or expired, refresh the access tokens and try to get the media again. - const accessTokens = await this.refreshAccessTokens(); - if (accessTokens?.passport) { - return await this.apiService.getMediaByIdWithPassport({ id: mediaId, siteId: this.siteId, planId: entitledPlan.id, passport: accessTokens.passport }); - } - - throw new Error('Failed to refresh access tokens and retrieve media.'); - } - throw error; + // If the initial attempt fails, it may indicate the passport does not have the latest plans. + // Force new access tokens generation and retry fetching the media. + // TODO: Revisit this logic once we address the passport update possibility in Q4 + return await getMediaWithPassport(true); } }; diff --git a/packages/common/src/controllers/AccountController.ts b/packages/common/src/controllers/AccountController.ts index 9a79dcf2e..08faef60d 100644 --- a/packages/common/src/controllers/AccountController.ts +++ b/packages/common/src/controllers/AccountController.ts @@ -9,15 +9,7 @@ import SubscriptionService from '../services/integrations/SubscriptionService'; import JWPEntitlementService from '../services/JWPEntitlementService'; import type { Offer } from '../../types/checkout'; import type { Plan } from '../../types/plans'; -import type { - Capture, - Customer, - CustomerConsent, - EmailConfirmPasswordInput, - FirstLastNameInput, - GetCaptureStatusResponse, - SubscribeToNotificationsPayload, -} from '../../types/account'; +import type { Capture, Customer, CustomerConsent, EmailConfirmPasswordInput, FirstLastNameInput, GetCaptureStatusResponse } from '../../types/account'; import { assertFeature, assertModuleMethod, getModule, getNamedModule } from '../modules/container'; import { INTEGRATION_TYPE } from '../modules/types'; import type { ServiceResponse } from '../../types/service'; @@ -92,8 +84,8 @@ export default class AccountController { // set the accessModel before restoring the user session useConfigStore.setState({ accessModel: this.accountService.accessModel }); - await this.loadUserData(); await this.getEntitledPlans(); + await this.loadUserData(); useAccountStore.setState({ loading: false }); }; @@ -173,6 +165,7 @@ export default class AccountController { if (response) { await this.accessController?.generateAccessTokens(); + await this.getEntitledPlans(); await this.afterLogin(response.user, response.customerConsents); return; } @@ -203,6 +196,8 @@ export default class AccountController { if (response) { const { user, customerConsents } = response; + await this.accessController?.generateAccessTokens(); + await this.getEntitledPlans(); await this.afterLogin(user, customerConsents, true); return; @@ -395,15 +390,14 @@ export default class AccountController { // TODO: Support for multiple plans should be added. Revisit this logic once the dependency on plan_id is changed. getEntitledPlans = async (): Promise => { const { config, settings } = useConfigStore.getState(); - const siteId = config.siteId; const isAccessBridgeEnabled = !!settings?.apiAccessBridgeUrl; - // This should be only used when access bridge is defined, regardless of the integration type. + // This should be only available when access bridge is defined, regardless of the integration type. if (!isAccessBridgeEnabled) { return null; } - const response = await this.entitlementService.getEntitledPlans({ siteId }); + const response = await this.entitlementService.getEntitledPlans({ siteId: config.siteId }); if (response?.plans?.length) { // Find the SVOD plan or fallback to the first available plan const entitledPlan = response.plans.find((plan) => plan.metadata.access_model === 'svod') || response.plans[0]; @@ -422,7 +416,7 @@ export default class AccountController { ): Promise => { useAccountStore.setState({ loading: true }); - const { getAccountInfo } = useAccountStore.getState(); + const { getAccountInfo, entitledPlan } = useAccountStore.getState(); const { customerId } = getAccountInfo(); const { accessModel } = useConfigStore.getState(); @@ -444,7 +438,7 @@ export default class AccountController { } const [activeSubscription, transactions, activePayment] = await Promise.all([ - this.subscriptionService.getActiveSubscription({ customerId }), + this.subscriptionService.getActiveSubscription({ customerId, entitledPlan }), this.subscriptionService.getAllTransactions({ customerId }), this.subscriptionService.getActivePayment({ customerId }), ]); @@ -533,10 +527,6 @@ export default class AccountController { return this.accountService.getAuthData(); }; - subscribeToNotifications = async ({ uuid, onMessage }: SubscribeToNotificationsPayload) => { - return this.accountService.subscribeToNotifications({ uuid, onMessage }); - }; - getFeatures() { return this.features; } diff --git a/packages/common/src/controllers/CheckoutController.ts b/packages/common/src/controllers/CheckoutController.ts index 8a9acc761..408e4abe6 100644 --- a/packages/common/src/controllers/CheckoutController.ts +++ b/packages/common/src/controllers/CheckoutController.ts @@ -28,6 +28,7 @@ import { useAccountStore } from '../stores/AccountStore'; import { FormValidationError } from '../errors/FormValidationError'; import { determineSwitchDirection } from '../utils/subscription'; import { findDefaultCardMethodId } from '../utils/payments'; +import { useConfigStore } from '../stores/ConfigStore'; @injectable() export default class CheckoutController { @@ -62,8 +63,10 @@ export default class CheckoutController { getSubscriptionOfferIds = () => this.accountService.svodOfferIds; - chooseOffer = async (selectedOffer: Offer) => { - useCheckoutStore.setState({ selectedOffer }); + chooseOffer = async (params: { offer: Offer; successUrl: string; cancelUrl: string }) => { + useCheckoutStore.setState({ selectedOffer: params.offer }); + + return this.checkoutService.chooseOffer(params); }; initialiseOrder = async (offer: Offer): Promise => { @@ -353,6 +356,19 @@ export default class CheckoutController { return response.responseData; }; + generateBillingPortalUrl = async (returnUrl: string) => { + assertModuleMethod(this.checkoutService.generateBillingPortalUrl, 'generateBillingPortalUrl is not available in checkout service'); + + // This method should only be available when Access Bridge is being used, regardless of the integration type. + const { settings } = useConfigStore.getState(); + const isAccessBridgeEnabled = !!settings?.apiAccessBridgeUrl; + if (!isAccessBridgeEnabled) { + return null; + } + + return this.checkoutService.generateBillingPortalUrl(returnUrl); + }; + getOffers: GetOffers = (payload) => { return this.checkoutService.getOffers(payload); }; diff --git a/packages/common/src/modules/register.ts b/packages/common/src/modules/register.ts index 8bad8ece3..afa8f4e1a 100644 --- a/packages/common/src/modules/register.ts +++ b/packages/common/src/modules/register.ts @@ -12,6 +12,7 @@ import JWPEntitlementService from '../services/JWPEntitlementService'; import FavoriteService from '../services/FavoriteService'; import ConfigService from '../services/ConfigService'; import SettingsService from '../services/SettingsService'; +import PaymentService from '../services/PaymentService'; import WatchHistoryController from '../controllers/WatchHistoryController'; import CheckoutController from '../controllers/CheckoutController'; @@ -57,6 +58,7 @@ container.bind(GenericEntitlementService).toSelf(); container.bind(ApiService).toSelf(); container.bind(SettingsService).toSelf(); container.bind(AccessService).toSelf(); +container.bind(PaymentService).toSelf(); // Common controllers container.bind(AppController).toSelf(); diff --git a/packages/common/src/paths.tsx b/packages/common/src/paths.tsx index 08bedab91..d1d320ea1 100644 --- a/packages/common/src/paths.tsx +++ b/packages/common/src/paths.tsx @@ -14,7 +14,9 @@ export const PATH_USER = `${PATH_USER_BASE}/*`; export const RELATIVE_PATH_USER_ACCOUNT = 'my-account'; export const RELATIVE_PATH_USER_FAVORITES = 'favorites'; export const RELATIVE_PATH_USER_PAYMENTS = 'payments'; +export const RELATIVE_PATH_USER_SUBSCRIPTION = 'subscription'; export const PATH_USER_ACCOUNT = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_ACCOUNT}`; 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_SUBSCRIPTIONS = `${PATH_USER_BASE}/${RELATIVE_PATH_USER_SUBSCRIPTION}`; diff --git a/packages/common/src/services/PaymentService.ts b/packages/common/src/services/PaymentService.ts new file mode 100644 index 000000000..ba38a086f --- /dev/null +++ b/packages/common/src/services/PaymentService.ts @@ -0,0 +1,94 @@ +import { inject, injectable } from 'inversify'; + +import { API_ACCESS_BRIDGE_URL, INTEGRATION_TYPE } from '../modules/types'; +import type { IntegrationType } from '../../types/config'; +import type { Product } from '../../types/payment'; +import { logError } from '../logger'; +import { getNamedModule } from '../modules/container'; + +import AccountService from './integrations/AccountService'; + +@injectable() +export default class PaymentService { + private readonly apiAccessBridgeUrl; + private readonly accountService; + + private siteId: string = ''; + + constructor(@inject(API_ACCESS_BRIDGE_URL) apiAccessBridgeUrl: string, @inject(INTEGRATION_TYPE) integrationType: IntegrationType) { + this.apiAccessBridgeUrl = apiAccessBridgeUrl; + this.accountService = getNamedModule(AccountService, integrationType); + } + + initialize = async (siteId: string) => { + this.siteId = siteId; + }; + + getProducts = async (): Promise => { + const url = `${this.apiAccessBridgeUrl}/v2/sites/${this.siteId}/products`; + const response = await fetch(url); + + if (!response.ok) { + logError('PaymentService', 'Failed to fetch products.', { + status: response.status, + error: response.json(), + }); + + return []; + } + + return (await response.json()) as Product[]; + }; + + generateCheckoutSessionUrl = async (priceId: string, successUrl: string, cancelUrl: string): Promise<{ url: string | null }> => { + const auth = await this.accountService.getAuthData(); + + const url = `${this.apiAccessBridgeUrl}/v2/sites/${this.siteId}/checkout`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth?.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + price_id: priceId, + success_url: successUrl, + cancel_url: cancelUrl, + }), + }); + + if (!response.ok) { + logError('PaymentService', `Failed to generate checkout URL. Status: ${response.status}`); + return { url: null }; + } + + return (await response.json()) as { url: string }; + }; + + generateBillingPortalUrl = async (returnUrl: string): Promise<{ url: string | null }> => { + const auth = await this.accountService.getAuthData(); + + const url = `${this.apiAccessBridgeUrl}/v2/sites/${this.siteId}/billing-portal`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${auth?.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + return_url: returnUrl, + }), + }); + + if (!response.ok) { + logError('PaymentService', 'Failed to generate billing portal url.', { + status: response.status, + error: response.json(), + }); + + return { url: null }; + } + + return (await response.json()) as { url: string }; + }; +} diff --git a/packages/common/src/services/integrations/AccountService.ts b/packages/common/src/services/integrations/AccountService.ts index deb186041..c08a66d1b 100644 --- a/packages/common/src/services/integrations/AccountService.ts +++ b/packages/common/src/services/integrations/AccountService.ts @@ -8,7 +8,6 @@ import type { GetCustomerConsents, GetPublisherConsents, Login, - NotificationsData, Register, ResetPassword, GetSocialURLs, @@ -87,8 +86,6 @@ export default abstract class AccountService { abstract getFavorites: GetFavorites; - abstract subscribeToNotifications: NotificationsData; - abstract getSocialUrls?: GetSocialURLs; abstract exportAccountData?: ExportAccountData; diff --git a/packages/common/src/services/integrations/CheckoutService.ts b/packages/common/src/services/integrations/CheckoutService.ts index 483422073..040dde140 100644 --- a/packages/common/src/services/integrations/CheckoutService.ts +++ b/packages/common/src/services/integrations/CheckoutService.ts @@ -1,5 +1,6 @@ import type { AddAdyenPaymentDetails, + ChooseOffer, CreateOrder, DeletePaymentMethod, FinalizeAdyenPaymentDetails, @@ -19,9 +20,12 @@ import type { SwitchSubscription, UpdateOrder, UpdatePaymentWithPayPal, + GenerateBillingPortalURL, } from '../../../types/checkout'; export default abstract class CheckoutService { + abstract initializePaymentService: () => void; + abstract getOffers: GetOffers; abstract createOrder: CreateOrder; @@ -38,6 +42,8 @@ export default abstract class CheckoutService { abstract directPostCardPayment: GetDirectPostCardPayment; + abstract chooseOffer: ChooseOffer; + abstract getOffer?: GetOffer; abstract getOrder?: GetOrder; @@ -61,4 +67,6 @@ export default abstract class CheckoutService { abstract addAdyenPaymentDetails?: AddAdyenPaymentDetails; abstract finalizeAdyenPaymentDetails?: FinalizeAdyenPaymentDetails; + + abstract generateBillingPortalUrl?: GenerateBillingPortalURL; } diff --git a/packages/common/src/services/integrations/cleeng/CleengAccountService.ts b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts index 9fe3a8c44..55ef0b00a 100644 --- a/packages/common/src/services/integrations/cleeng/CleengAccountService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengAccountService.ts @@ -13,7 +13,6 @@ import type { JwtDetails, Login, LoginPayload, - NotificationsData, Register, RegisterPayload, ResetPassword, @@ -345,10 +344,6 @@ export default class CleengAccountService extends AccountService { return (this.externalData['favorites'] || []) as SerializedFavorite[]; }; - subscribeToNotifications: NotificationsData = async () => { - return false; - }; - getSocialUrls: undefined; exportAccountData: undefined; diff --git a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts index ae4338076..8763b34c7 100644 --- a/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts +++ b/packages/common/src/services/integrations/cleeng/CleengCheckoutService.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'inversify'; import type { AddAdyenPaymentDetails, + ChooseOffer, CreateOrder, CreateOrderPayload, DeletePaymentMethod, @@ -41,6 +42,8 @@ export default class CleengCheckoutService extends CheckoutService { this.getCustomerIP = getCustomerIP; } + initializePaymentService = async () => {}; + getOffers: GetOffers = async (payload) => { return await Promise.all( payload.offerIds.map(async (offerId) => { @@ -55,6 +58,8 @@ export default class CleengCheckoutService extends CheckoutService { ); }; + chooseOffer: ChooseOffer = async () => {}; + getOffer: GetOffer = async (payload) => { const customerIP = await this.getCustomerIP(); @@ -184,4 +189,6 @@ export default class CleengCheckoutService extends CheckoutService { this.cleengService.post('/connectors/adyen/payment-details/finalize', JSON.stringify(payload), { authenticate: true }); directPostCardPayment = async () => false; + + generateBillingPortalUrl: undefined; } diff --git a/packages/common/src/services/integrations/jwp/JWPAPIService.ts b/packages/common/src/services/integrations/jwp/JWPAPIService.ts index 86439a7e0..fe0f64c3a 100644 --- a/packages/common/src/services/integrations/jwp/JWPAPIService.ts +++ b/packages/common/src/services/integrations/jwp/JWPAPIService.ts @@ -2,6 +2,7 @@ import { inject, injectable } from 'inversify'; import StorageService from '../../StorageService'; +import { API_CONSTS } from './constants'; import type { JWPError } from './types'; const INPLAYER_TOKEN_KEY = 'inplayer_token'; @@ -24,17 +25,19 @@ type RequestOptions = { export default class JWPAPIService { protected readonly storageService: StorageService; - protected useSandboxEnv = true; + private useSandboxEnv = true; + private siteId = ''; constructor(@inject(StorageService) storageService: StorageService) { this.storageService = storageService; } - setup = (useSandboxEnv: boolean) => { + setup = (useSandboxEnv: boolean, siteId: string) => { this.useSandboxEnv = useSandboxEnv; + this.siteId = siteId; }; - private getBaseUrl = () => (this.useSandboxEnv ? 'https://staging-sims.jwplayer.com' : 'https://sims.jwplayer.com'); + protected getBaseUrl = () => API_CONSTS[this.useSandboxEnv ? 'STAGING' : 'PROD'].API_BASE_URL; setToken = (token: string, refreshToken = '', expires: number) => { return this.storageService.setItem(INPLAYER_TOKEN_KEY, JSON.stringify({ token, refreshToken, expires }), false); @@ -63,7 +66,7 @@ export default class JWPAPIService { private performRequest = async ( path: string = '/', method = 'GET', - body?: Record, + bodyObject?: Record, { contentType = 'form', responseType = 'json', withAuthentication = false, keepalive, includeFullResponse = false }: RequestOptions = {}, searchParams?: Record, ) => { @@ -79,10 +82,16 @@ export default class JWPAPIService { } } - const formData = new URLSearchParams(); + const body = (() => { + if (!bodyObject) return; - if (body) { - Object.entries(body).forEach(([key, value]) => { + if (contentType === 'json') { + return JSON.stringify(bodyObject); + } + + const formData = new URLSearchParams(); + + Object.entries(bodyObject).forEach(([key, value]) => { if (value || value === 0) { if (typeof value === 'object') { Object.entries(value as Record).forEach(([innerKey, innerValue]) => { @@ -93,17 +102,21 @@ export default class JWPAPIService { } } }); - } - const endpoint = `${path.startsWith('http') ? path : `${this.getBaseUrl()}${path}`}${ - searchParams ? `?${new URLSearchParams(searchParams as Record).toString()}` : '' - }`; + return formData.toString(); + })(); + + const parsedPath = path.replace(':siteId', this.siteId); + const fullPath = `${parsedPath.startsWith('http') ? parsedPath : `${this.getBaseUrl()}${parsedPath}`}`; + const searchString = searchParams ? `?${new URLSearchParams(searchParams as Record).toString()}` : ''; + + const endpoint = `${fullPath}${searchString}`; const resp = await fetch(endpoint, { headers, keepalive, method, - body: body && formData.toString(), + body, }); const resParsed = await resp[responseType]?.(); diff --git a/packages/common/src/services/integrations/jwp/JWPAccountService.ts b/packages/common/src/services/integrations/jwp/JWPAccountService.ts index 37a343f31..9871f7456 100644 --- a/packages/common/src/services/integrations/jwp/JWPAccountService.ts +++ b/packages/common/src/services/integrations/jwp/JWPAccountService.ts @@ -1,4 +1,3 @@ -import InPlayer, { Env } from '@inplayer-org/inplayer.js'; import i18next from 'i18next'; import { inject, injectable } from 'inversify'; @@ -17,7 +16,6 @@ import type { GetCustomerConsents, GetPublisherConsents, Login, - NotificationsData, Register, ResetPassword, GetSocialURLs, @@ -50,12 +48,6 @@ import type { } from './types'; import JWPAPIService from './JWPAPIService'; -enum InPlayerEnv { - Development = 'development', - Production = 'production', - Daily = 'daily', -} - const JW_TERMS_URL = 'https://inplayer.com/legal/terms'; @injectable() @@ -149,11 +141,7 @@ export default class JWPAccountService extends AccountService { // set environment this.sandbox = !!jwpConfig.useSandbox; - - const env: string = this.sandbox ? InPlayerEnv.Development : InPlayerEnv.Production; - InPlayer.setConfig(env as Env); - - this.apiService.setup(this.sandbox); + this.apiService.setup(this.sandbox, config.siteId); // calculate access model if (jwpConfig.clientId) { @@ -366,10 +354,6 @@ export default class JWPAccountService extends AccountService { logout = async () => { try { - if (InPlayer.Notifications.isSubscribed()) { - InPlayer.Notifications.unsubscribe(); - } - if (await this.apiService.isAuthenticated()) { await this.apiService.get('/accounts/logout', { withAuthentication: true }); await this.apiService.removeToken(); @@ -545,20 +529,6 @@ export default class JWPAccountService extends AccountService { return watchHistoryData?.collection?.map(this.formatHistoryItem) || []; }; - subscribeToNotifications: NotificationsData = async ({ uuid, onMessage }) => { - try { - if (!InPlayer.Notifications.isSubscribed()) { - InPlayer.subscribe(uuid, { - onMessage: onMessage, - onOpen: () => true, - }); - } - return true; - } catch { - return false; - } - }; - exportAccountData: ExportAccountData = async () => { // password is sent as undefined because it is now optional on BE try { diff --git a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts index 18a5b10a3..e11a22c1e 100644 --- a/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts +++ b/packages/common/src/services/integrations/jwp/JWPCheckoutService.ts @@ -3,8 +3,10 @@ import { inject, injectable } from 'inversify'; import { isSVODOffer } from '../../../utils/offers'; import type { CardPaymentData, + ChooseOffer, CreateOrder, CreateOrderArgs, + GenerateBillingPortalURL, GetEntitlements, GetEntitlementsResponse, GetOffers, @@ -20,16 +22,11 @@ import type { } from '../../../../types/checkout'; import CheckoutService from '../CheckoutService'; import type { ServiceResponse } from '../../../../types/service'; +import type { Price } from '../../../../types/payment'; +import PaymentService from '../../PaymentService'; +import { useConfigStore } from '../../../../../common/src/stores/ConfigStore'; -import type { - CommonResponse, - GetAccessFeesResponse, - AccessFee, - MerchantPaymentMethod, - GeneratePayPalParameters, - VoucherDiscountPrice, - GetItemAccessResponse, -} from './types'; +import type { CommonResponse, MerchantPaymentMethod, GeneratePayPalParameters, VoucherDiscountPrice, GetItemAccessResponse } from './types'; import JWPAPIService from './JWPAPIService'; @injectable() @@ -37,12 +34,20 @@ export default class JWPCheckoutService extends CheckoutService { protected readonly cardPaymentProvider = 'stripe'; protected readonly apiService; + protected readonly paymentService; - constructor(@inject(JWPAPIService) apiService: JWPAPIService) { + constructor(@inject(JWPAPIService) apiService: JWPAPIService, @inject(PaymentService) paymentService: PaymentService) { super(); this.apiService = apiService; + this.paymentService = paymentService; + this.initializePaymentService(); } + initializePaymentService = async () => { + const { siteId } = useConfigStore.getState().config; + this.paymentService.initialize(siteId); + }; + private formatPaymentMethod = (method: MerchantPaymentMethod, cardPaymentProvider: string): PaymentMethod => { return { id: method.id, @@ -62,16 +67,6 @@ export default class JWPCheckoutService extends CheckoutService { }; }; - /** - * Format a (Cleeng like) offer id for the given access fee (pricing option). For JWP, we need the asset id and - * access fee id in some cases. - */ - private formatOfferId(offer: AccessFee) { - const ppvOffers = ['ppv', 'ppv_custom']; - - return ppvOffers.includes(offer.access_type.name) ? `C${offer.item_id}_${offer.id}` : `S${offer.item_id}_${offer.id}`; - } - /** * Parse the given offer id and extract the asset id. * The offer id might be the Cleeng format (`S_`) or the asset id as string. @@ -90,21 +85,6 @@ export default class JWPCheckoutService extends CheckoutService { return offerId; } - private formatOffer = (offer: AccessFee): Offer => { - return { - id: offer.id, - offerId: this.formatOfferId(offer), - offerCurrency: offer.currency, - customerPriceInclTax: offer.amount, - customerCurrency: offer.currency, - offerTitle: offer.description, - active: true, - period: offer.access_type.period === 'month' && offer.access_type.quantity === 12 ? 'year' : offer.access_type.period, - freePeriods: offer.trial_period ? 1 : 0, - planSwitchEnabled: offer.item.plan_switch_enabled ?? false, - } as Offer; - }; - private formatOrder = (payload: CreateOrderArgs): Order => { return { id: payload.offer.id, @@ -136,20 +116,40 @@ export default class JWPCheckoutService extends CheckoutService { }; }; - getOffers: GetOffers = async (payload) => { - const offers = await Promise.all( - payload.offerIds.map(async (offerId) => { - try { - const data = await this.apiService.get(`/v2/items/${this.parseOfferId(offerId)}/access-fees`); + chooseOffer: ChooseOffer = async ({ offer: { offerId }, successUrl, cancelUrl }) => { + try { + const { url } = await this.paymentService.generateCheckoutSessionUrl(offerId, successUrl, cancelUrl); + return url || undefined; + } catch (error) { + throw new Error('Failed to get checkout URL'); + } + }; - return data?.map((offer) => this.formatOffer(offer)); - } catch { - throw new Error('Failed to get offers'); - } - }), - ); + private formatPriceToOffer = (price: Price & { name: string }, i: number): Offer => + ({ + id: i, + offerId: price.store_price_id, + offerCurrency: price.default_currency, + customerPriceInclTax: (price.currencies[price.default_currency].amount || 0) / 100, + customerCurrency: price.default_currency, + offerTitle: price.name, + active: true, + period: price.recurrence === 'one_time' ? 'one_time' : price.recurrence.interval, + freePeriods: price.recurrence === 'one_time' ? 0 : price.recurrence.trial_period_duration ?? 0, + planSwitchEnabled: false, + } as unknown as Offer); + + getOffers: GetOffers = async () => { + try { + const products = await this.paymentService.getProducts(); + const offers = products.flatMap((product, i) => + product.prices.map((price, j) => this.formatPriceToOffer({ ...price, name: product.name }, parseInt(`${i + 1}${j}`))), + ); - return offers.flat(); + return offers; + } catch (error) { + throw new Error('Failed to get offers'); + } }; getPaymentMethods: GetPaymentMethods = async () => { @@ -303,6 +303,15 @@ export default class JWPCheckoutService extends CheckoutService { } }; + generateBillingPortalUrl: GenerateBillingPortalURL = async (returnUrl) => { + try { + const { url } = await this.paymentService.generateBillingPortalUrl(returnUrl); + return url; + } catch (error) { + throw new Error('Failed to generate billing portal URL'); + } + }; + getSubscriptionSwitches = undefined; getOrder = undefined; diff --git a/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts index 21754bdfa..c7ed85c6f 100644 --- a/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts +++ b/packages/common/src/services/integrations/jwp/JWPSubscriptionService.ts @@ -1,4 +1,3 @@ -import i18next from 'i18next'; import { inject, injectable, named } from 'inversify'; import type { @@ -8,26 +7,20 @@ import type { GetAllTransactions, PaymentDetail, Subscription, - Transaction, UpdateCardDetails, UpdateSubscription, } from '../../../../types/subscription'; import SubscriptionService from '../SubscriptionService'; -import AccountService from '../AccountService'; import type { - GetItemAccessResponse, GetSubscriptionsResponse, - GetPaymentHistoryResponse, GetDefaultCardResponse, CancelSubscriptionResponse, ChangeSubscriptionPlanResponse, SetDefaultCardResponse, Card, - PaymentHistory, JWPSubscription, } from './types'; -import type JWPAccountService from './JWPAccountService'; import JWPAPIService from './JWPAPIService'; interface SubscriptionDetails extends JWPSubscription { @@ -47,13 +40,11 @@ interface SubscriptionDetails extends JWPSubscription { @injectable() export default class JWPSubscriptionService extends SubscriptionService { - protected readonly accountService: JWPAccountService; protected readonly apiService: JWPAPIService; - constructor(@named('JWP') accountService: AccountService, @inject(JWPAPIService) apiService: JWPAPIService) { + constructor(@named('JWP') @inject(JWPAPIService) apiService: JWPAPIService) { super(); - this.accountService = accountService as JWPAccountService; this.apiService = apiService; } @@ -84,34 +75,6 @@ export default class JWPSubscriptionService extends SubscriptionService { } as PaymentDetail; }; - private formatTransaction = (transaction: PaymentHistory): Transaction => { - const purchasedAmount = transaction?.charged_amount?.toString() || '0'; - - return { - transactionId: transaction.transaction_token || i18next.t('user:payment.access_granted'), - transactionDate: transaction.created_at, - trxToken: transaction.trx_token, - offerId: transaction.item_id?.toString() || i18next.t('user:payment.no_transaction'), - offerType: transaction.item_type || '', - offerTitle: transaction?.item_title || '', - offerPeriod: '', - transactionPriceExclTax: purchasedAmount, - transactionCurrency: transaction.currency_iso || 'EUR', - discountedOfferPrice: purchasedAmount, - offerCurrency: transaction.currency_iso || 'EUR', - offerPriceExclTax: purchasedAmount, - applicableTax: '0', - transactionPriceInclTax: purchasedAmount, - customerId: transaction.consumer_id?.toString(), - customerEmail: '', - customerLocale: '', - customerCountry: 'en', - customerIpCountry: '', - customerCurrency: '', - paymentMethod: transaction.payment_method_name || i18next.t('user:payment.access_granted'), - }; - }; - private formatActiveSubscription = (subscription: SubscriptionDetails, expiresAt: number) => { let status = ''; switch (subscription.action_type) { @@ -150,77 +113,41 @@ export default class JWPSubscriptionService extends SubscriptionService { } as Subscription; }; - private formatGrantedSubscription = (subscription: GetItemAccessResponse) => { - return { - subscriptionId: '', - offerId: subscription.item.id.toString(), - status: 'active', - expiresAt: subscription.expires_at, - nextPaymentAt: subscription.expires_at, - nextPaymentPrice: 0, - nextPaymentCurrency: 'EUR', - paymentGateway: 'none', - paymentMethod: i18next.t('user:payment.access_granted'), - offerTitle: subscription.item.title, - period: 'granted', - totalPrice: 0, - unsubscribeUrl: '', - pendingSwitchId: null, - } as Subscription; - }; - - getActiveSubscription: GetActiveSubscription = async () => { - const assetId = this.accountService.assetId; - - if (assetId === null) throw new Error("Couldn't fetch active subscription, there is no assetId configured"); + getActiveSubscription: GetActiveSubscription = async ({ entitledPlan }) => { + if (!entitledPlan) { + return null; + } try { - const hasAccess = await this.apiService.get(`/items/${assetId}/access`, { - withAuthentication: true, - }); - - if (hasAccess) { - const data = await this.apiService.get( - '/subscriptions', - { - withAuthentication: true, - contentType: 'json', - }, - { - limit: 15, - page: 0, - }, - ); - - const activeSubscription = data.collection.find((subscription: SubscriptionDetails) => subscription.item_id === assetId); + const { collection: subscriptions } = await this.apiService.get( + '/subscriptions', + { + withAuthentication: true, + contentType: 'json', + }, + { + limit: 15, + page: 0, + }, + ); - if (activeSubscription) { - return this.formatActiveSubscription(activeSubscription, hasAccess?.expires_at); - } + const activeSubscription = subscriptions.find((subscription: SubscriptionDetails) => subscription.item_id === entitledPlan.original_id); - return this.formatGrantedSubscription(hasAccess); + if (activeSubscription) { + return this.formatActiveSubscription(activeSubscription, entitledPlan.exp); } + return null; - } catch (error: unknown) { + } catch (error) { if (JWPAPIService.isCommonError(error) && error.response.data.code === 402) { return null; } + throw new Error('Unable to fetch customer subscriptions.'); } }; - getAllTransactions: GetAllTransactions = async () => { - try { - const data = await this.apiService.get('/v2/accounting/payment-history', { - withAuthentication: true, - contentType: 'json', - }); - - return data?.collection?.map((transaction) => this.formatTransaction(transaction)); - } catch { - throw new Error('Failed to get transactions'); - } - }; + getAllTransactions: GetAllTransactions = async () => null; getActivePayment: GetActivePayment = async () => { try { diff --git a/packages/common/src/services/integrations/jwp/constants.ts b/packages/common/src/services/integrations/jwp/constants.ts new file mode 100644 index 000000000..067ddd318 --- /dev/null +++ b/packages/common/src/services/integrations/jwp/constants.ts @@ -0,0 +1,11 @@ +export const API_CONSTS = { + DAILY: { + API_BASE_URL: 'https://daily-sims.jwplayer.com', + }, + STAGING: { + API_BASE_URL: 'https://staging-sims.jwplayer.com', + }, + PROD: { + API_BASE_URL: 'https://sims.jwplayer.com', + }, +}; diff --git a/packages/common/src/services/integrations/jwp/types.ts b/packages/common/src/services/integrations/jwp/types.ts index 91302feef..e95dd32f5 100644 --- a/packages/common/src/services/integrations/jwp/types.ts +++ b/packages/common/src/services/integrations/jwp/types.ts @@ -282,6 +282,28 @@ export type JWPSubscription = { unsubscribe_url: string; }; +type CommonJWPListResponse = { + page: number; + page_length: number; + total: number; +} & { + [key in Property]: ItemType[] | null; +}; + +export type JWPSubscriptionPlan = { + id: string; + metadata: { + name: string; + access_model: 'free' | 'freeauth' | 'authvod' | 'svod'; + }; + original_id: number; + access_plan: { + exp: number; + }; +}; + +export type JWPSubscriptionPlanList = CommonJWPListResponse<'plans', JWPSubscriptionPlan>; + export type GetSubscriptionsResponse = { total: number; page: number; @@ -290,29 +312,6 @@ export type GetSubscriptionsResponse = { collection: JWPSubscription[]; }; -export type PaymentHistory = { - merchant_id: number; - consumer_id: number; - gateway_id: number; - transaction_token: string; - payment_tool_token: string; - trx_token: string; - payment_method_name: string; - action_type: string; - item_access_id: number; - item_id: number; - item_type: string; - item_title: string; - charged_amount: number; - currency_iso: string; - note: string; - created_at: number; -}; -export type GetPaymentHistoryResponse = { - collection: PaymentHistory[]; - total: number; -}; - export type Card = { number: number; card_name: string; diff --git a/packages/common/types/account.ts b/packages/common/types/account.ts index 4c2c47aa2..f9298895a 100644 --- a/packages/common/types/account.ts +++ b/packages/common/types/account.ts @@ -276,11 +276,6 @@ export type DeleteAccountPayload = { password: string; }; -export type SubscribeToNotificationsPayload = { - uuid: string; - onMessage: (payload: string) => void; -}; - export type GetSocialURLsPayload = { redirectUrl: string; }; @@ -329,7 +324,6 @@ export type ResetPassword = PromiseRequest; export type ChangePassword = PromiseRequest; export type ChangePasswordWithOldPassword = PromiseRequest; export type GetSocialURLs = PromiseRequest; -export type NotificationsData = PromiseRequest; export type UpdateWatchHistory = PromiseRequest; export type UpdateFavorites = PromiseRequest; export type GetWatchHistory = PromiseRequest; diff --git a/packages/common/types/checkout.ts b/packages/common/types/checkout.ts index c286f4ec6..0a7849ed4 100644 --- a/packages/common/types/checkout.ts +++ b/packages/common/types/checkout.ts @@ -151,6 +151,12 @@ export type GetOfferPayload = { offerId: string; }; +export type ChooseOfferPayload = { + offer: Offer; + successUrl: string; + cancelUrl: string; +}; + export type GetOffersPayload = { offerIds: string[] | number[]; }; @@ -368,6 +374,7 @@ export type FinalizeAdyenPaymentDetailsPayload = Omit; +export type ChooseOffer = PromiseRequest; export type GetOffer = EnvironmentServiceRequest; export type CreateOrder = EnvironmentServiceRequest; export type GetOrder = EnvironmentServiceRequest; @@ -388,4 +395,5 @@ export type DeletePaymentMethod = EnvironmentServiceRequest; export type FinalizeAdyenPaymentDetails = EnvironmentServiceRequest; export type GetDirectPostCardPayment = (cardPaymentPayload: CardPaymentData, order: Order, referrer: string, returnUrl: string) => Promise; +export type GenerateBillingPortalURL = (returnUrl: string) => Promise; export type GetEntitledPlans = PromiseRequest<{ siteId: string }, PlansResponse>; diff --git a/packages/common/types/subscription.ts b/packages/common/types/subscription.ts index 9d4b7afdc..946abea03 100644 --- a/packages/common/types/subscription.ts +++ b/packages/common/types/subscription.ts @@ -1,4 +1,5 @@ import type { CleengRequest } from './cleeng'; +import type { Plan } from './plans'; import type { EnvironmentServiceRequest, PromiseRequest } from './service'; // Subscription types @@ -163,6 +164,7 @@ type GetAllTransactionsPayload = { type GetActiveSubscriptionPayload = { customerId: string; + entitledPlan?: Plan | null; }; type GetActivePaymentResponse = PaymentDetail | null; diff --git a/packages/hooks-react/src/useBillingPortalURL.tsx b/packages/hooks-react/src/useBillingPortalURL.tsx new file mode 100644 index 000000000..e9ce0c034 --- /dev/null +++ b/packages/hooks-react/src/useBillingPortalURL.tsx @@ -0,0 +1,14 @@ +import { useMutation } from 'react-query'; +import { getModule } from '@jwp/ott-common/src/modules/container'; +import CheckoutController from '@jwp/ott-common/src/controllers/CheckoutController'; + +const useBillingPortalURL = () => { + const checkoutController = getModule(CheckoutController); + + return useMutation({ + mutationKey: 'billing-portal', + mutationFn: checkoutController.generateBillingPortalUrl, + }); +}; + +export default useBillingPortalURL; diff --git a/packages/hooks-react/src/useEntitlement.ts b/packages/hooks-react/src/useEntitlement.ts index caef0a950..eea037174 100644 --- a/packages/hooks-react/src/useEntitlement.ts +++ b/packages/hooks-react/src/useEntitlement.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useQueries } from 'react-query'; import type { PlaylistItem } from '@jwp/ott-common/types/playlist'; import type { GetEntitlementsResponse } from '@jwp/ott-common/types/checkout'; @@ -45,7 +46,7 @@ const useEntitlement: UseEntitlement = (playlistItem) => { const checkoutController = getModule(CheckoutController, false); const isPreEntitled = playlistItem && !isLocked(accessModel, !!user, !!subscription, playlistItem); - const mediaOffers = playlistItem?.mediaOffers || []; + const mediaOffers = useMemo(() => playlistItem?.mediaOffers || [], [playlistItem?.mediaOffers]); // this query is invalidated when the subscription gets reloaded const mediaEntitlementQueries = useQueries( @@ -59,7 +60,7 @@ const useEntitlement: UseEntitlement = (playlistItem) => { ); // when the user is logged out the useQueries will be disabled but could potentially return its cached data - const isMediaEntitled = !!user && mediaEntitlementQueries.some((item) => item.isSuccess && (item.data as QueryResult)?.responseData?.accessGranted); + const isMediaEntitled = !!user && !!mediaEntitlementQueries.some((item) => item.isSuccess && (item.data as QueryResult)?.responseData?.accessGranted); const isMediaEntitlementLoading = !isMediaEntitled && mediaEntitlementQueries.some((item) => item.isLoading); const isEntitled = !!playlistItem && (isPreEntitled || isMediaEntitled); diff --git a/packages/hooks-react/src/useForm.ts b/packages/hooks-react/src/useForm.ts index 3908b67ad..1bf5606e8 100644 --- a/packages/hooks-react/src/useForm.ts +++ b/packages/hooks-react/src/useForm.ts @@ -13,6 +13,7 @@ export type UseFormReturnValue = { handleBlur: UseFormBlurHandler; handleSubmit: UseFormSubmitHandler; setValue: (key: keyof T, value: T[keyof T]) => void; + setValues: (values: T | ((currValues: T) => T)) => void; setErrors: (errors: FormErrors) => void; setSubmitting: (submitting: boolean) => void; setValidationSchemaError: (error: boolean) => void; @@ -188,6 +189,7 @@ export default function useForm({ handleSubmit, submitting, setValue, + setValues, setErrors, setSubmitting, setValidationSchemaError, diff --git a/packages/theme/assets/icons/check-green.svg b/packages/theme/assets/icons/check-green.svg new file mode 100644 index 000000000..8d6f3f054 --- /dev/null +++ b/packages/theme/assets/icons/check-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/theme/assets/icons/exclamation-mark.svg b/packages/theme/assets/icons/exclamation-mark.svg new file mode 100644 index 000000000..ad05af8f2 --- /dev/null +++ b/packages/theme/assets/icons/exclamation-mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui-react/src/components/Button/Button.module.scss b/packages/ui-react/src/components/Button/Button.module.scss index 1beb0e308..a7901f879 100644 --- a/packages/ui-react/src/components/Button/Button.module.scss +++ b/packages/ui-react/src/components/Button/Button.module.scss @@ -160,3 +160,30 @@ $large-button-height: 40px; margin: auto; transform: translate(-5px, -5px); } + +.buttonGroup { + .button { + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &:not(:first-child):not(:last-child) { + border-right: none; + border-left: none; + } + + &:not(.disabled) { + &:hover, + &:focus { + z-index: 1; + transform: none; + } + } + } +} diff --git a/packages/ui-react/src/components/Button/ButtonGroup.tsx b/packages/ui-react/src/components/Button/ButtonGroup.tsx new file mode 100644 index 000000000..dca081720 --- /dev/null +++ b/packages/ui-react/src/components/Button/ButtonGroup.tsx @@ -0,0 +1,17 @@ +import type { FC, ReactNode } from 'react'; +import classNames from 'classnames'; + +import styles from './Button.module.scss'; + +type ButtonGroupProps = { + children?: ReactNode; + className?: string; +} & React.HTMLAttributes; + +const ButtonGroup: FC = ({ children, className, ...props }) => ( +
+ {children} +
+); + +export default ButtonGroup; diff --git a/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.module.scss b/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.module.scss new file mode 100644 index 000000000..cc0a44ab1 --- /dev/null +++ b/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.module.scss @@ -0,0 +1,24 @@ +.title { + margin-bottom: 16px; + font-weight: var(--body-font-weight-bold); + font-size: 24px; +} + +.tabs { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.groupedButton { + text-transform: capitalize; +} + +.offers { + display: flex; + justify-content: center; + margin: 0 -4px 24px; + padding-bottom: 8px; + overflow-x: auto; + column-gap: 12px; +} diff --git a/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.test.tsx b/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.test.tsx new file mode 100644 index 000000000..1d6e8e98c --- /dev/null +++ b/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { axe } from 'vitest-axe'; +import { fireEvent, render } from '@testing-library/react'; +import type { Offer } from '@jwp/ott-common/types/checkout'; +import monthlyOffer from '@jwp/ott-testing/fixtures/monthlyOffer.json'; +import yearlyOffer from '@jwp/ott-testing/fixtures/yearlyOffer.json'; + +import ChoosePlanForm from './ChoosePlanForm'; + +const svodOffers = [monthlyOffer, yearlyOffer] as unknown as Offer[]; + +describe('', () => { + test('renders and matches snapshot', () => { + const { container } = render( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('checks the monthly plan price correctly', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('S916977979_NL')).toBeChecked(); + }); + + test('checks the yearly plan price correctly', () => { + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId('offer-period-year')); + + expect(getByTestId('S345569153_NL')).toBeChecked(); + }); + + test('calls the onChange callback when changing the offer', () => { + const onChange = vi.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.click(getByTestId('offer-period-year')); + + fireEvent.click(getByTestId('S345569153_NL')); + + expect(onChange).toHaveBeenCalled(); + }); + + test('calls the onSubmit callback when submitting the form', () => { + const onSubmit = vi.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.submit(getByTestId('choose-offer-form')); + + expect(onSubmit).toBeCalled(); + }); + + test('WCAG 2.2 (AA) compliant', async () => { + const { container } = render( + , + ); + + expect(await axe(container, { runOnly: ['wcag21a', 'wcag21aa', 'wcag22aa'] })).toHaveNoViolations(); + }); +}); diff --git a/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.tsx b/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.tsx new file mode 100644 index 000000000..592e24713 --- /dev/null +++ b/packages/ui-react/src/components/ChoosePlanForm/ChoosePlanForm.tsx @@ -0,0 +1,92 @@ +import React, { useState, useMemo, useLayoutEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { FormErrors } from '@jwp/ott-common/types/form'; +import type { Offer, ChooseOfferFormData } from '@jwp/ott-common/types/checkout'; +import { testId } from '@jwp/ott-common/src/utils/common'; + +import Button from '../Button/Button'; +import ButtonGroup from '../Button/ButtonGroup'; +import FormFeedback from '../FormFeedback/FormFeedback'; +import DialogBackButton from '../DialogBackButton/DialogBackButton'; +import LoadingOverlay from '../LoadingOverlay/LoadingOverlay'; +import PriceBox from '../PriceBox/PriceBox'; + +import styles from './ChoosePlanForm.module.scss'; + +type OfferPeriod = 'month' | 'year'; + +type Props = { + values: ChooseOfferFormData; + errors: FormErrors; + onChange: React.ChangeEventHandler; + setValue: (key: keyof ChooseOfferFormData, value: string) => void; + onSubmit: React.FormEventHandler; + onBackButtonClickHandler?: () => void; + offers: Offer[]; + submitting: boolean; +}; + +const ChoosePlanForm: React.FC = ({ values, errors, submitting, offers, onChange, setValue, onSubmit, onBackButtonClickHandler }: Props) => { + const { t } = useTranslation('account'); + const { selectedOfferId } = values; + + const groupedOffers = useMemo( + () => offers.reduce((acc, offer) => ({ ...acc, [offer.period]: [...(acc[offer.period as OfferPeriod] || []), offer] }), {} as Record), + [offers], + ); + + const [offerFilter, setOfferFilter] = useState(() => Object.keys(groupedOffers)[0] as OfferPeriod); + + useLayoutEffect(() => { + const offerGroup = groupedOffers[offerFilter as OfferPeriod]; + + if (offerGroup) { + setValue('selectedOfferId', offerGroup[0].offerId); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offerFilter]); + + return ( +
+ {onBackButtonClickHandler ? : null} +

{t('choose_offer.title')}

+ {errors.form ? {errors.form} : null} +
+ + {Object.keys(groupedOffers).map((period) => ( +
+
+ {!offers.length ? ( +

{t('choose_offer.no_pricing_available')}

+ ) : ( + groupedOffers[offerFilter].map((offer) => ( + + )) + )} +
+ {submitting && } + + + + +
+
+ +
+
+
+ + +
+`; diff --git a/packages/ui-react/src/components/Modal/Modal.tsx b/packages/ui-react/src/components/Modal/Modal.tsx index e135c52b6..a3d19c006 100644 --- a/packages/ui-react/src/components/Modal/Modal.tsx +++ b/packages/ui-react/src/components/Modal/Modal.tsx @@ -40,6 +40,7 @@ const Modal: React.FC = ({ ...ariaAttributes }: Props) => { const modalRef = useRef() as React.MutableRefObject; + const mouseEventTargetsPairRef = useRef>({ downTarget: null, upTarget: null }); const { handleOpen, handleClose } = useModal(); @@ -89,6 +90,15 @@ const Modal: React.FC = ({ }, [openModalEvent, closeModalEvent, open]); const clickHandler: ReactEventHandler = (event) => { + const { + current: { downTarget, upTarget }, + } = mouseEventTargetsPairRef; + + // modal should not be closed if either of the 'mousedown' or 'mouseup' event targets is not the actual dialog's transparent overlay + if (downTarget !== upTarget) { + return; + } + // Backdrop click (the dialog itself) will close the modal if (event.target === modalRef.current) { onClose?.(); @@ -102,6 +112,12 @@ const Modal: React.FC = ({ onKeyDown={keyDownHandler} onClose={closeHandler} onClick={clickHandler} + onMouseDown={(e) => { + mouseEventTargetsPairRef.current.downTarget = e.target as HTMLElement; + }} + onMouseUp={(e) => { + mouseEventTargetsPairRef.current.upTarget = e.target as HTMLElement; + }} ref={modalRef} role={role} {...ariaAttributes} diff --git a/packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss index 557d8f05e..3d86ffde8 100644 --- a/packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss +++ b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.module.scss @@ -1,11 +1,42 @@ @use '@jwp/ott-ui-react/src/styles/variables'; @use '@jwp/ott-ui-react/src/styles/theme'; -.title { - font-weight: var(--body-font-weight-bold); - font-size: 24px; +.topContainer { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 40px; + row-gap: 32px; + + .circleHug { + display: flex; + justify-content: center; + align-items: center; + width: 80px; + height: 80px; + border: 2px solid variables.$orange; + border-radius: 50%; + } +} + +.innerContainer { + display: flex; + flex-direction: column; + align-items: center; + row-gap: 8px; + + .title { + font-weight: var(--body-font-weight-bold); + font-size: 24px; + } + + .message { + margin: 0; + font-size: 16px; + text-align: center; + } } -.message { - font-size: 16px; +.buttonContainer { + align-self: stretch; } diff --git a/packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx index db17d9d08..425a2436d 100644 --- a/packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx +++ b/packages/ui-react/src/components/PaymentFailed/PaymentFailed.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import ExclamationMarkSVG from '@jwp/ott-theme/assets/icons/exclamation-mark.svg?react'; import Button from '../Button/Button'; @@ -13,11 +14,17 @@ type Props = { const PaymentFailed: React.FC = ({ type, message, onCloseButtonClick }: Props) => { const { t } = useTranslation('account'); + return ( -
-

{type === 'cancelled' ? t('checkout.payment_cancelled') : t('checkout.payment_error')}

-

{type === 'cancelled' ? t('checkout.payment_cancelled_message') : message}

-
+
+
+ +
+
+

{type === 'cancelled' ? t('checkout.payment_cancelled') : t('checkout.payment_error')}

+

{type === 'cancelled' ? t('checkout.payment_cancelled_message') : message}

+
+
diff --git a/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap b/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap index 39895c02c..0bc11331a 100644 --- a/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap +++ b/packages/ui-react/src/components/PaymentFailed/__snapshots__/PaymentFailed.test.tsx.snap @@ -2,18 +2,45 @@ exports[` > renders and matches snapshot 1`] = `
-
-

+
- checkout.payment_error -

-

+ + +

+
+

+ checkout.payment_error +

+

+ Error message +

+
+
- Error message -

-
+
+ ); +}; + +export default PaymentSuccessful; diff --git a/packages/ui-react/src/components/PaymentSuccessful/__snapshots__/PaymentSuccessful.test.tsx.snap b/packages/ui-react/src/components/PaymentSuccessful/__snapshots__/PaymentSuccessful.test.tsx.snap new file mode 100644 index 000000000..da88c4471 --- /dev/null +++ b/packages/ui-react/src/components/PaymentSuccessful/__snapshots__/PaymentSuccessful.test.tsx.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders and matches snapshot 1`] = ` +
+
+
+ + + +
+
+

+ checkout.payment_successful_title +

+

+ checkout.payment_successful_description +

+
+
+ +
+
+
+`; diff --git a/packages/ui-react/src/components/PriceBox/PriceBox.module.scss b/packages/ui-react/src/components/PriceBox/PriceBox.module.scss new file mode 100644 index 000000000..dfa9839dd --- /dev/null +++ b/packages/ui-react/src/components/PriceBox/PriceBox.module.scss @@ -0,0 +1,93 @@ +@use '@jwp/ott-ui-react/src/styles/variables'; +@use '@jwp/ott-ui-react/src/styles/theme'; +@use '@jwp/ott-ui-react/src/styles/accessibility'; +@use '@jwp/ott-ui-react/src/styles/mixins/responsive'; + +.offer { + width: 190px; + + &:focus-within .label { + @include accessibility.accessibleOutlineContrast; + transform: scale(1.03); + } +} + +.radio { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + white-space: nowrap; + clip: rect(0 0 0 0); + clip-path: inset(50%); + + &:checked + .label { + color: variables.$black; + background-color: variables.$white; + border-color: variables.$white; + } +} + +.label { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + background-color: rgba(variables.$black, 0.34); + border: 1px solid rgba(variables.$white, 0.34); + border-radius: 4px; + cursor: pointer; + transition: border 0.2s ease, background 0.2s ease, transform 0.2s ease-out; +} + +.offerTitle { + font-weight: var(--body-font-weight-bold); + font-size: 20px; + text-align: center; +} + +.offerDivider { + width: 100%; + border: none; + border-bottom: 1px solid currentColor; + opacity: 0.54; +} + +.offerBenefits { + margin-bottom: 16px; + padding: 0; + + > li { + display: flex; + align-items: center; + margin-bottom: 4px; + padding: 4px 0; + + > svg { + flex-shrink: 0; + margin-right: 4px; + fill: variables.$green; + } + + @include responsive.mobile-only() { + font-size: 14px; + } + } +} + +.fill { + flex: 1; +} + +.offerPrice { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: baseline; + font-size: 32px; + + > small { + margin-left: 4px; + font-size: 12px; + } +} \ No newline at end of file diff --git a/packages/ui-react/src/components/PriceBox/PriceBox.tsx b/packages/ui-react/src/components/PriceBox/PriceBox.tsx new file mode 100644 index 000000000..0f7ce64c1 --- /dev/null +++ b/packages/ui-react/src/components/PriceBox/PriceBox.tsx @@ -0,0 +1,126 @@ +import React, { type FC, type SVGProps } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Offer } from '@jwp/ott-common/types/checkout'; +import { getOfferPrice, isSVODOffer } from '@jwp/ott-common/src/utils/offers'; +import { testId } from '@jwp/ott-common/src/utils/common'; +import CheckCircle from '@jwp/ott-theme/assets/icons/check_circle.svg?react'; + +import Icon from '../Icon/Icon'; + +import styles from './PriceBox.module.scss'; + +type ListItemProps = { + text: string; + icon: FC>; +}; + +const ListItem: FC = ({ text, icon }) => ( +
  • + + {text} + . +
  • +); + +type OptionProps = { + title: string; + periodString?: string; + secondBenefit?: string; + offer: Offer; + onChange: React.ChangeEventHandler; + selected: boolean; +}; + +const Option: FC = ({ title, periodString, secondBenefit, offer, onChange, selected }) => { + const { t } = useTranslation('account'); + + const getFreeTrialText = (offer: Offer) => { + if (offer.freeDays) { + return t('choose_offer.benefits.first_days_free', { count: offer.freeDays }); + } else if (offer.freePeriods) { + // t('periods.day', { count }) + // t('periods.week', { count }) + // t('periods.month', { count }) + // t('periods.year', { count }) + const period = t(`periods.${offer.period}`, { count: offer.freePeriods }); + + return t('choose_offer.benefits.first_periods_free', { count: offer.freePeriods, period }); + } + + return null; + }; + + return ( +
    + +
    +
    + ); +}; + +type Props = { + offer: Offer; + selected: boolean; + onChange: React.ChangeEventHandler; +}; + +const PriceBox: React.FC = ({ offer, selected, onChange }) => { + const { t } = useTranslation('account'); + + if (isSVODOffer(offer)) { + const isMonthly = offer.period === 'month'; + + return ( +