From fef9935883f38cedf0b20d4a967f2f99f66c0938 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 16 Jan 2024 10:19:48 -0800 Subject: [PATCH] Support notification for trading rewards (#224) * Support notification for trading rewards * bump abacus * Stack notifications based on notification type * address comments --- public/dots-background-2.svg | 230 ++++++++++++++++++ src/components/Icon.tsx | 3 + src/components/ToastArea.tsx | 3 +- src/constants/notifications.ts | 2 + src/hooks/useNotificationTypes.tsx | 28 +++ src/icons/index.ts | 1 + src/icons/reward-star.svg | 9 + .../NotifcationStack.tsx} | 93 +++---- src/layout/NotificationsToastArea/index.tsx | 71 ++++++ .../BlockRewardNotification/index.tsx | 78 ++++++ 10 files changed, 457 insertions(+), 61 deletions(-) create mode 100644 public/dots-background-2.svg create mode 100644 src/icons/reward-star.svg rename src/layout/{NotificationsToastArea.tsx => NotificationsToastArea/NotifcationStack.tsx} (66%) create mode 100644 src/layout/NotificationsToastArea/index.tsx create mode 100644 src/views/notifications/BlockRewardNotification/index.tsx diff --git a/public/dots-background-2.svg b/public/dots-background-2.svg new file mode 100644 index 0000000..82c1f22 --- /dev/null +++ b/public/dots-background-2.svg @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 615df90..f5e44e1 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -59,6 +59,7 @@ import { PriceChartIcon, PrivacyIcon, QrIcon, + RewardStarIcon, SearchIcon, SendIcon, ShareIcon, @@ -134,6 +135,7 @@ export enum IconName { PriceChart = 'PriceChart', Privacy = 'Privacy', Qr = 'Qr', + RewardStar = 'RewardStar', Search = 'Search', Send = 'Send', Share = 'Share', @@ -208,6 +210,7 @@ const icons = { [IconName.PriceChart]: PriceChartIcon, [IconName.Privacy]: PrivacyIcon, [IconName.Qr]: QrIcon, + [IconName.RewardStar]: RewardStarIcon, [IconName.Search]: SearchIcon, [IconName.Send]: SendIcon, [IconName.Share]: ShareIcon, diff --git a/src/components/ToastArea.tsx b/src/components/ToastArea.tsx index 0457db9..5aa9e98 100644 --- a/src/components/ToastArea.tsx +++ b/src/components/ToastArea.tsx @@ -27,9 +27,8 @@ export const ToastArea = ({ swipeDirection, children, className }: ToastAreaProp const $ToastArea = styled.aside` // Params --toasts-gap: 0.5rem; - + // Rules - ${layoutMixins.scrollArea} z-index: 1; pointer-events: none; diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index a455fd1..d6728d1 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -87,6 +87,8 @@ export type NotificationDisplayData = { slotTitleLeft?: React.ReactNode; slotTitleRight?: React.ReactNode; + groupKey: string; // Grouping key toast notification stacking + // Overrides title/body for Notification in NotificationMenu renderCustomBody?: ({ isToast, diff --git a/src/hooks/useNotificationTypes.tsx b/src/hooks/useNotificationTypes.tsx index 870e03e..b7be4f3 100644 --- a/src/hooks/useNotificationTypes.tsx +++ b/src/hooks/useNotificationTypes.tsx @@ -28,6 +28,7 @@ import { useLocalNotifications } from '@/hooks/useLocalNotifications'; import { AssetIcon } from '@/components/AssetIcon'; import { Icon, IconName } from '@/components/Icon'; +import { BlockRewardNotification } from '@/views/notifications/BlockRewardNotification'; import { TradeNotification } from '@/views/notifications/TradeNotification'; import { TransferStatusNotification } from '@/views/notifications/TransferStatusNotification'; @@ -80,6 +81,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '', toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS, toastSensitivity: 'foreground', + groupKey: abacusNotificationType, renderCustomBody: ({ isToast, notification }) => ( , + title: stringGetter({ key: abacusNotif.title }), + body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '', + toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS, + toastSensitivity: 'foreground', + groupKey: abacusNotificationType, + renderCustomBody: ({ isToast, notification }) => ( + + ), + }, + [abacusNotif.updateTimeInMilliseconds, abacusNotif.data], + true + ); + break; + } default: trigger( abacusNotif.id, @@ -102,6 +127,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '', toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS, toastSensitivity: 'foreground', + groupKey: abacusNotificationType, }, [abacusNotif.updateTimeInMilliseconds, abacusNotif.data] ); @@ -197,6 +223,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ /> ), toastSensitivity: 'foreground', + groupKey: NotificationType.SquidTransfer, }, [isFinished] ); @@ -242,6 +269,7 @@ export const notificationTypes: NotificationTypeConfig[] = [ }, }), toastSensitivity: 'foreground', + groupKey: NotificationType.ReleaseUpdates, }, [] ); diff --git a/src/icons/index.ts b/src/icons/index.ts index 8f22631..0dfb854 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -50,6 +50,7 @@ export { default as PriceChartIcon } from './price-chart.svg'; export { default as PrivacyIcon } from './privacy.svg'; export { default as ProfileIcon } from './profile.svg'; export { default as QrIcon } from './qr.svg'; +export { default as RewardStarIcon } from './reward-star.svg'; export { default as SearchIcon } from './search.svg'; export { default as SendIcon } from './send.svg'; export { default as ShareIcon } from './share.svg'; diff --git a/src/icons/reward-star.svg b/src/icons/reward-star.svg new file mode 100644 index 0000000..6aab7d7 --- /dev/null +++ b/src/icons/reward-star.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/layout/NotificationsToastArea.tsx b/src/layout/NotificationsToastArea/NotifcationStack.tsx similarity index 66% rename from src/layout/NotificationsToastArea.tsx rename to src/layout/NotificationsToastArea/NotifcationStack.tsx index db4e37e..a2f16dd 100644 --- a/src/layout/NotificationsToastArea.tsx +++ b/src/layout/NotificationsToastArea/NotifcationStack.tsx @@ -1,7 +1,11 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import styled, { css } from 'styled-components'; -import { NotificationStatus } from '@/constants/notifications'; +import { + type Notification, + type NotificationDisplayData, + NotificationStatus, +} from '@/constants/notifications'; import { ButtonShape, ButtonSize } from '@/constants/buttons'; import { useNotifications } from '@/hooks/useNotifications'; import { useBreakpoints } from '@/hooks/useBreakpoints'; @@ -13,54 +17,31 @@ import { Toast } from '@/components/Toast'; import { ToastArea } from '@/components/ToastArea'; import { ToggleButton } from '@/components/ToggleButton'; +type ElementProps = { + notifications: { + notification: Notification; + key: string; + displayData: NotificationDisplayData; + }[]; +}; + type StyleProps = { className?: string; }; -const MAX_TOASTS = 10; - -export const NotificationsToastArea = ({ className }: StyleProps) => { +export const NotificationStack = ({ notifications, className }: ElementProps & StyleProps) => { const [shouldStackNotifications, setshouldStackNotifications] = useState(true); - const { - notifications, - getKey, - getDisplayData, - markUnseen, - markSeen, - isMenuOpen, - onNotificationAction, - } = useNotifications(); - + const { markUnseen, markSeen, onNotificationAction } = useNotifications(); const { isMobile } = useBreakpoints(); - const notificationMap = useMemo(() => { - return ( - Object.values(notifications) - // Sort by time of first trigger - .sort( - (n1, n2) => - n1.timestamps[NotificationStatus.Triggered]! - - n2.timestamps[NotificationStatus.Triggered]! - ) - .map((notification) => ({ - notification, - key: getKey(notification), - displayData: getDisplayData(notification), - })) - .filter( - ({ displayData, notification }) => - displayData && notification.status < NotificationStatus.Unseen - ) - .slice(-MAX_TOASTS) - ); - }, [notifications, getKey, getDisplayData]); - - if (isMenuOpen) return null; - - const hasMultipleToasts = notificationMap.length > 1; + const hasMultipleToasts = notifications.length > 1; return ( - + {hasMultipleToasts && ( { )} - {notificationMap.map(({ notification, key, displayData }, idx) => ( + {notifications.map(({ notification, key, displayData }, idx) => ( 0 && shouldStackNotifications} isOpen={notification.status < NotificationStatus.Unseen} - layer={notificationMap.length - 1 - idx} + layer={idx} notification={notification} slotIcon={displayData.icon} slotTitle={displayData.title} @@ -110,19 +91,13 @@ export const NotificationsToastArea = ({ className }: StyleProps) => { ); }; -const StyledToastArea = styled(ToastArea)` - position: absolute; - width: min(17.5rem, 100%); - inset: 0 0 0 auto; - - padding: 0.75rem 0.75rem 0.75rem 0; - - mask-image: linear-gradient(to left, transparent, white 0.5rem); - - @media ${breakpoints.mobile} { - width: 100%; - position: fixed; - } +const StyledToastArea = styled(ToastArea)<{ size: number }>` + position: relative; + ${({ size }) => + size && + css` + padding-bottom: calc(${size - 1} * 2px); + `} `; const StyledToast = styled(Toast)<{ isStacked?: boolean; layer: number }>` @@ -133,7 +108,7 @@ const StyledToast = styled(Toast)<{ isStacked?: boolean; layer: number }>` position: absolute; left: 0; top: calc(${layer} * 2px); - right: calc(${layer} * -2px); + right: 0; ` : css` margin-bottom: 0.75rem; @@ -145,7 +120,7 @@ const StyledToggleButton = styled(ToggleButton)<{ isPressed: boolean }>` pointer-events: auto; display: none; position: absolute; - top: 4px; + top: -0.5rem; left: calc(50% - 0.75rem); --button-width: 2rem; --button-height: 1rem; diff --git a/src/layout/NotificationsToastArea/index.tsx b/src/layout/NotificationsToastArea/index.tsx new file mode 100644 index 0000000..fea6004 --- /dev/null +++ b/src/layout/NotificationsToastArea/index.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { groupBy } from 'lodash'; +import styled from 'styled-components'; + +import { breakpoints } from '@/styles'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { NotificationStatus } from '@/constants/notifications'; +import { useNotifications } from '@/hooks/useNotifications'; + +import { NotificationStack } from './NotifcationStack'; + +type StyleProps = { + className?: string; +}; + +const MAX_TOASTS = 10; + +export const NotificationsToastArea = ({ className }: StyleProps) => { + const { notifications, getKey, getDisplayData, isMenuOpen } = useNotifications(); + + const notificationMapByType = useMemo(() => { + const notificationMap = Object.values(notifications) + // Sort by time of first trigger + .sort( + (n1, n2) => + n1.timestamps[NotificationStatus.Triggered]! - + n2.timestamps[NotificationStatus.Triggered]! + ) + .map((notification) => ({ + notification, + key: getKey(notification), + displayData: getDisplayData(notification), + })) + .filter( + ({ displayData, notification }) => + displayData && notification.status < NotificationStatus.Unseen + ) + .slice(-MAX_TOASTS); + return groupBy(notificationMap, (notification) => notification.displayData?.groupKey); + }, [notifications, getKey, getDisplayData]); + + if (isMenuOpen) return null; + + return ( + + {Object.keys(notificationMapByType).map((groupKey) => ( + + ))} + + ); +}; + +const StyledToastArea = styled.div` + ${layoutMixins.scrollArea} + + position: absolute; + width: min(17.5rem, 100%); + inset: 0 0 0 auto; + + padding: 0.75rem 0.75rem 0.75rem 0; + + mask-image: linear-gradient(to left, transparent, white 0.5rem); + + pointer-events: none; + + @media ${breakpoints.mobile} { + width: 100%; + position: fixed; + } +`; diff --git a/src/views/notifications/BlockRewardNotification/index.tsx b/src/views/notifications/BlockRewardNotification/index.tsx new file mode 100644 index 0000000..200522b --- /dev/null +++ b/src/views/notifications/BlockRewardNotification/index.tsx @@ -0,0 +1,78 @@ +import styled, { type AnyStyledComponent } from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { useStringGetter } from '@/hooks'; + +import { Details } from '@/components/Details'; +import { Notification, NotificationProps } from '@/components/Notification'; +import { Output, OutputType } from '@/components/Output'; +import { Icon, IconName } from '@/components/Icon'; + +type ElementProps = { + data: { + BLOCK_REWARD_AMOUNT: string; + BLOCK_REWARD_HEIGHT: string; + BLOCK_REWARD_TIME_MILLISECONDS: string; + TOKEN_NAME: string; + }; +}; + +export type BlockRewardNotificationProps = NotificationProps & ElementProps; + +export const BlockRewardNotification = ({ + isToast, + data, + notification, +}: BlockRewardNotificationProps) => { + const stringGetter = useStringGetter(); + const { BLOCK_REWARD_AMOUNT, TOKEN_NAME } = data; + + return ( + } + slotTitle={stringGetter({ key: STRING_KEYS.TRADING_REWARD_RECEIVED })} + slotCustomContent={ + + ), + }, + ]} + /> + } + /> + ); +}; + +const Styled: Record = {}; + +Styled.Details = styled(Details)` + --details-item-height: 1.5rem; + + dd { + color: var(--color-text-2); + } +`; + +Styled.Notification = styled(Notification)` + background-image: url('/dots-background-2.svg'); + background-size: cover; +`; + +Styled.Output = styled(Output)` + &:before { + content: '+'; + color: var(--color-positive); + margin-right: 0.5ch; + } +`;