diff --git a/cms/quivr/src/api/notification-banner/content-types/notification-banner/schema.json b/cms/quivr/src/api/notification-banner/content-types/notification-banner/schema.json new file mode 100644 index 000000000..22de69272 --- /dev/null +++ b/cms/quivr/src/api/notification-banner/content-types/notification-banner/schema.json @@ -0,0 +1,63 @@ +{ + "kind": "singleType", + "collectionName": "notification_banners", + "info": { + "singularName": "notification-banner", + "pluralName": "notification-banners", + "displayName": "Notification banner" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "attributes": { + "notification_id": { + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "type": "uid", + "required": true + }, + "text": { + "pluginOptions": { + "i18n": { + "localized": true + } + }, + "type": "richtext", + "required": true + }, + "dismissible": { + "pluginOptions": { + "i18n": { + "localized": false + } + }, + "type": "boolean", + "default": true + }, + "isSticky": { + "pluginOptions": { + "i18n": { + "localized": false + } + }, + "type": "boolean", + "default": true + }, + "style": { + "pluginOptions": { + "i18n": { + "localized": false + } + }, + "type": "json" + } + } +} diff --git a/cms/quivr/src/api/notification-banner/controllers/notification-banner.js b/cms/quivr/src/api/notification-banner/controllers/notification-banner.js new file mode 100644 index 000000000..e447ac50e --- /dev/null +++ b/cms/quivr/src/api/notification-banner/controllers/notification-banner.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * notification-banner controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::notification-banner.notification-banner'); diff --git a/cms/quivr/src/api/notification-banner/routes/notification-banner.js b/cms/quivr/src/api/notification-banner/routes/notification-banner.js new file mode 100644 index 000000000..3738cbd4e --- /dev/null +++ b/cms/quivr/src/api/notification-banner/routes/notification-banner.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * notification-banner router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::notification-banner.notification-banner'); diff --git a/cms/quivr/src/api/notification-banner/services/notification-banner.js b/cms/quivr/src/api/notification-banner/services/notification-banner.js new file mode 100644 index 000000000..1f1e63aa4 --- /dev/null +++ b/cms/quivr/src/api/notification-banner/services/notification-banner.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * notification-banner service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::notification-banner.notification-banner'); diff --git a/cms/quivr/types/generated/contentTypes.d.ts b/cms/quivr/types/generated/contentTypes.d.ts index 745c7f801..1422e3a52 100644 --- a/cms/quivr/types/generated/contentTypes.d.ts +++ b/cms/quivr/types/generated/contentTypes.d.ts @@ -799,6 +799,81 @@ export interface ApiDiscussionDiscussion extends Schema.CollectionType { }; } +export interface ApiNotificationBannerNotificationBanner + extends Schema.SingleType { + collectionName: 'notification_banners'; + info: { + singularName: 'notification-banner'; + pluralName: 'notification-banners'; + displayName: 'Notification banner'; + }; + options: { + draftAndPublish: true; + }; + pluginOptions: { + i18n: { + localized: true; + }; + }; + attributes: { + notification_id: Attribute.UID & + Attribute.Required & + Attribute.SetPluginOptions<{ + i18n: { + localized: true; + }; + }>; + text: Attribute.RichText & + Attribute.Required & + Attribute.SetPluginOptions<{ + i18n: { + localized: true; + }; + }>; + dismissible: Attribute.Boolean & + Attribute.SetPluginOptions<{ + i18n: { + localized: false; + }; + }> & + Attribute.DefaultTo; + isSticky: Attribute.Boolean & + Attribute.SetPluginOptions<{ + i18n: { + localized: false; + }; + }> & + Attribute.DefaultTo; + style: Attribute.JSON & + Attribute.SetPluginOptions<{ + i18n: { + localized: false; + }; + }>; + createdAt: Attribute.DateTime; + updatedAt: Attribute.DateTime; + publishedAt: Attribute.DateTime; + createdBy: Attribute.Relation< + 'api::notification-banner.notification-banner', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + updatedBy: Attribute.Relation< + 'api::notification-banner.notification-banner', + 'oneToOne', + 'admin::user' + > & + Attribute.Private; + localizations: Attribute.Relation< + 'api::notification-banner.notification-banner', + 'oneToMany', + 'api::notification-banner.notification-banner' + >; + locale: Attribute.String; + }; +} + export interface ApiSecurityQuestionSecurityQuestion extends Schema.CollectionType { collectionName: 'security_questions'; @@ -1013,6 +1088,7 @@ declare module '@strapi/types' { 'api::blog.blog': ApiBlogBlog; 'api::demo-video.demo-video': ApiDemoVideoDemoVideo; 'api::discussion.discussion': ApiDiscussionDiscussion; + 'api::notification-banner.notification-banner': ApiNotificationBannerNotificationBanner; 'api::security-question.security-question': ApiSecurityQuestionSecurityQuestion; 'api::testimonial.testimonial': ApiTestimonialTestimonial; 'api::use-case.use-case': ApiUseCaseUseCase; diff --git a/frontend/app/chat/[chatId]/page.tsx b/frontend/app/chat/[chatId]/page.tsx index 116d3e993..ba9832b3f 100644 --- a/frontend/app/chat/[chatId]/page.tsx +++ b/frontend/app/chat/[chatId]/page.tsx @@ -11,22 +11,24 @@ const SelectedChatPage = (): JSX.Element => { const { shouldDisplayFeedCard } = useKnowledgeToFeedContext(); return ( -
+ <>
-
- +
+
+ +
+
-
-
+ ); }; diff --git a/frontend/app/chat/components/NotificationBanner/NotificationBanner.tsx b/frontend/app/chat/components/NotificationBanner/NotificationBanner.tsx new file mode 100644 index 000000000..abadaabe1 --- /dev/null +++ b/frontend/app/chat/components/NotificationBanner/NotificationBanner.tsx @@ -0,0 +1,47 @@ +import { Fragment } from "react"; +import { MdClose } from "react-icons/md"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; + +import Button from "@/lib/components/ui/Button"; + +import { useNotificationBanner } from "./hooks/useNotificationBanner"; + +export const NotificationBanner = (): JSX.Element => { + const { notificationBanner, isDismissed, dismissNotification } = + useNotificationBanner(); + + if (isDismissed || notificationBanner === undefined) { + return ; + } + + return ( +
+ + {notificationBanner.text} + + {Boolean(notificationBanner.dismissible) && ( + + )} +
+ ); +}; diff --git a/frontend/app/chat/components/NotificationBanner/hooks/useNotificationBanner.tsx b/frontend/app/chat/components/NotificationBanner/hooks/useNotificationBanner.tsx new file mode 100644 index 000000000..4ccc3b38a --- /dev/null +++ b/frontend/app/chat/components/NotificationBanner/hooks/useNotificationBanner.tsx @@ -0,0 +1,60 @@ +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; + +import { NOTIFICATION_BANNER_DATA_KEY } from "@/lib/api/cms/config"; +import { useCmsApi } from "@/lib/api/cms/useCmsApi"; + +import { + clearLocalStorageNotificationBanner, + getNotificationFromLocalStorage, + setNotificationAsDismissedInLocalStorage, +} from "../utils"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const useNotificationBanner = () => { + const [isDismissed, setIsDismissed] = useState(true); + + const { getNotificationBanner } = useCmsApi(); + const { data: notificationBanner } = useQuery({ + queryKey: [NOTIFICATION_BANNER_DATA_KEY], + queryFn: getNotificationBanner, + }); + + const dismissNotification = useCallback(() => { + if (notificationBanner?.id === undefined) { + return; + } + setNotificationAsDismissedInLocalStorage(notificationBanner.id); + setIsDismissed(true); + }, [notificationBanner?.id]); + + useEffect(() => { + const localStorageNotificationBanner = getNotificationFromLocalStorage(); + if (notificationBanner === undefined) { + return; + } + if (localStorageNotificationBanner?.id !== notificationBanner.id) { + clearLocalStorageNotificationBanner(); + setIsDismissed(false); + } + }, [dismissNotification, notificationBanner?.id]); + + useEffect(() => { + const onUnmount = () => { + if (notificationBanner?.isSticky === undefined) { + return; + } + if (!notificationBanner.isSticky) { + dismissNotification(); + } + }; + + return onUnmount; + }, [dismissNotification, notificationBanner?.isSticky]); + + return { + notificationBanner, + dismissNotification, + isDismissed, + }; +}; diff --git a/frontend/app/chat/components/NotificationBanner/index.ts b/frontend/app/chat/components/NotificationBanner/index.ts new file mode 100644 index 000000000..0fae01756 --- /dev/null +++ b/frontend/app/chat/components/NotificationBanner/index.ts @@ -0,0 +1 @@ +export * from "./NotificationBanner"; diff --git a/frontend/app/chat/components/NotificationBanner/utils.ts b/frontend/app/chat/components/NotificationBanner/utils.ts new file mode 100644 index 000000000..aa1d3eee4 --- /dev/null +++ b/frontend/app/chat/components/NotificationBanner/utils.ts @@ -0,0 +1,33 @@ +const notificationsLocalStorageKey = "homepage-notifications"; + +type LocalStorageNotification = { + isDismissed: boolean; + id: string; +}; + +export const getNotificationFromLocalStorage = (): + | LocalStorageNotification + | undefined => { + const notifications = localStorage.getItem(notificationsLocalStorageKey); + + if (notifications !== null) { + return JSON.parse(notifications) as LocalStorageNotification; + } + + return undefined; +}; + +export const setNotificationAsDismissedInLocalStorage = (id: string): void => { + const notificationPayload: LocalStorageNotification = { + isDismissed: true, + id, + }; + localStorage.setItem( + notificationsLocalStorageKey, + JSON.stringify(notificationPayload) + ); +}; + +export const clearLocalStorageNotificationBanner = (): void => { + localStorage.removeItem(notificationsLocalStorageKey); +}; diff --git a/frontend/app/chat/components/index.ts b/frontend/app/chat/components/index.ts index 66c944f5c..4c440dc61 100644 --- a/frontend/app/chat/components/index.ts +++ b/frontend/app/chat/components/index.ts @@ -1 +1,2 @@ export * from "./ChatsList"; +export * from "./NotificationBanner"; diff --git a/frontend/app/chat/layout.tsx b/frontend/app/chat/layout.tsx index f1b219a20..7cd243fd6 100644 --- a/frontend/app/chat/layout.tsx +++ b/frontend/app/chat/layout.tsx @@ -6,7 +6,7 @@ import { ChatsProvider } from "@/lib/context/ChatsProvider/chats-provider"; import { useSupabase } from "@/lib/context/SupabaseProvider"; import { redirectToLogin } from "@/lib/router/redirectToLogin"; -import { ChatsList } from "./components/ChatsList"; +import { ChatsList, NotificationBanner } from "./components"; interface LayoutProps { children?: ReactNode; @@ -23,6 +23,7 @@ const Layout = ({ children }: LayoutProps): JSX.Element => { +
{children} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f8338f2e6..313e6f762 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -36,7 +36,7 @@ const RootLayout = async ({ return ( diff --git a/frontend/app/user/page.tsx b/frontend/app/user/page.tsx index cb2d49f35..42cf2a0a4 100644 --- a/frontend/app/user/page.tsx +++ b/frontend/app/user/page.tsx @@ -84,7 +84,6 @@ const UserPage = (): JSX.Element => { - L ); diff --git a/frontend/lib/api/cms/config.ts b/frontend/lib/api/cms/config.ts index 6f9ffb5b7..58f4b8768 100644 --- a/frontend/lib/api/cms/config.ts +++ b/frontend/lib/api/cms/config.ts @@ -2,3 +2,4 @@ export const TESTIMONIALS_DATA_KEY = "testimonials"; export const USE_CASES_DATA_KEY = "useCases"; export const DEMO_VIDEO_DATA_KEY = "demoVideo"; export const SECURITY_QUESTIONS_DATA_KEY = "securityQuestions"; +export const NOTIFICATION_BANNER_DATA_KEY = "notificationBanner"; diff --git a/frontend/lib/api/cms/useCmsApi.ts b/frontend/lib/api/cms/useCmsApi.ts index 349cc5e4d..97c65cfcc 100644 --- a/frontend/lib/api/cms/useCmsApi.ts +++ b/frontend/lib/api/cms/useCmsApi.ts @@ -2,10 +2,11 @@ import axios from "axios"; import { DEFAULT_CMS_URL } from "@/lib/config/CONSTANTS"; -import { getDemoVideoUrl } from "./demoVideo"; -import { getSecurityQuestions } from "./securityQuestion"; -import { getTestimonials } from "./testimonials"; -import { getUseCases } from "./useCases"; +import { getDemoVideoUrl } from "./utils/demoVideo"; +import { getNotificationBanner } from "./utils/notificationBanner"; +import { getSecurityQuestions } from "./utils/securityQuestion"; +import { getTestimonials } from "./utils/testimonials"; +import { getUseCases } from "./utils/useCases"; const axiosInstance = axios.create({ baseURL: `${process.env.NEXT_PUBLIC_CMS_URL ?? DEFAULT_CMS_URL}`, @@ -18,5 +19,6 @@ export const useCmsApi = () => { getUseCases: () => getUseCases(axiosInstance), getDemoVideoUrl: () => getDemoVideoUrl(axiosInstance), getSecurityQuestions: () => getSecurityQuestions(axiosInstance), + getNotificationBanner: () => getNotificationBanner(axiosInstance), }; }; diff --git a/frontend/lib/api/cms/demoVideo.ts b/frontend/lib/api/cms/utils/demoVideo.ts similarity index 100% rename from frontend/lib/api/cms/demoVideo.ts rename to frontend/lib/api/cms/utils/demoVideo.ts diff --git a/frontend/lib/api/cms/utils/notificationBanner.ts b/frontend/lib/api/cms/utils/notificationBanner.ts new file mode 100644 index 000000000..cc9ae8038 --- /dev/null +++ b/frontend/lib/api/cms/utils/notificationBanner.ts @@ -0,0 +1,35 @@ +import { AxiosInstance } from "axios"; + +import { NotificationBanner } from "@/lib/types/NotificationBanner"; + +type CmsNotificationBanner = { + data: { + attributes: { + text: string; + notification_id: string; + style?: Record; + dismissible?: boolean; + isSticky?: boolean; + }; + }; +}; + +const mapCmsNotificationBannerToNotificationBanner = ( + cmsNotificationBanner: CmsNotificationBanner +): NotificationBanner => ({ + text: cmsNotificationBanner.data.attributes.text, + id: cmsNotificationBanner.data.attributes.notification_id, + style: cmsNotificationBanner.data.attributes.style, + dismissible: cmsNotificationBanner.data.attributes.dismissible, + isSticky: cmsNotificationBanner.data.attributes.isSticky, +}); + +export const getNotificationBanner = async ( + axiosInstance: AxiosInstance +): Promise => { + const response = await axiosInstance.get( + "/api/notification-banner" + ); + + return mapCmsNotificationBannerToNotificationBanner(response.data); +}; diff --git a/frontend/lib/api/cms/securityQuestion.ts b/frontend/lib/api/cms/utils/securityQuestion.ts similarity index 100% rename from frontend/lib/api/cms/securityQuestion.ts rename to frontend/lib/api/cms/utils/securityQuestion.ts diff --git a/frontend/lib/api/cms/testimonials.ts b/frontend/lib/api/cms/utils/testimonials.ts similarity index 100% rename from frontend/lib/api/cms/testimonials.ts rename to frontend/lib/api/cms/utils/testimonials.ts diff --git a/frontend/lib/api/cms/useCases.ts b/frontend/lib/api/cms/utils/useCases.ts similarity index 100% rename from frontend/lib/api/cms/useCases.ts rename to frontend/lib/api/cms/utils/useCases.ts diff --git a/frontend/lib/components/Sidebar/Sidebar.tsx b/frontend/lib/components/Sidebar/Sidebar.tsx index 5ba5d754d..8616e45f4 100644 --- a/frontend/lib/components/Sidebar/Sidebar.tsx +++ b/frontend/lib/components/Sidebar/Sidebar.tsx @@ -42,7 +42,7 @@ export const Sidebar = ({ setOpen(false); } }} - className="flex flex-col fixed sm:sticky top-0 left-0 h-fill-available overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-white dark:bg-black" + className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-white dark:bg-black" > {!open && (