Skip to content

Commit 4761815

Browse files
committed
feat: implement paywall feature with donation modal and related components
1 parent ec17dd9 commit 4761815

File tree

17 files changed

+399
-131
lines changed

17 files changed

+399
-131
lines changed

src/assets/App.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ a {
6868
z-index: 1;
6969
}
7070

71+
.AppHeader .upgradeButton {
72+
font-weight: bold;
73+
background-color: var(--tab-positive-button-background) !important;
74+
color: white !important;
75+
&:hover {
76+
opacity: 0.8;
77+
}
78+
}
79+
7180
.AppFooter {
7281
display: flex;
7382
flex-direction: row;

src/components/Layout/Header.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { clsx } from 'clsx'
22
import { useCallback, useEffect, useState } from 'react'
33
import { BsFillBookmarksFill, BsFillGearFill, BsMoonFill } from 'react-icons/bs'
44
import { CgTab } from 'react-icons/cg'
5+
import { FaCrown } from 'react-icons/fa'
56
import { IoMdSunny } from 'react-icons/io'
67
import { MdDoDisturbOff } from 'react-icons/md'
78
import { RiDashboardHorizontalFill } from 'react-icons/ri'
@@ -13,19 +14,25 @@ import HackertabLogo from 'src/assets/logo.svg?react'
1314
import { UserTags } from 'src/components/Elements/UserTags'
1415
import { useAuth } from 'src/features/auth'
1516
import { Changelog } from 'src/features/changelog'
17+
import { useRemoteConfigStore } from 'src/features/remoteConfig'
1618
import {
1719
identifyUserTheme,
1820
trackDNDDisable,
1921
trackDisplayTypeChange,
2022
trackThemeSelect,
2123
} from 'src/lib/analytics'
2224
import { useUserPreferences } from 'src/stores/preferences'
25+
import { lazyImport } from 'src/utils/lazyImport'
2326
import { Button, CircleButton } from '../Elements'
2427
import { SearchEngineBar } from '../Elements/SearchBar/SearchEngineBar'
28+
const { DonateModal } = lazyImport(() => import('src/features/donate'), 'DonateModal')
29+
2530
export const Header = () => {
2631
const { openAuthModal, user, isConnected, isConnecting } = useAuth()
2732

2833
const [themeIcon, setThemeIcon] = useState(<BsMoonFill />)
34+
const [openDonateModal, setOpenDonateModal] = useState(false)
35+
const { paywall } = useRemoteConfigStore()
2936
const { theme, setTheme, setDNDDuration, isDNDModeActive, layout, setLayout } =
3037
useUserPreferences()
3138
const navigate = useNavigate()
@@ -68,6 +75,10 @@ export const Header = () => {
6875
setDNDDuration('never')
6976
}, [setDNDDuration])
7077

78+
const onUpgradeClicked = useCallback(() => {
79+
setOpenDonateModal(true)
80+
}, [])
81+
7182
return (
7283
<>
7384
<header className="AppHeader">
@@ -129,7 +140,14 @@ export const Header = () => {
129140
<AvatarPlaceholder className="avatarPlaceholder" />
130141
)}
131142
</CircleButton>
143+
{paywall?.enabled && (
144+
<Button onClick={onUpgradeClicked} className="upgradeButton">
145+
<FaCrown />
146+
{paywall.headerCta}
147+
</Button>
148+
)}
132149
</div>
150+
{openDonateModal && <DonateModal setModalOpen={setOpenDonateModal} />}
133151
{location.pathname === '/' && <UserTags />}
134152
</header>
135153
</>

src/features/MarketingBanner/components/MarketingBanner.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import { Campaign, MarketingConfig } from '../types'
1818

1919
export const MarketingBanner = () => {
2020
const { setCampaignClosed, closedCampaigns } = useMarketingConfigStore()
21-
const { isConnected } = useAuth()
22-
const { userSelectedTags, cards, firstSeenDate, advStatus } = useUserPreferences()
21+
const { isConnected, user } = useAuth()
22+
const { userSelectedTags, cards, firstSeenDate } = useUserPreferences()
2323
const [availableCampaigns, setAvailableCampaigns] = useState<Campaign[]>([])
2424
const { data: marketingConfig } = useGetMarketingConfig({
2525
config: {
@@ -39,11 +39,11 @@ export const MarketingBanner = () => {
3939
userTags: userSelectedTags.map((tag) => tag.label),
4040
cards: cards.map((card) => card.name),
4141
firstSeenDate,
42-
adv: advStatus,
4342
isConnected,
43+
isSupported: user?.isSupporter || false,
4444
usageInDays: diffBetweenTwoDatesInDays(firstSeenDate, Date.now()),
4545
}
46-
}, [userSelectedTags, firstSeenDate, cards, advStatus])
46+
}, [userSelectedTags, firstSeenDate, cards, user])
4747

4848
useEffect(() => {
4949
if (marketingConfig && marketingConfig.version === 1) {

src/features/adv/api/getAd.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Ad } from '../types'
55

66
const getAd = async (keywords: string[], feed: boolean = false): Promise<Ad | null> => {
77
let params = { keywords: keywords.join(','), feed: feed ? 'true' : 'false' }
8-
return axios.get('/engine/ads/', { params })
8+
return axios.get('/engine/ads/adaptive', { params })
99
}
1010

1111
type QueryFnType = typeof getAd
@@ -18,7 +18,7 @@ type UseGetAdOptions = {
1818
export const useGetAd = ({ keywords, feed, config }: UseGetAdOptions) => {
1919
return useQuery<ExtractFnReturnType<QueryFnType>>({
2020
...config,
21-
queryKey: ['ad', keywords.join(',')],
21+
queryKey: ['ad', 'v2', keywords.join(',')],
2222
queryFn: () => getAd(keywords, feed),
2323
})
2424
}

src/features/adv/components/AdvBanner.css

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,21 @@
285285
.advFeed:has(.banneradv) .rowDetails {
286286
margin-left: auto;
287287
}
288+
289+
.houseBanner {
290+
max-width: none;
291+
width: 100%;
292+
img {
293+
border-radius: 8px;
294+
object-fit: contain;
295+
display: block;
296+
margin: 0 auto;
297+
max-width: 100%;
298+
}
299+
a {
300+
width: 100%;
301+
&:hover {
302+
opacity: 0.8;
303+
}
304+
}
305+
}
Lines changed: 16 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { useEffect } from 'react'
22
import { AdPlaceholder } from 'src/components/placeholders'
3-
import { useRemoteConfigStore } from 'src/features/remoteConfig'
3+
import { trackMarketingCampaignOpen } from 'src/lib/analytics'
44
import { useUserPreferences } from 'src/stores/preferences'
5-
import { isWebOrExtensionVersion } from 'src/utils/Environment'
65
import { useGetAd } from '../api/getAd'
7-
import { useDelayedFlag } from '../hooks/useDelayedFlag'
86
import { Ad } from '../types'
97
import './AdvBanner.css'
108

@@ -14,12 +12,8 @@ type AdvBannerProps = {
1412
loadingState?: React.ReactNode
1513
}
1614

17-
export const AdvBanner = ({ feedDisplay = false, loadingState, onAdLoaded }: AdvBannerProps) => {
15+
export const AdvBanner = ({ loadingState, onAdLoaded }: AdvBannerProps) => {
1816
const { userSelectedTags } = useUserPreferences()
19-
const adsFetchDelayMs = useRemoteConfigStore((s) => s.adsFetchDelayMs)
20-
const delay = isWebOrExtensionVersion() === 'extension' ? adsFetchDelayMs : undefined
21-
const isReady = useDelayedFlag(delay)
22-
2317
const {
2418
isSuccess,
2519
data: ad,
@@ -31,7 +25,6 @@ export const AdvBanner = ({ feedDisplay = false, loadingState, onAdLoaded }: Adv
3125
config: {
3226
cacheTime: 0,
3327
staleTime: 0,
34-
enabled: isReady,
3528
useErrorBoundary: false,
3629
},
3730
})
@@ -50,85 +43,22 @@ export const AdvBanner = ({ feedDisplay = false, loadingState, onAdLoaded }: Adv
5043
return null
5144
}
5245

53-
if (ad.largeImage) {
46+
const onAdClick = () => {
47+
if (ad?.id) {
48+
trackMarketingCampaignOpen(ad.id, {
49+
source: 'card',
50+
})
51+
}
52+
}
53+
if (ad.type === 'house-ad-banner') {
5454
return (
55-
<>
56-
<div
57-
className="carbonCoverTarget"
58-
style={
59-
{
60-
'--ad-dynamic-bg-image': `url(${ad.largeImage})`,
61-
'--ad-gradient-color': ad.backgroundColor,
62-
} as React.CSSProperties
63-
}>
64-
<a href={ad.link} className="carbonCover">
65-
<img className="carbonCoverImage" src={ad.largeImage} />
66-
<div className="carbonCoverMain">
67-
<img className="carbonCoverLogo" src={ad.logo} />
68-
<div className="carbonCoverTagline">{ad.companyTagline}</div>
69-
<div className="carbonCoverDescription">{ad.description}</div>
70-
<div className="carbonCoverButton">{ad.callToAction + ' ↗'}</div>
71-
</div>
72-
</a>
73-
</div>
74-
{ad.viewUrl &&
75-
ad.viewUrl
76-
.split('||')
77-
.map((viewUrl, i) => (
78-
<img
79-
key={i}
80-
src={viewUrl.replace('[timestamp]', `${Math.round(Date.now() / 10000) | 0}`)}
81-
className="hidden"
82-
alt=""
83-
/>
84-
))}
85-
</>
55+
<div className="houseBanner">
56+
<a onClick={onAdClick} href={ad.link} target="_blank" title={ad.title}>
57+
<img src={ad.imageUrl} alt={ad.title} />
58+
</a>
59+
</div>
8660
)
8761
}
8862

89-
return (
90-
<>
91-
<div className="banneradv">
92-
<a
93-
href={ad.link}
94-
className="img"
95-
target="_blank"
96-
rel="noopener sponsored noreferrer"
97-
title={ad.title}>
98-
<img
99-
src={ad.imageUrl}
100-
alt={ad.title}
101-
height={!feedDisplay ? '120' : '200'}
102-
width={!feedDisplay ? '156' : '260'}
103-
style={{ border: 0 }}
104-
/>
105-
</a>
106-
107-
<a href={ad.link} className="text" target="_blank" rel="noopener sponsored noreferrer">
108-
{ad.description}
109-
</a>
110-
111-
{!feedDisplay && (
112-
<a
113-
href={ad.provider.link}
114-
className="poweredby"
115-
target="_blank"
116-
rel="noopener sponsored noreferrer">
117-
{ad.provider.title}
118-
</a>
119-
)}
120-
</div>
121-
{ad.viewUrl &&
122-
ad.viewUrl
123-
.split('||')
124-
.map((viewUrl, i) => (
125-
<img
126-
key={i}
127-
src={viewUrl.replace('[timestamp]', `${Math.round(Date.now() / 10000) | 0}`)}
128-
className="hidden"
129-
alt=""
130-
/>
131-
))}
132-
</>
133-
)
63+
return null
13464
}

src/features/adv/types/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ type NextAdType = {
99
interval: number
1010
}
1111

12-
export type Ad = {
12+
export type Ad = DefaultAd | HouseAdBanner
13+
14+
export type DefaultAd = {
15+
id?: string
16+
type?: string
1317
title?: string
1418
description: string
1519
imageUrl: string
@@ -25,3 +29,11 @@ export type Ad = {
2529
callToAction?: string
2630
company?: string
2731
}
32+
export type HouseAdBanner = {
33+
type: 'house-ad-banner'
34+
id: string
35+
title: string
36+
description: string
37+
link: string
38+
imageUrl: string
39+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import ReactModal from 'react-modal'
2+
import { DonateView } from './DonateView'
3+
import './donate.css'
4+
5+
type DonateModalProps = {
6+
setModalOpen: (open: boolean) => void
7+
}
8+
export const DonateModal = ({ setModalOpen }: DonateModalProps) => {
9+
return (
10+
<ReactModal
11+
isOpen={true}
12+
ariaHideApp={false}
13+
shouldCloseOnEsc={false}
14+
shouldCloseOnOverlayClick={false}
15+
shouldFocusAfterRender={false}
16+
className="Modal scrollable donateModal"
17+
style={{
18+
overlay: {
19+
zIndex: 3,
20+
},
21+
}}
22+
overlayClassName="Overlay">
23+
<DonateView setModalOpen={setModalOpen} />
24+
</ReactModal>
25+
)
26+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { useEffect } from 'react'
2+
import { FaCrown } from 'react-icons/fa'
3+
import { TbCheck } from 'react-icons/tb'
4+
import { Button } from 'src/components/Elements'
5+
import { useRemoteConfigStore } from 'src/features/remoteConfig'
6+
import { trackMarketingCampaignOpen, trackMarketingCampaignView } from 'src/lib/analytics'
7+
8+
type DonateViewProps = {
9+
setModalOpen: (open: boolean) => void
10+
}
11+
export const DonateView = ({ setModalOpen }: DonateViewProps) => {
12+
const { paywall } = useRemoteConfigStore()
13+
14+
useEffect(() => {
15+
if (paywall?.id) {
16+
trackMarketingCampaignView(paywall.id, {
17+
source: 'modal',
18+
})
19+
}
20+
}, [paywall?.id])
21+
22+
if (!paywall) {
23+
return null
24+
}
25+
const { headerImage, ctaUrl, cta, leadDescription, caption, features } = paywall
26+
return (
27+
<div className="donateView">
28+
<header>
29+
<img src={headerImage} alt="Header img" />
30+
</header>
31+
<div className="body">
32+
<p className="leadDescription">{leadDescription}</p>
33+
<p className="description">
34+
<b>What you get</b>
35+
<ul className="features">
36+
{features.map((feature, index) => {
37+
return (
38+
<li key={index}>
39+
<TbCheck className="checkIcon" /> {feature}
40+
</li>
41+
)
42+
}) || []}
43+
</ul>
44+
</p>
45+
<div className="buttonsWrapper">
46+
<Button size="medium" className="cancelButton" onClick={() => setModalOpen(false)}>
47+
Cancel
48+
</Button>
49+
50+
<Button
51+
size="medium"
52+
className="upgradeButton"
53+
startIcon={<FaCrown />}
54+
onClick={() => {
55+
window.open(ctaUrl, '_blank')
56+
trackMarketingCampaignOpen(paywall.id, {
57+
source: 'modal',
58+
})
59+
setModalOpen(false)
60+
}}>
61+
{cta}
62+
</Button>
63+
</div>
64+
<p className="caption">{caption}</p>
65+
</div>
66+
</div>
67+
)
68+
}

0 commit comments

Comments
 (0)