diff --git a/src/components/banners/TopBanner.tsx b/src/components/banners/TopBanner.tsx index 5d0e1cc13..38cb11075 100644 --- a/src/components/banners/TopBanner.tsx +++ b/src/components/banners/TopBanner.tsx @@ -52,15 +52,10 @@ const TopBanner = ({ isBannerFixed, lang: pageLang }: TopBannerProps) => {

{textContent.title.normalText}

- +

{textContent.title.cta}

- +
diff --git a/src/hooks/usePricing.ts b/src/hooks/usePricing.ts index 12f7c3fde..e21530fca 100644 --- a/src/hooks/usePricing.ts +++ b/src/hooks/usePricing.ts @@ -1,9 +1,11 @@ -/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { currencyService } from '@/services/currency.service'; import { ProductsDataProps, stripeService } from '@/services/stripe.service'; import { useEffect, useReducer } from 'react'; import { PromoCodeName, PromoCodeProps } from '@/lib/types'; +import { notificationService } from '@/components/Snackbar'; + +type LifetimeCoupons = Record; type UsePricingOptions = { couponCode?: PromoCodeName; @@ -22,140 +24,155 @@ interface UseStripeProductsAndCurrencyResponse { businessCoupon?: PromoCodeProps; lifetimeCoupons?: any; products?: ProductsDataProps; + pricesError: boolean; +} + +interface PricesData { + products?: ProductsDataProps; + currency: string; + currencyValue: string; + lifetimeCoupons?: any; + pricesError: boolean; +} + +interface CouponsData { + coupon?: PromoCodeProps; + lifetimeCoupon?: PromoCodeProps; + businessCoupon?: PromoCodeProps; } -type ActionType = - | { type: 'SET_PRODUCTS'; payload: ProductsDataProps | undefined } - | { type: 'SET_LOADING_CARDS'; payload: boolean } - | { type: 'SET_CURRENCY'; payload: string } - | { type: 'SET_CURRENCY_VALUE'; payload: string } - | { type: 'SET_COUPON'; payload: PromoCodeProps | undefined } - | { type: 'SET_COUPON_LIFETIME'; payload: PromoCodeProps | undefined } - | { type: 'SET_BUSINESS_COUPON'; payload: PromoCodeProps | undefined } - | { type: 'SET_LIFETIME_COUPONS'; payload: any | undefined }; - -const reducer = (state: any, action: ActionType) => { +interface PricingState extends PricesData, CouponsData { + pricesLoaded: boolean; + couponsLoaded: boolean; +} + +type ActionType = { type: 'SET_PRICES'; payload: PricesData } | { type: 'SET_COUPONS'; payload: CouponsData }; + +const DEFAULT_CURRENCY = { currency: '€', currencyValue: 'eur' }; +const PRICES_ERROR_MESSAGE = 'We could not load the prices. Please reload the page and try again.'; + +const initialState: PricingState = { + ...DEFAULT_CURRENCY, + pricesError: false, + pricesLoaded: false, + couponsLoaded: false, +}; + +const reducer = (state: PricingState, action: ActionType): PricingState => { switch (action.type) { - case 'SET_PRODUCTS': - return { ...state, products: action.payload }; - case 'SET_LOADING_CARDS': - return { ...state, loadingCards: action.payload }; - case 'SET_CURRENCY': - return { ...state, currency: action.payload }; - case 'SET_CURRENCY_VALUE': - return { ...state, currencyValue: action.payload }; - case 'SET_COUPON': - return { ...state, coupon: action.payload }; - case 'SET_COUPON_LIFETIME': - return { ...state, lifetimeCoupon: action.payload }; - case 'SET_BUSINESS_COUPON': - return { ...state, businessCoupon: action.payload }; - case 'SET_LIFETIME_COUPONS': - return { ...state, lifetimeCoupons: action.payload }; + case 'SET_PRICES': + return { ...state, ...action.payload, pricesLoaded: true }; + case 'SET_COUPONS': + return { ...state, ...action.payload, couponsLoaded: true }; default: return state; } }; +const loadPrices = async (currencySpecified?: string): Promise> => { + try { + const { currency, currencyValue } = await currencyService.filterCurrencyByCountry(currencySpecified); + const products = await stripeService.getPrices(currencyValue); + + if (!products) throw new Error(`No prices available for ${currencyValue}`); + + return { products, currency, currencyValue }; + } catch (err) { + console.warn('Primary price fetch failed, falling back to EUR:', err); + + const products = await stripeService.getPrices(DEFAULT_CURRENCY.currencyValue); + + if (!products) throw new Error(`No prices available for ${DEFAULT_CURRENCY.currencyValue}`); + + return { products, ...DEFAULT_CURRENCY }; + } +}; + +const loadLifetimeCoupons = async (): Promise => { + return stripeService.getLifetimeCoupons(); +}; + +const loadCoupon = async (couponName?: PromoCodeName): Promise => { + if (!couponName) return undefined; + + try { + return await stripeService.getCoupon(couponName); + } catch (error) { + console.warn(`Coupon ${couponName} could not be loaded:`, error); + return undefined; + } +}; + function usePricing(options: UsePricingOptions = {}): UseStripeProductsAndCurrencyResponse { const { couponCode, couponCodeForLifetime, couponCodeForBusiness, currencySpecified, fetchLifetimeCoupons } = options; - const initialState = { - products: undefined, - loadingCards: true, - currency: '€', - coupon: undefined, - lifetimeCoupon: undefined, - businessCoupon: undefined, - lifetimeCoupons: undefined, - currencyValue: 'eur', - }; const [state, dispatch] = useReducer(reducer, initialState); - const fetchData = async () => { - try { - const res = await currencyService.filterCurrencyByCountry(currencySpecified); - const prices = await stripeService.getPrices(res.currencyValue); - - dispatch({ type: 'SET_PRODUCTS', payload: prices }); - dispatch({ type: 'SET_CURRENCY', payload: res.currency }); - dispatch({ type: 'SET_CURRENCY_VALUE', payload: res.currencyValue }); - } catch (err) { - console.warn('Primary price fetch failed, falling back to EUR:', err); - - try { - const prices = await stripeService.getPrices('eur'); - dispatch({ type: 'SET_PRODUCTS', payload: prices }); - } catch (error) { - console.error('Critical error: Failed to fetch fallback prices:', error); - } + useEffect(() => { + let cancelled = false; - dispatch({ type: 'SET_CURRENCY', payload: '€' }); - dispatch({ type: 'SET_CURRENCY_VALUE', payload: 'eur' }); - } + Promise.allSettled([ + loadPrices(currencySpecified), + fetchLifetimeCoupons ? loadLifetimeCoupons() : Promise.resolve(undefined), + ]).then(([pricesResult, lifetimeCouponsResult]) => { + if (cancelled) return; - if (fetchLifetimeCoupons) { - try { - const lifetimeCoupons = await stripeService.getLifetimeCoupons(); - dispatch({ type: 'SET_LIFETIME_COUPONS', payload: lifetimeCoupons }); - } catch (error) { - console.error('Error fetching lifetime coupons:', error); - } - } - - if (couponCode) { - try { - const coupon = await stripeService.getCoupon(couponCode); - dispatch({ type: 'SET_COUPON', payload: coupon }); - } catch (err) { - console.error('Error fetching coupon:', err); + const pricesError = pricesResult.status === 'rejected'; + + if (pricesError) { + console.error('Error loading prices:', pricesResult.reason); + notificationService.openErrorToast(PRICES_ERROR_MESSAGE); } - } - if (couponCodeForLifetime) { - const lifetimeCoupon = await stripeService.getCoupon(couponCodeForLifetime); + if (lifetimeCouponsResult.status === 'rejected') { + console.error('Error fetching lifetime coupons:', lifetimeCouponsResult.reason); + } - dispatch({ type: 'SET_COUPON_LIFETIME', payload: lifetimeCoupon }); - } + const prices = pricesResult.status === 'fulfilled' ? pricesResult.value : DEFAULT_CURRENCY; + const lifetimeCoupons = lifetimeCouponsResult.status === 'fulfilled' ? lifetimeCouponsResult.value : undefined; - if (couponCodeForBusiness) { - try { - const businessCoupon = await stripeService.getCoupon(couponCodeForBusiness); - dispatch({ type: 'SET_BUSINESS_COUPON', payload: businessCoupon }); - } catch (err) { - console.warn('Invalid or expired business coupon ignored', err); - } - } - }; + dispatch({ type: 'SET_PRICES', payload: { ...prices, lifetimeCoupons, pricesError } }); + }).catch((err) => console.error('Error loading prices:', err)); - useEffect(() => { - fetchData().then(() => { - dispatch({ type: 'SET_LOADING_CARDS', payload: false }); - }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - if (couponCode) { - stripeService - .getCoupon(couponCode) - .then((coupon) => { - dispatch({ type: 'SET_COUPON', payload: coupon }); - }) - .catch(() => { - // - }); - } - }, [couponCode]); + let cancelled = false; + + Promise.allSettled([ + loadCoupon(couponCode), + loadCoupon(couponCodeForLifetime), + loadCoupon(couponCodeForBusiness), + ]).then((results) => { + if (cancelled) return; + + const [coupon, lifetimeCoupon, businessCoupon] = results.map((result) => { + if (result.status === 'rejected') console.error('Error loading coupons:', result.reason); + return result.status === 'fulfilled' ? result.value : undefined; + }); + + dispatch({ type: 'SET_COUPONS', payload: { coupon, lifetimeCoupon, businessCoupon } }); + }).catch((err) => console.error('Error loading coupons:', err)); + + return () => { + cancelled = true; + }; + }, [couponCode, couponCodeForLifetime, couponCodeForBusiness]); return { products: state.products, - loadingCards: state.loadingCards, + loadingCards: !state.pricesLoaded || !state.couponsLoaded, currency: state.currency, currencyValue: state.currencyValue, coupon: state.coupon, businessCoupon: state.businessCoupon, lifetimeCoupon: state.lifetimeCoupon, lifetimeCoupons: state.lifetimeCoupons, + pricesError: state.pricesError, }; } diff --git a/src/lib/helpers/campaigns.ts b/src/lib/helpers/campaigns.ts index 2b3bc92d4..49ac48d30 100644 --- a/src/lib/helpers/campaigns.ts +++ b/src/lib/helpers/campaigns.ts @@ -21,7 +21,46 @@ export interface ActiveCampaigns { earlyReleaseCampaign?: Campaign; } -const CAMPAIGN_EXCEPTIONS: ReadonlySet = new Set([]); +const CAMPAIGN_EXCEPTIONS: ReadonlySet = new Set([ + PromoCodeName.blackmonth87, + PromoCodeName.blackmonthcn87, + PromoCodeName.cloudwardsbm87, + PromoCodeName.privacybm87, + PromoCodeName.heisebm87, + PromoCodeName.xatakabm87, + PromoCodeName.clubicbm87, + PromoCodeName.tdesbm87, + PromoCodeName.tdptbm87, + PromoCodeName.tditbm87, + PromoCodeName.gpesbm87, + PromoCodeName.gpitbm87, + PromoCodeName.chollobm87, + + PromoCodeName.blackfriday90, + PromoCodeName.blackfridaycn90, + PromoCodeName.cloudwardsbf90, + PromoCodeName.privacybf90, + PromoCodeName.heisebf90, + PromoCodeName.xatakabf90, + PromoCodeName.clubicbf90, + PromoCodeName.tdesbf90, + PromoCodeName.tdptbf90, + PromoCodeName.tditbf90, + PromoCodeName.gpesbf90, + PromoCodeName.gpitbf90, + PromoCodeName.chollobf90, + + PromoCodeName.cyberweek90, + PromoCodeName.cyberweekcn90, + PromoCodeName.cloudwardscm90, + PromoCodeName.privacycm90, + PromoCodeName.heisecm90, + PromoCodeName.xatakacm90, + PromoCodeName.clubiccm90, + PromoCodeName.gpescw90, + PromoCodeName.gpitcw90, + PromoCodeName.chollocw90, +]); export const EARLY_RELEASE_CAMPAIGNS: ReadonlySet = new Set([ Campaign.BlackFriday, @@ -36,8 +75,10 @@ export const isCampaign = (value: unknown): value is Campaign => export const hasEarlyRelease = (name: PromoCodeName): boolean => EARLY_RELEASE_CODES.has(name); +export const isCampaignException = (name: PromoCodeName): boolean => CAMPAIGN_EXCEPTIONS.has(name); + export const applyCampaign = (name: PromoCodeName, campaign?: Campaign): ResolvedPromoCode => { - if (!campaign || CAMPAIGN_EXCEPTIONS.has(name)) return name; + if (!campaign || isCampaignException(name)) return name; return `${name}${CAMPAIGN_SUFFIXES[campaign]}`; }; diff --git a/src/pages/api/stripe/get_coupons.ts b/src/pages/api/stripe/get_coupons.ts index bab919aa6..e2d6a035d 100644 --- a/src/pages/api/stripe/get_coupons.ts +++ b/src/pages/api/stripe/get_coupons.ts @@ -11,15 +11,21 @@ interface PromotionCode { export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise { if (req.method === 'OPTIONS') { res.status(200).json({}); + return; } - if (req.method === 'GET') { - const couponName = req.query.couponName; - if (!couponName) { - res.status(404).end(); - return; - } + if (req.method !== 'GET') { + res.status(405).end(); + return; + } + + const couponName = req.query.couponName; + if (!couponName) { + res.status(404).end(); + return; + } + try { const { data: promoCodeData } = await axios.get(`${process.env.NEXT_PUBLIC_PAYMENTS_API}/promo-code-info`, { params: { promotionCode: couponName, @@ -28,7 +34,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) //Return the coupon with its data res.status(200).send(promoCodeData); - } else { - res.status(405).end(); // Method Not Allowed + } catch (err) { + const status = axios.isAxiosError(err) && err.response ? err.response.status : 502; + res.status(status).json({ message: 'Coupon not available' }); } } diff --git a/src/services/currency.service.ts b/src/services/currency.service.ts index bc898db7d..607be13ea 100644 --- a/src/services/currency.service.ts +++ b/src/services/currency.service.ts @@ -46,9 +46,15 @@ const objectStorageCurrencyValue = { brl: 'eur', }; -const getCountry = async () => { - const { data } = await axios.get(`${process.env.NEXT_PUBLIC_COUNTRY_API_URL}`); - return data; +let countryRequest: Promise<{ country: string }> | undefined; + +const getCountry = () => { + if (!countryRequest) { + countryRequest = axios.get(`${process.env.NEXT_PUBLIC_COUNTRY_API_URL}`).then(({ data }) => data); + countryRequest.catch(() => (countryRequest = undefined)); + } + + return countryRequest; }; const filterCurrencyByCountry = async (currencySpecified?: string) => { diff --git a/src/services/stripe.service.ts b/src/services/stripe.service.ts index 253dcb9a0..3bd4394ae 100644 --- a/src/services/stripe.service.ts +++ b/src/services/stripe.service.ts @@ -3,7 +3,14 @@ import bytes from 'bytes'; import { currency as CURRENCY_MAP, currencyService } from './currency.service'; import { checkout, checkoutForPcComponentes } from '@/lib/auth'; import { PromoCodeName, PromoCodeProps } from '@/lib/types'; -import { ActiveCampaigns, ResolvedPromoCode, applyCampaign, hasEarlyRelease, isCampaign } from '@/lib/helpers/campaigns'; +import { + ActiveCampaigns, + ResolvedPromoCode, + applyCampaign, + hasEarlyRelease, + isCampaign, + isCampaignException, +} from '@/lib/helpers/campaigns'; import { getGclidFromURL, saveGclidToCookie } from '@/lib/cookies'; import { analyticsService } from './ga.services'; @@ -51,13 +58,19 @@ export interface ProductsDataProps { }; } -async function getPrices(currencySpecified?: string) { +const pricesRequests = new Map>(); + +async function getPrices(currencySpecified?: string): Promise { const currency = await getCurrency(currencySpecified); - const data = await fetchProductData(currency); - if (data) { - const transformedData = transformProductData(data.individuals, data.business); - return transformedData; + let request = pricesRequests.get(currency); + + if (!request) { + request = fetchProductData(currency).then((data) => (data ? transformProductData(data.individuals, data.business) : undefined)); + pricesRequests.set(currency, request); + request.then((prices) => prices ?? pricesRequests.delete(currency)).catch(() => pricesRequests.delete(currency)); } + + return request; } async function getCurrency(currencySpecified?: string): Promise { @@ -123,8 +136,6 @@ function transformProductData(individualsData: ProductValue[], businessData: Pro transform(individualsData, 'individuals'); transform(businessData, 'business'); - console.log(transformedData); - return transformedData; } @@ -155,7 +166,7 @@ function fetchActiveCampaigns(): Promise { return activeCampaigns; } -async function requestCoupon(couponName: ResolvedPromoCode): Promise { +async function fetchCoupon(couponName: ResolvedPromoCode): Promise { try { const res = await axios.get(`${window.origin}/api/stripe/get_coupons`, { params: { @@ -175,7 +186,25 @@ async function requestCoupon(couponName: ResolvedPromoCode): Promise>(); + +function requestCoupon(couponName: ResolvedPromoCode): Promise { + let request = couponRequests.get(couponName); + + if (!request) { + request = fetchCoupon(couponName); + couponRequests.set(couponName, request); + request.catch(() => couponRequests.delete(couponName)); + } + + return request; +} + async function getCoupon(couponName: PromoCodeName): Promise { + if (isCampaignException(couponName)) { + return requestCoupon(couponName); + } + const { campaign, earlyReleaseCampaign } = await fetchActiveCampaigns(); const resolvedName = applyCampaign(couponName, hasEarlyRelease(couponName) ? earlyReleaseCampaign : campaign);