Skip to content

Commit 5be2826

Browse files
committed
feat: ensure PromoBanner rotates properly
1 parent 5a756c8 commit 5be2826

File tree

1 file changed

+83
-26
lines changed

1 file changed

+83
-26
lines changed

src/components/banner/PromoBanner.tsx

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PromoData } from "../../assets/data/promotions";
44
import useBrowserOS from "../../hooks/useDetectOS";
55
import "../../styles/icons.css";
66
import { trackEvent } from "../../utils/matomo";
7+
import { useEffect, useMemo, useRef, useState } from "react";
78

89
const DEFAULT_PROMO_STYLES: NonNullable<PromoData["styles"]> = {
910
container: "bg-yellow-300",
@@ -12,14 +13,16 @@ const DEFAULT_PROMO_STYLES: NonNullable<PromoData["styles"]> = {
1213
};
1314

1415
const 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";
1617
const BASE_MESSAGE_CLASSNAME = "text-lg font-semibold";
1718
const 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+
2024
type PromoBannerProps = {
2125
requestPath?: string;
22-
seed?: string;
2326
};
2427

2528
const 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) => {
10193
const 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}>&nbsp;</p>
164+
</div>
165+
<span className={BASE_BUTTON_CLASSNAME}>&nbsp;</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

Comments
 (0)