Skip to content
Draft
2 changes: 1 addition & 1 deletion apps/main/src/dex/components/MonadBannerAlert.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Banner } from '@ui-kit/shared/ui/Banner'
import { Stack } from '@mui/material'
import { t } from '@ui-kit/lib/i18n'
import { Banner } from '@ui-kit/shared/ui/Banner'
import { PoolUrlParams } from '../types/main.types'

const MonadBannerAlert = ({ params }: { params: PoolUrlParams }) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/main/src/dex/components/PagePool/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import usePoolAlert from '@/dex/hooks/usePoolAlert'
import useTokensMapper from '@/dex/hooks/useTokensMapper'
import { getUserPoolActiveKey } from '@/dex/store/createUserSlice'
import useStore from '@/dex/store/useStore'
import type { PoolUrlParams } from '@/dex/types/main.types'
import { getChainPoolIdActiveKey } from '@/dex/utils'
import { getPath } from '@/dex/utils/utilsRouter'
import { ManageGauge } from '@/dex/widgets/manage-gauge'
import type { PoolUrlParams } from '@/dex/types/main.types'
import Stack from '@mui/material/Stack'
import AlertBox from '@ui/AlertBox'
import { AppFormContentWrapper } from '@ui/AppForm'
Expand Down
20 changes: 16 additions & 4 deletions packages/curve-ui-kit/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import lodash from 'lodash'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import type { Address } from '@curvefi/prices-api'
import type { VisibilityVariants } from '@ui-kit/shared/ui/DataTable/visibility.types'
import { defaultReleaseChannel, ReleaseChannel } from '@ui-kit/utils'
import { type MigrationOptions, useStoredState } from './useStoredState'

const { kebabCase } = lodash

// old keys that are not used anymore - clean them up
window.localStorage.removeItem('phishing-warning-dismissed')

function getFromLocalStorage<T>(storageKey: string): T | null {
if (typeof window === 'undefined') {
return null
Expand Down Expand Up @@ -72,3 +69,18 @@ export const useFavoriteMarkets = () => {
const initialValue = useMemo(() => [], [])
return useLocalStorage<Address[]>('favoriteMarkets', initialValue)
}

export const useDismissBanner = (bannerKey: string, expirationTime: number) => {
const [dismissedAt, setDismissedAt] = useLocalStorage<number | null>(bannerKey, null)

const shouldShowBanner = useMemo(
() => dismissedAt == null || Date.now() - dismissedAt >= expirationTime, // Show if dismissed more than the expiration time
[dismissedAt, expirationTime],
)

const dismissBanner = useCallback(() => {
setDismissedAt(Date.now())
}, [setDismissedAt])

return { shouldShowBanner, dismissBanner }
}
100 changes: 49 additions & 51 deletions packages/curve-ui-kit/src/shared/ui/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,88 +6,80 @@ import Card from '@mui/material/Card'
import IconButton from '@mui/material/IconButton'
import LinkMui from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography, { type TypographyProps } from '@mui/material/Typography'
import Typography from '@mui/material/Typography'
import { t } from '@ui-kit/lib/i18n'
import { ArrowTopRightIcon } from '@ui-kit/shared/icons/ArrowTopRightIcon'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
import { ChangeTheme, InvertTheme } from './ThemeProvider'

type BannerSeverity = 'default' | 'highlight' | 'warning' | 'alert'
type BannerSeverity = 'info' | 'highlight' | 'warning' | 'alert'

const WrapperSx: Record<BannerSeverity, SxProps<Theme>> = {
default: {
border: (t) => `1px solid ${t.design.Layer.Highlight.Outline}`,
backgroundColor: (t) => t.design.Layer[1].Fill,
const BannerSx: Record<BannerSeverity, { title: SxProps<Theme>; subtitle: SxProps<Theme>; wrapper: SxProps<Theme> }> = {
info: {
title: { color: (t) => t.design.Text.TextColors.FilledFeedback.Info.Primary },
subtitle: { color: (t) => t.design.Text.TextColors.FilledFeedback.Info.Secondary },
wrapper: {
border: (t) => `1px solid ${t.design.Layer.Highlight.Outline}`,
backgroundColor: (t) => t.design.Layer[1].Fill,
},
},
alert: {
title: { color: (t) => t.design.Text.TextColors.FilledFeedback.Alert.Primary },
subtitle: { color: (t) => t.design.Text.TextColors.FilledFeedback.Alert.Secondary },
wrapper: { backgroundColor: (t) => t.design.Layer.Feedback.Error },
},
warning: {
title: { color: (t) => t.design.Text.TextColors.FilledFeedback.Warning.Primary },
subtitle: { color: (t) => t.design.Text.TextColors.FilledFeedback.Warning.Secondary },
wrapper: { backgroundColor: (t) => t.design.Layer.Feedback.Warning },
},
highlight: {
border: (t) => `1px solid ${t.design.Layer.Highlight.Outline}`,
backgroundColor: (t) => t.design.Color.Primary[800],
title: { color: (t) => t.design.Text.TextColors.FilledFeedback.Highlight.Primary },
subtitle: { color: (t) => t.design.Text.TextColors.FilledFeedback.Highlight.Secondary },
wrapper: { backgroundColor: (t) => t.design.Layer.Feedback.Info },
},
alert: { backgroundColor: (t) => t.design.Layer.Feedback.Error },
warning: { backgroundColor: (t) => t.design.Layer.Feedback.Warning },
}

const TitleColor: Record<BannerSeverity, TypographyProps['color']> = {
default: 'textHighlight',
alert: 'textPrimary',
warning: 'textPrimary',
highlight: 'textPrimary',
}
const { MaxWidth, Spacing } = SizesAndSpaces

const TitleInverted: Record<BannerSeverity, boolean> = {
default: false,
alert: true,
warning: false,
highlight: true,
export type BannerProps = {
onClick?: () => void
buttonText?: string
children: ReactNode
severity?: BannerSeverity
learnMoreUrl?: string
subtitle?: ReactNode
testId?: string
}

const { MaxWidth, Spacing } = SizesAndSpaces

/**
* Banner message component used to display important information with different severity levels.
* This is not complete yet: it doesn't support a subtitle or a close button from the design system.
*/
export const Banner = ({
onClick,
buttonText,
children,
severity = 'default',
severity = 'info',
learnMoreUrl,
color,
}: {
onClick?: () => void
buttonText?: string
children: ReactNode
severity?: BannerSeverity
learnMoreUrl?: string
color?: TypographyProps['color']
}) => (
subtitle,
testId,
}: BannerProps) => (
<Card
sx={{
display: 'flex',
gap: Spacing.md,
alignSelf: 'stretch',
paddingInline: Spacing.md,
paddingBlock: Spacing.xs,
alignItems: 'center',
justifyContent: 'center',
...WrapperSx[severity],
...BannerSx[severity].wrapper,
}}
data-testid={testId}
>
<Stack
direction="row"
sx={{ width: '100%', maxWidth: MaxWidth.banner }}
alignItems="center"
justifyContent="space-between"
>
<InvertTheme inverted={TitleInverted[severity]}>
<Typography color={color ?? TitleColor[severity]} variant="headingXsBold">
<Stack direction="column" width="100%" maxWidth={MaxWidth.banner}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography sx={{ ...BannerSx[severity].title }} variant="headingXsBold">
{children}
</Typography>
</InvertTheme>
<Stack direction="row" alignItems="center" justifyContent="start" height="100%">
{/* fixme: currently using light theme on dark theme */}
<ChangeTheme to={color === '#000' && 'light'}>
<Stack direction="row" alignItems="center" justifyContent="start" height="100%">
{learnMoreUrl && (
<Button
component={LinkMui}
Expand All @@ -101,6 +93,7 @@ export const Banner = ({
{t`Learn more`}
</Button>
)}
{/* TODO: fix button colors */}
{onClick &&
(buttonText ? (
<Button color="ghost" onClick={onClick} size="extraSmall">
Expand All @@ -111,7 +104,12 @@ export const Banner = ({
<CloseIcon />
</IconButton>
))}
</ChangeTheme>
</Stack>
</Stack>
<Stack direction="row" alignItems="center" justifyContent="start" height="100%">
<Typography sx={{ ...BannerSx[severity].subtitle }} variant="bodySRegular">
{subtitle}
</Typography>
</Stack>
</Stack>
</Card>
Expand Down
11 changes: 3 additions & 8 deletions packages/curve-ui-kit/src/shared/ui/GlobalBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useAccount, useChainId, useSwitchChain } from 'wagmi'
import Box from '@mui/material/Box'
import { useTheme } from '@mui/material/styles'
import { isFailure, useConnection, type WagmiChainId } from '@ui-kit/features/connect-wallet'
import { useReleaseChannel } from '@ui-kit/hooks/useLocalStorage'
import { t } from '@ui-kit/lib/i18n'
import { LlamaIcon } from '@ui-kit/shared/icons/LlamaIcon'
import { Banner } from '@ui-kit/shared/ui/Banner'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
import { isCypress, ReleaseChannel } from '@ui-kit/utils'
import { PhishingWarningBanner } from '@ui-kit/widgets/Header/PhishingWarningBanner'

export type GlobalBannerProps = {
networkId: string
Expand All @@ -27,23 +27,18 @@ export const GlobalBanner = ({ networkId, chainId }: GlobalBannerProps) => {
const walletChainId = useChainId()
const showSwitchNetworkMessage = isConnected && chainId && walletChainId != chainId
const showConnectApiErrorMessage = !showSwitchNetworkMessage && isFailure(connectState)
const warnColor = useTheme().palette.mode === 'dark' ? '#000' : 'textSecondary' // todo: fix this in the design system of the alert component
return (
<Box>
<PhishingWarningBanner />
{releaseChannel !== ReleaseChannel.Stable && !isCypress && (
<Banner onClick={() => setReleaseChannel(ReleaseChannel.Stable)} buttonText={t`Disable ${releaseChannel} Mode`}>
<LlamaIcon sx={{ width: IconSize.sm, height: IconSize.sm }} /> {t`${releaseChannel} Mode Enabled`}
</Banner>
)}
{maintenanceMessage && (
<Banner severity="warning" color={warnColor}>
{maintenanceMessage}
</Banner>
)}
{maintenanceMessage && <Banner severity="warning">{maintenanceMessage}</Banner>}
{showSwitchNetworkMessage && (
<Banner
severity="warning"
color={warnColor}
buttonText={t`Change network`}
onClick={() => switchChain({ chainId: chainId as WagmiChainId })}
>
Expand Down
16 changes: 14 additions & 2 deletions packages/curve-ui-kit/src/shared/ui/stories/Banner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const meta: Meta<typeof Banner> = {
argTypes: {
severity: {
control: 'select',
options: ['error', 'warning', 'info'],
options: ['error', 'warning', 'info', 'highlight'],
description: 'The severity level of the banner message',
},
buttonText: {
Expand All @@ -35,6 +35,10 @@ const meta: Meta<typeof Banner> = {
action: 'clicked',
description: 'Function called when the button is clicked',
},
subtitle: {
control: 'text',
description: 'Subtitle for the banner message (optional)',
},
},
}

Expand All @@ -57,7 +61,7 @@ export const Warning: Story = {

export const Info: Story = {
args: {
severity: 'default',
severity: 'info',
children: 'This is a default message',
},
}
Expand Down Expand Up @@ -112,3 +116,11 @@ export const ApiErrorExample: Story = {
children: 'There is an issue connecting to the API. Please try to switch your RPC in your wallet settings.',
},
}

export const WithSubtitle: Story = {
args: {
severity: 'info',
children: 'This is an default message with a subtitle',
subtitle: 'This is a subtitle',
},
}
32 changes: 32 additions & 0 deletions packages/curve-ui-kit/src/widgets/Header/PhishingWarningBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useDismissBanner } from '@ui-kit/hooks/useLocalStorage'
import { t } from '@ui-kit/lib/i18n'
import { ExclamationTriangleIcon } from '@ui-kit/shared/icons/ExclamationTriangleIcon'
import { Banner } from '@ui-kit/shared/ui/Banner'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'

const { IconSize } = SizesAndSpaces

const URL = 'https://www.curve.finance'
const ONE_MONTH_MS = 30 * 24 * 60 * 60 * 1000 // 30 days in milliseconds

/**
* Displays a banner warning users about phishing risks and encourages them to verify they are on the official Curve domains.
* The banner will reappear after one month if dismissed.
*/
export const PhishingWarningBanner = () => {
const { shouldShowBanner, dismissBanner } = useDismissBanner('phishing-warning-dismissed', ONE_MONTH_MS)

return (
shouldShowBanner && (
<Banner
subtitle={t`Always carefully check that your URL is ${URL}.`}
severity="warning"
onClick={dismissBanner}
testId="phishing-warning-banner"
>
<ExclamationTriangleIcon sx={{ width: IconSize.sm, height: IconSize.sm, verticalAlign: 'text-bottom' }} />{' '}
{t`Make sure you are on the right domain`}
</Banner>
)
)
}
47 changes: 47 additions & 0 deletions tests/cypress/e2e/all/header.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
LOAD_TIMEOUT,
oneDesktopViewport,
oneMobileOrTabletViewport,
oneViewport,
SCROLL_WIDTH,
TABLET_BREAKPOINT,
} from '@cy/support/ui'
Expand All @@ -26,6 +27,7 @@ describe('Header', () => {
beforeEach(() => {
viewport = oneDesktopViewport()
cy.viewport(...viewport)
dismissPhishingWarningBanner()
route = oneAppRoute()
cy.visitWithoutTestConnector(route)
waitIsLoaded(route)
Expand Down Expand Up @@ -88,6 +90,7 @@ describe('Header', () => {
beforeEach(() => {
viewport = oneMobileOrTabletViewport()
cy.viewport(...viewport)
dismissPhishingWarningBanner()
route = oneAppRoute()
cy.visitWithoutTestConnector(route)
waitIsLoaded(route)
Expand Down Expand Up @@ -160,6 +163,50 @@ describe('Header', () => {
})
})

describe('Phishing Warning Banner', () => {
let route: AppRoute

beforeEach(() => {
const [width, height] = oneViewport()
viewport = [width, height]
cy.viewport(...viewport)
route = oneAppRoute()
cy.visitWithoutTestConnector(route)
waitIsLoaded(route)
})

it('should display the banner and allow dismissal', () => {
cy.get("[data-testid='phishing-warning-banner']").should('be.visible')
// Click the banner to dismiss it
cy.get("[data-testid='phishing-warning-banner']").find('button').first().click()
cy.get("[data-testid='phishing-warning-banner']").should('not.exist')
})

it('should reappear after one month', () => {
// Set dismissal date to 31 days ago (more than one month)
const oneMonthAgo = Date.now() - 31 * 24 * 60 * 60 * 1000
dismissPhishingWarningBanner(oneMonthAgo)
cy.reload()
waitIsLoaded(route)
cy.get("[data-testid='phishing-warning-banner']").should('be.visible')
})

it('should remain hidden within one month', () => {
// Set dismissal date to 15 days ago (less than one month)
const fifteenDaysAgo = Date.now() - 15 * 24 * 60 * 60 * 1000
dismissPhishingWarningBanner(fifteenDaysAgo)
cy.reload()
waitIsLoaded(route)
cy.get("[data-testid='phishing-warning-banner']").should('not.exist')
})
})

function dismissPhishingWarningBanner(date?: number) {
cy.window().then((win) => {
win.localStorage.setItem('phishing-warning-dismissed', JSON.stringify(date ?? Date.now()))
})
}

function waitIsLoaded(route: AppRoute) {
cy.get(`[data-testid='${getRouteTestId(route)}']`, API_LOAD_TIMEOUT).should('be.visible')
}
Expand Down
Loading