diff --git a/apps/oeth/src/components/Topnav.tsx b/apps/oeth/src/components/Topnav.tsx index 703d55a8f..c3f407eb1 100644 --- a/apps/oeth/src/components/Topnav.tsx +++ b/apps/oeth/src/components/Topnav.tsx @@ -10,7 +10,7 @@ import { useMediaQuery, useTheme, } from '@mui/material'; -import { AccountPopover } from '@origin/oeth/shared'; +import { AccountPopover, ActivityButton } from '@origin/oeth/shared'; import { OpenAccountModalButton } from '@origin/shared/providers'; import { useIntl } from 'react-intl'; import { Link, useLocation, useNavigate } from 'react-router-dom'; @@ -198,6 +198,16 @@ export function Topnav(props: BoxProps) { anchor={accountModalAnchor} setAnchor={setAccountModalAnchor} /> + , diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx new file mode 100644 index 000000000..a1af13cf5 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; + +import { IconButton } from '@mui/material'; + +import { useGlobalStatus } from '../hooks'; +import { ActivityIcon } from './ActivityIcon'; +import { ActivityPopover } from './ActivityPopover'; + +import type { IconButtonProps } from '@mui/material'; + +export const ActivityButton = ( + props: Omit, +) => { + const [anchorEl, setAnchorEl] = useState(null); + const status = useGlobalStatus(); + + return ( + <> + setAnchorEl(e.currentTarget)} + > + + + + + ); +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/ActivityIcon.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityIcon.tsx new file mode 100644 index 000000000..7cb615b01 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityIcon.tsx @@ -0,0 +1,40 @@ +import { keyframes } from '@emotion/react'; +import { Box, Fade } from '@mui/material'; + +import type { BoxProps } from '@mui/material'; + +import type { GlobalActivityStatus } from '../types'; + +const spin = keyframes` + to { + transform: rotate(360deg); + } +`; + +const iconPaths: Record = { + idle: '/images/activity.svg', + pending: '/images/pending.svg', + error: '/images/failed.svg', + success: '/images/success.svg', +}; + +type ActivityIconProps = { status: GlobalActivityStatus } & BoxProps<'img'>; + +export const ActivityIcon = ({ status, ...rest }: ActivityIconProps) => { + return ( + + + + ); +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx new file mode 100644 index 000000000..3b427d73e --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx @@ -0,0 +1,176 @@ +import { + Box, + Divider, + Popover, + Stack, + Typography, + useTheme, +} from '@mui/material'; +import { LinkIcon } from '@origin/shared/components'; +import { isNilOrEmpty } from '@origin/shared/utils'; +import { descend, pipe, prop, sort, take } from 'ramda'; +import { defineMessage, useIntl } from 'react-intl'; +import { formatUnits } from 'viem'; + +import { useActivityState } from '../state'; +import { ActivityIcon } from './ActivityIcon'; + +import type { StackProps } from '@mui/material'; +import type { MessageDescriptor } from 'react-intl'; + +import type { Activity, ActivityType } from '../types'; + +export type AcitivityPopoverProps = { + anchor: HTMLElement | null; + setAnchor: (value: HTMLButtonElement | null) => void; +}; + +export const ActivityPopover = ({ + anchor, + setAnchor, +}: AcitivityPopoverProps) => { + const intl = useIntl(); + const theme = useTheme(); + const [{ activities, maxVisible }] = useActivityState(); + + const handleClose = () => { + setAnchor(null); + }; + + const sortedActivities = pipe( + sort(descend(prop('createdOn'))), + take(maxVisible), + )(activities) as Activity[]; + + return ( + ({ + xs: '90vw', + md: `min(${theme.typography.pxToRem(400)}, 90vw)`, + }), + [theme.breakpoints.down('md')]: { + left: '0 !important', + right: 0, + marginInline: 'auto', + }, + }, + }} + > + + + {intl.formatMessage({ defaultMessage: 'Recent activity' })} + + + }> + {isNilOrEmpty(sortedActivities) ? ( + + ) : ( + sortedActivities.map((a) => ( + + )) + )} + + + + ); +}; + +type ActivityItemProps = { + activity: Activity; +} & StackProps; + +const activityLabel: Record = { + swap: defineMessage({ defaultMessage: 'Swapped' }), + approval: defineMessage({ defaultMessage: 'Approved' }), +}; + +function ActivityItem({ activity, ...rest }: ActivityItemProps) { + const intl = useIntl(); + + return ( + + + + + + {intl.formatMessage(activityLabel[activity.type])} + + {!isNilOrEmpty(activity?.txReceipt?.transactionHash) && ( + + )} + + + + {intl.formatMessage( + { + defaultMessage: + '{amountIn} {symbolIn} for {amountOut} {symbolOut}', + }, + { + amountIn: intl.formatNumber( + +formatUnits(activity.amountIn, activity.tokenIn.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + symbolIn: activity.tokenIn.symbol, + amountOut: intl.formatNumber( + +formatUnits(activity.amountOut, activity.tokenOut.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + symbolOut: activity.tokenOut.symbol, + }, + )} + + + + + + + + + + ); +} + +function EmptyActivity(props: StackProps) { + const intl = useIntl(); + + return ( + + + {intl.formatMessage({ defaultMessage: 'No activity' })} + + + ); +} diff --git a/libs/oeth/shared/src/components/ActivityProvider/hooks.ts b/libs/oeth/shared/src/components/ActivityProvider/hooks.ts new file mode 100644 index 000000000..278b65ead --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/hooks.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { isNilOrEmpty } from '@origin/shared/utils'; +import { usePrevious } from '@react-hookz/web'; +import { produce } from 'immer'; +import { groupBy, prop, propEq } from 'ramda'; + +import { useActivityState } from './state'; + +import type { Activity, GlobalActivityStatus } from './types'; + +export const usePushActivity = () => { + const [, setState] = useActivityState(); + + return useCallback( + (value: Omit) => { + const activity = { + ...value, + id: Date.now().toString(), + createdOn: Date.now(), + }; + setState( + produce((state) => { + state.activities.unshift(activity); + }), + ); + + return activity; + }, + [setState], + ); +}; + +export const useUpdateActivity = () => { + const [, setState] = useActivityState(); + + return useCallback( + (activity: Partial) => { + setState( + produce((state) => { + const idx = state.activities.findIndex(propEq(activity.id, 'id')); + console.log('update ', idx, state.activities, activity); + if (idx > -1) { + state.activities[idx] = { + ...state.activities[idx], + ...activity, + }; + } + }), + ); + }, + [setState], + ); +}; + +export const useGlobalStatus = () => { + const [{ activities }] = useActivityState(); + const [status, setStatus] = useState('idle'); + const prev = usePrevious(activities); + + useEffect(() => { + const prevGrouped = groupBy(prop('status'), prev ?? []); + const grouped = groupBy(prop('status'), activities ?? []); + + if (isNilOrEmpty(grouped.pending)) { + if (prevGrouped?.success?.length !== grouped?.success?.length) { + setStatus('success'); + setTimeout(() => { + setStatus('idle'); + }, 2000); + } else if (prevGrouped?.error?.length !== grouped?.error?.length) { + setStatus('error'); + setTimeout(() => { + setStatus('idle'); + }, 2000); + } else { + setStatus('idle'); + } + } else { + setStatus('pending'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activities]); + + return status; +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/index.ts b/libs/oeth/shared/src/components/ActivityProvider/index.ts new file mode 100644 index 000000000..24a15daa0 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/index.ts @@ -0,0 +1,4 @@ +export * from './components/ActivityButton'; +export * from './hooks'; +export * from './state'; +export * from './types'; diff --git a/libs/oeth/shared/src/components/ActivityProvider/state.ts b/libs/oeth/shared/src/components/ActivityProvider/state.ts new file mode 100644 index 000000000..62c2a9ef3 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/state.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +import { createContainer } from 'react-tracked'; + +import type { Activity } from './types'; + +type ActivityState = { + activities: Activity[]; + maxVisible: number; +}; + +export const { Provider: ActivityProvider, useTracked: useActivityState } = + createContainer(() => + useState({ activities: [], maxVisible: 10 }), + ); diff --git a/libs/oeth/shared/src/components/ActivityProvider/types.ts b/libs/oeth/shared/src/components/ActivityProvider/types.ts new file mode 100644 index 000000000..bbc77fb12 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/types.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Token } from '@origin/shared/contracts'; +import type { TransactionReceipt } from 'viem'; + +export type ActivityType = 'swap' | 'approval'; + +export type ActivityStatus = 'pending' | 'success' | 'error'; + +export type GlobalActivityStatus = 'idle' | ActivityStatus; + +export type Activity = { + id: string; + createdOn: number; + tokenIn: Token; + tokenOut: Token; + amountIn?: bigint; + amountOut?: bigint; + txReceipt?: TransactionReceipt; + type: ActivityType; + status: ActivityStatus; + error?: string; +}; diff --git a/libs/oeth/shared/src/components/index.ts b/libs/oeth/shared/src/components/index.ts index 45b4fb9d0..7d6dc9474 100644 --- a/libs/oeth/shared/src/components/index.ts +++ b/libs/oeth/shared/src/components/index.ts @@ -1,2 +1,3 @@ +export * from './ActivityProvider'; export * from './AccountPopover'; export * from './GasPopover'; diff --git a/libs/oeth/swap/src/hooks.ts b/libs/oeth/swap/src/hooks.ts index dd95fbc14..3cf41aa7b 100644 --- a/libs/oeth/swap/src/hooks.ts +++ b/libs/oeth/swap/src/hooks.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from 'react'; +import { usePushActivity, useUpdateActivity } from '@origin/oeth/shared'; import { useCurve, usePushNotification, @@ -179,8 +180,13 @@ export const useHandleApprove = () => { const queryClient = useQueryClient(); const wagmiClient = useWagmiClient(); const pushNotification = usePushNotification(); - const [{ amountIn, selectedSwapRoute, tokenIn, tokenOut }, setSwapState] = - useSwapState(); + const pushActivity = usePushActivity(); + const updateActivity = useUpdateActivity(); + + const [ + { amountIn, amountOut, selectedSwapRoute, tokenIn, tokenOut }, + setSwapState, + ] = useSwapState(); return useCallback(async () => { if (isNilOrEmpty(selectedSwapRoute) || isNilOrEmpty(address)) { @@ -192,33 +198,35 @@ export const useHandleApprove = () => { draft.isApprovalLoading = true; }), ); + const activity = pushActivity({ + tokenIn, + tokenOut, + type: 'approval', + status: 'pending', + amountIn, + amountOut, + }); await swapActions[selectedSwapRoute.action].approve({ tokenIn, tokenOut, amountIn, curve, - onSuccess: () => { + onSuccess: (txReceipt) => { wagmiClient.invalidateQueries({ queryKey: ['swap_balance'], }); queryClient.invalidateQueries({ queryKey: ['swap_allowance'], }); - pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Approval complete' }), - severity: 'success', - }); + updateActivity({ ...activity, status: 'success', txReceipt }); setSwapState( produce((draft) => { draft.isApprovalLoading = false; }), ); }, - onError: () => { - pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Approval failed' }), - severity: 'error', - }); + onError: (error: string) => { + updateActivity({ ...activity, status: 'error', error }); setSwapState( produce((draft) => { draft.isApprovalLoading = false; @@ -227,7 +235,7 @@ export const useHandleApprove = () => { }, onReject: () => { pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Approval cancelled' }), + title: intl.formatMessage({ defaultMessage: 'Approval Cancelled' }), severity: 'info', }); setSwapState( @@ -240,14 +248,17 @@ export const useHandleApprove = () => { }, [ address, amountIn, + amountOut, curve, intl, + pushActivity, pushNotification, queryClient, selectedSwapRoute, setSwapState, tokenIn, tokenOut, + updateActivity, wagmiClient, ]); }; @@ -260,6 +271,8 @@ export const useHandleSwap = () => { const queryClient = useQueryClient(); const wagmiClient = useWagmiClient(); const pushNotification = usePushNotification(); + const pushActivity = usePushActivity(); + const updateActivity = useUpdateActivity(); const [ { amountIn, amountOut, selectedSwapRoute, tokenIn, tokenOut }, setSwapState, @@ -270,6 +283,14 @@ export const useHandleSwap = () => { return; } + const activity = pushActivity({ + tokenIn, + tokenOut, + type: 'swap', + status: 'pending', + amountIn, + amountOut, + }); setSwapState( produce((draft) => { draft.isSwapLoading = true; @@ -283,27 +304,21 @@ export const useHandleSwap = () => { slippage, amountOut, curve, - onSuccess: () => { + onSuccess: (txReceipt) => { wagmiClient.invalidateQueries({ queryKey: ['swap_balance'], }); queryClient.invalidateQueries({ queryKey: ['swap_allowance'], }); - pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Swap complete' }), - severity: 'success', - }); + updateActivity({ ...activity, status: 'success', txReceipt }); }, - onError: () => { - pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Swap failed' }), - severity: 'error', - }); + onError: (error: string) => { + updateActivity({ ...activity, status: 'error', error }); }, onReject: () => { pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Swap cancelled' }), + title: intl.formatMessage({ defaultMessage: 'Swap Cancelled' }), severity: 'info', }); }, @@ -319,6 +334,7 @@ export const useHandleSwap = () => { amountOut, curve, intl, + pushActivity, pushNotification, queryClient, selectedSwapRoute, @@ -326,6 +342,7 @@ export const useHandleSwap = () => { slippage, tokenIn, tokenOut, + updateActivity, wagmiClient, ]); }; diff --git a/libs/shared/theme/src/theme.tsx b/libs/shared/theme/src/theme.tsx index 773463223..036fae1bf 100644 --- a/libs/shared/theme/src/theme.tsx +++ b/libs/shared/theme/src/theme.tsx @@ -364,6 +364,16 @@ export const theme = extendTheme({ }), }, }, + MuiIconButton: { + styleOverrides: { + root: ({ theme }) => ({ + background: theme.palette.background.gradientPaper, + '&:hover': { + background: theme.palette.background.paper, + }, + }), + }, + }, MuiInputBase: { styleOverrides: { root: ({ theme }) => ({