From 65f1c9ca508bd7cc4ef9bba5b25438ac607961a4 Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Sat, 26 Oct 2024 23:34:50 +0100 Subject: [PATCH] feat: add support for sticky sponsor banner (#7602) * Add sponsors functionality * Fix overlapping issue * Add sticky top sponsor --- src/components/FrameRenderer/renderer.ts | 8 +- .../GenerateRoadmap/RoadmapTopicDetail.tsx | 2 +- src/components/OnboardingNudge.tsx | 9 +- .../PageSponsors/BottomRightSponsor.tsx | 104 ++++++++++ src/components/PageSponsors/PageSponsors.tsx | 195 ++++++++++++++++++ .../PageSponsors/StickyTopSponsor.tsx | 87 ++++++++ src/components/TopicDetail/TopicDetail.tsx | 2 +- src/layouts/BaseLayout.astro | 5 +- src/stores/page.ts | 4 +- 9 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 src/components/PageSponsors/BottomRightSponsor.tsx create mode 100644 src/components/PageSponsors/PageSponsors.tsx create mode 100644 src/components/PageSponsors/StickyTopSponsor.tsx diff --git a/src/components/FrameRenderer/renderer.ts b/src/components/FrameRenderer/renderer.ts index 6f51f7ddf..c6baa2c0a 100644 --- a/src/components/FrameRenderer/renderer.ts +++ b/src/components/FrameRenderer/renderer.ts @@ -57,7 +57,7 @@ export class Renderer { } // Clone it so we can use it later - this.loaderHTML = this.loaderEl!.innerHTML; + this.loaderHTML = this.loaderEl?.innerHTML!; const dataset = this.containerEl.dataset; this.resourceType = dataset.resourceType!; @@ -66,11 +66,7 @@ export class Renderer { return true; } - /** - * @param { string } jsonUrl - * @returns {Promise} - */ - jsonToSvg(jsonUrl: string) { + jsonToSvg(jsonUrl: string): Promise | null { if (!jsonUrl) { console.error('jsonUrl not defined in frontmatter'); return null; diff --git a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx index 02726f5ed..560d9e940 100644 --- a/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx +++ b/src/components/GenerateRoadmap/RoadmapTopicDetail.tsx @@ -124,7 +124,7 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) { const openAIKey = getOpenAIKey(); return ( -
+
void; @@ -14,6 +16,7 @@ export function OnboardingNudge(props: OnboardingNudgeProps) { const [isLoading, setIsLoading] = useState(false); + const $isOnboardingStripHidden = useStore(isOnboardingStripHidden); const { y: scrollY } = useScrollPosition(); useEffect(() => { @@ -30,10 +33,14 @@ export function OnboardingNudge(props: OnboardingNudgeProps) { return null; } + if ($isOnboardingStripHidden) { + return null; + } + return (
void; + onSponsorImpression: () => void; + onSponsorHidden: () => void; +}; + +export function BottomRightSponsor(props: BottomRightSponsorProps) { + const { sponsor, onSponsorImpression, onSponsorClick, onSponsorHidden } = + props; + + const [isHidden, setIsHidden] = useState(false); + + useEffect(() => { + if (!sponsor) { + return; + } + + onSponsorImpression(); + }, []); + + const { url, title, imageUrl, description, company, gaLabel } = sponsor; + + const isRoadmapAd = title.toLowerCase() === 'advertise with us!'; + + if (isHidden) { + return null; + } + + return ( + + { + e.preventDefault(); + e.stopPropagation(); + + setIsHidden(true); + onSponsorHidden(); + }} + > + + + + Sponsor Banner + + + + {title} + {description} + + {!isRoadmapAd && ( + <> + + Partner Content + + + Partner Content + + + )} + + + ); +} diff --git a/src/components/PageSponsors/PageSponsors.tsx b/src/components/PageSponsors/PageSponsors.tsx new file mode 100644 index 000000000..cee656e07 --- /dev/null +++ b/src/components/PageSponsors/PageSponsors.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from 'react'; +import { httpGet, httpPatch } from '../../lib/http'; +import { sponsorHidden } from '../../stores/page'; +import { useStore } from '@nanostores/react'; +import { setViewSponsorCookie } from '../../lib/jwt'; +import { isMobile } from '../../lib/is-mobile'; +import Cookies from 'js-cookie'; +import { getUrlUtmParams } from '../../lib/browser.ts'; +import { StickyTopSponsor } from './StickyTopSponsor.tsx'; +import { BottomRightSponsor } from './BottomRightSponsor.tsx'; + +type PageSponsorType = { + company: string; + description: string; + gaLabel: string; + imageUrl: string; + pageUrl: string; + title: string; + url: string; + id: string; +}; + +export type StickyTopSponsorType = PageSponsorType & { + buttonText: string; + style?: { + fromColor?: string; + toColor?: string; + textColor?: string; + buttonBackgroundColor?: string; + buttonTextColor?: string; + }; +}; +export type BottomRightSponsorType = PageSponsorType; + +type V1GetSponsorResponse = { + bottomRightAd?: BottomRightSponsorType; + stickyTopAd?: StickyTopSponsorType; +}; + +type PageSponsorsProps = { + gaPageIdentifier?: string; +}; + +const CLOSE_SPONSOR_KEY = 'sponsorClosed'; + +function markSponsorHidden(sponsorId: string) { + Cookies.set(`${CLOSE_SPONSOR_KEY}-${sponsorId}`, '1', { + path: '/', + expires: 1, + sameSite: 'lax', + secure: true, + domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh', + }); +} + +function isSponsorMarkedHidden(sponsorId: string) { + return Cookies.get(`${CLOSE_SPONSOR_KEY}-${sponsorId}`) === '1'; +} + +export function PageSponsors(props: PageSponsorsProps) { + const { gaPageIdentifier } = props; + + const $isSponsorHidden = useStore(sponsorHidden); + + const [stickyTopSponsor, setStickyTopSponsor] = + useState(); + const [bottomRightSponsor, setBottomRightSponsor] = + useState(); + + useEffect(() => { + const foundUtmParams = getUrlUtmParams(); + + if (!foundUtmParams.utmSource) { + return; + } + + localStorage.setItem('utm_params', JSON.stringify(foundUtmParams)); + }, []); + + async function loadSponsor() { + const currentPath = window.location.pathname; + if ( + currentPath === '/' || + currentPath === '/best-practices' || + currentPath === '/roadmaps' || + currentPath.startsWith('/guides') || + currentPath.startsWith('/videos') || + currentPath.startsWith('/account') || + currentPath.startsWith('/team/') + ) { + return; + } + + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`, + { + href: window.location.pathname, + mobile: isMobile() ? 'true' : 'false', + }, + ); + + if (error) { + console.error(error); + return; + } + + setStickyTopSponsor(response?.stickyTopAd); + setBottomRightSponsor(response?.bottomRightAd); + } + + async function logSponsorImpression( + sponsor: BottomRightSponsorType | StickyTopSponsorType, + ) { + window.fireEvent({ + category: 'SponsorImpression', + action: `${sponsor?.company} Impression`, + label: + sponsor?.gaLabel || `${gaPageIdentifier} / ${sponsor?.company} Link`, + }); + } + + async function clickSponsor( + sponsor: BottomRightSponsorType | StickyTopSponsorType, + ) { + const { id: sponsorId, company, gaLabel } = sponsor; + + const labelValue = gaLabel || `${gaPageIdentifier} / ${company} Link`; + + window.fireEvent({ + category: 'SponsorClick', + action: `${company} Redirect`, + label: labelValue, + value: labelValue, + }); + + const clickUrl = new URL( + `${import.meta.env.PUBLIC_API_URL}/v1-view-sponsor/${sponsorId}`, + ); + + const { response, error } = await httpPatch<{ status: 'ok' }>( + clickUrl.toString(), + { + mobile: isMobile(), + }, + ); + + if (error || !response) { + console.error(error); + return; + } + + setViewSponsorCookie(sponsorId); + } + + useEffect(() => { + window.setTimeout(loadSponsor); + }, []); + + if ($isSponsorHidden) { + return null; + } + + return ( +
+ {stickyTopSponsor && !isSponsorMarkedHidden(stickyTopSponsor.id) && ( + { + logSponsorImpression(stickyTopSponsor).catch(console.error); + }} + onSponsorClick={() => { + clickSponsor(stickyTopSponsor).catch(console.error); + }} + onSponsorHidden={() => { + markSponsorHidden(stickyTopSponsor.id); + }} + /> + )} + {bottomRightSponsor && !isSponsorMarkedHidden(bottomRightSponsor.id) && ( + { + clickSponsor(bottomRightSponsor).catch(console.error); + }} + onSponsorHidden={() => { + markSponsorHidden(bottomRightSponsor.id); + }} + onSponsorImpression={() => { + logSponsorImpression(bottomRightSponsor).catch(console.error); + }} + /> + )} +
+ ); +} diff --git a/src/components/PageSponsors/StickyTopSponsor.tsx b/src/components/PageSponsors/StickyTopSponsor.tsx new file mode 100644 index 000000000..77a0c792e --- /dev/null +++ b/src/components/PageSponsors/StickyTopSponsor.tsx @@ -0,0 +1,87 @@ +import { cn } from '../../lib/classname.ts'; +import { useScrollPosition } from '../../hooks/use-scroll-position.ts'; +import { X } from 'lucide-react'; +import type { StickyTopSponsorType } from './PageSponsors.tsx'; +import { useEffect, useState } from 'react'; +import { isOnboardingStripHidden } from '../../stores/page.ts'; + +type StickyTopSponsorProps = { + sponsor: StickyTopSponsorType; + + onSponsorImpression: () => void; + onSponsorClick: () => void; + onSponsorHidden: () => void; +}; + +const SCROLL_DISTANCE = 100; + +export function StickyTopSponsor(props: StickyTopSponsorProps) { + const { sponsor, onSponsorHidden, onSponsorImpression, onSponsorClick } = + props; + + const { y: scrollY } = useScrollPosition(); + const [isImpressionLogged, setIsImpressionLogged] = useState(false); + const [isHidden, setIsHidden] = useState(false); + + useEffect(() => { + if (!sponsor) { + return; + } + + // hide the onboarding strip when the sponsor is visible + isOnboardingStripHidden.set(true); + }, [sponsor]); + + useEffect(() => { + if (scrollY < SCROLL_DISTANCE || isImpressionLogged) { + return; + } + + setIsImpressionLogged(true); + onSponsorImpression(); + }, [scrollY]); + + if (scrollY < SCROLL_DISTANCE || isHidden) { + return null; + } + + return ( + + {'ad'} + {sponsor.description} + + + + ); +} diff --git a/src/components/TopicDetail/TopicDetail.tsx b/src/components/TopicDetail/TopicDetail.tsx index e629407ad..c34fa7073 100644 --- a/src/components/TopicDetail/TopicDetail.tsx +++ b/src/components/TopicDetail/TopicDetail.tsx @@ -340,7 +340,7 @@ export function TopicDetail(props: TopicDetailProps) { ); return ( -
+
- diff --git a/src/stores/page.ts b/src/stores/page.ts index 3d507c736..f4041e842 100644 --- a/src/stores/page.ts +++ b/src/stores/page.ts @@ -4,4 +4,6 @@ export const pageProgressMessage = atom(undefined); export const sponsorHidden = atom(false); export const roadmapsDropdownOpen = atom(false); -export const navigationDropdownOpen = atom(false); \ No newline at end of file +export const navigationDropdownOpen = atom(false); + +export const isOnboardingStripHidden = atom(false); \ No newline at end of file