From 15142ecc14cdf9b7eeeae477a480789efddc8e04 Mon Sep 17 00:00:00 2001 From: Teetow Date: Tue, 24 Feb 2026 14:28:53 +0100 Subject: [PATCH 1/9] chore(.gitignore): add local directory to ignore list --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a199f7a..5c8ceb7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # build output dist/ +# misc local files +local/ + # generated types .astro/ From 4c1d67ebc5906b1f4c0602944316b7b8ce650fc6 Mon Sep 17 00:00:00 2001 From: Teetow Date: Mon, 2 Mar 2026 10:04:40 +0100 Subject: [PATCH 2/9] feat: normalize exit popup promos and selection --- src/assets/data/promotions.ts | 73 ++++- src/components/banner/BeforeYouGoPrompt.tsx | 300 ++++++++++++++++++++ src/layouts/BaseLayout.astro | 15 + src/utils/matomo.js | 42 ++- 4 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 src/components/banner/BeforeYouGoPrompt.tsx diff --git a/src/assets/data/promotions.ts b/src/assets/data/promotions.ts index 9bf5f0d..23a3290 100644 --- a/src/assets/data/promotions.ts +++ b/src/assets/data/promotions.ts @@ -1,7 +1,32 @@ -export type PromoType = "banner" | "video"; +export type PromoType = "banner" | "video" | "exit-popup"; + +export type ExitPopupPolicy = { + sessionCap?: number; + dismissCooldownDays?: number; + minDwellMs?: number; +}; + +export type ExitPopupCopy = { + title: string; + body: string; + dismissText: string; +}; + +export type TrackingConfig = { + category: string; + action: string; + name: string; +}; + +export type ExitPopupConfig = { + routeAllowlist: string[]; + copy: ExitPopupCopy; + policy?: ExitPopupPolicy; + impressionTracking?: TrackingConfig; +}; export type PromoData = { - type?: PromoType; + type: PromoType; isActive?: boolean; priority?: number; slot?: number; @@ -13,15 +38,12 @@ export type PromoData = { message?: string; button?: string; }; - tracking?: { - category: string; - action: string; - name: string; - }; + tracking?: TrackingConfig; cta?: { text: string; link: string; }; + exitPopup?: ExitPopupConfig; // Video-specific properties video?: { placeholderImage: string; @@ -36,6 +58,9 @@ type FilterOptions = { path?: string | null; }; +const routeMatchesAllowlist = (path: string, allowlist: string[]) => + allowlist.some((route) => path === route || path.startsWith(`${route}/`)); + /** Get all promos matching the filter criteria */ export const getFilteredPromos = ( promos: PromoData[], @@ -50,6 +75,11 @@ export const getFilteredPromos = ( // Check type match if (type && promo.type !== type) return false; + if (path && promo.type === "exit-popup") { + const allowlist = promo.exitPopup?.routeAllowlist ?? []; + if (!routeMatchesAllowlist(path, allowlist)) return false; + } + // Check path suppression if (path && promo.suppressOnPaths?.includes(path)) return false; @@ -280,6 +310,35 @@ const promoData: Record = { videoURL: "https://www.youtube-nocookie.com/embed/A4jPvCdbrKA?autoplay=1", }, }, + audioComExitPopup: { + type: "exit-popup", + isActive: true, + priority: 50, + message: + "Start with Audio.com to back up and access your projects across devices.", + cta: { + text: "Start with Audio.com", + link: "https://audio.com/", + }, + tracking: { + category: "Before You Go", + action: "before_you_go_cta_click", + name: "Audio.com Prompt", + }, + exitPopup: { + routeAllowlist: ["/download", "/post-download", "/cloud-saving"], + copy: { + title: "Keep your audio safe in the cloud", + body: "Start with Audio.com to back up and access your projects across devices.", + dismissText: "Not now", + }, + impressionTracking: { + category: "Before You Go", + action: "before_you_go_impression", + name: "Audio.com Prompt", + }, + }, + }, }; export default promoData; diff --git a/src/components/banner/BeforeYouGoPrompt.tsx b/src/components/banner/BeforeYouGoPrompt.tsx new file mode 100644 index 0000000..f2b0577 --- /dev/null +++ b/src/components/banner/BeforeYouGoPrompt.tsx @@ -0,0 +1,300 @@ +import promoData, { + getFilteredPromos, + type ExitPopupPolicy, + type PromoData, + type TrackingConfig, +} from "../../assets/data/promotions"; +import { trackEventIfConsented } from "../../utils/matomo"; +import { selectWeightedItem } from "../../utils/selectWeightedItem"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type BeforeYouGoPromptProps = { + requestPath?: string; +}; + +const SESSION_IMPRESSION_KEY = "before_you_go_session_impressions"; +const SUPPRESS_UNTIL_KEY = "before_you_go_suppress_until"; + +const DEFAULT_POLICY: Required = { + sessionCap: 1, + dismissCooldownDays: 14, + minDwellMs: 10000, +}; + +type ExitPopupPromo = PromoData & { + type: "exit-popup"; + cta: { + text: string; + link: string; + }; + tracking: TrackingConfig; + exitPopup: { + routeAllowlist: string[]; + copy: { + title: string; + body: string; + dismissText: string; + }; + policy?: ExitPopupPolicy; + impressionTracking?: TrackingConfig; + }; +}; + +const resolvePolicy = (policy: ExitPopupPolicy | undefined) => ({ + sessionCap: policy?.sessionCap ?? DEFAULT_POLICY.sessionCap, + dismissCooldownDays: + policy?.dismissCooldownDays ?? DEFAULT_POLICY.dismissCooldownDays, + minDwellMs: policy?.minDwellMs ?? DEFAULT_POLICY.minDwellMs, +}); + +const isExitPopupPromo = (promo: PromoData): promo is ExitPopupPromo => { + return ( + promo.type === "exit-popup" && + Boolean(promo.cta) && + Boolean(promo.tracking) && + Boolean(promo.exitPopup?.copy) + ); +}; + +const isDesktopCapableContext = () => { + if (typeof window === "undefined") { + return false; + } + + const hasMinWidth = window.matchMedia("(min-width: 1024px)").matches; + const hasPointer = window.matchMedia("(pointer: fine)").matches; + const hasHover = window.matchMedia("(hover: hover)").matches; + + return hasMinWidth && hasPointer && hasHover; +}; + +const getNumberFromStorage = (storage: Storage, key: string): number => { + const value = storage.getItem(key); + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const isSuppressedByCooldown = (nowMs: number) => { + const suppressUntil = getNumberFromStorage(localStorage, SUPPRESS_UNTIL_KEY); + return suppressUntil > nowMs; +}; + +const setSuppressCooldown = (days: number) => { + const cooldownMs = days * 24 * 60 * 60 * 1000; + localStorage.setItem(SUPPRESS_UNTIL_KEY, String(Date.now() + cooldownMs)); +}; + +const incrementSessionImpressions = () => { + const current = getNumberFromStorage(sessionStorage, SESSION_IMPRESSION_KEY); + sessionStorage.setItem(SESSION_IMPRESSION_KEY, String(current + 1)); +}; + +const hasReachedSessionCap = (sessionCap: number) => { + const current = getNumberFromStorage(sessionStorage, SESSION_IMPRESSION_KEY); + return current >= sessionCap; +}; + +const selectExitPopupPromo = (promos: ExitPopupPromo[]) => + selectWeightedItem(promos, (promo) => Math.max(promo.priority ?? 0, 0), { + fallback: "random", + }); + +const BeforeYouGoPrompt: React.FC = ({ + requestPath, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [isDwellReady, setIsDwellReady] = useState(false); + const [hasEngagement, setHasEngagement] = useState(false); + const [hasExitIntent, setHasExitIntent] = useState(false); + const [selectedPromo, setSelectedPromo] = useState( + null, + ); + const hasShownRef = useRef(false); + + const resolvedPath = useMemo(() => { + if (typeof window !== "undefined") { + return window.location.pathname; + } + return requestPath ?? ""; + }, [requestPath]); + + const resolvedPolicy = useMemo( + () => resolvePolicy(selectedPromo?.exitPopup?.policy), + [selectedPromo], + ); + + const impressionTracking = selectedPromo?.exitPopup?.impressionTracking; + const trackingForImpression = impressionTracking ?? selectedPromo?.tracking; + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const eligiblePopups = getFilteredPromos(Object.values(promoData), { + type: "exit-popup", + path: resolvedPath, + }).filter(isExitPopupPromo); + + setSelectedPromo(selectExitPopupPromo(eligiblePopups)); + setIsVisible(false); + setIsDwellReady(false); + setHasEngagement(false); + setHasExitIntent(false); + hasShownRef.current = false; + }, [resolvedPath]); + + const isRouteEligible = Boolean(selectedPromo); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + if (!isRouteEligible || !isDesktopCapableContext()) { + return; + } + + const nowMs = Date.now(); + + if (isSuppressedByCooldown(nowMs)) { + return; + } + + if (hasReachedSessionCap(resolvedPolicy.sessionCap)) { + return; + } + + const dwellTimer = window.setTimeout(() => { + setIsDwellReady(true); + }, resolvedPolicy.minDwellMs); + + const markEngagement = () => { + setHasEngagement(true); + }; + + const handleScroll = () => { + if (window.scrollY > 120) { + setHasEngagement(true); + } + }; + + const handleMouseOut = (event: MouseEvent) => { + if (event.relatedTarget !== null) { + return; + } + + if (event.clientY <= 0) { + setHasExitIntent(true); + } + }; + + const handleEscapeDismiss = (event: KeyboardEvent) => { + if (!isVisible || event.key !== "Escape") { + return; + } + + setSuppressCooldown(resolvedPolicy.dismissCooldownDays); + setIsVisible(false); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("click", markEngagement); + window.addEventListener("keydown", markEngagement); + document.addEventListener("mouseout", handleMouseOut); + window.addEventListener("keydown", handleEscapeDismiss); + + return () => { + window.clearTimeout(dwellTimer); + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("click", markEngagement); + window.removeEventListener("keydown", markEngagement); + document.removeEventListener("mouseout", handleMouseOut); + window.removeEventListener("keydown", handleEscapeDismiss); + }; + }, [isRouteEligible, isVisible, resolvedPolicy]); + + useEffect(() => { + if ( + !selectedPromo || + !isRouteEligible || + !isDwellReady || + !hasEngagement || + !hasExitIntent || + hasShownRef.current + ) { + return; + } + + hasShownRef.current = true; + incrementSessionImpressions(); + setIsVisible(true); + + if (trackingForImpression) { + trackEventIfConsented( + trackingForImpression.category, + trackingForImpression.action, + trackingForImpression.name, + ); + } + }, [ + hasEngagement, + hasExitIntent, + isDwellReady, + isRouteEligible, + selectedPromo, + trackingForImpression, + ]); + + const handleDismiss = () => { + setSuppressCooldown(resolvedPolicy.dismissCooldownDays); + setIsVisible(false); + }; + + const handleCtaClick = () => { + if (!selectedPromo) { + return; + } + + setSuppressCooldown(resolvedPolicy.dismissCooldownDays); + trackEventIfConsented( + selectedPromo.tracking.category, + selectedPromo.tracking.action, + selectedPromo.tracking.name, + ); + }; + + if (!isVisible || !selectedPromo) { + return null; + } + + const { copy } = selectedPromo.exitPopup; + + return ( + + ); +}; + +export default BeforeYouGoPrompt; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 814e502..d05b7ff 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,5 +1,7 @@ --- import promoData from "../assets/data/promotions"; +import { getFilteredPromos } from "../assets/data/promotions"; +import BeforeYouGoPrompt from "../components/banner/BeforeYouGoPrompt"; import CookieConsent from "../components/banner/CookieConsent"; import PromoBanner from "../components/banner/PromoBanner"; import Footer from "../components/footer/Footer.astro"; @@ -27,6 +29,14 @@ const hasActiveStaticPromo = Object.values(promoData).some((promo) => { }); const shouldShowPromoBanner = showPromoBanner && !isNoAdPage && hasActiveStaticPromo; + +const currentPath = Astro.url.pathname; +const hasEligibleExitPopup = + getFilteredPromos(Object.values(promoData), { + type: "exit-popup", + path: currentPath, + }).length > 0; +const shouldEnableBeforeYouGo = hasEligibleExitPopup; --- @@ -72,6 +82,11 @@ const shouldShowPromoBanner =