-
Notifications
You must be signed in to change notification settings - Fork 476
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat: Targeted survey banner popup [SW-269] (#4338)
* 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
Showing
16 changed files
with
413 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,7 +17,7 @@ | |
} | ||
|
||
@media (max-width: 599.95px) { | ||
.container { | ||
.popup { | ||
right: 0; | ||
bottom: 0; | ||
} | ||
|
129 changes: 129 additions & 0 deletions
129
src/features/targetedOutreach/components/OutreachPopup/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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're invited! | ||
</Typography> | ||
<Typography> | ||
As one of our top users, we'd love to hear your feedback on how we can enhance Safe. Share your | ||
contact info, and we'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'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 |
28 changes: 28 additions & 0 deletions
28
src/features/targetedOutreach/components/OutreachPopup/styles.module.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
120 changes: 120 additions & 0 deletions
120
src/features/targetedOutreach/hooks/__tests__/useShowOutreachPopup.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
29
src/features/targetedOutreach/hooks/useShowOutreachPopup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.