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"