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 }) => ({