Skip to content

Commit 7e6db4b

Browse files
committed
improve the marketing banner targeting
1 parent 8ed7af6 commit 7e6db4b

12 files changed

Lines changed: 215 additions & 24 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
"@testing-library/react": "^11.1.0",
1111
"@testing-library/user-event": "^12.1.10",
1212
"@types/dompurify": "^2.3.4",
13+
"@types/jspath": "^0.4.0",
1314
"axios": "^0.21.2",
1415
"axios-cache-adapter": "^2.7.3",
1516
"country-emoji": "^1.5.4",
1617
"dompurify": "^2.2.7",
1718
"eslint-plugin-react": "^7.28.0",
19+
"jspath": "^0.4.0",
1820
"localforage": "^1.9.0",
1921
"normalize.css": "^8.0.1",
2022
"prop-types": "^15.0.0-0",

src/App.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@ import { ScrollCardsNavigator } from './components/Layout'
77
import { AppContentLayout } from './components/Layout'
88
import 'react-contexify/dist/ReactContexify.css'
99
import { setupAnalytics, trackPageView, setupIdentification } from 'src/lib/analytics'
10-
import { useRemoteConfigStore } from 'src/features/remoteConfig'
1110

1211
function App() {
1312
const [showSideBar, setShowSideBar] = useState(false)
1413
const [showSettings, setShowSettings] = useState(false)
1514

16-
const { marketingBannerConfig } = useRemoteConfigStore()
17-
1815
useEffect(() => {
1916
setupAnalytics()
2017
setupIdentification()
@@ -23,7 +20,7 @@ function App() {
2320

2421
return (
2522
<>
26-
<MarketingBanner {...marketingBannerConfig} />
23+
<MarketingBanner />
2724
<div className="App">
2825
<Header
2926
setShowSideBar={setShowSideBar}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { ExtractFnReturnType, QueryConfig } from 'src/lib/react-query';
3+
import { MarketingConfig } from "../types";
4+
import { axios } from 'src/lib/axios';
5+
6+
const getMarketingConfig = async (): Promise<MarketingConfig> => {
7+
return axios.get('/data/marketingConfig.json');
8+
}
9+
10+
type QueryFnType = typeof getMarketingConfig;
11+
12+
type UseGetMarketingConfigOptions = {
13+
config?: QueryConfig<QueryFnType>;
14+
};
15+
export const useGetMarketingConfig = ({ config }: UseGetMarketingConfigOptions = {}) => {
16+
return useQuery<ExtractFnReturnType<QueryFnType>>({
17+
...config,
18+
queryKey: ['marketing-config'],
19+
queryFn: () => getMarketingConfig(),
20+
});
21+
}
Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,115 @@
11
import DOMPurify from 'dompurify'
2-
import { useMarketingBanner } from '../stores/marketingBanner'
2+
import { useMarketingConfigStore } from '../stores/marketingBanner'
3+
import JSPath from 'jspath'
4+
import { useUserPreferences } from 'src/stores/preferences'
5+
import { getAppVersion } from 'src/utils/Os'
6+
import { isWebOrExtensionVersion, isProduction, getBrowserName } from 'src/utils/Environment'
7+
import { useMemo, useState, useEffect } from 'react'
8+
import { Campaign, MarketingConfig } from '../types'
9+
import { useGetMarketingConfig } from '../api/getMarketingConfig'
10+
import {
11+
trackMarketingCampaignClose,
12+
trackMarketingCampaignView,
13+
trackMarketingCampaignOpen,
14+
} from 'src/lib/analytics'
15+
import { DiffBetweenTwoDatesInDays } from 'src/utils/DateUtils'
316

4-
type MarketingBannerProps = {
5-
show: boolean
6-
campaign_name: string
7-
htmlContent: string
8-
}
9-
export const MarketingBanner = ({ campaign_name, show, htmlContent }: MarketingBannerProps) => {
10-
const { setCampaignClosed, closedCampaigns } = useMarketingBanner()
17+
export const MarketingBanner = () => {
18+
const { setCampaignClosed, closedCampaigns } = useMarketingConfigStore()
19+
const { userSelectedTags, cards, firstSeenDate } = useUserPreferences()
20+
const [availableCampaigns, setAvailableCampaigns] = useState<Campaign[]>([])
21+
const { data: marketingConfig } = useGetMarketingConfig({
22+
config: {
23+
staleTime: 60000,
24+
cacheTime: 3600000,
25+
},
26+
})
27+
28+
const userAtttributes = useMemo(() => {
29+
return {
30+
platform: isWebOrExtensionVersion(),
31+
browser: getBrowserName(),
32+
version: getAppVersion() || '0.0.0',
33+
environment: isProduction() ? 'prod' : 'dev',
34+
userTags: userSelectedTags.map((tag) => tag.label),
35+
cards: cards.map((card) => card.name),
36+
firstSeenDate,
37+
usageInDays: DiffBetweenTwoDatesInDays(firstSeenDate, Date.now()),
38+
}
39+
}, [userSelectedTags, firstSeenDate, cards])
40+
41+
useEffect(() => {
42+
if (marketingConfig) {
43+
const availableCampaigns: Campaign[] = getAvailableCampaigns(marketingConfig)
44+
setAvailableCampaigns(availableCampaigns)
45+
}
46+
47+
// eslint-disable-next-line react-hooks/exhaustive-deps
48+
}, [marketingConfig, closedCampaigns, userSelectedTags, cards])
49+
50+
useEffect(() => {
51+
if (availableCampaigns.length) {
52+
trackMarketingCampaignView(availableCampaigns[0].id)
53+
}
54+
}, [availableCampaigns])
55+
56+
if (!marketingConfig) {
57+
return null
58+
}
59+
60+
const getAvailableCampaigns = (config: MarketingConfig) => {
61+
const campaignsWithUserAttr = config.campaigns.map((camp) => {
62+
return { ...camp, userAtttributes: userAtttributes }
63+
})
64+
65+
const lastVisibleAdDate = Math.max(...closedCampaigns.map((camp) => camp.date))
66+
if (lastVisibleAdDate > Date.now() - config.campaigns_interval) {
67+
return []
68+
}
69+
70+
const closedCampaignsSet = new Set(closedCampaigns.map((closedCamp) => closedCamp.id))
71+
const availableCampaigns = campaignsWithUserAttr
72+
.filter((camp) => camp.enabled && !closedCampaignsSet.has(camp.id))
73+
.flatMap((camp) => JSPath.apply(camp.condition, camp))
74+
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
75+
.reverse()
1176

12-
if (!show || closedCampaigns.includes(campaign_name)) {
77+
return availableCampaigns
78+
}
79+
80+
if (!marketingConfig.enabled) {
1381
return null
1482
}
1583

16-
let cleanHtmlContent = DOMPurify.sanitize(htmlContent)
84+
if (!availableCampaigns.length) {
85+
return null
86+
}
1787

18-
const onBannerClick = (e: React.MouseEvent<HTMLElement>) => {
88+
const onBannerClick = (e: React.MouseEvent<HTMLElement>, campaign: Campaign) => {
1989
if (e.target instanceof Element) {
2090
const closeButton = e.target.closest('.close')
91+
const ctaButton = e.target.closest('.cta')
2192
if (closeButton && e.currentTarget.contains(closeButton)) {
22-
setCampaignClosed(campaign_name)
93+
setCampaignClosed(campaign.id)
94+
trackMarketingCampaignClose(campaign.id)
95+
} else if (ctaButton && e.currentTarget.contains(ctaButton)) {
96+
trackMarketingCampaignOpen(campaign.id)
2397
}
2498
}
2599
}
26100

101+
const currentCampaign = availableCampaigns[0]
102+
27103
return (
28-
<div onClick={(e) => onBannerClick(e)} dangerouslySetInnerHTML={{ __html: cleanHtmlContent }} />
104+
<div
105+
id={currentCampaign.id}
106+
onClick={(e) => onBannerClick(e, currentCampaign)}
107+
dangerouslySetInnerHTML={{
108+
__html: DOMPurify.sanitize(currentCampaign.htmlContent, {
109+
ADD_ATTR: ['target'],
110+
USE_PROFILES: { html: true },
111+
}),
112+
}}
113+
/>
29114
)
30115
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from "./components"
1+
export * from "./components"
2+
export * from "./types"

src/features/MarketingBanner/stores/marketingBanner.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import create from 'zustand';
22
import { persist } from 'zustand/middleware'
33

4+
type ClosedCampaign = {
5+
id: string;
6+
date: number;
7+
}
8+
49
type MarketingBannerStore = {
5-
closedCampaigns: string[];
6-
setCampaignClosed: (compaignName: string) => void;
10+
closedCampaigns: ClosedCampaign[];
11+
setCampaignClosed: (compaignId: string) => void;
712
};
813

9-
export const useMarketingBanner = create(persist<MarketingBannerStore>((set) => ({
14+
export const useMarketingConfigStore = create(persist<MarketingBannerStore>((set) => ({
1015
closedCampaigns: [],
11-
setCampaignClosed: (compaignName: string) =>
16+
setCampaignClosed: (compaignId: string) =>
1217
set((state) => ({
13-
closedCampaigns: [...state.closedCampaigns, compaignName]
18+
closedCampaigns: [...state.closedCampaigns, {
19+
id: compaignId,
20+
date: Date.now()
21+
}]
1422
})),
1523
}), {
1624
name: 'ht_marketing_storage',
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export type MarketingConfig = {
2+
enabled: boolean;
3+
campaigns_interval: number;
4+
campaigns: Campaign[]
5+
}
6+
7+
export type Campaign = {
8+
id: string;
9+
name: string;
10+
htmlContent: string;
11+
condition: string;
12+
enabled: boolean;
13+
userAtttributes?: UserAttribute;
14+
priority?: number
15+
}
16+
17+
type UserAttribute = {
18+
[key: string]: string | string[];
19+
}

src/lib/analytics.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ enum Objects {
1717
SEARCH_ENGINE = 'Search Engine',
1818
LISTING_MODE = 'Listing Mode',
1919
CHANGE_LOG = 'Change Log',
20+
MARKETING_CAMPAIGN = 'Marketing Campaign',
2021
}
2122

2223
enum Verbs {
@@ -26,6 +27,7 @@ enum Verbs {
2627
SELECT = 'Select',
2728
ADD = 'Add',
2829
OPEN = 'Open',
30+
CLOSE = 'Close',
2931
TARGET = 'Target',
3032
BOOKMARK = 'Bookmark',
3133
UNBOOKMARK = 'Unbookmark',
@@ -49,7 +51,8 @@ export enum Attributes {
4951
TRIGERED_FROM = 'Trigered From',
5052
TITLE = 'Title',
5153
LINK = 'Link',
52-
SOURCE_TAGS = 'Source Tags'
54+
SOURCE_TAGS = 'Source Tags',
55+
CAMPAIGN_ID = 'Campaign Id'
5356
}
5457

5558
const _SEP_ = ' '
@@ -217,6 +220,29 @@ export const trackChangeLogOpen = () => {
217220
})
218221
}
219222

223+
export const trackMarketingCampaignOpen = (campaignId: string) => {
224+
trackEvent({
225+
object: Objects.MARKETING_CAMPAIGN,
226+
verb: Verbs.OPEN,
227+
attributes: { [Attributes.CAMPAIGN_ID]: campaignId }
228+
})
229+
}
230+
231+
export const trackMarketingCampaignClose = (campaignId: string) => {
232+
trackEvent({
233+
object: Objects.MARKETING_CAMPAIGN,
234+
verb: Verbs.CLOSE,
235+
attributes: { [Attributes.CAMPAIGN_ID]: campaignId }
236+
})
237+
}
238+
239+
export const trackMarketingCampaignView = (campaignId: string) => {
240+
trackEvent({
241+
object: Objects.MARKETING_CAMPAIGN,
242+
verb: Verbs.VIEW,
243+
attributes: { [Attributes.CAMPAIGN_ID]: campaignId }
244+
})
245+
}
220246
// Identification
221247

222248
export const identifyUserLanguages = (languages: string[]) => {

src/stores/preferences.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type UserPreferencesState = {
1212
searchEngine: string
1313
cards: SelectedCard[]
1414
cardsSettings: Record<string, CardSettingsType>
15+
firstSeenDate: number;
1516
}
1617

1718
type UserPreferencesStoreActions = {
@@ -34,6 +35,7 @@ export const useUserPreferences = create(
3435
searchEngine: 'google',
3536
listingMode: 'normal',
3637
openLinksNewTab: true,
38+
firstSeenDate: Date.now(),
3739
cards: [
3840
{ id: 0, name: 'github' },
3941
{ id: 1, name: 'hackernews' },

src/utils/DateUtils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const DiffBetweenTwoDatesInDays = (oldestDate: number, newestDate: number) => {
2+
return Math.floor((newestDate - oldestDate) / (1000 * 60 * 60 * 24));
3+
}

0 commit comments

Comments
 (0)