diff --git a/package.json b/package.json
index b69dd9b1d9..d4823a77b7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/images/common/outreach-popup-avatar.png b/public/images/common/outreach-popup-avatar.png
new file mode 100644
index 0000000000..1b2606f823
Binary files /dev/null and b/public/images/common/outreach-popup-avatar.png differ
diff --git a/src/components/common/CookieAndTermBanner/index.tsx b/src/components/common/CookieAndTermBanner/index.tsx
index 559684508e..3c31806db4 100644
--- a/src/components/common/CookieAndTermBanner/index.tsx
+++ b/src/components/common/CookieAndTermBanner/index.tsx
@@ -162,7 +162,7 @@ const CookieBannerPopup = (): ReactElement | null => {
}
}, [dispatch, shouldOpen])
- return cookiePopup?.open ? (
+ return cookiePopup.open ? (
diff --git a/src/components/common/CookieAndTermBanner/styles.module.css b/src/components/common/CookieAndTermBanner/styles.module.css
index 2ccbe89d17..aa540b514d 100644
--- a/src/components/common/CookieAndTermBanner/styles.module.css
+++ b/src/components/common/CookieAndTermBanner/styles.module.css
@@ -17,7 +17,7 @@
}
@media (max-width: 599.95px) {
- .container {
+ .popup {
right: 0;
bottom: 0;
}
diff --git a/src/features/targetedOutreach/components/OutreachPopup/index.tsx b/src/features/targetedOutreach/components/OutreachPopup/index.tsx
new file mode 100644
index 0000000000..d37c9ea672
--- /dev/null
+++ b/src/features/targetedOutreach/components/OutreachPopup/index.tsx
@@ -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(OUTREACH_LS_KEY)
+ const currentChainId = useChainId()
+ const safeAddress = useSafeAddress()
+ const wallet = useWallet()
+ const submission = useSubmission()
+
+ const [askAgainLaterTimestamp, setAskAgainLaterTimestamp] = useSessionStorage(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
+
+ {(safeTheme: Theme) => (
+
+
+
+
+
+
+
+ Clem Bihorel
+
+ Product Lead
+
+
+
+
+
+ EARN REWARDS
+
+ }
+ />
+
+
+ You're invited!
+
+
+ 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.
+
+
+
+
+ It'll only take 2 minutes.
+
+
+
+
+
+
+ )}
+
+ )
+}
+export default OutreachPopup
diff --git a/src/features/targetedOutreach/components/OutreachPopup/styles.module.css b/src/features/targetedOutreach/components/OutreachPopup/styles.module.css
new file mode 100644
index 0000000000..22f2c9b686
--- /dev/null
+++ b/src/features/targetedOutreach/components/OutreachPopup/styles.module.css
@@ -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;
+ }
+}
diff --git a/src/features/targetedOutreach/constants.ts b/src/features/targetedOutreach/constants.ts
new file mode 100644
index 0000000000..3b8a5157e8
--- /dev/null
+++ b/src/features/targetedOutreach/constants.ts
@@ -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
diff --git a/src/features/targetedOutreach/hooks/__tests__/useShowOutreachPopup.test.ts b/src/features/targetedOutreach/hooks/__tests__/useShowOutreachPopup.test.ts
new file mode 100644
index 0000000000..bd4d0e620e
--- /dev/null
+++ b/src/features/targetedOutreach/hooks/__tests__/useShowOutreachPopup.test.ts
@@ -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)
+ })
+})
diff --git a/src/features/targetedOutreach/hooks/useShowOutreachPopup.tsx b/src/features/targetedOutreach/hooks/useShowOutreachPopup.tsx
new file mode 100644
index 0000000000..5dbde24869
--- /dev/null
+++ b/src/features/targetedOutreach/hooks/useShowOutreachPopup.tsx
@@ -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
diff --git a/src/features/targetedOutreach/hooks/useSubmission.tsx b/src/features/targetedOutreach/hooks/useSubmission.tsx
new file mode 100644
index 0000000000..0f89b099fc
--- /dev/null
+++ b/src/features/targetedOutreach/hooks/useSubmission.tsx
@@ -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
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 70ff8bcfd2..f960836ede 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -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
@@ -129,6 +130,8 @@ const WebCoreApp = ({
+
+
diff --git a/src/services/analytics/events/outreach.ts b/src/services/analytics/events/outreach.ts
new file mode 100644
index 0000000000..d726fb5d44
--- /dev/null
+++ b/src/services/analytics/events/outreach.ts
@@ -0,0 +1,16 @@
+const OUTREACH_CATEGORY = 'outreach'
+
+export const OUTREACH_EVENTS = {
+ CLOSE_POPUP: {
+ action: 'Close outreach popup',
+ category: OUTREACH_CATEGORY,
+ },
+ ASK_AGAIN_LATER: {
+ action: 'Ask again later',
+ category: OUTREACH_CATEGORY,
+ },
+ OPEN_SURVEY: {
+ action: 'Open outreach survey',
+ category: OUTREACH_CATEGORY,
+ },
+}
diff --git a/src/services/local-storage/useLocalStorage.ts b/src/services/local-storage/useLocalStorage.ts
index 0c303e1651..3c65e4b0c7 100644
--- a/src/services/local-storage/useLocalStorage.ts
+++ b/src/services/local-storage/useLocalStorage.ts
@@ -1,6 +1,8 @@
import { useCallback, useEffect } from 'react'
import ExternalStore from '../ExternalStore'
+import session from './session'
import local from './local'
+import type Storage from './Storage'
// The setter accepts T or a function that takes the old value and returns T
// Mimics the behavior of useState
@@ -11,7 +13,7 @@ export type Setter = (val: T | ((prevVal: Undefinable) => Undefinable))
// External stores for each localStorage key which act as a shared cache for LS
const externalStores: Record> = {}
-const useLocalStorage = (key: string): [Undefinable, Setter] => {
+const useStorage = (key: string, storage: Storage): [Undefinable, Setter] => {
if (!externalStores[key]) {
externalStores[key] = new ExternalStore()
}
@@ -25,31 +27,31 @@ const useLocalStorage = (key: string): [Undefinable, Setter] => {
const newValue = value instanceof Function ? value(oldValue) : value
if (newValue !== oldValue) {
- local.setItem(key, newValue)
+ storage.setItem(key, newValue)
}
return newValue
})
},
- [key, setStore],
+ [key, setStore, storage],
)
// Set the initial value from LS on mount
useEffect(() => {
if (getStore() === undefined) {
- const lsValue = local.getItem(key)
+ const lsValue = storage.getItem(key)
if (lsValue !== null) {
setStore(lsValue)
}
}
- }, [key, getStore, setStore])
+ }, [key, getStore, setStore, storage])
// Subscribe to changes in local storage and update the cache
// This will work across tabs
useEffect(() => {
const onStorageEvent = (event: StorageEvent) => {
- if (event.key === local.getPrefixedKey(key)) {
- const lsValue = local.getItem(key)
+ if (event.key === storage.getPrefixedKey(key)) {
+ const lsValue = storage.getItem(key)
if (lsValue !== null && lsValue !== getStore()) {
setStore(lsValue)
}
@@ -61,9 +63,19 @@ const useLocalStorage = (key: string): [Undefinable, Setter] => {
return () => {
window.removeEventListener('storage', onStorageEvent)
}
- }, [key, getStore, setStore])
+ }, [key, getStore, setStore, storage])
return [useStore(), setNewValue]
}
+const useLocalStorage = (key: string): [Undefinable, Setter] => {
+ const localStorage = useStorage(key, local)
+ return localStorage
+}
+
+export const useSessionStorage = (key: string): [Undefinable, Setter] => {
+ const localStorage = useStorage(key, session)
+ return localStorage
+}
+
export default useLocalStorage
diff --git a/src/store/api/gateway/index.ts b/src/store/api/gateway/index.ts
index 63fc8a4aba..d626c91d9f 100644
--- a/src/store/api/gateway/index.ts
+++ b/src/store/api/gateway/index.ts
@@ -5,6 +5,7 @@ import { asError } from '@/services/exceptions/utils'
import { getDelegates } from '@safe-global/safe-gateway-typescript-sdk'
import type { DelegateResponse } from '@safe-global/safe-gateway-typescript-sdk/dist/types/delegates'
import { safeOverviewEndpoints } from './safeOverviews'
+import { getSubmission } from '@safe-global/safe-client-gateway-sdk'
async function buildQueryFn(fn: () => Promise) {
try {
@@ -33,6 +34,16 @@ export const gatewayApi = createApi({
return buildQueryFn(() => getDelegates(chainId, { safe: safeAddress }))
},
}),
+ getSubmission: builder.query<
+ getSubmission,
+ { outreachId: number; chainId: string; safeAddress: string; signerAddress: string }
+ >({
+ queryFn({ outreachId, chainId, safeAddress, signerAddress }) {
+ return buildQueryFn(() =>
+ getSubmission({ params: { path: { outreachId, chainId, safeAddress, signerAddress } } }),
+ )
+ },
+ }),
...safeOverviewEndpoints(builder),
}),
})
@@ -42,6 +53,7 @@ export const {
useGetMultipleTransactionDetailsQuery,
useLazyGetTransactionDetailsQuery,
useGetDelegatesQuery,
+ useGetSubmissionQuery,
useGetSafeOverviewQuery,
useGetMultipleSafeOverviewsQuery,
} = gatewayApi
diff --git a/src/store/popupSlice.ts b/src/store/popupSlice.ts
index 831fcc5c90..2140d57254 100644
--- a/src/store/popupSlice.ts
+++ b/src/store/popupSlice.ts
@@ -5,6 +5,7 @@ import type { RootState } from '.'
export enum PopupType {
COOKIES = 'cookies',
+ OUTREACH = 'outreach',
}
type PopupState = {
@@ -12,12 +13,18 @@ type PopupState = {
open: boolean
warningKey?: CookieAndTermType
}
+ [PopupType.OUTREACH]: {
+ open: boolean
+ }
}
const initialState: PopupState = {
[PopupType.COOKIES]: {
open: false,
},
+ [PopupType.OUTREACH]: {
+ open: false,
+ },
}
export const popupSlice = createSlice({
@@ -33,9 +40,16 @@ export const popupSlice = createSlice({
closeCookieBanner: (state) => {
state[PopupType.COOKIES] = { open: false }
},
+ openOutreachBanner: (state) => {
+ state[PopupType.OUTREACH] = { open: true }
+ },
+ closeOutreachBanner: (state) => {
+ state[PopupType.OUTREACH] = { open: false }
+ },
},
})
-export const { openCookieBanner, closeCookieBanner } = popupSlice.actions
+export const { openCookieBanner, closeCookieBanner, openOutreachBanner, closeOutreachBanner } = popupSlice.actions
export const selectCookieBanner = (state: RootState) => state[popupSlice.name][PopupType.COOKIES]
+export const selectOutreachBanner = (state: RootState) => state[popupSlice.name][PopupType.OUTREACH]
diff --git a/yarn.lock b/yarn.lock
index 780cdd118d..3ffec1e41e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4195,10 +4195,10 @@
"@safe-global/safe-gateway-typescript-sdk" "^3.5.3"
viem "^2.1.1"
-"@safe-global/safe-client-gateway-sdk@1.58.0-next-25bba61":
- version "1.58.0-next-25bba61"
- resolved "https://registry.yarnpkg.com/@safe-global/safe-client-gateway-sdk/-/safe-client-gateway-sdk-1.58.0-next-25bba61.tgz#1e5906d0a492ee72a77fbe1d782fee0376576968"
- integrity sha512-sm19eckk6yjGOSgOOk4wE8pD7Ygdkr81ANyJUnOH1hr6RDxLgIkhvvatEI5aPB4vFD/iAKiJ2zMq19Pk1o41EA==
+"@safe-global/safe-client-gateway-sdk@v1.60.1":
+ version "1.60.1"
+ resolved "https://registry.yarnpkg.com/@safe-global/safe-client-gateway-sdk/-/safe-client-gateway-sdk-1.60.1.tgz#4f24a4c7f0ba04a82a2208bd14163cde46189661"
+ integrity sha512-3vdDOSXLlvx9B+bo15MPTRGPrUn5jJEvtvWF0esY1oFWfI9riQOttWIIyQGYgDZhxMd+qW0aYKNMpn8DTP+7rw==
dependencies:
openapi-fetch "0.10.5"