@@ -4,6 +4,7 @@ import type { PromoData } from "../../assets/data/promotions";
44import useBrowserOS from "../../hooks/useDetectOS" ;
55import "../../styles/icons.css" ;
66import { trackEvent } from "../../utils/matomo" ;
7+ import { useEffect , useMemo , useRef , useState } from "react" ;
78
89const DEFAULT_PROMO_STYLES : NonNullable < PromoData [ "styles" ] > = {
910 container : "bg-yellow-300" ,
@@ -12,14 +13,16 @@ const DEFAULT_PROMO_STYLES: NonNullable<PromoData["styles"]> = {
1213} ;
1314
1415const BASE_CONTAINER_CLASSNAME =
15- "flex flex-col lg:flex-row justify-center items-center align-start py-4 gap-3 lg:gap-6" ;
16+ "flex flex-col lg:flex-row justify-center items-center align-start py-4 gap-3 lg:gap-6 transition-colors duration-200 " ;
1617const BASE_MESSAGE_CLASSNAME = "text-lg font-semibold" ;
1718const BASE_BUTTON_CLASSNAME =
1819 "flex h-8 justify-center items-center px-4 rounded-md font-semibold" ;
1920
21+ const PLACEHOLDER_CONTAINER_CLASSNAME =
22+ "flex flex-col lg:flex-row justify-center items-center align-start py-4 gap-3 lg:gap-6 transition-colors duration-200 opacity-0 pointer-events-none" ;
23+
2024type PromoBannerProps = {
2125 requestPath ?: string ;
22- seed ?: string ;
2326} ;
2427
2528const STATIC_PROMOS : PromoData [ ] = Object . values ( promoData ) ;
@@ -62,18 +65,7 @@ const getEligiblePromos = (promos: PromoData[], os: string | null) =>
6265 return promo . osTargets . includes ( os ) ;
6366 } ) ;
6467
65- const normalizeSeed = ( seed : string | null | undefined ) =>
66- seed && seed . length > 0 ? seed : "default" ;
67-
68- const deterministicRandom = ( seed : string ) => {
69- let hash = 0 ;
70- for ( let index = 0 ; index < seed . length ; index += 1 ) {
71- hash = ( hash * 31 + seed . charCodeAt ( index ) ) | 0 ;
72- }
73- return ( hash >>> 0 ) / 0xffffffff ;
74- } ;
75-
76- const selectWeightedPromo = ( promos : PromoData [ ] , seed : string ) => {
68+ const selectWeightedPromo = ( promos : PromoData [ ] ) => {
7769 if ( promos . length === 0 ) {
7870 return null ;
7971 }
@@ -85,7 +77,7 @@ const selectWeightedPromo = (promos: PromoData[], seed: string) => {
8577 return getHighestPriorityPromo ( promos ) ;
8678 }
8779
88- let threshold = deterministicRandom ( seed ) * totalWeight ;
80+ let threshold = Math . random ( ) * totalWeight ;
8981
9082 for ( let index = 0 ; index < promos . length ; index += 1 ) {
9183 threshold -= weights [ index ] ;
@@ -101,18 +93,83 @@ const selectWeightedPromo = (promos: PromoData[], seed: string) => {
10193const buildPromoList = ( path : string | null ) : PromoData [ ] =>
10294 STATIC_PROMOS . filter ( ( promo ) => ! isSuppressedOnPath ( promo , path ) ) ;
10395
104- const PromoBanner : React . FC < PromoBannerProps > = ( { requestPath, seed } ) => {
96+ const PromoBanner : React . FC < PromoBannerProps > = ( { requestPath } ) => {
10597 const browserOS = useBrowserOS ( ) ;
106- const pathName =
107- requestPath ??
108- ( typeof window !== "undefined" ? window . location . pathname : null ) ;
109- const promos = buildPromoList ( pathName ) ;
110- const eligiblePromos = getEligiblePromos ( promos , browserOS ) ;
111- const fallbackPromos = promos . filter ( ( promo ) => isPromoActive ( promo ) ) ;
112- const selectionPool =
113- eligiblePromos . length > 0 ? eligiblePromos : fallbackPromos ;
114- const selectionSeed = normalizeSeed ( seed ?? pathName ?? "default" ) ;
115- const selectedPromo = selectWeightedPromo ( selectionPool , selectionSeed ) ;
98+ const [ selectedPromo , setSelectedPromo ] = useState < PromoData | null > ( null ) ;
99+ const [ isReady , setIsReady ] = useState ( false ) ;
100+ const hasSelected = useRef ( false ) ;
101+ const [ shouldReserveSpace , setShouldReserveSpace ] = useState < boolean > ( ( ) => {
102+ const pathForEval =
103+ typeof window !== "undefined"
104+ ? window . location . pathname
105+ : requestPath ?? null ;
106+ const promos = buildPromoList ( pathForEval ) ;
107+ return promos . some ( ( promo ) => isPromoActive ( promo ) && Boolean ( promo . cta ) ) ;
108+ } ) ;
109+
110+ const initialPath = useMemo ( ( ) => {
111+ if ( typeof window !== "undefined" ) {
112+ return window . location . pathname ;
113+ }
114+ return requestPath ?? null ;
115+ } , [ requestPath ] ) ;
116+
117+ useEffect ( ( ) => {
118+ if ( hasSelected . current ) {
119+ return ;
120+ }
121+
122+ const pathName =
123+ typeof window !== "undefined" ? window . location . pathname : initialPath ;
124+
125+ if ( typeof window !== "undefined" && browserOS === null ) {
126+ return ;
127+ }
128+
129+ const promos = buildPromoList ( pathName ) ;
130+
131+ if ( promos . length === 0 ) {
132+ hasSelected . current = true ;
133+ setSelectedPromo ( null ) ;
134+ setIsReady ( true ) ;
135+ setShouldReserveSpace ( false ) ;
136+ return ;
137+ }
138+
139+ const eligiblePromos = getEligiblePromos ( promos , browserOS ) ;
140+ const fallbackPromos = promos . filter ( ( promo ) => isPromoActive ( promo ) ) ;
141+ const selectionPool =
142+ eligiblePromos . length > 0 ? eligiblePromos : fallbackPromos ;
143+
144+ if ( selectionPool . length === 0 ) {
145+ hasSelected . current = true ;
146+ setSelectedPromo ( null ) ;
147+ setIsReady ( true ) ;
148+ setShouldReserveSpace ( false ) ;
149+ return ;
150+ }
151+
152+ const promo = selectWeightedPromo ( selectionPool ) ;
153+ hasSelected . current = true ;
154+ setSelectedPromo ( promo && promo . cta ? promo : null ) ;
155+ setIsReady ( true ) ;
156+ setShouldReserveSpace ( true ) ;
157+ } , [ browserOS , initialPath ] ) ;
158+
159+ if ( ! isReady && shouldReserveSpace ) {
160+ return (
161+ < div className = { PLACEHOLDER_CONTAINER_CLASSNAME } aria-hidden = "true" >
162+ < div className = "lg:flex text-center gap-4 flex-wrap justify-center" >
163+ < p className = { BASE_MESSAGE_CLASSNAME } > </ p >
164+ </ div >
165+ < span className = { BASE_BUTTON_CLASSNAME } > </ span >
166+ </ div >
167+ ) ;
168+ }
169+
170+ if ( ! isReady ) {
171+ return null ;
172+ }
116173
117174 if ( ! selectedPromo || ! selectedPromo . cta ) {
118175 return null ;
0 commit comments