diff --git a/cypress/e2e/smoke/dashboard.cy.js b/cypress/e2e/smoke/dashboard.cy.js
index 02d8785a83..738183bbc1 100644
--- a/cypress/e2e/smoke/dashboard.cy.js
+++ b/cypress/e2e/smoke/dashboard.cy.js
@@ -58,11 +58,11 @@ describe('Dashboard', () => {
cy.get(`main section#featured-safe-apps a[href*="?appUrl=http"]`).should('have.length', 2)
})
- // it('should show the Safe Apps widget', () => {
- // cy.contains('main section#safe-apps h2', 'Safe Apps')
- // cy.contains('main section#safe-apps a[href="/app/apps"] button', 'Explore Safe Apps')
+ it('should show the Safe Apps Section', () => {
+ cy.contains('main section h2', 'Safe Apps')
+ cy.contains('main section ', 'Explore Safe Apps')
- // // Regular safe apps
- // cy.get(`main section#safe-apps a[href^="/app/${SAFE}/apps?appUrl=http"]`).should('have.length', 5)
- // })
+ // Regular safe apps
+ cy.get(`main section a[href^="/apps?safe=${SAFE}&appUrl=http"]`).should('have.length', 5)
+ })
})
diff --git a/jest.setup.js b/jest.setup.js
index d0fa162674..bec7e2b84e 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -50,3 +50,9 @@ jest.mock('@web3-onboard/core', () => () => ({
get: () => mockOnboardState,
},
}))
+
+// to avoid failing tests in some environments
+const NumberFormat = Intl.NumberFormat
+const englishTestLocale = 'en'
+
+jest.spyOn(Intl, 'NumberFormat').mockImplementation((locale, ...rest) => new NumberFormat([englishTestLocale], ...rest))
diff --git a/public/images/explore.svg b/public/images/explore.svg
new file mode 100644
index 0000000000..63b976a833
--- /dev/null
+++ b/public/images/explore.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx
index 2b2f10b043..e928442b5e 100644
--- a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx
+++ b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx
@@ -112,6 +112,13 @@ describe('SafeTokenWidget', () => {
loading: false,
error: undefined,
}))
+ // to avoid failing tests in some environments
+ const NumberFormat = Intl.NumberFormat
+ const englishTestLocale = 'en'
+
+ jest
+ .spyOn(Intl, 'NumberFormat')
+ .mockImplementation((locale, ...rest) => new NumberFormat([englishTestLocale], ...rest))
const result = render()
await waitFor(() => {
diff --git a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx
index cea24c4e20..8db128a7db 100644
--- a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx
+++ b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx
@@ -39,7 +39,7 @@ export const FeaturedApps = (): ReactElement | null => {
return (
-
+
Connect & transact
diff --git a/src/components/dashboard/Overview/Overview.tsx b/src/components/dashboard/Overview/Overview.tsx
index bf08cce932..e1e9e08dc3 100644
--- a/src/components/dashboard/Overview/Overview.tsx
+++ b/src/components/dashboard/Overview/Overview.tsx
@@ -86,7 +86,7 @@ const Overview = (): ReactElement => {
return (
-
+
Overview
diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx
index b6d57991e2..f3394824b1 100644
--- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx
+++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx
@@ -84,7 +84,7 @@ const PendingTxsList = ({ size = 4 }: { size?: number }): ReactElement | null =>
return (
-
+
Transaction queue {totalQueuedTxs ? ` (${totalQueuedTxs})` : ''}
{totalQueuedTxs > 0 && }
diff --git a/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx b/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx
new file mode 100644
index 0000000000..ce2840b99f
--- /dev/null
+++ b/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx
@@ -0,0 +1,54 @@
+import { useRouter } from 'next/router'
+import Typography from '@mui/material/Typography'
+import Grid from '@mui/material/Grid'
+import Box from '@mui/material/Box'
+import Button from '@mui/material/Button'
+
+import { WidgetContainer } from '../styled'
+import { useSafeApps } from '@/hooks/safe-apps/useSafeApps'
+import { AppCard, AppCardContainer } from '@/components/safe-apps/AppCard'
+import { AppRoutes } from '@/config/routes'
+import ExploreSafeAppsIcon from '@/public/images/explore.svg'
+
+const SafeAppsDashboardSection = () => {
+ const { rankedSafeApps } = useSafeApps()
+
+ return (
+
+
+ Safe Apps
+
+
+
+ {rankedSafeApps.map((rankedSafeApp) => (
+
+
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export default SafeAppsDashboardSection
+
+const ExploreSafeAppsCard = () => {
+ const router = useRouter()
+ const safeAppsLink = `${AppRoutes.apps}?safe=${router.query.safe}`
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx b/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx
new file mode 100644
index 0000000000..9616c36651
--- /dev/null
+++ b/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx
@@ -0,0 +1,117 @@
+import * as safeAppsGatewaySDK from '@gnosis.pm/safe-react-gateway-sdk'
+import { render, screen, waitFor } from '@/tests/test-utils'
+import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection'
+
+jest.mock('@gnosis.pm/safe-react-gateway-sdk', () => ({
+ ...jest.requireActual('@gnosis.pm/safe-react-gateway-sdk'),
+ getSafeApps: (chainId: string) =>
+ Promise.resolve([
+ {
+ id: 13,
+ url: 'https://cloudflare-ipfs.com/ipfs/QmX31xCdhFDmJzoVG33Y6kJtJ5Ujw8r5EJJBrsp8Fbjm7k',
+ name: 'Compound',
+ iconUrl: 'https://cloudflare-ipfs.com/ipfs/QmX31xCdhFDmJzoVG33Y6kJtJ5Ujw8r5EJJBrsp8Fbjm7k/Compound.png',
+ description: 'Money markets on the Ethereum blockchain',
+ chainIds: ['1', '4'],
+ provider: undefined,
+ accessControl: {
+ type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions,
+ },
+ tags: [],
+ },
+ {
+ id: 3,
+ url: 'https://app.ens.domains',
+ name: 'ENS App',
+ iconUrl: 'https://app.ens.domains/android-chrome-144x144.png',
+ description: 'Decentralised naming for wallets, websites, & more.',
+ chainIds: ['1', '4'],
+ provider: undefined,
+ accessControl: {
+ type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.DomainAllowlist,
+ value: ['https://gnosis-safe.io'],
+ },
+ tags: [],
+ },
+ {
+ id: 14,
+ url: 'https://cloudflare-ipfs.com/ipfs/QmXLxxczMH4MBEYDeeN9zoiHDzVkeBmB5rBjA3UniPEFcA',
+ name: 'Synthetix',
+ iconUrl: 'https://cloudflare-ipfs.com/ipfs/QmXLxxczMH4MBEYDeeN9zoiHDzVkeBmB5rBjA3UniPEFcA/Synthetix.png',
+ description: 'Trade synthetic assets on Ethereum',
+ chainIds: ['1', '4'],
+ provider: undefined,
+ accessControl: {
+ type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions,
+ },
+ tags: [],
+ },
+ {
+ id: 24,
+ url: 'https://cloudflare-ipfs.com/ipfs/QmdVaZxDov4bVARScTLErQSRQoxgqtBad8anWuw3YPQHCs',
+ name: 'Transaction Builder',
+ iconUrl: 'https://cloudflare-ipfs.com/ipfs/QmdVaZxDov4bVARScTLErQSRQoxgqtBad8anWuw3YPQHCs/tx-builder.png',
+ description: 'A Safe app to compose custom transactions',
+ chainIds: ['1', '4', '56', '100', '137', '246', '73799'],
+ provider: undefined,
+ accessControl: {
+ type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.DomainAllowlist,
+ value: ['https://gnosis-safe.io'],
+ },
+ tags: [],
+ },
+ ]),
+}))
+
+describe('Safe Apps Dashboard Section', () => {
+ beforeEach(() => {
+ window.localStorage.clear()
+ const mostUsedApps = JSON.stringify({
+ 24: {
+ openCount: 2,
+ timestamp: 1663779409409,
+ txCount: 1,
+ },
+ 3: {
+ openCount: 1,
+ timestamp: 1663779409409,
+ txCount: 0,
+ },
+ })
+ window.localStorage.setItem('SAFE_v2__APPS_DASHBOARD', mostUsedApps)
+ })
+
+ afterEach(() => {
+ window.localStorage.clear()
+ })
+
+ it('should display the Safe Apps Section', async () => {
+ render()
+
+ await waitFor(() => expect(screen.getByText('Safe Apps')).toBeInTheDocument())
+ })
+
+ it('should display Safe Apps Cards (Name & Description)', async () => {
+ render()
+
+ await waitFor(() => expect(screen.getByText('Compound')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('Money markets on the Ethereum blockchain')).toBeInTheDocument())
+
+ await waitFor(() => expect(screen.getByText('ENS App')).toBeInTheDocument())
+ await waitFor(() =>
+ expect(screen.getByText('Decentralised naming for wallets, websites, & more.')).toBeInTheDocument(),
+ )
+
+ await waitFor(() => expect(screen.getByText('Synthetix')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('Trade synthetic assets on Ethereum')).toBeInTheDocument())
+
+ await waitFor(() => expect(screen.getByText('Transaction Builder')).toBeInTheDocument())
+ await waitFor(() => expect(screen.getByText('A Safe app to compose custom transactions')).toBeInTheDocument())
+ })
+
+ it('should show the Explore Safe Apps Link', async () => {
+ render()
+
+ await waitFor(() => expect(screen.getByText('Explore Safe Apps')).toBeInTheDocument())
+ })
+})
diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx
index 86cb92043d..6f4ff288b6 100644
--- a/src/components/dashboard/index.tsx
+++ b/src/components/dashboard/index.tsx
@@ -3,6 +3,7 @@ import { Grid } from '@mui/material'
import PendingTxsList from '@/components/dashboard/PendingTxs/PendingTxsList'
import Overview from '@/components/dashboard/Overview/Overview'
import { FeaturedApps } from '@/components/dashboard/FeaturedApps/FeaturedApps'
+import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection'
const Dashboard = (): ReactElement => {
return (
@@ -15,7 +16,13 @@ const Dashboard = (): ReactElement => {
-
+
+
+
+
+
+
+
)
}
diff --git a/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx b/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx
index 733859ba59..f900227cf2 100644
--- a/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx
+++ b/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx
@@ -17,12 +17,13 @@ import { getInteractionTitle } from '../utils'
import { SafeAppsTxParams } from '.'
import { isEmptyHexData } from '@/utils/hex'
import { dispatchSafeAppsTx } from '@/services/tx/txSender'
+import { trackSafeAppTxCount } from '@/services/safe-apps/track-app-usage-count'
type ReviewSafeAppsTxProps = {
safeAppsTx: SafeAppsTxParams
}
-const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params } }: ReviewSafeAppsTxProps): ReactElement => {
+const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params, appId } }: ReviewSafeAppsTxProps): ReactElement => {
const chainId = useChainId()
const chain = useCurrentChain()
const { safe } = useSafeInfo()
@@ -48,6 +49,7 @@ const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params } }: ReviewSafe
}, [safeTx, chainId])
const handleSubmit = (txId: string) => {
+ trackSafeAppTxCount(Number(appId))
dispatchSafeAppsTx(txId, requestId)
}
diff --git a/src/hooks/safe-apps/useRankedSafeApps.ts b/src/hooks/safe-apps/useRankedSafeApps.ts
new file mode 100644
index 0000000000..aa88ef0811
--- /dev/null
+++ b/src/hooks/safe-apps/useRankedSafeApps.ts
@@ -0,0 +1,30 @@
+import { useMemo } from 'react'
+import { sampleSize } from 'lodash'
+import { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk'
+import { getAppsUsageData, rankSafeApps } from '@/services/safe-apps/track-app-usage-count'
+
+// number of ranked Safe Apps that we include in the array
+const NUMBER_OF_SAFE_APPS = 5
+
+const useRankedSafeApps = (safeApps: SafeAppData[], pinnedSafeApps: SafeAppData[]): SafeAppData[] => {
+ return useMemo(() => {
+ if (!safeApps.length) return []
+
+ const usageSafeAppsData = getAppsUsageData()
+ const mostUsedSafeAppsIds = rankSafeApps(usageSafeAppsData, pinnedSafeApps).slice(0, NUMBER_OF_SAFE_APPS)
+ const mostUsedSafeApps = mostUsedSafeAppsIds
+ .map((id) => safeApps.find((safeApp) => String(safeApp.id) === id))
+ .filter(Boolean) as SafeAppData[]
+
+ // we add random Safe Apps if no enough Safe Apps are present
+ const numberOfRandomSafeApps = NUMBER_OF_SAFE_APPS - mostUsedSafeApps.length
+ const nonRankedApps = safeApps.filter((app) => !mostUsedSafeAppsIds.includes(String(app.id)))
+ const randomSafeApps = sampleSize(nonRankedApps, numberOfRandomSafeApps)
+
+ const rankedSafeApps = [...mostUsedSafeApps, ...randomSafeApps]
+
+ return rankedSafeApps
+ }, [safeApps, pinnedSafeApps])
+}
+
+export { useRankedSafeApps }
diff --git a/src/hooks/safe-apps/useSafeApps.ts b/src/hooks/safe-apps/useSafeApps.ts
index f6e279243b..81e898a447 100644
--- a/src/hooks/safe-apps/useSafeApps.ts
+++ b/src/hooks/safe-apps/useSafeApps.ts
@@ -3,6 +3,7 @@ import { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk'
import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps'
import { useCustomSafeApps } from '@/hooks/safe-apps/useCustomSafeApps'
import { usePinnedSafeApps } from '@/hooks/safe-apps/usePinnedSafeApps'
+import { useRankedSafeApps } from '@/hooks/safe-apps/useRankedSafeApps'
type ReturnType = {
allSafeApps: SafeAppData[]
@@ -10,6 +11,7 @@ type ReturnType = {
pinnedSafeAppIds: Set
remoteSafeApps: SafeAppData[]
customSafeApps: SafeAppData[]
+ rankedSafeApps: SafeAppData[]
remoteSafeAppsLoading: boolean
customSafeAppsLoading: boolean
remoteSafeAppsError?: Error
@@ -52,6 +54,8 @@ const useSafeApps = (): ReturnType => {
[remoteSafeApps, pinnedSafeAppIds],
)
+ const rankedSafeApps = useRankedSafeApps(allSafeApps, pinnedSafeApps)
+
const addCustomApp = useCallback(
(app: SafeAppData) => {
updateCustomSafeApps([...customSafeApps, app])
@@ -83,6 +87,7 @@ const useSafeApps = (): ReturnType => {
pinnedSafeApps,
pinnedSafeAppIds,
customSafeApps,
+ rankedSafeApps,
remoteSafeAppsLoading,
customSafeAppsLoading,
remoteSafeAppsError,