diff --git a/src/apps/platform/src/PlatformApp.tsx b/src/apps/platform/src/PlatformApp.tsx index dd89390fb..e9c34cceb 100644 --- a/src/apps/platform/src/PlatformApp.tsx +++ b/src/apps/platform/src/PlatformApp.tsx @@ -1,7 +1,7 @@ import { FC } from 'react' import { toast, ToastContainer } from 'react-toastify' -import { useViewportUnitsFix } from '~/libs/shared' +import { NotificationsContainer, useViewportUnitsFix } from '~/libs/shared' import { AppFooter } from './components/app-footer' import { AppHeader } from './components/app-header' @@ -14,6 +14,7 @@ const PlatformApp: FC<{}> = () => { return ( +
diff --git a/src/apps/platform/src/providers/Providers.tsx b/src/apps/platform/src/providers/Providers.tsx index b16709bb2..b7064659d 100644 --- a/src/apps/platform/src/providers/Providers.tsx +++ b/src/apps/platform/src/providers/Providers.tsx @@ -1,7 +1,7 @@ import { FC, ReactNode } from 'react' import { authUrlLogout, ProfileProvider } from '~/libs/core' -import { ConfigContextProvider } from '~/libs/shared' +import { ConfigContextProvider, NotificationProvider } from '~/libs/shared' import { PlatformRouterProvider } from './platform-router.provider' @@ -13,7 +13,9 @@ const Providers: FC = props => ( - {props.children} + + {props.children} + diff --git a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx index bd44eb851..29e8b058d 100644 --- a/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx +++ b/src/apps/review/src/pages/active-review-assignements/ChallengeDetailsPage/ChallengeDetailsPage.tsx @@ -13,6 +13,7 @@ import { TableLoading } from '~/apps/admin/src/lib' import { handleError } from '~/apps/admin/src/lib/utils' import { EnvironmentConfig } from '~/config' import { BaseModal, Button, InputCheckbox, InputText } from '~/libs/ui' +import { NotificationContextType, useNotification } from '~/libs/shared' import { useFetchScreeningReview, @@ -226,6 +227,7 @@ const computePhaseCompletionFromScreenings = ( // eslint-disable-next-line complexity export const ChallengeDetailsPage: FC = (props: Props) => { + const { showBannerNotification, removeNotification }: NotificationContextType = useNotification() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() @@ -1323,6 +1325,16 @@ export const ChallengeDetailsPage: FC = (props: Props) => { : undefined const shouldShowChallengeMetaRow = Boolean(statusLabel) || trackTypePills.length > 0 + useEffect(() => { + const notification = showBannerNotification({ + id: 'ai-review-scores-warning', + message: `AI Review Scores are advisory only to provide immediate, + educational, and actionable feedback to members. + AI Review Scores do not influence winner selection.`, + }) + return () => notification && removeNotification(notification.id) + }, [showBannerNotification]) + return ( = createContext(defaultProfileContextData) +export const useProfileContext = (): ProfileContextData => useContext(profileContext) + export default profileContext diff --git a/src/libs/shared/lib/components/index.ts b/src/libs/shared/lib/components/index.ts index ac827849a..8b9d29e69 100644 --- a/src/libs/shared/lib/components/index.ts +++ b/src/libs/shared/lib/components/index.ts @@ -3,6 +3,7 @@ export * from './modals' export * from './profile-picture' export * from './input-skill-selector' export * from './member-skill-editor' +export * from './notifications' export * from './skill-pill' export * from './expandable-list' export * from './grouped-skills-ui' diff --git a/src/libs/shared/lib/components/notifications/Notifications.container.tsx b/src/libs/shared/lib/components/notifications/Notifications.container.tsx new file mode 100644 index 000000000..5aa329be7 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/Notifications.container.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react' + +import { Notification } from '~/libs/ui' + +import { NotificationContextType, useNotification } from './Notifications.context' +import styles from './NotificationsContainer.module.scss' + +const NotificationsContainer: FC = () => { + const { notifications, removeNotification }: NotificationContextType = useNotification() + + return ( +
+ {notifications.map(n => ( + + ))} +
+ ) +} + +export default NotificationsContainer diff --git a/src/libs/shared/lib/components/notifications/Notifications.context.tsx b/src/libs/shared/lib/components/notifications/Notifications.context.tsx new file mode 100644 index 000000000..23dbcf558 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/Notifications.context.tsx @@ -0,0 +1,92 @@ +import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react' + +import { useProfileContext } from '~/libs/core' + +import { dismiss, wasDismissed } from './localstorage.utils' + +export type NotificationType = 'success' | 'error' | 'info' | 'warning' | 'banner'; + +export interface Notification { + id: string; + type: NotificationType; + icon?: ReactNode + message: string; + duration?: number; // in ms +} + +type NotifyPayload = string | (Partial & { message: string }) + +export interface NotificationContextType { + notifications: Notification[]; + notify: (message: NotifyPayload, type?: NotificationType, duration?: number) => Notification | void; + showBannerNotification: (message: NotifyPayload) => Notification | void; + removeNotification: (id: string) => void; +} + +const NotificationContext = createContext(undefined) + +export const useNotification = (): NotificationContextType => { + const context = useContext(NotificationContext) + if (!context) throw new Error('useNotification must be used within a NotificationProvider') + return context +} + +export const NotificationProvider: React.FC<{ + children: ReactNode, +}> = props => { + const profileCtx = useProfileContext() + const uuid = profileCtx.profile?.userId ?? 'annon' + const [notifications, setNotifications] = useState([]) + + const removeNotification = useCallback((id: string, persist?: boolean) => { + setNotifications(prev => prev.filter(n => n.id !== id)) + if (persist) { + dismiss(id) + } + }, []) + + const notify = useCallback( + (message: NotifyPayload, type: NotificationType = 'info', duration = 3000) => { + const id = `${uuid}[${typeof message === 'string' ? message : message.id}]` + const newNotification: Notification + = typeof message === 'string' + ? { duration, id, message, type } + : { duration, type, ...message, id } + + if (wasDismissed(id)) { + return undefined + } + + setNotifications(prev => [...prev, newNotification]) + + if (duration > 0) { + setTimeout(() => removeNotification(id), duration) + } + + return newNotification + }, + [uuid], + ) + + const showBannerNotification = useCallback(( + message: NotifyPayload, + ) => notify(message, 'banner', 0), [notify]) + + const ctxValue = useMemo(() => ({ + notifications, + notify, + removeNotification, + showBannerNotification, + }), [ + notifications, + notify, + removeNotification, + showBannerNotification, + ]) + + return ( + + {props.children} + + ) +} diff --git a/src/libs/shared/lib/components/notifications/NotificationsContainer.module.scss b/src/libs/shared/lib/components/notifications/NotificationsContainer.module.scss new file mode 100644 index 000000000..9cbc394ef --- /dev/null +++ b/src/libs/shared/lib/components/notifications/NotificationsContainer.module.scss @@ -0,0 +1,7 @@ +@import "@libs/ui/styles/includes"; + +.wrap { + position: relative; + width: 100%; + z-index: 1000; +} diff --git a/src/libs/shared/lib/components/notifications/index.ts b/src/libs/shared/lib/components/notifications/index.ts new file mode 100644 index 000000000..d2eaff448 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/index.ts @@ -0,0 +1,2 @@ +export { default as NotificationsContainer } from './Notifications.container' +export * from './Notifications.context' diff --git a/src/libs/shared/lib/components/notifications/localstorage.utils.ts b/src/libs/shared/lib/components/notifications/localstorage.utils.ts new file mode 100644 index 000000000..c33d2ff72 --- /dev/null +++ b/src/libs/shared/lib/components/notifications/localstorage.utils.ts @@ -0,0 +1,9 @@ +const lsKeyPrefix = 'notificationDismissed' + +export const wasDismissed = (id: string): boolean => ( + (localStorage.getItem(`${lsKeyPrefix}[${id}]`)) !== null +) + +export const dismiss = (id: string): void => { + localStorage.setItem(`${lsKeyPrefix}[${id}]`, JSON.stringify(true)) +} diff --git a/src/libs/ui/lib/components/index.ts b/src/libs/ui/lib/components/index.ts index 306c469d6..2e0d2f067 100644 --- a/src/libs/ui/lib/components/index.ts +++ b/src/libs/ui/lib/components/index.ts @@ -3,6 +3,7 @@ export * from './content-layout' export * from './default-member-icon' // NOTE: for some reason, modals needs to be imported prior to form export * from './modals' +export * from './notification' export * from './form' export * from './loading-spinner' export * from './page-divider' diff --git a/src/libs/ui/lib/components/notification/Notification.tsx b/src/libs/ui/lib/components/notification/Notification.tsx new file mode 100644 index 000000000..34fe01595 --- /dev/null +++ b/src/libs/ui/lib/components/notification/Notification.tsx @@ -0,0 +1,33 @@ +import { FC, ReactNode, useCallback } from 'react' + +import { NotificationBanner } from './banner' + +interface NotificationProps { + notification: { + icon?: ReactNode; + id: string; + message: string; + type: string; +} + onClose: (id: string, save?: boolean) => void +} + +const Notification: FC = props => { + const handleClose = useCallback((save?: boolean) => { + props.onClose(props.notification.id, save) + }, [props.onClose, props.notification.id]) + + if (props.notification.type === 'banner') { + return ( + + ) + } + + return <> +} + +export default Notification diff --git a/src/libs/ui/lib/components/notification/banner/NotificationBanner.module.scss b/src/libs/ui/lib/components/notification/banner/NotificationBanner.module.scss new file mode 100644 index 000000000..e9aab0a7b --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/NotificationBanner.module.scss @@ -0,0 +1,38 @@ +@import '../../../styles/includes'; + +.wrap { + background: #60267D; + color: $tc-white; + + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + line-height: 20px; + + .inner { + max-width: $xxl-min; + padding: $sp-2 0; + @include pagePaddings; + margin: 0 auto; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + } +} + +.close { + cursor: pointer; + color: $tc-white; + flex: 0 0; + margin-left: auto; + border-radius: 50%; + border: 2px solid white; + @include ltemd { + margin-left: $sp-3; + } +} + +.icon { + flex: 0 0; + margin-right: $sp-2; +} diff --git a/src/libs/ui/lib/components/notification/banner/NotificationBanner.stories.tsx b/src/libs/ui/lib/components/notification/banner/NotificationBanner.stories.tsx new file mode 100644 index 000000000..127e09970 --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/NotificationBanner.stories.tsx @@ -0,0 +1,29 @@ +import { Meta, StoryObj } from '@storybook/react' + +import NotificationBanner from './NotificationBanner' + +const meta: Meta = { + argTypes: { + content: { + description: 'Content displayed inside the notification banner', + }, + persistent: { + defaultValue: false, + description: 'Set to true to hide the close icon button', + }, + }, + component: NotificationBanner, + excludeStories: /.*Decorator$/, + tags: ['autodocs'], + title: 'Components/NotificationBanner', +} + +export default meta + +type Story = StoryObj; + +export const Primary: Story = { + args: { + content: 'Help tooltip', + }, +} diff --git a/src/libs/ui/lib/components/notification/banner/NotificationBanner.tsx b/src/libs/ui/lib/components/notification/banner/NotificationBanner.tsx new file mode 100644 index 000000000..66900057a --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/NotificationBanner.tsx @@ -0,0 +1,42 @@ +import { FC, ReactNode, useCallback } from 'react' + +import { InformationCircleIcon } from '@heroicons/react/outline' + +import { IconOutline } from '../../svgs' + +import styles from './NotificationBanner.module.scss' + +interface NotificationBannerProps { + persistent?: boolean + content: ReactNode + icon?: ReactNode + onClose?: (save?: boolean) => void +} + +const NotificationBanner: FC = props => { + const handleClose = useCallback(() => { + props.onClose?.(true) + }, [props.onClose]) + + return ( +
+
+ {props.icon || ( +
+ +
+ )} + + {props.content} + + {!props.persistent && ( +
+ +
+ )} +
+
+ ) +} + +export default NotificationBanner diff --git a/src/libs/ui/lib/components/notification/banner/index.ts b/src/libs/ui/lib/components/notification/banner/index.ts new file mode 100644 index 000000000..51f3cf392 --- /dev/null +++ b/src/libs/ui/lib/components/notification/banner/index.ts @@ -0,0 +1 @@ +export { default as NotificationBanner } from './NotificationBanner' diff --git a/src/libs/ui/lib/components/notification/index.ts b/src/libs/ui/lib/components/notification/index.ts new file mode 100644 index 000000000..ef0ca420e --- /dev/null +++ b/src/libs/ui/lib/components/notification/index.ts @@ -0,0 +1,2 @@ +export * from './banner' +export { default as Notification } from './Notification'