diff --git a/src/components/CreditCardCVCField/CreditCardCVCField.tsx b/src/components/CreditCardCVCField/CreditCardCVCField.tsx index fc7089c98..f9053d6e5 100644 --- a/src/components/CreditCardCVCField/CreditCardCVCField.tsx +++ b/src/components/CreditCardCVCField/CreditCardCVCField.tsx @@ -27,6 +27,7 @@ const CreditCardCVCField: React.FC = ({ value, onChange, error, ...props type="text" value={value} onChange={formatCVC} + pattern="\d*" placeholder="cvc/cvv" required /> diff --git a/src/components/CreditCardCVCField/__snapshots__/CreditCardCVCField.test.tsx.snap b/src/components/CreditCardCVCField/__snapshots__/CreditCardCVCField.test.tsx.snap index 3a2688e3a..0c301e9ae 100644 --- a/src/components/CreditCardCVCField/__snapshots__/CreditCardCVCField.test.tsx.snap +++ b/src/components/CreditCardCVCField/__snapshots__/CreditCardCVCField.test.tsx.snap @@ -18,6 +18,7 @@ exports[` > renders and matches snapshot 1`] = ` class="_input_e16c1b" id="text-field_1235_cardCVC" name="cardCVC" + pattern="\\\\d*" placeholder="cvc/cvv" required="" type="text" diff --git a/src/components/CreditCardExpiryField/CreditCardExpiryField.tsx b/src/components/CreditCardExpiryField/CreditCardExpiryField.tsx index 047f4759f..b85439577 100644 --- a/src/components/CreditCardExpiryField/CreditCardExpiryField.tsx +++ b/src/components/CreditCardExpiryField/CreditCardExpiryField.tsx @@ -42,6 +42,7 @@ const CreditCardExpiryField: React.FC = ({ value, onChange, error, ...pro helperText={error ? error : null} onChange={formatExpirationDate} type="text" + pattern="\d*" placeholder="dd/mm" required /> diff --git a/src/components/CreditCardExpiryField/__snapshots__/CreditCardExpiryField.test.tsx.snap b/src/components/CreditCardExpiryField/__snapshots__/CreditCardExpiryField.test.tsx.snap index b818379bf..85895adea 100644 --- a/src/components/CreditCardExpiryField/__snapshots__/CreditCardExpiryField.test.tsx.snap +++ b/src/components/CreditCardExpiryField/__snapshots__/CreditCardExpiryField.test.tsx.snap @@ -18,6 +18,7 @@ exports[` > renders and matches snapshot 1`] = ` class="_input_e16c1b" id="text-field_1235_cardExpiry" name="cardExpiry" + pattern="\\\\d*" placeholder="dd/mm" required="" type="text" diff --git a/src/components/Root/Root.tsx b/src/components/Root/Root.tsx index 2664e8a05..8d2472900 100644 --- a/src/components/Root/Root.tsx +++ b/src/components/Root/Root.tsx @@ -13,10 +13,11 @@ import { cleanupQueryParams, getConfigSource } from '#src/utils/configOverride'; import { loadAndValidateConfig } from '#src/utils/configLoad'; import { initSettings } from '#src/stores/SettingsController'; import AppRoutes from '#src/containers/AppRoutes/AppRoutes'; +import useNotifications from '#src/hooks/useNotifications'; const Root: FC = () => { const { t } = useTranslation('error'); - + useNotifications(); const settingsQuery = useQuery('settings-init', initSettings, { enabled: true, retry: 1, diff --git a/src/containers/AccountModal/forms/Checkout.tsx b/src/containers/AccountModal/forms/Checkout.tsx index 5e8123235..e3930da70 100644 --- a/src/containers/AccountModal/forms/Checkout.tsx +++ b/src/containers/AccountModal/forms/Checkout.tsx @@ -19,12 +19,11 @@ import { useCheckoutStore } from '#src/stores/CheckoutStore'; import { adyenPayment, cardPayment, createOrder, getPaymentMethods, paymentWithoutDetails, paypalPayment, updateOrder } from '#src/stores/CheckoutController'; import { reloadActiveSubscription } from '#src/stores/AccountController'; import PaymentForm from '#src/components/PaymentForm/PaymentForm'; -import { useNotificationStore } from '#src/stores/NotificationStore'; +import useCheckAccess from '#src/hooks/useCheckAccess'; const Checkout = () => { const location = useLocation(); const { cleengSandbox } = useConfigStore((state) => state.getCleengData()); - const notification = useNotificationStore((state) => state); const { t } = useTranslation('account'); const navigate = useNavigate(); const [paymentError, setPaymentError] = useState(undefined); @@ -32,6 +31,7 @@ const Checkout = () => { const [couponFormOpen, setCouponFormOpen] = useState(false); const [couponCodeApplied, setCouponCodeApplied] = useState(false); const [paymentMethodId, setPaymentMethodId] = useState(undefined); + const { intervalCheckAccess } = useCheckAccess(); const { order, offer, paymentMethods, setOrder } = useCheckoutStore( ({ order, offer, paymentMethods, setOrder }) => ({ @@ -53,6 +53,7 @@ const Checkout = () => { async () => { setUpdatingOrder(true); await cardPayment(paymentDataForm.values); + intervalCheckAccess({ interval: 5000 }); }, object().shape({ cardNumber: string().test('card number validation', t('checkout.invalid_card_number'), (value) => { @@ -90,23 +91,6 @@ const Checkout = () => { paymentDataForm.setSubmitting(false); }; - useEffect(() => { - if (notification.type?.endsWith('.failed')) { - navigate( - addQueryParams(window.location.href, { - u: 'paypal-error', - message: (notification.resource as Error)?.message, - }), - ); - } else if (notification.type === 'access.granted') { - navigate(addQueryParam(location, 'u', 'welcome')); - } - - return () => { - useNotificationStore.setState({ type: null, resource: null }); - }; - }, [notification, navigate, location]); - useEffect(() => { if (paymentDataForm.values.cardExpiry) { const expiry = Payment.fns.cardExpiryVal(paymentDataForm.values.cardExpiry); diff --git a/src/hooks/useCheckAccess.ts b/src/hooks/useCheckAccess.ts new file mode 100644 index 000000000..9edfa81c3 --- /dev/null +++ b/src/hooks/useCheckAccess.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react'; +import { useLocation, useNavigate } from 'react-router'; + +import { addQueryParam } from '#src/utils/location'; +import { checkEntitlements, reloadActiveSubscription } from '#src/stores/AccountController'; + +type intervalCheckAccessPayload = { + interval?: number; + iterations?: number; + offerId?: string; +}; +const useCheckAccess = () => { + const intervalRef = useRef(); + const navigate = useNavigate(); + const location = useLocation(); + + const intervalCheckAccess = ({ interval = 3000, iterations = 5, offerId }: intervalCheckAccessPayload) => { + intervalRef.current = window.setInterval(async () => { + if (!offerId) { + offerId = '115047'; + } + const hasAccess = await checkEntitlements(offerId); + + if (hasAccess) { + await reloadActiveSubscription(); + navigate(addQueryParam(location, 'u', 'welcome')); + } else if (--iterations === 0) { + window.clearInterval(intervalRef.current); + } + }, interval); + }; + + useEffect(() => { + return () => { + window.clearInterval(intervalRef.current); + }; + }, []); + + return { intervalCheckAccess }; +}; + +export default useCheckAccess; diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 000000000..344b1baae --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router'; + +import { useNotificationStore } from '#src/stores/NotificationStore'; +import { addQueryParams } from '#src/utils/formatting'; +import { addQueryParam } from '#src/utils/location'; + +const useNotifications = () => { + const navigate = useNavigate(); + const location = useLocation(); + const notification = useNotificationStore((state) => state); + + useEffect(() => { + if (notification.type?.endsWith('.failed')) { + navigate( + addQueryParams(window.location.href, { + u: 'paypal-error', + message: (notification.resource as Error)?.message, + }), + ); + } else if (notification.type === 'access.granted') { + navigate(addQueryParam(location, 'u', 'welcome')); + } + + //eslint-disable-next-line + }, [notification]); + + return notification; +}; + +export default useNotifications; diff --git a/src/hooks/useOffers.ts b/src/hooks/useOffers.ts index 758f13f45..17a4778df 100644 --- a/src/hooks/useOffers.ts +++ b/src/hooks/useOffers.ts @@ -16,7 +16,6 @@ const useOffers = () => { accessModel, } = useConfigStore(({ getCleengData, accessModel }) => ({ cleeng: getCleengData(), accessModel }), shallow); const { checkoutService, integration } = useClientIntegration(); - const { requestedMediaOffers } = useCheckoutStore(({ requestedMediaOffers }) => ({ requestedMediaOffers }), shallow); const hasPremierOffer = (requestedMediaOffers || []).some((offer) => offer.premier); const [offerType, setOfferType] = useState(accessModel === 'SVOD' ? 'svod' : 'tvod'); diff --git a/src/services/inplayer.checkout.service.ts b/src/services/inplayer.checkout.service.ts index 533a23b45..0e44afe61 100644 --- a/src/services/inplayer.checkout.service.ts +++ b/src/services/inplayer.checkout.service.ts @@ -4,6 +4,7 @@ import type { CardPaymentData, CreateOrder, CreateOrderPayload, + GetEntitlements, GetOffers, GetPaymentMethods, Offer, @@ -173,6 +174,21 @@ export const cardPayment = async (cardPaymentPayload: CardPaymentData, order: Or } }; +export const getEntitlements: GetEntitlements = async ({ offerId }) => { + try { + const response = await InPlayer.Asset.checkAccessForAsset(parseInt(offerId)); + return { + errors: [], + responseData: { + accessGranted: true, + expiresAt: response.data.expires_at, + }, + }; + } catch { + throw new Error('no access for this resource'); + } +}; + const processOffer = (offer: GetAccessFee): Offer => { return { id: offer.id, diff --git a/src/stores/AccountController.ts b/src/stores/AccountController.ts index 1eeb09f46..c46adc430 100644 --- a/src/stores/AccountController.ts +++ b/src/stores/AccountController.ts @@ -35,9 +35,11 @@ let refreshTimeout: number; // actions needed when listening to InPlayer web socket notifications const notifications: Record Promise> = { - [NotificationsTypes.ACCESS_GRANTED]: reloadActiveSubscription, + [NotificationsTypes.ACCESS_GRANTED]: async () => { + reloadActiveSubscription({ delay: 2000 }); + }, [NotificationsTypes.ACCESS_REVOKED]: reloadActiveSubscription, - [NotificationsTypes.SUBSCRIBE_SUCCESS]: reloadActiveSubscription, + [NotificationsTypes.SUBSCRIBE_SUCCESS]: async () => true, [NotificationsTypes.SUBSCRIBE_FAILED]: async () => true, [NotificationsTypes.PAYMENT_CARD_SUCCESS]: reloadActiveSubscription, [NotificationsTypes.PAYMENT_CARD_FAILED]: async () => true, @@ -396,15 +398,19 @@ export const updateSubscription = async (status: 'active' | 'cancelled') => { }); }; +export async function checkEntitlements(offerId: string): Promise { + return await useAccount(async ({ auth: { jwt } }) => { + return await useService(async ({ checkoutService, sandbox }) => { + const response = await checkoutService.getEntitlements({ offerId }, sandbox, jwt); + return !!response; + }); + }); +} + export async function reloadActiveSubscription({ delay }: { delay: number } = { delay: 0 }): Promise { useAccountStore.setState({ loading: true }); return await useAccount(async ({ customerId, auth: { jwt } }) => { return await useService(async ({ subscriptionService, sandbox, config }) => { - const [activeSubscription, transactions, activePayment] = await Promise.all([ - subscriptionService.getActiveSubscription({ sandbox, customerId, jwt, config }), - subscriptionService.getAllTransactions({ sandbox, customerId, jwt }), - subscriptionService.getActivePayment({ sandbox, customerId, jwt }), - ]); // 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) { @@ -415,6 +421,11 @@ export async function reloadActiveSubscription({ delay }: { delay: number } = { }); } + const [activeSubscription, transactions, activePayment] = await Promise.all([ + subscriptionService.getActiveSubscription({ sandbox, customerId, jwt, config }), + subscriptionService.getAllTransactions({ sandbox, customerId, jwt }), + subscriptionService.getActivePayment({ sandbox, customerId, jwt }), + ]); // this invalidates all entitlements caches which makes the useEntitlement hook to verify the entitlements. await queryClient.invalidateQueries('entitlements');