Skip to content

Commit

Permalink
Feat: Targeted survey banner popup [SW-269] (#4338)
Browse files Browse the repository at this point in the history
* feat: add outreach popup

* feat: add close and ask again later funcitonality

* Feat: finish implementing design of outreach popup.

* refactor: extract getUpdatedUserActivity to utils

* fix: mobile popup styles

* fix: do not show at the same time as cookie banner

* fix: fix dark mode styles

* Chore: add tracking to outreach banner

* fix: mock cookie banner state in unit tests

* tests: add unit tests for utils

* fix: update import

* feat: use light theme also in dark mode

* feat: ask again after 24 hours or after current session

* remove unused utils

* feat: only show banner if the safe is targeted for the current outreach and has not already clicked the CTA

* use faker for ethereum addresses in the tests

* fix: cache getSubmission result to avoid multiple requests

* fix: some non targeted safes showing banner
  • Loading branch information
jmealy authored Oct 30, 2024
1 parent d744ac9 commit e828002
Show file tree
Hide file tree
Showing 16 changed files with 413 additions and 16 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"@safe-global/protocol-kit": "^4.1.1",
"@safe-global/safe-apps-sdk": "^9.1.0",
"@safe-global/safe-deployments": "1.37.12",
"@safe-global/safe-client-gateway-sdk": "1.58.0-next-25bba61",
"@safe-global/safe-client-gateway-sdk": "v1.60.1",
"@safe-global/safe-gateway-typescript-sdk": "3.22.3-beta.15",
"@safe-global/safe-modules-deployments": "^2.2.1",
"@sentry/react": "^7.91.0",
Expand Down
Binary file added public/images/common/outreach-popup-avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/components/common/CookieAndTermBanner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ const CookieBannerPopup = (): ReactElement | null => {
}
}, [dispatch, shouldOpen])

return cookiePopup?.open ? (
return cookiePopup.open ? (
<div className={css.popup}>
<CookieAndTermBanner warningKey={cookiePopup.warningKey} inverted />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
}

@media (max-width: 599.95px) {
.container {
.popup {
right: 0;
bottom: 0;
}
Expand Down
129 changes: 129 additions & 0 deletions src/features/targetedOutreach/components/OutreachPopup/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect, type ReactElement } from 'react'
import { Avatar, Box, Button, Chip, IconButton, Link, Paper, Stack, ThemeProvider, Typography } from '@mui/material'
import { Close } from '@mui/icons-material'
import type { Theme } from '@mui/material/styles'
import { useAppDispatch, useAppSelector } from '@/store'
import css from './styles.module.css'
import { closeOutreachBanner, openOutreachBanner, selectOutreachBanner } from '@/store/popupSlice'
import useLocalStorage, { useSessionStorage } from '@/services/local-storage/useLocalStorage'
import useShowOutreachPopup from '@/features/targetedOutreach/hooks/useShowOutreachPopup'
import { ACTIVE_OUTREACH, OUTREACH_LS_KEY, OUTREACH_SS_KEY } from '@/features/targetedOutreach/constants'
import Track from '@/components/common/Track'
import { OUTREACH_EVENTS } from '@/services/analytics/events/outreach'
import SafeThemeProvider from '@/components/theme/SafeThemeProvider'
import useChainId from '@/hooks/useChainId'
import useSafeAddress from '@/hooks/useSafeAddress'
import useWallet from '@/hooks/wallets/useWallet'
import { createSubmission } from '@safe-global/safe-client-gateway-sdk'
import useSubmission from '@/features/targetedOutreach/hooks/useSubmission'

const OutreachPopup = (): ReactElement | null => {
const dispatch = useAppDispatch()
const outreachPopup = useAppSelector(selectOutreachBanner)
const [isClosed, setIsClosed] = useLocalStorage<boolean>(OUTREACH_LS_KEY)
const currentChainId = useChainId()
const safeAddress = useSafeAddress()
const wallet = useWallet()
const submission = useSubmission()

const [askAgainLaterTimestamp, setAskAgainLaterTimestamp] = useSessionStorage<number>(OUTREACH_SS_KEY)

const shouldOpen = useShowOutreachPopup(isClosed, askAgainLaterTimestamp, submission)

const handleClose = () => {
setIsClosed(true)
dispatch(closeOutreachBanner())
}

const handleAskAgainLater = () => {
setAskAgainLaterTimestamp(Date.now())
dispatch(closeOutreachBanner())
}

// Decide whether to show the popup.
useEffect(() => {
if (shouldOpen) {
dispatch(openOutreachBanner())
} else {
dispatch(closeOutreachBanner())
}
}, [dispatch, shouldOpen])

if (!outreachPopup.open) return null

const handleOpenSurvey = async () => {
if (wallet) {
await createSubmission({
params: {
path: { outreachId: ACTIVE_OUTREACH.id, chainId: currentChainId, safeAddress, signerAddress: wallet.address },
},
body: { completed: true },
})
}
dispatch(closeOutreachBanner())
}

return (
// Enforce light theme for the popup
<SafeThemeProvider mode="light">
{(safeTheme: Theme) => (
<ThemeProvider theme={safeTheme}>
<Box className={css.popup}>
<Paper className={css.container}>
<Stack gap={2}>
<Box display="flex">
<Avatar alt="Clem Bihorel" src="/images/common/outreach-popup-avatar.png" />
<Box ml={1}>
<Typography variant="body2">Clem Bihorel</Typography>
<Typography variant="body2" color="primary.light">
Product Lead
</Typography>
</Box>
</Box>
<Box>
<Chip
size="small"
sx={{ backgroundColor: 'text.primary', color: 'background.paper', mt: '-2px' }}
label={
<Typography fontWeight={700} variant="overline">
EARN REWARDS
</Typography>
}
/>
</Box>
<Typography variant="h4" fontWeight={700}>
You&apos;re invited!
</Typography>
<Typography>
As one of our top users, we&apos;d love to hear your feedback on how we can enhance Safe. Share your
contact info, and we&apos;ll reach out for a short interview.
</Typography>
<Track {...OUTREACH_EVENTS.OPEN_SURVEY}>
<Link rel="noreferrer noopener" target="_blank" href={ACTIVE_OUTREACH.url}>
<Button fullWidth variant="contained" onClick={handleOpenSurvey}>
Get Involved
</Button>
</Link>
</Track>
<Track {...OUTREACH_EVENTS.ASK_AGAIN_LATER}>
<Button fullWidth variant="text" onClick={handleAskAgainLater}>
Ask me later
</Button>
</Track>
<Typography variant="body2" color="primary.light" mx="auto">
It&apos;ll only take 2 minutes.
</Typography>
<Track {...OUTREACH_EVENTS.CLOSE_POPUP}>
<IconButton className={css.close} aria-label="close" onClick={handleClose}>
<Close />
</IconButton>
</Track>
</Stack>
</Paper>
</Box>
</ThemeProvider>
)}
</SafeThemeProvider>
)
}
export default OutreachPopup
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.popup {
position: fixed;
z-index: 1300;
bottom: var(--space-2);
right: var(--space-2);
max-width: 400px;
}

.container {
padding: var(--space-2);
border-radius: var(--space-2);
background: linear-gradient(180deg, #b0ffc9 0%, #d7f6ff 99.5%);
}

.close {
position: absolute;
right: var(--space-1);
top: var(--space-1);
z-index: 1;
padding: var(--space-1);
}

@media (max-width: 599.99px) {
.popup {
right: 0;
bottom: 0;
}
}
7 changes: 7 additions & 0 deletions src/features/targetedOutreach/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const ACTIVE_OUTREACH = { id: 1, url: 'https://wn2n6ocviur.typeform.com/to/J1OK3Ikf' }

export const OUTREACH_LS_KEY = 'outreachPopup'
export const OUTREACH_SS_KEY = 'outreachPopup_session'

export const HOUR_IN_MS = 60 * 60 * 1000
export const MAX_ASK_AGAIN_DELAY = HOUR_IN_MS * 24
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { renderHook } from '@testing-library/react'
import useShowOutreachPopup from '../useShowOutreachPopup'
import * as useIsSafeOwner from '@/hooks/useIsSafeOwner'
import * as store from '@/store'
import { HOUR_IN_MS } from '../../constants'
import { faker } from '@faker-js/faker/.'

jest.mock('@/hooks/useIsSafeOwner')
jest.mock('@/store')

beforeEach(() => {
jest.mock('@/hooks/useSafeAddress', () => ({
default: jest.fn(() => faker.finance.ethereumAddress()),
}))
})

afterEach(() => {
jest.resetAllMocks()
})

describe('useShowOutreachPopup', () => {
it('should return false when the cookie banner is open', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: true }) // mock cookie banner state

const submission = {
outreachId: 1,
targetedSafeId: 1,
signerAddress: faker.finance.ethereumAddress(),
completionDate: null,
}

const { result } = renderHook(() => useShowOutreachPopup(false, undefined, submission))
expect(result.current).toEqual(false)
})

it('should return false for targeted safes that are already marked as completed', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: false })

const submission = {
outreachId: 1,
targetedSafeId: 1,
signerAddress: faker.finance.ethereumAddress(),
completionDate: faker.date.recent().getTime().toString(),
}
const { result } = renderHook(() => useShowOutreachPopup(false, undefined, submission))
expect(result.current).toEqual(false)
})

it('should return false for non targeted safes', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: false })

const submission = undefined

const { result } = renderHook(() => useShowOutreachPopup(false, undefined, submission))
expect(result.current).toEqual(false)
})

it('should return true for signers of targeted safes', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: false })

const submission = {
outreachId: 1,
targetedSafeId: 1,
signerAddress: faker.finance.ethereumAddress(),
completionDate: null,
}

const { result } = renderHook(() => useShowOutreachPopup(false, undefined, submission))
expect(result.current).toEqual(true)
})

it('should return false if a targeted user has previously closed the popup', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: false })

const submission = {
outreachId: 1,
targetedSafeId: 1,
signerAddress: faker.finance.ethereumAddress(),
completionDate: null,
}

const { result } = renderHook(() => useShowOutreachPopup(true, undefined, submission))
expect(result.current).toEqual(false)
})

it('should return false if the user has chosen ask me later within the same session and before the maximum delay', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: false })

const submission = {
outreachId: 1,
targetedSafeId: 1,
signerAddress: faker.finance.ethereumAddress(),
completionDate: null,
}

const { result } = renderHook(() => useShowOutreachPopup(false, Date.now() - HOUR_IN_MS * 2, submission))
expect(result.current).toEqual(false)
})

it('should return true if the user has chosen ask me later within the same session but after the maximum delay of 24 hours', async () => {
jest.spyOn(useIsSafeOwner, 'default').mockReturnValueOnce(true)
jest.spyOn(store, 'useAppSelector').mockReturnValueOnce({ open: false })

const submission = {
outreachId: 1,
targetedSafeId: 1,
signerAddress: faker.finance.ethereumAddress(),
completionDate: null,
}

const { result } = renderHook(() => useShowOutreachPopup(false, Date.now() - HOUR_IN_MS * 25, submission))
expect(result.current).toEqual(true)
})
})
29 changes: 29 additions & 0 deletions src/features/targetedOutreach/hooks/useShowOutreachPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
import { MAX_ASK_AGAIN_DELAY } from '@/features/targetedOutreach/constants'
import { useAppSelector } from '@/store'
import { selectCookieBanner } from '@/store/popupSlice'
import type { getSubmission } from '@safe-global/safe-client-gateway-sdk'

const useShowOutreachPopup = (
isDismissed: boolean | undefined,
askAgainLaterTimestamp: number | undefined,
submission: getSubmission | undefined,
) => {
const cookiesPopup = useAppSelector(selectCookieBanner)
const isSigner = useIsSafeOwner()

const isTargetedSafe = !!submission?.outreachId
const hasCompletedSurvey = !!submission?.completionDate

if (cookiesPopup?.open || isDismissed || !isSigner || !isTargetedSafe || hasCompletedSurvey) {
return false
}

if (askAgainLaterTimestamp) {
return Date.now() - askAgainLaterTimestamp > MAX_ASK_AGAIN_DELAY
}

return true
}

export default useShowOutreachPopup
27 changes: 27 additions & 0 deletions src/features/targetedOutreach/hooks/useSubmission.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useGetSubmissionQuery } from '@/store/slices'
import { ACTIVE_OUTREACH } from '../constants'
import useChainId from '@/hooks/useChainId'
import useSafeAddress from '@/hooks/useSafeAddress'
import useWallet from '@/hooks/wallets/useWallet'
import { skipToken } from '@reduxjs/toolkit/query'

const useSubmission = () => {
const currentChainId = useChainId()
const safeAddress = useSafeAddress()
const wallet = useWallet()

const { data } = useGetSubmissionQuery(
!wallet || !safeAddress
? skipToken
: {
outreachId: ACTIVE_OUTREACH.id,
chainId: currentChainId,
safeAddress,
signerAddress: wallet?.address,
},
)

return data
}

export default useSubmission
3 changes: 3 additions & 0 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import WalletProvider from '@/components/common/WalletProvider'
import CounterfactualHooks from '@/features/counterfactual/CounterfactualHooks'
import PkModulePopup from '@/services/private-key-module/PkModulePopup'
import GeoblockingProvider from '@/components/common/GeoblockingProvider'
import OutreachPopup from '@/features/targetedOutreach/components/OutreachPopup'

export const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING

Expand Down Expand Up @@ -129,6 +130,8 @@ const WebCoreApp = ({

<CookieAndTermBanner />

<OutreachPopup />

<Notifications />

<Recovery />
Expand Down
Loading

0 comments on commit e828002

Please sign in to comment.